feat: 补充后端API对齐腾讯IM
新增API:
- GET /api/im/admin/users/state - 查询用户在线状态
- POST /api/im/admin/users/kick - 强制用户下线
- POST /api/im/admin/messages/batch-send - 批量发消息
- POST /api/im/admin/messages/read - 管理员设置消息已读
- POST /api/im/admin/messages/import - 导入历史消息
- POST /api/im/friends/check - 校验好友关系
- PUT /api/im/groups/{groupId}/members/{userId}/info - 修改群成员资料
新增字段:
- ImGroupEntity.memberInfo - 群成员资料(JSON)
修复编译import错误
这个提交包含在:
父节点
b25b4746e9
当前提交
3f4d78f175
@ -5,6 +5,7 @@ import com.xuqm.common.security.JwtUtil;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
@ -21,6 +22,7 @@ import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtUtil jwtUtil;
|
||||
|
||||
@ -113,5 +113,21 @@ public class FriendController {
|
||||
return friendIds == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(friendIds));
|
||||
}
|
||||
|
||||
@PostMapping("/check")
|
||||
public ResponseEntity<ApiResponse<List<FriendCheckResult>>> checkFriends(
|
||||
@AuthenticationPrincipal String userId,
|
||||
@RequestParam String appId,
|
||||
@RequestBody FriendCheckRequest req) {
|
||||
List<FriendCheckResult> results = new ArrayList<>();
|
||||
for (String friendId : req.friendIds() == null ? List.<String>of() : req.friendIds()) {
|
||||
boolean isFriend = friendRepository.existsByAppIdAndUserIdAndFriendId(appId, userId, friendId)
|
||||
|| friendRepository.existsByAppIdAndUserIdAndFriendId(appId, friendId, userId);
|
||||
results.add(new FriendCheckResult(friendId, isFriend));
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(results));
|
||||
}
|
||||
|
||||
public record FriendBatchRequest(List<String> friendIds) {}
|
||||
public record FriendCheckRequest(List<String> friendIds) {}
|
||||
public record FriendCheckResult(String userId, boolean isFriend) {}
|
||||
}
|
||||
|
||||
@ -199,6 +199,16 @@ public class GroupController {
|
||||
groupService.rejectJoinRequests(appId, groupId, req.requestIds(), userId)));
|
||||
}
|
||||
|
||||
@PutMapping("/{groupId}/members/{userId}/info")
|
||||
public ResponseEntity<ApiResponse<ImGroupEntity>> modifyMemberInfo(
|
||||
@PathVariable String groupId,
|
||||
@PathVariable String userId,
|
||||
@RequestBody ModifyMemberInfoRequest req,
|
||||
@AuthenticationPrincipal String operatorId) {
|
||||
return ResponseEntity.ok(ApiResponse.success(
|
||||
groupService.modifyMemberInfo(groupId, userId, req.nickName())));
|
||||
}
|
||||
|
||||
public record CreateGroupRequest(String name, List<String> memberIds, String groupType) {}
|
||||
public record UpdateGroupRequest(String name, String groupType, String announcement) {}
|
||||
public record MemberRequest(String userId) {}
|
||||
@ -206,4 +216,5 @@ public class GroupController {
|
||||
public record SetRoleRequest(String userId, String role) {}
|
||||
public record MuteMemberRequest(String userId, long minutes) {}
|
||||
public record RequestBatch(List<String> requestIds) {}
|
||||
public record ModifyMemberInfoRequest(String nickName) {}
|
||||
}
|
||||
|
||||
@ -25,11 +25,18 @@ import com.xuqm.im.service.OperationLogService;
|
||||
import com.xuqm.im.service.WebhookConfigService;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.messaging.simp.user.SimpUserRegistry;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -49,6 +56,9 @@ public class ImAdminController {
|
||||
private final KeywordFilterService keywordFilterService;
|
||||
private final GlobalMuteService globalMuteService;
|
||||
private final OperationLogService operationLogService;
|
||||
private final SimpUserRegistry simpUserRegistry;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public ImAdminController(ImAccountRepository accountRepository,
|
||||
ImGroupRepository groupRepository,
|
||||
@ -61,7 +71,10 @@ public class ImAdminController {
|
||||
WebhookConfigService webhookConfigService,
|
||||
KeywordFilterService keywordFilterService,
|
||||
GlobalMuteService globalMuteService,
|
||||
OperationLogService operationLogService) {
|
||||
OperationLogService operationLogService,
|
||||
SimpUserRegistry simpUserRegistry,
|
||||
SimpMessagingTemplate messagingTemplate,
|
||||
StringRedisTemplate redisTemplate) {
|
||||
this.accountRepository = accountRepository;
|
||||
this.groupRepository = groupRepository;
|
||||
this.messageRepository = messageRepository;
|
||||
@ -74,6 +87,9 @@ public class ImAdminController {
|
||||
this.keywordFilterService = keywordFilterService;
|
||||
this.globalMuteService = globalMuteService;
|
||||
this.operationLogService = operationLogService;
|
||||
this.simpUserRegistry = simpUserRegistry;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
/** List all registered IM users for the given appId. */
|
||||
@ -528,6 +544,75 @@ public class ImAdminController {
|
||||
operationLogService.list(appId, PageRequest.of(page, size))));
|
||||
}
|
||||
|
||||
@GetMapping("/users/state")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<Map<String, Boolean>>> queryUserState(
|
||||
@RequestParam String userIds) {
|
||||
Map<String, Boolean> result = new LinkedHashMap<>();
|
||||
for (String userId : userIds.split(",")) {
|
||||
String trimmed = userId.trim();
|
||||
if (!trimmed.isBlank()) {
|
||||
result.put(trimmed, simpUserRegistry.getUser(trimmed) != null);
|
||||
}
|
||||
}
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/users/kick")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<Void>> kickUsers(
|
||||
@RequestParam String appId,
|
||||
@AuthenticationPrincipal String operatorId,
|
||||
@RequestBody KickRequest req) {
|
||||
for (String userId : req.userIds()) {
|
||||
messagingTemplate.convertAndSendToUser(userId, "/queue/kick",
|
||||
Map.of("type", "KICK", "reason", "管理员强制下线"));
|
||||
redisTemplate.opsForValue().set("im:kick:" + appId + ":" + userId, "1", Duration.ofMinutes(5));
|
||||
}
|
||||
operationLogService.record(appId, operatorId, "KICK_USERS", "ACCOUNT", null,
|
||||
String.join(",", req.userIds()));
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
@PostMapping("/messages/batch-send")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> batchSendMsg(
|
||||
@RequestParam String appId,
|
||||
@AuthenticationPrincipal String operatorId,
|
||||
@RequestBody BatchSendRequest req) {
|
||||
List<ImMessageEntity> result = new ArrayList<>();
|
||||
for (String toId : req.toIds()) {
|
||||
ImMessageEntity sent = messageService.adminSend(appId, operatorId, toId, req.msgType(), req.content());
|
||||
result.add(sent);
|
||||
}
|
||||
operationLogService.record(appId, operatorId, "BATCH_SEND_MESSAGE", "MESSAGE", null,
|
||||
"count=" + result.size());
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@PostMapping("/messages/read")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<Void>> adminSetMsgRead(
|
||||
@RequestParam String appId,
|
||||
@AuthenticationPrincipal String operatorId,
|
||||
@RequestBody SetMsgReadRequest req) {
|
||||
messageService.adminSetMsgRead(appId, req.userId());
|
||||
operationLogService.record(appId, operatorId, "ADMIN_SET_MSG_READ", "MESSAGE", req.userId(), null);
|
||||
return ResponseEntity.ok(ApiResponse.ok());
|
||||
}
|
||||
|
||||
@PostMapping("/messages/import")
|
||||
@PreAuthorize("hasAuthority('ROLE_OPS')")
|
||||
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> importMessages(
|
||||
@RequestParam String appId,
|
||||
@AuthenticationPrincipal String operatorId,
|
||||
@RequestBody List<MessageService.ImportMessageRequest> req) {
|
||||
List<ImMessageEntity> result = messageService.importMessages(appId, req);
|
||||
operationLogService.record(appId, operatorId, "IMPORT_MESSAGES", "MESSAGE", null,
|
||||
"count=" + result.size());
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
public record RegisterUserRequest(
|
||||
String userId,
|
||||
String nickname,
|
||||
@ -548,4 +633,7 @@ public class ImAdminController {
|
||||
public record MemberRequest(String userId) {}
|
||||
public record SetRoleRequest(String userId, String role) {}
|
||||
public record MuteMemberRequest(String userId, long minutes) {}
|
||||
public record KickRequest(List<String> userIds) {}
|
||||
public record BatchSendRequest(List<String> toIds, ImMessageEntity.MsgType msgType, String content) {}
|
||||
public record SetMsgReadRequest(String userId) {}
|
||||
}
|
||||
|
||||
@ -36,6 +36,9 @@ public class ImGroupEntity {
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String announcement;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String memberInfo;
|
||||
|
||||
@Column(nullable = false)
|
||||
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||
private LocalDateTime createdAt;
|
||||
@ -64,6 +67,9 @@ public class ImGroupEntity {
|
||||
public String getAnnouncement() { return announcement; }
|
||||
public void setAnnouncement(String announcement) { this.announcement = announcement; }
|
||||
|
||||
public String getMemberInfo() { return memberInfo; }
|
||||
public void setMemberInfo(String memberInfo) { this.memberInfo = memberInfo; }
|
||||
|
||||
@JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class)
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
@ -203,4 +203,15 @@ public interface ImMessageRepository extends JpaRepository<ImMessageEntity, Stri
|
||||
default long countTodayByAppId(String appId) {
|
||||
return countByAppIdAndCreatedAtAfter(appId, LocalDateTime.now().toLocalDate().atStartOfDay());
|
||||
}
|
||||
|
||||
@Query("""
|
||||
select m from ImMessageEntity m
|
||||
where m.appId = :appId
|
||||
and m.chatType = com.xuqm.im.entity.ImMessageEntity$ChatType.SINGLE
|
||||
and m.toId = :userId
|
||||
and m.status <> com.xuqm.im.entity.ImMessageEntity$MsgStatus.READ
|
||||
""")
|
||||
List<ImMessageEntity> findUnreadByAppIdAndToId(
|
||||
@Param("appId") String appId,
|
||||
@Param("userId") String userId);
|
||||
}
|
||||
|
||||
@ -22,8 +22,10 @@ import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@ -419,6 +421,14 @@ public class ImGroupService {
|
||||
try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; }
|
||||
}
|
||||
|
||||
private String toJson(Map<?, ?> map) {
|
||||
try { return objectMapper.writeValueAsString(map); } catch (Exception e) { return "{}"; }
|
||||
}
|
||||
|
||||
private String toJson(Object obj) {
|
||||
try { return objectMapper.writeValueAsString(obj); } catch (Exception e) { return "{}"; }
|
||||
}
|
||||
|
||||
private List<String> fromJson(String json) {
|
||||
try { return objectMapper.readValue(json, new TypeReference<>() {}); } catch (Exception e) { return new ArrayList<>(); }
|
||||
}
|
||||
@ -562,6 +572,40 @@ public class ImGroupService {
|
||||
return joinRequestRepository.findByAppIdAndGroupId(appId, groupId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ImGroupEntity modifyMemberInfo(String groupId, String userId, String nickName) {
|
||||
ImGroupEntity group = get(groupId);
|
||||
List<String> members = memberIds(group);
|
||||
if (!members.contains(userId)) {
|
||||
throw new BusinessException(404, "群成员不存在");
|
||||
}
|
||||
Map<String, Map<String, String>> memberInfoMap = parseMemberInfo(group.getMemberInfo());
|
||||
Map<String, String> info = memberInfoMap.computeIfAbsent(userId, k -> new java.util.HashMap<>());
|
||||
if (nickName != null) {
|
||||
info.put("nickName", nickName);
|
||||
}
|
||||
group.setMemberInfo(toJson(memberInfoMap));
|
||||
return groupRepository.save(group);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ImGroupEntity adminModifyMemberInfo(String appId, String groupId, String userId, String nickName) {
|
||||
ImGroupEntity group = get(groupId);
|
||||
ensureAppMatches(group, appId);
|
||||
return modifyMemberInfo(groupId, userId, nickName);
|
||||
}
|
||||
|
||||
private Map<String, Map<String, String>> parseMemberInfo(String memberInfoJson) {
|
||||
try {
|
||||
if (memberInfoJson == null || memberInfoJson.isBlank()) {
|
||||
return new java.util.HashMap<>();
|
||||
}
|
||||
return objectMapper.readValue(memberInfoJson, new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return new java.util.HashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ImGroupJoinRequestEntity adminAcceptJoinRequest(String appId, String groupId, String requestId) {
|
||||
ImGroupEntity group = get(groupId);
|
||||
|
||||
@ -19,6 +19,8 @@ import org.slf4j.LoggerFactory;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import com.xuqm.im.repository.ImMessageRepository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@ -556,4 +558,74 @@ public class MessageService {
|
||||
protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) {
|
||||
webhookDispatchService.dispatch(appId, "message", callbackEvent, payload);
|
||||
}
|
||||
|
||||
public ImMessageEntity adminSend(String appId, String fromUserId, String toId, ImMessageEntity.MsgType msgType, String content) {
|
||||
ImMessageEntity message = new ImMessageEntity();
|
||||
message.setId(UUID.randomUUID().toString());
|
||||
message.setAppId(appId);
|
||||
message.setFromUserId(fromUserId);
|
||||
message.setToId(toId);
|
||||
message.setChatType(ImMessageEntity.ChatType.SINGLE);
|
||||
message.setMsgType(msgType);
|
||||
message.setContent(content);
|
||||
message.setStatus(ImMessageEntity.MsgStatus.SENT);
|
||||
message.setCreatedAt(LocalDateTime.now());
|
||||
ImMessageEntity saved = messageRepository.save(message);
|
||||
|
||||
clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved);
|
||||
clusterPublisher.publish("/user/" + toId + "/queue/messages", saved);
|
||||
conversationStateService.clearHiddenForUsers(appId, toId, ImMessageEntity.ChatType.SINGLE.name(), List.of(fromUserId, toId));
|
||||
imPushBridge.sendOfflinePushToUsers(
|
||||
appId,
|
||||
List.of(toId),
|
||||
"新消息",
|
||||
saved.getContent(),
|
||||
buildPushPayload(saved)
|
||||
);
|
||||
|
||||
dispatchWebhooks(appId, "message.sent", saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
public void adminSetMsgRead(String appId, String userId) {
|
||||
List<ImMessageEntity> messages = messageRepository.findUnreadByAppIdAndToId(appId, userId);
|
||||
for (ImMessageEntity message : messages) {
|
||||
message.setStatus(ImMessageEntity.MsgStatus.READ);
|
||||
messageRepository.save(message);
|
||||
}
|
||||
}
|
||||
|
||||
public List<ImMessageEntity> importMessages(String appId, List<ImportMessageRequest> requests) {
|
||||
List<ImMessageEntity> result = new ArrayList<>();
|
||||
for (ImportMessageRequest req : requests == null ? List.<ImportMessageRequest>of() : requests) {
|
||||
if (req == null || req.fromUserId() == null || req.fromUserId().isBlank()
|
||||
|| req.toId() == null || req.toId().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
ImMessageEntity message = new ImMessageEntity();
|
||||
message.setId(req.messageId() != null && !req.messageId().isBlank()
|
||||
? req.messageId()
|
||||
: UUID.randomUUID().toString());
|
||||
message.setAppId(appId);
|
||||
message.setFromUserId(req.fromUserId());
|
||||
message.setToId(req.toId());
|
||||
message.setChatType(req.chatType() != null ? req.chatType() : ImMessageEntity.ChatType.SINGLE);
|
||||
message.setMsgType(req.msgType() != null ? req.msgType() : ImMessageEntity.MsgType.TEXT);
|
||||
message.setContent(req.content() != null ? req.content() : "");
|
||||
message.setStatus(req.status() != null ? req.status() : ImMessageEntity.MsgStatus.READ);
|
||||
message.setCreatedAt(req.createdAt() != null ? req.createdAt() : LocalDateTime.now());
|
||||
result.add(messageRepository.save(message));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public record ImportMessageRequest(
|
||||
String messageId,
|
||||
String fromUserId,
|
||||
String toId,
|
||||
ImMessageEntity.ChatType chatType,
|
||||
ImMessageEntity.MsgType msgType,
|
||||
String content,
|
||||
ImMessageEntity.MsgStatus status,
|
||||
LocalDateTime createdAt) {}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户