docs(deploy): 添加完整的部署文档和配置示例

- 新增 compose.production.yaml 和 compose.production.server.yaml 部署配置
- 添加 nginx.dev.xuqinmin.com.conf 和 nginx.sentry.xuqinmin.com.conf 反向代理配置
- 创建详细的部署指南文档 deploy/README.md,涵盖架构设计和部署步骤
- 添加前端访问文档 web/README.md,包含线上地址和接口说明
- 补充平台文档总览 README.md,整合各模块文档入口
- 配置多服务容器化部署,包括 tenant-service、im-service、push-service 等
- 设置外部数据库和 Redis 连接配置,确保服务间正确通信
- 配置 WebSocket 和 API 路由转发规则,支持实时通信和版本更新服务
这个提交包含在:
XuqmGroup 2026-05-09 14:53:42 +08:00
父节点 d54bfe25f1
当前提交 71929fef67
共有 25 个文件被更改,包括 854 次插入292 次删除

查看文件

@ -17,7 +17,7 @@ COPY demo-service ./demo-service
COPY file-service ./file-service
RUN --mount=type=cache,target=/root/.m2,sharing=locked \
mvn -s /workspace/maven-settings.xml -pl ${SERVICE_MODULE} -am -DskipTests package
mvn -U -s /workspace/maven-settings.xml -pl ${SERVICE_MODULE} -am -DskipTests package
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

查看文件

@ -138,7 +138,7 @@ cd update-service && mvn spring-boot:run &
}
```
> 说明SDK 和 demo 侧统一传 `appKey`。当前默认值是 `ak_demo_chat`,如果数据库里没有这条记录,tenant-service 会在启动时自动创建。
> 说明SDK 和 demo 侧统一传 `appKey`。当前默认值是 `ak_demo_chat`,如果数据库里没有这条记录,tenant-service 会在启动时自动创建。IM 登录必须先存在注册用户,不再支持“登录即注册”。
#### 功能服务(需 Token
@ -218,11 +218,10 @@ cd update-service && mvn spring-boot:run &
POST /api/im/auth/login
?appKey=ak_xxx
&userId=user_001
&nickname=张三 (可选,仅首次注册时存入外部系统)
&avatar=https://... (可选)
&userSig=your_user_sig
```
该接口需要由 demo-service 带上 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature` 头完成 AppSecret 验签
服务端可本地签发 `userSig`,IM 管理页也支持生成与校验;管理员账号可用于服务端 SDK / REST API
响应:`{ "data": { "token": "eyJ..." } }`

查看文件

@ -0,0 +1,142 @@
package com.xuqm.common.security;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
public final class UserSigUtil {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final String HMAC_ALG = "HmacSHA256";
private static final String HEADER_JSON = "{\"alg\":\"HS256\",\"typ\":\"UserSig\"}";
private UserSigUtil() {
}
public static String generate(String appSecret, String appKey, String userId) {
return generate(appSecret, appKey, userId, 180L * 24 * 60 * 60, "");
}
public static String generate(String appSecret, String appKey, String userId, long expireSeconds, String userBuf) {
long issuedAt = Instant.now().getEpochSecond();
long expiresAt = issuedAt + Math.max(expireSeconds, 60L);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("appKey", normalize(appKey));
payload.put("userId", normalize(userId));
payload.put("iat", issuedAt);
payload.put("exp", expiresAt);
payload.put("userBuf", normalize(userBuf));
payload.put("version", 1);
String encodedHeader = base64Url(HEADER_JSON.getBytes(StandardCharsets.UTF_8));
String encodedPayload = base64Url(writeJson(payload));
String signingInput = encodedHeader + "." + encodedPayload;
String signature = base64Url(hmac(appSecret, signingInput));
return signingInput + "." + signature;
}
public static UserSigClaims verify(String appSecret, String expectedAppKey, String expectedUserId, String userSig) {
String[] parts = normalize(userSig).split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid UserSig format");
}
String signingInput = parts[0] + "." + parts[1];
String expectedSignature = base64Url(hmac(appSecret, signingInput));
if (!MessageDigest.isEqual(
expectedSignature.getBytes(StandardCharsets.UTF_8),
parts[2].getBytes(StandardCharsets.UTF_8))) {
throw new IllegalArgumentException("Invalid UserSig signature");
}
JsonNode payload = readJson(base64UrlDecode(parts[1]));
String appKey = text(payload, "appKey");
String userId = text(payload, "userId");
long issuedAt = payload.path("iat").asLong(0L);
long expiresAt = payload.path("exp").asLong(0L);
String userBuf = text(payload, "userBuf");
long now = Instant.now().getEpochSecond();
if (expiresAt > 0 && now > expiresAt) {
throw new IllegalArgumentException("UserSig expired");
}
if (expectedAppKey != null && !expectedAppKey.isBlank() && !expectedAppKey.equals(appKey)) {
throw new IllegalArgumentException("UserSig appKey mismatch");
}
if (expectedUserId != null && !expectedUserId.isBlank() && !expectedUserId.equals(userId)) {
throw new IllegalArgumentException("UserSig userId mismatch");
}
return new UserSigClaims(appKey, userId, issuedAt, expiresAt, userBuf, userSig);
}
public static boolean matches(String appSecret, String expectedAppKey, String expectedUserId, String userSig) {
try {
verify(appSecret, expectedAppKey, expectedUserId, userSig);
return true;
} catch (Exception e) {
return false;
}
}
private static byte[] hmac(String secret, String value) {
try {
Mac mac = Mac.getInstance(HMAC_ALG);
mac.init(new SecretKeySpec(normalize(secret).getBytes(StandardCharsets.UTF_8), HMAC_ALG));
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Failed to sign UserSig", e);
}
}
private static byte[] writeJson(Map<String, Object> payload) {
try {
return OBJECT_MAPPER.writeValueAsBytes(payload);
} catch (Exception e) {
throw new IllegalStateException("Failed to serialize UserSig payload", e);
}
}
private static JsonNode readJson(byte[] bytes) {
try {
return OBJECT_MAPPER.readTree(bytes);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid UserSig payload", e);
}
}
private static String text(JsonNode node, String field) {
JsonNode value = node.path(field);
return value.isMissingNode() || value.isNull() ? "" : value.asText("");
}
private static String base64Url(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static byte[] base64UrlDecode(String value) {
return Base64.getUrlDecoder().decode(value);
}
private static String normalize(String value) {
return value == null ? "" : value;
}
public record UserSigClaims(
String appKey,
String userId,
long issuedAt,
long expiresAt,
String userBuf,
String userSig
) {}
}

查看文件

@ -105,7 +105,10 @@
| 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------|
| POST | `/api/im/auth/login` | 否 | 获取 IM Token;需要 `X-App-Timestamp` / `X-App-Nonce` / `X-App-Signature` |
| POST | `/api/im/auth/login` | 否 | 获取 IM Token;支持 `userSig` 登录 |
| GET | `/api/im/platform-events/token` | 是 | 获取平台事件账号 `platform` 的登录 token,用于实时刷新 |
| POST | `/api/im/admin/users/{userId}/usersig` | 是 | 生成 UserSig |
| POST | `/api/im/admin/users/{userId}/usersig/verify` | 是 | 校验 UserSig |
| GET | `/api/im/accounts/{userId}` | 是 | 查询用户资料 |
| PUT | `/api/im/accounts/{userId}` | 是 | 更新自己的资料 |
| GET | `/api/im/accounts/search` | 否 | 搜索账号 |
@ -128,6 +131,8 @@
| PUT | `/api/im/groups/{groupId}/attributes` | 是 | 设置群扩展属性 |
| POST | `/api/im/groups/{groupId}/attributes/delete` | 是 | 删除群扩展属性 |
| POST | `/api/im/messages/send` | 是 | 发送消息TEXT / IMAGE / AUDIO / VIDEO / FILE / LOCATION / CUSTOM / NOTIFY / RICH_TEXT / CALL_AUDIO / CALL_VIDEO / FORWARD / QUOTE / MERGE |
> IM 客户端登录使用 `userSig`;服务端 SDK 可以本地生成 `userSig` 并通过登录接口换取 IM Token。管理端接口可由具备管理员权限的 IM 账号使用。
| GET | `/api/im/messages/search` | 是 | 云端消息搜索 |
| PUT | `/api/im/messages/{id}` | 是 | 编辑自己发送的文本消息 |
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
@ -177,7 +182,7 @@
- 应用商店配置页分成两个 tab`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。
- 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret`
- 发布配置里新增 `allowAnonymousUpdateCheck` 开关,默认关闭。关闭时更新检查仍要求登录,且灰度发布可正常使用;开启后允许未登录设备检查更新,但所有灰度相关能力都会被禁用,服务端和租户平台都不会再允许操作灰度配置。
- `POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务
- `POST /api/im/auth/login` 推荐直接使用 `userSig`;服务端 SDK 可本地生成并校验 `userSig` 后再登录
- 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload``POST /api/v1/updates/app/inspect`
- 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。
- `GET /api/v1/updates/app/check` 现在按“当前版本之后的最高已发布版本”返回更新信息,但 `forceUpdate` 会按照“当前版本之后是否存在任意一条强更版本”来计算,所以后续发布的非强更版本不会覆盖更低版本当时应看到的强更提示。
@ -254,10 +259,10 @@ curl 'https://dev.xuqinmin.com/api/v1/rn/update/check?appKey=ak_demo_chat&platfo
### IM 登录
```bash
curl -X POST 'https://dev.xuqinmin.com/api/im/auth/login?appKey=ak_demo_chat&userId=demo_alice'
curl -X POST 'https://dev.xuqinmin.com/api/im/auth/login?appKey=ak_demo_chat&userId=demo_alice&userSig=your_user_sig'
```
返回示例中的 `data` 只包含 `token`。如果需要更新登录态,请由业务服务端重新调用登录接口并覆盖当前会话
返回示例中的 `data` 只包含 `token`。如果需要更新登录态,请由业务服务端重新签发 `userSig` 并重新登录
### IM 会话与关系链

查看文件

@ -24,6 +24,7 @@ import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.HexFormat;
import java.util.List;
@ -62,6 +63,81 @@ public final class XuqmImServerSdk {
return new Builder();
}
public String generateUserSig(String userId) {
return generateUserSig(userId, 180L * 24 * 60 * 60, "");
}
public String generateUserSig(String userId, long expireSeconds, String userBuf) {
long issuedAt = Instant.now().getEpochSecond();
long expiresAt = issuedAt + Math.max(expireSeconds, 60L);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("appKey", appKey);
payload.put("userId", userId);
payload.put("iat", issuedAt);
payload.put("exp", expiresAt);
payload.put("userBuf", userBuf == null ? "" : userBuf);
payload.put("version", 1);
String header = base64Url("{\"alg\":\"HS256\",\"typ\":\"UserSig\"}");
String body = base64Url(writeJson(payload));
String signature = base64Url(hmac(appSecret, header + "." + body));
return header + "." + body + "." + signature;
}
public boolean verifyUserSig(String userId, String userSig) {
try {
verifyUserSigClaims(userId, userSig);
return true;
} catch (Exception e) {
return false;
}
}
public UserSigClaims verifyUserSigClaims(String userId, String userSig) {
String[] parts = normalize(userSig).split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("Invalid UserSig format");
}
String signingInput = parts[0] + "." + parts[1];
String expectedSignature = base64Url(hmac(appSecret, signingInput));
if (!MessageDigest.isEqual(
expectedSignature.getBytes(StandardCharsets.UTF_8),
parts[2].getBytes(StandardCharsets.UTF_8))) {
throw new IllegalArgumentException("Invalid UserSig signature");
}
JsonNode payload = readJson(base64UrlDecode(parts[1]));
String tokenAppKey = text(payload, "appKey");
String tokenUserId = text(payload, "userId");
long issuedAt = payload.path("iat").asLong(0L);
long expiresAt = payload.path("exp").asLong(0L);
String userBuf = text(payload, "userBuf");
long now = Instant.now().getEpochSecond();
if (expiresAt > 0 && now > expiresAt) {
throw new IllegalArgumentException("UserSig expired");
}
if (!Objects.equals(appKey, tokenAppKey)) {
throw new IllegalArgumentException("UserSig appKey mismatch");
}
if (userId != null && !userId.isBlank() && !Objects.equals(userId, tokenUserId)) {
throw new IllegalArgumentException("UserSig userId mismatch");
}
return new UserSigClaims(tokenAppKey, tokenUserId, issuedAt, expiresAt, userBuf, normalize(userSig));
}
public LoginResponse login(String userId) {
return loginWithUserSig(userId, generateUserSig(userId));
}
public LoginResponse loginWithUserSig(String userId, String userSig) {
ApiResponse<LoginResponse> response = request(
"POST",
buildUri("/api/im/auth/login", Map.of("appKey", appKey, "userId", userId, "userSig", userSig)),
null,
null,
new TypeReference<>() {}
);
return response.data();
}
public ImMessage sendMessage(SendMessageRequest request) {
ApiResponse<ImMessage> response = request(
"POST",
@ -99,7 +175,7 @@ public final class XuqmImServerSdk {
ApiResponse<AccountView> response = request(
"POST",
buildUri("/api/im/accounts/import", appQuery()),
new ImportAccountRequest(userId, nickname, avatar, gender, status),
new ImportAccountRequest(userId, nickname, avatar, gender, status, null),
authorizedHeaders(),
new TypeReference<>() {}
);
@ -428,6 +504,55 @@ public final class XuqmImServerSdk {
);
}
public Map<String, Object> pushUserStatus(String userId) {
ApiResponse<Map<String, Object>> response = request(
"GET",
buildUri(pushBaseUrl, "/api/push/admin/user-status", Map.of(
"appKey", appKey,
"userId", userId
)),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public Map<String, Object> pushDeviceLogs(String userId, int page, int size) {
ApiResponse<Map<String, Object>> response = request(
"GET",
buildUri(pushBaseUrl, "/api/push/admin/device-logs", Map.of(
"appKey", appKey,
"userId", userId,
"page", String.valueOf(page),
"size", String.valueOf(size)
)),
null,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public Map<String, Object> testOfflinePush(String userId, String title, String body, String payload) {
Map<String, Object> req = new LinkedHashMap<>();
req.put("appKey", appKey);
req.put("userId", userId);
req.put("title", title);
req.put("body", body);
if (payload != null && !payload.isBlank()) {
req.put("payload", payload);
}
ApiResponse<Map<String, Object>> response = request(
"POST",
buildUri(pushBaseUrl, "/api/push/admin/test-offline", Map.of()),
req,
authorizedHeaders(),
new TypeReference<>() {}
);
return response.data();
}
public Map<String, Object> checkAppUpdate(String platform, int currentVersionCode) {
ApiResponse<Map<String, Object>> response = request(
"GET",
@ -1785,6 +1910,7 @@ public final class XuqmImServerSdk {
}
public record LoginResponse(String token) {}
public record UserSigClaims(String appKey, String userId, long issuedAt, long expiresAt, String userBuf, String userSig) {}
public record ConversationView(
String targetId,
@ -1811,6 +1937,7 @@ public final class XuqmImServerSdk {
String nickname,
String gender,
String avatar,
boolean admin,
String status,
LocalDateTime createdAt
) {}
@ -1820,7 +1947,8 @@ public final class XuqmImServerSdk {
String nickname,
String avatar,
String gender,
String status
String status,
Boolean admin
) {}
public record FriendLinkView(
@ -2165,6 +2293,44 @@ public final class XuqmImServerSdk {
}
}
private byte[] writeJson(Map<String, Object> payload) {
try {
return objectMapper.writeValueAsBytes(payload);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize UserSig payload", e);
}
}
private JsonNode readJson(byte[] bytes) {
try {
return objectMapper.readTree(bytes);
} catch (IOException e) {
throw new IllegalArgumentException("Invalid UserSig payload", e);
}
}
private static byte[] hmac(String secret, String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("Failed to sign UserSig", e);
}
}
private static String base64Url(String value) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8));
}
private static String base64Url(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private static byte[] base64UrlDecode(String value) {
return Base64.getUrlDecoder().decode(value);
}
private static List<String> parseJsonStringList(String value) {
if (value == null || value.isBlank()) {
return List.of();

查看文件

@ -50,7 +50,7 @@ public class AccountController {
throw new BusinessException(403, "Only the account owner can update profile");
}
return ResponseEntity.ok(ApiResponse.success(
accountService.updateAccount(appKey, userId, nickname, avatar, gender)));
accountService.updateAccount(appKey, userId, nickname, avatar, gender, null, null)));
}
@GetMapping("/search")
@ -66,7 +66,7 @@ public class AccountController {
@RequestParam String appKey,
@RequestBody ImportAccountRequest req) {
return ResponseEntity.ok(ApiResponse.success(
accountService.importAccount(appKey, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status())));
accountService.importAccount(appKey, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status(), req.admin())));
}
@PostMapping("/import/batch")
@ -77,7 +77,7 @@ public class AccountController {
appKey,
req == null ? List.of() : req.stream()
.map(item -> new ImAccountService.ImportAccountRequest(
item.userId(), item.nickname(), item.avatar(), item.gender(), item.status()))
item.userId(), item.nickname(), item.avatar(), item.gender(), item.status(), item.admin()))
.toList())));
}
@ -101,6 +101,7 @@ public class AccountController {
String nickname,
String avatar,
ImAccountEntity.Gender gender,
ImAccountEntity.Status status
ImAccountEntity.Status status,
Boolean admin
) {}
}

查看文件

@ -4,7 +4,6 @@ import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.service.ImAccountService;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@ -26,14 +25,11 @@ public class AuthController {
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
@RequestParam @NotBlank String appKey,
@RequestParam @NotBlank String userId,
@RequestHeader(value = "X-App-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-App-Nonce", required = false) String nonce,
@RequestHeader(value = "X-App-Signature", required = false) String signature) {
if (timestamp == null || nonce == null || signature == null) {
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
@RequestParam @NotBlank String userSig) {
if (userSig.isBlank()) {
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing userSig"));
}
accountService.validateSignature(appKey, userId, timestamp, nonce, signature);
ImAccountService.LoginResult result = accountService.loginOrRegister(appKey, userId);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token())));
ImAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin())));
}
}

查看文件

@ -7,6 +7,9 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@ -42,6 +45,17 @@ public class GlobalExceptionHandler {
return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误"));
}
@ExceptionHandler(AuthorizationDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAuthorizationDenied(AuthorizationDeniedException e) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String principal = authentication == null ? null : String.valueOf(authentication.getPrincipal());
String authorities = authentication == null ? "[]" : authentication.getAuthorities().toString();
log.warn("Access denied path={} principal={} authorities={} reason={}",
"im-service", principal, authorities, e.getMessage());
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(403, "Forbidden: current token lacks required role"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unhandled exception", e);

查看文件

@ -2,6 +2,7 @@ package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.UserSigUtil;
import com.xuqm.im.entity.ImBlacklistEntity;
import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.entity.ImGlobalMuteEntity;
@ -46,6 +47,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/im/admin")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public class ImAdminController {
private final ImAccountRepository accountRepository;
@ -144,7 +146,8 @@ public class ImAdminController {
req.nickname(),
req.avatar(),
req.gender(),
req.status());
req.status(),
req.admin());
operationLogService.record(appKey, operatorId, "UPDATE_USER", "ACCOUNT", userId, req.nickname());
return ResponseEntity.ok(ApiResponse.success(saved));
}
@ -167,11 +170,60 @@ public class ImAdminController {
req.nickname(),
req.avatar(),
req.gender(),
req.status());
req.status(),
req.admin());
operationLogService.record(appKey, operatorId, "REGISTER_USER", "ACCOUNT", req.userId(), req.nickname());
return ResponseEntity.ok(ApiResponse.success(account));
}
@PostMapping("/users/{userId}/usersig")
public ResponseEntity<ApiResponse<Map<String, Object>>> generateUserSig(
@RequestParam String appKey,
@PathVariable String userId,
@AuthenticationPrincipal String operatorId,
@RequestBody(required = false) UserSigRequest req) {
ImAccountEntity account = accountService.getAccount(appKey, userId);
if (!account.isAdmin()) {
throw new BusinessException(403, "Only admin accounts can generate UserSig for service-side usage");
}
long expireSeconds = req == null || req.expireSeconds() == null ? 180L * 24 * 60 * 60 : Math.max(req.expireSeconds(), 60L);
String userBuf = req == null ? "" : (req.userBuf() == null ? "" : req.userBuf());
String userSig = accountService.generateUserSigToken(appKey, userId, expireSeconds, userBuf);
UserSigUtil.UserSigClaims claims = accountService.verifyUserSig(appKey, userId, userSig);
operationLogService.record(appKey, operatorId, "GENERATE_USERSIG", "ACCOUNT", userId,
account.isAdmin() ? "admin" : "user");
return ResponseEntity.ok(ApiResponse.success(Map.of(
"appKey", claims.appKey(),
"userId", claims.userId(),
"userSig", claims.userSig(),
"issuedAt", claims.issuedAt(),
"expiresAt", claims.expiresAt(),
"userBuf", claims.userBuf()
)));
}
@PostMapping("/users/{userId}/usersig/verify")
public ResponseEntity<ApiResponse<Map<String, Object>>> verifyUserSig(
@RequestParam String appKey,
@PathVariable String userId,
@AuthenticationPrincipal String operatorId,
@RequestBody UserSigVerifyRequest req) {
ImAccountEntity account = accountService.getAccount(appKey, userId);
if (!account.isAdmin()) {
throw new BusinessException(403, "Only admin accounts can verify service-side UserSig");
}
UserSigUtil.UserSigClaims claims = accountService.verifyUserSig(appKey, userId, req.userSig());
operationLogService.record(appKey, operatorId, "VERIFY_USERSIG", "ACCOUNT", userId, "ok");
return ResponseEntity.ok(ApiResponse.success(Map.of(
"valid", true,
"appKey", claims.appKey(),
"userId", claims.userId(),
"issuedAt", claims.issuedAt(),
"expiresAt", claims.expiresAt(),
"userBuf", claims.userBuf()
)));
}
/** Admin creates a group. */
@PostMapping("/groups")
public ResponseEntity<ApiResponse<ImGroupEntity>> createGroup(
@ -670,7 +722,7 @@ public class ImAdminController {
}
@GetMapping("/users/state")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public ResponseEntity<ApiResponse<Map<String, Object>>> queryUserState(
@RequestParam String userIds) {
Map<String, Object> result = new LinkedHashMap<>();
@ -687,7 +739,7 @@ public class ImAdminController {
}
@PostMapping("/users/kick")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public ResponseEntity<ApiResponse<Void>> kickUsers(
@RequestParam String appKey,
@AuthenticationPrincipal String operatorId,
@ -703,7 +755,7 @@ public class ImAdminController {
}
@PostMapping("/messages/batch-send")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> batchSendMsg(
@RequestParam String appKey,
@AuthenticationPrincipal String operatorId,
@ -719,7 +771,7 @@ public class ImAdminController {
}
@PostMapping("/messages/read")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public ResponseEntity<ApiResponse<Void>> adminSetMsgRead(
@RequestParam String appKey,
@AuthenticationPrincipal String operatorId,
@ -730,7 +782,7 @@ public class ImAdminController {
}
@PostMapping("/messages/import")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> importMessages(
@RequestParam String appKey,
@AuthenticationPrincipal String operatorId,
@ -746,12 +798,16 @@ public class ImAdminController {
String nickname,
String avatar,
ImAccountEntity.Gender gender,
ImAccountEntity.Status status) {}
ImAccountEntity.Status status,
Boolean admin) {}
public record UpdateUserRequest(
String nickname,
String avatar,
ImAccountEntity.Gender gender,
ImAccountEntity.Status status) {}
ImAccountEntity.Status status,
Boolean admin) {}
public record UserSigRequest(Long expireSeconds, String userBuf) {}
public record UserSigVerifyRequest(String userSig) {}
public record CreateGroupRequest(String name, String creatorId, List<String> memberIds, String groupType, String announcement) {}
public record UpdateGroupRequest(String name, String groupType, String announcement) {}
public record WebhookConfigRequest(String url, String secret, Boolean enabled) {}

查看文件

@ -0,0 +1,41 @@
package com.xuqm.im.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.im.service.ImAccountService;
import org.springframework.http.ResponseEntity;
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.LinkedHashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/im/platform-events")
public class PlatformEventController {
private final ImAccountService accountService;
public PlatformEventController(ImAccountService accountService) {
this.accountService = accountService;
}
@GetMapping("/token")
public ResponseEntity<ApiResponse<Map<String, String>>> token(
@RequestParam String appKey,
@RequestParam(defaultValue = "platform") String userId) {
try {
String userSig = accountService.generateUserSigToken(appKey, userId, 24 * 60 * 60, "");
ImAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
Map<String, String> data = new LinkedHashMap<>();
data.put("appKey", appKey);
data.put("userId", userId);
data.put("token", result.token());
data.put("admin", String.valueOf(result.admin()));
return ResponseEntity.ok(ApiResponse.success(data));
} catch (Exception e) {
return ResponseEntity.status(500).body(ApiResponse.error(500, e.getMessage()));
}
}
}

查看文件

@ -38,6 +38,9 @@ public class ImAccountEntity {
@Column(length = 512)
private String avatar;
@Column(nullable = false)
private boolean admin;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Status status;
@ -64,6 +67,9 @@ public class ImAccountEntity {
public String getAvatar() { return avatar; }
public void setAvatar(String avatar) { this.avatar = avatar; }
public boolean isAdmin() { return admin; }
public void setAdmin(boolean admin) { this.admin = admin; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }

查看文件

@ -7,5 +7,5 @@ import java.util.List;
public interface KeywordFilterRepository extends JpaRepository<KeywordFilterEntity, String> {
List<KeywordFilterEntity> findByAppKeyAndEnabledTrue(String appKey);
List<KeywordFilterEntity> findByAppKey(String appKey);
java.util.Optional<KeywordFilterEntity> findByIdAndAppId(String id, String appKey);
java.util.Optional<KeywordFilterEntity> findByIdAndAppKey(String id, String appKey);
}

查看文件

@ -7,5 +7,5 @@ import java.util.List;
public interface WebhookConfigRepository extends JpaRepository<WebhookConfigEntity, String> {
List<WebhookConfigEntity> findByAppKeyAndEnabledTrue(String appKey);
List<WebhookConfigEntity> findByAppKey(String appKey);
java.util.Optional<WebhookConfigEntity> findByIdAndAppId(String id, String appKey);
java.util.Optional<WebhookConfigEntity> findByIdAndAppKey(String id, String appKey);
}

查看文件

@ -1,8 +1,8 @@
package com.xuqm.im.service;
import com.xuqm.common.exception.BusinessException;
import com.xuqm.common.security.AppRequestSignatureUtil;
import com.xuqm.common.security.JwtUtil;
import com.xuqm.common.security.UserSigUtil;
import com.xuqm.im.entity.ImAccountEntity;
import com.xuqm.im.repository.ImAccountRepository;
import org.springframework.data.domain.PageRequest;
@ -16,7 +16,7 @@ import java.util.UUID;
@Service
public class ImAccountService {
public record LoginResult(String token) {}
public record LoginResult(String token, boolean admin) {}
private final ImAccountRepository accountRepository;
private final JwtUtil jwtUtil;
@ -28,42 +28,36 @@ public class ImAccountService {
this.appSecretClient = appSecretClient;
}
public void validateSignature(String appKey,
String userId,
String timestamp,
String nonce,
String signature) {
long ts;
try {
ts = Long.parseLong(timestamp);
} catch (NumberFormatException e) {
throw new BusinessException(401, "Invalid app signature");
}
public UserSigUtil.UserSigClaims generateUserSig(String appKey, String userId, long expireSeconds, String userBuf) {
String secret = appSecretClient.getAppSecret(appKey);
String payload = AppRequestSignatureUtil.payload(appKey, userId, ts, nonce);
if (!AppRequestSignatureUtil.matches(secret, payload, signature)) {
throw new BusinessException(401, "Invalid app signature");
}
String userSig = UserSigUtil.generate(secret, appKey, userId, expireSeconds, userBuf);
return UserSigUtil.verify(secret, appKey, userId, userSig);
}
public LoginResult loginOrRegister(String appKey, String userId) {
public UserSigUtil.UserSigClaims verifyUserSig(String appKey, String userId, String userSig) {
String secret = appSecretClient.getAppSecret(appKey);
return UserSigUtil.verify(secret, appKey, userId, userSig);
}
public String generateUserSigToken(String appKey, String userId, long expireSeconds, String userBuf) {
String secret = appSecretClient.getAppSecret(appKey);
return UserSigUtil.generate(secret, appKey, userId, expireSeconds, userBuf);
}
public LoginResult loginWithUserSig(String appKey, String userId, String userSig) {
UserSigUtil.verify(appSecretClient.getAppSecret(appKey), appKey, userId, userSig);
return login(appKey, userId);
}
public LoginResult login(String appKey, String userId) {
ImAccountEntity account = accountRepository.findByAppKeyAndUserId(appKey, userId)
.orElseGet(() -> {
ImAccountEntity e = new ImAccountEntity();
e.setId(UUID.randomUUID().toString());
e.setAppKey(appKey);
e.setUserId(userId);
e.setGender(ImAccountEntity.Gender.UNKNOWN);
e.setStatus(ImAccountEntity.Status.ACTIVE);
e.setCreatedAt(LocalDateTime.now());
return accountRepository.save(e);
});
.orElseThrow(() -> new BusinessException(404, "账号不存在,请先注册"));
if (account.getStatus() == ImAccountEntity.Status.BANNED) {
throw new BusinessException(403, "账号已被封禁");
}
return new LoginResult(jwtUtil.generate(userId, Map.of("appKey", appKey, "role", "USER")));
String role = account.isAdmin() ? "ADMIN" : "USER";
return new LoginResult(jwtUtil.generate(userId, Map.of("appKey", appKey, "role", role)), account.isAdmin());
}
public ImAccountEntity getAccount(String appKey, String userId) {
@ -86,18 +80,21 @@ public class ImAccountService {
String nickname,
String avatar,
ImAccountEntity.Gender gender,
ImAccountEntity.Status status) {
ImAccountEntity.Status status,
Boolean admin) {
ImAccountEntity account = getAccount(appKey, userId);
if (nickname != null) account.setNickname(nickname);
if (avatar != null) account.setAvatar(avatar);
if (gender != null) account.setGender(gender);
if (status != null) account.setStatus(status);
if (admin != null) account.setAdmin(admin);
return accountRepository.save(account);
}
public ImAccountEntity importAccount(String appKey, String userId, String nickname,
String avatar, ImAccountEntity.Gender gender,
ImAccountEntity.Status status) {
ImAccountEntity.Status status,
Boolean admin) {
ImAccountEntity account = accountRepository.findByAppKeyAndUserId(appKey, userId)
.orElseGet(() -> {
ImAccountEntity entity = new ImAccountEntity();
@ -111,13 +108,16 @@ public class ImAccountService {
account.setAvatar(avatar);
account.setGender(gender == null ? ImAccountEntity.Gender.UNKNOWN : gender);
account.setStatus(status == null ? ImAccountEntity.Status.ACTIVE : status);
if (admin != null) {
account.setAdmin(admin);
}
return accountRepository.save(account);
}
public List<ImAccountEntity> importAccounts(String appKey, List<ImportAccountRequest> requests) {
return requests == null ? List.of() : requests.stream()
.filter(req -> req != null && req.userId() != null && !req.userId().isBlank())
.map(req -> importAccount(appKey, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status()))
.map(req -> importAccount(appKey, req.userId(), req.nickname(), req.avatar(), req.gender(), req.status(), req.admin()))
.toList();
}
@ -139,6 +139,7 @@ public class ImAccountService {
String nickname,
String avatar,
ImAccountEntity.Gender gender,
ImAccountEntity.Status status
ImAccountEntity.Status status,
Boolean admin
) {}
}

查看文件

@ -56,7 +56,7 @@ public class KeywordFilterService {
}
public KeywordFilterEntity update(String appKey, String id, String pattern, String replacement, String action, Boolean enabled) {
KeywordFilterEntity entity = repository.findByIdAndAppId(id, appKey)
KeywordFilterEntity entity = repository.findByIdAndAppKey(id, appKey)
.orElseThrow(() -> new BusinessException(404, "关键词过滤规则不存在"));
if (pattern != null) {
entity.setPattern(pattern);
@ -74,7 +74,7 @@ public class KeywordFilterService {
}
public void delete(String appKey, String id) {
KeywordFilterEntity entity = repository.findByIdAndAppId(id, appKey)
KeywordFilterEntity entity = repository.findByIdAndAppKey(id, appKey)
.orElseThrow(() -> new BusinessException(404, "关键词过滤规则不存在"));
repository.delete(entity);
}

查看文件

@ -23,7 +23,7 @@ public class WebhookConfigService {
}
public WebhookConfigEntity get(String appKey, String id) {
return repository.findByIdAndAppId(id, appKey)
return repository.findByIdAndAppKey(id, appKey)
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
}
@ -39,7 +39,7 @@ public class WebhookConfigService {
}
public WebhookConfigEntity update(String appKey, String id, String url, String secret, Boolean enabled) {
WebhookConfigEntity entity = repository.findByIdAndAppId(id, appKey)
WebhookConfigEntity entity = repository.findByIdAndAppKey(id, appKey)
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
if (url != null) {
entity.setUrl(url);
@ -54,7 +54,7 @@ public class WebhookConfigService {
}
public void delete(String appKey, String id) {
WebhookConfigEntity entity = repository.findByIdAndAppId(id, appKey)
WebhookConfigEntity entity = repository.findByIdAndAppKey(id, appKey)
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
repository.delete(entity);
}

查看文件

@ -5,6 +5,7 @@ import com.xuqm.push.entity.DeviceLoginLogEntity;
import com.xuqm.push.service.PushDiagnosticsService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@ -16,6 +17,7 @@ import java.util.Map;
@RestController
@RequestMapping("/api/push/admin")
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
public class PushManagementController {
private final PushDiagnosticsService diagnosticsService;

查看文件

@ -79,7 +79,8 @@ public class AuthService {
return jwtUtil.generate(tenant.getId(), Map.of(
"username", tenant.getUsername(),
"nickname", tenant.getNickname(),
"type", tenant.getType().name()
"type", tenant.getType().name(),
"role", "TENANT"
));
}

查看文件

@ -1,16 +1,14 @@
package com.xuqm.tenant.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.common.security.AppRequestSignatureUtil;
import com.xuqm.common.security.UserSigUtil;
import com.xuqm.im.sdk.XuqmImServerSdk;
import com.xuqm.tenant.entity.AppEntity;
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;
import java.util.UUID;
@ -18,18 +16,19 @@ import java.util.UUID;
@Service
public class ImPlatformEventService {
private final HttpClient httpClient = HttpClient.newHttpClient();
private static final Logger log = LoggerFactory.getLogger(ImPlatformEventService.class);
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-recipient-user:platform}")
private String platformEventsRecipientUser;
@Value("${sdk.im-platform-events-system-user:platform-events-system}")
private String platformEventsSystemUser;
@Value("${sdk.im-platform-events-admin-user:admin}")
private String platformEventsAdminUser;
public ImPlatformEventService(SdkAppProvisioningService provisioningService,
ObjectMapper objectMapper) {
@ -39,25 +38,23 @@ public class ImPlatformEventService {
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 userId = platformEventsRecipientUserId();
log.info("IM platform event token login start appKey={} userId={}", app.getAppKey(), 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);
log.info("IM platform event token issued appKey={} userId={}", app.getAppKey(), userId);
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, "系统通知");
String recipientUserId = platformEventsRecipientUserId();
String senderUserId = platformEventsAdminUserId();
String senderToken = requestImToken(app, senderUserId);
XuqmImServerSdk sdk = sdk(app, senderToken);
Map<String, Object> contentPayload = new LinkedHashMap<>();
contentPayload.put("event", request.event() == null || request.event().isBlank() ? "store_review_update" : request.event());
@ -73,6 +70,10 @@ public class ImPlatformEventService {
contentPayload.put("timestamp", System.currentTimeMillis());
String content = objectMapper.writeValueAsString(contentPayload);
log.info("IM platform event send appKey={} recipient={} sender={} event={} storeType={} state={} stage={} batchId={}",
app.getAppKey(), recipientUserId, senderUserId,
request.event() == null || request.event().isBlank() ? "store_review_update" : request.event(),
request.storeType(), request.reviewState(), request.stage(), request.batchId());
var message = sdk.sendMessage(new XuqmImServerSdk.SendMessageRequest(
UUID.randomUUID().toString(),
recipientUserId,
@ -81,6 +82,8 @@ public class ImPlatformEventService {
content,
null
));
log.info("IM platform event message sent appKey={} recipient={} messageId={}",
app.getAppKey(), recipientUserId, message.id());
Map<String, String> result = new LinkedHashMap<>();
result.put("appKey", app.getAppKey());
@ -89,65 +92,35 @@ public class ImPlatformEventService {
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) {
private XuqmImServerSdk sdk(AppEntity app, String bearerToken) {
return XuqmImServerSdk.builder()
.baseUrl(imApiUrl)
.appKey(app.getAppKey())
.appSecret(app.getAppSecret())
.bearerTokenSupplier(() -> bearerToken)
.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())
XuqmImServerSdk sdk = XuqmImServerSdk.builder()
.baseUrl(imApiUrl)
.appKey(app.getAppKey())
.appSecret(app.getAppSecret())
.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);
String userSig = UserSigUtil.generate(app.getAppSecret(), app.getAppKey(), userId);
String token = sdk.loginWithUserSig(userId, userSig).token();
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 platformEventsRecipientUserId() {
return platformEventsRecipientUser;
}
private String platformEventsUserId(String appKey) {
return platformEventsUserPrefix + appKey;
}
private String platformEventsSystemUserId() {
return platformEventsUserPrefix + platformEventsSystemUser;
private String platformEventsAdminUserId() {
return platformEventsAdminUser;
}
public record StoreReviewEventRequest(

查看文件

@ -88,4 +88,5 @@ sdk:
im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im}
file-service-url: ${SDK_FILE_SERVICE_URL:https://file.dev.xuqinmin.com}
im-api-url: ${SDK_IM_API_URL:https://im.dev.xuqinmin.com}
im-platform-events-user-prefix: ${SDK_IM_PLATFORM_EVENTS_USER_PREFIX:platform-events:}
im-platform-events-recipient-user: ${SDK_IM_PLATFORM_EVENTS_RECIPIENT_USER:platform}
im-platform-events-admin-user: ${SDK_IM_PLATFORM_EVENTS_ADMIN_USER:admin}

查看文件

@ -122,7 +122,7 @@ public class AppStoreController {
scheduledAt = java.time.LocalDateTime.parse(scheduledAtText);
}
AppVersionEntity v = storeService.markSubmitted(versionId,
storeTypes != null ? storeTypes : List.of(),
storeTypes,
submitMode,
scheduledAt,
autoPublishAfterReview);
@ -194,7 +194,8 @@ public class AppStoreController {
return;
}
try {
OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
Map<String, Object> config = OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
validateStoreConfigFields(storeType, config);
} catch (Exception e) {
throw new IllegalArgumentException("invalid store config payload: " + e.getMessage(), e);
}
@ -202,4 +203,45 @@ public class AppStoreController {
validateReviewWebhook(configJson);
}
}
private void validateStoreConfigFields(AppStoreConfigEntity.StoreType storeType, Map<String, Object> config) {
if (storeType == null || config == null) {
return;
}
switch (storeType) {
case HUAWEI, HONOR, OPPO -> {
requireText(config, "clientId", storeType.name());
requireText(config, "clientSecret", storeType.name());
}
case MI -> {
requireAnyText(config, storeType.name(), "account", "username", "userName");
requireAnyText(config, storeType.name(), "publicKey", "publicKeyPem", "public_key", "certificate", "cert", "publicCert", "publicCertPem");
requireText(config, "privateKey", storeType.name());
}
case VIVO -> {
requireText(config, "accessKey", storeType.name());
requireText(config, "accessSecret", storeType.name());
}
case APP_STORE, GOOGLE_PLAY, HARMONY_APP, REVIEW_WEBHOOK -> {
// no extra validation here; marketUrl / webhookUrl / service account rules are handled elsewhere
}
}
}
private void requireText(Map<String, Object> config, String key, String storeType) {
Object value = config.get(key);
if (value == null || value.toString().isBlank()) {
throw new IllegalArgumentException(storeType + " config missing required field: " + key);
}
}
private void requireAnyText(Map<String, Object> config, String storeType, String... keys) {
for (String key : keys) {
Object value = config.get(key);
if (value != null && !value.toString().isBlank()) {
return;
}
}
throw new IllegalArgumentException(storeType + " config missing required field: " + String.join(" / ", keys));
}
}

查看文件

@ -38,7 +38,7 @@ public class AppStoreConfigEntity {
* Every store config also carries its own marketUrl / jump page.
*
* HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."}
* MI: {"username":"...","privateKey":"..."}
* MI: {"username":"...","publicKey":"...","privateKey":"..."}
* OPPO: {"clientId":"...","clientSecret":"..."}
* VIVO: {"accessKey":"...","accessSecret":"..."}
* GOOGLE_PLAY: {"serviceAccountJson":"..."}

查看文件

@ -20,6 +20,8 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Service
public class AppStoreService {
@ -33,6 +35,7 @@ public class AppStoreService {
private final RnBundleRepository rnBundleRepository;
private final UpdateOperationLogService operationLogService;
private final StoreReviewImNotifier storeReviewImNotifier;
private final ConcurrentMap<String, Object> versionLocks = new ConcurrentHashMap<>();
public AppStoreService(AppStoreConfigRepository configRepo,
AppVersionRepository versionRepo,
@ -111,7 +114,9 @@ public class AppStoreService {
String submitMode,
LocalDateTime scheduledAt,
Boolean autoPublishAfterReview) throws Exception {
synchronized (lockFor(versionId)) {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
List<String> resolvedTargets = normalizeTargets(v.getAppKey(), storeTypes);
String normalizedMode = submitMode == null || submitMode.isBlank()
? "MANUAL"
: submitMode.trim().toUpperCase(Locale.ROOT);
@ -120,7 +125,7 @@ public class AppStoreService {
}
Map<String, Object> reviewMap = new LinkedHashMap<>();
for (String store : storeTypes) {
for (String store : resolvedTargets) {
reviewMap.put(store, reviewPayload(
AppVersionEntity.StoreReviewState.PENDING.name(),
null,
@ -129,7 +134,7 @@ public class AppStoreService {
null,
LocalDateTime.now().toString()));
}
v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes));
v.setStoreSubmitTargets(mapper.writeValueAsString(resolvedTargets));
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
v.setStoreSubmitMode(normalizedMode);
v.setStoreSubmitScheduledAt(scheduledAt);
@ -144,7 +149,7 @@ public class AppStoreService {
"STORE_SUBMIT_REQUEST",
null,
Map.of(
"storeTypes", storeTypes,
"storeTypes", resolvedTargets,
"submitMode", saved.getStoreSubmitMode(),
"scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(),
"autoPublishAfterReview", saved.isAutoPublishAfterReview()
@ -161,6 +166,7 @@ public class AppStoreService {
"store_submit_requested");
return saved;
}
}
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
return markSubmitted(versionId, storeTypes, "MANUAL", null, null);
@ -185,6 +191,15 @@ public class AppStoreService {
return result;
}
public List<String> resolveDefaultStoreTargets(String appKey) {
return configRepo.findByAppKeyAndEnabled(appKey, true).stream()
.map(AppStoreConfigEntity::getStoreType)
.filter(storeType -> storeType != AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK)
.sorted(Comparator.comparingInt(Enum::ordinal))
.map(Enum::name)
.toList();
}
public Map<String, String> getReviewWebhookConfig(String appKey) throws Exception {
AppStoreConfigEntity cfg = configRepo.findByAppKeyAndStoreType(
appKey, AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK).orElse(null);
@ -221,6 +236,7 @@ public class AppStoreService {
String storeType,
AppVersionEntity.StoreReviewState state,
String reason) throws Exception {
synchronized (lockFor(versionId)) {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
@ -279,12 +295,14 @@ public class AppStoreService {
"store_review_changed");
return saved;
}
}
public AppVersionEntity updateStoreSubmissionStage(String versionId,
String storeType,
String stage,
String reason,
String batchId) throws Exception {
synchronized (lockFor(versionId)) {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
@ -326,6 +344,7 @@ public class AppStoreService {
"store_submission_stage");
return saved;
}
}
// Scheduled publish
@ -413,6 +432,19 @@ public class AppStoreService {
return mapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {});
}
private List<String> normalizeTargets(String appKey, List<String> storeTypes) {
List<String> requested = storeTypes == null ? List.of() : storeTypes.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isBlank())
.distinct()
.toList();
if (!requested.isEmpty()) {
return requested;
}
return resolveDefaultStoreTargets(appKey);
}
private boolean allApproved(AppVersionEntity v, Map<String, Object> reviewMap) throws Exception {
if (v.getStoreSubmitTargets() == null) return false;
List<String> targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {});
@ -496,4 +528,8 @@ public class AppStoreService {
}
return value.toString();
}
private Object lockFor(String versionId) {
return versionLocks.computeIfAbsent(versionId, ignored -> new Object());
}
}

查看文件

@ -56,11 +56,16 @@ public class StoreReviewImNotifier {
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload)))
.build();
log.info("IM platform event notify request appKey={} versionId={} storeType={} state={} stage={} batchId={}",
appKey, versionId, storeType, reviewState, stage, batchId);
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());
} else {
log.info("IM platform event notify delivered appKey={} status={} body={}",
appKey, response.statusCode(), response.body());
}
})
.exceptionally(e -> {

查看文件

@ -36,6 +36,8 @@ import java.security.interfaces.RSAKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Handles actual APK/IPA submission to vendor app stores on behalf of the tenant.
@ -127,9 +129,10 @@ public class StoreSubmissionService {
return;
}
int successCount = 0;
int rejectedCount = 0;
int skippedCount = 0;
AtomicInteger successCount = new AtomicInteger();
AtomicInteger rejectedCount = new AtomicInteger();
AtomicInteger skippedCount = new AtomicInteger();
List<SubmissionPlan> plans = new ArrayList<>();
for (int index = 0; index < targets.size(); index++) {
String storeType = targets.get(index);
long storeStartedAt = System.currentTimeMillis();
@ -145,7 +148,7 @@ public class StoreSubmissionService {
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
.orElse(null);
if (cfg == null || !cfg.isEnabled()) {
skippedCount++;
skippedCount.incrementAndGet();
String reason = "Store config not found or disabled";
log.warn("Store config not found or disabled for {}/{} batchId={}", v.getAppKey(), storeType, batchId);
storeService.updateStoreReview(versionId, storeType,
@ -170,22 +173,14 @@ public class StoreSubmissionService {
"phase", "SUBMITTING",
"credentialKeys", new ArrayList<>(creds.keySet())
), null);
submitToStore(storeType, v, apkFile, creds);
storeService.updateStoreReview(versionId, storeType,
AppVersionEntity.StoreReviewState.UNDER_REVIEW);
successCount++;
recordStoreEvent(v, versionId, batchId, storeType, "STORE_SUBMIT_STORE_SUCCESS", Map.of(
"durationMs", System.currentTimeMillis() - storeStartedAt,
"reviewState", AppVersionEntity.StoreReviewState.UNDER_REVIEW.name()
), null);
log.info("Submitted version {} to {}", versionId, storeType);
plans.add(new SubmissionPlan(storeType, creds, storeStartedAt));
} catch (Exception e) {
rejectedCount++;
rejectedCount.incrementAndGet();
String message = describeException(e);
log.error("Submission to {} failed for version {}: {}", storeType, versionId, e.getMessage(), e);
log.error("Preflight for {} failed for version {}: {}", storeType, versionId, e.getMessage(), e);
recordStoreEvent(v, versionId, batchId, storeType, "STORE_SUBMIT_STORE_FAILED", Map.of(
"durationMs", System.currentTimeMillis() - storeStartedAt,
"phase", "SUBMISSION",
"phase", "CONFIG",
"errorClass", e.getClass().getName(),
"reason", message
), message);
@ -193,18 +188,57 @@ public class StoreSubmissionService {
storeService.updateStoreReview(versionId, storeType,
AppVersionEntity.StoreReviewState.REJECTED,
message);
} catch (Exception ex) { /* best effort */ }
} catch (Exception ex) {
log.warn("Failed to persist preflight rejection for {}/{} batchId={}: {}",
v.getAppKey(), storeType, batchId, ex.getMessage(), ex);
}
}
}
List<CompletableFuture<Void>> futures = plans.stream()
.map(plan -> CompletableFuture.runAsync(() -> {
try {
submitToStore(plan.storeType, v, apkFile, plan.creds);
storeService.updateStoreReview(versionId, plan.storeType,
AppVersionEntity.StoreReviewState.UNDER_REVIEW);
successCount.incrementAndGet();
recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_SUCCESS", Map.of(
"durationMs", System.currentTimeMillis() - plan.storeStartedAt,
"reviewState", AppVersionEntity.StoreReviewState.UNDER_REVIEW.name()
), null);
log.info("Submitted version {} to {}", versionId, plan.storeType);
} catch (Exception e) {
rejectedCount.incrementAndGet();
String message = describeException(e);
log.error("Submission to {} failed for version {}: {}", plan.storeType, versionId, e.getMessage(), e);
recordStoreEvent(v, versionId, batchId, plan.storeType, "STORE_SUBMIT_STORE_FAILED", Map.of(
"durationMs", System.currentTimeMillis() - plan.storeStartedAt,
"phase", "SUBMISSION",
"errorClass", e.getClass().getName(),
"reason", message
), message);
try {
storeService.updateStoreReview(versionId, plan.storeType,
AppVersionEntity.StoreReviewState.REJECTED,
message);
} catch (Exception ex) {
log.warn("Failed to persist rejection for {}/{} batchId={}: {}",
v.getAppKey(), plan.storeType, batchId, ex.getMessage(), ex);
}
}
}))
.toList();
if (!futures.isEmpty()) {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
recordBatchEvent(v, versionId, batchId, "STORE_SUBMIT_BATCH_END", startedAt, Map.of(
"targets", targets,
"successCount", successCount,
"rejectedCount", rejectedCount,
"skippedCount", skippedCount,
"successCount", successCount.get(),
"rejectedCount", rejectedCount.get(),
"skippedCount", skippedCount.get(),
"durationMs", Duration.between(startedAt, LocalDateTime.now()).toMillis()
));
log.info("Store submit batch end version={} appKey={} batchId={} success={} rejected={} skipped={}",
versionId, v.getAppKey(), batchId, successCount, rejectedCount, skippedCount);
versionId, v.getAppKey(), batchId, successCount.get(), rejectedCount.get(), skippedCount.get());
}
@Scheduled(fixedDelay = 60_000)
@ -300,7 +334,7 @@ public class StoreSubmissionService {
if (list.isEmpty()) {
throw new RuntimeException("Huawei: app not found for " + packageName + ", response=" + summarizeMap(body));
}
String appId = firstText(list.get(0), "id", "appId", "app_id");
String appId = firstText(list.get(0), "id", "appId", "app_id", "value");
if (appId.isBlank()) {
throw new RuntimeException("Huawei: app id missing for " + packageName + ", response=" + summarizeMap(list.get(0)));
}
@ -526,7 +560,7 @@ public class StoreSubmissionService {
private void submitToMi(AppVersionEntity v, File file, Map<String, String> creds) {
try {
String account = resolveMiAccount(creds);
String publicKey = require(creds, "publicKey", "MI");
String publicKey = resolveMiPublicKey(creds);
String privateKey = require(creds, "privateKey", "MI");
String packageName = requirePackageName(v);
JsonNode appInfo = miGetAppInfo(account, packageName, publicKey, privateKey);
@ -662,12 +696,41 @@ public class StoreSubmissionService {
if (account == null || account.isBlank()) {
account = creds.get("username");
}
if (account == null || account.isBlank()) {
account = creds.get("userName");
}
if (account == null || account.isBlank()) {
throw new IllegalStateException("MI credential missing: account");
}
return account;
}
private String resolveMiPublicKey(Map<String, String> creds) {
String publicKey = creds.get("publicKey");
if (publicKey == null || publicKey.isBlank()) {
publicKey = creds.get("publicKeyPem");
}
if (publicKey == null || publicKey.isBlank()) {
publicKey = creds.get("public_key");
}
if (publicKey == null || publicKey.isBlank()) {
publicKey = creds.get("certificate");
}
if (publicKey == null || publicKey.isBlank()) {
publicKey = creds.get("cert");
}
if (publicKey == null || publicKey.isBlank()) {
publicKey = creds.get("publicCert");
}
if (publicKey == null || publicKey.isBlank()) {
publicKey = creds.get("publicCertPem");
}
if (publicKey == null || publicKey.isBlank()) {
throw new IllegalStateException("MI credential missing: publicKey");
}
return publicKey;
}
private void miCheckSuccess(JsonNode root, String action) {
int code = root.path("result").asInt(-1);
String message = root.path("message").asText("未知");
@ -982,6 +1045,18 @@ public class StoreSubmissionService {
return mapper.readValue(json, new TypeReference<Map<String, String>>() {});
}
private static final class SubmissionPlan {
private final String storeType;
private final Map<String, String> creds;
private final long storeStartedAt;
private SubmissionPlan(String storeType, Map<String, String> creds, long storeStartedAt) {
this.storeType = storeType;
this.creds = creds;
this.storeStartedAt = storeStartedAt;
}
}
private String require(Map<String, String> creds, String key, String store) {
String v = creds.get(key);
if (v == null || v.isBlank())