diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index eef462e..2f673ef 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -113,8 +113,20 @@ | POST | `/api/im/accounts/import/batch` | 是 | 批量导入账号 | | DELETE | `/api/im/accounts/{userId}` | 是 | 删除账号 | | GET | `/api/im/accounts/{userId}/exists` | 是 | 检查账号是否存在 | +| PUT | `/api/im/conversations/{targetId}/hidden` | 是 | 隐藏 / 取消隐藏会话 | +| PUT | `/api/im/conversations/{targetId}/group` | 是 | 设置会话分组 | +| GET | `/api/im/conversation-groups` | 是 | 查询会话分组 | +| GET | `/api/im/conversation-groups/{groupName}` | 是 | 查询会话分组项 | +| DELETE | `/api/im/friends` | 是 | 删除当前用户全部好友 | +| PUT | `/api/im/friends/{friendId}/group` | 是 | 设置好友分组 | +| GET | `/api/im/friends/groups` | 是 | 查询好友分组 | +| GET | `/api/im/friends/groups/{groupName}` | 是 | 查询分组好友 | +| GET | `/api/im/blacklist/check` | 是 | 校验黑名单关系 | | GET | `/api/im/groups/{groupId}/members` | 是 | 查询群成员列表 | | GET | `/api/im/groups/{groupId}/members/search` | 是 | 搜索群成员 | +| POST | `/api/im/groups/{groupId}/owner` | 是 | 群主转让 | +| PUT | `/api/im/groups/{groupId}/attributes` | 是 | 设置群扩展属性 | +| POST | `/api/im/groups/{groupId}/attributes/delete` | 是 | 删除群扩展属性 | | POST | `/api/im/messages/send` | 是 | 发送消息(TEXT / IMAGE / AUDIO / VIDEO / FILE / LOCATION / CUSTOM / NOTIFY / RICH_TEXT / CALL_AUDIO / CALL_VIDEO / FORWARD / QUOTE / MERGE) | | GET | `/api/im/messages/search` | 是 | 云端消息搜索 | | PUT | `/api/im/messages/{id}` | 是 | 编辑自己发送的文本消息 | @@ -257,12 +269,29 @@ curl 'https://dev.xuqinmin.com/api/im/accounts/user_001/exists?appId=ak_demo_cha curl 'https://dev.xuqinmin.com/api/im/conversations?appId=ak_demo_chat' curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId=ak_demo_chat&chatType=SINGLE&pinned=true' curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/draft?appId=ak_demo_chat&chatType=SINGLE&draft=hello' +curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/hidden?appId=ak_demo_chat&chatType=SINGLE&hidden=true' +curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/group?appId=ak_demo_chat&chatType=SINGLE&groupName=重要客户' +curl 'https://dev.xuqinmin.com/api/im/conversation-groups?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/conversation-groups/重要客户?appId=ak_demo_chat' curl -X DELETE 'https://dev.xuqinmin.com/api/im/conversations/user_002?appId=ak_demo_chat&chatType=SINGLE' +curl -X PUT 'https://dev.xuqinmin.com/api/im/friends/user_002/group?appId=ak_demo_chat&groupName=同事' +curl 'https://dev.xuqinmin.com/api/im/friends/groups?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/friends/groups/同事?appId=ak_demo_chat' +curl -X DELETE 'https://dev.xuqinmin.com/api/im/friends?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/groups?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/groups/public?appId=ak_demo_chat&keyword=demo' curl 'https://dev.xuqinmin.com/api/im/groups/search?appId=ak_demo_chat&keyword=demo&size=20' curl 'https://dev.xuqinmin.com/api/im/groups/group_001/members?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/groups/group_001/members/search?appId=ak_demo_chat&keyword=demo&size=20' +curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/owner?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '{"newOwnerId":"user_002"}' +curl -X PUT 'https://dev.xuqinmin.com/api/im/groups/group_001/attributes?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '{"department":"sales","priority":"high"}' +curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/attributes/delete?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '{"keys":["priority"]}' curl 'https://dev.xuqinmin.com/api/im/messages/search?appId=ak_demo_chat&keyword=hello&page=0&size=20' curl 'https://dev.xuqinmin.com/api/im/admin/webhooks?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入' @@ -270,6 +299,7 @@ curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_de curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/reject?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/blacklist/check?appId=ak_demo_chat&targetUserId=user_002' curl 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&direction=incoming' ``` @@ -288,6 +318,15 @@ curl -X POST 'https://dev.xuqinmin.com/api/im/admin/blacklist?appId=ak_demo_chat -H 'Content-Type: application/json' \ -d '{"userId":"user_001","blockedUserId":"user_002"}' curl 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/members?appId=ak_demo_chat' +curl -X POST 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/owner?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '{"newOwnerId":"user_002"}' +curl -X PUT 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/attributes?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '{"department":"sales"}' +curl -X POST 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/read-receipts?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '{"messageIds":["msg_001","msg_002"]}' curl 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/join-requests?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/admin/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/admin/keyword-filters?appId=ak_demo_chat' diff --git a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java index 97dad02..1da82f5 100644 --- a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java +++ b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java @@ -681,6 +681,53 @@ public final class XuqmImServerSdk { ); } + public void removeAllFriends() { + request( + "DELETE", + buildUri("/api/im/friends", appQuery()), + null, + authorizedHeaders(), + new TypeReference>() {} + ); + } + + public FriendLinkView setFriendGroup(String friendId, String groupName) { + Map query = appQuery(); + if (groupName != null) { + query.put("groupName", groupName); + } + ApiResponse response = request( + "PUT", + buildUri("/api/im/friends/" + encode(friendId) + "/group", query), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List listFriendGroups() { + ApiResponse> response = request( + "GET", + buildUri("/api/im/friends/groups", appQuery()), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List listFriendsByGroup(String groupName) { + ApiResponse> response = request( + "GET", + buildUri("/api/im/friends/groups/" + encode(groupName), appQuery()), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public List listFriendRequests(String direction) { Map query = appQuery(); if (direction != null) { @@ -792,6 +839,19 @@ public final class XuqmImServerSdk { ); } + public BlacklistCheckResult checkBlacklist(String targetUserId) { + Map query = appQuery(); + query.put("targetUserId", targetUserId); + ApiResponse response = request( + "GET", + buildUri("/api/im/blacklist/check", query), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public List listGroups() { ApiResponse> response = request( "GET", @@ -939,6 +999,39 @@ public final class XuqmImServerSdk { return response.data(); } + public GroupView transferGroupOwner(String groupId, String newOwnerId) { + ApiResponse response = request( + "POST", + buildUri("/api/im/groups/" + encode(groupId) + "/owner", appQuery()), + new TransferOwnerRequest(newOwnerId), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public GroupView updateGroupAttributes(String groupId, Map attributes) { + ApiResponse response = request( + "PUT", + buildUri("/api/im/groups/" + encode(groupId) + "/attributes", appQuery()), + attributes == null ? Map.of() : attributes, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public GroupView removeGroupAttributes(String groupId, List keys) { + ApiResponse response = request( + "POST", + buildUri("/api/im/groups/" + encode(groupId) + "/attributes/delete", appQuery()), + new AttributeKeysRequest(keys), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public GroupView muteGroupMember(String groupId, String userId, long minutes) { ApiResponse response = request( "POST", @@ -1117,6 +1210,50 @@ public final class XuqmImServerSdk { return response.data(); } + public GroupView adminTransferGroupOwner(String groupId, String newOwnerId) { + ApiResponse response = request( + "POST", + buildUri("/api/im/admin/groups/" + encode(groupId) + "/owner", appQuery()), + new TransferOwnerRequest(newOwnerId), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public GroupView adminUpdateGroupAttributes(String groupId, Map attributes) { + ApiResponse response = request( + "PUT", + buildUri("/api/im/admin/groups/" + encode(groupId) + "/attributes", appQuery()), + attributes == null ? Map.of() : attributes, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public GroupView adminRemoveGroupAttributes(String groupId, List keys) { + ApiResponse response = request( + "POST", + buildUri("/api/im/admin/groups/" + encode(groupId) + "/attributes/delete", appQuery()), + new AttributeKeysRequest(keys), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List adminGroupReadReceipts(String groupId, List messageIds) { + ApiResponse> response = request( + "POST", + buildUri("/api/im/admin/groups/" + encode(groupId) + "/read-receipts", appQuery()), + new GroupReadReceiptRequest(messageIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public void setConversationPinned(String targetId, String chatType, boolean pinned) { Map query = appQuery(); query.put("chatType", chatType); @@ -1170,6 +1307,56 @@ public final class XuqmImServerSdk { ); } + public void setConversationHidden(String targetId, String chatType, boolean hidden) { + Map query = appQuery(); + query.put("chatType", chatType); + query.put("hidden", String.valueOf(hidden)); + request( + "PUT", + buildUri("/api/im/conversations/" + encode(targetId) + "/hidden", query), + null, + authorizedHeaders(), + new TypeReference>() {} + ); + } + + public void setConversationGroup(String targetId, String chatType, String groupName) { + Map query = appQuery(); + query.put("chatType", chatType); + if (groupName != null) { + query.put("groupName", groupName); + } + request( + "PUT", + buildUri("/api/im/conversations/" + encode(targetId) + "/group", query), + null, + authorizedHeaders(), + new TypeReference>() {} + ); + } + + public List listConversationGroups() { + ApiResponse> response = request( + "GET", + buildUri("/api/im/conversation-groups", appQuery()), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List listConversationGroupItems(String groupName) { + ApiResponse> response = request( + "GET", + buildUri("/api/im/conversation-groups/" + encode(groupName), appQuery()), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public void deleteConversation(String targetId, String chatType) { Map query = appQuery(); query.put("chatType", chatType); @@ -1586,7 +1773,14 @@ public final class XuqmImServerSdk { Long lastMsgTime, int unreadCount, boolean isMuted, - boolean isPinned + boolean isPinned, + String conversationGroup + ) {} + + public record ConversationGroupItem( + String targetId, + String chatType, + String groupName ) {} public record AccountView( @@ -1613,6 +1807,7 @@ public final class XuqmImServerSdk { String appId, String userId, String friendId, + String friendGroup, Instant createdAt ) {} @@ -1637,6 +1832,13 @@ public final class XuqmImServerSdk { LocalDateTime createdAt ) {} + public record BlacklistCheckResult( + String targetUserId, + boolean blockedByMe, + boolean blockedByTarget, + boolean eitherBlocked + ) {} + public record GroupView( String id, String appId, @@ -1646,6 +1848,8 @@ public final class XuqmImServerSdk { String memberIds, String adminIds, String announcement, + String memberInfo, + String extAttributes, LocalDateTime createdAt ) { public List memberIdList() { @@ -1657,6 +1861,14 @@ public final class XuqmImServerSdk { } } + public record GroupReadReceiptSummary( + String messageId, + String groupId, + int memberCount, + int readCount, + int unreadCount + ) {} + public record GroupJoinRequestView( String id, String appId, @@ -1899,8 +2111,14 @@ public final class XuqmImServerSdk { public record SetRoleRequest(String userId, String role) {} + public record TransferOwnerRequest(String newOwnerId) {} + + public record AttributeKeysRequest(List keys) {} + public record MuteMemberRequest(String userId, long minutes) {} + public record GroupReadReceiptRequest(List messageIds) {} + public record RequestBatch(List requestIds) {} public record FriendCheckResult(String userId, boolean isFriend) {} diff --git a/im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java b/im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java index 8655831..3ae9fce 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/BlacklistController.java @@ -47,4 +47,22 @@ public class BlacklistController { blacklistService.remove(appId, userId, blockedUserId); return ResponseEntity.ok(ApiResponse.ok()); } + + @GetMapping("/check") + public ResponseEntity> check( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestParam String targetUserId) { + boolean blockedByMe = blacklistService.isBlocked(appId, userId, targetUserId); + boolean blockedByTarget = blacklistService.isBlocked(appId, targetUserId, userId); + return ResponseEntity.ok(ApiResponse.success( + new BlacklistCheckResult(targetUserId, blockedByMe, blockedByTarget, blockedByMe || blockedByTarget))); + } + + public record BlacklistCheckResult( + String targetUserId, + boolean blockedByMe, + boolean blockedByTarget, + boolean eitherBlocked + ) {} } diff --git a/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java b/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java index 8b0cd0d..ff06d7b 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ConversationController.java @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/im") @@ -90,6 +91,52 @@ public class ConversationController { return ResponseEntity.ok(ApiResponse.ok()); } + @PutMapping("/conversations/{targetId}/hidden") + public ResponseEntity> setHidden( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String targetId, + @RequestParam String chatType, + @RequestParam boolean hidden) { + conversationStateService.setHidden(appId, userId, targetId, chatType, hidden); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PutMapping("/conversations/{targetId}/group") + public ResponseEntity> setConversationGroup( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String targetId, + @RequestParam String chatType, + @RequestParam(required = false) String groupName) { + conversationStateService.setConversationGroup(appId, userId, targetId, chatType, groupName); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @GetMapping("/conversation-groups") + public ResponseEntity>> listConversationGroups( + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(conversationStateService.listConversationGroups(appId, userId))); + } + + @GetMapping("/conversation-groups/{groupName}") + public ResponseEntity>>> listConversationGroupItems( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String groupName) { + List> items = conversationStateService + .listByConversationGroup(appId, userId, groupName) + .stream() + .map(state -> Map.of( + "targetId", state.getTargetId(), + "chatType", state.getChatType(), + "groupName", state.getConversationGroup() + )) + .toList(); + return ResponseEntity.ok(ApiResponse.success(items)); + } + @DeleteMapping("/conversations/{targetId}") public ResponseEntity> deleteConversation( @AuthenticationPrincipal String userId, 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 22a9c1b..01424bd 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 @@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -72,6 +73,15 @@ public class FriendController { return ResponseEntity.ok(ApiResponse.success(null)); } + @DeleteMapping + public ResponseEntity> removeAllFriends( + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + friendRepository.deleteByAppIdAndUserId(appId, userId); + friendRepository.deleteByAppIdAndFriendId(appId, userId); + return ResponseEntity.ok(ApiResponse.ok()); + } + @PostMapping("/batch/remove") public ResponseEntity> removeFriends( @AuthenticationPrincipal String userId, @@ -87,6 +97,44 @@ public class FriendController { return ResponseEntity.ok(ApiResponse.success(null)); } + @PutMapping("/{friendId}/group") + public ResponseEntity> setFriendGroup( + @AuthenticationPrincipal String userId, + @PathVariable String friendId, + @RequestParam String appId, + @RequestParam(required = false) String groupName) { + ImFriendEntity link = friendRepository.findByAppIdAndUserIdAndFriendId(appId, userId, friendId) + .orElseGet(() -> addFriendLink(appId, userId, friendId)); + link.setFriendGroup(normalizeGroup(groupName)); + return ResponseEntity.ok(ApiResponse.success(friendRepository.save(link))); + } + + @GetMapping("/groups") + public ResponseEntity>> listFriendGroups( + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + List groups = friendRepository.findByAppIdAndUserId(appId, userId).stream() + .map(ImFriendEntity::getFriendGroup) + .filter(group -> group != null && !group.isBlank()) + .distinct() + .sorted() + .toList(); + return ResponseEntity.ok(ApiResponse.success(groups)); + } + + @GetMapping("/groups/{groupName}") + public ResponseEntity>> listFriendsByGroup( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @PathVariable String groupName) { + List friendIds = friendRepository + .findByAppIdAndUserIdAndFriendGroup(appId, userId, normalizeGroup(groupName)) + .stream() + .map(ImFriendEntity::getFriendId) + .toList(); + return ResponseEntity.ok(ApiResponse.success(friendIds)); + } + private ImFriendEntity addFriendLink(String appId, String userId, String friendId) { ImFriendEntity forward = friendRepository .findByAppIdAndUserIdAndFriendId(appId, userId, friendId) @@ -113,6 +161,10 @@ public class FriendController { return friendIds == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(friendIds)); } + private String normalizeGroup(String groupName) { + return groupName == null || groupName.isBlank() ? null : groupName.trim(); + } + @PostMapping("/check") public ResponseEntity>> checkFriends( @AuthenticationPrincipal String userId, 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 bc322ec..4db76e9 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 @@ -10,6 +10,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @RestController @RequestMapping("/api/im/groups") @@ -127,6 +128,33 @@ public class GroupController { groupService.setRole(groupId, userId, req.userId(), req.role()))); } + @PostMapping("/{groupId}/owner") + public ResponseEntity> transferOwner( + @PathVariable String groupId, + @RequestBody TransferOwnerRequest req, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success( + groupService.transferOwner(groupId, userId, req.newOwnerId()))); + } + + @PutMapping("/{groupId}/attributes") + public ResponseEntity> updateAttributes( + @PathVariable String groupId, + @RequestBody Map attributes, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success( + groupService.updateExtAttributes(groupId, userId, attributes))); + } + + @PostMapping("/{groupId}/attributes/delete") + public ResponseEntity> removeAttributes( + @PathVariable String groupId, + @RequestBody AttributeKeysRequest req, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success( + groupService.removeExtAttributes(groupId, userId, req.keys()))); + } + @PostMapping("/{groupId}/mute") public ResponseEntity> muteMember( @PathVariable String groupId, @@ -214,6 +242,8 @@ public class GroupController { public record MemberRequest(String userId) {} public record MemberBatchRequest(List userIds) {} public record SetRoleRequest(String userId, String role) {} + public record TransferOwnerRequest(String newOwnerId) {} + public record AttributeKeysRequest(List keys) {} 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 647fd95..a08ff6b 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 @@ -408,6 +408,40 @@ public class ImAdminController { return ResponseEntity.ok(ApiResponse.success(saved)); } + @PostMapping("/groups/{groupId}/owner") + public ResponseEntity> transferGroupOwner( + @RequestParam String appId, + @PathVariable String groupId, + @AuthenticationPrincipal String operatorId, + @RequestBody TransferOwnerRequest req) { + ImGroupEntity saved = groupService.adminTransferOwner(appId, groupId, req.newOwnerId()); + operationLogService.record(appId, operatorId, "ADMIN_TRANSFER_GROUP_OWNER", "GROUP", groupId, req.newOwnerId()); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @PutMapping("/groups/{groupId}/attributes") + public ResponseEntity> updateGroupAttributes( + @RequestParam String appId, + @PathVariable String groupId, + @AuthenticationPrincipal String operatorId, + @RequestBody Map attributes) { + ImGroupEntity saved = groupService.adminUpdateExtAttributes(appId, groupId, attributes); + operationLogService.record(appId, operatorId, "ADMIN_UPDATE_GROUP_ATTRIBUTES", "GROUP", groupId, null); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @PostMapping("/groups/{groupId}/attributes/delete") + public ResponseEntity> removeGroupAttributes( + @RequestParam String appId, + @PathVariable String groupId, + @AuthenticationPrincipal String operatorId, + @RequestBody AttributeKeysRequest req) { + ImGroupEntity saved = groupService.adminRemoveExtAttributes(appId, groupId, req.keys()); + operationLogService.record(appId, operatorId, "ADMIN_REMOVE_GROUP_ATTRIBUTES", "GROUP", groupId, + String.join(",", req.keys() == null ? List.of() : req.keys())); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + @PostMapping("/groups/{groupId}/mute") public ResponseEntity> muteGroupMember( @RequestParam String appId, @@ -419,6 +453,19 @@ public class ImAdminController { return ResponseEntity.ok(ApiResponse.success(saved)); } + @PostMapping("/groups/{groupId}/read-receipts") + public ResponseEntity>> groupReadReceipts( + @RequestParam String appId, + @PathVariable String groupId, + @AuthenticationPrincipal String operatorId, + @RequestBody GroupReadReceiptRequest req) { + List receipts = + messageService.groupReadReceipts(appId, groupId, req.messageIds()); + operationLogService.record(appId, operatorId, "QUERY_GROUP_READ_RECEIPTS", "GROUP", groupId, + "count=" + receipts.size()); + return ResponseEntity.ok(ApiResponse.success(receipts)); + } + @GetMapping("/groups/{groupId}/join-requests") public ResponseEntity>> listGroupJoinRequests( @RequestParam String appId, @@ -632,7 +679,10 @@ public class ImAdminController { public record BlacklistRequest(String userId, String blockedUserId) {} public record MemberRequest(String userId) {} public record SetRoleRequest(String userId, String role) {} + public record TransferOwnerRequest(String newOwnerId) {} + public record AttributeKeysRequest(List keys) {} public record MuteMemberRequest(String userId, long minutes) {} + public record GroupReadReceiptRequest(List messageIds) {} 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/service/ConversationStateService.java b/im-service/src/main/java/com/xuqm/im/service/ConversationStateService.java index 5e56cdb..03f90bd 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ConversationStateService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ConversationStateService.java @@ -52,6 +52,30 @@ public class ConversationStateService { return repository.save(state); } + @Transactional + public ImConversationStateEntity setHidden(String appId, String userId, String targetId, String chatType, boolean hidden) { + ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); + state.setHidden(hidden); + if (hidden) { + state.setDraft(null); + } + touch(state); + return repository.save(state); + } + + @Transactional + public ImConversationStateEntity setConversationGroup( + String appId, + String userId, + String targetId, + String chatType, + String conversationGroup) { + ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); + state.setConversationGroup(normalizeGroup(conversationGroup)); + touch(state); + return repository.save(state); + } + @Transactional public ImConversationStateEntity hideConversation(String appId, String userId, String targetId, String chatType) { ImConversationStateEntity state = getOrCreate(appId, userId, targetId, chatType); @@ -106,6 +130,7 @@ public class ConversationStateService { entity.setMuted(false); entity.setHidden(false); entity.setDraft(null); + entity.setConversationGroup(null); entity.setLastReadAt(null); entity.setCreatedAt(LocalDateTime.now()); entity.setUpdatedAt(LocalDateTime.now()); @@ -122,7 +147,27 @@ public class ConversationStateService { return repository.findByAppIdAndUserIdAndHiddenFalse(appId, userId); } + public List listConversationGroups(String appId, String userId) { + return repository.findByAppIdAndUserId(appId, userId).stream() + .map(ImConversationStateEntity::getConversationGroup) + .filter(group -> group != null && !group.isBlank()) + .distinct() + .sorted() + .toList(); + } + + public List listByConversationGroup(String appId, String userId, String conversationGroup) { + return repository.findByAppIdAndUserIdAndConversationGroup(appId, userId, normalizeGroup(conversationGroup)); + } + private void touch(ImConversationStateEntity entity) { entity.setUpdatedAt(LocalDateTime.now()); } + + private String normalizeGroup(String group) { + if (group == null || group.isBlank()) { + return null; + } + return group.trim(); + } } 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 5c6c066..c662891 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 @@ -82,6 +82,7 @@ public class ImGroupService { group.setMemberIds(toJson(members)); group.setAdminIds(toJson(List.of(creatorId))); group.setAnnouncement(announcement); + group.setExtAttributes("{}"); group.setCreatedAt(LocalDateTime.now()); ImGroupEntity saved = groupRepository.save(group); webhookDispatchService.dispatch(appId, "group", "group.created", @@ -466,6 +467,50 @@ public class ImGroupService { .toList(); } + @Transactional + public ImGroupEntity transferOwner(String groupId, String operatorId, String newOwnerId) { + ImGroupEntity group = get(groupId); + if (!group.getCreatorId().equals(operatorId)) { + throw new BusinessException(403, "只有群主可以转让群"); + } + return transferOwnerInternal(group, newOwnerId); + } + + @Transactional + public ImGroupEntity adminTransferOwner(String appId, String groupId, String newOwnerId) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + return transferOwnerInternal(group, newOwnerId); + } + + @Transactional + public ImGroupEntity updateExtAttributes(String groupId, String operatorId, Map attributes) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + return updateExtAttributesInternal(group, attributes); + } + + @Transactional + public ImGroupEntity adminUpdateExtAttributes(String appId, String groupId, Map attributes) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + return updateExtAttributesInternal(group, attributes); + } + + @Transactional + public ImGroupEntity removeExtAttributes(String groupId, String operatorId, List keys) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + return removeExtAttributesInternal(group, keys); + } + + @Transactional + public ImGroupEntity adminRemoveExtAttributes(String appId, String groupId, List keys) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + return removeExtAttributesInternal(group, keys); + } + @Transactional public ImGroupEntity adminAddMember(String appId, String groupId, String userId) { ImGroupEntity group = get(groupId); @@ -606,6 +651,65 @@ public class ImGroupService { } } + private ImGroupEntity transferOwnerInternal(ImGroupEntity group, String newOwnerId) { + if (newOwnerId == null || newOwnerId.isBlank()) { + throw new BusinessException(400, "新群主不能为空"); + } + List members = new ArrayList<>(memberIds(group)); + if (!members.contains(newOwnerId)) { + members.add(newOwnerId); + group.setMemberIds(toJson(members)); + } + List admins = new ArrayList<>(adminIds(group)); + if (!admins.contains(newOwnerId)) { + admins.add(newOwnerId); + } + group.setCreatorId(newOwnerId); + group.setAdminIds(toJson(admins)); + ImGroupEntity saved = groupRepository.save(group); + webhookDispatchService.dispatch(saved.getAppId(), "group", "group.owner_transferred", + java.util.Map.of("groupId", saved.getId(), "newOwnerId", newOwnerId)); + return saved; + } + + private ImGroupEntity updateExtAttributesInternal(ImGroupEntity group, Map attributes) { + Map current = parseExtAttributes(group.getExtAttributes()); + if (attributes != null) { + attributes.forEach((key, value) -> { + if (key != null && !key.isBlank()) { + current.put(key, value); + } + }); + } + group.setExtAttributes(toJson(current)); + ImGroupEntity saved = groupRepository.save(group); + webhookDispatchService.dispatch(saved.getAppId(), "group", "group.attributes_updated", + java.util.Map.of("groupId", saved.getId(), "attributes", current)); + return saved; + } + + private ImGroupEntity removeExtAttributesInternal(ImGroupEntity group, List keys) { + Map current = parseExtAttributes(group.getExtAttributes()); + for (String key : keys == null ? List.of() : keys) { + if (key != null && !key.isBlank()) { + current.remove(key); + } + } + group.setExtAttributes(toJson(current)); + return groupRepository.save(group); + } + + private Map parseExtAttributes(String attributesJson) { + try { + if (attributesJson == null || attributesJson.isBlank()) { + return new HashMap<>(); + } + return objectMapper.readValue(attributesJson, new TypeReference<>() {}); + } catch (Exception e) { + return new 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 4c3e9d9..537901c 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 @@ -473,10 +473,34 @@ public class MessageService { toEpochMillis(lastMessage != null ? lastMessage.getCreatedAt() : summary.getLastTime()), (int) unreadCount, state != null && state.isMuted(), - state != null && state.isPinned() + state != null && state.isPinned(), + state == null ? null : state.getConversationGroup() ); } + public List groupReadReceipts(String appId, String groupId, List messageIds) { + ImGroupEntity group = groupService.get(groupId); + if (!group.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + List members = groupService.memberIds(group); + return messageRepository.findAllById(messageIds == null ? List.of() : messageIds).stream() + .filter(message -> appId.equals(message.getAppId())) + .filter(message -> groupId.equals(message.getToId())) + .filter(message -> message.getChatType() == ImMessageEntity.ChatType.GROUP) + .map(message -> { + int readCount = groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()); + return new GroupReadReceiptSummary( + message.getId(), + groupId, + members.size(), + readCount, + Math.max(members.size() - readCount, 0) + ); + }) + .toList(); + } + private long toEpochMillis(LocalDateTime time) { return time == null ? 0L : time.toInstant(ZoneOffset.UTC).toEpochMilli(); } @@ -628,4 +652,12 @@ public class MessageService { String content, ImMessageEntity.MsgStatus status, LocalDateTime createdAt) {} + + public record GroupReadReceiptSummary( + String messageId, + String groupId, + int memberCount, + int readCount, + int unreadCount + ) {} }