From 73060518f03c270df12fe4f0e94bbb7576656f2c Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 21:05:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20=E6=B7=BB=E5=8A=A0=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E8=AE=AFSDK=E6=A0=B8=E5=BF=83=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现IM API接口定义,包括消息、群组、好友、黑名单等功能 - 定义IM消息相关数据模型,包含聊天类型、消息类型、用户资料等 - 实现ImSDK单例类,提供登录、消息发送、群组管理、好友管理等核心功能 - 添加WebSocket连接管理,支持自动重连机制 - 实现历史消息查询、群组操作、用户资料管理等API调用 - 添加会话状态管理,支持置顶、静音、草稿等功能 - 集成文件上传结果,支持多媒体消息发送 - 实现连接状态监听和事件回调机制 --- .../java/com/xuqm/im/sdk/XuqmImServerSdk.java | 258 +++++++++++++++++- .../xuqm/im/controller/FriendController.java | 65 ++++- .../controller/FriendRequestController.java | 18 ++ .../xuqm/im/controller/GroupController.java | 38 +++ .../im/model/MessageReadCallbackPayload.java | 13 + .../im/model/WebhookCallbackEnvelope.java | 13 + .../xuqm/im/service/FriendRequestService.java | 60 ++++ .../com/xuqm/im/service/ImGroupService.java | 113 ++++++++ .../com/xuqm/im/service/MessageService.java | 99 ++++++- .../controller/AppVersionController.java | 30 +- .../update/controller/RnBundleController.java | 38 +-- .../controller/UnifiedReleaseController.java | 108 ++++++++ .../update/model/UnifiedReleaseManifest.java | 31 +++ .../update/model/UnifiedReleaseResult.java | 11 + .../update/service/UpdateAssetService.java | 63 +++++ 15 files changed, 882 insertions(+), 76 deletions(-) create mode 100644 im-service/src/main/java/com/xuqm/im/model/MessageReadCallbackPayload.java create mode 100644 im-service/src/main/java/com/xuqm/im/model/WebhookCallbackEnvelope.java create mode 100644 update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java create mode 100644 update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java create mode 100644 update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java 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 a0a8e00..e8c246c 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 @@ -320,6 +320,25 @@ public final class XuqmImServerSdk { } } + public ImMessage parseMessageCallbackPayload(String body) { + WebhookCallbackEnvelope envelope = parseCallbackEnvelope(body); + if (!envelope.isMessageEvent() + || !(envelope.isEvent("message.sent") + || envelope.isEvent("message.edited") + || envelope.isEvent("message.revoked"))) { + throw new ImSdkException("Callback event is not a message event"); + } + return readPayload(envelope, ImMessage.class); + } + + public MessageReadCallbackPayload parseMessageReadCallbackPayload(String body) { + WebhookCallbackEnvelope envelope = parseCallbackEnvelope(body); + if (!envelope.isReadReceiptEvent()) { + throw new ImSdkException("Callback event is not a message.read event"); + } + return readPayload(envelope, MessageReadCallbackPayload.class); + } + public PageResult searchMessages( String keyword, String chatType, @@ -540,6 +559,25 @@ public final class XuqmImServerSdk { return response.data(); } + public UnifiedReleaseResult uploadUnifiedRelease(UnifiedReleaseManifest manifest, Map files) { + Map form = new LinkedHashMap<>(); + form.put("appId", appId); + try { + form.put("manifest", objectMapper.writeValueAsString(manifest)); + } catch (JsonProcessingException e) { + throw new ImSdkException("Failed to serialize unified release manifest", e); + } + ApiResponse response = multipartRequest( + "POST", + buildUri(updateBaseUrl, "/api/v1/updates/unified/upload", Map.of()), + form, + files, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public AppVersionView publishAppVersion(String id) { ApiResponse response = request( "POST", @@ -698,6 +736,17 @@ public final class XuqmImServerSdk { return response.data(); } + public List addFriends(List friendIds) { + ApiResponse> response = request( + "POST", + buildUri("/api/im/friends/batch", appQuery()), + new FriendBatchRequest(friendIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public void removeFriend(String friendId) { Map query = appQuery(); query.put("friendId", friendId); @@ -710,6 +759,16 @@ public final class XuqmImServerSdk { ); } + public void removeFriends(List friendIds) { + request( + "POST", + buildUri("/api/im/friends/batch/remove", appQuery()), + new FriendBatchRequest(friendIds), + authorizedHeaders(), + new TypeReference>() {} + ); + } + public List listFriendRequests(String direction) { Map query = appQuery(); if (direction != null) { @@ -763,6 +822,28 @@ public final class XuqmImServerSdk { return response.data(); } + public List acceptFriendRequests(List requestIds) { + ApiResponse> response = request( + "POST", + buildUri("/api/im/friend-requests/batch/accept", appQuery()), + new RequestBatch(requestIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List rejectFriendRequests(List requestIds) { + ApiResponse> response = request( + "POST", + buildUri("/api/im/friend-requests/batch/reject", appQuery()), + new RequestBatch(requestIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public List listBlacklist() { ApiResponse> response = request( "GET", @@ -869,6 +950,17 @@ public final class XuqmImServerSdk { return response.data(); } + public GroupView addGroupMembers(String groupId, List userIds) { + ApiResponse response = request( + "POST", + buildUri("/api/im/groups/" + encode(groupId) + "/members/batch", appQuery()), + new MemberBatchRequest(userIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public GroupView removeGroupMember(String groupId, String targetUserId) { ApiResponse response = request( "DELETE", @@ -880,6 +972,17 @@ public final class XuqmImServerSdk { return response.data(); } + public GroupView removeGroupMembers(String groupId, List userIds) { + ApiResponse response = request( + "POST", + buildUri("/api/im/groups/" + encode(groupId) + "/members/batch/remove", appQuery()), + new MemberBatchRequest(userIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public GroupView setGroupRole(String groupId, String userId, String role) { ApiResponse response = request( "POST", @@ -960,6 +1063,28 @@ public final class XuqmImServerSdk { return response.data(); } + public List acceptGroupJoinRequests(String groupId, List requestIds) { + ApiResponse> response = request( + "POST", + buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/batch/accept", appQuery()), + new RequestBatch(requestIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + + public List rejectGroupJoinRequests(String groupId, List requestIds) { + ApiResponse> response = request( + "POST", + buildUri("/api/im/groups/" + encode(groupId) + "/join-requests/batch/reject", appQuery()), + new RequestBatch(requestIds), + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public void setConversationPinned(String targetId, String chatType, boolean pinned) { Map query = appQuery(); query.put("chatType", chatType); @@ -1107,6 +1232,14 @@ public final class XuqmImServerSdk { return value == null ? "" : value; } + private T readPayload(WebhookCallbackEnvelope envelope, Class payloadType) { + try { + return objectMapper.treeToValue(envelope.payload(), payloadType); + } catch (JsonProcessingException e) { + throw new ImSdkException("Failed to parse callback payload", e); + } + } + private static String text(JsonNode node, String field) { JsonNode value = node == null ? null : node.get(field); return value == null || value.isNull() ? null : value.asText(); @@ -1201,6 +1334,39 @@ public final class XuqmImServerSdk { } } + private ApiResponse multipartRequest( + String method, + URI uri, + Map formFields, + Map files, + Map headers, + TypeReference> responseType + ) { + String boundary = "----XuqmBoundary" + UUID.randomUUID().toString().replace("-", ""); + try { + HttpRequest.BodyPublisher bodyPublisher = ofMultipartData(boundary, formFields, files); + Map mergedHeaders = new LinkedHashMap<>(headers); + mergedHeaders.put("Content-Type", "multipart/form-data; boundary=" + boundary); + HttpRequest.Builder builder = HttpRequest.newBuilder(uri) + .headers(flatten(mergedHeaders)) + .method(method, bodyPublisher); + HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + throw new ImSdkException("HTTP " + response.statusCode() + ": " + response.body()); + } + ApiResponse apiResponse = objectMapper.readValue(response.body(), responseType); + if (apiResponse.code() != 200) { + throw new ImSdkException(apiResponse.message()); + } + return apiResponse; + } catch (IOException e) { + throw new ImSdkException("Request failed: " + e.getMessage(), e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ImSdkException("Request interrupted", e); + } + } + private HttpRequest.BodyPublisher ofMultipartData( String boundary, Map formFields, @@ -1227,6 +1393,34 @@ public final class XuqmImServerSdk { return HttpRequest.BodyPublishers.ofByteArrays(byteArrays); } + private HttpRequest.BodyPublisher ofMultipartData( + String boundary, + Map formFields, + Map files + ) throws IOException { + var byteArrays = new ArrayList(); + Charset charset = StandardCharsets.UTF_8; + + for (Map.Entry entry : formFields.entrySet()) { + byteArrays.add(("--" + boundary + "\r\n").getBytes(charset)); + byteArrays.add(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"\r\n\r\n").getBytes(charset)); + byteArrays.add(entry.getValue().getBytes(charset)); + byteArrays.add("\r\n".getBytes(charset)); + } + + for (Map.Entry entry : files.entrySet()) { + Path file = entry.getValue(); + byteArrays.add(("--" + boundary + "\r\n").getBytes(charset)); + byteArrays.add(("Content-Disposition: form-data; name=\"" + entry.getKey() + "\"; filename=\"" + file.getFileName() + "\"\r\n").getBytes(charset)); + byteArrays.add(("Content-Type: application/octet-stream\r\n\r\n").getBytes(charset)); + byteArrays.add(Files.readAllBytes(file)); + byteArrays.add("\r\n".getBytes(charset)); + } + + byteArrays.add(("--" + boundary + "--\r\n").getBytes(charset)); + return HttpRequest.BodyPublishers.ofByteArrays(byteArrays); + } + private URI buildUri(String path, Map query) { StringBuilder builder = new StringBuilder(baseUrl).append(path); if (query != null && !query.isEmpty()) { @@ -1417,6 +1611,8 @@ public final class XuqmImServerSdk { Instant createdAt ) {} + public record FriendBatchRequest(List friendIds) {} + public record FriendRequestView( String id, String appId, @@ -1509,7 +1705,23 @@ public final class XuqmImServerSdk { JsonNode payload, String signature, String appId - ) {} + ) { + public boolean isType(String type) { + return type != null && callbackType != null && callbackType.equalsIgnoreCase(type); + } + + public boolean isEvent(String event) { + return event != null && callbackEvent != null && callbackEvent.equalsIgnoreCase(event); + } + + public boolean isMessageEvent() { + return isType("message"); + } + + public boolean isReadReceiptEvent() { + return isEvent("message.read"); + } + } public record AppVersionView( String id, @@ -1624,6 +1836,46 @@ public final class XuqmImServerSdk { String note ) {} + public record MessageReadCallbackPayload( + String appId, + String readerId, + String peerId, + String groupId, + String chatType, + long readAt, + List messageIds + ) {} + + public record UnifiedReleaseManifest( + List appVersions, + List rnBundles + ) {} + + public record AppUploadItem( + String fileKey, + String platform, + String versionName, + int versionCode, + String changeLog, + boolean forceUpdate, + String appStoreUrl, + String marketUrl + ) {} + + public record RnBundleUploadItem( + String fileKey, + String moduleId, + String platform, + String version, + String minCommonVersion, + String note + ) {} + + public record UnifiedReleaseResult( + List appVersions, + List rnBundles + ) {} + public record CreateGroupRequest( String name, List memberIds, @@ -1637,10 +1889,14 @@ public final class XuqmImServerSdk { public record MemberRequest(String userId) {} + public record MemberBatchRequest(List userIds) {} + public record SetRoleRequest(String userId, String role) {} public record MuteMemberRequest(String userId, long minutes) {} + public record RequestBatch(List requestIds) {} + public static final class ImSdkException extends RuntimeException { public ImSdkException(String message) { super(message); diff --git a/im-service/src/main/java/com/xuqm/im/controller/FriendController.java b/im-service/src/main/java/com/xuqm/im/controller/FriendController.java index 81f2c2b..a1a6a8a 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/FriendController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/FriendController.java @@ -9,10 +9,13 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; @RestController @@ -41,7 +44,50 @@ public class FriendController { @AuthenticationPrincipal String userId, @RequestParam String appId, @RequestParam String friendId) { - // Insert userId -> friendId if not already present + return ResponseEntity.ok(ApiResponse.success(addFriendLink(appId, userId, friendId))); + } + + @PostMapping("/batch") + public ResponseEntity>> addFriends( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestBody FriendBatchRequest req) { + List links = new ArrayList<>(); + for (String friendId : unique(req.friendIds())) { + if (friendId == null || friendId.isBlank() || userId.equals(friendId)) { + continue; + } + links.add(addFriendLink(appId, userId, friendId)); + } + return ResponseEntity.ok(ApiResponse.success(links)); + } + + @DeleteMapping("/{friendId}") + public ResponseEntity> removeFriend( + @AuthenticationPrincipal String userId, + @PathVariable String friendId, + @RequestParam String appId) { + friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId); + friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + @PostMapping("/batch/remove") + public ResponseEntity> removeFriends( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestBody FriendBatchRequest req) { + for (String friendId : unique(req.friendIds())) { + if (friendId == null || friendId.isBlank() || userId.equals(friendId)) { + continue; + } + friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId); + friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId); + } + return ResponseEntity.ok(ApiResponse.success(null)); + } + + private ImFriendEntity addFriendLink(String appId, String userId, String friendId) { ImFriendEntity forward = friendRepository .findByAppIdAndUserIdAndFriendId(appId, userId, friendId) .orElseGet(() -> { @@ -52,7 +98,6 @@ public class FriendController { return friendRepository.save(e); }); - // Insert friendId -> userId bi-directionally if not already present friendRepository.findByAppIdAndUserIdAndFriendId(appId, friendId, userId) .orElseGet(() -> { ImFriendEntity e = new ImFriendEntity(); @@ -61,18 +106,12 @@ public class FriendController { e.setFriendId(userId); return friendRepository.save(e); }); - - return ResponseEntity.ok(ApiResponse.success(forward)); + return forward; } - @DeleteMapping("/{friendId}") - public ResponseEntity> removeFriend( - @AuthenticationPrincipal String userId, - @PathVariable String friendId, - @RequestParam String appId) { - // Remove both directions - friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, userId, friendId); - friendRepository.deleteByAppIdAndUserIdAndFriendId(appId, friendId, userId); - return ResponseEntity.ok(ApiResponse.success(null)); + private List unique(List friendIds) { + return friendIds == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(friendIds)); } + + public record FriendBatchRequest(List friendIds) {} } 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 5c2c117..9665794 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 @@ -59,4 +59,22 @@ public class FriendRequestController { @PathVariable String requestId) { return ResponseEntity.ok(ApiResponse.success(friendRequestService.reject(appId, requestId, userId))); } + + @PostMapping("/batch/accept") + public ResponseEntity>> acceptBatch( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestBody BatchRequest req) { + return ResponseEntity.ok(ApiResponse.success(friendRequestService.acceptBatch(appId, req.requestIds(), userId))); + } + + @PostMapping("/batch/reject") + public ResponseEntity>> rejectBatch( + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestBody BatchRequest req) { + return ResponseEntity.ok(ApiResponse.success(friendRequestService.rejectBatch(appId, req.requestIds(), userId))); + } + + public record BatchRequest(List requestIds) {} } 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 6bbe213..b09ee4c 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 @@ -75,6 +75,14 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.addMember(groupId, req.userId(), userId))); } + @PostMapping("/{groupId}/members/batch") + public ResponseEntity> addMembers( + @PathVariable String groupId, + @RequestBody MemberBatchRequest req, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success(groupService.addMembers(groupId, req.userIds(), userId))); + } + @DeleteMapping("/{groupId}/members/{targetUserId}") public ResponseEntity> removeMember( @PathVariable String groupId, @@ -83,6 +91,14 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.removeMember(groupId, targetUserId, userId))); } + @PostMapping("/{groupId}/members/batch/remove") + public ResponseEntity> removeMembers( + @PathVariable String groupId, + @RequestBody MemberBatchRequest req, + @AuthenticationPrincipal String userId) { + return ResponseEntity.ok(ApiResponse.success(groupService.removeMembers(groupId, req.userIds(), userId))); + } + @PostMapping("/{groupId}/roles") public ResponseEntity> setRole( @PathVariable String groupId, @@ -144,9 +160,31 @@ public class GroupController { return ResponseEntity.ok(ApiResponse.success(groupService.rejectJoinRequest(appId, requestId, userId))); } + @PostMapping("/{groupId}/join-requests/batch/accept") + public ResponseEntity>> acceptJoinRequests( + @PathVariable String groupId, + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestBody RequestBatch req) { + return ResponseEntity.ok(ApiResponse.success( + groupService.acceptJoinRequests(appId, groupId, req.requestIds(), userId))); + } + + @PostMapping("/{groupId}/join-requests/batch/reject") + public ResponseEntity>> rejectJoinRequests( + @PathVariable String groupId, + @AuthenticationPrincipal String userId, + @RequestParam String appId, + @RequestBody RequestBatch req) { + return ResponseEntity.ok(ApiResponse.success( + groupService.rejectJoinRequests(appId, groupId, req.requestIds(), userId))); + } + public record CreateGroupRequest(String name, List memberIds, String groupType) {} public record UpdateGroupRequest(String name, String announcement) {} public record MemberRequest(String userId) {} + public record MemberBatchRequest(List userIds) {} public record SetRoleRequest(String userId, String role) {} public record MuteMemberRequest(String userId, long minutes) {} + public record RequestBatch(List requestIds) {} } diff --git a/im-service/src/main/java/com/xuqm/im/model/MessageReadCallbackPayload.java b/im-service/src/main/java/com/xuqm/im/model/MessageReadCallbackPayload.java new file mode 100644 index 0000000..43734a6 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/MessageReadCallbackPayload.java @@ -0,0 +1,13 @@ +package com.xuqm.im.model; + +import java.util.List; + +public record MessageReadCallbackPayload( + String appId, + String readerId, + String peerId, + String groupId, + String chatType, + long readAt, + List messageIds +) {} diff --git a/im-service/src/main/java/com/xuqm/im/model/WebhookCallbackEnvelope.java b/im-service/src/main/java/com/xuqm/im/model/WebhookCallbackEnvelope.java new file mode 100644 index 0000000..f416157 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/model/WebhookCallbackEnvelope.java @@ -0,0 +1,13 @@ +package com.xuqm.im.model; + +import com.fasterxml.jackson.databind.JsonNode; + +public record WebhookCallbackEnvelope( + String callbackId, + String callbackType, + String callbackEvent, + long requestTime, + JsonNode payload, + String signature, + String appId +) {} 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 72ccbf9..7d4c01c 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 @@ -105,6 +105,24 @@ public class FriendRequestService { return saved; } + @Transactional + public List acceptBatch(String appId, List requestIds, String operatorId) { + List result = new java.util.ArrayList<>(); + for (String requestId : unique(requestIds)) { + result.add(acceptInternal(appId, requestId, operatorId)); + } + return result; + } + + @Transactional + public List rejectBatch(String appId, List requestIds, String operatorId) { + List result = new java.util.ArrayList<>(); + for (String requestId : unique(requestIds)) { + result.add(rejectInternal(appId, requestId, operatorId)); + } + return result; + } + public List incoming(String appId, String userId) { return requestRepository.findByAppIdAndToUserId(appId, userId).stream() .filter(request -> ImFriendRequestEntity.Status.PENDING.name().equals(request.getStatus())) @@ -124,6 +142,44 @@ public class FriendRequestService { return request; } + private ImFriendRequestEntity acceptInternal(String appId, String requestId, String operatorId) { + ImFriendRequestEntity request = getRequest(appId, requestId, operatorId); + 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())); + friendRepository + .findByAppIdAndUserIdAndFriendId(appId, request.getToUserId(), request.getFromUserId()) + .orElseGet(() -> friendEntity(appId, request.getToUserId(), request.getFromUserId())); + publishNotification( + request, + request.getToUserId(), + request.getFromUserId(), + "FRIEND_REQUEST_STATUS", + "好友申请已通过", + buildDescription("好友申请已通过", request.getRemark()) + ); + return saved; + } + + 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; + } + private com.xuqm.im.entity.ImFriendEntity friendEntity(String appId, String userId, String friendId) { com.xuqm.im.entity.ImFriendEntity entity = new com.xuqm.im.entity.ImFriendEntity(); entity.setAppId(appId); @@ -132,6 +188,10 @@ public class FriendRequestService { return friendRepository.save(entity); } + private List unique(List requestIds) { + return requestIds == null ? List.of() : new java.util.ArrayList<>(new java.util.LinkedHashSet<>(requestIds)); + } + private void publishNotification( ImFriendRequestEntity request, String fromUserId, 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 04647a1..9009b09 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 @@ -92,6 +92,28 @@ public class ImGroupService { return group; } + @Transactional + public ImGroupEntity addMembers(String groupId, List userIds, String operatorId) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + 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 removeMember(String groupId, String userId, String operatorId) { ImGroupEntity group = get(groupId); @@ -105,6 +127,28 @@ public class ImGroupService { return groupRepository.save(group); } + @Transactional + public ImGroupEntity removeMembers(String groupId, List userIds, String operatorId) { + ImGroupEntity group = get(groupId); + List admins = fromJson(group.getAdminIds()); + if (!admins.contains(operatorId) && !group.getCreatorId().equals(operatorId)) { + throw new BusinessException(403, "无权操作"); + } + 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 update(String groupId, String operatorId, String name, String announcement) { ImGroupEntity group = get(groupId); @@ -288,6 +332,28 @@ public class ImGroupService { return saved; } + @Transactional + public List acceptJoinRequests(String appId, String groupId, List requestIds, String operatorId) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + List result = new ArrayList<>(); + for (String requestId : unique(requestIds)) { + result.add(acceptJoinRequestInternal(appId, group, requestId, operatorId)); + } + return result; + } + + @Transactional + public List rejectJoinRequests(String appId, String groupId, List requestIds, String operatorId) { + ImGroupEntity group = get(groupId); + ensureCanManage(group, operatorId); + List result = new ArrayList<>(); + for (String requestId : unique(requestIds)) { + result.add(rejectJoinRequestInternal(appId, group, requestId, operatorId)); + } + return result; + } + private String toJson(List list) { try { return objectMapper.writeValueAsString(list); } catch (Exception e) { return "[]"; } } @@ -382,7 +448,54 @@ public class ImGroupService { return request; } + private ImGroupJoinRequestEntity acceptJoinRequestInternal(String appId, ImGroupEntity group, String requestId, String operatorId) { + ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId); + if (!group.getId().equals(request.getGroupId())) { + throw new BusinessException(400, "加群申请不属于当前群"); + } + ensureCanManage(group, operatorId); + request.setStatus(ImGroupJoinRequestEntity.Status.ACCEPTED.name()); + request.setReviewedAt(LocalDateTime.now()); + ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); + addMemberInternal(group, request.getRequesterId()); + publishJoinRequestNotification( + group, + operatorId, + List.of(request.getRequesterId()), + "GROUP_JOIN_REQUEST_STATUS", + "入群申请已通过", + buildDescription("入群申请已通过", null), + saved + ); + return saved; + } + + private ImGroupJoinRequestEntity rejectJoinRequestInternal(String appId, ImGroupEntity group, String requestId, String operatorId) { + ImGroupJoinRequestEntity request = getJoinRequest(appId, requestId); + if (!group.getId().equals(request.getGroupId())) { + throw new BusinessException(400, "加群申请不属于当前群"); + } + ensureCanManage(group, operatorId); + request.setStatus(ImGroupJoinRequestEntity.Status.REJECTED.name()); + request.setReviewedAt(LocalDateTime.now()); + ImGroupJoinRequestEntity saved = joinRequestRepository.save(request); + publishJoinRequestNotification( + group, + operatorId, + List.of(request.getRequesterId()), + "GROUP_JOIN_REQUEST_STATUS", + "入群申请已拒绝", + buildDescription("入群申请已拒绝", null), + saved + ); + return saved; + } + private String normalizeGroupType(String groupType) { return (groupType == null || groupType.isBlank()) ? "WORK" : groupType.trim().toUpperCase(); } + + private List unique(List values) { + return values == null ? List.of() : new ArrayList<>(new LinkedHashSet<>(values)); + } } 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 0a09704..b810ba0 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 @@ -8,13 +8,14 @@ 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.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,13 +24,19 @@ 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 { @@ -47,6 +54,7 @@ public class MessageService { private final ImPushBridgeClient pushBridgeClient; private final ImFeatureConfigClient featureConfigClient; private final ImFriendRepository friendRepository; + private final ImAppSecretClient appSecretClient; private final ObjectMapper objectMapper; @Value("${im.webhook-timeout-ms:3000}") @@ -63,6 +71,7 @@ public class MessageService { ImPushBridgeClient pushBridgeClient, ImFeatureConfigClient featureConfigClient, ImFriendRepository friendRepository, + ImAppSecretClient appSecretClient, ObjectMapper objectMapper) { this.messageRepository = messageRepository; this.webhookRepository = webhookRepository; @@ -75,6 +84,7 @@ public class MessageService { this.pushBridgeClient = pushBridgeClient; this.featureConfigClient = featureConfigClient; this.friendRepository = friendRepository; + this.appSecretClient = appSecretClient; this.objectMapper = objectMapper; } @@ -155,7 +165,7 @@ public class MessageService { ); } - dispatchWebhooks(appId, saved); + dispatchWebhooks(appId, "message.sent", saved); return saved; } @@ -188,6 +198,7 @@ public class MessageService { log.debug("revoke group messageId={} groupId={}", saved.getId(), saved.getToId()); clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); } + dispatchWebhooks(appId, "message.revoked", saved); return saved; } @@ -242,7 +253,7 @@ public class MessageService { ); } - dispatchWebhooks(appId, saved); + dispatchWebhooks(appId, "message.edited", saved); return saved; } @@ -266,6 +277,7 @@ public class MessageService { log.debug("admin revoke group messageId={} groupId={}", saved.getId(), saved.getToId()); clusterPublisher.publish("/topic/group/" + saved.getToId(), saved); } + dispatchWebhooks(appId, "message.revoked", saved); return saved; } @@ -314,14 +326,26 @@ public class MessageService { if (messages.isEmpty()) { return; } + List messageIds = new java.util.ArrayList<>(); for (ImMessageEntity message : messages) { if (message.getStatus() == ImMessageEntity.MsgStatus.READ) { + messageIds.add(message.getId()); continue; } message.setStatus(ImMessageEntity.MsgStatus.READ); ImMessageEntity saved = messageRepository.save(message); clusterPublisher.publish("/user/" + peerId + "/queue/messages", saved); + messageIds.add(saved.getId()); } + dispatchWebhooks(appId, "message.read", new MessageReadCallbackPayload( + appId, + readerId, + peerId, + null, + chatType, + toEpochMillis(readAt), + messageIds + )); } public void syncGroupReadReceipt(String appId, String readerId, String groupId, LocalDateTime readAt) { @@ -335,10 +359,21 @@ public class MessageService { if (messages.isEmpty()) { return; } + List messageIds = new java.util.ArrayList<>(); for (ImMessageEntity message : messages) { message.setGroupReadCount(groupReadCount(appId, groupId, message.getCreatedAt(), message.getFromUserId())); clusterPublisher.publish("/topic/group/" + groupId, message); + messageIds.add(message.getId()); } + dispatchWebhooks(appId, "message.read", new MessageReadCallbackPayload( + appId, + readerId, + null, + groupId, + ImMessageEntity.ChatType.GROUP.name(), + toEpochMillis(readAt), + messageIds + )); } public Page adminHistory( @@ -487,26 +522,74 @@ public class MessageService { return java.util.Optional.empty(); } - @Async - protected void dispatchWebhooks(String appId, ImMessageEntity message) { + protected void dispatchWebhooks(String appId, String callbackEvent, ImMessageEntity message) { + dispatchWebhooks(appId, callbackEvent, (Object) message); + } + + protected void dispatchWebhooks(String appId, String callbackEvent, Object payload) { List webhooks = webhookRepository.findByAppIdAndEnabledTrue(appId); if (webhooks.isEmpty()) return; try { - String body = objectMapper.writeValueAsString(message); + 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 ignored) { + } catch (Exception e) { + log.warn("dispatch webhook failed appId={} url={} event={} reason={}", + appId, webhook.getUrl(), callbackEvent, e.getMessage()); } } - } catch (Exception ignored) { + } 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); } } } 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 12f853d..408507b 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,35 +3,27 @@ package com.xuqm.update.controller; import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.repository.AppVersionRepository; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; +import com.xuqm.update.service.UpdateAssetService; @RestController @RequestMapping("/api/v1/updates") public class AppVersionController { private final AppVersionRepository versionRepository; + private final UpdateAssetService updateAssetService; - @Value("${update.upload-dir:/tmp/xuqm-update}") - private String uploadDir; - - @Value("${update.base-url:https://update.dev.xuqinmin.com}") - private String baseUrl; - - public AppVersionController(AppVersionRepository versionRepository) { + public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService) { this.versionRepository = versionRepository; + this.updateAssetService = updateAssetService; } @GetMapping("/app/check") @@ -69,17 +61,7 @@ public class AppVersionController { @RequestParam int versionCode, @RequestParam(required = false) String changeLog, @RequestParam(defaultValue = "false") boolean forceUpdate, - @RequestParam(required = false) MultipartFile apkFile) throws IOException { - - String downloadUrl = null; - if (apkFile != null && !apkFile.isEmpty()) { - String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename(); - Path dir = Paths.get(uploadDir, "apk"); - Files.createDirectories(dir); - Path dest = dir.resolve(filename); - apkFile.transferTo(dest.toFile()); - downloadUrl = baseUrl + "/files/apk/" + filename; - } + @RequestParam(required = false) MultipartFile apkFile) throws Exception { AppVersionEntity entity = new AppVersionEntity(); entity.setId(UUID.randomUUID().toString()); @@ -87,7 +69,7 @@ public class AppVersionController { entity.setPlatform(platform); entity.setVersionName(versionName); entity.setVersionCode(versionCode); - entity.setDownloadUrl(downloadUrl); + entity.setDownloadUrl(updateAssetService.storeAppPackage(apkFile)); entity.setChangeLog(changeLog); entity.setForceUpdate(forceUpdate); entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); 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 3379de9..88e45f5 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 @@ -8,33 +8,26 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.security.DigestInputStream; -import java.security.MessageDigest; import java.time.LocalDateTime; -import java.util.HexFormat; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; +import com.xuqm.update.service.UpdateAssetService; @RestController @RequestMapping("/api/v1/rn") public class RnBundleController { private final RnBundleRepository bundleRepository; - - @Value("${update.upload-dir:/tmp/xuqm-update}") - private String uploadDir; + private final UpdateAssetService updateAssetService; @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; - public RnBundleController(RnBundleRepository bundleRepository) { + public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService) { this.bundleRepository = bundleRepository; + this.updateAssetService = updateAssetService; } @GetMapping("/update/check") @@ -74,14 +67,8 @@ public class RnBundleController { @RequestParam(required = false) String minCommonVersion, @RequestParam(required = false) String note, @RequestParam MultipartFile bundle) throws Exception { - - String filename = moduleId + "." + platform.name().toLowerCase() + ".bundle"; - Path dir = Paths.get(uploadDir, "rn", appId, platform.name().toLowerCase(), moduleId); - Files.createDirectories(dir); - Path dest = dir.resolve(filename); - - String md5 = computeMd5(bundle); - bundle.transferTo(dest.toFile()); + UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle( + appId, platform.name(), moduleId, bundle); RnBundleEntity entity = new RnBundleEntity(); entity.setId(UUID.randomUUID().toString()); @@ -89,8 +76,8 @@ public class RnBundleController { entity.setModuleId(moduleId); entity.setPlatform(platform); entity.setVersion(version); - entity.setBundleUrl(dest.toAbsolutePath().toString()); - entity.setMd5(md5); + entity.setBundleUrl(stored.bundlePath()); + entity.setMd5(stored.md5()); entity.setMinCommonVersion(minCommonVersion); entity.setNote(note); entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); @@ -142,15 +129,6 @@ public class RnBundleController { return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); } - private String computeMd5(MultipartFile file) throws Exception { - MessageDigest digest = MessageDigest.getInstance("MD5"); - try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) { - byte[] buf = new byte[8192]; - while (dis.read(buf) != -1) {} - } - return HexFormat.of().formatHex(digest.digest()); - } - private String resolvePublicBaseUrl() { String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; String suffix = "/api/v1/updates"; diff --git a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java new file mode 100644 index 0000000..3344015 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java @@ -0,0 +1,108 @@ +package com.xuqm.update.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.common.model.ApiResponse; +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.entity.RnBundleEntity; +import com.xuqm.update.model.UnifiedReleaseManifest; +import com.xuqm.update.model.UnifiedReleaseResult; +import com.xuqm.update.repository.AppVersionRepository; +import com.xuqm.update.repository.RnBundleRepository; +import com.xuqm.update.service.UpdateAssetService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +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.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/updates") +public class UnifiedReleaseController { + + private final ObjectMapper objectMapper; + private final AppVersionRepository appVersionRepository; + private final RnBundleRepository rnBundleRepository; + private final UpdateAssetService updateAssetService; + + public UnifiedReleaseController( + ObjectMapper objectMapper, + AppVersionRepository appVersionRepository, + RnBundleRepository rnBundleRepository, + UpdateAssetService updateAssetService) { + this.objectMapper = objectMapper; + this.appVersionRepository = appVersionRepository; + this.rnBundleRepository = rnBundleRepository; + this.updateAssetService = updateAssetService; + } + + @PostMapping("/unified/upload") + public ResponseEntity> upload( + @RequestParam String appId, + @RequestParam String manifest, + HttpServletRequest request) throws Exception { + + if (!(request instanceof MultipartHttpServletRequest multipartRequest)) { + throw new IllegalArgumentException("multipart request required"); + } + + UnifiedReleaseManifest unifiedReleaseManifest = + objectMapper.readValue(manifest, UnifiedReleaseManifest.class); + + List appVersions = new ArrayList<>(); + for (UnifiedReleaseManifest.AppUploadItem item : safeList(unifiedReleaseManifest.appVersions())) { + MultipartFile file = multipartRequest.getFile(item.fileKey()); + AppVersionEntity entity = new AppVersionEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setPlatform(item.platform()); + entity.setVersionName(item.versionName()); + entity.setVersionCode(item.versionCode()); + entity.setChangeLog(item.changeLog()); + entity.setForceUpdate(item.forceUpdate()); + entity.setAppStoreUrl(item.appStoreUrl()); + entity.setMarketUrl(item.marketUrl()); + entity.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT); + entity.setCreatedAt(LocalDateTime.now()); + entity.setDownloadUrl(updateAssetService.storeAppPackage(file)); + appVersions.add(appVersionRepository.save(entity)); + } + + List rnBundles = new ArrayList<>(); + for (UnifiedReleaseManifest.RnBundleUploadItem item : safeList(unifiedReleaseManifest.rnBundles())) { + MultipartFile file = multipartRequest.getFile(item.fileKey()); + UpdateAssetService.StoredRnBundle stored = updateAssetService.storeRnBundle( + appId, + item.platform().name(), + item.moduleId(), + file); + + RnBundleEntity entity = new RnBundleEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setModuleId(item.moduleId()); + entity.setPlatform(item.platform()); + entity.setVersion(item.version()); + entity.setBundleUrl(stored.bundlePath()); + entity.setMd5(stored.md5()); + entity.setMinCommonVersion(item.minCommonVersion()); + entity.setNote(item.note()); + entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); + entity.setCreatedAt(LocalDateTime.now()); + rnBundles.add(rnBundleRepository.save(entity)); + } + + return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions, rnBundles))); + } + + private static List safeList(List input) { + return input == null ? List.of() : input; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java new file mode 100644 index 0000000..164aae8 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseManifest.java @@ -0,0 +1,31 @@ +package com.xuqm.update.model; + +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.entity.RnBundleEntity; + +import java.util.List; + +public record UnifiedReleaseManifest( + List appVersions, + List rnBundles) { + + public record AppUploadItem( + String fileKey, + AppVersionEntity.Platform platform, + String versionName, + int versionCode, + String changeLog, + boolean forceUpdate, + String appStoreUrl, + String marketUrl) { + } + + public record RnBundleUploadItem( + String fileKey, + String moduleId, + RnBundleEntity.Platform platform, + String version, + String minCommonVersion, + String note) { + } +} diff --git a/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java new file mode 100644 index 0000000..814df94 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/model/UnifiedReleaseResult.java @@ -0,0 +1,11 @@ +package com.xuqm.update.model; + +import com.xuqm.update.entity.AppVersionEntity; +import com.xuqm.update.entity.RnBundleEntity; + +import java.util.List; + +public record UnifiedReleaseResult( + List appVersions, + List rnBundles) { +} 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 new file mode 100644 index 0000000..3f621d4 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateAssetService.java @@ -0,0 +1,63 @@ +package com.xuqm.update.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.UUID; + +@Service +public class UpdateAssetService { + + @Value("${update.upload-dir:/tmp/xuqm-update}") + private String uploadDir; + + @Value("${update.base-url:https://update.dev.xuqinmin.com}") + private String baseUrl; + + public String storeAppPackage(MultipartFile apkFile) throws IOException { + if (apkFile == null || apkFile.isEmpty()) { + return null; + } + String filename = UUID.randomUUID() + "_" + apkFile.getOriginalFilename(); + Path dir = Paths.get(uploadDir, "apk"); + Files.createDirectories(dir); + Path dest = dir.resolve(filename); + apkFile.transferTo(dest.toFile()); + return baseUrl + "/files/apk/" + 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"); + } + String filename = moduleId + "." + platform.toLowerCase() + ".bundle"; + Path dir = Paths.get(uploadDir, "rn", appId, platform.toLowerCase(), moduleId); + Files.createDirectories(dir); + Path dest = dir.resolve(filename); + + String md5 = computeMd5(bundle); + bundle.transferTo(dest.toFile()); + return new StoredRnBundle(dest.toAbsolutePath().toString(), md5); + } + + private String computeMd5(MultipartFile file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("MD5"); + try (DigestInputStream dis = new DigestInputStream(file.getInputStream(), digest)) { + byte[] buf = new byte[8192]; + while (dis.read(buf) != -1) { + // read fully + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + public record StoredRnBundle(String bundlePath, String md5) {} +}