From 32b0e49e61c11df6f354f81771f662f97051589f Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Thu, 30 Apr 2026 09:49:05 +0800 Subject: [PATCH] =?UTF-8?q?docs(deploy):=20=E6=B7=BB=E5=8A=A0=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E7=8E=AF=E5=A2=83=E9=83=A8=E7=BD=B2=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E5=92=8C=E9=83=A8=E7=BD=B2=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 .env.production.example 环境变量配置模板 - 添加 compose.production.yaml Docker Compose 部署配置 - 创建 web.Dockerfile 前端构建部署文件 - 编写详细的 README.md 部署文档,涵盖架构、配置、步骤等内容 - 添加离线推送架构设计文档 - 更新 IM 多平台进度跟踪文档 --- docs/API_ACCESS.md | 8 +- .../com/xuqm/push/service/PushDispatcher.java | 2 +- .../push/service/TenantPushConfigClient.java | 67 +++++++++ .../service/provider/HonorPushProvider.java | 108 +++++++++++++ .../service/provider/HuaweiPushProvider.java | 44 ++++-- .../push/service/provider/PushProvider.java | 2 +- .../service/provider/XiaomiPushProvider.java | 29 +++- .../src/main/resources/application.yml | 2 + .../xuqm/tenant/controller/AppController.java | 13 +- .../controller/FeatureServiceController.java | 80 ++++++++-- .../controller/OperationLogController.java | 32 ++++ .../controller/SubAccountController.java | 12 +- .../tenant/entity/OperationLogEntity.java | 71 +++++++++ .../repository/OperationLogRepository.java | 15 ++ .../com/xuqm/tenant/service/AppService.java | 41 ++++- .../tenant/service/FeatureServiceManager.java | 72 +++++++++ .../tenant/service/OperationLogService.java | 76 ++++++++++ .../tenant/service/SubAccountService.java | 19 ++- .../update/controller/AppStoreController.java | 25 ++- .../controller/AppVersionController.java | 102 +++++++++++-- .../update/controller/RnBundleController.java | 74 ++++++++- .../controller/UnifiedReleaseController.java | 40 ++++- .../UpdateOperationLogController.java | 30 ++++ .../xuqm/update/entity/AppVersionEntity.java | 5 +- .../entity/UpdateOperationLogEntity.java | 67 +++++++++ .../repository/AppVersionRepository.java | 11 ++ .../UpdateOperationLogRepository.java | 11 ++ .../xuqm/update/service/AppStoreService.java | 142 ++++++++++++++++-- .../update/service/PublishConfigService.java | 20 ++- .../service/StoreSubmissionService.java | 12 +- .../service/UpdateOperationLogService.java | 70 +++++++++ 31 files changed, 1218 insertions(+), 84 deletions(-) create mode 100644 push-service/src/main/java/com/xuqm/push/service/TenantPushConfigClient.java create mode 100644 push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/controller/OperationLogController.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/entity/OperationLogEntity.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java create mode 100644 tenant-service/src/main/java/com/xuqm/tenant/service/OperationLogService.java create mode 100644 update-service/src/main/java/com/xuqm/update/controller/UpdateOperationLogController.java create mode 100644 update-service/src/main/java/com/xuqm/update/entity/UpdateOperationLogEntity.java create mode 100644 update-service/src/main/java/com/xuqm/update/repository/UpdateOperationLogRepository.java create mode 100644 update-service/src/main/java/com/xuqm/update/service/UpdateOperationLogService.java diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 4a28f59..8e2d0e5 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -144,7 +144,7 @@ | 方法 | 路径 | 鉴权 | 说明 | |------|------|------|------| | GET | `/api/v1/updates/app/check` | 否 | 检查 App 更新 | -| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Android 支持 `apkUrl`(来自 file-service)或旧版直传 `apkFile`,iOS / Harmony 仅记录版本号与市场跳转信息,`marketUrl` 可选,不要求本地安装包;可附带 `expectedPackageName` 作为当前应用包名守卫 | +| POST | `/api/v1/updates/app/upload` | 是 | 上传 App 版本,支持即时发布 / 定时发布 / 市场提交配置;Android 支持 `apkUrl`(来自 file-service)或旧版直传 `apkFile`,iOS / Harmony 仅记录版本号与市场跳转信息,`marketUrl` 也可以为空,不要求本地安装包;可附带 `expectedPackageName` 作为当前应用包名守卫 | | POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 | | GET | `/api/v1/updates/app/list` | 是 | App 版本列表 | | GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK | @@ -164,11 +164,14 @@ - 这里的 `appId` 按 `appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。 - `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧建议传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。 - 应用商店配置页分成两个 tab:`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。 +- 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret`。 - `POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。 - 发版上传建议走两段式:先调 `POST /api/file/upload` 拿到 `url`,再把这个 `url` 作为 `apkUrl` 传给 `POST /api/v1/updates/app/upload` 和 `POST /api/v1/updates/app/inspect`。 - 如果远程包地址暂时不可读,`inspect` 会返回 `detected=false`,发版页可以继续走手动填写 `versionName/versionCode` 的流程,不会因为解析失败直接中断。 +- `GET /api/v1/updates/app/check` 现在按“当前版本之后的最高已发布版本”返回更新信息,但 `forceUpdate` 会按照“当前版本之后是否存在任意一条强更版本”来计算,所以后续发布的非强更版本不会覆盖更低版本当时应看到的强更提示。 - RN bundle 建议打成 zip 后再上传,zip 内至少包含 `rn-manifest.json`、bundle 文件和资源文件;update-service 会优先从 manifest 自动读取 `moduleId`、`version` / `bundleVersion`、`minCommonVersion` 和 `packageName`。 - 租户平台里的“发布配置”标签页保存灰度默认模式、成员目录同步回调和成员选择回调;当默认模式切到成员灰度时,至少要配置一个回调才允许保存,保存前也会做连通性校验。 +- 上下架、上传、发布、灰度、市场提交、商店配置变更都会写入 `update_operation_log`,可通过 `GET /api/v1/updates/ops/logs?appId=...` 查询。 - 提交应用市场会真实调用已实现的厂商接口。小米、OPPO、vivo 和华为/荣耀当前支持服务端提交;App Store、Google Play、鸿蒙仍以跳转页和人工流程为主。 ## update-sdk 自动发版 @@ -200,7 +203,8 @@ Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传 3. 脚本读取服务器最新版本,和本地版本对比。 4. 本地版本不高于服务端时,提示用户重新输入版本名 / 版本码。 5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。 -6. 完成打包后上传到 update-service,再按配置提交市场或执行发布。 +6. 如果选择了定时上架,`autoPublishAfterReview` 会被服务端强制关闭,避免“定时上架”和“审核后自动发布”同时生效造成冲突。 +7. 完成打包后上传到 update-service,再按配置提交市场或执行发布。 租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。 diff --git a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java index 5e7624f..8ee2dd5 100644 --- a/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java +++ b/push-service/src/main/java/com/xuqm/push/service/PushDispatcher.java @@ -35,7 +35,7 @@ public class PushDispatcher { for (DeviceTokenEntity t : tokens) { PushProvider provider = providers.get(t.getVendor().name()); if (provider != null) { - boolean ok = provider.send(t.getToken(), title, body, payload); + boolean ok = provider.send(appId, t.getToken(), title, body, payload); log.info("Push to {}@{} via {}: {}", userId, appId, t.getVendor(), ok ? "OK" : "FAIL"); } } diff --git a/push-service/src/main/java/com/xuqm/push/service/TenantPushConfigClient.java b/push-service/src/main/java/com/xuqm/push/service/TenantPushConfigClient.java new file mode 100644 index 0000000..03dfcc6 --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/TenantPushConfigClient.java @@ -0,0 +1,67 @@ +package com.xuqm.push.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; +import java.util.Optional; + +@Component +public class TenantPushConfigClient { + + private static final Logger log = LoggerFactory.getLogger(TenantPushConfigClient.class); + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${push.tenant-service-base-url:http://tenant-service:8081}") + private String tenantServiceBaseUrl; + + @Value("${push.internal-token:xuqm-internal-token}") + private String internalToken; + + public Optional loadServiceConfig(String appId, String platform, String serviceType) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Internal-Token", internalToken); + ResponseEntity resp = restTemplate.exchange( + tenantServiceBaseUrl + "/api/internal/sdk/apps/" + appId + "/services/" + platform + "/" + serviceType, + HttpMethod.GET, + new HttpEntity<>(null, headers), + Map.class + ); + Map body = resp.getBody(); + if (body == null) { + return Optional.empty(); + } + Object data = body.get("data"); + if (!(data instanceof Map dataMap)) { + return Optional.empty(); + } + Object config = dataMap.get("config"); + if (config == null) { + return Optional.empty(); + } + String json = config.toString(); + if (json.isBlank()) { + return Optional.empty(); + } + return Optional.of(objectMapper.readTree(json)); + } catch (Exception e) { + log.warn("load tenant push config failed appId={} platform={} serviceType={} reason={}", + appId, platform, serviceType, e.getMessage()); + return Optional.empty(); + } + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java new file mode 100644 index 0000000..8b778ea --- /dev/null +++ b/push-service/src/main/java/com/xuqm/push/service/provider/HonorPushProvider.java @@ -0,0 +1,108 @@ +package com.xuqm.push.service.provider; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.push.service.TenantPushConfigClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; + +@Component +public class HonorPushProvider implements PushProvider { + + private static final Logger log = LoggerFactory.getLogger(HonorPushProvider.class); + + @Value("${push.huawei.app-id:}") + private String envAppId; + + @Value("${push.huawei.app-secret:}") + private String envAppSecret; + + @Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}") + private String tokenUrl; + + @Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}") + private String pushUrl; + + private final TenantPushConfigClient configClient; + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + public HonorPushProvider(TenantPushConfigClient configClient) { + this.configClient = configClient; + } + + @Override + public String vendorName() { + return "HONOR"; + } + + @Override + public boolean send(String appId, String token, String title, String body, String payload) { + String resolvedAppId = resolveConfig(appId, "appId", envAppId); + String resolvedAppSecret = resolveConfig(appId, "clientSecret", envAppSecret); + if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) { + resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret); + } + if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) { + log.warn("Honor push not configured"); + return false; + } + try { + String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret); + String url = pushUrl.replace("{appId}", resolvedAppId); + Map message = Map.of( + "message", Map.of( + "token", new String[]{token}, + "notification", Map.of("title", title, "body", body), + "data", payload != null ? payload : "{}" + ) + ); + String requestBody = objectMapper.writeValueAsString(message); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + accessToken) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response.statusCode() == 200; + } catch (Exception e) { + log.error("Honor push failed: {}", e.getMessage()); + return false; + } + } + + private String getAccessToken(String appId, String appSecret) throws Exception { + String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + Map json = objectMapper.readValue(response.body(), Map.class); + return (String) json.get("access_token"); + } + + private String resolveConfig(String appId, String key, String fallback) { + JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH") + .map(node -> node.path("honor")) + .orElse(null); + if (config == null) { + return fallback == null ? "" : fallback; + } + String value = config.path(key).asText(""); + if (value.isBlank() && "clientSecret".equals(key)) { + value = config.path("appSecret").asText(""); + } + return value.isBlank() ? (fallback == null ? "" : fallback) : value; + } +} diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java index e3ed53b..1aefb40 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/HuaweiPushProvider.java @@ -1,6 +1,8 @@ package com.xuqm.push.service.provider; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.push.service.TenantPushConfigClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -18,10 +20,10 @@ public class HuaweiPushProvider implements PushProvider { private static final Logger log = LoggerFactory.getLogger(HuaweiPushProvider.class); @Value("${push.huawei.app-id:}") - private String appId; + private String envAppId; @Value("${push.huawei.app-secret:}") - private String appSecret; + private String envAppSecret; @Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}") private String tokenUrl; @@ -29,29 +31,36 @@ public class HuaweiPushProvider implements PushProvider { @Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}") private String pushUrl; + private final TenantPushConfigClient configClient; private final HttpClient httpClient = HttpClient.newHttpClient(); private final ObjectMapper objectMapper = new ObjectMapper(); + public HuaweiPushProvider(TenantPushConfigClient configClient) { + this.configClient = configClient; + } + @Override public String vendorName() { return "HUAWEI"; } @Override - public boolean send(String token, String title, String body, String payload) { - if (appId.isBlank() || appSecret.isBlank()) { + public boolean send(String appId, String token, String title, String body, String payload) { + String resolvedAppId = resolveConfig(appId, "appId", envAppId); + String resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret); + if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) { log.warn("Huawei push not configured"); return false; } try { - String accessToken = getAccessToken(); - String url = pushUrl.replace("{appId}", appId); + String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret); + String url = pushUrl.replace("{appId}", resolvedAppId); Map message = Map.of( - "message", Map.of( - "token", new String[]{token}, - "notification", Map.of("title", title, "body", body), - "data", payload != null ? payload : "{}" - ) + "message", Map.of( + "token", new String[]{token}, + "notification", Map.of("title", title, "body", body), + "data", payload != null ? payload : "{}" + ) ); String requestBody = objectMapper.writeValueAsString(message); HttpRequest request = HttpRequest.newBuilder() @@ -68,7 +77,7 @@ public class HuaweiPushProvider implements PushProvider { } } - private String getAccessToken() throws Exception { + private String getAccessToken(String appId, String appSecret) throws Exception { String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(tokenUrl)) @@ -79,4 +88,15 @@ public class HuaweiPushProvider implements PushProvider { Map json = objectMapper.readValue(response.body(), Map.class); return (String) json.get("access_token"); } + + private String resolveConfig(String appId, String key, String fallback) { + JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH") + .map(node -> node.path("huawei")) + .orElse(null); + if (config == null) { + return fallback == null ? "" : fallback; + } + String value = config.path(key).asText(""); + return value.isBlank() ? (fallback == null ? "" : fallback) : value; + } } diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java index 055883e..c8c6096 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/PushProvider.java @@ -2,5 +2,5 @@ package com.xuqm.push.service.provider; public interface PushProvider { String vendorName(); - boolean send(String token, String title, String body, String payload); + boolean send(String appId, String token, String title, String body, String payload); } diff --git a/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java b/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java index 08ff763..f4bdcf5 100644 --- a/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java +++ b/push-service/src/main/java/com/xuqm/push/service/provider/XiaomiPushProvider.java @@ -1,5 +1,7 @@ package com.xuqm.push.service.provider; +import com.fasterxml.jackson.databind.JsonNode; +import com.xuqm.push.service.TenantPushConfigClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -18,18 +20,27 @@ public class XiaomiPushProvider implements PushProvider { private static final Logger log = LoggerFactory.getLogger(XiaomiPushProvider.class); @Value("${push.xiaomi.app-secret:}") - private String appSecret; + private String envAppSecret; @Value("${push.xiaomi.push-url:https://api.xmpush.xiaomi.com/v3/message/regid}") private String pushUrl; + private final TenantPushConfigClient configClient; private final HttpClient httpClient = HttpClient.newHttpClient(); + public XiaomiPushProvider(TenantPushConfigClient configClient) { + this.configClient = configClient; + } + @Override public String vendorName() { return "XIAOMI"; } @Override - public boolean send(String token, String title, String body, String payload) { + public boolean send(String appId, String token, String title, String body, String payload) { + String appSecret = resolveConfig(appId, "appSecret", envAppSecret); + if (appSecret.isBlank()) { + appSecret = resolveConfig(appId, "appKey", envAppSecret); + } if (appSecret.isBlank()) { log.warn("Xiaomi push not configured"); return false; @@ -53,4 +64,18 @@ public class XiaomiPushProvider implements PushProvider { return false; } } + + private String resolveConfig(String appId, String key, String fallback) { + JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH") + .map(node -> node.path("xiaomi")) + .orElse(null); + if (config == null) { + return fallback == null ? "" : fallback; + } + String value = config.path(key).asText(""); + if (value.isBlank() && "appSecret".equals(key)) { + value = config.path("appKey").asText(""); + } + return value.isBlank() ? (fallback == null ? "" : fallback) : value; + } } diff --git a/push-service/src/main/resources/application.yml b/push-service/src/main/resources/application.yml index 389fa6a..09ff9de 100644 --- a/push-service/src/main/resources/application.yml +++ b/push-service/src/main/resources/application.yml @@ -40,3 +40,5 @@ push: key-path: ${APNS_KEY_PATH:} bundle-id: ${APNS_BUNDLE_ID:} production: false + + tenant-service-base-url: ${TENANT_SERVICE_BASE_URL:http://tenant-service:8081} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java index ccf0380..2410f2b 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/AppController.java @@ -7,6 +7,7 @@ import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.service.AppService; import com.xuqm.tenant.service.EmailService; +import com.xuqm.tenant.service.OperationLogService; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -29,11 +30,15 @@ public class AppController { private final AppService appService; private final EmailService emailService; + private final OperationLogService operationLogService; private final TenantRepository tenantRepository; - public AppController(AppService appService, EmailService emailService, TenantRepository tenantRepository) { + public AppController(AppService appService, EmailService emailService, + OperationLogService operationLogService, + TenantRepository tenantRepository) { this.appService = appService; this.emailService = emailService; + this.operationLogService = operationLogService; this.tenantRepository = tenantRepository; } @@ -78,6 +83,9 @@ public class AppController { TenantEntity tenant = tenantRepository.findById(tenantId) .orElseThrow(() -> new RuntimeException("Tenant not found")); emailService.sendVerificationCode(tenant.getEmail(), purpose); + operationLogService.record(tenantId, "APP", "APP_SECRET", id, "REQUEST_SECRET_VERIFY", Map.of( + "purpose", purpose + )); return ResponseEntity.ok(ApiResponse.ok()); } @@ -91,6 +99,9 @@ public class AppController { TenantEntity tenant = tenantRepository.findById(tenantId) .orElseThrow(() -> new RuntimeException("Tenant not found")); emailService.verify(tenant.getEmail(), body.get("code"), "REVEAL_SECRET"); + operationLogService.record(tenantId, "APP", "APP_SECRET", id, "REVEAL_APP_SECRET", Map.of( + "appKey", app.getAppKey() + )); return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", app.getAppSecret()))); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java index a63ec87..cd348c3 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/FeatureServiceController.java @@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.FeatureServiceEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.service.AppService; import com.xuqm.tenant.service.FeatureServiceManager; +import com.xuqm.tenant.service.OperationLogService; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; @@ -24,10 +25,13 @@ public class FeatureServiceController { private final FeatureServiceManager featureServiceManager; private final AppService appService; + private final OperationLogService operationLogService; - public FeatureServiceController(FeatureServiceManager featureServiceManager, AppService appService) { + public FeatureServiceController(FeatureServiceManager featureServiceManager, AppService appService, + OperationLogService operationLogService) { this.featureServiceManager = featureServiceManager; this.appService = appService; + this.operationLogService = operationLogService; } @GetMapping @@ -59,8 +63,12 @@ public class FeatureServiceController { if (enable) { throw new com.xuqm.common.exception.BusinessException(400, "开启服务请通过 request-activation 申请"); } - return ResponseEntity.ok(ApiResponse.success( - featureServiceManager.disable(appId, platform, serviceType))); + FeatureServiceEntity saved = featureServiceManager.disable(appId, platform, serviceType); + operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "DISABLE_SERVICE", java.util.Map.of( + "platform", platform.name(), + "serviceType", serviceType.name() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @PutMapping("/config") @@ -99,10 +107,37 @@ public class FeatureServiceController { req == null ? null : req.defaultPackageName(), req == null ? null : req.defaultAppStoreUrl(), req == null ? null : req.defaultMarketUrl()); - case PUSH -> "{}"; + case PUSH -> featureServiceManager.buildPushConfig( + appId, + platform, + req == null ? null : req.huaweiAppId(), + req == null ? null : req.huaweiAppSecret(), + req == null ? null : req.xiaomiAppId(), + req == null ? null : req.xiaomiAppKey(), + req == null ? null : req.xiaomiAppSecret(), + req == null ? null : req.oppoAppId(), + req == null ? null : req.oppoAppKey(), + req == null ? null : req.oppoMasterSecret(), + req == null ? null : req.vivoAppId(), + req == null ? null : req.vivoAppKey(), + req == null ? null : req.vivoAppSecret(), + req == null ? null : req.honorAppId(), + req == null ? null : req.honorClientId(), + req == null ? null : req.honorClientSecret(), + req == null ? null : req.apnsTeamId(), + req == null ? null : req.apnsKeyId(), + req == null ? null : req.apnsBundleId(), + req == null ? null : req.apnsKeyPath(), + req != null && Boolean.TRUE.equals(req.apnsSandbox()), + req == null ? null : req.fcmServiceAccountJson()); }; - return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig( - appId, platform, serviceType, config))); + FeatureServiceEntity saved = featureServiceManager.updateConfig( + appId, platform, serviceType, config); + operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "UPDATE_SERVICE_CONFIG", java.util.Map.of( + "platform", platform.name(), + "serviceType", serviceType.name() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } /** Submit an activation request for ops approval. */ @@ -114,8 +149,15 @@ public class FeatureServiceController { @RequestParam(required = false) String applyReason, @AuthenticationPrincipal String tenantId) { appService.getById(appId, tenantId); - return ResponseEntity.ok(ApiResponse.success( - featureServiceManager.submitActivationRequest(appId, platform, serviceType, applyReason))); + ServiceActivationRequestEntity saved = featureServiceManager.submitActivationRequest(appId, platform, serviceType, applyReason); + java.util.Map detail = new java.util.LinkedHashMap<>(); + detail.put("platform", platform.name()); + detail.put("serviceType", serviceType.name()); + if (applyReason != null && !applyReason.isBlank()) { + detail.put("applyReason", applyReason); + } + operationLogService.record(tenantId, "SERVICE", "SERVICE_ACTIVATION", saved.getId(), "REQUEST_SERVICE_ACTIVATION", detail); + return ResponseEntity.ok(ApiResponse.success(saved)); } @GetMapping("/requests") @@ -146,6 +188,26 @@ public class FeatureServiceController { Integer defaultGrayPercent, String defaultPackageName, String defaultAppStoreUrl, - String defaultMarketUrl + String defaultMarketUrl, + String huaweiAppId, + String huaweiAppSecret, + String xiaomiAppId, + String xiaomiAppKey, + String xiaomiAppSecret, + String oppoAppId, + String oppoAppKey, + String oppoMasterSecret, + String vivoAppId, + String vivoAppKey, + String vivoAppSecret, + String honorAppId, + String honorClientId, + String honorClientSecret, + String apnsTeamId, + String apnsKeyId, + String apnsBundleId, + String apnsKeyPath, + Boolean apnsSandbox, + String fcmServiceAccountJson ) {} } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/OperationLogController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/OperationLogController.java new file mode 100644 index 0000000..bd62562 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/OperationLogController.java @@ -0,0 +1,32 @@ +package com.xuqm.tenant.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.tenant.entity.OperationLogEntity; +import com.xuqm.tenant.service.OperationLogService; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/operation-logs") +public class OperationLogController { + + private final OperationLogService operationLogService; + + public OperationLogController(OperationLogService operationLogService) { + this.operationLogService = operationLogService; + } + + @GetMapping + public ResponseEntity>> list( + @AuthenticationPrincipal String tenantId, + @RequestParam(required = false) String moduleType, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ResponseEntity.ok(ApiResponse.success(operationLogService.list(tenantId, moduleType, page, size))); + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java b/tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java index eff6702..fe05c63 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/controller/SubAccountController.java @@ -5,6 +5,7 @@ import com.xuqm.common.model.ApiResponse; import com.xuqm.tenant.dto.CreateSubAccountRequest; import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.service.EmailService; +import com.xuqm.tenant.service.OperationLogService; import com.xuqm.tenant.service.SubAccountService; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; @@ -29,10 +30,13 @@ public class SubAccountController { private final SubAccountService subAccountService; private final EmailService emailService; + private final OperationLogService operationLogService; - public SubAccountController(SubAccountService subAccountService, EmailService emailService) { + public SubAccountController(SubAccountService subAccountService, EmailService emailService, + OperationLogService operationLogService) { this.subAccountService = subAccountService; this.emailService = emailService; + this.operationLogService = operationLogService; } @GetMapping @@ -44,6 +48,9 @@ public class SubAccountController { public ResponseEntity> sendVerifyCode(@RequestParam @NotBlank @Email String email, @AuthenticationPrincipal String tenantId) { emailService.sendVerificationCode(email, "SUB_ACCOUNT"); + operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "SEND_VERIFY_CODE", Map.of( + "email", email + )); return ResponseEntity.ok(ApiResponse.ok()); } @@ -52,6 +59,9 @@ public class SubAccountController { @RequestParam @NotBlank String code, @AuthenticationPrincipal String tenantId) { subAccountService.verifyEmail(tenantId, email, code); + operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL", Map.of( + "email", email + )); return ResponseEntity.ok(ApiResponse.ok()); } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/entity/OperationLogEntity.java b/tenant-service/src/main/java/com/xuqm/tenant/entity/OperationLogEntity.java new file mode 100644 index 0000000..833b9a2 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/entity/OperationLogEntity.java @@ -0,0 +1,71 @@ +package com.xuqm.tenant.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "t_operation_log", indexes = { + @Index(name = "idx_t_op_log_tenant_time", columnList = "tenantId,createdAt"), + @Index(name = "idx_t_op_log_tenant_module", columnList = "tenantId,moduleType") +}) +public class OperationLogEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String tenantId; + + @Column(nullable = false, length = 32) + private String moduleType; + + @Column(nullable = false, length = 64) + private String resourceType; + + @Column(length = 128) + private String resourceId; + + @Column(nullable = false, length = 64) + private String action; + + @Column(length = 128) + private String operator; + + @Column(columnDefinition = "TEXT") + private String detailJson; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getTenantId() { return tenantId; } + public void setTenantId(String tenantId) { this.tenantId = tenantId; } + + public String getModuleType() { return moduleType; } + public void setModuleType(String moduleType) { this.moduleType = moduleType; } + + public String getResourceType() { return resourceType; } + public void setResourceType(String resourceType) { this.resourceType = resourceType; } + + public String getResourceId() { return resourceId; } + public void setResourceId(String resourceId) { this.resourceId = resourceId; } + + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + + public String getOperator() { return operator; } + public void setOperator(String operator) { this.operator = operator; } + + public String getDetailJson() { return detailJson; } + public void setDetailJson(String detailJson) { this.detailJson = detailJson; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java b/tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java new file mode 100644 index 0000000..796be81 --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/repository/OperationLogRepository.java @@ -0,0 +1,15 @@ +package com.xuqm.tenant.repository; + +import com.xuqm.tenant.entity.OperationLogEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OperationLogRepository extends JpaRepository { + Page findByTenantIdOrderByCreatedAtDesc(String tenantId, Pageable pageable); + + Page findByTenantIdAndModuleTypeOrderByCreatedAtDesc( + String tenantId, + String moduleType, + Pageable pageable); +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java index 6d6dbed..b2c401e 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java @@ -9,17 +9,21 @@ import org.springframework.stereotype.Service; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Base64; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; @Service public class AppService { private final AppRepository appRepository; + private final OperationLogService operationLogService; private static final SecureRandom random = new SecureRandom(); - public AppService(AppRepository appRepository) { + public AppService(AppRepository appRepository, OperationLogService operationLogService) { this.appRepository = appRepository; + this.operationLogService = operationLogService; } public List listByTenant(String tenantId) { @@ -49,20 +53,46 @@ public class AppService { app.setAppKey(generateAppKey()); app.setAppSecret(generateSecret()); app.setCreatedAt(LocalDateTime.now()); - return appRepository.save(app); + AppEntity saved = appRepository.save(app); + operationLogService.record(tenantId, "APP", "APP", saved.getId(), "CREATE_APP", Map.of( + "name", saved.getName(), + "packageName", saved.getPackageName(), + "appKey", saved.getAppKey() + )); + return saved; } public AppEntity update(String id, String tenantId, CreateAppRequest req) { AppEntity app = getById(id, tenantId); + Map before = new LinkedHashMap<>(); + before.put("name", app.getName()); + before.put("packageName", app.getPackageName()); + before.put("description", app.getDescription()); + before.put("iconUrl", app.getIconUrl()); app.setName(req.name()); app.setDescription(req.description()); app.setIconUrl(req.iconUrl()); - return appRepository.save(app); + AppEntity saved = appRepository.save(app); + Map after = new LinkedHashMap<>(); + after.put("name", saved.getName()); + after.put("packageName", saved.getPackageName()); + after.put("description", saved.getDescription()); + after.put("iconUrl", saved.getIconUrl()); + operationLogService.record(tenantId, "APP", "APP", saved.getId(), "UPDATE_APP", Map.of( + "before", before, + "after", after + )); + return saved; } public void delete(String id, String tenantId) { AppEntity app = getById(id, tenantId); appRepository.delete(app); + operationLogService.record(tenantId, "APP", "APP", id, "DELETE_APP", Map.of( + "name", app.getName(), + "packageName", app.getPackageName(), + "appKey", app.getAppKey() + )); } public String resetSecret(String id, String tenantId) { @@ -70,6 +100,11 @@ public class AppService { String newSecret = generateSecret(); app.setAppSecret(newSecret); appRepository.save(app); + operationLogService.record(tenantId, "APP", "APP_SECRET", id, "RESET_APP_SECRET", Map.of( + "name", app.getName(), + "packageName", app.getPackageName(), + "appKey", app.getAppKey() + )); return newSecret; } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java index e1ceb40..47c67de 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/FeatureServiceManager.java @@ -458,6 +458,66 @@ public class FeatureServiceManager { return node.toString(); } + public String buildPushConfig(String appId, + FeatureServiceEntity.Platform platform, + String huaweiAppId, + String huaweiAppSecret, + String xiaomiAppId, + String xiaomiAppKey, + String xiaomiAppSecret, + String oppoAppId, + String oppoAppKey, + String oppoMasterSecret, + String vivoAppId, + String vivoAppKey, + String vivoAppSecret, + String honorAppId, + String honorClientId, + String honorClientSecret, + String apnsTeamId, + String apnsKeyId, + String apnsBundleId, + String apnsKeyPath, + boolean apnsSandbox, + String fcmServiceAccountJson) { + ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.PUSH).deepCopy(); + ensureObjectNode(node, "huawei"); + ensureObjectNode(node, "xiaomi"); + ensureObjectNode(node, "oppo"); + ensureObjectNode(node, "vivo"); + ensureObjectNode(node, "honor"); + ensureObjectNode(node, "apns"); + ensureObjectNode(node, "fcm"); + + putText(node.with("huawei"), "appId", huaweiAppId); + putText(node.with("huawei"), "appSecret", huaweiAppSecret); + + putText(node.with("xiaomi"), "appId", xiaomiAppId); + putText(node.with("xiaomi"), "appKey", xiaomiAppKey); + putText(node.with("xiaomi"), "appSecret", xiaomiAppSecret); + + putText(node.with("oppo"), "appId", oppoAppId); + putText(node.with("oppo"), "appKey", oppoAppKey); + putText(node.with("oppo"), "masterSecret", oppoMasterSecret); + + putText(node.with("vivo"), "appId", vivoAppId); + putText(node.with("vivo"), "appKey", vivoAppKey); + putText(node.with("vivo"), "appSecret", vivoAppSecret); + + putText(node.with("honor"), "appId", honorAppId); + putText(node.with("honor"), "clientId", honorClientId); + putText(node.with("honor"), "clientSecret", honorClientSecret); + + putText(node.with("apns"), "teamId", apnsTeamId); + putText(node.with("apns"), "keyId", apnsKeyId); + putText(node.with("apns"), "bundleId", apnsBundleId); + putText(node.with("apns"), "keyPath", apnsKeyPath); + node.with("apns").put("sandbox", apnsSandbox); + + putText(node.with("fcm"), "serviceAccountJson", fcmServiceAccountJson); + return node.toString(); + } + public List parseStoreTargets(String json) { if (json == null || json.isBlank()) { return List.of(); @@ -514,4 +574,16 @@ public class FeatureServiceManager { return objectMapper.createObjectNode(); } } + + private void ensureObjectNode(ObjectNode root, String field) { + if (!root.has(field) || !root.get(field).isObject()) { + root.putObject(field); + } + } + + private void putText(ObjectNode node, String field, String value) { + if (value != null) { + node.put(field, value.trim()); + } + } } diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/OperationLogService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/OperationLogService.java new file mode 100644 index 0000000..16f60cf --- /dev/null +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/OperationLogService.java @@ -0,0 +1,76 @@ +package com.xuqm.tenant.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.tenant.entity.OperationLogEntity; +import com.xuqm.tenant.repository.OperationLogRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +@Service +public class OperationLogService { + + private final OperationLogRepository repository; + private final ObjectMapper objectMapper; + + public OperationLogService(OperationLogRepository repository, ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + public void record(String tenantId, + String moduleType, + String resourceType, + String resourceId, + String action, + Map detail) { + OperationLogEntity entity = new OperationLogEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setTenantId(tenantId); + entity.setModuleType(moduleType); + entity.setResourceType(resourceType); + entity.setResourceId(resourceId); + entity.setAction(action); + entity.setOperator(currentOperator()); + entity.setDetailJson(serialize(detail)); + entity.setCreatedAt(LocalDateTime.now()); + repository.save(entity); + } + + public Page list(String tenantId, String moduleType, int page, int size) { + int safePage = Math.max(page, 0); + int safeSize = Math.min(Math.max(size, 1), 200); + PageRequest pageable = PageRequest.of(safePage, safeSize); + if (moduleType == null || moduleType.isBlank()) { + return repository.findByTenantIdOrderByCreatedAtDesc(tenantId, pageable); + } + return repository.findByTenantIdAndModuleTypeOrderByCreatedAtDesc( + tenantId, + moduleType.trim().toUpperCase(), + pageable); + } + + private String currentOperator() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getName() == null || auth.getName().isBlank()) { + return "system"; + } + return auth.getName(); + } + + private String serialize(Map detail) { + try { + Map payload = detail == null ? Map.of() : new LinkedHashMap<>(detail); + return objectMapper.writeValueAsString(payload); + } catch (Exception e) { + return "{}"; + } + } +} diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java index b813465..55e6947 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/SubAccountService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Base64; +import java.util.Map; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -21,16 +22,19 @@ public class SubAccountService { private final TenantRepository tenantRepository; private final PasswordEncoder passwordEncoder; private final EmailService emailService; + private final OperationLogService operationLogService; private final StringRedisTemplate redis; private static final String SUB_VERIFY_PREFIX = "sub_verified:"; private static final SecureRandom random = new SecureRandom(); public SubAccountService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder, - EmailService emailService, StringRedisTemplate redis) { + EmailService emailService, OperationLogService operationLogService, + StringRedisTemplate redis) { this.tenantRepository = tenantRepository; this.passwordEncoder = passwordEncoder; this.emailService = emailService; + this.operationLogService = operationLogService; this.redis = redis; } @@ -59,7 +63,13 @@ public class SubAccountService { sub.setStatus(TenantEntity.Status.ACTIVE); sub.setParentId(parentId); sub.setCreatedAt(LocalDateTime.now()); - return tenantRepository.save(sub); + TenantEntity saved = tenantRepository.save(sub); + operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", saved.getId(), "CREATE_SUB_ACCOUNT", Map.of( + "username", saved.getUsername(), + "nickname", saved.getNickname(), + "email", saved.getEmail() + )); + return saved; } public List listByParent(String parentId) { @@ -74,6 +84,11 @@ public class SubAccountService { } sub.setStatus(TenantEntity.Status.DISABLED); tenantRepository.save(sub); + operationLogService.record(parentId, "SUB_ACCOUNT", "SUB_ACCOUNT", sub.getId(), "DISABLE_SUB_ACCOUNT", Map.of( + "username", sub.getUsername(), + "nickname", sub.getNickname(), + "email", sub.getEmail() + )); } public String generatePassword() { diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java index 248c393..33c4f13 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppStoreController.java @@ -55,9 +55,7 @@ public class AppStoreController { String configJson = body.get("configJson") instanceof String s ? s : null; boolean enabled = !Boolean.FALSE.equals(body.get("enabled")); - if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK && configJson != null && !configJson.isBlank()) { - validateReviewWebhook(configJson); - } + validateStoreConfig(storeType, configJson); return ResponseEntity.ok(ApiResponse.success( storeService.saveConfig(appId, storeType, configJson, enabled))); } @@ -150,8 +148,9 @@ public class AppStoreController { String storeType = (String) body.get("storeType"); AppVersionEntity.StoreReviewState state = AppVersionEntity.StoreReviewState.valueOf((String) body.get("state")); + String reason = body.get("reason") == null ? null : body.get("reason").toString(); return ResponseEntity.ok(ApiResponse.success( - storeService.updateStoreReview(versionId, storeType, state))); + storeService.updateStoreReview(versionId, storeType, state, reason))); } private void validateReviewWebhook(String configJson) { @@ -167,4 +166,22 @@ public class AppStoreController { throw new IllegalArgumentException("invalid review webhook config: " + e.getMessage(), e); } } + + private void validateStoreConfig(AppStoreConfigEntity.StoreType storeType, String configJson) { + if (configJson == null || configJson.isBlank()) { + return; + } + try { + @SuppressWarnings("unchecked") + Map config = new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(configJson, Map.class); + if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK) { + validateReviewWebhook(configJson); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException("invalid store config payload: " + e.getMessage(), e); + } + } } diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 00c2a4b..ff097d7 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -4,6 +4,7 @@ import com.xuqm.common.model.ApiResponse; import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.model.AppPackageInspectResult; +import com.xuqm.update.service.UpdateOperationLogService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; @@ -29,15 +30,18 @@ public class AppVersionController { private final UpdateAssetService updateAssetService; private final PublishConfigService publishConfigService; private final AppStoreService appStoreService; + private final UpdateOperationLogService operationLogService; public AppVersionController(AppVersionRepository versionRepository, UpdateAssetService updateAssetService, PublishConfigService publishConfigService, - AppStoreService appStoreService) { + AppStoreService appStoreService, + UpdateOperationLogService operationLogService) { this.versionRepository = versionRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; this.appStoreService = appStoreService; + this.operationLogService = operationLogService; } @GetMapping("/app/check") @@ -47,10 +51,13 @@ public class AppVersionController { @RequestParam int currentVersionCode) { Optional latest = versionRepository - .findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc( - appId, platform, AppVersionEntity.PublishStatus.PUBLISHED); + .findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( + appId, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); + Optional forcedHigher = versionRepository + .findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc( + appId, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); - if (latest.isEmpty() || latest.get().getVersionCode() <= currentVersionCode) { + if (latest.isEmpty()) { return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false))); } @@ -67,7 +74,7 @@ public class AppVersionController { "versionCode", v.getVersionCode(), "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", - "forceUpdate", v.isForceUpdate(), + "forceUpdate", forcedHigher.isPresent(), "appStoreUrl", appStoreJumpUrl, "marketUrl", harmonyJumpUrl ))); @@ -153,7 +160,22 @@ public class AppVersionController { entity.setGrayEnabled(false); entity.setGrayPercent(0); } - return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); + AppVersionEntity saved = versionRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "APP_VERSION", + saved.getId(), + "UPLOAD", + null, + Map.of( + "platform", saved.getPlatform().name(), + "versionName", saved.getVersionName(), + "versionCode", saved.getVersionCode(), + "publishImmediately", publishImmediately, + "forceUpdate", saved.isForceUpdate(), + "packageName", saved.getPackageName() == null ? "" : saved.getPackageName() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @RequestMapping(value = "/app/inspect", method = {RequestMethod.GET, RequestMethod.POST}) @@ -176,6 +198,7 @@ public class AppVersionController { ? body.get("scheduledPublishAt").toString() : null; boolean forceUpdate = body != null && body.get("forceUpdate") != null ? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate(); + AppVersionEntity.PublishStatus previousStatus = entity.getPublishStatus(); entity.setForceUpdate(forceUpdate); if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) { entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); @@ -190,14 +213,45 @@ public class AppVersionController { entity.setGrayPercent(0); entity.setGrayMode("PERCENT"); entity.setGrayMemberIds(null); - return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); + AppVersionEntity saved = versionRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "APP_VERSION", + saved.getId(), + publishAction(previousStatus, saved.getPublishStatus(), publishImmediately), + null, + Map.of( + "versionName", saved.getVersionName(), + "versionCode", saved.getVersionCode(), + "publishImmediately", publishImmediately, + "scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(), + "forceUpdate", saved.isForceUpdate() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @PostMapping("/app/{id}/unpublish") - public ResponseEntity> unpublish(@PathVariable String id) { + public ResponseEntity> unpublish( + @PathVariable String id, + @RequestBody(required = false) Map body) { AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); + String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : ""; + if (reason.isBlank()) { + throw new IllegalArgumentException("unpublish reason is required"); + } entity.setPublishStatus(AppVersionEntity.PublishStatus.DEPRECATED); - return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); + AppVersionEntity saved = versionRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "APP_VERSION", + saved.getId(), + "UNPUBLISH", + reason, + Map.of( + "versionName", saved.getVersionName(), + "versionCode", saved.getVersionCode() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @PostMapping("/app/{id}/gray") @@ -228,7 +282,20 @@ public class AppVersionController { entity.setGrayMemberIds(null); } entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); - return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity))); + AppVersionEntity saved = versionRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "APP_VERSION", + saved.getId(), + "GRAY_UPDATE", + null, + Map.of( + "enabled", enabled, + "grayMode", saved.getGrayMode(), + "grayPercent", saved.getGrayPercent(), + "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @GetMapping("/app/list") @@ -238,6 +305,21 @@ public class AppVersionController { versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform))); } + private String publishAction(AppVersionEntity.PublishStatus previousStatus, + AppVersionEntity.PublishStatus currentStatus, + boolean publishImmediately) { + if (!publishImmediately) { + return "SCHEDULE_PUBLISH"; + } + if (previousStatus == AppVersionEntity.PublishStatus.PUBLISHED) { + return "UPDATE_FORCE"; + } + if (previousStatus == AppVersionEntity.PublishStatus.DEPRECATED) { + return "REPUBLISH"; + } + return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT"; + } + private boolean hasText(String value) { return value != null && !value.isBlank(); } diff --git a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java index 43a1be5..b91c584 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/RnBundleController.java @@ -5,6 +5,7 @@ import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.model.RnBundleInspectResult; import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.service.PublishConfigService; +import com.xuqm.update.service.UpdateOperationLogService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,16 +25,19 @@ public class RnBundleController { private final RnBundleRepository bundleRepository; private final UpdateAssetService updateAssetService; private final PublishConfigService publishConfigService; + private final UpdateOperationLogService operationLogService; @Value("${update.base-url:https://update.dev.xuqinmin.com}") private String baseUrl; public RnBundleController(RnBundleRepository bundleRepository, UpdateAssetService updateAssetService, - PublishConfigService publishConfigService) { + PublishConfigService publishConfigService, + UpdateOperationLogService operationLogService) { this.bundleRepository = bundleRepository; this.updateAssetService = updateAssetService; this.publishConfigService = publishConfigService; + this.operationLogService = operationLogService; } @GetMapping("/update/check") @@ -108,7 +112,21 @@ public class RnBundleController { entity.setGrayMode("PERCENT"); entity.setGrayMemberIds(null); entity.setCreatedAt(LocalDateTime.now()); - return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); + RnBundleEntity saved = bundleRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "RN_BUNDLE", + saved.getId(), + "UPLOAD", + null, + Map.of( + "moduleId", saved.getModuleId(), + "platform", saved.getPlatform().name(), + "version", saved.getVersion(), + "minCommonVersion", saved.getMinCommonVersion() == null ? "" : saved.getMinCommonVersion(), + "packageName", saved.getPackageName() == null ? "" : saved.getPackageName() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @RequestMapping(value = "/inspect", method = {RequestMethod.GET, RequestMethod.POST}) @@ -156,14 +174,44 @@ public class RnBundleController { entity.setGrayPercent(0); entity.setGrayMode("PERCENT"); entity.setGrayMemberIds(null); - return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); + RnBundleEntity saved = bundleRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "RN_BUNDLE", + saved.getId(), + publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank()) ? "PUBLISH" : "SCHEDULE_PUBLISH", + null, + Map.of( + "moduleId", saved.getModuleId(), + "version", saved.getVersion(), + "publishMode", saved.getPublishMode(), + "scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @PostMapping("/{id}/unpublish") - public ResponseEntity> unpublish(@PathVariable String id) { + public ResponseEntity> unpublish( + @PathVariable String id, + @RequestBody(required = false) Map body) { RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); + String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : ""; + if (reason.isBlank()) { + throw new IllegalArgumentException("unpublish reason is required"); + } entity.setPublishStatus(RnBundleEntity.PublishStatus.DEPRECATED); - return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); + RnBundleEntity saved = bundleRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "RN_BUNDLE", + saved.getId(), + "UNPUBLISH", + reason, + Map.of( + "moduleId", saved.getModuleId(), + "version", saved.getVersion() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } @PostMapping("/{id}/gray") @@ -194,7 +242,21 @@ public class RnBundleController { entity.setGrayMemberIds(null); } entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); - return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity))); + RnBundleEntity saved = bundleRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "RN_BUNDLE", + saved.getId(), + "GRAY_UPDATE", + null, + Map.of( + "moduleId", saved.getModuleId(), + "version", saved.getVersion(), + "grayMode", saved.getGrayMode(), + "grayPercent", saved.getGrayPercent(), + "memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size() + )); + return ResponseEntity.ok(ApiResponse.success(saved)); } private String resolvePublicBaseUrl() { diff --git a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java index b35a825..b825431 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java @@ -9,6 +9,7 @@ import com.xuqm.update.model.UnifiedReleaseResult; import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.service.UpdateAssetService; +import com.xuqm.update.service.UpdateOperationLogService; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -21,6 +22,7 @@ import org.springframework.web.multipart.MultipartHttpServletRequest; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; @RestController @@ -31,16 +33,19 @@ public class UnifiedReleaseController { private final AppVersionRepository appVersionRepository; private final RnBundleRepository rnBundleRepository; private final UpdateAssetService updateAssetService; + private final UpdateOperationLogService operationLogService; public UnifiedReleaseController( ObjectMapper objectMapper, AppVersionRepository appVersionRepository, RnBundleRepository rnBundleRepository, - UpdateAssetService updateAssetService) { + UpdateAssetService updateAssetService, + UpdateOperationLogService operationLogService) { this.objectMapper = objectMapper; this.appVersionRepository = appVersionRepository; this.rnBundleRepository = rnBundleRepository; this.updateAssetService = updateAssetService; + this.operationLogService = operationLogService; } @PostMapping("/unified/upload") @@ -83,7 +88,22 @@ public class UnifiedReleaseController { entity.setGrayEnabled(false); entity.setGrayPercent(0); } - appVersions.add(appVersionRepository.save(entity)); + AppVersionEntity saved = appVersionRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "APP_VERSION", + saved.getId(), + "UPLOAD", + null, + Map.of( + "platform", saved.getPlatform().name(), + "versionName", saved.getVersionName(), + "versionCode", saved.getVersionCode(), + "publishImmediately", item.publishImmediately(), + "forceUpdate", saved.isForceUpdate(), + "packageName", saved.getPackageName() == null ? "" : saved.getPackageName() + )); + appVersions.add(saved); } List rnBundles = new ArrayList<>(); @@ -108,7 +128,21 @@ public class UnifiedReleaseController { entity.setNote(item.note()); entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); entity.setCreatedAt(LocalDateTime.now()); - rnBundles.add(rnBundleRepository.save(entity)); + RnBundleEntity saved = rnBundleRepository.save(entity); + operationLogService.record( + saved.getAppId(), + "RN_BUNDLE", + saved.getId(), + "UPLOAD", + null, + Map.of( + "moduleId", saved.getModuleId(), + "platform", saved.getPlatform().name(), + "version", saved.getVersion(), + "minCommonVersion", saved.getMinCommonVersion() == null ? "" : saved.getMinCommonVersion(), + "packageName", saved.getPackageName() == null ? "" : saved.getPackageName() + )); + rnBundles.add(saved); } return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions, rnBundles))); diff --git a/update-service/src/main/java/com/xuqm/update/controller/UpdateOperationLogController.java b/update-service/src/main/java/com/xuqm/update/controller/UpdateOperationLogController.java new file mode 100644 index 0000000..536a853 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/controller/UpdateOperationLogController.java @@ -0,0 +1,30 @@ +package com.xuqm.update.controller; + +import com.xuqm.common.model.ApiResponse; +import com.xuqm.update.entity.UpdateOperationLogEntity; +import com.xuqm.update.service.UpdateOperationLogService; +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.List; + +@RestController +@RequestMapping("/api/v1/updates/ops") +public class UpdateOperationLogController { + + private final UpdateOperationLogService operationLogService; + + public UpdateOperationLogController(UpdateOperationLogService operationLogService) { + this.operationLogService = operationLogService; + } + + @GetMapping("/logs") + public ResponseEntity>> list( + @RequestParam String appId, + @RequestParam(defaultValue = "100") int limit) { + return ResponseEntity.ok(ApiResponse.success(operationLogService.list(appId, limit))); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index 435e900..3c9983b 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -74,8 +74,9 @@ public class AppVersionEntity { private String storeSubmitTargets; /** - * JSON map of StoreType -> StoreReviewState, e.g. {"HUAWEI":"UNDER_REVIEW","MI":"APPROVED"}. - * Updated by the store webhook endpoint. + * JSON map of StoreType -> review payload, e.g. + * {"HUAWEI":{"state":"UNDER_REVIEW","reason":""},"MI":{"state":"REJECTED","reason":"..."}} + * Older string-only values are still accepted by the readers. */ @Column(columnDefinition = "TEXT") private String storeReviewStatus; diff --git a/update-service/src/main/java/com/xuqm/update/entity/UpdateOperationLogEntity.java b/update-service/src/main/java/com/xuqm/update/entity/UpdateOperationLogEntity.java new file mode 100644 index 0000000..a3c6265 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/entity/UpdateOperationLogEntity.java @@ -0,0 +1,67 @@ +package com.xuqm.update.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "update_operation_log") +public class UpdateOperationLogEntity { + + @Id + private String id; + + @Column(nullable = false, length = 64) + private String appId; + + @Column(nullable = false, length = 32) + private String resourceType; + + @Column(nullable = false, length = 64) + private String resourceId; + + @Column(nullable = false, length = 32) + private String action; + + @Column(length = 128) + private String operator; + + @Column(length = 1024) + private String reason; + + @Column(columnDefinition = "TEXT") + private String detailJson; + + @Column(nullable = false) + private LocalDateTime createdAt; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getAppId() { return appId; } + public void setAppId(String appId) { this.appId = appId; } + + public String getResourceType() { return resourceType; } + public void setResourceType(String resourceType) { this.resourceType = resourceType; } + + public String getResourceId() { return resourceId; } + public void setResourceId(String resourceId) { this.resourceId = resourceId; } + + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + + public String getOperator() { return operator; } + public void setOperator(String operator) { this.operator = operator; } + + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + + public String getDetailJson() { return detailJson; } + public void setDetailJson(String detailJson) { this.detailJson = detailJson; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java index 45d4d5b..aefa18c 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java @@ -12,6 +12,17 @@ public interface AppVersionRepository extends JpaRepository findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc( String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status); + Optional findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( + String appId, + AppVersionEntity.Platform platform, + AppVersionEntity.PublishStatus status, + int versionCode); + + Optional findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc( + String appId, + AppVersionEntity.Platform platform, + AppVersionEntity.PublishStatus status, + int versionCode); List findByPublishStatusAndScheduledPublishAtBefore( AppVersionEntity.PublishStatus status, LocalDateTime before); diff --git a/update-service/src/main/java/com/xuqm/update/repository/UpdateOperationLogRepository.java b/update-service/src/main/java/com/xuqm/update/repository/UpdateOperationLogRepository.java new file mode 100644 index 0000000..94dc340 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/repository/UpdateOperationLogRepository.java @@ -0,0 +1,11 @@ +package com.xuqm.update.repository; + +import com.xuqm.update.entity.UpdateOperationLogEntity; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UpdateOperationLogRepository extends JpaRepository { + List findByAppIdOrderByCreatedAtDesc(String appId, Pageable pageable); +} diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java index d10a62c..0c017e3 100644 --- a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -8,6 +8,7 @@ import com.xuqm.update.entity.RnBundleEntity; import com.xuqm.update.repository.AppStoreConfigRepository; import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.repository.RnBundleRepository; +import com.xuqm.update.service.UpdateOperationLogService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -30,13 +31,16 @@ public class AppStoreService { private final AppStoreConfigRepository configRepo; private final AppVersionRepository versionRepo; private final RnBundleRepository rnBundleRepository; + private final UpdateOperationLogService operationLogService; public AppStoreService(AppStoreConfigRepository configRepo, AppVersionRepository versionRepo, - RnBundleRepository rnBundleRepository) { + RnBundleRepository rnBundleRepository, + UpdateOperationLogService operationLogService) { this.configRepo = configRepo; this.versionRepo = versionRepo; this.rnBundleRepository = rnBundleRepository; + this.operationLogService = operationLogService; } // ── Store config CRUD ──────────────────────────────────────────────────── @@ -49,6 +53,7 @@ public class AppStoreService { AppStoreConfigEntity.StoreType storeType, String configJson, boolean enabled) { + boolean isCreate = configRepo.findByAppIdAndStoreType(appId, storeType).isEmpty(); AppStoreConfigEntity entity = configRepo .findByAppIdAndStoreType(appId, storeType) .orElseGet(AppStoreConfigEntity::new); @@ -61,11 +66,33 @@ public class AppStoreService { entity.setConfigJson(configJson); entity.setEnabled(enabled); entity.setUpdatedAt(LocalDateTime.now()); - return configRepo.save(entity); + AppStoreConfigEntity saved = configRepo.save(entity); + operationLogService.record( + appId, + "STORE_CONFIG", + saved.getId(), + isCreate ? "CREATE_STORE_CONFIG" : "UPDATE_STORE_CONFIG", + null, + Map.of( + "storeType", storeType.name(), + "enabled", enabled + )); + return saved; } public void deleteConfig(String appId, AppStoreConfigEntity.StoreType storeType) { - configRepo.findByAppIdAndStoreType(appId, storeType).ifPresent(configRepo::delete); + configRepo.findByAppIdAndStoreType(appId, storeType).ifPresent(cfg -> { + configRepo.delete(cfg); + operationLogService.record( + appId, + "STORE_CONFIG", + cfg.getId(), + "DELETE_STORE_CONFIG", + null, + Map.of( + "storeType", storeType.name() + )); + }); } // ── Store submission ───────────────────────────────────────────────────── @@ -82,19 +109,38 @@ public class AppStoreService { LocalDateTime scheduledAt, Boolean autoPublishAfterReview) throws Exception { AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); + String normalizedMode = submitMode == null || submitMode.isBlank() + ? "MANUAL" + : submitMode.trim().toUpperCase(Locale.ROOT); + if ("SCHEDULED".equals(normalizedMode) && scheduledAt == null) { + throw new IllegalArgumentException("scheduledAt is required when submitMode is SCHEDULED"); + } - Map reviewMap = new LinkedHashMap<>(); + Map reviewMap = new LinkedHashMap<>(); for (String store : storeTypes) { - reviewMap.put(store, AppVersionEntity.StoreReviewState.PENDING.name()); + reviewMap.put(store, reviewPayload(AppVersionEntity.StoreReviewState.PENDING.name(), null)); } v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes)); v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); - v.setStoreSubmitMode(submitMode == null || submitMode.isBlank() ? "MANUAL" : submitMode.trim().toUpperCase(Locale.ROOT)); + v.setStoreSubmitMode(normalizedMode); v.setStoreSubmitScheduledAt(scheduledAt); if (autoPublishAfterReview != null) { - v.setAutoPublishAfterReview(autoPublishAfterReview); + v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode)); } - return versionRepo.save(v); + AppVersionEntity saved = versionRepo.save(v); + operationLogService.record( + saved.getAppId(), + "APP_VERSION", + saved.getId(), + "STORE_SUBMIT", + null, + Map.of( + "storeTypes", storeTypes, + "submitMode", saved.getStoreSubmitMode(), + "scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(), + "autoPublishAfterReview", saved.isAutoPublishAfterReview() + )); + return saved; } public AppVersionEntity markSubmitted(String versionId, List storeTypes) throws Exception { @@ -149,19 +195,47 @@ public class AppStoreService { public AppVersionEntity updateStoreReview(String versionId, String storeType, AppVersionEntity.StoreReviewState state) throws Exception { + return updateStoreReview(versionId, storeType, state, null); + } + + public AppVersionEntity updateStoreReview(String versionId, + String storeType, + AppVersionEntity.StoreReviewState state, + String reason) throws Exception { AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); - Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); - reviewMap.put(storeType, state.name()); + Map reviewMap = parseReviewStatus(v.getStoreReviewStatus()); + reviewMap.put(storeType, reviewPayload(state.name(), reason)); v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) { v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); log.info("Auto-published version {} after all stores approved", versionId); + operationLogService.record( + v.getAppId(), + "APP_VERSION", + v.getId(), + "AUTO_PUBLISH", + reason, + Map.of( + "storeType", storeType, + "publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name() + )); } AppVersionEntity saved = versionRepo.save(v); - sendWebhook(saved, storeType, state); + operationLogService.record( + saved.getAppId(), + "APP_VERSION", + saved.getId(), + "STORE_REVIEW", + reason, + Map.of( + "storeType", storeType, + "reviewState", state.name(), + "publishStatus", saved.getPublishStatus().name() + )); + sendWebhook(saved, storeType, state, reason); return saved; } @@ -177,6 +251,13 @@ public class AppStoreService { v.setScheduledPublishAt(null); versionRepo.save(v); log.info("Scheduled publish executed for version {}", v.getId()); + operationLogService.record( + v.getAppId(), + "APP_VERSION", + v.getId(), + "SCHEDULE_PUBLISH", + null, + Map.of("publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name())); } List dueBundles = rnBundleRepository @@ -187,12 +268,19 @@ public class AppStoreService { bundle.setScheduledPublishAt(null); rnBundleRepository.save(bundle); log.info("Scheduled publish executed for RN bundle {}", bundle.getId()); + operationLogService.record( + bundle.getAppId(), + "RN_BUNDLE", + bundle.getId(), + "SCHEDULE_PUBLISH", + null, + Map.of("publishStatus", RnBundleEntity.PublishStatus.PUBLISHED.name())); } } // ── Webhook delivery ───────────────────────────────────────────────────── - private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) { + private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state, String reason) { String url = v.getWebhookUrl(); if (url == null || url.isBlank()) { try { @@ -212,6 +300,7 @@ public class AppStoreService { "versionName", v.getVersionName(), "storeType", storeType, "reviewState", state.name(), + "reviewReason", reason == null ? "" : reason, "publishStatus", v.getPublishStatus().name(), "timestamp", System.currentTimeMillis() )); @@ -231,16 +320,16 @@ public class AppStoreService { // ── Helpers ────────────────────────────────────────────────────────────── - private Map parseReviewStatus(String json) throws Exception { + private Map parseReviewStatus(String json) throws Exception { if (json == null || json.isBlank()) return new LinkedHashMap<>(); - return mapper.readValue(json, new TypeReference>() {}); + return mapper.readValue(json, new TypeReference>() {}); } - private boolean allApproved(AppVersionEntity v, Map reviewMap) throws Exception { + private boolean allApproved(AppVersionEntity v, Map reviewMap) throws Exception { if (v.getStoreSubmitTargets() == null) return false; List targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {}); return targets.stream().allMatch(t -> - AppVersionEntity.StoreReviewState.APPROVED.name().equals(reviewMap.get(t))); + AppVersionEntity.StoreReviewState.APPROVED.name().equals(readReviewState(reviewMap.get(t)))); } private String resolveWebhookSecret(String appId) { @@ -263,4 +352,25 @@ public class AppStoreService { return ""; } } + + private Map reviewPayload(String state, String reason) { + Map payload = new LinkedHashMap<>(); + payload.put("state", state); + payload.put("reason", reason == null ? "" : reason); + return payload; + } + + private String readReviewState(Object value) { + if (value == null) { + return ""; + } + if (value instanceof String s) { + return s; + } + if (value instanceof Map map) { + Object state = map.get("state"); + return state == null ? "" : state.toString(); + } + return value.toString(); + } } diff --git a/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java index a1f0add..a913db2 100644 --- a/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java +++ b/update-service/src/main/java/com/xuqm/update/service/PublishConfigService.java @@ -11,7 +11,7 @@ import com.xuqm.update.repository.AppPublishConfigRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; @@ -128,9 +128,15 @@ public class PublishConfigService { "timestamp", System.currentTimeMillis(), "action", "sync" ); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String secret = config.path("grayDirectorySyncCallbackSecret").asText(""); + if (!secret.isBlank()) { + headers.set("X-Xuqm-Callback-Secret", secret); + } ResponseEntity response = restTemplate.exchange( URI.create(url), org.springframework.http.HttpMethod.POST, - new org.springframework.http.HttpEntity<>(requestBody), String.class); + new org.springframework.http.HttpEntity<>(requestBody, headers), String.class); return replaceGrayMembers(appId, response.getBody()); } @@ -146,9 +152,15 @@ public class PublishConfigService { } payload.put("appId", appId); payload.put("timestamp", System.currentTimeMillis()); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String secret = config.path("graySelectCallbackSecret").asText(""); + if (!secret.isBlank()) { + headers.set("X-Xuqm-Callback-Secret", secret); + } ResponseEntity response = restTemplate.exchange( URI.create(url), org.springframework.http.HttpMethod.POST, - new org.springframework.http.HttpEntity<>(payload), String.class); + new org.springframework.http.HttpEntity<>(payload, headers), String.class); return extractMemberIds(response.getBody()); } @@ -303,7 +315,7 @@ public class PublishConfigService { private String defaultConfigJson() { return """ - {"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","grayDirectorySyncCallbackUrl":"","graySelectionSource":"LOCAL"} + {"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"} """.trim(); } diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java index 043b377..c896879 100644 --- a/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java +++ b/update-service/src/main/java/com/xuqm/update/service/StoreSubmissionService.java @@ -98,7 +98,8 @@ public class StoreSubmissionService { if (cfg == null || !cfg.isEnabled()) { log.warn("Store config not found or disabled for {}/{}", v.getAppId(), storeType); storeService.updateStoreReview(versionId, storeType, - AppVersionEntity.StoreReviewState.REJECTED); + AppVersionEntity.StoreReviewState.REJECTED, + "Store config not found or disabled"); continue; } Map creds = parseConfig(cfg.getConfigJson()); @@ -110,7 +111,8 @@ public class StoreSubmissionService { log.error("Submission to {} failed for version {}: {}", storeType, versionId, e.getMessage(), e); try { storeService.updateStoreReview(versionId, storeType, - AppVersionEntity.StoreReviewState.REJECTED); + AppVersionEntity.StoreReviewState.REJECTED, + e.getMessage()); } catch (Exception ex) { /* best effort */ } } } @@ -137,7 +139,7 @@ public class StoreSubmissionService { case "VIVO" -> submitToVivo(v, file, creds); case "APP_STORE" -> submitToAppStore(v, file, creds); case "GOOGLE_PLAY" -> submitToGooglePlay(v, file, creds); - case "HARMONY_APP" -> log.warn("Harmony app store submission is not implemented yet - keep this channel for market link and manual review tracking"); + case "HARMONY_APP" -> log.info("Harmony app store submission is skipped; this channel only records the market link and manual review tracking"); default -> throw new IllegalArgumentException("Unknown store: " + storeType); } } @@ -343,13 +345,13 @@ public class StoreSubmissionService { // API: https://developer.apple.com/documentation/appstoreconnectapi private void submitToAppStore(AppVersionEntity v, File file, Map creds) { - log.warn("App Store submission not yet implemented in server-side submission service - use the platform release script or Transporter/fastlane"); + log.info("App Store submission is handled by the release script or App Store Connect tooling; server-side submission service only records the market link and review tracking"); } // ── Google Play ─────────────────────────────────────────────────────────── private void submitToGooglePlay(AppVersionEntity v, File file, Map creds) { - log.warn("Google Play submission not yet implemented in server-side submission service - use the platform release script or Play Console"); + log.info("Google Play submission is handled by the release script or Play Console; server-side submission service only records the market link and review tracking"); } // ── Utilities ───────────────────────────────────────────────────────────── diff --git a/update-service/src/main/java/com/xuqm/update/service/UpdateOperationLogService.java b/update-service/src/main/java/com/xuqm/update/service/UpdateOperationLogService.java new file mode 100644 index 0000000..1ede0f1 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/UpdateOperationLogService.java @@ -0,0 +1,70 @@ +package com.xuqm.update.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.update.entity.UpdateOperationLogEntity; +import com.xuqm.update.repository.UpdateOperationLogRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +public class UpdateOperationLogService { + + private final UpdateOperationLogRepository repository; + private final ObjectMapper objectMapper; + + public UpdateOperationLogService(UpdateOperationLogRepository repository, ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + public void record(String appId, + String resourceType, + String resourceId, + String action, + String reason, + Map detail) { + UpdateOperationLogEntity entity = new UpdateOperationLogEntity(); + entity.setId(UUID.randomUUID().toString()); + entity.setAppId(appId); + entity.setResourceType(resourceType); + entity.setResourceId(resourceId); + entity.setAction(action); + entity.setOperator(currentOperator()); + entity.setReason(reason == null || reason.isBlank() ? null : reason.trim()); + entity.setDetailJson(serialize(detail)); + entity.setCreatedAt(LocalDateTime.now()); + repository.save(entity); + } + + public List list(String appId, int limit) { + int size = Math.min(Math.max(limit, 1), 200); + return repository.findByAppIdOrderByCreatedAtDesc( + appId, + PageRequest.of(0, size)); + } + + private String currentOperator() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || auth.getName() == null || auth.getName().isBlank()) { + return "system"; + } + return auth.getName(); + } + + private String serialize(Map detail) { + try { + Map payload = detail == null ? Map.of() : new LinkedHashMap<>(detail); + return objectMapper.writeValueAsString(payload); + } catch (Exception e) { + return "{}"; + } + } +}