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;
+ }
+}