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

查看文件

@ -35,7 +35,7 @@ public class PushDispatcher {
for (DeviceTokenEntity t : tokens) {
PushProvider provider = providers.get(t.getVendor().name());
if (provider != null) {
boolean ok = provider.send(t.getToken(), title, body, payload);
boolean ok = provider.send(appId, t.getToken(), title, body, payload);
log.info("Push to {}@{} via {}: {}", userId, appId, t.getVendor(), ok ? "OK" : "FAIL");
}
}

查看文件

@ -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;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@ -18,10 +20,10 @@ public class HuaweiPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(HuaweiPushProvider.class);
@Value("${push.huawei.app-id:}")
private String appId;
private String envAppId;
@Value("${push.huawei.app-secret:}")
private String appSecret;
private String envAppSecret;
@Value("${push.huawei.token-url:https://oauth-login.cloud.huawei.com/oauth2/v3/token}")
private String tokenUrl;
@ -29,29 +31,36 @@ public class HuaweiPushProvider implements PushProvider {
@Value("${push.huawei.push-url:https://push-api.cloud.huawei.com/v1/{appId}/messages:send}")
private String pushUrl;
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
public HuaweiPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() {
return "HUAWEI";
}
@Override
public boolean send(String token, String title, String body, String payload) {
if (appId.isBlank() || appSecret.isBlank()) {
public boolean send(String appId, String token, String title, String body, String payload) {
String resolvedAppId = resolveConfig(appId, "appId", envAppId);
String resolvedAppSecret = resolveConfig(appId, "appSecret", envAppSecret);
if (resolvedAppId.isBlank() || resolvedAppSecret.isBlank()) {
log.warn("Huawei push not configured");
return false;
}
try {
String accessToken = getAccessToken();
String url = pushUrl.replace("{appId}", appId);
String accessToken = getAccessToken(resolvedAppId, resolvedAppSecret);
String url = pushUrl.replace("{appId}", resolvedAppId);
Map<String, Object> message = Map.of(
"message", Map.of(
"token", new String[]{token},
"notification", Map.of("title", title, "body", body),
"data", payload != null ? payload : "{}"
)
"message", Map.of(
"token", new String[]{token},
"notification", Map.of("title", title, "body", body),
"data", payload != null ? payload : "{}"
)
);
String requestBody = objectMapper.writeValueAsString(message);
HttpRequest request = HttpRequest.newBuilder()
@ -68,7 +77,7 @@ public class HuaweiPushProvider implements PushProvider {
}
}
private String getAccessToken() throws Exception {
private String getAccessToken(String appId, String appSecret) throws Exception {
String form = "grant_type=client_credentials&client_id=" + appId + "&client_secret=" + appSecret;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenUrl))
@ -79,4 +88,15 @@ public class HuaweiPushProvider implements PushProvider {
Map<?, ?> json = objectMapper.readValue(response.body(), Map.class);
return (String) json.get("access_token");
}
private String resolveConfig(String appId, String key, String fallback) {
JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH")
.map(node -> node.path("huawei"))
.orElse(null);
if (config == null) {
return fallback == null ? "" : fallback;
}
String value = config.path(key).asText("");
return value.isBlank() ? (fallback == null ? "" : fallback) : value;
}
}

查看文件

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

查看文件

@ -1,5 +1,7 @@
package com.xuqm.push.service.provider;
import com.fasterxml.jackson.databind.JsonNode;
import com.xuqm.push.service.TenantPushConfigClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@ -18,18 +20,27 @@ public class XiaomiPushProvider implements PushProvider {
private static final Logger log = LoggerFactory.getLogger(XiaomiPushProvider.class);
@Value("${push.xiaomi.app-secret:}")
private String appSecret;
private String envAppSecret;
@Value("${push.xiaomi.push-url:https://api.xmpush.xiaomi.com/v3/message/regid}")
private String pushUrl;
private final TenantPushConfigClient configClient;
private final HttpClient httpClient = HttpClient.newHttpClient();
public XiaomiPushProvider(TenantPushConfigClient configClient) {
this.configClient = configClient;
}
@Override
public String vendorName() { return "XIAOMI"; }
@Override
public boolean send(String token, String title, String body, String payload) {
public boolean send(String appId, String token, String title, String body, String payload) {
String appSecret = resolveConfig(appId, "appSecret", envAppSecret);
if (appSecret.isBlank()) {
appSecret = resolveConfig(appId, "appKey", envAppSecret);
}
if (appSecret.isBlank()) {
log.warn("Xiaomi push not configured");
return false;
@ -53,4 +64,18 @@ public class XiaomiPushProvider implements PushProvider {
return false;
}
}
private String resolveConfig(String appId, String key, String fallback) {
JsonNode config = configClient.loadServiceConfig(appId, "ANDROID", "PUSH")
.map(node -> node.path("xiaomi"))
.orElse(null);
if (config == null) {
return fallback == null ? "" : fallback;
}
String value = config.path(key).asText("");
if (value.isBlank() && "appSecret".equals(key)) {
value = config.path("appKey").asText("");
}
return value.isBlank() ? (fallback == null ? "" : fallback) : value;
}
}

查看文件

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

查看文件

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

查看文件

@ -5,6 +5,7 @@ import com.xuqm.tenant.entity.FeatureServiceEntity;
import com.xuqm.tenant.entity.ServiceActivationRequestEntity;
import com.xuqm.tenant.service.AppService;
import com.xuqm.tenant.service.FeatureServiceManager;
import com.xuqm.tenant.service.OperationLogService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
@ -24,10 +25,13 @@ public class FeatureServiceController {
private final FeatureServiceManager featureServiceManager;
private final AppService appService;
private final OperationLogService operationLogService;
public FeatureServiceController(FeatureServiceManager featureServiceManager, AppService appService) {
public FeatureServiceController(FeatureServiceManager featureServiceManager, AppService appService,
OperationLogService operationLogService) {
this.featureServiceManager = featureServiceManager;
this.appService = appService;
this.operationLogService = operationLogService;
}
@GetMapping
@ -59,8 +63,12 @@ public class FeatureServiceController {
if (enable) {
throw new com.xuqm.common.exception.BusinessException(400, "开启服务请通过 request-activation 申请");
}
return ResponseEntity.ok(ApiResponse.success(
featureServiceManager.disable(appId, platform, serviceType)));
FeatureServiceEntity saved = featureServiceManager.disable(appId, platform, serviceType);
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "DISABLE_SERVICE", java.util.Map.of(
"platform", platform.name(),
"serviceType", serviceType.name()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PutMapping("/config")
@ -99,10 +107,37 @@ public class FeatureServiceController {
req == null ? null : req.defaultPackageName(),
req == null ? null : req.defaultAppStoreUrl(),
req == null ? null : req.defaultMarketUrl());
case PUSH -> "{}";
case PUSH -> featureServiceManager.buildPushConfig(
appId,
platform,
req == null ? null : req.huaweiAppId(),
req == null ? null : req.huaweiAppSecret(),
req == null ? null : req.xiaomiAppId(),
req == null ? null : req.xiaomiAppKey(),
req == null ? null : req.xiaomiAppSecret(),
req == null ? null : req.oppoAppId(),
req == null ? null : req.oppoAppKey(),
req == null ? null : req.oppoMasterSecret(),
req == null ? null : req.vivoAppId(),
req == null ? null : req.vivoAppKey(),
req == null ? null : req.vivoAppSecret(),
req == null ? null : req.honorAppId(),
req == null ? null : req.honorClientId(),
req == null ? null : req.honorClientSecret(),
req == null ? null : req.apnsTeamId(),
req == null ? null : req.apnsKeyId(),
req == null ? null : req.apnsBundleId(),
req == null ? null : req.apnsKeyPath(),
req != null && Boolean.TRUE.equals(req.apnsSandbox()),
req == null ? null : req.fcmServiceAccountJson());
};
return ResponseEntity.ok(ApiResponse.success(featureServiceManager.updateConfig(
appId, platform, serviceType, config)));
FeatureServiceEntity saved = featureServiceManager.updateConfig(
appId, platform, serviceType, config);
operationLogService.record(tenantId, "SERVICE", "FEATURE_SERVICE", saved.getId(), "UPDATE_SERVICE_CONFIG", java.util.Map.of(
"platform", platform.name(),
"serviceType", serviceType.name()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
/** Submit an activation request for ops approval. */
@ -114,8 +149,15 @@ public class FeatureServiceController {
@RequestParam(required = false) String applyReason,
@AuthenticationPrincipal String tenantId) {
appService.getById(appId, tenantId);
return ResponseEntity.ok(ApiResponse.success(
featureServiceManager.submitActivationRequest(appId, platform, serviceType, applyReason)));
ServiceActivationRequestEntity saved = featureServiceManager.submitActivationRequest(appId, platform, serviceType, applyReason);
java.util.Map<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")
@ -146,6 +188,26 @@ public class FeatureServiceController {
Integer defaultGrayPercent,
String defaultPackageName,
String defaultAppStoreUrl,
String defaultMarketUrl
String defaultMarketUrl,
String huaweiAppId,
String huaweiAppSecret,
String xiaomiAppId,
String xiaomiAppKey,
String xiaomiAppSecret,
String oppoAppId,
String oppoAppKey,
String oppoMasterSecret,
String vivoAppId,
String vivoAppKey,
String vivoAppSecret,
String honorAppId,
String honorClientId,
String honorClientSecret,
String apnsTeamId,
String apnsKeyId,
String apnsBundleId,
String apnsKeyPath,
Boolean apnsSandbox,
String fcmServiceAccountJson
) {}
}

查看文件

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

查看文件

@ -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.time.LocalDateTime;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class AppService {
private final AppRepository appRepository;
private final OperationLogService operationLogService;
private static final SecureRandom random = new SecureRandom();
public AppService(AppRepository appRepository) {
public AppService(AppRepository appRepository, OperationLogService operationLogService) {
this.appRepository = appRepository;
this.operationLogService = operationLogService;
}
public List<AppEntity> listByTenant(String tenantId) {
@ -49,20 +53,46 @@ public class AppService {
app.setAppKey(generateAppKey());
app.setAppSecret(generateSecret());
app.setCreatedAt(LocalDateTime.now());
return appRepository.save(app);
AppEntity saved = appRepository.save(app);
operationLogService.record(tenantId, "APP", "APP", saved.getId(), "CREATE_APP", Map.of(
"name", saved.getName(),
"packageName", saved.getPackageName(),
"appKey", saved.getAppKey()
));
return saved;
}
public AppEntity update(String id, String tenantId, CreateAppRequest req) {
AppEntity app = getById(id, tenantId);
Map<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.setDescription(req.description());
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) {
AppEntity app = getById(id, tenantId);
appRepository.delete(app);
operationLogService.record(tenantId, "APP", "APP", id, "DELETE_APP", Map.of(
"name", app.getName(),
"packageName", app.getPackageName(),
"appKey", app.getAppKey()
));
}
public String resetSecret(String id, String tenantId) {
@ -70,6 +100,11 @@ public class AppService {
String newSecret = generateSecret();
app.setAppSecret(newSecret);
appRepository.save(app);
operationLogService.record(tenantId, "APP", "APP_SECRET", id, "RESET_APP_SECRET", Map.of(
"name", app.getName(),
"packageName", app.getPackageName(),
"appKey", app.getAppKey()
));
return newSecret;
}

查看文件

@ -458,6 +458,66 @@ public class FeatureServiceManager {
return node.toString();
}
public String buildPushConfig(String appId,
FeatureServiceEntity.Platform platform,
String huaweiAppId,
String huaweiAppSecret,
String xiaomiAppId,
String xiaomiAppKey,
String xiaomiAppSecret,
String oppoAppId,
String oppoAppKey,
String oppoMasterSecret,
String vivoAppId,
String vivoAppKey,
String vivoAppSecret,
String honorAppId,
String honorClientId,
String honorClientSecret,
String apnsTeamId,
String apnsKeyId,
String apnsBundleId,
String apnsKeyPath,
boolean apnsSandbox,
String fcmServiceAccountJson) {
ObjectNode node = readConfigNode(appId, platform, FeatureServiceEntity.ServiceType.PUSH).deepCopy();
ensureObjectNode(node, "huawei");
ensureObjectNode(node, "xiaomi");
ensureObjectNode(node, "oppo");
ensureObjectNode(node, "vivo");
ensureObjectNode(node, "honor");
ensureObjectNode(node, "apns");
ensureObjectNode(node, "fcm");
putText(node.with("huawei"), "appId", huaweiAppId);
putText(node.with("huawei"), "appSecret", huaweiAppSecret);
putText(node.with("xiaomi"), "appId", xiaomiAppId);
putText(node.with("xiaomi"), "appKey", xiaomiAppKey);
putText(node.with("xiaomi"), "appSecret", xiaomiAppSecret);
putText(node.with("oppo"), "appId", oppoAppId);
putText(node.with("oppo"), "appKey", oppoAppKey);
putText(node.with("oppo"), "masterSecret", oppoMasterSecret);
putText(node.with("vivo"), "appId", vivoAppId);
putText(node.with("vivo"), "appKey", vivoAppKey);
putText(node.with("vivo"), "appSecret", vivoAppSecret);
putText(node.with("honor"), "appId", honorAppId);
putText(node.with("honor"), "clientId", honorClientId);
putText(node.with("honor"), "clientSecret", honorClientSecret);
putText(node.with("apns"), "teamId", apnsTeamId);
putText(node.with("apns"), "keyId", apnsKeyId);
putText(node.with("apns"), "bundleId", apnsBundleId);
putText(node.with("apns"), "keyPath", apnsKeyPath);
node.with("apns").put("sandbox", apnsSandbox);
putText(node.with("fcm"), "serviceAccountJson", fcmServiceAccountJson);
return node.toString();
}
public List<String> parseStoreTargets(String json) {
if (json == null || json.isBlank()) {
return List.of();
@ -514,4 +574,16 @@ public class FeatureServiceManager {
return objectMapper.createObjectNode();
}
}
private void ensureObjectNode(ObjectNode root, String field) {
if (!root.has(field) || !root.get(field).isObject()) {
root.putObject(field);
}
}
private void putText(ObjectNode node, String field, String value) {
if (value != null) {
node.put(field, value.trim());
}
}
}

查看文件

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

查看文件

@ -55,9 +55,7 @@ public class AppStoreController {
String configJson = body.get("configJson") instanceof String s ? s : null;
boolean enabled = !Boolean.FALSE.equals(body.get("enabled"));
if (storeType == AppStoreConfigEntity.StoreType.REVIEW_WEBHOOK && configJson != null && !configJson.isBlank()) {
validateReviewWebhook(configJson);
}
validateStoreConfig(storeType, configJson);
return ResponseEntity.ok(ApiResponse.success(
storeService.saveConfig(appId, storeType, configJson, enabled)));
}
@ -150,8 +148,9 @@ public class AppStoreController {
String storeType = (String) body.get("storeType");
AppVersionEntity.StoreReviewState state =
AppVersionEntity.StoreReviewState.valueOf((String) body.get("state"));
String reason = body.get("reason") == null ? null : body.get("reason").toString();
return ResponseEntity.ok(ApiResponse.success(
storeService.updateStoreReview(versionId, storeType, state)));
storeService.updateStoreReview(versionId, storeType, state, reason)));
}
private void validateReviewWebhook(String configJson) {
@ -167,4 +166,22 @@ public class AppStoreController {
throw new IllegalArgumentException("invalid review webhook config: " + e.getMessage(), e);
}
}
private void validateStoreConfig(AppStoreConfigEntity.StoreType storeType, String configJson) {
if (configJson == null || configJson.isBlank()) {
return;
}
try {
@SuppressWarnings("unchecked")
Map<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.repository.AppVersionRepository;
import com.xuqm.update.model.AppPackageInspectResult;
import com.xuqm.update.service.UpdateOperationLogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@ -29,15 +30,18 @@ public class AppVersionController {
private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService;
private final AppStoreService appStoreService;
private final UpdateOperationLogService operationLogService;
public AppVersionController(AppVersionRepository versionRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService,
AppStoreService appStoreService) {
AppStoreService appStoreService,
UpdateOperationLogService operationLogService) {
this.versionRepository = versionRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.appStoreService = appStoreService;
this.operationLogService = operationLogService;
}
@GetMapping("/app/check")
@ -47,10 +51,13 @@ public class AppVersionController {
@RequestParam int currentVersionCode) {
Optional<AppVersionEntity> latest = versionRepository
.findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
appId, platform, AppVersionEntity.PublishStatus.PUBLISHED);
.findTopByAppIdAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
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)));
}
@ -67,7 +74,7 @@ public class AppVersionController {
"versionCode", v.getVersionCode(),
"downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "",
"changeLog", v.getChangeLog() != null ? v.getChangeLog() : "",
"forceUpdate", v.isForceUpdate(),
"forceUpdate", forcedHigher.isPresent(),
"appStoreUrl", appStoreJumpUrl,
"marketUrl", harmonyJumpUrl
)));
@ -153,7 +160,22 @@ public class AppVersionController {
entity.setGrayEnabled(false);
entity.setGrayPercent(0);
}
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"APP_VERSION",
saved.getId(),
"UPLOAD",
null,
Map.of(
"platform", saved.getPlatform().name(),
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode(),
"publishImmediately", publishImmediately,
"forceUpdate", saved.isForceUpdate(),
"packageName", saved.getPackageName() == null ? "" : saved.getPackageName()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@RequestMapping(value = "/app/inspect", method = {RequestMethod.GET, RequestMethod.POST})
@ -176,6 +198,7 @@ public class AppVersionController {
? body.get("scheduledPublishAt").toString() : null;
boolean forceUpdate = body != null && body.get("forceUpdate") != null
? Boolean.parseBoolean(body.get("forceUpdate").toString()) : entity.isForceUpdate();
AppVersionEntity.PublishStatus previousStatus = entity.getPublishStatus();
entity.setForceUpdate(forceUpdate);
if (publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank())) {
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
@ -190,14 +213,45 @@ public class AppVersionController {
entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"APP_VERSION",
saved.getId(),
publishAction(previousStatus, saved.getPublishStatus(), publishImmediately),
null,
Map.of(
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode(),
"publishImmediately", publishImmediately,
"scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString(),
"forceUpdate", saved.isForceUpdate()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/app/{id}/unpublish")
public ResponseEntity<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();
String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : "";
if (reason.isBlank()) {
throw new IllegalArgumentException("unpublish reason is required");
}
entity.setPublishStatus(AppVersionEntity.PublishStatus.DEPRECATED);
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"APP_VERSION",
saved.getId(),
"UNPUBLISH",
reason,
Map.of(
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/app/{id}/gray")
@ -228,7 +282,20 @@ public class AppVersionController {
entity.setGrayMemberIds(null);
}
entity.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
return ResponseEntity.ok(ApiResponse.success(versionRepository.save(entity)));
AppVersionEntity saved = versionRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"APP_VERSION",
saved.getId(),
"GRAY_UPDATE",
null,
Map.of(
"enabled", enabled,
"grayMode", saved.getGrayMode(),
"grayPercent", saved.getGrayPercent(),
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@GetMapping("/app/list")
@ -238,6 +305,21 @@ public class AppVersionController {
versionRepository.findByAppIdAndPlatformOrderByVersionCodeDesc(appId, platform)));
}
private String publishAction(AppVersionEntity.PublishStatus previousStatus,
AppVersionEntity.PublishStatus currentStatus,
boolean publishImmediately) {
if (!publishImmediately) {
return "SCHEDULE_PUBLISH";
}
if (previousStatus == AppVersionEntity.PublishStatus.PUBLISHED) {
return "UPDATE_FORCE";
}
if (previousStatus == AppVersionEntity.PublishStatus.DEPRECATED) {
return "REPUBLISH";
}
return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT";
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}

查看文件

@ -5,6 +5,7 @@ import com.xuqm.update.entity.RnBundleEntity;
import com.xuqm.update.model.RnBundleInspectResult;
import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.PublishConfigService;
import com.xuqm.update.service.UpdateOperationLogService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@ -24,16 +25,19 @@ public class RnBundleController {
private final RnBundleRepository bundleRepository;
private final UpdateAssetService updateAssetService;
private final PublishConfigService publishConfigService;
private final UpdateOperationLogService operationLogService;
@Value("${update.base-url:https://update.dev.xuqinmin.com}")
private String baseUrl;
public RnBundleController(RnBundleRepository bundleRepository,
UpdateAssetService updateAssetService,
PublishConfigService publishConfigService) {
PublishConfigService publishConfigService,
UpdateOperationLogService operationLogService) {
this.bundleRepository = bundleRepository;
this.updateAssetService = updateAssetService;
this.publishConfigService = publishConfigService;
this.operationLogService = operationLogService;
}
@GetMapping("/update/check")
@ -108,7 +112,21 @@ public class RnBundleController {
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
entity.setCreatedAt(LocalDateTime.now());
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"RN_BUNDLE",
saved.getId(),
"UPLOAD",
null,
Map.of(
"moduleId", saved.getModuleId(),
"platform", saved.getPlatform().name(),
"version", saved.getVersion(),
"minCommonVersion", saved.getMinCommonVersion() == null ? "" : saved.getMinCommonVersion(),
"packageName", saved.getPackageName() == null ? "" : saved.getPackageName()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@RequestMapping(value = "/inspect", method = {RequestMethod.GET, RequestMethod.POST})
@ -156,14 +174,44 @@ public class RnBundleController {
entity.setGrayPercent(0);
entity.setGrayMode("PERCENT");
entity.setGrayMemberIds(null);
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"RN_BUNDLE",
saved.getId(),
publishImmediately && (scheduledPublishAt == null || scheduledPublishAt.isBlank()) ? "PUBLISH" : "SCHEDULE_PUBLISH",
null,
Map.of(
"moduleId", saved.getModuleId(),
"version", saved.getVersion(),
"publishMode", saved.getPublishMode(),
"scheduledPublishAt", saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/{id}/unpublish")
public ResponseEntity<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();
String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : "";
if (reason.isBlank()) {
throw new IllegalArgumentException("unpublish reason is required");
}
entity.setPublishStatus(RnBundleEntity.PublishStatus.DEPRECATED);
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"RN_BUNDLE",
saved.getId(),
"UNPUBLISH",
reason,
Map.of(
"moduleId", saved.getModuleId(),
"version", saved.getVersion()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@PostMapping("/{id}/gray")
@ -194,7 +242,21 @@ public class RnBundleController {
entity.setGrayMemberIds(null);
}
entity.setPublishStatus(RnBundleEntity.PublishStatus.PUBLISHED);
return ResponseEntity.ok(ApiResponse.success(bundleRepository.save(entity)));
RnBundleEntity saved = bundleRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"RN_BUNDLE",
saved.getId(),
"GRAY_UPDATE",
null,
Map.of(
"moduleId", saved.getModuleId(),
"version", saved.getVersion(),
"grayMode", saved.getGrayMode(),
"grayPercent", saved.getGrayPercent(),
"memberCount", saved.getGrayMemberIds() == null ? 0 : extractMemberIds(saved.getGrayMemberIds()).size()
));
return ResponseEntity.ok(ApiResponse.success(saved));
}
private String resolvePublicBaseUrl() {

查看文件

@ -9,6 +9,7 @@ import com.xuqm.update.model.UnifiedReleaseResult;
import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.UpdateOperationLogService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@ -21,6 +22,7 @@ import org.springframework.web.multipart.MultipartHttpServletRequest;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@ -31,16 +33,19 @@ public class UnifiedReleaseController {
private final AppVersionRepository appVersionRepository;
private final RnBundleRepository rnBundleRepository;
private final UpdateAssetService updateAssetService;
private final UpdateOperationLogService operationLogService;
public UnifiedReleaseController(
ObjectMapper objectMapper,
AppVersionRepository appVersionRepository,
RnBundleRepository rnBundleRepository,
UpdateAssetService updateAssetService) {
UpdateAssetService updateAssetService,
UpdateOperationLogService operationLogService) {
this.objectMapper = objectMapper;
this.appVersionRepository = appVersionRepository;
this.rnBundleRepository = rnBundleRepository;
this.updateAssetService = updateAssetService;
this.operationLogService = operationLogService;
}
@PostMapping("/unified/upload")
@ -83,7 +88,22 @@ public class UnifiedReleaseController {
entity.setGrayEnabled(false);
entity.setGrayPercent(0);
}
appVersions.add(appVersionRepository.save(entity));
AppVersionEntity saved = appVersionRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"APP_VERSION",
saved.getId(),
"UPLOAD",
null,
Map.of(
"platform", saved.getPlatform().name(),
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode(),
"publishImmediately", item.publishImmediately(),
"forceUpdate", saved.isForceUpdate(),
"packageName", saved.getPackageName() == null ? "" : saved.getPackageName()
));
appVersions.add(saved);
}
List<RnBundleEntity> rnBundles = new ArrayList<>();
@ -108,7 +128,21 @@ public class UnifiedReleaseController {
entity.setNote(item.note());
entity.setPublishStatus(RnBundleEntity.PublishStatus.DRAFT);
entity.setCreatedAt(LocalDateTime.now());
rnBundles.add(rnBundleRepository.save(entity));
RnBundleEntity saved = rnBundleRepository.save(entity);
operationLogService.record(
saved.getAppId(),
"RN_BUNDLE",
saved.getId(),
"UPLOAD",
null,
Map.of(
"moduleId", saved.getModuleId(),
"platform", saved.getPlatform().name(),
"version", saved.getVersion(),
"minCommonVersion", saved.getMinCommonVersion() == null ? "" : saved.getMinCommonVersion(),
"packageName", saved.getPackageName() == null ? "" : saved.getPackageName()
));
rnBundles.add(saved);
}
return ResponseEntity.ok(ApiResponse.success(new UnifiedReleaseResult(appVersions, rnBundles)));

查看文件

@ -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;
/**
* JSON map of StoreType -> StoreReviewState, e.g. {"HUAWEI":"UNDER_REVIEW","MI":"APPROVED"}.
* Updated by the store webhook endpoint.
* JSON map of StoreType -> review payload, e.g.
* {"HUAWEI":{"state":"UNDER_REVIEW","reason":""},"MI":{"state":"REJECTED","reason":"..."}}
* Older string-only values are still accepted by the readers.
*/
@Column(columnDefinition = "TEXT")
private String storeReviewStatus;

查看文件

@ -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);
Optional<AppVersionEntity> findTopByAppIdAndPlatformAndPublishStatusOrderByVersionCodeDesc(
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(
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.AppVersionRepository;
import com.xuqm.update.repository.RnBundleRepository;
import com.xuqm.update.service.UpdateOperationLogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
@ -30,13 +31,16 @@ public class AppStoreService {
private final AppStoreConfigRepository configRepo;
private final AppVersionRepository versionRepo;
private final RnBundleRepository rnBundleRepository;
private final UpdateOperationLogService operationLogService;
public AppStoreService(AppStoreConfigRepository configRepo,
AppVersionRepository versionRepo,
RnBundleRepository rnBundleRepository) {
RnBundleRepository rnBundleRepository,
UpdateOperationLogService operationLogService) {
this.configRepo = configRepo;
this.versionRepo = versionRepo;
this.rnBundleRepository = rnBundleRepository;
this.operationLogService = operationLogService;
}
// Store config CRUD
@ -49,6 +53,7 @@ public class AppStoreService {
AppStoreConfigEntity.StoreType storeType,
String configJson,
boolean enabled) {
boolean isCreate = configRepo.findByAppIdAndStoreType(appId, storeType).isEmpty();
AppStoreConfigEntity entity = configRepo
.findByAppIdAndStoreType(appId, storeType)
.orElseGet(AppStoreConfigEntity::new);
@ -61,11 +66,33 @@ public class AppStoreService {
entity.setConfigJson(configJson);
entity.setEnabled(enabled);
entity.setUpdatedAt(LocalDateTime.now());
return configRepo.save(entity);
AppStoreConfigEntity saved = configRepo.save(entity);
operationLogService.record(
appId,
"STORE_CONFIG",
saved.getId(),
isCreate ? "CREATE_STORE_CONFIG" : "UPDATE_STORE_CONFIG",
null,
Map.of(
"storeType", storeType.name(),
"enabled", enabled
));
return saved;
}
public void deleteConfig(String appId, AppStoreConfigEntity.StoreType storeType) {
configRepo.findByAppIdAndStoreType(appId, storeType).ifPresent(configRepo::delete);
configRepo.findByAppIdAndStoreType(appId, storeType).ifPresent(cfg -> {
configRepo.delete(cfg);
operationLogService.record(
appId,
"STORE_CONFIG",
cfg.getId(),
"DELETE_STORE_CONFIG",
null,
Map.of(
"storeType", storeType.name()
));
});
}
// Store submission
@ -82,19 +109,38 @@ public class AppStoreService {
LocalDateTime scheduledAt,
Boolean autoPublishAfterReview) throws Exception {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
String normalizedMode = submitMode == null || submitMode.isBlank()
? "MANUAL"
: submitMode.trim().toUpperCase(Locale.ROOT);
if ("SCHEDULED".equals(normalizedMode) && scheduledAt == null) {
throw new IllegalArgumentException("scheduledAt is required when submitMode is SCHEDULED");
}
Map<String, String> reviewMap = new LinkedHashMap<>();
Map<String, Object> reviewMap = new LinkedHashMap<>();
for (String store : storeTypes) {
reviewMap.put(store, AppVersionEntity.StoreReviewState.PENDING.name());
reviewMap.put(store, reviewPayload(AppVersionEntity.StoreReviewState.PENDING.name(), null));
}
v.setStoreSubmitTargets(mapper.writeValueAsString(storeTypes));
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
v.setStoreSubmitMode(submitMode == null || submitMode.isBlank() ? "MANUAL" : submitMode.trim().toUpperCase(Locale.ROOT));
v.setStoreSubmitMode(normalizedMode);
v.setStoreSubmitScheduledAt(scheduledAt);
if (autoPublishAfterReview != null) {
v.setAutoPublishAfterReview(autoPublishAfterReview);
v.setAutoPublishAfterReview(autoPublishAfterReview && !"SCHEDULED".equals(normalizedMode));
}
return versionRepo.save(v);
AppVersionEntity saved = versionRepo.save(v);
operationLogService.record(
saved.getAppId(),
"APP_VERSION",
saved.getId(),
"STORE_SUBMIT",
null,
Map.of(
"storeTypes", storeTypes,
"submitMode", saved.getStoreSubmitMode(),
"scheduledAt", saved.getStoreSubmitScheduledAt() == null ? "" : saved.getStoreSubmitScheduledAt().toString(),
"autoPublishAfterReview", saved.isAutoPublishAfterReview()
));
return saved;
}
public AppVersionEntity markSubmitted(String versionId, List<String> storeTypes) throws Exception {
@ -149,19 +195,47 @@ public class AppStoreService {
public AppVersionEntity updateStoreReview(String versionId,
String storeType,
AppVersionEntity.StoreReviewState state) throws Exception {
return updateStoreReview(versionId, storeType, state, null);
}
public AppVersionEntity updateStoreReview(String versionId,
String storeType,
AppVersionEntity.StoreReviewState state,
String reason) throws Exception {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
Map<String, String> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
reviewMap.put(storeType, state.name());
Map<String, Object> reviewMap = parseReviewStatus(v.getStoreReviewStatus());
reviewMap.put(storeType, reviewPayload(state.name(), reason));
v.setStoreReviewStatus(mapper.writeValueAsString(reviewMap));
if (v.isAutoPublishAfterReview() && allApproved(v, reviewMap)) {
v.setPublishStatus(AppVersionEntity.PublishStatus.PUBLISHED);
log.info("Auto-published version {} after all stores approved", versionId);
operationLogService.record(
v.getAppId(),
"APP_VERSION",
v.getId(),
"AUTO_PUBLISH",
reason,
Map.of(
"storeType", storeType,
"publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name()
));
}
AppVersionEntity saved = versionRepo.save(v);
sendWebhook(saved, storeType, state);
operationLogService.record(
saved.getAppId(),
"APP_VERSION",
saved.getId(),
"STORE_REVIEW",
reason,
Map.of(
"storeType", storeType,
"reviewState", state.name(),
"publishStatus", saved.getPublishStatus().name()
));
sendWebhook(saved, storeType, state, reason);
return saved;
}
@ -177,6 +251,13 @@ public class AppStoreService {
v.setScheduledPublishAt(null);
versionRepo.save(v);
log.info("Scheduled publish executed for version {}", v.getId());
operationLogService.record(
v.getAppId(),
"APP_VERSION",
v.getId(),
"SCHEDULE_PUBLISH",
null,
Map.of("publishStatus", AppVersionEntity.PublishStatus.PUBLISHED.name()));
}
List<RnBundleEntity> dueBundles = rnBundleRepository
@ -187,12 +268,19 @@ public class AppStoreService {
bundle.setScheduledPublishAt(null);
rnBundleRepository.save(bundle);
log.info("Scheduled publish executed for RN bundle {}", bundle.getId());
operationLogService.record(
bundle.getAppId(),
"RN_BUNDLE",
bundle.getId(),
"SCHEDULE_PUBLISH",
null,
Map.of("publishStatus", RnBundleEntity.PublishStatus.PUBLISHED.name()));
}
}
// Webhook delivery
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state) {
private void sendWebhook(AppVersionEntity v, String storeType, AppVersionEntity.StoreReviewState state, String reason) {
String url = v.getWebhookUrl();
if (url == null || url.isBlank()) {
try {
@ -212,6 +300,7 @@ public class AppStoreService {
"versionName", v.getVersionName(),
"storeType", storeType,
"reviewState", state.name(),
"reviewReason", reason == null ? "" : reason,
"publishStatus", v.getPublishStatus().name(),
"timestamp", System.currentTimeMillis()
));
@ -231,16 +320,16 @@ public class AppStoreService {
// Helpers
private Map<String, String> parseReviewStatus(String json) throws Exception {
private Map<String, Object> parseReviewStatus(String json) throws Exception {
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;
List<String> targets = mapper.readValue(v.getStoreSubmitTargets(), new TypeReference<>() {});
return targets.stream().allMatch(t ->
AppVersionEntity.StoreReviewState.APPROVED.name().equals(reviewMap.get(t)));
AppVersionEntity.StoreReviewState.APPROVED.name().equals(readReviewState(reviewMap.get(t))));
}
private String resolveWebhookSecret(String appId) {
@ -263,4 +352,25 @@ public class AppStoreService {
return "";
}
}
private Map<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.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
@ -128,9 +128,15 @@ public class PublishConfigService {
"timestamp", System.currentTimeMillis(),
"action", "sync"
);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String secret = config.path("grayDirectorySyncCallbackSecret").asText("");
if (!secret.isBlank()) {
headers.set("X-Xuqm-Callback-Secret", secret);
}
ResponseEntity<String> response = restTemplate.exchange(
URI.create(url), org.springframework.http.HttpMethod.POST,
new org.springframework.http.HttpEntity<>(requestBody), String.class);
new org.springframework.http.HttpEntity<>(requestBody, headers), String.class);
return replaceGrayMembers(appId, response.getBody());
}
@ -146,9 +152,15 @@ public class PublishConfigService {
}
payload.put("appId", appId);
payload.put("timestamp", System.currentTimeMillis());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String secret = config.path("graySelectCallbackSecret").asText("");
if (!secret.isBlank()) {
headers.set("X-Xuqm-Callback-Secret", secret);
}
ResponseEntity<String> response = restTemplate.exchange(
URI.create(url), org.springframework.http.HttpMethod.POST,
new org.springframework.http.HttpEntity<>(payload), String.class);
new org.springframework.http.HttpEntity<>(payload, headers), String.class);
return extractMemberIds(response.getBody());
}
@ -303,7 +315,7 @@ public class PublishConfigService {
private String defaultConfigJson() {
return """
{"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","grayDirectorySyncCallbackUrl":"","graySelectionSource":"LOCAL"}
{"defaultGrayPercent":0,"grayMode":"PERCENT","graySelectCallbackUrl":"","graySelectCallbackSecret":"","grayDirectorySyncCallbackUrl":"","grayDirectorySyncCallbackSecret":"","graySelectionSource":"LOCAL"}
""".trim();
}

查看文件

@ -98,7 +98,8 @@ public class StoreSubmissionService {
if (cfg == null || !cfg.isEnabled()) {
log.warn("Store config not found or disabled for {}/{}", v.getAppId(), storeType);
storeService.updateStoreReview(versionId, storeType,
AppVersionEntity.StoreReviewState.REJECTED);
AppVersionEntity.StoreReviewState.REJECTED,
"Store config not found or disabled");
continue;
}
Map<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);
try {
storeService.updateStoreReview(versionId, storeType,
AppVersionEntity.StoreReviewState.REJECTED);
AppVersionEntity.StoreReviewState.REJECTED,
e.getMessage());
} catch (Exception ex) { /* best effort */ }
}
}
@ -137,7 +139,7 @@ public class StoreSubmissionService {
case "VIVO" -> submitToVivo(v, file, creds);
case "APP_STORE" -> submitToAppStore(v, file, creds);
case "GOOGLE_PLAY" -> submitToGooglePlay(v, file, creds);
case "HARMONY_APP" -> log.warn("Harmony app store submission is not implemented yet - keep this channel for market link and manual review tracking");
case "HARMONY_APP" -> log.info("Harmony app store submission is skipped; this channel only records the market link and manual review tracking");
default -> throw new IllegalArgumentException("Unknown store: " + storeType);
}
}
@ -343,13 +345,13 @@ public class StoreSubmissionService {
// API: https://developer.apple.com/documentation/appstoreconnectapi
private void submitToAppStore(AppVersionEntity v, File file, Map<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
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

查看文件

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