feat(im): 添加平台事件通知功能支持应用审核状态实时更新

- 新增 ImPlatformEventController 提供令牌获取接口
- 新增 InternalImPlatformEventController 处理内部通知请求
- 实现 ImPlatformEventService 核心服务逻辑包括令牌签发和消息推送
- 添加 StoreReviewImNotifier 在更新服务中触发审核状态变更通知
- 在前端平台中集成实时审核状态更新功能
- 配置各项目环境版本管理文件 (.java-version, .nvmrc)
- 更新 Docker 忽略文件和 Maven 配置以优化构建流程
这个提交包含在:
XuqmGroup 2026-05-08 18:32:46 +08:00
父节点 dc1ada94ea
当前提交 cf2013a52d
共有 6 个文件被更改,包括 390 次插入0 次删除

10
.dockerignore 普通文件
查看文件

@ -0,0 +1,10 @@
.git
.idea
.gradle
**/target
**/build
**/dist
**/node_modules
**/.DS_Store
**/*.log
*.iml

48
maven-settings.xml 普通文件
查看文件

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0
https://maven.apache.org/xsd/settings-1.2.0.xsd">
<profiles>
<profile>
<id>xuqm-nexus</id>
<repositories>
<repository>
<id>xuqm-maven-public</id>
<url>https://nexus.xuqinmin.com/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>xuqm-maven-snapshots</id>
<url>https://nexus.xuqinmin.com/repository/maven-snapshots/</url>
<releases>
<enabled>false</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>xuqm-maven-public</id>
<url>https://nexus.xuqinmin.com/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>xuqm-nexus</activeProfile>
</activeProfiles>
</settings>

查看文件

@ -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<ApiResponse<Map<String, String>>> 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()));
}
}
}

查看文件

@ -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<ApiResponse<Map<String, String>>> 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()));
}
}
}

查看文件

@ -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<String, String> 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<String, String> result = new LinkedHashMap<>();
result.put("appKey", app.getAppKey());
result.put("userId", userId);
result.put("token", token);
return result;
}
public Map<String, String> 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<String, Object> 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<String, String> 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<String> 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
) {}
}

查看文件

@ -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<String, Object> 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;
}
}