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 路由转发规则,支持实时通信和版本更新服务
这个提交包含在:
父节点
d54bfe25f1
当前提交
71929fef67
@ -17,7 +17,7 @@ COPY demo-service ./demo-service
|
|||||||
COPY file-service ./file-service
|
COPY file-service ./file-service
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/root/.m2,sharing=locked \
|
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
|
FROM eclipse-temurin:21-jre-jammy
|
||||||
WORKDIR /app
|
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)
|
#### 功能服务(需 Token)
|
||||||
|
|
||||||
@ -218,11 +218,10 @@ cd update-service && mvn spring-boot:run &
|
|||||||
POST /api/im/auth/login
|
POST /api/im/auth/login
|
||||||
?appKey=ak_xxx
|
?appKey=ak_xxx
|
||||||
&userId=user_001
|
&userId=user_001
|
||||||
&nickname=张三 (可选,仅首次注册时存入外部系统)
|
&userSig=your_user_sig
|
||||||
&avatar=https://... (可选)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
该接口需要由 demo-service 带上 `X-App-Timestamp`、`X-App-Nonce`、`X-App-Signature` 头完成 AppSecret 验签。
|
服务端可本地签发 `userSig`,IM 管理页也支持生成与校验;管理员账号可用于服务端 SDK / REST API。
|
||||||
|
|
||||||
响应:`{ "data": { "token": "eyJ..." } }`
|
响应:`{ "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}` | 是 | 查询用户资料 |
|
| GET | `/api/im/accounts/{userId}` | 是 | 查询用户资料 |
|
||||||
| PUT | `/api/im/accounts/{userId}` | 是 | 更新自己的资料 |
|
| PUT | `/api/im/accounts/{userId}` | 是 | 更新自己的资料 |
|
||||||
| GET | `/api/im/accounts/search` | 否 | 搜索账号 |
|
| GET | `/api/im/accounts/search` | 否 | 搜索账号 |
|
||||||
@ -128,6 +131,8 @@
|
|||||||
| PUT | `/api/im/groups/{groupId}/attributes` | 是 | 设置群扩展属性 |
|
| PUT | `/api/im/groups/{groupId}/attributes` | 是 | 设置群扩展属性 |
|
||||||
| POST | `/api/im/groups/{groupId}/attributes/delete` | 是 | 删除群扩展属性 |
|
| 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) |
|
| 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` | 是 | 云端消息搜索 |
|
| GET | `/api/im/messages/search` | 是 | 云端消息搜索 |
|
||||||
| PUT | `/api/im/messages/{id}` | 是 | 编辑自己发送的文本消息 |
|
| PUT | `/api/im/messages/{id}` | 是 | 编辑自己发送的文本消息 |
|
||||||
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
|
| POST | `/api/im/messages/{id}/revoke` | 是 | 撤回消息 |
|
||||||
@ -177,7 +182,7 @@
|
|||||||
- 应用商店配置页分成两个 tab:`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。
|
- 应用商店配置页分成两个 tab:`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。
|
||||||
- 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret`。
|
- 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret`。
|
||||||
- 发布配置里新增 `allowAnonymousUpdateCheck` 开关,默认关闭。关闭时更新检查仍要求登录,且灰度发布可正常使用;开启后允许未登录设备检查更新,但所有灰度相关能力都会被禁用,服务端和租户平台都不会再允许操作灰度配置。
|
- 发布配置里新增 `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`。
|
- 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload` 和 `POST /api/v1/updates/app/inspect`。
|
||||||
- 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。
|
- 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。
|
||||||
- `GET /api/v1/updates/app/check` 现在按“当前版本之后的最高已发布版本”返回更新信息,但 `forceUpdate` 会按照“当前版本之后是否存在任意一条强更版本”来计算,所以后续发布的非强更版本不会覆盖更低版本当时应看到的强更提示。
|
- `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 登录
|
### IM 登录
|
||||||
|
|
||||||
```bash
|
```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 会话与关系链
|
### IM 会话与关系链
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -62,6 +63,81 @@ public final class XuqmImServerSdk {
|
|||||||
return new Builder();
|
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) {
|
public ImMessage sendMessage(SendMessageRequest request) {
|
||||||
ApiResponse<ImMessage> response = request(
|
ApiResponse<ImMessage> response = request(
|
||||||
"POST",
|
"POST",
|
||||||
@ -99,7 +175,7 @@ public final class XuqmImServerSdk {
|
|||||||
ApiResponse<AccountView> response = request(
|
ApiResponse<AccountView> response = request(
|
||||||
"POST",
|
"POST",
|
||||||
buildUri("/api/im/accounts/import", appQuery()),
|
buildUri("/api/im/accounts/import", appQuery()),
|
||||||
new ImportAccountRequest(userId, nickname, avatar, gender, status),
|
new ImportAccountRequest(userId, nickname, avatar, gender, status, null),
|
||||||
authorizedHeaders(),
|
authorizedHeaders(),
|
||||||
new TypeReference<>() {}
|
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) {
|
public Map<String, Object> checkAppUpdate(String platform, int currentVersionCode) {
|
||||||
ApiResponse<Map<String, Object>> response = request(
|
ApiResponse<Map<String, Object>> response = request(
|
||||||
"GET",
|
"GET",
|
||||||
@ -1785,6 +1910,7 @@ public final class XuqmImServerSdk {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record LoginResponse(String token) {}
|
public record LoginResponse(String token) {}
|
||||||
|
public record UserSigClaims(String appKey, String userId, long issuedAt, long expiresAt, String userBuf, String userSig) {}
|
||||||
|
|
||||||
public record ConversationView(
|
public record ConversationView(
|
||||||
String targetId,
|
String targetId,
|
||||||
@ -1811,6 +1937,7 @@ public final class XuqmImServerSdk {
|
|||||||
String nickname,
|
String nickname,
|
||||||
String gender,
|
String gender,
|
||||||
String avatar,
|
String avatar,
|
||||||
|
boolean admin,
|
||||||
String status,
|
String status,
|
||||||
LocalDateTime createdAt
|
LocalDateTime createdAt
|
||||||
) {}
|
) {}
|
||||||
@ -1820,7 +1947,8 @@ public final class XuqmImServerSdk {
|
|||||||
String nickname,
|
String nickname,
|
||||||
String avatar,
|
String avatar,
|
||||||
String gender,
|
String gender,
|
||||||
String status
|
String status,
|
||||||
|
Boolean admin
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record FriendLinkView(
|
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) {
|
private static List<String> parseJsonStringList(String value) {
|
||||||
if (value == null || value.isBlank()) {
|
if (value == null || value.isBlank()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
|
|||||||
@ -50,7 +50,7 @@ public class AccountController {
|
|||||||
throw new BusinessException(403, "Only the account owner can update profile");
|
throw new BusinessException(403, "Only the account owner can update profile");
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
return ResponseEntity.ok(ApiResponse.success(
|
||||||
accountService.updateAccount(appKey, userId, nickname, avatar, gender)));
|
accountService.updateAccount(appKey, userId, nickname, avatar, gender, null, null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
@ -66,7 +66,7 @@ public class AccountController {
|
|||||||
@RequestParam String appKey,
|
@RequestParam String appKey,
|
||||||
@RequestBody ImportAccountRequest req) {
|
@RequestBody ImportAccountRequest req) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(
|
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")
|
@PostMapping("/import/batch")
|
||||||
@ -77,7 +77,7 @@ public class AccountController {
|
|||||||
appKey,
|
appKey,
|
||||||
req == null ? List.of() : req.stream()
|
req == null ? List.of() : req.stream()
|
||||||
.map(item -> new ImAccountService.ImportAccountRequest(
|
.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())));
|
.toList())));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,6 +101,7 @@ public class AccountController {
|
|||||||
String nickname,
|
String nickname,
|
||||||
String avatar,
|
String avatar,
|
||||||
ImAccountEntity.Gender gender,
|
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 com.xuqm.im.service.ImAccountService;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@ -26,14 +25,11 @@ public class AuthController {
|
|||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
||||||
@RequestParam @NotBlank String appKey,
|
@RequestParam @NotBlank String appKey,
|
||||||
@RequestParam @NotBlank String userId,
|
@RequestParam @NotBlank String userId,
|
||||||
@RequestHeader(value = "X-App-Timestamp", required = false) String timestamp,
|
@RequestParam @NotBlank String userSig) {
|
||||||
@RequestHeader(value = "X-App-Nonce", required = false) String nonce,
|
if (userSig.isBlank()) {
|
||||||
@RequestHeader(value = "X-App-Signature", required = false) String signature) {
|
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing userSig"));
|
||||||
if (timestamp == null || nonce == null || signature == null) {
|
|
||||||
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing app signature"));
|
|
||||||
}
|
}
|
||||||
accountService.validateSignature(appKey, userId, timestamp, nonce, signature);
|
ImAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
|
||||||
ImAccountService.LoginResult result = accountService.loginOrRegister(appKey, userId);
|
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin())));
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token())));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
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.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
@ -42,6 +45,17 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(ApiResponse.badRequest("请求体格式错误"));
|
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)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
|
||||||
log.error("Unhandled 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.model.ApiResponse;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
|
import com.xuqm.common.security.UserSigUtil;
|
||||||
import com.xuqm.im.entity.ImBlacklistEntity;
|
import com.xuqm.im.entity.ImBlacklistEntity;
|
||||||
import com.xuqm.im.entity.ImAccountEntity;
|
import com.xuqm.im.entity.ImAccountEntity;
|
||||||
import com.xuqm.im.entity.ImGlobalMuteEntity;
|
import com.xuqm.im.entity.ImGlobalMuteEntity;
|
||||||
@ -46,6 +47,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/im/admin")
|
@RequestMapping("/api/im/admin")
|
||||||
|
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
|
||||||
public class ImAdminController {
|
public class ImAdminController {
|
||||||
|
|
||||||
private final ImAccountRepository accountRepository;
|
private final ImAccountRepository accountRepository;
|
||||||
@ -144,7 +146,8 @@ public class ImAdminController {
|
|||||||
req.nickname(),
|
req.nickname(),
|
||||||
req.avatar(),
|
req.avatar(),
|
||||||
req.gender(),
|
req.gender(),
|
||||||
req.status());
|
req.status(),
|
||||||
|
req.admin());
|
||||||
operationLogService.record(appKey, operatorId, "UPDATE_USER", "ACCOUNT", userId, req.nickname());
|
operationLogService.record(appKey, operatorId, "UPDATE_USER", "ACCOUNT", userId, req.nickname());
|
||||||
return ResponseEntity.ok(ApiResponse.success(saved));
|
return ResponseEntity.ok(ApiResponse.success(saved));
|
||||||
}
|
}
|
||||||
@ -167,11 +170,60 @@ public class ImAdminController {
|
|||||||
req.nickname(),
|
req.nickname(),
|
||||||
req.avatar(),
|
req.avatar(),
|
||||||
req.gender(),
|
req.gender(),
|
||||||
req.status());
|
req.status(),
|
||||||
|
req.admin());
|
||||||
operationLogService.record(appKey, operatorId, "REGISTER_USER", "ACCOUNT", req.userId(), req.nickname());
|
operationLogService.record(appKey, operatorId, "REGISTER_USER", "ACCOUNT", req.userId(), req.nickname());
|
||||||
return ResponseEntity.ok(ApiResponse.success(account));
|
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. */
|
/** Admin creates a group. */
|
||||||
@PostMapping("/groups")
|
@PostMapping("/groups")
|
||||||
public ResponseEntity<ApiResponse<ImGroupEntity>> createGroup(
|
public ResponseEntity<ApiResponse<ImGroupEntity>> createGroup(
|
||||||
@ -670,7 +722,7 @@ public class ImAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/users/state")
|
@GetMapping("/users/state")
|
||||||
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
|
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> queryUserState(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> queryUserState(
|
||||||
@RequestParam String userIds) {
|
@RequestParam String userIds) {
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
@ -687,7 +739,7 @@ public class ImAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/users/kick")
|
@PostMapping("/users/kick")
|
||||||
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
|
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
|
||||||
public ResponseEntity<ApiResponse<Void>> kickUsers(
|
public ResponseEntity<ApiResponse<Void>> kickUsers(
|
||||||
@RequestParam String appKey,
|
@RequestParam String appKey,
|
||||||
@AuthenticationPrincipal String operatorId,
|
@AuthenticationPrincipal String operatorId,
|
||||||
@ -703,7 +755,7 @@ public class ImAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/messages/batch-send")
|
@PostMapping("/messages/batch-send")
|
||||||
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
|
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
|
||||||
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> batchSendMsg(
|
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> batchSendMsg(
|
||||||
@RequestParam String appKey,
|
@RequestParam String appKey,
|
||||||
@AuthenticationPrincipal String operatorId,
|
@AuthenticationPrincipal String operatorId,
|
||||||
@ -719,7 +771,7 @@ public class ImAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/messages/read")
|
@PostMapping("/messages/read")
|
||||||
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
|
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
|
||||||
public ResponseEntity<ApiResponse<Void>> adminSetMsgRead(
|
public ResponseEntity<ApiResponse<Void>> adminSetMsgRead(
|
||||||
@RequestParam String appKey,
|
@RequestParam String appKey,
|
||||||
@AuthenticationPrincipal String operatorId,
|
@AuthenticationPrincipal String operatorId,
|
||||||
@ -730,7 +782,7 @@ public class ImAdminController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/messages/import")
|
@PostMapping("/messages/import")
|
||||||
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT')")
|
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
|
||||||
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> importMessages(
|
public ResponseEntity<ApiResponse<List<ImMessageEntity>>> importMessages(
|
||||||
@RequestParam String appKey,
|
@RequestParam String appKey,
|
||||||
@AuthenticationPrincipal String operatorId,
|
@AuthenticationPrincipal String operatorId,
|
||||||
@ -746,12 +798,16 @@ public class ImAdminController {
|
|||||||
String nickname,
|
String nickname,
|
||||||
String avatar,
|
String avatar,
|
||||||
ImAccountEntity.Gender gender,
|
ImAccountEntity.Gender gender,
|
||||||
ImAccountEntity.Status status) {}
|
ImAccountEntity.Status status,
|
||||||
|
Boolean admin) {}
|
||||||
public record UpdateUserRequest(
|
public record UpdateUserRequest(
|
||||||
String nickname,
|
String nickname,
|
||||||
String avatar,
|
String avatar,
|
||||||
ImAccountEntity.Gender gender,
|
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 CreateGroupRequest(String name, String creatorId, List<String> memberIds, String groupType, String announcement) {}
|
||||||
public record UpdateGroupRequest(String name, String groupType, String announcement) {}
|
public record UpdateGroupRequest(String name, String groupType, String announcement) {}
|
||||||
public record WebhookConfigRequest(String url, String secret, Boolean enabled) {}
|
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)
|
@Column(length = 512)
|
||||||
private String avatar;
|
private String avatar;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private boolean admin;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false, length = 16)
|
@Column(nullable = false, length = 16)
|
||||||
private Status status;
|
private Status status;
|
||||||
@ -64,6 +67,9 @@ public class ImAccountEntity {
|
|||||||
public String getAvatar() { return avatar; }
|
public String getAvatar() { return avatar; }
|
||||||
public void setAvatar(String avatar) { this.avatar = 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 Status getStatus() { return status; }
|
||||||
public void setStatus(Status status) { this.status = status; }
|
public void setStatus(Status status) { this.status = status; }
|
||||||
|
|
||||||
|
|||||||
@ -7,5 +7,5 @@ import java.util.List;
|
|||||||
public interface KeywordFilterRepository extends JpaRepository<KeywordFilterEntity, String> {
|
public interface KeywordFilterRepository extends JpaRepository<KeywordFilterEntity, String> {
|
||||||
List<KeywordFilterEntity> findByAppKeyAndEnabledTrue(String appKey);
|
List<KeywordFilterEntity> findByAppKeyAndEnabledTrue(String appKey);
|
||||||
List<KeywordFilterEntity> findByAppKey(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> {
|
public interface WebhookConfigRepository extends JpaRepository<WebhookConfigEntity, String> {
|
||||||
List<WebhookConfigEntity> findByAppKeyAndEnabledTrue(String appKey);
|
List<WebhookConfigEntity> findByAppKeyAndEnabledTrue(String appKey);
|
||||||
List<WebhookConfigEntity> findByAppKey(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;
|
package com.xuqm.im.service;
|
||||||
|
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.common.security.AppRequestSignatureUtil;
|
|
||||||
import com.xuqm.common.security.JwtUtil;
|
import com.xuqm.common.security.JwtUtil;
|
||||||
|
import com.xuqm.common.security.UserSigUtil;
|
||||||
import com.xuqm.im.entity.ImAccountEntity;
|
import com.xuqm.im.entity.ImAccountEntity;
|
||||||
import com.xuqm.im.repository.ImAccountRepository;
|
import com.xuqm.im.repository.ImAccountRepository;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
@ -16,7 +16,7 @@ import java.util.UUID;
|
|||||||
@Service
|
@Service
|
||||||
public class ImAccountService {
|
public class ImAccountService {
|
||||||
|
|
||||||
public record LoginResult(String token) {}
|
public record LoginResult(String token, boolean admin) {}
|
||||||
|
|
||||||
private final ImAccountRepository accountRepository;
|
private final ImAccountRepository accountRepository;
|
||||||
private final JwtUtil jwtUtil;
|
private final JwtUtil jwtUtil;
|
||||||
@ -28,42 +28,36 @@ public class ImAccountService {
|
|||||||
this.appSecretClient = appSecretClient;
|
this.appSecretClient = appSecretClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void validateSignature(String appKey,
|
public UserSigUtil.UserSigClaims generateUserSig(String appKey, String userId, long expireSeconds, String userBuf) {
|
||||||
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");
|
|
||||||
}
|
|
||||||
String secret = appSecretClient.getAppSecret(appKey);
|
String secret = appSecretClient.getAppSecret(appKey);
|
||||||
String payload = AppRequestSignatureUtil.payload(appKey, userId, ts, nonce);
|
String userSig = UserSigUtil.generate(secret, appKey, userId, expireSeconds, userBuf);
|
||||||
if (!AppRequestSignatureUtil.matches(secret, payload, signature)) {
|
return UserSigUtil.verify(secret, appKey, userId, userSig);
|
||||||
throw new BusinessException(401, "Invalid app signature");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
ImAccountEntity account = accountRepository.findByAppKeyAndUserId(appKey, userId)
|
||||||
.orElseGet(() -> {
|
.orElseThrow(() -> new BusinessException(404, "账号不存在,请先注册"));
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (account.getStatus() == ImAccountEntity.Status.BANNED) {
|
if (account.getStatus() == ImAccountEntity.Status.BANNED) {
|
||||||
throw new BusinessException(403, "账号已被封禁");
|
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) {
|
public ImAccountEntity getAccount(String appKey, String userId) {
|
||||||
@ -86,18 +80,21 @@ public class ImAccountService {
|
|||||||
String nickname,
|
String nickname,
|
||||||
String avatar,
|
String avatar,
|
||||||
ImAccountEntity.Gender gender,
|
ImAccountEntity.Gender gender,
|
||||||
ImAccountEntity.Status status) {
|
ImAccountEntity.Status status,
|
||||||
|
Boolean admin) {
|
||||||
ImAccountEntity account = getAccount(appKey, userId);
|
ImAccountEntity account = getAccount(appKey, userId);
|
||||||
if (nickname != null) account.setNickname(nickname);
|
if (nickname != null) account.setNickname(nickname);
|
||||||
if (avatar != null) account.setAvatar(avatar);
|
if (avatar != null) account.setAvatar(avatar);
|
||||||
if (gender != null) account.setGender(gender);
|
if (gender != null) account.setGender(gender);
|
||||||
if (status != null) account.setStatus(status);
|
if (status != null) account.setStatus(status);
|
||||||
|
if (admin != null) account.setAdmin(admin);
|
||||||
return accountRepository.save(account);
|
return accountRepository.save(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ImAccountEntity importAccount(String appKey, String userId, String nickname,
|
public ImAccountEntity importAccount(String appKey, String userId, String nickname,
|
||||||
String avatar, ImAccountEntity.Gender gender,
|
String avatar, ImAccountEntity.Gender gender,
|
||||||
ImAccountEntity.Status status) {
|
ImAccountEntity.Status status,
|
||||||
|
Boolean admin) {
|
||||||
ImAccountEntity account = accountRepository.findByAppKeyAndUserId(appKey, userId)
|
ImAccountEntity account = accountRepository.findByAppKeyAndUserId(appKey, userId)
|
||||||
.orElseGet(() -> {
|
.orElseGet(() -> {
|
||||||
ImAccountEntity entity = new ImAccountEntity();
|
ImAccountEntity entity = new ImAccountEntity();
|
||||||
@ -111,13 +108,16 @@ public class ImAccountService {
|
|||||||
account.setAvatar(avatar);
|
account.setAvatar(avatar);
|
||||||
account.setGender(gender == null ? ImAccountEntity.Gender.UNKNOWN : gender);
|
account.setGender(gender == null ? ImAccountEntity.Gender.UNKNOWN : gender);
|
||||||
account.setStatus(status == null ? ImAccountEntity.Status.ACTIVE : status);
|
account.setStatus(status == null ? ImAccountEntity.Status.ACTIVE : status);
|
||||||
|
if (admin != null) {
|
||||||
|
account.setAdmin(admin);
|
||||||
|
}
|
||||||
return accountRepository.save(account);
|
return accountRepository.save(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<ImAccountEntity> importAccounts(String appKey, List<ImportAccountRequest> requests) {
|
public List<ImAccountEntity> importAccounts(String appKey, List<ImportAccountRequest> requests) {
|
||||||
return requests == null ? List.of() : requests.stream()
|
return requests == null ? List.of() : requests.stream()
|
||||||
.filter(req -> req != null && req.userId() != null && !req.userId().isBlank())
|
.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();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,6 +139,7 @@ public class ImAccountService {
|
|||||||
String nickname,
|
String nickname,
|
||||||
String avatar,
|
String avatar,
|
||||||
ImAccountEntity.Gender gender,
|
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) {
|
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, "关键词过滤规则不存在"));
|
.orElseThrow(() -> new BusinessException(404, "关键词过滤规则不存在"));
|
||||||
if (pattern != null) {
|
if (pattern != null) {
|
||||||
entity.setPattern(pattern);
|
entity.setPattern(pattern);
|
||||||
@ -74,7 +74,7 @@ public class KeywordFilterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void delete(String appKey, String id) {
|
public void delete(String appKey, String id) {
|
||||||
KeywordFilterEntity entity = repository.findByIdAndAppId(id, appKey)
|
KeywordFilterEntity entity = repository.findByIdAndAppKey(id, appKey)
|
||||||
.orElseThrow(() -> new BusinessException(404, "关键词过滤规则不存在"));
|
.orElseThrow(() -> new BusinessException(404, "关键词过滤规则不存在"));
|
||||||
repository.delete(entity);
|
repository.delete(entity);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ public class WebhookConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public WebhookConfigEntity get(String appKey, String id) {
|
public WebhookConfigEntity get(String appKey, String id) {
|
||||||
return repository.findByIdAndAppId(id, appKey)
|
return repository.findByIdAndAppKey(id, appKey)
|
||||||
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
|
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ public class WebhookConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public WebhookConfigEntity update(String appKey, String id, String url, String secret, Boolean enabled) {
|
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, "回调配置不存在"));
|
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
entity.setUrl(url);
|
entity.setUrl(url);
|
||||||
@ -54,7 +54,7 @@ public class WebhookConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void delete(String appKey, String id) {
|
public void delete(String appKey, String id) {
|
||||||
WebhookConfigEntity entity = repository.findByIdAndAppId(id, appKey)
|
WebhookConfigEntity entity = repository.findByIdAndAppKey(id, appKey)
|
||||||
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
|
.orElseThrow(() -> new BusinessException(404, "回调配置不存在"));
|
||||||
repository.delete(entity);
|
repository.delete(entity);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import com.xuqm.push.entity.DeviceLoginLogEntity;
|
|||||||
import com.xuqm.push.service.PushDiagnosticsService;
|
import com.xuqm.push.service.PushDiagnosticsService;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
@ -16,6 +17,7 @@ import java.util.Map;
|
|||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/push/admin")
|
@RequestMapping("/api/push/admin")
|
||||||
|
@PreAuthorize("hasAnyAuthority('ROLE_OPS', 'ROLE_TENANT', 'ROLE_ADMIN')")
|
||||||
public class PushManagementController {
|
public class PushManagementController {
|
||||||
|
|
||||||
private final PushDiagnosticsService diagnosticsService;
|
private final PushDiagnosticsService diagnosticsService;
|
||||||
|
|||||||
@ -79,7 +79,8 @@ public class AuthService {
|
|||||||
return jwtUtil.generate(tenant.getId(), Map.of(
|
return jwtUtil.generate(tenant.getId(), Map.of(
|
||||||
"username", tenant.getUsername(),
|
"username", tenant.getUsername(),
|
||||||
"nickname", tenant.getNickname(),
|
"nickname", tenant.getNickname(),
|
||||||
"type", tenant.getType().name()
|
"type", tenant.getType().name(),
|
||||||
|
"role", "TENANT"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
package com.xuqm.tenant.service;
|
package com.xuqm.tenant.service;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.im.sdk.XuqmImServerSdk;
|
||||||
import com.xuqm.tenant.entity.AppEntity;
|
import com.xuqm.tenant.entity.AppEntity;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
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.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -18,18 +16,19 @@ import java.util.UUID;
|
|||||||
@Service
|
@Service
|
||||||
public class ImPlatformEventService {
|
public class ImPlatformEventService {
|
||||||
|
|
||||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
private static final Logger log = LoggerFactory.getLogger(ImPlatformEventService.class);
|
||||||
|
|
||||||
private final SdkAppProvisioningService provisioningService;
|
private final SdkAppProvisioningService provisioningService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}")
|
@Value("${sdk.im-api-url:https://im.dev.xuqinmin.com}")
|
||||||
private String imApiUrl;
|
private String imApiUrl;
|
||||||
|
|
||||||
@Value("${sdk.im-platform-events-user-prefix:platform-events:}")
|
@Value("${sdk.im-platform-events-recipient-user:platform}")
|
||||||
private String platformEventsUserPrefix;
|
private String platformEventsRecipientUser;
|
||||||
|
|
||||||
@Value("${sdk.im-platform-events-system-user:platform-events-system}")
|
@Value("${sdk.im-platform-events-admin-user:admin}")
|
||||||
private String platformEventsSystemUser;
|
private String platformEventsAdminUser;
|
||||||
|
|
||||||
public ImPlatformEventService(SdkAppProvisioningService provisioningService,
|
public ImPlatformEventService(SdkAppProvisioningService provisioningService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
@ -39,25 +38,23 @@ public class ImPlatformEventService {
|
|||||||
|
|
||||||
public Map<String, String> issueToken(String appKey) throws Exception {
|
public Map<String, String> issueToken(String appKey) throws Exception {
|
||||||
AppEntity app = provisioningService.resolveApp(appKey);
|
AppEntity app = provisioningService.resolveApp(appKey);
|
||||||
XuqmImServerSdk sdk = sdk(app);
|
String userId = platformEventsRecipientUserId();
|
||||||
String userId = platformEventsUserId(appKey);
|
log.info("IM platform event token login start appKey={} userId={}", app.getAppKey(), userId);
|
||||||
ensureAccount(sdk, app, userId, "平台通知");
|
|
||||||
String token = requestImToken(app, userId);
|
String token = requestImToken(app, userId);
|
||||||
Map<String, String> result = new LinkedHashMap<>();
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
result.put("appKey", app.getAppKey());
|
result.put("appKey", app.getAppKey());
|
||||||
result.put("userId", userId);
|
result.put("userId", userId);
|
||||||
result.put("token", token);
|
result.put("token", token);
|
||||||
|
log.info("IM platform event token issued appKey={} userId={}", app.getAppKey(), userId);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, String> notifyStoreReviewChange(StoreReviewEventRequest request) throws Exception {
|
public Map<String, String> notifyStoreReviewChange(StoreReviewEventRequest request) throws Exception {
|
||||||
AppEntity app = provisioningService.resolveApp(request.appKey());
|
AppEntity app = provisioningService.resolveApp(request.appKey());
|
||||||
XuqmImServerSdk sdk = sdk(app);
|
String recipientUserId = platformEventsRecipientUserId();
|
||||||
String recipientUserId = platformEventsUserId(request.appKey());
|
String senderUserId = platformEventsAdminUserId();
|
||||||
String senderUserId = platformEventsSystemUserId();
|
String senderToken = requestImToken(app, senderUserId);
|
||||||
|
XuqmImServerSdk sdk = sdk(app, senderToken);
|
||||||
ensureAccount(sdk, app, recipientUserId, "平台通知");
|
|
||||||
ensureAccount(sdk, app, senderUserId, "系统通知");
|
|
||||||
|
|
||||||
Map<String, Object> contentPayload = new LinkedHashMap<>();
|
Map<String, Object> contentPayload = new LinkedHashMap<>();
|
||||||
contentPayload.put("event", request.event() == null || request.event().isBlank() ? "store_review_update" : request.event());
|
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());
|
contentPayload.put("timestamp", System.currentTimeMillis());
|
||||||
String content = objectMapper.writeValueAsString(contentPayload);
|
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(
|
var message = sdk.sendMessage(new XuqmImServerSdk.SendMessageRequest(
|
||||||
UUID.randomUUID().toString(),
|
UUID.randomUUID().toString(),
|
||||||
recipientUserId,
|
recipientUserId,
|
||||||
@ -81,6 +82,8 @@ public class ImPlatformEventService {
|
|||||||
content,
|
content,
|
||||||
null
|
null
|
||||||
));
|
));
|
||||||
|
log.info("IM platform event message sent appKey={} recipient={} messageId={}",
|
||||||
|
app.getAppKey(), recipientUserId, message.id());
|
||||||
|
|
||||||
Map<String, String> result = new LinkedHashMap<>();
|
Map<String, String> result = new LinkedHashMap<>();
|
||||||
result.put("appKey", app.getAppKey());
|
result.put("appKey", app.getAppKey());
|
||||||
@ -89,65 +92,35 @@ public class ImPlatformEventService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureAccount(XuqmImServerSdk sdk, AppEntity app, String userId, String suffix) {
|
private XuqmImServerSdk sdk(AppEntity app, String bearerToken) {
|
||||||
sdk.importAccount(
|
|
||||||
userId,
|
|
||||||
app.getName() + " " + suffix,
|
|
||||||
app.getIconUrl(),
|
|
||||||
"UNKNOWN",
|
|
||||||
"ACTIVE"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private XuqmImServerSdk sdk(AppEntity app) {
|
|
||||||
return XuqmImServerSdk.builder()
|
return XuqmImServerSdk.builder()
|
||||||
.baseUrl(imApiUrl)
|
.baseUrl(imApiUrl)
|
||||||
.appKey(app.getAppKey())
|
.appKey(app.getAppKey())
|
||||||
.appSecret(app.getAppSecret())
|
.appSecret(app.getAppSecret())
|
||||||
|
.bearerTokenSupplier(() -> bearerToken)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private String requestImToken(AppEntity app, String userId) throws Exception {
|
private String requestImToken(AppEntity app, String userId) throws Exception {
|
||||||
long timestamp = System.currentTimeMillis();
|
XuqmImServerSdk sdk = XuqmImServerSdk.builder()
|
||||||
String nonce = UUID.randomUUID().toString();
|
.baseUrl(imApiUrl)
|
||||||
String payload = AppRequestSignatureUtil.payload(app.getAppKey(), userId, timestamp, nonce);
|
.appKey(app.getAppKey())
|
||||||
String signature = AppRequestSignatureUtil.sign(app.getAppSecret(), payload);
|
.appSecret(app.getAppSecret())
|
||||||
URI uri = URI.create(imApiUrl + "/api/im/auth/login?appKey="
|
|
||||||
+ encodeQuery(app.getAppKey())
|
|
||||||
+ "&userId="
|
|
||||||
+ encodeQuery(userId));
|
|
||||||
HttpRequest request = HttpRequest.newBuilder(uri)
|
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
.header("X-App-Timestamp", String.valueOf(timestamp))
|
|
||||||
.header("X-App-Nonce", nonce)
|
|
||||||
.header("X-App-Signature", signature)
|
|
||||||
.POST(HttpRequest.BodyPublishers.noBody())
|
|
||||||
.build();
|
.build();
|
||||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
String userSig = UserSigUtil.generate(app.getAppSecret(), app.getAppKey(), userId);
|
||||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
String token = sdk.loginWithUserSig(userId, userSig).token();
|
||||||
throw new IllegalStateException("Failed to issue IM token: HTTP " + response.statusCode());
|
|
||||||
}
|
|
||||||
var root = objectMapper.readTree(response.body());
|
|
||||||
if (root.path("code").asInt() != 200) {
|
|
||||||
throw new IllegalStateException("Failed to issue IM token: " + root.path("message").asText("unknown error"));
|
|
||||||
}
|
|
||||||
String token = root.path("data").path("token").asText(null);
|
|
||||||
if (token == null || token.isBlank()) {
|
if (token == null || token.isBlank()) {
|
||||||
throw new IllegalStateException("Failed to issue IM token: empty token");
|
throw new IllegalStateException("Failed to issue IM token: empty token");
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String encodeQuery(String value) {
|
private String platformEventsRecipientUserId() {
|
||||||
return java.net.URLEncoder.encode(value == null ? "" : value, java.nio.charset.StandardCharsets.UTF_8);
|
return platformEventsRecipientUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String platformEventsUserId(String appKey) {
|
private String platformEventsAdminUserId() {
|
||||||
return platformEventsUserPrefix + appKey;
|
return platformEventsAdminUser;
|
||||||
}
|
|
||||||
|
|
||||||
private String platformEventsSystemUserId() {
|
|
||||||
return platformEventsUserPrefix + platformEventsSystemUser;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record StoreReviewEventRequest(
|
public record StoreReviewEventRequest(
|
||||||
|
|||||||
@ -88,4 +88,5 @@ sdk:
|
|||||||
im-ws-url: ${SDK_IM_WS_URL:wss://im.dev.xuqinmin.com/ws/im}
|
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}
|
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-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);
|
scheduledAt = java.time.LocalDateTime.parse(scheduledAtText);
|
||||||
}
|
}
|
||||||
AppVersionEntity v = storeService.markSubmitted(versionId,
|
AppVersionEntity v = storeService.markSubmitted(versionId,
|
||||||
storeTypes != null ? storeTypes : List.of(),
|
storeTypes,
|
||||||
submitMode,
|
submitMode,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
autoPublishAfterReview);
|
autoPublishAfterReview);
|
||||||
@ -194,7 +194,8 @@ public class AppStoreController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
|
Map<String, Object> config = OBJECT_MAPPER.readValue(configJson, MAP_TYPE);
|
||||||
|
validateStoreConfigFields(storeType, config);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IllegalArgumentException("invalid store config payload: " + e.getMessage(), e);
|
throw new IllegalArgumentException("invalid store config payload: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
@ -202,4 +203,45 @@ public class AppStoreController {
|
|||||||
validateReviewWebhook(configJson);
|
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.
|
* Every store config also carries its own marketUrl / jump page.
|
||||||
*
|
*
|
||||||
* HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."}
|
* HUAWEI / HONOR: {"clientId":"...","clientSecret":"..."}
|
||||||
* MI: {"username":"...","privateKey":"..."}
|
* MI: {"username":"...","publicKey":"...","privateKey":"..."}
|
||||||
* OPPO: {"clientId":"...","clientSecret":"..."}
|
* OPPO: {"clientId":"...","clientSecret":"..."}
|
||||||
* VIVO: {"accessKey":"...","accessSecret":"..."}
|
* VIVO: {"accessKey":"...","accessSecret":"..."}
|
||||||
* GOOGLE_PLAY: {"serviceAccountJson":"..."}
|
* GOOGLE_PLAY: {"serviceAccountJson":"..."}
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import java.net.http.HttpRequest;
|
|||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AppStoreService {
|
public class AppStoreService {
|
||||||
@ -33,6 +35,7 @@ public class AppStoreService {
|
|||||||
private final RnBundleRepository rnBundleRepository;
|
private final RnBundleRepository rnBundleRepository;
|
||||||
private final UpdateOperationLogService operationLogService;
|
private final UpdateOperationLogService operationLogService;
|
||||||
private final StoreReviewImNotifier storeReviewImNotifier;
|
private final StoreReviewImNotifier storeReviewImNotifier;
|
||||||
|
private final ConcurrentMap<String, Object> versionLocks = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public AppStoreService(AppStoreConfigRepository configRepo,
|
public AppStoreService(AppStoreConfigRepository configRepo,
|
||||||
AppVersionRepository versionRepo,
|
AppVersionRepository versionRepo,
|
||||||
@ -111,7 +114,9 @@ public class AppStoreService {
|
|||||||
String submitMode,
|
String submitMode,
|
||||||
LocalDateTime scheduledAt,
|
LocalDateTime scheduledAt,
|
||||||
Boolean autoPublishAfterReview) throws Exception {
|
Boolean autoPublishAfterReview) throws Exception {
|
||||||
|
synchronized (lockFor(versionId)) {
|
||||||
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
||||||
|
List<String> resolvedTargets = normalizeTargets(v.getAppKey(), storeTypes);
|
||||||
String normalizedMode = submitMode == null || submitMode.isBlank()
|
String normalizedMode = submitMode == null || submitMode.isBlank()
|
||||||
? "MANUAL"
|
? "MANUAL"
|
||||||
: submitMode.trim().toUpperCase(Locale.ROOT);
|
: submitMode.trim().toUpperCase(Locale.ROOT);
|
||||||
@ -120,7 +125,7 @@ public class AppStoreService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> reviewMap = new LinkedHashMap<>();
|
Map<String, Object> reviewMap = new LinkedHashMap<>();
|
||||||
for (String store : storeTypes) {
|
for (String store : resolvedTargets) {
|
||||||
reviewMap.put(store, reviewPayload(
|
reviewMap.put(store, reviewPayload(
|
||||||
AppVersionEntity.StoreReviewState.PENDING.name(),
|
AppVersionEntity.StoreReviewState.PENDING.name(),
|
||||||
null,
|
null,
|
||||||
@ -129,7 +134,7 @@ public class AppStoreService {
|
|||||||
null,
|
null,
|
||||||
LocalDateTime.now().toString()));
|
LocalDateTime.now().toString()));
|
||||||
}
|
}
|
||||||
v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes));
|
v.setStoreSubmitTargets(mapper.writeValueAsString(resolvedTargets));
|
||||||
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
|
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
|
||||||
v.setStoreSubmitMode(normalizedMode);
|
v.setStoreSubmitMode(normalizedMode);
|
||||||
v.setStoreSubmitScheduledAt(scheduledAt);
|
v.setStoreSubmitScheduledAt(scheduledAt);
|
||||||
@ -144,7 +149,7 @@ public class AppStoreService {
|
|||||||
"STORE_SUBMIT_REQUEST",
|
"STORE_SUBMIT_REQUEST",
|
||||||
null,
|
null,
|
||||||
Map.of(
|
Map.of(
|
||||||
"storeTypes", storeTypes,
|
"storeTypes", resolvedTargets,
|
||||||
"submitMode", saved.getStoreSubmitMode(),
|
"submitMode", saved.getStoreSubmitMode(),
|
||||||
"scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(),
|
"scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(),
|
||||||
"autoPublishAfterReview", saved.isAutoPublishAfterReview()
|
"autoPublishAfterReview", saved.isAutoPublishAfterReview()
|
||||||
@ -161,6 +166,7 @@ public class AppStoreService {
|
|||||||
"store_submit_requested");
|
"store_submit_requested");
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
|
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
|
||||||
return markSubmitted(versionId, storeTypes, "MANUAL", null, null);
|
return markSubmitted(versionId, storeTypes, "MANUAL", null, null);
|
||||||
@ -185,6 +191,15 @@ public class AppStoreService {
|
|||||||
return result;
|
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 {
|
public Map<String, String> getReviewWebhookConfig(String appKey) throws Exception {
|
||||||
AppStoreConfigEntity cfg = configRepo.findByAppKeyAndStoreType(
|
AppStoreConfigEntity cfg = configRepo.findByAppKeyAndStoreType(
|
||||||
appKey, AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK).orElse(null);
|
appKey, AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK).orElse(null);
|
||||||
@ -221,6 +236,7 @@ public class AppStoreService {
|
|||||||
String storeType,
|
String storeType,
|
||||||
AppVersionEntity.StoreReviewState state,
|
AppVersionEntity.StoreReviewState state,
|
||||||
String reason) throws Exception {
|
String reason) throws Exception {
|
||||||
|
synchronized (lockFor(versionId)) {
|
||||||
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
||||||
|
|
||||||
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
|
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
|
||||||
@ -279,12 +295,14 @@ public class AppStoreService {
|
|||||||
"store_review_changed");
|
"store_review_changed");
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public AppVersionEntity updateStoreSubmissionStage(String versionId,
|
public AppVersionEntity updateStoreSubmissionStage(String versionId,
|
||||||
String storeType,
|
String storeType,
|
||||||
String stage,
|
String stage,
|
||||||
String reason,
|
String reason,
|
||||||
String batchId) throws Exception {
|
String batchId) throws Exception {
|
||||||
|
synchronized (lockFor(versionId)) {
|
||||||
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
|
||||||
|
|
||||||
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
|
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
|
||||||
@ -326,6 +344,7 @@ public class AppStoreService {
|
|||||||
"store_submission_stage");
|
"store_submission_stage");
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scheduled publish ────────────────────────────────────────────────────
|
// ── Scheduled publish ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -413,6 +432,19 @@ public class AppStoreService {
|
|||||||
return mapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
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 {
|
private boolean allApproved(AppVersionEntity v, Map<String, Object> reviewMap) throws Exception {
|
||||||
if (v.getStoreSubmitTargets() == null) return false;
|
if (v.getStoreSubmitTargets() == null) return false;
|
||||||
List<String> targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {});
|
List<String> targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {});
|
||||||
@ -496,4 +528,8 @@ public class AppStoreService {
|
|||||||
}
|
}
|
||||||
return value.toString();
|
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)))
|
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload)))
|
||||||
.build();
|
.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())
|
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
.thenAccept(response -> {
|
.thenAccept(response -> {
|
||||||
if (response.statusCode() >= 400) {
|
if (response.statusCode() >= 400) {
|
||||||
log.warn("IM platform event notify failed appKey={} status={} body={}",
|
log.warn("IM platform event notify failed appKey={} status={} body={}",
|
||||||
appKey, response.statusCode(), response.body());
|
appKey, response.statusCode(), response.body());
|
||||||
|
} else {
|
||||||
|
log.info("IM platform event notify delivered appKey={} status={} body={}",
|
||||||
|
appKey, response.statusCode(), response.body());
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.exceptionally(e -> {
|
.exceptionally(e -> {
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import java.security.interfaces.RSAKey;
|
|||||||
import java.security.cert.CertificateFactory;
|
import java.security.cert.CertificateFactory;
|
||||||
import java.security.cert.X509Certificate;
|
import java.security.cert.X509Certificate;
|
||||||
import java.util.*;
|
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.
|
* Handles actual APK/IPA submission to vendor app stores on behalf of the tenant.
|
||||||
@ -127,9 +129,10 @@ public class StoreSubmissionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int successCount = 0;
|
AtomicInteger successCount = new AtomicInteger();
|
||||||
int rejectedCount = 0;
|
AtomicInteger rejectedCount = new AtomicInteger();
|
||||||
int skippedCount = 0;
|
AtomicInteger skippedCount = new AtomicInteger();
|
||||||
|
List<SubmissionPlan> plans = new ArrayList<>();
|
||||||
for (int index = 0; index < targets.size(); index++) {
|
for (int index = 0; index < targets.size(); index++) {
|
||||||
String storeType = targets.get(index);
|
String storeType = targets.get(index);
|
||||||
long storeStartedAt = System.currentTimeMillis();
|
long storeStartedAt = System.currentTimeMillis();
|
||||||
@ -145,7 +148,7 @@ public class StoreSubmissionService {
|
|||||||
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
|
.findByAppKeyAndStoreType(v.getAppKey(), AppStoreConfigEntity.StoreType.valueOf(storeType))
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
if (cfg == null || !cfg.isEnabled()) {
|
if (cfg == null || !cfg.isEnabled()) {
|
||||||
skippedCount++;
|
skippedCount.incrementAndGet();
|
||||||
String reason = "Store config not found or disabled";
|
String reason = "Store config not found or disabled";
|
||||||
log.warn("Store config not found or disabled for {}/{} batchId={}", v.getAppKey(), storeType, batchId);
|
log.warn("Store config not found or disabled for {}/{} batchId={}", v.getAppKey(), storeType, batchId);
|
||||||
storeService.updateStoreReview(versionId, storeType,
|
storeService.updateStoreReview(versionId, storeType,
|
||||||
@ -170,22 +173,14 @@ public class StoreSubmissionService {
|
|||||||
"phase", "SUBMITTING",
|
"phase", "SUBMITTING",
|
||||||
"credentialKeys", new ArrayList<>(creds.keySet())
|
"credentialKeys", new ArrayList<>(creds.keySet())
|
||||||
), null);
|
), null);
|
||||||
submitToStore(storeType, v, apkFile, creds);
|
plans.add(new SubmissionPlan(storeType, creds, storeStartedAt));
|
||||||
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);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
rejectedCount++;
|
rejectedCount.incrementAndGet();
|
||||||
String message = describeException(e);
|
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(
|
recordStoreEvent(v, versionId, batchId, storeType, "STORE_SUBMIT_STORE_FAILED", Map.of(
|
||||||
"durationMs", System.currentTimeMillis() - storeStartedAt,
|
"durationMs", System.currentTimeMillis() - storeStartedAt,
|
||||||
"phase", "SUBMISSION",
|
"phase", "CONFIG",
|
||||||
"errorClass", e.getClass().getName(),
|
"errorClass", e.getClass().getName(),
|
||||||
"reason", message
|
"reason", message
|
||||||
), message);
|
), message);
|
||||||
@ -193,18 +188,57 @@ public class StoreSubmissionService {
|
|||||||
storeService.updateStoreReview(versionId, storeType,
|
storeService.updateStoreReview(versionId, storeType,
|
||||||
AppVersionEntity.StoreReviewState.REJECTED,
|
AppVersionEntity.StoreReviewState.REJECTED,
|
||||||
message);
|
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(
|
recordBatchEvent(v, versionId, batchId, "STORE_SUBMIT_BATCH_END", startedAt, Map.of(
|
||||||
"targets", targets,
|
"targets", targets,
|
||||||
"successCount", successCount,
|
"successCount", successCount.get(),
|
||||||
"rejectedCount", rejectedCount,
|
"rejectedCount", rejectedCount.get(),
|
||||||
"skippedCount", skippedCount,
|
"skippedCount", skippedCount.get(),
|
||||||
"durationMs", Duration.between(startedAt, LocalDateTime.now()).toMillis()
|
"durationMs", Duration.between(startedAt, LocalDateTime.now()).toMillis()
|
||||||
));
|
));
|
||||||
log.info("Store submit batch end version={} appKey={} batchId={} success={} rejected={} skipped={}",
|
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)
|
@Scheduled(fixedDelay = 60_000)
|
||||||
@ -300,7 +334,7 @@ public class StoreSubmissionService {
|
|||||||
if (list.isEmpty()) {
|
if (list.isEmpty()) {
|
||||||
throw new RuntimeException("Huawei: app not found for " + packageName + ", response=" + summarizeMap(body));
|
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()) {
|
if (appId.isBlank()) {
|
||||||
throw new RuntimeException("Huawei: app id missing for " + packageName + ", response=" + summarizeMap(list.get(0)));
|
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) {
|
private void submitToMi(AppVersionEntity v, File file, Map<String, String> creds) {
|
||||||
try {
|
try {
|
||||||
String account = resolveMiAccount(creds);
|
String account = resolveMiAccount(creds);
|
||||||
String publicKey = require(creds, "publicKey", "MI");
|
String publicKey = resolveMiPublicKey(creds);
|
||||||
String privateKey = require(creds, "privateKey", "MI");
|
String privateKey = require(creds, "privateKey", "MI");
|
||||||
String packageName = requirePackageName(v);
|
String packageName = requirePackageName(v);
|
||||||
JsonNode appInfo = miGetAppInfo(account, packageName, publicKey, privateKey);
|
JsonNode appInfo = miGetAppInfo(account, packageName, publicKey, privateKey);
|
||||||
@ -662,12 +696,41 @@ public class StoreSubmissionService {
|
|||||||
if (account == null || account.isBlank()) {
|
if (account == null || account.isBlank()) {
|
||||||
account = creds.get("username");
|
account = creds.get("username");
|
||||||
}
|
}
|
||||||
|
if (account == null || account.isBlank()) {
|
||||||
|
account = creds.get("userName");
|
||||||
|
}
|
||||||
if (account == null || account.isBlank()) {
|
if (account == null || account.isBlank()) {
|
||||||
throw new IllegalStateException("MI credential missing: account");
|
throw new IllegalStateException("MI credential missing: account");
|
||||||
}
|
}
|
||||||
return 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) {
|
private void miCheckSuccess(JsonNode root, String action) {
|
||||||
int code = root.path("result").asInt(-1);
|
int code = root.path("result").asInt(-1);
|
||||||
String message = root.path("message").asText("未知");
|
String message = root.path("message").asText("未知");
|
||||||
@ -982,6 +1045,18 @@ public class StoreSubmissionService {
|
|||||||
return mapper.readValue(json, new TypeReference<Map<String, String>>() {});
|
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) {
|
private String require(Map<String, String> creds, String key, String store) {
|
||||||
String v = creds.get(key);
|
String v = creds.get(key);
|
||||||
if (v == null || v.isBlank())
|
if (v == null || v.isBlank())
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户