docs(deploy): 添加生产环境部署配置示例和部署文档

- 新增 .env.production.example 环境变量配置模板
- 添加 compose.production.yaml Docker Compose 部署配置
- 创建 web.Dockerfile 前端构建部署文件
- 编写详细的 README.md 部署文档,涵盖架构、配置、步骤等内容
- 添加离线推送架构设计文档
- 更新 IM 多平台进度跟踪文档
这个提交包含在:
XuqmGroup 2026-04-30 09:49:05 +08:00
父节点 93311f1739
当前提交 32b0e49e61
共有 31 个文件被更改,包括 1218 次插入84 次删除

查看文件

@ -144,7 +144,7 @@
| 方法 | 路径 | 鉴权 | 说明 | | 方法 | 路径 | 鉴权 | 说明 |
|------|------|------|------| |------|------|------|------|
| GET | `/api/v1/updates/app/check` | 否 | 检查 App 更新 | | 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 版本 | | POST | `/api/v1/updates/app/{id}/publish` | 是 | 发布 App 版本 |
| GET | `/api/v1/updates/app/list` | 是 | App 版本列表 | | GET | `/api/v1/updates/app/list` | 是 | App 版本列表 |
| GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK | | GET | `/api/v1/updates/files/apk/{filename}` | 否 | 下载 APK |
@ -164,11 +164,14 @@
- 这里的 `appId``appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。 - 这里的 `appId``appKey` 解析;当前 demo 默认使用 `ak_demo_chat`,`tenant-service` 会优先复用数据库里已有的应用,如果没有,会自动补一条默认 demo 应用和基础服务配置。
- `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧建议传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。 - `GET /api/sdk/config` 主要给 `update-sdk` 脚本使用,脚本侧建议传 `appKey`。接口会根据 `platform` 返回当前平台的 UPDATE 默认配置和开关状态。
- 应用商店配置页分成两个 tab`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。 - 应用商店配置页分成两个 tab`凭据配置` 和 `应用配置指引`。App Store / 鸿蒙只保留 `marketUrl` 跳转页,且该字段是可选项;Android 市场继续保留审核凭据。审核通知使用单独的 `REVIEW_WEBHOOK` 配置,只保存一次,所有市场共用,并会在保存时先做连通性校验。
- 发布配置页保存灰度默认模式、成员目录同步回调和成员选择回调,两个回调都支持单独配置 `secret`,调用时会带 `X-Xuqm-Callback-Secret`
- `POST /api/im/auth/login` 还要求 demo-service 通过 AppSecret 生成签名头再转发给 IM 服务。 - `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` - 发版上传建议走两段式:先调 `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` 会按照“当前版本之后是否存在任意一条强更版本”来计算,所以后续发布的非强更版本不会覆盖更低版本当时应看到的强更提示。
- RN bundle 建议打成 zip 后再上传,zip 内至少包含 `rn-manifest.json`、bundle 文件和资源文件;update-service 会优先从 manifest 自动读取 `moduleId`、`version` / `bundleVersion`、`minCommonVersion` 和 `packageName` - 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、鸿蒙仍以跳转页和人工流程为主。 - 提交应用市场会真实调用已实现的厂商接口。小米、OPPO、vivo 和华为/荣耀当前支持服务端提交;App Store、Google Play、鸿蒙仍以跳转页和人工流程为主。
## update-sdk 自动发版 ## update-sdk 自动发版
@ -200,7 +203,8 @@ Android SDK 的整包发版脚本支持通过“检查版本 -> 打包 -> 上传
3. 脚本读取服务器最新版本,和本地版本对比。 3. 脚本读取服务器最新版本,和本地版本对比。
4. 本地版本不高于服务端时,提示用户重新输入版本名 / 版本码。 4. 本地版本不高于服务端时,提示用户重新输入版本名 / 版本码。
5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。 5. 让用户确认发布时间、市场提交目标、是否审核通过后自动发布、是否启用灰度、是否使用 webhook。
6. 完成打包后上传到 update-service,再按配置提交市场或执行发布。 6. 如果选择了定时上架,`autoPublishAfterReview` 会被服务端强制关闭,避免“定时上架”和“审核后自动发布”同时生效造成冲突。
7. 完成打包后上传到 update-service,再按配置提交市场或执行发布。
租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。 租户平台里的“发布配置”标签页保存的就是这组脚本默认值。它按平台分别保存,Android / iOS / Harmony 互不覆盖。

查看文件

@ -35,7 +35,7 @@ public class PushDispatcher {
for (DeviceTokenEntity t : tokens) { for (DeviceTokenEntity t : tokens) {
PushProvider provider = providers.get(t.getVendor().name()); PushProvider provider = providers.get(t.getVendor().name());
if (provider != null) { 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"); log.info("Push to {}@{} via {}: {}", userId, appId, t.getVendor(), ok ? "OK" : "FAIL");
} }
} }

查看文件

@ -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<JsonNode> loadServiceConfig(String appId, String platform, String serviceType) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Internal-Token", internalToken);
ResponseEntity<Map> 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();
}
}
}

查看文件

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

查看文件

@ -1,6 +1,8 @@
package com.xuqm.push.service.provider; package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; 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); private static final Logger log = LoggerFactory.getLogger(HuaweiPushProvider.class);
@Value("${push.huawei.app-id:}") @Value("${push.huawei.app-id:}")
private String appId; private String envAppId;
@Value("${push.huawei.app-secret:}") @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}") @Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}")
private String tokenUrl; private String tokenUrl;
@ -29,23 +31,30 @@ public class HuaweiPushProvider implements PushProvider {
@Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}") @Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}")
private String pushUrl; private String pushUrl;
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient(); private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
public HuaweiPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override @Override
public String vendorName() { public String vendorName() {
return "HUAWEI"; return "HUAWEI";
} }
@Override @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) {
if (appId.isBlank() || appSecret.isBlank()) { String resolvedAppId = resolveConfig(appId, "appId", envAppId);
String resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret);
if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) {
log.warn("Huawei push not configured"); log.warn("Huawei push not configured");
return false; return false;
} }
try { try {
String accessToken = getAccessToken(); String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret);
String url = pushUrl.replace("{appId}", appId); String url = pushUrl.replace("{appId}", resolvedAppId);
Map<String, Object> message = Map.of( Map<String, Object> message = Map.of(
"message", Map.of( "message", Map.of(
"token", new String[]{token}, "token", new String[]{token},
@ -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; String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret;
HttpRequest request = HttpRequest.newBuilder() HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenUrl)) .uri(URI.create(tokenUrl))
@ -79,4 +88,15 @@ public class HuaweiPushProvider implements PushProvider {
Map<?, ?> json = objectMapper.readValue(response.body(), Map.class); Map<?, ?> json = objectMapper.readValue(response.body(), Map.class);
return (String) json.get("access_token"); 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;
}
} }

查看文件

@ -2,5 +2,5 @@ package com.xuqm.push.service.provider;
public interface PushProvider { public interface PushProvider {
String vendorName(); String vendorName();
boolean send(String token, String title, String body, String payload); boolean send(String appId, String token, String title, String body, String payload);
} }

查看文件

@ -1,5 +1,7 @@
package com.xuqm.push.service.provider; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; 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); private static final Logger log = LoggerFactory.getLogger(XiaomiPushProvider.class);
@Value("${push.xiaomi.app-secret:}") @Value("${push.xiaomi.app-secret:}")
private String appSecret; private String envAppSecret;
@Value("${push.xiaomi.push-url:https://api.xmpush.xiaomi.com/v3/message/regid}") @Value("${push.xiaomi.push-url:https://api.xmpush.xiaomi.com/v3/message/regid}")
private String pushUrl; private String pushUrl;
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient(); private final HttpClient httpClient = HttpClient.newHttpClient();
public XiaomiPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override @Override
public String vendorName() { return "XIAOMI"; } public String vendorName() { return "XIAOMI"; }
@Override @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()) { if (appSecret.isBlank()) {
log.warn("Xiaomi push not configured"); log.warn("Xiaomi push not configured");
return false; return false;
@ -53,4 +64,18 @@ public class XiaomiPushProvider implements PushProvider {
return false; 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;
}
} }

查看文件

@ -40,3 +40,5 @@ push:
key-path: ${APNS_KEY_PATH:} key-path: ${APNS_KEY_PATH:}
bundle-id: ${APNS_BUNDLE_ID:} bundle-id: ${APNS_BUNDLE_ID:}
production: false production: false
tenant-service-base-url: ${TENANT_SERVICE_BASE_URL:http://tenant-service:8081}

查看文件

@ -7,6 +7,7 @@ import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.repository.TenantRepository; import com.xuqm.tenant.repository.TenantRepository;
import com.xuqm.tenant.service.AppService; import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.EmailService;
import com.xuqm.tenant.service.OperationLogService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@ -29,11 +30,15 @@ public class AppController {
private final AppService appService; private final AppService appService;
private final EmailService emailService; private final EmailService emailService;
private final OperationLogService operationLogService;
private final TenantRepository tenantRepository; 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.appService = appService;
this.emailService = emailService; this.emailService = emailService;
this.operationLogService = operationLogService;
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
} }
@ -78,6 +83,9 @@ public class AppController {
TenantEntity tenant = tenantRepository.findById(tenantId) TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new RuntimeException("Tenant not found")); .orElseThrow(() -> new RuntimeException("Tenant not found"));
emailService.sendVerificationCode(tenant.getEmail(), purpose); emailService.sendVerificationCode(tenant.getEmail(), purpose);
operationLogService.record(tenantId, "APP", "APP_SECRET", id, "REQUEST_SECRET_VERIFY", Map.of(
"purpose", purpose
));
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }
@ -91,6 +99,9 @@ public class AppController {
TenantEntity tenant = tenantRepository.findById(tenantId) TenantEntity tenant = tenantRepository.findById(tenantId)
.orElseThrow(() -> new RuntimeException("Tenant not found")); .orElseThrow(() -> new RuntimeException("Tenant not found"));
emailService.verify(tenant.getEmail(), body.get("code"), "REVEAL_SECRET"); 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()))); return ResponseEntity.ok(ApiResponse.success(Map.of("appSecret", app.getAppSecret())));
} }

查看文件

@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity; import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.service.AppService; import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.FeatureServiceManager; import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.service.OperationLogService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -24,10 +25,13 @@ public class FeatureServiceController {
private final FeatureServiceManager featureServiceManager; private final FeatureServiceManager featureServiceManager;
private final AppService appService; 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.featureServiceManager = featureServiceManager;
this.appService = appService; this.appService = appService;
this.operationLogService = operationLogService;
} }
@GetMapping @GetMapping
@ -59,8 +63,12 @@ public class FeatureServiceController {
if (enable) { if (enable) {
throw new com.xuqm.common.exception.BusinessException(400, "开启服务请通过 request-activation 申请"); throw new com.xuqm.common.exception.BusinessException(400, "开启服务请通过 request-activation 申请");
} }
return ResponseEntity.ok(ApiResponse.success( FeatureServiceEntity saved = featureServiceManager.disable(appId, platform, serviceType);
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") @PutMapping("/config")
@ -99,10 +107,37 @@ public class FeatureServiceController {
req == null ? null : req.defaultPackageName(), req == null ? null : req.defaultPackageName(),
req == null ? null : req.defaultAppStoreUrl(), req == null ? null : req.defaultAppStoreUrl(),
req == null ? null : req.defaultMarketUrl()); 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( FeatureServiceEntity saved = featureServiceManager.updateConfig(
appId, platform, serviceType, config))); 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. */ /** Submit an activation request for ops approval. */
@ -114,8 +149,15 @@ public class FeatureServiceController {
@RequestParam(required = false) String applyReason, @RequestParam(required = false) String applyReason,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId); appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success( ServiceActivationRequestEntity saved = featureServiceManager.submitActivationRequest(appId, platform, serviceType, applyReason);
featureServiceManager.submitActivationRequest(appId, platform, serviceType, applyReason))); java.util.Map<String, Object> 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") @GetMapping("/requests")
@ -146,6 +188,26 @@ public class FeatureServiceController {
Integer defaultGrayPercent, Integer defaultGrayPercent,
String defaultPackageName, String defaultPackageName,
String defaultAppStoreUrl, 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
) {} ) {}
} }

查看文件

@ -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<ApiResponse<Page<OperationLogEntity>>> 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)));
}
}

查看文件

@ -5,6 +5,7 @@ import com.xuqm.common.model.ApiResponse;
import com.xuqm.tenant.dto.CreateSubAccountRequest; import com.xuqm.tenant.dto.CreateSubAccountRequest;
import com.xuqm.tenant.entity.TenantEntity; import com.xuqm.tenant.entity.TenantEntity;
import com.xuqm.tenant.service.EmailService; import com.xuqm.tenant.service.EmailService;
import com.xuqm.tenant.service.OperationLogService;
import com.xuqm.tenant.service.SubAccountService; import com.xuqm.tenant.service.SubAccountService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
@ -29,10 +30,13 @@ public class SubAccountController {
private final SubAccountService subAccountService; private final SubAccountService subAccountService;
private final EmailService emailService; 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.subAccountService = subAccountService;
this.emailService = emailService; this.emailService = emailService;
this.operationLogService = operationLogService;
} }
@GetMapping @GetMapping
@ -44,6 +48,9 @@ public class SubAccountController {
public ResponseEntity<ApiResponse<Void>> sendVerifyCode(@RequestParam @NotBlank @Email String email, public ResponseEntity<ApiResponse<Void>> sendVerifyCode(@RequestParam @NotBlank @Email String email,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
emailService.sendVerificationCode(email, "SUB_ACCOUNT"); 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()); return ResponseEntity.ok(ApiResponse.ok());
} }
@ -52,6 +59,9 @@ public class SubAccountController {
@RequestParam @NotBlank String code, @RequestParam @NotBlank String code,
@AuthenticationPrincipal String tenantId) { @AuthenticationPrincipal String tenantId) {
subAccountService.verifyEmail(tenantId, email, code); subAccountService.verifyEmail(tenantId, email, code);
operationLogService.record(tenantId, "SUB_ACCOUNT", "EMAIL_VERIFY", email, "VERIFY_EMAIL", Map.of(
"email", email
));
return ResponseEntity.ok(ApiResponse.ok()); return ResponseEntity.ok(ApiResponse.ok());
} }

查看文件

@ -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; }
}

查看文件

@ -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<OperationLogEntity, String> {
Page<OperationLogEntity> findByTenantIdOrderByCreatedAtDesc(String tenantId, Pageable pageable);
Page<OperationLogEntity> findByTenantIdAndModuleTypeOrderByCreatedAtDesc(
String tenantId,
String moduleType,
Pageable pageable);
}

查看文件

@ -9,17 +9,21 @@ import org.springframework.stereotype.Service;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
@Service @Service
public class AppService { public class AppService {
private final AppRepository appRepository; private final AppRepository appRepository;
private final OperationLogService operationLogService;
private static final SecureRandom random = new SecureRandom(); private static final SecureRandom random = new SecureRandom();
public AppService(AppRepository appRepository) { public AppService(AppRepository appRepository, OperationLogService operationLogService) {
this.appRepository = appRepository; this.appRepository = appRepository;
this.operationLogService = operationLogService;
} }
public List<AppEntity> listByTenant(String tenantId) { public List<AppEntity> listByTenant(String tenantId) {
@ -49,20 +53,46 @@ public class AppService {
app.setAppKey(generateAppKey()); app.setAppKey(generateAppKey());
app.setAppSecret(generateSecret()); app.setAppSecret(generateSecret());
app.setCreatedAt(LocalDateTime.now()); 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) { public AppEntity update(String id, String tenantId, CreateAppRequest req) {
AppEntity app = getById(id, tenantId); AppEntity app = getById(id, tenantId);
Map<String, Object> 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.setName(req.name());
app.setDescription(req.description()); app.setDescription(req.description());
app.setIconUrl(req.iconUrl()); app.setIconUrl(req.iconUrl());
return appRepository.save(app); AppEntity saved = appRepository.save(app);
Map<String, Object> 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) { public void delete(String id, String tenantId) {
AppEntity app = getById(id, tenantId); AppEntity app = getById(id, tenantId);
appRepository.delete(app); 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) { public String resetSecret(String id, String tenantId) {
@ -70,6 +100,11 @@ public class AppService {
String newSecret = generateSecret(); String newSecret = generateSecret();
app.setAppSecret(newSecret); app.setAppSecret(newSecret);
appRepository.save(app); 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; return newSecret;
} }

查看文件

@ -458,6 +458,66 @@ public class FeatureServiceManager {
return node.toString(); 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<String> parseStoreTargets(String json) { public List<String> parseStoreTargets(String json) {
if (json == null || json.isBlank()) { if (json == null || json.isBlank()) {
return List.of(); return List.of();
@ -514,4 +574,16 @@ public class FeatureServiceManager {
return objectMapper.createObjectNode(); 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());
}
}
} }

查看文件

@ -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<String, Object> 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<OperationLogEntity> 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<String, Object> detail) {
try {
Map<String, Object> payload = detail == null ? Map.of() : new LinkedHashMap<>(detail);
return objectMapper.writeValueAsString(payload);
} catch (Exception e) {
return "{}";
}
}
}

查看文件

@ -11,6 +11,7 @@ import org.springframework.stereotype.Service;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Base64; import java.util.Base64;
import java.util.Map;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -21,16 +22,19 @@ public class SubAccountService {
private final TenantRepository tenantRepository; private final TenantRepository tenantRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final EmailService emailService; private final EmailService emailService;
private final OperationLogService operationLogService;
private final StringRedisTemplate redis; private final StringRedisTemplate redis;
private static final String SUB_VERIFY_PREFIX = "sub_verified:"; private static final String SUB_VERIFY_PREFIX = "sub_verified:";
private static final SecureRandom random = new SecureRandom(); private static final SecureRandom random = new SecureRandom();
public SubAccountService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder, public SubAccountService(TenantRepository tenantRepository, PasswordEncoder passwordEncoder,
EmailService emailService, StringRedisTemplate redis) { EmailService emailService, OperationLogService operationLogService,
StringRedisTemplate redis) {
this.tenantRepository = tenantRepository; this.tenantRepository = tenantRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.emailService = emailService; this.emailService = emailService;
this.operationLogService = operationLogService;
this.redis = redis; this.redis = redis;
} }
@ -59,7 +63,13 @@ public class SubAccountService {
sub.setStatus(TenantEntity.Status.ACTIVE); sub.setStatus(TenantEntity.Status.ACTIVE);
sub.setParentId(parentId); sub.setParentId(parentId);
sub.setCreatedAt(LocalDateTime.now()); 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<TenantEntity> listByParent(String parentId) { public List<TenantEntity> listByParent(String parentId) {
@ -74,6 +84,11 @@ public class SubAccountService {
} }
sub.setStatus(TenantEntity.Status.DISABLED); sub.setStatus(TenantEntity.Status.DISABLED);
tenantRepository.save(sub); 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() { public String generatePassword() {

查看文件

@ -55,9 +55,7 @@ public class AppStoreController {
String configJson = body.get("configJson") instanceof String s ? s : null; String configJson = body.get("configJson") instanceof String s ? s : null;
boolean enabled = !Boolean.FALSE.equals(body.get("enabled")); boolean enabled = !Boolean.FALSE.equals(body.get("enabled"));
if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK && configJson != null && !configJson.isBlank()) { validateStoreConfig(storeType, configJson);
validateReviewWebhook(configJson);
}
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
storeService.saveConfig(appId, storeType, configJson, enabled))); storeService.saveConfig(appId, storeType, configJson, enabled)));
} }
@ -150,8 +148,9 @@ public class AppStoreController {
String storeType = (String) body.get("storeType"); String storeType = (String) body.get("storeType");
AppVersionEntity.StoreReviewState state = AppVersionEntity.StoreReviewState state =
AppVersionEntity.StoreReviewState.valueOf((String) body.get("state")); AppVersionEntity.StoreReviewState.valueOf((String) body.get("state"));
String reason = body.get("reason") == null ? null : body.get("reason").toString();
return ResponseEntity.ok(ApiResponse.success( return ResponseEntity.ok(ApiResponse.success(
storeService.updateStoreReview(versionId, storeType, state))); storeService.updateStoreReview(versionId, storeType, state, reason)));
} }
private void validateReviewWebhook(String configJson) { private void validateReviewWebhook(String configJson) {
@ -167,4 +166,22 @@ public class AppStoreController {
throw new IllegalArgumentException("invalid review webhook config: " + e.getMessage(), e); 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<String, Object> 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);
}
}
} }

查看文件

@ -4,6 +4,7 @@ import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.AppVersionEntity; import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.model.AppPackageInspectResult; import com.xuqm.update.model.AppPackageInspectResult;
import com.xuqm.update.service.UpdateOperationLogService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -29,15 +30,18 @@ public class AppVersionController {
private final UpdateAssetService updateAssetService; private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService; private final PublishConfigService publishConfigService;
private final AppStoreService appStoreService; private final AppStoreService appStoreService;
private final UpdateOperationLogService operationLogService;
public AppVersionController(AppVersionRepository versionRepository, public AppVersionController(AppVersionRepository versionRepository,
UpdateAssetService updateAssetService, UpdateAssetService updateAssetService,
PublishConfigService publishConfigService, PublishConfigService publishConfigService,
AppStoreService appStoreService) { AppStoreService appStoreService,
UpdateOperationLogService operationLogService) {
this.versionRepository = versionRepository; this.versionRepository = versionRepository;
this.updateAssetService = updateAssetService; this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService; this.publishConfigService = publishConfigService;
this.appStoreService = appStoreService; this.appStoreService = appStoreService;
this.operationLogService = operationLogService;
} }
@GetMapping("/app/check") @GetMapping("/app/check")
@ -47,10 +51,13 @@ public class AppVersionController {
@RequestParam int currentVersionCode) { @RequestParam int currentVersionCode) {
Optional<AppVersionEntity> latest = versionRepository Optional<AppVersionEntity> latest = versionRepository
.findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc( .findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
appId, platform, AppVersionEntity.PublishStatus.PUBLISHED); appId, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
Optional<AppVersionEntity> 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))); return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
} }
@ -67,7 +74,7 @@ public class AppVersionController {
"versionCode", v.getVersionCode(), "versionCode", v.getVersionCode(),
"downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "", "downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "",
"changeLog", v.getChangeLog() != null ? v.getChangeLog() : "", "changeLog", v.getChangeLog() != null ? v.getChangeLog() : "",
"forceUpdate", v.isForceUpdate(), "forceUpdate", forcedHigher.isPresent(),
"appStoreUrl", appStoreJumpUrl, "appStoreUrl", appStoreJumpUrl,
"marketUrl", harmonyJumpUrl "marketUrl", harmonyJumpUrl
))); )));
@ -153,7 +160,22 @@ public class AppVersionController {
entity.setGrayEnabled(false); entity.setGrayEnabled(false);
entity.setGrayPercent(0); 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}) @RequestMapping(value = "/app/inspect", method = {RequestMethod.GET, RequestMethod.POST})
@ -176,6 +198,7 @@ public class AppVersionController {
? body.get("scheduledPublishAt").toString() : null; ? body.get("scheduledPublishAt").toString() : null;
boolean forceUpdate = body != null && body.get("forceUpdate") != null boolean forceUpdate = body != null && body.get("forceUpdate") != null
? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate(); ? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate();
AppVersionEntity.PublishStatus previousStatus = entity.getPublishStatus();
entity.setForceUpdate(forceUpdate); entity.setForceUpdate(forceUpdate);
if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) { if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) {
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
@ -190,14 +213,45 @@ public class AppVersionController {
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMode("PERCENT"); entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null); 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") @PostMapping("/app/{id}/unpublish")
public ResponseEntity<ApiResponse<AppVersionEntity>> unpublish(@PathVariable String id) { public ResponseEntity<ApiResponse<AppVersionEntity>> unpublish(
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow(); 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); 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") @PostMapping("/app/{id}/gray")
@ -228,7 +282,20 @@ public class AppVersionController {
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
} }
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); 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") @GetMapping("/app/list")
@ -238,6 +305,21 @@ public class AppVersionController {
versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform))); 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) { private boolean hasText(String value) {
return value != null && !value.isBlank(); return value != null && !value.isBlank();
} }

查看文件

@ -5,6 +5,7 @@ import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.model.RnBundleInspectResult; import com.xuqm.update.model.RnBundleInspectResult;
import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.PublishConfigService; import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.UpdateOperationLogService;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -24,16 +25,19 @@ public class RnBundleController {
private final RnBundleRepository bundleRepository; private final RnBundleRepository bundleRepository;
private final UpdateAssetService updateAssetService; private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService; private final PublishConfigService publishConfigService;
private final UpdateOperationLogService operationLogService;
@Value("${update.base-url:https://update.dev.xuqinmin.com}") @Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl; private String baseUrl;
public RnBundleController(RnBundleRepository bundleRepository, public RnBundleController(RnBundleRepository bundleRepository,
UpdateAssetService updateAssetService, UpdateAssetService updateAssetService,
PublishConfigService publishConfigService) { PublishConfigService publishConfigService,
UpdateOperationLogService operationLogService) {
this.bundleRepository = bundleRepository; this.bundleRepository = bundleRepository;
this.updateAssetService = updateAssetService; this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService; this.publishConfigService = publishConfigService;
this.operationLogService = operationLogService;
} }
@GetMapping("/update/check") @GetMapping("/update/check")
@ -108,7 +112,21 @@ public class RnBundleController {
entity.setGrayMode("PERCENT"); entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
entity.setCreatedAt(LocalDateTime.now()); 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}) @RequestMapping(value = "/inspect", method = {RequestMethod.GET, RequestMethod.POST})
@ -156,14 +174,44 @@ public class RnBundleController {
entity.setGrayPercent(0); entity.setGrayPercent(0);
entity.setGrayMode("PERCENT"); entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null); 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") @PostMapping("/{id}/unpublish")
public ResponseEntity<ApiResponse<RnBundleEntity>> unpublish(@PathVariable String id) { public ResponseEntity<ApiResponse<RnBundleEntity>> unpublish(
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
RnBundleEntity entity = bundleRepository.findById(id).orElseThrow(); 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); 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") @PostMapping("/{id}/gray")
@ -194,7 +242,21 @@ public class RnBundleController {
entity.setGrayMemberIds(null); entity.setGrayMemberIds(null);
} }
entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED); 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() { private String resolvePublicBaseUrl() {

查看文件

@ -9,6 +9,7 @@ import com.xuqm.update.model.UnifiedReleaseResult;
import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.UpdateAssetService; import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.UpdateOperationLogService;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -21,6 +22,7 @@ import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@ -31,16 +33,19 @@ public class UnifiedReleaseController {
private final AppVersionRepository appVersionRepository; private final AppVersionRepository appVersionRepository;
private final RnBundleRepository rnBundleRepository; private final RnBundleRepository rnBundleRepository;
private final UpdateAssetService updateAssetService; private final UpdateAssetService updateAssetService;
private final UpdateOperationLogService operationLogService;
public UnifiedReleaseController( public UnifiedReleaseController(
ObjectMapper objectMapper, ObjectMapper objectMapper,
AppVersionRepository appVersionRepository, AppVersionRepository appVersionRepository,
RnBundleRepository rnBundleRepository, RnBundleRepository rnBundleRepository,
UpdateAssetService updateAssetService) { UpdateAssetService updateAssetService,
UpdateOperationLogService operationLogService) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.appVersionRepository = appVersionRepository; this.appVersionRepository = appVersionRepository;
this.rnBundleRepository = rnBundleRepository; this.rnBundleRepository = rnBundleRepository;
this.updateAssetService = updateAssetService; this.updateAssetService = updateAssetService;
this.operationLogService = operationLogService;
} }
@PostMapping("/unified/upload") @PostMapping("/unified/upload")
@ -83,7 +88,22 @@ public class UnifiedReleaseController {
entity.setGrayEnabled(false); entity.setGrayEnabled(false);
entity.setGrayPercent(0); 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<RnBundleEntity> rnBundles = new ArrayList<>(); List<RnBundleEntity> rnBundles = new ArrayList<>();
@ -108,7 +128,21 @@ public class UnifiedReleaseController {
entity.setNote(item.note()); entity.setNote(item.note());
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT); entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
entity.setCreatedAt(LocalDateTime.now()); 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))); return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions, rnBundles)));

查看文件

@ -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<ApiResponse<List<UpdateOperationLogEntity>>> list(
@RequestParam String appId,
@RequestParam(defaultValue = "100") int limit) {
return ResponseEntity.ok(ApiResponse.success(operationLogService.list(appId, limit)));
}
}

查看文件

@ -74,8 +74,9 @@ public class AppVersionEntity {
private String storeSubmitTargets; private String storeSubmitTargets;
/** /**
* JSON map of StoreType -> StoreReviewState, e.g. {"HUAWEI":"UNDER_REVIEW","MI":"APPROVED"}. * JSON map of StoreType -> review payload, e.g.
* Updated by the store webhook endpoint. * {"HUAWEI":{"state":"UNDER_REVIEW","reason":""},"MI":{"state":"REJECTED","reason":"..."}}
* Older string-only values are still accepted by the readers.
*/ */
@Column(columnDefinition = "TEXT") @Column(columnDefinition = "TEXT")
private String storeReviewStatus; private String storeReviewStatus;

查看文件

@ -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; }
}

查看文件

@ -12,6 +12,17 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
String appId, AppVersionEntity.Platform platform); String appId, AppVersionEntity.Platform platform);
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc( Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status); String appId, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status);
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
String appId,
AppVersionEntity.Platform platform,
AppVersionEntity.PublishStatus status,
int versionCode);
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
String appId,
AppVersionEntity.Platform platform,
AppVersionEntity.PublishStatus status,
int versionCode);
List<AppVersionEntity> findByPublishStatusAndScheduledPublishAtBefore( List<AppVersionEntity> findByPublishStatusAndScheduledPublishAtBefore(
AppVersionEntity.PublishStatus status, LocalDateTime before); AppVersionEntity.PublishStatus status, LocalDateTime before);

查看文件

@ -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<UpdateOperationLogEntity, String> {
List<UpdateOperationLogEntity> findByAppIdOrderByCreatedAtDesc(String appId, Pageable pageable);
}

查看文件

@ -8,6 +8,7 @@ import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.repository.AppStoreConfigRepository; import com.xuqm.update.repository.AppStoreConfigRepository;
import com.xuqm.update.repository.AppVersionRepository; import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.repository.RnBundleRepository; import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.UpdateOperationLogService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@ -30,13 +31,16 @@ public class AppStoreService {
private final AppStoreConfigRepository configRepo; private final AppStoreConfigRepository configRepo;
private final AppVersionRepository versionRepo; private final AppVersionRepository versionRepo;
private final RnBundleRepository rnBundleRepository; private final RnBundleRepository rnBundleRepository;
private final UpdateOperationLogService operationLogService;
public AppStoreService(AppStoreConfigRepository configRepo, public AppStoreService(AppStoreConfigRepository configRepo,
AppVersionRepository versionRepo, AppVersionRepository versionRepo,
RnBundleRepository rnBundleRepository) { RnBundleRepository rnBundleRepository,
UpdateOperationLogService operationLogService) {
this.configRepo = configRepo; this.configRepo = configRepo;
this.versionRepo = versionRepo; this.versionRepo = versionRepo;
this.rnBundleRepository = rnBundleRepository; this.rnBundleRepository = rnBundleRepository;
this.operationLogService = operationLogService;
} }
// Store config CRUD // Store config CRUD
@ -49,6 +53,7 @@ public class AppStoreService {
AppStoreConfigEntity.StoreType storeType, AppStoreConfigEntity.StoreType storeType,
String configJson, String configJson,
boolean enabled) { boolean enabled) {
boolean isCreate = configRepo.findByAppIdAndStoreType(appId, storeType).isEmpty();
AppStoreConfigEntity entity = configRepo AppStoreConfigEntity entity = configRepo
.findByAppIdAndStoreType(appId, storeType) .findByAppIdAndStoreType(appId, storeType)
.orElseGet(AppStoreConfigEntity::new); .orElseGet(AppStoreConfigEntity::new);
@ -61,11 +66,33 @@ public class AppStoreService {
entity.setConfigJson(configJson); entity.setConfigJson(configJson);
entity.setEnabled(enabled); entity.setEnabled(enabled);
entity.setUpdatedAt(LocalDateTime.now()); 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) { 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 // Store submission
@ -82,19 +109,38 @@ public class AppStoreService {
LocalDateTime scheduledAt, LocalDateTime scheduledAt,
Boolean autoPublishAfterReview) throws Exception { Boolean autoPublishAfterReview) throws Exception {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow(); 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<String, String> reviewMap = new LinkedHashMap<>(); Map<String, Object> reviewMap = new LinkedHashMap<>();
for (String store : storeTypes) { 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.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes));
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
v.setStoreSubmitMode(submitMode == null || submitMode.isBlank() ? "MANUAL" : submitMode.trim().toUpperCase(Locale.ROOT)); v.setStoreSubmitMode(normalizedMode);
v.setStoreSubmitScheduledAt(scheduledAt); v.setStoreSubmitScheduledAt(scheduledAt);
if (autoPublishAfterReview != null) { 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<String> storeTypes) throws Exception { public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
@ -149,19 +195,47 @@ public class AppStoreService {
public AppVersionEntity updateStoreReview(String versionId, public AppVersionEntity updateStoreReview(String versionId,
String storeType, String storeType,
AppVersionEntity.StoreReviewState state) throws Exception { 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(); AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, String> reviewMap = parseReviewStatus(v.getStoreReviewStatus()); Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
reviewMap.put(storeType, state.name()); reviewMap.put(storeType, reviewPayload(state.name(), reason));
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap)); v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) { if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) {
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED); v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
log.info("Auto-published version {} after all stores approved", versionId); 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); 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; return saved;
} }
@ -177,6 +251,13 @@ public class AppStoreService {
v.setScheduledPublishAt(null); v.setScheduledPublishAt(null);
versionRepo.save(v); versionRepo.save(v);
log.info("Scheduled publish executed for version {}", v.getId()); 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<RnBundleEntity> dueBundles = rnBundleRepository List<RnBundleEntity> dueBundles = rnBundleRepository
@ -187,12 +268,19 @@ public class AppStoreService {
bundle.setScheduledPublishAt(null); bundle.setScheduledPublishAt(null);
rnBundleRepository.save(bundle); rnBundleRepository.save(bundle);
log.info("Scheduled publish executed for RN bundle {}", bundle.getId()); 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 // 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(); String url = v.getWebhookUrl();
if (url == null || url.isBlank()) { if (url == null || url.isBlank()) {
try { try {
@ -212,6 +300,7 @@ public class AppStoreService {
"versionName", v.getVersionName(), "versionName", v.getVersionName(),
"storeType", storeType, "storeType", storeType,
"reviewState", state.name(), "reviewState", state.name(),
"reviewReason", reason == null ? "" : reason,
"publishStatus", v.getPublishStatus().name(), "publishStatus", v.getPublishStatus().name(),
"timestamp", System.currentTimeMillis() "timestamp", System.currentTimeMillis()
)); ));
@ -231,16 +320,16 @@ public class AppStoreService {
// Helpers // Helpers
private Map<String, String> parseReviewStatus(String json) throws Exception { private Map<String, Object> parseReviewStatus(String json) throws Exception {
if (json == null || json.isBlank()) return new LinkedHashMap<>(); if (json == null || json.isBlank()) return new LinkedHashMap<>();
return mapper.readValue(json, new TypeReference<LinkedHashMap<String, String>>() {}); return mapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {});
} }
private boolean allApproved(AppVersionEntity v, Map<String, String> 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<>() {});
return targets.stream().allMatch(t -> 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) { private String resolveWebhookSecret(String appId) {
@ -263,4 +352,25 @@ public class AppStoreService {
return ""; return "";
} }
} }
private Map<String, Object> reviewPayload(String state, String reason) {
Map<String, Object> 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();
}
} }

查看文件

@ -11,7 +11,7 @@ import com.xuqm.update.repository.AppPublishConfigRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity; import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@ -128,9 +128,15 @@ public class PublishConfigService {
"timestamp", System.currentTimeMillis(), "timestamp", System.currentTimeMillis(),
"action", "sync" "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<String> response = restTemplate.exchange( ResponseEntity<String> response = restTemplate.exchange(
URI.create(url), org.springframework.http.HttpMethod.POST, 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()); return replaceGrayMembers(appId, response.getBody());
} }
@ -146,9 +152,15 @@ public class PublishConfigService {
} }
payload.put("appId", appId); payload.put("appId", appId);
payload.put("timestamp", System.currentTimeMillis()); 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<String> response = restTemplate.exchange( ResponseEntity<String> response = restTemplate.exchange(
URI.create(url), org.springframework.http.HttpMethod.POST, 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()); return extractMemberIds(response.getBody());
} }
@ -303,7 +315,7 @@ public class PublishConfigService {
private String defaultConfigJson() { private String defaultConfigJson() {
return """ return """
{"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","grayDirectorySyncCallbackUrl":"","graySelectionSource":"LOCAL"} {"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"}
""".trim(); """.trim();
} }

查看文件

@ -98,7 +98,8 @@ public class StoreSubmissionService {
if (cfg == null || !cfg.isEnabled()) { if (cfg == null || !cfg.isEnabled()) {
log.warn("Store config not found or disabled for {}/{}", v.getAppId(), storeType); log.warn("Store config not found or disabled for {}/{}", v.getAppId(), storeType);
storeService.updateStoreReview(versionId, storeType, storeService.updateStoreReview(versionId, storeType,
AppVersionEntity.StoreReviewState.REJECTED); AppVersionEntity.StoreReviewState.REJECTED,
"Store config not found or disabled");
continue; continue;
} }
Map<String, String> creds = parseConfig(cfg.getConfigJson()); Map<String, String> creds = parseConfig(cfg.getConfigJson());
@ -110,7 +111,8 @@ public class StoreSubmissionService {
log.error("Submission to {} failed for version {}: {}", storeType, versionId, e.getMessage(), e); log.error("Submission to {} failed for version {}: {}", storeType, versionId, e.getMessage(), e);
try { try {
storeService.updateStoreReview(versionId, storeType, storeService.updateStoreReview(versionId, storeType,
AppVersionEntity.StoreReviewState.REJECTED); AppVersionEntity.StoreReviewState.REJECTED,
e.getMessage());
} catch (Exception ex) { /* best effort */ } } catch (Exception ex) { /* best effort */ }
} }
} }
@ -137,7 +139,7 @@ public class StoreSubmissionService {
case "VIVO" -> submitToVivo(v, file, creds); case "VIVO" -> submitToVivo(v, file, creds);
case "APP_STORE" -> submitToAppStore(v, file, creds); case "APP_STORE" -> submitToAppStore(v, file, creds);
case "GOOGLE_PLAY" -> submitToGooglePlay(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); default -> throw new IllegalArgumentException("Unknown store: " + storeType);
} }
} }
@ -343,13 +345,13 @@ public class StoreSubmissionService {
// API: https://developer.apple.com/documentation/appstoreconnectapi // API: https://developer.apple.com/documentation/appstoreconnectapi
private void submitToAppStore(AppVersionEntity v, File file, Map<String, String> creds) { private void submitToAppStore(AppVersionEntity v, File file, Map<String, String> 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 // Google Play
private void submitToGooglePlay(AppVersionEntity v, File file, Map<String, String> creds) { private void submitToGooglePlay(AppVersionEntity v, File file, Map<String, String> 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 // Utilities

查看文件

@ -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<String, Object> 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<UpdateOperationLogEntity> 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<String, Object> detail) {
try {
Map<String, Object> payload = detail == null ? Map.of() : new LinkedHashMap<>(detail);
return objectMapper.writeValueAsString(payload);
} catch (Exception e) {
return "{}";
}
}
}