diff --git a/docs/STORE_MONITORING_V3.md b/docs/STORE_MONITORING_V3.md new file mode 100644 index 0000000..c3f93a9 --- /dev/null +++ b/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=`。签名原文为 +`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 的直接通知类已删除。 diff --git a/update-service/pom.xml b/update-service/pom.xml index 3dd298d..bbb978a 100644 --- a/update-service/pom.xml +++ b/update-service/pom.xml @@ -40,6 +40,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-mail + org.springframework.boot spring-boot-starter-actuator diff --git a/update-service/src/main/java/com/xuqm/update/config/SchemaMigrationRunner.java b/update-service/src/main/java/com/xuqm/update/config/SchemaMigrationRunner.java deleted file mode 100644 index 4d3b1c6..0000000 --- a/update-service/src/main/java/com/xuqm/update/config/SchemaMigrationRunner.java +++ /dev/null @@ -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); - } - } -} diff --git a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java index 83b9d7a..0286746 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/AppVersionController.java @@ -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>> 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 latest = versionRepository - .findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( - resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode); - Optional 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 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 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 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 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 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> 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 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 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 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 versionIdentity(AppVersionEntity version) { + Map 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 接口获取最新信息, diff --git a/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java b/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java index 6cfeadc..c51e0b9 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/PublicDownloadController.java @@ -80,7 +80,7 @@ public class PublicDownloadController { } private Map androidInfo(String appKey) { - List all = versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc( + List all = versionRepository.findByAppKeyAndPlatformOrderByReleaseSequenceDesc( appKey, AppVersionEntity.Platform.ANDROID); List versions = all.stream().filter(this::everReleased).toList(); if (versions.isEmpty()) { @@ -130,18 +130,21 @@ public class PublicDownloadController { private Map storeOnlyInfo(String appKey, AppVersionEntity.Platform platform, AppStoreConfigEntity.StoreType storeType) { - String storeUrl = appStoreService.getStoreJumpUrl(appKey, storeType); - if (storeUrl == null || storeUrl.isBlank()) { - return null; - } - List all = versionRepository.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform); + List 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 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; diff --git a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java index b0c4541..bdc4480 100644 --- a/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java +++ b/update-service/src/main/java/com/xuqm/update/controller/UnifiedReleaseController.java @@ -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 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); } diff --git a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java index 7598bb3..69dd740 100644 --- a/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java +++ b/update-service/src/main/java/com/xuqm/update/entity/AppVersionEntity.java @@ -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; } diff --git a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java index c4ee6e2..9ea200b 100644 --- a/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java +++ b/update-service/src/main/java/com/xuqm/update/repository/AppVersionRepository.java @@ -9,12 +9,32 @@ import java.util.List; import java.util.Optional; public interface AppVersionRepository extends JpaRepository { - List findByAppKeyAndPlatformOrderByVersionCodeDesc( + List findByAppKeyAndPlatformOrderByReleaseSequenceDesc( String appKey, AppVersionEntity.Platform platform); List findByAppKeyAndPlatformOrderByCreatedAtDesc( String appKey, AppVersionEntity.Platform platform); - Optional findTopByAppKeyAndPlatformAndPublishStatusOrderByVersionCodeDesc( + + List + findByAppKeyAndPlatformAndPublishStatusAndReleaseSequenceGreaterThanOrderByReleaseSequenceDesc( + String appKey, + AppVersionEntity.Platform platform, + AppVersionEntity.PublishStatus status, + long releaseSequence); + + Optional + 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 findTopByAppKeyAndPlatformAndPublishStatusOrderByReleaseSequenceDesc( String appKey, AppVersionEntity.Platform platform, AppVersionEntity.PublishStatus status); Optional findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( String appKey, @@ -22,6 +42,12 @@ public interface AppVersionRepository extends JpaRepository findByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc( + String appKey, + AppVersionEntity.Platform platform, + AppVersionEntity.PublishStatus status, + int versionCode); + Optional findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc( String appKey, AppVersionEntity.Platform platform, @@ -57,7 +83,7 @@ public interface AppVersionRepository extends JpaRepository findByAppKeyAndPlatformAndPublishStatusInOrderByVersionCodeDesc( + List findByAppKeyAndPlatformAndPublishStatusInOrderByReleaseSequenceDesc( String appKey, AppVersionEntity.Platform platform, List statuses); @Query(value = "SELECT * FROM update_app_version WHERE download_url LIKE 'https://file.dev.xuqinmin.com%'", nativeQuery = true) diff --git a/update-service/src/main/java/com/xuqm/update/service/AppReleaseSequenceService.java b/update-service/src/main/java/com/xuqm/update/service/AppReleaseSequenceService.java new file mode 100644 index 0000000..63bf4d1 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/service/AppReleaseSequenceService.java @@ -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 发布序列的唯一分配器。 + * + *

平台原生构建号可能不是数字,不能承担跨版本排序;序列只由数据库原子分配。

+ */ +@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; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java index d790854..822c49b 100644 --- a/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java +++ b/update-service/src/main/java/com/xuqm/update/service/AppStoreService.java @@ -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 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 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 storeTypes) throws Exception { Set storeSet = new HashSet<>(storeTypes); - List others = versionRepo.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform); + List 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 storeTypes) throws Exception { Set storeSet = new HashSet<>(storeTypes); - List others = versionRepo.findByAppKeyAndPlatformOrderByVersionCodeDesc(appKey, platform); + List others = versionRepo.findByAppKeyAndPlatformOrderByReleaseSequenceDesc(appKey, platform); for (AppVersionEntity other : others) { if (other.getId().equals(currentVersionId)) continue; if (other.getVersionCode() <= currentVersionCode) continue; diff --git a/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java b/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java deleted file mode 100644 index 9314e5d..0000000 --- a/update-service/src/main/java/com/xuqm/update/service/StoreReviewImNotifier.java +++ /dev/null @@ -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 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; - } -} diff --git a/update-service/src/main/java/com/xuqm/update/store/adapter/AppStoreConnectTokenFactory.java b/update-service/src/main/java/com/xuqm/update/store/adapter/AppStoreConnectTokenFactory.java new file mode 100644 index 0000000..1228b01 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/adapter/AppStoreConnectTokenFactory.java @@ -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; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/adapter/AppleStoreInventoryAdapter.java b/update-service/src/main/java/com/xuqm/update/store/adapter/AppleStoreInventoryAdapter.java new file mode 100644 index 0000000..33fc326 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/adapter/AppleStoreInventoryAdapter.java @@ -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 credential = request.credential(); + String token = AppStoreConnectTokenFactory.create( + required(credential, "issuerId"), + required(credential, "keyId"), + required(credential, "privateKey")); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + HttpEntity 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 builds = new HashMap<>(); + for (JsonNode included : root.path("included")) { + if ("builds".equals(included.path("type").asText())) { + builds.put(included.path("id").asText(), included); + } + } + + List 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 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 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 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 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; + } + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/adapter/HarmonyStoreInventoryAdapter.java b/update-service/src/main/java/com/xuqm/update/store/adapter/HarmonyStoreInventoryAdapter.java new file mode 100644 index 0000000..2c443f6 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/adapter/HarmonyStoreInventoryAdapter.java @@ -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 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(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 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 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 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 ""; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/controller/StoreMonitoringController.java b/update-service/src/main/java/com/xuqm/update/store/controller/StoreMonitoringController.java new file mode 100644 index 0000000..41cb15f --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/controller/StoreMonitoringController.java @@ -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>> listBindings( + @RequestParam String appKey) { + requireOwnership(appKey); + return ResponseEntity.ok(ApiResponse.success(monitoringService.listBindings(appKey))); + } + + @PutMapping("/bindings/{storeType}") + public ResponseEntity> 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>> 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>> listVersions( + @RequestParam String appKey, + @PathVariable String bindingId) { + requireOwnership(appKey); + return ResponseEntity.ok(ApiResponse.success( + monitoringService.listVersions(appKey, bindingId))); + } + + @GetMapping("/bindings/{bindingId}/events") + public ResponseEntity>> 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> 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> getNotificationPolicy( + @RequestParam String appKey) { + requireOwnership(appKey); + return ResponseEntity.ok(ApiResponse.success(notificationService.getPolicy(appKey))); + } + + @PutMapping("/notification-policy") + public ResponseEntity> 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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreApplicationEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreApplicationEntity.java new file mode 100644 index 0000000..bc3e912 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreApplicationEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreCredentialProfileEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreCredentialProfileEntity.java new file mode 100644 index 0000000..9bfbd62 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreCredentialProfileEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreNotificationOutboxEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreNotificationOutboxEntity.java new file mode 100644 index 0000000..16795c3 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreNotificationOutboxEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreNotificationPolicyEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreNotificationPolicyEntity.java new file mode 100644 index 0000000..6b320e0 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreNotificationPolicyEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreRegionAvailabilityEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreRegionAvailabilityEntity.java new file mode 100644 index 0000000..0d2079f --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreRegionAvailabilityEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreSecretEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreSecretEntity.java new file mode 100644 index 0000000..738a645 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreSecretEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreStateEventEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreStateEventEntity.java new file mode 100644 index 0000000..84befe0 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreStateEventEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreUpdateLinkEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreUpdateLinkEntity.java new file mode 100644 index 0000000..2ef689f --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreUpdateLinkEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/entity/StoreVersionEntity.java b/update-service/src/main/java/com/xuqm/update/store/entity/StoreVersionEntity.java new file mode 100644 index 0000000..7b024a8 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/entity/StoreVersionEntity.java @@ -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; } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreBindingRequest.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreBindingRequest.java new file mode 100644 index 0000000..790175b --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreBindingRequest.java @@ -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 credential +) { +} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreBindingView.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreBindingView.java new file mode 100644 index 0000000..3b3fb2c --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreBindingView.java @@ -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 +) { +} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreEventView.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreEventView.java new file mode 100644 index 0000000..5a59993 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreEventView.java @@ -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 +) {} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreInventorySnapshot.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreInventorySnapshot.java new file mode 100644 index 0000000..ad0cc17 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreInventorySnapshot.java @@ -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 versions +) { + public StoreInventorySnapshot { + officialAppId = officialAppId == null ? "" : officialAppId; + synchronizedAt = synchronizedAt == null ? Instant.now() : synchronizedAt; + versions = versions == null ? List.of() : List.copyOf(versions); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreMonitorRequest.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreMonitorRequest.java new file mode 100644 index 0000000..f9cde42 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreMonitorRequest.java @@ -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 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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreNotificationPolicyRequest.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreNotificationPolicyRequest.java new file mode 100644 index 0000000..5366c0c --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreNotificationPolicyRequest.java @@ -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 emailRecipients, + boolean webhookEnabled, + String webhookUrl, + String webhookSecret, + Set eventTypes +) {} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreNotificationPolicyView.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreNotificationPolicyView.java new file mode 100644 index 0000000..e8a9122 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreNotificationPolicyView.java @@ -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 emailRecipients, + boolean webhookEnabled, + String webhookUrl, + boolean webhookSecretConfigured, + Set eventTypes, + LocalDateTime updatedAt +) {} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreUpdateDraftView.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreUpdateDraftView.java new file mode 100644 index 0000000..3a5d4fd --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreUpdateDraftView.java @@ -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 +) {} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionSnapshot.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionSnapshot.java new file mode 100644 index 0000000..0d8e383 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionSnapshot.java @@ -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 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; + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionState.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionState.java new file mode 100644 index 0000000..c212c87 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionState.java @@ -0,0 +1,26 @@ +package com.xuqm.update.store.model; + +/** + * 应用商店版本的统一状态。 + * + *

该枚举只表达 Xuqm 的领域语义。Apple、华为等官方状态必须由各自适配器 + * 映射后才能进入领域层,同时仍需保存原始状态用于审计。

+ */ +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 +} diff --git a/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionView.java b/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionView.java new file mode 100644 index 0000000..91bb215 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/model/StoreVersionView.java @@ -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 +) { +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreApplicationRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreApplicationRepository.java new file mode 100644 index 0000000..4ce9c39 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreApplicationRepository.java @@ -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 { + Optional findByAppKeyAndStoreType( + String appKey, AppStoreConfigEntity.StoreType storeType); + List findByAppKeyOrderByStoreType(String appKey); + List findByEnabledTrue(); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreCredentialProfileRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreCredentialProfileRepository.java new file mode 100644 index 0000000..0d5aa6c --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreCredentialProfileRepository.java @@ -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 { + Optional findByAppKeyAndStoreTypeAndPurpose( + String appKey, + AppStoreConfigEntity.StoreType storeType, + StoreCredentialProfileEntity.Purpose purpose); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreNotificationOutboxRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreNotificationOutboxRepository.java new file mode 100644 index 0000000..920e877 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreNotificationOutboxRepository.java @@ -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 { + List + findTop50ByStatusInAndNextAttemptAtLessThanEqualOrderByCreatedAtAsc( + Collection 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); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreNotificationPolicyRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreNotificationPolicyRepository.java new file mode 100644 index 0000000..1f8eb50 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreNotificationPolicyRepository.java @@ -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 { + Optional findByAppKey(String appKey); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreRegionAvailabilityRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreRegionAvailabilityRepository.java new file mode 100644 index 0000000..6050699 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreRegionAvailabilityRepository.java @@ -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 { + Optional findByStoreVersionIdAndRegionCode( + String storeVersionId, String regionCode); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreSecretRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreSecretRepository.java new file mode 100644 index 0000000..a6a7abf --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreSecretRepository.java @@ -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 { +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreStateEventRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreStateEventRepository.java new file mode 100644 index 0000000..4abb798 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreStateEventRepository.java @@ -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 { + List findTop100ByStoreApplicationIdOrderByOccurredAtDesc( + String storeApplicationId); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreUpdateLinkRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreUpdateLinkRepository.java new file mode 100644 index 0000000..52a76b3 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreUpdateLinkRepository.java @@ -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 { + Optional findByStoreVersionIdAndActiveTrue(String storeVersionId); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/repository/StoreVersionRepository.java b/update-service/src/main/java/com/xuqm/update/store/repository/StoreVersionRepository.java new file mode 100644 index 0000000..30d723e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/repository/StoreVersionRepository.java @@ -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 { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select version from StoreVersionEntity version where version.id = :id") + Optional findLockedById(String id); + Optional findByStoreApplicationIdAndOfficialVersionId( + String storeApplicationId, String officialVersionId); + List findByStoreApplicationIdOrderByLastSyncedAtDesc(String storeApplicationId); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/service/EncryptedDatabaseStoreSecretProvider.java b/update-service/src/main/java/com/xuqm/update/store/service/EncryptedDatabaseStoreSecretProvider.java new file mode 100644 index 0000000..bf4a6ab --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/service/EncryptedDatabaseStoreSecretProvider.java @@ -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 后端。 + * + *

密钥只从部署环境注入。未配置密钥时服务仍可启动并展示历史监测数据, + * 但保存或读取凭据会明确失败,不能使用硬编码默认值。

+ */ +@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> 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 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 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"); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/service/StoreCredentialService.java b/update-service/src/main/java/com/xuqm/update/store/service/StoreCredentialService.java new file mode 100644 index 0000000..d8d9658 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/service/StoreCredentialService.java @@ -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 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 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 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 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 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); + } + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/service/StoreInventoryReconciler.java b/update-service/src/main/java/com/xuqm/update/store/service/StoreInventoryReconciler.java new file mode 100644 index 0000000..dda69a0 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/service/StoreInventoryReconciler.java @@ -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; + +/** + * 将官方商店快照写入本地权威模型。 + * + *

只有状态真实变化时才写事件,定时轮询同一响应不会产生重复通知。

+ */ +@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 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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/service/StoreMonitoringService.java b/update-service/src/main/java/com/xuqm/update/store/service/StoreMonitoringService.java new file mode 100644 index 0000000..59d0389 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/service/StoreMonitoringService.java @@ -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 adapters; + + public StoreMonitoringService(StoreApplicationRepository applicationRepository, + StoreVersionRepository versionRepository, + StoreRegionAvailabilityRepository availabilityRepository, + StoreStateEventRepository eventRepository, + StoreCredentialService credentialService, + StoreInventoryReconciler reconciler, + List 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 listBindings(String appKey) { + return applicationRepository.findByAppKeyOrderByStoreType(appKey).stream() + .map(this::view) + .toList(); + } + + public List 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 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 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 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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/service/StoreNotificationService.java b/update-service/src/main/java/com/xuqm/update/store/service/StoreNotificationService.java new file mode 100644 index 0000000..ef9fa8e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/service/StoreNotificationService.java @@ -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.*; + +/** + * 商店事件通知的唯一实现。 + * + *

状态事件始终入库;只有租户显式开启的邮件或 Webhook 才进入 Outbox。 + * 外部发送失败只影响该投递任务,不得回滚商店状态或阻断租户平台。

+ */ +@Service +public class StoreNotificationService { + private static final Logger log = LoggerFactory.getLogger(StoreNotificationService.class); + private static final int MAX_ATTEMPTS = 6; + private static final TypeReference> STRING_LIST = new TypeReference<>() {}; + private static final TypeReference> 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 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 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 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 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 payload) { + if (!policy.isEmailEnabled()) throw new IllegalStateException("邮件通知已关闭"); + if (mailSender == null || mailFrom == null || mailFrom.isBlank()) { + throw new IllegalStateException("邮件服务未配置"); + } + List 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 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 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 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 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 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 normalizeRecipients(List values) { + if (values == null) return List.of(); + return values.stream().filter(Objects::nonNull).map(String::trim) + .filter(value -> !value.isBlank()).distinct().toList(); + } + + private Set normalizeEventTypes(Set values) { + if (values == null) return Set.of(); + TreeSet 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 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 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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/service/StoreStateMapper.java b/update-service/src/main/java/com/xuqm/update/store/service/StoreStateMapper.java new file mode 100644 index 0000000..1773032 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/service/StoreStateMapper.java @@ -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; + +/** + * 官方状态到统一状态的唯一映射入口。 + * + *

未知状态必须返回 UNKNOWN,不能猜测为审核中或已上架。

+ */ +public final class StoreStateMapper { + private StoreStateMapper() {} + + private static final Set APPLE_AVAILABLE = Set.of( + "READY_FOR_DISTRIBUTION", "READY_FOR_SALE"); + private static final Set APPLE_PROCESSING = Set.of( + "PROCESSING_FOR_DISTRIBUTION"); + private static final Set APPLE_WAITING = Set.of( + "READY_FOR_REVIEW", "WAITING_FOR_REVIEW"); + private static final Set APPLE_REVIEWING = Set.of("IN_REVIEW"); + private static final Set 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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/service/StoreUpdateDraftService.java b/update-service/src/main/java/com/xuqm/update/store/service/StoreUpdateDraftService.java new file mode 100644 index 0000000..882c77e --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/service/StoreUpdateDraftService.java @@ -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); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/store/spi/StoreInventoryAdapter.java b/update-service/src/main/java/com/xuqm/update/store/spi/StoreInventoryAdapter.java new file mode 100644 index 0000000..3bd6e26 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/spi/StoreInventoryAdapter.java @@ -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; + +/** + * 官方应用商店只读适配器。 + * + *

提交审核、立即发布等写操作不属于当前迭代,禁止在此接口伪实现。

+ */ +public interface StoreInventoryAdapter { + AppStoreConfigEntity.StoreType storeType(); + + StoreInventorySnapshot fetch(StoreMonitorRequest request); +} diff --git a/update-service/src/main/java/com/xuqm/update/store/spi/StoreSecretProvider.java b/update-service/src/main/java/com/xuqm/update/store/spi/StoreSecretProvider.java new file mode 100644 index 0000000..417d395 --- /dev/null +++ b/update-service/src/main/java/com/xuqm/update/store/spi/StoreSecretProvider.java @@ -0,0 +1,10 @@ +package com.xuqm.update.store.spi; + +import java.util.Map; + +/** 商店 Secrets 存储边界;业务表和前端只能持有返回的引用。 */ +public interface StoreSecretProvider { + String put(String previousRef, Map secret); + Map resolve(String secretRef); + void delete(String secretRef); +} diff --git a/update-service/src/main/resources/application.yml b/update-service/src/main/resources/application.yml index 9d8aa5b..8064ae8 100644 --- a/update-service/src/main/resources/application.yml +++ b/update-service/src/main/resources/application.yml @@ -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:} diff --git a/update-service/src/main/resources/db/migration/V4__gray_mode_simplify_and_health.sql b/update-service/src/main/resources/db/migration/V4__gray_mode_simplify_and_health.sql new file mode 100644 index 0000000..b9c1bba --- /dev/null +++ b/update-service/src/main/resources/db/migration/V4__gray_mode_simplify_and_health.sql @@ -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; diff --git a/update-service/src/main/resources/db/migration/V5__store_monitoring_domain.sql b/update-service/src/main/resources/db/migration/V5__store_monitoring_domain.sql new file mode 100644 index 0000000..40189e1 --- /dev/null +++ b/update-service/src/main/resources/db/migration/V5__store_monitoring_domain.sql @@ -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; diff --git a/update-service/src/main/resources/db/migration/V6__platform_native_app_version.sql b/update-service/src/main/resources/db/migration/V6__platform_native_app_version.sql new file mode 100644 index 0000000..92a36ba --- /dev/null +++ b/update-service/src/main/resources/db/migration/V6__platform_native_app_version.sql @@ -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; diff --git a/update-service/src/test/java/com/xuqm/update/store/adapter/AppleStoreInventoryAdapterTest.java b/update-service/src/test/java/com/xuqm/update/store/adapter/AppleStoreInventoryAdapterTest.java new file mode 100644 index 0000000..cad03e7 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/store/adapter/AppleStoreInventoryAdapterTest.java @@ -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()); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/store/adapter/HarmonyStoreInventoryAdapterTest.java b/update-service/src/test/java/com/xuqm/update/store/adapter/HarmonyStoreInventoryAdapterTest.java new file mode 100644 index 0000000..9324d17 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/store/adapter/HarmonyStoreInventoryAdapterTest.java @@ -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()); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/store/service/StoreNotificationServiceTest.java b/update-service/src/test/java/com/xuqm/update/store/service/StoreNotificationServiceTest.java new file mode 100644 index 0000000..edf8f98 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/store/service/StoreNotificationServiceTest.java @@ -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 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), + ""); + } + } +} diff --git a/update-service/src/test/java/com/xuqm/update/store/service/StoreStateMapperTest.java b/update-service/src/test/java/com/xuqm/update/store/service/StoreStateMapperTest.java new file mode 100644 index 0000000..ea3a765 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/store/service/StoreStateMapperTest.java @@ -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)); + } +} diff --git a/update-service/src/test/java/com/xuqm/update/store/service/StoreUpdateDraftServiceTest.java b/update-service/src/test/java/com/xuqm/update/store/service/StoreUpdateDraftServiceTest.java new file mode 100644 index 0000000..1f4bf21 --- /dev/null +++ b/update-service/src/test/java/com/xuqm/update/store/service/StoreUpdateDraftServiceTest.java @@ -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); + } + } +} diff --git a/update-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/update-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/update-service/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass