From 114bcf33d875d110b800666c0a00cb32d76ac664 Mon Sep 17 00:00:00 2001 From: dark Date: Tue, 28 Apr 2026 14:49:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=89=88=E6=9C=AC=E5=88=86=E6=94=AF=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E6=98=A0=E5=B0=84=E3=80=81=E5=8A=A8=E6=80=81=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=88=86=E6=94=AF=E5=92=8C=20ackFail=20=E5=AE=9A?= =?UTF-8?q?=E5=90=91=E9=87=8D=E6=8B=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Git -> PROD 改为按 branch 作为 configVersion - 按 airportId/appName/fileName 目录结构解析 pushConfig 参数 - PROD -> Git 改为写入 snapshot-branch/ 动态分支 - pullConfig 支持 configVersion/fileName 可选过滤 - 抽出 ConfigCryptoService,统一收口加解密扩展点 - ackFail 落库增加重试上下文,支持按 airportId/appName/configVersion/fileName 定向重拉 - 同步更新测试、接口文档和 current.md --- current.md | 70 ++ docs/ftp-sync-tool-design.md | 598 ++++++------------ docs/ftp-sync-tool-detail-design.md | 442 +++++++------ docs/git-direct-sync-tool-design.md | 364 +++++------ docs/prod-api-v1.md | 264 +++++--- .../sync/config/ProdApiProperties.java | 33 +- .../sync/entity/ProdPullAckRecord.java | 90 ++- .../ftptool/sync/model/ProdPullResult.java | 85 ++- .../orchestrator/ProdSyncCoordinator.java | 181 ++++-- .../ProdPullAckRecordRepository.java | 2 + .../sync/service/ConfigCryptoService.java | 33 + .../sync/service/ProdConfigApiService.java | 299 ++++++--- .../sync/service/ProdPullAckService.java | 213 ++++++- .../sync/service/WorkDirectoryService.java | 18 +- .../application-prod-agent.properties | 2 + src/main/resources/application.properties | 4 +- src/main/resources/schema.sql | 16 + .../ProdSyncCoordinatorIntegrationTest.java | 137 +++- .../service/ProdConfigApiServiceHttpTest.java | 202 +++++- 19 files changed, 1882 insertions(+), 1171 deletions(-) create mode 100644 current.md create mode 100644 src/main/java/com/ftptool/sync/service/ConfigCryptoService.java diff --git a/current.md b/current.md new file mode 100644 index 0000000..73569fe --- /dev/null +++ b/current.md @@ -0,0 +1,70 @@ +当前架构 + +已从 开发 -> FTP -> 生产 改为 生产环境单 prod-agent -> 开发 Git / 生产 push-pull API +正式启动类是 GitDirectSyncToolApplication.java +已完成 + +Git -> PROD 主链路已可用 +PROD -> Git 主链路已可用 +生产真实接口已按 testapi.txt 适配 +pushConfig:POST + JSON数组 +pullConfig:GET + JSON响应 +login:已支持 token 获取与缓存 +ackSuc/ackFail:已接入回传与本地落库 +ConfigCryptoService:已抽出,当前默认透传实现,后续只需替换该服务内算法 +Git -> PROD:已支持最小增量推送,删除场景自动回退全量 +Git -> PROD:已改为按“版本分支 + 机场目录 + 模块目录”解析参数 +PROD -> Git:已按 airportId/appName/fileName 目录结构回写到动态 snapshot 分支 +Git -> PROD:sourceVersion/configVersion 已改为 Git 分支名,不再用 commit SHA +Git -> PROD:baseline 已按版本分支隔离 +pullConfig:已支持 configVersion/fileName 可选过滤参数 +ackFail:已支持按 airportId/appName/configVersion/fileName 定向重拉 +ackFail:失败记录已增加 retryCount/nextRetryAt/lastErrorMsg 元数据 +管理接口已加: +GET /api/admin/sync/overview +GET /api/admin/sync/tasks/recent +GET /api/admin/sync/tasks/failed +docs 下设计文档和接口文档已同步更新到当前口径 + +Git 仓库约定 + +Git -> PROD: +- git.repo.scan-branch 直接指向待同步版本分支 +- 分支名本身就是 configVersion +- 分支内目录结构必须为:airportId/appName/模块内文件 +- pushConfig 参数映射: + - airportId = 路径第1段 + - appName = 路径第2段 + - fileName = 模块内相对路径 + - configVersion = 当前分支名 + +PROD -> Git: +- pullConfig 返回项会恢复为:airportId/appName/fileName +- 当前提交目标分支为:git.repo.snapshot-branch/ +- 例如:config-prod-snapshot/R_XXX_V3.0.3_XXX +- 若 pullConfig 返回缺少统一版本号,则按当前 result 的 sourceVersion 退化生成动态分支名 + +关键文件 + +生产接口适配:ProdConfigApiService.java +加解密扩展点:ConfigCryptoService.java +主协调器:ProdSyncCoordinator.java +ACK 落库: +ProdPullAckRecord.java +ProdPullAckService.java +管理接口:SyncManagementController.java +接口文档:prod-api-v1.md +设计文档: +git-direct-sync-tool-design.md +ftp-sync-tool-design.md +ftp-sync-tool-detail-design.md +当前 TODO + +configContent 推送前加密 +configContent 拉取后解密 +验证状态 + +mvn -s build-support/maven-settings.xml test 已通过 +如果你是想让我继续下一步,最顺的就是: + +把 ConfigCryptoService 的透传实现替换为正式加解密算法 diff --git a/docs/ftp-sync-tool-design.md b/docs/ftp-sync-tool-design.md index 36f88c8..9a3a2ae 100644 --- a/docs/ftp-sync-tool-design.md +++ b/docs/ftp-sync-tool-design.md @@ -1,266 +1,196 @@ -# 基于 Git 直连的配置双向同步工具设计方案 +# 基于 Git 版本分支的配置双向同步工具设计方案 ## 1. 文档目的 -本文档用于说明一套基于 Git 直连的配置同步工具设计方案,满足以下目标: +本文档说明当前 Git 直连同步工具的目标模型、仓库约定和双向同步流程。 -- 生产环境定时从开发 Git 拉取新配置,并调用生产 `push` 接口导入生产 -- 生产环境定时从生产 `pull` 接口拉取当前配置,并回写到开发 Git -- 在 FTP 不再使用的前提下,简化整体架构、降低维护成本 +适用范围: -## 2. 已知约束 +- 生产环境定时从开发 Git 拉取某个版本分支,并调用生产 `pushConfig` 下发配置 +- 生产环境定时从生产 `pullConfig` 拉取当前配置,并回写到 Git 快照分支 +- FTP 已退出主运行面 + +## 2. 核心约束 ### 2.1 技术约束 - JDK:`1.8` - Spring Boot:`2.7.18` -- 轻量数据库:`H2` 或同类开源可商用数据库 -- 其他依赖必须为开源可商用组件 +- 状态库:`H2` +- Git 操作:`JGit` ### 2.2 网络与部署约束 -- 生产环境可以访问开发 Git 仓库 -- 生产环境需要能够调用生产系统 `push/pull` 接口 -- FTP 不再使用 +- 生产环境可以读取开发 Git +- 生产环境需要能向 Git 推送快照分支 +- 生产环境需要能访问生产 `push/pull/login` 接口 +- FTP 不再参与同步流程 -建议先确认一个关键前提: +## 3. 总体架构 -- 生产环境是否对开发 Git 具备“读 + 写”权限 - -说明: - -- 如果生产环境只能读取 Git,无法推送分支,那么“生产 -> 开发 Git”这条链路不能闭环 -- 如果生产环境可以读取和推送 Git,则整套同步可以收敛为单点部署 - -## 3. 新架构结论 - -在新条件下,不再推荐“双端代理 + FTP 中转”。 - -推荐改为: - -- **单端代理 + Git 直连 + 本地状态库** - -即只在生产环境部署一套同步服务: - -- `Sync-Agent-Prod` - -它同时承担两类任务: - -1. 从开发 Git 拉取配置,推送到生产 -2. 从生产 `pull` 接口拉取配置,回写到开发 Git - -整体结构如下: +推荐拓扑: ```text -开发 Git 仓库 <----> 生产环境 Sync-Agent-Prod <----> 生产系统 push/pull 接口 +开发 Git 仓库 <----> 生产环境 prod-agent <----> 生产系统 push/pull 接口 ``` -## 4. 为什么要改成单端部署 - -新架构相比旧方案有明显优势: - -- 去掉 FTP,中转链路减少一跳 -- 去掉打包上传、轮询下载、ACK 回执等中间环节 -- 部署节点减少为 1 个,运维更简单 -- 故障点减少,排查路径更短 -- 数据流更直接,状态一致性更容易控制 - -## 5. 总体方案 - -推荐在生产环境部署唯一同步实例: - -- `Sync-Agent-Prod` - -其职责如下: - -- 拉取开发 Git 主配置分支 -- 检查是否存在待下发的新版本 -- 调用生产 `push` 接口导入配置 -- 定时调用生产 `pull` 接口获取当前生产配置 -- 将生产配置写回 Git 快照分支 -- 使用 H2 记录同步状态、检查点、失败记录 - -## 6. 技术选型 - -| 分类 | 选型 | 说明 | -| --- | --- | --- | -| 运行时 | JDK 1.8 | 满足约束 | -| 框架 | Spring Boot 2.7.18 | 主体框架 | -| 调度 | Spring Scheduling | 实现定时任务 | -| 重试 | Spring Retry | 失败重试 | -| 数据库 | H2 File Mode | 持久化检查点与任务状态 | -| Git 操作 | JGit | 生产环境直接读写 Git | -| HTTP 调用 | RestTemplate | 调用生产 `push/pull` 接口 | -| JSON | Jackson | 标准序列化 | -| 日志 | SLF4J + Logback | 默认日志能力 | - -说明: - -- FTP 客户端依赖在新方案里已经不是核心能力 -- 标准同步包、FTP 目录、ACK 文件等设计可以整体下线 - -## 7. 部署模式 - -### 7.1 推荐模式 - -推荐只部署: +当前只保留一个正式运行角色: - `prod-agent` -不再需要: +## 4. Git 仓库约定 -- `dev-agent` -- FTP 中转服务 +## 4.1 版本分支约定 -### 7.2 运行位置 +当前需求下: -同步工具建议运行在生产环境可控节点上,要求: +- 一个待发布版本对应一个 Git 分支 +- `git.repo.scan-branch` 直接配置为当前待同步版本分支 +- **分支名本身就是 `configVersion`** -- 能访问开发 Git -- 能访问生产 `push/pull` 接口 -- 能持久化本地 H2 文件数据库 +示例: -## 8. 两条核心链路 +- `R_XXX_V3.0.3_XXX` +- `R_XXX_V3.0.4_XXX` -### 8.1 链路一:开发 Git -> 生产 push 接口 +### 4.2 分支内目录约定 -用途: - -- 将开发配置分支中的新配置同步到生产环境 - -流程如下: - -1. `Sync-Agent-Prod` 定时拉取开发 Git 指定分支 -2. 获取最新提交版本号,例如 Git Commit ID -3. 判断该版本是否已成功同步 -4. 如果未同步,则导出配置目录 -5. 调用生产 `push` 接口导入配置 -6. 成功后更新本地检查点和任务状态 - -建议时序图如下: - -```mermaid -sequenceDiagram - participant G as Git(开发) - participant P as Sync-Agent-Prod - participant API as 生产Push接口 - - P->>G: 定时 pull config-dev-main - P->>P: 判断是否有新 commit - P->>P: 导出配置目录 - P->>API: 调用 push 接口 - API-->>P: 返回处理结果 - P->>P: 更新 sync_task / checkpoint -``` - -### 8.2 链路二:生产 pull 接口 -> 开发 Git - -用途: - -- 将当前生产配置快照回写到开发 Git,用于镜像、审计、回溯 - -流程如下: - -1. `Sync-Agent-Prod` 定时调用生产 `pull` 接口 -2. 将返回结果标准化并计算内容哈希 -3. 判断该版本或哈希是否已同步 -4. 如果未同步,则切换到生产快照分支 -5. 写入配置文件 -6. 提交 commit 并 push 到开发 Git -7. 成功后更新本地检查点和任务状态 - -建议时序图如下: - -```mermaid -sequenceDiagram - participant API as 生产Pull接口 - participant P as Sync-Agent-Prod - participant G as Git(开发) - - P->>API: 定时调用 pull 接口 - API-->>P: 返回当前生产配置 - P->>P: 标准化并计算 hash/version - P->>G: checkout config-prod-snapshot - P->>G: commit + push - P->>P: 更新 sync_task / checkpoint -``` - -## 9. Git 分支策略 - -这个设计点仍然必须保留。 - -不建议将“开发配置推生产”和“生产配置回写 Git”使用同一个 Git 分支,否则非常容易形成同步闭环。 - -推荐分支如下: - -- `config-dev-main`:开发主配置分支 -- `config-prod-snapshot`:生产配置镜像分支 - -同步规则: - -- `Git -> PROD` 只消费 `config-dev-main` -- `PROD -> Git` 只写入 `config-prod-snapshot` - -### 9.1 这样设计的价值 - -- 避免生产回写内容再次触发下发 -- 生产快照不会污染开发主线 -- 便于审计“生产当前实际配置” - -### 9.2 机器人提交标记 - -建议同步工具统一使用固定 commit message 前缀,例如: +每个版本分支内部必须按以下结构组织: ```text -sync(prod->git): traceId=xxx version=xxx +//<模块内文件相对路径> ``` -同时: +示例: -- `Git -> PROD` 扫描时只关注 `config-dev-main` -- 不读取 `config-prod-snapshot` +```text +R_XXX_V3.0.3_XXX +├─ PEK +│ └─ monitor +│ ├─ application.yml +│ └─ jobs/sync-job.json +└─ SHA + └─ gate + └─ gate-rule.json +``` -## 10. 状态库设计 +### 4.3 快照分支约定 -新方案建议保留以下核心表: +当前实现使用动态快照分支: -### 10.1 `sync_checkpoint` +- `git.repo.snapshot-branch=config-prod-snapshot` -用于记录各方向最后一次成功同步的检查点。 +`PROD -> Git` 会把拉回的数据提交到: -关键字段: +```text +git.repo.snapshot-branch/ +``` -- `direction` -- `last_success_version` -- `last_success_hash` -- `updated_at` +目录结构同样为: -### 10.2 `sync_task` +```text +// +``` -用于记录每次同步任务生命周期。 +## 5. 两条核心链路 -关键字段: +### 5.1 Git -> PROD -- `trace_id` -- `direction` -- `source_version` -- `content_hash` -- `status` -- `retry_count` -- `error_msg` +目标: -### 10.3 `sync_ack` +- 将当前版本分支中的配置同步到生产 -在新架构下: +流程: -- 不再作为跨节点 ACK 使用 -- 已退出当前主 schema +1. 拉取 `git.repo.scan-branch` +2. 读取当前 `HEAD revision` +3. 以 **分支名** 作为业务版本号 `sourceVersion` +4. 导出该分支工作树 +5. 解析所有 `airportId/appName/fileName` 配置项 +6. 调用生产 `pushConfig` +7. 成功后更新 `sync_task` 和 `sync_checkpoint` -如果后续需要审计扩展,可以单独恢复为接口调用日志表。 +说明: -## 11. 幂等设计 +- `revision` 仅用于日志和 staging 目录隔离 +- 当前不再使用 `commit SHA` 作为下发版本号 -建议继续使用以下组合作为幂等键: +### 5.2 PROD -> Git + +目标: + +- 将生产当前配置快照回写到 Git + +流程: + +1. 调用生产 `pullConfig` +2. 把返回项落盘为 `airportId/appName/fileName` +3. 计算内容哈希和来源版本 +4. 提交到 `git.repo.snapshot-branch/` +5. 成功后更新 `sync_task` 和 `sync_checkpoint` + +## 6. 接口参数与路径映射 + +Git -> PROD 时,每个文件都映射为一条 `pushConfig` 记录: + +| Git 信息 | 接口字段 | +| --- | --- | +| 分支名 | `configVersion` | +| 路径第 1 段 | `airportId` | +| 路径第 2 段 | `appName` | +| 路径第 3 段及之后 | `fileName` | +| 文件内容 | `configContent` | + +例如: + +```text +PEK/monitor/jobs/sync-job.json +``` + +会变成: + +```json +{ + "airportId": "PEK", + "appName": "monitor", + "configVersion": "R_XXX_V3.0.3_XXX", + "fileName": "jobs/sync-job.json" +} +``` + +## 7. 增量策略 + +当前 Git -> PROD 已实现: + +- 首次同步全量 +- 后续优先按文件哈希做最小增量 +- 检测到删除时自动回退为全量 + +并且 baseline 已按版本分支隔离: + +```text +work/baseline/git-to-prod// +``` + +避免不同版本分支之间相互污染。 + +## 8. 状态设计 + +当前状态表: + +- `sync_checkpoint` +- `sync_task` +- `prod_pull_ack` + +说明: + +- `sync_checkpoint`:记录某方向最后一次成功版本和哈希 +- `sync_task`:记录每次同步任务和重试状态 +- `prod_pull_ack`:记录 `pullConfig` 的 `ackSuc/ackFail` 回传状态 + +## 9. 幂等设计 + +当前幂等键: ```text direction + sourceVersion + contentHash @@ -268,219 +198,61 @@ direction + sourceVersion + contentHash 作用: -- 同一开发版本不会重复推生产 -- 同一生产快照不会重复写 Git +- 同一版本分支下同一内容不会重复推送 +- 同一生产快照不会重复回写 Git -## 12. 失败处理与补偿 +## 10. 当前配置口径 -### 12.1 自动重试 +关键 Git 配置: -以下场景建议自动重试: +```properties +git.repo.scan-branch=R_XXX_V3.0.3_XXX +git.repo.snapshot-branch=config-prod-snapshot +git.repo.commit-message-prefix=sync(prod->git) +``` -- Git pull 失败 -- Git push 失败 -- 生产 `push` 接口调用失败 -- 生产 `pull` 接口调用失败 +关键接口配置: -建议策略: - -- 最大重试次数:`3 ~ 5` -- 指数退避:`30s / 60s / 120s` - -### 12.2 失败落库 - -失败后建议: - -- 更新 `sync_task.status=FAILED` -- 记录异常堆栈摘要 -- 增加重试次数 -- 保留最近一次成功检查点不变 - -### 12.3 人工补偿 - -后续可增加管理接口,支持: - -- 按 `traceId` 重试 -- 按方向重跑最近一次失败任务 -- 查询最近同步记录 - -## 13. 安全设计 - -### 13.1 Git 访问建议 - -推荐使用: - -- HTTPS + Token - -或: - -- SSH Deploy Key - -### 13.2 权限建议 - -生产环境访问 Git 的账号建议采用最小权限原则: - -- 对 `config-dev-main` 至少有读取权限 -- 对 `config-prod-snapshot` 需要推送权限 - -更理想的做法: - -- 使用专用机器人账号 -- 对开发主分支启用保护 -- 限制机器人只写快照分支 - -### 13.3 生产接口认证 - -生产 `push/pull` 接口建议使用: - -- `Bearer Token` -- HTTPS - -## 14. 项目结构建议 - -新架构下建议进一步简化模块职责: - -```text -sync-tool - |- src/main/java - | |- config - | |- git - | |- job - | |- repository - | |- service - | |- web - |- src/main/resources - | |- application.properties - | |- application-prod-agent.properties +```properties +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 +prod.api.login-path=/pic_bus_manage_monitor/pam-monitor/login +prod.api.token=replace-me +prod.api.token-header-name=token +prod.api.airport-id= +prod.api.app-name= +prod.api.pull-config-version= +prod.api.pull-file-name= ``` 说明: -- `prod-agent` 是唯一正式运行角色 -- `dev-agent` 与 FTP 相关模块已退出主运行面 +- `prod.api.airport-id`、`prod.api.app-name` 当前只作为 `pullConfig` 可选过滤参数 +- 它们不再承担 Git -> PROD 的参数组装职责 +- `prod.api.pull-config-version`、`prod.api.pull-file-name` 可用于进一步缩小 `pullConfig` 拉取范围 -## 15. 核心模块划分 +## 11. 当前已实现能力 -建议保留并聚焦以下模块: +- `pushConfig`:`POST + JSON 数组` +- `pullConfig`:`GET + JSON 响应` +- `login`:自动获取并缓存 token +- `ackSuc/ackFail`:已接入请求回传与本地落库 +- Git -> PROD:已支持最小增量推送 +- 管理接口: + - `GET /api/admin/sync/overview` + - `GET /api/admin/sync/tasks/recent` + - `GET /api/admin/sync/tasks/failed` -- `GitClientService` - - clone / pull / checkout / commit / push -- `ProdConfigApiService` - - 调用生产 `push/pull` 接口 -- `SyncTaskService` - - 任务创建、状态变更、重试次数维护 -- `CheckpointService` - - 成功检查点维护 -- `ProdSyncCoordinator` - - 串联双向同步流程 -- `JobScheduler` - - 定时调度 +## 12. 当前未完成项 -已退出主运行面: +- 将 `ConfigCryptoService` 的透传实现替换为正式加解密算法 -- FTP 包上传下载逻辑 -- FTP ACK 逻辑 -- 双端代理运行路径 +## 13. 结论 -## 16. 定时任务建议 +当前文档口径应统一为: -新架构下推荐保留两类核心任务: - -### 16.1 `GitToProdSyncJob` - -职责: - -- 拉取 `config-dev-main` -- 判断是否有新 commit -- 调用生产 `push` 接口 - -### 16.2 `ProdToGitSnapshotJob` - -职责: - -- 调用生产 `pull` 接口 -- 判断是否有新快照 -- 提交到 `config-prod-snapshot` - -可选任务: - -- `RetryFailedTaskJob` -- `HealthCheckJob` - -## 17. 一期 MVP 建议 - -建议重新按最小可交付版本收敛: - -### 阶段 1:打通 Git -> 生产 - -- 生产环境直连开发 Git -- 实现 `config-dev-main` 拉取 -- 实现生产 `push` 接口调用 -- 落库记录同步状态 - -### 阶段 2:打通 生产 -> Git - -- 接入生产 `pull` 接口 -- 回写 `config-prod-snapshot` -- 实现 commit + push - -### 阶段 3:增强稳定性 - -- 补充重试 -- 补充管理接口 -- 补充告警与审计日志 - -## 18. 风险与注意事项 - -### 18.1 最大风险:Git 写权限不足 - -如果生产环境对开发 Git 没有推送权限,则“生产 -> Git”链路无法完成。 - -解决方案: - -- 申请机器人账号 -- 或将“生产回写 Git”改成调用开发侧服务接口 - -### 18.2 最大风险:双向同步闭环 - -如果生产回写到了开发主分支,会再次触发下发。 - -规避措施: - -- 使用独立快照分支 -- 不扫描快照分支 -- 使用幂等键和机器人提交标记 - -### 18.3 最大风险:生产直连开发 Git 的安全边界 - -需要明确: - -- 网络访问是否合规 -- Git 账号权限是否受控 -- Token 或 SSH Key 是否可轮换 - -## 19. 结论 - -在“生产环境可以直接访问开发 Git,FTP 不再需要”的前提下,推荐将旧方案调整为: - -- **生产环境单点部署** -- **Git 直连** -- **保留生产 `push/pull` 接口** -- **保留 H2 状态库** - -这是比原来 FTP 中转更合适的方案,原因是: - -- 架构更简单 -- 故障点更少 -- 链路更短 -- 运维成本更低 - -## 20. 下一步建议 - -下一步建议按下面顺序推进: - -1. 先确认生产环境对开发 Git 是否具备推送权限 -2. 确认生产 `push/pull` 接口最终协议 -3. 删除退役标记文件 `application-dev-agent.properties` -4. 将工程命名中残留的 `ftp` 语义继续清理 -5. 补充新的 `application-prod-agent.properties` 配置说明 +- **版本用 Git 分支表达** +- **机场和模块用目录表达** +- **接口参数由路径直接解析** +- **快照按 `git.repo.snapshot-branch/` 动态分支写回** diff --git a/docs/ftp-sync-tool-detail-design.md b/docs/ftp-sync-tool-detail-design.md index 127fef6..b9d4e59 100644 --- a/docs/ftp-sync-tool-detail-design.md +++ b/docs/ftp-sync-tool-detail-design.md @@ -1,53 +1,30 @@ -# Git 直连架构详细设计 +# Git 直连架构详细设计 ## 1. 文档说明 -本文档用于承接主方案文档,说明在“FTP 下线、生产环境可直接访问开发 Git”条件下的详细设计。 +本文档描述当前实现层面的详细设计,重点覆盖: -当前目标是把系统收敛为: +- Git 版本分支和目录结构约定 +- `Git -> PROD` 的增量与参数映射逻辑 +- `PROD -> Git` 的落盘与回写逻辑 +- 运行配置、工作目录和已知限制 -- 单 `prod-agent` -- Git 直连 -- 生产 `push/pull` 接口驱动 -- H2 本地状态控制 +## 2. 当前推荐部署 -## 2. 架构变化摘要 - -旧架构: - -```text -开发环境 <-> FTP <-> 生产环境 -``` - -新架构: - -```text -开发 Git <-> 生产环境 Sync-Agent-Prod <-> 生产 push/pull 接口 -``` - -关键变化: - -- FTP 中转取消 -- `dev-agent` 不再是必需部署节点 -- 生产环境成为唯一同步执行节点 -- ACK 文件机制不再作为主流程依赖 - -## 3. 当前推荐部署 - -推荐只部署: +当前推荐只部署: - `prod-agent` 运行要求: - 能访问开发 Git -- 能 push 指定 Git 分支 -- 能访问生产 `push/pull` 接口 +- 能向 Git 推送 `snapshotBranch` +- 能访问生产 `push/pull/login` 接口 - 能写本地 H2 文件数据库 -## 4. 配置文件策略 +## 3. 配置文件策略 -当前配置文件仍使用 `properties`: +当前使用: ```text src/main/resources/ @@ -58,224 +35,243 @@ src/main/resources/ 说明: -- `application-dev-agent.properties` 现阶段仅保留为退役标记文件 -- `application-prod-agent.properties` 已开始收敛到新架构 +- `application.properties` 提供公共默认值 +- `application-prod-agent.properties` 提供生产侧 profile 配置 -## 5. 当前配置口径 +## 4. 核心配置口径 -### 5.1 仍然保留的核心配置 +### 4.1 Git 配置 -公共配置: +| 配置项 | 说明 | +| --- | --- | +| `git.repo.remote-uri` | Git 远端地址 | +| `git.repo.username` | Git 用户名 | +| `git.repo.password` | Git 密码或 token | +| `git.repo.scan-branch` | 当前待同步的版本分支名 | +| `git.repo.snapshot-branch` | 动态生产快照分支前缀 | +| `git.repo.commit-message-prefix` | PROD -> Git 提交前缀 | -- `spring.datasource.*` -- `spring.jpa.*` -- `spring.sql.init.*` +### 4.2 生产接口配置 -同步配置: +| 配置项 | 说明 | +| --- | --- | +| `prod.api.base-url` | 生产接口基础地址 | +| `prod.api.push-path` | `pushConfig` 路径 | +| `prod.api.pull-path` | `pullConfig` 路径 | +| `prod.api.login-path` | `login` 路径 | +| `prod.api.token` | 静态 token,可选 | +| `prod.api.token-header-name` | token 请求头名称 | +| `prod.api.airport-id` | `pullConfig` 可选过滤 | +| `prod.api.app-name` | `pullConfig` 可选过滤 | +| `prod.api.pull-config-version` | `pullConfig` 可选版本过滤 | +| `prod.api.pull-file-name` | `pullConfig` 可选文件过滤 | -- `sync.node-id` -- `sync.role` -- `sync.work-dir` -- `sync.package-temp-dir` -- `sync.dev-to-prod-staging-dir` -- `sync.prod-to-dev-staging-dir` -- `sync.max-retry-count` +说明: -Git 配置: +- `prod.api.airport-id`、`prod.api.app-name` 当前只用于 `pullConfig` 的可选过滤 +- 它们已经不再用于 Git -> PROD 参数解析 -- `git.repo.remote-uri` -- `git.repo.username` -- `git.repo.password` -- `git.repo.scan-branch` -- `git.repo.snapshot-branch` -- `git.repo.commit-message-prefix` +### 4.3 调度配置 -生产接口配置: - -- `prod.api.base-url` -- `prod.api.push-path` -- `prod.api.pull-path` -- `prod.api.token` - -### 5.2 新的生产侧调度配置 - -当前已调整为: +当前生产侧调度: - `sync.jobs.prod-git-to-prod.cron` - `sync.jobs.prod-to-git.cron` -对应文件: +## 5. Git 仓库约定 -- [application-prod-agent.properties](e:/AIcoding/FtpTool/src/main/resources/application-prod-agent.properties) +### 5.1 版本分支 -### 5.3 遗留 FTP 配置 +当前实现约定: -当前 `application.properties` 中已经移除了 `ftp.*` 相关公共配置。 +- `git.repo.scan-branch` 直接指向当前待同步版本分支 +- **分支名本身就是 `configVersion`** -当前状态: +例如: -- FTP 调度主路径已退出运行面 -- FTP 相关主类已从当前源码树中移除 +- `R_XXX_V3.0.3_XXX` -## 6. H2 状态设计 +### 5.2 分支内目录结构 -当前主表保留为: +当前分支内目录必须为: + +```text +//<模块内文件相对路径> +``` + +示例: + +```text +R_XXX_V3.0.3_XXX +├─ PEK +│ └─ monitor +│ ├─ application.yml +│ └─ jobs/sync-job.json +└─ SHA + └─ gate + └─ gate-rule.json +``` + +### 5.3 路径校验 + +当前实现要求每个待推送文件都至少有 3 段路径: + +```text +airportId / appName / fileName +``` + +否则直接视为非法仓库结构并终止同步。 + +## 6. 工作目录设计 + +当前工作目录: + +```text +work/ +├─ package/ +├─ staging/ +│ ├─ dev-to-prod/ +│ └─ prod-to-dev/ +└─ baseline/ + └─ git-to-prod/ + └─ / +``` + +说明: + +- `dev-to-prod/`:保存导出的 Git 版本快照与增量目录 +- `prod-to-dev/`:保存 `pullConfig` 恢复出的临时目录 +- `baseline/git-to-prod//`:保存某个版本分支上次成功下发后的基线 + +## 7. Git -> PROD 详细流程 + +### 7.1 流程步骤 + +1. 拉取 `git.repo.scan-branch` +2. 读取当前 `HEAD revision` +3. 以分支名作为 `sourceVersion` +4. 导出分支工作树到 staging 目录 +5. 计算目录内容哈希 +6. 按 `direction + sourceVersion + contentHash` 做幂等判断 +7. 计算本次推送目录: + - 首次全量 + - 删除回退全量 + - 其余场景最小增量 +8. 遍历目录内所有文件,按路径解析出 `airportId/appName/fileName` +9. 组装 `pushConfig` JSON 数组并提交 +10. 成功后刷新 checkpoint、task 和 baseline + +### 7.2 参数映射规则 + +| Git 信息 | pushConfig 字段 | +| --- | --- | +| `scan-branch` | `configVersion` | +| 路径第 1 段 | `airportId` | +| 路径第 2 段 | `appName` | +| 第 3 段及之后 | `fileName` | +| 文件 UTF-8 内容 | `configContent` | + +### 7.3 当前实现说明 + +- `HEAD revision` 仅用于日志和 staging 目录名 +- 不再用 `commit SHA` 作为对外业务版本号 +- `configContent` 目前仍是明文 + +## 8. PROD -> Git 详细流程 + +### 8.1 流程步骤 + +1. 调用 `pullConfig` +2. 可选附带 `airportId/appName` 过滤 +3. 自动附带本地待回传的 `ackSuc/ackFail` +4. 解析响应项 +5. 按 `airportId/appName/fileName` 落盘到 staging 目录 +6. 计算内容哈希和来源版本 +7. 按幂等键判断是否已处理 +8. 提交到 `git.repo.snapshot-branch/` +9. 成功后更新 checkpoint、task 和本地 ACK 状态 + +### 8.2 来源版本规则 + +当前实现: + +- 如果本次 `pullConfig` 返回项里的 `configVersion` 全部一致,则该值作为 `sourceVersion` +- 如果返回多种不同 `configVersion`,则退回为内容哈希 + +### 8.3 动态快照分支规则 + +- 每个 `ProdPullResult` 都会写入独立目标分支: + +```text +git.repo.snapshot-branch/ +``` + +- 当 `sourceVersion` 就是接口返回的统一 `configVersion` 时,目标分支即按版本动态展开 +- 当某组缺少版本号时,会退回为基于内容哈希的 `sourceVersion` + +## 9. 当前接口协议实现口径 + +### 9.1 pushConfig + +- 方法:`POST` +- 载荷:`JSON 数组` +- 每个文件一条记录 +- `ackFail` 非空时本次同步直接失败 +- `configContent` 已统一通过 `ConfigCryptoService` 处理,当前默认实现仍为透传 + +### 9.2 pullConfig + +- 方法:`GET` +- 参数:query string +- 当前已支持: + - `airportId` + - `appName` + - `ackSuc` + - `ackFail` +- 当前已支持: + - `configVersion` + - `fileName` +- 对于本地处理失败的 `ackFail` 项,当前会持久化重试上下文,并在下一个周期按 `airportId/appName/configVersion/fileName` 定向重拉 + +### 9.3 login + +- 方法:`POST` +- 如果未配置静态 token,则自动调用 +- token 会按 `expireTime` 缓存 + +## 10. 状态表设计 + +当前主表: - `sync_checkpoint` - `sync_task` +- `prod_pull_ack` -对应脚本: +作用: -- [schema.sql](e:/AIcoding/FtpTool/src/main/resources/schema.sql) +- `sync_checkpoint`:方向级最后成功版本和哈希 +- `sync_task`:任务状态、重试次数、错误摘要 +- `prod_pull_ack`:待回传给生产侧的 ACK 状态 -### 6.1 保留原因 +## 11. 当前已实现能力 -即使 FTP 已下线,状态库仍然必须保留,用于: +- Git -> PROD 主链路已可用 +- PROD -> Git 主链路已可用 +- `pushConfig`/`pullConfig`/`login` 已适配正式协议 +- `ackSuc/ackFail` 已接入回传与本地落库 +- Git -> PROD 已支持最小增量推送 +- 管理接口已提供最近任务、失败任务和检查点视图 -- 幂等控制 -- 检查点管理 -- 失败重试 -- 审计追踪 +## 12. 当前主要 TODO -### 6.2 当前建议 +- 将 `ConfigCryptoService` 的透传实现替换为正式加解密算法 -- `sync_checkpoint` 和 `sync_task` 继续作为主表 -- `sync_ack` 已退出当前主 schema +## 13. 结论 -## 7. 当前代码结构与新架构对应关系 +当前详细设计已经不再是“固定主分支 + 全局机场配置”的口径,而是: -### 7.1 已经可继续复用的核心类 +- 版本靠分支表达 +- 机场和模块靠目录表达 +- 路径直接决定接口参数 -- [GitClientService.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/service/GitClientService.java) -- [ProdConfigApiService.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java) -- [PackageService.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/service/PackageService.java) -- [SyncTaskService.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/service/SyncTaskService.java) -- [CheckpointService.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/service/CheckpointService.java) - -### 7.2 当前生产侧主协调器 - -- [ProdSyncCoordinator.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java) - -当前生产侧主流程已经切到新架构: - -- `syncLatestGitToProd()`:Git -> 生产 `push` -- `syncProdSnapshotToGit()`:生产 `pull` -> Git snapshot 分支 - -### 7.3 当前生产侧正式调度类 - -- [GitToProdSyncJob.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/job/GitToProdSyncJob.java) -- [ProdToGitSnapshotJob.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/job/ProdToGitSnapshotJob.java) - -说明: - -- 这两个类是当前推荐使用的正式调度入口 -- 已分别对应 `Git -> PROD` 和 `PROD -> Git` - -### 7.4 当前遗留占位类 - -这批旧调度类已经从当前源码树中删除: - -说明: - -- 已不再作为正式运行入口 -- 当前代码树只保留 Git 直连架构需要的正式 job - -### 7.5 当前遗留代码 - -以下内容仍然存在于代码库,但属于旧架构遗留: - -- `application-dev-agent.properties` 退役标记文件 -- 少量文件名或文档中的 `ftp` 语义残留 - -这些不是当前推荐运行路径。 - -## 8. 新架构下的两条任务流 - -### 8.1 Git -> 生产 - -当前推荐实现步骤: - -1. 拉取 `config-dev-main` -2. 获取最新 commit 作为 `sourceVersion` -3. 导出工作树快照 -4. 计算内容哈希 -5. 生成标准 zip 包 -6. 调用生产 `push` 接口 -7. 更新 `sync_task` 和 `sync_checkpoint` - -### 8.2 生产 -> Git - -当前推荐实现步骤: - -1. 调用生产 `pull` 接口 -2. 保存返回配置到本地 staging 目录 -3. 计算哈希和版本号 -4. 写入 `config-prod-snapshot` -5. commit + push -6. 更新 `sync_task` 和 `sync_checkpoint` - -## 9. 当前接口假设 - -当前生产接口仍按以下假设实现: - -- `push` 使用 `POST + multipart/form-data` -- `pull` 使用 `GET` -- `pull` 返回原始 JSON 字节流 -- 版本号优先取 `X-Config-Version`,其次 `ETag` - -详细协议文档见: - -- [prod-api-v1.md](e:/AIcoding/FtpTool/docs/prod-api-v1.md) - -## 9.1 管理接口 - -当前已新增一组只读管理接口,用于查看最近同步状态和失败任务: - -- `GET /api/admin/sync/overview` -- `GET /api/admin/sync/tasks/recent` -- `GET /api/admin/sync/tasks/failed` - -对应实现: - -- [SyncManagementController.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/web/SyncManagementController.java) - -## 10. 当前主要风险 - -### 10.1 Git 写权限 - -如果生产环境对 Git 没有 push 权限,则“生产 -> Git”链路无法完成。 - -### 10.2 旧代码残留 - -当前主运行面已经切到 Git 直连,但源码树里仍保留少量退役占位类。 - -当前状态: - -- 文件名和类名仍可能误导维护者 -- 旧架构源码主体已经删除 -- 主要剩余问题转为命名和文档口径统一 - -### 10.3 双向同步闭环 - -如果误把生产回写分支也作为下发分支扫描,会造成循环同步。 - -## 11. 推荐的下一轮改造 - -建议按下面顺序继续: - -1. 删除或隔离 `dev-agent` 运行路径 -2. 删除退役标记文件 `application-dev-agent.properties` -3. 统一清理残留的 `ftp` 命名 -4. 补充管理接口和健康检查接口 -5. 增加集成测试 - -## 12. 结论 - -当前系统已经从“FTP 中转”开始转向“Git 直连”。 - -现阶段最重要的不是继续增强旧链路,而是彻底收敛到: - -- 单 `prod-agent` -- 两个核心任务 -- 一个 Git 仓库入口 -- 一组生产 `push/pull` 接口 +后续再扩展功能时,应在这个模型上继续演进。 diff --git a/docs/git-direct-sync-tool-design.md b/docs/git-direct-sync-tool-design.md index ab378b8..197d022 100644 --- a/docs/git-direct-sync-tool-design.md +++ b/docs/git-direct-sync-tool-design.md @@ -1,253 +1,237 @@ -# Git 直连架构补充方案 +# Git 直连架构补充方案 ## 1. 背景变化 -旧方案的前提是: +旧方案依赖: -- 开发环境和生产环境不能直接交换同步数据 -- 需要通过 `开发环境 -> FTP -> 生产环境` 中转 +- 开发环境 -> FTP -> 生产环境 -现在条件已经变化为: +当前条件已经变为: - FTP 不再使用 -- 生产环境可以直接访问开发 Git 仓库 +- 生产环境可以直接访问开发 Git +- 生产侧继续通过 `pushConfig/pullConfig/login` 接口与业务系统交互 -这意味着旧架构里的 FTP 中转层已经没有存在价值,应该直接删除。 - -## 2. 新架构结论 - -推荐把原来的“双端代理 + FTP 中转”收敛为: +因此当前架构已经收敛为: - **单端代理 + Git 直连 + 本地状态库** -也就是只在生产环境部署一套同步服务: +## 2. 新架构结论 -- `Sync-Agent-Prod` - -整体拓扑如下: - -```text -开发 Git 仓库 <----> 生产环境 Sync-Agent-Prod <----> 生产系统 push/pull 接口 -``` - -## 3. 为什么这样改 - -新架构比旧架构更合理,原因很直接: - -- 去掉 FTP,中间链路减少一跳 -- 去掉打包上传、轮询下载、ACK 回执等中间机制 -- 部署节点从 2 个变成 1 个 -- 故障点减少,排查成本更低 -- 数据路径更直接,状态控制更简单 - -## 4. 新方案的核心思路 - -既然生产环境已经能访问开发 Git,那么同步动作都可以在生产环境完成。 - -生产环境部署的 `Sync-Agent-Prod` 同时承担两条链路: - -### 4.1 开发 Git -> 生产 - -流程: - -1. 定时拉取开发 Git 的配置分支 -2. 获取最新 commit 版本 -3. 判断该版本是否已同步 -4. 如果未同步,则读取配置内容 -5. 调用生产 `push` 接口导入配置 -6. 成功后更新本地检查点 - -### 4.2 生产 -> 开发 Git - -流程: - -1. 定时调用生产 `pull` 接口 -2. 获取当前生产配置快照 -3. 计算内容哈希或版本号 -4. 判断该快照是否已同步 -5. 如果未同步,则写入 Git 快照分支 -6. commit 并 push 到开发 Git -7. 成功后更新本地检查点 - -## 5. 部署建议 - -### 5.1 推荐部署方式 - -推荐只保留: +当前推荐只在生产环境部署一套同步服务: - `prod-agent` -不再需要: +整体拓扑: -- `dev-agent` -- FTP 服务 -- FTP ACK、包中转、失败目录等机制 +```text +开发 Git 仓库 <----> 生产环境 prod-agent <----> 生产系统 push/pull 接口 +``` -### 5.2 运行前提 +## 3. Git 仓库模型 -生产环境需要同时满足: +### 3.1 版本分支 -- 能读取开发 Git -- 能向开发 Git 推送指定分支 -- 能调用生产 `push/pull` 接口 -- 能持久化 H2 文件数据库 +当前需求下: -这里有一个必须先确认的关键点: +- **每个待下发版本对应一个 Git 分支** +- `git.repo.scan-branch` 指向当前待同步的版本分支 +- **分支名本身就是 `configVersion`** -- **生产环境对开发 Git 是否有 push 权限** +例如: -如果只有读权限,没有 push 权限,那么第二条链路“生产 -> 开发 Git”无法闭环。 +- `R_XXX_V3.0.3_XXX` +- `R_XXX_V3.0.4_XXX` -## 6. Git 分支策略 +### 3.2 分支内目录结构 -这个设计必须保留,不然非常容易形成同步闭环。 +每个版本分支内目录必须为: -建议继续使用两个分支: +```text +//<模块内文件相对路径> +``` -- `config-dev-main`:开发主配置分支 -- `config-prod-snapshot`:生产配置镜像分支 +示例: -同步规则: +```text +R_XXX_V3.0.3_XXX +├─ PEK +│ └─ monitor +│ ├─ application.yml +│ └─ jobs/sync-job.json +└─ SHA + └─ gate + └─ gate-rule.json +``` -- `Git -> PROD` 只读 `config-dev-main` -- `PROD -> Git` 只写 `config-prod-snapshot` +### 3.3 当前快照分支 -这样做的好处: +当前实现使用“固定前缀 + 动态版本段”的快照分支: -- 避免生产回写内容再次触发生产下发 -- 生产快照不污染开发主线 -- 便于审计和回溯 +- `git.repo.snapshot-branch` -## 7. 状态管理 +`PROD -> Git` 会把生产拉回的配置写入: -虽然 FTP 没了,但本地状态库仍然必须保留。 +```text +git.repo.snapshot-branch/ +``` -建议继续保留: +分支内目录结构同样为: + +```text +// +``` + +说明: + +- `git.repo.snapshot-branch` 当前表示分支前缀,不再是唯一固定目标分支 +- 实际写入目标由 `sourceVersion/configVersion` 动态展开 + +## 4. 两条核心链路 + +### 4.1 Git -> PROD + +流程: + +1. `prod-agent` 拉取 `git.repo.scan-branch` 指定的版本分支 +2. 读取当前 `HEAD revision`,仅用于日志和 staging 隔离 +3. 以 **分支名** 作为 `sourceVersion/configVersion` +4. 导出分支工作树 +5. 按 `airportId/appName/fileName` 解析所有配置文件 +6. 调用生产 `pushConfig` +7. 成功后更新本地检查点和任务状态 + +### 4.2 PROD -> Git + +流程: + +1. `prod-agent` 调用生产 `pullConfig` +2. 读取返回项中的 `airportId/appName/fileName` +3. 恢复到本地 staging 目录 +4. 计算内容哈希和来源版本 +5. 提交到 `git.repo.snapshot-branch/` +6. 成功后更新本地检查点和任务状态 + +## 5. 当前实现细节 + +### 5.1 Git -> PROD 版本语义 + +当前代码已经调整为: + +- `sourceVersion = git.repo.scan-branch` +- 不再使用 `commit SHA` 作为业务版本号 + +`HEAD revision` 仍然保留,但仅用于: + +- staging 目录命名 +- 日志排查 + +### 5.2 增量推送 + +当前实现: + +- 首次推送走全量 +- 后续和本地 baseline 做对比 +- 无删除时只推变更文件 +- 检测到删除时自动回退为全量 + +并且 baseline 已按版本分支隔离,避免不同版本分支之间相互污染。 + +### 5.3 pushConfig 参数映射 + +当前映射关系如下: + +- `airportId` = Git 路径第 1 段 +- `appName` = Git 路径第 2 段 +- `fileName` = 模块内相对路径 +- `configVersion` = 当前版本分支名 + +### 5.4 pullConfig 回写结构 + +当前回写目录: + +```text +// +``` + +因此 `snapshotBranch` 应当被视为快照分支前缀,不建议在该前缀下混入其他开发分支。 + +## 6. 状态管理 + +当前继续保留: - `sync_checkpoint` - `sync_task` +- `prod_pull_ack` -`sync_ack` 在新架构下不再承担跨节点 ACK 作用。 +作用分别是: -当前建议: +- `sync_checkpoint`:记录某个方向最后一次成功版本和哈希 +- `sync_task`:记录同步任务生命周期和重试状态 +- `prod_pull_ack`:记录下次 `pullConfig` 需要回传的 `ackSuc/ackFail` -- 已退出主 schema -- 如后续需要审计,可独立恢复 +## 7. 幂等设计 -## 8. 幂等设计 - -建议继续使用: +当前幂等键仍然是: ```text direction + sourceVersion + contentHash ``` -作为业务幂等键。 +在 Git -> PROD 场景下: -作用: +- `sourceVersion` = 版本分支名 -- 同一个开发版本不会重复推生产 -- 同一个生产快照不会重复写 Git +在 PROD -> Git 场景下: -## 9. 失败处理 +- `sourceVersion` 优先取 `pullConfig` 返回的统一 `configVersion` +- 如果返回多版本混合数据,则退回为内容哈希 -建议自动重试以下场景: +## 8. 当前配置口径 -- Git pull 失败 -- Git push 失败 -- 生产 `push` 接口失败 -- 生产 `pull` 接口失败 +### 8.1 Git 配置 -建议策略: +```properties +git.repo.scan-branch=R_XXX_V3.0.3_XXX +git.repo.snapshot-branch=config-prod-snapshot +``` -- 最大重试次数:`3 ~ 5` -- 指数退避:`30s / 60s / 120s` +说明: -失败后: +- `scan-branch` 当前应直接配置为待同步版本分支名 +- `snapshot-branch` 当前表示动态快照分支前缀 -- 更新 `sync_task` 状态 -- 保留错误信息 -- 不推进检查点 +### 8.2 生产接口配置 -## 10. 安全建议 +```properties +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 +prod.api.login-path=/pic_bus_manage_monitor/pam-monitor/login +prod.api.token=replace-me +prod.api.token-header-name=token +prod.api.airport-id= +prod.api.app-name= +prod.api.pull-config-version= +prod.api.pull-file-name= +``` -### 10.1 Git 访问 +说明: -推荐使用: +- `prod.api.airport-id`、`prod.api.app-name` 不再用于 Git -> PROD 参数解析 +- 它们当前只作为 `pullConfig` 的可选过滤参数 +- `prod.api.pull-config-version`、`prod.api.pull-file-name` 可用于精细过滤拉取范围 -- HTTPS + Token +## 9. 当前仍保留的 TODO -或: +- 将 `ConfigCryptoService` 的透传实现替换为正式加解密算法 -- SSH Deploy Key +## 10. 结论 -### 10.2 权限控制 +当前最重要的变化不是“继续抽象通用分支”,而是先把仓库约定和接口参数映射对齐: -生产环境访问 Git 的账号建议最小权限化: +- 版本用分支表达 +- 机场和模块用目录表达 +- 生产接口参数由路径直接解析 -- 对 `config-dev-main` 有读权限 -- 对 `config-prod-snapshot` 有写权限 - -更理想的做法: - -- 使用专用机器人账号 -- 对主分支做保护 -- 机器人只写快照分支 - -## 11. 对现有代码的影响 - -这次需求变化对当前代码影响很大,结论如下: - -### 11.1 可以继续保留的部分 - -- `GitClientService` -- `ProdConfigApiService` -- `SyncTaskService` -- `CheckpointService` -- H2 表结构 -- 定时任务框架 - -### 11.2 已退出主运行面的部分 - -- FTP 目录配置 -- 包上传/下载流程 -- ACK 文件机制 -- 双端部署假设 - -### 11.3 当前代码状态 - -当前代码已经收敛为: - -- 一个正式运行角色 `prod-agent` -- 两个正式调度任务 - -即: - -1. `GitToProdSyncJob` -2. `ProdToGitSnapshotJob` - -旧架构源码主体已经删除,当前剩余问题主要是: - -- 个别资源文件仍保留退役标记 -- 工程内少量 `ftp` 命名仍待统一 - -## 12. 结论 - -现在最合理的做法不是“在旧 FTP 方案上修修补补”,而是直接把架构收敛成: - -- **生产环境单点部署** -- **直连开发 Git** -- **继续调用生产 `push/pull` 接口** -- **保留 H2 做状态控制** - -这是一次简化,不是一次退化。 - -## 13. 下一步建议 - -建议按这个顺序推进: - -1. 先确认生产环境是否具备开发 Git 的 push 权限 -2. 确认生产 `push/pull` 接口最终协议 -3. 删除退役标记文件 `application-dev-agent.properties` -4. 继续清理工程中残留的 `ftp` 命名 -5. 补充健康检查和管理能力 +这一层已经进入正式实现口径,后续文档和代码都应以此为准。 diff --git a/docs/prod-api-v1.md b/docs/prod-api-v1.md index 6c847f2..0f78943 100644 --- a/docs/prod-api-v1.md +++ b/docs/prod-api-v1.md @@ -1,33 +1,91 @@ -# 生产端配置同步接口文档 V1 +# 生产端配置同步接口文档 V1 ## 1. 文档目的 -本文档基于 `testapi.txt` 中给出的正式接口协议,定义生产端配置同步相关接口,供 Git 直连同步工具使用。 +本文档基于 `testapi.txt` 的正式协议,说明 Git 直连同步工具如何对接生产侧接口。 -本文档覆盖三类接口: +当前文档同时覆盖两层内容: -1. `pushConfig`:把 Git 配置推送到生产 -2. `pullConfig`:把生产配置拉回到 Git -3. `login`:获取调用接口所需 token +1. 接口协议本身 +2. 当前代码如何把 Git 仓库结构映射成接口参数 -## 2. 统一约定 +## 2. 当前 Git 仓库约定 -### 2.1 编码与格式 +### 2.1 Git -> PROD 输入约定 + +当前实现约定: + +- `git.repo.scan-branch` 指向当前待同步的版本分支 +- **分支名本身就是 `configVersion`** +- 分支内目录结构必须为: + +```text +//<模块内文件相对路径> +``` + +示例: + +```text +R_XXX_V3.0.3_XXX +├─ PEK +│ └─ monitor +│ ├─ application.yml +│ └─ jobs/sync-job.json +└─ SHA + └─ gate + └─ gate-rule.json +``` + +映射规则: + +- `airportId` = 路径第 1 段 +- `appName` = 路径第 2 段 +- `fileName` = 从第 3 段开始的模块内相对路径 +- `configVersion` = 当前 Git 分支名 + +例如文件: + +```text +PEK/monitor/jobs/sync-job.json +``` + +会被映射为: + +```json +{ + "airportId": "PEK", + "appName": "monitor", + "configVersion": "R_XXX_V3.0.3_XXX", + "fileName": "jobs/sync-job.json" +} +``` + +### 2.2 PROD -> Git 输出约定 + +当前实现会把 `pullConfig` 返回项按版本分组后落到本地 staging 目录,目录结构同样为: + +```text +// +``` + +之后按动态分支提交到 `git.repo.snapshot-branch/`。 + +## 3. 统一接口约定 + +### 3.1 编码与格式 - 编码:`UTF-8` - 返回格式:`JSON` -- 鉴权方式:请求头中携带 `token` +- 鉴权:请求头中携带 `token` -### 2.2 成功判定 +### 3.2 成功判定 接口成功时返回: - `code = "0"` - `msg = "ok"` -### 2.3 失败响应格式 - -所有接口失败时的响应格式统一如下: +### 3.3 失败响应格式 ```json { @@ -38,31 +96,31 @@ } ``` -## 3. 接口一:推送 Git 配置到生产 +## 4. 接口一:推送 Git 配置到生产 -### 3.1 基本信息 +### 4.1 基本信息 - 地址:`http://ip:port/pic_bus_manage_monitor/configSync/pushConfig` - 方法:`POST` - 内容格式:`JSON` - 鉴权:Header 中携带 `token` -### 3.2 请求参数 +### 4.2 请求参数 无 URL 参数。 -### 3.3 请求体 +### 4.3 请求体 请求体为 JSON 数组,每个数组元素代表一个配置文件: ```json [ { - "airportId": "test", - "appName": "XXX", + "airportId": "PEK", + "appName": "monitor", "configVersion": "R_XXX_V3.0.3_XXX", "configContent": "配置内容", - "fileName": "配置文件名" + "fileName": "jobs/sync-job.json" } ] ``` @@ -71,13 +129,13 @@ | 字段 | 必填 | 说明 | | --- | --- | --- | -| `airportId` | 是 | 机场编码 | -| `appName` | 是 | 应用名称 | -| `configVersion` | 是 | 配置版本号 | -| `configContent` | 是 | 配置内容 | -| `fileName` | 是 | 配置文件名 | +| `airportId` | 是 | 机场编码,来自 Git 路径第 1 段 | +| `appName` | 是 | 模块名,来自 Git 路径第 2 段 | +| `configVersion` | 是 | 配置版本号,当前实现取 Git 分支名 | +| `configContent` | 是 | 文件内容,当前实现按 UTF-8 文本直接读取 | +| `fileName` | 是 | 模块内文件相对路径,不包含 `airportId/appName` 前缀 | -### 3.4 响应体 +### 4.4 响应体 ```json { @@ -85,10 +143,10 @@ "data": { "ackFail": [ { - "airportId": "test", - "appName": "XXXx", - "configVersion": "R_XXX_V3.0.35.6.1_XXX", - "fileName": "配置文件名" + "airportId": "PEK", + "appName": "monitor", + "configVersion": "R_XXX_V3.0.3_XXX", + "fileName": "jobs/sync-job.json" } ] }, @@ -100,48 +158,65 @@ - `ackFail`:本次推送失败的配置项列表 - 如果 `ackFail` 为空或不存在,可视为本次推送成功 -- 如果 `ackFail` 非空,应视为部分失败或失败 +- 如果 `ackFail` 非空,当前代码直接抛错,视为本次同步失败 -### 3.5 业务说明 +### 4.5 当前实现说明 -- 第一次推送为全量 -- 之后按增量推送 -- 如果全量配置过大,需要视情况拆分多次推送 -- `configContent` 需要加密 -- **加密方式当前留为 `TODO`** +- 第一次推送走全量 +- 后续优先走最小增量 +- 如果检测到文件删除,会自动回退到全量推送 +- 基线目录按版本分支隔离保存 +- `configContent` 当前已统一经过 `ConfigCryptoService` +- 默认实现仍为透传,正式加密算法待补 -## 4. 接口二:从生产拉取配置到 Git +## 5. 接口二:从生产拉取配置到 Git -### 4.1 基本信息 +### 5.1 基本信息 - 地址:`http://ip:port/pic_bus_manage_monitor/configSync/pullConfig` - 方法:`GET` - 内容格式:`JSON` - 鉴权:Header 中携带 `token` -### 4.2 请求参数 +### 5.2 请求参数 -接口文档给出的请求参数如下: +接口协议中的字段如下: ```json { - "airportId": "test", - "appName": "XXXx", - "configVersion": "R_XXX_V3.0.35.6.1_XXX", - "fileName": "配置文件名", - "ackSuc": "id,id", - "ackFail": "id,id" + "airportId": "PEK", + "appName": "monitor", + "configVersion": "R_XXX_V3.0.3_XXX", + "fileName": "jobs/sync-job.json", + "ackSuc": "1,2", + "ackFail": "9" } ``` 说明: -- 由于该接口是 `GET`,当前实现按查询参数方式理解这些字段 -- 当前代码只使用 `airportId`、`appName` 做基础过滤 -- `ackSuc`、`ackFail` 暂未纳入当前实现 -- 这部分如果后续要完全对齐,可继续补充 +- 由于接口是 `GET`,当前实现按 query string 传参 +- 当前实现已经支持: + - `airportId` 可选过滤 + - `appName` 可选过滤 + - `ackSuc` 自动回传 + - `ackFail` 自动回传 +- 当前已经支持: + - `configVersion` 可选过滤 + - `fileName` 可选过滤 -### 4.3 响应体 +### 5.3 当前请求行为 + +当前代码只有在配置了过滤条件时才会发送: + +- `prod.api.airport-id` +- `prod.api.app-name` +- `prod.api.pull-config-version` +- `prod.api.pull-file-name` + +如果这两个配置为空,则不会在请求中携带它们,此时依赖生产端返回所有已审核且未同步的配置。 + +### 5.4 响应体 ```json { @@ -149,11 +224,11 @@ "data": [ { "id": "1", - "airportId": "test", - "appName": "XXXx", - "configVersion": "R_XXX_V3.0.35.6.1_XXX", + "airportId": "PEK", + "appName": "monitor", + "configVersion": "R_XXX_V3.0.3_XXX", "configContent": "配置内容(加密)", - "fileName": "配置文件名" + "fileName": "jobs/sync-job.json" } ], "msg": "ok" @@ -166,27 +241,28 @@ | --- | --- | | `id` | 配置记录标识 | | `airportId` | 机场编码 | -| `appName` | 应用名称 | +| `appName` | 模块名 | | `configVersion` | 配置版本号 | | `configContent` | 配置内容 | -| `fileName` | 配置文件名 | +| `fileName` | 模块内文件相对路径 | -### 4.4 业务说明 +### 5.5 当前实现说明 -- 不带请求参数时,返回所有“未同步到 Git 且已审核通过”的配置 -- `configContent` 为加密内容 -- **解密方式当前留为 `TODO`** +- `pullConfig` 返回项会先按 `configVersion` 分组,再分别落盘为 `airportId/appName/fileName` +- 如果某组内所有项的 `configVersion` 相同,则该值作为该组的 `sourceVersion` +- 如果某组缺少统一版本号,则该组退回为内容哈希作为 `sourceVersion` +- `configContent` 当前已统一经过 `ConfigCryptoService`,默认实现仍为透传 +- 当本地处理失败时,会把失败项写入 ACK 重试表,并在下次按 `airportId/appName/configVersion/fileName` 定向重拉 -## 5. 接口三:获取 token +## 6. 接口三:获取 token -### 5.1 基本信息 +### 6.1 基本信息 - 地址:`http://ip:port/pic_bus_manage_monitor/pam-monitor/login` - 方法:`POST` - 内容格式:`JSON` -- 说明:该接口已存在,用于获取配置同步接口所需 token -### 5.2 请求体 +### 6.2 请求体 ```json { @@ -195,7 +271,7 @@ } ``` -### 5.3 响应体 +### 6.3 响应体 ```json { @@ -208,40 +284,34 @@ } ``` -字段说明: +### 6.4 当前实现说明 -| 字段 | 说明 | -| --- | --- | -| `token` | 鉴权 token | -| `expireTime` | token 过期时间 | +- 如果 `prod.api.token` 已显式配置,则优先使用静态 token +- 如果未配置静态 token,则调用 `login` 接口获取 token +- token 会按 `expireTime` 做本地缓存 -### 5.4 业务说明 +## 7. 当前代码实现对齐情况 -- 如果 token 过期,需要重新调用登录接口获取新 token - -## 6. 当前代码实现对齐情况 - -当前代码实现文件: +当前接口适配实现: - [ProdConfigApiService.java](e:/AIcoding/FtpTool/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java) -当前实现已经对齐到以下协议: +当前已经对齐到以下协议: - `pushConfig` 使用 `POST + JSON 数组` - `pullConfig` 使用 `GET + JSON 响应` - `login` 使用 `POST + JSON` -- 请求头默认使用 `token` 作为 token 头名称 -- `token` 未静态配置时,会自动调用登录接口获取并缓存 +- `ackSuc/ackFail` 已接入请求回传和本地状态更新 +- `pushConfig` 参数已按 `airportId/appName/fileName` 目录结构解析 +- `pullConfig` 响应已按 `airportId/appName/fileName` 结构恢复到本地目录 当前仍保留的 `TODO`: -- `configContent` 推送前加密 -- `configContent` 拉取后解密 -- `pullConfig` 中 `ackSuc/ackFail` 参数的完整处理 +- 将 `ConfigCryptoService` 的透传实现替换为正式加解密算法 -## 7. 当前配置项 +## 8. 当前配置项说明 -为配合上述接口,当前配置项建议如下: +建议配置: ```properties prod.api.base-url=https://prod.example.com @@ -250,23 +320,27 @@ prod.api.pull-path=/pic_bus_manage_monitor/configSync/pullConfig prod.api.login-path=/pic_bus_manage_monitor/pam-monitor/login prod.api.token=replace-me prod.api.token-header-name=token -prod.api.airport-id=replace-me -prod.api.app-name=replace-me +prod.api.airport-id= +prod.api.app-name= +prod.api.pull-config-version= +prod.api.pull-file-name= prod.api.login-name= prod.api.login-password= ``` 说明: -- 如果 `prod.api.token` 已直接配置,则当前实现优先使用静态 token -- 如果没有配置 token,则走 `login` 接口获取 token +- `prod.api.airport-id`、`prod.api.app-name` **不再用于 pushConfig 参数组装** +- 它们当前只作为 `pullConfig` 的可选过滤条件 +- `prod.api.pull-config-version`、`prod.api.pull-file-name` 可用于精细过滤拉取范围 +- 如果希望拉全量已审核数据,建议留空 -## 8. 联调建议 +## 9. 联调建议 -联调时建议优先确认以下几点: +联调时建议优先确认: 1. `token` 请求头名称是否就是 `token` -2. `pullConfig` 的 GET 请求参数是否确实按 query string 传递 -3. `ackFail` 非空时是否要整体视为失败 +2. `pullConfig` 的 GET 参数是否按 query string 传递 +3. `configVersion/fileName` 过滤规则是否需要尽快接入 4. `configContent` 加密/解密算法何时补齐 -5. `fileName` 是否可能包含目录层级 +5. `fileName` 是否始终是模块内相对路径,而不是包含 `airportId/appName` diff --git a/src/main/java/com/ftptool/sync/config/ProdApiProperties.java b/src/main/java/com/ftptool/sync/config/ProdApiProperties.java index 4146158..3771aec 100644 --- a/src/main/java/com/ftptool/sync/config/ProdApiProperties.java +++ b/src/main/java/com/ftptool/sync/config/ProdApiProperties.java @@ -3,34 +3,21 @@ package com.ftptool.sync.config; import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "prod.api") -/** - * 生产系统 push/pull 接口配置。 - */ public class ProdApiProperties { - /** 生产接口基础地址。 */ private String baseUrl; - /** 配置导入接口路径。 */ private String pushPath; - /** 生产快照导出接口路径。 */ private String pullPath; - /** 获取 token 的登录接口路径。 */ private String loginPath; - /** 访问生产接口的 Bearer Token。 */ private String token; - /** 放置 token 的请求头名称,默认按对方接口约定使用 token。 */ private String tokenHeaderName = "token"; - /** 业务归属机场编码。 */ private String airportId; - /** 业务应用名称。 */ private String appName; - /** 登录接口用户名。 */ + private String pullConfigVersion; + private String pullFileName; private String loginName; - /** 登录接口密码。 */ private String loginPassword; - /** HTTP 连接超时。 */ private int connectTimeoutMs = 10000; - /** HTTP 读取超时。 */ private int readTimeoutMs = 30000; public String getBaseUrl() { @@ -97,6 +84,22 @@ public class ProdApiProperties { this.appName = appName; } + public String getPullConfigVersion() { + return pullConfigVersion; + } + + public void setPullConfigVersion(String pullConfigVersion) { + this.pullConfigVersion = pullConfigVersion; + } + + public String getPullFileName() { + return pullFileName; + } + + public void setPullFileName(String pullFileName) { + this.pullFileName = pullFileName; + } + public String getLoginName() { return loginName; } diff --git a/src/main/java/com/ftptool/sync/entity/ProdPullAckRecord.java b/src/main/java/com/ftptool/sync/entity/ProdPullAckRecord.java index 21bc9ea..1425160 100644 --- a/src/main/java/com/ftptool/sync/entity/ProdPullAckRecord.java +++ b/src/main/java/com/ftptool/sync/entity/ProdPullAckRecord.java @@ -9,6 +9,7 @@ import javax.persistence.Enumerated; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; +import javax.persistence.Lob; import javax.persistence.PrePersist; import javax.persistence.PreUpdate; import javax.persistence.Table; @@ -16,8 +17,7 @@ import javax.persistence.UniqueConstraint; import java.time.LocalDateTime; /** - * pullConfig 回执记录。 - * 用于记录需要在下一次 pull 请求里回传给生产端的 ackSuc/ackFail。 + * Tracks ackSuc/ackFail status and retry metadata for pulled production configs. */ @Entity @Table(name = "prod_pull_ack", uniqueConstraints = { @@ -29,24 +29,41 @@ public class ProdPullAckRecord { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - /** 生产端返回的配置项 id。 */ @Column(name = "remote_config_id", nullable = false, length = 128) private String remoteConfigId; - /** 回执状态。 */ @Enumerated(EnumType.STRING) @Column(name = "ack_status", nullable = false, length = 16) private ProdPullAckStatus ackStatus; - /** 是否已经在 pullConfig 请求里成功回传给生产端。 */ @Column(name = "reported", nullable = false) private Boolean reported; - /** 创建时间。 */ + @Column(name = "source_version", length = 128) + private String sourceVersion; + + @Column(name = "airport_id", length = 128) + private String airportId; + + @Column(name = "app_name", length = 128) + private String appName; + + @Column(name = "file_name", length = 512) + private String fileName; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount; + + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Lob + @Column(name = "last_error_msg") + private String lastErrorMsg; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; - /** 更新时间。 */ @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; @@ -58,6 +75,9 @@ public class ProdPullAckRecord { if (this.reported == null) { this.reported = Boolean.FALSE; } + if (this.retryCount == null) { + this.retryCount = 0; + } } @PreUpdate @@ -97,6 +117,62 @@ public class ProdPullAckRecord { this.reported = reported; } + public String getSourceVersion() { + return sourceVersion; + } + + public void setSourceVersion(String sourceVersion) { + this.sourceVersion = sourceVersion; + } + + public String getAirportId() { + return airportId; + } + + public void setAirportId(String airportId) { + this.airportId = airportId; + } + + public String getAppName() { + return appName; + } + + public void setAppName(String appName) { + this.appName = appName; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public Integer getRetryCount() { + return retryCount; + } + + public void setRetryCount(Integer retryCount) { + this.retryCount = retryCount; + } + + public LocalDateTime getNextRetryAt() { + return nextRetryAt; + } + + public void setNextRetryAt(LocalDateTime nextRetryAt) { + this.nextRetryAt = nextRetryAt; + } + + public String getLastErrorMsg() { + return lastErrorMsg; + } + + public void setLastErrorMsg(String lastErrorMsg) { + this.lastErrorMsg = lastErrorMsg; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/src/main/java/com/ftptool/sync/model/ProdPullResult.java b/src/main/java/com/ftptool/sync/model/ProdPullResult.java index cf15caf..1ff56d5 100644 --- a/src/main/java/com/ftptool/sync/model/ProdPullResult.java +++ b/src/main/java/com/ftptool/sync/model/ProdPullResult.java @@ -1,27 +1,41 @@ package com.ftptool.sync.model; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** - * 生产 pull 接口返回结果在本地落盘后的封装对象。 + * Local representation of one pulled production snapshot group. */ public class ProdPullResult { - /** 保存生产快照内容的目录。 */ private final Path contentDirectory; - /** 来源版本号,优先取服务端显式返回值。 */ private final String sourceVersion; - /** 响应体内容哈希。 */ private final String contentHash; - /** 本次 pull 返回的生产配置项 id 列表。 */ private final List pulledConfigIds; + private final List pulledConfigs; public ProdPullResult(Path contentDirectory, String sourceVersion, String contentHash, List pulledConfigIds) { + this(contentDirectory, sourceVersion, contentHash, pulledConfigIds, buildIdOnlyRefs(pulledConfigIds)); + } + + public ProdPullResult( + Path contentDirectory, + String sourceVersion, + String contentHash, + List pulledConfigIds, + List pulledConfigs + ) { this.contentDirectory = contentDirectory; this.sourceVersion = sourceVersion; this.contentHash = contentHash; - this.pulledConfigIds = pulledConfigIds; + this.pulledConfigIds = pulledConfigIds == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(pulledConfigIds)); + this.pulledConfigs = pulledConfigs == null + ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(pulledConfigs)); } public Path getContentDirectory() { @@ -39,4 +53,63 @@ public class ProdPullResult { public List getPulledConfigIds() { return pulledConfigIds; } + + public List getPulledConfigs() { + return pulledConfigs; + } + + private static List buildIdOnlyRefs(List pulledConfigIds) { + if (pulledConfigIds == null) { + return Collections.emptyList(); + } + + List refs = new ArrayList(); + for (String pulledConfigId : pulledConfigIds) { + refs.add(new PulledConfigRef(pulledConfigId, null, null, null, null)); + } + return refs; + } + + public static class PulledConfigRef { + + private final String remoteConfigId; + private final String airportId; + private final String appName; + private final String configVersion; + private final String fileName; + + public PulledConfigRef( + String remoteConfigId, + String airportId, + String appName, + String configVersion, + String fileName + ) { + this.remoteConfigId = remoteConfigId; + this.airportId = airportId; + this.appName = appName; + this.configVersion = configVersion; + this.fileName = fileName; + } + + public String getRemoteConfigId() { + return remoteConfigId; + } + + public String getAirportId() { + return airportId; + } + + public String getAppName() { + return appName; + } + + public String getConfigVersion() { + return configVersion; + } + + public String getFileName() { + return fileName; + } + } } diff --git a/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java b/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java index 58c1470..7af9050 100644 --- a/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java +++ b/src/main/java/com/ftptool/sync/orchestrator/ProdSyncCoordinator.java @@ -12,11 +12,13 @@ import com.ftptool.sync.model.SyncStatus; import com.ftptool.sync.service.CheckpointService; import com.ftptool.sync.service.GitClientService; import com.ftptool.sync.service.PackageService; -import com.ftptool.sync.service.ProdPullAckService; import com.ftptool.sync.service.ProdConfigApiService; +import com.ftptool.sync.service.ProdPullAckService; import com.ftptool.sync.service.SyncMetadataService; import com.ftptool.sync.service.SyncTaskService; import com.ftptool.sync.service.WorkDirectoryService; +import com.ftptool.sync.util.FileHashUtils; +import com.ftptool.sync.util.FileTreeUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; @@ -34,12 +36,8 @@ import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; -import com.ftptool.sync.util.FileHashUtils; -import com.ftptool.sync.util.FileTreeUtils; - /** - * 生产侧同步协调器。 - * 串联两条核心链路: + * Coordinates the two production-side sync flows: * 1. Git -> PROD * 2. PROD -> Git */ @@ -88,23 +86,25 @@ public class ProdSyncCoordinator { } /** - * 主链路一:从 Git 拉取最新配置并推送到生产。 + * Pull the configured Git version branch and push its config files to PROD. */ public void syncLatestGitToProd() { 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={}, pushPath={}", + "PROD git->prod tick. nodeId={}, branch={}, revision={}, pushPath={}", syncProperties.getNodeId(), - gitRepoProperties.getScanBranch(), + branch, + sourceRevision, prodApiProperties.getPushPath() ); - String branch = gitRepoProperties.getScanBranch(); - String sourceVersion = gitClientService.prepareRepositoryAndGetHead(branch); - Path exportDirectory = workDirectoryService.getDevToProdStagingDir().resolve("git-" + sourceVersion); - - // 先导出 Git 工作树快照,再计算内容哈希,避免直接拿工作目录参与后续修改。 + Path exportDirectory = workDirectoryService.getDevToProdStagingDir().resolve("git-" + stagingKey); gitClientService.exportBranchSnapshot(branch, exportDirectory); String contentHash = packageService.calculateDirectoryHash(exportDirectory); @@ -114,7 +114,8 @@ public class ProdSyncCoordinator { contentHash ); if (shouldSkip(existing)) { - log.info("Git version already pushed to prod. version={}, hash={}", sourceVersion, contentHash); + log.info("Git version already pushed to prod. version={}, revision={}, hash={}", + sourceVersion, sourceRevision, contentHash); return; } @@ -138,34 +139,49 @@ public class ProdSyncCoordinator { traceId ); - // Git 提交哈希 + 内容哈希作为业务幂等键,避免同一版本重复推送到生产。 syncTaskService.markStatus(task.getTraceId(), SyncStatus.CONSUMING, null); - Path pushDirectory = preparePushDirectory(exportDirectory, sourceVersion); + Path pushDirectory = preparePushDirectory(exportDirectory, branch, stagingKey); prodConfigApiService.pushPackage(manifest, pushDirectory); syncTaskService.markStatus(task.getTraceId(), SyncStatus.SUCCESS, null); checkpointService.saveCheckpoint(task.getDirection(), task.getSourceVersion(), task.getContentHash()); - refreshGitToProdBaseline(exportDirectory); - log.info("Git version pushed to prod successfully. traceId={}, version={}", task.getTraceId(), task.getSourceVersion()); + refreshGitToProdBaseline(exportDirectory, branch); + 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); } } /** - * 主链路二:从生产拉取当前配置并回写到 Git 快照分支。 + * Pull the current production snapshot and write it back to the Git snapshot branch. */ public void syncProdSnapshotToGit() { - String traceId = null; - ProdPullResult pullResult = null; - try { - log.info( - "PROD prod->git tick. apiBaseUrl={}, pullPath={}, snapshotBranch={}", - prodApiProperties.getBaseUrl(), - prodApiProperties.getPullPath(), - gitRepoProperties.getSnapshotBranch() - ); + log.info( + "PROD prod->git tick. apiBaseUrl={}, pullPath={}, snapshotBranchPrefix={}", + prodApiProperties.getBaseUrl(), + prodApiProperties.getPullPath(), + gitRepoProperties.getSnapshotBranch() + ); - pullResult = prodConfigApiService.pullConfigSnapshot(); + 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 syncSingleProdSnapshotToGit(ProdPullResult pullResult, boolean retryAttempt) { + String traceId = null; + try { Optional existing = syncTaskService.findByBusinessKey( SyncDirection.PROD_TO_DEV, pullResult.getSourceVersion(), @@ -174,6 +190,7 @@ public class ProdSyncCoordinator { if (shouldSkip(existing)) { log.info("Production snapshot already synced to Git. version={}, hash={}", pullResult.getSourceVersion(), pullResult.getContentHash()); + prodPullAckService.recordAckSuccess(pullResult.getPulledConfigs()); return; } @@ -187,48 +204,72 @@ public class ProdSyncCoordinator { ); syncTaskService.markStatus(task.getTraceId(), SyncStatus.CONSUMING, null); - // 生产快照只写入独立 snapshot 分支,避免与开发主分支形成闭环。 + String targetBranch = resolveSnapshotBranch(task.getSourceVersion()); String commitMessage = gitRepoProperties.getCommitMessagePrefix() + ": traceId=" + task.getTraceId() + " version=" + task.getSourceVersion(); boolean pushed = gitClientService.syncDirectoryToBranch( pullResult.getContentDirectory(), - gitRepoProperties.getSnapshotBranch(), + targetBranch, commitMessage ); syncTaskService.markStatus(task.getTraceId(), SyncStatus.SUCCESS, null); checkpointService.saveCheckpoint(task.getDirection(), task.getSourceVersion(), task.getContentHash()); - prodPullAckService.recordAckResult(pullResult.getPulledConfigIds(), ProdPullAckStatus.SUCCESS); + prodPullAckService.recordAckSuccess(pullResult.getPulledConfigs()); log.info( - "Production snapshot synced to Git. traceId={}, version={}, gitPushed={}", + "Production snapshot synced to Git. traceId={}, version={}, branch={}, gitPushed={}", task.getTraceId(), task.getSourceVersion(), + targetBranch, pushed ); } catch (Exception e) { - if (pullResult != null) { - prodPullAckService.recordAckResult(pullResult.getPulledConfigIds(), ProdPullAckStatus.FAILED); + prodPullAckService.recordPullFailure( + pullResult.getPulledConfigs(), + summarizeException(e), + retryAttempt + ); + handleFailure(traceId, "PROD prod->git snapshot sync failed", e); + } + } + + private void retryFailedProdPulls() { + List retryPullRequests = + prodPullAckService.getRetryPullRequests(syncProperties.getMaxRetryCount()); + for (ProdPullAckService.RetryPullRequest retryPullRequest : retryPullRequests) { + try { + List retryResults = prodConfigApiService.pullConfigSnapshots( + retryPullRequest.getAirportId(), + retryPullRequest.getAppName(), + retryPullRequest.getSourceVersion(), + retryPullRequest.getFileName() + ); + for (ProdPullResult retryResult : retryResults) { + syncSingleProdSnapshotToGit(retryResult, true); + } + } catch (Exception e) { + prodPullAckService.markRetryAttemptFailed(retryPullRequest, summarizeException(e)); + log.warn( + "Retry pull failed. version={}, airportId={}, appName={}, fileName={}", + retryPullRequest.getSourceVersion(), + retryPullRequest.getAirportId(), + retryPullRequest.getAppName(), + retryPullRequest.getFileName(), + e + ); } - handleFailure(traceId, "PROD prod->git sync failed", e); } } /** - * 已成功的同版本任务直接跳过,避免重复同步。 + * Generate the directory to push this round: + * 1. First push of a branch is full + * 2. Deletions fall back to full push + * 3. Otherwise only changed files are pushed */ - private boolean shouldSkip(Optional existing) { - return existing.isPresent() && existing.get().getStatus() == SyncStatus.SUCCESS; - } - - /** - * 生成本次推送目录: - * 1. 首次推送走全量 - * 2. 检测到删除时回退全量 - * 3. 其余场景仅推送变更文件 - */ - private Path preparePushDirectory(Path exportDirectory, String sourceVersion) throws IOException { - Path baselineDirectory = workDirectoryService.getGitToProdBaselineDir(); + private Path preparePushDirectory(Path exportDirectory, String branch, String stagingKey) throws IOException { + Path baselineDirectory = workDirectoryService.getGitToProdBaselineDir(branch); if (Files.notExists(baselineDirectory) || isDirectoryEmpty(baselineDirectory)) { return exportDirectory; } @@ -240,7 +281,7 @@ public class ProdSyncCoordinator { .filter(path -> !currentFileHashes.containsKey(path)) .collect(Collectors.toSet()); if (!deletedFiles.isEmpty()) { - log.info("Git->PROD 检测到文件删除,回退为全量推送。deletedCount={}", deletedFiles.size()); + log.info("Git->PROD detected deleted files, fallback to full push. deletedCount={}", deletedFiles.size()); return exportDirectory; } @@ -256,19 +297,16 @@ public class ProdSyncCoordinator { return exportDirectory; } - Path incrementalDirectory = workDirectoryService.getDevToProdStagingDir().resolve("git-delta-" + sourceVersion); + Path incrementalDirectory = workDirectoryService.getDevToProdStagingDir().resolve("git-delta-" + stagingKey); FileTreeUtils.deleteRecursively(incrementalDirectory); FileTreeUtils.ensureDirectory(incrementalDirectory); FileTreeUtils.copySelectedFiles(exportDirectory, incrementalDirectory, changedFiles); - log.info("Git->PROD 本次采用最小增量推送。changedCount={}", changedFiles.size()); + log.info("Git->PROD uses incremental push this round. changedCount={}", changedFiles.size()); return incrementalDirectory; } - /** - * 成功推送后刷新本地基线目录,作为下一次增量比较的依据。 - */ - private void refreshGitToProdBaseline(Path exportDirectory) throws IOException { - Path baselineDirectory = workDirectoryService.getGitToProdBaselineDir(); + private void refreshGitToProdBaseline(Path exportDirectory, String branch) throws IOException { + Path baselineDirectory = workDirectoryService.getGitToProdBaselineDir(branch); FileTreeUtils.ensureDirectory(baselineDirectory); try (Stream stream = Files.list(baselineDirectory)) { for (Path child : stream.collect(Collectors.toList())) { @@ -295,16 +333,12 @@ public class ProdSyncCoordinator { return fileHashes; } - /** - * 统一失败处理逻辑。 - */ private void handleFailure(String traceId, String logMessage, Exception e) { log.error(logMessage, e); if (traceId == null) { return; } - // 只有达到最大重试次数后才把任务标记为失败,之前保留为可重试状态。 syncTaskService.increaseRetryCount(traceId, summarizeException(e)); Optional task = syncTaskService.findByTraceId(traceId); int retryCount = task.map(SyncTask::getRetryCount).orElse(0); @@ -313,9 +347,6 @@ public class ProdSyncCoordinator { } } - /** - * 统一截断异常摘要,避免错误信息过长污染数据库字段。 - */ private String summarizeException(Exception e) { String message = e.getMessage(); if (message == null || message.trim().isEmpty()) { @@ -323,4 +354,24 @@ public class ProdSyncCoordinator { } return message.length() > 400 ? message.substring(0, 400) : message; } + + private String buildStagingKey(String branch, String sourceRevision) { + return sanitizePathToken(branch) + "-" + sanitizePathToken(sourceRevision); + } + + private String resolveSnapshotBranch(String sourceVersion) { + String baseBranch = gitRepoProperties.getSnapshotBranch(); + String versionSegment = sanitizePathToken(sourceVersion); + if (baseBranch == null || baseBranch.trim().isEmpty()) { + return versionSegment; + } + return baseBranch.endsWith("/") ? baseBranch + versionSegment : baseBranch + "/" + versionSegment; + } + + private String sanitizePathToken(String value) { + if (value == null || value.trim().isEmpty()) { + return "unknown"; + } + return value.replaceAll("[^a-zA-Z0-9._-]", "_"); + } } diff --git a/src/main/java/com/ftptool/sync/repository/ProdPullAckRecordRepository.java b/src/main/java/com/ftptool/sync/repository/ProdPullAckRecordRepository.java index 6ffd48e..2695bdf 100644 --- a/src/main/java/com/ftptool/sync/repository/ProdPullAckRecordRepository.java +++ b/src/main/java/com/ftptool/sync/repository/ProdPullAckRecordRepository.java @@ -17,4 +17,6 @@ public interface ProdPullAckRecordRepository extends JpaRepository findByReportedFalseOrderByUpdatedAtAsc(); List findByAckStatusAndReportedFalseOrderByUpdatedAtAsc(ProdPullAckStatus ackStatus); + + List findByAckStatusOrderByUpdatedAtAsc(ProdPullAckStatus ackStatus); } diff --git a/src/main/java/com/ftptool/sync/service/ConfigCryptoService.java b/src/main/java/com/ftptool/sync/service/ConfigCryptoService.java new file mode 100644 index 0000000..acb0689 --- /dev/null +++ b/src/main/java/com/ftptool/sync/service/ConfigCryptoService.java @@ -0,0 +1,33 @@ +package com.ftptool.sync.service; + +import org.springframework.stereotype.Service; + +/** + * Central extension point for config content encryption and decryption. + * The current implementation is intentionally a no-op placeholder. + */ +@Service +public class ConfigCryptoService { + + public String encryptForPush( + String airportId, + String appName, + String configVersion, + String fileName, + String plainContent + ) { + // TODO: Replace the pass-through implementation with the production encryption algorithm. + return plainContent; + } + + public String decryptAfterPull( + String airportId, + String appName, + String configVersion, + String fileName, + String encryptedContent + ) { + // TODO: Replace the pass-through implementation with the production decryption algorithm. + return encryptedContent; + } +} diff --git a/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java b/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java index a25267f..0965684 100644 --- a/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java +++ b/src/main/java/com/ftptool/sync/service/ProdConfigApiService.java @@ -3,7 +3,6 @@ package com.ftptool.sync.service; import com.ftptool.sync.config.ProdApiProperties; import com.ftptool.sync.config.SyncProperties; import com.ftptool.sync.model.PackageManifest; -import com.ftptool.sync.model.ProdPullAckStatus; import com.ftptool.sync.model.ProdPullResult; import com.ftptool.sync.util.FileHashUtils; import com.ftptool.sync.util.FileTreeUtils; @@ -27,15 +26,16 @@ import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; /** - * 生产接口访问服务。 - * 封装对生产 pushConfig / pullConfig / login 接口的 HTTP 调用。 + * Encapsulates HTTP calls to production pushConfig / pullConfig / login APIs. */ @Service public class ProdConfigApiService { @@ -49,6 +49,7 @@ public class ProdConfigApiService { private final RestTemplate restTemplate; private final WorkDirectoryService workDirectoryService; private final ProdPullAckService prodPullAckService; + private final ConfigCryptoService configCryptoService; private volatile String cachedToken; private volatile LocalDateTime cachedTokenExpireTime; @@ -58,17 +59,19 @@ public class ProdConfigApiService { SyncProperties syncProperties, RestTemplate restTemplate, WorkDirectoryService workDirectoryService, - ProdPullAckService prodPullAckService + ProdPullAckService prodPullAckService, + ConfigCryptoService configCryptoService ) { this.prodApiProperties = prodApiProperties; this.syncProperties = syncProperties; this.restTemplate = restTemplate; this.workDirectoryService = workDirectoryService; this.prodPullAckService = prodPullAckService; + this.configCryptoService = configCryptoService; } /** - * 调用 pushConfig 接口,把目录中的配置内容按文件维度推送到生产。 + * Push the files in the given directory to production as a JSON array. */ public void pushPackage(PackageManifest manifest, Path sourceDirectory) throws IOException { String url = buildUrl(prodApiProperties.getPushPath()); @@ -84,35 +87,58 @@ public class ProdConfigApiService { ); ProdApiResponse body = response.getBody(); - validateSuccess(body, "生产 pushConfig 接口调用失败"); + validateSuccess(body, "Production pushConfig call failed"); List ackFail = body.getData() == null ? null : body.getData().getAckFail(); if (ackFail != null && !ackFail.isEmpty()) { String failedFiles = ackFail.stream() .map(ProdPushAckItem::getFileName) .collect(Collectors.joining(",")); - throw new IllegalStateException("生产 pushConfig 返回部分失败,失败文件:" + failedFiles); + throw new IllegalStateException("Production pushConfig partially failed. files=" + failedFiles); } log.info("Prod pushConfig finished. traceId={}, itemCount={}", manifest.getTraceId(), requestBody.size()); } /** - * 调用 pullConfig 接口,拉取生产快照并按文件恢复到本地目录。 + * Pull production config snapshots using optional configured filters. */ - public ProdPullResult pullConfigSnapshot() throws IOException { + public List pullConfigSnapshots() throws IOException { + return pullConfigSnapshots( + prodApiProperties.getAirportId(), + prodApiProperties.getAppName(), + prodApiProperties.getPullConfigVersion(), + prodApiProperties.getPullFileName() + ); + } + + /** + * Pull production config snapshots using optional configVersion/fileName filters. + */ + public List pullConfigSnapshots( + String airportIdFilter, + String appNameFilter, + String configVersionFilter, + String fileNameFilter + ) throws IOException { String url = buildUrl(prodApiProperties.getPullPath()); HttpHeaders headers = defaultJsonHeaders(); UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url); ProdPullAckService.PendingAckSummary pendingAckSummary = prodPullAckService.getPendingAckSummary(); - // 当前先按应用维度过滤。 - if (StringUtils.hasText(prodApiProperties.getAirportId()) && !isPlaceholder(prodApiProperties.getAirportId())) { - builder.queryParam("airportId", prodApiProperties.getAirportId()); + // Optional filters: leave them empty to pull all approved unsynced configs. + if (StringUtils.hasText(airportIdFilter) && !isPlaceholder(airportIdFilter)) { + builder.queryParam("airportId", airportIdFilter.trim()); } - if (StringUtils.hasText(prodApiProperties.getAppName()) && !isPlaceholder(prodApiProperties.getAppName())) { - builder.queryParam("appName", prodApiProperties.getAppName()); + if (StringUtils.hasText(appNameFilter) && !isPlaceholder(appNameFilter)) { + builder.queryParam("appName", appNameFilter.trim()); + } + if (StringUtils.hasText(configVersionFilter) && !isPlaceholder(configVersionFilter)) { + builder.queryParam("configVersion", configVersionFilter.trim()); + } + if (StringUtils.hasText(fileNameFilter) && !isPlaceholder(fileNameFilter)) { + builder.queryParam("fileName", fileNameFilter.trim()); } if (pendingAckSummary.getAckSucIds() != null && !pendingAckSummary.getAckSucIds().isEmpty()) { builder.queryParam("ackSuc", StringUtils.collectionToCommaDelimitedString(pendingAckSummary.getAckSucIds())); @@ -130,29 +156,18 @@ public class ProdConfigApiService { ); ProdApiResponse> body = response.getBody(); - validateSuccess(body, "生产 pullConfig 接口调用失败"); + validateSuccess(body, "Production pullConfig call failed"); if (pendingAckSummary.hasPendingAck()) { prodPullAckService.markPendingAsReported(); } + List items = body.getData(); if (items == null || items.isEmpty()) { - throw new IllegalStateException("生产 pullConfig 未返回可同步配置"); + throw new IllegalStateException("Production pullConfig returned no syncable config"); } - - Path tempDir = Files.createTempDirectory(workDirectoryService.getProdToDevStagingDir(), "pull-"); - FileTreeUtils.ensureDirectory(tempDir); - for (ProdPulledConfigItem item : items) { - writePulledConfigItem(tempDir, item); - } - - String contentHash = FileHashUtils.sha256Directory(tempDir); - String sourceVersion = resolvePullVersion(items, contentHash); - return new ProdPullResult(tempDir, sourceVersion, contentHash, collectPulledIds(items)); + return buildPullResults(items); } - /** - * 统一构造 JSON 调用头,并补齐 token 鉴权。 - */ private HttpHeaders defaultJsonHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setAccept(java.util.Collections.singletonList(MediaType.APPLICATION_JSON)); @@ -167,9 +182,6 @@ public class ProdConfigApiService { return headers; } - /** - * 优先使用静态 token;如果未配置,则走登录接口获取并缓存。 - */ private String resolveToken() { if (StringUtils.hasText(prodApiProperties.getToken()) && !isPlaceholder(prodApiProperties.getToken())) { return prodApiProperties.getToken().trim(); @@ -177,9 +189,6 @@ public class ProdConfigApiService { return loginAndGetToken(); } - /** - * 调用登录接口获取 token,并根据过期时间做简单缓存。 - */ private synchronized String loginAndGetToken() { if (StringUtils.hasText(cachedToken) && cachedTokenExpireTime != null @@ -206,9 +215,9 @@ public class ProdConfigApiService { ); ProdApiResponse body = response.getBody(); - validateSuccess(body, "生产登录接口调用失败"); + validateSuccess(body, "Production login call failed"); if (body.getData() == null || !StringUtils.hasText(body.getData().getToken())) { - throw new IllegalStateException("生产登录接口未返回有效 token"); + throw new IllegalStateException("Production login did not return a token"); } cachedToken = body.getData().getToken().trim(); @@ -221,11 +230,10 @@ public class ProdConfigApiService { } /** - * 把本地目录转换成 pushConfig 需要的 JSON 数组。 + * Convert a branch snapshot directory to the pushConfig JSON array. + * Layout is expected to be: airportId/appName/fileName */ private List buildPushRequest(PackageManifest manifest, Path sourceDirectory) throws IOException { - validateBusinessConfig(); - List result = new ArrayList(); try (Stream stream = Files.walk(sourceDirectory)) { List files = stream @@ -233,40 +241,57 @@ public class ProdConfigApiService { .sorted() .collect(Collectors.toList()); for (Path file : files) { + GitConfigPath gitConfigPath = parseGitConfigPath(sourceDirectory, file); + ProdPushConfigItem item = new ProdPushConfigItem(); - item.setAirportId(prodApiProperties.getAirportId()); - item.setAppName(prodApiProperties.getAppName()); + item.setAirportId(gitConfigPath.getAirportId()); + item.setAppName(gitConfigPath.getAppName()); item.setConfigVersion(manifest.getSourceVersion()); - // TODO: 配置内容需按生产接口约定加密后再发送。 - item.setConfigContent(new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); - item.setFileName(sourceDirectory.relativize(file).toString().replace('\\', '/')); + String plainContent = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); + item.setConfigContent(configCryptoService.encryptForPush( + gitConfigPath.getAirportId(), + gitConfigPath.getAppName(), + manifest.getSourceVersion(), + gitConfigPath.getFileName(), + plainContent + )); + item.setFileName(gitConfigPath.getFileName()); result.add(item); } } if (result.isEmpty()) { - throw new IllegalStateException("待推送目录为空,无法构造 pushConfig 请求"); + throw new IllegalStateException("No files found to build pushConfig request"); } return result; } /** - * 将 pullConfig 返回的单条配置恢复为本地文件。 + * Restore one pulled config item under airportId/appName/fileName. */ private void writePulledConfigItem(Path baseDirectory, ProdPulledConfigItem item) throws IOException { - String fileName = StringUtils.hasText(item.getFileName()) ? item.getFileName() : syncProperties.getPullResponseFileName(); - Path targetFile = baseDirectory.resolve(fileName).normalize(); + String airportId = requireDirectorySegment(item.getAirportId(), "airportId"); + String appName = requireDirectorySegment(item.getAppName(), "appName"); + String fileName = StringUtils.hasText(item.getFileName()) + ? item.getFileName().trim() + : syncProperties.getPullResponseFileName(); + + Path targetFile = baseDirectory.resolve(airportId).resolve(appName).resolve(fileName).normalize(); if (!targetFile.startsWith(baseDirectory)) { - throw new IOException("pullConfig 返回的 fileName 非法:" + fileName); + throw new IOException("pullConfig returned illegal fileName: " + fileName); } + Files.createDirectories(targetFile.getParent()); - // TODO: configContent 当前直接按返回值落盘,后续需补充解密逻辑。 - Files.write(targetFile, safeString(item.getConfigContent()).getBytes(StandardCharsets.UTF_8)); + String decryptedContent = configCryptoService.decryptAfterPull( + airportId, + appName, + safeString(item.getConfigVersion()), + fileName, + safeString(item.getConfigContent()) + ); + Files.write(targetFile, safeString(decryptedContent).getBytes(StandardCharsets.UTF_8)); } - /** - * 解析本次 pull 结果的来源版本。 - */ private String resolvePullVersion(List items, String contentHash) { Set versions = new LinkedHashSet(); for (ProdPulledConfigItem item : items) { @@ -280,9 +305,6 @@ public class ProdConfigApiService { return contentHash; } - /** - * 提取 pullConfig 返回项中的 id,供下一次请求回传 ackSuc/ackFail。 - */ private List collectPulledIds(List items) { List ids = new ArrayList(); for (ProdPulledConfigItem item : items) { @@ -293,48 +315,76 @@ public class ProdConfigApiService { return ids; } - /** - * 校验接口返回是否成功。 - */ + private List buildPullResults(List items) throws IOException { + Map> itemsByVersion = new LinkedHashMap>(); + for (ProdPulledConfigItem item : items) { + String versionKey = StringUtils.hasText(item.getConfigVersion()) + ? item.getConfigVersion().trim() + : "__missing_version__"; + if (!itemsByVersion.containsKey(versionKey)) { + itemsByVersion.put(versionKey, new ArrayList()); + } + itemsByVersion.get(versionKey).add(item); + } + + List results = new ArrayList(); + for (List groupedItems : itemsByVersion.values()) { + Path tempDir = Files.createTempDirectory(workDirectoryService.getProdToDevStagingDir(), "pull-"); + FileTreeUtils.ensureDirectory(tempDir); + for (ProdPulledConfigItem item : groupedItems) { + writePulledConfigItem(tempDir, item); + } + + String contentHash = FileHashUtils.sha256Directory(tempDir); + String sourceVersion = resolvePullVersion(groupedItems, contentHash); + results.add(new ProdPullResult( + tempDir, + sourceVersion, + contentHash, + collectPulledIds(groupedItems), + collectPulledRefs(groupedItems, sourceVersion) + )); + } + return results; + } + + private List collectPulledRefs(List items, String sourceVersion) { + List refs = new ArrayList(); + for (ProdPulledConfigItem item : items) { + refs.add(new ProdPullResult.PulledConfigRef( + safeTrim(item.getId()), + safeTrim(item.getAirportId()), + safeTrim(item.getAppName()), + StringUtils.hasText(item.getConfigVersion()) ? safeTrim(item.getConfigVersion()) : sourceVersion, + safeTrim(item.getFileName()) + )); + } + return refs; + } + private void validateSuccess(ProdApiResponse response, String failureMessage) { if (response == null) { - throw new IllegalStateException(failureMessage + ":响应体为空"); + throw new IllegalStateException(failureMessage + ": response body is empty"); } if (!SUCCESS_CODE.equals(response.getCode())) { - throw new IllegalStateException(failureMessage + ":" + safeString(response.getMsg()) + ",code=" + safeString(response.getCode())); + throw new IllegalStateException( + failureMessage + ": msg=" + safeString(response.getMsg()) + ", code=" + safeString(response.getCode()) + ); } } - /** - * 校验 push/pull 业务维度配置是否已提供。 - */ - private void validateBusinessConfig() { - if (!StringUtils.hasText(prodApiProperties.getAirportId()) || isPlaceholder(prodApiProperties.getAirportId())) { - throw new IllegalStateException("未配置 prod.api.airport-id"); - } - if (!StringUtils.hasText(prodApiProperties.getAppName()) || isPlaceholder(prodApiProperties.getAppName())) { - throw new IllegalStateException("未配置 prod.api.app-name"); - } - } - - /** - * 校验登录配置是否已提供。 - */ private void validateLoginConfig() { if (!StringUtils.hasText(prodApiProperties.getLoginPath())) { - throw new IllegalStateException("未配置 prod.api.login-path"); + throw new IllegalStateException("Missing prod.api.login-path"); } if (!StringUtils.hasText(prodApiProperties.getLoginName())) { - throw new IllegalStateException("未配置 prod.api.login-name"); + throw new IllegalStateException("Missing prod.api.login-name"); } if (!StringUtils.hasText(prodApiProperties.getLoginPassword())) { - throw new IllegalStateException("未配置 prod.api.login-password"); + throw new IllegalStateException("Missing prod.api.login-password"); } } - /** - * 按基础地址和接口路径拼接完整 URL。 - */ private String buildUrl(String path) { String base = prodApiProperties.getBaseUrl(); if (base.endsWith("/") && path.startsWith("/")) { @@ -354,9 +404,41 @@ public class ProdConfigApiService { return value == null ? "" : value; } - /** - * 通用接口响应包装。 - */ + private String safeTrim(String value) { + return value == null ? null : value.trim(); + } + + private GitConfigPath parseGitConfigPath(Path sourceDirectory, Path file) { + Path relativePath = sourceDirectory.relativize(file); + if (relativePath.getNameCount() < 3) { + throw new IllegalStateException( + "Git config path does not match airportId/appName/fileName layout: " + normalizePath(relativePath) + ); + } + + String airportId = relativePath.getName(0).toString(); + String appName = relativePath.getName(1).toString(); + String fileName = normalizePath(relativePath.subpath(2, relativePath.getNameCount())); + return new GitConfigPath(airportId, appName, fileName); + } + + private String requireDirectorySegment(String value, String fieldName) throws IOException { + if (!StringUtils.hasText(value)) { + throw new IOException("pullConfig response missing " + fieldName); + } + + String trimmed = value.trim(); + if (".".equals(trimmed) || "..".equals(trimmed) + || trimmed.contains("/") || trimmed.contains("\\")) { + throw new IOException("pullConfig returned illegal " + fieldName + ": " + trimmed); + } + return trimmed; + } + + private String normalizePath(Path path) { + return path.toString().replace('\\', '/'); + } + public static class ProdApiResponse { private String code; @@ -397,9 +479,31 @@ public class ProdConfigApiService { } } - /** - * pushConfig 请求体项。 - */ + private static class GitConfigPath { + + private final String airportId; + private final String appName; + private final String fileName; + + private GitConfigPath(String airportId, String appName, String fileName) { + this.airportId = airportId; + this.appName = appName; + this.fileName = fileName; + } + + public String getAirportId() { + return airportId; + } + + public String getAppName() { + return appName; + } + + public String getFileName() { + return fileName; + } + } + public static class ProdPushConfigItem { private String airportId; @@ -449,9 +553,6 @@ public class ProdConfigApiService { } } - /** - * pushConfig 响应 data。 - */ public static class ProdPushResponseData { private List ackFail; @@ -465,9 +566,6 @@ public class ProdConfigApiService { } } - /** - * pushConfig 失败回执项。 - */ public static class ProdPushAckItem { private String airportId; @@ -508,9 +606,6 @@ public class ProdConfigApiService { } } - /** - * pullConfig 返回的配置项。 - */ public static class ProdPulledConfigItem { private String id; @@ -569,9 +664,6 @@ public class ProdConfigApiService { } } - /** - * 登录请求体。 - */ public static class ProdLoginRequest { private String name; @@ -594,9 +686,6 @@ public class ProdConfigApiService { } } - /** - * 登录接口返回 data。 - */ public static class ProdLoginResponseData { private String token; diff --git a/src/main/java/com/ftptool/sync/service/ProdPullAckService.java b/src/main/java/com/ftptool/sync/service/ProdPullAckService.java index 623a325..700dd8a 100644 --- a/src/main/java/com/ftptool/sync/service/ProdPullAckService.java +++ b/src/main/java/com/ftptool/sync/service/ProdPullAckService.java @@ -2,29 +2,33 @@ package com.ftptool.sync.service; import com.ftptool.sync.entity.ProdPullAckRecord; import com.ftptool.sync.model.ProdPullAckStatus; +import com.ftptool.sync.model.ProdPullResult; import com.ftptool.sync.repository.ProdPullAckRecordRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** - * pullConfig 回执服务。 + * Stores pullConfig ack states and retry plans. */ @Service public class ProdPullAckService { + private static final int RETRY_DELAY_BASE_SECONDS = 30; + private final ProdPullAckRecordRepository prodPullAckRecordRepository; public ProdPullAckService(ProdPullAckRecordRepository prodPullAckRecordRepository) { this.prodPullAckRecordRepository = prodPullAckRecordRepository; } - /** - * 读取所有尚未回传给生产端的回执状态。 - */ @Transactional(readOnly = true) public PendingAckSummary getPendingAckSummary() { List successIds = toRemoteIds( @@ -36,31 +40,116 @@ public class ProdPullAckService { return new PendingAckSummary(successIds, failedIds); } - /** - * 记录一批配置项的处理结果。 - * 若同一 remote id 已存在,则以最新状态覆盖。 - */ @Transactional - public void recordAckResult(Collection remoteIds, ProdPullAckStatus ackStatus) { - if (remoteIds == null) { + public void recordAckSuccess(Collection pulledConfigs) { + if (pulledConfigs == null) { return; } - for (String remoteId : remoteIds) { - if (remoteId == null || remoteId.trim().isEmpty()) { + + for (ProdPullResult.PulledConfigRef pulledConfig : pulledConfigs) { + if (!hasRemoteId(pulledConfig)) { continue; } - ProdPullAckRecord record = prodPullAckRecordRepository.findByRemoteConfigId(remoteId) + + ProdPullAckRecord record = prodPullAckRecordRepository.findByRemoteConfigId(pulledConfig.getRemoteConfigId()) .orElseGet(ProdPullAckRecord::new); - record.setRemoteConfigId(remoteId); - record.setAckStatus(ackStatus); + record.setRemoteConfigId(pulledConfig.getRemoteConfigId()); + record.setAckStatus(ProdPullAckStatus.SUCCESS); record.setReported(Boolean.FALSE); + applyPullMetadata(record, pulledConfig); + record.setRetryCount(0); + record.setNextRetryAt(null); + record.setLastErrorMsg(null); prodPullAckRecordRepository.save(record); } } - /** - * 当前批次 pull 请求发送成功后,将待回执记录标记为已回传。 - */ + @Transactional + public void recordPullFailure( + Collection pulledConfigs, + String errorMsg, + boolean retryAttempt + ) { + if (pulledConfigs == null) { + return; + } + + for (ProdPullResult.PulledConfigRef pulledConfig : pulledConfigs) { + if (!hasRemoteId(pulledConfig)) { + continue; + } + + ProdPullAckRecord record = prodPullAckRecordRepository.findByRemoteConfigId(pulledConfig.getRemoteConfigId()) + .orElseGet(ProdPullAckRecord::new); + record.setRemoteConfigId(pulledConfig.getRemoteConfigId()); + record.setAckStatus(ProdPullAckStatus.FAILED); + record.setReported(Boolean.FALSE); + applyPullMetadata(record, pulledConfig); + record.setLastErrorMsg(errorMsg); + if (retryAttempt) { + int retryCount = safeRetryCount(record) + 1; + record.setRetryCount(retryCount); + record.setNextRetryAt(calculateNextRetryAt(retryCount)); + } else { + record.setRetryCount(0); + record.setNextRetryAt(LocalDateTime.now()); + } + prodPullAckRecordRepository.save(record); + } + } + + @Transactional + public void markRetryAttemptFailed(RetryPullRequest retryPullRequest, String errorMsg) { + if (retryPullRequest == null || retryPullRequest.getRemoteConfigIds().isEmpty()) { + return; + } + + for (String remoteConfigId : retryPullRequest.getRemoteConfigIds()) { + prodPullAckRecordRepository.findByRemoteConfigId(remoteConfigId).ifPresent(record -> { + int retryCount = safeRetryCount(record) + 1; + record.setRetryCount(retryCount); + record.setNextRetryAt(calculateNextRetryAt(retryCount)); + record.setLastErrorMsg(errorMsg); + record.setAckStatus(ProdPullAckStatus.FAILED); + prodPullAckRecordRepository.save(record); + }); + } + } + + @Transactional(readOnly = true) + public List getRetryPullRequests(int maxRetryCount) { + List failedRecords = prodPullAckRecordRepository.findByAckStatusOrderByUpdatedAtAsc(ProdPullAckStatus.FAILED); + LocalDateTime now = LocalDateTime.now(); + Map retryPullRequests = new LinkedHashMap(); + + for (ProdPullAckRecord failedRecord : failedRecords) { + if (!hasRetryMetadata(failedRecord)) { + continue; + } + if (safeRetryCount(failedRecord) >= maxRetryCount) { + continue; + } + if (failedRecord.getNextRetryAt() != null && failedRecord.getNextRetryAt().isAfter(now)) { + continue; + } + + String groupKey = buildRetryGroupKey(failedRecord); + RetryPullRequest retryPullRequest = retryPullRequests.get(groupKey); + if (retryPullRequest == null) { + retryPullRequest = new RetryPullRequest( + failedRecord.getSourceVersion(), + failedRecord.getAirportId(), + failedRecord.getAppName(), + failedRecord.getFileName() + ); + retryPullRequests.put(groupKey, retryPullRequest); + } + retryPullRequest.addRemoteConfigId(failedRecord.getRemoteConfigId()); + } + + return new ArrayList(retryPullRequests.values()); + } + @Transactional public void markPendingAsReported() { List records = prodPullAckRecordRepository.findByReportedFalseOrderByUpdatedAtAsc(); @@ -70,6 +159,51 @@ public class ProdPullAckService { } } + private void applyPullMetadata(ProdPullAckRecord record, ProdPullResult.PulledConfigRef pulledConfig) { + if (pulledConfig == null) { + return; + } + if (StringUtils.hasText(pulledConfig.getConfigVersion())) { + record.setSourceVersion(pulledConfig.getConfigVersion().trim()); + } + if (StringUtils.hasText(pulledConfig.getAirportId())) { + record.setAirportId(pulledConfig.getAirportId().trim()); + } + if (StringUtils.hasText(pulledConfig.getAppName())) { + record.setAppName(pulledConfig.getAppName().trim()); + } + if (StringUtils.hasText(pulledConfig.getFileName())) { + record.setFileName(pulledConfig.getFileName().trim()); + } + } + + private boolean hasRemoteId(ProdPullResult.PulledConfigRef pulledConfig) { + return pulledConfig != null && StringUtils.hasText(pulledConfig.getRemoteConfigId()); + } + + private boolean hasRetryMetadata(ProdPullAckRecord failedRecord) { + return StringUtils.hasText(failedRecord.getSourceVersion()) + && StringUtils.hasText(failedRecord.getAirportId()) + && StringUtils.hasText(failedRecord.getAppName()) + && StringUtils.hasText(failedRecord.getFileName()); + } + + private int safeRetryCount(ProdPullAckRecord record) { + return record.getRetryCount() == null ? 0 : record.getRetryCount().intValue(); + } + + private LocalDateTime calculateNextRetryAt(int retryCount) { + long delaySeconds = RETRY_DELAY_BASE_SECONDS * (1L << Math.max(0, retryCount - 1)); + return LocalDateTime.now().plusSeconds(delaySeconds); + } + + private String buildRetryGroupKey(ProdPullAckRecord failedRecord) { + return failedRecord.getSourceVersion() + "|" + + failedRecord.getAirportId() + "|" + + failedRecord.getAppName() + "|" + + failedRecord.getFileName(); + } + private List toRemoteIds(List records) { List ids = new ArrayList(); for (ProdPullAckRecord record : records) { @@ -78,9 +212,6 @@ public class ProdPullAckService { return ids; } - /** - * 待回传 ACK 摘要。 - */ public static class PendingAckSummary { private final List ackSucIds; @@ -103,4 +234,44 @@ public class ProdPullAckService { return !(ackSucIds == null || ackSucIds.isEmpty()) || !(ackFailIds == null || ackFailIds.isEmpty()); } } + + public static class RetryPullRequest { + + private final String sourceVersion; + private final String airportId; + private final String appName; + private final String fileName; + private final List remoteConfigIds = new ArrayList(); + + public RetryPullRequest(String sourceVersion, String airportId, String appName, String fileName) { + this.sourceVersion = sourceVersion; + this.airportId = airportId; + this.appName = appName; + this.fileName = fileName; + } + + public String getSourceVersion() { + return sourceVersion; + } + + public String getAirportId() { + return airportId; + } + + public String getAppName() { + return appName; + } + + public String getFileName() { + return fileName; + } + + public List getRemoteConfigIds() { + return remoteConfigIds; + } + + private void addRemoteConfigId(String remoteConfigId) { + this.remoteConfigIds.add(remoteConfigId); + } + } } diff --git a/src/main/java/com/ftptool/sync/service/WorkDirectoryService.java b/src/main/java/com/ftptool/sync/service/WorkDirectoryService.java index 8fb1b03..1874485 100644 --- a/src/main/java/com/ftptool/sync/service/WorkDirectoryService.java +++ b/src/main/java/com/ftptool/sync/service/WorkDirectoryService.java @@ -31,7 +31,7 @@ public class WorkDirectoryService { FileTreeUtils.ensureDirectory(getPackageTempDir()); FileTreeUtils.ensureDirectory(getDevToProdStagingDir()); FileTreeUtils.ensureDirectory(getProdToDevStagingDir()); - FileTreeUtils.ensureDirectory(getGitToProdBaselineDir()); + FileTreeUtils.ensureDirectory(getGitToProdBaselineRootDir()); } /** @@ -65,7 +65,21 @@ public class WorkDirectoryService { /** * Git -> PROD 链路用于比较增量的基线目录。 */ - public Path getGitToProdBaselineDir() { + public Path getGitToProdBaselineRootDir() { return getWorkDir().resolve("baseline").resolve("git-to-prod"); } + + /** + * Git -> PROD 链路按版本分支隔离的基线目录。 + */ + public Path getGitToProdBaselineDir(String branch) { + return getGitToProdBaselineRootDir().resolve(sanitizePathSegment(branch)); + } + + private String sanitizePathSegment(String value) { + if (value == null || value.trim().isEmpty()) { + return "default"; + } + return value.replaceAll("[^a-zA-Z0-9._-]", "_"); + } } diff --git a/src/main/resources/application-prod-agent.properties b/src/main/resources/application-prod-agent.properties index 8cb2f47..830f3ec 100644 --- a/src/main/resources/application-prod-agent.properties +++ b/src/main/resources/application-prod-agent.properties @@ -17,5 +17,7 @@ prod.api.token=change-me prod.api.token-header-name=token prod.api.airport-id=replace-me prod.api.app-name=replace-me +prod.api.pull-config-version= +prod.api.pull-file-name= prod.api.login-name= prod.api.login-password= diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ad5df0e..1abdcae 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -33,7 +33,7 @@ git.repo.local-path=./work/git/config-repo git.repo.remote-uri=https://git.example.com/config.git git.repo.username=replace-me git.repo.password=replace-me -git.repo.scan-branch=config-dev-main +git.repo.scan-branch=R_XXX_V3.0.3_XXX git.repo.snapshot-branch=config-prod-snapshot git.repo.commit-author-name=git-sync-bot git.repo.commit-author-email=git-sync-bot@example.com @@ -49,6 +49,8 @@ prod.api.token=replace-me prod.api.token-header-name=token prod.api.airport-id=replace-me prod.api.app-name=replace-me +prod.api.pull-config-version= +prod.api.pull-file-name= prod.api.login-name= prod.api.login-password= prod.api.connect-timeout-ms=10000 diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 4064992..e76c626 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -31,9 +31,25 @@ create table if not exists prod_pull_ack ( remote_config_id varchar(128) not null, ack_status varchar(16) not null, reported boolean not null default false, + source_version varchar(128), + airport_id varchar(128), + app_name varchar(128), + file_name varchar(512), + retry_count int not null default 0, + next_retry_at timestamp, + last_error_msg clob, created_at timestamp not null, updated_at timestamp not null, constraint uk_prod_pull_ack_remote_id unique (remote_config_id) ); +alter table prod_pull_ack add column if not exists source_version varchar(128); +alter table prod_pull_ack add column if not exists airport_id varchar(128); +alter table prod_pull_ack add column if not exists app_name varchar(128); +alter table prod_pull_ack add column if not exists file_name varchar(512); +alter table prod_pull_ack add column if not exists retry_count int default 0; +alter table prod_pull_ack add column if not exists next_retry_at timestamp; +alter table prod_pull_ack add column if not exists last_error_msg clob; + create index if not exists idx_prod_pull_ack_reported on prod_pull_ack (reported); +create index if not exists idx_prod_pull_ack_status on prod_pull_ack (ack_status); diff --git a/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java b/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java index 2c63adc..8cb6208 100644 --- a/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java +++ b/src/test/java/com/ftptool/sync/orchestrator/ProdSyncCoordinatorIntegrationTest.java @@ -98,7 +98,13 @@ class ProdSyncCoordinatorIntegrationTest { void shouldSyncGitToProdAndKeepItIdempotent() throws Exception { when(gitClientService.prepareRepositoryAndGetHead("config-dev-main")).thenReturn("commit-a"); when(gitClientService.exportBranchSnapshot(eq("config-dev-main"), any(Path.class))) - .thenAnswer(invocation -> invocation.getArgument(1)); + .thenAnswer(invocation -> { + Path target = invocation.getArgument(1); + Path configFile = target.resolve("PEK").resolve("monitor").resolve("application.yml"); + Files.createDirectories(configFile.getParent()); + Files.write(configFile, "key: value".getBytes("UTF-8")); + return target; + }); when(packageService.calculateDirectoryHash(any(Path.class))).thenReturn("hash-a"); doNothing().when(prodConfigApiService).pushPackage(any(PackageManifest.class), any(Path.class)); @@ -109,13 +115,13 @@ class ProdSyncCoordinatorIntegrationTest { assertEquals(1, tasks.size()); SyncTask task = tasks.get(0); assertEquals(SyncDirection.DEV_TO_PROD, task.getDirection()); - assertEquals("commit-a", task.getSourceVersion()); + assertEquals("config-dev-main", task.getSourceVersion()); assertEquals("hash-a", task.getContentHash()); assertEquals(SyncStatus.SUCCESS, task.getStatus()); Optional checkpoint = syncCheckpointRepository.findByDirection(SyncDirection.DEV_TO_PROD); assertTrue(checkpoint.isPresent()); - assertEquals("commit-a", checkpoint.get().getLastSuccessVersion()); + assertEquals("config-dev-main", checkpoint.get().getLastSuccessVersion()); assertEquals("hash-a", checkpoint.get().getLastSuccessHash()); verify(prodConfigApiService, times(1)).pushPackage(any(PackageManifest.class), any(Path.class)); @@ -125,12 +131,12 @@ class ProdSyncCoordinatorIntegrationTest { void shouldSyncProdSnapshotToGitAndKeepItIdempotent() throws Exception { Path contentDirectory = Files.createTempDirectory("prod-to-git-"); Files.write(contentDirectory.resolve("prod-config.json"), "{\"version\":\"prod-v1\"}".getBytes("UTF-8")); - when(prodConfigApiService.pullConfigSnapshot()).thenReturn( + when(prodConfigApiService.pullConfigSnapshots()).thenReturn(Arrays.asList( new ProdPullResult(contentDirectory, "prod-v1", "hash-b", Arrays.asList("1", "2")) - ); + )); when(gitClientService.syncDirectoryToBranch( eq(contentDirectory), - eq("config-prod-snapshot"), + eq("config-prod-snapshot/prod-v1"), contains("prod-v1") )).thenReturn(true); @@ -159,21 +165,58 @@ class ProdSyncCoordinatorIntegrationTest { verify(gitClientService, times(1)).syncDirectoryToBranch( eq(contentDirectory), - eq("config-prod-snapshot"), + eq("config-prod-snapshot/prod-v1"), contains("prod-v1") ); } + @Test + void shouldSyncMultipleProdVersionsToDynamicSnapshotBranches() throws Exception { + Path firstDirectory = Files.createTempDirectory("prod-to-git-v1-"); + Path secondDirectory = Files.createTempDirectory("prod-to-git-v2-"); + Files.write(firstDirectory.resolve("a.txt"), "one".getBytes("UTF-8")); + Files.write(secondDirectory.resolve("b.txt"), "two".getBytes("UTF-8")); + when(prodConfigApiService.pullConfigSnapshots()).thenReturn(Arrays.asList( + new ProdPullResult(firstDirectory, "prod-v1", "hash-v1", Arrays.asList("1")), + new ProdPullResult(secondDirectory, "prod-v2", "hash-v2", Arrays.asList("2")) + )); + when(gitClientService.syncDirectoryToBranch( + eq(firstDirectory), + eq("config-prod-snapshot/prod-v1"), + contains("prod-v1") + )).thenReturn(true); + when(gitClientService.syncDirectoryToBranch( + eq(secondDirectory), + eq("config-prod-snapshot/prod-v2"), + contains("prod-v2") + )).thenReturn(true); + + prodSyncCoordinator.syncProdSnapshotToGit(); + + List tasks = syncTaskRepository.findAll(); + assertEquals(2, tasks.size()); + verify(gitClientService, times(1)).syncDirectoryToBranch( + eq(firstDirectory), + eq("config-prod-snapshot/prod-v1"), + contains("prod-v1") + ); + verify(gitClientService, times(1)).syncDirectoryToBranch( + eq(secondDirectory), + eq("config-prod-snapshot/prod-v2"), + contains("prod-v2") + ); + } + @Test void shouldRecordFailedAckWhenProdSnapshotSyncFails() throws Exception { Path contentDirectory = Files.createTempDirectory("prod-to-git-fail-"); Files.write(contentDirectory.resolve("prod-config.json"), "{\"version\":\"prod-v2\"}".getBytes("UTF-8")); - when(prodConfigApiService.pullConfigSnapshot()).thenReturn( + when(prodConfigApiService.pullConfigSnapshots()).thenReturn(Arrays.asList( new ProdPullResult(contentDirectory, "prod-v2", "hash-fail", Arrays.asList("9")) - ); + )); when(gitClientService.syncDirectoryToBranch( eq(contentDirectory), - eq("config-prod-snapshot"), + eq("config-prod-snapshot/prod-v2"), contains("prod-v2") )).thenThrow(new IllegalStateException("git push fail")); @@ -186,6 +229,64 @@ class ProdSyncCoordinatorIntegrationTest { assertFalse(ackRecords.get(0).getReported()); } + @Test + void shouldRetryFailedAckByTargetedPullFilters() throws Exception { + Path failedDirectory = Files.createTempDirectory("prod-to-git-retry-fail-"); + Path retriedDirectory = Files.createTempDirectory("prod-to-git-retry-success-"); + Files.write(failedDirectory.resolve("prod-config.json"), "{\"version\":\"prod-v3\"}".getBytes("UTF-8")); + Files.write(retriedDirectory.resolve("prod-config.json"), "{\"version\":\"prod-v3\"}".getBytes("UTF-8")); + + List pulledConfigs = Arrays.asList( + new ProdPullResult.PulledConfigRef("11", "PEK", "monitor", "prod-v3", "jobs/sync-job.json") + ); + ProdPullResult failedResult = new ProdPullResult( + failedDirectory, + "prod-v3", + "hash-fail-v3", + Arrays.asList("11"), + pulledConfigs + ); + ProdPullResult retriedResult = new ProdPullResult( + retriedDirectory, + "prod-v3", + "hash-success-v3", + Arrays.asList("11"), + pulledConfigs + ); + + when(prodConfigApiService.pullConfigSnapshots()) + .thenReturn(Arrays.asList(failedResult)) + .thenReturn(new ArrayList()); + when(prodConfigApiService.pullConfigSnapshots("PEK", "monitor", "prod-v3", "jobs/sync-job.json")) + .thenReturn(Arrays.asList(retriedResult)); + when(gitClientService.syncDirectoryToBranch( + eq(failedDirectory), + eq("config-prod-snapshot/prod-v3"), + contains("prod-v3") + )).thenThrow(new IllegalStateException("first git push fail")); + when(gitClientService.syncDirectoryToBranch( + eq(retriedDirectory), + eq("config-prod-snapshot/prod-v3"), + contains("prod-v3") + )).thenReturn(true); + + prodSyncCoordinator.syncProdSnapshotToGit(); + prodSyncCoordinator.syncProdSnapshotToGit(); + + List ackRecords = prodPullAckRecordRepository.findAll(); + assertEquals(1, ackRecords.size()); + ProdPullAckRecord ackRecord = ackRecords.get(0); + assertEquals(ProdPullAckStatus.SUCCESS, ackRecord.getAckStatus()); + assertFalse(ackRecord.getReported()); + assertEquals(Integer.valueOf(0), ackRecord.getRetryCount()); + assertEquals("PEK", ackRecord.getAirportId()); + assertEquals("monitor", ackRecord.getAppName()); + assertEquals("jobs/sync-job.json", ackRecord.getFileName()); + + verify(prodConfigApiService, times(1)) + .pullConfigSnapshots("PEK", "monitor", "prod-v3", "jobs/sync-job.json"); + } + @Test void shouldUseIncrementalDirectoryForSecondGitToProdPush() throws Exception { AtomicInteger exportCounter = new AtomicInteger(0); @@ -197,14 +298,16 @@ class ProdSyncCoordinatorIntegrationTest { when(gitClientService.exportBranchSnapshot(eq("config-dev-main"), any(Path.class))) .thenAnswer(invocation -> { Path target = invocation.getArgument(1); - Files.createDirectories(target); + Path fileA = target.resolve("PEK").resolve("monitor").resolve("a.txt"); + Path fileB = target.resolve("PEK").resolve("monitor").resolve("b.txt"); + Files.createDirectories(fileA.getParent()); int round = exportCounter.incrementAndGet(); if (round == 1) { - Files.write(target.resolve("a.txt"), "v1".getBytes("UTF-8")); - Files.write(target.resolve("b.txt"), "same".getBytes("UTF-8")); + Files.write(fileA, "v1".getBytes("UTF-8")); + Files.write(fileB, "same".getBytes("UTF-8")); } else { - Files.write(target.resolve("a.txt"), "v2".getBytes("UTF-8")); - Files.write(target.resolve("b.txt"), "same".getBytes("UTF-8")); + Files.write(fileA, "v2".getBytes("UTF-8")); + Files.write(fileB, "same".getBytes("UTF-8")); } return target; }); @@ -221,8 +324,8 @@ class ProdSyncCoordinatorIntegrationTest { assertEquals(2, pushedDirectories.size()); Path secondPushDirectory = pushedDirectories.get(1); - assertTrue(Files.exists(secondPushDirectory.resolve("a.txt"))); - assertFalse(Files.exists(secondPushDirectory.resolve("b.txt"))); + assertTrue(Files.exists(secondPushDirectory.resolve("PEK").resolve("monitor").resolve("a.txt"))); + assertFalse(Files.exists(secondPushDirectory.resolve("PEK").resolve("monitor").resolve("b.txt"))); assertTrue(secondPushDirectory.getFileName().toString().startsWith("git-delta-")); } } diff --git a/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java b/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java index f424f56..e980506 100644 --- a/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java +++ b/src/test/java/com/ftptool/sync/service/ProdConfigApiServiceHttpTest.java @@ -1,11 +1,15 @@ 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; @@ -13,8 +17,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -26,18 +32,118 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat class ProdConfigApiServiceHttpTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Test + void shouldBuildPushPayloadFromGitDirectoryLayout() throws Exception { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + + SyncProperties syncProperties = newSyncProperties("./target/http-test-work-push"); + 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("static-token"); + prodApiProperties.setTokenHeaderName("token"); + ConfigCryptoService configCryptoService = mock(ConfigCryptoService.class); + when(configCryptoService.encryptForPush("PEK", "monitor", "R_XXX_V3.0.3_XXX", "jobs/sync-job.json", "{\"enabled\":true}")) + .thenReturn("{\"enabled\":true}"); + when(configCryptoService.encryptForPush("SHA", "gate", "R_XXX_V3.0.3_XXX", "gate-rule.json", "{\"allow\":false}")) + .thenReturn("{\"allow\":false}"); + + ProdConfigApiService service = new ProdConfigApiService( + prodApiProperties, + syncProperties, + restTemplate, + workDirectoryService, + mock(ProdPullAckService.class), + configCryptoService + ); + + Path sourceDirectory = Files.createTempDirectory("push-layout-"); + Path pekFile = sourceDirectory.resolve("PEK").resolve("monitor").resolve("jobs").resolve("sync-job.json"); + Path shaFile = sourceDirectory.resolve("SHA").resolve("gate").resolve("gate-rule.json"); + Files.createDirectories(pekFile.getParent()); + Files.createDirectories(shaFile.getParent()); + Files.write(pekFile, "{\"enabled\":true}".getBytes(StandardCharsets.UTF_8)); + Files.write(shaFile, "{\"allow\":false}".getBytes(StandardCharsets.UTF_8)); + + server.expect(once(), request -> { + JsonNode body = OBJECT_MAPPER.readTree(((MockClientHttpRequest) request).getBodyAsString()); + assertEquals(2, 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("jobs/sync-job.json", body.get(0).get("fileName").asText()); + assertEquals("{\"enabled\":true}", body.get(0).get("configContent").asText()); + + assertEquals("SHA", body.get(1).get("airportId").asText()); + assertEquals("gate", body.get(1).get("appName").asText()); + assertEquals("gate-rule.json", body.get(1).get("fileName").asText()); + assertEquals("{\"allow\":false}", body.get(1).get("configContent").asText()); + }) + .andExpect(method(HttpMethod.POST)) + .andExpect(header("token", "static-token")) + .andRespond(withSuccess( + "{\"code\":\"0\",\"data\":{\"ackFail\":[]},\"msg\":\"ok\"}", + MediaType.APPLICATION_JSON + )); + + PackageManifest manifest = new PackageManifest(); + manifest.setTraceId("trace-1"); + manifest.setSourceVersion("R_XXX_V3.0.3_XXX"); + + service.pushPackage(manifest, sourceDirectory); + + verify(configCryptoService).encryptForPush("PEK", "monitor", "R_XXX_V3.0.3_XXX", "jobs/sync-job.json", "{\"enabled\":true}"); + verify(configCryptoService).encryptForPush("SHA", "gate", "R_XXX_V3.0.3_XXX", "gate-rule.json", "{\"allow\":false}"); + server.verify(); + } + + @Test + void shouldRejectPushFileOutsideAirportAppLayout() throws Exception { + SyncProperties syncProperties = newSyncProperties("./target/http-test-work-invalid-push"); + 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("static-token"); + + ProdConfigApiService service = new ProdConfigApiService( + prodApiProperties, + syncProperties, + new RestTemplate(), + workDirectoryService, + mock(ProdPullAckService.class), + new ConfigCryptoService() + ); + + Path sourceDirectory = Files.createTempDirectory("invalid-push-layout-"); + Files.write(sourceDirectory.resolve("README.md"), "bad".getBytes(StandardCharsets.UTF_8)); + + PackageManifest manifest = new PackageManifest(); + manifest.setTraceId("trace-2"); + manifest.setSourceVersion("R_XXX_V3.0.4_XXX"); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> service.pushPackage(manifest, sourceDirectory) + ); + assertTrue(exception.getMessage().contains("airportId/appName/fileName")); + } + @Test void shouldSendAckParamsWhenPullingConfig() throws Exception { RestTemplate restTemplate = new RestTemplate(); MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); - SyncProperties syncProperties = new SyncProperties(); - syncProperties.setWorkDir("./target/http-test-work"); - syncProperties.setPackageTempDir("./target/http-test-work/package"); - syncProperties.setDevToProdStagingDir("./target/http-test-work/dev-to-prod"); - syncProperties.setProdToDevStagingDir("./target/http-test-work/prod-to-dev"); - syncProperties.setPullResponseFileName("prod-config.json"); - + SyncProperties syncProperties = newSyncProperties("./target/http-test-work-pull"); WorkDirectoryService workDirectoryService = new WorkDirectoryService(syncProperties); workDirectoryService.initialize(); @@ -48,24 +154,32 @@ class ProdConfigApiServiceHttpTest { prodApiProperties.setTokenHeaderName("token"); prodApiProperties.setAirportId("test-airport"); prodApiProperties.setAppName("test-app"); + prodApiProperties.setPullConfigVersion("v1"); + prodApiProperties.setPullFileName("a.txt"); ProdPullAckService prodPullAckService = mock(ProdPullAckService.class); when(prodPullAckService.getPendingAckSummary()).thenReturn( new ProdPullAckService.PendingAckSummary(Arrays.asList("1", "2"), Arrays.asList("9")) ); + ConfigCryptoService configCryptoService = mock(ConfigCryptoService.class); + when(configCryptoService.decryptAfterPull("test-airport", "test-app", "v1", "a.txt", "content")) + .thenReturn("decoded-content"); ProdConfigApiService service = new ProdConfigApiService( prodApiProperties, syncProperties, restTemplate, workDirectoryService, - prodPullAckService + prodPullAckService, + configCryptoService ); server.expect(once(), request -> { String uri = request.getURI().toString(); assertTrue(uri.contains("airportId=test-airport")); assertTrue(uri.contains("appName=test-app")); + assertTrue(uri.contains("configVersion=v1")); + assertTrue(uri.contains("fileName=a.txt")); assertTrue(uri.contains("ackSuc=1,2") || uri.contains("ackSuc=1%2C2")); assertTrue(uri.contains("ackFail=9")); }) @@ -76,16 +190,82 @@ class ProdConfigApiServiceHttpTest { MediaType.APPLICATION_JSON )); - ProdPullResult result = service.pullConfigSnapshot(); + List results = service.pullConfigSnapshots(); + assertEquals(1, results.size()); + ProdPullResult result = results.get(0); assertEquals("v1", result.getSourceVersion()); assertEquals(1, result.getPulledConfigIds().size()); assertEquals("1", result.getPulledConfigIds().get(0)); - Path restoredFile = result.getContentDirectory().resolve("a.txt"); + Path restoredFile = result.getContentDirectory() + .resolve("test-airport") + .resolve("test-app") + .resolve("a.txt"); assertTrue(Files.exists(restoredFile)); - assertEquals("content", new String(Files.readAllBytes(restoredFile), StandardCharsets.UTF_8)); + assertEquals("decoded-content", new String(Files.readAllBytes(restoredFile), StandardCharsets.UTF_8)); + verify(configCryptoService).decryptAfterPull("test-airport", "test-app", "v1", "a.txt", "content"); verify(prodPullAckService).markPendingAsReported(); server.verify(); } + + @Test + void shouldSplitPulledResultsByConfigVersion() throws Exception { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + + SyncProperties syncProperties = newSyncProperties("./target/http-test-work-pull-grouped"); + 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("static-token"); + + ProdPullAckService prodPullAckService = mock(ProdPullAckService.class); + when(prodPullAckService.getPendingAckSummary()).thenReturn( + new ProdPullAckService.PendingAckSummary(Arrays.asList(), Arrays.asList()) + ); + + ProdConfigApiService service = new ProdConfigApiService( + prodApiProperties, + syncProperties, + restTemplate, + workDirectoryService, + prodPullAckService, + new ConfigCryptoService() + ); + + server.expect(once(), request -> { + }) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess( + "{\"code\":\"0\",\"data\":[" + + "{\"id\":\"1\",\"airportId\":\"PEK\",\"appName\":\"monitor\",\"configVersion\":\"v1\",\"configContent\":\"one\",\"fileName\":\"a.txt\"}," + + "{\"id\":\"2\",\"airportId\":\"SHA\",\"appName\":\"gate\",\"configVersion\":\"v2\",\"configContent\":\"two\",\"fileName\":\"b.txt\"}" + + "],\"msg\":\"ok\"}", + MediaType.APPLICATION_JSON + )); + + List results = service.pullConfigSnapshots(); + + assertEquals(2, results.size()); + assertEquals("v1", results.get(0).getSourceVersion()); + assertEquals("v2", results.get(1).getSourceVersion()); + assertTrue(Files.exists(results.get(0).getContentDirectory().resolve("PEK").resolve("monitor").resolve("a.txt"))); + assertTrue(Files.exists(results.get(1).getContentDirectory().resolve("SHA").resolve("gate").resolve("b.txt"))); + + 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; + } }