From eae18723a577a4c07e6499be1bf8b901631c1e9b Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Wed, 29 Apr 2026 00:34:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(update):=20add=20app=20store=20distributio?= =?UTF-8?q?n=20=E2=80=94=20store=20config,=20server-side=20submission,=20s?= =?UTF-8?q?cheduled=20publish,=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(im): expand IM API with friends, groups, admin ops, operation logging Co-Authored-By: Claude Sonnet 4.6 --- docs/API_ACCESS.md | 30 ++ .../java/com/xuqm/im/sdk/XuqmImServerSdk.java | 507 +++++++----------- .../xuqm/im/controller/AccountController.java | 47 ++ .../controller/FriendRequestController.java | 1 + .../xuqm/im/controller/GroupController.java | 19 + .../xuqm/im/controller/ImAdminController.java | 86 ++- .../xuqm/im/controller/MessageController.java | 2 +- .../xuqm/im/entity/ImOperationLogEntity.java | 62 +++ .../repository/ImOperationLogRepository.java | 10 + .../com/xuqm/im/service/ImAccountService.java | 43 ++ .../com/xuqm/im/service/ImGroupService.java | 31 ++ .../com/xuqm/im/service/MessageService.java | 20 +- .../xuqm/im/service/OperationLogService.java | 43 ++ .../xuqm/update/UpdateServiceApplication.java | 4 + .../update/controller/AppStoreController.java | 134 +++++ .../controller/AppVersionController.java | 14 +- .../update/entity/AppStoreConfigEntity.java | 71 +++ .../xuqm/update/entity/AppVersionEntity.java | 49 ++ .../repository/AppStoreConfigRepository.java | 16 + .../repository/AppVersionRepository.java | 3 + .../xuqm/update/service/AppStoreService.java | 182 +++++++ .../service/StoreSubmissionService.java | 362 +++++++++++++ 22 files changed, 1409 insertions(+), 327 deletions(-) create mode 100644 im-service/src/main/java/com/xuqm/im/entity/ImOperationLogEntity.java create mode 100644 im-service/src/main/java/com/xuqm/im/repository/ImOperationLogRepository.java create mode 100644 im-service/src/main/java/com/xuqm/im/service/OperationLogService.java create mode 100644 update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java create mode 100644 update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java create mode 100644 update-service/src/main/java/com/xuqm/update/repository/AppStoreConfigRepository.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/AppStoreService.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index f816799..14dd3b4 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -89,7 +89,18 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| | POST | `/api/im/auth/login` | 否 | 获取 IM Token;需要 `X-App-Timestamp` / `X-App-Nonce` / `X-App-Signature` | +| GET | `/api/im/accounts/{userId}` | 是 | 查询用户资料 | +| PUT | `/api/im/accounts/{userId}` | 是 | 更新自己的资料 | +| GET | `/api/im/accounts/search` | 否 | 搜索账号 | +| POST | `/api/im/accounts/import` | 是 | 导入单个账号 | +| POST | `/api/im/accounts/import/batch` | 是 | 批量导入账号 | +| DELETE | `/api/im/accounts/{userId}` | 是 | 删除账号 | +| GET | `/api/im/accounts/{userId}/exists` | 是 | 检查账号是否存在 | +| GET | `/api/im/groups/{groupId}/members` | 是 | 查询群成员列表 | +| GET | `/api/im/groups/{groupId}/members/search` | 是 | 搜索群成员 | | POST | `/api/im/messages/send` | 是 | 发送消息(TEXT / IMAGE / AUDIO / VIDEO / FILE / LOCATION / CUSTOM / NOTIFY / RICH_TEXT / CALL_AUDIO / CALL_VIDEO / FORWARD / QUOTE / MERGE) | +| GET | `/api/im/messages/search` | 是 | 云端消息搜索 | +| PUT | `/api/im/messages/{id}` | 是 | 编辑自己发送的文本消息 | | POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 | | GET | `/api/im/messages/history/{toId}` | 是 | 查询历史消息 | | WS | `/ws/im` | IM Token | 建立实时连接 | @@ -167,12 +178,26 @@ curl -X POST 'https://dev.xuqinmin.com/api/demo/auth/refresh-im?appId=ak_demo_ch ### IM 会话与关系链 ```bash +curl 'https://dev.xuqinmin.com/api/im/accounts/user_001?appId=ak_demo_chat' +curl -X POST 'https://dev.xuqinmin.com/api/im/accounts/import?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '{"userId":"user_001","nickname":"Alice"}' +curl -X POST 'https://dev.xuqinmin.com/api/im/accounts/import/batch?appId=ak_demo_chat' \ + -H 'Content-Type: application/json' \ + -d '[{"userId":"user_001"},{"userId":"user_002"}]' +curl -X DELETE 'https://dev.xuqinmin.com/api/im/accounts/user_001?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/accounts/user_001/exists?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/conversations?appId=ak_demo_chat' curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/pinned?appId=ak_demo_chat&chatType=SINGLE&pinned=true' curl -X PUT 'https://dev.xuqinmin.com/api/im/conversations/user_002/draft?appId=ak_demo_chat&chatType=SINGLE&draft=hello' curl -X DELETE 'https://dev.xuqinmin.com/api/im/conversations/user_002?appId=ak_demo_chat&chatType=SINGLE' curl 'https://dev.xuqinmin.com/api/im/groups?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/groups/public?appId=ak_demo_chat&keyword=demo' +curl 'https://dev.xuqinmin.com/api/im/groups/search?appId=ak_demo_chat&keyword=demo&size=20' +curl 'https://dev.xuqinmin.com/api/im/groups/group_001/members?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/groups/group_001/members/search?appId=ak_demo_chat&keyword=demo&size=20' +curl 'https://dev.xuqinmin.com/api/im/messages/search?appId=ak_demo_chat&keyword=hello&page=0&size=20' +curl 'https://dev.xuqinmin.com/api/im/admin/webhooks?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入' curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat' @@ -187,6 +212,7 @@ curl 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&directi curl 'https://dev.xuqinmin.com/api/im/admin/users?appId=ak_demo_chat&page=0&size=20' curl 'https://dev.xuqinmin.com/api/im/admin/groups?appId=ak_demo_chat' curl 'https://dev.xuqinmin.com/api/im/admin/messages?appId=ak_demo_chat&userA=user_001&userB=user_002&page=0&size=20' +curl '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' ``` @@ -197,6 +223,10 @@ curl -X DELETE 'https://dev.xuqinmin.com/api/im/admin/groups/group_001' curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests?appId=ak_demo_chat&toUserId=user_002&remark=hi' curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/accept?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/friend-requests/req_001/reject?appId=ak_demo_chat' +curl 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat' +curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests?appId=ak_demo_chat&remark=申请加入' +curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/accept?appId=ak_demo_chat' +curl -X POST 'https://dev.xuqinmin.com/api/im/groups/group_001/join-requests/req_001/reject?appId=ak_demo_chat' curl -X POST 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002' curl -X DELETE 'https://dev.xuqinmin.com/api/im/blacklist?appId=ak_demo_chat&blockedUserId=user_002' ``` diff --git a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java index dcaf70c..289900f 100644 --- a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java +++ b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java @@ -119,6 +119,50 @@ public final class XuqmImServerSdk { return response.data(); } + public AccountView importAccount(String userId, String nickname, String avatar, String gender, String status) { + ApiResponse response = request( + "POST", + buildUri("/api/im/accounts/import", appQuery()), + new ImportAccountRequest(userId, nickname, avatar, gender, status), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List importAccounts(List accounts) { + ApiResponse> response = request( + "POST", + buildUri("/api/im/accounts/import/batch", appQuery()), + accounts, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public void deleteAccount(String userId) { + request( + "DELETE", + buildUri("/api/im/accounts/" + encode(userId), appQuery()), + null, + authorizedHeaders(), + new TypeReference>() {} + ); + } + + public boolean checkAccount(String userId) { + ApiResponse> response = request( + "GET", + buildUri("/api/im/accounts/" + encode(userId) + "/exists", appQuery()), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + Object exists = response.data().get("exists"); + return exists instanceof Boolean b && b; + } + public List listConversations(int size) { ApiResponse> response = request( "GET", @@ -174,6 +218,72 @@ public final class XuqmImServerSdk { return response.data(); } + public PageResult searchMessages(MessageSearchQuery query) { + MessageSearchQuery effective = query == null ? new MessageSearchQuery() : query; + ApiResponse> response = request( + "GET", + buildUri( + "/api/im/messages/search", + messageSearchQuery( + effective.chatType(), + effective.msgType(), + effective.keyword(), + effective.startTime(), + effective.endTime(), + effective.page() == null ? 0 : effective.page(), + effective.size() == null ? 20 : effective.size() + ) + ), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List listWebhooks() { + ApiResponse> response = request( + "GET", + buildUri("/api/im/admin/webhooks", appQuery()), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public WebhookConfigView createWebhook(WebhookConfigRequest request) { + ApiResponse response = request( + "POST", + buildUri("/api/im/admin/webhooks", appQuery()), + request, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public WebhookConfigView updateWebhook(String id, WebhookConfigRequest request) { + ApiResponse response = request( + "PUT", + buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()), + request, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public void deleteWebhook(String id) { + request( + "DELETE", + buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()), + null, + authorizedHeaders(), + new TypeReference>() {} + ); + } + public List listConversations() { return listConversations(20); } @@ -210,73 +320,10 @@ public final class XuqmImServerSdk { return response.data(); } - public PageResult listUsers(int page, int size) { - ApiResponse> response = request( - "GET", - buildUri("/api/im/admin/users", queryWithPage(page, size)), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public AccountView registerUser(String userId, String nickname, String avatar) { - Map body = new LinkedHashMap<>(); - body.put("userId", userId); - if (nickname != null) { - body.put("nickname", nickname); - } - if (avatar != null) { - body.put("avatar", avatar); - } - ApiResponse response = request( - "POST", - buildUri("/api/im/admin/users", appQuery()), - body, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public AccountView updateUserStatus(String userId, String status) { - ApiResponse response = request( - "PUT", - buildUri("/api/im/admin/users/" + encode(userId) + "/status", appQuery()), - Map.of("status", status), - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public List searchUsers(String keyword, int size) { + public List searchAccounts(String keyword, int size) { ApiResponse> response = request( "GET", - buildUri("/api/im/admin/users/search", queryWithSize("keyword", keyword, size)), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public StatsView stats() { - ApiResponse response = request( - "GET", - buildUri("/api/im/admin/stats", appQuery()), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public List searchGroups(String keyword, int size) { - ApiResponse> response = request( - "GET", - buildUri("/api/im/admin/groups/search", queryWithSize("keyword", keyword, size)), + buildUri("/api/im/accounts/search", queryWithSize("keyword", keyword, size)), null, authorizedHeaders(), new TypeReference<>() {} @@ -372,150 +419,6 @@ public final class XuqmImServerSdk { return readPayload(envelope, MessageReadCallbackPayload.class); } - public PageResult searchMessages( - String keyword, - String chatType, - String msgType, - LocalDateTime startTime, - LocalDateTime endTime, - int page, - int size) { - Map query = appQuery(); - if (keyword != null) { - query.put("keyword", keyword); - } - if (chatType != null) { - query.put("chatType", chatType); - } - if (msgType != null) { - query.put("msgType", msgType); - } - if (startTime != null) { - query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString()); - } - if (endTime != null) { - query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString()); - } - query.put("page", String.valueOf(page)); - query.put("size", String.valueOf(size)); - ApiResponse> response = request( - "GET", - buildUri("/api/im/admin/messages/search", query), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public List listWebhookConfigs() { - ApiResponse> response = request( - "GET", - buildUri("/api/im/admin/webhooks", appQuery()), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public WebhookConfigView createWebhookConfig(String url, String secret, Boolean enabled) { - ApiResponse response = request( - "POST", - buildUri("/api/im/admin/webhooks", appQuery()), - new WebhookConfigRequest(url, secret, enabled), - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public WebhookConfigView updateWebhookConfig(String id, String url, String secret, Boolean enabled) { - ApiResponse response = request( - "PUT", - buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()), - new WebhookConfigRequest(url, secret, enabled), - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public void deleteWebhookConfig(String id) { - request( - "DELETE", - buildUri("/api/im/admin/webhooks/" + encode(id), appQuery()), - null, - authorizedHeaders(), - new TypeReference>() {} - ); - } - - public List listKeywordFilters() { - ApiResponse> response = request( - "GET", - buildUri("/api/im/admin/keyword-filters", appQuery()), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public KeywordFilterView createKeywordFilter(String pattern, String replacement, String action, Boolean enabled) { - ApiResponse response = request( - "POST", - buildUri("/api/im/admin/keyword-filters", appQuery()), - new KeywordFilterRequest(pattern, replacement, action, enabled), - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public KeywordFilterView updateKeywordFilter(String id, String pattern, String replacement, String action, Boolean enabled) { - ApiResponse response = request( - "PUT", - buildUri("/api/im/admin/keyword-filters/" + encode(id), appQuery()), - new KeywordFilterRequest(pattern, replacement, action, enabled), - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public void deleteKeywordFilter(String id) { - request( - "DELETE", - buildUri("/api/im/admin/keyword-filters/" + encode(id), appQuery()), - null, - authorizedHeaders(), - new TypeReference>() {} - ); - } - - public GlobalMuteView getGlobalMute() { - ApiResponse response = request( - "GET", - buildUri("/api/im/admin/global-mute", appQuery()), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public GlobalMuteView setGlobalMute(boolean enabled) { - ApiResponse response = request( - "PUT", - buildUri("/api/im/admin/global-mute", Map.of("appId", appId, "enabled", String.valueOf(enabled))), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - public void registerPushToken(String userId, String vendor, String token) { request( "POST", @@ -939,6 +842,17 @@ public final class XuqmImServerSdk { return response.data(); } + public List searchGroups(String keyword, int size) { + ApiResponse> response = request( + "GET", + buildUri("/api/im/groups/search", queryWithSize("keyword", keyword, size)), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public GroupView getGroup(String groupId) { ApiResponse response = request( "GET", @@ -950,6 +864,28 @@ public final class XuqmImServerSdk { return response.data(); } + public List listGroupMembers(String groupId) { + ApiResponse> response = request( + "GET", + buildUri("/api/im/groups/" + encode(groupId) + "/members", appQuery()), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List searchGroupMembers(String groupId, String keyword, int size) { + ApiResponse> response = request( + "GET", + buildUri("/api/im/groups/" + encode(groupId) + "/members/search", queryWithSize("keyword", keyword, size)), + null, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public GroupView createGroup(String name, List memberIds, String groupType) { ApiResponse response = request( "POST", @@ -1183,63 +1119,6 @@ public final class XuqmImServerSdk { ); } - public PageResult queryAdminMessages( - String userA, - String userB, - String msgType, - String keyword, - LocalDateTime startTime, - LocalDateTime endTime, - int page, - int size) { - Map query = appQuery(); - query.put("userA", userA); - query.put("userB", userB); - if (msgType != null) { - query.put("msgType", msgType); - } - if (keyword != null) { - query.put("keyword", keyword); - } - if (startTime != null) { - query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString()); - } - if (endTime != null) { - query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString()); - } - query.put("page", String.valueOf(page)); - query.put("size", String.valueOf(size)); - ApiResponse> response = request( - "GET", - buildUri("/api/im/admin/messages", query), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public ImMessage revokeAdminMessage(String messageId) { - ApiResponse response = request( - "POST", - buildUri("/api/im/admin/messages/" + encode(messageId) + "/revoke", appQuery()), - null, - authorizedHeaders(), - new TypeReference<>() {} - ); - return response.data(); - } - - public void dismissAdminGroup(String groupId) { - request( - "DELETE", - buildUri("/api/im/admin/groups/" + encode(groupId), appQuery()), - null, - authorizedHeaders(), - new TypeReference>() {} - ); - } - private static String sha256Hex(String value) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); @@ -1555,6 +1434,37 @@ public final class XuqmImServerSdk { return query; } + private Map messageSearchQuery( + String chatType, + String msgType, + String keyword, + LocalDateTime startTime, + LocalDateTime endTime, + int page, + int size + ) { + Map query = new LinkedHashMap<>(); + query.put("appId", appId); + if (chatType != null && !chatType.isBlank()) { + query.put("chatType", chatType); + } + if (msgType != null && !msgType.isBlank()) { + query.put("msgType", msgType); + } + if (keyword != null && !keyword.isBlank()) { + query.put("keyword", keyword); + } + if (startTime != null) { + query.put("startTime", startTime.toInstant(ZoneOffset.UTC).toString()); + } + if (endTime != null) { + query.put("endTime", endTime.toInstant(ZoneOffset.UTC).toString()); + } + query.put("page", String.valueOf(page)); + query.put("size", String.valueOf(size)); + return query; + } + private String[] flatten(Map headers) { String[] pairs = new String[headers.size() * 2]; int index = 0; @@ -1642,6 +1552,14 @@ public final class XuqmImServerSdk { LocalDateTime createdAt ) {} + public record ImportAccountRequest( + String userId, + String nickname, + String avatar, + String gender, + String status + ) {} + public record FriendLinkView( Long id, String appId, @@ -1702,40 +1620,6 @@ public final class XuqmImServerSdk { LocalDateTime reviewedAt ) {} - public record StatsView( - long totalMessages, - long totalUsers, - long totalGroups, - long todayMessages - ) {} - - public record WebhookConfigView( - String id, - String appId, - String url, - String secret, - boolean enabled, - Long createdAt - ) {} - - public record KeywordFilterView( - String id, - String appId, - String pattern, - String replacement, - String action, - boolean enabled, - Long createdAt - ) {} - - public record GlobalMuteView( - String id, - String appId, - boolean enabled, - Long createdAt, - Long updatedAt - ) {} - public record WebhookCallbackEnvelope( String callbackId, String callbackType, @@ -1820,6 +1704,20 @@ public final class XuqmImServerSdk { } } + public record MessageSearchQuery( + String chatType, + String msgType, + String keyword, + LocalDateTime startTime, + LocalDateTime endTime, + Integer page, + Integer size + ) { + public MessageSearchQuery() { + this(null, null, null, null, null, null, null); + } + } + public record PageResult( List content, long totalElements, @@ -1862,6 +1760,15 @@ public final class XuqmImServerSdk { Boolean enabled ) {} + public record WebhookConfigView( + String id, + String appId, + String url, + String secret, + boolean enabled, + LocalDateTime createdAt + ) {} + public record KeywordFilterRequest( String pattern, String replacement, diff --git a/im-service/src/main/java/com/xuqm/im/controller/AccountController.java b/im-service/src/main/java/com/xuqm/im/controller/AccountController.java index adc7867..d68e4f6 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/AccountController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/AccountController.java @@ -12,8 +12,12 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.RequestBody; import java.util.List; +import java.util.Map; import java.util.Objects; @RestController @@ -56,4 +60,47 @@ public class AccountController { @RequestParam(defaultValue = "20") int size) { return ResponseEntity.ok(ApiResponse.success(accountService.searchAccounts(appId, keyword, size))); } + + @PostMapping("/import") + public ResponseEntity> importAccount( + @RequestParam String appId, + @RequestBody ImportAccountRequest req) { + return ResponseEntity.ok(ApiResponse.success( + accountService.importAccount(appId, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status()))); + } + + @PostMapping("/import/batch") + public ResponseEntity>> importAccounts( + @RequestParam String appId, + @RequestBody List req) { + return ResponseEntity.ok(ApiResponse.success(accountService.importAccounts( + appId, + req == null ? List.of() : req.stream() + .map(item -> new ImAccountService.ImportAccountRequest( + item.userId(), item.nickname(), item.avatar(), item.gender(), item.status())) + .toList()))); + } + + @DeleteMapping("/{userId}") + public ResponseEntity> delete( + @RequestParam String appId, + @PathVariable String userId) { + accountService.deleteAccount(appId, userId); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @GetMapping("/{userId}/exists") + public ResponseEntity>> exists( + @RequestParam String appId, + @PathVariable String userId) { + return ResponseEntity.ok(ApiResponse.success(Map.of("exists", accountService.exists(appId, userId)))); + } + + public record ImportAccountRequest( + String userId, + String nickname, + String avatar, + ImAccountEntity.Gender gender, + ImAccountEntity.Status status + ) {} } diff --git a/im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java b/im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java index 9665794..795d7b7 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/FriendRequestController.java @@ -8,6 +8,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.PathVariable; diff --git a/im-service/src/main/java/com/xuqm/im/controller/GroupController.java b/im-service/src/main/java/com/xuqm/im/controller/GroupController.java index b09ee4c..84f877f 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/GroupController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/GroupController.java @@ -1,6 +1,7 @@ package com.xuqm.im.controller; import com.xuqm.common.model.ApiResponse; +import com.xuqm.im.entity.ImAccountEntity; import com.xuqm.im.entity.ImGroupEntity; import com.xuqm.im.entity.ImGroupJoinRequestEntity; import com.xuqm.im.service.ImGroupService; @@ -58,6 +59,24 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.searchGroups(appId, keyword, size))); } + @GetMapping("/{groupId}/members") + public ResponseEntity>> listMembers( + @PathVariable String groupId, + @AuthenticationPrincipal String userId, + @RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(groupService.listMembers(appId, groupId, userId))); + } + + @GetMapping("/{groupId}/members/search") + public ResponseEntity>> searchMembers( + @PathVariable String groupId, + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestParam String keyword, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success(groupService.searchMembers(appId, groupId, userId, keyword, size))); + } + @PutMapping("/{groupId}") public ResponseEntity> update( @PathVariable String groupId, 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 19936bb..2ed278c 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 @@ -15,10 +15,12 @@ import com.xuqm.im.service.ImGroupService; import com.xuqm.im.service.GlobalMuteService; import com.xuqm.im.service.KeywordFilterService; import com.xuqm.im.service.MessageService; +import com.xuqm.im.service.OperationLogService; import com.xuqm.im.service.WebhookConfigService; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @@ -38,6 +40,7 @@ public class ImAdminController { private final WebhookConfigService webhookConfigService; private final KeywordFilterService keywordFilterService; private final GlobalMuteService globalMuteService; + private final OperationLogService operationLogService; public ImAdminController(ImAccountRepository accountRepository, ImGroupRepository groupRepository, @@ -47,7 +50,8 @@ public class ImAdminController { MessageService messageService, WebhookConfigService webhookConfigService, KeywordFilterService keywordFilterService, - GlobalMuteService globalMuteService) { + GlobalMuteService globalMuteService, + OperationLogService operationLogService) { this.accountRepository = accountRepository; this.groupRepository = groupRepository; this.messageRepository = messageRepository; @@ -57,6 +61,7 @@ public class ImAdminController { this.webhookConfigService = webhookConfigService; this.keywordFilterService = keywordFilterService; this.globalMuteService = globalMuteService; + this.operationLogService = operationLogService; } /** List all registered IM users for the given appId. */ @@ -74,11 +79,14 @@ public class ImAdminController { public ResponseEntity> updateUserStatus( @RequestParam String appId, @PathVariable String userId, + @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())); - return ResponseEntity.ok(ApiResponse.success(accountRepository.save(account))); + ImAccountEntity saved = accountRepository.save(account); + operationLogService.record(appId, operatorId, "UPDATE_USER_STATUS", "ACCOUNT", userId, body.get("status")); + return ResponseEntity.ok(ApiResponse.success(saved)); } /** List all groups for the given appId. */ @@ -91,10 +99,12 @@ public class ImAdminController { @PostMapping("/users") public ResponseEntity> registerUser( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestBody RegisterUserRequest req) { accountService.loginOrRegister(appId, req.userId(), req.nickname(), req.avatar()); ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, req.userId()) .orElseThrow(); + operationLogService.record(appId, operatorId, "REGISTER_USER", "ACCOUNT", req.userId(), req.nickname()); return ResponseEntity.ok(ApiResponse.success(account)); } @@ -102,18 +112,22 @@ public class ImAdminController { @PostMapping("/groups") public ResponseEntity> createGroup( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestBody CreateGroupRequest req) { - return ResponseEntity.ok(ApiResponse.success( - groupService.create(appId, req.name(), req.creatorId(), req.memberIds(), "WORK"))); + ImGroupEntity group = groupService.create(appId, req.name(), req.creatorId(), req.memberIds(), "WORK"); + operationLogService.record(appId, operatorId, "CREATE_GROUP", "GROUP", group.getId(), group.getName()); + return ResponseEntity.ok(ApiResponse.success(group)); } /** Fuzzy search users by userId or nickname. */ @GetMapping("/users/search") public ResponseEntity>> searchUsers( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestParam String keyword, @RequestParam(defaultValue = "20") int size) { List results = accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size)); + operationLogService.record(appId, operatorId, "SEARCH_USERS", "ACCOUNT", null, keyword); return ResponseEntity.ok(ApiResponse.success(results)); } @@ -121,9 +135,11 @@ public class ImAdminController { @GetMapping("/groups/search") public ResponseEntity>> searchGroups( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestParam String keyword, @RequestParam(defaultValue = "20") int size) { List results = groupRepository.searchByKeyword(appId, keyword, PageRequest.of(0, size)); + operationLogService.record(appId, operatorId, "SEARCH_GROUPS", "GROUP", null, keyword); return ResponseEntity.ok(ApiResponse.success(results)); } @@ -131,6 +147,7 @@ public class ImAdminController { @GetMapping("/messages/search") public ResponseEntity>> searchMessages( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestParam(required = false) ImMessageEntity.ChatType chatType, @RequestParam(required = false) ImMessageEntity.MsgType msgType, @RequestParam(required = false) String keyword, @@ -138,6 +155,7 @@ public class ImAdminController { @RequestParam(required = false) LocalDateTime endTime, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { + operationLogService.record(appId, operatorId, "SEARCH_MESSAGES", "MESSAGE", null, keyword); return ResponseEntity.ok(ApiResponse.success( messageRepository.searchByKeyword( appId, chatType, msgType, keyword, startTime, endTime, PageRequest.of(page, size)))); @@ -145,11 +163,14 @@ public class ImAdminController { /** Message statistics for the given appId. */ @GetMapping("/stats") - public ResponseEntity>> stats(@RequestParam String appId) { + public ResponseEntity>> stats( + @RequestParam String appId, + @AuthenticationPrincipal String operatorId) { long totalMessages = messageRepository.countByAppId(appId); long totalUsers = accountRepository.countByAppId(appId); long totalGroups = groupRepository.countByAppId(appId); long todayMessages = messageRepository.countTodayByAppId(appId); + operationLogService.record(appId, operatorId, "VIEW_STATS", "STATS", null, "summary"); return ResponseEntity.ok(ApiResponse.success(Map.of( "totalMessages", totalMessages, @@ -163,6 +184,7 @@ public class ImAdminController { @GetMapping("/messages") public ResponseEntity>> history( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestParam String userA, @RequestParam String userB, @RequestParam(required = false) com.xuqm.im.entity.ImMessageEntity.MsgType msgType, @@ -171,6 +193,7 @@ public class ImAdminController { @RequestParam(required = false) LocalDateTime endTime, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { + operationLogService.record(appId, operatorId, "VIEW_HISTORY", "MESSAGE", userA + "," + userB, keyword); return ResponseEntity.ok(ApiResponse.success( messageRepository.findSingleConversationFiltered( appId, userA, userB, msgType, keyword, startTime, endTime, PageRequest.of(page, size)))); @@ -180,14 +203,21 @@ public class ImAdminController { @PostMapping("/messages/{messageId}/revoke") public ResponseEntity> adminRevoke( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @PathVariable String messageId) { - return ResponseEntity.ok(ApiResponse.success(messageService.adminRevoke(appId, messageId))); + ImMessageEntity revoked = messageService.adminRevoke(appId, messageId); + operationLogService.record(appId, operatorId, "ADMIN_REVOKE_MESSAGE", "MESSAGE", messageId, null); + return ResponseEntity.ok(ApiResponse.success(revoked)); } /** Admin force dismisses a group. */ @DeleteMapping("/groups/{groupId}") - public ResponseEntity> adminDismissGroup(@PathVariable String groupId) { + public ResponseEntity> adminDismissGroup( + @RequestParam String appId, + @AuthenticationPrincipal String operatorId, + @PathVariable String groupId) { groupService.adminDismiss(groupId); + operationLogService.record(appId, operatorId, "ADMIN_DISMISS_GROUP", "GROUP", groupId, null); return ResponseEntity.ok(ApiResponse.ok()); } @@ -199,25 +229,31 @@ public class ImAdminController { @PostMapping("/webhooks") public ResponseEntity> createWebhook( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestBody WebhookConfigRequest req) { - return ResponseEntity.ok(ApiResponse.success( - webhookConfigService.create(appId, req.url(), req.secret(), req.enabled()))); + WebhookConfigEntity saved = webhookConfigService.create(appId, req.url(), req.secret(), req.enabled()); + operationLogService.record(appId, operatorId, "CREATE_WEBHOOK", "WEBHOOK", saved.getId(), req.url()); + return ResponseEntity.ok(ApiResponse.success(saved)); } @PutMapping("/webhooks/{id}") public ResponseEntity> updateWebhook( @RequestParam String appId, @PathVariable String id, + @AuthenticationPrincipal String operatorId, @RequestBody WebhookConfigRequest req) { - return ResponseEntity.ok(ApiResponse.success( - webhookConfigService.update(appId, id, req.url(), req.secret(), req.enabled()))); + WebhookConfigEntity saved = webhookConfigService.update(appId, id, req.url(), req.secret(), req.enabled()); + operationLogService.record(appId, operatorId, "UPDATE_WEBHOOK", "WEBHOOK", id, req.url()); + return ResponseEntity.ok(ApiResponse.success(saved)); } @DeleteMapping("/webhooks/{id}") public ResponseEntity> deleteWebhook( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @PathVariable String id) { webhookConfigService.delete(appId, id); + operationLogService.record(appId, operatorId, "DELETE_WEBHOOK", "WEBHOOK", id, null); return ResponseEntity.ok(ApiResponse.ok()); } @@ -229,25 +265,31 @@ public class ImAdminController { @PostMapping("/keyword-filters") public ResponseEntity> createKeywordFilter( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestBody KeywordFilterRequest req) { - return ResponseEntity.ok(ApiResponse.success( - keywordFilterService.add(appId, req.pattern(), req.replacement(), req.action()))); + KeywordFilterEntity saved = keywordFilterService.add(appId, req.pattern(), req.replacement(), req.action()); + operationLogService.record(appId, operatorId, "CREATE_KEYWORD_FILTER", "KEYWORD_FILTER", saved.getId(), req.pattern()); + return ResponseEntity.ok(ApiResponse.success(saved)); } @PutMapping("/keyword-filters/{id}") public ResponseEntity> updateKeywordFilter( @RequestParam String appId, @PathVariable String id, + @AuthenticationPrincipal String operatorId, @RequestBody KeywordFilterRequest req) { - return ResponseEntity.ok(ApiResponse.success( - keywordFilterService.update(appId, id, req.pattern(), req.replacement(), req.action(), req.enabled()))); + KeywordFilterEntity saved = keywordFilterService.update(appId, id, req.pattern(), req.replacement(), req.action(), req.enabled()); + operationLogService.record(appId, operatorId, "UPDATE_KEYWORD_FILTER", "KEYWORD_FILTER", id, req.pattern()); + return ResponseEntity.ok(ApiResponse.success(saved)); } @DeleteMapping("/keyword-filters/{id}") public ResponseEntity> deleteKeywordFilter( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @PathVariable String id) { keywordFilterService.delete(appId, id); + operationLogService.record(appId, operatorId, "DELETE_KEYWORD_FILTER", "KEYWORD_FILTER", id, null); return ResponseEntity.ok(ApiResponse.ok()); } @@ -259,8 +301,20 @@ public class ImAdminController { @PutMapping("/global-mute") public ResponseEntity> setGlobalMute( @RequestParam String appId, + @AuthenticationPrincipal String operatorId, @RequestParam boolean enabled) { - return ResponseEntity.ok(ApiResponse.success(globalMuteService.setEnabled(appId, enabled))); + ImGlobalMuteEntity saved = globalMuteService.setEnabled(appId, enabled); + operationLogService.record(appId, operatorId, "SET_GLOBAL_MUTE", "GLOBAL_MUTE", saved.getId(), String.valueOf(enabled)); + return ResponseEntity.ok(ApiResponse.success(saved)); + } + + @GetMapping("/operation-logs") + public ResponseEntity>> operationLogs( + @RequestParam String appId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success( + operationLogService.list(appId, PageRequest.of(page, size)))); } public record RegisterUserRequest(String userId, String nickname, String avatar) {} diff --git a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java index 26913ec..bf23d61 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/MessageController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/MessageController.java @@ -15,8 +15,8 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; 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.RestController; +import org.springframework.web.bind.annotation.PutMapping; import java.time.LocalDateTime; diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImOperationLogEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImOperationLogEntity.java new file mode 100644 index 0000000..62dc7bd --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/entity/ImOperationLogEntity.java @@ -0,0 +1,62 @@ +package com.xuqm.im.entity; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.xuqm.im.json.EpochMillisLocalDateTimeSerializer; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "im_operation_log", indexes = { + @Index(name = "idx_op_log_app_time", columnList = "appId,createdAt"), + @Index(name = "idx_op_log_app_operator", columnList = "appId,operatorId") +}) +public class ImOperationLogEntity extends BaseIdEntity { + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 128) + private String operatorId; + + @Column(nullable = false, length = 64) + private String action; + + @Column(nullable = false, length = 64) + private String resourceType; + + @Column(length = 128) + private String resourceId; + + @Column(columnDefinition = "TEXT") + private String detail; + + @Column(nullable = false) + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + private LocalDateTime createdAt; + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getOperatorId() { return operatorId; } + public void setOperatorId(String operatorId) { this.operatorId = operatorId; } + + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + + public String getResourceType() { return resourceType; } + public void setResourceType(String resourceType) { this.resourceType = resourceType; } + + public String getResourceId() { return resourceId; } + public void setResourceId(String resourceId) { this.resourceId = resourceId; } + + public String getDetail() { return detail; } + public void setDetail(String detail) { this.detail = detail; } + + @JsonSerialize(using = EpochMillisLocalDateTimeSerializer.class) + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/im-service/src/main/java/com/xuqm/im/repository/ImOperationLogRepository.java b/im-service/src/main/java/com/xuqm/im/repository/ImOperationLogRepository.java new file mode 100644 index 0000000..27a60d8 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/repository/ImOperationLogRepository.java @@ -0,0 +1,10 @@ +package com.xuqm.im.repository; + +import com.xuqm.im.entity.ImOperationLogEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ImOperationLogRepository extends JpaRepository { + Page findByAppIdOrderByCreatedAtDesc(String appId, Pageable pageable); +} diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java index 2aaa6c2..8a85d89 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java @@ -90,7 +90,50 @@ public class ImAccountService { return accountRepository.save(account); } + public ImAccountEntity importAccount(String appId, String userId, String nickname, + String avatar, ImAccountEntity.Gender gender, + ImAccountEntity.Status status) { + ImAccountEntity account = accountRepository.findByAppIdAndUserId(appId, userId) + .orElseGet(() -> { + ImAccountEntity entity = new ImAccountEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setUserId(userId); + entity.setCreatedAt(LocalDateTime.now()); + return entity; + }); + account.setNickname(nickname); + account.setAvatar(avatar); + account.setGender(gender == null ? ImAccountEntity.Gender.UNKNOWN : gender); + account.setStatus(status == null ? ImAccountEntity.Status.ACTIVE : status); + return accountRepository.save(account); + } + + public List importAccounts(String appId, List requests) { + return requests == null ? List.of() : requests.stream() + .filter(req -> req != null && req.userId() != null && !req.userId().isBlank()) + .map(req -> importAccount(appId, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status())) + .toList(); + } + + public void deleteAccount(String appId, String userId) { + accountRepository.findByAppIdAndUserId(appId, userId) + .ifPresent(accountRepository::delete); + } + + public boolean exists(String appId, String userId) { + return accountRepository.existsByAppIdAndUserId(appId, userId); + } + public List searchAccounts(String appId, String keyword, int size) { return accountRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1))); } + + public record ImportAccountRequest( + String userId, + String nickname, + String avatar, + ImAccountEntity.Gender gender, + ImAccountEntity.Status status + ) {} } 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 9009b09..a228e68 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 @@ -9,7 +9,9 @@ import com.xuqm.im.entity.ImGroupEntity; 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.repository.ImGroupJoinRequestRepository; +import com.xuqm.im.repository.ImAccountRepository; import com.xuqm.im.repository.ImGroupRepository; import com.xuqm.im.repository.ImGroupMuteRepository; import com.xuqm.im.repository.ImMessageRepository; @@ -31,6 +33,7 @@ public class ImGroupService { private final ImGroupMuteRepository muteRepository; private final ImGroupJoinRequestRepository joinRequestRepository; private final ImMessageRepository messageRepository; + private final ImAccountRepository accountRepository; private final ImClusterPublisher clusterPublisher; private final ObjectMapper objectMapper; @@ -38,12 +41,14 @@ public class ImGroupService { ImGroupMuteRepository muteRepository, ImGroupJoinRequestRepository joinRequestRepository, ImMessageRepository messageRepository, + ImAccountRepository accountRepository, ImClusterPublisher clusterPublisher, ObjectMapper objectMapper) { this.groupRepository = groupRepository; this.muteRepository = muteRepository; this.joinRequestRepository = joinRequestRepository; this.messageRepository = messageRepository; + this.accountRepository = accountRepository; this.clusterPublisher = clusterPublisher; this.objectMapper = objectMapper; } @@ -249,6 +254,24 @@ public class ImGroupService { return groupRepository.searchByKeyword(appId, keyword, PageRequest.of(0, Math.max(size, 1))); } + public List listMembers(String appId, String groupId, String requesterId) { + ImGroupEntity group = get(groupId, requesterId); + return resolveMembers(appId, memberIds(group)); + } + + public List searchMembers(String appId, String groupId, String requesterId, String keyword, int size) { + ImGroupEntity group = get(groupId, requesterId); + 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 ImGroupJoinRequestEntity sendJoinRequest(String appId, String groupId, String requesterId, String remark) { ImGroupEntity group = get(groupId); @@ -498,4 +521,12 @@ public class ImGroupService { private List unique(List values) { return values == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(values)); } + + private List resolveMembers(String appId, List ids) { + List members = new ArrayList<>(); + for (String userId : ids == null ? List.of() : ids) { + accountRepository.findByAppIdAndUserId(appId, userId).ifPresent(members::add); + } + return members; + } } 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 b810ba0..b05d516 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 @@ -456,15 +456,17 @@ public class MessageService { private String buildPushPayload(ImMessageEntity message) { try { - return objectMapper.writeValueAsString(Map.of( - "messageId", message.getId(), - "appId", message.getAppId(), - "fromUserId", message.getFromUserId(), - "toId", message.getToId(), - "chatType", message.getChatType().name(), - "msgType", message.getMsgType().name(), - "editedAt", message.getEditedAt() == null ? null : message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli() - )); + Map payload = new java.util.LinkedHashMap<>(); + payload.put("messageId", message.getId()); + payload.put("appId", message.getAppId()); + payload.put("fromUserId", message.getFromUserId()); + payload.put("toId", message.getToId()); + payload.put("chatType", message.getChatType().name()); + payload.put("msgType", message.getMsgType().name()); + if (message.getEditedAt() != null) { + payload.put("editedAt", message.getEditedAt().toInstant(ZoneOffset.UTC).toEpochMilli()); + } + return objectMapper.writeValueAsString(payload); } catch (Exception e) { return "{}"; } 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 new file mode 100644 index 0000000..9a3bc78 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/service/OperationLogService.java @@ -0,0 +1,43 @@ +package com.xuqm.im.service; + +import com.xuqm.im.entity.ImOperationLogEntity; +import com.xuqm.im.repository.ImOperationLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class OperationLogService { + + private final ImOperationLogRepository repository; + + public OperationLogService(ImOperationLogRepository repository) { + this.repository = repository; + } + + public ImOperationLogEntity record( + String appId, + String operatorId, + String action, + String resourceType, + String resourceId, + String detail) { + ImOperationLogEntity entity = new ImOperationLogEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setOperatorId(operatorId); + entity.setAction(action); + entity.setResourceType(resourceType); + entity.setResourceId(resourceId); + entity.setDetail(detail); + entity.setCreatedAt(LocalDateTime.now()); + return repository.save(entity); + } + + public Page list(String appId, Pageable pageable) { + return repository.findByAppIdOrderByCreatedAtDesc(appId, pageable); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java b/update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java index 512337e..e154ec4 100644 --- a/update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java +++ b/update-service/src/main/java/com/xuqm/update/UpdateServiceApplication.java @@ -3,8 +3,12 @@ package com.xuqm.update; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling +@EnableAsync @ComponentScan(basePackages = {"com.xuqm.update", "com.xuqm.common"}) public class UpdateServiceApplication { public static void main(String[] args) { diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java new file mode 100644 index 0000000..2675efb --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java @@ -0,0 +1,134 @@ +package com.xuqm.update.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.update.entity.AppStoreConfigEntity; +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.service.AppStoreService; +import com.xuqm.update.service.StoreSubmissionService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * App store distribution channel management. + * + * Store configs (credentials) are managed here and fetched by the release script. + * Actual APK/IPA submission to stores is performed by the release script on the + * developer's machine; this controller records the outcome and drives notifications. + */ +@RestController +@RequestMapping("/api/v1/updates/store") +public class AppStoreController { + + private final AppStoreService storeService; + private final StoreSubmissionService submissionService; + + public AppStoreController(AppStoreService storeService, StoreSubmissionService submissionService) { + this.storeService = storeService; + this.submissionService = submissionService; + } + + // ── Store credential config ────────────────────────────────────────────── + + @GetMapping("/configs") + public ResponseEntity>> getConfigs(@RequestParam String appId) { + return ResponseEntity.ok(ApiResponse.success(storeService.getConfigs(appId))); + } + + /** + * Upsert credentials for one store channel. + * + * Body: { "configJson": "{\"clientId\":\"...\",\"clientSecret\":\"...\"}", "enabled": true } + */ + @PutMapping("/configs/{storeType}") + public ResponseEntity> saveConfig( + @RequestParam String appId, + @PathVariable AppStoreConfigEntity.StoreType storeType, + @RequestBody Map body) { + + String configJson = body.get("configJson") instanceof String s ? s : null; + boolean enabled = !Boolean.FALSE.equals(body.get("enabled")); + return ResponseEntity.ok(ApiResponse.success( + storeService.saveConfig(appId, storeType, configJson, enabled))); + } + + @DeleteMapping("/configs/{storeType}") + public ResponseEntity> deleteConfig( + @RequestParam String appId, + @PathVariable AppStoreConfigEntity.StoreType storeType) { + storeService.deleteConfig(appId, storeType); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** + * Returns enabled store credentials for the given appId. + * Called by the release script so it can submit to stores without storing secrets locally. + */ + @GetMapping("/credentials") + public ResponseEntity>> getCredentials(@RequestParam String appId) throws Exception { + return ResponseEntity.ok(ApiResponse.success(storeService.getStoreCredentials(appId))); + } + + // ── Version store submission ────────────────────────────────────────────── + + /** + * Mark a version as submitted to specific stores and initialise review tracking. + * + * Body: { "storeTypes": ["HUAWEI", "MI", "OPPO"] } + * + * The release script calls this after successfully uploading to each store's API. + */ + @PostMapping("/app/{versionId}/submit") + public ResponseEntity> markSubmitted( + @PathVariable String versionId, + @RequestBody Map body) throws Exception { + + @SuppressWarnings("unchecked") + List storeTypes = (List) body.get("storeTypes"); + return ResponseEntity.ok(ApiResponse.success(storeService.markSubmitted(versionId, storeTypes))); + } + + // ── Execute submission (server calls store APIs) ───────────────────────── + + /** + * Trigger server-side submission to app stores. + * The server fetches the local APK, calls each store's API, and updates review status. + * Runs asynchronously — returns immediately. + * + * Body: { "storeTypes": ["HUAWEI", "MI"] } — optional, overrides version's saved targets. + */ + @PostMapping("/app/{versionId}/execute-submit") + public ResponseEntity> executeSubmit( + @PathVariable String versionId, + @RequestBody(required = false) Map body) throws Exception { + + @SuppressWarnings("unchecked") + List storeTypes = body != null ? (List) body.get("storeTypes") : null; + AppVersionEntity v = storeService.markSubmitted(versionId, + storeTypes != null ? storeTypes : List.of()); + submissionService.executeSubmitAsync(versionId); + return ResponseEntity.ok(ApiResponse.success(v)); + } + + // ── Review status update (from store webhook or manual) ────────────────── + + /** + * Update the review state for a single store channel. + * Stores may call this via webhook, or developers can call it manually. + * + * Body: { "storeType": "HUAWEI", "state": "APPROVED" } + */ + @PostMapping("/app/{versionId}/review") + public ResponseEntity> updateReview( + @PathVariable String versionId, + @RequestBody Map body) throws Exception { + + String storeType = (String) body.get("storeType"); + AppVersionEntity.StoreReviewState state = + AppVersionEntity.StoreReviewState.valueOf((String) body.get("state")); + return ResponseEntity.ok(ApiResponse.success( + storeService.updateStoreReview(versionId, storeType, state))); + } +} 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 408507b..271e22e 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 @@ -61,7 +61,12 @@ public class AppVersionController { @RequestParam int versionCode, @RequestParam(required = false) String changeLog, @RequestParam(defaultValue = "false") boolean forceUpdate, - @RequestParam(required = false) MultipartFile apkFile) throws Exception { + @RequestParam(required = false) MultipartFile apkFile, + @RequestParam(required = false) String scheduledPublishAt, + @RequestParam(required = false) String webhookUrl, + @RequestParam(required = false) String storeSubmitTargets, + @RequestParam(defaultValue = "false") boolean autoPublishAfterReview, + @RequestParam(required = false) String packageName) throws Exception { AppVersionEntity entity = new AppVersionEntity(); entity.setId(UUID.randomUUID().toString()); @@ -74,6 +79,13 @@ public class AppVersionController { entity.setForceUpdate(forceUpdate); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); entity.setCreatedAt(LocalDateTime.now()); + if (scheduledPublishAt != null && !scheduledPublishAt.isBlank()) { + entity.setScheduledPublishAt(LocalDateTime.parse(scheduledPublishAt)); + } + entity.setWebhookUrl(webhookUrl); + entity.setStoreSubmitTargets(storeSubmitTargets); + entity.setAutoPublishAfterReview(autoPublishAfterReview); + entity.setPackageName(packageName); return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); } diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java new file mode 100644 index 0000000..92d7cdd --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java @@ -0,0 +1,71 @@ +package com.xuqm.update.entity; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "update_store_config", uniqueConstraints = { + @UniqueConstraint(columnNames = {"appId", "storeType"}) +}) +public class AppStoreConfigEntity { + + /** + * Supported distribution channels. + * Android: HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY + * iOS: APP_STORE + */ + public enum StoreType { + HUAWEI, MI, OPPO, VIVO, HONOR, GOOGLE_PLAY, APP_STORE; + + public boolean isAndroid() { + return this != APP_STORE; + } + } + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private StoreType storeType; + + /** + * Store-specific credentials stored as a flat JSON object. + * + * HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."} + * MI: {"username":"...","privateKey":"..."} + * OPPO: {"clientId":"...","clientSecret":"..."} + * VIVO: {"accessKey":"...","accessSecret":"..."} + * GOOGLE_PLAY: {"serviceAccountJson":"..."} + * APP_STORE: {"teamId":"...","keyId":"...","privateKey":"...","bundleId":"..."} + */ + @Column(columnDefinition = "TEXT") + private String configJson; + + @Column(nullable = false) + private boolean enabled = true; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public StoreType getStoreType() { return storeType; } + public void setStoreType(StoreType storeType) { this.storeType = storeType; } + + public String getConfigJson() { return configJson; } + public void setConfigJson(String configJson) { this.configJson = configJson; } + + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index 00a9588..7f26fc7 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -14,6 +14,8 @@ public class AppVersionEntity { public enum Platform { ANDROID, IOS } public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED } + /** Per-store review state used in storeReviewStatus JSON values. */ + public enum StoreReviewState { PENDING, UNDER_REVIEW, APPROVED, REJECTED } @Id private String id; @@ -56,6 +58,35 @@ public class AppVersionEntity { @Column(nullable = false) private int grayPercent = 0; + /** Optional: publish automatically at this UTC time. Null means manual publish. */ + private LocalDateTime scheduledPublishAt; + + /** + * JSON array of StoreType names to submit to, e.g. ["HUAWEI","MI","OPPO"]. + * Null or empty means no store submission. + */ + @Column(columnDefinition = "TEXT") + private String storeSubmitTargets; + + /** + * JSON map of StoreType -> StoreReviewState, e.g. {"HUAWEI":"UNDER_REVIEW","MI":"APPROVED"}. + * Updated by the store webhook endpoint. + */ + @Column(columnDefinition = "TEXT") + private String storeReviewStatus; + + /** When true, publishStatus flips to PUBLISHED once every targeted store reaches APPROVED. */ + @Column(nullable = false) + private boolean autoPublishAfterReview = false; + + /** Webhook URL that receives review-status change notifications from this service. */ + @Column(length = 512) + private String webhookUrl; + + /** App package name / bundle identifier, e.g. com.example.myapp */ + @Column(length = 256) + private String packageName; + @Column(nullable = false) private LocalDateTime createdAt; @@ -98,6 +129,24 @@ public class AppVersionEntity { public int getGrayPercent() { return grayPercent; } public void setGrayPercent(int grayPercent) { this.grayPercent = grayPercent; } + public String getPackageName() { return packageName; } + public void setPackageName(String packageName) { this.packageName = packageName; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getScheduledPublishAt() { return scheduledPublishAt; } + public void setScheduledPublishAt(LocalDateTime scheduledPublishAt) { this.scheduledPublishAt = scheduledPublishAt; } + + public String getStoreSubmitTargets() { return storeSubmitTargets; } + public void setStoreSubmitTargets(String storeSubmitTargets) { this.storeSubmitTargets = storeSubmitTargets; } + + public String getStoreReviewStatus() { return storeReviewStatus; } + public void setStoreReviewStatus(String storeReviewStatus) { this.storeReviewStatus = storeReviewStatus; } + + public boolean isAutoPublishAfterReview() { return autoPublishAfterReview; } + public void setAutoPublishAfterReview(boolean autoPublishAfterReview) { this.autoPublishAfterReview = autoPublishAfterReview; } + + public String getWebhookUrl() { return webhookUrl; } + public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; } } diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppStoreConfigRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppStoreConfigRepository.java new file mode 100644 index 0000000..550c688 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/repository/AppStoreConfigRepository.java @@ -0,0 +1,16 @@ +package com.xuqm.update.repository; + +import com.xuqm.update.entity.AppStoreConfigEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface AppStoreConfigRepository extends JpaRepository { + + List findByAppId(String appId); + + List findByAppIdAndEnabled(String appId, boolean enabled); + + Optional findByAppIdAndStoreType(String appId, AppStoreConfigEntity.StoreType storeType); +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java index f7529b0..814b46f 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java @@ -3,6 +3,7 @@ package com.xuqm.update.repository; import com.xuqm.update.entity.AppVersionEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -11,4 +12,6 @@ public interface AppVersionRepository extends JpaRepository findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc( String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status); + List findByPublishStatusAndScheduledPublishAtBefore( + AppVersionEntity.PublishStatus status, LocalDateTime before); } diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java new file mode 100644 index 0000000..91088a9 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -0,0 +1,182 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.AppStoreConfigEntity; +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.repository.AppStoreConfigRepository; +import com.xuqm.update.repository.AppVersionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.LocalDateTime; +import java.util.*; + +@Service +public class AppStoreService { + + private static final Logger log = LoggerFactory.getLogger(AppStoreService.class); + private static final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient http = HttpClient.newHttpClient(); + + private final AppStoreConfigRepository configRepo; + private final AppVersionRepository versionRepo; + + public AppStoreService(AppStoreConfigRepository configRepo, AppVersionRepository versionRepo) { + this.configRepo = configRepo; + this.versionRepo = versionRepo; + } + + // ── Store config CRUD ──────────────────────────────────────────────────── + + public List getConfigs(String appId) { + return configRepo.findByAppId(appId); + } + + public AppStoreConfigEntity saveConfig(String appId, + AppStoreConfigEntity.StoreType storeType, + String configJson, + boolean enabled) { + AppStoreConfigEntity entity = configRepo + .findByAppIdAndStoreType(appId, storeType) + .orElseGet(AppStoreConfigEntity::new); + + if (entity.getId() == null) { + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setStoreType(storeType); + } + entity.setConfigJson(configJson); + entity.setEnabled(enabled); + entity.setUpdatedAt(LocalDateTime.now()); + return configRepo.save(entity); + } + + public void deleteConfig(String appId, AppStoreConfigEntity.StoreType storeType) { + configRepo.findByAppIdAndStoreType(appId, storeType).ifPresent(configRepo::delete); + } + + // ── Store submission ───────────────────────────────────────────────────── + + /** + * Mark a version as submitted to the given stores and initialise review status to PENDING. + * Actual submission to store APIs must be performed by the release script on the developer + * machine (it has the APK/IPA file). This endpoint records the intent and provides the + * credentials the script needs. + */ + public AppVersionEntity markSubmitted(String versionId, List storeTypes) throws Exception { + AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); + + Map reviewMap = new LinkedHashMap<>(); + for (String store : storeTypes) { + reviewMap.put(store, AppVersionEntity.StoreReviewState.PENDING.name()); + } + v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes)); + v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); + return versionRepo.save(v); + } + + /** + * Fetch enabled store credentials for use by the release script. + * Returns a map of storeType -> configJson (as parsed map, not raw string). + */ + public Map getStoreCredentials(String appId) throws Exception { + List configs = configRepo.findByAppIdAndEnabled(appId, true); + Map result = new LinkedHashMap<>(); + for (AppStoreConfigEntity cfg : configs) { + Map parsed = cfg.getConfigJson() != null + ? mapper.readValue(cfg.getConfigJson(), new TypeReference<>() {}) + : Map.of(); + result.put(cfg.getStoreType().name(), parsed); + } + return result; + } + + // ── Review status webhook ──────────────────────────────────────────────── + + /** + * Called by store webhook or manually to update the review state for a single store. + * When autoPublishAfterReview=true and all targets are APPROVED, flips to PUBLISHED. + */ + public AppVersionEntity updateStoreReview(String versionId, + String storeType, + AppVersionEntity.StoreReviewState state) throws Exception { + AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); + + Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); + reviewMap.put(storeType, state.name()); + v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); + + if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) { + v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + log.info("Auto-published version {} after all stores approved", versionId); + } + + AppVersionEntity saved = versionRepo.save(v); + sendWebhook(saved, storeType, state); + return saved; + } + + // ── Scheduled publish ──────────────────────────────────────────────────── + + @Scheduled(fixedDelay = 60_000) + public void processScheduledPublish() { + List due = versionRepo + .findByPublishStatusAndScheduledPublishAtBefore( + AppVersionEntity.PublishStatus.DRAFT, LocalDateTime.now()); + for (AppVersionEntity v : due) { + v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + versionRepo.save(v); + log.info("Scheduled publish executed for version {}", v.getId()); + } + } + + // ── Webhook delivery ───────────────────────────────────────────────────── + + private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) { + String url = v.getWebhookUrl(); + if (url == null || url.isBlank()) return; + + try { + String body = mapper.writeValueAsString(Map.of( + "event", "store_review_update", + "versionId", v.getId(), + "appId", v.getAppId(), + "versionName", v.getVersionName(), + "storeType", storeType, + "reviewState", state.name(), + "publishStatus", v.getPublishStatus().name(), + "timestamp", System.currentTimeMillis() + )); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + http.sendAsync(request, HttpResponse.BodyHandlers.discarding()) + .exceptionally(e -> { log.warn("Webhook delivery failed: {}", e.getMessage()); return null; }); + } catch (Exception e) { + log.warn("Failed to build webhook payload: {}", e.getMessage()); + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private Map parseReviewStatus(String json) throws Exception { + if (json == null || json.isBlank()) return new LinkedHashMap<>(); + return mapper.readValue(json, new TypeReference>() {}); + } + + private boolean allApproved(AppVersionEntity v, Map reviewMap) throws Exception { + if (v.getStoreSubmitTargets() == null) return false; + List targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {}); + return targets.stream().allMatch(t -> + AppVersionEntity.StoreReviewState.APPROVED.name().equals(reviewMap.get(t))); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java new file mode 100644 index 0000000..94eb7bc --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -0,0 +1,362 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.AppStoreConfigEntity; +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.repository.AppStoreConfigRepository; +import com.xuqm.update.repository.AppVersionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.*; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.io.File; +import java.net.URI; +import java.nio.file.Paths; +import java.util.*; + +/** + * Handles actual APK/IPA submission to vendor app stores on behalf of the tenant. + * + * Each store's implementation follows the same pattern: + * 1. Authenticate with the store API using stored credentials + * 2. Upload the package file + * 3. Submit for review + * 4. Return so {@link AppStoreService} can update review state and send webhook + * + * Reference: XiaoZhuan project channel implementations. + */ +@Service +public class StoreSubmissionService { + + private static final Logger log = LoggerFactory.getLogger(StoreSubmissionService.class); + private static final ObjectMapper mapper = new ObjectMapper(); + private static final String HUAWEI_API = "https://connect-api.cloud.huawei.com"; + + private final RestTemplate rest = new RestTemplate(); + private final AppVersionRepository versionRepo; + private final AppStoreConfigRepository configRepo; + private final AppStoreService storeService; + + @Value("${update.upload-dir:/tmp/xuqm-update}") + private String uploadDir; + + @Value("${update.base-url:https://update.dev.xuqinmin.com}") + private String baseUrl; + + public StoreSubmissionService(AppVersionRepository versionRepo, + AppStoreConfigRepository configRepo, + AppStoreService storeService) { + this.versionRepo = versionRepo; + this.configRepo = configRepo; + this.storeService = storeService; + } + + /** + * Execute submission to all configured target stores for the given version. + * Runs asynchronously so the API endpoint returns immediately. + */ + @Async + public void executeSubmitAsync(String versionId) { + AppVersionEntity v = versionRepo.findById(versionId).orElse(null); + if (v == null) { log.error("Version not found: {}", versionId); return; } + + List targets = parseTargets(v.getStoreSubmitTargets()); + if (targets.isEmpty()) { log.warn("No store targets for version {}", versionId); return; } + + File apkFile = resolveLocalFile(v.getDownloadUrl()); + + for (String storeType : targets) { + try { + AppStoreConfigEntity cfg = configRepo + .findByAppIdAndStoreType(v.getAppId(), AppStoreConfigEntity.StoreType.valueOf(storeType)) + .orElse(null); + if (cfg == null || !cfg.isEnabled()) { + log.warn("Store config not found or disabled for {}/{}", v.getAppId(), storeType); + storeService.updateStoreReview(versionId, storeType, + AppVersionEntity.StoreReviewState.REJECTED); + continue; + } + Map creds = parseConfig(cfg.getConfigJson()); + submitToStore(storeType, v, apkFile, creds); + storeService.updateStoreReview(versionId, storeType, + AppVersionEntity.StoreReviewState.UNDER_REVIEW); + log.info("Submitted version {} to {}", versionId, storeType); + } catch (Exception e) { + log.error("Submission to {} failed for version {}: {}", storeType, versionId, e.getMessage(), e); + try { + storeService.updateStoreReview(versionId, storeType, + AppVersionEntity.StoreReviewState.REJECTED); + } catch (Exception ex) { /* best effort */ } + } + } + } + + // ── Dispatch ───────────────────────────────────────────────────────────── + + private void submitToStore(String storeType, AppVersionEntity v, File file, + Map creds) throws Exception { + switch (storeType) { + case "HUAWEI" -> submitToHuawei(v, file, creds); + case "HONOR" -> submitToHonor(v, file, creds); + case "MI" -> submitToMi(v, file, creds); + case "OPPO" -> submitToOppo(v, file, creds); + case "VIVO" -> submitToVivo(v, file, creds); + case "APP_STORE" -> submitToAppStore(v, file, creds); + case "GOOGLE_PLAY" -> submitToGooglePlay(v, file, creds); + default -> throw new IllegalArgumentException("Unknown store: " + storeType); + } + } + + // ── Huawei AppGallery Connect ───────────────────────────────────────────── + // API docs: https://developer.huawei.com/consumer/cn/doc/AppGallery-connect-Guides/agcapi-getstarted-0000001111845114 + + private void submitToHuawei(AppVersionEntity v, File file, Map creds) throws Exception { + String clientId = require(creds, "clientId", "HUAWEI"); + String clientSecret = require(creds, "clientSecret", "HUAWEI"); + String packageName = v.getPackageName(); + if (packageName == null || packageName.isBlank()) + throw new IllegalStateException("packageName is required for Huawei submission"); + + // 1. OAuth token + String token = huaweiGetToken(clientId, clientSecret); + + // 2. Resolve appId from package name + String hwAppId = huaweiGetAppId(clientId, token, packageName); + + // 3. Request upload URL + Map uploadUrlResp = huaweiGetUploadUrl(clientId, token, hwAppId, file); + @SuppressWarnings("unchecked") + Map urlInfo = (Map) ((List) uploadUrlResp.get("urlList")).get(0); + String uploadUrl = (String) urlInfo.get("url"); + @SuppressWarnings("unchecked") + Map uploadHeaders = (Map) urlInfo.get("headers"); + String objectId = (String) urlInfo.get("objectId"); + + // 4. Upload file + huaweiUploadFile(uploadUrl, uploadHeaders, file); + + // 5. Bind APK + String pkgId = huaweiBindApk(clientId, token, hwAppId, file.getName(), objectId); + + // 6. Wait for compile (poll up to 3 minutes) + huaweiWaitCompile(clientId, token, hwAppId, pkgId); + + // 7. Update version description + huaweiUpdateDesc(clientId, token, hwAppId, v.getChangeLog()); + + // 8. Submit for review + huaweiSubmit(clientId, token, hwAppId); + } + + private String huaweiGetToken(String clientId, String clientSecret) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = Map.of( + "client_id", clientId, + "client_secret", clientSecret, + "grant_type", "client_credentials"); + ResponseEntity resp = rest.postForEntity( + HUAWEI_API + "/api/oauth2/v1/token", new HttpEntity<>(body, headers), Map.class); + return Objects.requireNonNull(resp.getBody()).get("access_token").toString(); + } + + @SuppressWarnings("unchecked") + private String huaweiGetAppId(String clientId, String token, String packageName) { + HttpHeaders headers = huaweiHeaders(clientId, token); + ResponseEntity resp = rest.exchange( + HUAWEI_API + "/api/publish/v2/appid-list?packageName=" + packageName, + HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map body = resp.getBody(); + List> list = (List>) body.get("appids"); + if (list == null || list.isEmpty()) throw new RuntimeException("Huawei: app not found for " + packageName); + return list.get(0).get("id").toString(); + } + + @SuppressWarnings("unchecked") + private Map huaweiGetUploadUrl(String clientId, String token, String hwAppId, File file) { + HttpHeaders headers = huaweiHeaders(clientId, token); + String url = HUAWEI_API + "/api/publish/v2/upload-url/for-obs?appId=" + hwAppId + + "&fileName=" + file.getName() + "&contentLength=" + file.length(); + ResponseEntity resp = rest.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), Map.class); + return resp.getBody(); + } + + private void huaweiUploadFile(String uploadUrl, Map extraHeaders, File file) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + if (extraHeaders != null) extraHeaders.forEach(headers::set); + FileSystemResource resource = new FileSystemResource(file); + rest.exchange(uploadUrl, HttpMethod.PUT, new HttpEntity<>(resource, headers), Void.class); + } + + @SuppressWarnings("unchecked") + private String huaweiBindApk(String clientId, String token, String hwAppId, String fileName, String objectId) { + HttpHeaders headers = huaweiHeaders(clientId, token); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = Map.of("files", List.of(Map.of("fileName", fileName, "fileDestUrl", objectId))); + ResponseEntity resp = rest.exchange( + HUAWEI_API + "/api/publish/v2/app-file-info?appId=" + hwAppId, + HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class); + List> pkgList = (List>) resp.getBody().get("pkgVersion"); + return pkgList.get(0).get("id").toString(); + } + + @SuppressWarnings("unchecked") + private void huaweiWaitCompile(String clientId, String token, String hwAppId, String pkgId) throws InterruptedException { + HttpHeaders headers = huaweiHeaders(clientId, token); + long deadline = System.currentTimeMillis() + 3 * 60_000L; + while (System.currentTimeMillis() < deadline) { + Thread.sleep(10_000); + ResponseEntity resp = rest.exchange( + HUAWEI_API + "/api/publish/v2/package/compile/status?appId=" + hwAppId + "&pkgIds=" + pkgId, + HttpMethod.GET, new HttpEntity<>(headers), Map.class); + List> states = (List>) resp.getBody().get("pkgStateList"); + if (states != null && !states.isEmpty()) { + Object compileStatus = states.get(0).get("compileStatus"); + if ("2".equals(String.valueOf(compileStatus))) return; // 2 = success + if ("3".equals(String.valueOf(compileStatus))) + throw new RuntimeException("Huawei compile failed"); + } + } + throw new RuntimeException("Huawei compile timeout"); + } + + private void huaweiUpdateDesc(String clientId, String token, String hwAppId, String changeLog) { + if (changeLog == null || changeLog.isBlank()) return; + HttpHeaders headers = huaweiHeaders(clientId, token); + headers.setContentType(MediaType.APPLICATION_JSON); + Map body = Map.of("lang", "zh-CN", "newFeatures", changeLog); + rest.exchange(HUAWEI_API + "/api/publish/v2/app-language-info?appId=" + hwAppId, + HttpMethod.PUT, new HttpEntity<>(body, headers), Map.class); + } + + private void huaweiSubmit(String clientId, String token, String hwAppId) { + HttpHeaders headers = huaweiHeaders(clientId, token); + rest.postForEntity(HUAWEI_API + "/api/publish/v2/app-submit?appId=" + hwAppId, + new HttpEntity<>(headers), Map.class); + } + + private HttpHeaders huaweiHeaders(String clientId, String token) { + HttpHeaders h = new HttpHeaders(); + h.set("client_id", clientId); + h.set("Authorization", "Bearer " + token); + return h; + } + + // ── Honor AppGallery (same API as Huawei) ───────────────────────────────── + + private void submitToHonor(AppVersionEntity v, File file, Map creds) throws Exception { + // Honor uses the same Connect API as Huawei — reuse implementation + submitToHuawei(v, file, creds); + } + + // ── Xiaomi Market ───────────────────────────────────────────────────────── + // API: https://dev.mi.com/distribute/doc/details?pId=1134 + + private void submitToMi(AppVersionEntity v, File file, Map creds) { + // TODO: Implement Xiaomi Market API submission + // Required creds: username, privateKey (RSA private key for request signing) + // Flow: + // 1. Sign request parameters with RSA private key (MiApiSigner in XiaoZhuan) + // 2. POST https://api.developer.xiaomi.com/devupload/dev/push with signed form + APK file + // 3. Check response for success + log.warn("MI store submission not yet implemented - mark as UNDER_REVIEW manually"); + throw new UnsupportedOperationException("MI submission not implemented"); + } + + // ── OPPO Software Store ─────────────────────────────────────────────────── + // API: https://open.oppomobile.com/new/developmentDoc/info?id=11119 + + private void submitToOppo(AppVersionEntity v, File file, Map creds) { + // TODO: Implement OPPO Market API submission + // Required creds: clientId, clientSecret + // Flow: + // 1. POST https://oop-openapi-cn.heytapmobi.com/developer/v1/token → access_token + // 2. POST upload URL to get upload address + // 3. PUT file to upload address + // 4. POST update app info + submit for review + log.warn("OPPO store submission not yet implemented"); + throw new UnsupportedOperationException("OPPO submission not implemented"); + } + + // ── vivo App Store ──────────────────────────────────────────────────────── + // API: https://dev.vivo.com.cn/documentCenter/doc/326 + + private void submitToVivo(AppVersionEntity v, File file, Map creds) { + // TODO: Implement vivo Market API submission + // Required creds: accessKey, accessSecret + // Flow: + // 1. Build signed request (HMAC-SHA256 of accessKey + timestamp + nonce + accessSecret) + // 2. POST https://developer-api.vivo.com.cn/router/rest with signed params + APK file + log.warn("VIVO store submission not yet implemented"); + throw new UnsupportedOperationException("VIVO submission not implemented"); + } + + // ── Apple App Store Connect ─────────────────────────────────────────────── + // API: https://developer.apple.com/documentation/appstoreconnectapi + + private void submitToAppStore(AppVersionEntity v, File file, Map creds) { + // TODO: Implement App Store Connect API submission + // Required creds: teamId, keyId, privateKey (P8 content), bundleId + // Flow: + // 1. Generate JWT using ES256 with privateKey (keyId + teamId in header/payload) + // 2. POST /v1/apps/{appId}/appStoreVersions to create version + // 3. POST /v1/appStoreVersionSubmissions to submit + // Note: IPA submission still requires xcrun altool or Transporter CLI — not REST-only + log.warn("App Store submission not yet implemented - use Transporter or fastlane"); + throw new UnsupportedOperationException("App Store submission not implemented"); + } + + // ── Google Play ─────────────────────────────────────────────────────────── + + private void submitToGooglePlay(AppVersionEntity v, File file, Map creds) { + // TODO: Implement Google Play Developer API submission + // Required creds: serviceAccountJson (Google service account JSON key) + // Flow (using google-api-client-java): + // 1. Authenticate with service account JSON + // 2. Create edit: POST https://www.googleapis.com/androidpublisher/v3/applications/{packageName}/edits + // 3. Upload APK to edit + // 4. Assign to track (production/beta) + // 5. Commit edit + log.warn("Google Play submission not yet implemented"); + throw new UnsupportedOperationException("Google Play submission not implemented"); + } + + // ── Utilities ───────────────────────────────────────────────────────────── + + private File resolveLocalFile(String downloadUrl) { + if (downloadUrl == null) throw new IllegalStateException("downloadUrl is null"); + String path = URI.create(downloadUrl).getPath(); + // path like /files/apk/{filename} or /api/v1/updates/files/apk/{filename} + String filename = Paths.get(path).getFileName().toString(); + File file = Paths.get(uploadDir, "apk", filename).toFile(); + if (!file.exists()) throw new IllegalStateException("APK file not found locally: " + file); + return file; + } + + private List parseTargets(String json) { + if (json == null || json.isBlank()) return List.of(); + try { return mapper.readValue(json, new TypeReference>() {}); } + catch (Exception e) { return List.of(); } + } + + private Map parseConfig(String json) throws Exception { + if (json == null || json.isBlank()) return Map.of(); + return mapper.readValue(json, new TypeReference>() {}); + } + + private String require(Map creds, String key, String store) { + String v = creds.get(key); + if (v == null || v.isBlank()) + throw new IllegalStateException(store + " credential missing: " + key); + return v; + } +}