diff --git a/Dockerfile b/Dockerfile index 45c33ba..21915c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 5911c28..39a6837 100644 --- a/README.md +++ b/README.md @@ -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..." } }` diff --git a/common/src/main/java/com/xuqm/common/security/UserSigUtil.java b/common/src/main/java/com/xuqm/common/security/UserSigUtil.java new file mode 100644 index 0000000..5c34b24 --- /dev/null +++ b/common/src/main/java/com/xuqm/common/security/UserSigUtil.java @@ -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 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 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 + ) {} +} diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index f707b21..c65c16e 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -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 会话与关系链 diff --git a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java index ac91449..abe08d9 100644 --- a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java +++ b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java @@ -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 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 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 response = request( "POST", @@ -99,7 +175,7 @@ public final class XuqmImServerSdk { ApiResponse 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 pushUserStatus(String userId) { + ApiResponse> 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 pushDeviceLogs(String userId, int page, int size) { + ApiResponse> 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 testOfflinePush(String userId, String title, String body, String payload) { + Map 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> response = request( + "POST", + buildUri(pushBaseUrl, "/api/push/admin/test-offline", Map.of()), + req, + authorizedHeaders(), + new TypeReference<>() {} + ); + return response.data(); + } + public Map checkAppUpdate(String platform, int currentVersionCode) { ApiResponse> 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 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 parseJsonStringList(String value) { if (value == null || value.isBlank()) { return List.of(); diff --git a/im-service/src/main/java/com/xuqm/im/controller/AccountController.java b/im-service/src/main/java/com/xuqm/im/controller/AccountController.java index 3641c58..1633bd7 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/AccountController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/AccountController.java @@ -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 ) {} } diff --git a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java index f2698d2..aef3657 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/AuthController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/AuthController.java @@ -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>> 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()))); } } diff --git a/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java b/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java index f250dce..107c1d9 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java +++ b/im-service/src/main/java/com/xuqm/im/controller/GlobalExceptionHandler.java @@ -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> 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> handleException(Exception e) { log.error("Unhandled exception", e); diff --git a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java index 4b79fd4..2aa4ad7 100644 --- a/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java +++ b/im-service/src/main/java/com/xuqm/im/controller/ImAdminController.java @@ -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>> 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>> 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> 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>> queryUserState( @RequestParam String userIds) { Map 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> 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>> 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> 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>> 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 memberIds, String groupType, String announcement) {} public record UpdateGroupRequest(String name, String groupType, String announcement) {} public record WebhookConfigRequest(String url, String secret, Boolean enabled) {} diff --git a/im-service/src/main/java/com/xuqm/im/controller/PlatformEventController.java b/im-service/src/main/java/com/xuqm/im/controller/PlatformEventController.java new file mode 100644 index 0000000..ca0b4e4 --- /dev/null +++ b/im-service/src/main/java/com/xuqm/im/controller/PlatformEventController.java @@ -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>> 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 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())); + } + } +} diff --git a/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java b/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java index 2d5d9c7..ac542ca 100644 --- a/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java +++ b/im-service/src/main/java/com/xuqm/im/entity/ImAccountEntity.java @@ -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; } diff --git a/im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java b/im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java index 8708f79..0bbc43c 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/KeywordFilterRepository.java @@ -7,5 +7,5 @@ import java.util.List; public interface KeywordFilterRepository extends JpaRepository { List findByAppKeyAndEnabledTrue(String appKey); List findByAppKey(String appKey); - java.util.Optional findByIdAndAppId(String id, String appKey); + java.util.Optional findByIdAndAppKey(String id, String appKey); } diff --git a/im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java b/im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java index 1d2c8d6..7af4b0c 100644 --- a/im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java +++ b/im-service/src/main/java/com/xuqm/im/repository/WebhookConfigRepository.java @@ -7,5 +7,5 @@ import java.util.List; public interface WebhookConfigRepository extends JpaRepository { List findByAppKeyAndEnabledTrue(String appKey); List findByAppKey(String appKey); - java.util.Optional findByIdAndAppId(String id, String appKey); + java.util.Optional findByIdAndAppKey(String id, String appKey); } diff --git a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java index ff6a948..05cbd87 100644 --- a/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java +++ b/im-service/src/main/java/com/xuqm/im/service/ImAccountService.java @@ -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) { - 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); - }); + 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) + .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 importAccounts(String appKey, List 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 ) {} } diff --git a/im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java b/im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java index 489cefa..ce892e8 100644 --- a/im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java +++ b/im-service/src/main/java/com/xuqm/im/service/KeywordFilterService.java @@ -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); } diff --git a/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java b/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java index 39e45ab..119d4b4 100644 --- a/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java +++ b/im-service/src/main/java/com/xuqm/im/service/WebhookConfigService.java @@ -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); } diff --git a/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java b/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java index 04e0654..9cdb115 100644 --- a/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java +++ b/push-service/src/main/java/com/xuqm/push/controller/PushManagementController.java @@ -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; diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java index e7b513f..bd64fcc 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AuthService.java @@ -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" )); } 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 index f19a9c7..e8ce1ea 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/ImPlatformEventService.java @@ -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 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 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 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 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 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 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( diff --git a/tenant-service/src/main/resources/application.yml b/tenant-service/src/main/resources/application.yml index e6c1439..78c7e4d 100644 --- a/tenant-service/src/main/resources/application.yml +++ b/tenant-service/src/main/resources/application.yml @@ -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} diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java index 953a69a..34d6a96 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java @@ -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 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 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 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 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)); + } } diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java index 0b7c129..70a4934 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppStoreConfigEntity.java @@ -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":"..."} diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java index 9387203..c84b6f5 100644 --- a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -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 versionLocks = new ConcurrentHashMap<>(); public AppStoreService(AppStoreConfigRepository configRepo, AppVersionRepository versionRepo, @@ -111,55 +114,58 @@ public class AppStoreService { String submitMode, LocalDateTime scheduledAt, Boolean autoPublishAfterReview) throws Exception { - AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); - String normalizedMode = submitMode == null || submitMode.isBlank() - ? "MANUAL" - : submitMode.trim().toUpperCase(Locale.ROOT); - if ("SCHEDULED".equals(normalizedMode) && scheduledAt == null) { - throw new IllegalArgumentException("scheduledAt is required when submitMode is SCHEDULED"); - } + synchronized (lockFor(versionId)) { + AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); + List resolvedTargets = normalizeTargets(v.getAppKey(), storeTypes); + String normalizedMode = submitMode == null || submitMode.isBlank() + ? "MANUAL" + : submitMode.trim().toUpperCase(Locale.ROOT); + if ("SCHEDULED".equals(normalizedMode) && scheduledAt == null) { + throw new IllegalArgumentException("scheduledAt is required when submitMode is SCHEDULED"); + } - Map reviewMap = new LinkedHashMap<>(); - for (String store : storeTypes) { - reviewMap.put(store, reviewPayload( + Map reviewMap = new LinkedHashMap<>(); + for (String store : resolvedTargets) { + reviewMap.put(store, reviewPayload( + AppVersionEntity.StoreReviewState.PENDING.name(), + null, + "QUEUED", + null, + null, + LocalDateTime.now().toString())); + } + v.setStoreSubmitTargets(mapper.writeValueAsString(resolvedTargets)); + v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); + v.setStoreSubmitMode(normalizedMode); + v.setStoreSubmitScheduledAt(scheduledAt); + if (autoPublishAfterReview != null) { + v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode)); + } + AppVersionEntity saved = versionRepo.save(v); + operationLogService.record( + saved.getAppKey(), + "APP_VERSION", + saved.getId(), + "STORE_SUBMIT_REQUEST", + null, + Map.of( + "storeTypes", resolvedTargets, + "submitMode", saved.getStoreSubmitMode(), + "scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(), + "autoPublishAfterReview", saved.isAutoPublishAfterReview() + )); + storeReviewImNotifier.notifyStoreReviewChange( + saved.getAppKey(), + saved.getId(), + null, AppVersionEntity.StoreReviewState.PENDING.name(), null, "QUEUED", null, - null, - LocalDateTime.now().toString())); + saved.getPublishStatus().name(), + "store_submit_requested"); + return saved; } - v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes)); - v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); - v.setStoreSubmitMode(normalizedMode); - v.setStoreSubmitScheduledAt(scheduledAt); - if (autoPublishAfterReview != null) { - v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode)); - } - AppVersionEntity saved = versionRepo.save(v); - operationLogService.record( - saved.getAppKey(), - "APP_VERSION", - saved.getId(), - "STORE_SUBMIT_REQUEST", - null, - Map.of( - "storeTypes", storeTypes, - "submitMode", saved.getStoreSubmitMode(), - "scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(), - "autoPublishAfterReview", saved.isAutoPublishAfterReview() - )); - storeReviewImNotifier.notifyStoreReviewChange( - saved.getAppKey(), - saved.getId(), - null, - AppVersionEntity.StoreReviewState.PENDING.name(), - null, - "QUEUED", - null, - saved.getPublishStatus().name(), - "store_submit_requested"); - return saved; } public AppVersionEntity markSubmitted(String versionId, List storeTypes) throws Exception { @@ -185,6 +191,15 @@ public class AppStoreService { return result; } + public List 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 getReviewWebhookConfig(String appKey) throws Exception { AppStoreConfigEntity cfg = configRepo.findByAppKeyAndStoreType( appKey, AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK).orElse(null); @@ -221,63 +236,65 @@ public class AppStoreService { String storeType, AppVersionEntity.StoreReviewState state, String reason) throws Exception { - AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); + synchronized (lockFor(versionId)) { + AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); - Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); - Map current = asReviewPayload(reviewMap.get(storeType)); - String batchId = readText(current.get("batchId")); - String submittedAt = readText(current.get("submittedAt")); - String stage = stageForFinalState(state); - reviewMap.put(storeType, reviewPayload( - state.name(), - reason, - stage, - batchId.isBlank() ? null : batchId, - submittedAt.isBlank() ? null : submittedAt, - LocalDateTime.now().toString())); - v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); + Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); + Map current = asReviewPayload(reviewMap.get(storeType)); + String batchId = readText(current.get("batchId")); + String submittedAt = readText(current.get("submittedAt")); + String stage = stageForFinalState(state); + reviewMap.put(storeType, reviewPayload( + state.name(), + reason, + stage, + batchId.isBlank() ? null : batchId, + submittedAt.isBlank() ? null : submittedAt, + LocalDateTime.now().toString())); + v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); - if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) { - v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); - log.info("Auto-published version {} after all stores approved", versionId); + if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) { + v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); + log.info("Auto-published version {} after all stores approved", versionId); + operationLogService.record( + v.getAppKey(), + "APP_VERSION", + v.getId(), + "AUTO_PUBLISH", + reason, + Map.of( + "storeType", storeType, + "publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name() + )); + } + + AppVersionEntity saved = versionRepo.save(v); operationLogService.record( - v.getAppKey(), + saved.getAppKey(), "APP_VERSION", - v.getId(), - "AUTO_PUBLISH", + saved.getId(), + "STORE_REVIEW", reason, Map.of( "storeType", storeType, - "publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name() + "reviewState", state.name(), + "stage", stage, + "batchId", batchId, + "publishStatus", saved.getPublishStatus().name() )); + sendWebhook(saved, storeType, state, reason); + storeReviewImNotifier.notifyStoreReviewChange( + saved.getAppKey(), + saved.getId(), + storeType, + state.name(), + reason, + stage, + batchId, + saved.getPublishStatus().name(), + "store_review_changed"); + return saved; } - - AppVersionEntity saved = versionRepo.save(v); - operationLogService.record( - saved.getAppKey(), - "APP_VERSION", - saved.getId(), - "STORE_REVIEW", - reason, - Map.of( - "storeType", storeType, - "reviewState", state.name(), - "stage", stage, - "batchId", batchId, - "publishStatus", saved.getPublishStatus().name() - )); - sendWebhook(saved, storeType, state, reason); - storeReviewImNotifier.notifyStoreReviewChange( - saved.getAppKey(), - saved.getId(), - storeType, - state.name(), - reason, - stage, - batchId, - saved.getPublishStatus().name(), - "store_review_changed"); - return saved; } public AppVersionEntity updateStoreSubmissionStage(String versionId, @@ -285,46 +302,48 @@ public class AppStoreService { String stage, String reason, String batchId) throws Exception { - AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); + synchronized (lockFor(versionId)) { + AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); - Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); - Map current = asReviewPayload(reviewMap.get(storeType)); - String submittedAt = readText(current.get("submittedAt")); - if (submittedAt.isBlank()) { - submittedAt = LocalDateTime.now().toString(); + Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); + Map current = asReviewPayload(reviewMap.get(storeType)); + String submittedAt = readText(current.get("submittedAt")); + if (submittedAt.isBlank()) { + submittedAt = LocalDateTime.now().toString(); + } + reviewMap.put(storeType, reviewPayload( + AppVersionEntity.StoreReviewState.SUBMITTING.name(), + reason, + stage, + batchId, + submittedAt, + LocalDateTime.now().toString())); + v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); + AppVersionEntity saved = versionRepo.save(v); + operationLogService.record( + saved.getAppKey(), + "APP_VERSION", + saved.getId(), + "STORE_SUBMIT_STAGE", + reason, + Map.of( + "storeType", storeType, + "stage", stage, + "batchId", batchId, + "reviewState", AppVersionEntity.StoreReviewState.SUBMITTING.name() + )); + storeReviewImNotifier.notifyStoreReviewChange( + saved.getAppKey(), + saved.getId(), + storeType, + AppVersionEntity.StoreReviewState.SUBMITTING.name(), + reason, + stage, + batchId, + saved.getPublishStatus().name(), + "store_submission_stage"); + return saved; } - reviewMap.put(storeType, reviewPayload( - AppVersionEntity.StoreReviewState.SUBMITTING.name(), - reason, - stage, - batchId, - submittedAt, - LocalDateTime.now().toString())); - v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); - AppVersionEntity saved = versionRepo.save(v); - operationLogService.record( - saved.getAppKey(), - "APP_VERSION", - saved.getId(), - "STORE_SUBMIT_STAGE", - reason, - Map.of( - "storeType", storeType, - "stage", stage, - "batchId", batchId, - "reviewState", AppVersionEntity.StoreReviewState.SUBMITTING.name() - )); - storeReviewImNotifier.notifyStoreReviewChange( - saved.getAppKey(), - saved.getId(), - storeType, - AppVersionEntity.StoreReviewState.SUBMITTING.name(), - reason, - stage, - batchId, - saved.getPublishStatus().name(), - "store_submission_stage"); - return saved; } // ── Scheduled publish ──────────────────────────────────────────────────── @@ -413,6 +432,19 @@ public class AppStoreService { return mapper.readValue(json, new TypeReference>() {}); } + private List normalizeTargets(String appKey, List storeTypes) { + List 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 reviewMap) throws Exception { if (v.getStoreSubmitTargets() == null) return false; List 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()); + } } 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 index 646326c..c4e7968 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java @@ -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 -> { diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index 1b73c42..037aaf6 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -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 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> 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 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 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>() {}); } + private static final class SubmissionPlan { + private final String storeType; + private final Map creds; + private final long storeStartedAt; + + private SubmissionPlan(String storeType, Map creds, long storeStartedAt) { + this.storeType = storeType; + this.creds = creds; + this.storeStartedAt = storeStartedAt; + } + } + private String require(Map creds, String key, String store) { String v = creds.get(key); if (v == null || v.isBlank())