Compare commits

...
148 Commits
Author SHA1 Message Date
liuyihe 802893b2ff Merge remote-tracking branch 'origin/dev' into dev 2026-08-20 13:34:55 +08:00
liuyihe b139255d3b 入库修改 2026-08-20 13:34:34 +08:00
ouyangxq d3183d8f2c 优化 2026-08-20 13:34:29 +08:00
ouyangxq 3e517ee74e Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 13:34:04 +08:00
ouyangxq d340c418e8 优化 2026-08-20 13:07:40 +08:00
ouyangxq 3b19a1bc16 Merge branch 'test' into dev 2026-08-20 13:00:49 +08:00
ouyangxq 15d3ffbc0a 优化 2026-08-20 13:00:39 +08:00
ouyangxq b060be18a9 Merge branch 'test' into dev 2026-08-20 12:50:05 +08:00
ouyangxq b029f4ce28 优化 2026-08-20 12:49:54 +08:00
ouyangxq fd1da73fa8 Merge branch 'test' into dev 2026-08-20 12:34:49 +08:00
ouyangxq c83620a86a 优化 2026-08-20 12:34:10 +08:00
ouyangxq b464de1edb Merge branch 'test' into dev 2026-08-20 10:39:13 +08:00
ouyangxq 3d57abf260 优化 2026-08-20 10:37:32 +08:00
ouyangxq 0384f54da7 Merge branch 'test' into dev 2026-08-20 10:28:57 +08:00
ouyangxq bdf545ee0a 优化 2026-08-20 10:28:45 +08:00
ouyangxq 0575ea247f Merge branch 'test' into dev 2026-08-20 10:23:00 +08:00
ouyangxq 7a5719b5a1 优化 2026-08-20 10:22:51 +08:00
ouyangxq d36e480413 优化 2026-08-20 10:17:39 +08:00
ouyangxq e469749a98 Merge branch 'test' into dev 2026-08-20 09:50:48 +08:00
ouyangxq be5d89a4e6 优化 2026-08-20 09:49:28 +08:00
ouyangxq 7cf4a8d562 优化 2026-08-20 09:43:31 +08:00
ouyangxq cf2356bccb Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-20 09:10:32 +08:00
ouyangxq f227cf05a3 优化 2026-08-20 09:10:30 +08:00
liuyihe f03ea227ed 暂存提交 2026-08-19 17:23:54 +08:00
liuyihe 26eba227eb 暂存提交 2026-08-19 17:22:03 +08:00
ouyangxq 064247a8c9 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 17:08:42 +08:00
ouyangxq f78b44297d 优化 2026-08-19 17:08:41 +08:00
liuyihe 88100f3536 Merge remote-tracking branch 'origin/dev' into dev 2026-08-19 16:49:58 +08:00
liuyihe 63618c20dc 暂存提交 2026-08-19 16:49:41 +08:00
ouyangxq a4ec4e8e99 Merge branch 'dev' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 16:45:45 +08:00
ouyangxq 3b8934fc60 优化 2026-08-19 16:45:44 +08:00
liuyihe 6acb6178da 暂存提交 2026-08-19 16:24:02 +08:00
ouyangxq f5c4bf130a Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 14:14:04 +08:00
ouyangxq f25e270f22 优化 2026-08-19 14:13:47 +08:00
ouyangxq 000fd10b6a fix(sql): complete IAM security event comments 2026-08-19 14:04:16 +08:00
ouyangxq 23e78bb33f Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev 2026-08-19 14:02:11 +08:00
ouyangxq 41d70841fc 优化 2026-08-19 09:58:43 +08:00
ouyangxq 008d7ba574 优化 2026-08-19 09:57:50 +08:00
ouyangxq b908d9f248 优化 2026-08-19 09:56:17 +08:00
ouyangxq 80527fa15f 优化 2026-08-19 09:55:29 +08:00
ouyangxq a381d443da 优化 2026-08-19 09:44:18 +08:00
ouyangxq b5864bfcda 优化 2026-08-19 09:42:07 +08:00
ouyangxq ff1d330c2d 优化 2026-08-19 09:14:30 +08:00
ouyangxq 8845a66749 优化 2026-08-18 19:04:22 +08:00
ouyangxq 688e5649b0 优化 2026-08-18 17:40:32 +08:00
ouyangxq d70c08a378 优化 2026-08-18 16:42:01 +08:00
ouyangxq a9061eca54 优化 2026-08-18 16:39:39 +08:00
ouyangxq 7fe5b4e31f 优化 2026-08-18 16:10:44 +08:00
ouyangxq fa77170304 优化 2026-08-18 16:09:52 +08:00
ouyangxq 82ee80226e 优化 2026-08-18 16:03:42 +08:00
ouyangxq 12e9f3746e 优化 2026-08-18 15:49:52 +08:00
ouyangxq 6d5ec5759b 优化 2026-08-18 15:49:37 +08:00
ouyangxq 9db371ab0e 伦 2026-08-18 15:47:44 +08:00
ouyangxq 81e6610eb8 傣给 2026-08-18 15:46:56 +08:00
ouyangxq b26e64bb8d 优化 2026-08-18 15:46:52 +08:00
ouyangxq 4b0b3e9172 优化 2026-08-18 15:43:58 +08:00
ouyangxq 4e4d047862 优化 2026-08-18 11:46:10 +08:00
ouyangxq cad9a81d21 优化 2026-08-18 11:44:39 +08:00
ouyangxq ec4b1e0fe5 优化 2026-08-18 11:41:10 +08:00
ouyangxq 3e1010fa26 优化 2026-08-18 11:32:55 +08:00
ouyangxq ea26eecc50 优化 2026-08-18 09:09:28 +08:00
ouyangxq 092c003e3a 优化 2026-08-17 20:21:44 +08:00
ouyangxq b8c7f9fea2 优化 2026-08-17 20:01:48 +08:00
ouyangxq 45b51415cf 优化 2026-08-17 19:51:54 +08:00
ouyangxq cddd23e4c7 优化 2026-08-17 19:13:08 +08:00
ouyangxq 5199146e97 优化 2026-08-17 18:32:38 +08:00
ouyangxq 5c22018edd 优化 2026-08-17 18:04:04 +08:00
ouyangxq fd4afd6ae6 优化 2026-08-17 18:00:38 +08:00
ouyangxq 7c66097672 优化 2026-08-17 17:49:03 +08:00
ouyangxq c71db4b481 优化 2026-08-17 16:45:43 +08:00
ouyangxq 592da4d7e2 优化 2026-08-17 16:17:20 +08:00
ouyangxq 75580c3585 优化 2026-08-17 16:07:20 +08:00
ouyangxq 68d9216cb8 优化 2026-08-17 15:55:11 +08:00
ouyangxq 5f79616623 优化 2026-08-17 15:41:42 +08:00
ouyangxq 4abcb2b80f 优化 2026-08-17 14:50:00 +08:00
ouyangxq a34b5d5ba0 优化 2026-08-17 14:46:44 +08:00
ouyangxq b914bb7532 优化 2026-08-17 13:47:20 +08:00
ouyangxq 6739c83467 优化 2026-08-17 13:24:48 +08:00
ouyangxq ac6cf9c904 优化 2026-08-17 12:31:24 +08:00
ouyangxq 8566c6ea9e 优化 2026-08-17 11:23:35 +08:00
ouyangxq 6b416621bb 优化 2026-08-17 11:23:28 +08:00
ouyangxq d51e054def 优化 2026-08-17 10:54:08 +08:00
ouyangxq 7a9c72a6a2 优化 2026-08-17 09:41:08 +08:00
ouyangxq 20d836ab3d test: preserve fee item identifiers 2026-08-15 18:07:23 +08:00
ouyangxq f2a49d032a test: map finance supplier category from fee type 2026-08-15 18:03:47 +08:00
ouyangxq 2cec065582 test: provision finance fixture prerequisites 2026-08-15 17:58:15 +08:00
ouyangxq 0e2a291e7a test: use active suppliers for finance fixtures 2026-08-15 17:52:46 +08:00
ouyangxq 5e1e49085d test: normalize finance container allocation sources 2026-08-15 17:45:51 +08:00
ouyangxq 28daf15082 test: confirm plans before PDA acceptance 2026-08-15 17:40:01 +08:00
ouyangxq 05921eca13 test: stabilize preload push replay assertions 2026-08-15 17:32:16 +08:00
ouyangxq 8350bcdf00 fix: replay preload confirmation idempotently 2026-08-15 17:27:26 +08:00
ouyangxq a35794e918 feat: close preload confirmation and loading flow 2026-08-15 17:04:00 +08:00
ouyangxq 5b8e7d6989 fix: harden smart loading push idempotency 2026-08-15 12:39:30 +08:00
ouyangxq 0ddffeddc8 优化 2026-08-15 12:33:23 +08:00
ouyangxq 3a9558bd1c 优化 2026-08-15 12:12:34 +08:00
ouyangxq 08187363a1 优化 2026-08-15 11:51:15 +08:00
ouyangxq 46cb95db15 优化 2026-08-15 11:36:53 +08:00
ouyangxq 282eb73371 优化 2026-08-15 11:20:45 +08:00
ouyangxq 0f915d7b24 优化 2026-08-15 11:19:10 +08:00
ouyangxq c544b1bc39 优化 2026-08-15 11:19:08 +08:00
ouyangxq 792965c822 优化 2026-08-15 11:14:57 +08:00
ouyangxq dfa3e8cc53 优化 2026-08-15 11:14:50 +08:00
ouyangxq fdea2a6f31 优化 2026-08-15 11:14:47 +08:00
ouyangxq c4adba57db 优化 2026-08-15 11:12:19 +08:00
ouyangxq 557ab75319 优化 2026-08-15 11:05:58 +08:00
ouyangxq c4282020b5 优化 2026-08-15 10:35:54 +08:00
ouyangxq 555acca779 优化 2026-08-15 10:32:39 +08:00
ouyangxq 9e7cc55eab 优化 2026-08-15 10:27:15 +08:00
ouyangxq c6d7fb0d84 优化 2026-08-15 10:09:04 +08:00
ouyangxq 233f8f312d 优化 2026-08-15 10:05:03 +08:00
ouyangxq bafabf7217 优化 2026-08-15 09:57:46 +08:00
ouyangxq e7c0788a0b 优化 2026-08-15 09:49:13 +08:00
ouyangxq c07af65c3b 优化 2026-08-15 09:49:09 +08:00
ouyangxq 2bf3ff9d8e 优化 2026-08-15 09:49:00 +08:00
ouyangxq 20907e4685 优化 2026-08-15 09:47:17 +08:00
ouyangxq 1982a7dd69 test: align ocean booking fixtures with direct confirmed flow 2026-08-15 09:35:52 +08:00
ouyangxq e4d5e6d631 test: release split bookings before pda 2026-08-14 23:21:47 +08:00
ouyangxq 5bb8f4db6d test: align manual booking list dependencies 2026-08-14 23:14:11 +08:00
ouyangxq 32e9b69556 test(finance): complete automatic fee fixture workflow 2026-08-14 23:03:36 +08:00
ouyangxq 1db7c3b3cf test: align customer archive acceptance status 2026-08-14 22:59:57 +08:00
ouyangxq b89f14217d test: bind real containers before pda push 2026-08-14 22:59:10 +08:00
ouyangxq 0c845874f4 test: distinguish active and void overview photos 2026-08-14 22:43:07 +08:00
ouyangxq 011c30cc09 test: align container flow acceptance identities 2026-08-14 22:30:28 +08:00
ouyangxq 8aa58a4f72 fix(firstleg): hide void photo download paths 2026-08-14 22:29:50 +08:00
ouyangxq b2a093b31b test: release bookings before opening PDA 2026-08-14 22:21:24 +08:00
ouyangxq 010098c4af fix(firstleg): require real container before pda push 2026-08-14 22:03:21 +08:00
ouyangxq fdd011bc57 test: correct container loading acceptance order 2026-08-14 21:45:54 +08:00
ouyangxq dbca4a1403 test: stop container acceptance after failed booking link 2026-08-14 21:37:02 +08:00
ouyangxq 9bb046063c fix(firstleg): execute container customer scope query 2026-08-14 21:31:46 +08:00
ouyangxq 7cb5da753e fix(firstleg): keep truck links on container ownership 2026-08-14 21:14:42 +08:00
ouyangxq a4a4374c67 test(firstleg): cover simulated container booking links 2026-08-14 21:00:20 +08:00
ouyangxq 7184f5083a feat(firstleg): link simulated containers to real bookings 2026-08-14 20:59:03 +08:00
ouyangxq 993b3dfe03 优化 2026-08-14 19:59:26 +08:00
ouyangxq 7d4256dbd2 优化 2026-08-14 19:09:51 +08:00
ouyangxq 10d3390bb1 优化 2026-08-14 18:50:07 +08:00
ouyangxq 9b36978448 修复开发部署 SSH 指纹读取 2026-08-14 17:54:53 +08:00
ouyangxq e33b2c8645 优化 2026-08-14 17:53:09 +08:00
ouyangxq 2752cfb5b2 支持开发环境远程部署 2026-08-14 17:45:43 +08:00
ouyangxq feced8eb92 优化 2026-08-14 17:37:59 +08:00
ouyangxq 87e919f3c4 优化 2026-08-14 16:06:27 +08:00
ouyangxq 87eb1aa72f 优化 2026-08-14 14:54:48 +08:00
ouyangxq 36a048a2bc 优化 2026-08-14 14:50:39 +08:00
ouyangxq 261a8bf2df 优化 2026-08-14 14:26:56 +08:00
ouyangxq 06c264db0a 优化 2026-08-14 13:03:09 +08:00
ouyangxq ce6963a25e 优化 2026-08-14 12:50:37 +08:00
ouyangxq 91e781b3ff 优化 2026-08-14 10:57:05 +08:00
ouyangxq 66089f9b99 优化 2026-08-14 10:09:46 +08:00
ouyangxq 0bc752a2be fix: seed IT customer qualifications 2026-08-14 09:46:50 +08:00
988 changed files with 51160 additions and 5615 deletions
+19 -6
View File
@@ -1,16 +1,17 @@
# 国内头程后端项目指引
# 头程与海外仓后端项目指引
## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。
## 不可突破的产品与数据边界
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,国内仓储资源归 `warehouse`;海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外仓库/库位/托盘资源归 `warehouse-overseas-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
## 服务路由(按关键词定位)
@@ -32,6 +33,9 @@
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 海外仓库、海外库位、海外托盘、资源锁 | `warehouse-overseas-service/` | `warehouse-overseas-service/AGENTS.md` |
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
## 全量验收路由
@@ -39,19 +43,27 @@
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
## 国内头程与海外仓协同
- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。
- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验,海外服务不得访问国内 Redis,只按限定 Audience 的海外 Token Claims 完成本地认证和授权;海外业务请求不得经国内 Gateway 中转。
- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。
- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。
## 契约、数据库与验证入口
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
## 全量 HTTP 验收脚本路由
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
@@ -62,6 +74,7 @@
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
- 四期:`../docs/四期/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/`
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
Vendored
+22 -8
View File
@@ -28,6 +28,12 @@ def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:]
def environmentChoicesForJob(String jobBaseName) {
return jobBaseName == 'backend-java-dev'
? ['development', 'test', 'staging', 'production']
: ['test', 'development', 'staging', 'production']
}
pipeline {
// 总控流水线只识别变更并调度独立的服务 Job;实际 Maven、镜像和部署在 Jenkinsfile.service 中完成。
agent any
@@ -52,11 +58,11 @@ pipeline {
booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)')
booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service)')
booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'INITIALIZE_TEST_DATABASE', defaultValue: false, description: '仅 test:调度服务前幂等初始化测试数据库')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选')
string(name: 'IMAGE_REPOSITORY', defaultValue: '8.163.30.128:10082/java-project/baiyue-firstleg', description: '预发布/生产镜像仓库地址;test 环境自动使用本机镜像')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库地址')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
string(name: 'DOWNSTREAM_JOB_PREFIX', defaultValue: 'baiyue-firstleg-', description: '单服务 Jenkins Job 名称前缀,例如 baiyue-firstleg-auth')
}
@@ -65,10 +71,18 @@ pipeline {
stage('确定构建范围') {
steps {
script {
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
? 'development'
: params.ENVIRONMENT
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
String requestedRepository
if (params.ENVIRONMENT == 'test') {
requestedRepository = 'baiyue-firstleg-test'
echo '测试环境使用 Jenkins 节点本机镜像,不登录、不推送远程 Registry'
if (['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
requestedRepository = "baiyue-firstleg-${env.RESOLVED_ENVIRONMENT}"
echo "${env.RESOLVED_ENVIRONMENT} 环境使用本地镜像,由构建节点直接传输到部署目标"
} else {
requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
@@ -147,7 +161,7 @@ pipeline {
stage('生产环境部署确认(仅 production 执行)') {
when {
allOf {
expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
}
}
steps {
@@ -158,7 +172,7 @@ pipeline {
stage('测试数据库初始化(可选)') {
when {
allOf {
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
}
}
steps {
@@ -183,7 +197,7 @@ pipeline {
parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
string(name: 'ENVIRONMENT', value: params.ENVIRONMENT),
string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY),
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
+176 -100
View File
@@ -19,12 +19,8 @@ def serviceJars = [
]
pipeline {
// 每个 Jenkins 子 Job 使用自己的 workspace,因此不同服务可以并行执行 mvn clean。
agent any
tools {
maven 'maven-3.8.8'
}
// 构建在任意 java-builder 节点执行,部署再回到目标环境节点。
agent none
options {
skipDefaultCheckout(true)
@@ -36,134 +32,217 @@ pipeline {
parameters {
string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入')
string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位')
choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境')
choice(name: 'ENVIRONMENT', choices: ['test', 'development', 'staging', 'production'], description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
string(name: 'IMAGE_REPOSITORY', defaultValue: 'baiyue-firstleg-test', description: '镜像仓库基础路径,不包含服务名和标签')
string(name: 'IMAGE_REPOSITORY', defaultValue: '172.31.198.7:10082/java-project/baiyue-firstleg', description: '内网共享镜像仓库基础路径,不包含服务名和标签')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'docker.m.daocloud.io/library/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
}
environment {
REGISTRY_CREDENTIALS = 'gitea-test-token'
MAVEN_SETTINGS = 'deploy/maven/settings-cn.xml'
MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml'
MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3'
DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8'
DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy'
DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/application'
}
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
stage('构建并发布单服务') {
agent { label 'java-builder' }
tools { maven 'maven-3.8.8' }
stages {
stage('校验参数并检出指定提交') {
steps {
checkout scm
script {
String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}")
}
String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = params.IMAGE_REPOSITORY?.trim()
script {
String service = serviceModules.find { candidate ->
env.JOB_BASE_NAME?.endsWith("-${candidate}")
}
String sourceCommit = params.SOURCE_COMMIT?.trim()
String imageTag = params.IMAGE_TAG?.trim()
String imageRepository = ['development', 'test'].contains(params.ENVIRONMENT)
? "baiyue-firstleg-${params.ENVIRONMENT}"
: params.IMAGE_REPOSITORY?.trim()
if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
}
if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
if (!sourceCommit) {
sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
}
if (!imageTag) {
imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
}
if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
}
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
}
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含 http:// 或 https://')
}
if (params.ENVIRONMENT != 'test' && !imageRepository.contains('/')) {
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
if (!serviceModules.contains(service)) {
error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}")
}
if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) {
error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit')
}
if (!imageTag || !(imageTag ==~ /[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}/)) {
error('IMAGE_TAG 不是合法的 Docker 镜像标签')
}
if (!imageRepository || imageRepository.contains('://')) {
error('IMAGE_REPOSITORY 不能为空且不能包含协议前缀')
}
if (!['development', 'test'].contains(params.ENVIRONMENT) && !imageRepository.contains('/')) {
error('预发布/生产环境 IMAGE_REPOSITORY 必须使用 host[:port]/owner/image 格式')
}
env.RESOLVED_SERVICE = service
env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
env.RESOLVED_SERVICE = service
env.RESOLVED_SOURCE_COMMIT = sourceCommit
env.RESOLVED_IMAGE_TAG = imageTag
env.RESOLVED_IMAGE_REPOSITORY = imageRepository
}
sh '''
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then
git fetch --no-tags origin
fi
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
'''
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT} | ${env.NODE_NAME}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}"
}
}
}
sh '''
if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then
git fetch --no-tags origin
fi
git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}"
git checkout --detach "${RESOLVED_SOURCE_COMMIT}"
test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")"
'''
script {
currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${params.ENVIRONMENT}"
echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT}"
}
}
}
stage('Maven 测试与打包') {
steps {
script {
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
stage('Maven 测试与打包') {
steps {
script {
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
error('生产环境禁止跳过测试')
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
String testOption = skipTestExecution ? '-DskipTests' : ''
if (params.ENVIRONMENT == 'test') {
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
}
}
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/TEST-*.xml'
stage('构建并发布单服务镜像') {
steps {
script {
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}"
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
String jar = serviceJars[env.RESOLVED_SERVICE]
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
if (['development', 'test'].contains(params.ENVIRONMENT)) {
echo "${params.ENVIRONMENT} 镜像已保存在构建节点 ${env.NODE_NAME}: ${image}"
} else {
echo "推送共享镜像: ${image}"
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
try {
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
sh "docker push ${image}"
} finally {
sh "docker logout ${registryHost} || true"
}
}
if (params.DEPLOY) {
stash name: 'deployment-files', includes: 'deploy/**', useDefaultExcludes: true
}
}
}
}
}
}
}
stage('构建并发布单服务镜像') {
steps {
script {
String image = "${env.RESOLVED_IMAGE_REPOSITORY}/${env.RESOLVED_SERVICE}:${env.RESOLVED_IMAGE_TAG}"
String contextDir = ".docker-context/${env.RESOLVED_SERVICE}"
String jar = serviceJars[env.RESOLVED_SERVICE]
sh "mkdir -p '${contextDir}' && cp -f '${jar}' '${contextDir}/app.jar'"
sh "docker build -f deploy/docker/Dockerfile --build-arg BASE_IMAGE=${params.DOCKER_BASE_IMAGE} --build-arg JAR_FILE=app.jar -t ${image} '${contextDir}'"
stage('直接部署开发或测试环境') {
when {
expression { params.DEPLOY && ['development', 'test'].contains(params.ENVIRONMENT) }
}
steps {
script {
boolean development = params.ENVIRONMENT == 'development'
boolean targetIsLocal = development
? env.NODE_LABELS?.tokenize()?.contains('java-dev')
: env.NODE_NAME == 'master'
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
String remoteTarget = development ? 'deploy@172.31.198.8' : 'deploy@172.31.198.7'
String remoteKey = development
? '/var/jenkins_home/.ssh/dev_deploy'
: '/home/deploy/.ssh/test_deploy'
String remoteKnownHosts = development
? '/var/jenkins_home/.ssh/known_hosts'
: '/home/deploy/.ssh/known_hosts'
String remoteDir = development
? '/opt/firstleg/application'
: '/opt/firstleg/test-application'
if (params.ENVIRONMENT == 'test') {
echo "测试镜像已保存在当前 Docker 节点:${image}"
} else {
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
try {
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
sh "docker push ${image}"
} finally {
sh "docker logout ${registryHost} || true"
withEnv([
"TARGET_IS_LOCAL=${targetIsLocal}",
"TARGET_SSH_TARGET=${remoteTarget}",
"TARGET_SSH_KEY=${remoteKey}",
"TARGET_SSH_KNOWN_HOSTS=${remoteKnownHosts}",
"TARGET_DEPLOY_DIR=${remoteDir}"
]) {
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
sh "sh deploy/scripts/deploy-built-service.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
}
}
}
}
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
stage('部署单个服务') {
when { expression { params.DEPLOY } }
when {
beforeAgent true
expression { params.DEPLOY && !['development', 'test'].contains(params.ENVIRONMENT) }
}
agent {
label "${params.ENVIRONMENT == 'development' ? 'java-dev' : 'master'}"
}
steps {
deleteDir()
unstash 'deployment-files'
script {
String credentialId = "firstleg-${params.ENVIRONMENT}-env"
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
String registryHost = env.RESOLVED_IMAGE_REPOSITORY.tokenize('/')[0]
boolean localDevelopmentDeploy = params.ENVIRONMENT == 'development' &&
env.NODE_LABELS?.tokenize()?.contains('java-dev')
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
try {
sh 'printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "' + registryHost + '"'
withEnv(["DEVELOPMENT_DEPLOY_LOCAL=${localDevelopmentDeploy}"]) {
withCredentials([file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE')]) {
sh "sh deploy/scripts/deploy-services.sh '${params.ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\""
}
}
} finally {
sh "docker logout ${registryHost} || true"
}
}
}
}
post {
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
}
@@ -171,8 +250,5 @@ pipeline {
success {
echo "${env.RESOLVED_SERVICE} 已独立完成构建${params.DEPLOY ? '和部署' : ''}"
}
always {
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
@@ -1,8 +1,10 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
@@ -10,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/
@MapperScan("com.baiyue.audit.mapper")
@EnableScheduling
@Import(MybatisPlusConfiguration.class)
@SpringBootApplication
public class BaiyueAuditApplication {
@@ -24,6 +24,7 @@ public final class AuditLogResponses {
String username,
String appCode,
String clientType,
String clientIp,
String requestId,
String traceId,
String serviceName,
@@ -41,7 +41,7 @@ public class AuditLogQueryService {
return new AuditLogResponses.ListItem(
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getClientIp(), entity.getRequestId(),
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
}
@@ -0,0 +1,17 @@
package com.baiyue.audit;
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import static org.assertj.core.api.Assertions.assertThat;
class BaiyueAuditApplicationContractTest {
@Test
void importsSharedMybatisPaginationConfiguration() {
Import imported = BaiyueAuditApplication.class.getAnnotation(Import.class);
assertThat(imported).isNotNull();
assertThat(imported.value()).contains(MybatisPlusConfiguration.class);
}
}
@@ -10,6 +10,7 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.List;
import java.util.Set;
@@ -73,6 +74,18 @@ class PlatformAuditLogControllerContractTest {
verify(service).detail(8L);
}
@Test
void listAndDetailUseTheSamePlatformAuditPermission() throws NoSuchMethodException {
String expected = "hasAuthority('platform:audit:list')";
assertThat(PlatformAuditLogController.class.getMethod("page", AuthenticatedUser.class,
long.class, long.class, Long.class, Long.class, String.class, String.class, String.class,
Boolean.class, java.time.Instant.class, java.time.Instant.class, String.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
assertThat(PlatformAuditLogController.class.getMethod("detail", AuthenticatedUser.class, Long.class)
.getAnnotation(PreAuthorize.class).value()).isEqualTo(expected);
}
private static AuthenticatedUser user(String appCode) {
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
}
@@ -45,6 +45,19 @@ class AuditLogQueryServiceTest {
.doesNotContain("beforeData", "afterData");
}
@Test
void pagePreservesTotalAndPagesForCustomerLogPagination() {
AuditLogQuery query = new AuditLogQuery(10001L, null, null, "TENANT", null, null,
null, null, null);
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 13, 1));
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
assertThat(result.records()).hasSize(1);
assertThat(result.total()).isEqualTo(13);
assertThat(result.pages()).isEqualTo(1);
}
@Test
void detailReturnsSanitizedSnapshots() {
when(repository.findById(8L)).thenReturn(log(8L));
+5 -2
View File
@@ -4,6 +4,7 @@
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 模块入口
@@ -12,7 +13,9 @@
|---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
| 海外访问 Token 签发 | 登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience 并携带海外 API 所需权限快照;前端统一认证层负责按区域 API 自动选择 |
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
@@ -22,7 +25,7 @@
## 修改约束
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
@@ -11,6 +11,10 @@ public record TokenResponse(
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn,
@Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空",
format = "password", nullable = true) String overseasAccessToken,
@Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0",
example = "900", minimum = "0") long overseasExpiresIn
) {
}
}
@@ -23,6 +23,7 @@ import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.beans.factory.ObjectProvider;
import java.security.SecureRandom;
import java.time.Instant;
@@ -49,6 +50,8 @@ public class AuthService {
private AuthorizationLoader authorizationLoader;
@Resource
private TokenSessionStore tokenSessionStore;
@Resource
private ObjectProvider<OverseasTokenService> overseasTokens;
private final SecureRandom secureRandom = new SecureRandom();
@@ -134,8 +137,13 @@ public class AuthService {
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
properties.accessTokenTtl(), properties.refreshTokenTtl());
OverseasTokenService service = overseasTokens.getIfAvailable();
OverseasTokenService.OverseasTokenResponse overseas = service == null
? null : service.issue(account, authorization);
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
rawRefresh, properties.refreshTokenTtl().toSeconds());
rawRefresh, properties.refreshTokenTtl().toSeconds(),
overseas == null ? null : overseas.accessToken(),
overseas == null ? 0L : overseas.expiresIn());
}
@Transactional
@@ -36,16 +36,22 @@ public class OverseasTokenService {
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
}
AuthorizationSnapshot authorization = authorizationLoader.load(account);
return issue(account, authorization);
}
public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(properties.issuer()).audience(List.of(properties.audience()))
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
.issuedAt(issuedAt).expiresAt(expiresAt)
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
.claim("user_id", account.getId()).claim("tenant_id", account.getDefaultTenantId())
.claim("membership_id", authorization.getMembershipId())
.claim("app_code", account.getDefaultAppCode()).claim("client_type", authorization.getClientType())
.claim("roles", authorization.getRoles())
.claim("authorities", authorization.getPermissions())
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
.build();
String token = encoder.encode(JwtEncoderParameters.from(
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
@@ -34,6 +34,7 @@ class AuthServiceSmallJwtTest {
@MockBean private JwtEncoder jwtEncoder;
@MockBean private AuthorizationLoader authorizationLoader;
@MockBean private TokenSessionStore tokenSessionStore;
@MockBean private OverseasTokenService overseasTokenService;
@Resource private AuthService service;
@Configuration(proxyBeanMethods = false)
@@ -59,7 +60,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("PLATFORM");
account.setTokenVersion(1);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"), 1L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(repository.findByUsername("operator")).thenReturn(account);
@@ -67,8 +68,10 @@ class AuthServiceSmallJwtTest {
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("small-token");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900));
service.login(new LoginRequest("operator", "secret"));
var response = service.login(new LoginRequest("operator", "secret"));
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
verify(jwtEncoder).encode(jwt.capture());
@@ -88,6 +91,42 @@ class AuthServiceSmallJwtTest {
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
verify(overseasTokenService).issue(account, snapshot);
assertThat(response.overseasAccessToken()).isEqualTo("overseas-token");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
}
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
account.setId(52L);
account.setUsername("merchant");
account.setAccountStatus("ACTIVE");
account.setDefaultTenantId(10001L);
account.setOperatorTenantId(10001L);
account.setDefaultMembershipId(94L);
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(3);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(52L, 10001L, 94L, "MERCHANT",
"WEB", Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 3L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
when(tokenSessionStore.consume("refresh-old")).thenReturn(
new TokenSessionStore.RefreshSession(52L, "refresh-session"));
when(repository.findById(52L)).thenReturn(account);
when(authorizationLoader.load(account)).thenReturn(snapshot);
when(jwtEncoder.encode(any())).thenReturn(encoded);
when(encoded.getTokenValue()).thenReturn("domestic-refreshed");
when(overseasTokenService.issue(account, snapshot)).thenReturn(
new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900));
var response = service.refresh("refresh-old");
assertThat(response.accessToken()).isEqualTo("domestic-refreshed");
assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed");
assertThat(response.overseasExpiresIn()).isEqualTo(900);
verify(authorizationLoader).load(account);
verify(overseasTokenService).issue(account, snapshot);
}
@Test
@@ -102,7 +141,7 @@ class AuthServiceSmallJwtTest {
account.setDefaultAppCode("MERCHANT");
account.setTokenVersion(2);
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"), 2L);
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
@@ -64,11 +64,13 @@ class OverseasTokenServiceTest {
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
assertThat(parameters.getClaims().getClaims())
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
.containsEntry("user_id", 42L).containsEntry("tenant_id", 100L)
.containsEntry("membership_id", 84L)
.containsEntry("app_code", "MERCHANT").containsEntry("client_type", "WEB")
.containsEntry("permission_version", 7L)
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
.containsEntry("authorities", Set.of("merchant:order:list"))
.doesNotContainKeys("user_id", "tenant_id", "app_code");
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
assertThat(response.expiresIn()).isEqualTo(900);
}
}
@@ -48,7 +48,26 @@ class BusinessAuditAspectTest {
assertThat(event.operatorTenantId()).isEqualTo(10001L);
}
@Test
void failedOperationStillPublishesSanitizedBeforeSnapshot() throws Throwable {
AuditEvent event = invokeFailure(new AuthenticatedUser(7L, 0L, 0L, 1L,
"platform.admin", "PLATFORM", "WEB", Set.of()), 10001L);
assertThat(event.success()).isFalse();
assertThat(event.errorCode()).isEqualTo("IllegalStateException");
assertThat(event.beforeData()).isEqualTo("[10001]");
assertThat(event.afterData()).isNull();
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, null);
}
private static AuditEvent invokeFailure(AuthenticatedUser user, long requestedTenantId) throws Throwable {
return invoke(user, requestedTenantId, new IllegalStateException("validation failed"));
}
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId, Throwable failure) throws Throwable {
AuditSink sink = mock(AuditSink.class);
BusinessAuditAspect aspect = new BusinessAuditAspect();
ReflectionTestUtils.setField(aspect, "sink", sink);
@@ -68,9 +87,14 @@ class BusinessAuditAspectTest {
when(joinPoint.getSignature()).thenReturn(signature);
when(joinPoint.getTarget()).thenReturn(target);
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
when(joinPoint.proceed()).thenReturn("ok");
if (failure == null) when(joinPoint.proceed()).thenReturn("ok");
else when(joinPoint.proceed()).thenThrow(failure);
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
try {
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
} catch (Throwable error) {
if (failure == null) throw error;
}
ArgumentCaptor<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
verify(sink).publish(event.capture());
@@ -21,4 +21,18 @@ class SensitiveDataSanitizerTest {
assertThat(parsed.path("_truncated").asBoolean()).isTrue();
assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40);
}
@Test
void redactsIdentityAndContactAliasesInsideNestedObjects() throws Exception {
SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 1024);
String sanitized = sanitizer.sanitize(Map.of(
"profile", Map.of("idCard", "4401...", "contactPhone", "13800138000"),
"contacts", java.util.List.of(Map.of("emailAddress", "user@example.com"))));
JsonNode parsed = mapper.readTree(sanitized);
assertThat(parsed.at("/profile/idCard").asText()).isEqualTo("******");
assertThat(parsed.at("/profile/contactPhone").asText()).isEqualTo("******");
assertThat(parsed.at("/contacts/0/emailAddress").asText()).isEqualTo("******");
}
}
+1 -1
View File
@@ -18,7 +18,7 @@
## 修改与验证
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 新能力只有被多个模块稳定复用时才放入本模块。
+5 -4
View File
@@ -14,12 +14,13 @@
| 事件 ID 与业务键 | `EventIdentifiers.java` |
| Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` |
| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` |
| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` |
| 契约说明 | `package-info.java` |
| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` |
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` |
## 修改与验证
- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。
- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。
- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。
- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。
- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。
- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。
- 从父目录运行 `mvn -pl common-event-contract -am test`。
@@ -0,0 +1,79 @@
package com.baiyue.common.event;
import java.math.BigDecimal;
import java.util.List;
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
public record FirstLegLoadingPlanCompletedV2(
String loadingPlanId, String planNo, String containerBookingId,
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
List<Order> orders) {
public record Order(
String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
public Order {
items = items == null ? List.of() : items;
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
pallets = pallets == null ? List.of() : pallets;
}
public Order(String sourceInboundId, String rootInboundId,
String sourceOrderId, String sourceOrderNo, String rootOrderId,
String inboundCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
List<Item> items) {
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
inboundCode, destinationWarehouseId, destinationWarehouseCode,
destinationWarehouseName, packageCount, productQuantity, palletCount,
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
}
}
public record Item(
String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark,
String palletNo, List<String> cartonMarks) {
public Item {
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
}
public Item(String sourceInboundDetailId, String skuCode,
String productNameZh, String productNameEn,
int packageCount, Integer productQuantity, Integer palletCount,
BigDecimal grossWeightKg, BigDecimal netWeightKg,
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
String destinationWarehouseId, String destinationWarehouseCode,
String destinationWarehouseName, String fbaTrackingNo,
String shipmentNo, String shippingMark,
String customerRemark, String internalRemark, String warehouseRemark) {
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
productQuantity, palletCount, grossWeightKg, netWeightKg,
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
shipmentNo, shippingMark, customerRemark, internalRemark, warehouseRemark,
null, List.of());
}
}
/** 一键代发入仓单的托盘冻结快照。 */
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
public record SkuPackageSnapshot(String skuCode, String productName,
int packageCount, int productQuantity,
List<String> cartonMarks) {}
}
@@ -0,0 +1,42 @@
package com.baiyue.common.event;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FirstLegLoadingPlanCompletedV2Test {
@Test
void carriesTheThreeItemRemarks() throws Exception {
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
2, null, null, null, null, null, null, null, null, null, null, null,
"客户备注", "内部备注", "仓库备注");
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
null, null, null, 1, 2, null, null, null, null, List.of(item));
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
String json = new ObjectMapper().writeValueAsString(payload);
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
.isEqualTo("仓库备注");
}
@Test
void carriesDropshipPalletAndCartonSnapshots() throws Exception {
var sku = new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(
"SKU", "产品", 2, 20, List.of("BOX-001", "BOX-002"));
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
var order = new FirstLegLoadingPlanCompletedV2.Order(
"2", "2", "3", "ORD", "3", "RK", null, null, null,
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
var payload = new FirstLegLoadingPlanCompletedV2(
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
var json = new ObjectMapper().valueToTree(payload);
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
}
}
+1 -1
View File
@@ -18,6 +18,6 @@
- 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。
- 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。
- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。
- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。
- 从父目录运行 `mvn -pl common-openapi -am test`。
- 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。
+5 -3
View File
@@ -9,17 +9,19 @@
| 功能 | 入口 |
|---|---|
| 自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java`、`HmacJwtDecoderAutoConfiguration.java` |
| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` |
| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 |
| JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` |
| 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` |
| Redis 会话校验 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`、`RedisTokenKeys.java` |
| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` |
| 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` |
| 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` |
| 安全回归测试 | `src/test/java/com/baiyue/common/security/` |
## 修改与验证
- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。
- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。
- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。
- `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。
- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。
- 从父目录运行 `mvn -pl common-security -am test`。
+1 -1
View File
@@ -22,6 +22,6 @@
- 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。
- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。
- 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。
- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。
- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。
- 从父目录运行 `mvn -pl common-web -am test`。
- 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
+83
View File
@@ -21,3 +21,86 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
+1
View File
@@ -4,3 +4,4 @@ spring:
username: ${FINANCE_DB_USERNAME}
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+4
View File
@@ -8,3 +8,7 @@ spring:
baiyue.clients:
# 容器环境默认通过 Nacos 的 warehouse-service 实例调用;仅端口回归时显式设置直连 URL。
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
+83
View File
@@ -17,4 +17,87 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:}
baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:}
+1
View File
@@ -4,3 +4,4 @@ spring:
url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${FINANCE_DB_USERNAME:root}
password: ${FINANCE_DB_PASSWORD:123456}
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
+4
View File
@@ -5,3 +5,7 @@ spring.datasource:
password: ${ORDER_DB_PASSWORD:123456}
baiyue.clients:
warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.order:
# 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
+5
View File
@@ -0,0 +1,5 @@
# 海外 Nacos 配置
将本目录文件按原文件名作为 Data ID 发布到 `prod-overseas` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `overseas` Profile。
海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。
+6
View File
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${AUDIT_DB_URL}
username: ${AUDIT_DB_USERNAME}
password: ${AUDIT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+62
View File
@@ -0,0 +1,62 @@
baiyue:
deployment:
region-code: ${REGION_CODE:OVERSEAS}
cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
security:
issuer: ${JWT_ISSUER:https://auth.example.com}
audience: ${JWT_AUDIENCE:lastleg-api}
jwk-set-uri: ${JWT_JWK_SET_URI:https://auth.example.com/.well-known/jwks.json}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot}
openapi.enabled: ${SWAGGER_ENABLED:false}
audit.enabled: true
spring:
jackson:
default-property-inclusion: non_null
deserialization:
fail-on-unknown-properties: false
serialization:
write-dates-as-timestamps: false
data:
redis:
host: ${REDIS_HOST:redis-overseas.internal}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: ${REDIS_DATABASE:0}
timeout: ${REDIS_TIMEOUT:3s}
cloud:
nacos:
discovery:
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:prod-overseas}
openfeign:
circuitbreaker.enabled: true
client.config.default:
connect-timeout: 3000
read-timeout: 5000
server:
shutdown: graceful
management:
endpoints:
web:
exposure:
include: health,info,prometheus
endpoint:
health:
probes.enabled: true
show-details: never
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
call-setters-on-nulls: true
global-config:
banner: false
db-config:
id-type: assign_id
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -0,0 +1,20 @@
spring:
datasource:
url: ${EVENT_BRIDGE_DB_URL}
username: ${EVENT_BRIDGE_DB_USERNAME}
password: ${EVENT_BRIDGE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue:
region: ${REGION_CODE:OVERSEAS}
deployment-cell: ${DEPLOYMENT_CELL:prod-overseas}
data-residency: ${DATA_RESIDENCY:OVERSEAS}
event-dispatcher:
consumer-url: ${LASTLEG_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
+13
View File
@@ -0,0 +1,13 @@
spring:
datasource:
url: ${FILE_DB_URL}
username: ${FILE_DB_USERNAME}
password: ${FILE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
file:
storage:
provider: ${FILE_STORAGE_PROVIDER:s3}
endpoint: ${FILE_STORAGE_ENDPOINT:}
bucket: ${FILE_STORAGE_BUCKET:}
access-key: ${FILE_STORAGE_ACCESS_KEY:}
secret-key: ${FILE_STORAGE_SECRET_KEY:}
@@ -0,0 +1,3 @@
springdoc:
api-docs.enabled: ${SWAGGER_ENABLED:false}
swagger-ui.enabled: ${SWAGGER_ENABLED:false}
@@ -0,0 +1,12 @@
baiyue:
integration:
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
amazon-sp-api:
enabled: ${AMAZON_SP_API_ENABLED:true}
client-id: ${AMAZON_SP_API_CLIENT_ID:}
client-secret: ${AMAZON_SP_API_CLIENT_SECRET:}
refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:}
aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:}
aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:}
region: ${AMAZON_SP_API_REGION:}
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${LASTLEG_DB_URL}
username: ${LASTLEG_DB_USERNAME}
password: ${LASTLEG_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+6
View File
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${PRINT_DB_URL}
username: ${PRINT_DB_USERNAME}
password: ${PRINT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
@@ -0,0 +1,6 @@
spring:
datasource:
url: ${WAREHOUSE_DB_URL}
username: ${WAREHOUSE_DB_USERNAME}
password: ${WAREHOUSE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0 https://maven.apache.org/xsd/settings-1.2.0.xsd">
<mirrors>
<mirror>
<id>aliyun-public</id>
<name>阿里云 Maven 公共仓库</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
<profiles>
<profile>
<id>国内-CI-仓库</id>
<repositories>
<repository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central</id>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>国内-CI-仓库</activeProfile>
</activeProfiles>
</settings>
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 5 ]; then
echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件>" >&2
exit 2
fi
DEPLOY_ENV="$1"
IMAGE_REPOSITORY="$2"
IMAGE_TAG="$3"
SERVICE="$4"
ENV_FILE="$5"
COMPOSE_FILE="deploy/docker-compose.yml"
IMAGE="$IMAGE_REPOSITORY/$SERVICE:$IMAGE_TAG"
PROJECT_NAME="firstleg-$DEPLOY_ENV"
case "$DEPLOY_ENV" in
development|test) ;;
*)
echo "直接镜像传输仅支持 development/test,当前: $DEPLOY_ENV" >&2
exit 2
;;
esac
case "$SERVICE" in
*[!A-Za-z0-9_-]*|'')
echo "非法服务名: $SERVICE" >&2
exit 2
;;
esac
for file in "$ENV_FILE" "$COMPOSE_FILE"; do
if [ ! -f "$file" ]; then
echo "必需文件不存在: $file" >&2
exit 2
fi
done
env_file_has_key() {
grep -q "^$1=" "$ENV_FILE"
}
env_file_value() {
sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'
}
if [ "$SERVICE" = "finance" ]; then
for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do
if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then
echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2
exit 2
fi
done
fi
if [ "$SERVICE" = "file" ]; then
if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then
echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2
exit 2
fi
if ! env_file_has_key MQ_TOPIC_PREFIX; then
echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX" >&2
exit 2
fi
MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX)
if [ "$DEPLOY_ENV" = "test" ] && [ "$MQ_PREFIX" != "test-" ]; then
echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2
exit 2
fi
if [ "$DEPLOY_ENV" = "development" ] && { [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; }; then
echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2
exit 2
fi
fi
docker image inspect "$IMAGE" >/dev/null
deploy_local() {
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" \
up -d --remove-orphans --pull never "$SERVICE"
docker compose \
--project-name "$PROJECT_NAME" \
--env-file "$ENV_FILE" \
-f "$COMPOSE_FILE" \
ps "$SERVICE"
}
if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then
echo "镜像已在目标服务器本机,直接部署: $IMAGE"
deploy_local
exit 0
fi
SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}"
SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}"
SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}"
REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}"
for file in "$SSH_KEY" "$SSH_KNOWN_HOSTS"; do
if [ ! -f "$file" ]; then
echo "SSH 文件不存在: $file" >&2
exit 2
fi
done
run_ssh() {
ssh -i "$SSH_KEY" \
-o BatchMode=yes \
-o Compression=no \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$SSH_TARGET" "$@"
}
run_scp() {
scp -i "$SSH_KEY" \
-o BatchMode=yes \
-o Compression=no \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$@"
}
run_ssh install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE}"
REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp"
REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp"
run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP"
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP"
run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE'
set -eu
remote_dir="$1"
env_tmp="$2"
compose_tmp="$3"
chmod 600 "$env_tmp"
chmod 644 "$compose_tmp"
mv -f "$env_tmp" "$remote_dir/.env"
mv -f "$compose_tmp" "$remote_dir/docker-compose.yml"
REMOTE_PREPARE
echo "传输镜像到 $SSH_TARGET: $IMAGE"
docker save "$IMAGE" | gzip -1 | run_ssh 'gzip -dc | docker load'
run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" <<'REMOTE_DEPLOY'
set -eu
remote_dir="$1"
deploy_env="$2"
image_repository="$3"
image_tag="$4"
service="$5"
cd "$remote_dir"
export DEPLOY_ENV="$deploy_env"
export IMAGE_REPOSITORY="$image_repository"
export IMAGE_TAG="$image_tag"
docker compose \
--project-name "firstleg-$deploy_env" \
--env-file .env \
-f docker-compose.yml \
up -d --remove-orphans --pull never "$service"
docker compose \
--project-name "firstleg-$deploy_env" \
--env-file .env \
-f docker-compose.yml \
ps "$service"
REMOTE_DEPLOY
@@ -0,0 +1,156 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -ne 4 ]; then
echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2
exit 2
fi
IMAGE_REPOSITORY="$1"
IMAGE_TAG="$2"
SERVICE_CSV="$3"
ENV_FILE="$4"
SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}"
SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}"
SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}"
REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}"
COMPOSE_FILE="deploy/docker-compose.yml"
if [ ! -f "$SSH_KEY" ]; then
echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2
exit 2
fi
if [ ! -f "$SSH_KNOWN_HOSTS" ]; then
echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2
exit 2
fi
if [ ! -f "$ENV_FILE" ]; then
echo "开发环境变量文件不存在: $ENV_FILE" >&2
exit 2
fi
if [ ! -f "$COMPOSE_FILE" ]; then
echo "Compose 文件不存在: $COMPOSE_FILE" >&2
exit 2
fi
SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ')
for service in $SERVICES; do
case "$service" in
*[!A-Za-z0-9_-]*|'')
echo "非法服务名: $service" >&2
exit 2
;;
esac
done
if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then
echo "开发镜像已在开发节点本机构建,直接部署: $SERVICE_CSV"
install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}"
LOCAL_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp"
LOCAL_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp"
install -m 600 "$ENV_FILE" "$LOCAL_ENV_TMP"
install -m 644 "$COMPOSE_FILE" "$LOCAL_COMPOSE_TMP"
mv -f "$LOCAL_ENV_TMP" "$REMOTE_DIR/.env"
mv -f "$LOCAL_COMPOSE_TMP" "$REMOTE_DIR/docker-compose.yml"
for service in $SERVICES; do
docker image inspect "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" >/dev/null
done
cd "$REMOTE_DIR"
export DEPLOY_ENV=development
export IMAGE_REPOSITORY IMAGE_TAG
docker compose \
--project-name firstleg-development \
--env-file .env \
-f docker-compose.yml \
pull $SERVICES
docker compose \
--project-name firstleg-development \
--env-file .env \
-f docker-compose.yml \
up -d --remove-orphans --pull never $SERVICES
docker compose \
--project-name firstleg-development \
--env-file .env \
-f docker-compose.yml \
ps $SERVICES
exit 0
fi
run_ssh() {
ssh -i "$SSH_KEY" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$SSH_TARGET" "$@"
}
run_scp() {
scp -i "$SSH_KEY" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \
-o ConnectTimeout=10 \
"$@"
}
run_ssh install -d -m 750 "$REMOTE_DIR"
SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}"
REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp"
REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp"
run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP"
run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP"
run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE'
set -eu
remote_dir="$1"
env_tmp="$2"
compose_tmp="$3"
chmod 600 "$env_tmp"
chmod 644 "$compose_tmp"
mv -f "$env_tmp" "$remote_dir/.env"
mv -f "$compose_tmp" "$remote_dir/docker-compose.yml"
REMOTE_PREPARE
for service in $SERVICES; do
image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG"
docker image inspect "$image" >/dev/null
echo "传输开发镜像: $image"
docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load'
done
run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" <<'REMOTE_DEPLOY'
set -eu
remote_dir="$1"
image_repository="$2"
image_tag="$3"
service_csv="$4"
services=$(printf '%s' "$service_csv" | tr ',' ' ')
cd "$remote_dir"
export DEPLOY_ENV=development
export IMAGE_REPOSITORY="$image_repository"
export IMAGE_TAG="$image_tag"
docker compose \
--project-name firstleg-development \
--env-file .env \
-f docker-compose.yml \
up -d --remove-orphans --pull never $services
docker compose \
--project-name firstleg-development \
--env-file .env \
-f docker-compose.yml \
ps $services
REMOTE_DEPLOY
+10 -10
View File
@@ -74,22 +74,22 @@ esac
export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG
PULL_POLICY_ARGS=""
if [ "$DEPLOY_ENV" = "test" ]; then
PULL_POLICY_ARGS="--pull never"
else
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
--env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \
pull $SERVICES
if [ "$DEPLOY_ENV" = "development" ]; then
exec sh deploy/scripts/deploy-development-service.sh \
"$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE"
fi
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
--env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \
up -d --remove-orphans $PULL_POLICY_ARGS $SERVICES
pull $SERVICES
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
--env-file "$ENV_FILE" \
-f deploy/docker-compose.yml \
up -d --remove-orphans --pull never $SERVICES
docker compose \
--project-name "firstleg-${DEPLOY_ENV}" \
File diff suppressed because it is too large Load Diff
@@ -155,6 +155,8 @@ Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三
Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。
头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。
## 6. 配置、部署和辅助目录
### `config/`
+20 -19
View File
@@ -2,31 +2,32 @@
## 职责边界
- 负责跨区域事件接收、内部令牌保护、契约校验和 Inbox 幂等持久化。
- 事件模型来自 `common-event-contract/`;本服务不共享国内/国外业务数据库,也不承载头程或尾程领域逻辑。
- 真实跨区发送、重试调度和死信补投仍需按需求继续完善,不把当前接收骨架描述为完整消息平台。
- 负责跨区事件接收、Inbox 持久化、向海外尾程投递、失败重试、投递记录和人工补投。
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
## 快速索引
| 功能 | 入口 |
|---|---|
| 应用入口 | `src/main/java/com/baiyue/eventbridge/EventBridgeApplication.java` |
| 事件接收与状态 API | `controller/EventBridgeController.java`(`/internal/event-bridge`) |
| 接收结果与状态 DTO | `dto/EventReceiveResult.java`、`BridgeStatus.java` |
| 校验与 Inbox 编排 | `service/EventInboxService.java` |
| Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` |
| Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` |
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
| Inbox 接收与幂等 | `service/EventInboxService.java` |
| 海外调度与重试 | `service/OverseasEventDispatcher.java` |
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
| 人工补投 | `service/EventReplayService.java` |
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 表结构 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` |
| 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 |
| 数据库 | `src/main/resources/db/event-bridge.sql` |
| 测试 | `src/test/java/com/baiyue/eventbridge/` |
## 可靠投递规则
- `RECEIVED` 和到期的 `RETRY` 都必须可被调度;只有尾程确认成功后才能标记 `PROCESSED`。
- 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。
- 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。
- 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。
## 修改与验证
- 先使用 `common-event-contract` 校验事件 ID、区域、版本、业务键、时间与 Payload 哈希,再执行幂等入库。
- 重复事件应返回稳定结果且不重复产生副作用;数据库唯一约束与应用幂等逻辑要同步。
- 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。
- Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。
- 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。
- 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。
- 从父目录运行 `mvn -pl event-bridge-service -am test`。
- 定向验证:`mvn -pl event-bridge-service -am test`。
- 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。
@@ -1,14 +1,19 @@
package com.baiyue.eventbridge;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 跨区域事件桥启动入口。
*/
@MapperScan("com.baiyue.eventbridge.mapper")
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(EventDispatcherProperties.class)
public class EventBridgeApplication {
public static void main(String[] args) {
SpringApplication.run(EventBridgeApplication.class, args);
@@ -0,0 +1,34 @@
package com.baiyue.eventbridge.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.net.URI;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.event-dispatcher")
public record EventDispatcherProperties(
URI consumerUrl,
int batchSize,
int maxAttempts,
Duration leaseDuration,
Duration initialBackoff,
Duration maxBackoff,
Duration connectTimeout,
Duration requestTimeout) {
public EventDispatcherProperties {
consumerUrl = consumerUrl == null
? URI.create("http://127.0.0.1:9303/internal/lastleg/events") : consumerUrl;
batchSize = batchSize <= 0 ? 50 : batchSize;
maxAttempts = maxAttempts <= 0 ? 10 : maxAttempts;
leaseDuration = positiveOrDefault(leaseDuration, Duration.ofSeconds(30));
initialBackoff = positiveOrDefault(initialBackoff, Duration.ofSeconds(5));
maxBackoff = positiveOrDefault(maxBackoff, Duration.ofMinutes(15));
connectTimeout = positiveOrDefault(connectTimeout, Duration.ofSeconds(3));
requestTimeout = positiveOrDefault(requestTimeout, Duration.ofSeconds(10));
}
private static Duration positiveOrDefault(Duration value, Duration fallback) {
return value == null || value.isNegative() || value.isZero() ? fallback : value;
}
}
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.eventbridge.dto.BridgeStatus;
import com.baiyue.eventbridge.dto.EventReceiveResult;
import com.baiyue.eventbridge.dto.EventReplayRequest;
import com.baiyue.eventbridge.service.EventInboxService;
import com.baiyue.eventbridge.service.EventReplayService;
import com.baiyue.eventbridge.security.InternalServiceTokenGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
/**
* 事件桥内部接口。生产环境必须在网关或基础设施层增加 mTLS 与来源白名单。
@@ -26,13 +29,11 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/internal/event-bridge")
public class EventBridgeController {
@Resource
private EventInboxService eventInboxService;
@Resource private EventInboxService eventInboxService;
@Resource private EventReplayService eventReplayService;
@Resource private InternalServiceTokenGuard internalServiceTokenGuard;
@Resource
private InternalServiceTokenGuard internalServiceTokenGuard;
@Value("${baiyue.region:CN}")
@Value("${baiyue.region:OVERSEAS}")
private String region;
@Operation(summary = "幂等接收事件", description = "当前仅完成数据库接收骨架,不代表生产跨区传输和 mTLS 已开通")
@@ -50,6 +51,18 @@ public class EventBridgeController {
return ApiResponse.success(eventInboxService.receive(envelope));
}
@Operation(summary = "人工补投死信事件")
@PostMapping("/events/{eventId}/replay")
public ApiResponse<Void> replay(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") String operatorId,
@PathVariable String eventId,
@RequestBody EventReplayRequest request) {
internalServiceTokenGuard.verify(token);
eventReplayService.replayDead(eventId, operatorId, request.reason());
return ApiResponse.success(null);
}
@Operation(summary = "查询事件桥能力状态")
@ApiResponses(@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"))
@GetMapping("/status")
@@ -0,0 +1,24 @@
package com.baiyue.eventbridge.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("bridge_delivery_attempt")
public class BridgeDeliveryAttempt {
@TableId(type = IdType.AUTO)
private Long id;
private String eventId;
private String sourceRegion;
private String targetRegion;
private Integer attemptNo;
private String status;
private String responseCode;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
}
@@ -20,7 +20,7 @@ public class BridgeInbox {
private Integer eventVersion;
private String sourceRegion;
private String targetRegion;
private String tenantId;
private String customerId;
private String aggregateType;
private String aggregateId;
private Long aggregateVersion;
@@ -32,5 +32,11 @@ public class BridgeInbox {
private String payloadHash;
private String payloadJson;
private String status;
private String leaseOwner;
private LocalDateTime leaseUntil;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private String lastError;
private LocalDateTime receivedAt;
private LocalDateTime processedAt;
}
@@ -0,0 +1,4 @@
package com.baiyue.eventbridge.dto;
public record EventReplayRequest(String reason) {
}
@@ -0,0 +1,9 @@
package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BridgeDeliveryAttemptMapper extends BaseMapper<BridgeDeliveryAttempt> {
}
@@ -3,10 +3,62 @@ package com.baiyue.eventbridge.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.eventbridge.domain.BridgeInbox;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 入站事件持久化映射。
*/
@Mapper
public interface BridgeInboxMapper extends BaseMapper<BridgeInbox> {
@Select("""
SELECT event_id FROM bridge_inbox
WHERE target_region = 'OVERSEAS'
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status = 'PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
ORDER BY received_at LIMIT #{limit}
""")
List<String> dispatchCandidates(@Param("limit") int limit);
@Update("""
UPDATE bridge_inbox SET status='PROCESSING', lease_owner=#{owner},
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{leaseSeconds} SECOND)
WHERE event_id=#{eventId} AND target_region='OVERSEAS'
AND ((status IN ('RECEIVED','RETRY') AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP(3)))
OR (status='PROCESSING' AND lease_until < CURRENT_TIMESTAMP(3)))
""")
int claim(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("leaseSeconds") long leaseSeconds);
@Update("""
UPDATE bridge_inbox SET status='PROCESSED', processed_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, next_retry_at=NULL, last_error=NULL
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markProcessed(@Param("eventId") String eventId, @Param("owner") String owner);
@Update("""
UPDATE bridge_inbox SET retry_count=retry_count+1,
status=CASE WHEN retry_count+1 >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRY' END,
next_retry_at=CASE WHEN retry_count+1 >= #{maxAttempts} THEN NULL
ELSE DATE_ADD(CURRENT_TIMESTAMP(3), INTERVAL #{backoffSeconds} SECOND) END,
lease_owner=NULL, lease_until=NULL, last_error=#{error}
WHERE event_id=#{eventId} AND status='PROCESSING' AND lease_owner=#{owner}
""")
int markFailed(@Param("eventId") String eventId, @Param("owner") String owner,
@Param("maxAttempts") int maxAttempts,
@Param("backoffSeconds") long backoffSeconds, @Param("error") String error);
@Select("SELECT COALESCE(MAX(attempt_no), 0) + 1 FROM bridge_delivery_attempt WHERE event_id=#{eventId}")
int nextAttemptNo(@Param("eventId") String eventId);
@Update("""
UPDATE bridge_inbox SET status='RETRY', retry_count=0, next_retry_at=CURRENT_TIMESTAMP(3),
lease_owner=NULL, lease_until=NULL, last_error=#{reason}
WHERE event_id=#{eventId} AND status='DEAD'
""")
int replayDead(@Param("eventId") String eventId, @Param("reason") String reason);
}
@@ -13,8 +13,11 @@ import java.security.MessageDigest;
*/
@Component
public class InternalServiceTokenGuard {
@Value("${baiyue.security.internal-service-token:}")
private String expectedToken;
private final String expectedToken;
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token:}") String expectedToken) {
this.expectedToken = expectedToken;
}
public void verify(String actualToken) {
if (expectedToken == null || expectedToken.isBlank()) {
@@ -1,7 +1,5 @@
package com.baiyue.eventbridge.service;
import jakarta.annotation.Resource;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -13,21 +11,20 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.Objects;
/**
* 入站事件校验与幂等落库服务。
*/
@Service
public class EventInboxService {
@Resource
private InboxStore inboxStore;
@Resource
private ObjectMapper objectMapper;
@Resource private InboxStore inboxStore;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.region:CN}")
private String localRegion;
private String localRegion;
@Transactional
public EventReceiveResult receive(EventEnvelope envelope) {
@@ -61,24 +58,9 @@ public class EventInboxService {
if (!existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) {
throw new IllegalArgumentException("相同 eventId 对应的 payloadHash 不一致,拒绝覆盖既有事件");
}
if (!hasSameEnvelopeIdentity(existing, envelope)) {
throw new IllegalArgumentException("相同 eventId 对应的客户或事件信封身份不一致,拒绝覆盖既有事件");
}
return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt());
}
private boolean hasSameEnvelopeIdentity(BridgeInbox existing, EventEnvelope envelope) {
return Objects.equals(existing.getTenantId(), envelope.tenantId())
&& Objects.equals(existing.getEventType(), envelope.eventType())
&& Objects.equals(existing.getEventVersion(), envelope.eventVersion())
&& Objects.equals(existing.getSourceRegion(), envelope.sourceRegion().name())
&& Objects.equals(existing.getTargetRegion(), envelope.targetRegion().name())
&& Objects.equals(existing.getAggregateType(), envelope.aggregateType())
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion());
}
private BridgeInbox toInbox(EventEnvelope envelope) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId());
@@ -87,7 +69,7 @@ public class EventInboxService {
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setTenantId(envelope.tenantId());
inbox.setCustomerId(envelope.tenantId());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
@@ -0,0 +1,27 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@Service
public class EventReplayService {
@Resource private BridgeInboxMapper inboxMapper;
public void replayDead(String eventId, String operatorId, String reason) {
if (operatorId == null || operatorId.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供操作人");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("人工补投必须提供原因");
}
String auditReason = abbreviate("manual replay by " + operatorId + ": " + reason, 1000);
if (inboxMapper.replayDead(eventId, auditReason) != 1) {
throw new IllegalStateException("事件不存在或未处于 DEAD 状态");
}
}
private static String abbreviate(String value, int maxLength) {
return value.length() <= maxLength ? value : value.substring(0, maxLength);
}
}
@@ -0,0 +1,55 @@
package com.baiyue.eventbridge.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@Component
public class HttpLastlegEventClient implements LastlegEventClient {
@Resource private ObjectMapper objectMapper;
@Resource private EventDispatcherProperties properties;
@Value("${baiyue.security.internal-service-token:}")
private String serviceToken;
private HttpClient httpClient;
@PostConstruct
void initializeHttpClient() {
this.httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build();
}
@Override
public String deliver(BridgeInbox row) throws Exception {
EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(),
row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()),
row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(),
row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(),
objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash());
String body = objectMapper.writeValueAsString(envelope);
HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl())
.timeout(properties.requestTimeout())
.header("Content-Type", "application/json")
.header("X-Internal-Service-Token", serviceToken)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody = objectMapper.readTree(response.body());
if (response.statusCode() / 100 != 2 || responseBody == null
|| !"200".equals(responseBody.path("code").asText())) {
throw new IllegalStateException("尾程消费失败: HTTP " + response.statusCode());
}
return String.valueOf(response.statusCode());
}
}
@@ -0,0 +1,7 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
public interface LastlegEventClient {
String deliver(BridgeInbox event) throws Exception;
}
@@ -2,16 +2,15 @@ package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import lombok.RequiredArgsConstructor;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/**
* 基于 MyBatis-Plus 的入站幂等存储。
*/
@Repository
@RequiredArgsConstructor
public class MybatisInboxStore implements InboxStore {
private final BridgeInboxMapper mapper;
@Resource private BridgeInboxMapper mapper;
@Override
public BridgeInbox findByEventId(String eventId) {
@@ -0,0 +1,88 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.lang.management.ManagementFactory;
import java.time.LocalDateTime;
import java.util.UUID;
@Component
@Slf4j
public class OverseasEventDispatcher {
@Resource private BridgeInboxMapper inboxMapper;
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
@Resource private LastlegEventClient eventClient;
@Resource private EventDispatcherProperties properties;
private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID();
@Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}")
public void dispatch() {
for (String eventId : inboxMapper.dispatchCandidates(properties.batchSize())) {
dispatchOne(eventId);
}
}
void dispatchOne(String eventId) {
if (inboxMapper.claim(eventId, leaseOwner, properties.leaseDuration().toSeconds()) != 1) {
return;
}
BridgeInbox event = inboxMapper.selectById(eventId);
int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1;
int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId);
BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo);
try {
String responseCode = eventClient.deliver(event);
if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) {
throw new IllegalStateException("事件租约已失效,无法确认投递结果");
}
finishAttempt(attempt, "DELIVERED", responseCode, null);
} catch (Exception exception) {
String error = abbreviate(exception.getMessage(), 1000);
inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error);
finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error);
log.warn("尾程事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}",
eventId, retryAttempt, deliveryAttemptNo, error);
}
}
private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) {
BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt();
attempt.setEventId(event.getEventId());
attempt.setSourceRegion(event.getSourceRegion());
attempt.setTargetRegion(event.getTargetRegion());
attempt.setAttemptNo(attemptNo);
attempt.setStatus("PROCESSING");
attempt.setStartedAt(LocalDateTime.now());
attemptMapper.insert(attempt);
return attempt;
}
private void finishAttempt(BridgeDeliveryAttempt attempt, String status, String responseCode, String error) {
attempt.setStatus(status);
attempt.setResponseCode(responseCode);
attempt.setErrorMessage(error);
attempt.setFinishedAt(LocalDateTime.now());
attemptMapper.updateById(attempt);
}
private long backoffSeconds(int attemptNo) {
long initial = properties.initialBackoff().toSeconds();
int shift = Math.min(Math.max(attemptNo - 1, 0), 20);
long delay = initial > Long.MAX_VALUE >> shift ? Long.MAX_VALUE : initial << shift;
return Math.min(delay, properties.maxBackoff().toSeconds());
}
private static String abbreviate(String value, int maxLength) {
String safe = value == null || value.isBlank() ? "未知投递错误" : value;
return safe.length() <= maxLength ? safe : safe.substring(0, maxLength);
}
}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${EVENT_BRIDGE_DB_USERNAME:root}
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${EVENT_BRIDGE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${EVENT_BRIDGE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
@@ -1,18 +1,25 @@
server:
port: ${EVENT_BRIDGE_PORT:9300}
port: ${EVENT_BRIDGE_PORT:9312}
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
data:
redis:
host: ${REDIS_HOST:127.0.0.1}
port: ${REDIS_PORT:6380}
application:
name: event-bridge-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true}
username: ${EVENT_BRIDGE_DB_USERNAME:root}
password: ${EVENT_BRIDGE_DB_PASSWORD:123456}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8858}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
@@ -22,7 +29,17 @@ spring:
namespace: ${NACOS_NAMESPACE:}
baiyue:
region: ${REGION_CODE:CN}
region: ${REGION_CODE:OVERSEAS}
event-dispatcher:
consumer-url: ${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10}
lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s}
initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s}
max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m}
connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s}
request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s}
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
@@ -35,14 +52,3 @@ management:
springdoc:
api-docs:
path: /v3/api-docs
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -8,7 +8,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS bridge_inbox (
event_version INT NOT NULL COMMENT '事件契约版本',
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
target_region VARCHAR(32) NOT NULL COMMENT '目标区域',
tenant_id VARCHAR(128) NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径',
customer_id VARCHAR(128) NOT NULL COMMENT '租户标识',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(128) NOT NULL COMMENT '聚合标识',
aggregate_version BIGINT NOT NULL COMMENT '聚合版本',
@@ -1,43 +1,29 @@
package com.baiyue.eventbridge.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.AfterEach;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DisplayName("内部服务令牌校验")
@SpringJUnitConfig(InternalServiceTokenGuard.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=a-secure-internal-token")
class InternalServiceTokenGuardTest {
@Resource private InternalServiceTokenGuard guard;
@AfterEach
void resetToken() {
ReflectionTestUtils.setField(guard, "expectedToken", "a-secure-internal-token");
}
@Test
void validTokenAllowsAccess() {
void 正确令牌允许访问() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("a-secure-internal-token");
assertThatCode(() -> guard.verify("a-secure-internal-token")).doesNotThrowAnyException();
}
@Test
void unconfiguredTokenDisablesReceiveEndpoint() {
ReflectionTestUtils.setField(guard, "expectedToken", "");
void 未配置令牌时默认关闭接收接口() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("");
assertThatThrownBy(() -> guard.verify("anything"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("接口已关闭");
}
@Test
void invalidTokenIsRejected() {
ReflectionTestUtils.setField(guard, "expectedToken", "expected");
void 错误令牌被拒绝() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard("expected");
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("令牌无效");
@@ -6,16 +6,8 @@ import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.RegionCode;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
@@ -25,35 +17,17 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@DisplayName("跨区域事件收件箱")
@SpringJUnitConfig(classes = {EventInboxService.class, EventInboxServiceTest.TestConfig.class})
@TestPropertySource(properties = "baiyue.region=OVERSEAS")
class EventInboxServiceTest {
@Resource
private ObjectMapper objectMapper;
@MockBean
private InboxStore store;
@Resource
private EventInboxService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean
ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
@AfterEach
void resetRegion() {
ReflectionTestUtils.setField(service, "localRegion", "OVERSEAS");
}
private final ObjectMapper objectMapper = new ObjectMapper();
private final InboxStore store = mock(InboxStore.class);
private final EventInboxService service = service("OVERSEAS");
@Test
void firstEventPersistsAndReturnsReceived() {
void 首次事件落库并返回已接收() {
EventEnvelope envelope = envelope("sha256:placeholder");
envelope = withHash(envelope);
@@ -62,19 +36,31 @@ class EventInboxServiceTest {
}
@Test
void identicalEventAndDigestDoesNotPersistAgain() {
void 数字规范化上线前的历史摘要仍可接收() {
EventEnvelope source = envelope("sha256:placeholder");
source.payload().withObject("/snapshot").put("weight", 30.0).put("volume", 0.3150);
String legacyHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(source.payload()));
EventEnvelope envelope = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(),
source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(),
source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(),
source.traceId(), source.correlationId(), source.causationId(), source.payload(), legacyHash);
assertThat(service.receive(envelope).result()).isEqualTo("已接收");
}
@Test
void 相同事件和摘要重复接收不再次写入() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
BridgeInbox existing = existing(envelope, envelope.payloadHash());
BridgeInbox existing = existing(envelope.payloadHash());
when(store.findByEventId(envelope.eventId())).thenReturn(existing);
assertThat(service.receive(envelope).result()).isEqualTo("重复");
}
@Test
void sameEventWithDifferentDigestRejectsOverwrite() {
void 相同事件但摘要不同时拒绝覆盖() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId()))
.thenReturn(existing(envelope, "sha256:" + "0".repeat(64)));
when(store.findByEventId(envelope.eventId())).thenReturn(existing("sha256:" + "0".repeat(64)));
assertThatThrownBy(() -> service.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
@@ -82,45 +68,20 @@ class EventInboxServiceTest {
}
@Test
void sameEventAndPayloadWithDifferentTenantRejectsOverwrite() {
EventEnvelope original = withHash(envelope("sha256:placeholder"));
EventEnvelope otherTenant = withTenant(original, "20002");
when(store.findByEventId(otherTenant.eventId()))
.thenReturn(existing(original, original.payloadHash()));
assertThatThrownBy(() -> service.receive(otherTenant))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("信封身份不一致");
}
@Test
void sameEventAndPayloadWithDifferentAggregateVersionRejectsOverwrite() {
EventEnvelope original = withHash(envelope("sha256:placeholder"));
EventEnvelope otherEnvelope = withAggregateVersion(original, 2L);
when(store.findByEventId(otherEnvelope.eventId()))
.thenReturn(existing(original, original.payloadHash()));
assertThatThrownBy(() -> service.receive(otherEnvelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("信封身份不一致");
}
@Test
void duplicateInsertAfterRaceIsTreatedAsDuplicate() {
void 并发插入冲突后按既有记录判定为重复() {
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
when(store.findByEventId(envelope.eventId()))
.thenReturn(null, existing(envelope, envelope.payloadHash()));
when(store.findByEventId(envelope.eventId())).thenReturn(null, existing(envelope.payloadHash()));
doThrow(new DuplicateKeyException("并发重复")).when(store).insert(any());
assertThat(service.receive(envelope).result()).isEqualTo("重复");
}
@Test
void targetRegionDifferentFromCurrentDeploymentIsRejected() {
ReflectionTestUtils.setField(service, "localRegion", "CN");
void 目标区域与当前部署区域不一致时拒绝接收() {
EventInboxService cnService = service("CN");
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
assertThatThrownBy(() -> service.receive(envelope))
assertThatThrownBy(() -> cnService.receive(envelope))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("目标区域");
}
@@ -132,6 +93,14 @@ class EventInboxServiceTest {
objectMapper.createObjectNode().put("orderId", "90001"), hash);
}
private EventInboxService service(String region) {
EventInboxService service = new EventInboxService();
ReflectionTestUtils.setField(service, "inboxStore", store);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "localRegion", region);
return service;
}
private EventEnvelope withHash(EventEnvelope source) {
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload()));
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
@@ -140,32 +109,9 @@ class EventInboxServiceTest {
source.correlationId(), source.causationId(), source.payload(), hash);
}
private EventEnvelope withTenant(EventEnvelope source, String tenantId) {
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
source.sourceRegion(), source.targetRegion(), tenantId, source.aggregateType(),
source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), source.traceId(),
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
}
private EventEnvelope withAggregateVersion(EventEnvelope source, Long aggregateVersion) {
return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(),
source.sourceRegion(), source.targetRegion(), source.tenantId(), source.aggregateType(),
source.aggregateId(), aggregateVersion, source.handoverVersion(), source.traceId(),
source.correlationId(), source.causationId(), source.payload(), source.payloadHash());
}
private BridgeInbox existing(EventEnvelope envelope, String hash) {
private BridgeInbox existing(String hash) {
BridgeInbox inbox = new BridgeInbox();
inbox.setEventId(envelope.eventId());
inbox.setTenantId(envelope.tenantId());
inbox.setEventType(envelope.eventType());
inbox.setEventVersion(envelope.eventVersion());
inbox.setSourceRegion(envelope.sourceRegion().name());
inbox.setTargetRegion(envelope.targetRegion().name());
inbox.setAggregateType(envelope.aggregateType());
inbox.setAggregateId(envelope.aggregateId());
inbox.setAggregateVersion(envelope.aggregateVersion());
inbox.setHandoverVersion(envelope.handoverVersion());
inbox.setEventId("550e8400-e29b-41d4-a716-446655440000");
inbox.setPayloadHash(hash);
inbox.setReceivedAt(LocalDateTime.of(2026, 7, 29, 18, 0));
return inbox;
@@ -0,0 +1,36 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class EventReplayServiceTest {
private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class);
private final EventReplayService service = service();
@Test
void requeuesDeadEventWithAuditReason() {
when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1);
service.replayDead("event-1", "operator-7", "incident-42 approved");
verify(mapper).replayDead(eq("event-1"), contains("operator-7"));
}
@Test
void rejectsEventThatIsNotDead() {
when(mapper.replayDead(anyString(), anyString())).thenReturn(0);
assertThatThrownBy(() -> service.replayDead("event-1", "operator-7", "retry"))
.isInstanceOf(IllegalStateException.class);
}
private EventReplayService service() {
EventReplayService service = new EventReplayService();
ReflectionTestUtils.setField(service, "inboxMapper", mapper);
return service;
}
}
@@ -0,0 +1,94 @@
package com.baiyue.eventbridge.service;
import com.baiyue.eventbridge.config.EventDispatcherProperties;
import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt;
import com.baiyue.eventbridge.domain.BridgeInbox;
import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper;
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.net.URI;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OverseasEventDispatcherTest {
private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class);
private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class);
private final LastlegEventClient eventClient = mock(LastlegEventClient.class);
private OverseasEventDispatcher dispatcher;
private BridgeInbox event;
@BeforeEach
void setUp() {
EventDispatcherProperties properties = new EventDispatcherProperties(
URI.create("https://lastleg.internal/events"), 50, 3,
Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1),
Duration.ofSeconds(3), Duration.ofSeconds(10));
dispatcher = new OverseasEventDispatcher();
ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper);
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
ReflectionTestUtils.setField(dispatcher, "properties", properties);
event = new BridgeInbox();
event.setEventId("event-1");
event.setSourceRegion("CN");
event.setTargetRegion("OVERSEAS");
event.setRetryCount(0);
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1);
when(inboxMapper.selectById("event-1")).thenReturn(event);
when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1);
}
@Test
void successfulDeliveryIsConfirmedAndAudited() throws Exception {
when(eventClient.deliver(event)).thenReturn("200");
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markProcessed(eq("event-1"), anyString());
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
}
@Test
void failedDeliveryIsScheduledForRetry() throws Exception {
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down"));
dispatcher.dispatchOne("event-1");
verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"));
assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY");
}
@Test
void finalFailureIsMovedToDeadState() throws Exception {
event.setRetryCount(2);
when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down"));
dispatcher.dispatchOne("event-1");
assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD");
}
@Test
void eventClaimedByAnotherInstanceIsSkipped() {
when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(0);
dispatcher.dispatchOne("event-1");
verifyNoInteractions(eventClient);
verify(attemptMapper, never()).insert(any(BridgeDeliveryAttempt.class));
}
private BridgeDeliveryAttempt updatedAttempt() {
ArgumentCaptor<BridgeDeliveryAttempt> captor = ArgumentCaptor.forClass(BridgeDeliveryAttempt.class);
verify(attemptMapper).updateById(captor.capture());
return captor.getValue();
}
}
+3 -1
View File
@@ -10,8 +10,9 @@
| 目标 | 入口 |
|---|---|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
@@ -21,6 +22,7 @@
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
## 全量验收导航
@@ -20,12 +20,14 @@ public class ExportProviderClient {
@Resource private OrderExportProviderClient order;
@Resource private WarehouseExportProviderClient warehouse;
@Resource private FirstlegExportProviderClient firstleg;
@Resource private FinanceExportProviderClient finance;
@Resource
private MeterRegistry meters;
@PostConstruct
void initializeClients() {
this.clients = Map.of("iam-service", iam, "order-service", order,
"warehouse-service", warehouse, "firstleg-service", firstleg);
"warehouse-service", warehouse, "firstleg-service", firstleg,
"finance-service", finance);
}
public ApiResponse<ExportBatchResponse> batch(String internalToken, String exportType,
@@ -0,0 +1,7 @@
package com.baiyue.file.client;
import org.springframework.cloud.openfeign.FeignClient;
@FeignClient(name="finance-service", contextId="financeExportProviderClient",
url="${baiyue.clients.finance.url:}", configuration=ExportProviderFeignConfiguration.class)
interface FinanceExportProviderClient extends ExportProviderFeignClient { }
@@ -11,10 +11,10 @@ import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "baiyue.file.export")
public class ExportDefinitionRegistry {
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service");
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service");
private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+");
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+:[a-z-]+:[a-z-]+");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}");
private Map<String, Definition> definitions = Map.of();
@PostConstruct
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -43,9 +44,32 @@ public class InternalLoadingEvidenceController {
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
private void requireToken(String token) {
if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
}
}
private static MediaType safeImageMediaType(String value) {
if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM;
try {
MediaType mediaType = MediaType.parseMediaType(value);
return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM;
} catch (org.springframework.http.InvalidMediaTypeException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -36,7 +36,7 @@ public class PlatformLoadingEvidenceController {
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
var value = service.download(tenantId, inboundId, fileId);
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.contentType(MediaType.parseMediaType(value.contentType())).contentLength(value.content().length)
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
.filename(value.fileName(), StandardCharsets.UTF_8).build().toString()).body(value.content());
}
@@ -51,4 +51,13 @@ public class PlatformLoadingEvidenceController {
private static void requirePositive(long... ids) {
for (long id : ids) if (id <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须大于0");
}
private static MediaType safeImageMediaType(String value) {
if (value == null || value.isBlank()) return MediaType.APPLICATION_OCTET_STREAM;
try {
MediaType mediaType = MediaType.parseMediaType(value);
return "image".equalsIgnoreCase(mediaType.getType()) ? mediaType : MediaType.APPLICATION_OCTET_STREAM;
} catch (InvalidMediaTypeException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
+86
View File
@@ -35,6 +35,9 @@ spring:
firstlegExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
financeExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
servlet:
multipart:
max-file-size: 10MB
@@ -53,6 +56,8 @@ baiyue:
url: ${IAM_SERVICE_URL:}
integration:
url: ${INTEGRATION_SERVICE_URL:}
finance:
url: ${FINANCE_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
price-book:
@@ -96,6 +101,87 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_CUSTOMER_ACCOUNT_LEDGER:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches
required-permission: platform:finance:customer-account:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_FEE_SOURCE_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches
required-permission: platform:finance:customer-arap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECONCILIATION_STATEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches
required-permission: platform:finance:customer-statement:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ar:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_LIST:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_PAYABLE_SETTLEMENT_DETAIL:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches
required-permission: platform:finance:settlement-ap:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
FINANCE_RECEIVABLE_STATISTICS:
provider-service: finance-service
provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches
required-permission: platform:finance:receivable-statistics:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 500
management.endpoints.web.exposure.include: health,info,prometheus
logging:
@@ -24,6 +24,7 @@ class ExportProviderClientTest {
@MockBean private OrderExportProviderClient order;
@MockBean private WarehouseExportProviderClient warehouse;
@MockBean private FirstlegExportProviderClient firstleg;
@MockBean private FinanceExportProviderClient finance;
@Resource private ExportProviderClient client;
@Configuration(proxyBeanMethods = false)
@@ -44,6 +45,19 @@ class ExportProviderClientTest {
assertSame(response,actual);
verify(order).batch("token","ORDER_LIST",request);
verifyNoInteractions(iam,warehouse,firstleg);
verifyNoInteractions(iam,warehouse,firstleg,finance);
}
@Test void routesFinanceExportsToFinanceService() {
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
when(finance.batch(anyString(),anyString(),any())).thenReturn(response);
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("finance-service");
ExportBatchRequest request=new ExportBatchRequest("9","0","PLATFORM","7",1, Map.of(),null,null,500);
var actual=client.batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",definition,request);
assertSame(response,actual);
verify(finance).batch("token","FINANCE_RECONCILIATION_STATEMENT_LIST",request);
verifyNoInteractions(iam,order,warehouse,firstleg);
}
}
@@ -12,6 +12,20 @@ class ExportDefinitionRegistryTest {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry(); registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("iam-service","/internal/exports/v1/CUSTOMER_LIST/batches")));
assertDoesNotThrow(registry::validate);
}
@Test void acceptsTheControlledFinanceProvider() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches");
finance.setRequiredPermission("platform:finance:customer-statement:export");
registry.setDefinitions(Map.of("FINANCE_RECONCILIATION_STATEMENT_LIST", finance));
assertDoesNotThrow(registry::validate);
}
@Test void acceptsTheReceivableStatisticsProvider() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
var finance=definition("finance-service","/internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches");
finance.setRequiredPermission("platform:finance:receivable-statistics:export");
registry.setDefinitions(Map.of("FINANCE_RECEIVABLE_STATISTICS", finance));
assertDoesNotThrow(registry::validate);
}
@Test void rejectsSsrfAndPathTypeMismatch() {
ExportDefinitionRegistry registry=new ExportDefinitionRegistry();
assertThrows(IllegalStateException.class, () -> registry.setDefinitions(Map.of("CUSTOMER_LIST", definition("evil-service","/internal/exports/v1/OTHER/batches"))));
@@ -0,0 +1,52 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.service.LoadingEvidenceService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalLoadingEvidenceController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class InternalLoadingEvidenceControllerTest {
@MockBean LoadingEvidenceService service;
@Resource InternalLoadingEvidenceController controller;
@Test void downloadsOwnedEvidenceBehindInternalToken() {
when(service.download(81L, 91L, 101L)).thenReturn(
new LoadingEvidenceService.Download("door.jpg", "image/jpeg", new byte[]{1, 2, 3}));
var response = controller.download("internal-token", 81L, 91L, 101L);
assertThat(response.getBody()).containsExactly(1, 2, 3);
assertThat(response.getHeaders().getContentType().toString()).isEqualTo("image/jpeg");
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
verify(service).download(81L, 91L, 101L);
}
@Test void rejectsInvalidTokenBeforeReadingEvidence() {
assertThatThrownBy(() -> controller.download("wrong", 81L, 91L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("凭证无效");
verifyNoInteractions(service);
}
@Test void fallsBackForInvalidOrNonImageContentTypes() {
when(service.download(81L, 91L, 101L)).thenReturn(
new LoadingEvidenceService.Download("door.jpg", "not a/media/type", new byte[]{1}));
when(service.download(81L, 91L, 102L)).thenReturn(
new LoadingEvidenceService.Download("door.txt", "text/plain", new byte[]{2}));
assertThat(controller.download("internal-token", 81L, 91L, 101L).getHeaders().getContentType())
.isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
assertThat(controller.download("internal-token", 81L, 91L, 102L).getHeaders().getContentType())
.isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM);
}
}
+13 -2
View File
@@ -2,7 +2,7 @@
## 模块定位
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、收款及分配、成本及分摊、账单修订、幂等记录和财务审计状态。
- 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。
- 产品范围先读 `../../docs/财务管理/财务管理产品确认表.md` 与 `财务一期五页产品与接口验收契约.md`,跨服务契约再读 `Firstleg-Finance跨服务契约.md`;专题 README、证据矩阵和测试环境报告只用于状态或验收问题,不作为普通开发首读。
- Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。
@@ -13,16 +13,22 @@
| 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` |
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
| 一期账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM |
| 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` |
| 应收款月度统计 | `controller/ReceivableStatisticsController.java`、`service/ReceivableStatisticsService.java`、`dto/ReceivableStatisticsDtos.java`;按结算单生成月份和客户汇总 CNY 正式应收结算单 |
| 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` |
| 对账单客户确认 | `controller/ClientFinanceController.java`、`service/ReconciliationStatementService.java`;客户接口 `/api/client/finance/v1/statements/{statementId}/confirm` |
| 财务单据编号 | `service/FinanceDocumentNumberGenerator.java`;对账单、应收结算单、应付结算单分别使用 `REC`、`AR`、`AP` 前缀 |
| Finance 导出数据 Provider | `controller/FinanceExportProviderController.java`、`service/FinanceExportProviderService.java`;由 File `ExportProviderClient` 按已登记 `exportType` 分批拉取,不从 Admin 直接生成文件 |
| 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` |
| Firstleg 费用来源事件入口 | `controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java` |
| 查询模型与客户可见性 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java` |
| 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` |
| Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` |
| Warehouse 供应商校验与名称快照 | `client/WarehouseSupplierClient.java`、`service/FinanceSupplierValidator.java`;供应商事实来源及类别契约见 `../warehouse/AGENTS.md`、`../warehouse/src/main/resources/openapi/supplier-v1.yaml` |
| 收款/成本凭证 | `client/FinanceFileClient.java`;文件归属与下载规则见 `../file/AGENTS.md` |
| 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` |
| 数据库 | `src/main/resources/db/finance.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 数据库 | `src/main/resources/db/finance.sql`、`src/main/resources/db/fee-source-event-migration.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` |
| 独立服务部署链 | `../Jenkinsfile`、`../Jenkinsfile.service`、`../deploy/service-map.yml`、`../deploy/docker-compose.yml`、`../deploy/scripts/deploy-services.sh`;契约检查见 `../scripts/finance-deployment-contract.test.mjs`,部署授权边界见 `../../docs/测试环境Jenkins部署指南.md` |
| 测试环境全链路验收模块 | `../scripts/admin-test-env-modules/finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs` 及同名 `*-contract.test.mjs`;仅表示验收入口,不代表环境已部署或验收通过 |
@@ -32,9 +38,14 @@
- 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
- 整柜成本分摊使用 Firstleg 返回的真实客户归属与订单体积快照;不得信任前端提交的订单归属或体积。凭证必须由 File 元数据校验客户和业务类型,依赖不可用时不得静默跳过。
- 应付费用来源、成本登记和应付结算单引用的供应商必须通过 Warehouse 校验 ID 与 `ACTIVE` 状态;费用项目 `supplierCategory` 为空时允许任意启用供应商,非空时严格校验类别,不得从费用编码或名称猜测。名称快照只能取 Warehouse 返回值,不能接受自由文本替代。Warehouse 不可用、供应商停用或非空类别不匹配时必须失败关闭;修改该链路时同步检查事件消费、人工成本、结算创建/付款、Nacos 客户端地址和双方契约测试。
- 修改平台或客户 DTO 时同步检查 Admin `../../apps/admin-web/src/modules/finance/`、Client `../../apps/client-web/src/modules/finance/`、Gateway 路由、权限码和契约测试;客户响应不得暴露供应商成本、内部备注、平台凭证或其他客户数据。
- 修改 Finance 导出字段、筛选或 `exportType` 时,同步检查 `FinanceExportProviderService`、File `ExportDefinitionRegistry`/`ExportProviderClient`、Admin `modules/finance/` 与 `modules/downloads/`、导出权限和双方契约测试;页面只创建异步下载任务,不绕过 File 导出中心直接拼装业务文件。
## 验证
@@ -4,7 +4,10 @@ import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -15,20 +18,37 @@ import java.time.LocalDateTime;
public interface FirstlegFeeSourceClient {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long inboundId, @PathVariable long feeId);
@PathVariable long inboundId, @PathVariable long feeId,
@RequestParam int sourceVersion);
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long containerId, @PathVariable long feeId);
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam int sourceVersion);
@PostMapping("/fee-sources/commands/void")
ApiResponse<CommandResult> voidSource(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Request-Hash") String requestHash,
@RequestBody VoidCommand request);
record VoidCommand(String sourceType, long sourceFeeId, int sourceVersion,
long customerId, Long inboundId, Long containerId,
long operatorId, String operatorName, String reason) {}
record CommandResult(String sourceType, long sourceFeeId, int sourceVersion,
String status, boolean replayed) {}
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
String customerName, Long partnerId, String partnerName,
String customerName, Long supplierId, String supplierName,
String supplierCategory,
Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
String publicationStatus, boolean settlementIncluded,
String publicationStatus,
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
String chargeUnit, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
@@ -0,0 +1,24 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "iam-service", contextId = "financeIamCustomerClient",
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamCustomerClient {
@GetMapping("/finance-customers")
ApiResponse<PageResult<Customer>> page(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestParam int page,
@RequestParam int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false) Long customerId);
record Customer(long id, String tenantCode, String tenantName, String status,
String contactName, String contactPhone) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "warehouse-service", contextId = "financeWarehouseSupplierClient",
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouse/v1/suppliers")
public interface WarehouseSupplierClient {
@GetMapping("/{supplierId}")
ApiResponse<SupplierSnapshot> get(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long supplierId,
@RequestParam(defaultValue = "true") boolean requireActive,
@RequestParam(required = false) String category);
record SupplierSnapshot(long supplierId, String name, String category, String status) {}
}
@@ -4,8 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.ReconciliationStatementService;
import com.baiyue.finance.dto.ReconciliationStatementDtos.CustomerConfirmationRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -16,9 +19,12 @@ import org.springframework.web.bind.annotation.*;
public class ClientFinanceController {
private final FinanceService service;
private final FinanceQueryService queryService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
private final ReconciliationStatementService reconciliationStatementService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService,
ReconciliationStatementService reconciliationStatementService) {
this.service = service;
this.queryService = queryService;
this.reconciliationStatementService = reconciliationStatementService;
}
@GetMapping("/bills")
@@ -45,6 +51,17 @@ public class ClientFinanceController {
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
}
@PostMapping("/statements/{statementId}/confirm")
@PreAuthorize("hasAuthority('merchant:finance:statement:confirm')")
@Operation(summary = "客户确认本人已出账对账单")
public ApiResponse<?> confirmStatement(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long statementId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody CustomerConfirmationRequest request) {
return FinanceResponses.success(reconciliationStatementService.confirmForCustomer(
user.tenantId(), user.userId(), statementId, idempotencyKey, request));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询结算应收")
@@ -0,0 +1,51 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.finance.service.FinanceExportProviderService;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Internal export provider consumed only by File Service. */
@RestController
@RequestMapping("/internal/exports/v1")
public class FinanceExportProviderController {
private final FinanceExportProviderService service;
private final String configuredToken;
private byte[] internalToken;
public FinanceExportProviderController(FinanceExportProviderService service,
@Value("${baiyue.security.internal-service-token}") String configuredToken) {
this.service = service;
this.configuredToken = configuredToken;
}
@PostConstruct
void initializeToken() {
byte[] configured = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8);
if (configured.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
internalToken = configured;
}
@PostMapping("/{exportType}/batches")
public ApiResponse<ExportBatchResponse> batch(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable String exportType,
@RequestBody ExportBatchRequest request) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(internalToken, supplied)) throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
return ApiResponse.success(service.exportBatch(exportType, request));
}
}
@@ -3,8 +3,11 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import org.springframework.beans.factory.annotation.Autowired;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -24,15 +27,23 @@ import java.time.LocalDate;
@RequestMapping("/api/platform/finance/v1")
public class FinancePhase1OperationController {
private final FinancePhase1OperationService service;
public FinancePhase1OperationController(FinancePhase1OperationService service) { this.service = service; }
private final FinanceCustomerAccountService customerAccountService;
@Autowired
public FinancePhase1OperationController(FinancePhase1OperationService service,
FinanceCustomerAccountService customerAccountService) {
this.service = service;
this.customerAccountService = customerAccountService;
}
@GetMapping("/accounts")
@PreAuthorize("hasAuthority('platform:finance:customer-account:list')")
public Object listDerivedCustomerAccounts(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status) {
return service.listDerivedCustomerAccounts(page, pageSize, keyword, status);
@RequestParam(required = false) String status,
@RequestParam(required = false) Long customerId) {
return customerAccountService.list(page, pageSize, keyword, status, customerId);
}
@GetMapping("/accounts/{customerId}/ledger")
@@ -46,6 +57,22 @@ public class FinancePhase1OperationController {
return service.listDerivedCustomerAccountLedger(customerId, page, pageSize, businessType, startDate, endDate);
}
@GetMapping("/fee-sources")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public Object listFinanceFeeSources(@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(required = false) Long customerId,
@RequestParam(required = false) String direction,
@RequestParam(required = false) String status,
@RequestParam(required = false) String reviewStatus,
@RequestParam(required = false) Boolean statementGenerated,
@RequestParam(required = false) LocalDate businessDateFrom,
@RequestParam(required = false) LocalDate businessDateTo,
@RequestParam(required = false) String keyword) {
return service.listFinanceFeeSources(page, pageSize, customerId, direction, status, reviewStatus,
statementGenerated, businessDateFrom, businessDateTo, keyword);
}
@GetMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<?> getFinanceFeeSource(@PathVariable long feeSourceId) {
@@ -61,12 +88,40 @@ public class FinancePhase1OperationController {
return FinanceResponses.success(service.reviewFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PutMapping("/fee-sources/{feeSourceId}/settlement-inclusion")
@PreAuthorize("hasAuthority('platform:finance:fee-source:settlement')")
public ApiResponse<?> updateFeeSourceSettlementInclusion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody SettlementInclusionRequest request) {
return FinanceResponses.success(service.updateFeeSourceSettlementInclusion(feeSourceId, user.userId(), key, request));
@PutMapping("/fee-sources/{feeSourceId}")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> editFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, false));
}
@PostMapping("/fee-sources/{feeSourceId}/submit")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> submitFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.submitFinanceFeeSource(feeSourceId, user.userId(), key, request));
}
@PostMapping("/fee-sources/{feeSourceId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> adjustFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceEditRequest request) {
return FinanceResponses.success(service.editFinanceFeeSource(feeSourceId, user.userId(), key, request, true));
}
@PostMapping("/fee-sources/{feeSourceId}/void")
@PreAuthorize("hasAuthority('platform:finance:fee-source:edit')")
public ApiResponse<?> voidFinanceFeeSource(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.voidFinanceFeeSource(
feeSourceId, user.userId(), user.username(), key, request));
}
}
@@ -74,7 +74,7 @@ public class PlatformFinanceController {
public ApiResponse<?> statements(
@Parameter(description = "可选客户筛选;省略时查询全部客户")
@RequestParam(required = false) Long customerId) {
return FinanceResponses.success(queryService.statements(customerId, false));
return FinanceResponses.success(queryService.reconciliationStatements(customerId));
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@@ -105,22 +105,6 @@ public class PlatformFinanceController {
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
}
@PostMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
}
@PostMapping("/bills/{billId}/issue")
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:customer-statement:list')")
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@@ -228,7 +212,6 @@ public class PlatformFinanceController {
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
public record TermsRequest(String customerName, Integer creditDays) {}
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
public record BillLineInput(long sourceFeeId, int sourceFeeVersion, Long orderId, Long inboundId, String feeItemName, java.math.BigDecimal amount) {}
public record VersionRequest(int expectedVersion) {}
public record ReasonRequest(int expectedVersion, String reason) {}
@@ -0,0 +1,42 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsResponse;
import com.baiyue.finance.service.ReceivableStatisticsService;
import com.baiyue.finance.service.FinanceEventException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.YearMonth;
@Tag(name = "应收款统计")
@RestController
@RequestMapping("/api/platform/finance/v1/receivable-statistics")
public class ReceivableStatisticsController {
private final ReceivableStatisticsService service;
public ReceivableStatisticsController(ReceivableStatisticsService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:finance:receivable-statistics:list')")
@Operation(summary = "按生成月份和客户汇总CNY正式应收结算单")
public ApiResponse<ReceivableStatisticsResponse> query(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM") String month,
@RequestParam(required = false) Long customerId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize) {
try {
return FinanceResponses.success(service.query(YearMonth.parse(month), customerId, page, pageSize));
} catch (java.time.format.DateTimeParseException exception) {
throw new FinanceEventException("FIN-STAT-400", "统计月份格式必须为yyyy-MM");
}
}
}
@@ -37,21 +37,33 @@ public final class FinancePhase1Dtos {
BigDecimal outstandingReceivableAfter, String operatorName,
String remark) {}
public record FeeSourceDetail(long feeSourceId, String sourceType, long sourceFeeId,
public record FeeSourceSummary(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId, int sourceVersion,
String direction, long customerId, String customerName,
Long supplierId, String supplierName, Long orderId,
Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated) {}
public record FeeSourceDetail(long feeSourceId, String sourceSystem, String sourceType,
String sourceBusinessId, long sourceFeeId,
int sourceVersion, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long inboundId, String inboundCode, Long containerId,
Long orderId, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount,
String currency, String reviewStatus, boolean settlementIncluded,
String currency, String reviewStatus,
String sourceStatus, boolean statementGenerated,
String reviewReason, String settlementReason, String sourceHash,
String reviewReason, String sourceHash,
int operationVersion, List<OperationLog> operationLogs) {}
public record OperationLog(LocalDateTime occurredAt, String action, String operatorName,
String result, String remark) {}
public record ReviewRequest(String action, String reason, int expectedVersion) {}
public record SettlementInclusionRequest(boolean included, String reason, int expectedVersion) {}
public record FeeSourceEditRequest(BigDecimal amount, String reason, int expectedVersion) {}
public record FeeSourceActionRequest(String reason, int expectedVersion) {}
private static String correlation(String first, String second) {
String value = MDC.get(first);
@@ -69,9 +69,50 @@ public final class FinanceReadModels {
String currency,
String status,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
long createdBy,
String createdByName,
LocalDateTime createdAt,
long updatedBy,
String updatedByName,
LocalDateTime updatedAt) {}
public record ReconciliationSummary(
long statementId,
String statementNo,
long customerId,
String customerName,
LocalDate periodStart,
LocalDate periodEnd,
int creditDays,
String currency,
int lineCount,
int sourceCount,
BigDecimal sourceAmount,
BigDecimal adjustmentAmount,
BigDecimal totalReceivable,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
LocalDateTime lastReceiptAt,
boolean settled,
String status,
LocalDateTime submittedAt,
LocalDateTime reviewedAt,
LocalDateTime issuedAt,
int version,
String customerConfirmationStatus,
Long customerConfirmedBy,
String customerConfirmedByName,
LocalDateTime customerConfirmedAt,
String remark,
long createdBy,
String createdByName,
LocalDateTime createdAt,
long updatedBy,
String updatedByName,
LocalDateTime updatedAt) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
@@ -168,6 +209,7 @@ public final class FinanceReadModels {
String billSnapshot,
String lineSnapshot,
long createdBy,
String createdByName,
LocalDateTime createdAt) {}
public record CustomerTerms(long customerId, String customerName, int creditDays, String status) {}
@@ -22,23 +22,28 @@ public final class FinanceSettlementDtos {
public record SettlementSummary(long settlementId, String settlementNo, String direction, long customerId,
String customerName, Long supplierId, String supplierName,
Long reconciliationStatementId, String businessPeriod, String currency,
Long reconciliationStatementId, String reconciliationStatementNo,
String businessPeriod, String feeItemSummary, String currency,
BigDecimal totalAmount, BigDecimal paidAmount, BigDecimal outstandingAmount,
LocalDate generatedDate, LocalDate dueDate, String paymentStatus,
String status, int version) {}
String status, long createdBy, String createdByName,
LocalDateTime createdAt, LocalDateTime updatedAt,
LocalDateTime lastPaymentAt, int version) {}
public record SettlementLine(long feeSourceId, String sourceType, long sourceFeeId, int sourceVersion,
String direction, long customerId, String customerName, Long supplierId,
String supplierName, Long inboundId, String inboundCode, Long containerId,
String containerNo, LocalDate businessDate, String feeItemName,
String supplierName, Long orderId, String orderNo, Long inboundId,
String inboundCode, Long containerId, String containerNo,
LocalDate businessDate, String feeItemName,
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, boolean settlementIncluded, String sourceStatus,
String reviewStatus, String sourceStatus,
boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, LocalDateTime createdAt,
List<String> voucherFileIds, long createdBy, String createdByName,
LocalDateTime createdAt,
String remark) {}
public record SettlementRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId,
BigDecimal afterAmount, String reason, long operatorId, String operatorName,
LocalDateTime occurredAt) {}
public record SettlementDetail(@JsonUnwrapped SettlementSummary summary, List<SettlementLine> lines,
List<SettlementTransaction> transactions,
@@ -0,0 +1,19 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.util.List;
public final class ReceivableStatisticsDtos {
private ReceivableStatisticsDtos() {}
public record ReceivableStatisticsRow(long customerId, String customerName, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding) {}
public record ReceivableStatisticsResponse(YearMonth statisticsMonth, String currency,
BigDecimal totalReceivable, BigDecimal totalReceived,
BigDecimal totalOutstanding, long totalCustomers,
int page, int pageSize,
List<ReceivableStatisticsRow> records) {}
}
@@ -15,6 +15,12 @@ public final class ReconciliationStatementDtos {
public record ReconciliationActionRequest(String action, String reason, int expectedVersion,
List<StatementAdjustmentLineRequest> adjustmentLines) {}
public record CustomerConfirmationRequest(int expectedVersion) {}
public record CustomerConfirmationResult(long statementId, String confirmationStatus,
long confirmedBy, String confirmedByName,
LocalDateTime confirmedAt, int version) {}
public record StatementAdjustmentLineRequest(long statementLineId, BigDecimal adjustedAmount,
String reason) {}
@@ -23,23 +29,29 @@ public final class ReconciliationStatementDtos {
int creditDays, String currency, BigDecimal totalReceivable,
BigDecimal receivedAmount, BigDecimal outstandingAmount,
boolean settled, String status, int version,
long createdBy, LocalDateTime createdAt,
long updatedBy, LocalDateTime updatedAt, String remark,
String customerConfirmationStatus, Long customerConfirmedBy,
String customerConfirmedByName, LocalDateTime customerConfirmedAt,
long createdBy, String createdByName, LocalDateTime createdAt,
long updatedBy, String updatedByName, LocalDateTime updatedAt, String remark,
List<ReconciliationLine> lines, List<PaymentHistory> paymentHistory,
List<ReconciliationRevision> revisions) {}
public record ReconciliationLine(long statementLineId, long feeSourceId, int sourceVersion,
String sourceHash, Long inboundId, Long containerId,
String sourceHash, String direction, String sourceType,
String sourceNo, Long orderId, Long inboundId, Long containerId,
String inboundCode, String containerNo, LocalDate businessDate,
BigDecimal quantity, BigDecimal unitPrice,
String feeItemName, BigDecimal sourceAmount,
BigDecimal statementAmount, String currency,
String adjustmentReason) {}
public record PaymentHistory(long transactionId, String transactionType,
LocalDate transactionDate, BigDecimal amount,
String method, String referenceNo, long createdBy,
String method, String referenceNo, List<String> voucherFileIds,
long createdBy, String createdByName,
LocalDateTime createdAt, String remark) {}
public record ReconciliationRevision(int revisionNo, String action, BigDecimal beforeAmount,
BigDecimal afterAmount, String reason, long operatorId,
BigDecimal afterAmount, String reason, long operatorId, String operatorName,
LocalDateTime occurredAt) {}
}
@@ -0,0 +1,124 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.finance.client.IamCustomerClient;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class FinanceCustomerAccountService {
private final JdbcTemplate jdbc;
private final IamCustomerClient iamCustomerClient;
private final String internalToken;
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.jdbc = jdbc;
this.iamCustomerClient = iamCustomerClient;
this.internalToken = internalToken;
}
public PageEnvelope<AccountSummary> list(int page, int pageSize, String keyword, String status, Long customerId) {
validatePage(page, pageSize);
String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase();
if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) {
throw new FinanceEventException("FIN-REQ-400", "账户状态无效");
}
ApiResponse<PageResult<IamCustomerClient.Customer>> response;
try {
response = iamCustomerClient.page(internalToken, page, pageSize, blankToNull(keyword), normalizedStatus, customerId);
} catch (RuntimeException error) {
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
}
if (response == null || !response.successful() || response.data() == null) {
throw new FinanceEventException("FIN_503", "客户主数据暂不可用");
}
PageResult<IamCustomerClient.Customer> customers = response.data();
Map<Long, Metrics> metrics = loadMetrics(customers.records().stream().map(IamCustomerClient.Customer::id).toList());
List<AccountSummary> rows = customers.records().stream().map(customer -> {
Metrics value = metrics.getOrDefault(customer.id(), Metrics.ZERO);
return new AccountSummary(customer.id(), customer.tenantCode(), customer.tenantName(),
customer.contactName(), customer.contactPhone(), value.outstandingReceivable(),
value.pendingBilling(), value.receivedInPeriod(), value.outstandingPayable(),
customer.status(), value.lastChangedAt());
}).toList();
return PageEnvelope.success(rows, (int) customers.page(), (int) customers.size(), customers.total());
}
private Map<Long, Metrics> loadMetrics(List<Long> customerIds) {
if (customerIds.isEmpty()) return Map.of();
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
Map<Long, MutableMetrics> result = new HashMap<>();
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_bill WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version) GROUP BY customer_id", customerIds.toArray())
.forEach(row -> metric(result, row, "pendingBilling", "amount"));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(received_amount) amount FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") AND status='CONFIRMED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "receivedInPeriod", "amount"));
jdbc.queryForList("SELECT tenant_id customer_id,SUM(cost_amount) amount FROM fin_cost WHERE tenant_id IN (" + placeholders + ") AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
Object[] changeArgs = new Object[customerIds.size() * 3];
for (int i = 0; i < 3; i++) System.arraycopy(customerIds.toArray(), 0, changeArgs, i * customerIds.size(), customerIds.size());
jdbc.queryForList("SELECT customer_id,MAX(updated_at) last_changed_at FROM (SELECT tenant_id customer_id,updated_at FROM fin_bill WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_receipt WHERE tenant_id IN (" + placeholders + ") UNION ALL SELECT tenant_id customer_id,updated_at FROM fin_cost WHERE tenant_id IN (" + placeholders + ")) changes GROUP BY customer_id", changeArgs)
.forEach(row -> metric(result, row, "lastChangedAt", "last_changed_at"));
Map<Long, Metrics> frozen = new HashMap<>();
result.forEach((id, value) -> frozen.put(id, value.freeze()));
return frozen;
}
private static void metric(Map<Long, MutableMetrics> result, Map<String, Object> row, String field, String valueKey) {
Object id = row.get("customer_id");
if (!(id instanceof Number number)) return;
MutableMetrics target = result.get(number.longValue());
if (target == null) return;
Object value = row.get(valueKey);
if ("lastChangedAt".equals(field)) {
target.lastChangedAt = value instanceof Timestamp timestamp ? timestamp.toLocalDateTime()
: value instanceof LocalDateTime dateTime ? dateTime : null;
} else if (value != null) {
target.set(field, value instanceof BigDecimal decimal ? decimal : new BigDecimal(value.toString()));
}
}
private static void validatePage(int page, int pageSize) {
if (page < 1 || pageSize < 1 || pageSize > 200) throw new FinanceEventException("FIN-REQ-400", "分页参数无效");
}
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static final class MutableMetrics {
private BigDecimal outstandingReceivable = BigDecimal.ZERO;
private BigDecimal pendingBilling = BigDecimal.ZERO;
private BigDecimal receivedInPeriod = BigDecimal.ZERO;
private BigDecimal outstandingPayable = BigDecimal.ZERO;
private LocalDateTime lastChangedAt;
void set(String field, BigDecimal value) {
switch (field) {
case "outstandingReceivable" -> outstandingReceivable = value;
case "pendingBilling" -> pendingBilling = value;
case "receivedInPeriod" -> receivedInPeriod = value;
case "outstandingPayable" -> outstandingPayable = value;
default -> { }
}
}
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, outstandingPayable, lastChangedAt); }
}
private record Metrics(BigDecimal outstandingReceivable, BigDecimal pendingBilling,
BigDecimal receivedInPeriod, BigDecimal outstandingPayable,
LocalDateTime lastChangedAt) {
private static final Metrics ZERO = new Metrics(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null);
}
}
@@ -0,0 +1,55 @@
package com.baiyue.finance.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
@Component
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private final Clock clock;
@Autowired
public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
}
FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported finance document prefix: " + prefix);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
jdbc.update(
"INSERT INTO fin_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
}
}
@@ -0,0 +1,508 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementDetail;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementLine;
import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationDetail;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.ReceivableStatisticsDtos.ReceivableStatisticsRow;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
/** Provides versioned, read-only Finance export batches to File Service. */
@Service
public class FinanceExportProviderService {
public static final String CUSTOMER_ACCOUNT_LIST = "FINANCE_CUSTOMER_ACCOUNT_LIST";
public static final String CUSTOMER_ACCOUNT_LEDGER = "FINANCE_CUSTOMER_ACCOUNT_LEDGER";
public static final String FEE_SOURCE_LIST = "FINANCE_FEE_SOURCE_LIST";
public static final String RECONCILIATION_STATEMENT_LIST = "FINANCE_RECONCILIATION_STATEMENT_LIST";
public static final String RECEIVABLE_SETTLEMENT_LIST = "FINANCE_RECEIVABLE_SETTLEMENT_LIST";
public static final String RECEIVABLE_SETTLEMENT_DETAIL = "FINANCE_RECEIVABLE_SETTLEMENT_DETAIL";
public static final String PAYABLE_SETTLEMENT_LIST = "FINANCE_PAYABLE_SETTLEMENT_LIST";
public static final String PAYABLE_SETTLEMENT_DETAIL = "FINANCE_PAYABLE_SETTLEMENT_DETAIL";
public static final String RECEIVABLE_STATISTICS = "FINANCE_RECEIVABLE_STATISTICS";
private static final int SCHEMA_VERSION = 1;
private static final int MAX_BATCH_SIZE = 500;
private static final int QUERY_PAGE_SIZE = 200;
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
private static final Set<String> TYPES = Set.of(CUSTOMER_ACCOUNT_LIST, CUSTOMER_ACCOUNT_LEDGER,
FEE_SOURCE_LIST, RECONCILIATION_STATEMENT_LIST, RECEIVABLE_SETTLEMENT_LIST,
RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_DETAIL,
RECEIVABLE_STATISTICS);
private static final Set<String> SETTLEMENT_STATUSES = Set.of("ISSUED", "PARTIALLY_PAID", "PAID", "VOIDED");
private static final Set<String> LEDGER_TYPES = Set.of("FEE_SOURCE_EDIT", "FEE_SOURCE_SUBMIT", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_REVIEW_REJECT", "FEE_SOURCE_ADJUST", "FEE_SOURCE_VOID", "RECONCILIATION_CREATED",
"SETTLEMENT_ISSUED", "RECEIPT", "PAYMENT", "ADJUSTMENT", "VOID");
private static final List<ExportColumn> ACCOUNT_COLUMNS = List.of(
column("customerCode", "客户编码", ExportColumnType.STRING, 18), column("customerName", "客户", ExportColumnType.STRING, 28),
column("contactName", "联系人", ExportColumnType.STRING, 16), column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
column("outstandingReceivable", "待收(CNY)", ExportColumnType.DECIMAL, 16), column("pendingBilling", "待开账(CNY)", ExportColumnType.DECIMAL, 16),
column("receivedInPeriod", "本期已收(CNY)", ExportColumnType.DECIMAL, 16), column("outstandingPayable", "待付(CNY)", ExportColumnType.DECIMAL, 16),
column("status", "客户状态", ExportColumnType.STRING, 14), column("lastChangedAt", "最近变动", ExportColumnType.DATETIME, 21));
private static final List<ExportColumn> LEDGER_COLUMNS = List.of(
column("ledgerId", "流水号", ExportColumnType.STRING, 22), column("occurredAt", "发生时间", ExportColumnType.DATETIME, 21),
column("businessType", "业务类型", ExportColumnType.STRING, 18), column("documentNo", "关联单据", ExportColumnType.STRING, 24),
column("receivableChange", "应收变动(CNY)", ExportColumnType.DECIMAL, 18), column("payableChange", "应付变动(CNY)", ExportColumnType.DECIMAL, 18),
column("outstandingReceivableAfter", "变动后未收(CNY)", ExportColumnType.DECIMAL, 20), column("operatorName", "操作人", ExportColumnType.STRING, 16),
column("remark", "备注", ExportColumnType.STRING, 28));
private static final List<ExportColumn> FEE_COLUMNS = List.of(
column("customerName", "客户", ExportColumnType.STRING, 28), column("direction", "收付方向", ExportColumnType.STRING, 14),
column("sourceType", "来源类型", ExportColumnType.STRING, 18), column("sourceFeeId", "来源单据ID", ExportColumnType.STRING, 22),
column("sourceVersion", "来源版本", ExportColumnType.INTEGER, 12), column("inboundCode", "入仓号", ExportColumnType.STRING, 20),
column("containerNo", "柜次号", ExportColumnType.STRING, 20), column("businessDate", "业务发生日", ExportColumnType.DATE, 14),
column("feeItemName", "费用项目", ExportColumnType.STRING, 22), column("quantity", "计费量", ExportColumnType.DECIMAL, 14),
column("unitPrice", "单价(CNY)", ExportColumnType.DECIMAL, 14), column("amount", "金额(CNY)", ExportColumnType.DECIMAL, 16),
column("reviewStatus", "审核状态", ExportColumnType.STRING, 14),
column("sourceStatus", "来源状态", ExportColumnType.STRING, 14), column("statementGenerated", "已生成对账单", ExportColumnType.BOOLEAN, 16));
private static final List<ExportColumn> STATEMENT_COLUMNS = List.of(
column("statementNo", "对账单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
column("periodStart", "结算起始日", ExportColumnType.DATE, 14), column("periodEnd", "结算结束日", ExportColumnType.DATE, 14),
column("creditDays", "账期(天)", ExportColumnType.INTEGER, 12), column("currency", "货币", ExportColumnType.STRING, 10),
column("totalReceivable", "总应收金额(CNY)", ExportColumnType.DECIMAL, 20), column("receivedAmount", "已收金额(CNY)", ExportColumnType.DECIMAL, 18),
column("outstandingAmount", "未收金额(CNY)", ExportColumnType.DECIMAL, 18), column("settled", "是否结清", ExportColumnType.BOOLEAN, 12),
column("status", "审核状态", ExportColumnType.STRING, 14), column("remark", "备注", ExportColumnType.STRING, 28),
column("createdByName", "创建人", ExportColumnType.STRING, 16), column("createdAt", "创建时间", ExportColumnType.DATETIME, 21),
column("updatedByName", "更新人", ExportColumnType.STRING, 16), column("updatedAt", "更新时间", ExportColumnType.DATETIME, 21));
private static final List<ExportColumn> SETTLEMENT_LIST_COLUMNS = List.of(
column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
column("supplierName", "供应商", ExportColumnType.STRING, 24), column("currency", "货币", ExportColumnType.STRING, 10),
column("totalAmount", "总金额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("businessPeriod", "业务期间", ExportColumnType.STRING, 18),
column("generatedDate", "生成日期", ExportColumnType.DATE, 14), column("dueDate", "到期日期", ExportColumnType.DATE, 14),
column("status", "状态", ExportColumnType.STRING, 16));
private static final List<ExportColumn> SETTLEMENT_DETAIL_COLUMNS = List.of(
column("settlementNo", "结算单号", ExportColumnType.STRING, 26), column("customerName", "客户", ExportColumnType.STRING, 28),
column("supplierName", "供应商", ExportColumnType.STRING, 24), column("feeItemName", "费用项目", ExportColumnType.STRING, 22),
column("inboundCode", "入仓号", ExportColumnType.STRING, 20), column("containerNo", "柜次号", ExportColumnType.STRING, 20),
column("businessDate", "业务发生日", ExportColumnType.DATE, 14), column("lineAmount", "明细金额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalAmount", "结算总额(CNY)", ExportColumnType.DECIMAL, 18), column("paidAmount", "已收/付金额(CNY)", ExportColumnType.DECIMAL, 20),
column("outstandingAmount", "未收/付金额(CNY)", ExportColumnType.DECIMAL, 20), column("status", "状态", ExportColumnType.STRING, 16));
private static final List<ExportColumn> RECEIVABLE_STATISTICS_COLUMNS = List.of(
column("statisticsMonth", "统计月份", ExportColumnType.STRING, 14),
column("customerName", "应收单位", ExportColumnType.STRING, 28),
column("currency", "币种", ExportColumnType.STRING, 10),
column("totalReceivable", "应收总额(CNY)", ExportColumnType.DECIMAL, 18),
column("totalReceived", "已收合计(CNY)", ExportColumnType.DECIMAL, 18),
column("totalOutstanding", "余额未收(CNY)", ExportColumnType.DECIMAL, 18));
private final FinanceCustomerAccountService accountService;
private final FinancePhase1OperationService operationService;
private final FinanceQueryService queryService;
private final ReconciliationStatementService reconciliationService;
private final FinanceSettlementService settlementService;
private final ReceivableStatisticsService receivableStatisticsService;
private final ObjectMapper objectMapper;
private final String internalServiceToken;
private byte[] snapshotSigningKey;
public FinanceExportProviderService(FinanceCustomerAccountService accountService,
FinancePhase1OperationService operationService,
FinanceQueryService queryService,
ReconciliationStatementService reconciliationService,
FinanceSettlementService settlementService,
ReceivableStatisticsService receivableStatisticsService,
ObjectMapper objectMapper,
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
this.accountService = accountService;
this.operationService = operationService;
this.queryService = queryService;
this.reconciliationService = reconciliationService;
this.settlementService = settlementService;
this.receivableStatisticsService = receivableStatisticsService;
this.objectMapper = objectMapper;
this.internalServiceToken = internalServiceToken;
}
@PostConstruct
void initializeSigningKey() {
snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8);
}
public ExportBatchResponse exportBatch(String exportType, ExportBatchRequest request) {
String type = normalizeType(exportType);
ValidatedRequest validated = validate(type, request);
boolean firstBatch = validated.snapshotToken() == null;
String snapshot = firstBatch ? signSnapshot(type, validated) : verifySnapshot(type, validated);
BatchRows batch = rows(type, validated);
String nextCursor = String.valueOf(validated.offset() + batch.rows().size());
return new ExportBatchResponse(SCHEMA_VERSION, schemaHash(columns(type)), snapshot,
firstBatch ? columns(type) : List.of(), batch.rows(), nextCursor,
validated.offset() + batch.rows().size() < batch.totalRows(), firstBatch ? batch.totalRows() : null);
}
private BatchRows rows(String type, ValidatedRequest request) {
int offset = request.offset();
int limit = request.source().limit();
Map<String, Object> p = request.parameters();
if (CUSTOMER_ACCOUNT_LIST.equals(type)) {
int querySize = Math.min(limit, QUERY_PAGE_SIZE);
int page = offset / querySize + 1;
var result = accountService.list(page, querySize, null, text(p, "status"), longValue(p, "customerId", false));
return new BatchRows(result.data().stream().map(this::accountRow).toList(), result.page().total());
}
if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) {
int querySize = Math.min(limit, QUERY_PAGE_SIZE);
int page = offset / querySize + 1;
var result = operationService.listDerivedCustomerAccountLedger(longValue(p, "customerId", true), page, querySize,
text(p, "businessType"), date(p, "startDate"), date(p, "endDate"));
return new BatchRows(result.data().stream().map(this::ledgerRow).toList(), result.page().total());
}
List<Map<String, Object>> all = switch (type) {
case FEE_SOURCE_LIST -> feeRows(p);
case RECONCILIATION_STATEMENT_LIST -> statementRows(p);
case RECEIVABLE_SETTLEMENT_LIST -> settlementRows("RECEIVABLE", p);
case PAYABLE_SETTLEMENT_LIST -> settlementRows("PAYABLE", p);
case RECEIVABLE_SETTLEMENT_DETAIL -> settlementDetailRows("RECEIVABLE", p);
case PAYABLE_SETTLEMENT_DETAIL -> settlementDetailRows("PAYABLE", p);
case RECEIVABLE_STATISTICS -> receivableStatisticsRows(p);
default -> throw invalid("Unsupported Finance export type");
};
int from = Math.min(offset, all.size());
int to = Math.min(from + limit, all.size());
return new BatchRows(List.copyOf(all.subList(from, to)), (long) all.size());
}
private List<Map<String, Object>> receivableStatisticsRows(Map<String, Object> parameters) {
YearMonth month = statisticsMonth(parameters);
Long customerId = longValue(parameters, "customerId", false);
return receivableStatisticsService.exportRows(month, customerId).stream()
.map(value -> receivableStatisticsRow(month, value)).toList();
}
private List<Map<String, Object>> feeRows(Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String direction = text(p, "direction");
String status = text(p, "status");
String reviewStatus = text(p, "reviewStatus");
Boolean generated = bool(p, "statementGenerated");
LocalDate from = date(p, "businessDateFrom"), to = date(p, "businessDateTo");
String keyword = lower(text(p, "keyword"));
List<Map<String, Object>> rows = new ArrayList<>();
for (FeePoolItem item : queryService.feePool(customerId, direction)) {
FeeSourceDetail detail = detail(item);
String sourceStatus = detail == null ? item.status() : detail.sourceStatus();
String actualReview = detail == null ? null : detail.reviewStatus();
Boolean actualGenerated = detail == null ? null : detail.statementGenerated();
LocalDate businessDate = detail == null || detail.businessDate() == null
? (item.createdAt() == null ? null : item.createdAt().toLocalDate()) : detail.businessDate();
if (status != null && !status.equalsIgnoreCase(sourceStatus)) continue;
if (reviewStatus != null && !reviewStatus.equalsIgnoreCase(actualReview)) continue;
if (generated != null && !generated.equals(actualGenerated)) continue;
if (from != null && (businessDate == null || businessDate.isBefore(from))) continue;
if (to != null && (businessDate == null || businessDate.isAfter(to))) continue;
String searchable = lower(String.join(" ", value(item.poolItemId()), value(item.sourceId()), value(item.feeItemName()),
detail == null ? "" : value(detail.inboundCode()), detail == null ? "" : value(detail.containerNo())));
if (keyword != null && !searchable.contains(keyword)) continue;
rows.add(feeRow(item, detail, businessDate));
}
return rows;
}
private FeeSourceDetail detail(FeePoolItem item) {
if ("MANUAL_COST".equals(item.sourceType())) return null;
String[] parts = item.poolItemId().split(":", -1);
if (parts.length != 2) return null;
try { return operationService.getFinanceFeeSource(Long.parseLong(parts[1])); }
catch (RuntimeException ignored) { return null; }
}
private List<Map<String, Object>> statementRows(Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String status = text(p, "status"), billNo = lower(text(p, "billNo")), containerNo = lower(text(p, "containerNo"));
LocalDate periodStartFrom = date(p, "periodStartFrom"), periodStartTo = date(p, "periodStartTo");
LocalDate periodEndFrom = date(p, "periodEndFrom"), periodEndTo = date(p, "periodEndTo");
LocalDate lastPaymentFrom = date(p, "lastPaymentFrom"), lastPaymentTo = date(p, "lastPaymentTo");
LocalDate createdFrom = date(p, "createdFrom"), createdTo = date(p, "createdTo");
Boolean settled = bool(p, "settled");
List<Map<String, Object>> rows = new ArrayList<>();
queryService.statements(customerId, false).forEach(summary -> {
ReconciliationDetail detail = reconciliationService.detailForPlatform(summary.id());
LocalDate lastPayment = detail.paymentHistory().stream().map(row -> row.transactionDate()).max(LocalDate::compareTo).orElse(null);
boolean containerMatches = containerNo == null || detail.lines().stream()
.map(line -> feeSourceContainerNo(line.feeSourceId()))
.anyMatch(value -> value != null && value.toLowerCase(Locale.ROOT).contains(containerNo));
if (status != null && !status.equalsIgnoreCase(detail.status())) return;
if (billNo != null && !detail.statementNo().toLowerCase(Locale.ROOT).contains(billNo)) return;
if (!containerMatches || (settled != null && settled != detail.settled())) return;
if (!between(detail.periodStart(), periodStartFrom, periodStartTo) || !between(detail.periodEnd(), periodEndFrom, periodEndTo)) return;
if ((lastPaymentFrom != null || lastPaymentTo != null) && !between(lastPayment, lastPaymentFrom, lastPaymentTo)) return;
if (!between(detail.createdAt().toLocalDate(), createdFrom, createdTo)) return;
rows.add(statementRow(detail));
});
return rows;
}
private String feeSourceContainerNo(long feeSourceId) {
try {
return operationService.getFinanceFeeSource(feeSourceId).containerNo();
} catch (RuntimeException ignored) {
return null;
}
}
private List<Map<String, Object>> settlementRows(String direction, Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
String status = text(p, "status"), no = lower(text(p, "settlementNo")), supplier = lower(text(p, "supplierName"));
return settlementService.listSettlements(direction, customerId).stream()
.filter(row -> status == null || status.equalsIgnoreCase(row.status()))
.filter(row -> no == null || row.settlementNo().toLowerCase(Locale.ROOT).contains(no))
.filter(row -> supplier == null || lower(row.supplierName()) != null && lower(row.supplierName()).contains(supplier))
.map(this::settlementRow).toList();
}
private List<Map<String, Object>> settlementDetailRows(String direction, Map<String, Object> p) {
SettlementDetail detail = settlementService.getSettlement(longValue(p, "settlementId", true));
if (!direction.equals(detail.summary().direction())) throw invalid("结算单方向与导出类型不匹配");
if (detail.lines().isEmpty()) return List.of(settlementDetailRow(detail.summary(), null));
return detail.lines().stream().map(line -> settlementDetailRow(detail.summary(), line)).toList();
}
private ValidatedRequest validate(String type, ExportBatchRequest request) {
if (request == null) throw invalid("Export batch request is required");
positive(request.taskId(), "taskId"); positive(request.requestedBy(), "requestedBy");
if (!"0".equals(request.tenantId()) || !"PLATFORM".equals(request.appCode())) throw invalid("Finance export only supports PLATFORM tenant 0");
if (request.dispatchVersion() <= 0 || request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) throw invalid("Finance export batch limit is invalid");
Map<String, Object> parameters = request.parameters() == null ? Map.of() : request.parameters();
Set<String> allowed = allowedParameters(type);
if (!allowed.containsAll(parameters.keySet())) throw invalid(type + " contains unsupported parameters");
validateParameters(type, parameters);
String snapshot = normalize(request.snapshotToken());
if (snapshot != null && snapshot.length() > MAX_SNAPSHOT_TOKEN_LENGTH) throw invalid("snapshotToken is too long");
int offset = integerCursor(request.cursor());
if (snapshot == null && offset != 0) throw invalid("cursor requires a snapshotToken");
return new ValidatedRequest(request, Map.copyOf(parameters), offset, snapshot);
}
private void validateParameters(String type, Map<String, Object> p) {
Long customerId = longValue(p, "customerId", false);
if (customerId != null && customerId <= 0) throw invalid("customerId must be positive");
String status = text(p, "status");
if (CUSTOMER_ACCOUNT_LIST.equals(type) && status != null && !Set.of("ACTIVE", "DISABLED").contains(status)) throw invalid("account status is invalid");
if (CUSTOMER_ACCOUNT_LEDGER.equals(type)) {
longValue(p, "customerId", true);
String businessType = text(p, "businessType");
if (businessType != null && !LEDGER_TYPES.contains(businessType)) throw invalid("ledger businessType is invalid");
range(p, "startDate", "endDate");
}
if (FEE_SOURCE_LIST.equals(type)) {
String direction = text(p, "direction");
if (direction != null && !Set.of("RECEIVABLE", "PAYABLE").contains(direction)) throw invalid("fee direction is invalid");
String review = text(p, "reviewStatus");
if (review != null && !Set.of("PENDING", "APPROVED", "REJECTED").contains(review)) throw invalid("reviewStatus is invalid");
bool(p, "statementGenerated"); range(p, "businessDateFrom", "businessDateTo");
max(text(p, "keyword"), 128, "keyword"); max(status, 64, "status");
}
if (RECONCILIATION_STATEMENT_LIST.equals(type)) {
bool(p, "settled"); range(p, "periodStartFrom", "periodStartTo"); range(p, "periodEndFrom", "periodEndTo");
range(p, "lastPaymentFrom", "lastPaymentTo"); range(p, "createdFrom", "createdTo");
max(text(p, "billNo"), 128, "billNo"); max(text(p, "containerNo"), 128, "containerNo");
}
if (type.contains("SETTLEMENT")) {
if (type.endsWith("_DETAIL")) longValue(p, "settlementId", true);
else {
if (status != null && !SETTLEMENT_STATUSES.contains(status)) throw invalid("settlement status is invalid");
max(text(p, "settlementNo"), 128, "settlementNo"); max(text(p, "supplierName"), 128, "supplierName");
}
}
if (RECEIVABLE_STATISTICS.equals(type)) statisticsMonth(p);
}
private String signSnapshot(String type, ValidatedRequest request) {
return "v1." + Base64.getUrlEncoder().withoutPadding().encodeToString(hmac(snapshotSubject(type, request)));
}
private String verifySnapshot(String type, ValidatedRequest request) {
String expected = signSnapshot(type, request);
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), request.snapshotToken().getBytes(StandardCharsets.UTF_8))) {
throw invalid("snapshotToken does not match this export task");
}
return request.snapshotToken();
}
private String snapshotSubject(String type, ValidatedRequest request) {
try {
ExportBatchRequest source = request.source();
return String.join("\n", type, source.taskId(), source.tenantId(), source.appCode(), source.requestedBy(),
String.valueOf(source.dispatchVersion()), objectMapper.writeValueAsString(new TreeMap<>(request.parameters())));
} catch (Exception exception) { throw invalid("Finance export parameters cannot be serialized"); }
}
private byte[] hmac(String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256"));
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException exception) { throw new IllegalStateException("HmacSHA256 is unavailable", exception); }
}
private Map<String, Object> accountRow(AccountSummary value) {
return row("customerCode", value.customerCode(), "customerName", value.customerName(), "contactName", value.contactName(),
"contactPhone", value.contactPhone(), "outstandingReceivable", value.outstandingReceivable(), "pendingBilling", value.pendingBilling(),
"receivedInPeriod", value.receivedInPeriod(), "outstandingPayable", value.outstandingPayable(), "status", value.status(), "lastChangedAt", value.lastChangedAt());
}
private Map<String, Object> ledgerRow(LedgerEntry value) {
return row("ledgerId", String.valueOf(value.ledgerId()), "occurredAt", value.occurredAt(), "businessType", value.businessType(),
"documentNo", value.documentNo(), "receivableChange", value.receivableChange(), "payableChange", value.payableChange(),
"outstandingReceivableAfter", value.outstandingReceivableAfter(), "operatorName", value.operatorName(), "remark", value.remark());
}
private Map<String, Object> feeRow(FeePoolItem item, FeeSourceDetail detail, LocalDate businessDate) {
return row("customerName", detail == null ? null : detail.customerName(), "direction", item.direction(), "sourceType", item.sourceType(),
"sourceFeeId", String.valueOf(item.sourceId()), "sourceVersion", item.sourceVersion(), "inboundCode", detail == null ? null : detail.inboundCode(),
"containerNo", detail == null ? null : detail.containerNo(), "businessDate", businessDate, "feeItemName", item.feeItemName(),
"quantity", detail == null ? null : detail.quantity(), "unitPrice", detail == null ? null : detail.unitPrice(), "amount", item.amount(),
"reviewStatus", detail == null ? null : detail.reviewStatus(),
"sourceStatus", detail == null ? item.status() : detail.sourceStatus(), "statementGenerated", detail == null ? null : detail.statementGenerated());
}
private Map<String, Object> statementRow(ReconciliationDetail value) {
return row("statementNo", value.statementNo(), "customerName", value.customerName(), "periodStart", value.periodStart(),
"periodEnd", value.periodEnd(), "creditDays", value.creditDays(), "currency", value.currency(), "totalReceivable", value.totalReceivable(),
"receivedAmount", value.receivedAmount(), "outstandingAmount", value.outstandingAmount(), "settled", value.settled(), "status", value.status(),
"remark", value.remark(), "createdByName", value.createdByName(), "createdAt", value.createdAt(), "updatedByName", value.updatedByName(), "updatedAt", value.updatedAt());
}
private Map<String, Object> settlementRow(SettlementSummary value) {
return row("settlementNo", value.settlementNo(), "customerName", value.customerName(), "supplierName", value.supplierName(),
"currency", value.currency(), "totalAmount", value.totalAmount(), "paidAmount", value.paidAmount(), "outstandingAmount", value.outstandingAmount(),
"businessPeriod", value.businessPeriod(), "generatedDate", value.generatedDate(), "dueDate", value.dueDate(), "status", value.status());
}
private Map<String, Object> settlementDetailRow(SettlementSummary summary, SettlementLine line) {
return row("settlementNo", summary.settlementNo(), "customerName", summary.customerName(), "supplierName", summary.supplierName(),
"feeItemName", line == null ? null : line.feeItemName(), "inboundCode", line == null ? null : line.inboundCode(),
"containerNo", line == null ? null : line.containerNo(), "businessDate", line == null ? null : line.businessDate(),
"lineAmount", line == null ? null : line.amount(), "totalAmount", summary.totalAmount(), "paidAmount", summary.paidAmount(),
"outstandingAmount", summary.outstandingAmount(), "status", summary.status());
}
private Map<String, Object> receivableStatisticsRow(YearMonth month, ReceivableStatisticsRow value) {
return row("statisticsMonth", month.toString(), "customerName", value.customerName(),
"currency", value.currency(), "totalReceivable", value.totalReceivable(),
"totalReceived", value.totalReceived(), "totalOutstanding", value.totalOutstanding());
}
private static List<ExportColumn> columns(String type) {
return switch (type) {
case CUSTOMER_ACCOUNT_LIST -> ACCOUNT_COLUMNS;
case CUSTOMER_ACCOUNT_LEDGER -> LEDGER_COLUMNS;
case FEE_SOURCE_LIST -> FEE_COLUMNS;
case RECONCILIATION_STATEMENT_LIST -> STATEMENT_COLUMNS;
case RECEIVABLE_SETTLEMENT_LIST, PAYABLE_SETTLEMENT_LIST -> SETTLEMENT_LIST_COLUMNS;
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> SETTLEMENT_DETAIL_COLUMNS;
case RECEIVABLE_STATISTICS -> RECEIVABLE_STATISTICS_COLUMNS;
default -> throw invalid("Unsupported Finance export type");
};
}
private static Set<String> allowedParameters(String type) {
return switch (type) {
case CUSTOMER_ACCOUNT_LIST -> Set.of("customerId", "status");
case CUSTOMER_ACCOUNT_LEDGER -> Set.of("customerId", "businessType", "startDate", "endDate");
case FEE_SOURCE_LIST -> Set.of("customerId", "direction", "status", "reviewStatus", "statementGenerated", "businessDateFrom", "businessDateTo", "keyword");
case RECONCILIATION_STATEMENT_LIST -> Set.of("customerId", "status", "billNo", "containerNo", "periodStartFrom", "periodStartTo", "periodEndFrom", "periodEndTo", "settled", "lastPaymentFrom", "lastPaymentTo", "createdFrom", "createdTo");
case RECEIVABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo");
case PAYABLE_SETTLEMENT_LIST -> Set.of("customerId", "status", "settlementNo", "supplierName");
case RECEIVABLE_SETTLEMENT_DETAIL, PAYABLE_SETTLEMENT_DETAIL -> Set.of("settlementId");
case RECEIVABLE_STATISTICS -> Set.of("month", "customerId");
default -> throw invalid("Unsupported Finance export type");
};
}
private static String normalizeType(String value) {
String type = normalize(value);
if (type == null || !TYPES.contains(type.toUpperCase(Locale.ROOT))) throw invalid("Unsupported Finance export type");
return type.toUpperCase(Locale.ROOT);
}
private static ExportColumn column(String key, String title, ExportColumnType type, int width) { return new ExportColumn(key, title, type, width); }
private static String schemaHash(List<ExportColumn> columns) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (ExportColumn column : columns) digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type() + "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8));
return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest());
} catch (GeneralSecurityException exception) { throw new ExceptionInInitializerError(exception); }
}
private static Map<String, Object> row(Object... values) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 0; i < values.length; i += 2) row.put(String.valueOf(values[i]), values[i + 1]);
return row;
}
private static int integerCursor(String value) {
String normalized = normalize(value);
if (normalized == null) return 0;
try { int cursor = Integer.parseInt(normalized); if (cursor < 0) throw invalid("cursor must not be negative"); return cursor; }
catch (NumberFormatException exception) { throw invalid("cursor must be a numeric offset"); }
}
private static Long longValue(Map<String, Object> values, String key, boolean required) {
Object value = values.get(key);
if (value == null || value.toString().isBlank()) { if (required) throw invalid(key + " is required"); return null; }
if (!(value instanceof String)) throw invalid(key + " must be a string ID");
try { long result = Long.parseLong(value.toString()); if (result <= 0) throw invalid(key + " must be positive"); return result; }
catch (NumberFormatException exception) { throw invalid(key + " must be a positive ID"); }
}
private static String text(Map<String, Object> values, String key) {
Object value = values.get(key);
if (value == null) return null;
if (!(value instanceof String text)) throw invalid(key + " must be a string");
return normalize(text);
}
private static Boolean bool(Map<String, Object> values, String key) {
Object value = values.get(key);
if (value == null) return null;
if (!(value instanceof Boolean result)) throw invalid(key + " must be a boolean");
return result;
}
private static LocalDate date(Map<String, Object> values, String key) {
String value = text(values, key);
if (value == null) return null;
try { return LocalDate.parse(value); }
catch (RuntimeException exception) { throw invalid(key + " must use YYYY-MM-DD"); }
}
private static YearMonth statisticsMonth(Map<String, Object> values) {
String value = text(values, "month");
if (value == null) throw invalid("month is required");
try { return YearMonth.parse(value); }
catch (RuntimeException exception) { throw invalid("month must use YYYY-MM"); }
}
private static void range(Map<String, Object> values, String fromKey, String toKey) {
LocalDate from = date(values, fromKey), to = date(values, toKey);
if (from != null && to != null && from.isAfter(to)) throw invalid(fromKey + " cannot be after " + toKey);
}
private static boolean between(LocalDate value, LocalDate from, LocalDate to) {
if (value == null) return from == null && to == null;
return (from == null || !value.isBefore(from)) && (to == null || !value.isAfter(to));
}
private static void positive(String value, String field) {
try { if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID"); }
catch (NumberFormatException exception) { throw invalid(field + " must be a positive ID"); }
}
private static void max(String value, int max, String field) { if (value != null && value.length() > max) throw invalid(field + " is too long"); }
private static String lower(String value) { return value == null ? null : value.toLowerCase(Locale.ROOT); }
private static String normalize(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String value(Object value) { return value == null ? "" : value.toString(); }
private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); }
private record ValidatedRequest(ExportBatchRequest source, Map<String, Object> parameters, int offset, String snapshotToken) {}
private record BatchRows(List<Map<String, Object>> rows, long totalRows) {}
}
@@ -28,6 +28,7 @@ public class FinanceFeeSourceEventService {
private final JdbcTemplate jdbc;
private final FirstlegFeeSourceClient firstleg;
private final ObjectMapper objectMapper;
private final FinanceSupplierValidator supplierValidator;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -71,7 +72,7 @@ public class FinanceFeeSourceEventService {
}
long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", id);
jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
}
return id;
}
@@ -93,8 +94,10 @@ public class FinanceFeeSourceEventService {
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
FinanceFeeSourceEvent.Payload payload = event.payload();
var response = payload.inboundId() != null
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"),
positive(payload.feeId(), "feeId"), payload.feeVersion())
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"),
positive(payload.feeId(), "feeId"), payload.feeVersion());
if (response == null || response.data() == null || !"200".equals(response.code())) {
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
}
@@ -119,13 +122,26 @@ public class FinanceFeeSourceEventService {
long feeId = positive(event.payload().feeId(), "feeId");
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
if (!"APPROVED".equals(value.reviewStatus())) throw error("FIN-EVT-009", "费用未审核通过");
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
throw error("FIN-EVT-015", "费用来源尚未发布");
}
if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效");
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("PAYABLE".equals(value.direction())) {
if (value.supplierId() == null || value.supplierId() <= 0
|| blank(value.supplierName())) {
throw error("FIN-EVT-012", "应付费用来源供应商快照不完整");
}
var supplier = supplierValidator.requireActive(value.supplierId(), value.supplierCategory());
if (!value.supplierName().equals(supplier.name())) {
throw error("FIN-EVT-012", "应付费用来源供应商名称快照不一致");
}
} else if (value.supplierId() != null || !blank(value.supplierName())
|| !blank(value.supplierCategory())) {
throw error("FIN-EVT-012", "应收费用不得携带供应商快照");
}
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
@@ -0,0 +1,26 @@
package com.baiyue.finance.service;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
final class FinanceOperatorSnapshot {
private FinanceOperatorSnapshot() { }
static String currentName(long operatorId) {
if (operatorId == 0) return "系统";
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user
&& user.userId() != null && user.userId() == operatorId
&& StringUtils.hasText(user.username())) {
return user.username().trim();
}
return null;
}
static String requiredName(long operatorId) {
String name = currentName(operatorId);
return name == null ? "操作人 " + operatorId : name;
}
}
@@ -2,13 +2,19 @@ package com.baiyue.finance.service;
import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary;
import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry;
import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.SettlementInclusionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
@@ -34,18 +40,29 @@ public class FinancePhase1OperationService {
private static final String CUSTOMER_IDS = "SELECT tenant_id customer_id FROM fin_customer_settlement_profile UNION SELECT tenant_id FROM fin_bill UNION SELECT customer_id FROM fin_fee_source UNION SELECT tenant_id FROM fin_cost";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
private final FirstlegFeeSourceClient firstleg;
private final String internalToken;
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
@Autowired
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FirstlegFeeSourceClient firstleg,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
this.firstleg = firstleg;
this.internalToken = internalToken;
}
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String keyword, String status) {
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, null, "");
}
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
page = page(page); pageSize = pageSize(pageSize);
String normalizedStatus = status == null || status.isBlank() ? null : status.trim().toUpperCase(java.util.Locale.ROOT);
if (normalizedStatus != null && !List.of("ACTIVE", "DISABLED").contains(normalizedStatus)) throw error("FIN-REQ-400", "账户状态无效");
String filter = " WHERE (? IS NULL OR CAST(c.customer_id AS CHAR) LIKE CONCAT('%',?,'%') OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)";
Object[] filterArgs = {blankToNull(keyword), blankToNull(keyword), blankToNull(keyword), normalizedStatus, normalizedStatus};
String filter = " WHERE (? IS NULL OR COALESCE(p.customer_name_snapshot,'') LIKE CONCAT('%',?,'%')) AND (? IS NULL OR COALESCE(p.status,'ACTIVE')=?)";
Object[] filterArgs = {blankToNull(customerName), blankToNull(customerName), normalizedStatus, normalizedStatus};
String base = " FROM (" + CUSTOMER_IDS + ") c LEFT JOIN fin_customer_settlement_profile p ON p.tenant_id=c.customer_id" + filter;
Long total = jdbc.queryForObject("SELECT COUNT(*)" + base, Long.class, filterArgs);
String select = "SELECT c.customer_id,COALESCE(p.customer_name_snapshot,CONCAT('客户',c.customer_id)) customer_name,COALESCE(p.status,'ACTIVE') account_status," +
@@ -66,9 +83,9 @@ public class FinancePhase1OperationService {
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
if (startDate != null && endDate != null && startDate.isAfter(endDate)) throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
List<RawLedger> rows = new ArrayList<>();
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
rows.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,created_by,created_by_name,remark FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "SETTLEMENT_ISSUED", "BILL", "bill_no", "total_amount", null), customerId));
rows.addAll(jdbc.query("SELECT id,receipt_no,received_at received_time,received_amount,created_by,created_by_name,remark FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED'", (rs, n) -> raw(rs, "RECEIPT", "RECEIPT", "receipt_no", null, "received_amount"), customerId));
rows.addAll(jdbc.query("SELECT id,cost_no,created_at,cost_amount,created_by,created_by_name,remark FROM fin_cost WHERE tenant_id=? AND status<>'VOIDED'", (rs, n) -> raw(rs, "PAYMENT", "COST", "cost_no", null, null), customerId));
rows.sort(Comparator.comparing(RawLedger::occurredAt).thenComparing(RawLedger::id));
BigDecimal outstanding = BigDecimal.ZERO;
List<LedgerEntry> projected = new ArrayList<>();
@@ -76,7 +93,7 @@ public class FinancePhase1OperationService {
BigDecimal receivableChange = row.receivable();
outstanding = outstanding.add(receivableChange);
BigDecimal payable = "COST".equals(row.documentType()) ? row.amount() : BigDecimal.ZERO;
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, "operator-" + row.operatorId(), row.remark());
LedgerEntry entry = new LedgerEntry(row.id(), row.occurredAt(), row.businessType(), row.documentType(), row.id(), row.documentNo(), receivableChange, payable, outstanding, row.operatorName(), row.remark());
if ((businessType == null || businessType.isBlank() || businessType.equals(entry.businessType())) && inRange(entry.occurredAt(), startDate, endDate)) projected.add(entry);
}
projected.sort(Comparator.comparing(LedgerEntry::occurredAt).reversed()
@@ -86,6 +103,50 @@ public class FinancePhase1OperationService {
return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size());
}
public PageEnvelope<FeeSourceSummary> listFinanceFeeSources(int page, int pageSize, Long customerId,
String direction, String sourceStatus,
String reviewStatus,
Boolean statementGenerated, LocalDate businessDateFrom,
LocalDate businessDateTo, String keyword) {
page = page(page); pageSize = pageSize(pageSize);
if (customerId != null) customer(customerId);
if (businessDateFrom != null && businessDateTo != null && businessDateFrom.isAfter(businessDateTo)) {
throw error("FIN-REQ-400", "开始日期不能晚于结束日期");
}
String normalizedDirection = normalized(direction, List.of("RECEIVABLE", "PAYABLE"), "费用方向无效");
String normalizedReview = normalized(reviewStatus, List.of("PENDING", "APPROVED", "REJECTED"), "审核状态无效");
String normalizedSourceStatus = normalized(sourceStatus,
List.of("AVAILABLE", "LOCKED", "BILLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效");
String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)";
String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END";
String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')";
StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1");
List<Object> args = new ArrayList<>();
if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); }
if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); }
if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); }
if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); }
if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); }
if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); }
if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); }
String normalizedKeyword = blankToNull(keyword);
if (normalizedKeyword != null) {
where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')")
.append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))");
for (int i = 0; i < 6; i++) args.add(normalizedKeyword);
}
Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray());
List<Object> queryArgs = new ArrayList<>(args);
queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize);
String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" +
where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?";
List<FeeSourceSummary> rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray());
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
}
public FeeSourceDetail getFinanceFeeSource(long feeSourceId) {
if (feeSourceId <= 0) throw error("FIN-REQ-400", "费用来源ID无效");
List<FeeSourceDetail> rows = jdbc.query("SELECT * FROM fin_fee_source WHERE id=?", (rs, n) -> detail(rs), feeSourceId);
@@ -101,7 +162,7 @@ public class FinancePhase1OperationService {
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_REVIEW", key, "POST", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/review", request);
if (replay != null) return getFinanceFeeSource(replay);
String review = "APPROVE".equals(request.action()) ? "APPROVED" : "REJECTED";
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
int changed = jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status='PENDING'",
review, request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许审核");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
@@ -111,19 +172,106 @@ public class FinancePhase1OperationService {
}
@Transactional
public FeeSourceDetail updateFeeSourceSettlementInclusion(long feeSourceId, long operatorId, String key, SettlementInclusionRequest request) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) throw error("FIN-REQ-400", "参与结算请求无效");
public FeeSourceDetail editFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceEditRequest request, boolean adjustment) {
if (request == null || request.expectedVersion() < 0 || request.amount() == null
|| request.amount().signum() <= 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, "PUT", "/api/platform/finance/v1/fee-sources/" + feeSourceId + "/settlement-inclusion", request);
if ("FIRSTLEG".equals(before.sourceSystem())) {
throw error("FIN-FEE-409", "Firstleg费用必须在入库或柜次费用明细中调整,Finance不可直接修改业务金额");
}
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
Long replay = reserve(before.customerId(), operatorId, operation, key,
adjustment ? "POST" : "PUT", path, request);
if (replay != null) return getFinanceFeeSource(replay);
if (request.included() && !"APPROVED".equals(before.reviewStatus())) throw error("FIN-FEE-409", "只有审核通过的费用来源可参与结算");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成对账单的费用来源不可修改参与结算状态");
int changed = jdbc.update("UPDATE fin_fee_source SET settlement_included=?,settlement_reason=?,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE'",
request.included(), request.reason(), feeSourceId, request.expectedVersion());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许修改参与结算状态");
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可编辑或调整");
String requiredStatus = adjustment ? "APPROVED" : before.reviewStatus();
if (!adjustment && !List.of("DRAFT", "REJECTED").contains(requiredStatus)) {
throw error("FIN-FEE-409", "只有草稿或已驳回费用可编辑");
}
int changed = jdbc.update("UPDATE fin_fee_source SET amount=?,review_status='DRAFT',review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
request.amount(), feeSourceId, request.expectedVersion(), requiredStatus);
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本不允许编辑或调整");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, request.included() ? "FEE_INCLUDED" : "FEE_EXCLUDED", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, "FEE_SOURCE_SETTLEMENT", key, feeSourceId);
audit(after.customerId(), feeSourceId, adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT", request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@Transactional
public FeeSourceDetail submitFinanceFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_SUBMIT",
"PENDING", List.of("DRAFT", "REJECTED"), false);
}
@Transactional
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId,
String operatorName, String key,
FeeSourceActionRequest request) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
if (!"FIRSTLEG".equals(before.sourceSystem())) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
}
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, "POST",
"/api/platform/finance/v1/fee-sources/" + feeSourceId + "/void", request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可作废");
if (before.operationVersion() != request.expectedVersion()) {
throw error("FIN-FEE-409", "费用来源状态或版本已变化");
}
if (!List.of("DRAFT", "PENDING", "REJECTED", "APPROVED").contains(before.reviewStatus())
|| !"AVAILABLE".equals(before.sourceStatus())) {
throw error("FIN-FEE-409", "费用来源当前状态不允许作废");
}
FirstlegFeeSourceClient.VoidCommand command = new FirstlegFeeSourceClient.VoidCommand(
before.sourceType(), before.sourceFeeId(), before.sourceVersion(), before.customerId(),
before.inboundId(), before.containerId(), operatorId,
StringUtils.hasText(operatorName) ? operatorName.trim() : "SYSTEM", request.reason().trim());
try {
var response = firstleg.voidSource(internalToken, key, hash(command), command);
if (response == null || !response.successful() || response.data() == null
|| !"VOIDED".equals(response.data().status())) {
throw error("FIN-UPSTREAM-502", "Firstleg未确认费用来源作废");
}
} catch (FeignException exception) {
throw error("FIN-UPSTREAM-503", "Firstleg费用作废服务暂不可用");
}
audit(before.customerId(), feeSourceId, "FEE_SOURCE_VOID_REQUESTED",
request.reason(), before, before, operatorId);
complete(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, feeSourceId);
return getFinanceFeeSource(feeSourceId);
}
private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key,
FeeSourceActionRequest request, String operation,
String target, List<String> allowedReviews,
boolean voidSource) {
if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
Long replay = reserve(before.customerId(), operatorId, operation, key, "POST",
"/api/platform/finance/v1/fee-sources/" + feeSourceId + (voidSource ? "/void" : "/submit"), request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可操作");
if (!allowedReviews.contains(before.reviewStatus())) throw error("FIN-FEE-409", "费用来源当前状态不允许该操作");
int changed = voidSource
? jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
feeSourceId, request.expectedVersion(), before.reviewStatus())
: jdbc.update("UPDATE fin_fee_source SET review_status=?,review_reason=NULL,operation_version=operation_version+1 WHERE id=? AND operation_version=? AND status='AVAILABLE' AND review_status=?",
target, feeSourceId, request.expectedVersion(), before.reviewStatus());
if (changed != 1) throw error("FIN-FEE-409", "费用来源状态或版本已变化");
FeeSourceDetail after = getFinanceFeeSource(feeSourceId);
audit(after.customerId(), feeSourceId, operation, request.reason(), before, after, operatorId);
complete(after.customerId(), operatorId, operation, key, feeSourceId);
return after;
}
@@ -136,9 +284,27 @@ public class FinancePhase1OperationService {
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = rs.getBigDecimal("amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0 ? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
return new FeeSourceDetail(id, rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), null, null,
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "PENDING"), rs.getBoolean("settlement_included"), status, generated,
rs.getString("review_reason"), rs.getString("settlement_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
return new FeeSourceDetail(id, rs.getString("source_system"), rs.getString("source_type"), firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), quantity, unitPrice, amount, rs.getString("currency_code"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated,
rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id));
}
private FeeSourceSummary summary(ResultSet rs) throws SQLException {
JsonNode snapshot = parse(rs.getString("source_snapshot"));
boolean generated = rs.getBoolean("statement_generated");
BigDecimal quantity = decimal(snapshot, "chargeableQuantity");
BigDecimal amount = money(rs, "amount");
BigDecimal unitPrice = quantity != null && quantity.signum() > 0
? amount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null;
return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"),
firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"),
jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"),
nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"),
text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"),
quantity, unitPrice, amount, rs.getString("currency_code"),
defaultString(rs.getString("review_status"), "DRAFT"),
rs.getString("effective_status"), generated);
}
private List<OperationLog> logs(long customerId, long id) {
@@ -166,23 +332,33 @@ public class FinancePhase1OperationService {
private void audit(long customerId, long id, String action, String reason, Object before, Object after, long operatorId) {
jdbc.update("INSERT INTO fin_audit_log(tenant_id,resource_type,resource_id,action,reason,before_data,after_data,operator_id,operator_name_snapshot,request_id,trace_id) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId, "operator-" + operatorId, MDC.get("requestId"), MDC.get("traceId"));
customerId, "FIN_FEE_SOURCE", id, action, reason, json(before), json(after), operatorId,
FinanceOperatorSnapshot.requiredName(operatorId), MDC.get("requestId"), MDC.get("traceId"));
}
private static RawLedger raw(ResultSet rs, String businessType, String documentType, String noColumn, String debitColumn, String creditColumn) throws SQLException {
LocalDateTime occurred = time(rs, documentType.equals("RECEIPT") ? "received_time" : "created_at");
BigDecimal amount = documentType.equals("COST") ? money(rs, "cost_amount") : debitColumn != null ? money(rs, debitColumn) : money(rs, creditColumn);
BigDecimal receivable = debitColumn != null ? amount : creditColumn != null ? amount.negate() : BigDecimal.ZERO;
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable, rs.getLong("created_by"), rs.getString("remark"));
return new RawLedger(rs.getLong("id"), occurred, businessType, documentType, rs.getString(noColumn), amount, receivable,
rs.getLong("created_by"), rs.getString("created_by_name"), rs.getString("remark"));
}
private record RawLedger(long id, LocalDateTime occurredAt, String businessType, String documentType,
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId, String remark) {}
String documentNo, BigDecimal amount, BigDecimal receivable, long operatorId,
String operatorName, String remark) {}
private static int page(int value) { if (value < 1) throw error("FIN-REQ-400", "页码必须大于0"); return value; }
private static int pageSize(int value) { if (value < 1 || value > 200) throw error("FIN-REQ-400", "每页数量必须在1到200之间"); return value; }
private static void customer(long value) { if (value <= 0) throw error("FIN-REQ-400", "客户ID无效"); }
private static boolean inRange(LocalDateTime value, LocalDate start, LocalDate end) { return value != null && (start == null || !value.toLocalDate().isBefore(start)) && (end == null || !value.toLocalDate().isAfter(end)); }
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String normalized(String value, List<String> allowed, String message) {
String normalized = blankToNull(value);
if (normalized == null) return null;
normalized = normalized.toUpperCase(java.util.Locale.ROOT);
if (!allowed.contains(normalized)) throw error("FIN-REQ-400", message);
return normalized;
}
private static BigDecimal money(ResultSet rs, String column) throws SQLException { BigDecimal value = rs.getBigDecimal(column); return value == null ? BigDecimal.ZERO : value; }
private static LocalDateTime time(ResultSet rs, String column) throws SQLException { Timestamp value = rs.getTimestamp(column); return value == null ? null : value.toLocalDateTime(); }
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
@@ -190,6 +366,9 @@ public class FinancePhase1OperationService {
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); return value.isIntegralNumber() && value.asLong() > 0 ? value.asLong() : null; }
private static Long firstLong(Long first, Long second) { return first != null ? first : second; }
private static String firstText(String first, String second) { return StringUtils.hasText(first) ? first : second; }
private static String defaultText(JsonNode node, String field, String fallback) { String value = text(node, field); return value == null || value.isBlank() ? fallback : value; }
private static String defaultString(String value, String fallback) { return value == null || value.isBlank() ? fallback : value; }
private static BigDecimal decimal(JsonNode node, String field) { String value = text(node, field); return value == null || value.isBlank() ? null : new BigDecimal(value); }

Some files were not shown because too many files have changed in this diff Show More