From 00b81bf7ef93a10cb97cefdb3e982e2483042a40 Mon Sep 17 00:00:00 2001 From: dark Date: Tue, 28 Apr 2026 17:00:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=94=AF=E6=8C=81=E6=AD=A3=E5=88=99?= =?UTF-8?q?=E5=8C=B9=E9=85=8D=E8=BD=AE=E8=AF=A2=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- current.md | 6 +- docs/ftp-sync-tool-design.md | 20 +- docs/ftp-sync-tool-detail-design.md | 28 +- docs/git-direct-sync-tool-design.md | 12 +- docs/prod-api-v1.md | 3 +- .../sync/config/GitRepoProperties.java | 12 +- .../orchestrator/ProdSyncCoordinator.java | 114 +++++--- .../sync/service/GitClientService.java | 41 ++- .../application-prod-agent.properties | 2 + src/main/resources/application.properties | 9 +- .../ProdSyncCoordinatorIntegrationTest.java | 46 +++- .../ProdConfigApiServiceContractTest.java | 258 ++++++++++++++++++ 12 files changed, 477 insertions(+), 74 deletions(-) create mode 100644 src/test/java/com/ftptool/sync/service/ProdConfigApiServiceContractTest.java diff --git a/current.md b/current.md index 73569fe..7081941 100644 --- a/current.md +++ b/current.md @@ -13,7 +13,7 @@ login:已支持 token 获取与缓存 ackSuc/ackFail:已接入回传与本地落库 ConfigCryptoService:已抽出,当前默认透传实现,后续只需替换该服务内算法 Git -> PROD:已支持最小增量推送,删除场景自动回退全量 -Git -> PROD:已改为按“版本分支 + 机场目录 + 模块目录”解析参数 +Git -> PROD:已改为先按 git.repo.scan-branch-pattern 批量扫描版本分支,再逐个按“版本分支 + 机场目录 + 模块目录”解析参数 PROD -> Git:已按 airportId/appName/fileName 目录结构回写到动态 snapshot 分支 Git -> PROD:sourceVersion/configVersion 已改为 Git 分支名,不再用 commit SHA Git -> PROD:baseline 已按版本分支隔离 @@ -29,7 +29,9 @@ docs 下设计文档和接口文档已同步更新到当前口径 Git 仓库约定 Git -> PROD: -- git.repo.scan-branch 直接指向待同步版本分支 +- 支持两种入口: + - git.repo.scan-branch:只同步一个指定版本分支 + - git.repo.scan-branch-pattern:批量扫描所有匹配分支,当前默认用于 R_XXX_.* - 分支名本身就是 configVersion - 分支内目录结构必须为:airportId/appName/模块内文件 - pushConfig 参数映射: diff --git a/docs/ftp-sync-tool-design.md b/docs/ftp-sync-tool-design.md index 9a3a2ae..6de52a9 100644 --- a/docs/ftp-sync-tool-design.md +++ b/docs/ftp-sync-tool-design.md @@ -45,7 +45,9 @@ 当前需求下: - 一个待发布版本对应一个 Git 分支 -- `git.repo.scan-branch` 直接配置为当前待同步版本分支 +- Git -> PROD 支持: + - `git.repo.scan-branch`:单分支同步 + - `git.repo.scan-branch-pattern`:批量扫描同步 - **分支名本身就是 `configVersion`** 示例: @@ -102,13 +104,14 @@ git.repo.snapshot-branch/ 流程: -1. 拉取 `git.repo.scan-branch` -2. 读取当前 `HEAD revision` -3. 以 **分支名** 作为业务版本号 `sourceVersion` -4. 导出该分支工作树 -5. 解析所有 `airportId/appName/fileName` 配置项 -6. 调用生产 `pushConfig` -7. 成功后更新 `sync_task` 和 `sync_checkpoint` +1. 解析 `scan-branch` 或 `scan-branch-pattern` +2. 逐个拉取命中的版本分支 +3. 读取当前 `HEAD revision` +4. 以 **分支名** 作为业务版本号 `sourceVersion` +5. 导出该分支工作树 +6. 解析所有 `airportId/appName/fileName` 配置项 +7. 调用生产 `pushConfig` +8. 成功后更新 `sync_task` 和 `sync_checkpoint` 说明: @@ -207,6 +210,7 @@ direction + sourceVersion + contentHash ```properties git.repo.scan-branch=R_XXX_V3.0.3_XXX +git.repo.scan-branch-pattern=^R_XXX_.*$ git.repo.snapshot-branch=config-prod-snapshot git.repo.commit-message-prefix=sync(prod->git) ``` diff --git a/docs/ftp-sync-tool-detail-design.md b/docs/ftp-sync-tool-detail-design.md index b9d4e59..7070240 100644 --- a/docs/ftp-sync-tool-detail-design.md +++ b/docs/ftp-sync-tool-detail-design.md @@ -47,7 +47,8 @@ src/main/resources/ | `git.repo.remote-uri` | Git 远端地址 | | `git.repo.username` | Git 用户名 | | `git.repo.password` | Git 密码或 token | -| `git.repo.scan-branch` | 当前待同步的版本分支名 | +| `git.repo.scan-branch` | 当前待同步的单个版本分支名 | +| `git.repo.scan-branch-pattern` | 批量扫描的版本分支匹配规则 | | `git.repo.snapshot-branch` | 动态生产快照分支前缀 | | `git.repo.commit-message-prefix` | PROD -> Git 提交前缀 | @@ -84,7 +85,9 @@ src/main/resources/ 当前实现约定: -- `git.repo.scan-branch` 直接指向当前待同步版本分支 +- Git -> PROD 支持两种入口: + - `git.repo.scan-branch`:单分支定向同步 + - `git.repo.scan-branch-pattern`:批量枚举版本分支 - **分支名本身就是 `configVersion`** 例如: @@ -147,19 +150,20 @@ work/ ### 7.1 流程步骤 -1. 拉取 `git.repo.scan-branch` -2. 读取当前 `HEAD revision` -3. 以分支名作为 `sourceVersion` -4. 导出分支工作树到 staging 目录 -5. 计算目录内容哈希 -6. 按 `direction + sourceVersion + contentHash` 做幂等判断 -7. 计算本次推送目录: +1. 解析单分支或批量匹配规则 +2. 逐个拉取命中的版本分支 +3. 读取当前 `HEAD revision` +4. 以分支名作为 `sourceVersion` +5. 导出分支工作树到 staging 目录 +6. 计算目录内容哈希 +7. 按 `direction + sourceVersion + contentHash` 做幂等判断 +8. 计算本次推送目录: - 首次全量 - 删除回退全量 - 其余场景最小增量 -8. 遍历目录内所有文件,按路径解析出 `airportId/appName/fileName` -9. 组装 `pushConfig` JSON 数组并提交 -10. 成功后刷新 checkpoint、task 和 baseline +9. 遍历目录内所有文件,按路径解析出 `airportId/appName/fileName` +10. 组装 `pushConfig` JSON 数组并提交 +11. 成功后刷新 checkpoint、task 和 baseline ### 7.2 参数映射规则 diff --git a/docs/git-direct-sync-tool-design.md b/docs/git-direct-sync-tool-design.md index 197d022..ba4494d 100644 --- a/docs/git-direct-sync-tool-design.md +++ b/docs/git-direct-sync-tool-design.md @@ -35,7 +35,9 @@ 当前需求下: - **每个待下发版本对应一个 Git 分支** -- `git.repo.scan-branch` 指向当前待同步的版本分支 +- Git -> PROD 支持两种入口: + - `git.repo.scan-branch`:指定单个版本分支 + - `git.repo.scan-branch-pattern`:批量匹配多个版本分支 - **分支名本身就是 `configVersion`** 例如: @@ -93,7 +95,7 @@ git.repo.snapshot-branch/ 流程: -1. `prod-agent` 拉取 `git.repo.scan-branch` 指定的版本分支 +1. `prod-agent` 解析单分支或批量匹配规则,得到待同步版本分支列表 2. 读取当前 `HEAD revision`,仅用于日志和 staging 隔离 3. 以 **分支名** 作为 `sourceVersion/configVersion` 4. 导出分支工作树 @@ -118,7 +120,7 @@ git.repo.snapshot-branch/ 当前代码已经调整为: -- `sourceVersion = git.repo.scan-branch` +- `sourceVersion = 当前正在处理的版本分支名` - 不再使用 `commit SHA` 作为业务版本号 `HEAD revision` 仍然保留,但仅用于: @@ -193,12 +195,14 @@ direction + sourceVersion + contentHash ```properties git.repo.scan-branch=R_XXX_V3.0.3_XXX +git.repo.scan-branch-pattern=^R_XXX_.*$ git.repo.snapshot-branch=config-prod-snapshot ``` 说明: -- `scan-branch` 当前应直接配置为待同步版本分支名 +- `scan-branch` 适合手工定向同步单个版本 +- `scan-branch-pattern` 适合批量扫描所有待下发版本 - `snapshot-branch` 当前表示动态快照分支前缀 ### 8.2 生产接口配置 diff --git a/docs/prod-api-v1.md b/docs/prod-api-v1.md index 0f78943..fe1c4c6 100644 --- a/docs/prod-api-v1.md +++ b/docs/prod-api-v1.md @@ -15,7 +15,8 @@ 当前实现约定: -- `git.repo.scan-branch` 指向当前待同步的版本分支 +- 优先使用 `git.repo.scan-branch-pattern` 批量匹配版本分支 +- 如需定向同步单个版本,也可以显式配置 `git.repo.scan-branch` - **分支名本身就是 `configVersion`** - 分支内目录结构必须为: diff --git a/src/main/java/com/ftptool/sync/config/GitRepoProperties.java b/src/main/java/com/ftptool/sync/config/GitRepoProperties.java index eb464dc..13969b3 100644 --- a/src/main/java/com/ftptool/sync/config/GitRepoProperties.java +++ b/src/main/java/com/ftptool/sync/config/GitRepoProperties.java @@ -16,8 +16,10 @@ public class GitRepoProperties { private String username; /** Git 访问密码或 Token。 */ private String password; - /** 当前待同步的版本分支,Git -> PROD 只读取此分支。 */ + /** 当前待同步的单个版本分支。配置为空时,退回使用 scanBranchPattern。 */ private String scanBranch; + /** Git -> PROD 的版本分支匹配规则,例如 ^R_XXX_.*$ 。 */ + private String scanBranchPattern; /** 生产快照分支前缀,PROD -> Git 会写入该前缀下的动态版本分支。 */ private String snapshotBranch; /** Git 机器人提交用户名。 */ @@ -69,6 +71,14 @@ public class GitRepoProperties { this.scanBranch = scanBranch; } + public String getScanBranchPattern() { + return scanBranchPattern; + } + + public void setScanBranchPattern(String scanBranchPattern) { + this.scanBranchPattern = scanBranchPattern; + } + public String getSnapshotBranch() { return snapshotBranch; } diff --git a/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java b/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java index b76df8b..37ad0e3 100644 --- a/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java +++ b/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java @@ -87,19 +87,73 @@ public class ProdSyncCoordinator { } /** - * 拉取当前版本分支,并把配置文件推送到生产接口。 - * 当前约定:分支名本身就是 configVersion。 + * 扫描待同步的版本分支,并逐个把配置文件推送到生产接口。 + * 当前支持两种模式: + * 1. 指定单个 scanBranch + * 2. 按 scanBranchPattern 批量匹配多个版本分支 */ public void syncLatestGitToProd() { + try { + List branches = resolveGitToProdBranches(); + if (branches.isEmpty()) { + log.info("PROD git->prod tick. nodeId={}, no matching branches found.", syncProperties.getNodeId()); + return; + } + + log.info( + "PROD git->prod tick. nodeId={}, branchCount={}, pushPath={}", + syncProperties.getNodeId(), + branches.size(), + prodApiProperties.getPushPath() + ); + + for (String branch : branches) { + syncSingleGitBranchToProd(branch); + } + } catch (Exception e) { + log.error("PROD git->prod sync failed before branch loop completed", e); + } + } + + /** + * 拉取生产配置快照,并按版本写回 Git 快照分支。 + * 执行顺序是: + * 1. 先尝试消费上轮失败的 ACK 定向重拉 + * 2. 再执行本轮正常的 pullConfig 拉取 + */ + public void syncProdSnapshotToGit() { + log.info( + "PROD prod->git tick. apiBaseUrl={}, pullPath={}, snapshotBranchPrefix={}", + prodApiProperties.getBaseUrl(), + prodApiProperties.getPullPath(), + gitRepoProperties.getSnapshotBranch() + ); + + retryFailedProdPulls(); + + try { + List pullResults = prodConfigApiService.pullConfigSnapshots(); + for (ProdPullResult pullResult : pullResults) { + syncSingleProdSnapshotToGit(pullResult, false); + } + } catch (Exception e) { + log.error("PROD prod->git sync failed before new snapshot groups were processed", e); + } + } + + private boolean shouldSkip(Optional existing) { + return existing.isPresent() && existing.get().getStatus() == SyncStatus.SUCCESS; + } + + private void syncSingleGitBranchToProd(String branch) { String traceId = null; try { - String branch = gitRepoProperties.getScanBranch(); String sourceRevision = gitClientService.prepareRepositoryAndGetHead(branch); String sourceVersion = branch; String stagingKey = buildStagingKey(branch, sourceRevision); log.info( - "PROD git->prod tick. nodeId={}, branch={}, revision={}, pushPath={}", + "PROD git->prod branch sync. nodeId={}, branch={}, revision={}, pushPath={}", syncProperties.getNodeId(), branch, sourceRevision, @@ -150,40 +204,10 @@ public class ProdSyncCoordinator { log.info("Git version pushed to prod successfully. traceId={}, version={}, revision={}", task.getTraceId(), task.getSourceVersion(), sourceRevision); } catch (Exception e) { - handleFailure(traceId, "PROD git->prod sync failed", e); + handleFailure(traceId, "PROD git->prod branch sync failed. branch=" + branch, e); } } - /** - * 拉取生产配置快照,并按版本写回 Git 快照分支。 - * 执行顺序是: - * 1. 先尝试消费上轮失败的 ACK 定向重拉 - * 2. 再执行本轮正常的 pullConfig 拉取 - */ - public void syncProdSnapshotToGit() { - log.info( - "PROD prod->git tick. apiBaseUrl={}, pullPath={}, snapshotBranchPrefix={}", - prodApiProperties.getBaseUrl(), - prodApiProperties.getPullPath(), - gitRepoProperties.getSnapshotBranch() - ); - - retryFailedProdPulls(); - - try { - List pullResults = prodConfigApiService.pullConfigSnapshots(); - for (ProdPullResult pullResult : pullResults) { - syncSingleProdSnapshotToGit(pullResult, false); - } - } catch (Exception e) { - log.error("PROD prod->git sync failed before new snapshot groups were processed", e); - } - } - - private boolean shouldSkip(Optional existing) { - return existing.isPresent() && existing.get().getStatus() == SyncStatus.SUCCESS; - } - /** * 处理一组已经按 sourceVersion 切分好的生产快照。 * retryAttempt=true 表示该组来自 ACK 失败后的定向重拉。 @@ -374,6 +398,26 @@ public class ProdSyncCoordinator { return sanitizePathToken(branch) + "-" + sanitizePathToken(sourceRevision); } + /** + * 解析本轮 Git -> PROD 要处理的版本分支列表。 + * 优先级: + * 1. 配置了 scanBranch,则只处理这一个分支 + * 2. 否则使用 scanBranchPattern 枚举所有匹配分支 + */ + private List resolveGitToProdBranches() throws Exception { + if (gitRepoProperties.getScanBranch() != null && !gitRepoProperties.getScanBranch().trim().isEmpty()) { + List singleBranch = new ArrayList(); + singleBranch.add(gitRepoProperties.getScanBranch().trim()); + return singleBranch; + } + + String branchPattern = gitRepoProperties.getScanBranchPattern(); + if (branchPattern == null || branchPattern.trim().isEmpty()) { + throw new IllegalStateException("Missing git.repo.scan-branch or git.repo.scan-branch-pattern"); + } + return gitClientService.listMatchingBranches(branchPattern.trim()); + } + /** * 把 snapshot 分支前缀和当前 sourceVersion 组装成最终回写分支。 * 例如:config-prod-snapshot/R_XXX_V3.0.3_XXX diff --git a/src/main/java/com/ftptool/sync/service/GitClientService.java b/src/main/java/com/ftptool/sync/service/GitClientService.java index d978a6e..b283150 100644 --- a/src/main/java/com/ftptool/sync/service/GitClientService.java +++ b/src/main/java/com/ftptool/sync/service/GitClientService.java @@ -3,12 +3,12 @@ package com.ftptool.sync.service; import com.ftptool.sync.config.GitRepoProperties; import com.ftptool.sync.util.FileTreeUtils; import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.ListBranchCommand; import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; -import org.eclipse.jgit.storage.file.FileRepositoryBuilder; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; @@ -21,6 +21,10 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; import java.util.stream.Stream; @Service @@ -57,6 +61,41 @@ public class GitClientService { return new File(gitRepoProperties.getLocalPath()).toPath().toAbsolutePath().normalize(); } + /** + * 列出远端所有匹配正则的版本分支名。 + */ + public List listMatchingBranches(String branchRegex) throws IOException, GitAPIException { + synchronized (lock) { + try (Git git = openOrCloneRepository()) { + git.fetch() + .setCredentialsProvider(credentialsProvider()) + .setRemote("origin") + .call(); + + Pattern pattern = Pattern.compile(branchRegex); + List matchedBranches = new ArrayList(); + List remoteBranches = git.branchList() + .setListMode(ListBranchCommand.ListMode.REMOTE) + .call(); + for (Ref remoteBranch : remoteBranches) { + String shortenedRef = Repository.shortenRefName(remoteBranch.getName()); + if (!shortenedRef.startsWith("origin/")) { + continue; + } + String branchName = shortenedRef.substring("origin/".length()); + if ("HEAD".equals(branchName) || branchName.startsWith("HEAD/")) { + continue; + } + if (pattern.matcher(branchName).matches()) { + matchedBranches.add(branchName); + } + } + Collections.sort(matchedBranches); + return matchedBranches; + } + } + } + /** * 导出指定分支的工作树快照,供后续打包或哈希计算使用。 */ diff --git a/src/main/resources/application-prod-agent.properties b/src/main/resources/application-prod-agent.properties index a266d0d..87e5080 100644 --- a/src/main/resources/application-prod-agent.properties +++ b/src/main/resources/application-prod-agent.properties @@ -13,6 +13,8 @@ sync.jobs.prod-git-to-prod.cron=0 */1 * * * * sync.jobs.prod-to-git.cron=20 */2 * * * * # 生产接口覆盖示例 +# 如需批量扫描版本分支,建议在公共配置中使用: +# git.repo.scan-branch-pattern=^R_XXX_.*$ prod.api.base-url=https://prod.example.com prod.api.push-path=/pic_bus_manage_monitor/configSync/pushConfig prod.api.pull-path=/pic_bus_manage_monitor/configSync/pullConfig diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 76cecbf..4c66189 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -44,9 +44,12 @@ git.repo.remote-uri=https://git.example.com/config.git git.repo.username=replace-me git.repo.password=replace-me -# 当前待同步的版本分支。 -# 当前业务约定:分支名本身就是 configVersion。 -git.repo.scan-branch=R_XXX_V3.0.3_XXX +# Git -> PROD 支持两种模式: +# 1. scan-branch:只同步一个指定版本分支 +# 2. scan-branch-pattern:扫描所有匹配的版本分支 +# 当前默认使用模式 2,匹配所有 R_XXX_.* 版本分支。 +git.repo.scan-branch= +git.repo.scan-branch-pattern=^R_XXX_.*$ # 生产快照分支前缀。 # PROD -> Git 实际回写目标为:snapshot-branch/ diff --git a/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java b/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java index 8cb6208..1a96da6 100644 --- a/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java +++ b/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java @@ -57,7 +57,8 @@ import static org.mockito.Mockito.when; "sync.package-temp-dir=${test.work-root}/package", "sync.dev-to-prod-staging-dir=${test.work-root}/dev-to-prod", "sync.prod-to-dev-staging-dir=${test.work-root}/prod-to-dev", - "git.repo.scan-branch=config-dev-main", + "git.repo.scan-branch=", + "git.repo.scan-branch-pattern=^R_XXX_.*$", "git.repo.snapshot-branch=config-prod-snapshot", "git.repo.local-path=${test.work-root}/git/config-repo" } @@ -96,8 +97,9 @@ class ProdSyncCoordinatorIntegrationTest { @Test void shouldSyncGitToProdAndKeepItIdempotent() throws Exception { - when(gitClientService.prepareRepositoryAndGetHead("config-dev-main")).thenReturn("commit-a"); - when(gitClientService.exportBranchSnapshot(eq("config-dev-main"), any(Path.class))) + when(gitClientService.listMatchingBranches("^R_XXX_.*$")).thenReturn(Arrays.asList("R_XXX_V3.0.3_XXX")); + when(gitClientService.prepareRepositoryAndGetHead("R_XXX_V3.0.3_XXX")).thenReturn("commit-a"); + when(gitClientService.exportBranchSnapshot(eq("R_XXX_V3.0.3_XXX"), any(Path.class))) .thenAnswer(invocation -> { Path target = invocation.getArgument(1); Path configFile = target.resolve("PEK").resolve("monitor").resolve("application.yml"); @@ -115,18 +117,45 @@ class ProdSyncCoordinatorIntegrationTest { assertEquals(1, tasks.size()); SyncTask task = tasks.get(0); assertEquals(SyncDirection.DEV_TO_PROD, task.getDirection()); - assertEquals("config-dev-main", task.getSourceVersion()); + assertEquals("R_XXX_V3.0.3_XXX", task.getSourceVersion()); assertEquals("hash-a", task.getContentHash()); assertEquals(SyncStatus.SUCCESS, task.getStatus()); Optional checkpoint = syncCheckpointRepository.findByDirection(SyncDirection.DEV_TO_PROD); assertTrue(checkpoint.isPresent()); - assertEquals("config-dev-main", checkpoint.get().getLastSuccessVersion()); + assertEquals("R_XXX_V3.0.3_XXX", checkpoint.get().getLastSuccessVersion()); assertEquals("hash-a", checkpoint.get().getLastSuccessHash()); verify(prodConfigApiService, times(1)).pushPackage(any(PackageManifest.class), any(Path.class)); } + @Test + void shouldSyncMultipleMatchingGitBranches() throws Exception { + when(gitClientService.listMatchingBranches("^R_XXX_.*$")) + .thenReturn(Arrays.asList("R_XXX_V3.0.3_XXX", "R_XXX_V3.0.4_XXX")); + when(gitClientService.prepareRepositoryAndGetHead("R_XXX_V3.0.3_XXX")).thenReturn("commit-a"); + when(gitClientService.prepareRepositoryAndGetHead("R_XXX_V3.0.4_XXX")).thenReturn("commit-b"); + when(gitClientService.exportBranchSnapshot(any(String.class), any(Path.class))) + .thenAnswer(invocation -> { + String branch = invocation.getArgument(0); + Path target = invocation.getArgument(1); + Path configFile = target.resolve("PEK").resolve("monitor").resolve(branch + ".yml"); + Files.createDirectories(configFile.getParent()); + Files.write(configFile, branch.getBytes("UTF-8")); + return target; + }); + when(packageService.calculateDirectoryHash(any(Path.class))) + .thenReturn("hash-a") + .thenReturn("hash-b"); + doNothing().when(prodConfigApiService).pushPackage(any(PackageManifest.class), any(Path.class)); + + prodSyncCoordinator.syncLatestGitToProd(); + + List tasks = syncTaskRepository.findAll(); + assertEquals(2, tasks.size()); + verify(prodConfigApiService, times(2)).pushPackage(any(PackageManifest.class), any(Path.class)); + } + @Test void shouldSyncProdSnapshotToGitAndKeepItIdempotent() throws Exception { Path contentDirectory = Files.createTempDirectory("prod-to-git-"); @@ -292,10 +321,13 @@ class ProdSyncCoordinatorIntegrationTest { AtomicInteger exportCounter = new AtomicInteger(0); List pushedDirectories = new ArrayList(); - when(gitClientService.prepareRepositoryAndGetHead("config-dev-main")) + when(gitClientService.listMatchingBranches("^R_XXX_.*$")) + .thenReturn(Arrays.asList("R_XXX_V3.0.3_XXX")) + .thenReturn(Arrays.asList("R_XXX_V3.0.3_XXX")); + when(gitClientService.prepareRepositoryAndGetHead("R_XXX_V3.0.3_XXX")) .thenReturn("commit-base") .thenReturn("commit-delta"); - when(gitClientService.exportBranchSnapshot(eq("config-dev-main"), any(Path.class))) + when(gitClientService.exportBranchSnapshot(eq("R_XXX_V3.0.3_XXX"), any(Path.class))) .thenAnswer(invocation -> { Path target = invocation.getArgument(1); Path fileA = target.resolve("PEK").resolve("monitor").resolve("a.txt"); diff --git a/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceContractTest.java b/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceContractTest.java new file mode 100644 index 0000000..b81a804 --- /dev/null +++ b/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceContractTest.java @@ -0,0 +1,258 @@ +package com.ftptool.sync.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ftptool.sync.config.ProdApiProperties; +import com.ftptool.sync.config.SyncProperties; +import com.ftptool.sync.model.PackageManifest; +import com.ftptool.sync.model.ProdPullResult; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * 面向联调的生产接口模拟单元测试。 + * 这里的 mock 数据和请求格式可以直接拿来对照生产接口联调。 + */ +class ProdConfigApiServiceContractTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void shouldCallPushConfigWithMockProdApi() throws Exception { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + + SyncProperties syncProperties = newSyncProperties("./target/contract-push-work"); + WorkDirectoryService workDirectoryService = new WorkDirectoryService(syncProperties); + workDirectoryService.initialize(); + + ProdApiProperties prodApiProperties = new ProdApiProperties(); + prodApiProperties.setBaseUrl("https://prod.example.com"); + prodApiProperties.setPushPath("/pic_bus_manage_monitor/configSync/pushConfig"); + prodApiProperties.setToken("mock-static-token"); + prodApiProperties.setTokenHeaderName("token"); + + ConfigCryptoService configCryptoService = mock(ConfigCryptoService.class); + when(configCryptoService.encryptForPush( + "PEK", "monitor", "R_XXX_V3.0.3_XXX", "application.yml", "server.port: 8080" + )).thenReturn("encrypted-content"); + + ProdConfigApiService service = new ProdConfigApiService( + prodApiProperties, + syncProperties, + restTemplate, + workDirectoryService, + mock(ProdPullAckService.class), + configCryptoService + ); + + Path sourceDirectory = Files.createTempDirectory("contract-push-source-"); + Path configFile = sourceDirectory.resolve("PEK").resolve("monitor").resolve("application.yml"); + Files.createDirectories(configFile.getParent()); + Files.write(configFile, "server.port: 8080".getBytes(StandardCharsets.UTF_8)); + + server.expect(once(), request -> { + JsonNode body = OBJECT_MAPPER.readTree(((MockClientHttpRequest) request).getBodyAsString()); + assertEquals(1, body.size()); + assertEquals("PEK", body.get(0).get("airportId").asText()); + assertEquals("monitor", body.get(0).get("appName").asText()); + assertEquals("R_XXX_V3.0.3_XXX", body.get(0).get("configVersion").asText()); + assertEquals("application.yml", body.get(0).get("fileName").asText()); + assertEquals("encrypted-content", body.get(0).get("configContent").asText()); + }) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("token", "mock-static-token")) + .andRespond(withSuccess( + "{\"code\":\"0\",\"data\":{\"ackFail\":[]},\"msg\":\"ok\"}", + MediaType.APPLICATION_JSON + )); + + PackageManifest manifest = new PackageManifest(); + manifest.setTraceId("trace-contract-push"); + manifest.setSourceVersion("R_XXX_V3.0.3_XXX"); + + service.pushPackage(manifest, sourceDirectory); + + verify(configCryptoService).encryptForPush( + "PEK", "monitor", "R_XXX_V3.0.3_XXX", "application.yml", "server.port: 8080" + ); + server.verify(); + } + + @Test + void shouldCallPullConfigWithMockProdApi() throws Exception { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + + SyncProperties syncProperties = newSyncProperties("./target/contract-pull-work"); + WorkDirectoryService workDirectoryService = new WorkDirectoryService(syncProperties); + workDirectoryService.initialize(); + + ProdApiProperties prodApiProperties = new ProdApiProperties(); + prodApiProperties.setBaseUrl("https://prod.example.com"); + prodApiProperties.setPullPath("/pic_bus_manage_monitor/configSync/pullConfig"); + prodApiProperties.setToken("mock-static-token"); + prodApiProperties.setTokenHeaderName("token"); + prodApiProperties.setAirportId("PEK"); + prodApiProperties.setAppName("monitor"); + prodApiProperties.setPullConfigVersion("R_XXX_V3.0.3_XXX"); + prodApiProperties.setPullFileName("application.yml"); + + ProdPullAckService prodPullAckService = mock(ProdPullAckService.class); + when(prodPullAckService.getPendingAckSummary()).thenReturn( + new ProdPullAckService.PendingAckSummary(Collections.emptyList(), Collections.emptyList()) + ); + + ConfigCryptoService configCryptoService = mock(ConfigCryptoService.class); + when(configCryptoService.decryptAfterPull( + "PEK", "monitor", "R_XXX_V3.0.3_XXX", "application.yml", "encrypted-content" + )).thenReturn("server.port: 8080"); + + ProdConfigApiService service = new ProdConfigApiService( + prodApiProperties, + syncProperties, + restTemplate, + workDirectoryService, + prodPullAckService, + configCryptoService + ); + + server.expect(once(), request -> { + String uri = request.getURI().toString(); + assertTrue(uri.contains("airportId=PEK")); + assertTrue(uri.contains("appName=monitor")); + assertTrue(uri.contains("configVersion=R_XXX_V3.0.3_XXX")); + assertTrue(uri.contains("fileName=application.yml")); + }) + .andExpect(method(HttpMethod.GET)) + .andExpect(header("token", "mock-static-token")) + .andRespond(withSuccess( + "{\"code\":\"0\",\"data\":[" + + "{\"id\":\"101\",\"airportId\":\"PEK\",\"appName\":\"monitor\"," + + "\"configVersion\":\"R_XXX_V3.0.3_XXX\",\"configContent\":\"encrypted-content\"," + + "\"fileName\":\"application.yml\"}" + + "],\"msg\":\"ok\"}", + MediaType.APPLICATION_JSON + )); + + List results = service.pullConfigSnapshots(); + + assertEquals(1, results.size()); + ProdPullResult result = results.get(0); + assertEquals("R_XXX_V3.0.3_XXX", result.getSourceVersion()); + assertEquals(Arrays.asList("101"), result.getPulledConfigIds()); + Path restoredFile = result.getContentDirectory() + .resolve("PEK") + .resolve("monitor") + .resolve("application.yml"); + assertTrue(Files.exists(restoredFile)); + assertEquals("server.port: 8080", new String(Files.readAllBytes(restoredFile), StandardCharsets.UTF_8)); + + verify(configCryptoService).decryptAfterPull( + "PEK", "monitor", "R_XXX_V3.0.3_XXX", "application.yml", "encrypted-content" + ); + server.verify(); + } + + @Test + void shouldLoginThenCallPushConfigWhenStaticTokenMissing() throws Exception { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).ignoreExpectOrder(false).build(); + + SyncProperties syncProperties = newSyncProperties("./target/contract-login-work"); + WorkDirectoryService workDirectoryService = new WorkDirectoryService(syncProperties); + workDirectoryService.initialize(); + + ProdApiProperties prodApiProperties = new ProdApiProperties(); + prodApiProperties.setBaseUrl("https://prod.example.com"); + prodApiProperties.setLoginPath("/pic_bus_manage_monitor/pam-monitor/login"); + prodApiProperties.setPushPath("/pic_bus_manage_monitor/configSync/pushConfig"); + prodApiProperties.setToken("replace-me"); + prodApiProperties.setTokenHeaderName("token"); + prodApiProperties.setLoginName("mock-user"); + prodApiProperties.setLoginPassword("mock-password"); + + ConfigCryptoService configCryptoService = mock(ConfigCryptoService.class); + when(configCryptoService.encryptForPush( + "SHA", "gate", "R_XXX_V3.0.4_XXX", "gate-rule.json", "{\"enabled\":true}" + )).thenReturn("encrypted-gate-rule"); + + ProdConfigApiService service = new ProdConfigApiService( + prodApiProperties, + syncProperties, + restTemplate, + workDirectoryService, + mock(ProdPullAckService.class), + configCryptoService + ); + + Path sourceDirectory = Files.createTempDirectory("contract-login-source-"); + Path configFile = sourceDirectory.resolve("SHA").resolve("gate").resolve("gate-rule.json"); + Files.createDirectories(configFile.getParent()); + Files.write(configFile, "{\"enabled\":true}".getBytes(StandardCharsets.UTF_8)); + + server.expect(once(), request -> { + JsonNode body = OBJECT_MAPPER.readTree(((MockClientHttpRequest) request).getBodyAsString()); + assertEquals("mock-user", body.get("name").asText()); + assertEquals("mock-password", body.get("password").asText()); + }) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess( + "{\"code\":\"0\",\"data\":{\"token\":\"login-token-001\",\"expireTime\":\"2026-12-31 23:59:59\"},\"msg\":\"ok\"}", + MediaType.APPLICATION_JSON + )); + + server.expect(once(), request -> { + JsonNode body = OBJECT_MAPPER.readTree(((MockClientHttpRequest) request).getBodyAsString()); + assertEquals("SHA", body.get(0).get("airportId").asText()); + assertEquals("gate", body.get(0).get("appName").asText()); + assertEquals("R_XXX_V3.0.4_XXX", body.get(0).get("configVersion").asText()); + assertEquals("encrypted-gate-rule", body.get(0).get("configContent").asText()); + }) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("token", "login-token-001")) + .andRespond(withSuccess( + "{\"code\":\"0\",\"data\":{\"ackFail\":[]},\"msg\":\"ok\"}", + MediaType.APPLICATION_JSON + )); + + PackageManifest manifest = new PackageManifest(); + manifest.setTraceId("trace-contract-login"); + manifest.setSourceVersion("R_XXX_V3.0.4_XXX"); + + service.pushPackage(manifest, sourceDirectory); + + server.verify(); + } + + private SyncProperties newSyncProperties(String workDir) { + SyncProperties syncProperties = new SyncProperties(); + syncProperties.setWorkDir(workDir); + syncProperties.setPackageTempDir(workDir + "/package"); + syncProperties.setDevToProdStagingDir(workDir + "/dev-to-prod"); + syncProperties.setProdToDevStagingDir(workDir + "/prod-to-dev"); + syncProperties.setPullResponseFileName("prod-config.json"); + return syncProperties; + } +}