From 3f4d78f1755d4ac331d3d1cddec33f14ad180856 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 1 May 2026 23:13:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=85=E5=90=8E=E7=AB=AFAPI?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E8=85=BE=E8=AE=AFIM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增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错误 --- .../com/xuqm/im/config/SecurityConfig.java | 2 + .../xuqm/im/controller/FriendController.java | 16 ++++ .../xuqm/im/controller/GroupController.java | 11 +++ .../xuqm/im/controller/ImAdminController.java | 90 ++++++++++++++++++- .../com/xuqm/im/entity/ImGroupEntity.java | 6 ++ .../im/repository/ImMessageRepository.java | 11 +++ .../com/xuqm/im/service/ImGroupService.java | 44 +++++++++ .../com/xuqm/im/service/MessageService.java | 72 +++++++++++++++ 8 files changed, 251 insertions(+), 1 deletion(-) diff --git a/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java index 27665b4..4faf403 100644 --- a/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java +++ b/im-service/src/main/java/com/xuqm/im/config/SecurityConfig.java @@ -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; diff --git a/im-service/src/main/java/com/xuqm/im/controller/FriendController.java b/im-service/src/main/java/com/xuqm/im/controller/FriendController.java index a1a6a8a..22a9c1b 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/FriendController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/FriendController.java @@ -113,5 +113,21 @@ public class FriendController { return friendIds == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(friendIds)); } + @PostMapping("/check") + public ResponseEntity>> checkFriends( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestBody FriendCheckRequest req) { + List results = new ArrayList<>(); + for (String friendId : req.friendIds() == null ? List.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 friendIds) {} + public record FriendCheckRequest(List friendIds) {} + public record FriendCheckResult(String userId, boolean isFriend) {} } diff --git a/im-service/src/main/java/com/xuqm/im/controller/GroupController.java b/im-service/src/main/java/com/xuqm/im/controller/GroupController.java index 548112f..bc322ec 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/GroupController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/GroupController.java @@ -199,6 +199,16 @@ public class GroupController { groupService.rejectJoinRequests(appId, groupId, req.requestIds(), userId))); } + @PutMapping("/{groupId}/members/{userId}/info") + public ResponseEntity> 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 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 requestIds) {} + public record ModifyMemberInfoRequest(String nickName) {} } diff --git a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java index 8481836..647fd95 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java @@ -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>> queryUserState( + @RequestParam String userIds) { + Map 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> 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>> batchSendMsg( + @RequestParam String appId, + @AuthenticationPrincipal String operatorId, + @RequestBody BatchSendRequest req) { + List 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> 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>> importMessages( + @RequestParam String appId, + @AuthenticationPrincipal String operatorId, + @RequestBody List req) { + List 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 userIds) {} + public record BatchSendRequest(List toIds, ImMessageEntity.MsgType msgType, String content) {} + public record SetMsgReadRequest(String userId) {} } diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java index 449adc4..2a8c7f6 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImGroupEntity.java @@ -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; } diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java index df16bda..547377a 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImMessageRepository.java @@ -203,4 +203,15 @@ public interface ImMessageRepository extends JpaRepository com.xuqm.im.entity.ImMessageEntity$MsgStatus.READ + """) + List findUnreadByAppIdAndToId( + @Param("appId") String appId, + @Param("userId") String userId); } diff --git a/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java index 2b2ca3a..5c6c066 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImGroupService.java @@ -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 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 members = memberIds(group); + if (!members.contains(userId)) { + throw new BusinessException(404, "群成员不存在"); + } + Map> memberInfoMap = parseMemberInfo(group.getMemberInfo()); + Map 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> 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); diff --git a/im-service/src/main/java/com/xuqm/im/service/MessageService.java b/im-service/src/main/java/com/xuqm/im/service/MessageService.java index 3c27ad7..4c3e9d9 100644 --- a/im-service/src/main/java/com/xuqm/im/service/MessageService.java +++ b/im-service/src/main/java/com/xuqm/im/service/MessageService.java @@ -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 messages = messageRepository.findUnreadByAppIdAndToId(appId, userId); + for (ImMessageEntity message : messages) { + message.setStatus(ImMessageEntity.MsgStatus.READ); + messageRepository.save(message); + } + } + + public List importMessages(String appId, List requests) { + List result = new ArrayList<>(); + for (ImportMessageRequest req : requests == null ? List.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) {} }