From 763c097289613aa1f845eb46254b609d3c41d43e Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 09:45:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E6=B7=BB=E5=8A=A0=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E5=8A=9F=E8=83=BD=E7=9B=B8=E5=85=B3API=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=81=E6=9C=AC=E5=9C=B0=E7=BC=93=E5=AD=98=E5=92=8C?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加DemoApi接口定义用户认证和资料管理API - 实现LocalImCache用于本地存储IM对话和消息历史 - 添加MessageContent模型处理多媒体消息内容 - 创建AttachmentRepository处理图片视频音频文件发送 - 实现AuthRepository管理用户登录注册和会话 - 添加VoiceRecorder支持语音录制功能 - 创建AppDependencies依赖注入容器 - 添加ChatScreen界面组件实现聊天UI逻辑 --- README.md | 15 ++ .../com/xuqm/common/security/JwtUtil.java | 4 + .../demo/controller/DemoAuthController.java | 21 ++ .../xuqm/demo/service/DemoAuthService.java | 49 +++- docs/API_ACCESS.md | 75 +++++- .../xuqm/im/controller/AuthController.java | 10 +- .../im/controller/ConversationController.java | 7 +- .../xuqm/im/controller/GroupController.java | 47 +++- .../xuqm/im/controller/ImAdminController.java | 41 +++- .../xuqm/im/controller/MessageController.java | 18 +- .../com/xuqm/im/entity/ImGroupEntity.java | 6 + .../im/entity/ImGroupJoinRequestEntity.java | 63 +++++ .../com/xuqm/im/entity/ImMessageEntity.java | 9 +- .../ImGroupJoinRequestRepository.java | 15 ++ .../im/repository/ImMessageRepository.java | 61 +++++ .../com/xuqm/im/service/ImAccountService.java | 8 +- .../com/xuqm/im/service/ImGroupService.java | 100 +++++++- .../xuqm/im/service/ImPushBridgeClient.java | 54 +++++ .../com/xuqm/im/service/MessageService.java | 224 +++++++++++++++++- im-service/src/main/resources/application.yml | 1 + .../com/xuqm/push/config/SecurityConfig.java | 36 +++ .../controller/InternalPushController.java | 47 ++++ .../com/xuqm/push/service/PushDispatcher.java | 10 + .../src/main/resources/application.yml | 3 +- 24 files changed, 884 insertions(+), 40 deletions(-) create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImGroupJoinRequestEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImGroupJoinRequestRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java create mode 100644 push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java create mode 100644 push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java diff --git a/README.md b/README.md index 7b7c715..9a91eec 100644 --- a/README.md +++ b/README.md @@ -356,7 +356,11 @@ Frame 格式: | 方法 | 路径 | 说明 | |------|------|------| | POST | `/api/push/register` | 注册设备 token | +| DELETE | `/api/push/device/unregister` | 解绑设备 token | | POST | `/api/push/send` | 向指定用户推送通知 | +| POST | `/api/push/internal/notify` | IM 服务内部调用,批量触发离线推送 | + +其中 `/api/push/register`、`/api/push/device/unregister` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。 **注册 token** ``` @@ -377,6 +381,17 @@ POST /api/push/send &payload={"type":"IM","msgId":"uuid"} ``` +**内部通知** +```json +{ + "appId": "ak_xxx", + "userIds": ["user_001", "user_002"], + "title": "群聊消息", + "body": "张三: Hello!", + "payload": "{\"type\":\"IM\",\"msgId\":\"uuid\"}" +} +``` + ### 环境变量配置 ```yaml diff --git a/common/src/main/java/com/xuqm/common/security/JwtUtil.java b/common/src/main/java/com/xuqm/common/security/JwtUtil.java index d384f93..1aa4983 100644 --- a/common/src/main/java/com/xuqm/common/security/JwtUtil.java +++ b/common/src/main/java/com/xuqm/common/security/JwtUtil.java @@ -20,6 +20,10 @@ public class JwtUtil { @Value("${jwt.expiration:86400000}") private long expiration; + public long getExpirationMillis() { + return expiration; + } + private SecretKey getSigningKey() { byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); return Keys.hmacShaKeyFor(keyBytes); diff --git a/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java b/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java index 974ea83..be1d5b4 100644 --- a/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java +++ b/demo-service/src/main/java/com/xuqm/demo/controller/DemoAuthController.java @@ -2,6 +2,7 @@ package com.xuqm.demo.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.demo.service.DemoAuthService; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -55,11 +56,31 @@ public class DemoAuthController { private Map buildResponse(DemoAuthService.AuthResult result) { return Map.of( "demoToken", result.demoToken() != null ? result.demoToken() : "", + "demoTokenExpiresAt", result.demoTokenExpiresAt(), "imToken", result.imToken() != null ? result.imToken() : "", + "imTokenExpiresAt", result.imTokenExpiresAt(), "profile", result.profile() ); } + @PostMapping("/refresh-im") + public ApiResponse> refreshIm( + @RequestParam(required = false) String appId, + Authentication auth) { + if (auth == null || auth.getPrincipal() == null) { + return ApiResponse.unauthorized("Unauthorized"); + } + if (appId == null || appId.isBlank()) { + return ApiResponse.badRequest("appId is required"); + } + + DemoAuthService.ImCredential result = authService.refreshImToken(appId, auth.getPrincipal().toString()); + return ApiResponse.success(Map.of( + "imToken", result.token(), + "imTokenExpiresAt", result.expiresAt() + )); + } + @PostMapping("/reset-password") public ApiResponse resetPassword(@RequestBody ResetPasswordRequest body) { if (body.appId() == null || body.appId().isBlank()) { diff --git a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java index 33675b2..f57dd18 100644 --- a/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java +++ b/demo-service/src/main/java/com/xuqm/demo/service/DemoAuthService.java @@ -50,7 +50,8 @@ public class DemoAuthService { this.appSecretClient = appSecretClient; } - public record AuthResult(String demoToken, String imToken, UserProfile profile) {} + public record AuthResult(String demoToken, long demoTokenExpiresAt, String imToken, long imTokenExpiresAt, UserProfile profile) {} + public record ImCredential(String token, long expiresAt) {} public record UserProfile(String appId, String userId, String nickname, String avatar, String gender) {} @@ -71,9 +72,15 @@ public class DemoAuthService { userRepository.save(user); String demoToken = generateDemoToken(appId, userId); - String imToken = callImServiceLogin(appId, userId, user.getNickname()); + ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname()); - return new AuthResult(demoToken, imToken, toProfile(user)); + return new AuthResult( + demoToken, + tokenExpiresAt(), + imCredential.token(), + imCredential.expiresAt(), + toProfile(user) + ); } @Transactional(readOnly = true) @@ -86,9 +93,15 @@ public class DemoAuthService { } String demoToken = generateDemoToken(appId, userId); - String imToken = callImServiceLogin(appId, userId, user.getNickname()); + ImCredential imCredential = callImServiceLogin(appId, userId, user.getNickname()); - return new AuthResult(demoToken, imToken, toProfile(user)); + return new AuthResult( + demoToken, + tokenExpiresAt(), + imCredential.token(), + imCredential.expiresAt(), + toProfile(user) + ); } @Transactional @@ -103,12 +116,22 @@ public class DemoAuthService { return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")); } + private long tokenExpiresAt() { + return Instant.now().toEpochMilli() + jwtUtil.getExpirationMillis(); + } + + public ImCredential refreshImToken(String appId, String userId) { + DemoUserEntity user = userRepository.findByAppIdAndUserId(appId, userId) + .orElseThrow(() -> new BusinessException(404, "User not found: " + userId)); + return callImServiceLogin(appId, userId, user.getNickname()); + } + /** * Calls im-service to ensure the IM account exists and obtain an IM token. * POST {imServiceUrl}/api/im/auth/login?appId={appId}&userId={userId}&nickname={nickname} * Response: {"code":200,"data":{"token":"..."}} */ - private String callImServiceLogin(String appId, String userId, String nickname) { + private ImCredential callImServiceLogin(String appId, String userId, String nickname) { long timestamp = System.currentTimeMillis(); String nonce = UUID.randomUUID().toString(); String effectiveNickname = nickname != null ? nickname : userId; @@ -136,13 +159,21 @@ public class DemoAuthService { ); JsonNode body = response.getBody(); if (body != null && body.path("code").asInt() == 200) { - return body.path("data").path("token").asText(); + JsonNode data = body.path("data"); + String token = data.path("token").asText(null); + if (token == null || token.isBlank()) { + throw new BusinessException(502, "Failed to refresh IM token"); + } + return new ImCredential( + token, + data.path("expiresAt").asLong(tokenExpiresAt()) + ); } log.warn("im-service login returned unexpected response for appId={} userId={}: {}", appId, userId, body); - return null; + throw new BusinessException(502, "Failed to refresh IM token"); } catch (RestClientException e) { log.error("Failed to call im-service login for appId={} userId={}: {}", appId, userId, e.getMessage()); - return null; + throw new BusinessException(502, "Failed to refresh IM token"); } } diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 90a117e..f816799 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -1,6 +1,6 @@ # XuqmGroup Server 联调接口文档 -> 最后更新:2026-04-24 +> 最后更新:2026-04-28 ## 线上入口 @@ -9,6 +9,7 @@ | 租户服务 | `https://dev.xuqinmin.com/api/` | 认证、应用、子账号、运营平台 | | IM HTTP | `https://dev.xuqinmin.com/api/im/` | IM 登录、消息发送、撤回、历史消息 | | IM WebSocket | `wss://dev.xuqinmin.com/ws/im` | 实时消息 | +| 文件服务 | `https://file.dev.xuqinmin.com/api/file/` | 文件上传、下载、缩略图 | | App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 | | RN 热更新 | `https://dev.xuqinmin.com/api/v1/rn/` | Bundle 热更新 | @@ -88,7 +89,7 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| | POST | `/api/im/auth/login` | 否 | 获取 IM Token;需要 `X-App-Timestamp` / `X-App-Nonce` / `X-App-Signature` | -| POST | `/api/im/messages/send` | 是 | 发送消息 | +| POST | `/api/im/messages/send` | 是 | 发送消息(TEXT / IMAGE / AUDIO / VIDEO / FILE / LOCATION / CUSTOM / NOTIFY / RICH_TEXT / CALL_AUDIO / CALL_VIDEO / FORWARD / QUOTE / MERGE) | | POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 | | GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 | | WS | `/ws/im` | IM Token | 建立实时连接 | @@ -100,6 +101,14 @@ | POST | `/api/push/register` | 是 | 注册设备 token | | POST | `/api/push/send` | 是 | 发送推送通知 | +### file-service + +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| POST | `/api/file/upload` | 是 | 文件上传,按 SHA-256 去重 | +| GET | `/api/file/{hash}` | 否 | 按 hash 获取文件 | +| GET | `/api/file/{hash}/thumbnail` | 否 | 按 hash 获取缩略图 | + ### update-service | 方法 | 路径 | 鉴权 | 说明 | @@ -144,6 +153,17 @@ curl 'https://dev.xuqinmin.com/api/v1/rn/update/check?appId=ak_demo_chat&platfor curl -X POST 'https://dev.xuqinmin.com/api/im/auth/login?appId=ak_demo_chat&userId=demo_alice' ``` +返回示例中的 `data` 会同时包含 `token` 和 `expiresAt`,用于客户端提前做静默续签。 + +### Demo IM 刷新 + +```bash +curl -X POST 'https://dev.xuqinmin.com/api/demo/auth/refresh-im?appId=ak_demo_chat' \ + -H 'Authorization: Bearer ' +``` + +该接口会基于 demo 登录态重新签发 IM token,并返回新的 `expiresAt`。 + ### IM 会话与关系链 ```bash @@ -152,6 +172,57 @@ curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/draft?appId=ak_demo_chat&chatType=SINGLE&draft=hello' curl -X DELETE 'https://dev.xuqinmin.com/api/im/conversations/user_002?appId=ak_demo_chat&chatType=SINGLE' 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 -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入' +curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat' +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/friend-requests?appId=ak_demo_chat&direction=incoming' ``` + +### IM 管理后台接口 + +```bash +curl 'https://dev.xuqinmin.com/api/im/admin/users?appId=ak_demo_chat&page=0&size=20' +curl 'https://dev.xuqinmin.com/api/im/admin/groups?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=user_001&userB=user_002&page=0&size=20' +curl -X POST 'https://dev.xuqinmin.com/api/im/admin/messages/msg_001/revoke?appId=ak_demo_chat' +curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001' +``` + +### 好友申请 / 黑名单 + +```bash +curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&toUserId=user_002&remark=hi' +curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/accept?appId=ak_demo_chat' +curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/reject?appId=ak_demo_chat' +curl -X POST 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002' +curl -X DELETE 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002' +``` + +### IM 历史过滤查询 + +```bash +curl 'https://dev.xuqinmin.com/api/im/messages/history/user_002?appId=ak_demo_chat&page=0&size=20&keyword=hello&msgType=TEXT' +curl 'https://dev.xuqinmin.com/api/im/messages/group-history/group_001?appId=ak_demo_chat&page=0&size=20&keyword=user_002&startTime=2026-04-27T00:00:00&endTime=2026-04-27T23:59:59' +``` + +支持的筛选参数:`keyword`、`msgType`、`startTime`、`endTime`。 + +### IM 媒体消息 + +```bash +curl -X POST 'https://file.dev.xuqinmin.com/api/file/upload' \ + -H 'Authorization: Bearer ' \ + -F 'file=@image.jpg' + +curl -X POST 'https://im.dev.xuqinmin.com/api/im/messages/send?appId=ak_demo_chat' \ + -H 'Authorization: Bearer ' \ + -H 'Content-Type: application/json' \ + -d '{"toId":"user_002","chatType":"SINGLE","msgType":"IMAGE","content":"{\"url\":\"https://file.dev.xuqinmin.com/api/file/abc\"}"}' +``` + +当前 `im-service` 的消息发送接口已经支持 `AUDIO`、`LOCATION`、`CUSTOM`、`RICH_TEXT`、`FORWARD`、`QUOTE`、`MERGE`、`CALL_AUDIO`、`CALL_VIDEO` 这些通用类型,客户端只需按协议传入对应 JSON 内容即可。 + +群聊消息在历史查询和实时推送里会携带 `groupReadCount`,用于展示 `N 人已读`。 diff --git a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java index 6f28116..9caad10 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java @@ -1,7 +1,6 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; -import com.xuqm.common.security.AppRequestSignatureUtil; import com.xuqm.im.service.ImAccountService; import jakarta.validation.constraints.NotBlank; import org.springframework.http.ResponseEntity; @@ -24,7 +23,7 @@ public class AuthController { } @PostMapping("/login") - public ResponseEntity>> login( + public ResponseEntity>> login( @RequestParam @NotBlank String appId, @RequestParam @NotBlank String userId, @RequestParam(required = false) String nickname, @@ -36,7 +35,10 @@ public class AuthController { return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature")); } accountService.validateSignature(appId, userId, nickname, avatar, timestamp, nonce, signature); - String token = accountService.loginOrRegister(appId, userId, nickname, avatar); - return ResponseEntity.ok(ApiResponse.success(Map.of("token", token))); + ImAccountService.LoginResult result = accountService.loginOrRegister(appId, userId, nickname, avatar); + return ResponseEntity.ok(ApiResponse.success(Map.of( + "token", result.token(), + "expiresAt", result.expiresAt() + ))); } } 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 2a11af7..1017406 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 @@ -66,7 +66,12 @@ public class ConversationController { @RequestParam String appId, @PathVariable String targetId, @RequestParam String chatType) { - conversationStateService.markRead(appId, userId, targetId, chatType); + var state = conversationStateService.markRead(appId, userId, targetId, chatType); + if ("GROUP".equalsIgnoreCase(chatType)) { + messageService.syncGroupReadReceipt(appId, userId, targetId, state.getLastReadAt()); + } else { + messageService.syncReadReceipt(appId, userId, targetId, chatType, state.getLastReadAt()); + } return ResponseEntity.ok(ApiResponse.ok()); } 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 ae9bd7a..c8d7c8b 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 @@ -2,6 +2,7 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.im.entity.ImGroupEntity; +import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.service.ImGroupService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -32,7 +33,7 @@ public class GroupController { @AuthenticationPrincipal String userId, @RequestParam String appId) { return ResponseEntity.ok(ApiResponse.success( - groupService.create(appId, req.name(), userId, req.memberIds()))); + groupService.create(appId, req.name(), userId, req.memberIds(), req.groupType()))); } @GetMapping @@ -42,6 +43,13 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.listUserGroups(appId, userId))); } + @GetMapping("/public") + public ResponseEntity>> listPublic( + @RequestParam String appId, + @RequestParam(required = false) String keyword) { + return ResponseEntity.ok(ApiResponse.success(groupService.listPublicGroups(appId, keyword))); + } + @PutMapping("/{groupId}") public ResponseEntity> update( @PathVariable String groupId, @@ -93,7 +101,42 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.ok()); } - public record CreateGroupRequest(String name, List memberIds) {} + @PostMapping("/{groupId}/join-requests") + public ResponseEntity> sendJoinRequest( + @PathVariable String groupId, + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestParam(required = false) String remark) { + return ResponseEntity.ok(ApiResponse.success(groupService.sendJoinRequest(appId, groupId, userId, remark))); + } + + @GetMapping("/{groupId}/join-requests") + public ResponseEntity>> listJoinRequests( + @PathVariable String groupId, + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(groupService.listJoinRequests(appId, groupId, userId))); + } + + @PostMapping("/{groupId}/join-requests/{requestId}/accept") + public ResponseEntity> acceptJoinRequest( + @PathVariable String groupId, + @PathVariable String requestId, + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(groupService.acceptJoinRequest(appId, requestId, userId))); + } + + @PostMapping("/{groupId}/join-requests/{requestId}/reject") + public ResponseEntity> rejectJoinRequest( + @PathVariable String groupId, + @PathVariable String requestId, + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(groupService.rejectJoinRequest(appId, requestId, userId))); + } + + public record CreateGroupRequest(String name, List memberIds, String groupType) {} public record UpdateGroupRequest(String name, String announcement) {} public record MemberRequest(String userId) {} public record SetRoleRequest(String userId, String role) {} 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 f90b01d..552a64f 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 @@ -8,11 +8,13 @@ import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImMessageRepository; import com.xuqm.im.service.ImAccountService; import com.xuqm.im.service.ImGroupService; +import com.xuqm.im.service.MessageService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -25,17 +27,20 @@ public class ImAdminController { private final ImMessageRepository messageRepository; private final ImAccountService accountService; private final ImGroupService groupService; + private final MessageService messageService; public ImAdminController(ImAccountRepository accountRepository, ImGroupRepository groupRepository, ImMessageRepository messageRepository, ImAccountService accountService, - ImGroupService groupService) { + ImGroupService groupService, + MessageService messageService) { this.accountRepository = accountRepository; this.groupRepository = groupRepository; this.messageRepository = messageRepository; this.accountService = accountService; this.groupService = groupService; + this.messageService = messageService; } /** List all registered IM users for the given appId. */ @@ -83,7 +88,7 @@ public class ImAdminController { @RequestParam String appId, @RequestBody CreateGroupRequest req) { return ResponseEntity.ok(ApiResponse.success( - groupService.create(appId, req.name(), req.creatorId(), req.memberIds()))); + groupService.create(appId, req.name(), req.creatorId(), req.memberIds(), "WORK"))); } /** Fuzzy search users by userId or nickname. */ @@ -112,6 +117,38 @@ public class ImAdminController { ))); } + /** Admin queries conversation history between any two users. */ + @GetMapping("/messages") + public ResponseEntity>> history( + @RequestParam String appId, + @RequestParam String userA, + @RequestParam String userB, + @RequestParam(required = false) com.xuqm.im.entity.ImMessageEntity.MsgType msgType, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) LocalDateTime startTime, + @RequestParam(required = false) LocalDateTime endTime, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success( + messageRepository.findSingleConversationFiltered( + appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size)))); + } + + /** Admin revokes an arbitrary message. */ + @PostMapping("/messages/{messageId}/revoke") + public ResponseEntity> adminRevoke( + @RequestParam String appId, + @PathVariable String messageId) { + return ResponseEntity.ok(ApiResponse.success(messageService.adminRevoke(appId, messageId))); + } + + /** Admin force dismisses a group. */ + @DeleteMapping("/groups/{groupId}") + public ResponseEntity> adminDismissGroup(@PathVariable String groupId) { + groupService.adminDismiss(groupId); + return ResponseEntity.ok(ApiResponse.ok()); + } + public record RegisterUserRequest(String userId, String nickname, String avatar) {} public record CreateGroupRequest(String name, String creatorId, List memberIds) {} } diff --git a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java index 4579254..dfeac30 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java @@ -4,8 +4,8 @@ import com.xuqm.common.model.ApiResponse; import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.model.SendMessageRequest; import com.xuqm.im.service.MessageService; -import io.jsonwebtoken.Claims; import jakarta.validation.Valid; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -16,6 +16,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDateTime; + @RestController @RequestMapping("/api/im/messages") public class MessageController { @@ -47,9 +49,14 @@ public class MessageController { @PathVariable String toId, @AuthenticationPrincipal String userId, @RequestParam String appId, + @RequestParam(required = false) ImMessageEntity.MsgType msgType, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - return ResponseEntity.ok(ApiResponse.success(messageService.history(appId, userId, toId, page, size))); + return ResponseEntity.ok(ApiResponse.success( + messageService.history(appId, userId, toId, msgType, keyword, startTime, endTime, page, size))); } @GetMapping("/group-history/{groupId}") @@ -57,8 +64,13 @@ public class MessageController { @PathVariable String groupId, @AuthenticationPrincipal String userId, @RequestParam String appId, + @RequestParam(required = false) ImMessageEntity.MsgType msgType, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - return ResponseEntity.ok(ApiResponse.success(messageService.groupHistory(appId, groupId, userId, page, size))); + return ResponseEntity.ok(ApiResponse.success( + messageService.groupHistory(appId, groupId, userId, msgType, keyword, startTime, endTime, page, size))); } } 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 bf699f6..449adc4 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 @@ -21,6 +21,9 @@ public class ImGroupEntity { @Column(nullable = false, length = 128) private String name; + @Column(length = 16) + private String groupType; + @Column(nullable = false, length = 128) private String creatorId; @@ -46,6 +49,9 @@ public class ImGroupEntity { public String getName() { return name; } public void setName(String name) { this.name = name; } + public String getGroupType() { return groupType; } + public void setGroupType(String groupType) { this.groupType = groupType; } + public String getCreatorId() { return creatorId; } public void setCreatorId(String creatorId) { this.creatorId = creatorId; } diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImGroupJoinRequestEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImGroupJoinRequestEntity.java new file mode 100644 index 0000000..06a0735 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImGroupJoinRequestEntity.java @@ -0,0 +1,63 @@ +package com.xuqm.im.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "im_group_join_request", + indexes = @Index(name = "idx_group_join_request_app_group", columnList = "appId,groupId") +) +public class ImGroupJoinRequestEntity extends BaseIdEntity { + + public enum Status { PENDING, ACCEPTED, REJECTED } + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 64) + private String groupId; + + @Column(nullable = false, length = 128) + private String requesterId; + + @Column(length = 256) + private String remark; + + @Column(nullable = false, length = 16) + private String status; + + @Column(nullable = false) + private LocalDateTime createdAt; + + private LocalDateTime reviewedAt; + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getGroupId() { return groupId; } + public void setGroupId(String groupId) { this.groupId = groupId; } + + public String getRequesterId() { return requesterId; } + public void setRequesterId(String requesterId) { this.requesterId = requesterId; } + + public String getRemark() { return remark; } + public void setRemark(String remark) { this.remark = remark; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + public LocalDateTime getReviewedAt() { return reviewedAt; } + public void setReviewedAt(LocalDateTime reviewedAt) { this.reviewedAt = reviewedAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java index 4daf776..6287ef8 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImMessageEntity.java @@ -9,6 +9,7 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; +import jakarta.persistence.Transient; import java.time.LocalDateTime; @Entity @@ -21,7 +22,7 @@ public class ImMessageEntity { public enum ChatType { SINGLE, GROUP } public enum MsgType { TEXT, IMAGE, VIDEO, AUDIO, FILE, CUSTOM, LOCATION, NOTIFY, - RICH_TEXT, CALL_AUDIO, CALL_VIDEO, REVOKED, FORWARD + RICH_TEXT, CALL_AUDIO, CALL_VIDEO, FORWARD, QUOTE, MERGE, REVOKED } public enum MsgStatus { SENT, DELIVERED, READ, REVOKED } @@ -55,6 +56,9 @@ public class ImMessageEntity { @Column(length = 128) private String mentionedUserIds; + @Transient + private Integer groupReadCount; + @Column(nullable = false) @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) private LocalDateTime createdAt; @@ -86,6 +90,9 @@ public class ImMessageEntity { public String getMentionedUserIds() { return mentionedUserIds; } public void setMentionedUserIds(String mentionedUserIds) { this.mentionedUserIds = mentionedUserIds; } + public Integer getGroupReadCount() { return groupReadCount; } + public void setGroupReadCount(Integer groupReadCount) { this.groupReadCount = groupReadCount; } + @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/ImGroupJoinRequestRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImGroupJoinRequestRepository.java new file mode 100644 index 0000000..01868f7 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImGroupJoinRequestRepository.java @@ -0,0 +1,15 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImGroupJoinRequestEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface ImGroupJoinRequestRepository extends JpaRepository { + Optional findByAppIdAndGroupIdAndRequesterId( + String appId, String groupId, String requesterId); + + List findByAppIdAndGroupId(String appId, String groupId); + List findByAppIdAndRequesterId(String appId, String requesterId); +} 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 b5fe1f4..b14e06b 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 @@ -64,6 +64,44 @@ public interface ImMessageRepository extends JpaRepository= :startTime) + and (:endTime is null or m.createdAt <= :endTime) + order by m.createdAt desc + """) + Page findSingleConversationFiltered( + @Param("appId") String appId, + @Param("userId") String userId, + @Param("peerId") String peerId, + @Param("msgType") ImMessageEntity.MsgType msgType, + @Param("keyword") String keyword, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime, + Pageable pageable); + + List findByAppIdAndFromUserIdAndToIdAndCreatedAtLessThanEqualOrderByCreatedAtAsc( + String appId, + String fromUserId, + String toId, + LocalDateTime createdAt); + + List findByAppIdAndToIdAndChatTypeAndCreatedAtLessThanEqualOrderByCreatedAtAsc( + String appId, + String toId, + ImMessageEntity.ChatType chatType, + LocalDateTime createdAt); + @Query(""" select m from ImMessageEntity m where m.appId = :appId @@ -76,6 +114,29 @@ public interface ImMessageRepository extends JpaRepository= :startTime) + and (:endTime is null or m.createdAt <= :endTime) + order by m.createdAt desc + """) + Page findGroupHistoryFiltered( + @Param("appId") String appId, + @Param("groupId") String groupId, + @Param("msgType") ImMessageEntity.MsgType msgType, + @Param("keyword") String keyword, + @Param("startTime") LocalDateTime startTime, + @Param("endTime") LocalDateTime endTime, + Pageable pageable); + @Query(""" select count(m) from ImMessageEntity m where m.appId = :appId diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java index af88a49..1a14b78 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java @@ -8,12 +8,15 @@ import com.xuqm.im.repository.ImAccountRepository; import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.time.Instant; import java.util.Map; import java.util.UUID; @Service public class ImAccountService { + public record LoginResult(String token, long expiresAt) {} + private final ImAccountRepository accountRepository; private final JwtUtil jwtUtil; private final ImAppSecretClient appSecretClient; @@ -48,7 +51,7 @@ public class ImAccountService { } } - public String loginOrRegister(String appId, String userId, String nickname, String avatar) { + public LoginResult loginOrRegister(String appId, String userId, String nickname, String avatar) { ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId) .orElseGet(() -> { ImAccountEntity e = new ImAccountEntity(); @@ -67,7 +70,8 @@ public class ImAccountService { throw new BusinessException(403, "账号已被封禁"); } - return jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")); + long expiresAt = Instant.now().toEpochMilli() + jwtUtil.getExpirationMillis(); + return new LoginResult(jwtUtil.generate(userId, Map.of("appId", appId, "role", "USER")), expiresAt); } public ImAccountEntity getAccount(String appId, 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 f9efabd..8839e9b 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 @@ -4,7 +4,9 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.xuqm.common.exception.BusinessException; import com.xuqm.im.entity.ImGroupEntity; +import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.entity.ImGroupMuteEntity; +import com.xuqm.im.repository.ImGroupJoinRequestRepository; import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImGroupMuteRepository; import org.springframework.stereotype.Service; @@ -21,18 +23,21 @@ public class ImGroupService { private final ImGroupRepository groupRepository; private final ImGroupMuteRepository muteRepository; + private final ImGroupJoinRequestRepository joinRequestRepository; private final ObjectMapper objectMapper; public ImGroupService(ImGroupRepository groupRepository, ImGroupMuteRepository muteRepository, + ImGroupJoinRequestRepository joinRequestRepository, ObjectMapper objectMapper) { this.groupRepository = groupRepository; this.muteRepository = muteRepository; + this.joinRequestRepository = joinRequestRepository; this.objectMapper = objectMapper; } @Transactional - public ImGroupEntity create(String appId, String name, String creatorId, List memberIds) { + public ImGroupEntity create(String appId, String name, String creatorId, List memberIds, String groupType) { List members = new ArrayList<>(memberIds); if (!members.contains(creatorId)) members.add(creatorId); @@ -40,6 +45,7 @@ public class ImGroupService { group.setId(UUID.randomUUID().toString()); group.setAppId(appId); group.setName(name); + group.setGroupType(normalizeGroupType(groupType)); group.setCreatorId(creatorId); group.setMemberIds(toJson(members)); group.setAdminIds(toJson(List.of(creatorId))); @@ -147,6 +153,12 @@ public class ImGroupService { groupRepository.delete(group); } + @Transactional + public void adminDismiss(String groupId) { + muteRepository.deleteByGroupId(groupId); + groupRepository.deleteById(groupId); + } + public boolean isMemberMuted(String groupId, String userId) { return muteRepository.findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()).isPresent(); } @@ -167,6 +179,70 @@ public class ImGroupService { return groupRepository.findUserGroups(appId, userId); } + public List listPublicGroups(String appId, String keyword) { + String normalizedKeyword = keyword == null ? "" : keyword.trim().toLowerCase(); + return groupRepository.findByAppId(appId).stream() + .filter(group -> "PUBLIC".equalsIgnoreCase(normalizeGroupType(group.getGroupType()))) + .filter(group -> normalizedKeyword.isBlank() + || group.getName().toLowerCase().contains(normalizedKeyword) + || group.getId().toLowerCase().contains(normalizedKeyword)) + .toList(); + } + + @Transactional + public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) { + ImGroupEntity group = get(groupId); + if (!group.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + if (!"PUBLIC".equalsIgnoreCase(normalizeGroupType(group.getGroupType()))) { + throw new BusinessException(400, "该群不支持申请加入"); + } + if (memberIds(group).contains(requesterId)) { + throw new BusinessException(400, "已经在群内"); + } + return joinRequestRepository.findByAppIdAndGroupIdAndRequesterId(appId, groupId, requesterId) + .orElseGet(() -> { + ImGroupJoinRequestEntity entity = new ImGroupJoinRequestEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setGroupId(groupId); + entity.setRequesterId(requesterId); + entity.setRemark(remark); + entity.setStatus(ImGroupJoinRequestEntity.Status.PENDING.name()); + entity.setCreatedAt(LocalDateTime.now()); + return joinRequestRepository.save(entity); + }); + } + + public List listJoinRequests(String appId, String groupId, String operatorId) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + return joinRequestRepository.findByAppIdAndGroupId(appId, groupId); + } + + @Transactional + public ImGroupJoinRequestEntity acceptJoinRequest(String appId, String requestId, String operatorId) { + ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId); + ImGroupEntity group = get(request.getGroupId()); + ensureCanManage(group, operatorId); + request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name()); + request.setReviewedAt(LocalDateTime.now()); + joinRequestRepository.save(request); + addMemberInternal(group, request.getRequesterId()); + return request; + } + + @Transactional + public ImGroupJoinRequestEntity rejectJoinRequest(String appId, String requestId, String operatorId) { + ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId); + ImGroupEntity group = get(request.getGroupId()); + ensureCanManage(group, operatorId); + request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); + request.setReviewedAt(LocalDateTime.now()); + return joinRequestRepository.save(request); + } + private String toJson(List list) { try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; } } @@ -181,4 +257,26 @@ public class ImGroupService { throw new BusinessException(403, "无权操作"); } } + + private void addMemberInternal(ImGroupEntity group, String userId) { + List members = fromJson(group.getMemberIds()); + if (!members.contains(userId)) { + members.add(userId); + group.setMemberIds(toJson(members)); + groupRepository.save(group); + } + } + + private ImGroupJoinRequestEntity getJoinRequest(String appId, String requestId) { + ImGroupJoinRequestEntity request = joinRequestRepository.findById(requestId) + .orElseThrow(() -> new BusinessException(404, "加群申请不存在")); + if (!request.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + return request; + } + + private String normalizeGroupType(String groupType) { + return (groupType == null || groupType.isBlank()) ? "WORK" : groupType.trim().toUpperCase(); + } } diff --git a/im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java b/im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java new file mode 100644 index 0000000..67e101b --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/ImPushBridgeClient.java @@ -0,0 +1,54 @@ +package com.xuqm.im.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClientException; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class ImPushBridgeClient { + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper objectMapper; + + @Value("${im.push-service-url:http://xuqm-push-service:8083}") + private String pushServiceUrl; + + @Value("${im.internal-token:xuqm-internal-token}") + private String internalToken; + + public ImPushBridgeClient(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public void notifyUsers(String appId, List userIds, String title, String body, String payload) { + if (userIds == null || userIds.isEmpty()) { + return; + } + try { + Map bodyMap = new LinkedHashMap<>(); + bodyMap.put("appId", appId); + bodyMap.put("userIds", userIds); + bodyMap.put("title", title); + bodyMap.put("body", body); + bodyMap.put("payload", payload); + String json = objectMapper.writeValueAsString(bodyMap); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(pushServiceUrl + "/api/push/internal/notify")) + .header("Content-Type", "application/json") + .header("X-Internal-Token", internalToken) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .build(); + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (Exception ignored) { + } + } +} 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 afaea86..ffb9fbe 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 @@ -23,6 +23,7 @@ import java.time.LocalDateTime; import java.time.ZoneOffset; import com.xuqm.im.repository.ImMessageRepository; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -36,6 +37,7 @@ public class MessageService { private final ImGroupService groupService; private final BlacklistService blacklistService; private final ConversationStateService conversationStateService; + private final ImPushBridgeClient pushBridgeClient; private final ObjectMapper objectMapper; @Value("${im.webhook-timeout-ms:3000}") @@ -48,6 +50,7 @@ public class MessageService { ImGroupService groupService, BlacklistService blacklistService, ConversationStateService conversationStateService, + ImPushBridgeClient pushBridgeClient, ObjectMapper objectMapper) { this.messageRepository = messageRepository; this.webhookRepository = webhookRepository; @@ -56,6 +59,7 @@ public class MessageService { this.groupService = groupService; this.blacklistService = blacklistService; this.conversationStateService = conversationStateService; + this.pushBridgeClient = pushBridgeClient; this.objectMapper = objectMapper; } @@ -91,21 +95,41 @@ public class MessageService { message.setStatus(ImMessageEntity.MsgStatus.SENT); message.setMentionedUserIds(req.mentionedUserIds()); message.setCreatedAt(LocalDateTime.now()); - messageRepository.save(message); + ImMessageEntity saved = messageRepository.save(message); + if (req.chatType() == ImMessageEntity.ChatType.GROUP) { + saved.setGroupReadCount(groupReadCount(appId, req.toId(), saved.getCreatedAt(), saved.getFromUserId())); + } String destination = req.chatType() == ImMessageEntity.ChatType.SINGLE ? "/user/" + req.toId() + "/queue/messages" : "/topic/group/" + req.toId(); - clusterPublisher.publish(destination, message); + clusterPublisher.publish(destination, saved); if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) { - clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", message); + clusterPublisher.publish("/user/" + fromUserId + "/queue/messages", saved); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId, req.toId())); + pushBridgeClient.notifyUsers( + appId, + List.of(req.toId()), + "新消息", + saved.getContent(), + buildPushPayload(saved) + ); } else if (req.chatType() == ImMessageEntity.ChatType.GROUP) { - conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), groupService.memberIds(group)); + List memberIds = groupService.memberIds(group); + conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds); + pushBridgeClient.notifyUsers( + appId, + memberIds.stream() + .filter(memberId -> !memberId.equals(fromUserId)) + .toList(), + "群聊消息", + saved.getContent(), + buildPushPayload(saved) + ); } - dispatchWebhooks(appId, message); - return message; + dispatchWebhooks(appId, saved); + return saved; } public ImMessageEntity revoke(String appId, String messageId, String requestUserId) { @@ -132,17 +156,126 @@ public class MessageService { return saved; } - public Page history(String appId, String userId, String toId, int page, int size) { - return messageRepository.findSingleConversation( - appId, userId, toId, PageRequest.of(page, size)); + public ImMessageEntity adminRevoke(String appId, String messageId) { + ImMessageEntity message = messageRepository.findById(messageId) + .orElseThrow(() -> new BusinessException(404, "消息不存在")); + if (!message.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + message.setStatus(ImMessageEntity.MsgStatus.REVOKED); + message.setMsgType(ImMessageEntity.MsgType.REVOKED); + ImMessageEntity saved = messageRepository.save(message); + if (saved.getChatType() == ImMessageEntity.ChatType.SINGLE) { + clusterPublisher.publish("/user/" + saved.getToId() + "/queue/messages", saved); + if (!saved.getFromUserId().equals(saved.getToId())) { + clusterPublisher.publish("/user/" + saved.getFromUserId() + "/queue/messages", saved); + } + } else { + clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); + } + return saved; } - public Page groupHistory(String appId, String groupId, String userId, int page, int size) { + public Page history( + String appId, + String userId, + String toId, + ImMessageEntity.MsgType msgType, + String keyword, + LocalDateTime startTime, + LocalDateTime endTime, + int page, + int size) { + return messageRepository.findSingleConversationFiltered( + appId, userId, toId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + } + + public Page groupHistory( + String appId, + String groupId, + String userId, + ImMessageEntity.MsgType msgType, + String keyword, + LocalDateTime startTime, + LocalDateTime endTime, + int page, + int size) { ImGroupEntity group = groupService.get(groupId); if (!groupService.memberIds(group).contains(userId)) { throw new BusinessException(403, "不在群内"); } - return messageRepository.findGroupHistory(appId, groupId, PageRequest.of(page, size)); + Page pageResult = messageRepository.findGroupHistoryFiltered( + appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + pageResult.forEach(message -> message.setGroupReadCount( + groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()))); + return pageResult; + } + + public void syncReadReceipt(String appId, String readerId, String peerId, String chatType, LocalDateTime readAt) { + if (!ImMessageEntity.ChatType.SINGLE.name().equals(chatType) || readerId.equals(peerId)) { + return; + } + List messages = messageRepository + .findByAppIdAndFromUserIdAndToIdAndCreatedAtLessThanEqualOrderByCreatedAtAsc( + appId, peerId, readerId, readAt); + if (messages.isEmpty()) { + return; + } + for (ImMessageEntity message : messages) { + if (message.getStatus() == ImMessageEntity.MsgStatus.READ) { + continue; + } + message.setStatus(ImMessageEntity.MsgStatus.READ); + ImMessageEntity saved = messageRepository.save(message); + clusterPublisher.publish("/user/" + peerId + "/queue/messages", saved); + } + } + + public void syncGroupReadReceipt(String appId, String readerId, String groupId, LocalDateTime readAt) { + ImGroupEntity group = groupService.get(groupId); + if (!groupService.memberIds(group).contains(readerId)) { + return; + } + List messages = messageRepository + .findByAppIdAndToIdAndChatTypeAndCreatedAtLessThanEqualOrderByCreatedAtAsc( + appId, groupId, ImMessageEntity.ChatType.GROUP, readAt); + if (messages.isEmpty()) { + return; + } + for (ImMessageEntity message : messages) { + message.setGroupReadCount(groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())); + clusterPublisher.publish("/topic/group/" + groupId, message); + } + } + + public Page adminHistory( + String appId, + String userA, + String userB, + ImMessageEntity.MsgType msgType, + String keyword, + LocalDateTime startTime, + LocalDateTime endTime, + int page, + int size) { + return messageRepository.findSingleConversationFiltered( + appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + } + + public Page adminGroupHistory( + String appId, + String groupId, + ImMessageEntity.MsgType msgType, + String keyword, + LocalDateTime startTime, + LocalDateTime endTime, + int page, + int size) { + Page pageResult = messageRepository.findGroupHistoryFiltered( + appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + pageResult.forEach(message -> message.setGroupReadCount( + groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()))); + return pageResult; } public List conversations(String appId, String userId, int size) { @@ -180,7 +313,7 @@ public class MessageService { return new ConversationView( targetId, chatType, - lastMessage != null ? lastMessage.getContent() : null, + lastMessage != null ? conversationPreview(lastMessage) : null, lastMessage != null ? lastMessage.getMsgType().name() : null, toEpochMillis(lastMessage != null ? lastMessage.getCreatedAt() : summary.getLastTime()), (int) unreadCount, @@ -193,6 +326,73 @@ public class MessageService { return time == null ? 0L : time.toInstant(ZoneOffset.UTC).toEpochMilli(); } + private String buildPushPayload(ImMessageEntity message) { + try { + return objectMapper.writeValueAsString(Map.of( + "messageId", message.getId(), + "appId", message.getAppId(), + "fromUserId", message.getFromUserId(), + "toId", message.getToId(), + "chatType", message.getChatType().name(), + "msgType", message.getMsgType().name() + )); + } catch (Exception e) { + return "{}"; + } + } + + private String conversationPreview(ImMessageEntity message) { + String content = message.getContent(); + return switch (message.getMsgType()) { + case TEXT -> extractJsonField(content, "text").orElse(content); + case IMAGE -> "[图片]"; + case AUDIO -> "[语音]"; + case VIDEO -> "[视频]"; + case FILE -> "[文件]" + extractJsonField(content, "name").map(name -> " " + name).orElse(""); + case LOCATION -> "[位置]"; + case CUSTOM -> "[自定义]"; + case RICH_TEXT -> "[富文本]"; + case CALL_AUDIO -> "[语音通话]"; + case CALL_VIDEO -> "[视频通话]"; + case FORWARD -> "[转发]"; + case QUOTE -> extractJsonField(content, "text").orElse("[引用]"); + case MERGE -> extractJsonField(content, "title").orElse("[合并转发]"); + case REVOKED -> "[消息已撤回]"; + case NOTIFY -> extractJsonField(content, "content").orElse("[通知]"); + default -> content; + }; + } + + private int groupReadCount(String appId, String groupId, LocalDateTime createdAt, String senderId) { + ImGroupEntity group = groupService.get(groupId); + int count = 0; + for (String memberId : groupService.memberIds(group)) { + if (memberId.equals(senderId)) { + count += 1; + continue; + } + var state = conversationStateService.find(appId, memberId, groupId, ImMessageEntity.ChatType.GROUP.name()); + if (state != null && state.getLastReadAt() != null && !state.getLastReadAt().isBefore(createdAt)) { + count += 1; + } + } + return Math.max(count, 1); + } + + private java.util.Optional extractJsonField(String content, String field) { + try { + var node = objectMapper.readTree(content); + if (node.hasNonNull(field)) { + String value = node.get(field).asText(); + if (!value.isBlank()) { + return java.util.Optional.of(value); + } + } + } catch (Exception ignored) { + } + return java.util.Optional.empty(); + } + @Async protected void dispatchWebhooks(String appId, ImMessageEntity message) { List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); diff --git a/im-service/src/main/resources/application.yml b/im-service/src/main/resources/application.yml index 7f234cb..b7c798f 100644 --- a/im-service/src/main/resources/application.yml +++ b/im-service/src/main/resources/application.yml @@ -46,6 +46,7 @@ jwt: im: tenant-service-url: ${TENANT_SERVICE_URL:http://xuqm-tenant-service:8081} internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} + push-service-url: ${PUSH_SERVICE_URL:http://xuqm-push-service:8083} multi-login: true message-history-days: 30 webhook-timeout-ms: 3000 diff --git a/push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java b/push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java new file mode 100644 index 0000000..ec2ce24 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/config/SecurityConfig.java @@ -0,0 +1,36 @@ +package com.xuqm.push.config; + +import com.xuqm.common.security.JwtAuthFilter; +import com.xuqm.common.security.JwtUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtUtil jwtUtil; + + public SecurityConfig(JwtUtil jwtUtil) { + this.jwtUtil = jwtUtil; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/push/internal/**", "/actuator/health", "/actuator/info").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java b/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java new file mode 100644 index 0000000..6f87812 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/controller/InternalPushController.java @@ -0,0 +1,47 @@ +package com.xuqm.push.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.push.service.PushDispatcher; +import jakarta.validation.constraints.NotBlank; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/push/internal") +public class InternalPushController { + + private final PushDispatcher pushDispatcher; + + @Value("${push.internal-token:xuqm-internal-token}") + private String internalToken; + + public InternalPushController(PushDispatcher pushDispatcher) { + this.pushDispatcher = pushDispatcher; + } + + @PostMapping("/notify") + public ResponseEntity> notify( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestBody NotifyRequest request) { + if (token == null || !internalToken.equals(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + pushDispatcher.pushToUsers(request.appId(), request.userIds(), request.title(), request.body(), request.payload()); + return ResponseEntity.ok(ApiResponse.ok()); + } + + public record NotifyRequest( + @NotBlank String appId, + List<@NotBlank String> userIds, + @NotBlank String title, + @NotBlank String body, + String payload + ) {} +} diff --git a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java index f637354..d35a9ca 100644 --- a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java +++ b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java @@ -41,6 +41,16 @@ public class PushDispatcher { } } + @Async + public void pushToUsers(String appId, List userIds, String title, String body, String payload) { + if (userIds == null || userIds.isEmpty()) { + return; + } + for (String userId : userIds) { + pushToUser(appId, userId, title, body, payload); + } + } + public void registerToken(String appId, String userId, DeviceTokenEntity.Vendor vendor, String token) { Optional existing = tokenRepository.findByAppIdAndUserIdAndVendor(appId, userId, vendor); DeviceTokenEntity entity = existing.orElseGet(() -> { diff --git a/push-service/src/main/resources/application.yml b/push-service/src/main/resources/application.yml index bc42098..389fa6a 100644 --- a/push-service/src/main/resources/application.yml +++ b/push-service/src/main/resources/application.yml @@ -21,10 +21,11 @@ spring: show-sql: false jwt: - secret: xuqm-push-service-secret-key-must-be-at-least-256-bits-long-for-hmac-sha + secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac} expiration: 86400000 push: + internal-token: ${SDK_INTERNAL_TOKEN:xuqm-internal-token} huawei: app-id: ${HUAWEI_APP_ID:} app-secret: ${HUAWEI_APP_SECRET:}