From cf2013a52dd27c01b9171285598bea43847cf22e Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Fri, 8 May 2026 18:32:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20=E6=B7=BB=E5=8A=A0=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=BA=8B=E4=BB=B6=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=BA=94=E7=94=A8=E5=AE=A1=E6=A0=B8=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=AE=9E=E6=97=B6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ImPlatformEventController 提供令牌获取接口 - 新增 InternalImPlatformEventController 处理内部通知请求 - 实现 ImPlatformEventService 核心服务逻辑包括令牌签发和消息推送 - 添加 StoreReviewImNotifier 在更新服务中触发审核状态变更通知 - 在前端平台中集成实时审核状态更新功能 - 配置各项目环境版本管理文件 (.java-version, .nvmrc) - 更新 Docker 忽略文件和 Maven 配置以优化构建流程 --- .dockerignore | 10 ++ maven-settings.xml | 48 +++++ .../controller/ImPlatformEventController.java | 44 +++++ .../InternalImPlatformEventController.java | 42 +++++ .../service/ImPlatformEventService.java | 165 ++++++++++++++++++ .../update/service/StoreReviewImNotifier.java | 81 +++++++++ 6 files changed, 390 insertions(+) create mode 100644 .dockerignore create mode 100644 maven-settings.xml create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/ImPlatformEventController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/InternalImPlatformEventController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b4d800 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.idea +.gradle +**/target +**/build +**/dist +**/node_modules +**/.DS_Store +**/*.log +*.iml diff --git a/maven-settings.xml b/maven-settings.xml new file mode 100644 index 0000000..9cfcce5 --- /dev/null +++ b/maven-settings.xml @@ -0,0 +1,48 @@ + + + + + xuqm-nexus + + + xuqm-maven-public + https://nexus.xuqinmin.com/repository/maven-public/ + + true + + + false + + + + xuqm-maven-snapshots + https://nexus.xuqinmin.com/repository/maven-snapshots/ + + false + + + true + + + + + + xuqm-maven-public + https://nexus.xuqinmin.com/repository/maven-public/ + + true + + + false + + + + + + + xuqm-nexus + + diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/ImPlatformEventController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/ImPlatformEventController.java new file mode 100644 index 0000000..7b035c5 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/ImPlatformEventController.java @@ -0,0 +1,44 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.service.ImPlatformEventService; +import com.xuqm.tenant.service.SdkAppProvisioningService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/im/platform-events") +public class ImPlatformEventController { + + private final SdkAppProvisioningService provisioningService; + private final ImPlatformEventService platformEventService; + + public ImPlatformEventController(SdkAppProvisioningService provisioningService, + ImPlatformEventService platformEventService) { + this.provisioningService = provisioningService; + this.platformEventService = platformEventService; + } + + @GetMapping("/token") + public ResponseEntity>> token( + @AuthenticationPrincipal String tenantId, + @RequestParam String appKey) { + try { + AppEntity app = provisioningService.resolveApp(appKey); + if (tenantId == null || tenantId.isBlank() || !tenantId.equals(app.getTenantId())) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + return ResponseEntity.ok(ApiResponse.success(platformEventService.issueToken(appKey))); + } catch (Exception e) { + return ResponseEntity.status(500) + .body(ApiResponse.error(500, e.getMessage())); + } + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalImPlatformEventController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalImPlatformEventController.java new file mode 100644 index 0000000..1931a76 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/InternalImPlatformEventController.java @@ -0,0 +1,42 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.service.ImPlatformEventService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/internal/im/platform-events") +public class InternalImPlatformEventController { + + private final ImPlatformEventService platformEventService; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + public InternalImPlatformEventController(ImPlatformEventService platformEventService) { + this.platformEventService = platformEventService; + } + + @PostMapping("/notify") + public ResponseEntity>> notify( + @RequestHeader(value = "X-Internal-Token", required = false) String token, + @RequestBody ImPlatformEventService.StoreReviewEventRequest request) { + if (token == null || !internalToken.equals(token)) { + return ResponseEntity.status(403).body(ApiResponse.error(403, "Forbidden")); + } + try { + return ResponseEntity.ok(ApiResponse.success(platformEventService.notifyStoreReviewChange(request))); + } catch (Exception e) { + return ResponseEntity.status(500) + .body(ApiResponse.error(500, e.getMessage())); + } + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java new file mode 100644 index 0000000..f19a9c7 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java @@ -0,0 +1,165 @@ +package com.xuqm.tenant.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.common.security.AppRequestSignatureUtil; +import com.xuqm.im.sdk.XuqmImServerSdk; +import com.xuqm.tenant.entity.AppEntity; +import org.springframework.beans.factory.annotation.Value; +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.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +@Service +public class ImPlatformEventService { + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final SdkAppProvisioningService provisioningService; + private final ObjectMapper objectMapper; + + @Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}") + private String imApiUrl; + + @Value("${sdk.im-platform-events-user-prefix:platform-events:}") + private String platformEventsUserPrefix; + + @Value("${sdk.im-platform-events-system-user:platform-events-system}") + private String platformEventsSystemUser; + + public ImPlatformEventService(SdkAppProvisioningService provisioningService, + ObjectMapper objectMapper) { + this.provisioningService = provisioningService; + this.objectMapper = objectMapper; + } + + public Map issueToken(String appKey) throws Exception { + AppEntity app = provisioningService.resolveApp(appKey); + XuqmImServerSdk sdk = sdk(app); + String userId = platformEventsUserId(appKey); + ensureAccount(sdk, app, userId, "平台通知"); + String token = requestImToken(app, userId); + Map result = new LinkedHashMap<>(); + result.put("appKey", app.getAppKey()); + result.put("userId", userId); + result.put("token", token); + return result; + } + + public Map notifyStoreReviewChange(StoreReviewEventRequest request) throws Exception { + AppEntity app = provisioningService.resolveApp(request.appKey()); + XuqmImServerSdk sdk = sdk(app); + String recipientUserId = platformEventsUserId(request.appKey()); + String senderUserId = platformEventsSystemUserId(); + + ensureAccount(sdk, app, recipientUserId, "平台通知"); + ensureAccount(sdk, app, senderUserId, "系统通知"); + + Map contentPayload = new LinkedHashMap<>(); + contentPayload.put("event", request.event() == null || request.event().isBlank() ? "store_review_update" : request.event()); + contentPayload.put("appKey", request.appKey()); + contentPayload.put("versionId", request.versionId() == null ? "" : request.versionId()); + contentPayload.put("storeType", request.storeType() == null ? "" : request.storeType()); + contentPayload.put("reviewState", request.reviewState() == null ? "" : request.reviewState()); + contentPayload.put("reviewReason", request.reviewReason() == null ? "" : request.reviewReason()); + contentPayload.put("stage", request.stage() == null ? "" : request.stage()); + contentPayload.put("batchId", request.batchId() == null ? "" : request.batchId()); + contentPayload.put("publishStatus", request.publishStatus() == null ? "" : request.publishStatus()); + contentPayload.put("source", request.source() == null ? "update-service" : request.source()); + contentPayload.put("timestamp", System.currentTimeMillis()); + String content = objectMapper.writeValueAsString(contentPayload); + + var message = sdk.sendMessage(new XuqmImServerSdk.SendMessageRequest( + UUID.randomUUID().toString(), + recipientUserId, + "SINGLE", + "NOTIFY", + content, + null + )); + + Map result = new LinkedHashMap<>(); + result.put("appKey", app.getAppKey()); + result.put("userId", recipientUserId); + result.put("messageId", message.id()); + return result; + } + + private void ensureAccount(XuqmImServerSdk sdk, AppEntity app, String userId, String suffix) { + sdk.importAccount( + userId, + app.getName() + " " + suffix, + app.getIconUrl(), + "UNKNOWN", + "ACTIVE" + ); + } + + private XuqmImServerSdk sdk(AppEntity app) { + return XuqmImServerSdk.builder() + .baseUrl(imApiUrl) + .appKey(app.getAppKey()) + .appSecret(app.getAppSecret()) + .build(); + } + + private String requestImToken(AppEntity app, String userId) throws Exception { + long timestamp = System.currentTimeMillis(); + String nonce = UUID.randomUUID().toString(); + String payload = AppRequestSignatureUtil.payload(app.getAppKey(), userId, timestamp, nonce); + String signature = AppRequestSignatureUtil.sign(app.getAppSecret(), payload); + URI uri = URI.create(imApiUrl + "/api/im/auth/login?appKey=" + + encodeQuery(app.getAppKey()) + + "&userId=" + + encodeQuery(userId)); + HttpRequest request = HttpRequest.newBuilder(uri) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("X-App-Timestamp", String.valueOf(timestamp)) + .header("X-App-Nonce", nonce) + .header("X-App-Signature", signature) + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("Failed to issue IM token: HTTP " + response.statusCode()); + } + var root = objectMapper.readTree(response.body()); + if (root.path("code").asInt() != 200) { + throw new IllegalStateException("Failed to issue IM token: " + root.path("message").asText("unknown error")); + } + String token = root.path("data").path("token").asText(null); + if (token == null || token.isBlank()) { + throw new IllegalStateException("Failed to issue IM token: empty token"); + } + return token; + } + + private String encodeQuery(String value) { + return java.net.URLEncoder.encode(value == null ? "" : value, java.nio.charset.StandardCharsets.UTF_8); + } + + private String platformEventsUserId(String appKey) { + return platformEventsUserPrefix + appKey; + } + + private String platformEventsSystemUserId() { + return platformEventsUserPrefix + platformEventsSystemUser; + } + + public record StoreReviewEventRequest( + String appKey, + String versionId, + String storeType, + String reviewState, + String reviewReason, + String stage, + String batchId, + String publishStatus, + String event, + String source + ) {} +} diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java b/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java new file mode 100644 index 0000000..646326c --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java @@ -0,0 +1,81 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +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.util.LinkedHashMap; +import java.util.Map; + +@Service +public class StoreReviewImNotifier { + + private static final Logger log = LoggerFactory.getLogger(StoreReviewImNotifier.class); + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") + private String tenantServiceUrl; + + @Value("${sdk.internal-token:xuqm-internal-token}") + private String internalToken; + + public void notifyStoreReviewChange(String appKey, + String versionId, + String storeType, + String reviewState, + String reviewReason, + String stage, + String batchId, + String publishStatus, + String event) { + try { + Map payload = new LinkedHashMap<>(); + payload.put("appKey", appKey); + payload.put("versionId", versionId == null ? "" : versionId); + payload.put("storeType", storeType == null ? "" : storeType); + payload.put("reviewState", reviewState == null ? "" : reviewState); + payload.put("reviewReason", reviewReason == null ? "" : reviewReason); + payload.put("stage", stage == null ? "" : stage); + payload.put("batchId", batchId == null ? "" : batchId); + payload.put("publishStatus", publishStatus == null ? "" : publishStatus); + payload.put("event", event == null || event.isBlank() ? "store_review_update" : event); + payload.put("source", "update-service"); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(trimTrailingSlash(tenantServiceUrl) + "/api/internal/im/platform-events/notify")) + .header("Content-Type", "application/json") + .header("X-Internal-Token", internalToken) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload))) + .build(); + + httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenAccept(response -> { + if (response.statusCode() >= 400) { + log.warn("IM platform event notify failed appKey={} status={} body={}", + appKey, response.statusCode(), response.body()); + } + }) + .exceptionally(e -> { + log.warn("IM platform event notify error appKey={} msg={}", appKey, e.getMessage()); + return null; + }); + } catch (Exception e) { + log.warn("IM platform event notify build failed appKey={} msg={}", appKey, e.getMessage()); + } + } + + private String trimTrailingSlash(String value) { + if (value == null) { + return ""; + } + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } +}