Compare commits
148
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802893b2ff | ||
|
|
b139255d3b | ||
|
|
d3183d8f2c | ||
|
|
3e517ee74e | ||
|
|
d340c418e8 | ||
|
|
3b19a1bc16 | ||
|
|
15d3ffbc0a | ||
|
|
b060be18a9 | ||
|
|
b029f4ce28 | ||
|
|
fd1da73fa8 | ||
|
|
c83620a86a | ||
|
|
b464de1edb | ||
|
|
3d57abf260 | ||
|
|
0384f54da7 | ||
|
|
bdf545ee0a | ||
|
|
0575ea247f | ||
|
|
7a5719b5a1 | ||
|
|
d36e480413 | ||
|
|
e469749a98 | ||
|
|
be5d89a4e6 | ||
|
|
7cf4a8d562 | ||
|
|
cf2356bccb | ||
|
|
f227cf05a3 | ||
|
|
f03ea227ed | ||
|
|
26eba227eb | ||
|
|
064247a8c9 | ||
|
|
f78b44297d | ||
|
|
88100f3536 | ||
|
|
63618c20dc | ||
|
|
a4ec4e8e99 | ||
|
|
3b8934fc60 | ||
|
|
6acb6178da | ||
|
|
f5c4bf130a | ||
|
|
f25e270f22 | ||
|
|
000fd10b6a | ||
|
|
23e78bb33f | ||
|
|
41d70841fc | ||
|
|
008d7ba574 | ||
|
|
b908d9f248 | ||
|
|
80527fa15f | ||
|
|
a381d443da | ||
|
|
b5864bfcda | ||
|
|
ff1d330c2d | ||
|
|
8845a66749 | ||
|
|
688e5649b0 | ||
|
|
d70c08a378 | ||
|
|
a9061eca54 | ||
|
|
7fe5b4e31f | ||
|
|
fa77170304 | ||
|
|
82ee80226e | ||
|
|
12e9f3746e | ||
|
|
6d5ec5759b | ||
|
|
9db371ab0e | ||
|
|
81e6610eb8 | ||
|
|
b26e64bb8d | ||
|
|
4b0b3e9172 | ||
|
|
4e4d047862 | ||
|
|
cad9a81d21 | ||
|
|
ec4b1e0fe5 | ||
|
|
3e1010fa26 | ||
|
|
ea26eecc50 | ||
|
|
092c003e3a | ||
|
|
b8c7f9fea2 | ||
|
|
45b51415cf | ||
|
|
cddd23e4c7 | ||
|
|
5199146e97 | ||
|
|
5c22018edd | ||
|
|
fd4afd6ae6 | ||
|
|
7c66097672 | ||
|
|
c71db4b481 | ||
|
|
592da4d7e2 | ||
|
|
75580c3585 | ||
|
|
68d9216cb8 | ||
|
|
5f79616623 | ||
|
|
4abcb2b80f | ||
|
|
a34b5d5ba0 | ||
|
|
b914bb7532 | ||
|
|
6739c83467 | ||
|
|
ac6cf9c904 | ||
|
|
8566c6ea9e | ||
|
|
6b416621bb | ||
|
|
d51e054def | ||
|
|
7a9c72a6a2 | ||
|
|
20d836ab3d | ||
|
|
f2a49d032a | ||
|
|
2cec065582 | ||
|
|
0e2a291e7a | ||
|
|
5e1e49085d | ||
|
|
28daf15082 | ||
|
|
05921eca13 | ||
|
|
8350bcdf00 | ||
|
|
a35794e918 | ||
|
|
5b8e7d6989 | ||
|
|
0ddffeddc8 | ||
|
|
3a9558bd1c | ||
|
|
08187363a1 | ||
|
|
46cb95db15 | ||
|
|
282eb73371 | ||
|
|
0f915d7b24 | ||
|
|
c544b1bc39 | ||
|
|
792965c822 | ||
|
|
dfa3e8cc53 | ||
|
|
fdea2a6f31 | ||
|
|
c4adba57db | ||
|
|
557ab75319 | ||
|
|
c4282020b5 | ||
|
|
555acca779 | ||
|
|
9e7cc55eab | ||
|
|
c6d7fb0d84 | ||
|
|
233f8f312d | ||
|
|
bafabf7217 | ||
|
|
e7c0788a0b | ||
|
|
c07af65c3b | ||
|
|
2bf3ff9d8e | ||
|
|
20907e4685 | ||
|
|
1982a7dd69 | ||
|
|
e4d5e6d631 | ||
|
|
5bb8f4db6d | ||
|
|
32e9b69556 | ||
|
|
1db7c3b3cf | ||
|
|
b89f14217d | ||
|
|
0c845874f4 | ||
|
|
011c30cc09 | ||
|
|
8aa58a4f72 | ||
|
|
b2a093b31b | ||
|
|
010098c4af | ||
|
|
fdd011bc57 | ||
|
|
dbca4a1403 | ||
|
|
9bb046063c | ||
|
|
7cb5da753e | ||
|
|
a4a4374c67 | ||
|
|
7184f5083a | ||
|
|
993b3dfe03 | ||
|
|
7d4256dbd2 | ||
|
|
10d3390bb1 | ||
|
|
9b36978448 | ||
|
|
e33b2c8645 | ||
|
|
2752cfb5b2 | ||
|
|
feced8eb92 | ||
|
|
87e919f3c4 | ||
|
|
87eb1aa72f | ||
|
|
36a048a2bc | ||
|
|
261a8bf2df | ||
|
|
06c264db0a | ||
|
|
ce6963a25e | ||
|
|
91e781b3ff | ||
|
|
66089f9b99 | ||
|
|
0bc752a2be |
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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
@@ -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("******");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
|
||||
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
|
||||
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。
|
||||
- 新能力只有被多个模块稳定复用时才放入本模块。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
+79
@@ -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) {}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 序列化保持一致。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 模块。
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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}" \
|
||||
|
||||
+982
-276
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/`
|
||||
|
||||
@@ -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);
|
||||
|
||||
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -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")
|
||||
|
||||
+24
@@ -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) {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
|
||||
+5
-2
@@ -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()) {
|
||||
|
||||
+6
-24
@@ -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());
|
||||
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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());
|
||||
}
|
||||
}
|
||||
+7
@@ -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
-3
@@ -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) {
|
||||
|
||||
+88
@@ -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 '聚合版本',
|
||||
|
||||
+6
-20
@@ -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("令牌无效");
|
||||
|
||||
+37
-91
@@ -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;
|
||||
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+94
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))));
|
||||
|
||||
+52
@@ -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
@@ -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 = "客户只读查询结算应收")
|
||||
|
||||
+51
@@ -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));
|
||||
}
|
||||
}
|
||||
+66
-11
@@ -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) {}
|
||||
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+205
-26
@@ -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
Reference in New Issue
Block a user