feat:支持正则匹配轮询分支

This commit is contained in:
dark 2026-04-28 17:00:58 +08:00
parent 14a8cc6a99
commit 00b81bf7ef
12 changed files with 477 additions and 74 deletions

View File

@ -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 -> PRODsourceVersion/configVersion 已改为 Git 分支名,不再用 commit SHA
Git -> PRODbaseline 已按版本分支隔离
@ -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 参数映射:

View File

@ -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/<configVersion>
流程:
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)
```

View File

@ -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 参数映射规则

View File

@ -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/<configVersion>
流程:
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/<configVersion>
当前代码已经调整为:
- `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 生产接口配置

View File

@ -15,7 +15,8 @@
当前实现约定:
- `git.repo.scan-branch` 指向当前待同步的版本分支
- 优先使用 `git.repo.scan-branch-pattern` 批量匹配版本分支
- 如需定向同步单个版本,也可以显式配置 `git.repo.scan-branch`
- **分支名本身就是 `configVersion`**
- 分支内目录结构必须为:

View File

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

View File

@ -87,19 +87,73 @@ public class ProdSyncCoordinator {
}
/**
* 拉取当前版本分支并把配置文件推送到生产接口
* 当前约定分支名本身就是 configVersion
* 扫描待同步的版本分支并逐个把配置文件推送到生产接口
* 当前支持两种模式
* 1. 指定单个 scanBranch
* 2. scanBranchPattern 批量匹配多个版本分支
*/
public void syncLatestGitToProd() {
try {
List<String> 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<ProdPullResult> 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<SyncTask> 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<ProdPullResult> 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<SyncTask> 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<String> resolveGitToProdBranches() throws Exception {
if (gitRepoProperties.getScanBranch() != null && !gitRepoProperties.getScanBranch().trim().isEmpty()) {
List<String> singleBranch = new ArrayList<String>();
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

View File

@ -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<String> 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<String> matchedBranches = new ArrayList<String>();
List<Ref> 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;
}
}
}
/**
* 导出指定分支的工作树快照供后续打包或哈希计算使用
*/

View File

@ -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

View File

@ -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/<configVersion>

View File

@ -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<SyncCheckpoint> 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<SyncTask> 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<Path> pushedDirectories = new ArrayList<Path>();
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");

View File

@ -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.<String>emptyList(), Collections.<String>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<ProdPullResult> 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;
}
}