feat(update): add official store monitoring domain
这个提交包含在:
父节点
1665dbc735
当前提交
f96a27facc
162
docs/STORE_MONITORING_V3.md
普通文件
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,33 +107,69 @@ 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);
|
||||
|
||||
if (latest.isEmpty()) {
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
||||
}
|
||||
|
||||
AppVersionEntity v = latest.get();
|
||||
|
||||
if (v.isGrayEnabled()) {
|
||||
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
|
||||
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)));
|
||||
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();
|
||||
}
|
||||
}
|
||||
} else if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) {
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
||||
}
|
||||
|
||||
// 按版本号降序遍历,跳过灰度版本中用户不在名单内的情况,找到第一个用户可用的版本
|
||||
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)));
|
||||
}
|
||||
|
||||
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()
|
||||
: appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);
|
||||
@ -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 原生 versionCode。iOS/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
|
||||
正在加载...
在新工单中引用
屏蔽一个用户