diff --git a/README.md b/README.md index 9a91eec..64aac88 100644 --- a/README.md +++ b/README.md @@ -356,11 +356,12 @@ Frame 格式: | 方法 | 路径 | 说明 | |------|------|------| | POST | `/api/push/register` | 注册设备 token | +| POST | `/api/push/receive-push` | 开启或关闭接收推送 | | 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。 +其中 `/api/push/register`、`/api/push/device/unregister`、`/api/push/receive-push` 走 JWT 鉴权;`/api/push/internal/notify` 走内部 token。 **注册 token** ``` @@ -381,6 +382,14 @@ POST /api/push/send &payload={"type":"IM","msgId":"uuid"} ``` +**开关接收推送** +``` +POST /api/push/receive-push + ?appId=ak_xxx + &userId=user_001 + &enabled=false +``` + **内部通知** ```json { diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 14dd3b4..c0a17dd 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -13,6 +13,21 @@ | App 更新 | `https://dev.xuqinmin.com/api/v1/updates/` | 原生版本管理 | | RN 热更新 | `https://dev.xuqinmin.com/api/v1/rn/` | Bundle 热更新 | +## ID 约定 + +这套工程里同时存在两种“应用标识”,不要混用: + +| 名称 | 含义 | 常见位置 | +|------|------|----------| +| `tenant appId` | 租户平台应用主键,`tenant-service` 的 `/api/apps/{id}`、`/api/apps/{appId}/services` 使用它 | 租户平台、服务配置页 | +| `IM appKey` | IM 业务域作用域标识,`im-service` 的管理接口和消息接口虽然参数名仍叫 `appId`,但实际传的是这个值 | IM 管理页、IM HTTP 接口 | + +结论: + +- `tenant-platform` 的“服务配置”只认租户 `app.id` +- `tenant-platform` 的“IM 管理”必须带 `appKey` +- `im-service` 代码里沿用旧参数名 `appId`,但这是历史命名,调用方传的是 `appKey` + ## 初始化管理员账号 | 字段 | 值 | @@ -110,6 +125,7 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| | POST | `/api/push/register` | 是 | 注册设备 token | +| POST | `/api/push/receive-push` | 是 | 开启或关闭接收推送 | | POST | `/api/push/send` | 是 | 发送推送通知 | ### file-service @@ -215,6 +231,170 @@ curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=us curl 'https://dev.xuqinmin.com/api/im/admin/operation-logs?appId=ak_demo_chat&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' +curl 'https://dev.xuqinmin.com/api/im/admin/friend-requests?appId=ak_demo_chat' +curl -X POST 'https://dev.xuqinmin.com/api/im/admin/friend-requests/req_001/accept?appId=ak_demo_chat' +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 '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' +curl 'https://dev.xuqinmin.com/api/im/admin/global-mute?appId=ak_demo_chat' +``` + +### IM Webhook 回调 + +IM 服务会在消息、好友和已读等事件发生后,以 `POST` 方式向你配置的回调地址推送事件。 + +请求头: + +- `Content-Type: application/json` +- `X-App-Id`: 应用 `appId` +- `X-App-Timestamp`: 请求时间戳 +- `X-App-Nonce`: 随机串 +- `X-App-Signature`: 签名结果 + +签名规则: + +```text +HMAC-SHA256(appSecret, appId + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body)) +``` + +统一请求体结构: + +```json +{ + "callbackId": "uuid", + "callbackType": "message", + "callbackEvent": "message.sent", + "requestTime": 1714360000000, + "payload": {}, + "signature": null, + "appId": "ak_demo_chat" +} +``` + +字段说明: + +| 字段 | 说明 | +|------|------| +| `callbackId` | 回调唯一 ID,用于去重和排障 | +| `callbackType` | 回调大类,例如 `message`、`friend`、`group`、`blacklist` | +| `callbackEvent` | 具体事件,例如 `message.sent`、`friend.request.sent` | +| `requestTime` | 毫秒时间戳 | +| `payload` | 事件数据,结构随事件变化 | +| `signature` | 预留字段,当前由请求头承载 | +| `appId` | 回调所属应用 ID | + +`payload` 会根据事件类型变化: + +| 事件 | payload 类型 | 说明 | +|------|-------------|------| +| `message.sent` | `ImMessageEntity` | 发送消息成功后触发 | +| `message.revoked` | `ImMessageEntity` | 撤回消息后触发 | +| `message.edited` | `ImMessageEntity` | 编辑文本消息后触发 | +| `message.read` | `MessageReadCallbackPayload` | 已读回执同步后触发 | +| `friend.request.sent` | `FriendRequestCallbackPayload` | 好友申请创建后触发 | +| `friend.request.accepted` | `FriendRequestCallbackPayload` | 好友申请通过后触发 | +| `friend.request.rejected` | `FriendRequestCallbackPayload` | 好友申请拒绝后触发 | +| `group.join.request.sent` | `GroupJoinRequestCallbackPayload` | 入群申请创建后触发 | +| `group.join.request.accepted` | `GroupJoinRequestCallbackPayload` | 入群申请通过后触发 | +| `group.join.request.rejected` | `GroupJoinRequestCallbackPayload` | 入群申请拒绝后触发 | +| `blacklist.added` | `BlacklistCallbackPayload` | 黑名单记录新增后触发 | +| `blacklist.removed` | `BlacklistCallbackPayload` | 黑名单记录移除后触发 | + +各 payload 字段如下: + +#### `MessageReadCallbackPayload` + +```json +{ + "appId": "ak_demo_chat", + "readerId": "user_001", + "peerId": "user_002", + "groupId": null, + "chatType": "SINGLE", + "readAt": 1714360000000, + "messageIds": ["msg_001", "msg_002"] +} +``` + +#### `FriendRequestCallbackPayload` + +```json +{ + "appId": "ak_demo_chat", + "requestId": "req_001", + "fromUserId": "user_001", + "toUserId": "user_002", + "remark": "hi", + "status": "PENDING", + "reviewedAt": null +} +``` + +#### `GroupJoinRequestCallbackPayload` + +```json +{ + "appId": "ak_demo_chat", + "requestId": "req_001", + "groupId": "group_001", + "groupName": "技术群", + "requesterId": "user_003", + "remark": "申请加入", + "status": "PENDING", + "reviewedAt": null +} +``` + +#### `BlacklistCallbackPayload` + +```json +{ + "appId": "ak_demo_chat", + "id": "blk_001", + "userId": "user_001", + "blockedUserId": "user_002", + "action": "ADD", + "createdAt": 1714360000000 +} +``` + +说明: + +- 回调发送失败不会影响主流程,只记录日志。 +- 当前版本的回调配置是按应用维度管理的,多个地址会同时接收同一事件。 +- 新增的好友、入群、黑名单回调都复用同一 envelope 和签名规则,payload 字段按上表中的类型序列化。 +- 接收方建议用 `callbackId` 做幂等去重,避免重复投递造成重复处理。 +- 服务端当前不做重试队列,失败只会在调用端记录日志。 + +示例验签: + +```ts +import crypto from 'crypto' + +export function verifyWebhook({ + appId, + timestamp, + nonce, + body, + signature, + appSecret, +}: { + appId: string + timestamp: string + nonce: string + body: string + signature: string + appSecret: string +}) { + const bodyHash = crypto.createHash('sha256').update(body, 'utf8').digest('hex') + const payload = `${appId}\n${timestamp}\n${nonce}\n${bodyHash}` + const expected = crypto.createHmac('sha256', appSecret).update(payload, 'utf8').digest('hex') + return expected === signature +} ``` ### 好友申请 / 黑名单 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 1017406..8b0cd0d 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 @@ -2,6 +2,7 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.im.model.ConversationView; +import com.xuqm.im.service.ImFeatureConfigClient; import com.xuqm.im.service.ConversationStateService; import com.xuqm.im.service.MessageService; import org.springframework.http.ResponseEntity; @@ -22,11 +23,14 @@ public class ConversationController { private final MessageService messageService; private final ConversationStateService conversationStateService; + private final ImFeatureConfigClient featureConfigClient; public ConversationController(MessageService messageService, - ConversationStateService conversationStateService) { + ConversationStateService conversationStateService, + ImFeatureConfigClient featureConfigClient) { this.messageService = messageService; this.conversationStateService = conversationStateService; + this.featureConfigClient = featureConfigClient; } @GetMapping("/conversations") @@ -92,7 +96,8 @@ public class ConversationController { @RequestParam String appId, @PathVariable String targetId, @RequestParam String chatType) { - conversationStateService.hideConversation(appId, userId, targetId, chatType); + boolean syncAcrossClients = featureConfigClient.multiClientConversationDeleteSync(appId); + conversationStateService.deleteConversation(appId, userId, targetId, chatType, syncAcrossClients); return ResponseEntity.ok(ApiResponse.ok()); } } diff --git a/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java b/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java index 4a4ced8..b5c522d 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java +++ b/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import com.xuqm.common.exception.BusinessException; import com.xuqm.common.model.ApiResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -27,6 +28,16 @@ public class GlobalExceptionHandler { .orElse("参数错误"))); } + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(ApiResponse.badRequest(e.getMessage() == null ? "参数错误" : e.getMessage())); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e) { + return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误")); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) 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 fa92dde..8481836 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 @@ -1,9 +1,13 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; +import com.xuqm.common.exception.BusinessException; +import com.xuqm.im.entity.ImBlacklistEntity; import com.xuqm.im.entity.ImAccountEntity; import com.xuqm.im.entity.ImGlobalMuteEntity; import com.xuqm.im.entity.ImGroupEntity; +import com.xuqm.im.entity.ImFriendRequestEntity; +import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.KeywordFilterEntity; import com.xuqm.im.entity.WebhookConfigEntity; @@ -11,6 +15,8 @@ import com.xuqm.im.repository.ImAccountRepository; import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImMessageRepository; import com.xuqm.im.service.ImAccountService; +import com.xuqm.im.service.BlacklistService; +import com.xuqm.im.service.FriendRequestService; import com.xuqm.im.service.ImGroupService; import com.xuqm.im.service.GlobalMuteService; import com.xuqm.im.service.KeywordFilterService; @@ -35,6 +41,8 @@ public class ImAdminController { private final ImGroupRepository groupRepository; private final ImMessageRepository messageRepository; private final ImAccountService accountService; + private final FriendRequestService friendRequestService; + private final BlacklistService blacklistService; private final ImGroupService groupService; private final MessageService messageService; private final WebhookConfigService webhookConfigService; @@ -46,6 +54,8 @@ public class ImAdminController { ImGroupRepository groupRepository, ImMessageRepository messageRepository, ImAccountService accountService, + FriendRequestService friendRequestService, + BlacklistService blacklistService, ImGroupService groupService, MessageService messageService, WebhookConfigService webhookConfigService, @@ -56,6 +66,8 @@ public class ImAdminController { this.groupRepository = groupRepository; this.messageRepository = messageRepository; this.accountService = accountService; + this.friendRequestService = friendRequestService; + this.blacklistService = blacklistService; this.groupService = groupService; this.messageService = messageService; this.webhookConfigService = webhookConfigService; @@ -82,10 +94,14 @@ public class ImAdminController { @AuthenticationPrincipal String operatorId, @RequestBody Map body) { ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId) - .orElseThrow(() -> new RuntimeException("User not found")); - account.setStatus(ImAccountEntity.Status.valueOf(body.get("status").toUpperCase())); + .orElseThrow(() -> new BusinessException(404, "账号不存在")); + String status = body.get("status"); + if (status == null || status.isBlank()) { + throw new BusinessException(400, "状态不能为空"); + } + account.setStatus(ImAccountEntity.Status.valueOf(status.toUpperCase())); ImAccountEntity saved = accountRepository.save(account); - operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, body.get("status")); + operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, status); return ResponseEntity.ok(ApiResponse.success(saved)); } @@ -266,6 +282,156 @@ public class ImAdminController { return ResponseEntity.ok(ApiResponse.ok()); } + @GetMapping("/friend-requests") + public ResponseEntity>> listFriendRequests(@RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(friendRequestService.listByApp(appId))); + } + + @PostMapping("/friend-requests") + public ResponseEntity> createFriendRequest( + @RequestParam String appId, + @AuthenticationPrincipal String operatorId, + @RequestBody FriendRequestCreateRequest req) { + ImFriendRequestEntity saved = friendRequestService.send(appId, req.fromUserId(), req.toUserId(), req.remark()); + operationLogService.record(appId, operatorId, "CREATE_FRIEND_REQUEST", "FRIEND_REQUEST", saved.getId(), req.toUserId()); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @PostMapping("/friend-requests/{requestId}/accept") + public ResponseEntity> acceptFriendRequest( + @RequestParam String appId, + @PathVariable String requestId, + @AuthenticationPrincipal String operatorId) { + ImFriendRequestEntity saved = friendRequestService.adminAccept(appId, requestId); + operationLogService.record(appId, operatorId, "ACCEPT_FRIEND_REQUEST", "FRIEND_REQUEST", requestId, null); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @PostMapping("/friend-requests/{requestId}/reject") + public ResponseEntity> rejectFriendRequest( + @RequestParam String appId, + @PathVariable String requestId, + @AuthenticationPrincipal String operatorId) { + ImFriendRequestEntity saved = friendRequestService.adminReject(appId, requestId); + operationLogService.record(appId, operatorId, "REJECT_FRIEND_REQUEST", "FRIEND_REQUEST", requestId, null); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @GetMapping("/blacklist") + public ResponseEntity>> listBlacklist(@RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(blacklistService.listByApp(appId))); + } + + @PostMapping("/blacklist") + public ResponseEntity> addBlacklist( + @RequestParam String appId, + @AuthenticationPrincipal String operatorId, + @RequestBody BlacklistRequest req) { + ImBlacklistEntity saved = blacklistService.add(appId, req.userId(), req.blockedUserId()); + operationLogService.record(appId, operatorId, "ADD_BLACKLIST", "BLACKLIST", saved.getId(), req.blockedUserId()); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @DeleteMapping("/blacklist") + public ResponseEntity> removeBlacklist( + @RequestParam String appId, + @RequestParam String userId, + @RequestParam String blockedUserId, + @AuthenticationPrincipal String operatorId) { + blacklistService.remove(appId, userId, blockedUserId); + operationLogService.record(appId, operatorId, "REMOVE_BLACKLIST", "BLACKLIST", userId, blockedUserId); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @GetMapping("/groups/{groupId}/members") + public ResponseEntity>> listGroupMembers( + @RequestParam String appId, + @PathVariable String groupId) { + return ResponseEntity.ok(ApiResponse.success(groupService.adminListMembers(appId, groupId))); + } + + @GetMapping("/groups/{groupId}/members/search") + public ResponseEntity>> searchGroupMembers( + @RequestParam String appId, + @PathVariable String groupId, + @RequestParam String keyword, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success(groupService.adminSearchMembers(appId, groupId, keyword, size))); + } + + @PostMapping("/groups/{groupId}/members") + public ResponseEntity> addGroupMember( + @RequestParam String appId, + @PathVariable String groupId, + @AuthenticationPrincipal String operatorId, + @RequestBody MemberRequest req) { + ImGroupEntity saved = groupService.adminAddMember(appId, groupId, req.userId()); + operationLogService.record(appId, operatorId, "ADMIN_ADD_GROUP_MEMBER", "GROUP", groupId, req.userId()); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @DeleteMapping("/groups/{groupId}/members/{userId}") + public ResponseEntity> removeGroupMember( + @RequestParam String appId, + @PathVariable String groupId, + @PathVariable String userId, + @AuthenticationPrincipal String operatorId) { + ImGroupEntity saved = groupService.adminRemoveMember(appId, groupId, userId); + operationLogService.record(appId, operatorId, "ADMIN_REMOVE_GROUP_MEMBER", "GROUP", groupId, userId); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @PostMapping("/groups/{groupId}/roles") + public ResponseEntity> setGroupRole( + @RequestParam String appId, + @PathVariable String groupId, + @AuthenticationPrincipal String operatorId, + @RequestBody SetRoleRequest req) { + ImGroupEntity saved = groupService.adminSetRole(appId, groupId, req.userId(), req.role()); + operationLogService.record(appId, operatorId, "ADMIN_SET_GROUP_ROLE", "GROUP", groupId, req.userId() + ":" + req.role()); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @PostMapping("/groups/{groupId}/mute") + public ResponseEntity> muteGroupMember( + @RequestParam String appId, + @PathVariable String groupId, + @AuthenticationPrincipal String operatorId, + @RequestBody MuteMemberRequest req) { + ImGroupEntity saved = groupService.adminMuteMember(appId, groupId, req.userId(), req.minutes()); + operationLogService.record(appId, operatorId, "ADMIN_MUTE_GROUP_MEMBER", "GROUP", groupId, req.userId() + ":" + req.minutes()); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @GetMapping("/groups/{groupId}/join-requests") + public ResponseEntity>> listGroupJoinRequests( + @RequestParam String appId, + @PathVariable String groupId) { + return ResponseEntity.ok(ApiResponse.success(groupService.adminListJoinRequests(appId, groupId))); + } + + @PostMapping("/groups/{groupId}/join-requests/{requestId}/accept") + public ResponseEntity> acceptGroupJoinRequest( + @RequestParam String appId, + @PathVariable String groupId, + @PathVariable String requestId, + @AuthenticationPrincipal String operatorId) { + ImGroupJoinRequestEntity saved = groupService.adminAcceptJoinRequest(appId, groupId, requestId); + operationLogService.record(appId, operatorId, "ADMIN_ACCEPT_GROUP_JOIN_REQUEST", "GROUP_JOIN_REQUEST", requestId, null); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @PostMapping("/groups/{groupId}/join-requests/{requestId}/reject") + public ResponseEntity> rejectGroupJoinRequest( + @RequestParam String appId, + @PathVariable String groupId, + @PathVariable String requestId, + @AuthenticationPrincipal String operatorId) { + ImGroupJoinRequestEntity saved = groupService.adminRejectJoinRequest(appId, groupId, requestId); + operationLogService.record(appId, operatorId, "ADMIN_REJECT_GROUP_JOIN_REQUEST", "GROUP_JOIN_REQUEST", requestId, null); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + @GetMapping("/webhooks") public ResponseEntity>> listWebhooks(@RequestParam String appId) { return ResponseEntity.ok(ApiResponse.success(webhookConfigService.list(appId))); @@ -377,4 +543,9 @@ public class ImAdminController { public record UpdateGroupRequest(String name, String groupType, String announcement) {} public record WebhookConfigRequest(String url, String secret, Boolean enabled) {} public record KeywordFilterRequest(String pattern, String replacement, String action, Boolean enabled) {} + public record FriendRequestCreateRequest(String fromUserId, String toUserId, String remark) {} + public record BlacklistRequest(String userId, String blockedUserId) {} + public record MemberRequest(String userId) {} + public record SetRoleRequest(String userId, String role) {} + public record MuteMemberRequest(String userId, long minutes) {} } diff --git a/im-service/src/main/java/com/xuqm/im/model/BlacklistCallbackPayload.java b/im-service/src/main/java/com/xuqm/im/model/BlacklistCallbackPayload.java new file mode 100644 index 0000000..4d36ebb --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/BlacklistCallbackPayload.java @@ -0,0 +1,10 @@ +package com.xuqm.im.model; + +public record BlacklistCallbackPayload( + String appId, + String id, + String userId, + String blockedUserId, + String action, + Long createdAt +) {} diff --git a/im-service/src/main/java/com/xuqm/im/model/FriendRequestCallbackPayload.java b/im-service/src/main/java/com/xuqm/im/model/FriendRequestCallbackPayload.java new file mode 100644 index 0000000..0226afc --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/FriendRequestCallbackPayload.java @@ -0,0 +1,11 @@ +package com.xuqm.im.model; + +public record FriendRequestCallbackPayload( + String appId, + String requestId, + String fromUserId, + String toUserId, + String remark, + String status, + Long reviewedAt +) {} diff --git a/im-service/src/main/java/com/xuqm/im/model/GroupJoinRequestCallbackPayload.java b/im-service/src/main/java/com/xuqm/im/model/GroupJoinRequestCallbackPayload.java new file mode 100644 index 0000000..71343e7 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/GroupJoinRequestCallbackPayload.java @@ -0,0 +1,12 @@ +package com.xuqm.im.model; + +public record GroupJoinRequestCallbackPayload( + String appId, + String requestId, + String groupId, + String groupName, + String requesterId, + String remark, + String status, + Long reviewedAt +) {} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java index 5ea6949..6b6d9b7 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImBlacklistRepository.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Optional; public interface ImBlacklistRepository extends JpaRepository { + List findByAppId(String appId); List findByAppIdAndUserId(String appId, String userId); Optional findByAppIdAndUserIdAndBlockedUserId( diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java index 757185f..d4f75f4 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImFriendRequestRepository.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Optional; public interface ImFriendRequestRepository extends JpaRepository { + List findByAppId(String appId); Optional findByAppIdAndFromUserIdAndToUserId( String appId, String fromUserId, String toUserId); 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 index 01868f7..d75d324 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/ImGroupJoinRequestRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/ImGroupJoinRequestRepository.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Optional; public interface ImGroupJoinRequestRepository extends JpaRepository { + List findByAppId(String appId); Optional findByAppIdAndGroupIdAndRequesterId( String appId, String groupId, String requesterId); diff --git a/im-service/src/main/java/com/xuqm/im/service/BlacklistService.java b/im-service/src/main/java/com/xuqm/im/service/BlacklistService.java index 80c24b9..84852d0 100644 --- a/im-service/src/main/java/com/xuqm/im/service/BlacklistService.java +++ b/im-service/src/main/java/com/xuqm/im/service/BlacklistService.java @@ -1,6 +1,7 @@ package com.xuqm.im.service; import com.xuqm.im.entity.ImBlacklistEntity; +import com.xuqm.im.model.BlacklistCallbackPayload; import com.xuqm.im.repository.ImBlacklistRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,9 +14,11 @@ import java.util.UUID; public class BlacklistService { private final ImBlacklistRepository repository; + private final WebhookDispatchService webhookDispatchService; - public BlacklistService(ImBlacklistRepository repository) { + public BlacklistService(ImBlacklistRepository repository, WebhookDispatchService webhookDispatchService) { this.repository = repository; + this.webhookDispatchService = webhookDispatchService; } @Transactional @@ -28,19 +31,30 @@ public class BlacklistService { entity.setUserId(userId); entity.setBlockedUserId(blockedUserId); entity.setCreatedAt(LocalDateTime.now()); - return repository.save(entity); + ImBlacklistEntity saved = repository.save(entity); + dispatchWebhook(saved, "blacklist.added"); + return saved; }); } @Transactional public void remove(String appId, String userId, String blockedUserId) { + ImBlacklistEntity entity = repository.findByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId) + .orElse(null); repository.deleteByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId); + if (entity != null) { + dispatchWebhook(entity, "blacklist.removed"); + } } public List list(String appId, String userId) { return repository.findByAppIdAndUserId(appId, userId); } + public List listByApp(String appId) { + return repository.findByAppId(appId); + } + public boolean isBlocked(String appId, String userId, String blockedUserId) { return repository.existsByAppIdAndUserIdAndBlockedUserId(appId, userId, blockedUserId); } @@ -48,4 +62,20 @@ public class BlacklistService { public boolean isEitherBlocked(String appId, String userId, String targetUserId) { return isBlocked(appId, userId, targetUserId) || isBlocked(appId, targetUserId, userId); } + + private void dispatchWebhook(ImBlacklistEntity entity, String callbackEvent) { + webhookDispatchService.dispatch( + entity.getAppId(), + "blacklist", + callbackEvent, + new BlacklistCallbackPayload( + entity.getAppId(), + entity.getId(), + entity.getUserId(), + entity.getBlockedUserId(), + callbackEvent, + entity.getCreatedAt() == null ? null : entity.getCreatedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + ) + ); + } } 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 9e3959f..5e56cdb 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 @@ -66,6 +66,19 @@ public class ConversationStateService { repository.deleteByAppIdAndUserIdAndTargetIdAndChatType(appId, userId, targetId, chatType); } + @Transactional + public void deleteConversation(String appId, + String userId, + String targetId, + String chatType, + boolean syncAcrossClients) { + if (syncAcrossClients) { + deleteConversationState(appId, userId, targetId, chatType); + return; + } + hideConversation(appId, userId, targetId, chatType); + } + @Transactional public void clearHiddenForUsers(String appId, String targetId, String chatType, Collection userIds) { for (String userId : userIds) { diff --git a/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java b/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java index 7d4c01c..e09efa5 100644 --- a/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java +++ b/im-service/src/main/java/com/xuqm/im/service/FriendRequestService.java @@ -6,6 +6,7 @@ import com.xuqm.common.exception.BusinessException; import com.xuqm.im.cluster.ImClusterPublisher; import com.xuqm.im.entity.ImFriendRequestEntity; import com.xuqm.im.entity.ImMessageEntity; +import com.xuqm.im.model.FriendRequestCallbackPayload; import com.xuqm.im.repository.ImFriendRequestRepository; import com.xuqm.im.repository.ImFriendRepository; import com.xuqm.im.repository.ImMessageRepository; @@ -23,24 +24,36 @@ public class FriendRequestService { private final ImFriendRepository friendRepository; private final ImMessageRepository messageRepository; private final ImClusterPublisher clusterPublisher; + private final ImFeatureConfigClient featureConfigClient; + private final WebhookDispatchService webhookDispatchService; private final ObjectMapper objectMapper; public FriendRequestService(ImFriendRequestRepository requestRepository, ImFriendRepository friendRepository, ImMessageRepository messageRepository, ImClusterPublisher clusterPublisher, + ImFeatureConfigClient featureConfigClient, + WebhookDispatchService webhookDispatchService, ObjectMapper objectMapper) { this.requestRepository = requestRepository; this.friendRepository = friendRepository; this.messageRepository = messageRepository; this.clusterPublisher = clusterPublisher; + this.featureConfigClient = featureConfigClient; + this.webhookDispatchService = webhookDispatchService; this.objectMapper = objectMapper; } @Transactional public ImFriendRequestEntity send(String appId, String fromUserId, String toUserId, String remark) { + String mode = featureConfigClient.friendRequestMode(appId); + if ("DISALLOW".equals(mode)) { + throw new BusinessException(403, "当前应用未开放好友申请"); + } + final boolean[] created = {false}; ImFriendRequestEntity saved = requestRepository.findByAppIdAndFromUserIdAndToUserId(appId, fromUserId, toUserId) .orElseGet(() -> { + created[0] = true; ImFriendRequestEntity entity = new ImFriendRequestEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppId(appId); @@ -51,9 +64,18 @@ public class FriendRequestService { entity.setCreatedAt(LocalDateTime.now()); return requestRepository.save(entity); }); + if ("DIRECT_ACCEPT".equals(mode)) { + if (ImFriendRequestEntity.Status.ACCEPTED.name().equals(saved.getStatus())) { + return saved; + } + return acceptRequest(saved); + } if (!ImFriendRequestEntity.Status.PENDING.name().equals(saved.getStatus())) { return saved; } + if (created[0]) { + dispatchWebhook(saved, "friend.request.sent"); + } publishNotification( saved, saved.getFromUserId(), @@ -77,6 +99,7 @@ public class FriendRequestService { friendRepository .findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) .orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId())); + dispatchWebhook(request, "friend.request.accepted"); publishNotification( request, request.getToUserId(), @@ -94,6 +117,7 @@ public class FriendRequestService { request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); request.setReviewedAt(LocalDateTime.now()); ImFriendRequestEntity saved = requestRepository.save(request); + dispatchWebhook(saved, "friend.request.rejected"); publishNotification( saved, saved.getToUserId(), @@ -133,6 +157,22 @@ public class FriendRequestService { return requestRepository.findByAppIdAndFromUserId(appId, userId); } + public List listByApp(String appId) { + return requestRepository.findByAppId(appId); + } + + @Transactional + public ImFriendRequestEntity adminAccept(String appId, String requestId) { + ImFriendRequestEntity request = getRequest(appId, requestId); + return acceptRequest(request); + } + + @Transactional + public ImFriendRequestEntity adminReject(String appId, String requestId) { + ImFriendRequestEntity request = getRequest(appId, requestId); + return rejectRequest(request); + } + private ImFriendRequestEntity getRequest(String appId, String requestId, String operatorId) { ImFriendRequestEntity request = requestRepository.findById(requestId) .orElseThrow(() -> new BusinessException(404, "好友申请不存在")); @@ -142,17 +182,31 @@ public class FriendRequestService { return request; } + private ImFriendRequestEntity getRequest(String appId, String requestId) { + ImFriendRequestEntity request = requestRepository.findById(requestId) + .orElseThrow(() -> new BusinessException(404, "好友申请不存在")); + if (!request.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + return request; + } + private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) { ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); + return acceptRequest(request); + } + + private ImFriendRequestEntity acceptRequest(ImFriendRequestEntity request) { request.setStatus(ImFriendRequestEntity.Status.ACCEPTED.name()); request.setReviewedAt(LocalDateTime.now()); ImFriendRequestEntity saved = requestRepository.save(request); friendRepository - .findByAppIdAndUserIdAndFriendId(appId, request.getFromUserId(), request.getToUserId()) - .orElseGet(() -> friendEntity(appId, request.getFromUserId(), request.getToUserId())); + .findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getFromUserId(), request.getToUserId()) + .orElseGet(() -> friendEntity(request.getAppId(), request.getFromUserId(), request.getToUserId())); friendRepository - .findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) - .orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId())); + .findByAppIdAndUserIdAndFriendId(request.getAppId(), request.getToUserId(), request.getFromUserId()) + .orElseGet(() -> friendEntity(request.getAppId(), request.getToUserId(), request.getFromUserId())); + dispatchWebhook(saved, "friend.request.accepted"); publishNotification( request, request.getToUserId(), @@ -166,18 +220,7 @@ public class FriendRequestService { private ImFriendRequestEntity rejectInternal(String appId, String requestId, String operatorId) { ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); - request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); - request.setReviewedAt(LocalDateTime.now()); - ImFriendRequestEntity saved = requestRepository.save(request); - publishNotification( - saved, - saved.getToUserId(), - saved.getFromUserId(), - "FRIEND_REQUEST_STATUS", - "好友申请已拒绝", - buildDescription("好友申请已拒绝", saved.getRemark()) - ); - return saved; + return rejectRequest(request); } private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) { @@ -240,4 +283,37 @@ public class FriendRequestService { } return prefix + ":" + remark; } + + private ImFriendRequestEntity rejectRequest(ImFriendRequestEntity request) { + request.setStatus(ImFriendRequestEntity.Status.REJECTED.name()); + request.setReviewedAt(LocalDateTime.now()); + ImFriendRequestEntity saved = requestRepository.save(request); + dispatchWebhook(saved, "friend.request.rejected"); + publishNotification( + saved, + saved.getToUserId(), + saved.getFromUserId(), + "FRIEND_REQUEST_STATUS", + "好友申请已拒绝", + buildDescription("好友申请已拒绝", saved.getRemark()) + ); + return saved; + } + + private void dispatchWebhook(ImFriendRequestEntity request, String callbackEvent) { + webhookDispatchService.dispatch( + request.getAppId(), + "friend_request", + callbackEvent, + new FriendRequestCallbackPayload( + request.getAppId(), + request.getId(), + request.getFromUserId(), + request.getToUserId(), + request.getRemark(), + request.getStatus(), + request.getReviewedAt() == null ? null : request.getReviewedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + ) + ); + } } diff --git a/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java b/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java index aeaeb55..ca9b4e8 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImFeatureConfigClient.java @@ -29,6 +29,48 @@ public class ImFeatureConfigClient { } public boolean allowStrangerMessage(String appId) { + return readConfig(appId).path("allowStrangerMessage").asBoolean(false); + } + + public boolean allowFriendRequest(String appId) { + return readConfig(appId).path("allowFriendRequest").asBoolean(true); + } + + public String friendRequestMode(String appId) { + JsonNode node = readConfig(appId); + String mode = node.path("friendRequestMode").asText(""); + String normalized = mode == null ? "" : mode.trim().toUpperCase(); + return switch (normalized) { + case "DIRECT_ACCEPT", "DISALLOW", "REQUIRE_CONFIRM" -> normalized; + default -> node.path("allowFriendRequest").asBoolean(true) ? "REQUIRE_CONFIRM" : "DISALLOW"; + }; + } + + public boolean allowGroupJoinRequest(String appId) { + return readConfig(appId).path("allowGroupJoinRequest").asBoolean(true); + } + + public boolean blacklistSendSuccess(String appId) { + return readConfig(appId).path("blacklistSendSuccess").asBoolean(true); + } + + public int messageRecallMinutes(String appId) { + return Math.max(readConfig(appId).path("messageRecallMinutes").asInt(2), 0); + } + + public int historyRetentionDays(String appId) { + return Math.max(readConfig(appId).path("historyRetentionDays").asInt(7), 1); + } + + public int conversationPullLimit(String appId) { + return Math.min(Math.max(readConfig(appId).path("conversationPullLimit").asInt(100), 1), 500); + } + + public boolean multiClientConversationDeleteSync(String appId) { + return readConfig(appId).path("multiClientConversationDeleteSync").asBoolean(false); + } + + private JsonNode readConfig(String appId) { String url = UriComponentsBuilder.fromHttpUrl(tenantServiceUrl) .path("/api/internal/sdk/apps/{appId}/services/{platform}/{serviceType}") .buildAndExpand(appId, "ANDROID", "IM") @@ -45,14 +87,14 @@ public class ImFeatureConfigClient { JsonNode body = response.getBody(); if (response.getStatusCode().is2xxSuccessful() && body != null && body.path("code").asInt() == 200) { String config = body.path("data").path("config").asText(""); - if (config.isBlank()) { - return false; + if (!config.isBlank()) { + JsonNode node = OBJECT_MAPPER.readTree(config); + return node == null ? OBJECT_MAPPER.createObjectNode() : node; } - return OBJECT_MAPPER.readTree(config).path("allowStrangerMessage").asBoolean(false); } } catch (Exception e) { - return false; + // Fail closed: if config cannot be read, keep the feature disabled. } - return false; + return OBJECT_MAPPER.createObjectNode(); } } 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 753eb14..d21e98b 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 @@ -10,6 +10,7 @@ import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.entity.ImMessageEntity; import com.xuqm.im.entity.ImGroupMuteEntity; import com.xuqm.im.entity.ImAccountEntity; +import com.xuqm.im.model.GroupJoinRequestCallbackPayload; import com.xuqm.im.repository.ImGroupJoinRequestRepository; import com.xuqm.im.repository.ImAccountRepository; import com.xuqm.im.repository.ImGroupRepository; @@ -35,6 +36,8 @@ public class ImGroupService { private final ImMessageRepository messageRepository; private final ImAccountRepository accountRepository; private final ImClusterPublisher clusterPublisher; + private final ImFeatureConfigClient featureConfigClient; + private final WebhookDispatchService webhookDispatchService; private final ObjectMapper objectMapper; public ImGroupService(ImGroupRepository groupRepository, @@ -43,6 +46,8 @@ public class ImGroupService { ImMessageRepository messageRepository, ImAccountRepository accountRepository, ImClusterPublisher clusterPublisher, + ImFeatureConfigClient featureConfigClient, + WebhookDispatchService webhookDispatchService, ObjectMapper objectMapper) { this.groupRepository = groupRepository; this.muteRepository = muteRepository; @@ -50,6 +55,8 @@ public class ImGroupService { this.messageRepository = messageRepository; this.accountRepository = accountRepository; this.clusterPublisher = clusterPublisher; + this.featureConfigClient = featureConfigClient; + this.webhookDispatchService = webhookDispatchService; this.objectMapper = objectMapper; } @@ -283,6 +290,9 @@ public class ImGroupService { @Transactional public ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) { + if (!featureConfigClient.allowGroupJoinRequest(appId)) { + throw new BusinessException(403, "当前应用未开放群加入申请"); + } ImGroupEntity group = get(groupId); if (!group.getAppId().equals(appId)) { throw new BusinessException(403, "无权操作"); @@ -313,6 +323,7 @@ public class ImGroupService { buildDescription("入群申请", remark), saved ); + dispatchJoinRequestWebhook(group, saved, "group.join.request.sent"); return saved; }); } @@ -332,6 +343,7 @@ public class ImGroupService { request.setReviewedAt(LocalDateTime.now()); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); addMemberInternal(group, request.getRequesterId()); + dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted"); publishJoinRequestNotification( group, operatorId, @@ -352,6 +364,7 @@ public class ImGroupService { request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); request.setReviewedAt(LocalDateTime.now()); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); + dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected"); publishJoinRequestNotification( group, operatorId, @@ -401,6 +414,169 @@ public class ImGroupService { } } + private void ensureAppMatches(ImGroupEntity group, String appId) { + if (!group.getAppId().equals(appId)) { + throw new BusinessException(403, "无权操作"); + } + } + + public List adminListMembers(String appId, String groupId) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + return resolveMembers(appId, memberIds(group)); + } + + public List adminSearchMembers(String appId, String groupId, String keyword, int size) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + List ids = memberIds(group); + if (keyword == null || keyword.isBlank()) { + return resolveMembers(appId, ids).stream().limit(Math.max(size, 1)).toList(); + } + LinkedHashSet memberIdSet = new LinkedHashSet<>(ids); + return accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1))) + .stream() + .filter(account -> memberIdSet.contains(account.getUserId())) + .toList(); + } + + @Transactional + public ImGroupEntity adminAddMember(String appId, String groupId, String userId) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + addMemberInternal(group, userId); + return group; + } + + @Transactional + public ImGroupEntity adminAddMembers(String appId, String groupId, List userIds) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + List members = new ArrayList<>(fromJson(group.getMemberIds())); + boolean changed = false; + for (String userId : userIds == null ? List.of() : userIds) { + if (userId == null || userId.isBlank()) { + continue; + } + if (!members.contains(userId)) { + members.add(userId); + changed = true; + } + } + if (changed) { + group.setMemberIds(toJson(members)); + return groupRepository.save(group); + } + return group; + } + + @Transactional + public ImGroupEntity adminRemoveMember(String appId, String groupId, String userId) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + List members = new ArrayList<>(fromJson(group.getMemberIds())); + if (members.remove(userId)) { + group.setMemberIds(toJson(members)); + return groupRepository.save(group); + } + return group; + } + + @Transactional + public ImGroupEntity adminRemoveMembers(String appId, String groupId, List userIds) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + List members = new ArrayList<>(fromJson(group.getMemberIds())); + boolean changed = false; + for (String userId : userIds == null ? List.of() : userIds) { + if (userId == null || userId.isBlank()) { + continue; + } + changed |= members.remove(userId); + } + if (changed) { + group.setMemberIds(toJson(members)); + return groupRepository.save(group); + } + return group; + } + + @Transactional + public ImGroupEntity adminSetRole(String appId, String groupId, String userId, String role) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + List admins = new ArrayList<>(fromJson(group.getAdminIds())); + if ("ADMIN".equalsIgnoreCase(role)) { + if (!admins.contains(userId)) { + admins.add(userId); + } + } else { + admins.remove(userId); + if (userId.equals(group.getCreatorId())) { + throw new BusinessException(403, "群主不能降级"); + } + } + group.setAdminIds(toJson(admins)); + return groupRepository.save(group); + } + + @Transactional + public ImGroupEntity adminMuteMember(String appId, String groupId, String userId, long minutes) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + ImGroupMuteEntity mute = muteRepository + .findByGroupIdAndUserIdAndMutedUntilAfter(groupId, userId, LocalDateTime.now()) + .orElseGet(() -> { + ImGroupMuteEntity entity = new ImGroupMuteEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setGroupId(groupId); + entity.setUserId(userId); + entity.setCreatedAt(LocalDateTime.now()); + return entity; + }); + mute.setMutedUntil(LocalDateTime.now().plusMinutes(Math.max(minutes, 0))); + mute.setUpdatedAt(LocalDateTime.now()); + muteRepository.save(mute); + return group; + } + + public List adminListJoinRequests(String appId, String groupId) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + return joinRequestRepository.findByAppIdAndGroupId(appId, groupId); + } + + @Transactional + public ImGroupJoinRequestEntity adminAcceptJoinRequest(String appId, String groupId, String requestId) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId); + if (!group.getId().equals(request.getGroupId())) { + throw new BusinessException(400, "加群申请不属于当前群"); + } + request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name()); + request.setReviewedAt(LocalDateTime.now()); + ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); + addMemberInternal(group, request.getRequesterId()); + dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted"); + return saved; + } + + @Transactional + public ImGroupJoinRequestEntity adminRejectJoinRequest(String appId, String groupId, String requestId) { + ImGroupEntity group = get(groupId); + ensureAppMatches(group, appId); + ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId); + if (!group.getId().equals(request.getGroupId())) { + throw new BusinessException(400, "加群申请不属于当前群"); + } + request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); + request.setReviewedAt(LocalDateTime.now()); + ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); + dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected"); + return saved; + } + private List uniqueRecipients(ImGroupEntity group) { LinkedHashSet recipients = new LinkedHashSet<>(fromJson(group.getAdminIds())); recipients.add(group.getCreatorId()); @@ -433,6 +609,24 @@ public class ImGroupService { } } + private void dispatchJoinRequestWebhook(ImGroupEntity group, ImGroupJoinRequestEntity request, String callbackEvent) { + webhookDispatchService.dispatch( + group.getAppId(), + "group_join_request", + callbackEvent, + new GroupJoinRequestCallbackPayload( + group.getAppId(), + request.getId(), + request.getGroupId(), + group.getName(), + request.getRequesterId(), + request.getRemark(), + request.getStatus(), + request.getReviewedAt() == null ? null : request.getReviewedAt().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli() + ) + ); + } + private String buildNotificationContent( String type, String title, @@ -490,6 +684,7 @@ public class ImGroupService { request.setReviewedAt(LocalDateTime.now()); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); addMemberInternal(group, request.getRequesterId()); + dispatchJoinRequestWebhook(group, saved, "group.join.request.accepted"); publishJoinRequestNotification( group, operatorId, @@ -511,6 +706,7 @@ public class ImGroupService { request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); request.setReviewedAt(LocalDateTime.now()); ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); + dispatchJoinRequestWebhook(group, saved, "group.join.request.rejected"); publishJoinRequestNotification( group, operatorId, 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 b05d516..f6fc384 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 @@ -5,38 +5,24 @@ import com.xuqm.common.exception.BusinessException; import com.xuqm.im.cluster.ImClusterPublisher; import com.xuqm.im.entity.ImGroupEntity; import com.xuqm.im.entity.ImMessageEntity; -import com.xuqm.im.entity.WebhookConfigEntity; import com.xuqm.im.model.ConversationView; import com.xuqm.im.model.EditMessageRequest; import com.xuqm.im.model.MessageReadCallbackPayload; import com.xuqm.im.model.SendMessageRequest; -import com.xuqm.im.model.WebhookCallbackEnvelope; import com.xuqm.im.repository.ImFriendRepository; -import com.xuqm.im.repository.WebhookConfigRepository; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.time.ZoneOffset; import com.xuqm.im.repository.ImMessageRepository; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; @Service public class MessageService { @@ -44,7 +30,6 @@ public class MessageService { private static final Logger log = LoggerFactory.getLogger(MessageService.class); private final ImMessageRepository messageRepository; - private final WebhookConfigRepository webhookRepository; private final KeywordFilterService keywordFilterService; private final GlobalMuteService globalMuteService; private final ImClusterPublisher clusterPublisher; @@ -54,14 +39,10 @@ public class MessageService { private final ImPushBridgeClient pushBridgeClient; private final ImFeatureConfigClient featureConfigClient; private final ImFriendRepository friendRepository; - private final ImAppSecretClient appSecretClient; + private final WebhookDispatchService webhookDispatchService; private final ObjectMapper objectMapper; - @Value("${im.webhook-timeout-ms:3000}") - private int webhookTimeoutMs; - public MessageService(ImMessageRepository messageRepository, - WebhookConfigRepository webhookRepository, KeywordFilterService keywordFilterService, GlobalMuteService globalMuteService, ImClusterPublisher clusterPublisher, @@ -71,10 +52,9 @@ public class MessageService { ImPushBridgeClient pushBridgeClient, ImFeatureConfigClient featureConfigClient, ImFriendRepository friendRepository, - ImAppSecretClient appSecretClient, + WebhookDispatchService webhookDispatchService, ObjectMapper objectMapper) { this.messageRepository = messageRepository; - this.webhookRepository = webhookRepository; this.keywordFilterService = keywordFilterService; this.globalMuteService = globalMuteService; this.clusterPublisher = clusterPublisher; @@ -84,7 +64,7 @@ public class MessageService { this.pushBridgeClient = pushBridgeClient; this.featureConfigClient = featureConfigClient; this.friendRepository = friendRepository; - this.appSecretClient = appSecretClient; + this.webhookDispatchService = webhookDispatchService; this.objectMapper = objectMapper; } @@ -100,6 +80,7 @@ public class MessageService { } } ImGroupEntity group = null; + boolean receiverBlocksSender = false; if (req.chatType() == ImMessageEntity.ChatType.GROUP) { group = groupService.get(req.toId()); if (!groupService.memberIds(group).contains(fromUserId)) { @@ -108,11 +89,19 @@ public class MessageService { if (groupService.isMemberMuted(req.toId(), fromUserId)) { throw new BusinessException(403, "当前用户已被禁言"); } - } else if (blacklistService.isEitherBlocked(appId, fromUserId, req.toId())) { - throw new BusinessException(403, "已被拉黑,无法发送消息"); } else if (!isFriend(appId, fromUserId, req.toId()) && !featureConfigClient.allowStrangerMessage(appId)) { throw new BusinessException(403, "仅允许好友之间发送消息"); + } else { + boolean senderBlocksReceiver = blacklistService.isBlocked(appId, fromUserId, req.toId()); + receiverBlocksSender = blacklistService.isBlocked(appId, req.toId(), fromUserId); + boolean blacklistSendSuccess = featureConfigClient.blacklistSendSuccess(appId); + if (senderBlocksReceiver && receiverBlocksSender) { + throw new BusinessException(403, "已被拉黑,无法发送消息"); + } + if (receiverBlocksSender && !blacklistSendSuccess) { + throw new BusinessException(403, "已被拉黑,无法发送消息"); + } } ImMessageEntity message = new ImMessageEntity(); @@ -133,25 +122,30 @@ public class MessageService { 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(); - log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}", - appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination); - clusterPublisher.publish(destination, saved); if (req.chatType() == ImMessageEntity.ChatType.SINGLE && !fromUserId.equals(req.toId())) { log.debug("echo message back to sender appId={} from={} to={}", appId, fromUserId, req.toId()); 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) - ); + if (!receiverBlocksSender) { + log.debug("deliver message to receiver appId={} from={} to={}", + appId, fromUserId, req.toId()); + clusterPublisher.publish("/user/" + req.toId() + "/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 { + conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), List.of(fromUserId)); + } } else if (req.chatType() == ImMessageEntity.ChatType.GROUP) { + String destination = "/topic/group/" + req.toId(); + log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}", + appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination); + clusterPublisher.publish(destination, saved); List memberIds = groupService.memberIds(group); conversationStateService.clearHiddenForUsers(appId, req.toId(), req.chatType().name(), memberIds); pushBridgeClient.notifyUsers( @@ -163,6 +157,13 @@ public class MessageService { saved.getContent(), buildPushPayload(saved) ); + } else { + String destination = "/user/" + req.toId() + "/queue/messages"; + log.debug("send message appId={} from={} to={} chatType={} msgType={} destination={}", + appId, fromUserId, req.toId(), req.chatType(), req.msgType(), destination); + if (!receiverBlocksSender) { + clusterPublisher.publish(destination, saved); + } } dispatchWebhooks(appId, "message.sent", saved); @@ -183,6 +184,10 @@ public class MessageService { if (!message.getFromUserId().equals(requestUserId)) { throw new BusinessException(403, "只能撤回自己发送的消息"); } + int recallMinutes = featureConfigClient.messageRecallMinutes(appId); + if (recallMinutes > 0 && message.getCreatedAt().plusMinutes(recallMinutes).isBefore(LocalDateTime.now())) { + throw new BusinessException(403, "已超过可撤回时长"); + } message.setStatus(ImMessageEntity.MsgStatus.REVOKED); message.setMsgType(ImMessageEntity.MsgType.REVOKED); ImMessageEntity saved = messageRepository.save(message); @@ -286,13 +291,14 @@ public class MessageService { String userId, String toId, ImMessageEntity.MsgType msgType, - String keyword, - LocalDateTime startTime, - LocalDateTime endTime, - int page, - int size) { + String keyword, + LocalDateTime startTime, + LocalDateTime endTime, + int page, + int size) { + LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime); return messageRepository.findSingleConversationFiltered( - appId, userId, toId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + appId, userId, toId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size)); } public Page groupHistory( @@ -302,15 +308,16 @@ public class MessageService { ImMessageEntity.MsgType msgType, String keyword, LocalDateTime startTime, - LocalDateTime endTime, - int page, - int size) { + LocalDateTime endTime, + int page, + int size) { + LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime); ImGroupEntity group = groupService.get(groupId); if (!groupService.memberIds(group).contains(userId)) { throw new BusinessException(403, "不在群内"); } Page pageResult = messageRepository.findGroupHistoryFiltered( - appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + appId, groupId, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size)); pageResult.forEach(message -> message.setGroupReadCount( groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId()))); return pageResult; @@ -383,11 +390,12 @@ public class MessageService { ImMessageEntity.MsgType msgType, String keyword, LocalDateTime startTime, - LocalDateTime endTime, - int page, - int size) { + LocalDateTime endTime, + int page, + int size) { + LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime); return messageRepository.findSingleConversationFiltered( - appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + appId, userA, userB, msgType, keyword, effectiveStart, endTime, PageRequest.of(page, size)); } public Page adminGroupHistory( @@ -396,29 +404,46 @@ public class MessageService { ImMessageEntity.MsgType msgType, String keyword, LocalDateTime startTime, - LocalDateTime endTime, - int page, - int size) { + LocalDateTime endTime, + int page, + int size) { + LocalDateTime effectiveStart = applyHistoryRetention(appId, startTime); Page pageResult = messageRepository.findGroupHistoryFiltered( - appId, groupId, msgType, keyword, startTime, endTime, PageRequest.of(page, size)); + appId, groupId, msgType, keyword, effectiveStart, 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) { - return messageRepository.findConversations(appId, userId, size); + return messageRepository.findConversations(appId, userId, normalizeConversationSize(appId, size)); } public List conversationViews(String appId, String userId, int size) { - int fetchSize = Math.max(size * 3, size); + int cappedSize = normalizeConversationSize(appId, size); + int fetchSize = Math.max(cappedSize * 3, cappedSize); return messageRepository.findConversations(appId, userId, fetchSize).stream() .map(summary -> toConversationView(appId, userId, summary)) .filter(Objects::nonNull) - .limit(size) + .limit(cappedSize) .toList(); } + private LocalDateTime applyHistoryRetention(String appId, LocalDateTime requestedStart) { + int retentionDays = featureConfigClient.historyRetentionDays(appId); + LocalDateTime retentionStart = LocalDateTime.now().minusDays(retentionDays); + if (requestedStart == null || requestedStart.isBefore(retentionStart)) { + return retentionStart; + } + return requestedStart; + } + + private int normalizeConversationSize(String appId, int requestedSize) { + int limit = featureConfigClient.conversationPullLimit(appId); + int safeRequested = Math.max(requestedSize, 1); + return Math.min(safeRequested, limit); + } + private ConversationView toConversationView( String appId, String userId, @@ -525,73 +550,10 @@ public class MessageService { } protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) { - dispatchWebhooks(appId, callbackEvent, (Object) message); + webhookDispatchService.dispatch(appId, "message", callbackEvent, message); } protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) { - List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); - if (webhooks.isEmpty()) return; - - try { - String appSecret = appSecretClient.getAppSecret(appId); - long requestTime = System.currentTimeMillis(); - String nonce = UUID.randomUUID().toString().replace("-", ""); - String callbackId = UUID.randomUUID().toString(); - WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope( - callbackId, - "message", - callbackEvent, - requestTime, - objectMapper.valueToTree(payload), - null, - appId - ); - String body = objectMapper.writeValueAsString(envelope); - String signature = signWebhook(appId, appSecret, requestTime, nonce, body); - HttpClient client = HttpClient.newHttpClient(); - for (WebhookConfigEntity webhook : webhooks) { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(webhook.getUrl())) - .header("Content-Type", "application/json") - .header("X-App-Id", appId) - .header("X-App-Timestamp", String.valueOf(requestTime)) - .header("X-App-Nonce", nonce) - .header("X-App-Signature", signature) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .build(); - client.send(request, HttpResponse.BodyHandlers.ofString()); - } catch (Exception e) { - log.warn("dispatch webhook failed appId={} url={} event={} reason={}", - appId, webhook.getUrl(), callbackEvent, e.getMessage()); - } - } - } catch (Exception e) { - log.warn("prepare webhook failed appId={} event={} reason={}", appId, callbackEvent, e.getMessage()); - } - } - - private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) { - String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body); - return hmacSha256Hex(appSecret, payload); - } - - private String sha256Hex(String value) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("Failed to hash webhook body", e); - } - } - - private String hmacSha256Hex(String secret, String payload) { - try { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); - } catch (Exception e) { - throw new IllegalStateException("Failed to sign webhook body", e); - } + webhookDispatchService.dispatch(appId, "message", callbackEvent, payload); } } diff --git a/im-service/src/main/java/com/xuqm/im/service/OperationLogService.java b/im-service/src/main/java/com/xuqm/im/service/OperationLogService.java index 9a3bc78..657edd6 100644 --- a/im-service/src/main/java/com/xuqm/im/service/OperationLogService.java +++ b/im-service/src/main/java/com/xuqm/im/service/OperationLogService.java @@ -28,7 +28,7 @@ public class OperationLogService { ImOperationLogEntity entity = new ImOperationLogEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppId(appId); - entity.setOperatorId(operatorId); + entity.setOperatorId(operatorId == null || operatorId.isBlank() ? "system" : operatorId); entity.setAction(action); entity.setResourceType(resourceType); entity.setResourceId(resourceId); diff --git a/im-service/src/main/java/com/xuqm/im/service/WebhookDispatchService.java b/im-service/src/main/java/com/xuqm/im/service/WebhookDispatchService.java new file mode 100644 index 0000000..c4f3df6 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/WebhookDispatchService.java @@ -0,0 +1,112 @@ +package com.xuqm.im.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.im.entity.WebhookConfigEntity; +import com.xuqm.im.model.WebhookCallbackEnvelope; +import com.xuqm.im.repository.WebhookConfigRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpClient; +import java.time.Duration; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +@Component +public class WebhookDispatchService { + + private final WebhookConfigRepository webhookRepository; + private final ImAppSecretClient appSecretClient; + private final ObjectMapper objectMapper; + + @Value("${im.webhook-timeout-ms:3000}") + private int webhookTimeoutMs; + + public WebhookDispatchService(WebhookConfigRepository webhookRepository, + ImAppSecretClient appSecretClient, + ObjectMapper objectMapper) { + this.webhookRepository = webhookRepository; + this.appSecretClient = appSecretClient; + this.objectMapper = objectMapper; + } + + public void dispatch(String appId, String callbackType, String callbackEvent, Object payload) { + List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); + if (webhooks.isEmpty()) { + return; + } + try { + String appSecret = appSecretClient.getAppSecret(appId); + long requestTime = System.currentTimeMillis(); + String nonce = UUID.randomUUID().toString().replace("-", ""); + String callbackId = UUID.randomUUID().toString(); + WebhookCallbackEnvelope envelope = new WebhookCallbackEnvelope( + callbackId, + callbackType, + callbackEvent, + requestTime, + objectMapper.valueToTree(payload), + null, + appId + ); + String body = objectMapper.writeValueAsString(envelope); + String signature = signWebhook(appId, appSecret, requestTime, nonce, body); + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(webhookTimeoutMs)) + .build(); + for (WebhookConfigEntity webhook : webhooks) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhook.getUrl())) + .timeout(Duration.ofMillis(webhookTimeoutMs)) + .header("Content-Type", "application/json") + .header("X-App-Id", appId) + .header("X-App-Timestamp", String.valueOf(requestTime)) + .header("X-App-Nonce", nonce) + .header("X-App-Signature", signature) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + client.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (Exception e) { + // 回调失败不影响主流程 + } + } + } catch (Exception e) { + // 准备失败不影响主流程 + } + } + + private String signWebhook(String appId, String appSecret, long requestTime, String nonce, String body) { + String payload = appId + "\n" + requestTime + "\n" + nonce + "\n" + sha256Hex(body); + return hmacSha256Hex(appSecret, payload); + } + + private String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Failed to hash webhook body", e); + } + } + + private String hmacSha256Hex(String secret, String payload) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("Failed to sign webhook body", e); + } + } +} diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushController.java b/push-service/src/main/java/com/xuqm/push/controller/PushController.java index 504479e..1287377 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/PushController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/PushController.java @@ -31,6 +31,15 @@ public class PushController { return ResponseEntity.ok(ApiResponse.ok()); } + @PostMapping("/receive-push") + public ResponseEntity> receivePush( + @RequestParam @NotBlank String appId, + @RequestParam @NotBlank String userId, + @RequestParam boolean enabled) { + pushDispatcher.setReceivePush(appId, userId, enabled); + return ResponseEntity.ok(ApiResponse.ok()); + } + @PostMapping("/send") public ResponseEntity> send( @RequestParam @NotBlank String appId, diff --git a/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java index 5e97bae..fa72ada 100644 --- a/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java +++ b/push-service/src/main/java/com/xuqm/push/entity/DeviceTokenEntity.java @@ -32,6 +32,9 @@ public class DeviceTokenEntity { @Column(nullable = false, length = 512) private String token; + @Column(nullable = false) + private boolean receivePush = true; + @Column(nullable = false) private LocalDateTime createdAt; @@ -53,6 +56,9 @@ public class DeviceTokenEntity { public String getToken() { return token; } public void setToken(String token) { this.token = token; } + public boolean isReceivePush() { return receivePush; } + public void setReceivePush(boolean receivePush) { this.receivePush = receivePush; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } diff --git a/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java index e790cbd..7d1808e 100644 --- a/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java +++ b/push-service/src/main/java/com/xuqm/push/repository/DeviceTokenRepository.java @@ -7,7 +7,8 @@ import java.util.List; import java.util.Optional; public interface DeviceTokenRepository extends JpaRepository { - List findByAppIdAndUserId(String appId, String userId); + List findByAppIdAndUserIdAndReceivePushTrue(String appId, String userId); Optional findByAppIdAndUserIdAndVendor( String appId, String userId, DeviceTokenEntity.Vendor vendor); + List findByAppIdAndUserId(String appId, String userId); } 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 d35a9ca..5e7624f 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 @@ -31,7 +31,7 @@ public class PushDispatcher { @Async public void pushToUser(String appId, String userId, String title, String body, String payload) { - List tokens = tokenRepository.findByAppIdAndUserId(appId, userId); + List tokens = tokenRepository.findByAppIdAndUserIdAndReceivePushTrue(appId, userId); for (DeviceTokenEntity t : tokens) { PushProvider provider = providers.get(t.getVendor().name()); if (provider != null) { @@ -63,7 +63,17 @@ public class PushDispatcher { return e; }); entity.setToken(token); + entity.setReceivePush(true); entity.setUpdatedAt(LocalDateTime.now()); tokenRepository.save(entity); } + + public void setReceivePush(String appId, String userId, boolean enabled) { + List tokens = tokenRepository.findByAppIdAndUserId(appId, userId); + for (DeviceTokenEntity token : tokens) { + token.setReceivePush(enabled); + token.setUpdatedAt(LocalDateTime.now()); + } + tokenRepository.saveAll(tokens); + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index 4fcb9a4..b5ac5aa 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -55,17 +56,31 @@ public class FeatureServiceController { @PutMapping("/config") public ResponseEntity> updateConfig( - @PathVariable String appId, - @RequestParam FeatureServiceEntity.Platform platform, - @RequestParam FeatureServiceEntity.ServiceType serviceType, - @RequestParam boolean allowStrangerMessage, - @AuthenticationPrincipal String tenantId) { + @PathVariable String appId, + @RequestParam FeatureServiceEntity.Platform platform, + @RequestParam FeatureServiceEntity.ServiceType serviceType, + @RequestBody FeatureServiceConfigRequest req, + @AuthenticationPrincipal String tenantId) { appService.getById(appId, tenantId); return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig( appId, platform, serviceType, - featureServiceManager.buildAllowStrangerConfig(allowStrangerMessage) + serviceType == FeatureServiceEntity.ServiceType.IM + ? featureServiceManager.buildImConfig( + appId, + platform, + req == null ? null : req.allowStrangerMessage(), + req == null ? null : req.allowFriendRequest(), + req == null ? null : req.friendRequestMode(), + req == null ? null : req.allowGroupJoinRequest(), + req == null ? null : req.blacklistSendSuccess(), + req == null ? null : req.messageRecallMinutes(), + req == null ? null : req.historyRetentionDays(), + req == null ? null : req.conversationPullLimit(), + req == null ? null : req.multiClientConversationDeleteSync()) + : featureServiceManager.buildAllowStrangerConfig( + req != null && Boolean.TRUE.equals(req.allowStrangerMessage())) ))); } @@ -88,4 +103,16 @@ public class FeatureServiceController { appService.getById(appId, tenantId); return ResponseEntity.ok(ApiResponse.success(featureServiceManager.listRequestsByApp(appId))); } + + public record FeatureServiceConfigRequest( + Boolean allowStrangerMessage, + Boolean allowFriendRequest, + String friendRequestMode, + Boolean allowGroupJoinRequest, + Boolean blacklistSendSuccess, + Integer messageRecallMinutes, + Integer historyRetentionDays, + Integer conversationPullLimit, + Boolean multiClientConversationDeleteSync + ) {} } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java index ed1e5de..499d9f8 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/FeatureServiceRepository.java @@ -8,6 +8,7 @@ import java.util.Optional; public interface FeatureServiceRepository extends JpaRepository { List findByAppId(String appId); + List findByAppIdAndServiceType(String appId, FeatureServiceEntity.ServiceType serviceType); Optional findByAppIdAndPlatformAndServiceType( String appId, FeatureServiceEntity.Platform platform, diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/ServiceActivationRequestRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/ServiceActivationRequestRepository.java index 2494b42..a79f8a4 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/repository/ServiceActivationRequestRepository.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/ServiceActivationRequestRepository.java @@ -15,6 +15,9 @@ public interface ServiceActivationRequestRepository extends JpaRepository findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc( String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType); + Optional findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc( + String appId, FeatureServiceEntity.ServiceType serviceType); + List findByAppIdOrderByCreatedAtDesc(String appId); Page findByStatusOrderByCreatedAtDesc(Status status, Pageable pageable); diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index ea70ad6..b20037f 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -32,11 +33,25 @@ public class FeatureServiceManager { } public List listByApp(String appId) { - return repository.findByAppId(appId); + List services = repository.findByAppId(appId); + if (services.isEmpty()) { + return services; + } + + List normalized = new ArrayList<>(); + services.stream() + .filter(service -> service.getServiceType() == FeatureServiceEntity.ServiceType.IM) + .findFirst() + .ifPresent(normalized::add); + services.stream() + .filter(service -> service.getServiceType() != FeatureServiceEntity.ServiceType.IM) + .forEach(normalized::add); + return normalized.isEmpty() ? services : normalized; } /** * Submit an activation request. Disabling is immediate; enabling requires ops approval. + * IM is app-wide, so duplicate checks ignore platform. */ @Transactional public ServiceActivationRequestEntity submitActivationRequest( @@ -45,13 +60,21 @@ public class FeatureServiceManager { FeatureServiceEntity.ServiceType serviceType, String applyReason) { - // Check if there's already a pending request - requestRepository.findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(appId, platform, serviceType) - .ifPresent(req -> { - if (req.getStatus() == Status.PENDING) { - throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理"); - } - }); + if (serviceType == FeatureServiceEntity.ServiceType.IM) { + requestRepository.findFirstByAppIdAndServiceTypeOrderByCreatedAtDesc(appId, serviceType) + .ifPresent(req -> { + if (req.getStatus() == Status.PENDING) { + throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理"); + } + }); + } else { + requestRepository.findFirstByAppIdAndPlatformAndServiceTypeOrderByCreatedAtDesc(appId, platform, serviceType) + .ifPresent(req -> { + if (req.getStatus() == Status.PENDING) { + throw new BusinessException(400, "已有待审核的开通申请,请等待运营人员处理"); + } + }); + } ServiceActivationRequestEntity req = new ServiceActivationRequestEntity(); req.setId(UUID.randomUUID().toString()); @@ -70,6 +93,16 @@ public class FeatureServiceManager { @Transactional public FeatureServiceEntity disable(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { + if (serviceType == FeatureServiceEntity.ServiceType.IM) { + List services = repository.findByAppIdAndServiceType(appId, serviceType); + if (services.isEmpty()) { + throw new BusinessException(404, "服务未开通"); + } + services.forEach(service -> service.setEnabled(false)); + repository.saveAll(services); + return services.get(0); + } + FeatureServiceEntity entity = repository .findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) .orElseThrow(() -> new BusinessException(404, "服务未开通")); @@ -92,7 +125,24 @@ public class FeatureServiceManager { req.setReviewedAt(LocalDateTime.now()); requestRepository.save(req); - // Activate the service + if (req.getServiceType() == FeatureServiceEntity.ServiceType.IM) { + List services = repository.findByAppIdAndServiceType(req.getAppId(), req.getServiceType()); + if (services.isEmpty()) { + FeatureServiceEntity created = new FeatureServiceEntity(); + created.setId(UUID.randomUUID().toString()); + created.setAppId(req.getAppId()); + created.setPlatform(req.getPlatform()); + created.setServiceType(req.getServiceType()); + created.setEnabled(true); + created.setCreatedAt(LocalDateTime.now()); + repository.save(created); + } else { + services.forEach(service -> service.setEnabled(true)); + repository.saveAll(services); + } + return req; + } + FeatureServiceEntity entity = repository .findByAppIdAndPlatformAndServiceType(req.getAppId(), req.getPlatform(), req.getServiceType()) .orElseGet(() -> { @@ -131,6 +181,12 @@ public class FeatureServiceManager { public FeatureServiceEntity getOrFail(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { + if (serviceType == FeatureServiceEntity.ServiceType.IM) { + return repository.findByAppIdAndServiceType(appId, serviceType) + .stream() + .findFirst() + .orElseThrow(() -> new BusinessException(404, "服务未配置")); + } return repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) .orElseThrow(() -> new BusinessException(404, "服务未配置")); } @@ -140,6 +196,16 @@ public class FeatureServiceManager { FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType, String config) { + if (serviceType == FeatureServiceEntity.ServiceType.IM) { + List services = repository.findByAppIdAndServiceType(appId, serviceType); + if (services.isEmpty()) { + throw new BusinessException(404, "服务未配置"); + } + services.forEach(service -> service.setConfig(config)); + repository.saveAll(services); + return services.get(0); + } + FeatureServiceEntity entity = getOrFail(appId, platform, serviceType); entity.setConfig(config); return repository.save(entity); @@ -148,23 +214,179 @@ public class FeatureServiceManager { public boolean allowStrangerMessage(String appId, FeatureServiceEntity.Platform platform, FeatureServiceEntity.ServiceType serviceType) { - FeatureServiceEntity entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) - .orElse(null); - if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) { - return false; + return readConfigNode(appId, platform, serviceType).path("allowStrangerMessage").asBoolean(false); + } + + public boolean allowFriendRequest(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + return readConfigNode(appId, platform, serviceType).path("allowFriendRequest").asBoolean(true); + } + + public boolean allowGroupJoinRequest(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + return readConfigNode(appId, platform, serviceType).path("allowGroupJoinRequest").asBoolean(true); + } + + public String friendRequestMode(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + String mode = readConfigNode(appId, platform, serviceType).path("friendRequestMode").asText(""); + return switch (mode == null ? "" : mode.trim().toUpperCase()) { + case "DIRECT_ACCEPT", "DISALLOW" -> mode.trim().toUpperCase(); + default -> "REQUIRE_CONFIRM"; + }; + } + + public boolean blacklistSendSuccess(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + return readConfigNode(appId, platform, serviceType).path("blacklistSendSuccess").asBoolean(true); + } + + public int messageRecallMinutes(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + int minutes = readConfigNode(appId, platform, serviceType).path("messageRecallMinutes").asInt(2); + return Math.max(minutes, 0); + } + + public int conversationPullLimit(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + int limit = readConfigNode(appId, platform, serviceType).path("conversationPullLimit").asInt(100); + return Math.min(Math.max(limit, 1), 500); + } + + public int historyRetentionDays(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + int days = readConfigNode(appId, platform, serviceType).path("historyRetentionDays").asInt(7); + return Math.max(days, 1); + } + + public String buildImConfig(String appId, + FeatureServiceEntity.Platform platform, + Boolean allowStrangerMessage, + Boolean allowFriendRequest, + String friendRequestMode, + Boolean allowGroupJoinRequest, + Boolean blacklistSendSuccess, + Integer messageRecallMinutes, + Integer historyRetentionDays, + Integer conversationPullLimit, + Boolean multiClientConversationDeleteSync) { + ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.IM).deepCopy(); + if (!node.has("allowStrangerMessage")) { + node.put("allowStrangerMessage", false); } - try { - JsonNode node = objectMapper.readTree(entity.getConfig()); - return node.path("allowStrangerMessage").asBoolean(false); - } catch (Exception e) { - return false; + if (!node.has("allowFriendRequest")) { + node.put("allowFriendRequest", true); } + if (!node.has("friendRequestMode")) { + node.put("friendRequestMode", "REQUIRE_CONFIRM"); + } + if (!node.has("allowGroupJoinRequest")) { + node.put("allowGroupJoinRequest", true); + } + if (!node.has("blacklistSendSuccess")) { + node.put("blacklistSendSuccess", true); + } + if (!node.has("messageRecallMinutes")) { + node.put("messageRecallMinutes", 2); + } + if (!node.has("historyRetentionDays")) { + node.put("historyRetentionDays", 7); + } + if (!node.has("conversationPullLimit")) { + node.put("conversationPullLimit", 100); + } + if (!node.has("multiClientConversationDeleteSync")) { + node.put("multiClientConversationDeleteSync", false); + } + if (allowStrangerMessage != null) { + node.put("allowStrangerMessage", allowStrangerMessage); + } + if (allowFriendRequest != null) { + node.put("allowFriendRequest", allowFriendRequest); + } + String effectiveFriendRequestMode = null; + if (friendRequestMode != null && !friendRequestMode.isBlank()) { + effectiveFriendRequestMode = normalizeFriendRequestMode(friendRequestMode); + } else if (allowFriendRequest != null && !allowFriendRequest) { + effectiveFriendRequestMode = "DISALLOW"; + } + if (effectiveFriendRequestMode != null) { + node.put("friendRequestMode", effectiveFriendRequestMode); + if ("DISALLOW".equals(effectiveFriendRequestMode)) { + node.put("allowFriendRequest", false); + } + } + if (allowGroupJoinRequest != null) { + node.put("allowGroupJoinRequest", allowGroupJoinRequest); + } + if (blacklistSendSuccess != null) { + node.put("blacklistSendSuccess", blacklistSendSuccess); + } + if (messageRecallMinutes != null) { + node.put("messageRecallMinutes", Math.max(messageRecallMinutes, 0)); + } + if (historyRetentionDays != null) { + node.put("historyRetentionDays", Math.max(historyRetentionDays, 1)); + } + if (conversationPullLimit != null) { + node.put("conversationPullLimit", Math.min(Math.max(conversationPullLimit, 1), 500)); + } + if (multiClientConversationDeleteSync != null) { + node.put("multiClientConversationDeleteSync", multiClientConversationDeleteSync); + } + return node.toString(); } public String buildAllowStrangerConfig(boolean allowStrangerMessage) { ObjectNode node = objectMapper.createObjectNode(); node.put("allowStrangerMessage", allowStrangerMessage); + node.put("allowFriendRequest", true); + node.put("friendRequestMode", "REQUIRE_CONFIRM"); + node.put("allowGroupJoinRequest", true); + node.put("blacklistSendSuccess", true); + node.put("messageRecallMinutes", 2); + node.put("historyRetentionDays", 7); + node.put("conversationPullLimit", 100); + node.put("multiClientConversationDeleteSync", false); return node.toString(); } + private String normalizeFriendRequestMode(String mode) { + String normalized = mode == null ? "" : mode.trim().toUpperCase(); + return switch (normalized) { + case "DIRECT_ACCEPT", "DISALLOW" -> normalized; + default -> "REQUIRE_CONFIRM"; + }; + } + + private JsonNode readConfigNode(String appId, + FeatureServiceEntity.Platform platform, + FeatureServiceEntity.ServiceType serviceType) { + FeatureServiceEntity entity; + if (serviceType == FeatureServiceEntity.ServiceType.IM) { + entity = repository.findByAppIdAndServiceType(appId, serviceType) + .stream() + .findFirst() + .orElse(null); + } else { + entity = repository.findByAppIdAndPlatformAndServiceType(appId, platform, serviceType) + .orElse(null); + } + if (entity == null || entity.getConfig() == null || entity.getConfig().isBlank()) { + return objectMapper.createObjectNode(); + } + try { + JsonNode node = objectMapper.readTree(entity.getConfig()); + return node == null ? objectMapper.createObjectNode() : node; + } catch (Exception e) { + return objectMapper.createObjectNode(); + } + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java index 8c1ed41..b3ee024 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SdkAppProvisioningService.java @@ -117,12 +117,28 @@ public class SdkAppProvisioningService { } private void ensureFeatureDefaults(AppEntity app) { + featureServiceRepository.findByAppIdAndServiceType(app.getAppKey(), FeatureServiceEntity.ServiceType.IM) + .stream() + .findFirst() + .orElseGet(() -> { + FeatureServiceEntity feature = new FeatureServiceEntity(); + feature.setId(UUID.randomUUID().toString()); + feature.setAppId(app.getAppKey()); + feature.setPlatform(FeatureServiceEntity.Platform.ANDROID); + feature.setServiceType(FeatureServiceEntity.ServiceType.IM); + feature.setEnabled(true); + feature.setConfig(""" + {"allowStrangerMessage":false,"allowFriendRequest":true,"friendRequestMode":"REQUIRE_CONFIRM","allowGroupJoinRequest":true,"blacklistSendSuccess":true,"messageRecallMinutes":2,"historyRetentionDays":7,"conversationPullLimit":100,"multiClientConversationDeleteSync":false} + """.trim()); + feature.setCreatedAt(LocalDateTime.now()); + return featureServiceRepository.save(feature); + }); + for (FeatureServiceEntity.Platform platform : List.of( FeatureServiceEntity.Platform.ANDROID, FeatureServiceEntity.Platform.IOS, FeatureServiceEntity.Platform.HARMONY)) { for (FeatureServiceEntity.ServiceType serviceType : List.of( - FeatureServiceEntity.ServiceType.IM, FeatureServiceEntity.ServiceType.PUSH, FeatureServiceEntity.ServiceType.UPDATE)) { featureServiceRepository.findByAppIdAndPlatformAndServiceType(app.getAppKey(), platform, serviceType) diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java index 5e9dfe5..f30457a 100644 --- a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java +++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java @@ -2,11 +2,17 @@ package com.xuqm.update.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; 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.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity @@ -16,8 +22,10 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> {}) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers( "/actuator/**", "/api/v1/updates/app/check", @@ -31,4 +39,25 @@ public class SecurityConfig { .formLogin(AbstractHttpConfigurer::disable); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + config.setAllowedOriginPatterns(List.of( + "http://localhost:*", + "http://127.0.0.1:*", + "http://192.168.116.9:*", + "http://*.xuqinmin.com", + "https://*.xuqinmin.com" + )); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Location")); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 271e22e..1835daa 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -3,6 +3,7 @@ package com.xuqm.update.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.repository.AppVersionRepository; +import com.xuqm.update.model.AppPackageInspectResult; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -57,8 +58,8 @@ public class AppVersionController { public ResponseEntity> upload( @RequestParam String appId, @RequestParam AppVersionEntity.Platform platform, - @RequestParam String versionName, - @RequestParam int versionCode, + @RequestParam(required = false) String versionName, + @RequestParam(required = false) Integer versionCode, @RequestParam(required = false) String changeLog, @RequestParam(defaultValue = "false") boolean forceUpdate, @RequestParam(required = false) MultipartFile apkFile, @@ -68,12 +69,20 @@ public class AppVersionController { @RequestParam(defaultValue = "false") boolean autoPublishAfterReview, @RequestParam(required = false) String packageName) throws Exception { + AppPackageInspectResult inspected = updateAssetService.inspectAppPackage(apkFile); + String resolvedVersionName = hasText(versionName) ? versionName : inspected.versionName(); + Integer resolvedVersionCode = versionCode != null ? versionCode : inspected.versionCode(); + String resolvedPackageName = hasText(packageName) ? packageName : inspected.packageName(); + if (!hasText(resolvedVersionName) || resolvedVersionCode == null) { + throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package"); + } + AppVersionEntity entity = new AppVersionEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppId(appId); entity.setPlatform(platform); - entity.setVersionName(versionName); - entity.setVersionCode(versionCode); + entity.setVersionName(resolvedVersionName); + entity.setVersionCode(resolvedVersionCode); entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile)); entity.setChangeLog(changeLog); entity.setForceUpdate(forceUpdate); @@ -85,10 +94,15 @@ public class AppVersionController { entity.setWebhookUrl(webhookUrl); entity.setStoreSubmitTargets(storeSubmitTargets); entity.setAutoPublishAfterReview(autoPublishAfterReview); - entity.setPackageName(packageName); + entity.setPackageName(resolvedPackageName); return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); } + @PostMapping("/app/inspect") + public ResponseEntity> inspect(@RequestParam(required = false) MultipartFile apkFile) throws Exception { + return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectAppPackage(apkFile))); + } + @PostMapping("/app/{id}/publish") public ResponseEntity> publish(@PathVariable String id) { AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); @@ -122,4 +136,8 @@ public class AppVersionController { return ResponseEntity.ok(ApiResponse.success( versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform))); } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java index 88e45f5..27c061a 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -2,6 +2,7 @@ package com.xuqm.update.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.model.RnBundleInspectResult; import com.xuqm.update.repository.RnBundleRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; @@ -61,30 +62,43 @@ public class RnBundleController { @PostMapping("/upload") public ResponseEntity> upload( @RequestParam String appId, - @RequestParam String moduleId, - @RequestParam RnBundleEntity.Platform platform, - @RequestParam String version, + @RequestParam(required = false) String moduleId, + @RequestParam(required = false) RnBundleEntity.Platform platform, + @RequestParam(required = false) String version, @RequestParam(required = false) String minCommonVersion, @RequestParam(required = false) String note, @RequestParam MultipartFile bundle) throws Exception { + RnBundleInspectResult inspected = updateAssetService.inspectRnBundle(bundle); + String resolvedModuleId = hasText(moduleId) ? moduleId : inspected.moduleId(); + String resolvedVersion = hasText(version) ? version : inspected.version(); + String resolvedMinCommonVersion = hasText(minCommonVersion) ? minCommonVersion : inspected.minCommonVersion(); + RnBundleEntity.Platform resolvedPlatform = platform != null ? platform : parsePlatform(inspected.platform()); + if (!hasText(resolvedModuleId) || !hasText(resolvedVersion) || resolvedPlatform == null) { + throw new IllegalArgumentException("moduleId, version and platform are required or must be readable from the bundle name"); + } UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle( - appId, platform.name(), moduleId, bundle); + appId, resolvedPlatform.name(), resolvedModuleId, bundle); RnBundleEntity entity = new RnBundleEntity(); entity.setId(UUID.randomUUID().toString()); entity.setAppId(appId); - entity.setModuleId(moduleId); - entity.setPlatform(platform); - entity.setVersion(version); + entity.setModuleId(resolvedModuleId); + entity.setPlatform(resolvedPlatform); + entity.setVersion(resolvedVersion); entity.setBundleUrl(stored.bundlePath()); entity.setMd5(stored.md5()); - entity.setMinCommonVersion(minCommonVersion); + entity.setMinCommonVersion(resolvedMinCommonVersion); entity.setNote(note); entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); entity.setCreatedAt(LocalDateTime.now()); return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); } + @PostMapping("/inspect") + public ResponseEntity> inspect(@RequestParam(required = false) MultipartFile bundle) throws Exception { + return ResponseEntity.ok(ApiResponse.success(updateAssetService.inspectRnBundle(bundle))); + } + @GetMapping("/list") public ResponseEntity>> list( @RequestParam String appId, @@ -137,4 +151,19 @@ public class RnBundleController { } return normalized; } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private RnBundleEntity.Platform parsePlatform(String platform) { + if (!hasText(platform)) { + return null; + } + try { + return RnBundleEntity.Platform.valueOf(platform.toUpperCase()); + } catch (Exception e) { + return null; + } + } } diff --git a/update-service/src/main/java/com/xuqm/update/model/AppPackageInspectResult.java b/update-service/src/main/java/com/xuqm/update/model/AppPackageInspectResult.java new file mode 100644 index 0000000..a8c996c --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/AppPackageInspectResult.java @@ -0,0 +1,10 @@ +package com.xuqm.update.model; + +public record AppPackageInspectResult( + String platform, + String packageName, + String versionName, + Integer versionCode, + String fileName, + boolean detected) { +} diff --git a/update-service/src/main/java/com/xuqm/update/model/RnBundleInspectResult.java b/update-service/src/main/java/com/xuqm/update/model/RnBundleInspectResult.java new file mode 100644 index 0000000..6d0021c --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/RnBundleInspectResult.java @@ -0,0 +1,10 @@ +package com.xuqm.update.model; + +public record RnBundleInspectResult( + String moduleId, + String platform, + String version, + String minCommonVersion, + String fileName, + boolean detected) { +} diff --git a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java index 3f621d4..4316b78 100644 --- a/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java @@ -1,17 +1,34 @@ package com.xuqm.update.service; +import com.xuqm.update.model.AppPackageInspectResult; +import com.xuqm.update.model.RnBundleInspectResult; +import net.dongliu.apk.parser.ApkFile; +import net.dongliu.apk.parser.bean.ApkMeta; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; import java.security.DigestInputStream; import java.security.MessageDigest; +import java.util.Map; +import java.util.Locale; import java.util.HexFormat; +import java.util.Optional; import java.util.UUID; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; @Service public class UpdateAssetService { @@ -34,6 +51,41 @@ public class UpdateAssetService { return baseUrl + "/files/apk/" + filename; } + public AppPackageInspectResult inspectAppPackage(MultipartFile packageFile) throws Exception { + String fileName = Optional.ofNullable(packageFile != null ? packageFile.getOriginalFilename() : null) + .orElse(""); + String normalized = fileName.toLowerCase(Locale.ROOT); + if (packageFile == null || packageFile.isEmpty()) { + return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false); + } + + Path temp = Files.createTempFile("xuqm-package-inspect-", suffixFor(fileName)); + try { + try (InputStream in = packageFile.getInputStream()) { + Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING); + } + + if (normalized.endsWith(".apk")) { + return inspectApk(temp, fileName); + } + if (normalized.endsWith(".ipa")) { + return inspectIpa(temp, fileName); + } + return new AppPackageInspectResult(platformFromFileName(fileName), null, null, null, fileName, false); + } finally { + Files.deleteIfExists(temp); + } + } + + public RnBundleInspectResult inspectRnBundle(MultipartFile bundle) throws Exception { + String fileName = Optional.ofNullable(bundle != null ? bundle.getOriginalFilename() : null) + .orElse(""); + if (bundle == null || bundle.isEmpty()) { + return new RnBundleInspectResult(null, null, null, null, fileName, false); + } + return inspectRnBundleName(fileName); + } + public StoredRnBundle storeRnBundle(String appId, String platform, String moduleId, MultipartFile bundle) throws Exception { if (bundle == null || bundle.isEmpty()) { throw new IllegalArgumentException("bundle file is required"); @@ -59,5 +111,138 @@ public class UpdateAssetService { return HexFormat.of().formatHex(digest.digest()); } + private AppPackageInspectResult inspectApk(Path file, String fileName) throws Exception { + try (ApkFile apk = new ApkFile(file.toFile())) { + ApkMeta meta = apk.getApkMeta(); + Integer versionCode = meta.getVersionCode() == null ? null : meta.getVersionCode().intValue(); + return new AppPackageInspectResult( + "ANDROID", + blankToNull(meta.getPackageName()), + blankToNull(meta.getVersionName()), + versionCode, + fileName, + true); + } + } + + private AppPackageInspectResult inspectIpa(Path file, String fileName) throws Exception { + try (ZipFile zipFile = new ZipFile(file.toFile())) { + ZipEntry entry = zipFile.stream() + .filter(e -> e.getName().endsWith("Info.plist")) + .findFirst() + .orElse(null); + if (entry != null) { + byte[] data = zipFile.getInputStream(entry).readAllBytes(); + String text = new String(data, StandardCharsets.UTF_8); + if (text.contains("= 4) { + return new RnBundleInspectResult( + blankToNull(parts[0]), + platformFromToken(parts[1]), + blankToNull(parts[2]), + blankToNull(parts[3]), + fileName, + true); + } + return new RnBundleInspectResult( + null, + platformFromFileName(fileName), + null, + null, + fileName, + false); + } + + private Map parsePlistXml(String xml) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setExpandEntityReferences(false); + Document document = factory.newDocumentBuilder().parse(new java.io.ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + NodeList dictNodes = document.getElementsByTagName("dict"); + if (dictNodes.getLength() == 0) { + return Map.of(); + } + Element dict = (Element) dictNodes.item(0); + Map values = new java.util.LinkedHashMap<>(); + Node child = dict.getFirstChild(); + String currentKey = null; + while (child != null) { + if (child.getNodeType() == Node.ELEMENT_NODE) { + String nodeName = child.getNodeName(); + if ("key".equals(nodeName)) { + currentKey = child.getTextContent(); + } else if (currentKey != null && ("string".equals(nodeName) || "integer".equals(nodeName))) { + values.put(currentKey, child.getTextContent()); + currentKey = null; + } + } + child = child.getNextSibling(); + } + return values; + } + + private String platformFromFileName(String fileName) { + String lower = Optional.ofNullable(fileName).orElse("").toLowerCase(Locale.ROOT); + if (lower.contains("ios") || lower.endsWith(".ipa")) { + return "IOS"; + } + return "ANDROID"; + } + + private String platformFromToken(String token) { + if (token == null) { + return null; + } + String normalized = token.trim().toUpperCase(Locale.ROOT); + return switch (normalized) { + case "ANDROID", "IOS" -> normalized; + case "A", "ANDROIDSDK" -> "ANDROID"; + case "I", "IOSSDK" -> "IOS"; + default -> normalized.isBlank() ? null : normalized; + }; + } + + private String stripExtension(String fileName) { + if (fileName == null) return ""; + int idx = fileName.lastIndexOf('.'); + return idx > 0 ? fileName.substring(0, idx) : fileName; + } + + private String suffixFor(String fileName) { + if (fileName == null) return ".tmp"; + int idx = fileName.lastIndexOf('.'); + return idx > 0 ? fileName.substring(idx) : ".tmp"; + } + + private Integer parseInteger(String value) { + if (value == null || value.isBlank()) return null; + try { + return Integer.valueOf(value.trim()); + } catch (NumberFormatException e) { + return null; + } + } + + private String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + public record StoredRnBundle(String bundlePath, String md5) {} }