feat(update): add official store monitoring domain

这个提交包含在:
XuqmGroup 2026-07-28 19:53:15 +08:00
父节点 1665dbc735
当前提交 f96a27facc
共有 63 个文件被更改,包括 3785 次插入329 次删除

162
docs/STORE_MONITORING_V3.md 普通文件
查看文件

@ -0,0 +1,162 @@
# 应用商店监测领域设计V3
## 1. 文档状态
- 文档类型:服务端对内设计与实施交接
- 当前迭代Android 全链路;租户平台 iOS/HarmonyOS 只读商店监测
- 目标地区:中国大陆(`CHN`
- 更新时间2026-07-28
- 实施状态:进行中
本文件是 `update-service` 应用商店监测领域的权威设计。历史
`storeReviewStatus` JSON、`APPROVED/liveOnStore` 混合状态以及
`StoreReviewImNotifier` 只能作为迁移线索,不是新实现契约。
## 2. 当前迭代边界
### 2.1 必须实现
1. 绑定 App Store Connect 与华为 AppGallery Connect 的只读监测凭据。
2. 拉取当前在线、审核中、审核通过待发布、拒绝等官方版本。
3. 分别保存官方版本 ID、展示版本号和平台原生构建号。
4. 以中国大陆实际可下载作为 `AVAILABLE` 的唯一条件。
5. 官方事件优先、定时轮询最终对账;相同状态不得重复产生通知事件。
6. 已上架外部版本允许由管理员创建“跳转应用商店”型 Xuqm 更新草稿。
7. 通知仅支持邮件和 Webhook,默认全部关闭。
8. 所有商店状态、人工操作和投递结果必须可审计。
### 2.2 本期禁止伪实现
1. 上传 IPA/HAP。
2. 提交 iOS/HarmonyOS 审核。
3. 对 iOS/HarmonyOS 执行立即发布、定时发布或撤回审核。
4. 在页面保留能够点击但后端只写日志的按钮。
上述写操作只在设计文档中保留扩展边界,不进入当前可用能力。
## 3. 核心不变量
1. 官方商店版本与 Xuqm 客户端更新版本是两个聚合,通过
`update_store_update_link` 显式关联。
2. 官方资源 ID 是商店版本唯一标识;展示版本号不能代替资源 ID。
3. iOS `CFBundleVersion` 是原始字符串,禁止强转为 Android `int versionCode`
4. `APPROVED_PENDING_RELEASE` 不等于 `AVAILABLE`
5. 调用官方发布接口成功也不能直接标记上架,必须由监测确认中国大陆可下载。
6. 未识别的官方状态统一映射为 `UNKNOWN`,不得猜测成审核中或已上架。
7. 凭据正文只能由 `StoreSecretProvider` 解析,控制器、实体视图和日志不得返回。
8. 监测失败只更新同步健康状态,不覆盖最后一个已验证的商店版本状态。
## 4. 统一状态机
| 状态 | 含义 | 是否允许配置客户端更新 |
| --- | --- | --- |
| `DISCOVERED` | 首次发现 | 否 |
| `PREPARING` | 商店后台准备中 | 否 |
| `SUBMITTED` | 已提交 | 否 |
| `WAITING_FOR_REVIEW` | 等待审核 | 否 |
| `IN_REVIEW` | 审核中 | 否 |
| `REJECTED` | 审核拒绝 | 否 |
| `APPROVED_PENDING_RELEASE` | 审核通过,尚未发布 | 否 |
| `RELEASE_SCHEDULED` | 已配置官方发布计划(后续版本) | 否 |
| `RELEASE_REQUESTED` | 已请求官方发布(后续版本) | 否 |
| `PROCESSING_DISTRIBUTION` | 商店分发处理中 | 否 |
| `PARTIALLY_AVAILABLE` | 非全部目标地区可用 | 否 |
| `AVAILABLE` | 中国大陆实际可下载 | 是 |
| `WITHDRAWN` | 已撤回 | 否 |
| `REMOVED` | 已下架或被替代 | 否 |
| `UNKNOWN` | 未识别官方状态 | 否 |
| `SYNC_FAILED` | 应用级同步失败视图 | 否 |
官方原始状态必须与统一状态同时保存。
## 5. 数据模型
### 5.1 商店与版本
- `update_store_application`Xuqm 应用与官方商店应用绑定。
- `update_store_version`:官方版本库存。
- `update_store_region_availability`:目标地区可用性。
- `update_store_state_event`:状态变化的不可变事件历史。
- `update_store_update_link`:官方版本与 Xuqm 更新版本的唯一有效关联。
### 5.2 凭据与通知
- `update_store_credential_profile`:非敏感凭据元数据和 Secrets 引用。
- `update_store_secret`:私有化默认 Secrets 后端的 AES-256-GCM 密文。
- `update_store_notification_policy`:应用级邮件/Webhook 策略,默认关闭。
- `update_store_notification_outbox`:异步投递、重试和最终结果。
公有化部署后续可替换 `StoreSecretProvider` 为 KMS/Vault 适配器;业务层不得感知后端类型。
公有化与私有化不允许自动回退到另一套 Secrets 服务。
## 6. API当前迭代
基础路径:`/api/v1/updates/store-monitoring`
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| GET | `/bindings?appKey=...` | 查询已配置的只读绑定 |
| PUT | `/bindings/{APP_STORE\|HARMONY_APP}?appKey=...` | 保存绑定或替换监测凭据 |
| POST | `/bindings/{storeType}/sync?appKey=...` | 人工立即同步 |
| GET | `/bindings/{bindingId}/versions?appKey=...` | 查询官方版本库存 |
| GET | `/bindings/{bindingId}/events?appKey=...` | 查询最近 100 条状态事件 |
| POST | `/bindings/{bindingId}/versions/{storeVersionId}/update-draft?appKey=...` | 由 CHN 已上架版本创建 Xuqm 更新草稿 |
| GET | `/notification-policy?appKey=...` | 查询通知策略;默认全部关闭 |
| PUT | `/notification-policy?appKey=...` | 保存邮件/Webhook 通知策略 |
所有接口执行应用归属校验。凭据只在保存请求中出现一次,响应永不返回正文。
Webhook 请求包含 `X-Xuqm-Event-Id`、`X-Xuqm-Timestamp` 和
`X-Xuqm-Signature: sha256=<hex>`。签名原文为
`timestamp + "." + rawBody`,使用租户保存的 Webhook 密钥执行 HMAC-SHA256。
同一商店事件、同一渠道只产生一条 Outbox;失败采用退避重试,达到 6 次后进入
`DEAD` 并保留审计记录,不影响商店状态和平台主流程。
创建更新草稿要求商店统一状态为 `AVAILABLE``CHN` 可用性为
`AVAILABLE`。该操作只生成 `DRAFT`,不会自动发布或强制更新。重复请求返回同一条
有效关联,不重复创建版本。App 更新版本使用以下统一标识:
- Android`versionCode`;
- iOS/Harmony平台原生字符串 `buildVersion`,`versionCode` 必须为空;
- 所有平台:数据库原子分配 `releaseSequence` 负责服务端排序;
- 外部商店草稿:`updateSource=EXTERNAL_STORE`、
`installMode=STORE_REDIRECT`
## 7. 配置与部署
必须通过运行环境提供:
- `SPRING_DATASOURCE_URL`
- `SPRING_DATASOURCE_USERNAME`
- `SPRING_DATASOURCE_PASSWORD`
- `XUQM_JWT_SECRET`
- `XUQM_STORE_SECRET_MASTER_KEY`32 字节随机值的 Base64
可选配置:
- `XUQM_STORE_SECRET_KEY_VERSION`,默认 `v1`
- `XUQM_STORE_MONITORING_POLL_DELAY_MS`,默认 `600000`
- `XUQM_STORE_NOTIFICATION_DELAY_MS`,默认 `5000`
- `SMTP_HOST`、`SMTP_PORT`、`SMTP_USERNAME`、`SMTP_PASSWORD`、`SMTP_TLS`、
`SMTP_SSL`:仅启用邮件通知时需要
禁止在 Git、普通文档、日志或容器镜像中写入真实值。
## 8. 当前验证记录
2026-07-28
- `./mvnw -pl update-service -am test -DskipITs`
- 结果35 个测试通过,0 失败。
- 已覆盖统一状态映射、iOS 在线/待发布分离、HarmonyOS 在线/审核中分离、
通知默认关闭、邮件/Webhook 独立入队、CHN 上架门禁、商店草稿不伪造
Android versionCode、现有 RN release-set 与鉴权回归。
- 未验证:真实 App Store Connect、AppGallery Connect 账号联调;Flyway 对真实
MySQL 的 V5 迁移;真实 SMTP/Webhook 投递;租户平台 UI。
## 9. 下一实施顺序
1. 租户平台接入绑定、同步、版本库存、事件时间线、更新草稿和通知配置。
2. 增加真实 MySQL Flyway 集成测试与官方沙箱/测试账号联调。
3. 完成旧 `storeReviewStatus` 数据一次性迁移并删除旧读写路径及旧直发
Webhook;Update 对 IM 的直接通知类已删除。

查看文件

@ -40,6 +40,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>

查看文件

@ -1,116 +0,0 @@
package com.xuqm.update.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
/**
* update-service 数据库迁移 Spring 上下文完全初始化后执行ApplicationRunner
* 避免 BeanFactoryPostProcessor 过早访问 DataSource 导致的类加载器时序问题
*/
@Component
public class SchemaMigrationRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(SchemaMigrationRunner.class);
private final DataSource dataSource;
public SchemaMigrationRunner(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) {
migrate_v20260610_gray_mode_simplify(dataSource);
}
/**
* 将旧灰度模式迁移到新的 PERCENT / MEMBERS 模式
*
* 旧值IM_PUSH_USERS / CUSTOMER_SYNC / CUSTOMER_CALLBACK MEMBERS
* 清理grayCallbackUrl 旧数据已废弃改为配置级 publishCallbackUrl
*/
private void migrate_v20260610_gray_mode_simplify(DataSource dataSource) {
String migrationId = "v20260610_gray_mode_simplify";
try (Connection conn = dataSource.getConnection()) {
// 确保迁移记录表存在
try (Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS _schema_migrations (
id VARCHAR(128) NOT NULL PRIMARY KEY,
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
description VARCHAR(255)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
}
// 检查是否已执行
try (var ps = conn.prepareStatement(
"SELECT COUNT(*) FROM _schema_migrations WHERE id = ?")) {
ps.setString(1, migrationId);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next() && rs.getInt(1) > 0) {
return; // 已执行跳过
}
}
}
log.info("Applying migration: {}", migrationId);
// 迁移 update_app_version
int appVersions = 0;
try (var ps = conn.prepareStatement(
"UPDATE update_app_version SET gray_mode = 'MEMBERS' " +
"WHERE gray_mode IN ('IM_PUSH_USERS', 'CUSTOMER_SYNC', 'CUSTOMER_CALLBACK')")) {
appVersions = ps.executeUpdate();
}
// 迁移 update_rn_bundle
int rnBundles = 0;
try (var ps = conn.prepareStatement(
"UPDATE update_rn_bundle SET gray_mode = 'MEMBERS' " +
"WHERE gray_mode IN ('IM_PUSH_USERS', 'CUSTOMER_SYNC', 'CUSTOMER_CALLBACK')")) {
rnBundles = ps.executeUpdate();
}
// 清理旧的 grayCallbackUrl
try (var ps = conn.prepareStatement(
"UPDATE update_app_version SET gray_callback_url = NULL " +
"WHERE gray_callback_url IS NOT NULL AND gray_callback_url != ''")) {
int cleaned = ps.executeUpdate();
if (cleaned > 0) {
log.info("Cleared {} old grayCallbackUrl from update_app_version", cleaned);
}
}
try (var ps = conn.prepareStatement(
"UPDATE update_rn_bundle SET gray_callback_url = NULL " +
"WHERE gray_callback_url IS NOT NULL AND gray_callback_url != ''")) {
int cleaned = ps.executeUpdate();
if (cleaned > 0) {
log.info("Cleared {} old grayCallbackUrl from update_rn_bundle", cleaned);
}
}
// 记录迁移
try (var ps = conn.prepareStatement(
"INSERT IGNORE INTO _schema_migrations (id, description) VALUES (?, ?)")) {
ps.setString(1, migrationId);
ps.setString(2, "Simplify GrayMode to PERCENT/MEMBERS, clear grayCallbackUrl");
ps.executeUpdate();
}
log.info("Migration {} done: {} app versions, {} RN bundles converted to MEMBERS",
migrationId, appVersions, rnBundles);
} catch (Exception e) {
log.error("Migration {} failed: {}", migrationId, e.getMessage(), e);
}
}
}

查看文件

@ -26,7 +26,10 @@ import com.xuqm.update.service.ImPushUserClient;
import com.xuqm.update.service.UpdateTenantClient;
import com.xuqm.update.handler.UpdateWebSocketHandler;
import com.xuqm.update.service.GrayMemberService;
import com.xuqm.update.service.RnBundleAuthorizationService;
import com.xuqm.update.service.AppReleaseSequenceService;
import com.xuqm.common.exception.BusinessException;
import org.springframework.security.core.context.SecurityContextHolder;
@RestController
@RequestMapping("/api/v1/updates")
@ -44,6 +47,8 @@ public class AppVersionController {
private final UpdateTenantClient tenantClient;
private final UpdateWebSocketHandler webSocketHandler;
private final GrayMemberService grayMemberService;
private final RnBundleAuthorizationService authorizationService;
private final AppReleaseSequenceService releaseSequenceService;
private final com.fasterxml.jackson.databind.ObjectMapper objectMapper;
public AppVersionController(AppVersionRepository versionRepository,
@ -56,6 +61,8 @@ public class AppVersionController {
UpdateTenantClient tenantClient,
UpdateWebSocketHandler webSocketHandler,
GrayMemberService grayMemberService,
RnBundleAuthorizationService authorizationService,
AppReleaseSequenceService releaseSequenceService,
com.fasterxml.jackson.databind.ObjectMapper objectMapper) {
this.versionRepository = versionRepository;
this.deviceRecordRepository = deviceRecordRepository;
@ -67,6 +74,8 @@ public class AppVersionController {
this.tenantClient = tenantClient;
this.webSocketHandler = webSocketHandler;
this.grayMemberService = grayMemberService;
this.authorizationService = authorizationService;
this.releaseSequenceService = releaseSequenceService;
this.objectMapper = objectMapper;
}
@ -74,7 +83,8 @@ public class AppVersionController {
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
@RequestParam(required = false) String appKey,
@RequestParam AppVersionEntity.Platform platform,
@RequestParam int currentVersionCode,
@RequestParam(required = false) Integer currentVersionCode,
@RequestParam(required = false) String currentBuildVersion,
@RequestParam(required = false) String userId,
@RequestParam(required = false) String deviceId,
@RequestParam(required = false) String model,
@ -83,9 +93,13 @@ public class AppVersionController {
@RequestParam(required = false) String currentVersionName) {
String resolvedAppKey = requireAppKey(appKey);
if (platform == AppVersionEntity.Platform.ANDROID && currentVersionCode == null) {
throw new IllegalArgumentException("Android 检查更新必须提供 currentVersionCode");
}
// 异步记录设备信息不影响响应性能
recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor, currentVersionCode, currentVersionName);
recordDevice(resolvedAppKey, platform.name(), deviceId, userId, model, osVersion, vendor,
currentVersionCode == null ? 0 : currentVersionCode, currentVersionName);
boolean serviceActivated = tenantClient.isUpdateServiceEnabled(resolvedAppKey, platform.name());
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey);
@ -93,32 +107,68 @@ public class AppVersionController {
return ResponseEntity.ok(ApiResponse.error(40404, "更新服务未开通"));
}
Optional<AppVersionEntity> latest = versionRepository
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
Optional<AppVersionEntity> forcedHigher = versionRepository
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
List<AppVersionEntity> candidates;
if (platform == AppVersionEntity.Platform.ANDROID) {
candidates = versionRepository
.findByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED,
currentVersionCode);
} else {
long currentReleaseSequence = hasText(currentBuildVersion)
? versionRepository
.findTopByAppKeyAndPlatformAndBuildVersionOrderByReleaseSequenceDesc(
resolvedAppKey, platform, currentBuildVersion)
.map(AppVersionEntity::getReleaseSequence)
.orElse(0L)
: 0L;
candidates = versionRepository
.findByAppKeyAndPlatformAndPublishStatusAndReleaseSequenceGreaterThanOrderByReleaseSequenceDesc(
resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED,
currentReleaseSequence);
if (currentReleaseSequence == 0 && !candidates.isEmpty()) {
AppVersionEntity latest = candidates.getFirst();
boolean alreadyLatest = hasText(currentVersionName)
&& currentVersionName.equals(latest.getVersionName())
&& (!hasText(currentBuildVersion)
|| currentBuildVersion.equals(latest.getBuildVersion()));
if (alreadyLatest) {
candidates = List.of();
}
}
}
if (latest.isEmpty()) {
if (candidates.isEmpty()) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
AppVersionEntity v = latest.get();
if (v.isGrayEnabled()) {
// 按版本号降序遍历跳过灰度版本中用户不在名单内的情况找到第一个用户可用的版本
AppVersionEntity v = null;
for (AppVersionEntity candidate : candidates) {
if (!candidate.isGrayEnabled()) {
v = candidate;
break;
}
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
continue;
}
if (userId != null && !userId.isBlank() && !isInGrayRelease(candidate, userId)) {
continue;
}
v = candidate;
break;
}
if (v == null) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
if (userId != null && !userId.isBlank()) {
boolean inGray = isInGrayRelease(v, userId);
if (!inGray) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
}
} else if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
}
AppVersionEntity selected = v;
boolean hasForceUpdate = platform == AppVersionEntity.Platform.ANDROID
? candidates.stream().anyMatch(c -> c.isForceUpdate()
&& c.getVersionCode() != null
&& c.getVersionCode() >= selected.getVersionCode())
: candidates.stream().anyMatch(c -> c.isForceUpdate()
&& c.getReleaseSequence() >= selected.getReleaseSequence());
String appStoreJumpUrl = hasText(v.getAppStoreUrl())
? v.getAppStoreUrl()
@ -130,9 +180,12 @@ public class AppVersionController {
response.put("needsUpdate", true);
response.put("versionName", v.getVersionName());
response.put("versionCode", v.getVersionCode());
response.put("buildVersion", v.getBuildVersion() == null ? "" : v.getBuildVersion());
response.put("releaseSequence", v.getReleaseSequence());
response.put("installMode", v.getInstallMode().name());
response.put("downloadUrl", v.getDownloadUrl() != null ? v.getDownloadUrl() : "");
response.put("changeLog", v.getChangeLog() != null ? v.getChangeLog() : "");
response.put("forceUpdate", forcedHigher.isPresent());
response.put("forceUpdate", hasForceUpdate);
response.put("appStoreUrl", appStoreJumpUrl);
response.put("marketUrl", harmonyJumpUrl);
if (v.getApkHash() != null && !v.getApkHash().isBlank()) {
@ -147,6 +200,8 @@ public class AppVersionController {
@RequestParam AppVersionEntity.Platform platform,
@RequestParam(required = false) String versionName,
@RequestParam(required = false) Integer versionCode,
@RequestParam(required = false) String buildVersion,
@RequestParam(required = false) String buildId,
@RequestParam(required = false) String changeLog,
@RequestParam(defaultValue = "false") boolean forceUpdate,
@RequestParam(required = false) String apkUrl,
@ -162,6 +217,8 @@ public class AppVersionController {
@RequestParam(required = false) String appStoreUrl,
@RequestParam(required = false) String marketUrl) throws Exception {
requireOwnership(requireAppKey(appKey));
AppPackageInspectResult inspected = null;
try {
inspected = hasText(apkUrl)
@ -174,8 +231,14 @@ public class AppVersionController {
String resolvedVersionName = hasText(versionName) ? versionName : (inspected != null ? inspected.versionName() : null);
Integer resolvedVersionCode = versionCode != null ? versionCode : (inspected != null ? inspected.versionCode() : null);
String resolvedPackageName = hasText(packageName) ? packageName : (inspected != null ? inspected.packageName() : null);
if (!hasText(resolvedVersionName) || resolvedVersionCode == null) {
throw new IllegalArgumentException("versionName and versionCode are required or must be readable from the uploaded package");
String resolvedBuildVersion = hasText(buildVersion)
? buildVersion.trim()
: (resolvedVersionCode == null ? null : String.valueOf(resolvedVersionCode));
if (!hasText(resolvedVersionName)
|| (platform == AppVersionEntity.Platform.ANDROID && resolvedVersionCode == null)
|| (platform != AppVersionEntity.Platform.ANDROID && !hasText(resolvedBuildVersion))) {
throw new IllegalArgumentException(
"versionName 和平台原生版本标识不能为空;Android 使用 versionCode,iOS/Harmony 使用 buildVersion");
}
if (hasText(expectedPackageName) && hasText(resolvedPackageName) && !expectedPackageName.equals(resolvedPackageName)) {
throw new IllegalArgumentException("packageName does not match current app packageName");
@ -188,7 +251,15 @@ public class AppVersionController {
entity.setAppKey(appKey);
entity.setPlatform(platform);
entity.setVersionName(resolvedVersionName);
entity.setVersionCode(resolvedVersionCode);
entity.setVersionCode(platform == AppVersionEntity.Platform.ANDROID
? resolvedVersionCode : null);
entity.setBuildVersion(resolvedBuildVersion);
entity.setReleaseSequence(releaseSequenceService.next(appKey, platform));
entity.setUpdateSource(AppVersionEntity.UpdateSource.UPLOADED_PACKAGE);
entity.setInstallMode(platform == AppVersionEntity.Platform.ANDROID
? AppVersionEntity.InstallMode.DIRECT_PACKAGE
: AppVersionEntity.InstallMode.STORE_REDIRECT);
entity.setBuildId(hasText(buildId) ? buildId.trim() : UUID.randomUUID().toString());
if (platform == AppVersionEntity.Platform.ANDROID) {
if (hasText(apkUrl)) {
entity.setDownloadUrl(apkUrl);
@ -238,20 +309,23 @@ public class AppVersionController {
entity.setGrayPercent(0);
}
AppVersionEntity saved = versionRepository.save(entity);
Map<String, Object> uploadDetails = new java.util.LinkedHashMap<>();
uploadDetails.put("platform", saved.getPlatform().name());
uploadDetails.put("versionName", saved.getVersionName());
uploadDetails.put("versionCode", saved.getVersionCode());
uploadDetails.put("buildVersion", saved.getBuildVersion());
uploadDetails.put("releaseSequence", saved.getReleaseSequence());
uploadDetails.put("buildId", saved.getBuildId());
uploadDetails.put("publishImmediately", publishImmediately);
uploadDetails.put("forceUpdate", saved.isForceUpdate());
uploadDetails.put("packageName", saved.getPackageName() == null ? "" : saved.getPackageName());
operationLogService.record(
saved.getAppKey(),
"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()
));
uploadDetails);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@ -270,6 +344,7 @@ public class AppVersionController {
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
requireOwnership(entity.getAppKey());
boolean publishImmediately = body == null || !Boolean.FALSE.equals(body.get("publishImmediately"));
String scheduledPublishAt = body != null && body.get("scheduledPublishAt") != null
? body.get("scheduledPublishAt").toString() : null;
@ -304,19 +379,18 @@ public class AppVersionController {
entity.setGrayGroupNames(null);
entity.setExtraMemberIds(null);
AppVersionEntity saved = versionRepository.save(entity);
Map<String, Object> publishDetails = versionIdentity(saved);
publishDetails.put("publishImmediately", publishImmediately);
publishDetails.put("scheduledPublishAt",
saved.getScheduledPublishAt() == null ? "" : saved.getScheduledPublishAt().toString());
publishDetails.put("forceUpdate", saved.isForceUpdate());
operationLogService.record(
saved.getAppKey(),
"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()
));
publishDetails);
// 发布成功后发送 WebSocket 实时通知
if (saved.getPublishStatus() == AppVersionEntity.PublishStatus.PUBLISHED) {
notifyClientsIfEnabled(saved);
@ -329,6 +403,7 @@ public class AppVersionController {
@PathVariable String id,
@RequestBody(required = false) Map<String, Object> body) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
requireOwnership(entity.getAppKey());
String reason = body != null && body.get("reason") != null ? body.get("reason").toString().trim() : "";
if (reason.isBlank()) {
throw new IllegalArgumentException("unpublish reason is required");
@ -341,16 +416,14 @@ public class AppVersionController {
saved.getId(),
"UNPUBLISH",
reason,
Map.of(
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode()
));
versionIdentity(saved));
return ResponseEntity.ok(ApiResponse.success(saved));
}
@DeleteMapping("/app/{id}")
public ResponseEntity<ApiResponse<Void>> deleteVersion(@PathVariable String id) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
requireOwnership(entity.getAppKey());
if (entity.getPublishStatus() != AppVersionEntity.PublishStatus.DRAFT) {
throw new IllegalArgumentException("已发布或已下架的版本不允许删除,请先下架");
}
@ -365,10 +438,7 @@ public class AppVersionController {
id,
"DELETE",
null,
Map.of(
"versionName", entity.getVersionName(),
"versionCode", entity.getVersionCode()
));
versionIdentity(entity));
return ResponseEntity.ok(ApiResponse.success(null));
}
@ -398,6 +468,7 @@ public class AppVersionController {
@PathVariable String id,
@RequestBody Map<String, Object> body) throws Exception {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
requireOwnership(entity.getAppKey());
if (publishConfigService.allowAnonymousUpdateCheck(entity.getAppKey())) {
throw new com.xuqm.common.exception.BusinessException(400, "允许免登录检查更新的应用不支持灰度发布");
}
@ -484,7 +555,7 @@ public class AppVersionController {
private boolean isInGrayRelease(AppVersionEntity v, String userId) {
return switch (v.getGrayMode()) {
case PERCENT -> Math.abs(userId.hashCode()) % 100 < v.getGrayPercent();
case PERCENT -> Math.floorMod(userId.hashCode(), 100) < v.getGrayPercent();
case MEMBERS -> {
if (v.getGrayMemberIds() == null) yield false;
try {
@ -503,23 +574,22 @@ public class AppVersionController {
@PathVariable String id,
@RequestBody Map<String, Object> body) {
AppVersionEntity entity = versionRepository.findById(id).orElseThrow();
requireOwnership(entity.getAppKey());
String oldChangeLog = entity.getChangeLog();
Object raw = body.get("changeLog");
String newChangeLog = raw != null && !raw.toString().isBlank() ? raw.toString().trim() : null;
entity.setChangeLog(newChangeLog);
AppVersionEntity saved = versionRepository.save(entity);
Map<String, Object> changeLogDetails = versionIdentity(saved);
changeLogDetails.put("before", oldChangeLog != null ? oldChangeLog : "");
changeLogDetails.put("after", newChangeLog != null ? newChangeLog : "");
operationLogService.record(
saved.getAppKey(),
"APP_VERSION",
saved.getId(),
"CHANGELOG_UPDATE",
null,
Map.of(
"versionName", saved.getVersionName(),
"versionCode", saved.getVersionCode(),
"before", oldChangeLog != null ? oldChangeLog : "",
"after", newChangeLog != null ? newChangeLog : ""
));
changeLogDetails);
return ResponseEntity.ok(ApiResponse.success(saved));
}
@ -552,10 +622,25 @@ public class AppVersionController {
return appKey;
}
private void requireOwnership(String appKey) {
authorizationService.requireOwned(
SecurityContextHolder.getContext().getAuthentication(), appKey);
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private Map<String, Object> versionIdentity(AppVersionEntity version) {
Map<String, Object> identity = new java.util.LinkedHashMap<>();
identity.put("versionName", version.getVersionName());
identity.put("versionCode", version.getVersionCode());
identity.put("buildVersion", version.getBuildVersion());
identity.put("releaseSequence", version.getReleaseSequence());
identity.put("buildId", version.getBuildId());
return identity;
}
/**
* 如果发布配置中启用了实时通知 WebSocket 客户端推送轻量通知
* 不发送版本详情由客户端 SDK 收到通知后自动调用 checkUpdate 接口获取最新信息

查看文件

@ -80,7 +80,7 @@ public class PublicDownloadController {
}
private Map<String, Object> androidInfo(String appKey) {
List<AppVersionEntity> all = versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc(
List<AppVersionEntity> all = versionRepository.findByAppKeyAndPlatformOrderByReleaseSequenceDesc(
appKey, AppVersionEntity.Platform.ANDROID);
List<AppVersionEntity> versions = all.stream().filter(this::everReleased).toList();
if (versions.isEmpty()) {
@ -130,18 +130,21 @@ public class PublicDownloadController {
private Map<String, Object> storeOnlyInfo(String appKey, AppVersionEntity.Platform platform,
AppStoreConfigEntity.StoreType storeType) {
String storeUrl = appStoreService.getStoreJumpUrl(appKey, storeType);
if (storeUrl == null || storeUrl.isBlank()) {
return null;
}
List<AppVersionEntity> all = versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform);
List<AppVersionEntity> all = versionRepository.findByAppKeyAndPlatformOrderByReleaseSequenceDesc(appKey, platform);
AppVersionEntity latest = all.stream().filter(this::everReleased).findFirst().orElse(null);
if (latest == null) {
return null;
}
String storeUrl = platform == AppVersionEntity.Platform.IOS
? latest.getAppStoreUrl() : latest.getMarketUrl();
if (storeUrl == null || storeUrl.isBlank()) {
storeUrl = appStoreService.getStoreJumpUrl(appKey, storeType);
}
if (storeUrl == null || storeUrl.isBlank()) return null;
Map<String, Object> info = new LinkedHashMap<>();
info.put("versionName", latest.getVersionName());
info.put("versionCode", latest.getVersionCode());
info.put("buildVersion", latest.getBuildVersion());
info.put("releaseSequence", latest.getReleaseSequence());
info.put("storeUrl", storeUrl);
info.put("publishedAt", latest.getCreatedAt() != null ? latest.getCreatedAt().toString() : "");
return info;

查看文件

@ -8,6 +8,7 @@ import com.xuqm.update.model.UnifiedReleaseResult;
import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.service.UpdateAssetService;
import com.xuqm.update.service.UpdateOperationLogService;
import com.xuqm.update.service.AppReleaseSequenceService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
@ -31,16 +32,19 @@ public class UnifiedReleaseController {
private final AppVersionRepository appVersionRepository;
private final UpdateAssetService updateAssetService;
private final UpdateOperationLogService operationLogService;
private final AppReleaseSequenceService releaseSequenceService;
public UnifiedReleaseController(
ObjectMapper objectMapper,
AppVersionRepository appVersionRepository,
UpdateAssetService updateAssetService,
UpdateOperationLogService operationLogService) {
UpdateOperationLogService operationLogService,
AppReleaseSequenceService releaseSequenceService) {
this.objectMapper = objectMapper;
this.appVersionRepository = appVersionRepository;
this.updateAssetService = updateAssetService;
this.operationLogService = operationLogService;
this.releaseSequenceService = releaseSequenceService;
}
@PostMapping("/unified/upload")
@ -67,7 +71,15 @@ public class UnifiedReleaseController {
entity.setAppKey(appKey);
entity.setPlatform(item.platform());
entity.setVersionName(item.versionName());
entity.setVersionCode(item.versionCode());
entity.setVersionCode(item.platform() == AppVersionEntity.Platform.ANDROID
? item.versionCode() : null);
entity.setBuildVersion(String.valueOf(item.versionCode()));
entity.setReleaseSequence(releaseSequenceService.next(appKey, item.platform()));
entity.setUpdateSource(AppVersionEntity.UpdateSource.UPLOADED_PACKAGE);
entity.setInstallMode(item.platform() == AppVersionEntity.Platform.ANDROID
? AppVersionEntity.InstallMode.DIRECT_PACKAGE
: AppVersionEntity.InstallMode.STORE_REDIRECT);
entity.setBuildId(UUID.randomUUID().toString());
entity.setChangeLog(item.changeLog());
entity.setForceUpdate(item.forceUpdate());
entity.setPackageName(item.packageName());
@ -86,20 +98,23 @@ public class UnifiedReleaseController {
entity.setGrayPercent(0);
}
AppVersionEntity saved = appVersionRepository.save(entity);
Map<String, Object> uploadDetails = new java.util.LinkedHashMap<>();
uploadDetails.put("platform", saved.getPlatform().name());
uploadDetails.put("versionName", saved.getVersionName());
uploadDetails.put("versionCode", saved.getVersionCode());
uploadDetails.put("buildVersion", saved.getBuildVersion());
uploadDetails.put("releaseSequence", saved.getReleaseSequence());
uploadDetails.put("buildId", saved.getBuildId());
uploadDetails.put("publishImmediately", item.publishImmediately());
uploadDetails.put("forceUpdate", saved.isForceUpdate());
uploadDetails.put("packageName", saved.getPackageName() == null ? "" : saved.getPackageName());
operationLogService.record(
saved.getAppKey(),
"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()
));
uploadDetails);
appVersions.add(saved);
}

查看文件

@ -14,6 +14,8 @@ public class AppVersionEntity {
public enum Platform { ANDROID, IOS, HARMONY }
public enum PublishStatus { DRAFT, PUBLISHED, DEPRECATED }
public enum UpdateSource { UPLOADED_PACKAGE, EXTERNAL_STORE }
public enum InstallMode { DIRECT_PACKAGE, STORE_REDIRECT }
/** Per-store review state used in storeReviewStatus JSON values. */
public enum StoreReviewState { PENDING, SUBMITTING, UNDER_REVIEW, APPROVED, REJECTED, WITHDRAWN }
/**
@ -36,8 +38,30 @@ public class AppVersionEntity {
@Column(nullable = false, length = 32)
private String versionName;
/**
* Android 原生 versionCodeiOS/Harmony 不得伪造该值使用 buildVersion
*/
private Integer versionCode;
/** iOS CFBundleVersion 或 Harmony versionCode 的原始字符串表示。 */
@Column(length = 64)
private String buildVersion;
/** 服务端分配的单调发布顺序,只用于排序,不替代平台原生版本标识。 */
@Column(nullable = false)
private int versionCode;
private long releaseSequence;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private UpdateSource updateSource = UpdateSource.UPLOADED_PACKAGE;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private InstallMode installMode = InstallMode.DIRECT_PACKAGE;
/** 同一业务版本的不同安装包构建标识;每次重新打包必须变化。 */
@Column(length = 128)
private String buildId;
@Column(length = 512)
private String downloadUrl;
@ -151,8 +175,18 @@ public class AppVersionEntity {
public String getVersionName() { return versionName; }
public void setVersionName(String versionName) { this.versionName = versionName; }
public int getVersionCode() { return versionCode; }
public void setVersionCode(int versionCode) { this.versionCode = versionCode; }
public Integer getVersionCode() { return versionCode; }
public void setVersionCode(Integer versionCode) { this.versionCode = versionCode; }
public String getBuildVersion() { return buildVersion; }
public void setBuildVersion(String buildVersion) { this.buildVersion = buildVersion; }
public long getReleaseSequence() { return releaseSequence; }
public void setReleaseSequence(long releaseSequence) { this.releaseSequence = releaseSequence; }
public UpdateSource getUpdateSource() { return updateSource; }
public void setUpdateSource(UpdateSource updateSource) { this.updateSource = updateSource; }
public InstallMode getInstallMode() { return installMode; }
public void setInstallMode(InstallMode installMode) { this.installMode = installMode; }
public String getBuildId() { return buildId; }
public void setBuildId(String buildId) { this.buildId = buildId; }
public String getDownloadUrl() { return downloadUrl; }
public void setDownloadUrl(String downloadUrl) { this.downloadUrl = downloadUrl; }

查看文件

@ -9,12 +9,32 @@ import java.util.List;
import java.util.Optional;
public interface AppVersionRepository extends JpaRepository<AppVersionEntity, String> {
List<AppVersionEntity> findByAppKeyAndPlatformOrderByVersionCodeDesc(
List<AppVersionEntity> findByAppKeyAndPlatformOrderByReleaseSequenceDesc(
String appKey, AppVersionEntity.Platform platform);
List<AppVersionEntity> findByAppKeyAndPlatformOrderByCreatedAtDesc(
String appKey, AppVersionEntity.Platform platform);
Optional<AppVersionEntity> findTopByAppKeyAndPlatformAndPublishStatusOrderByVersionCodeDesc(
List<AppVersionEntity>
findByAppKeyAndPlatformAndPublishStatusAndReleaseSequenceGreaterThanOrderByReleaseSequenceDesc(
String appKey,
AppVersionEntity.Platform platform,
AppVersionEntity.PublishStatus status,
long releaseSequence);
Optional<AppVersionEntity>
findTopByAppKeyAndPlatformAndBuildVersionOrderByReleaseSequenceDesc(
String appKey,
AppVersionEntity.Platform platform,
String buildVersion);
@Query("""
select coalesce(max(version.releaseSequence), 0)
from AppVersionEntity version
where version.appKey = :appKey and version.platform = :platform
""")
long findMaxReleaseSequence(String appKey, AppVersionEntity.Platform platform);
Optional<AppVersionEntity> findTopByAppKeyAndPlatformAndPublishStatusOrderByReleaseSequenceDesc(
String appKey, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status);
Optional<AppVersionEntity> findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
String appKey,
@ -22,6 +42,12 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
AppVersionEntity.PublishStatus status,
int versionCode);
List<AppVersionEntity> findByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
String appKey,
AppVersionEntity.Platform platform,
AppVersionEntity.PublishStatus status,
int versionCode);
Optional<AppVersionEntity> findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
String appKey,
AppVersionEntity.Platform platform,
@ -57,7 +83,7 @@ public interface AppVersionRepository extends JpaRepository<AppVersionEntity, St
String appKey, AppVersionEntity.Platform platform, String packageName, int versionCode,
AppVersionEntity.PublishStatus publishStatus);
List<AppVersionEntity> findByAppKeyAndPlatformAndPublishStatusInOrderByVersionCodeDesc(
List<AppVersionEntity> findByAppKeyAndPlatformAndPublishStatusInOrderByReleaseSequenceDesc(
String appKey, AppVersionEntity.Platform platform, List<AppVersionEntity.PublishStatus> statuses);
@Query(value = "SELECT * FROM update_app_version WHERE download_url LIKE 'https://file.dev.xuqinmin.com%'", nativeQuery = true)

查看文件

@ -0,0 +1,47 @@
package com.xuqm.update.service;
import com.xuqm.update.entity.AppVersionEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
/**
* App 发布序列的唯一分配器
*
* <p>平台原生构建号可能不是数字不能承担跨版本排序序列只由数据库原子分配</p>
*/
@Service
public class AppReleaseSequenceService {
private final JdbcTemplate jdbcTemplate;
public AppReleaseSequenceService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public long next(String appKey, AppVersionEntity.Platform platform) {
String id = appKey + ":" + platform.name();
jdbcTemplate.update("""
INSERT INTO update_app_release_sequence
(id, app_key, platform, current_value, updated_at)
SELECT ?, ?, ?, COALESCE(MAX(release_sequence), 0) + 1, ?
FROM update_app_version
WHERE app_key = ? AND platform = ?
ON DUPLICATE KEY UPDATE
current_value = current_value + 1,
updated_at = VALUES(updated_at)
""",
id, appKey, platform.name(),
Timestamp.valueOf(LocalDateTime.now(ZoneOffset.UTC)),
appKey, platform.name());
Long value = jdbcTemplate.queryForObject(
"SELECT current_value FROM update_app_release_sequence WHERE id = ?",
Long.class, id);
if (value == null) throw new IllegalStateException("无法分配 App 发布序列");
return value;
}
}

查看文件

@ -45,7 +45,6 @@ public class AppStoreService {
private final AppVersionRepository versionRepo;
private final RnBundleRepository rnBundleRepository;
private final UpdateOperationLogService operationLogService;
private final StoreReviewImNotifier storeReviewImNotifier;
private final UpdateWebSocketHandler webSocketHandler;
private final PublishConfigService publishConfigService;
private final ConcurrentMap<String, Object> versionLocks = new ConcurrentHashMap<>();
@ -54,14 +53,12 @@ public class AppStoreService {
AppVersionRepository versionRepo,
RnBundleRepository rnBundleRepository,
UpdateOperationLogService operationLogService,
StoreReviewImNotifier storeReviewImNotifier,
UpdateWebSocketHandler webSocketHandler,
PublishConfigService publishConfigService) {
this.configRepo = configRepo;
this.versionRepo = versionRepo;
this.rnBundleRepository = rnBundleRepository;
this.operationLogService = operationLogService;
this.storeReviewImNotifier = storeReviewImNotifier;
this.webSocketHandler = webSocketHandler;
this.publishConfigService = publishConfigService;
}
@ -142,6 +139,11 @@ public class AppStoreService {
LocalDateTime scheduledPublishAt) throws Exception {
synchronized (lockFor(versionId)) {
AppVersionEntity v = versionRepo.findById(versionId).orElseThrow();
if (v.getPlatform() != AppVersionEntity.Platform.ANDROID
|| v.getVersionCode() == null) {
throw new IllegalArgumentException(
"当前迭代仅支持 Android 应用商店提交;iOS/HarmonyOS 只提供官方状态监测");
}
List<String> resolvedTargets = normalizeTargets(v.getAppKey(), storeTypes);
String normalizedMode = submitMode == null || submitMode.isBlank()
? "MANUAL"
@ -205,16 +207,6 @@ public class AppStoreService {
"STORE_SUBMIT_REQUEST",
null,
submitLogDetails);
storeReviewImNotifier.notifyStoreReviewChange(
saved.getAppKey(),
saved.getId(),
null,
AppVersionEntity.StoreReviewState.PENDING.name(),
null,
"QUEUED",
null,
saved.getPublishStatus().name(),
"store_submit_requested");
return saved;
}
}
@ -338,16 +330,6 @@ public class AppStoreService {
"publishStatus", saved.getPublishStatus().name()
));
sendWebhook(saved, storeType, state, reason);
storeReviewImNotifier.notifyStoreReviewChange(
saved.getAppKey(),
saved.getId(),
storeType,
state.name(),
reason,
stage,
batchId,
saved.getPublishStatus().name(),
"store_review_changed");
return saved;
}
}
@ -410,11 +392,6 @@ public class AppStoreService {
"preExisting", preExisting,
"publishStatus", saved.getPublishStatus().name()));
sendWebhook(saved, storeType, AppVersionEntity.StoreReviewState.APPROVED, reason);
storeReviewImNotifier.notifyStoreReviewChange(
saved.getAppKey(), saved.getId(),
storeType, AppVersionEntity.StoreReviewState.APPROVED.name(),
reason, "APPROVED", batchId, saved.getPublishStatus().name(),
"store_live_detected");
return saved;
}
}
@ -454,16 +431,6 @@ public class AppStoreService {
"batchId", batchId,
"reviewState", AppVersionEntity.StoreReviewState.SUBMITTING.name()
));
storeReviewImNotifier.notifyStoreReviewChange(
saved.getAppKey(),
saved.getId(),
storeType,
AppVersionEntity.StoreReviewState.SUBMITTING.name(),
reason,
stage,
batchId,
saved.getPublishStatus().name(),
"store_submission_stage");
return saved;
}
}
@ -811,7 +778,7 @@ public class AppStoreService {
int currentVersionCode,
List<String> storeTypes) throws Exception {
Set<String> storeSet = new HashSet<>(storeTypes);
List<AppVersionEntity> others = versionRepo.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform);
List<AppVersionEntity> others = versionRepo.findByAppKeyAndPlatformOrderByReleaseSequenceDesc(appKey, platform);
for (AppVersionEntity other : others) {
if (other.getId().equals(currentVersionId)) continue;
if (other.getVersionCode() >= currentVersionCode) continue;
@ -853,7 +820,7 @@ public class AppStoreService {
int currentVersionCode,
List<String> storeTypes) throws Exception {
Set<String> storeSet = new HashSet<>(storeTypes);
List<AppVersionEntity> others = versionRepo.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform);
List<AppVersionEntity> others = versionRepo.findByAppKeyAndPlatformOrderByReleaseSequenceDesc(appKey, platform);
for (AppVersionEntity other : others) {
if (other.getId().equals(currentVersionId)) continue;
if (other.getVersionCode() <= currentVersionCode) continue;

查看文件

@ -1,86 +0,0 @@
package com.xuqm.update.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.LinkedHashMap;
import java.util.Map;
@Service
public class StoreReviewImNotifier {
private static final Logger log = LoggerFactory.getLogger(StoreReviewImNotifier.class);
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}")
private String tenantServiceUrl;
@Value("${sdk.internal-token:xuqm-internal-token}")
private String internalToken;
public void notifyStoreReviewChange(String appKey,
String versionId,
String storeType,
String reviewState,
String reviewReason,
String stage,
String batchId,
String publishStatus,
String event) {
try {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("appKey", appKey);
payload.put("versionId", versionId == null ? "" : versionId);
payload.put("storeType", storeType == null ? "" : storeType);
payload.put("reviewState", reviewState == null ? "" : reviewState);
payload.put("reviewReason", reviewReason == null ? "" : reviewReason);
payload.put("stage", stage == null ? "" : stage);
payload.put("batchId", batchId == null ? "" : batchId);
payload.put("publishStatus", publishStatus == null ? "" : publishStatus);
payload.put("event", "store_review_update");
payload.put("source", event == null || event.isBlank() ? "update-service" : event);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(trimTrailingSlash(tenantServiceUrl) + "/api/internal/im/platform-events/notify"))
.header("Content-Type", "application/json")
.header("X-Internal-Token", internalToken)
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(payload)))
.build();
log.info("IM platform event notify request appKey={} versionId={} storeType={} state={} stage={} batchId={}",
appKey, versionId, storeType, reviewState, stage, batchId);
httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenAccept(response -> {
if (response.statusCode() >= 400) {
log.warn("IM platform event notify failed appKey={} status={} body={}",
appKey, response.statusCode(), response.body());
} else {
log.info("IM platform event notify delivered appKey={} status={} body={}",
appKey, response.statusCode(), response.body());
}
})
.exceptionally(e -> {
log.warn("IM platform event notify error appKey={} msg={}", appKey, e.getMessage());
return null;
});
} catch (Exception e) {
log.warn("IM platform event notify build failed appKey={} msg={}", appKey, e.getMessage());
}
}
private String trimTrailingSlash(String value) {
if (value == null) {
return "";
}
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
}
}

查看文件

@ -0,0 +1,49 @@
package com.xuqm.update.store.adapter;
import io.jsonwebtoken.Jwts;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
/** 生成符合 JOSE ES256 签名格式的 App Store Connect 访问令牌。 */
final class AppStoreConnectTokenFactory {
private AppStoreConnectTokenFactory() {}
static String create(String issuerId, String keyId, String privateKeyPem) {
try {
PrivateKey privateKey = parsePrivateKey(privateKeyPem);
Instant now = Instant.now();
return Jwts.builder()
.header().keyId(requireText(keyId, "keyId")).and()
.issuer(requireText(issuerId, "issuerId"))
.audience().add("appstoreconnect-v1").and()
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(20 * 60)))
.signWith(privateKey, Jwts.SIG.ES256)
.compact();
} catch (Exception e) {
throw new IllegalArgumentException("invalid App Store Connect credential", e);
}
}
private static PrivateKey parsePrivateKey(String pem) throws Exception {
String normalized = requireText(pem, "privateKey")
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(normalized);
return KeyFactory.getInstance("EC")
.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value;
}
}

查看文件

@ -0,0 +1,170 @@
package com.xuqm.update.store.adapter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.store.model.StoreInventorySnapshot;
import com.xuqm.update.store.model.StoreMonitorRequest;
import com.xuqm.update.store.model.StoreVersionSnapshot;
import com.xuqm.update.store.service.StoreStateMapper;
import com.xuqm.update.store.spi.StoreInventoryAdapter;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
/** App Store Connect 的只读版本库存适配器。 */
@Component
public class AppleStoreInventoryAdapter implements StoreInventoryAdapter {
private static final String API = "https://api.appstoreconnect.apple.com";
private static final String CHINA_TERRITORY = "CHN";
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public AppleStoreInventoryAdapter(RestTemplateBuilder builder, ObjectMapper objectMapper) {
this.restTemplate = builder
.connectTimeout(Duration.ofSeconds(15))
.readTimeout(Duration.ofSeconds(30))
.build();
this.objectMapper = objectMapper;
}
@Override
public AppStoreConfigEntity.StoreType storeType() {
return AppStoreConfigEntity.StoreType.APP_STORE;
}
@Override
public StoreInventorySnapshot fetch(StoreMonitorRequest request) {
Map<String, String> credential = request.credential();
String token = AppStoreConnectTokenFactory.create(
required(credential, "issuerId"),
required(credential, "keyId"),
required(credential, "privateKey"));
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<Void> entity = new HttpEntity<>(headers);
String appLookupUrl = UriComponentsBuilder.fromHttpUrl(API + "/v1/apps")
.queryParam("filter[bundleId]", request.packageIdentifier())
.build().encode().toUriString();
JsonNode appRoot = getJson(appLookupUrl, entity);
JsonNode appData = appRoot.path("data");
if (!appData.isArray() || appData.isEmpty()) {
throw new IllegalStateException(
"App Store Connect 未找到 bundleId=" + request.packageIdentifier());
}
String officialAppId = appData.get(0).path("id").asText();
boolean chinaAvailable = fetchChinaAvailability(officialAppId, entity);
String versionsUrl = UriComponentsBuilder
.fromHttpUrl(API + "/v1/apps/" + officialAppId + "/appStoreVersions")
.queryParam("limit", 20)
.queryParam("sort", "-version")
.queryParam("include", "build")
.queryParam("fields[appStoreVersions]", "versionString,appStoreState,build")
.queryParam("fields[builds]", "version,uploadedDate")
.build().encode().toUriString();
JsonNode versionsRoot = getJson(versionsUrl, entity);
return parseInventory(officialAppId, versionsRoot, chinaAvailable);
}
StoreInventorySnapshot parseInventory(String officialAppId,
JsonNode root,
boolean chinaAvailable) {
Map<String, JsonNode> builds = new HashMap<>();
for (JsonNode included : root.path("included")) {
if ("builds".equals(included.path("type").asText())) {
builds.put(included.path("id").asText(), included);
}
}
List<StoreVersionSnapshot> versions = new ArrayList<>();
for (JsonNode version : root.path("data")) {
JsonNode attributes = version.path("attributes");
String rawState = attributes.path("appStoreState").asText("");
String versionName = attributes.path("versionString").asText("");
if (versionName.isBlank()) {
continue;
}
String buildId = version.path("relationships").path("build")
.path("data").path("id").asText("");
JsonNode build = builds.get(buildId);
String buildVersion = build == null
? ""
: build.path("attributes").path("version").asText("");
boolean versionAvailable = chinaAvailable
&& Set.of("READY_FOR_DISTRIBUTION", "READY_FOR_SALE").contains(rawState);
Map<String, Object> audit = new LinkedHashMap<>();
audit.put("platform", "IOS");
audit.put("targetRegion", CHINA_TERRITORY);
audit.put("buildId", buildId);
versions.add(new StoreVersionSnapshot(
version.path("id").asText(),
versionName,
buildVersion,
rawState,
StoreStateMapper.apple(rawState, versionAvailable),
versionAvailable,
parseInstant(attributes.path("createdDate").asText(null)),
audit));
}
return new StoreInventorySnapshot(officialAppId, Instant.now(), versions);
}
private boolean fetchChinaAvailability(String appId, HttpEntity<Void> entity) {
String url = UriComponentsBuilder
.fromHttpUrl(API + "/v1/apps/" + appId + "/appAvailabilityV2")
.queryParam("include", "territoryAvailabilities")
.queryParam("fields[territoryAvailabilities]",
"available,releaseDate,contentStatuses,territory")
.queryParam("limit[territoryAvailabilities]", 50)
.build().encode().toUriString();
JsonNode root = getJson(url, entity);
for (JsonNode item : root.path("included")) {
if (!"territoryAvailabilities".equals(item.path("type").asText())) {
continue;
}
String territoryId = item.path("relationships").path("territory")
.path("data").path("id").asText("");
if (CHINA_TERRITORY.equals(territoryId)) {
return item.path("attributes").path("available").asBoolean(false);
}
}
return false;
}
private JsonNode getJson(String url, HttpEntity<Void> entity) {
String body = restTemplate.exchange(url, HttpMethod.GET, entity, String.class).getBody();
try {
return objectMapper.readTree(Objects.requireNonNull(body, "empty App Store response"));
} catch (Exception e) {
throw new IllegalStateException("invalid App Store Connect response", e);
}
}
private String required(Map<String, String> values, String key) {
String value = values.get(key);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("App Store monitor credential missing: " + key);
}
return value;
}
private Instant parseInstant(String value) {
if (value == null || value.isBlank()) return null;
try {
return Instant.parse(value);
} catch (Exception ignored) {
return null;
}
}
}

查看文件

@ -0,0 +1,149 @@
package com.xuqm.update.store.adapter;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.store.model.StoreInventorySnapshot;
import com.xuqm.update.store.model.StoreMonitorRequest;
import com.xuqm.update.store.model.StoreVersionSnapshot;
import com.xuqm.update.store.service.StoreStateMapper;
import com.xuqm.update.store.spi.StoreInventoryAdapter;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
/** 华为 AppGallery Connect 中 HarmonyOS 应用的只读库存适配器。 */
@Component
public class HarmonyStoreInventoryAdapter implements StoreInventoryAdapter {
private static final String TOKEN_URL =
"https://connect-api.cloud.huawei.com/api/oauth2/v1/token";
private static final String APP_INFO_URL =
"https://connect-api.cloud.huawei.com/api/publish/v2/app-info";
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public HarmonyStoreInventoryAdapter(RestTemplateBuilder builder, ObjectMapper objectMapper) {
this.restTemplate = builder
.connectTimeout(Duration.ofSeconds(15))
.readTimeout(Duration.ofSeconds(30))
.build();
this.objectMapper = objectMapper;
}
@Override
public AppStoreConfigEntity.StoreType storeType() {
return AppStoreConfigEntity.StoreType.HARMONY_APP;
}
@Override
public StoreInventorySnapshot fetch(StoreMonitorRequest request) {
Map<String, String> credential = request.credential();
String clientId = required(credential, "clientId");
String token = fetchToken(clientId, required(credential, "clientSecret"));
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
headers.set("client_id", clientId);
String url = UriComponentsBuilder.fromHttpUrl(APP_INFO_URL)
.queryParam("packageName", request.packageIdentifier())
.build().encode().toUriString();
String body = restTemplate.exchange(
url, HttpMethod.GET, new HttpEntity<Void>(headers), String.class).getBody();
try {
return parseInventory(objectMapper.readTree(
Objects.requireNonNull(body, "empty AppGallery response")));
} catch (Exception e) {
throw new IllegalStateException("invalid AppGallery response", e);
}
}
StoreInventorySnapshot parseInventory(JsonNode root) {
JsonNode ret = root.path("ret");
if (!ret.isMissingNode() && ret.path("code").asInt(0) != 0) {
throw new IllegalStateException("AppGallery 查询失败: "
+ ret.path("msg").asText("unknown error"));
}
JsonNode appInfo = root.path("appInfo");
String appId = firstText(appInfo, "appId", "appid", "id");
List<StoreVersionSnapshot> versions = new ArrayList<>();
String onlineCode = firstText(appInfo,
"onShelfVersionCode", "onlineVersionCode");
String onlineName = firstText(appInfo,
"onShelfVersionName", "onlineVersionName");
if (!onlineName.isBlank() || !onlineCode.isBlank()) {
String versionName = onlineName.isBlank() ? onlineCode : onlineName;
versions.add(new StoreVersionSnapshot(
"harmony-online-" + (onlineCode.isBlank() ? versionName : onlineCode),
versionName,
onlineCode,
"ON_SHELF",
StoreStateMapper.harmony("ON_SHELF", true),
true,
null,
Map.of("platform", "HARMONY", "targetRegion", "CHN")));
}
String pendingCode = firstText(appInfo,
"versionCode", "pendingVersionCode", "reviewVersionCode");
String pendingName = firstText(appInfo,
"versionName", "pendingVersionName", "reviewVersionName");
String releaseState = firstText(appInfo, "releaseState", "status");
boolean sameAsOnline = !pendingCode.isBlank() && pendingCode.equals(onlineCode);
if ((!pendingName.isBlank() || !pendingCode.isBlank()) && !sameAsOnline) {
String versionName = pendingName.isBlank() ? pendingCode : pendingName;
versions.add(new StoreVersionSnapshot(
"harmony-review-" + (pendingCode.isBlank() ? versionName : pendingCode),
versionName,
pendingCode,
releaseState,
StoreStateMapper.harmony(releaseState, false),
false,
null,
Map.of("platform", "HARMONY", "targetRegion", "CHN")));
}
return new StoreInventorySnapshot(appId, Instant.now(), versions);
}
private String fetchToken(String clientId, String clientSecret) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, String> request = Map.of(
"grant_type", "client_credentials",
"client_id", clientId,
"client_secret", clientSecret);
Map<?, ?> body = restTemplate.postForObject(
TOKEN_URL, new HttpEntity<>(request, headers), Map.class);
Object token = body == null ? null : body.get("access_token");
if (token == null || token.toString().isBlank()) {
throw new IllegalStateException("AppGallery token request failed");
}
return token.toString();
}
private String required(Map<String, String> values, String key) {
String value = values.get(key);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Harmony monitor credential missing: " + key);
}
return value;
}
private String firstText(JsonNode node, String... names) {
for (String name : names) {
JsonNode value = node.path(name);
if (!value.isMissingNode() && !value.isNull()) {
String text = value.asText("");
if (!text.isBlank()) return text;
}
}
return "";
}
}

查看文件

@ -0,0 +1,116 @@
package com.xuqm.update.store.controller;
import com.xuqm.common.model.ApiResponse;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.service.RnBundleAuthorizationService;
import com.xuqm.update.store.model.StoreBindingRequest;
import com.xuqm.update.store.model.StoreBindingView;
import com.xuqm.update.store.model.StoreEventView;
import com.xuqm.update.store.model.StoreNotificationPolicyRequest;
import com.xuqm.update.store.model.StoreNotificationPolicyView;
import com.xuqm.update.store.model.StoreUpdateDraftView;
import com.xuqm.update.store.model.StoreVersionView;
import com.xuqm.update.store.service.StoreMonitoringService;
import com.xuqm.update.store.service.StoreNotificationService;
import com.xuqm.update.store.service.StoreUpdateDraftService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 租户平台 iOS/鸿蒙官方商店只读监测 API。 */
@RestController
@RequestMapping("/api/v1/updates/store-monitoring")
public class StoreMonitoringController {
private final StoreMonitoringService monitoringService;
private final StoreNotificationService notificationService;
private final StoreUpdateDraftService updateDraftService;
private final RnBundleAuthorizationService authorizationService;
public StoreMonitoringController(StoreMonitoringService monitoringService,
StoreNotificationService notificationService,
StoreUpdateDraftService updateDraftService,
RnBundleAuthorizationService authorizationService) {
this.monitoringService = monitoringService;
this.notificationService = notificationService;
this.updateDraftService = updateDraftService;
this.authorizationService = authorizationService;
}
@GetMapping("/bindings")
public ResponseEntity<ApiResponse<List<StoreBindingView>>> listBindings(
@RequestParam String appKey) {
requireOwnership(appKey);
return ResponseEntity.ok(ApiResponse.success(monitoringService.listBindings(appKey)));
}
@PutMapping("/bindings/{storeType}")
public ResponseEntity<ApiResponse<StoreBindingView>> saveBinding(
@RequestParam String appKey,
@PathVariable AppStoreConfigEntity.StoreType storeType,
@RequestBody StoreBindingRequest request) {
requireOwnership(appKey);
return ResponseEntity.ok(ApiResponse.success(
monitoringService.saveBinding(appKey, storeType, request)));
}
@PostMapping("/bindings/{storeType}/sync")
public ResponseEntity<ApiResponse<List<StoreVersionView>>> synchronize(
@RequestParam String appKey,
@PathVariable AppStoreConfigEntity.StoreType storeType) {
requireOwnership(appKey);
return ResponseEntity.ok(ApiResponse.success(
monitoringService.synchronize(appKey, storeType)));
}
@GetMapping("/bindings/{bindingId}/versions")
public ResponseEntity<ApiResponse<List<StoreVersionView>>> listVersions(
@RequestParam String appKey,
@PathVariable String bindingId) {
requireOwnership(appKey);
return ResponseEntity.ok(ApiResponse.success(
monitoringService.listVersions(appKey, bindingId)));
}
@GetMapping("/bindings/{bindingId}/events")
public ResponseEntity<ApiResponse<List<StoreEventView>>> listEvents(
@RequestParam String appKey,
@PathVariable String bindingId) {
requireOwnership(appKey);
return ResponseEntity.ok(ApiResponse.success(
monitoringService.listEvents(appKey, bindingId)));
}
@PostMapping("/bindings/{bindingId}/versions/{storeVersionId}/update-draft")
public ResponseEntity<ApiResponse<StoreUpdateDraftView>> createUpdateDraft(
@RequestParam String appKey,
@PathVariable String bindingId,
@PathVariable String storeVersionId) {
requireOwnership(appKey);
String operator = SecurityContextHolder.getContext().getAuthentication().getName();
return ResponseEntity.ok(ApiResponse.success(updateDraftService.createDraft(
appKey, bindingId, storeVersionId, operator)));
}
@GetMapping("/notification-policy")
public ResponseEntity<ApiResponse<StoreNotificationPolicyView>> getNotificationPolicy(
@RequestParam String appKey) {
requireOwnership(appKey);
return ResponseEntity.ok(ApiResponse.success(notificationService.getPolicy(appKey)));
}
@PutMapping("/notification-policy")
public ResponseEntity<ApiResponse<StoreNotificationPolicyView>> saveNotificationPolicy(
@RequestParam String appKey,
@RequestBody StoreNotificationPolicyRequest request) {
requireOwnership(appKey);
return ResponseEntity.ok(ApiResponse.success(
notificationService.savePolicy(appKey, request)));
}
private void requireOwnership(String appKey) {
authorizationService.requireOwned(
SecurityContextHolder.getContext().getAuthentication(), appKey);
}
}

查看文件

@ -0,0 +1,98 @@
package com.xuqm.update.store.entity;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** Xuqm 应用与一个官方应用商店应用的绑定。 */
@Entity
@Table(name = "update_store_application", uniqueConstraints = {
@UniqueConstraint(name = "uk_store_application", columnNames = {"app_key", "store_type"})
})
public class StoreApplicationEntity {
@Id
@Column(length = 64)
private String id;
@Column(name = "app_key", nullable = false, length = 64)
private String appKey;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private AppVersionEntity.Platform platform;
@Enumerated(EnumType.STRING)
@Column(name = "store_type", nullable = false, length = 24)
private AppStoreConfigEntity.StoreType storeType;
@Column(name = "package_identifier", nullable = false, length = 256)
private String packageIdentifier;
@Column(name = "official_app_id", length = 128)
private String officialAppId;
@Column(name = "market_url", length = 512)
private String marketUrl;
@Column(name = "target_region", nullable = false, length = 8)
private String targetRegion = "CHN";
@Column(name = "monitor_credential_profile_id", length = 64)
private String monitorCredentialProfileId;
@Column(name = "publish_credential_profile_id", length = 64)
private String publishCredentialProfileId;
@Column(nullable = false)
private boolean enabled = true;
@Column(name = "last_sync_status", length = 24)
private String lastSyncStatus;
@Column(name = "last_sync_error", length = 1024)
private String lastSyncError;
@Column(name = "last_synced_at")
private LocalDateTime lastSyncedAt;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public AppVersionEntity.Platform getPlatform() { return platform; }
public void setPlatform(AppVersionEntity.Platform platform) { this.platform = platform; }
public AppStoreConfigEntity.StoreType getStoreType() { return storeType; }
public void setStoreType(AppStoreConfigEntity.StoreType storeType) { this.storeType = storeType; }
public String getPackageIdentifier() { return packageIdentifier; }
public void setPackageIdentifier(String packageIdentifier) { this.packageIdentifier = packageIdentifier; }
public String getOfficialAppId() { return officialAppId; }
public void setOfficialAppId(String officialAppId) { this.officialAppId = officialAppId; }
public String getMarketUrl() { return marketUrl; }
public void setMarketUrl(String marketUrl) { this.marketUrl = marketUrl; }
public String getTargetRegion() { return targetRegion; }
public void setTargetRegion(String targetRegion) { this.targetRegion = targetRegion; }
public String getMonitorCredentialProfileId() { return monitorCredentialProfileId; }
public void setMonitorCredentialProfileId(String monitorCredentialProfileId) { this.monitorCredentialProfileId = monitorCredentialProfileId; }
public String getPublishCredentialProfileId() { return publishCredentialProfileId; }
public void setPublishCredentialProfileId(String publishCredentialProfileId) { this.publishCredentialProfileId = publishCredentialProfileId; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public String getLastSyncStatus() { return lastSyncStatus; }
public void setLastSyncStatus(String lastSyncStatus) { this.lastSyncStatus = lastSyncStatus; }
public String getLastSyncError() { return lastSyncError; }
public void setLastSyncError(String lastSyncError) { this.lastSyncError = lastSyncError; }
public LocalDateTime getLastSyncedAt() { return lastSyncedAt; }
public void setLastSyncedAt(LocalDateTime lastSyncedAt) { this.lastSyncedAt = lastSyncedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,69 @@
package com.xuqm.update.store.entity;
import com.xuqm.update.entity.AppStoreConfigEntity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** 商店凭据的非敏感元数据;秘密正文由 StoreSecretProvider 管理。 */
@Entity
@Table(name = "update_store_credential_profile", uniqueConstraints = {
@UniqueConstraint(name = "uk_store_credential_purpose",
columnNames = {"app_key", "store_type", "purpose"})
})
public class StoreCredentialProfileEntity {
public enum Purpose { MONITOR, PUBLISH }
@Id
@Column(length = 64)
private String id;
@Column(name = "app_key", nullable = false, length = 64)
private String appKey;
@Enumerated(EnumType.STRING)
@Column(name = "store_type", nullable = false, length = 24)
private AppStoreConfigEntity.StoreType storeType;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Purpose purpose;
@Column(name = "display_name", nullable = false, length = 128)
private String displayName;
@Column(name = "secret_ref", nullable = false, length = 256)
private String secretRef;
@Column(name = "key_hint", length = 128)
private String keyHint;
@Column(name = "capabilities_json", columnDefinition = "TEXT")
private String capabilitiesJson;
@Column(nullable = false)
private boolean enabled = true;
@Column(name = "verified_at")
private LocalDateTime verifiedAt;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public AppStoreConfigEntity.StoreType getStoreType() { return storeType; }
public void setStoreType(AppStoreConfigEntity.StoreType storeType) { this.storeType = storeType; }
public Purpose getPurpose() { return purpose; }
public void setPurpose(Purpose purpose) { this.purpose = purpose; }
public String getDisplayName() { return displayName; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public String getSecretRef() { return secretRef; }
public void setSecretRef(String secretRef) { this.secretRef = secretRef; }
public String getKeyHint() { return keyHint; }
public void setKeyHint(String keyHint) { this.keyHint = keyHint; }
public String getCapabilitiesJson() { return capabilitiesJson; }
public void setCapabilitiesJson(String capabilitiesJson) { this.capabilitiesJson = capabilitiesJson; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public LocalDateTime getVerifiedAt() { return verifiedAt; }
public void setVerifiedAt(LocalDateTime verifiedAt) { this.verifiedAt = verifiedAt; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,58 @@
package com.xuqm.update.store.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** 商店状态通知 Outbox;事件与渠道的唯一约束保证幂等入队。 */
@Entity
@Table(name = "update_store_notification_outbox")
public class StoreNotificationOutboxEntity {
public enum Channel { EMAIL, WEBHOOK }
public enum Status { PENDING, PROCESSING, RETRY, DELIVERED, DEAD }
@Id
@Column(length = 64)
private String id;
@Column(name = "app_key", nullable = false, length = 64)
private String appKey;
@Column(name = "store_event_id", nullable = false, length = 64)
private String storeEventId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Channel channel;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private Status status;
@Column(name = "attempt_count", nullable = false)
private int attemptCount;
@Column(name = "next_attempt_at")
private LocalDateTime nextAttemptAt;
@Column(name = "last_error", length = 1024)
private String lastError;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public String getStoreEventId() { return storeEventId; }
public void setStoreEventId(String storeEventId) { this.storeEventId = storeEventId; }
public Channel getChannel() { return channel; }
public void setChannel(Channel channel) { this.channel = channel; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public int getAttemptCount() { return attemptCount; }
public void setAttemptCount(int attemptCount) { this.attemptCount = attemptCount; }
public LocalDateTime getNextAttemptAt() { return nextAttemptAt; }
public void setNextAttemptAt(LocalDateTime nextAttemptAt) { this.nextAttemptAt = nextAttemptAt; }
public String getLastError() { return lastError; }
public void setLastError(String lastError) { this.lastError = lastError; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,53 @@
package com.xuqm.update.store.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** 单个应用的商店状态通知策略;所有渠道默认关闭。 */
@Entity
@Table(name = "update_store_notification_policy")
public class StoreNotificationPolicyEntity {
@Id
@Column(length = 64)
private String id;
@Column(name = "app_key", nullable = false, unique = true, length = 64)
private String appKey;
@Column(name = "email_enabled", nullable = false)
private boolean emailEnabled;
@Column(name = "webhook_enabled", nullable = false)
private boolean webhookEnabled;
@Column(name = "recipient_json", columnDefinition = "TEXT")
private String recipientJson;
@Column(name = "webhook_url", length = 512)
private String webhookUrl;
@Column(name = "webhook_secret_ref", length = 256)
private String webhookSecretRef;
@Column(name = "event_types_json", columnDefinition = "TEXT")
private String eventTypesJson;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getAppKey() { return appKey; }
public void setAppKey(String appKey) { this.appKey = appKey; }
public boolean isEmailEnabled() { return emailEnabled; }
public void setEmailEnabled(boolean emailEnabled) { this.emailEnabled = emailEnabled; }
public boolean isWebhookEnabled() { return webhookEnabled; }
public void setWebhookEnabled(boolean webhookEnabled) { this.webhookEnabled = webhookEnabled; }
public String getRecipientJson() { return recipientJson; }
public void setRecipientJson(String recipientJson) { this.recipientJson = recipientJson; }
public String getWebhookUrl() { return webhookUrl; }
public void setWebhookUrl(String webhookUrl) { this.webhookUrl = webhookUrl; }
public String getWebhookSecretRef() { return webhookSecretRef; }
public void setWebhookSecretRef(String webhookSecretRef) { this.webhookSecretRef = webhookSecretRef; }
public String getEventTypesJson() { return eventTypesJson; }
public void setEventTypesJson(String eventTypesJson) { this.eventTypesJson = eventTypesJson; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,48 @@
package com.xuqm.update.store.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** 一个官方版本在指定商店地区的实际可下载状态。 */
@Entity
@Table(name = "update_store_region_availability", uniqueConstraints = {
@UniqueConstraint(name = "uk_store_version_region",
columnNames = {"store_version_id", "region_code"})
})
public class StoreRegionAvailabilityEntity {
public enum AvailabilityState { AVAILABLE, NOT_AVAILABLE, PROCESSING, UNKNOWN }
@Id
@Column(length = 64)
private String id;
@Column(name = "store_version_id", nullable = false, length = 64)
private String storeVersionId;
@Column(name = "region_code", nullable = false, length = 8)
private String regionCode;
@Enumerated(EnumType.STRING)
@Column(name = "availability_state", nullable = false, length = 32)
private AvailabilityState availabilityState;
@Column(name = "raw_state", length = 64)
private String rawState;
@Column(name = "checked_at", nullable = false)
private LocalDateTime checkedAt;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getStoreVersionId() { return storeVersionId; }
public void setStoreVersionId(String storeVersionId) { this.storeVersionId = storeVersionId; }
public String getRegionCode() { return regionCode; }
public void setRegionCode(String regionCode) { this.regionCode = regionCode; }
public AvailabilityState getAvailabilityState() { return availabilityState; }
public void setAvailabilityState(AvailabilityState availabilityState) { this.availabilityState = availabilityState; }
public String getRawState() { return rawState; }
public void setRawState(String rawState) { this.rawState = rawState; }
public LocalDateTime getCheckedAt() { return checkedAt; }
public void setCheckedAt(LocalDateTime checkedAt) { this.checkedAt = checkedAt; }
}

查看文件

@ -0,0 +1,40 @@
package com.xuqm.update.store.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
/** 数据库型 Secrets 后端的密文记录,禁止通过控制器直接返回。 */
@Entity
@Table(name = "update_store_secret")
public class StoreSecretEntity {
@Id
@Column(name = "secret_ref", length = 256)
private String secretRef;
@Column(nullable = false, columnDefinition = "TEXT")
private String ciphertext;
@Column(nullable = false, length = 64)
private String nonce;
@Column(name = "key_version", nullable = false, length = 32)
private String keyVersion;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public String getSecretRef() { return secretRef; }
public void setSecretRef(String secretRef) { this.secretRef = secretRef; }
public String getCiphertext() { return ciphertext; }
public void setCiphertext(String ciphertext) { this.ciphertext = ciphertext; }
public String getNonce() { return nonce; }
public void setNonce(String nonce) { this.nonce = nonce; }
public String getKeyVersion() { return keyVersion; }
public void setKeyVersion(String keyVersion) { this.keyVersion = keyVersion; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

查看文件

@ -0,0 +1,71 @@
package com.xuqm.update.store.entity;
import com.xuqm.update.store.model.StoreVersionState;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** 官方回调、轮询或人工操作产生的不可变商店事件。 */
@Entity
@Table(name = "update_store_state_event")
public class StoreStateEventEntity {
public enum Source { WEBHOOK, POLL, MANUAL, IMPORT }
@Id
@Column(length = 64)
private String id;
@Column(name = "store_application_id", nullable = false, length = 64)
private String storeApplicationId;
@Column(name = "store_version_id", length = 64)
private String storeVersionId;
@Column(name = "official_event_id", length = 192)
private String officialEventId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private Source source;
@Column(name = "event_type", nullable = false, length = 64)
private String eventType;
@Enumerated(EnumType.STRING)
@Column(name = "previous_state", length = 32)
private StoreVersionState previousState;
@Enumerated(EnumType.STRING)
@Column(name = "current_state", length = 32)
private StoreVersionState currentState;
@Column(name = "raw_state", length = 64)
private String rawState;
@Column(name = "occurred_at", nullable = false)
private LocalDateTime occurredAt;
@Column(name = "received_at", nullable = false)
private LocalDateTime receivedAt;
@Column(name = "payload_digest", length = 64)
private String payloadDigest;
@Column(name = "detail_json", columnDefinition = "TEXT")
private String detailJson;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getStoreApplicationId() { return storeApplicationId; }
public void setStoreApplicationId(String storeApplicationId) { this.storeApplicationId = storeApplicationId; }
public String getStoreVersionId() { return storeVersionId; }
public void setStoreVersionId(String storeVersionId) { this.storeVersionId = storeVersionId; }
public String getOfficialEventId() { return officialEventId; }
public void setOfficialEventId(String officialEventId) { this.officialEventId = officialEventId; }
public Source getSource() { return source; }
public void setSource(Source source) { this.source = source; }
public String getEventType() { return eventType; }
public void setEventType(String eventType) { this.eventType = eventType; }
public StoreVersionState getPreviousState() { return previousState; }
public void setPreviousState(StoreVersionState previousState) { this.previousState = previousState; }
public StoreVersionState getCurrentState() { return currentState; }
public void setCurrentState(StoreVersionState currentState) { this.currentState = currentState; }
public String getRawState() { return rawState; }
public void setRawState(String rawState) { this.rawState = rawState; }
public LocalDateTime getOccurredAt() { return occurredAt; }
public void setOccurredAt(LocalDateTime occurredAt) { this.occurredAt = occurredAt; }
public LocalDateTime getReceivedAt() { return receivedAt; }
public void setReceivedAt(LocalDateTime receivedAt) { this.receivedAt = receivedAt; }
public String getPayloadDigest() { return payloadDigest; }
public void setPayloadDigest(String payloadDigest) { this.payloadDigest = payloadDigest; }
public String getDetailJson() { return detailJson; }
public void setDetailJson(String detailJson) { this.detailJson = detailJson; }
}

查看文件

@ -0,0 +1,37 @@
package com.xuqm.update.store.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** 官方商店版本与 Xuqm 更新草稿的显式关联。 */
@Entity
@Table(name = "update_store_update_link")
public class StoreUpdateLinkEntity {
@Id
@Column(length = 64)
private String id;
@Column(name = "store_version_id", nullable = false, length = 64)
private String storeVersionId;
@Column(name = "app_version_id", nullable = false)
private String appVersionId;
@Column(name = "created_by", length = 128)
private String createdBy;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(nullable = false)
private boolean active;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getStoreVersionId() { return storeVersionId; }
public void setStoreVersionId(String storeVersionId) { this.storeVersionId = storeVersionId; }
public String getAppVersionId() { return appVersionId; }
public void setAppVersionId(String appVersionId) { this.appVersionId = appVersionId; }
public String getCreatedBy() { return createdBy; }
public void setCreatedBy(String createdBy) { this.createdBy = createdBy; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}

查看文件

@ -0,0 +1,85 @@
package com.xuqm.update.store.entity;
import com.xuqm.update.store.model.StoreVersionState;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/** 官方商店版本记录;不等同于 Xuqm 客户端更新版本。 */
@Entity
@Table(name = "update_store_version", uniqueConstraints = {
@UniqueConstraint(name = "uk_store_version",
columnNames = {"store_application_id", "official_version_id"})
})
public class StoreVersionEntity {
public enum Source { XUQM_PLATFORM, EXTERNAL_STORE }
@Id
@Column(length = 64)
private String id;
@Column(name = "store_application_id", nullable = false, length = 64)
private String storeApplicationId;
@Column(name = "official_version_id", nullable = false, length = 128)
private String officialVersionId;
@Column(name = "version_name", nullable = false, length = 64)
private String versionName;
@Column(name = "build_version", length = 64)
private String buildVersion;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 24)
private Source source = Source.EXTERNAL_STORE;
@Enumerated(EnumType.STRING)
@Column(name = "normalized_state", nullable = false, length = 32)
private StoreVersionState normalizedState;
@Column(name = "raw_state", length = 64)
private String rawState;
@Column(name = "release_sequence")
private Long releaseSequence;
@Column(name = "state_occurred_at")
private LocalDateTime stateOccurredAt;
@Column(name = "discovered_at", nullable = false)
private LocalDateTime discoveredAt;
@Column(name = "last_synced_at", nullable = false)
private LocalDateTime lastSyncedAt;
@Column(name = "raw_snapshot_json", columnDefinition = "TEXT")
private String rawSnapshotJson;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public String getStoreApplicationId() { return storeApplicationId; }
public void setStoreApplicationId(String storeApplicationId) { this.storeApplicationId = storeApplicationId; }
public String getOfficialVersionId() { return officialVersionId; }
public void setOfficialVersionId(String officialVersionId) { this.officialVersionId = officialVersionId; }
public String getVersionName() { return versionName; }
public void setVersionName(String versionName) { this.versionName = versionName; }
public String getBuildVersion() { return buildVersion; }
public void setBuildVersion(String buildVersion) { this.buildVersion = buildVersion; }
public Source getSource() { return source; }
public void setSource(Source source) { this.source = source; }
public StoreVersionState getNormalizedState() { return normalizedState; }
public void setNormalizedState(StoreVersionState normalizedState) { this.normalizedState = normalizedState; }
public String getRawState() { return rawState; }
public void setRawState(String rawState) { this.rawState = rawState; }
public Long getReleaseSequence() { return releaseSequence; }
public void setReleaseSequence(Long releaseSequence) { this.releaseSequence = releaseSequence; }
public LocalDateTime getStateOccurredAt() { return stateOccurredAt; }
public void setStateOccurredAt(LocalDateTime stateOccurredAt) { this.stateOccurredAt = stateOccurredAt; }
public LocalDateTime getDiscoveredAt() { return discoveredAt; }
public void setDiscoveredAt(LocalDateTime discoveredAt) { this.discoveredAt = discoveredAt; }
public LocalDateTime getLastSyncedAt() { return lastSyncedAt; }
public void setLastSyncedAt(LocalDateTime lastSyncedAt) { this.lastSyncedAt = lastSyncedAt; }
public String getRawSnapshotJson() { return rawSnapshotJson; }
public void setRawSnapshotJson(String rawSnapshotJson) { this.rawSnapshotJson = rawSnapshotJson; }
}

查看文件

@ -0,0 +1,13 @@
package com.xuqm.update.store.model;
import java.util.Map;
/** 租户平台保存 iOS/鸿蒙只读监测绑定时的请求。 */
public record StoreBindingRequest(
String packageIdentifier,
String marketUrl,
Boolean enabled,
String credentialDisplayName,
Map<String, String> credential
) {
}

查看文件

@ -0,0 +1,24 @@
package com.xuqm.update.store.model;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity;
import java.time.LocalDateTime;
/** 不包含任何凭据正文的商店绑定视图。 */
public record StoreBindingView(
String id,
String appKey,
AppVersionEntity.Platform platform,
AppStoreConfigEntity.StoreType storeType,
String packageIdentifier,
String officialAppId,
String marketUrl,
String targetRegion,
boolean enabled,
boolean monitorCredentialConfigured,
String lastSyncStatus,
String lastSyncError,
LocalDateTime lastSyncedAt
) {
}

查看文件

@ -0,0 +1,17 @@
package com.xuqm.update.store.model;
import com.xuqm.update.store.entity.StoreStateEventEntity;
import java.time.LocalDateTime;
/** 租户平台展示的商店状态时间线,不回传官方原始凭据或敏感响应。 */
public record StoreEventView(
String id,
String eventType,
StoreStateEventEntity.Source source,
StoreVersionState previousState,
StoreVersionState currentState,
String rawState,
LocalDateTime occurredAt,
LocalDateTime receivedAt
) {}

查看文件

@ -0,0 +1,17 @@
package com.xuqm.update.store.model;
import java.time.Instant;
import java.util.List;
/** 一次官方商店查询得到的应用级版本库存。 */
public record StoreInventorySnapshot(
String officialAppId,
Instant synchronizedAt,
List<StoreVersionSnapshot> versions
) {
public StoreInventorySnapshot {
officialAppId = officialAppId == null ? "" : officialAppId;
synchronizedAt = synchronizedAt == null ? Instant.now() : synchronizedAt;
versions = versions == null ? List.of() : List.copyOf(versions);
}
}

查看文件

@ -0,0 +1,24 @@
package com.xuqm.update.store.model;
import com.xuqm.update.entity.AppStoreConfigEntity;
import java.util.Map;
/** 调用官方商店只读接口所需的最小上下文。 */
public record StoreMonitorRequest(
AppStoreConfigEntity.StoreType storeType,
String packageIdentifier,
String targetRegion,
Map<String, String> credential
) {
public StoreMonitorRequest {
if (storeType == null) {
throw new IllegalArgumentException("storeType must not be null");
}
if (packageIdentifier == null || packageIdentifier.isBlank()) {
throw new IllegalArgumentException("packageIdentifier must not be blank");
}
targetRegion = targetRegion == null || targetRegion.isBlank() ? "CHN" : targetRegion;
credential = credential == null ? Map.of() : Map.copyOf(credential);
}
}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.update.store.model;
import java.util.List;
import java.util.Set;
/** 保存通知策略时,webhookSecret 只写不回显。 */
public record StoreNotificationPolicyRequest(
boolean emailEnabled,
List<String> emailRecipients,
boolean webhookEnabled,
String webhookUrl,
String webhookSecret,
Set<String> eventTypes
) {}

查看文件

@ -0,0 +1,15 @@
package com.xuqm.update.store.model;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
public record StoreNotificationPolicyView(
boolean emailEnabled,
List<String> emailRecipients,
boolean webhookEnabled,
String webhookUrl,
boolean webhookSecretConfigured,
Set<String> eventTypes,
LocalDateTime updatedAt
) {}

查看文件

@ -0,0 +1,14 @@
package com.xuqm.update.store.model;
import com.xuqm.update.entity.AppVersionEntity;
public record StoreUpdateDraftView(
String storeVersionId,
String appVersionId,
AppVersionEntity.Platform platform,
String versionName,
String buildVersion,
long releaseSequence,
AppVersionEntity.PublishStatus publishStatus,
String storeUrl
) {}

查看文件

@ -0,0 +1,39 @@
package com.xuqm.update.store.model;
import java.time.Instant;
import java.util.Map;
/**
* 官方商店适配器返回的不可变版本快照
*
* @param officialVersionId 官方资源 ID禁止用展示版本号代替
* @param versionName 用户可见版本号
* @param buildVersion 平台原生构建号iOS 必须保持字符串
* @param targetRegionAvailable 中国大陆地区是否已经可下载
*/
public record StoreVersionSnapshot(
String officialVersionId,
String versionName,
String buildVersion,
String rawState,
StoreVersionState state,
boolean targetRegionAvailable,
Instant stateOccurredAt,
Map<String, Object> auditAttributes
) {
public StoreVersionSnapshot {
officialVersionId = requireText(officialVersionId, "officialVersionId");
versionName = requireText(versionName, "versionName");
buildVersion = buildVersion == null ? "" : buildVersion;
rawState = rawState == null ? "" : rawState;
state = state == null ? StoreVersionState.UNKNOWN : state;
auditAttributes = auditAttributes == null ? Map.of() : Map.copyOf(auditAttributes);
}
private static String requireText(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value;
}
}

查看文件

@ -0,0 +1,26 @@
package com.xuqm.update.store.model;
/**
* 应用商店版本的统一状态
*
* <p>该枚举只表达 Xuqm 的领域语义Apple华为等官方状态必须由各自适配器
* 映射后才能进入领域层同时仍需保存原始状态用于审计</p>
*/
public enum StoreVersionState {
DISCOVERED,
PREPARING,
SUBMITTED,
WAITING_FOR_REVIEW,
IN_REVIEW,
REJECTED,
APPROVED_PENDING_RELEASE,
RELEASE_SCHEDULED,
RELEASE_REQUESTED,
PROCESSING_DISTRIBUTION,
PARTIALLY_AVAILABLE,
AVAILABLE,
WITHDRAWN,
REMOVED,
UNKNOWN,
SYNC_FAILED
}

查看文件

@ -0,0 +1,21 @@
package com.xuqm.update.store.model;
import com.xuqm.update.store.entity.StoreVersionEntity;
import java.time.LocalDateTime;
/** 租户平台展示的官方商店版本。 */
public record StoreVersionView(
String id,
String officialVersionId,
String versionName,
String buildVersion,
StoreVersionEntity.Source source,
StoreVersionState state,
String rawState,
boolean chinaAvailable,
Long releaseSequence,
LocalDateTime stateOccurredAt,
LocalDateTime lastSyncedAt
) {
}

查看文件

@ -0,0 +1,15 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.store.entity.StoreApplicationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface StoreApplicationRepository extends JpaRepository<StoreApplicationEntity, String> {
Optional<StoreApplicationEntity> findByAppKeyAndStoreType(
String appKey, AppStoreConfigEntity.StoreType storeType);
List<StoreApplicationEntity> findByAppKeyOrderByStoreType(String appKey);
List<StoreApplicationEntity> findByEnabledTrue();
}

查看文件

@ -0,0 +1,15 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.store.entity.StoreCredentialProfileEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface StoreCredentialProfileRepository
extends JpaRepository<StoreCredentialProfileEntity, String> {
Optional<StoreCredentialProfileEntity> findByAppKeyAndStoreTypeAndPurpose(
String appKey,
AppStoreConfigEntity.StoreType storeType,
StoreCredentialProfileEntity.Purpose purpose);
}

查看文件

@ -0,0 +1,43 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.store.entity.StoreNotificationOutboxEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
public interface StoreNotificationOutboxRepository
extends JpaRepository<StoreNotificationOutboxEntity, String> {
List<StoreNotificationOutboxEntity>
findTop50ByStatusInAndNextAttemptAtLessThanEqualOrderByCreatedAtAsc(
Collection<StoreNotificationOutboxEntity.Status> statuses,
LocalDateTime now);
@Modifying
@Query("""
update StoreNotificationOutboxEntity item
set item.status = com.xuqm.update.store.entity.StoreNotificationOutboxEntity.Status.PROCESSING,
item.updatedAt = :now
where item.id = :id
and item.status in (
com.xuqm.update.store.entity.StoreNotificationOutboxEntity.Status.PENDING,
com.xuqm.update.store.entity.StoreNotificationOutboxEntity.Status.RETRY
)
""")
int claim(String id, LocalDateTime now);
@Modifying
@Query("""
update StoreNotificationOutboxEntity item
set item.status = com.xuqm.update.store.entity.StoreNotificationOutboxEntity.Status.RETRY,
item.nextAttemptAt = :now,
item.lastError = '投递进程中断任务已自动恢复',
item.updatedAt = :now
where item.status = com.xuqm.update.store.entity.StoreNotificationOutboxEntity.Status.PROCESSING
and item.updatedAt < :staleBefore
""")
int recoverStale(LocalDateTime staleBefore, LocalDateTime now);
}

查看文件

@ -0,0 +1,11 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.store.entity.StoreNotificationPolicyEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface StoreNotificationPolicyRepository
extends JpaRepository<StoreNotificationPolicyEntity, String> {
Optional<StoreNotificationPolicyEntity> findByAppKey(String appKey);
}

查看文件

@ -0,0 +1,12 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.store.entity.StoreRegionAvailabilityEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface StoreRegionAvailabilityRepository
extends JpaRepository<StoreRegionAvailabilityEntity, String> {
Optional<StoreRegionAvailabilityEntity> findByStoreVersionIdAndRegionCode(
String storeVersionId, String regionCode);
}

查看文件

@ -0,0 +1,7 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.store.entity.StoreSecretEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface StoreSecretRepository extends JpaRepository<StoreSecretEntity, String> {
}

查看文件

@ -0,0 +1,11 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.store.entity.StoreStateEventEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface StoreStateEventRepository extends JpaRepository<StoreStateEventEntity, String> {
List<StoreStateEventEntity> findTop100ByStoreApplicationIdOrderByOccurredAtDesc(
String storeApplicationId);
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.store.entity.StoreUpdateLinkEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface StoreUpdateLinkRepository extends JpaRepository<StoreUpdateLinkEntity, String> {
Optional<StoreUpdateLinkEntity> findByStoreVersionIdAndActiveTrue(String storeVersionId);
}

查看文件

@ -0,0 +1,19 @@
package com.xuqm.update.store.repository;
import com.xuqm.update.store.entity.StoreVersionEntity;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
import java.util.Optional;
public interface StoreVersionRepository extends JpaRepository<StoreVersionEntity, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select version from StoreVersionEntity version where version.id = :id")
Optional<StoreVersionEntity> findLockedById(String id);
Optional<StoreVersionEntity> findByStoreApplicationIdAndOfficialVersionId(
String storeApplicationId, String officialVersionId);
List<StoreVersionEntity> findByStoreApplicationIdOrderByLastSyncedAtDesc(String storeApplicationId);
}

查看文件

@ -0,0 +1,126 @@
package com.xuqm.update.store.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.store.entity.StoreSecretEntity;
import com.xuqm.update.store.repository.StoreSecretRepository;
import com.xuqm.update.store.spi.StoreSecretProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
/**
* 私有化默认 Secrets 后端
*
* <p>密钥只从部署环境注入未配置密钥时服务仍可启动并展示历史监测数据
* 但保存或读取凭据会明确失败不能使用硬编码默认值</p>
*/
@Service
public class EncryptedDatabaseStoreSecretProvider implements StoreSecretProvider {
private static final int GCM_TAG_BITS = 128;
private static final int NONCE_BYTES = 12;
private static final TypeReference<Map<String, String>> SECRET_TYPE = new TypeReference<>() {};
private final StoreSecretRepository repository;
private final ObjectMapper objectMapper;
private final String encodedMasterKey;
private final String keyVersion;
private final SecureRandom secureRandom = new SecureRandom();
public EncryptedDatabaseStoreSecretProvider(
StoreSecretRepository repository,
ObjectMapper objectMapper,
@Value("${xuqm.store-secrets.master-key:}") String encodedMasterKey,
@Value("${xuqm.store-secrets.key-version:v1}") String keyVersion) {
this.repository = repository;
this.objectMapper = objectMapper;
this.encodedMasterKey = encodedMasterKey;
this.keyVersion = keyVersion;
}
@Override
@Transactional
public String put(String previousRef, Map<String, String> secret) {
if (secret == null || secret.isEmpty()) {
throw new IllegalArgumentException("store credential must not be empty");
}
try {
String secretRef = previousRef == null || previousRef.isBlank()
? "store-secret/" + UUID.randomUUID()
: previousRef;
byte[] nonce = new byte[NONCE_BYTES];
secureRandom.nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, masterKey(), new GCMParameterSpec(GCM_TAG_BITS, nonce));
cipher.updateAAD(secretRef.getBytes(StandardCharsets.UTF_8));
byte[] ciphertext = cipher.doFinal(objectMapper.writeValueAsBytes(secret));
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
StoreSecretEntity entity = repository.findById(secretRef).orElseGet(StoreSecretEntity::new);
if (entity.getSecretRef() == null) {
entity.setSecretRef(secretRef);
entity.setCreatedAt(now);
}
entity.setCiphertext(Base64.getEncoder().encodeToString(ciphertext));
entity.setNonce(Base64.getEncoder().encodeToString(nonce));
entity.setKeyVersion(keyVersion);
entity.setUpdatedAt(now);
repository.save(entity);
return secretRef;
} catch (Exception e) {
throw new IllegalStateException("无法安全保存应用商店凭据", e);
}
}
@Override
@Transactional(readOnly = true)
public Map<String, String> resolve(String secretRef) {
StoreSecretEntity entity = repository.findById(secretRef)
.orElseThrow(() -> new IllegalArgumentException("store secret not found"));
try {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, masterKey(), new GCMParameterSpec(
GCM_TAG_BITS, Base64.getDecoder().decode(entity.getNonce())));
cipher.updateAAD(secretRef.getBytes(StandardCharsets.UTF_8));
byte[] plaintext = cipher.doFinal(Base64.getDecoder().decode(entity.getCiphertext()));
return Map.copyOf(objectMapper.readValue(plaintext, SECRET_TYPE));
} catch (Exception e) {
throw new IllegalStateException("无法解密应用商店凭据", e);
}
}
@Override
@Transactional
public void delete(String secretRef) {
if (secretRef != null && !secretRef.isBlank()) {
repository.deleteById(secretRef);
}
}
private SecretKeySpec masterKey() {
if (encodedMasterKey == null || encodedMasterKey.isBlank()) {
throw new IllegalStateException("未配置 XUQM_STORE_SECRET_MASTER_KEY");
}
byte[] decoded;
try {
decoded = Base64.getDecoder().decode(encodedMasterKey);
} catch (IllegalArgumentException e) {
throw new IllegalStateException("XUQM_STORE_SECRET_MASTER_KEY 必须为 Base64", e);
}
if (decoded.length != 32) {
throw new IllegalStateException("XUQM_STORE_SECRET_MASTER_KEY 必须解码为 32 字节");
}
return new SecretKeySpec(decoded, "AES");
}
}

查看文件

@ -0,0 +1,110 @@
package com.xuqm.update.store.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.store.entity.StoreCredentialProfileEntity;
import com.xuqm.update.store.repository.StoreCredentialProfileRepository;
import com.xuqm.update.store.spi.StoreSecretProvider;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/** 商店凭据元数据与 Secrets 引用的唯一管理入口。 */
@Service
public class StoreCredentialService {
private final StoreCredentialProfileRepository repository;
private final StoreSecretProvider secretProvider;
private final ObjectMapper objectMapper;
public StoreCredentialService(StoreCredentialProfileRepository repository,
StoreSecretProvider secretProvider,
ObjectMapper objectMapper) {
this.repository = repository;
this.secretProvider = secretProvider;
this.objectMapper = objectMapper;
}
@Transactional
public StoreCredentialProfileEntity saveMonitorCredential(
String appKey,
AppStoreConfigEntity.StoreType storeType,
String displayName,
Map<String, String> secret) {
validateMonitorSecret(storeType, secret);
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
StoreCredentialProfileEntity profile = repository
.findByAppKeyAndStoreTypeAndPurpose(
appKey, storeType, StoreCredentialProfileEntity.Purpose.MONITOR)
.orElseGet(StoreCredentialProfileEntity::new);
if (profile.getId() == null) {
profile.setId(UUID.randomUUID().toString());
profile.setAppKey(appKey);
profile.setStoreType(storeType);
profile.setPurpose(StoreCredentialProfileEntity.Purpose.MONITOR);
profile.setCreatedAt(now);
}
String secretRef = secretProvider.put(profile.getSecretRef(), secret);
profile.setSecretRef(secretRef);
profile.setDisplayName(displayName == null || displayName.isBlank()
? storeType.name() + " 监测凭据"
: displayName.trim());
profile.setKeyHint(keyHint(storeType, secret));
profile.setCapabilitiesJson(writeJson(Set.of("MONITOR")));
profile.setEnabled(true);
profile.setUpdatedAt(now);
return repository.save(profile);
}
@Transactional(readOnly = true)
public Map<String, String> resolveMonitorCredential(String profileId) {
StoreCredentialProfileEntity profile = repository.findById(profileId)
.filter(StoreCredentialProfileEntity::isEnabled)
.orElseThrow(() -> new IllegalArgumentException("监测凭据不存在或已禁用"));
if (profile.getPurpose() != StoreCredentialProfileEntity.Purpose.MONITOR) {
throw new IllegalArgumentException("凭据用途不是 MONITOR");
}
return secretProvider.resolve(profile.getSecretRef());
}
private void validateMonitorSecret(AppStoreConfigEntity.StoreType storeType,
Map<String, String> secret) {
if (secret == null) {
throw new IllegalArgumentException("credential 不能为空");
}
switch (storeType) {
case APP_STORE -> require(secret, "issuerId", "keyId", "privateKey");
case HARMONY_APP -> require(secret, "clientId", "clientSecret");
default -> throw new IllegalArgumentException(
"当前监测 API 只支持 APP_STORE 和 HARMONY_APP");
}
}
private void require(Map<String, String> secret, String... keys) {
for (String key : keys) {
if (secret.get(key) == null || secret.get(key).isBlank()) {
throw new IllegalArgumentException("credential." + key + " 不能为空");
}
}
}
private String keyHint(AppStoreConfigEntity.StoreType storeType, Map<String, String> secret) {
String value = storeType == AppStoreConfigEntity.StoreType.APP_STORE
? secret.get("keyId")
: secret.get("clientId");
if (value == null || value.isBlank()) return null;
return value.length() <= 8 ? value : value.substring(0, 4) + "" + value.substring(value.length() - 4);
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception e) {
throw new IllegalStateException("credential capabilities serialization failed", e);
}
}
}

查看文件

@ -0,0 +1,194 @@
package com.xuqm.update.store.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.store.entity.StoreApplicationEntity;
import com.xuqm.update.store.entity.StoreRegionAvailabilityEntity;
import com.xuqm.update.store.entity.StoreStateEventEntity;
import com.xuqm.update.store.entity.StoreVersionEntity;
import com.xuqm.update.store.model.StoreInventorySnapshot;
import com.xuqm.update.store.model.StoreVersionSnapshot;
import com.xuqm.update.store.model.StoreVersionState;
import com.xuqm.update.store.repository.StoreApplicationRepository;
import com.xuqm.update.store.repository.StoreRegionAvailabilityRepository;
import com.xuqm.update.store.repository.StoreStateEventRepository;
import com.xuqm.update.store.repository.StoreVersionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
/**
* 将官方商店快照写入本地权威模型
*
* <p>只有状态真实变化时才写事件定时轮询同一响应不会产生重复通知</p>
*/
@Service
public class StoreInventoryReconciler {
private final StoreApplicationRepository applicationRepository;
private final StoreVersionRepository versionRepository;
private final StoreRegionAvailabilityRepository availabilityRepository;
private final StoreStateEventRepository eventRepository;
private final StoreNotificationService notificationService;
private final ObjectMapper objectMapper;
public StoreInventoryReconciler(StoreApplicationRepository applicationRepository,
StoreVersionRepository versionRepository,
StoreRegionAvailabilityRepository availabilityRepository,
StoreStateEventRepository eventRepository,
StoreNotificationService notificationService,
ObjectMapper objectMapper) {
this.applicationRepository = applicationRepository;
this.versionRepository = versionRepository;
this.availabilityRepository = availabilityRepository;
this.eventRepository = eventRepository;
this.notificationService = notificationService;
this.objectMapper = objectMapper;
}
@Transactional
public List<StoreVersionEntity> reconcile(String storeApplicationId,
StoreInventorySnapshot inventory,
StoreStateEventEntity.Source source) {
StoreApplicationEntity application = applicationRepository.findById(storeApplicationId)
.orElseThrow(() -> new IllegalArgumentException(
"store application not found: " + storeApplicationId));
LocalDateTime synchronizedAt = LocalDateTime.ofInstant(
inventory.synchronizedAt(), ZoneOffset.UTC);
if (!inventory.officialAppId().isBlank()) {
application.setOfficialAppId(inventory.officialAppId());
}
application.setLastSyncStatus("SUCCESS");
application.setLastSyncError(null);
application.setLastSyncedAt(synchronizedAt);
application.setUpdatedAt(synchronizedAt);
applicationRepository.save(application);
return inventory.versions().stream()
.map(snapshot -> reconcileVersion(application, snapshot, synchronizedAt, source))
.toList();
}
@Transactional
public void recordSyncFailure(String storeApplicationId, String error) {
StoreApplicationEntity application = applicationRepository.findById(storeApplicationId)
.orElseThrow(() -> new IllegalArgumentException(
"store application not found: " + storeApplicationId));
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
application.setLastSyncStatus("FAILED");
application.setLastSyncError(truncate(error, 1024));
application.setLastSyncedAt(now);
application.setUpdatedAt(now);
applicationRepository.save(application);
}
private StoreVersionEntity reconcileVersion(StoreApplicationEntity application,
StoreVersionSnapshot snapshot,
LocalDateTime synchronizedAt,
StoreStateEventEntity.Source source) {
StoreVersionEntity version = versionRepository
.findByStoreApplicationIdAndOfficialVersionId(
application.getId(), snapshot.officialVersionId())
.orElseGet(StoreVersionEntity::new);
boolean created = version.getId() == null;
StoreVersionState previousState = version.getNormalizedState();
if (created) {
version.setId(UUID.randomUUID().toString());
version.setStoreApplicationId(application.getId());
version.setOfficialVersionId(snapshot.officialVersionId());
version.setSource(StoreVersionEntity.Source.EXTERNAL_STORE);
version.setDiscoveredAt(synchronizedAt);
}
version.setVersionName(snapshot.versionName());
version.setBuildVersion(snapshot.buildVersion());
version.setNormalizedState(snapshot.state());
version.setRawState(snapshot.rawState());
version.setStateOccurredAt(snapshot.stateOccurredAt() == null
? synchronizedAt
: LocalDateTime.ofInstant(snapshot.stateOccurredAt(), ZoneOffset.UTC));
version.setLastSyncedAt(synchronizedAt);
version.setRawSnapshotJson(writeJson(snapshot.auditAttributes()));
StoreVersionEntity saved = versionRepository.save(version);
upsertRegionAvailability(application, saved, snapshot, synchronizedAt);
if (created || previousState != snapshot.state()) {
StoreStateEventEntity event = eventRepository.save(buildStateEvent(
application, saved, previousState, snapshot, synchronizedAt, source));
notificationService.enqueue(event, application.getAppKey());
}
return saved;
}
private void upsertRegionAvailability(StoreApplicationEntity application,
StoreVersionEntity version,
StoreVersionSnapshot snapshot,
LocalDateTime synchronizedAt) {
StoreRegionAvailabilityEntity availability = availabilityRepository
.findByStoreVersionIdAndRegionCode(version.getId(), application.getTargetRegion())
.orElseGet(StoreRegionAvailabilityEntity::new);
if (availability.getId() == null) {
availability.setId(UUID.randomUUID().toString());
availability.setStoreVersionId(version.getId());
availability.setRegionCode(application.getTargetRegion());
}
availability.setAvailabilityState(snapshot.targetRegionAvailable()
? StoreRegionAvailabilityEntity.AvailabilityState.AVAILABLE
: StoreRegionAvailabilityEntity.AvailabilityState.NOT_AVAILABLE);
availability.setRawState(snapshot.rawState());
availability.setCheckedAt(synchronizedAt);
availabilityRepository.save(availability);
}
private StoreStateEventEntity buildStateEvent(StoreApplicationEntity application,
StoreVersionEntity version,
StoreVersionState previousState,
StoreVersionSnapshot snapshot,
LocalDateTime receivedAt,
StoreStateEventEntity.Source source) {
String details = writeJson(snapshot.auditAttributes());
StoreStateEventEntity event = new StoreStateEventEntity();
event.setId(UUID.randomUUID().toString());
event.setStoreApplicationId(application.getId());
event.setStoreVersionId(version.getId());
event.setSource(source);
event.setEventType(previousState == null ? "STORE_VERSION_DISCOVERED" : "STORE_STATE_CHANGED");
event.setPreviousState(previousState);
event.setCurrentState(snapshot.state());
event.setRawState(snapshot.rawState());
event.setOccurredAt(version.getStateOccurredAt());
event.setReceivedAt(receivedAt);
event.setPayloadDigest(sha256(details));
event.setDetailJson(details);
return event;
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception e) {
throw new IllegalArgumentException("store audit attributes cannot be serialized", e);
}
}
private String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
private String truncate(String value, int max) {
if (value == null) return null;
return value.length() <= max ? value : value.substring(0, max);
}
}

查看文件

@ -0,0 +1,236 @@
package com.xuqm.update.store.service;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.store.entity.StoreApplicationEntity;
import com.xuqm.update.store.entity.StoreCredentialProfileEntity;
import com.xuqm.update.store.entity.StoreRegionAvailabilityEntity;
import com.xuqm.update.store.entity.StoreStateEventEntity;
import com.xuqm.update.store.model.*;
import com.xuqm.update.store.repository.StoreApplicationRepository;
import com.xuqm.update.store.repository.StoreRegionAvailabilityRepository;
import com.xuqm.update.store.repository.StoreStateEventRepository;
import com.xuqm.update.store.repository.StoreVersionRepository;
import com.xuqm.update.store.spi.StoreInventoryAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/** iOS 与鸿蒙官方商店只读监测的应用服务。 */
@Service
public class StoreMonitoringService {
private static final Logger log = LoggerFactory.getLogger(StoreMonitoringService.class);
private static final String ONLY_REGION = "CHN";
private final StoreApplicationRepository applicationRepository;
private final StoreVersionRepository versionRepository;
private final StoreRegionAvailabilityRepository availabilityRepository;
private final StoreStateEventRepository eventRepository;
private final StoreCredentialService credentialService;
private final StoreInventoryReconciler reconciler;
private final Map<AppStoreConfigEntity.StoreType, StoreInventoryAdapter> adapters;
public StoreMonitoringService(StoreApplicationRepository applicationRepository,
StoreVersionRepository versionRepository,
StoreRegionAvailabilityRepository availabilityRepository,
StoreStateEventRepository eventRepository,
StoreCredentialService credentialService,
StoreInventoryReconciler reconciler,
List<StoreInventoryAdapter> adapters) {
this.applicationRepository = applicationRepository;
this.versionRepository = versionRepository;
this.availabilityRepository = availabilityRepository;
this.eventRepository = eventRepository;
this.credentialService = credentialService;
this.reconciler = reconciler;
this.adapters = adapters.stream().collect(Collectors.toUnmodifiableMap(
StoreInventoryAdapter::storeType, Function.identity()));
}
@Transactional
public StoreBindingView saveBinding(String appKey,
AppStoreConfigEntity.StoreType storeType,
StoreBindingRequest request) {
requireSupportedStore(storeType);
if (request == null || request.packageIdentifier() == null
|| request.packageIdentifier().isBlank()) {
throw new IllegalArgumentException("packageIdentifier 不能为空");
}
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
StoreApplicationEntity application = applicationRepository
.findByAppKeyAndStoreType(appKey, storeType)
.orElseGet(StoreApplicationEntity::new);
if (application.getId() == null) {
application.setId(UUID.randomUUID().toString());
application.setAppKey(appKey);
application.setStoreType(storeType);
application.setPlatform(platformFor(storeType));
application.setCreatedAt(now);
}
application.setPackageIdentifier(request.packageIdentifier().trim());
application.setMarketUrl(blankToNull(request.marketUrl()));
application.setTargetRegion(ONLY_REGION);
application.setEnabled(!Boolean.FALSE.equals(request.enabled()));
application.setUpdatedAt(now);
if (request.credential() != null && !request.credential().isEmpty()) {
StoreCredentialProfileEntity profile = credentialService.saveMonitorCredential(
appKey, storeType, request.credentialDisplayName(), request.credential());
application.setMonitorCredentialProfileId(profile.getId());
}
if (application.getMonitorCredentialProfileId() == null) {
throw new IllegalArgumentException("首次绑定必须提供监测凭据");
}
return view(applicationRepository.save(application));
}
@Transactional(readOnly = true)
public List<StoreBindingView> listBindings(String appKey) {
return applicationRepository.findByAppKeyOrderByStoreType(appKey).stream()
.map(this::view)
.toList();
}
public List<StoreVersionView> synchronize(String appKey,
AppStoreConfigEntity.StoreType storeType) {
StoreApplicationEntity application = applicationRepository
.findByAppKeyAndStoreType(appKey, storeType)
.orElseThrow(() -> new IllegalArgumentException("商店监测绑定不存在"));
synchronize(application);
return listVersions(appKey, application.getId());
}
@Transactional(readOnly = true)
public List<StoreVersionView> listVersions(String appKey, String storeApplicationId) {
applicationRepository.findById(storeApplicationId)
.filter(application -> application.getAppKey().equals(appKey))
.orElseThrow(() -> new IllegalArgumentException("商店监测绑定不存在"));
return versionRepository
.findByStoreApplicationIdOrderByLastSyncedAtDesc(storeApplicationId)
.stream()
.map(this::versionView)
.toList();
}
@Transactional(readOnly = true)
public List<StoreEventView> listEvents(String appKey, String storeApplicationId) {
applicationRepository.findById(storeApplicationId)
.filter(application -> application.getAppKey().equals(appKey))
.orElseThrow(() -> new IllegalArgumentException("商店监测绑定不存在"));
return eventRepository
.findTop100ByStoreApplicationIdOrderByOccurredAtDesc(storeApplicationId)
.stream()
.map(event -> new StoreEventView(
event.getId(),
event.getEventType(),
event.getSource(),
event.getPreviousState(),
event.getCurrentState(),
event.getRawState(),
event.getOccurredAt(),
event.getReceivedAt()))
.toList();
}
/** 当前迭代只读轮询;审核中状态每十分钟最终对账。 */
@Scheduled(fixedDelayString = "${xuqm.store-monitoring.poll-delay-ms:600000}")
public void synchronizeEnabledBindings() {
for (StoreApplicationEntity application : applicationRepository.findByEnabledTrue()) {
try {
synchronize(application);
} catch (Exception e) {
log.warn("Store monitoring sync failed: appKey={} store={} reason={}",
application.getAppKey(), application.getStoreType(), e.getMessage());
}
}
}
private void synchronize(StoreApplicationEntity application) {
try {
StoreInventoryAdapter adapter = Optional.ofNullable(adapters.get(application.getStoreType()))
.orElseThrow(() -> new IllegalStateException(
"没有可用的商店监测适配器: " + application.getStoreType()));
Map<String, String> credential = credentialService.resolveMonitorCredential(
application.getMonitorCredentialProfileId());
StoreInventorySnapshot inventory = adapter.fetch(new StoreMonitorRequest(
application.getStoreType(),
application.getPackageIdentifier(),
application.getTargetRegion(),
credential));
reconciler.reconcile(application.getId(), inventory, StoreStateEventEntity.Source.POLL);
} catch (Exception e) {
reconciler.recordSyncFailure(application.getId(), safeError(e));
throw e instanceof RuntimeException runtimeException
? runtimeException
: new IllegalStateException("商店状态同步失败", e);
}
}
private StoreVersionView versionView(com.xuqm.update.store.entity.StoreVersionEntity version) {
boolean chinaAvailable = availabilityRepository
.findByStoreVersionIdAndRegionCode(version.getId(), ONLY_REGION)
.map(item -> item.getAvailabilityState()
== StoreRegionAvailabilityEntity.AvailabilityState.AVAILABLE)
.orElse(false);
return new StoreVersionView(
version.getId(),
version.getOfficialVersionId(),
version.getVersionName(),
version.getBuildVersion(),
version.getSource(),
version.getNormalizedState(),
version.getRawState(),
chinaAvailable,
version.getReleaseSequence(),
version.getStateOccurredAt(),
version.getLastSyncedAt());
}
private StoreBindingView view(StoreApplicationEntity application) {
return new StoreBindingView(
application.getId(),
application.getAppKey(),
application.getPlatform(),
application.getStoreType(),
application.getPackageIdentifier(),
application.getOfficialAppId(),
application.getMarketUrl(),
application.getTargetRegion(),
application.isEnabled(),
application.getMonitorCredentialProfileId() != null,
application.getLastSyncStatus(),
application.getLastSyncError(),
application.getLastSyncedAt());
}
private void requireSupportedStore(AppStoreConfigEntity.StoreType storeType) {
if (storeType != AppStoreConfigEntity.StoreType.APP_STORE
&& storeType != AppStoreConfigEntity.StoreType.HARMONY_APP) {
throw new IllegalArgumentException("当前监测只支持 APP_STORE 和 HARMONY_APP");
}
}
private AppVersionEntity.Platform platformFor(AppStoreConfigEntity.StoreType storeType) {
return storeType == AppStoreConfigEntity.StoreType.APP_STORE
? AppVersionEntity.Platform.IOS
: AppVersionEntity.Platform.HARMONY;
}
private String blankToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private String safeError(Exception error) {
String message = error.getMessage();
if (message == null || message.isBlank()) return error.getClass().getSimpleName();
return message.length() <= 1024 ? message : message.substring(0, 1024);
}
}

查看文件

@ -0,0 +1,371 @@
package com.xuqm.update.store.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.store.entity.*;
import com.xuqm.update.store.model.StoreNotificationPolicyRequest;
import com.xuqm.update.store.model.StoreNotificationPolicyView;
import com.xuqm.update.store.repository.*;
import com.xuqm.update.store.spi.StoreSecretProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.client.RestTemplate;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*;
/**
* 商店事件通知的唯一实现
*
* <p>状态事件始终入库只有租户显式开启的邮件或 Webhook 才进入 Outbox
* 外部发送失败只影响该投递任务不得回滚商店状态或阻断租户平台</p>
*/
@Service
public class StoreNotificationService {
private static final Logger log = LoggerFactory.getLogger(StoreNotificationService.class);
private static final int MAX_ATTEMPTS = 6;
private static final TypeReference<List<String>> STRING_LIST = new TypeReference<>() {};
private static final TypeReference<Set<String>> STRING_SET = new TypeReference<>() {};
private final StoreNotificationPolicyRepository policyRepository;
private final StoreNotificationOutboxRepository outboxRepository;
private final StoreStateEventRepository eventRepository;
private final StoreApplicationRepository applicationRepository;
private final StoreVersionRepository versionRepository;
private final StoreSecretProvider secretProvider;
private final ObjectMapper objectMapper;
private final JavaMailSender mailSender;
private final RestTemplate restTemplate;
private final TransactionTemplate transactionTemplate;
private final String mailFrom;
public StoreNotificationService(
StoreNotificationPolicyRepository policyRepository,
StoreNotificationOutboxRepository outboxRepository,
StoreStateEventRepository eventRepository,
StoreApplicationRepository applicationRepository,
StoreVersionRepository versionRepository,
StoreSecretProvider secretProvider,
ObjectMapper objectMapper,
ObjectProvider<JavaMailSender> mailSenderProvider,
org.springframework.boot.web.client.RestTemplateBuilder restTemplateBuilder,
PlatformTransactionManager transactionManager,
@Value("${spring.mail.username:}") String mailFrom) {
this.policyRepository = policyRepository;
this.outboxRepository = outboxRepository;
this.eventRepository = eventRepository;
this.applicationRepository = applicationRepository;
this.versionRepository = versionRepository;
this.secretProvider = secretProvider;
this.objectMapper = objectMapper;
this.mailSender = mailSenderProvider.getIfAvailable();
this.restTemplate = restTemplateBuilder
.connectTimeout(java.time.Duration.ofSeconds(5))
.readTimeout(java.time.Duration.ofSeconds(10))
.build();
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.mailFrom = mailFrom;
}
@Transactional
public StoreNotificationPolicyView savePolicy(
String appKey, StoreNotificationPolicyRequest request) {
Objects.requireNonNull(request, "通知策略不能为空");
List<String> recipients = normalizeRecipients(request.emailRecipients());
if (request.emailEnabled() && recipients.isEmpty()) {
throw new IllegalArgumentException("启用邮件通知时至少配置一个收件人");
}
String webhookUrl = normalizeWebhookUrl(request.webhookUrl(), request.webhookEnabled());
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
StoreNotificationPolicyEntity policy = policyRepository.findByAppKey(appKey)
.orElseGet(StoreNotificationPolicyEntity::new);
if (policy.getId() == null) {
policy.setId(UUID.randomUUID().toString());
policy.setAppKey(appKey);
policy.setCreatedAt(now);
}
policy.setEmailEnabled(request.emailEnabled());
policy.setRecipientJson(writeJson(recipients));
policy.setWebhookEnabled(request.webhookEnabled());
policy.setWebhookUrl(webhookUrl);
policy.setEventTypesJson(writeJson(normalizeEventTypes(request.eventTypes())));
if (request.webhookSecret() != null && !request.webhookSecret().isBlank()) {
policy.setWebhookSecretRef(secretProvider.put(
policy.getWebhookSecretRef(),
Map.of("webhookSecret", request.webhookSecret())));
}
if (request.webhookEnabled() && policy.getWebhookSecretRef() == null) {
throw new IllegalArgumentException("启用 Webhook 通知时必须配置签名密钥");
}
policy.setUpdatedAt(now);
return view(policyRepository.save(policy));
}
@Transactional(readOnly = true)
public StoreNotificationPolicyView getPolicy(String appKey) {
return policyRepository.findByAppKey(appKey)
.map(this::view)
.orElseGet(() -> new StoreNotificationPolicyView(
false, List.of(), false, null, false, Set.of(), null));
}
/** 必须和状态事件在同一事务内调用,确保事件与待发送任务原子落库。 */
@Transactional
public void enqueue(StoreStateEventEntity event, String appKey) {
StoreNotificationPolicyEntity policy = policyRepository.findByAppKey(appKey).orElse(null);
if (policy == null || !accepts(policy, event.getEventType())) return;
if (policy.isEmailEnabled()) enqueue(event, appKey, StoreNotificationOutboxEntity.Channel.EMAIL);
if (policy.isWebhookEnabled()) enqueue(event, appKey, StoreNotificationOutboxEntity.Channel.WEBHOOK);
}
@Scheduled(fixedDelayString = "${xuqm.store-monitoring.notification-delay-ms:5000}")
public void deliverDueNotifications() {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
transactionTemplate.executeWithoutResult(status ->
outboxRepository.recoverStale(now.minusMinutes(5), now));
List<StoreNotificationOutboxEntity> due = outboxRepository
.findTop50ByStatusInAndNextAttemptAtLessThanEqualOrderByCreatedAtAsc(
List.of(StoreNotificationOutboxEntity.Status.PENDING,
StoreNotificationOutboxEntity.Status.RETRY),
now);
for (StoreNotificationOutboxEntity candidate : due) {
Boolean claimed = transactionTemplate.execute(status ->
outboxRepository.claim(candidate.getId(), now) == 1);
if (!Boolean.TRUE.equals(claimed)) continue;
deliver(candidate.getId());
}
}
private void deliver(String outboxId) {
StoreNotificationOutboxEntity outbox = outboxRepository.findById(outboxId).orElse(null);
if (outbox == null) return;
try {
StoreNotificationPolicyEntity policy = policyRepository.findByAppKey(outbox.getAppKey())
.orElseThrow(() -> new IllegalStateException("通知策略已删除"));
StoreStateEventEntity event = eventRepository.findById(outbox.getStoreEventId())
.orElseThrow(() -> new IllegalStateException("商店事件不存在"));
Map<String, Object> payload = payload(event);
if (outbox.getChannel() == StoreNotificationOutboxEntity.Channel.EMAIL) {
sendEmail(policy, payload);
} else {
sendWebhook(policy, event.getId(), payload);
}
finish(outboxId, null);
} catch (Exception error) {
finish(outboxId, safeError(error));
}
}
private void sendEmail(StoreNotificationPolicyEntity policy, Map<String, Object> payload) {
if (!policy.isEmailEnabled()) throw new IllegalStateException("邮件通知已关闭");
if (mailSender == null || mailFrom == null || mailFrom.isBlank()) {
throw new IllegalStateException("邮件服务未配置");
}
List<String> recipients = readList(policy.getRecipientJson());
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(mailFrom);
message.setTo(recipients.toArray(String[]::new));
message.setSubject("Xuqm 应用商店状态变更");
message.setText("""
应用%s
商店%s
版本%s (%s)
状态%s -> %s
事件%s
时间%s
""".formatted(
payload.get("appKey"), payload.get("storeType"),
payload.get("versionName"), payload.get("buildVersion"),
payload.get("previousState"), payload.get("currentState"),
payload.get("eventId"), payload.get("occurredAt")));
mailSender.send(message);
}
private void sendWebhook(StoreNotificationPolicyEntity policy,
String eventId,
Map<String, Object> payload) {
if (!policy.isWebhookEnabled()) throw new IllegalStateException("Webhook 通知已关闭");
String body = writeJson(payload);
String timestamp = String.valueOf(System.currentTimeMillis());
String secret = secretProvider.resolve(policy.getWebhookSecretRef()).get("webhookSecret");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Xuqm-Event-Id", eventId);
headers.set("X-Xuqm-Timestamp", timestamp);
headers.set("X-Xuqm-Signature", "sha256=" + hmac(secret, timestamp + "." + body));
ResponseEntity<Void> response = restTemplate.exchange(
URI.create(policy.getWebhookUrl()), HttpMethod.POST,
new HttpEntity<>(body, headers), Void.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new IllegalStateException("Webhook 返回 " + response.getStatusCode().value());
}
}
private Map<String, Object> payload(StoreStateEventEntity event) {
StoreApplicationEntity application = applicationRepository
.findById(event.getStoreApplicationId())
.orElseThrow(() -> new IllegalStateException("商店绑定不存在"));
StoreVersionEntity version = event.getStoreVersionId() == null ? null
: versionRepository.findById(event.getStoreVersionId()).orElse(null);
Map<String, Object> result = new LinkedHashMap<>();
result.put("schemaVersion", 1);
result.put("eventId", event.getId());
result.put("eventType", event.getEventType());
result.put("appKey", application.getAppKey());
result.put("platform", application.getPlatform().name());
result.put("storeType", application.getStoreType().name());
result.put("versionName", version == null ? null : version.getVersionName());
result.put("buildVersion", version == null ? null : version.getBuildVersion());
result.put("previousState", event.getPreviousState());
result.put("currentState", event.getCurrentState());
result.put("occurredAt", event.getOccurredAt());
return result;
}
@Transactional
protected void finish(String outboxId, String error) {
StoreNotificationOutboxEntity outbox = outboxRepository.findById(outboxId).orElse(null);
if (outbox == null) return;
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
if (error == null) {
outbox.setStatus(StoreNotificationOutboxEntity.Status.DELIVERED);
outbox.setLastError(null);
outbox.setNextAttemptAt(null);
} else {
int attempts = outbox.getAttemptCount() + 1;
outbox.setAttemptCount(attempts);
outbox.setLastError(error);
if (attempts >= MAX_ATTEMPTS) {
outbox.setStatus(StoreNotificationOutboxEntity.Status.DEAD);
outbox.setNextAttemptAt(null);
} else {
outbox.setStatus(StoreNotificationOutboxEntity.Status.RETRY);
outbox.setNextAttemptAt(now.plusSeconds(Math.min(3600, 30L << (attempts - 1))));
}
log.warn("Store notification delivery failed: id={} channel={} attempt={} reason={}",
outboxId, outbox.getChannel(), attempts, error);
}
outbox.setUpdatedAt(now);
outboxRepository.save(outbox);
}
private void enqueue(StoreStateEventEntity event,
String appKey,
StoreNotificationOutboxEntity.Channel channel) {
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
StoreNotificationOutboxEntity outbox = new StoreNotificationOutboxEntity();
outbox.setId(UUID.randomUUID().toString());
outbox.setAppKey(appKey);
outbox.setStoreEventId(event.getId());
outbox.setChannel(channel);
outbox.setStatus(StoreNotificationOutboxEntity.Status.PENDING);
outbox.setNextAttemptAt(now);
outbox.setCreatedAt(now);
outbox.setUpdatedAt(now);
outboxRepository.save(outbox);
}
private boolean accepts(StoreNotificationPolicyEntity policy, String eventType) {
Set<String> configured = readSet(policy.getEventTypesJson());
return configured.isEmpty() || configured.contains(eventType);
}
private StoreNotificationPolicyView view(StoreNotificationPolicyEntity policy) {
return new StoreNotificationPolicyView(
policy.isEmailEnabled(),
readList(policy.getRecipientJson()),
policy.isWebhookEnabled(),
policy.getWebhookUrl(),
policy.getWebhookSecretRef() != null,
readSet(policy.getEventTypesJson()),
policy.getUpdatedAt());
}
private List<String> normalizeRecipients(List<String> values) {
if (values == null) return List.of();
return values.stream().filter(Objects::nonNull).map(String::trim)
.filter(value -> !value.isBlank()).distinct().toList();
}
private Set<String> normalizeEventTypes(Set<String> values) {
if (values == null) return Set.of();
TreeSet<String> normalized = new TreeSet<>();
values.stream().filter(Objects::nonNull).map(String::trim)
.filter(value -> !value.isBlank()).forEach(normalized::add);
return Collections.unmodifiableSet(normalized);
}
private String normalizeWebhookUrl(String value, boolean enabled) {
if (value == null || value.isBlank()) {
if (enabled) throw new IllegalArgumentException("启用 Webhook 时必须配置 URL");
return null;
}
URI uri = URI.create(value.trim());
if (!"https".equalsIgnoreCase(uri.getScheme())
&& !"http".equalsIgnoreCase(uri.getScheme())) {
throw new IllegalArgumentException("Webhook URL 仅支持 HTTP/HTTPS");
}
return uri.toString();
}
private String hmac(String secret, String value) {
if (secret == null || secret.isBlank()) {
throw new IllegalStateException("Webhook 签名密钥不存在");
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("Webhook 签名失败", e);
}
}
private List<String> readList(String value) {
if (value == null || value.isBlank()) return List.of();
try {
return List.copyOf(objectMapper.readValue(value, STRING_LIST));
} catch (Exception e) {
throw new IllegalStateException("通知收件人配置损坏", e);
}
}
private Set<String> readSet(String value) {
if (value == null || value.isBlank()) return Set.of();
try {
return Set.copyOf(objectMapper.readValue(value, STRING_SET));
} catch (Exception e) {
throw new IllegalStateException("通知事件类型配置损坏", e);
}
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (Exception e) {
throw new IllegalArgumentException("通知配置无法序列化", e);
}
}
private String safeError(Exception error) {
String message = error.getMessage();
String value = message == null || message.isBlank()
? error.getClass().getSimpleName() : message;
return value.length() <= 1024 ? value : value.substring(0, 1024);
}
}

查看文件

@ -0,0 +1,86 @@
package com.xuqm.update.store.service;
import com.xuqm.update.store.model.StoreVersionState;
import java.util.Locale;
import java.util.Set;
/**
* 官方状态到统一状态的唯一映射入口
*
* <p>未知状态必须返回 UNKNOWN不能猜测为审核中或已上架</p>
*/
public final class StoreStateMapper {
private StoreStateMapper() {}
private static final Set<String> APPLE_AVAILABLE = Set.of(
"READY_FOR_DISTRIBUTION", "READY_FOR_SALE");
private static final Set<String> APPLE_PROCESSING = Set.of(
"PROCESSING_FOR_DISTRIBUTION");
private static final Set<String> APPLE_WAITING = Set.of(
"READY_FOR_REVIEW", "WAITING_FOR_REVIEW");
private static final Set<String> APPLE_REVIEWING = Set.of("IN_REVIEW");
private static final Set<String> APPLE_REJECTED = Set.of(
"REJECTED", "METADATA_REJECTED", "INVALID_BINARY", "DEVELOPER_REJECTED");
public static StoreVersionState apple(String rawState, boolean chinaAvailable) {
String state = normalize(rawState);
if (chinaAvailable && APPLE_AVAILABLE.contains(state)) {
return StoreVersionState.AVAILABLE;
}
if (APPLE_AVAILABLE.contains(state)) {
return StoreVersionState.PARTIALLY_AVAILABLE;
}
if (APPLE_PROCESSING.contains(state)) {
return StoreVersionState.PROCESSING_DISTRIBUTION;
}
if ("PENDING_DEVELOPER_RELEASE".equals(state) || "ACCEPTED".equals(state)) {
return StoreVersionState.APPROVED_PENDING_RELEASE;
}
if ("PENDING_APPLE_RELEASE".equals(state)) {
return StoreVersionState.PROCESSING_DISTRIBUTION;
}
if (APPLE_WAITING.contains(state)) {
return StoreVersionState.WAITING_FOR_REVIEW;
}
if (APPLE_REVIEWING.contains(state)) {
return StoreVersionState.IN_REVIEW;
}
if (APPLE_REJECTED.contains(state)) {
return StoreVersionState.REJECTED;
}
if ("PREPARE_FOR_SUBMISSION".equals(state)
|| "WAITING_FOR_EXPORT_COMPLIANCE".equals(state)) {
return StoreVersionState.PREPARING;
}
if ("REPLACED_WITH_NEW_VERSION".equals(state)) {
return StoreVersionState.REMOVED;
}
return StoreVersionState.UNKNOWN;
}
/**
* 华为 Connect API releaseState 在不同应用形态下可能返回数字或文本
* 适配器必须保留原值这里只映射官方已知值
*/
public static StoreVersionState harmony(String rawState, boolean chinaAvailable) {
String state = normalize(rawState);
if (Set.of("1", "3", "ON_SHELF", "PUBLISHED").contains(state)) {
return chinaAvailable ? StoreVersionState.AVAILABLE : StoreVersionState.PARTIALLY_AVAILABLE;
}
if (Set.of("2", "UNDER_REVIEW", "IN_REVIEW").contains(state)) {
return StoreVersionState.IN_REVIEW;
}
if (Set.of("4", "REJECTED").contains(state)) {
return StoreVersionState.REJECTED;
}
if (Set.of("APPROVED", "PENDING_RELEASE").contains(state)) {
return StoreVersionState.APPROVED_PENDING_RELEASE;
}
return StoreVersionState.UNKNOWN;
}
private static String normalize(String state) {
return state == null ? "" : state.trim().toUpperCase(Locale.ROOT);
}
}

查看文件

@ -0,0 +1,138 @@
package com.xuqm.update.store.service;
import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.service.AppReleaseSequenceService;
import com.xuqm.update.store.entity.*;
import com.xuqm.update.store.model.StoreUpdateDraftView;
import com.xuqm.update.store.model.StoreVersionState;
import com.xuqm.update.store.repository.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
/** 将中国大陆已上架的官方版本显式转换为可编辑的 Xuqm 更新草稿。 */
@Service
public class StoreUpdateDraftService {
private static final String TARGET_REGION = "CHN";
private final StoreApplicationRepository applicationRepository;
private final StoreVersionRepository storeVersionRepository;
private final StoreRegionAvailabilityRepository availabilityRepository;
private final StoreUpdateLinkRepository linkRepository;
private final AppVersionRepository appVersionRepository;
private final AppReleaseSequenceService sequenceService;
public StoreUpdateDraftService(
StoreApplicationRepository applicationRepository,
StoreVersionRepository storeVersionRepository,
StoreRegionAvailabilityRepository availabilityRepository,
StoreUpdateLinkRepository linkRepository,
AppVersionRepository appVersionRepository,
AppReleaseSequenceService sequenceService) {
this.applicationRepository = applicationRepository;
this.storeVersionRepository = storeVersionRepository;
this.availabilityRepository = availabilityRepository;
this.linkRepository = linkRepository;
this.appVersionRepository = appVersionRepository;
this.sequenceService = sequenceService;
}
@Transactional
public StoreUpdateDraftView createDraft(
String appKey,
String storeApplicationId,
String storeVersionId,
String operator) {
StoreApplicationEntity application = applicationRepository.findById(storeApplicationId)
.filter(item -> item.getAppKey().equals(appKey))
.orElseThrow(() -> new IllegalArgumentException("商店监测绑定不存在"));
StoreVersionEntity storeVersion = storeVersionRepository.findLockedById(storeVersionId)
.filter(item -> item.getStoreApplicationId().equals(storeApplicationId))
.orElseThrow(() -> new IllegalArgumentException("商店版本不存在"));
requireAvailableInChina(storeVersion);
StoreUpdateLinkEntity existingLink = linkRepository
.findByStoreVersionIdAndActiveTrue(storeVersionId)
.orElse(null);
if (existingLink != null) {
AppVersionEntity existing = appVersionRepository.findById(existingLink.getAppVersionId())
.orElseThrow(() -> new IllegalStateException("商店版本关联的更新草稿不存在"));
return view(storeVersionId, existing);
}
if (application.getMarketUrl() == null || application.getMarketUrl().isBlank()) {
throw new IllegalArgumentException("请先配置中国大陆应用商店地址");
}
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
AppVersionEntity draft = new AppVersionEntity();
draft.setId(UUID.randomUUID().toString());
draft.setAppKey(appKey);
draft.setPlatform(application.getPlatform());
draft.setVersionName(storeVersion.getVersionName());
draft.setBuildVersion(storeVersion.getBuildVersion());
draft.setVersionCode(null);
draft.setReleaseSequence(sequenceService.next(appKey, application.getPlatform()));
draft.setUpdateSource(AppVersionEntity.UpdateSource.EXTERNAL_STORE);
draft.setInstallMode(AppVersionEntity.InstallMode.STORE_REDIRECT);
draft.setBuildId("store:" + storeVersion.getOfficialVersionId());
draft.setDownloadUrl(null);
draft.setChangeLog("");
draft.setForceUpdate(false);
draft.setPublishStatus(AppVersionEntity.PublishStatus.DRAFT);
draft.setStoreSubmitMode("MANUAL");
draft.setGrayEnabled(false);
draft.setGrayPercent(0);
draft.setGrayMode(AppVersionEntity.GrayMode.PERCENT);
draft.setPackageName(application.getPackageIdentifier());
draft.setFileSize(0);
draft.setCreatedAt(now);
if (application.getPlatform() == AppVersionEntity.Platform.IOS) {
draft.setAppStoreUrl(application.getMarketUrl());
} else {
draft.setMarketUrl(application.getMarketUrl());
}
AppVersionEntity saved = appVersionRepository.save(draft);
StoreUpdateLinkEntity link = new StoreUpdateLinkEntity();
link.setId(UUID.randomUUID().toString());
link.setStoreVersionId(storeVersionId);
link.setAppVersionId(saved.getId());
link.setCreatedBy(operator);
link.setCreatedAt(now);
link.setActive(true);
linkRepository.save(link);
return view(storeVersionId, saved);
}
private void requireAvailableInChina(StoreVersionEntity version) {
if (version.getNormalizedState() != StoreVersionState.AVAILABLE) {
throw new IllegalArgumentException("只有已上架版本可以创建更新草稿");
}
boolean available = availabilityRepository
.findByStoreVersionIdAndRegionCode(version.getId(), TARGET_REGION)
.map(item -> item.getAvailabilityState()
== StoreRegionAvailabilityEntity.AvailabilityState.AVAILABLE)
.orElse(false);
if (!available) {
throw new IllegalArgumentException("该版本尚未在中国大陆可下载");
}
}
private StoreUpdateDraftView view(String storeVersionId, AppVersionEntity version) {
String url = version.getPlatform() == AppVersionEntity.Platform.IOS
? version.getAppStoreUrl() : version.getMarketUrl();
return new StoreUpdateDraftView(
storeVersionId,
version.getId(),
version.getPlatform(),
version.getVersionName(),
version.getBuildVersion(),
version.getReleaseSequence(),
version.getPublishStatus(),
url);
}
}

查看文件

@ -0,0 +1,16 @@
package com.xuqm.update.store.spi;
import com.xuqm.update.entity.AppStoreConfigEntity;
import com.xuqm.update.store.model.StoreInventorySnapshot;
import com.xuqm.update.store.model.StoreMonitorRequest;
/**
* 官方应用商店只读适配器
*
* <p>提交审核立即发布等写操作不属于当前迭代禁止在此接口伪实现</p>
*/
public interface StoreInventoryAdapter {
AppStoreConfigEntity.StoreType storeType();
StoreInventorySnapshot fetch(StoreMonitorRequest request);
}

查看文件

@ -0,0 +1,10 @@
package com.xuqm.update.store.spi;
import java.util.Map;
/** 商店 Secrets 存储边界;业务表和前端只能持有返回的引用。 */
public interface StoreSecretProvider {
String put(String previousRef, Map<String, String> secret);
Map<String, String> resolve(String secretRef);
void delete(String secretRef);
}

查看文件

@ -5,9 +5,9 @@ spring:
application:
name: update-service
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://39.107.53.187:3306/xuqm_update?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true}
username: ${SPRING_DATASOURCE_USERNAME:xuqm}
password: ${SPRING_DATASOURCE_PASSWORD:Xuqm@2026}
url: ${SPRING_DATASOURCE_URL}
username: ${SPRING_DATASOURCE_USERNAME}
password: ${SPRING_DATASOURCE_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
minimum-idle: 5
@ -17,23 +17,48 @@ spring:
max-lifetime: 900000
jpa:
hibernate:
ddl-auto: update
ddl-auto: validate
show-sql: false
servlet:
multipart:
max-file-size: 200MB
max-request-size: 200MB
flyway:
enabled: false
enabled: true
baseline-on-migrate: true
baseline-version: 3
validate-on-migrate: true
mail:
host: ${SMTP_HOST:}
port: ${SMTP_PORT:465}
username: ${SMTP_USERNAME:}
password: ${SMTP_PASSWORD:}
properties:
mail:
smtp:
auth: ${SMTP_AUTH:true}
starttls:
enable: ${SMTP_TLS:true}
ssl:
enable: ${SMTP_SSL:true}
jwt:
secret: ${XUQM_JWT_SECRET:xuqm-tenant-service-secret-key-must-be-at-least-256-bits-long-for-hmac}
secret: ${XUQM_JWT_SECRET}
expiration: 3153600000000
update:
upload-dir: ${UPDATE_UPLOAD_DIR:/tmp/xuqm-update}
base-url: ${UPDATE_BASE_URL:https://update.dev.xuqinmin.com}
xuqm:
store-secrets:
# 32 字节随机密钥的 Base64。未配置时允许只读启动,但保存/读取商店凭据会失败。
master-key: ${XUQM_STORE_SECRET_MASTER_KEY:}
key-version: ${XUQM_STORE_SECRET_KEY_VERSION:v1}
store-monitoring:
poll-delay-ms: ${XUQM_STORE_MONITORING_POLL_DELAY_MS:600000}
notification-delay-ms: ${XUQM_STORE_NOTIFICATION_DELAY_MS:5000}
sdk:
tenant-service-url: ${SDK_TENANT_SERVICE_URL:http://xuqm-tenant-service:9001}
internal-token: ${SDK_INTERNAL_TOKEN:}

查看文件

@ -0,0 +1,92 @@
-- 灰度模式数据迁移:旧枚举值统一为 MEMBERS
UPDATE update_app_version
SET gray_mode = 'MEMBERS'
WHERE gray_mode IN ('IM_PUSH_USERS', 'CUSTOMER_SYNC', 'CUSTOMER_CALLBACK');
UPDATE update_rn_bundle
SET gray_mode = 'MEMBERS'
WHERE gray_mode IN ('IM_PUSH_USERS', 'CUSTOMER_SYNC', 'CUSTOMER_CALLBACK');
-- 清理 JPA ddl-auto 遗留的 gray_callback_url 列
-- 使用 prepared statement 处理列不存在的情况
SET @col_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'update_app_version'
AND column_name = 'gray_callback_url'
);
SET @sql = IF(@col_exists > 0,
'ALTER TABLE update_app_version DROP COLUMN gray_callback_url',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @col_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'update_rn_bundle'
AND column_name = 'gray_callback_url'
);
SET @sql = IF(@col_exists > 0,
'ALTER TABLE update_rn_bundle DROP COLUMN gray_callback_url',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- App 版本表补齐历史上由 Hibernate 自动建出的字段。
SET @col_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'update_app_version'
AND column_name = 'build_id'
);
SET @sql = IF(@col_exists = 0,
'ALTER TABLE update_app_version ADD COLUMN build_id VARCHAR(128) NULL AFTER version_code',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @col_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'update_app_version'
AND column_name = 'file_size'
);
SET @sql = IF(@col_exists = 0,
'ALTER TABLE update_app_version ADD COLUMN file_size BIGINT NOT NULL DEFAULT 0 AFTER apk_hash',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 健康/熔断记录表
CREATE TABLE IF NOT EXISTS update_module_health (
id VARCHAR(255) NOT NULL PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
module_id VARCHAR(64) NOT NULL,
module_version VARCHAR(64) NOT NULL,
build_id VARCHAR(128),
platform VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'HEALTHY',
failure_code VARCHAR(32),
failure_count INT NOT NULL DEFAULT 0,
last_check_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL,
INDEX idx_health_app_module (app_key, module_id, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 灰度目录快照表(记录发版时的灰度成员列表)
CREATE TABLE IF NOT EXISTS update_gray_snapshot (
id VARCHAR(255) NOT NULL PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
version_id VARCHAR(255) NOT NULL,
version_type VARCHAR(16) NOT NULL,
gray_mode VARCHAR(24) NOT NULL,
gray_percent INT,
member_ids TEXT,
snapshot_at DATETIME(6) NOT NULL,
INDEX idx_snapshot_version (app_key, version_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

查看文件

@ -0,0 +1,155 @@
-- 应用商店监测领域模型。
-- 商店实际状态与 Xuqm 更新版本相互独立,禁止再把多个商店状态写入
-- update_app_version.store_review_status JSON。
CREATE TABLE update_store_application (
id VARCHAR(64) NOT NULL PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
platform VARCHAR(16) NOT NULL,
store_type VARCHAR(24) NOT NULL,
package_identifier VARCHAR(256) NOT NULL,
official_app_id VARCHAR(128),
market_url VARCHAR(512),
target_region VARCHAR(8) NOT NULL DEFAULT 'CHN',
monitor_credential_profile_id VARCHAR(64),
publish_credential_profile_id VARCHAR(64),
enabled BIT(1) NOT NULL DEFAULT 1,
last_sync_status VARCHAR(24),
last_sync_error VARCHAR(1024),
last_synced_at DATETIME(6),
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_store_application (app_key, store_type),
INDEX idx_store_application_sync (enabled, last_synced_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_credential_profile (
id VARCHAR(64) NOT NULL PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
store_type VARCHAR(24) NOT NULL,
purpose VARCHAR(16) NOT NULL,
display_name VARCHAR(128) NOT NULL,
secret_ref VARCHAR(256) NOT NULL,
key_hint VARCHAR(128),
capabilities_json TEXT,
enabled BIT(1) NOT NULL DEFAULT 1,
verified_at DATETIME(6),
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_store_credential_purpose (app_key, store_type, purpose)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_secret (
secret_ref VARCHAR(256) NOT NULL PRIMARY KEY,
ciphertext TEXT NOT NULL,
nonce VARCHAR(64) NOT NULL,
key_version VARCHAR(32) NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_version (
id VARCHAR(64) NOT NULL PRIMARY KEY,
store_application_id VARCHAR(64) NOT NULL,
official_version_id VARCHAR(128) NOT NULL,
version_name VARCHAR(64) NOT NULL,
build_version VARCHAR(64),
source VARCHAR(24) NOT NULL,
normalized_state VARCHAR(32) NOT NULL,
raw_state VARCHAR(64),
release_sequence BIGINT,
state_occurred_at DATETIME(6),
discovered_at DATETIME(6) NOT NULL,
last_synced_at DATETIME(6) NOT NULL,
raw_snapshot_json TEXT,
UNIQUE KEY uk_store_version (store_application_id, official_version_id),
INDEX idx_store_version_state (store_application_id, normalized_state),
CONSTRAINT fk_store_version_application
FOREIGN KEY (store_application_id) REFERENCES update_store_application(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_region_availability (
id VARCHAR(64) NOT NULL PRIMARY KEY,
store_version_id VARCHAR(64) NOT NULL,
region_code VARCHAR(8) NOT NULL,
availability_state VARCHAR(32) NOT NULL,
raw_state VARCHAR(64),
checked_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_store_version_region (store_version_id, region_code),
CONSTRAINT fk_store_region_version
FOREIGN KEY (store_version_id) REFERENCES update_store_version(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_state_event (
id VARCHAR(64) NOT NULL PRIMARY KEY,
store_application_id VARCHAR(64) NOT NULL,
store_version_id VARCHAR(64),
official_event_id VARCHAR(192),
source VARCHAR(16) NOT NULL,
event_type VARCHAR(64) NOT NULL,
previous_state VARCHAR(32),
current_state VARCHAR(32),
raw_state VARCHAR(64),
occurred_at DATETIME(6) NOT NULL,
received_at DATETIME(6) NOT NULL,
payload_digest VARCHAR(64),
detail_json TEXT,
UNIQUE KEY uk_store_official_event (store_application_id, official_event_id),
INDEX idx_store_event_timeline (store_application_id, occurred_at),
CONSTRAINT fk_store_event_application
FOREIGN KEY (store_application_id) REFERENCES update_store_application(id)
ON DELETE CASCADE,
CONSTRAINT fk_store_event_version
FOREIGN KEY (store_version_id) REFERENCES update_store_version(id)
ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_update_link (
id VARCHAR(64) NOT NULL PRIMARY KEY,
store_version_id VARCHAR(64) NOT NULL,
app_version_id VARCHAR(255) NOT NULL,
created_by VARCHAR(128),
created_at DATETIME(6) NOT NULL,
active BIT(1) NOT NULL DEFAULT 1,
UNIQUE KEY uk_store_version_active_link (store_version_id, active),
CONSTRAINT fk_store_link_version
FOREIGN KEY (store_version_id) REFERENCES update_store_version(id)
ON DELETE CASCADE,
CONSTRAINT fk_store_link_app_version
FOREIGN KEY (app_version_id) REFERENCES update_app_version(id)
ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_notification_policy (
id VARCHAR(64) NOT NULL PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
email_enabled BIT(1) NOT NULL DEFAULT 0,
webhook_enabled BIT(1) NOT NULL DEFAULT 0,
recipient_json TEXT,
webhook_url VARCHAR(512),
webhook_secret_ref VARCHAR(256),
event_types_json TEXT,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_store_notification_policy (app_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE update_store_notification_outbox (
id VARCHAR(64) NOT NULL PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
store_event_id VARCHAR(64) NOT NULL,
channel VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at DATETIME(6),
last_error VARCHAR(1024),
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_store_notification_delivery (store_event_id, channel),
INDEX idx_store_notification_due (status, next_attempt_at),
CONSTRAINT fk_store_notification_event
FOREIGN KEY (store_event_id) REFERENCES update_store_state_event(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

查看文件

@ -0,0 +1,40 @@
-- App 更新版本改为平台原生版本标识。
-- Android 继续使用 version_code;iOS/Harmony 使用 build_version。
ALTER TABLE update_app_version
MODIFY COLUMN version_code INT NULL,
ADD COLUMN build_version VARCHAR(64) NULL AFTER version_code,
ADD COLUMN release_sequence BIGINT NULL AFTER build_version,
ADD COLUMN update_source VARCHAR(24) NOT NULL DEFAULT 'UPLOADED_PACKAGE' AFTER release_sequence,
ADD COLUMN install_mode VARCHAR(24) NOT NULL DEFAULT 'DIRECT_PACKAGE' AFTER update_source;
UPDATE update_app_version
SET build_version = CAST(version_code AS CHAR)
WHERE build_version IS NULL
AND version_code IS NOT NULL;
-- 历史数据没有统一发布序列,以创建时间和旧 versionCode 生成稳定排序值。
UPDATE update_app_version
SET release_sequence = UNIX_TIMESTAMP(created_at) * 100000
+ COALESCE(version_code, 0)
WHERE release_sequence IS NULL;
UPDATE update_app_version
SET install_mode = CASE
WHEN platform = 'ANDROID' THEN 'DIRECT_PACKAGE'
ELSE 'STORE_REDIRECT'
END;
ALTER TABLE update_app_version
MODIFY COLUMN release_sequence BIGINT NOT NULL;
CREATE INDEX idx_app_version_release_sequence
ON update_app_version (app_key, platform, release_sequence);
CREATE TABLE update_app_release_sequence (
id VARCHAR(96) NOT NULL PRIMARY KEY,
app_key VARCHAR(64) NOT NULL,
platform VARCHAR(16) NOT NULL,
current_value BIGINT NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_app_release_sequence (app_key, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

查看文件

@ -0,0 +1,54 @@
package com.xuqm.update.store.adapter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.store.model.StoreVersionState;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.client.RestTemplateBuilder;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AppleStoreInventoryAdapterTest {
private final ObjectMapper mapper = new ObjectMapper();
private final AppleStoreInventoryAdapter adapter =
new AppleStoreInventoryAdapter(new RestTemplateBuilder(), mapper);
@Test
void parsesLiveAndPendingDeveloperReleaseAsDifferentStates() throws Exception {
String json = """
{
"data": [
{
"type": "appStoreVersions",
"id": "version-live",
"attributes": {
"versionString": "8.0.0",
"appStoreState": "READY_FOR_DISTRIBUTION"
},
"relationships": {"build": {"data": {"type": "builds", "id": "build-1"}}}
},
{
"type": "appStoreVersions",
"id": "version-approved",
"attributes": {
"versionString": "8.0.1",
"appStoreState": "PENDING_DEVELOPER_RELEASE"
},
"relationships": {"build": {"data": {"type": "builds", "id": "build-2"}}}
}
],
"included": [
{"type": "builds", "id": "build-1", "attributes": {"version": "100"}},
{"type": "builds", "id": "build-2", "attributes": {"version": "101"}}
]
}
""";
var inventory = adapter.parseInventory("apple-app-id", mapper.readTree(json), true);
assertEquals(2, inventory.versions().size());
assertEquals(StoreVersionState.AVAILABLE, inventory.versions().get(0).state());
assertEquals("100", inventory.versions().get(0).buildVersion());
assertEquals(StoreVersionState.APPROVED_PENDING_RELEASE,
inventory.versions().get(1).state());
}
}

查看文件

@ -0,0 +1,37 @@
package com.xuqm.update.store.adapter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.store.model.StoreVersionState;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.client.RestTemplateBuilder;
import static org.junit.jupiter.api.Assertions.assertEquals;
class HarmonyStoreInventoryAdapterTest {
private final ObjectMapper mapper = new ObjectMapper();
private final HarmonyStoreInventoryAdapter adapter =
new HarmonyStoreInventoryAdapter(new RestTemplateBuilder(), mapper);
@Test
void keepsOnlineAndReviewVersionsIndependent() throws Exception {
String json = """
{
"ret": {"code": 0},
"appInfo": {
"appId": "harmony-app-id",
"onShelfVersionName": "8.0.0",
"onShelfVersionCode": "80000",
"versionName": "8.0.1",
"versionCode": "80001",
"releaseState": 2
}
}
""";
var inventory = adapter.parseInventory(mapper.readTree(json));
assertEquals(2, inventory.versions().size());
assertEquals(StoreVersionState.AVAILABLE, inventory.versions().get(0).state());
assertEquals(StoreVersionState.IN_REVIEW, inventory.versions().get(1).state());
}
}

查看文件

@ -0,0 +1,78 @@
package com.xuqm.update.store.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xuqm.update.store.entity.StoreNotificationOutboxEntity;
import com.xuqm.update.store.entity.StoreNotificationPolicyEntity;
import com.xuqm.update.store.entity.StoreStateEventEntity;
import com.xuqm.update.store.repository.*;
import com.xuqm.update.store.spi.StoreSecretProvider;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.transaction.PlatformTransactionManager;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class StoreNotificationServiceTest {
@Test
void notificationIsNotQueuedWithoutExplicitPolicy() {
Fixture fixture = new Fixture();
when(fixture.policyRepository.findByAppKey("app")).thenReturn(Optional.empty());
fixture.service.enqueue(event(), "app");
verifyNoInteractions(fixture.outboxRepository);
}
@Test
void explicitlyEnabledChannelsAreQueuedIndependently() {
Fixture fixture = new Fixture();
StoreNotificationPolicyEntity policy = new StoreNotificationPolicyEntity();
policy.setAppKey("app");
policy.setEmailEnabled(true);
policy.setWebhookEnabled(true);
when(fixture.policyRepository.findByAppKey("app")).thenReturn(Optional.of(policy));
fixture.service.enqueue(event(), "app");
verify(fixture.outboxRepository, times(2)).save(any(StoreNotificationOutboxEntity.class));
}
private StoreStateEventEntity event() {
StoreStateEventEntity event = new StoreStateEventEntity();
event.setId("event");
event.setEventType("STORE_STATE_CHANGED");
return event;
}
@SuppressWarnings("unchecked")
private static final class Fixture {
private final StoreNotificationPolicyRepository policyRepository =
mock(StoreNotificationPolicyRepository.class);
private final StoreNotificationOutboxRepository outboxRepository =
mock(StoreNotificationOutboxRepository.class);
private final StoreNotificationService service;
private Fixture() {
ObjectProvider<JavaMailSender> mailProvider = mock(ObjectProvider.class);
when(mailProvider.getIfAvailable()).thenReturn(null);
service = new StoreNotificationService(
policyRepository,
outboxRepository,
mock(StoreStateEventRepository.class),
mock(StoreApplicationRepository.class),
mock(StoreVersionRepository.class),
mock(StoreSecretProvider.class),
new ObjectMapper(),
mailProvider,
new RestTemplateBuilder(),
mock(PlatformTransactionManager.class),
"");
}
}
}

查看文件

@ -0,0 +1,35 @@
package com.xuqm.update.store.service;
import com.xuqm.update.store.model.StoreVersionState;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class StoreStateMapperTest {
@Test
void appleSeparatesApprovalDistributionAndChinaAvailability() {
assertEquals(StoreVersionState.APPROVED_PENDING_RELEASE,
StoreStateMapper.apple("PENDING_DEVELOPER_RELEASE", false));
assertEquals(StoreVersionState.PROCESSING_DISTRIBUTION,
StoreStateMapper.apple("PROCESSING_FOR_DISTRIBUTION", false));
assertEquals(StoreVersionState.PARTIALLY_AVAILABLE,
StoreStateMapper.apple("READY_FOR_DISTRIBUTION", false));
assertEquals(StoreVersionState.AVAILABLE,
StoreStateMapper.apple("READY_FOR_DISTRIBUTION", true));
}
@Test
void appleAndHarmonyNeverGuessUnknownStates() {
assertEquals(StoreVersionState.UNKNOWN, StoreStateMapper.apple("NEW_VENDOR_STATE", false));
assertEquals(StoreVersionState.UNKNOWN, StoreStateMapper.harmony("99", false));
}
@Test
void harmonyRequiresChinaAvailabilityForAvailableState() {
assertEquals(StoreVersionState.PARTIALLY_AVAILABLE,
StoreStateMapper.harmony("1", false));
assertEquals(StoreVersionState.AVAILABLE,
StoreStateMapper.harmony("1", true));
}
}

查看文件

@ -0,0 +1,101 @@
package com.xuqm.update.store.service;
import com.xuqm.update.entity.AppVersionEntity;
import com.xuqm.update.repository.AppVersionRepository;
import com.xuqm.update.service.AppReleaseSequenceService;
import com.xuqm.update.store.entity.*;
import com.xuqm.update.store.model.StoreVersionState;
import com.xuqm.update.store.repository.*;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class StoreUpdateDraftServiceTest {
@Test
void rejectsVersionThatIsNotAvailableInChina() {
Fixture fixture = new Fixture(StoreVersionState.IN_REVIEW, false);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> fixture.service.createDraft("app", "binding", "store-version", "operator"));
assertTrue(error.getMessage().contains("已上架"));
verifyNoInteractions(fixture.appVersionRepository);
}
@Test
void createsStoreRedirectDraftWithoutFakeAndroidVersionCode() {
Fixture fixture = new Fixture(StoreVersionState.AVAILABLE, true);
when(fixture.sequenceService.next("app", AppVersionEntity.Platform.IOS)).thenReturn(12L);
when(fixture.appVersionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(fixture.linkRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
var view = fixture.service.createDraft(
"app", "binding", "store-version", "operator");
assertEquals("5.0.0", view.versionName());
assertEquals("2026072801", view.buildVersion());
assertEquals(12L, view.releaseSequence());
verify(fixture.appVersionRepository).save(argThat(version ->
version.getVersionCode() == null
&& version.getInstallMode() == AppVersionEntity.InstallMode.STORE_REDIRECT
&& version.getUpdateSource() == AppVersionEntity.UpdateSource.EXTERNAL_STORE));
}
private static final class Fixture {
private final StoreApplicationRepository applicationRepository =
mock(StoreApplicationRepository.class);
private final StoreVersionRepository storeVersionRepository =
mock(StoreVersionRepository.class);
private final StoreRegionAvailabilityRepository availabilityRepository =
mock(StoreRegionAvailabilityRepository.class);
private final StoreUpdateLinkRepository linkRepository =
mock(StoreUpdateLinkRepository.class);
private final AppVersionRepository appVersionRepository =
mock(AppVersionRepository.class);
private final AppReleaseSequenceService sequenceService =
mock(AppReleaseSequenceService.class);
private final StoreUpdateDraftService service;
private Fixture(StoreVersionState state, boolean chinaAvailable) {
StoreApplicationEntity application = new StoreApplicationEntity();
application.setId("binding");
application.setAppKey("app");
application.setPlatform(AppVersionEntity.Platform.IOS);
application.setPackageIdentifier("com.example.app");
application.setMarketUrl("https://apps.apple.com/cn/app/id1");
StoreVersionEntity version = new StoreVersionEntity();
version.setId("store-version");
version.setStoreApplicationId("binding");
version.setVersionName("5.0.0");
version.setBuildVersion("2026072801");
version.setNormalizedState(state);
StoreRegionAvailabilityEntity availability = new StoreRegionAvailabilityEntity();
availability.setAvailabilityState(chinaAvailable
? StoreRegionAvailabilityEntity.AvailabilityState.AVAILABLE
: StoreRegionAvailabilityEntity.AvailabilityState.NOT_AVAILABLE);
availability.setCheckedAt(LocalDateTime.now());
when(applicationRepository.findById("binding")).thenReturn(Optional.of(application));
when(storeVersionRepository.findLockedById("store-version")).thenReturn(Optional.of(version));
when(availabilityRepository.findByStoreVersionIdAndRegionCode("store-version", "CHN"))
.thenReturn(Optional.of(availability));
when(linkRepository.findByStoreVersionIdAndActiveTrue("store-version"))
.thenReturn(Optional.empty());
service = new StoreUpdateDraftService(
applicationRepository,
storeVersionRepository,
availabilityRepository,
linkRepository,
appVersionRepository,
sequenceService);
}
}
}

查看文件

@ -0,0 +1 @@
mock-maker-subclass