Compare commits
218
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b42c7579 | ||
|
|
fd3dbb2fc6 | ||
|
|
757f6ef24f | ||
|
|
a3715f618f | ||
|
|
cf77e19765 | ||
|
|
6b2339e50a | ||
|
|
16a60a9175 | ||
|
|
12b749d7d8 | ||
|
|
6f5398770b | ||
|
|
e4ee999098 | ||
|
|
fa9a98f078 | ||
|
|
71f6dfa1eb | ||
|
|
9adbdfbcc1 | ||
|
|
30090e741f | ||
|
|
596a5c925c | ||
|
|
62855915fc | ||
|
|
18c673811f | ||
|
|
5099a46581 | ||
|
|
3bdaba2c60 | ||
|
|
6cd477a037 | ||
|
|
03d04431f2 | ||
|
|
12fbf2aa67 | ||
|
|
601ae83504 | ||
|
|
5f688d6131 | ||
|
|
ecf4eb1211 | ||
|
|
2902ab6077 | ||
|
|
25c4dd50b3 | ||
|
|
9e0d2f6cdb | ||
|
|
70d66c2fae | ||
|
|
b65f98451d | ||
|
|
e48e8dca42 | ||
|
|
8da57487c0 | ||
|
|
19a6af1c6d | ||
|
|
9ea4ccb9b1 | ||
|
|
b6800a48c6 | ||
|
|
5d098c9762 | ||
|
|
f4c139815c | ||
|
|
69ab3e9a90 | ||
|
|
68340eb616 | ||
|
|
f7f8ff53c0 | ||
|
|
275d4df35d | ||
|
|
e3fd48c3b9 | ||
|
|
a4d8fa9f8b | ||
|
|
9e70f4339a | ||
|
|
f0da6e5110 | ||
|
|
d786b88030 | ||
|
|
6acb5297e5 | ||
|
|
bb9fb9ffe6 | ||
|
|
8e7835fe86 | ||
|
|
31996491be | ||
|
|
e0a1f15777 | ||
|
|
371aa3dbcb | ||
|
|
5f8b7baa06 | ||
|
|
37c37c75c8 | ||
|
|
70bbdd5b7b | ||
|
|
da56f261cd | ||
|
|
8a66af81b1 | ||
|
|
e83dff6ac7 | ||
|
|
b85e021cbe | ||
|
|
e82cefe493 | ||
|
|
47723c7c0f | ||
|
|
b9cb5555e2 | ||
|
|
858061452c | ||
|
|
94c67b5c51 | ||
|
|
8e17401f37 | ||
|
|
539030d2b6 | ||
|
|
b4e496e9cb | ||
|
|
2ec8fcb886 | ||
|
|
405d6b94d7 | ||
|
|
caffcb90a9 | ||
|
|
222c3790dc | ||
|
|
0ba3df2fce | ||
|
|
9098d8dfdf | ||
|
|
eb385fe906 | ||
|
|
ecc83ed64a | ||
|
|
ca8127ce83 | ||
|
|
aa828ca1eb | ||
|
|
df1ffbc6bc | ||
|
|
8b2a964bfb | ||
|
|
07ffd8ddce | ||
|
|
d1225845ec | ||
|
|
4d7ea28a61 | ||
|
|
1e8f504f15 | ||
|
|
a10e32a012 | ||
|
|
76d2da5bf9 | ||
|
|
f0ac0c42c9 | ||
|
|
105660bd09 | ||
|
|
2ccfc38cdf | ||
|
|
d2092bf0b6 | ||
|
|
9e32efdd90 | ||
|
|
6eaf484fcd | ||
|
|
53078b4281 | ||
|
|
49d216cc13 | ||
|
|
9a3b408de0 | ||
|
|
68b584128c | ||
|
|
b5bc77b153 | ||
|
|
de0eddeee8 | ||
|
|
4991536065 | ||
|
|
7ace1ded56 | ||
|
|
5af79df4bd | ||
|
|
6eb53751a3 | ||
|
|
09e81b429c | ||
|
|
179a20fc38 | ||
|
|
6a0f51f136 | ||
|
|
7d9eaa7f43 | ||
|
|
bfc2a4dbd4 | ||
|
|
0613d1095e | ||
|
|
3d7990250f | ||
|
|
2c3efad70a | ||
|
|
cdcf3e0f4f | ||
|
|
3d6216910f | ||
|
|
505188c3fd | ||
|
|
477971dda1 | ||
|
|
249b182e7f | ||
|
|
936412f351 | ||
|
|
0cc4eae209 | ||
|
|
f2a980e5c1 | ||
|
|
47ce84d122 | ||
|
|
01385c4bc7 | ||
|
|
9b09663b2e | ||
|
|
838bd93bc4 | ||
|
|
60d45613c7 | ||
|
|
4ddfc6fb99 | ||
|
|
df0fbc1c7f | ||
|
|
4795ab958c | ||
|
|
1288c8937c | ||
|
|
ba8e3595e4 | ||
|
|
17d81b0b94 | ||
|
|
c9792b1f63 | ||
|
|
0122ad314b | ||
|
|
ea615b864a | ||
|
|
314ce09b5d | ||
|
|
942dbace19 | ||
|
|
b386f35ab4 | ||
|
|
d6f47e650c | ||
|
|
bef91151a3 | ||
|
|
bf1dc1f209 | ||
|
|
aa583efa4a | ||
|
|
ff6a8498a6 | ||
|
|
ca9e7b60e8 | ||
|
|
0aa77327d5 | ||
|
|
5c83b81e8a | ||
|
|
c9ee319e9d | ||
|
|
4581c8a294 | ||
|
|
9a15e78b2e | ||
|
|
bfcb80b326 | ||
|
|
0cf76df168 | ||
|
|
ba641754de | ||
|
|
81ef43c9fd | ||
|
|
0605c32829 | ||
|
|
66816d5341 | ||
|
|
de101621dd | ||
|
|
b4db37392d | ||
|
|
b1ab909184 | ||
|
|
775dd71227 | ||
|
|
3339ec91f9 | ||
|
|
ac6d3289a6 | ||
|
|
e6ee9cb442 | ||
|
|
da32a67d7f | ||
|
|
969ff05815 | ||
|
|
7f565a24d6 | ||
|
|
1e389d95dd | ||
|
|
f53ff76143 | ||
|
|
f87647d4eb | ||
|
|
833f928a30 | ||
|
|
621aec9b2b | ||
|
|
f72238e43f | ||
|
|
7a15481e2d | ||
|
|
2dca7dfdb2 | ||
|
|
1c51373704 | ||
|
|
9664aa4bf0 | ||
|
|
603bd29cc2 | ||
|
|
0afe75379e | ||
|
|
0ea4aeecc9 | ||
|
|
bb9673358d | ||
|
|
6b5f2f0565 | ||
|
|
fe68551124 | ||
|
|
f998bed09c | ||
|
|
1e5ce54858 | ||
|
|
b34fbb2bd1 | ||
|
|
a6f266687e | ||
|
|
dd117b8fc2 | ||
|
|
a719a903cd | ||
|
|
3b1b4ab9b2 | ||
|
|
9d54c57b7c | ||
|
|
e72ac14425 | ||
|
|
ec8f84c1c0 | ||
|
|
2b7f66cb04 | ||
|
|
c7061b8a74 | ||
|
|
5250f304be | ||
|
|
f5f72d681d | ||
|
|
c226b6a99b | ||
|
|
600b2a2dc4 | ||
|
|
76c24867a4 | ||
|
|
61a97cd057 | ||
|
|
ecf48886d5 | ||
|
|
1db3e48506 | ||
|
|
0f2e201c38 | ||
|
|
adabec13d3 | ||
|
|
0fb51211aa | ||
|
|
d7bff980ca | ||
|
|
ed3c1dafcc | ||
|
|
0a3ec0b8a6 | ||
|
|
3b38fdd33d | ||
|
|
daf0ee2acd | ||
|
|
086a0f9c89 | ||
|
|
c80307a838 | ||
|
|
575cee7a86 | ||
|
|
c1b136c3d6 | ||
|
|
4612181ab0 | ||
|
|
b4c46280de | ||
|
|
15b563610c | ||
|
|
079ff43f73 | ||
|
|
554b6c93b7 | ||
|
|
817e356c21 | ||
|
|
ca12f58d38 | ||
|
|
8fb63e9adc | ||
|
|
d736fdba1b |
Binary file not shown.
@@ -1,39 +0,0 @@
|
|||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.ResultSetMetaData;
|
|
||||||
import java.sql.Statement;
|
|
||||||
|
|
||||||
public final class DbQuery {
|
|
||||||
public static void main(String[] args) throws Exception {
|
|
||||||
if (args.length < 2) {
|
|
||||||
throw new IllegalArgumentException("usage: DbQuery <database> <sql>");
|
|
||||||
}
|
|
||||||
String url = "jdbc:mysql://127.0.0.1:3306/" + args[0]
|
|
||||||
+ "?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"
|
|
||||||
+ "&useSSL=false&allowPublicKeyRetrieval=true";
|
|
||||||
try (Connection connection = DriverManager.getConnection(url, "root", "123456");
|
|
||||||
Statement statement = connection.createStatement()) {
|
|
||||||
boolean hasResult = statement.execute(args[1]);
|
|
||||||
if (!hasResult) {
|
|
||||||
System.out.println("updateCount=" + statement.getUpdateCount());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try (ResultSet result = statement.getResultSet()) {
|
|
||||||
ResultSetMetaData meta = result.getMetaData();
|
|
||||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
|
||||||
if (column > 1) System.out.print("\t");
|
|
||||||
System.out.print(meta.getColumnLabel(column));
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
while (result.next()) {
|
|
||||||
for (int column = 1; column <= meta.getColumnCount(); column++) {
|
|
||||||
if (column > 1) System.out.print("\t");
|
|
||||||
System.out.print(String.valueOf(result.getObject(column)));
|
|
||||||
}
|
|
||||||
System.out.println();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,13 +22,19 @@
|
|||||||
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||||
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 |
|
||||||
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||||
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 |
|
||||||
|
| 平台修改客户原始申报、履约渠道变更与跨服务同步 | `order/`、`firstleg/` | Order `OrderDeclarationUpdateService.java`、`OrderInboundOutboxPublisher.java`、`order-v1.yaml`;Firstleg `OrderDeclarationSyncService.java`、`OrderDeclarationSyncMapper.java`、`firstleg-v1.yaml`。只更新申报快照;渠道变化先失败关闭校验履约可逆性与重新计价,成功后由版本化 `ORDER_DECLARATION_UPDATED_V1` 幂等同步,不覆盖实测、箱码或已确认费用 |
|
||||||
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||||
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
|
||||||
|
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
|
||||||
|
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在。装柜证据上传重试按幂等键与包含文件内容的请求哈希识别,完全重放不得再次调用 File |
|
||||||
|
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
|
||||||
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
||||||
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
|
||||||
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||||
|
| 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | Firstleg `PlatformContainerFeeDetailController.java`、`ContainerFeeDetailService.java`、`container-fees-v1.yaml` 按柜内真实客户与订单原子创建 `allocation-groups`;金额合计必须等于总额,分组审核、驳回、调整、作废均整组处理,Finance 只消费各客户不可变来源快照 |
|
||||||
|
| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | Lastleg `financefee/` 审核五类海外费用并发布 `BUSINESS_FEE_SOURCE_CHANGED` 完整快照;Event Bridge 对 `targetRegion=CN` 定向投递 Finance `/internal/finance/v1/business-fee-source-events`;Finance 按事件 ID、Payload 哈希、来源系统/费用 ID/版本幂等入池,`PAYABLE + DIRECT_PAID` 单独形成已确认成本且不进入 AP 待结算 |
|
||||||
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
| 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | 业务费用与付款快照见 `firstleg/AGENTS.md` 的 `FeePaymentSnapshotValidator`;`/api/platform/finance/v1/vouchers` 上传凭证,File 按 `FINANCE_VOUCHER` 和客户范围校验 |
|
||||||
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||||
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
||||||
@@ -40,6 +46,7 @@
|
|||||||
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` |
|
||||||
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` |
|
||||||
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
|
||||||
|
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合,分页前排除全部入仓码均已生成有效独立计划的柜,只返回仍有待生成计划入仓码的柜;详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实并升序、空值最后;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 |
|
||||||
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
| 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 |
|
||||||
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
|
||||||
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
| 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` |
|
||||||
@@ -77,6 +84,7 @@
|
|||||||
|
|
||||||
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||||
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
- 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。
|
||||||
|
- 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。
|
||||||
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
|
- 平台后台存在正式列表页时,`*: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` 必须停用/移除,不得重新提升或兼容。
|
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||||
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||||
@@ -85,16 +93,20 @@
|
|||||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn 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/数据库,除非用户明确要求。
|
||||||
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、仓库主档唯一权威、订舱关联候选与柜量语义、整柜出仓与船节点单调性、海外 Gateway 覆盖、PDA 运行配置/独立更新/语言预留、人工分段验收链、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
|
||||||
|
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
|
||||||
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
|
||||||
|
|
||||||
## 全量 HTTP 验收脚本路由
|
## 全量 HTTP 验收脚本路由
|
||||||
|
|
||||||
|
- 脚本管理的唯一总索引是 `scripts/README.md`,最近执行规则是 `scripts/AGENTS.md`。仓库级脚本、Admin 内部模块、服务局部合同和部署脚本的目录归属以这两个文件为准,不再通过无范围搜索猜测入口。
|
||||||
- 全量验收唯一指向测试环境 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`,不替代客户端全量入口。
|
||||||
|
- 固定客户待审核订单使用 `scripts/create-pending-review-orders.mjs`;固定“IT测试”与“义乌仓”、随机真实线路并推进至 `WAIT_SPLIT` 使用 `scripts/it-test-wait-split-bulk.mjs`。二者都是造数入口,不属于全量验证;会写目标环境且不自动清理成功数据。
|
||||||
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||||
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
- Java 模块、OpenAPI 覆盖、JaCoCo 与 SpotBugs 聚合摘要使用 `node scripts/java-quality-summary.mjs`;默认仅报告存量缺口,是否阻断由显式 `--require-*` 参数决定,输出位于 `target/` 时不得作为业务事实或提交物。
|
||||||
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||||
|
- 修改脚本目录、入口或说明后,至少运行 `node --test scripts/script-documentation-contract.test.mjs`;修改 `AGENTS.md` 后继续运行 `./scripts/agents-index-contract.ps1`。两者均为离线只读合同,不能代替真实 HTTP 验收。
|
||||||
|
|
||||||
## 阶段文档入口
|
## 阶段文档入口
|
||||||
|
|
||||||
|
|||||||
Vendored
+24
-9
@@ -60,14 +60,15 @@ def databaseMigrationPaths = ['deploy/sql/']
|
|||||||
def downstreamResults = [:]
|
def downstreamResults = [:]
|
||||||
|
|
||||||
def parseAutoBuildState(String description, List<String> allowedServices) {
|
def parseAutoBuildState(String description, List<String> allowedServices) {
|
||||||
String marker = ' | RESULTS'
|
if (!description?.startsWith('AUTO ')) return null
|
||||||
if (!description?.startsWith('AUTO ') || !description.contains(marker)) return null
|
int markerIndex = description.indexOf(' | ')
|
||||||
int markerIndex = description.indexOf(marker)
|
if (markerIndex < 0) return null
|
||||||
String commit = description.substring('AUTO '.length(), markerIndex).trim()
|
String commit = description.substring('AUTO '.length(), markerIndex).trim()
|
||||||
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
|
if (!(commit ==~ /[0-9a-fA-F]{7,40}/)) return null
|
||||||
String summary = description.substring(markerIndex + marker.length()).trim()
|
String state = description.substring(markerIndex + ' | '.length()).trim()
|
||||||
List<String> failedServices = []
|
List<String> failedServices = []
|
||||||
if (summary) {
|
if (state.startsWith('RESULTS')) {
|
||||||
|
String summary = state.substring('RESULTS'.length()).trim()
|
||||||
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
summary.split(',').collect { it.trim() }.findAll { it }.each { item ->
|
||||||
int separator = item.indexOf('=')
|
int separator = item.indexOf('=')
|
||||||
if (separator > 0) {
|
if (separator > 0) {
|
||||||
@@ -76,6 +77,17 @@ def parseAutoBuildState(String description, List<String> allowedServices) {
|
|||||||
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
|
if (allowedServices.contains(service) && result != 'SUCCESS') failedServices << service
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (state.startsWith('PENDING')) {
|
||||||
|
// 上一轮若在统一测试、排队或并行部署期间失败/取消,description 仍是 PENDING。
|
||||||
|
// 将该轮已选服务作为精确补跑范围,同时让 Git 基线前进到该轮提交,
|
||||||
|
// 避免回退到很早的成功构建后把历史公共文件再次解释成全量变更。
|
||||||
|
String selected = state.substring('PENDING'.length()).trim()
|
||||||
|
int suffixIndex = selected.indexOf(' | ')
|
||||||
|
if (suffixIndex >= 0) selected = selected.substring(0, suffixIndex).trim()
|
||||||
|
failedServices.addAll(selected.split(',').collect { it.trim() }
|
||||||
|
.findAll { allowedServices.contains(it) })
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
return [commit: commit, failedServices: failedServices.unique()]
|
return [commit: commit, failedServices: failedServices.unique()]
|
||||||
}
|
}
|
||||||
@@ -85,7 +97,8 @@ def previousAutoBuildState(def previousBuild, List<String> allowedServices) {
|
|||||||
int inspected = 0
|
int inspected = 0
|
||||||
while (candidate != null && inspected < 30) {
|
while (candidate != null && inspected < 30) {
|
||||||
def state = parseAutoBuildState(candidate.description, allowedServices)
|
def state = parseAutoBuildState(candidate.description, allowedServices)
|
||||||
if (state != null && ['SUCCESS', 'UNSTABLE'].contains(candidate.result)) return state
|
// 完成的 RESULTS 和中断留下的 PENDING 都能推进 AUTO 基线;PENDING 中的服务会被精确补跑。
|
||||||
|
if (state != null) return state
|
||||||
candidate = candidate.previousBuild
|
candidate = candidate.previousBuild
|
||||||
inspected++
|
inspected++
|
||||||
}
|
}
|
||||||
@@ -190,9 +203,10 @@ pipeline {
|
|||||||
String base = autoState?.commit?.trim()
|
String base = autoState?.commit?.trim()
|
||||||
List retryServices = autoState?.failedServices ?: []
|
List retryServices = autoState?.failedServices ?: []
|
||||||
if (!base) {
|
if (!base) {
|
||||||
// 首次启用新算法时兼容旧构建记录;后续只读取标记为 AUTO 的完成记录,
|
// 首次启用或旧构建记录已淘汰时,只比较当前提交的直接父提交。
|
||||||
// SELECT/ALL 不会推进 AUTO 基线。
|
// 需要初始化全部服务时应显式使用 ALL,不能因历史成功基线过旧而意外全量部署。
|
||||||
base = env.GIT_PREVIOUS_SUCCESSFUL_COMMIT?.trim()
|
base = sh(script: 'git rev-parse HEAD^ 2>/dev/null || true', returnStdout: true).trim()
|
||||||
|
if (base) echo "AUTO 未找到历史记录,本次使用直接父提交作为基线: ${base}"
|
||||||
}
|
}
|
||||||
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
if (!base || sh(script: "git cat-file -e ${base}^{commit} 2>/dev/null", returnStatus: true) != 0) {
|
||||||
selected = serviceModules
|
selected = serviceModules
|
||||||
@@ -295,6 +309,7 @@ pipeline {
|
|||||||
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}"
|
||||||
sh """
|
sh """
|
||||||
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\"
|
||||||
|
find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete
|
||||||
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test
|
||||||
"""
|
"""
|
||||||
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试"
|
||||||
|
|||||||
@@ -33,6 +33,22 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务
|
|||||||
|
|
||||||
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
||||||
|
|
||||||
|
## 容器日志
|
||||||
|
|
||||||
|
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
|
||||||
|
|
||||||
|
单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖:
|
||||||
|
|
||||||
|
| 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|
||||||
|
|---|---|---:|---:|---|
|
||||||
|
| 开发 | `json-file` | `20m` | `2` | 不要求 |
|
||||||
|
| 测试 | `json-file` | `50m` | `3` | 不要求 |
|
||||||
|
| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 |
|
||||||
|
|
||||||
|
当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。
|
||||||
|
|
||||||
|
调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。
|
||||||
|
|
||||||
## 网关跨域配置
|
## 网关跨域配置
|
||||||
|
|
||||||
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
|
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
|
- Mapper 只由 `BaiyueAuditApplication.java` 对 `com.baiyue.audit` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
|
- 负责接收 `common-audit` 产生的审计事件并持久化到 `baiyue_audit`。
|
||||||
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
|
- 审计注解、切面、脱敏和发送策略在 `common-audit/`;本服务不复制业务服务的审计采集逻辑。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.audit;
|
package com.baiyue.audit;
|
||||||
|
|
||||||
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
|
import com.baiyue.common.core.mybatis.MybatisPlusConfiguration;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
/**
|
/**
|
||||||
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.audit.mapper")
|
@MapperScan(basePackages = "com.baiyue.audit", annotationClass = Mapper.class)
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@Import(MybatisPlusConfiguration.class)
|
@Import(MybatisPlusConfiguration.class)
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package com.baiyue.audit.mapper;
|
package com.baiyue.audit.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.baiyue.audit.entity.AuditLogEntity;
|
import com.baiyue.audit.entity.AuditLogEntity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
|
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
|
||||||
*/
|
*/
|
||||||
|
@Mapper
|
||||||
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
|
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,14 +40,3 @@ baiyue:
|
|||||||
rocketmq:
|
rocketmq:
|
||||||
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
||||||
management.endpoints.web.exposure.include: health,info,prometheus
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|||||||
+4
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
|
- Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||||
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
||||||
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
|
- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。
|
||||||
@@ -13,9 +14,10 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
||||||
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||||
|
| 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 |
|
||||||
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 |
|
||||||
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 |
|
||||||
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只保留平台海外仓/海外业务/海外主数据/海外财务 API 所需的 `platform:finance:*` 及 `lastleg:*` 权限前缀;前端统一认证层负责按区域 API 自动选择 |
|
||||||
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 |
|
||||||
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
| 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` |
|
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
|
|
||||||
- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;登录/刷新同时签发短时海外 Token并携带必要权限 Claims,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`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 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||||
|
- 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。
|
||||||
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||||
|
|
||||||
## 全量验收导航
|
## 全量验收导航
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.baiyue.auth;
|
package com.baiyue.auth;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -8,7 +9,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
|||||||
/**
|
/**
|
||||||
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.auth.mapper")
|
@MapperScan(basePackages = "com.baiyue.auth", annotationClass = Mapper.class)
|
||||||
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
||||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||||
public class BaiyueAuthApplication {
|
public class BaiyueAuthApplication {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public interface IamOnboardingClient {
|
|||||||
@PathVariable("authUserId") Long authUserId);
|
@PathVariable("authUserId") Long authUserId);
|
||||||
|
|
||||||
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
||||||
String legalPerson, String legalPhone, String contactEmail,
|
String legalPerson, String administratorPhone, String contactEmail,
|
||||||
String countryCode, String address) { }
|
String countryCode, String address) { }
|
||||||
|
|
||||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class AuthController {
|
|||||||
@SecurityRequirements
|
@SecurityRequirements
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@Operation(summary = "Merchant registration / 客户注册",
|
@Operation(summary = "Merchant registration / 客户注册",
|
||||||
description = "Create a login account and an enterprise profile awaiting the required business license and legal-person ID; other qualification files are optional.")
|
description = "Create a login account and enterprise profile. The supplied phone initializes both the first administrator login phone and company contact phone; they are maintained independently after creation. Only the business license and unified social credit code are required for review; legal-person ID is optional.")
|
||||||
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
||||||
return ApiResponse.success(registrationService.register(request));
|
return ApiResponse.success(registrationService.register(request));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ public class InternalAccountController {
|
|||||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||||
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
|
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
@PutMapping("/{id}/login-identifiers")
|
||||||
|
public ApiResponse<AccountSummary> loginIdentifiers(
|
||||||
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) {
|
||||||
|
verify(token);
|
||||||
|
return ApiResponse.success(service.changeLoginIdentifiers(id, request));
|
||||||
|
}
|
||||||
|
@PutMapping("/{id}/phone")
|
||||||
|
public ApiResponse<Void> phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) {
|
||||||
|
verify(token);
|
||||||
|
service.updatePhone(id, request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
@PutMapping("/{id}/password")
|
@PutMapping("/{id}/password")
|
||||||
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
|
|||||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||||
String newPassword) { }
|
String newPassword) { }
|
||||||
public record CreateAccountRequest(@NotNull Long accountId,
|
public record CreateAccountRequest(@NotNull Long accountId,
|
||||||
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||||
@NotBlank @Size(min = 8, max = 64)
|
@NotBlank @Size(min = 8, max = 64)
|
||||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
||||||
@NotBlank String displayName, @NotNull Long tenantId,
|
@NotBlank String displayName, @NotNull Long tenantId,
|
||||||
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
|
|||||||
String accountStatus) {
|
String accountStatus) {
|
||||||
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
||||||
Long tenantId, Long membershipId, String appCode) {
|
Long tenantId, Long membershipId, String appCode) {
|
||||||
this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
|
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
|
||||||
|
}
|
||||||
|
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
|
||||||
|
Long tenantId, Long membershipId, String appCode, String accountStatus) {
|
||||||
|
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public record ChangeLoginIdentifiersRequest(
|
||||||
|
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||||
|
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||||
|
@NotBlank String currentPassword) { }
|
||||||
|
public record UpdateAccountPhoneRequest(
|
||||||
|
@Pattern(regexp = "^1[3-9]\\d{9}$") String phone) { }
|
||||||
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
||||||
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||||
public record RevokeAccountSessionsRequest(
|
public record RevokeAccountSessionsRequest(
|
||||||
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
|
|||||||
public record ImpersonationExchangeRequest(@NotBlank String code) { }
|
public record ImpersonationExchangeRequest(@NotBlank String code) { }
|
||||||
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
|
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
|
||||||
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
||||||
public record AccountSummary(Long accountId, String username, String displayName, String status,
|
public record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
|
||||||
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
|
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
|
||||||
public AccountSummary(Long accountId, String username, String displayName, String status) {
|
public AccountSummary(Long accountId, String username, String displayName, String status) {
|
||||||
this(accountId, username, displayName, status, null, null, null, null);
|
this(accountId, username, null, displayName, status, null, null, null, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ package com.baiyue.auth.dto;
|
|||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
|
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
|
||||||
*/
|
*/
|
||||||
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
|
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
|
||||||
public record LoginRequest(
|
public record LoginRequest(
|
||||||
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
|
@Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin")
|
||||||
|
@NotBlank @Size(max = 64) String loginId,
|
||||||
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
|
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
@@ -8,12 +8,13 @@ import jakarta.validation.constraints.Size;
|
|||||||
|
|
||||||
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
|
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
|
||||||
public record MerchantRegisterRequest(
|
public record MerchantRegisterRequest(
|
||||||
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
|
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
|
||||||
@NotBlank @Size(min = 8, max = 72) String password,
|
@NotBlank @Size(min = 8, max = 72) String password,
|
||||||
@NotBlank @Size(max = 64) String displayName,
|
@NotBlank @Size(max = 64) String displayName,
|
||||||
@NotBlank @Size(max = 128) String companyName,
|
@NotBlank @Size(max = 128) String companyName,
|
||||||
@NotBlank @Size(max = 64) String legalPerson,
|
@NotBlank @Size(max = 64) String legalPerson,
|
||||||
@NotBlank @Size(max = 32) String legalPhone,
|
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
|
||||||
|
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||||
@Email @Size(max = 128) String contactEmail,
|
@Email @Size(max = 128) String contactEmail,
|
||||||
@Size(min = 2, max = 2) String countryCode,
|
@Size(min = 2, max = 2) String countryCode,
|
||||||
@Size(max = 255) String address
|
@Size(max = 255) String address
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
|
|||||||
public class AuthAccount {
|
public class AuthAccount {
|
||||||
@TableId private Long id;
|
@TableId private Long id;
|
||||||
private String username;
|
private String username;
|
||||||
|
private String phone;
|
||||||
private String passwordHash;
|
private String passwordHash;
|
||||||
private String displayName;
|
private String displayName;
|
||||||
private String accountStatus;
|
private String accountStatus;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.baiyue.auth.mapper;
|
package com.baiyue.auth.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
import com.baiyue.auth.entity.AuthAccount;
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
||||||
*/
|
*/
|
||||||
|
@Mapper
|
||||||
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.baiyue.auth.mapper;
|
package com.baiyue.auth.mapper;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
||||||
*/
|
*/
|
||||||
|
@Mapper
|
||||||
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
||||||
|
|||||||
@@ -22,11 +22,34 @@ public class AuthAccountRepository {
|
|||||||
.last("LIMIT 1"));
|
.last("LIMIT 1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AuthAccount findByPhone(String phone) {
|
||||||
|
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getPhone, phone)
|
||||||
|
.last("LIMIT 1"));
|
||||||
|
}
|
||||||
|
|
||||||
public boolean existsByUsername(String username) {
|
public boolean existsByUsername(String username) {
|
||||||
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
.eq(AuthAccount::getUsername, username)) > 0;
|
.eq(AuthAccount::getUsername, username)) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean existsByPhone(String phone) {
|
||||||
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getPhone, phone)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
|
||||||
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getUsername, username)
|
||||||
|
.ne(AuthAccount::getId, accountId)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
|
||||||
|
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getPhone, phone)
|
||||||
|
.ne(AuthAccount::getId, accountId)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
public void insert(AuthAccount account) { mapper.insert(account); }
|
public void insert(AuthAccount account) { mapper.insert(account); }
|
||||||
|
|
||||||
public AuthAccount findById(Long id) {
|
public AuthAccount findById(Long id) {
|
||||||
|
|||||||
@@ -71,9 +71,13 @@ public class AccountManagementService {
|
|||||||
if (repository.existsByUsername(request.username())) {
|
if (repository.existsByUsername(request.username())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
||||||
}
|
}
|
||||||
|
if (request.phone() != null && repository.existsByPhone(request.phone())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||||
|
}
|
||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
account.setId(request.accountId());
|
account.setId(request.accountId());
|
||||||
account.setUsername(request.username());
|
account.setUsername(request.username());
|
||||||
|
account.setPhone(request.phone());
|
||||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||||
account.setDisplayName(request.displayName());
|
account.setDisplayName(request.displayName());
|
||||||
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
|
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
|
||||||
@@ -117,6 +121,45 @@ public class AccountManagementService {
|
|||||||
bumpVersion(account);
|
bumpVersion(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||||
|
description = "修改本人用户名和手机号", captureAfter = false)
|
||||||
|
public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
|
||||||
|
AuthAccount account = required(accountId);
|
||||||
|
if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
|
||||||
|
throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
|
||||||
|
}
|
||||||
|
String username = request.username().trim();
|
||||||
|
String phone = request.phone().trim();
|
||||||
|
if (repository.existsByUsernameForOtherAccount(username, accountId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||||
|
}
|
||||||
|
if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||||
|
}
|
||||||
|
if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
|
||||||
|
return summary(account);
|
||||||
|
}
|
||||||
|
account.setUsername(username);
|
||||||
|
account.setPhone(phone);
|
||||||
|
bumpVersion(account);
|
||||||
|
return summary(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||||
|
description = "同步受管账号手机号", captureAfter = false)
|
||||||
|
public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
|
||||||
|
AuthAccount account = required(accountId);
|
||||||
|
String phone = request.phone() == null ? null : request.phone().trim();
|
||||||
|
if (Objects.equals(account.getPhone(), phone)) return;
|
||||||
|
if (phone != null && repository.existsByPhoneForOtherAccount(phone, accountId)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||||
|
}
|
||||||
|
account.setPhone(phone);
|
||||||
|
bumpVersion(account);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||||
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
||||||
@@ -185,7 +228,7 @@ public class AccountManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private AccountSummary summary(AuthAccount a) {
|
private AccountSummary summary(AuthAccount a) {
|
||||||
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(),
|
return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
|
||||||
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
|
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +244,7 @@ public class AccountManagementService {
|
|||||||
|
|
||||||
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
|
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
|
||||||
return Objects.equals(account.getUsername(), request.username())
|
return Objects.equals(account.getUsername(), request.username())
|
||||||
|
&& Objects.equals(account.getPhone(), request.phone())
|
||||||
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
|
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
|
||||||
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
|
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
|
||||||
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
|
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
|
||||||
|
|||||||
@@ -57,10 +57,13 @@ public class AuthService {
|
|||||||
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
|
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
|
||||||
description = "User login / 用户登录", captureAfter = false)
|
description = "User login / 用户登录", captureAfter = false)
|
||||||
public TokenResponse login(LoginRequest request) {
|
public TokenResponse login(LoginRequest request) {
|
||||||
AuthAccount account = accountRepository.findByUsername(request.username());
|
String loginId = request.loginId().trim();
|
||||||
|
AuthAccount account = loginId.matches("^1[3-9]\\d{9}$")
|
||||||
|
? accountRepository.findByPhone(loginId)
|
||||||
|
: accountRepository.findByUsername(loginId);
|
||||||
if (account == null) throw invalidCredentials();
|
if (account == null) throw invalidCredentials();
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
if (!"ACTIVE".equals(account.getAccountStatus()))
|
if (!"ACTIVE".equals(account.getAccountStatus()))
|
||||||
@@ -211,7 +214,7 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static BizException invalidCredentials() {
|
private static BizException invalidCredentials() {
|
||||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ public class MerchantRegistrationService {
|
|||||||
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
||||||
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
||||||
if (accountRepository.existsByUsername(request.username())) {
|
if (accountRepository.existsByUsername(request.username())) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "Username already exists");
|
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||||
|
}
|
||||||
|
if (accountRepository.existsByPhone(request.phone())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||||
}
|
}
|
||||||
long accountId = IdWorker.getId();
|
long accountId = IdWorker.getId();
|
||||||
String passwordHash = passwordEncoder.encode(request.password());
|
String passwordHash = passwordEncoder.encode(request.password());
|
||||||
@@ -46,7 +49,7 @@ public class MerchantRegistrationService {
|
|||||||
try {
|
try {
|
||||||
var response = onboardingClient.register(internalToken,
|
var response = onboardingClient.register(internalToken,
|
||||||
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
||||||
request.companyName(), request.legalPerson(), request.legalPhone(),
|
request.companyName(), request.legalPerson(), request.phone(),
|
||||||
request.contactEmail(), request.countryCode(), request.address()));
|
request.contactEmail(), request.countryCode(), request.address()));
|
||||||
if (response == null) {
|
if (response == null) {
|
||||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
||||||
@@ -81,6 +84,7 @@ public class MerchantRegistrationService {
|
|||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
account.setId(accountId);
|
account.setId(accountId);
|
||||||
account.setUsername(request.username());
|
account.setUsername(request.username());
|
||||||
|
account.setPhone(request.phone());
|
||||||
account.setPasswordHash(passwordHash);
|
account.setPasswordHash(passwordHash);
|
||||||
account.setDisplayName(request.displayName());
|
account.setDisplayName(request.displayName());
|
||||||
account.setAccountStatus("ACTIVE");
|
account.setAccountStatus("ACTIVE");
|
||||||
@@ -94,7 +98,7 @@ public class MerchantRegistrationService {
|
|||||||
try {
|
try {
|
||||||
accountRepository.insert(account);
|
accountRepository.insert(account);
|
||||||
} catch (DuplicateKeyException exception) {
|
} catch (DuplicateKeyException exception) {
|
||||||
BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
|
BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
|
||||||
failure.addSuppressed(exception);
|
failure.addSuppressed(exception);
|
||||||
compensateOnboarding(accountId, failure, compensationCompleted);
|
compensateOnboarding(accountId, failure, compensationCompleted);
|
||||||
throw failure;
|
throw failure;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public class OverseasTokenService {
|
|||||||
"platform:overseas-warehouse:",
|
"platform:overseas-warehouse:",
|
||||||
"platform:overseas-business:",
|
"platform:overseas-business:",
|
||||||
"platform:overseas-master-data:",
|
"platform:overseas-master-data:",
|
||||||
|
"platform:finance:",
|
||||||
"lastleg:"
|
"lastleg:"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -52,14 +52,3 @@ baiyue.clients:
|
|||||||
url: ${IAM_SERVICE_URL:}
|
url: ${IAM_SERVICE_URL:}
|
||||||
|
|
||||||
management.endpoints.web.exposure.include: health,info,prometheus
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Auth 单一登录标识增量:用户名或中国大陆手机号。
|
||||||
|
-- 可重复执行;手机号仅从 IAM 当前用户档案回填,无法确认的账号保持 NULL。
|
||||||
|
USE baiyue_auth;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS auth_add_login_phone;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE auth_add_login_phone()
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' AND column_name = 'phone') THEN
|
||||||
|
ALTER TABLE auth_account ADD COLUMN phone VARCHAR(32) NULL COMMENT '全局唯一中国大陆登录手机号' AFTER username;
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL auth_add_login_phone();
|
||||||
|
DROP PROCEDURE auth_add_login_phone;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS auth_backfill_login_phone;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE auth_backfill_login_phone()
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT user.phone
|
||||||
|
FROM baiyue_iam.iam_user user
|
||||||
|
WHERE user.deleted = 0 AND user.phone REGEXP '^1[3-9][0-9]{9}$'
|
||||||
|
GROUP BY user.phone HAVING COUNT(DISTINCT user.auth_user_id) > 1) THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'IAM 存在重复手机号,禁止建立 Auth 唯一登录索引';
|
||||||
|
END IF;
|
||||||
|
UPDATE auth_account account
|
||||||
|
JOIN baiyue_iam.iam_user user ON user.auth_user_id = account.id AND user.deleted = 0
|
||||||
|
SET account.phone = user.phone
|
||||||
|
WHERE account.phone IS NULL AND user.phone REGEXP '^1[3-9][0-9]{9}$';
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.statistics
|
||||||
|
WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account'
|
||||||
|
AND index_name = 'uk_auth_account_phone') THEN
|
||||||
|
ALTER TABLE auth_account ADD UNIQUE KEY uk_auth_account_phone (phone);
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL auth_backfill_login_phone();
|
||||||
|
DROP PROCEDURE auth_backfill_login_phone;
|
||||||
@@ -6,6 +6,7 @@ USE baiyue_auth;
|
|||||||
CREATE TABLE IF NOT EXISTS auth_account (
|
CREATE TABLE IF NOT EXISTS auth_account (
|
||||||
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
|
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
|
||||||
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
||||||
|
phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号',
|
||||||
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
||||||
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
||||||
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
|
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
|
||||||
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
|
|||||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_auth_account_username (username),
|
UNIQUE KEY uk_auth_account_username (username),
|
||||||
|
UNIQUE KEY uk_auth_account_phone (phone),
|
||||||
KEY idx_auth_account_status (account_status, locked_until)
|
KEY idx_auth_account_status (account_status, locked_until)
|
||||||
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
|
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.auth.repository;
|
||||||
|
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
|
||||||
|
import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||||
|
import jakarta.validation.Validation;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class LoginIdentifierContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void registrationExposesAnAccountPhoneInsteadOfALegalPersonPhoneAlias() {
|
||||||
|
var componentNames = Arrays.stream(MerchantRegisterRequest.class.getRecordComponents())
|
||||||
|
.map(component -> component.getName())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
assertThat(componentNames).contains("phone").doesNotContain("legalPhone");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mainlandMobileRuleRejectsPlaceholderNumbersAcrossJavaAndSql() throws Exception {
|
||||||
|
try (var factory = Validation.buildDefaultValidatorFactory()) {
|
||||||
|
var validator = factory.getValidator();
|
||||||
|
var invalidRegistration = new MerchantRegisterRequest(
|
||||||
|
"merchant.user", "Password1", "测试用户", "测试企业", "张三",
|
||||||
|
"11111111111", null, "CN", null);
|
||||||
|
var validChange = new ChangeLoginIdentifiersRequest(
|
||||||
|
"merchant.user", "13800138000", "Password1");
|
||||||
|
var invalidChange = new ChangeLoginIdentifiersRequest(
|
||||||
|
"merchant.user", "11111111111", "Password1");
|
||||||
|
|
||||||
|
assertThat(validator.validate(invalidRegistration)).isNotEmpty();
|
||||||
|
assertThat(validator.validate(validChange)).isEmpty();
|
||||||
|
assertThat(validator.validate(invalidChange)).isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String sql = new ClassPathResource("db/auth-login-identifiers.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8);
|
||||||
|
assertThat(sql)
|
||||||
|
.contains("REGEXP '^1[3-9][0-9]{9}$'")
|
||||||
|
.doesNotContain("REGEXP '^1[0-9]{10}$'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
|||||||
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountPhoneRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
|
||||||
import com.baiyue.auth.entity.AuthAccount;
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
@@ -157,6 +159,44 @@ class AccountManagementServiceTest {
|
|||||||
verify(sessions).revokeAll(10L);
|
verify(sessions).revokeAll(10L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() {
|
||||||
|
account.setUsername("old.user");
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
|
||||||
|
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false);
|
||||||
|
|
||||||
|
var result = service.changeLoginIdentifiers(10L,
|
||||||
|
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"));
|
||||||
|
|
||||||
|
assertEquals("new.user", result.username());
|
||||||
|
assertEquals("13900139000", result.phone());
|
||||||
|
assertEquals(3, account.getTokenVersion());
|
||||||
|
verify(sessions).revokeAll(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void managedPhoneChangeOrRemovalRevokesOnlyThatAccountsSessions() {
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
|
||||||
|
service.updatePhone(10L, new UpdateAccountPhoneRequest(null));
|
||||||
|
|
||||||
|
assertEquals(null, account.getPhone());
|
||||||
|
assertEquals(3, account.getTokenVersion());
|
||||||
|
verify(repository).save(account);
|
||||||
|
verify(sessions).revokeAll(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
|
||||||
|
account.setUsername("old.user");
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
|
||||||
|
when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L,
|
||||||
|
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")));
|
||||||
|
verify(repository, never()).save(any());
|
||||||
|
verify(sessions, never()).revokeAll(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
|
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
|
||||||
AuthAccount second = new AuthAccount();
|
AuthAccount second = new AuthAccount();
|
||||||
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
|
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
account.setDefaultAppCode("PLATFORM");
|
account.setDefaultAppCode("PLATFORM");
|
||||||
account.setTokenVersion(1);
|
account.setTokenVersion(1);
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||||
"WEB", Set.of("WAREHOUSE_PICKER"), Set.of("platform:order:list"), 1L, 900L);
|
"WEB", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("platform:order:list"), 1L, 900L);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
|
||||||
when(repository.findByUsername("operator")).thenReturn(account);
|
when(repository.findByUsername("operator")).thenReturn(account);
|
||||||
@@ -91,7 +91,7 @@ class AuthServiceSmallJwtTest {
|
|||||||
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||||
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||||
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("WAREHOUSE_PICKER")),
|
org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")),
|
||||||
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
@@ -100,6 +100,46 @@ class AuthServiceSmallJwtTest {
|
|||||||
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
assertThat(response.overseasExpiresIn()).isEqualTo(900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mainlandPhoneIsResolvedAsTheSinglePhoneLoginIdentifier() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(42L);
|
||||||
|
account.setUsername("operator");
|
||||||
|
account.setPhone("13800138000");
|
||||||
|
account.setPasswordHash("hash");
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setDefaultTenantId(0L);
|
||||||
|
account.setOperatorTenantId(0L);
|
||||||
|
account.setDefaultMembershipId(84L);
|
||||||
|
account.setDefaultAppCode("PLATFORM");
|
||||||
|
account.setTokenVersion(1);
|
||||||
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||||
|
"WEB", Set.of(), Set.of(), 1L);
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
when(repository.findByPhone("13800138000")).thenReturn(account);
|
||||||
|
when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("small-token");
|
||||||
|
|
||||||
|
service.login(new LoginRequest("13800138000", "secret"));
|
||||||
|
|
||||||
|
verify(repository).findByPhone("13800138000");
|
||||||
|
verify(repository, never()).findByUsername("13800138000");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void placeholderNumberIsNeverResolvedAsAMobileLoginIdentifier() {
|
||||||
|
when(repository.findByUsername("11111111111")).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.login(new LoginRequest("11111111111", "secret")))
|
||||||
|
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
|
||||||
|
.hasMessageContaining("登录标识或密码错误");
|
||||||
|
|
||||||
|
verify(repository).findByUsername("11111111111");
|
||||||
|
verify(repository, never()).findByPhone("11111111111");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
|
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
|
||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class MerchantRegistrationServiceTest {
|
|||||||
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
|
assertEquals(30001L, storedAccount.getValue().getDefaultMembershipId());
|
||||||
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
|
assertEquals("MERCHANT", storedAccount.getValue().getDefaultAppCode());
|
||||||
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
|
||||||
|
assertEquals("13800138000", storedAccount.getValue().getPhone());
|
||||||
|
assertEquals("13800138000", onboardingRequest.getValue().administratorPhone());
|
||||||
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
|
||||||
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
|
||||||
assertEquals("PENDING", result.dimensionInitializationStatus());
|
assertEquals("PENDING", result.dimensionInitializationStatus());
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@ class OverseasSessionValidationServiceTest {
|
|||||||
|
|
||||||
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) {
|
||||||
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void inject(String fieldName, Object value) throws Exception {
|
private void inject(String fieldName, Object value) throws Exception {
|
||||||
|
|||||||
@@ -50,9 +50,11 @@ class OverseasTokenServiceTest {
|
|||||||
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
||||||
String authority = "platform:overseas-master-data:quote:list";
|
String authority = "platform:overseas-master-data:quote:list";
|
||||||
String lastlegAuthority = "lastleg:pda:operate";
|
String lastlegAuthority = "lastleg:pda:operate";
|
||||||
|
String financeAuthority = "platform:finance:fee-source:edit";
|
||||||
String domesticAuthority = "platform:firstleg:order:list";
|
String domesticAuthority = "platform:firstleg:order:list";
|
||||||
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of(authority, lastlegAuthority, domesticAuthority), 7L, 900L);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"),
|
||||||
|
Set.of(authority, lastlegAuthority, financeAuthority, domesticAuthority), 7L, 900L);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
when(accounts.findById(42L)).thenReturn(account);
|
when(accounts.findById(42L)).thenReturn(account);
|
||||||
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
@@ -74,8 +76,8 @@ class OverseasTokenServiceTest {
|
|||||||
.containsEntry("permission_version", 7L)
|
.containsEntry("permission_version", 7L)
|
||||||
.containsEntry("token_version", 0)
|
.containsEntry("token_version", 0)
|
||||||
.containsEntry("overseas_warehouse_id", 900L)
|
.containsEntry("overseas_warehouse_id", 900L)
|
||||||
.containsEntry("roles", Set.of("WAREHOUSE_PICKER"))
|
.containsEntry("roles", Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"))
|
||||||
.containsEntry("authorities", Set.of(authority, lastlegAuthority))
|
.containsEntry("authorities", Set.of(authority, lastlegAuthority, financeAuthority))
|
||||||
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
.doesNotContainKeys("userId", "tenantId", "appCode", "permissionVersion");
|
||||||
assertThat(response.expiresIn()).isEqualTo(900);
|
assertThat(response.expiresIn()).isEqualTo(900);
|
||||||
}
|
}
|
||||||
@@ -87,9 +89,9 @@ class OverseasTokenServiceTest {
|
|||||||
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM");
|
||||||
account.setTokenVersion(8);
|
account.setTokenVersion(8);
|
||||||
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L);
|
||||||
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L);
|
||||||
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
Jwt first = org.mockito.Mockito.mock(Jwt.class);
|
||||||
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
Jwt second = org.mockito.Mockito.mock(Jwt.class);
|
||||||
when(encoder.encode(any())).thenReturn(first, second);
|
when(encoder.encode(any())).thenReturn(first, second);
|
||||||
@@ -117,7 +119,7 @@ class OverseasTokenServiceTest {
|
|||||||
AuthAccount account = new AuthAccount();
|
AuthAccount account = new AuthAccount();
|
||||||
account.setId(44L); account.setAccountStatus("ACTIVE");
|
account.setId(44L); account.setAccountStatus("ACTIVE");
|
||||||
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA",
|
||||||
Set.of("WAREHOUSE_PICKER"), Set.of("lastleg:pda:operate"), 10L, null);
|
Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null);
|
||||||
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
when(encoder.encode(any())).thenReturn(encoded);
|
when(encoder.encode(any())).thenReturn(encoded);
|
||||||
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
when(encoded.getTokenValue()).thenReturn("overseas-unbound");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
| 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 |
|
||||||
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
| 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` |
|
||||||
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
| 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` |
|
||||||
|
| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 |
|
||||||
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 |
|
||||||
| 契约说明 | `package-info.java` |
|
| 契约说明 | `package-info.java` |
|
||||||
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` |
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨区域业务费用来源的完整不可变快照。
|
||||||
|
*
|
||||||
|
* <p>海外业务与国内 Finance 不做同步回查,因此事件必须携带可独立校验、入池的完整快照。
|
||||||
|
* 标识统一使用字符串,避免跨语言大整数精度损失。</p>
|
||||||
|
*/
|
||||||
|
public record BusinessFeeSourceChangedV1(
|
||||||
|
String action,
|
||||||
|
String sourceSystem,
|
||||||
|
String sourceType,
|
||||||
|
String sourceFeeId,
|
||||||
|
int sourceVersion,
|
||||||
|
String customerId,
|
||||||
|
String customerName,
|
||||||
|
String sourceNo,
|
||||||
|
String businessType,
|
||||||
|
String businessId,
|
||||||
|
String businessNo,
|
||||||
|
String orderId,
|
||||||
|
String inboundId,
|
||||||
|
String inboundCode,
|
||||||
|
String containerId,
|
||||||
|
String containerBookingId,
|
||||||
|
String containerNo,
|
||||||
|
String warehouseId,
|
||||||
|
String feeItemCode,
|
||||||
|
String feeItemName,
|
||||||
|
String direction,
|
||||||
|
String supplierId,
|
||||||
|
String supplierName,
|
||||||
|
String supplierCategory,
|
||||||
|
String settlementMode,
|
||||||
|
Instant paidAt,
|
||||||
|
String paymentMethod,
|
||||||
|
String paymentReferenceNo,
|
||||||
|
List<String> paymentVoucherFileIds,
|
||||||
|
LocalDate businessDate,
|
||||||
|
String currency,
|
||||||
|
BigDecimal totalAmount,
|
||||||
|
String reviewStatus,
|
||||||
|
String publicationStatus,
|
||||||
|
String sourceStatus,
|
||||||
|
String sourceHash
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/** Canonical physical container number used by cross-region business facts. */
|
||||||
|
public final class ContainerNumbers {
|
||||||
|
private static final int[] LETTER_VALUES = {
|
||||||
|
10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||||
|
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38
|
||||||
|
};
|
||||||
|
|
||||||
|
private ContainerNumbers() { }
|
||||||
|
|
||||||
|
public static String normalizeRequired(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("containerNo is required");
|
||||||
|
}
|
||||||
|
StringBuilder compact = new StringBuilder(value.length());
|
||||||
|
value.codePoints().filter(codePoint -> !Character.isWhitespace(codePoint)
|
||||||
|
&& !Character.isSpaceChar(codePoint)).forEach(compact::appendCodePoint);
|
||||||
|
String normalized = compact.toString().toUpperCase(Locale.ROOT);
|
||||||
|
if (!normalized.matches("[A-Z]{3}[UJZ][0-9]{7}")) {
|
||||||
|
throw new IllegalArgumentException("containerNo must comply with ISO 6346");
|
||||||
|
}
|
||||||
|
int sum = 0;
|
||||||
|
for (int index = 0; index < 10; index++) {
|
||||||
|
char current = normalized.charAt(index);
|
||||||
|
int number = Character.isDigit(current) ? current - '0' : LETTER_VALUES[current - 'A'];
|
||||||
|
sum += number * (1 << index);
|
||||||
|
}
|
||||||
|
if (normalized.charAt(10) - '0' != sum % 11 % 10) {
|
||||||
|
throw new IllegalArgumentException("containerNo must comply with ISO 6346 check digit");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String requireCanonical(String value) {
|
||||||
|
String normalized = normalizeRequired(value);
|
||||||
|
if (!normalized.equals(value)) {
|
||||||
|
throw new IllegalArgumentException("containerNo must be normalized");
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public final class EventTypes {
|
|||||||
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
|
public static final String FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED =
|
||||||
"FirstLegContainerShippingNodeRecorded";
|
"FirstLegContainerShippingNodeRecorded";
|
||||||
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
public static final String OVERSEAS_CONTAINER_UNLOADED = "OverseasContainerUnloaded";
|
||||||
|
public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged";
|
||||||
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||||
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||||
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
|||||||
String queryId,
|
String queryId,
|
||||||
String customerId,
|
String customerId,
|
||||||
String oceanBookingId,
|
String oceanBookingId,
|
||||||
String containerBookingId,
|
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||||
String containerNo,
|
String containerNo,
|
||||||
String standardNode,
|
String standardNode,
|
||||||
int nodeSequence,
|
int nodeSequence,
|
||||||
@@ -34,6 +35,7 @@ public record FirstLegContainerShippingNodeRecordedV1(
|
|||||||
|| containerNo == null || containerNo.isBlank()) {
|
|| containerNo == null || containerNo.isBlank()) {
|
||||||
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
throw new IllegalArgumentException("船节点交接业务标识不能为空");
|
||||||
}
|
}
|
||||||
|
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||||
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
if (standardNode == null || !Integer.valueOf(nodeSequence).equals(STANDARD_NODES.get(standardNode))
|
||||||
|| observedAt == null || actualAt == null) {
|
|| observedAt == null || actualAt == null) {
|
||||||
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
throw new IllegalArgumentException("船节点交接只接受带实际时间的标准节点事实");
|
||||||
|
|||||||
+23
-5
@@ -7,7 +7,14 @@ import java.util.List;
|
|||||||
public record FirstLegLoadingPlanCompletedV1(
|
public record FirstLegLoadingPlanCompletedV1(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo, String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
|
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||||
List<Order> orders) {
|
List<Order> orders) {
|
||||||
|
public FirstLegLoadingPlanCompletedV1(String loadingPlanId, String planNo, String containerBookingId,
|
||||||
|
String containerNo, String cabinetNo, String warehouseId,
|
||||||
|
String warehouseName, List<Order> orders) {
|
||||||
|
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
|
||||||
|
warehouseName, null, null, null, orders);
|
||||||
|
}
|
||||||
|
|
||||||
public record Order(
|
public record Order(
|
||||||
String sourceInboundId, String rootInboundId,
|
String sourceInboundId, String rootInboundId,
|
||||||
@@ -16,10 +23,17 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
BigDecimal volumeWeightKg,
|
||||||
|
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||||
String destinationType) {
|
String destinationType) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
items = items == null ? List.of() : items;
|
||||||
|
if (customerId == null || customerId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerId is required");
|
||||||
|
}
|
||||||
|
if (customerName == null || customerName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerName is required");
|
||||||
|
}
|
||||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
@@ -29,11 +43,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, null);
|
||||||
}
|
}
|
||||||
public Order(String sourceInboundId, String rootInboundId,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
@@ -41,11 +57,13 @@ public record FirstLegLoadingPlanCompletedV1(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, destinationType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+47
-6
@@ -1,13 +1,38 @@
|
|||||||
package com.baiyue.common.event;
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
|
/** 装柜完成交接快照,包含三类逐货物备注及一键代发托盘快照。 */
|
||||||
public record FirstLegLoadingPlanCompletedV2(
|
public record FirstLegLoadingPlanCompletedV2(
|
||||||
String loadingPlanId, String planNo, String containerBookingId,
|
String loadingPlanId, String planNo,
|
||||||
|
@JsonProperty("sourceContainerBookingId") String containerBookingId,
|
||||||
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
|
String estimatedPortArrivalAt, String actualPortArrivalAt, String estimatedWarehouseArrivalAt,
|
||||||
List<Order> orders) {
|
List<Order> orders) {
|
||||||
|
public FirstLegLoadingPlanCompletedV2 {
|
||||||
|
if (loadingPlanId == null || loadingPlanId.isBlank()
|
||||||
|
|| containerBookingId == null || containerBookingId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("loadingPlanId and sourceContainerBookingId are required");
|
||||||
|
}
|
||||||
|
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||||
|
orders = orders == null ? List.of() : List.copyOf(orders);
|
||||||
|
if (orders.isEmpty()) throw new IllegalArgumentException("orders are required");
|
||||||
|
Order owner = orders.get(0);
|
||||||
|
if (orders.stream().anyMatch(order -> !owner.customerId().equals(order.customerId())
|
||||||
|
|| !owner.customerName().equals(order.customerName()))) {
|
||||||
|
throw new IllegalArgumentException("orders must share one immutable customerId/customerName snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public FirstLegLoadingPlanCompletedV2(String loadingPlanId, String planNo, String containerBookingId,
|
||||||
|
String containerNo, String cabinetNo, String warehouseId,
|
||||||
|
String warehouseName, List<Order> orders) {
|
||||||
|
this(loadingPlanId, planNo, containerBookingId, containerNo, cabinetNo, warehouseId,
|
||||||
|
warehouseName, null, null, null, orders);
|
||||||
|
}
|
||||||
|
|
||||||
public record Order(
|
public record Order(
|
||||||
String sourceInboundId, String rootInboundId,
|
String sourceInboundId, String rootInboundId,
|
||||||
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
@@ -15,11 +40,23 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets,
|
BigDecimal volumeWeightKg,
|
||||||
|
List<Item> items, String customerId, String customerName, String orderType, List<PalletSnapshot> pallets,
|
||||||
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
/** 目的类型:FBA 可按箱数收货,OVERSEAS 必须逐箱扫码。 */
|
||||||
String destinationType) {
|
String destinationType) {
|
||||||
public Order {
|
public Order {
|
||||||
items = items == null ? List.of() : items;
|
items = items == null ? List.of() : items;
|
||||||
|
if (customerId == null || customerId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerId is required");
|
||||||
|
}
|
||||||
|
if (customerName == null || customerName.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("customerName is required");
|
||||||
|
}
|
||||||
|
customerId = customerId.trim();
|
||||||
|
customerName = customerName.trim();
|
||||||
|
if (customerId.length() > 128 || customerName.length() > 128) {
|
||||||
|
throw new IllegalArgumentException("customerId/customerName snapshot is too long");
|
||||||
|
}
|
||||||
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
pallets = pallets == null ? List.of() : pallets;
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
}
|
}
|
||||||
@@ -30,11 +67,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, customerId, orderType, pallets, null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Order(String sourceInboundId, String rootInboundId,
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
@@ -43,11 +82,13 @@ public record FirstLegLoadingPlanCompletedV2(
|
|||||||
String destinationWarehouseCode, String destinationWarehouseName,
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
int packageCount, Integer productQuantity, Integer palletCount,
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
List<Item> items) {
|
List<Item> items, String customerId, String customerName,
|
||||||
|
String orderType, List<PalletSnapshot> pallets, String destinationType) {
|
||||||
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of(), null);
|
grossWeightKg, netWeightKg, volumeCbm, null, items, customerId, customerName,
|
||||||
|
orderType, pallets, destinationType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
@@ -13,4 +13,10 @@ public record OverseasContainerUnloadedV1(
|
|||||||
Instant unloadingPhotoUploadedAt,
|
Instant unloadingPhotoUploadedAt,
|
||||||
Instant actualEmptyReportedAt
|
Instant actualEmptyReportedAt
|
||||||
) {
|
) {
|
||||||
|
public OverseasContainerUnloadedV1 {
|
||||||
|
if (sourceContainerBookingId == null || sourceContainerBookingId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("sourceContainerBookingId is required");
|
||||||
|
}
|
||||||
|
containerNo = ContainerNumbers.requireCanonical(containerNo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Baiyue Cross-region Event Contracts
|
||||||
|
version: 1.0.0
|
||||||
|
paths: {}
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
FirstLegLoadingPlanCompletedV2:
|
||||||
|
type: object
|
||||||
|
required: [loadingPlanId, sourceContainerBookingId, containerNo, estimatedPortArrivalAt, actualPortArrivalAt, estimatedWarehouseArrivalAt, orders]
|
||||||
|
properties:
|
||||||
|
loadingPlanId: {type: string}
|
||||||
|
sourceContainerBookingId: {type: string, minLength: 1, description: Immutable Firstleg container booking identity}
|
||||||
|
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number frozen before loading completion publication}
|
||||||
|
cabinetNo: {type: string}
|
||||||
|
warehouseId: {type: string}
|
||||||
|
warehouseName: {type: string}
|
||||||
|
estimatedPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated port arrival; null when Firstleg has no fact}
|
||||||
|
actualPortArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local actual port arrival; never copied from an estimate}
|
||||||
|
estimatedWarehouseArrivalAt: {type: [string, 'null'], format: local-date-time, description: Frozen source-local estimated warehouse arrival; null when Firstleg has no fact}
|
||||||
|
orders:
|
||||||
|
type: array
|
||||||
|
items: {$ref: '#/components/schemas/FirstLegLoadingPlanCompletedOrderV2'}
|
||||||
|
FirstLegLoadingPlanCompletedOrderV2:
|
||||||
|
type: object
|
||||||
|
required: [sourceInboundId, customerId, customerName, palletCount, packageCount, productQuantity, volumeCbm, volumeWeightKg, items, orderType, pallets, destinationType]
|
||||||
|
properties:
|
||||||
|
sourceInboundId: {type: string}
|
||||||
|
sourceOrderId: {type: string}
|
||||||
|
sourceOrderNo: {type: string}
|
||||||
|
inboundCode: {type: string}
|
||||||
|
customerId: {type: string, minLength: 1, description: Trusted customer identity frozen by Firstleg}
|
||||||
|
customerName: {type: string, minLength: 1, maxLength: 128, description: Immutable trusted container/customer name snapshot; consumers must not query domestic IAM}
|
||||||
|
packageCount: {type: integer, minimum: 1}
|
||||||
|
productQuantity: {type: [integer, 'null'], minimum: 0, description: Final loaded product quantity; null when unavailable}
|
||||||
|
palletCount: {type: [integer, 'null'], minimum: 1, description: Distinct physical pallet count from Firstleg allocation pallet identity; never a planned zero placeholder}
|
||||||
|
volumeCbm: {type: [number, 'null'], minimum: 0, description: Final loaded volume in cubic metres}
|
||||||
|
volumeWeightKg: {type: [number, 'null'], minimum: 0, description: Sum of final loaded item dimensional weight facts; null when unavailable}
|
||||||
|
items: {type: array, items: {type: object}}
|
||||||
|
orderType: {type: string, enum: [FIRST_LEG, ONE_CLICK_DROPSHIP]}
|
||||||
|
pallets: {type: array, items: {type: object}}
|
||||||
|
destinationType: {type: string, enum: [FBA, OVERSEAS]}
|
||||||
|
FirstLegContainerShippingNodeRecordedV1:
|
||||||
|
type: object
|
||||||
|
required: [queryId, customerId, oceanBookingId, sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash]
|
||||||
|
properties:
|
||||||
|
queryId: {type: string}
|
||||||
|
customerId: {type: string}
|
||||||
|
oceanBookingId: {type: string}
|
||||||
|
sourceContainerBookingId: {type: string, minLength: 1}
|
||||||
|
containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity}
|
||||||
|
standardNode: {type: string}
|
||||||
|
nodeSequence: {type: integer}
|
||||||
|
observedAt: {type: string, format: date-time}
|
||||||
|
actualAt: {type: string, format: date-time}
|
||||||
|
providerCode: {type: string}
|
||||||
|
providerEventId: {type: string}
|
||||||
|
providerNodeCode: {type: string}
|
||||||
|
rawPayloadHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'}
|
||||||
|
OverseasContainerUnloadedV1:
|
||||||
|
type: object
|
||||||
|
required: [sourceContainerBookingId, containerNo, unloadingCompletedAt, unloadingPhotoUploadedAt]
|
||||||
|
properties:
|
||||||
|
sourceContainerBookingId: {type: string, minLength: 1}
|
||||||
|
containerNo: {type: string, minLength: 1, description: Must equal the immutable normalized Firstleg container number; the CN consumer never overwrites it}
|
||||||
|
unloadingCompletedAt: {type: string, format: date-time}
|
||||||
|
unloadingPhotoUploadedAt: {type: string, format: date-time}
|
||||||
|
actualEmptyReportedAt: {type: [string, 'null'], format: date-time}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
class ContainerNumbersTest {
|
||||||
|
@Test
|
||||||
|
void normalizesWhitespaceAndCaseButCanonicalFactsRequireExactIso6346Value() {
|
||||||
|
assertThat(ContainerNumbers.normalizeRequired(" zcsu 7881970 ")).isEqualTo("ZCSU7881970");
|
||||||
|
assertThat(ContainerNumbers.requireCanonical("ZCSU7881970")).isEqualTo("ZCSU7881970");
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.requireCanonical("zcsu7881970"))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("ZCSU7881971"))
|
||||||
|
.hasMessageContaining("check digit");
|
||||||
|
assertThatThrownBy(() -> ContainerNumbers.normalizeRequired("CAB-260831-0001"))
|
||||||
|
.hasMessageContaining("ISO 6346");
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -21,6 +21,18 @@ class FirstLegContainerShippingNodeRecordedV1Test {
|
|||||||
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
assertThat(restored.containerBookingId()).isEqualTo("2088000000000000003");
|
||||||
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
assertThat(restored.standardNode()).isEqualTo("PICKUP_READY");
|
||||||
assertThat(restored.nodeSequence()).isEqualTo(80);
|
assertThat(restored.nodeSequence()).isEqualTo(80);
|
||||||
|
var json = mapper.valueToTree(payload);
|
||||||
|
assertThat(json.path("sourceContainerBookingId").asText()).isEqualTo("2088000000000000003");
|
||||||
|
assertThat(json.has("containerBookingId")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonCanonicalPhysicalContainerNumber() {
|
||||||
|
assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1(
|
||||||
|
"1", "2", "3", "4", " zcsu7881970 ", "PICKUP_READY", 80,
|
||||||
|
Instant.parse("2026-08-25T01:00:00Z"), Instant.parse("2026-08-25T01:00:00Z"),
|
||||||
|
null, null, "ZIM", "evt", "AVAILABLE", "ZIM:evt", "a".repeat(64)))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
+12
-4
@@ -19,7 +19,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||||
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||||
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||||
new BigDecimal("0.200"), List.of(item));
|
new BigDecimal("0.200"), List.of(item), "9001", "客户甲", "FIRST_LEG", List.of());
|
||||||
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||||
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||||
String json = mapper.writeValueAsString(payload);
|
String json = mapper.writeValueAsString(payload);
|
||||||
@@ -35,7 +35,7 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||||
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||||
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||||
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "客户甲", "ONE_CLICK_DROPSHIP",
|
||||||
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||||
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||||
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||||
@@ -52,12 +52,20 @@ class FirstLegLoadingPlanCompletedV1Test {
|
|||||||
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
void legacyParserRetainsDestinationTypeWhenV2OrderCarriesIt() throws Exception {
|
||||||
String json = """
|
String json = """
|
||||||
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
{"loadingPlanId":"1","planNo":"LP-1","containerBookingId":"2","containerNo":"CONT-1",
|
||||||
"warehouseId":"3","warehouseName":"海外仓","orders":[{"sourceInboundId":"100",
|
"warehouseId":"3","warehouseName":"海外仓","estimatedPortArrivalAt":"2026-09-30T12:00",
|
||||||
"destinationWarehouseId":"3","packageCount":1,"items":[],"destinationType":"OVERSEAS"}]}
|
"actualPortArrivalAt":null,"estimatedWarehouseArrivalAt":"2026-10-03T09:00",
|
||||||
|
"orders":[{"sourceInboundId":"100","destinationWarehouseId":"3","packageCount":1,
|
||||||
|
"palletCount":2,"volumeWeightKg":12.500,"items":[],"customerId":"9001",
|
||||||
|
"customerName":"客户甲","destinationType":"OVERSEAS"}]}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||||
|
|
||||||
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
assertThat(restored.orders().get(0).destinationType()).isEqualTo("OVERSEAS");
|
||||||
|
assertThat(restored.estimatedPortArrivalAt()).isEqualTo("2026-09-30T12:00");
|
||||||
|
assertThat(restored.actualPortArrivalAt()).isNull();
|
||||||
|
assertThat(restored.estimatedWarehouseArrivalAt()).isEqualTo("2026-10-03T09:00");
|
||||||
|
assertThat(restored.orders().get(0).palletCount()).isEqualTo(2);
|
||||||
|
assertThat(restored.orders().get(0).volumeWeightKg()).isEqualByComparingTo("12.500");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-8
@@ -4,18 +4,59 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class FirstLegLoadingPlanCompletedV2Test {
|
class FirstLegLoadingPlanCompletedV2Test {
|
||||||
|
@Test
|
||||||
|
void carriesOnlyFrozenContainerTimesAndActualOrderAggregates() {
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
|
||||||
|
12, 120, 3, null, null, new BigDecimal("2.500"), new BigDecimal("18.750"),
|
||||||
|
List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "OVERSEAS");
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH",
|
||||||
|
"2026-09-30T12:00", null, "2026-10-03T09:00", List.of(order));
|
||||||
|
|
||||||
|
var json = new ObjectMapper().valueToTree(payload);
|
||||||
|
|
||||||
|
assertThat(json.at("/estimatedPortArrivalAt").asText()).isEqualTo("2026-09-30T12:00");
|
||||||
|
assertThat(json.at("/actualPortArrivalAt").isNull()).isTrue();
|
||||||
|
assertThat(json.at("/estimatedWarehouseArrivalAt").asText()).isEqualTo("2026-10-03T09:00");
|
||||||
|
assertThat(json.at("/sourceContainerBookingId").asText()).isEqualTo("5");
|
||||||
|
assertThat(json.has("containerBookingId")).isFalse();
|
||||||
|
assertThat(json.at("/orders/0/palletCount").asInt()).isEqualTo(3);
|
||||||
|
assertThat(json.at("/orders/0/packageCount").asInt()).isEqualTo(12);
|
||||||
|
assertThat(json.at("/orders/0/productQuantity").asInt()).isEqualTo(120);
|
||||||
|
assertThat(json.at("/orders/0/volumeCbm").decimalValue()).isEqualByComparingTo("2.500");
|
||||||
|
assertThat(json.at("/orders/0/volumeWeightKg").decimalValue()).isEqualByComparingTo("18.750");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsNonCanonicalOrMissingPhysicalContainerIdentity() {
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "ONT8",
|
||||||
|
1, 1, null, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of());
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", " zcsu7881970 ", "CAB", "6", "WH", List.of(order)))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", " ", "CAB", "6", "WH", List.of(order)))
|
||||||
|
.hasMessageContaining("containerNo");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void carriesTheThreeItemRemarks() throws Exception {
|
void carriesTheThreeItemRemarks() throws Exception {
|
||||||
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
var item = new FirstLegLoadingPlanCompletedV2.Item("1", "SKU", "中文", "EN", 1,
|
||||||
2, null, null, null, null, null, null, null, null, null, null, null,
|
2, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
"客户备注", "内部备注", "仓库备注");
|
"客户备注", "内部备注", "仓库备注");
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||||
null, null, null, 1, 2, null, null, null, null, List.of(item));
|
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));
|
"11", "客户甲", "FIRST_LEG", List.of());
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV2("4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||||
String json = new ObjectMapper().writeValueAsString(payload);
|
String json = new ObjectMapper().writeValueAsString(payload);
|
||||||
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
assertThat(json).contains("customerRemark", "internalRemark", "warehouseRemark");
|
||||||
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
assertThat(new ObjectMapper().readTree(json).at("/orders/0/items/0/warehouseRemark").asText())
|
||||||
@@ -29,13 +70,14 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
var pallet = new FirstLegLoadingPlanCompletedV2.PalletSnapshot("PLT-01", List.of(sku));
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||||
2, 20, 1, null, null, null, List.of(), "11", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
2, 20, 1, null, null, null, List.of(), "11", "客户甲", "ONE_CLICK_DROPSHIP", List.of(pallet));
|
||||||
var payload = new FirstLegLoadingPlanCompletedV2(
|
var payload = new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order));
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order));
|
||||||
|
|
||||||
var json = new ObjectMapper().valueToTree(payload);
|
var json = new ObjectMapper().valueToTree(payload);
|
||||||
|
|
||||||
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
assertThat(json.at("/orders/0/customerId").asText()).isEqualTo("11");
|
||||||
|
assertThat(json.at("/orders/0/customerName").asText()).isEqualTo("客户甲");
|
||||||
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
assertThat(json.at("/orders/0/orderType").asText()).isEqualTo("ONE_CLICK_DROPSHIP");
|
||||||
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
assertThat(json.at("/orders/0/pallets/0/skuPackages/0/cartonMarks")).hasSize(2);
|
||||||
}
|
}
|
||||||
@@ -44,10 +86,10 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
void carriesFrozenDestinationType() {
|
void carriesFrozenDestinationType() {
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
var order = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
"2", "2", "3", "ORD", "3", "RK", null, "ONT8", "FBA ONT8",
|
||||||
2, 20, 0, null, null, null, List.of(), "11", "FIRST_LEG", List.of(), "FBA");
|
2, 20, 0, null, null, null, List.of(), "11", "客户甲", "FIRST_LEG", List.of(), "FBA");
|
||||||
|
|
||||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||||
|
|
||||||
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
assertThat(json.at("/orders/0/destinationType").asText()).isEqualTo("FBA");
|
||||||
}
|
}
|
||||||
@@ -58,12 +100,55 @@ class FirstLegLoadingPlanCompletedV2Test {
|
|||||||
20, null, null, null, null, null, null, null, null, null, null, null,
|
20, null, null, null, null, null, null, null, null, null, null, null,
|
||||||
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
null, null, null, null, List.of("HDHK260711126-01", "HDHK260711126-02"));
|
||||||
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
var order = new FirstLegLoadingPlanCompletedV2.Order("2", "2", "3", "ORD", "3", "RK",
|
||||||
null, null, null, 2, 20, null, null, null, null, List.of(item));
|
null, null, null, 2, 20, null, null, null, null, List.of(item),
|
||||||
|
"11", "客户甲", "FIRST_LEG", List.of());
|
||||||
|
|
||||||
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
var json = new ObjectMapper().valueToTree(new FirstLegLoadingPlanCompletedV2(
|
||||||
"4", "P", "5", "CONT", "CAB", "6", "WH", List.of(order)));
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(order)));
|
||||||
|
|
||||||
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
assertThat(json.at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU-PSC2");
|
||||||
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
assertThat(json.at("/orders/0/items/0/cartonMarks")).hasSize(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingImmutableCustomerName() {
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||||
|
1, 1, 0, null, null, null, List.of(), "11", " ", "FIRST_LEG", List.of()))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("customerName");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void freezesTrimmedOwnerSnapshotAndRejectsMixedOwners() {
|
||||||
|
var first = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"2", "2", "3", "ORD", "3", "RK", null, null, null,
|
||||||
|
1, 1, 0, null, null, null, List.of(), " 11 ", " 客户甲 ", "FIRST_LEG", List.of());
|
||||||
|
assertThat(first.customerId()).isEqualTo("11");
|
||||||
|
assertThat(first.customerName()).isEqualTo("客户甲");
|
||||||
|
|
||||||
|
var anotherOwner = new FirstLegLoadingPlanCompletedV2.Order(
|
||||||
|
"3", "3", "4", "ORD-2", "4", "RK-2", null, null, null,
|
||||||
|
1, 1, 0, null, null, null, List.of(), "12", "客户乙", "FIRST_LEG", List.of());
|
||||||
|
assertThatThrownBy(() -> new FirstLegLoadingPlanCompletedV2(
|
||||||
|
"4", "P", "5", "ZCSU7881970", "CAB", "6", "WH", List.of(first, anotherOwner)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("immutable customerId/customerName");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openApiRequiresImmutableCustomerNameNextToCustomerId() throws Exception {
|
||||||
|
String yaml;
|
||||||
|
try (var input = FirstLegLoadingPlanCompletedV2Test.class.getResourceAsStream(
|
||||||
|
"/openapi/cross-region-events-v1.yaml")) {
|
||||||
|
assertThat(input).isNotNull();
|
||||||
|
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
assertThat(yaml)
|
||||||
|
.contains("required: [sourceInboundId, customerId, customerName")
|
||||||
|
.contains("customerId: {type: string, minLength: 1")
|
||||||
|
.contains("customerName: {type: string, minLength: 1, maxLength: 128")
|
||||||
|
.contains("consumers must not query domestic IAM")
|
||||||
|
.contains("estimatedPortArrivalAt", "actualPortArrivalAt", "estimatedWarehouseArrivalAt")
|
||||||
|
.contains("palletCount", "volumeWeightKg", "never a planned zero placeholder");
|
||||||
|
assertThat(yaml).contains("sourceContainerBookingId", "Uppercase normalized physical container number");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -7,20 +7,28 @@ import org.junit.jupiter.api.Test;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class OverseasContainerUnloadedV1Test {
|
class OverseasContainerUnloadedV1Test {
|
||||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
void payloadRoundTripsWithStableContainerIdAndUtcFacts() throws Exception {
|
||||||
var payload = new OverseasContainerUnloadedV1("1001", "MSCU1234567",
|
var payload = new OverseasContainerUnloadedV1("1001", "ZCSU7881970",
|
||||||
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null);
|
||||||
|
|
||||||
String json = mapper.writeValueAsString(payload);
|
String json = mapper.writeValueAsString(payload);
|
||||||
|
|
||||||
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
assertThat(mapper.readValue(json, OverseasContainerUnloadedV1.class)).isEqualTo(payload);
|
||||||
assertThat(json).contains("sourceContainerBookingId", "1001", "MSCU1234567", "unloadingPhotoUploadedAt");
|
assertThat(json).contains("sourceContainerBookingId", "1001", "ZCSU7881970", "unloadingPhotoUploadedAt");
|
||||||
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
assertThat(payload.unloadingCompletedAt()).isAfter(payload.unloadingPhotoUploadedAt());
|
||||||
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
assertThat(payload.unloadingCompletedAt()).isNotEqualTo(payload.unloadingPhotoUploadedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsContainerNumberDriftBeforeDomesticConsumption() {
|
||||||
|
assertThatThrownBy(() -> new OverseasContainerUnloadedV1("1001", "zcsu7881970",
|
||||||
|
Instant.parse("2026-08-20T03:00:00Z"), Instant.parse("2026-08-20T02:30:00Z"), null))
|
||||||
|
.hasMessageContaining("normalized");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework</groupId>
|
||||||
|
<artifactId>spring-tx</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.security</groupId>
|
<groupId>org.springframework.security</groupId>
|
||||||
<artifactId>spring-security-core</artifactId>
|
<artifactId>spring-security-core</artifactId>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.springframework.security.core.AuthenticationException;
|
|||||||
import org.springframework.validation.BindException;
|
import org.springframework.validation.BindException;
|
||||||
import org.springframework.validation.FieldError;
|
import org.springframework.validation.FieldError;
|
||||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.dao.CannotAcquireLockException;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
@@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
|||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -96,6 +98,24 @@ public class GlobalExceptionHandler {
|
|||||||
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
|
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(CannotAcquireLockException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleCannotAcquireLock(
|
||||||
|
CannotAcquireLockException exception, HttpServletRequest request) {
|
||||||
|
log.warn("Database lock acquisition conflict requestId={} traceId={}",
|
||||||
|
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
|
||||||
|
return failure(ResultCode.CONFLICT, "资源正在处理中,请稍后重试", request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSize(
|
||||||
|
MaxUploadSizeExceededException exception, HttpServletRequest request) {
|
||||||
|
long maxBytes = exception.getMaxUploadSize();
|
||||||
|
String message = maxBytes > 0
|
||||||
|
? "上传文件不能超过" + Math.max(1, (maxBytes + 1024 * 1024 - 1) / (1024 * 1024)) + "MB"
|
||||||
|
: "上传文件超过服务允许大小";
|
||||||
|
return failure(ResultCode.BAD_REQUEST, message, request);
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
|
||||||
log.error("Unhandled request failure requestId={} traceId={}",
|
log.error("Unhandled request failure requestId={} traceId={}",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import org.springframework.web.servlet.resource.NoResourceFoundException;
|
|||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.dao.CannotAcquireLockException;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
@@ -116,6 +118,32 @@ class GlobalExceptionHandlerTest {
|
|||||||
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lockWaitTimeoutMapsToCanonicalConflict() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setAttribute(CorrelationContext.REQUEST_ID, "lock-timeout");
|
||||||
|
|
||||||
|
var response = handler.handleCannotAcquireLock(
|
||||||
|
new CannotAcquireLockException("Lock wait timeout exceeded"), request);
|
||||||
|
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.CONFLICT.code());
|
||||||
|
assertThat(response.getBody().requestId()).isEqualTo("lock-timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oversizedMultipartReturnsClearBadRequestInsteadOfInternalError() {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.setAttribute(CorrelationContext.REQUEST_ID, "oversized-photo");
|
||||||
|
|
||||||
|
var response = handler.handleMaxUploadSize(
|
||||||
|
new MaxUploadSizeExceededException(10L * 1024 * 1024), request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||||
|
assertThat(response.getBody().code()).isEqualTo(ResultCode.BAD_REQUEST.code());
|
||||||
|
assertThat(response.getBody().message()).isEqualTo("上传文件不能超过10MB");
|
||||||
|
assertThat(response.getBody().requestId()).isEqualTo("oversized-photo");
|
||||||
|
}
|
||||||
|
|
||||||
private void paging(long page) {
|
private void paging(long page) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,6 @@ spring:
|
|||||||
client.config.default:
|
client.config.default:
|
||||||
connect-timeout: 3000
|
connect-timeout: 3000
|
||||||
read-timeout: 5000
|
read-timeout: 5000
|
||||||
logging:
|
|
||||||
file:
|
|
||||||
path: ${LOG_PATH:/app/logs}
|
|
||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ baiyue:
|
|||||||
event-dispatcher:
|
event-dispatcher:
|
||||||
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
|
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events}
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ baiyue:
|
|||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
batch-size: 500
|
||||||
|
|
||||||
|
DOMESTIC_WAREHOUSE_ORDER_LIST:
|
||||||
|
provider-service: order-service
|
||||||
|
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
|
||||||
|
required-permission: platform:order:export
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 200
|
||||||
|
|
||||||
|
LOADING_LIST_BUY_DECLARATION_ITEMS:
|
||||||
|
provider-service: firstleg-service
|
||||||
|
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
|
||||||
|
required-permission: platform:firstleg:customs:manage
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 500
|
||||||
|
|
||||||
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
||||||
provider-service: finance-service
|
provider-service: finance-service
|
||||||
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ spring:
|
|||||||
username: ${FIRSTLEG_DB_USERNAME}
|
username: ${FIRSTLEG_DB_USERNAME}
|
||||||
password: ${FIRSTLEG_DB_PASSWORD}
|
password: ${FIRSTLEG_DB_PASSWORD}
|
||||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
|
||||||
|
max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ spring:
|
|||||||
client.config.default:
|
client.config.default:
|
||||||
connect-timeout: 3000
|
connect-timeout: 3000
|
||||||
read-timeout: 5000
|
read-timeout: 5000
|
||||||
logging:
|
|
||||||
file:
|
|
||||||
path: ${LOG_PATH:./logs}
|
|
||||||
server:
|
server:
|
||||||
shutdown: graceful
|
shutdown: graceful
|
||||||
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ baiyue:
|
|||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
batch-size: 500
|
batch-size: 500
|
||||||
|
|
||||||
|
DOMESTIC_WAREHOUSE_ORDER_LIST:
|
||||||
|
provider-service: order-service
|
||||||
|
provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches
|
||||||
|
required-permission: platform:order:export
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 200
|
||||||
|
|
||||||
|
LOADING_LIST_BUY_DECLARATION_ITEMS:
|
||||||
|
provider-service: firstleg-service
|
||||||
|
provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches
|
||||||
|
required-permission: platform:firstleg:customs:manage
|
||||||
|
app-scope: PLATFORM
|
||||||
|
enabled: true
|
||||||
|
max-rows: 200000
|
||||||
|
max-attempts: 3
|
||||||
|
batch-size: 500
|
||||||
|
|
||||||
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
FINANCE_CUSTOMER_ACCOUNT_LIST:
|
||||||
provider-service: finance-service
|
provider-service: finance-service
|
||||||
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ spring.datasource:
|
|||||||
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||||
username: ${FIRSTLEG_DB_USERNAME:root}
|
username: ${FIRSTLEG_DB_USERNAME:root}
|
||||||
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
password: ${FIRSTLEG_DB_PASSWORD:123456}
|
||||||
|
spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB}
|
||||||
|
spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB}
|
||||||
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
|
||||||
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
baiyue.clients.order.url: ${ORDER_SERVICE_URL:}
|
||||||
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
baiyue.clients.file.url: ${FILE_SERVICE_URL:}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
spring:
|
spring:
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: ${LASTLEG_MULTIPART_MAX_FILE_SIZE:10MB}
|
||||||
|
max-request-size: ${LASTLEG_MULTIPART_MAX_REQUEST_SIZE:11MB}
|
||||||
datasource:
|
datasource:
|
||||||
url: ${LASTLEG_DB_URL}
|
url: ${LASTLEG_DB_URL}
|
||||||
username: ${LASTLEG_DB_USERNAME}
|
username: ${LASTLEG_DB_USERNAME}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ x-overseas-common: &overseas-common
|
|||||||
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
|
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
|
||||||
DATA_RESIDENCY: OVERSEAS
|
DATA_RESIDENCY: OVERSEAS
|
||||||
SPRING_SQL_INIT_MODE: never
|
SPRING_SQL_INIT_MODE: never
|
||||||
LOG_PATH: /app/logs
|
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
# 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
options:
|
options:
|
||||||
max-size: 100m
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
max-file: "5"
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gateway-overseas:
|
gateway-overseas:
|
||||||
@@ -38,7 +38,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PORT:-9300}:9300"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway-overseas:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9300
|
SERVER_PORT: 9300
|
||||||
@@ -55,7 +54,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9303
|
SERVER_PORT: 9303
|
||||||
@@ -73,7 +71,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PORT:-9305}:9305"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse-overseas-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9305
|
SERVER_PORT: 9305
|
||||||
@@ -87,7 +84,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service-overseas:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *overseas-environment
|
<<: *overseas-environment
|
||||||
SERVER_PORT: 9312
|
SERVER_PORT: 9312
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
name: firstleg-${DEPLOY_ENV:-test}-rocketmq
|
name: firstleg-${DEPLOY_ENV:-test}-rocketmq
|
||||||
|
|
||||||
|
x-bounded-logging: &bounded-logging
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
|
options:
|
||||||
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
rocketmq-namesrv:
|
rocketmq-namesrv:
|
||||||
image: apache/rocketmq:5.3.1
|
image: apache/rocketmq:5.3.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: sh mqnamesrv
|
command: sh mqnamesrv
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [infra-network]
|
networks: [infra-network]
|
||||||
|
|
||||||
rocketmq-broker:
|
rocketmq-broker:
|
||||||
@@ -17,6 +24,7 @@ services:
|
|||||||
- ./rocketmq:/config:ro
|
- ./rocketmq:/config:ro
|
||||||
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
|
- ${ROCKETMQ_DATA_DIR:-/data/firstleg/rocketmq/store}:/home/rocketmq/store
|
||||||
command: sh /config/render-broker-config.sh
|
command: sh /config/render-broker-config.sh
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [infra-network]
|
networks: [infra-network]
|
||||||
|
|
||||||
rocketmq-topic-init:
|
rocketmq-topic-init:
|
||||||
@@ -46,6 +54,7 @@ services:
|
|||||||
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
|
-t "%DLQ%$${MQ_TOPIC_PREFIX}baiyue-audit-consumer-v1" -r 1 -w 1 -p 6'
|
||||||
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
|
# Broker 注册较慢或任一初始化命令失败时整组重试;所有命令均幂等。
|
||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [infra-network]
|
networks: [infra-network]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ x-common: &common
|
|||||||
REGION_CODE: ${REGION_CODE:-CN}
|
REGION_CODE: ${REGION_CODE:-CN}
|
||||||
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
|
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
|
||||||
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
|
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
|
||||||
LOG_PATH: /app/logs
|
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
# 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
options:
|
options:
|
||||||
max-size: 100m
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
max-file: "5"
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
gateway:
|
gateway:
|
||||||
@@ -40,7 +40,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
|
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9000
|
SERVER_PORT: 9000
|
||||||
@@ -53,7 +52,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-auth
|
container_name: firstleg-${DEPLOY_ENV:-test}-auth
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9100
|
SERVER_PORT: 9100
|
||||||
@@ -72,7 +70,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-iam
|
container_name: firstleg-${DEPLOY_ENV:-test}-iam
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9201
|
SERVER_PORT: 9201
|
||||||
@@ -86,7 +83,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-business
|
container_name: firstleg-${DEPLOY_ENV:-test}-business
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9202
|
SERVER_PORT: 9202
|
||||||
@@ -112,7 +108,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-finance
|
container_name: firstleg-${DEPLOY_ENV:-test}-finance
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9203
|
SERVER_PORT: 9203
|
||||||
@@ -125,7 +120,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-order
|
container_name: firstleg-${DEPLOY_ENV:-test}-order
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9204
|
SERVER_PORT: 9204
|
||||||
@@ -138,7 +132,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
|
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9205
|
SERVER_PORT: 9205
|
||||||
@@ -153,7 +146,6 @@ services:
|
|||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
|
||||||
volumes:
|
volumes:
|
||||||
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
|
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
|
||||||
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9206
|
SERVER_PORT: 9206
|
||||||
@@ -175,7 +167,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-print
|
container_name: firstleg-${DEPLOY_ENV:-test}-print
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9207
|
SERVER_PORT: 9207
|
||||||
@@ -186,7 +177,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-integration
|
container_name: firstleg-${DEPLOY_ENV:-test}-integration
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9210
|
SERVER_PORT: 9210
|
||||||
@@ -236,7 +226,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
|
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
|
||||||
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
|
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PORT:-9312}:9312"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/event-bridge-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9312
|
SERVER_PORT: 9312
|
||||||
@@ -245,6 +234,7 @@ services:
|
|||||||
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
|
EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD}
|
||||||
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events}
|
||||||
|
FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events}
|
||||||
SPRING_SQL_INIT_MODE: never
|
SPRING_SQL_INIT_MODE: never
|
||||||
networks:
|
networks:
|
||||||
firstleg-network:
|
firstleg-network:
|
||||||
@@ -256,7 +246,6 @@ services:
|
|||||||
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
|
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
|
||||||
container_name: firstleg-${DEPLOY_ENV:-test}-audit
|
container_name: firstleg-${DEPLOY_ENV:-test}-audit
|
||||||
expose: ["9700"]
|
expose: ["9700"]
|
||||||
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/audit-service:/app/logs"]
|
|
||||||
environment:
|
environment:
|
||||||
<<: *common-environment
|
<<: *common-environment
|
||||||
SERVER_PORT: 9700
|
SERVER_PORT: 9700
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ RUN addgroup -S app \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
|
# 预建镜像内运行时数据和日志目录并归属 app 用户;宿主机 bind mount 的属主需在部署前单独设置为 UID 10001。
|
||||||
RUN mkdir -p /data/files /app/logs && chown -R app:app /data /app/logs
|
RUN mkdir -p /data/files && chown -R app:app /data
|
||||||
|
|
||||||
# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。
|
# 依赖层稳定、业务代码层高频变化;Registry 只需传输实际变化的 Layer。
|
||||||
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
|
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
|
||||||
|
|||||||
+824
-564
File diff suppressed because it is too large
Load Diff
@@ -491,6 +491,24 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''',
|
'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''',
|
||||||
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id'));
|
||||||
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'),
|
||||||
|
'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container','SELECT 1'));
|
||||||
|
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'),
|
||||||
|
'SELECT 1','ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)'));
|
||||||
|
PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt;
|
||||||
|
DROP PROCEDURE IF EXISTS assert_container_progress_index_ready;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE assert_container_progress_index_ready()
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||||
|
AND table_name='ll_order' AND index_name='idx_ll_order_container_progress') THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外区域初始化失败:柜级入库计划进度索引缺失';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL assert_container_progress_index_ready();
|
||||||
|
DROP PROCEDURE assert_container_progress_index_ready;
|
||||||
|
|
||||||
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
|
-- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'),
|
||||||
@@ -498,6 +516,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type'));
|
||||||
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
PREPARE phase4_destination_type_stmt FROM @ddl; EXECUTE phase4_destination_type_stmt; DEALLOCATE PREPARE phase4_destination_type_stmt;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence (
|
||||||
|
business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)',
|
||||||
|
last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号',
|
||||||
|
PRIMARY KEY(business_date)
|
||||||
|
) ENGINE=InnoDB COMMENT='尾程单号按日原子流水';
|
||||||
|
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_line' AND column_name='customer_id'),
|
||||||
'SELECT 1',
|
'SELECT 1',
|
||||||
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
'ALTER TABLE ll_inbound_plan_line ADD COLUMN customer_id VARCHAR(128) NOT NULL DEFAULT '''' COMMENT ''客户账号标识'' AFTER id'));
|
||||||
@@ -579,7 +603,8 @@ CALL assert_pending_orders_do_not_use_source_warehouse();
|
|||||||
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
|
DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||||
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识',
|
||||||
|
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||||
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
|
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
|
||||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||||
@@ -590,6 +615,13 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
|||||||
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
|
KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at)
|
||||||
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
|
) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话';
|
||||||
|
|
||||||
|
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='source_container_booking_id'),
|
||||||
|
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''稳定来源柜预订ID'' AFTER customer_id'));
|
||||||
|
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='container_fact_hash'),
|
||||||
|
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN container_fact_hash VARCHAR(64) NULL COMMENT ''领取时整柜订单与计划事实摘要'' AFTER source_container_booking_id'));
|
||||||
|
PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt;
|
||||||
|
|
||||||
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
|
SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'),
|
||||||
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
|
'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no'));
|
||||||
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
|
PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt;
|
||||||
@@ -745,6 +777,14 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
|||||||
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
|
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
|
||||||
'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code'));
|
'ALTER TABLE ll_inventory_balance ADD COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度'' AFTER inbound_code'));
|
||||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='customer_name'),
|
||||||
|
'ALTER TABLE ll_inventory_balance MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''',
|
||||||
|
'ALTER TABLE ll_inventory_balance ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id'));
|
||||||
|
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||||
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_ledger' AND column_name='customer_name'),
|
||||||
|
'ALTER TABLE ll_inventory_ledger MODIFY COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照''',
|
||||||
|
'ALTER TABLE ll_inventory_ledger ADD COLUMN customer_name VARCHAR(255) NOT NULL COMMENT ''客户名称不可变快照'' AFTER customer_id'));
|
||||||
|
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
|
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inventory_balance' AND column_name='product_name_zh'),
|
||||||
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
|
'SELECT 1', 'ALTER TABLE ll_inventory_balance ADD COLUMN product_name_zh VARCHAR(255) NULL COMMENT ''中文品名快照'' AFTER sku_code'));
|
||||||
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCATE PREPARE phase4_inventory_stmt;
|
||||||
@@ -1076,4 +1116,43 @@ END$$
|
|||||||
DELIMITER ;
|
DELIMITER ;
|
||||||
CALL assert_lastleg_security_projection_ready();
|
CALL assert_lastleg_security_projection_ready();
|
||||||
DROP PROCEDURE assert_lastleg_security_projection_ready;
|
DROP PROCEDURE assert_lastleg_security_projection_ready;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_finance_fee_source (
|
||||||
|
id BIGINT NOT NULL COMMENT '费用版本记录ID', source_fee_id BIGINT NOT NULL COMMENT '跨版本稳定费用来源ID',
|
||||||
|
source_version INT NOT NULL COMMENT '费用来源版本,从1递增', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键',
|
||||||
|
customer_name VARCHAR(200) NULL COMMENT '客户名称快照', source_type VARCHAR(64) NOT NULL COMMENT '海外费用来源类型',
|
||||||
|
source_no VARCHAR(128) NOT NULL COMMENT '来源业务编号或入仓码快照', business_type VARCHAR(32) NOT NULL COMMENT '来源业务类型',
|
||||||
|
business_id BIGINT NULL COMMENT '来源业务记录ID', business_no VARCHAR(128) NOT NULL COMMENT '来源业务编号',
|
||||||
|
order_id BIGINT NULL COMMENT '关联订单ID', inbound_id BIGINT NULL COMMENT '关联海外入仓计划或入仓记录ID',
|
||||||
|
inbound_code VARCHAR(64) NULL COMMENT '关联入仓码;并非所有费用必填', container_id BIGINT NULL COMMENT '关联柜次ID',
|
||||||
|
container_booking_id BIGINT NULL COMMENT '来源订舱柜ID', container_no VARCHAR(64) NULL COMMENT '真实集装箱号快照',
|
||||||
|
warehouse_id BIGINT NULL COMMENT '关联海外仓ID', fee_item_code VARCHAR(64) NOT NULL COMMENT '费用项目编码快照',
|
||||||
|
fee_item_name VARCHAR(200) NOT NULL COMMENT '费用项目名称快照', direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付',
|
||||||
|
supplier_id BIGINT NULL COMMENT '应付供应商ID', supplier_name VARCHAR(200) NULL COMMENT '应付供应商名称快照',
|
||||||
|
supplier_category VARCHAR(32) NULL COMMENT '应付供应商类别约束', settlement_mode VARCHAR(16) NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付',
|
||||||
|
paid_at DATETIME(3) NULL COMMENT '直接已付时间', payment_method VARCHAR(32) NULL COMMENT '直接已付方式',
|
||||||
|
payment_reference_no VARCHAR(128) NULL COMMENT '直接已付流水号', payment_voucher_file_ids JSON NULL COMMENT '直接已付凭证文件ID快照',
|
||||||
|
business_date DATE NOT NULL COMMENT '费用业务日期', currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY',
|
||||||
|
amount DECIMAL(20,6) NOT NULL COMMENT '费用金额', review_status VARCHAR(16) NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED',
|
||||||
|
publication_status VARCHAR(16) NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', source_status VARCHAR(16) NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED',
|
||||||
|
review_reason VARCHAR(500) NULL COMMENT '审核或作废原因', reviewer_id VARCHAR(128) NULL COMMENT '审核人ID', reviewed_at DATETIME(3) NULL COMMENT '审核时间',
|
||||||
|
created_by VARCHAR(128) NOT NULL COMMENT '创建人ID', updated_by VARCHAR(128) NOT NULL COMMENT '最后更新人ID',
|
||||||
|
created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_version(source_fee_id,source_version),
|
||||||
|
KEY idx_ll_finance_fee_customer(customer_id,source_status,created_at), KEY idx_ll_finance_fee_business(business_type,business_no,created_at),
|
||||||
|
CONSTRAINT chk_ll_finance_fee_amount CHECK(amount>0), CONSTRAINT chk_ll_finance_fee_currency CHECK(currency='CNY'),
|
||||||
|
CONSTRAINT chk_ll_finance_fee_direction CHECK(direction IN('RECEIVABLE','PAYABLE')),
|
||||||
|
CONSTRAINT chk_ll_finance_fee_review CHECK(review_status IN('DRAFT','PENDING','APPROVED','REJECTED')),
|
||||||
|
CONSTRAINT chk_ll_finance_fee_publication CHECK(publication_status IN('UNPUBLISHED','PUBLISHED')),
|
||||||
|
CONSTRAINT chk_ll_finance_fee_status CHECK(source_status IN('ACTIVE','SUPERSEDED','VOIDED'))
|
||||||
|
) ENGINE=InnoDB COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ll_finance_fee_command (
|
||||||
|
id BIGINT NOT NULL COMMENT '幂等命令ID', operator_id VARCHAR(128) NOT NULL COMMENT '操作人ID',
|
||||||
|
action VARCHAR(16) NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键',
|
||||||
|
request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', source_fee_id BIGINT NOT NULL COMMENT '稳定费用来源ID',
|
||||||
|
source_version INT NOT NULL COMMENT '命令结果版本', created_at DATETIME(3) NOT NULL COMMENT '命令创建时间',
|
||||||
|
PRIMARY KEY(id), UNIQUE KEY uk_ll_finance_fee_command(operator_id,action,idempotency_key),
|
||||||
|
KEY idx_ll_finance_fee_command_source(source_fee_id,source_version)
|
||||||
|
) ENGINE=InnoDB COMMENT='海外费用来源写操作幂等审计';
|
||||||
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message;
|
SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
name: firstleg-test-infra
|
name: firstleg-test-infra
|
||||||
|
|
||||||
|
x-bounded-logging: &bounded-logging
|
||||||
|
driver: ${DOCKER_LOG_DRIVER:-json-file}
|
||||||
|
options:
|
||||||
|
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
|
||||||
|
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nacos:
|
nacos:
|
||||||
image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2
|
image: docker.m.daocloud.io/nacos/nacos-server:v2.3.2
|
||||||
@@ -22,6 +28,7 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [baiyue]
|
networks: [baiyue]
|
||||||
|
|
||||||
mysql:
|
mysql:
|
||||||
@@ -46,6 +53,7 @@ services:
|
|||||||
retries: 20
|
retries: 20
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [baiyue]
|
networks: [baiyue]
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
@@ -62,6 +70,7 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
logging: *bounded-logging
|
||||||
networks: [baiyue]
|
networks: [baiyue]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 职责边界
|
## 职责边界
|
||||||
|
|
||||||
|
- Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
|
- 负责跨区事件接收、Inbox 持久化、按目标区域向海外尾程或国内头程投递、失败重试、投递记录和人工补投。
|
||||||
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
- 只承载可靠传输,不执行尾程建单逻辑,不查询头程或尾程业务表。
|
||||||
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
- 事件结构和 Payload 哈希以 `common-event-contract` 为准。
|
||||||
@@ -12,9 +13,10 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
| 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` |
|
||||||
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
| Inbox 接收与幂等 | `service/EventInboxService.java` |
|
||||||
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
|
| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 的 `BUSINESS_FEE_SOURCE_CHANGED` 定向投 Finance,其余国内回投 Firstleg;海外业务事件投 Lastleg 通用入口,三类 IAM 安全事件按 eventType 投 Lastleg `/internal/lastleg/security/v1/events`,传输层不解析 payload |
|
||||||
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
| 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` |
|
||||||
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
| 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` |
|
||||||
|
| 国内 Finance 费用事件客户端 | `service/FinanceEventClient.java`、`HttpFinanceEventClient.java`;统一内部入口 `/internal/finance/v1/business-fee-source-events`,配置 `baiyue.event-dispatcher.finance-consumer-url`,不得误投 Firstleg |
|
||||||
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
|
| 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` |
|
||||||
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
| Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper |
|
||||||
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
|
| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` |
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.eventbridge;
|
package com.baiyue.eventbridge;
|
||||||
|
|
||||||
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
import com.baiyue.eventbridge.config.EventDispatcherProperties;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
@@ -10,7 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
/**
|
/**
|
||||||
* 跨区域事件桥启动入口。
|
* 跨区域事件桥启动入口。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.eventbridge.mapper")
|
@MapperScan(basePackages = "com.baiyue.eventbridge", annotationClass = Mapper.class)
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
@EnableConfigurationProperties(EventDispatcherProperties.class)
|
||||||
|
|||||||
+1
@@ -30,6 +30,7 @@ public class EventBridgeMetrics {
|
|||||||
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
|
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
|
||||||
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
|
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED,
|
||||||
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
EventTypes.OVERSEAS_CONTAINER_UNLOADED,
|
||||||
|
EventTypes.BUSINESS_FEE_SOURCE_CHANGED,
|
||||||
EventTypes.IAM_USER_STATUS_CHANGED,
|
EventTypes.IAM_USER_STATUS_CHANGED,
|
||||||
EventTypes.IAM_TENANT_STATUS_CHANGED,
|
EventTypes.IAM_TENANT_STATUS_CHANGED,
|
||||||
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
|
EventTypes.IAM_PERMISSION_VERSION_CHANGED,
|
||||||
|
|||||||
+31
-6
@@ -45,6 +45,11 @@ public class EventInboxService {
|
|||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
return duplicateOrConflict(existing, envelope);
|
return duplicateOrConflict(existing, envelope);
|
||||||
}
|
}
|
||||||
|
String idempotencyKey = idempotencyKey(envelope);
|
||||||
|
BridgeInbox sameBusinessEvent = inboxStore.findByIdempotencyKey(idempotencyKey);
|
||||||
|
if (sameBusinessEvent != null) {
|
||||||
|
return duplicateBusinessEventOrConflict(sameBusinessEvent, envelope);
|
||||||
|
}
|
||||||
BridgeInbox inbox = toInbox(envelope);
|
BridgeInbox inbox = toInbox(envelope);
|
||||||
try {
|
try {
|
||||||
inboxStore.insert(inbox);
|
inboxStore.insert(inbox);
|
||||||
@@ -52,13 +57,25 @@ public class EventInboxService {
|
|||||||
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
|
return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt());
|
||||||
} catch (DuplicateKeyException exception) {
|
} catch (DuplicateKeyException exception) {
|
||||||
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
|
BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId());
|
||||||
|
if (concurrent == null) concurrent = inboxStore.findByIdempotencyKey(idempotencyKey);
|
||||||
if (concurrent == null) {
|
if (concurrent == null) {
|
||||||
throw exception;
|
throw exception;
|
||||||
}
|
}
|
||||||
return duplicateOrConflict(concurrent, envelope);
|
return Objects.equals(concurrent.getEventId(), envelope.eventId())
|
||||||
|
? duplicateOrConflict(concurrent, envelope)
|
||||||
|
: duplicateBusinessEventOrConflict(concurrent, envelope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private EventReceiveResult duplicateBusinessEventOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||||
|
if (!sameBusinessEvent(existing, envelope)) {
|
||||||
|
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
||||||
|
throw new IllegalArgumentException("相同eventKey对应的事件负载不一致,拒绝柜号或事实漂移");
|
||||||
|
}
|
||||||
|
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE);
|
||||||
|
return new EventReceiveResult(existing.getEventId(), "重复", existing.getReceivedAt());
|
||||||
|
}
|
||||||
|
|
||||||
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) {
|
||||||
if (!sameEnvelope(existing, envelope)) {
|
if (!sameEnvelope(existing, envelope)) {
|
||||||
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT);
|
||||||
@@ -69,6 +86,13 @@ public class EventInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
|
private boolean sameEnvelope(BridgeInbox existing, EventEnvelope envelope) {
|
||||||
|
return sameBusinessEvent(existing, envelope)
|
||||||
|
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
|
||||||
|
&& Objects.equals(existing.getTraceId(), envelope.traceId())
|
||||||
|
&& Objects.equals(existing.getCausationId(), envelope.causationId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sameBusinessEvent(BridgeInbox existing, EventEnvelope envelope) {
|
||||||
return existing.getPayloadHash() != null
|
return existing.getPayloadHash() != null
|
||||||
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
|
&& existing.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())
|
||||||
&& Objects.equals(existing.getEventType(), envelope.eventType())
|
&& Objects.equals(existing.getEventType(), envelope.eventType())
|
||||||
@@ -80,16 +104,13 @@ public class EventInboxService {
|
|||||||
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
&& Objects.equals(existing.getAggregateId(), envelope.aggregateId())
|
||||||
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
&& Objects.equals(existing.getAggregateVersion(), envelope.aggregateVersion())
|
||||||
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
|
&& Objects.equals(existing.getHandoverVersion(), envelope.handoverVersion())
|
||||||
&& Objects.equals(normalizeOccurredAt(existing.getOccurredAt()), normalizeOccurredAt(envelope.occurredAt()))
|
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId());
|
||||||
&& Objects.equals(existing.getTraceId(), envelope.traceId())
|
|
||||||
&& Objects.equals(existing.getCorrelationId(), envelope.correlationId())
|
|
||||||
&& Objects.equals(existing.getCausationId(), envelope.causationId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private BridgeInbox toInbox(EventEnvelope envelope) {
|
private BridgeInbox toInbox(EventEnvelope envelope) {
|
||||||
BridgeInbox inbox = new BridgeInbox();
|
BridgeInbox inbox = new BridgeInbox();
|
||||||
inbox.setEventId(envelope.eventId());
|
inbox.setEventId(envelope.eventId());
|
||||||
inbox.setIdempotencyKey(envelope.sourceRegion() + ":" + envelope.eventId());
|
inbox.setIdempotencyKey(idempotencyKey(envelope));
|
||||||
inbox.setEventType(envelope.eventType());
|
inbox.setEventType(envelope.eventType());
|
||||||
inbox.setEventVersion(envelope.eventVersion());
|
inbox.setEventVersion(envelope.eventVersion());
|
||||||
inbox.setSourceRegion(envelope.sourceRegion().name());
|
inbox.setSourceRegion(envelope.sourceRegion().name());
|
||||||
@@ -117,4 +138,8 @@ public class EventInboxService {
|
|||||||
private static Instant normalizeOccurredAt(Instant value) {
|
private static Instant normalizeOccurredAt(Instant value) {
|
||||||
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
|
return value == null ? null : value.truncatedTo(ChronoUnit.MILLIS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String idempotencyKey(EventEnvelope envelope) {
|
||||||
|
return envelope.sourceRegion() + ":" + envelope.correlationId();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
package com.baiyue.eventbridge.service;
|
||||||
|
|
||||||
|
import com.baiyue.eventbridge.domain.BridgeInbox;
|
||||||
|
|
||||||
|
public interface FinanceEventClient {
|
||||||
|
String deliver(BridgeInbox event) throws Exception;
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
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.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class HttpFinanceEventClient implements FinanceEventClient {
|
||||||
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
@Resource private EventDispatcherProperties properties;
|
||||||
|
@Resource private EventBridgeHttpTracePropagation tracePropagation;
|
||||||
|
@Value("${baiyue.event-dispatcher.finance-consumer-url:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}")
|
||||||
|
private URI consumerUrl;
|
||||||
|
@Value("${baiyue.security.internal-service-token:}") private String serviceToken;
|
||||||
|
private HttpClient httpClient;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
void initializeHttpClient() {
|
||||||
|
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());
|
||||||
|
HttpRequest request = tracePropagation.apply(HttpRequest.newBuilder(consumerUrl)
|
||||||
|
.timeout(properties.requestTimeout()).header("Content-Type", "application/json")
|
||||||
|
.header("X-Internal-Service-Token", serviceToken))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
JsonNode body;
|
||||||
|
try { body = objectMapper.readTree(response.body()); }
|
||||||
|
catch (Exception ignored) { body = null; }
|
||||||
|
if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText())) {
|
||||||
|
throw new IllegalStateException("Finance消费失败: HTTP " + response.statusCode()
|
||||||
|
+ ", code=" + (body == null ? "missing" : body.path("code").asText("missing")));
|
||||||
|
}
|
||||||
|
return String.valueOf(response.statusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
|
|||||||
*/
|
*/
|
||||||
public interface InboxStore {
|
public interface InboxStore {
|
||||||
BridgeInbox findByEventId(String eventId);
|
BridgeInbox findByEventId(String eventId);
|
||||||
|
BridgeInbox findByIdempotencyKey(String idempotencyKey);
|
||||||
void insert(BridgeInbox inbox);
|
void insert(BridgeInbox inbox);
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -4,6 +4,7 @@ import com.baiyue.eventbridge.domain.BridgeInbox;
|
|||||||
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
import com.baiyue.eventbridge.mapper.BridgeInboxMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 基于 MyBatis-Plus 的入站幂等存储。
|
* 基于 MyBatis-Plus 的入站幂等存储。
|
||||||
@@ -17,6 +18,12 @@ public class MybatisInboxStore implements InboxStore {
|
|||||||
return mapper.selectById(eventId);
|
return mapper.selectById(eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BridgeInbox findByIdempotencyKey(String idempotencyKey) {
|
||||||
|
return mapper.selectOne(new LambdaQueryWrapper<BridgeInbox>()
|
||||||
|
.eq(BridgeInbox::getIdempotencyKey, idempotencyKey));
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void insert(BridgeInbox inbox) {
|
public void insert(BridgeInbox inbox) {
|
||||||
mapper.insert(inbox);
|
mapper.insert(inbox);
|
||||||
|
|||||||
+3
-1
@@ -27,6 +27,7 @@ public class OverseasEventDispatcher {
|
|||||||
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
|
@Resource private BridgeDeliveryAttemptMapper attemptMapper;
|
||||||
@Resource private LastlegEventClient eventClient;
|
@Resource private LastlegEventClient eventClient;
|
||||||
@Resource private FirstlegEventClient firstlegEventClient;
|
@Resource private FirstlegEventClient firstlegEventClient;
|
||||||
|
@Resource private FinanceEventClient financeEventClient;
|
||||||
@Resource private SecurityProjectionEventClient securityProjectionEventClient;
|
@Resource private SecurityProjectionEventClient securityProjectionEventClient;
|
||||||
@Resource private EventDispatcherProperties properties;
|
@Resource private EventDispatcherProperties properties;
|
||||||
@Resource private EventBridgeMetrics metrics;
|
@Resource private EventBridgeMetrics metrics;
|
||||||
@@ -89,7 +90,8 @@ public class OverseasEventDispatcher {
|
|||||||
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
|
throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者");
|
||||||
}
|
}
|
||||||
return switch (event.getTargetRegion()) {
|
return switch (event.getTargetRegion()) {
|
||||||
case "CN" -> firstlegEventClient.deliver(event);
|
case "CN" -> EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(eventType)
|
||||||
|
? financeEventClient.deliver(event) : firstlegEventClient.deliver(event);
|
||||||
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
|
case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType)
|
||||||
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
|
? securityProjectionEventClient.deliver(event) : eventClient.deliver(event);
|
||||||
default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion());
|
default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion());
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ baiyue:
|
|||||||
event-dispatcher:
|
event-dispatcher:
|
||||||
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
|
consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}}
|
||||||
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
|
firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events}
|
||||||
|
finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://127.0.0.1:9203/internal/finance/v1/business-fee-source-events}
|
||||||
security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events}
|
security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/security/v1/events}
|
||||||
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
|
fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000}
|
||||||
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS bridge_outbox (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bridge_inbox (
|
CREATE TABLE IF NOT EXISTS bridge_inbox (
|
||||||
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
event_id VARCHAR(128) NOT NULL COMMENT '事件全局标识',
|
||||||
idempotency_key VARCHAR(191) NOT NULL COMMENT '接收幂等键',
|
idempotency_key VARCHAR(191) NOT NULL COMMENT '来源区域与业务eventKey组成的接收幂等键',
|
||||||
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
|
||||||
event_version INT NOT NULL COMMENT '事件契约版本',
|
event_version INT NOT NULL COMMENT '事件契约版本',
|
||||||
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
source_region VARCHAR(32) NOT NULL COMMENT '来源区域',
|
||||||
|
|||||||
+26
@@ -106,6 +106,32 @@ class EventInboxServiceTest {
|
|||||||
.hasMessageContaining("事件信封");
|
.hasMessageContaining("事件信封");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void 相同业务eventKey的新eventId只允许同负载重放并拒绝柜号漂移() {
|
||||||
|
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||||
|
BridgeInbox existing = existing(envelope, envelope.payloadHash());
|
||||||
|
when(store.findByEventId(envelope.eventId())).thenReturn(null);
|
||||||
|
when(store.findByIdempotencyKey("CN:flow-1")).thenReturn(existing);
|
||||||
|
EventEnvelope replay = new EventEnvelope("550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
envelope.eventType(), envelope.eventVersion(), Instant.parse("2026-07-29T09:00:00Z"),
|
||||||
|
envelope.sourceRegion(), envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(),
|
||||||
|
envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), "trace-2",
|
||||||
|
envelope.correlationId(), null, envelope.payload(), envelope.payloadHash());
|
||||||
|
assertThat(service.receive(replay).result()).isEqualTo("重复");
|
||||||
|
|
||||||
|
com.fasterxml.jackson.databind.node.ObjectNode driftPayload =
|
||||||
|
(com.fasterxml.jackson.databind.node.ObjectNode) envelope.payload().deepCopy();
|
||||||
|
driftPayload.put("containerNo", "DIFFERENT");
|
||||||
|
String driftHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(driftPayload));
|
||||||
|
EventEnvelope drift = new EventEnvelope("550e8400-e29b-41d4-a716-446655440002",
|
||||||
|
envelope.eventType(), envelope.eventVersion(), envelope.occurredAt(), envelope.sourceRegion(),
|
||||||
|
envelope.targetRegion(), envelope.tenantId(), envelope.aggregateType(), envelope.aggregateId(),
|
||||||
|
envelope.aggregateVersion(), envelope.handoverVersion(), "trace-3", envelope.correlationId(),
|
||||||
|
null, driftPayload, driftHash);
|
||||||
|
assertThatThrownBy(() -> service.receive(drift))
|
||||||
|
.hasMessageContaining("eventKey").hasMessageContaining("漂移");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void 并发插入冲突后按既有记录判定为重复() {
|
void 并发插入冲突后按既有记录判定为重复() {
|
||||||
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
EventEnvelope envelope = withHash(envelope("sha256:placeholder"));
|
||||||
|
|||||||
+18
@@ -31,6 +31,7 @@ class OverseasEventDispatcherTest {
|
|||||||
mock(com.baiyue.eventbridge.service.LastlegEventClient.class);
|
mock(com.baiyue.eventbridge.service.LastlegEventClient.class);
|
||||||
private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient =
|
private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient =
|
||||||
mock(com.baiyue.eventbridge.service.FirstlegEventClient.class);
|
mock(com.baiyue.eventbridge.service.FirstlegEventClient.class);
|
||||||
|
private final FinanceEventClient financeEventClient = mock(FinanceEventClient.class);
|
||||||
private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class);
|
private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class);
|
||||||
private OverseasEventDispatcher dispatcher;
|
private OverseasEventDispatcher dispatcher;
|
||||||
private BridgeInbox event;
|
private BridgeInbox event;
|
||||||
@@ -48,6 +49,7 @@ class OverseasEventDispatcherTest {
|
|||||||
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
|
ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper);
|
||||||
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
|
ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient);
|
||||||
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
|
ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient);
|
||||||
|
ReflectionTestUtils.setField(dispatcher, "financeEventClient", financeEventClient);
|
||||||
ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient);
|
ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient);
|
||||||
ReflectionTestUtils.setField(dispatcher, "properties", properties);
|
ReflectionTestUtils.setField(dispatcher, "properties", properties);
|
||||||
ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry));
|
ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry));
|
||||||
@@ -150,6 +152,22 @@ class OverseasEventDispatcherTest {
|
|||||||
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void overseasFeeSourceIsDeliveredToFinanceAndNeverFirstleg() throws Exception {
|
||||||
|
event.setEventType(EventTypes.BUSINESS_FEE_SOURCE_CHANGED);
|
||||||
|
event.setSourceRegion("OVERSEAS");
|
||||||
|
event.setTargetRegion("CN");
|
||||||
|
when(financeEventClient.deliver(event)).thenReturn("200");
|
||||||
|
when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1);
|
||||||
|
|
||||||
|
dispatcher.dispatchOne("event-1");
|
||||||
|
|
||||||
|
verify(financeEventClient).deliver(event);
|
||||||
|
verifyNoInteractions(firstlegEventClient);
|
||||||
|
verifyNoInteractions(eventClient);
|
||||||
|
assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception {
|
void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception {
|
||||||
for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED,
|
for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED,
|
||||||
|
|||||||
+9
-3
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 职责与边界
|
## 职责与边界
|
||||||
|
|
||||||
|
- Mapper 只由 `FileServiceApplication.java` 对 `com.baiyue.file` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。
|
||||||
- 负责企业资质文件、公共导出、二期入库测量图片的上传、存储、读取和鉴权。
|
- 负责企业资质文件、公共导出、二期入库测量图片的上传、存储、读取和鉴权。
|
||||||
- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。
|
- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。
|
||||||
|
|
||||||
@@ -9,12 +10,15 @@
|
|||||||
|
|
||||||
| 目标 | 入口 |
|
| 目标 | 入口 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
|
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java`、`controller/QualificationTypes.java`;契约 `src/main/resources/openapi/qualification-required-package-v1.yaml`。营业执照必须与 18 位统一社会信用代码走 `required-package`;法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换 |
|
||||||
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java`、`service/ExportStateService.java`、`service/GenericXlsxExportRunner.java`、`config/ExportDefinitionRegistry.java`、`client/ExportProviderClient.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/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
|
||||||
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
|
| 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` |
|
||||||
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` |
|
||||||
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
|
| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;业务类型分别为 `FINANCE_VOUCHER`、`OVERSEAS_UNLOAD_PHOTO`,必须按客户与业务 ID 精确校验 |
|
||||||
|
| 报关源文件、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 |
|
||||||
|
| 订舱文件 | 通用内部业务文件入口,契约 `src/main/resources/openapi/ocean-booking-file-v1.yaml`;类型固定 `OCEAN_BOOKING_DOCUMENT`、`tenantId=订舱customerId`、`businessId=oceanBookingId`,任意格式且最多 50MiB,不得绑定拖车;`BOOKING_CONFIRMATION/BILL_OF_LADING` 文档类型及有效状态由 Firstleg 保存 |
|
||||||
|
| 拖车预约附件 | 通用内部业务文件入口;固定 `tenantId=0`、`businessType=TRUCK_APPOINTMENT_ATTACHMENT`、`businessId=appointmentId`,只允许派车单、司机/车辆资料和其他平台附件;提单文件归订舱 `OCEAN_BOOKING_DOCUMENT`,不得复制为拖车附件 |
|
||||||
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
|
||||||
| 下游 OCR | `client/WeightOcrClient.java` |
|
| 下游 OCR | `client/WeightOcrClient.java` |
|
||||||
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
|
||||||
@@ -23,10 +27,12 @@
|
|||||||
## 修改约束
|
## 修改约束
|
||||||
|
|
||||||
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
|
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
|
||||||
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
|
- 客户单文件资质入口只接受 `LEGAL_PERSON_ID/FREIGHT_FORWARDER_CERTIFICATE/BANK_ACCOUNT_LICENSE`,客户范围只取登录 Token;营业执照不得绕过 `required-package`。同类当前资质已存在时必须显式 `replace=true`,成功后旧元数据标记为已替代;上传不会自动提交审核。
|
||||||
|
- 客户业务文件按客户、业务 ID 和文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。装柜四步照片与铅封是跨客户物理柜证据,所有者固定为平台(`tenantId=0`),业务 ID 固定为 `containerBookingId`,File 按柜与五类 `LOADING_*` 精确校验且不得跨柜复用;PDA `taskId` 只由 Firstleg 保存为来源/审计,不进入 File 所有权。国内 PDA 禁止直连 File 平台接口,必须经 Firstleg 校验任务、仓库和操作者后,由 Firstleg 使用内部服务令牌访问 File。
|
||||||
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
|
- 商品图片平台读取必须同时校验平台身份、`platform:order:list`、客户范围、文件业务类型与正整数 ID;不得提供匿名 URL、跨客户读取或把内部存储路径返回页面。
|
||||||
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
|
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
|
||||||
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
|
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
|
||||||
|
- 报关源文件和完成文件的 `businessId` 固定为 loadingPlanId,File DTO 仍为 `Metadata(fileId, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes)`,其中 `businessType` 即 fileCategory;Firstleg 保存业务文件版本并决定用户入口。权限固定为 Platform 上传结果 `platform:firstleg:customs:manage`、下载 `platform:firstleg:loading-list:list`,Client 上传源文件 `merchant:order:edit`、下载自身源文件或获准结果 `merchant:order:view`。结果还必须满足整柜完成和可见性,买单客户是否存在结果入口同样由 Firstleg 判定,File 不复制这些状态。
|
||||||
|
|
||||||
## 全量验收导航
|
## 全量验收导航
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.baiyue.file;
|
package com.baiyue.file;
|
||||||
|
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||||
@@ -9,6 +11,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
* 文件中心服务启动入口。
|
* 文件中心服务启动入口。
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||||
|
@MapperScan(basePackages = "com.baiyue.file", annotationClass = Mapper.class)
|
||||||
@EnableFeignClients(basePackages = "com.baiyue.file.client")
|
@EnableFeignClients(basePackages = "com.baiyue.file.client")
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
public class FileServiceApplication {
|
public class FileServiceApplication {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import jakarta.validation.constraints.NotBlank;
|
|||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.CacheControl;
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -65,11 +66,17 @@ public class InternalBusinessFileController {
|
|||||||
check(token);
|
check(token);
|
||||||
checkTenantId(tenantId);
|
checkTenantId(tenantId);
|
||||||
var value = service.download(tenantId, businessType, businessId, fileId);
|
var value = service.download(tenantId, businessType, businessId, fileId);
|
||||||
String name = value.fileName().replace("\r", "").replace("\n", "").replace("\"", "");
|
MediaType contentType;
|
||||||
|
try {
|
||||||
|
contentType = MediaType.parseMediaType(value.contentType());
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.cacheControl(CacheControl.noStore())
|
.cacheControl(CacheControl.noStore())
|
||||||
.contentType(MediaType.parseMediaType(value.contentType()))
|
.contentType(contentType)
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + name + "\"")
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||||
.contentLength(value.content().length)
|
.contentLength(value.content().length)
|
||||||
.body(value.content());
|
.body(value.content());
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -25,32 +25,32 @@ public class InternalLoadingEvidenceController {
|
|||||||
|
|
||||||
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); }
|
@PostConstruct void initializeToken() { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); }
|
||||||
|
|
||||||
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
|
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long tenantId, @PathVariable long inboundId,
|
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long containerBookingId,
|
||||||
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
|
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
|
||||||
requireToken(token);
|
requireToken(token);
|
||||||
if (tenantId <= 0 || inboundId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和操作主体ID必须大于0");
|
if (containerBookingId <= 0 || operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和操作主体ID必须大于0");
|
||||||
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
|
return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
|
@GetMapping("/{containerBookingId}/{fileId}")
|
||||||
public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
|
public ApiResponse<LoadingEvidenceService.FileMetadata> validate(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
|
@PathVariable long containerBookingId, @PathVariable long fileId) {
|
||||||
requireToken(token);
|
requireToken(token);
|
||||||
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
|
if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
|
||||||
return ApiResponse.success(service.validate(tenantId, inboundId, fileId));
|
return ApiResponse.success(service.validate(containerBookingId, fileId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}/content")
|
@GetMapping("/{containerBookingId}/{fileId}/content")
|
||||||
public ResponseEntity<byte[]> download(
|
public ResponseEntity<byte[]> download(
|
||||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||||
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
|
@PathVariable long containerBookingId, @PathVariable long fileId) {
|
||||||
requireToken(token);
|
requireToken(token);
|
||||||
if (tenantId <= 0 || inboundId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "租户、入库单和文件ID必须大于0");
|
if (containerBookingId <= 0 || fileId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "柜次和文件ID必须大于0");
|
||||||
var value = service.download(tenantId, inboundId, fileId);
|
var value = service.download(containerBookingId, fileId);
|
||||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||||
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
|
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||||
|
|||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.baiyue.file.controller;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.baiyue.file.service.InternalBusinessFileService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ContentDisposition;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/merchant/v1/files/order-batch-imports")
|
||||||
|
public class MerchantOrderBatchImportFileController {
|
||||||
|
private static final String ERROR_REPORT = "ORDER_BATCH_IMPORT_ERROR_REPORT";
|
||||||
|
|
||||||
|
@Resource private InternalBusinessFileService files;
|
||||||
|
|
||||||
|
@GetMapping("/{batchNo}/error-reports/{fileId}")
|
||||||
|
@PreAuthorize("hasAuthority('merchant:order:create')")
|
||||||
|
public ResponseEntity<byte[]> downloadErrorReport(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable String batchNo,
|
||||||
|
@PathVariable long fileId) {
|
||||||
|
var value = files.download(merchantTenant(user), ERROR_REPORT, batchNo, fileId);
|
||||||
|
MediaType contentType;
|
||||||
|
try {
|
||||||
|
contentType = MediaType.parseMediaType(value.contentType());
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.contentType(contentType)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||||
|
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||||
|
.contentLength(value.content().length)
|
||||||
|
.body(value.content());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long merchantTenant(AuthenticatedUser user) {
|
||||||
|
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|
||||||
|
|| user.tenantId() == null || user.tenantId() <= 0
|
||||||
|
|| user.userId() == null || user.userId() <= 0) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required");
|
||||||
|
}
|
||||||
|
return user.tenantId();
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -20,21 +20,21 @@ import java.nio.charset.StandardCharsets;
|
|||||||
public class PlatformLoadingEvidenceController {
|
public class PlatformLoadingEvidenceController {
|
||||||
@Resource private LoadingEvidenceService service;
|
@Resource private LoadingEvidenceService service;
|
||||||
|
|
||||||
@PostMapping(value = "/{tenantId}/{inboundId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/{containerBookingId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
|
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:edit')")
|
||||||
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
|
public ApiResponse<LoadingEvidenceService.UploadResult> upload(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable long inboundId,
|
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long containerBookingId,
|
||||||
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
|
@RequestParam String evidenceType, @RequestPart("file") MultipartFile file) {
|
||||||
long operatorId = requirePlatform(user); requirePositive(tenantId, inboundId);
|
long operatorId = requirePlatform(user); requirePositive(containerBookingId);
|
||||||
return ApiResponse.success(service.upload(tenantId, inboundId, operatorId, evidenceType, file));
|
return ApiResponse.success(service.upload(containerBookingId, operatorId, evidenceType, file));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
|
@GetMapping("/{containerBookingId}/{fileId}")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
|
@PreAuthorize("hasAuthority('platform:firstleg:inbound:loading:view')")
|
||||||
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
|
public ResponseEntity<byte[]> download(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable long tenantId, @PathVariable long inboundId, @PathVariable long fileId) {
|
@PathVariable long containerBookingId, @PathVariable long fileId) {
|
||||||
requirePlatform(user); requirePositive(tenantId, inboundId, fileId);
|
requirePlatform(user); requirePositive(containerBookingId, fileId);
|
||||||
var value = service.download(tenantId, inboundId, fileId);
|
var value = service.download(containerBookingId, fileId);
|
||||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||||
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
|
.contentType(safeImageMediaType(value.contentType())).contentLength(value.content().length)
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
|
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
|
||||||
|
|||||||
+2
-2
@@ -87,8 +87,8 @@ public class PlatformQualificationFileController {
|
|||||||
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@PathVariable long tenantId,
|
@PathVariable long tenantId,
|
||||||
@RequestPart("businessLicense") MultipartFile businessLicense,
|
@RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
|
||||||
@RequestPart("legalPersonId") MultipartFile legalPersonId,
|
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
|
||||||
@RequestParam String unifiedSocialCreditCode,
|
@RequestParam String unifiedSocialCreditCode,
|
||||||
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
||||||
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
|
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class QualificationFileController {
|
|||||||
@RequestParam(required = false) LocalDate validTo,
|
@RequestParam(required = false) LocalDate validTo,
|
||||||
@RequestParam(defaultValue = "false") boolean replace) {
|
@RequestParam(defaultValue = "false") boolean replace) {
|
||||||
long tenantId = merchantTenant(user);
|
long tenantId = merchantTenant(user);
|
||||||
qualificationType = QualificationTypes.requireOptional(qualificationType);
|
qualificationType = QualificationTypes.requireSingleUpload(qualificationType);
|
||||||
var stored = storage.store(tenantId, file);
|
var stored = storage.store(tenantId, file);
|
||||||
try {
|
try {
|
||||||
var response = iamClient.submit(internalToken,
|
var response = iamClient.submit(internalToken,
|
||||||
@@ -78,8 +78,8 @@ public class QualificationFileController {
|
|||||||
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
|
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
|
||||||
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestPart("businessLicense") MultipartFile businessLicense,
|
@RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
|
||||||
@RequestPart("legalPersonId") MultipartFile legalPersonId,
|
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
|
||||||
@RequestParam String unifiedSocialCreditCode,
|
@RequestParam String unifiedSocialCreditCode,
|
||||||
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
||||||
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
|
@RequestParam(required = false) LocalDate legalPersonIdValidTo,
|
||||||
|
|||||||
@@ -21,11 +21,22 @@ final class QualificationTypes {
|
|||||||
return qualificationType;
|
return qualificationType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String requireSingleUpload(String qualificationType) {
|
||||||
|
String type = requireSupported(qualificationType);
|
||||||
|
if ("BUSINESS_LICENSE".equals(type)) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST,
|
||||||
|
"The business license must be uploaded with its unified social credit code"
|
||||||
|
+ " / 营业执照必须与统一社会信用代码一并上传");
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
static String requireOptional(String qualificationType) {
|
static String requireOptional(String qualificationType) {
|
||||||
String type = requireSupported(qualificationType);
|
String type = requireSupported(qualificationType);
|
||||||
if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
|
if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST,
|
throw new BizException(ResultCode.BAD_REQUEST,
|
||||||
"Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传");
|
"Business license and legal-person ID use the platform qualification package"
|
||||||
|
+ " / 平台端营业执照与法人身份证通过资质包上传");
|
||||||
}
|
}
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public final class ExportTaskDtos {
|
|||||||
String id, String taskNo, String exportType, String format, String taskName,
|
String id, String taskNo, String exportType, String format, String taskName,
|
||||||
String status, Long totalRows, long processedRows, String fileName,
|
String status, Long totalRows, long processedRows, String fileName,
|
||||||
String contentType, Long sizeBytes, String failureMessage,
|
String contentType, Long sizeBytes, String failureMessage,
|
||||||
|
int attemptCount, Integer maxAttempts, LocalDateTime nextRetryAt,
|
||||||
String requestedBy, String requestedUsername, LocalDateTime requestedAt,
|
String requestedBy, String requestedUsername, LocalDateTime requestedAt,
|
||||||
LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { }
|
LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ public interface FileExportOutboxMapper extends BaseMapper<FileExportOutbox> {
|
|||||||
int sendFailed(@Param("id") long id, @Param("owner") String owner,
|
int sendFailed(@Param("id") long id, @Param("owner") String owner,
|
||||||
@Param("backoffSeconds") int backoffSeconds, @Param("error") String error);
|
@Param("backoffSeconds") int backoffSeconds, @Param("error") String error);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE file_export_outbox SET next_retry_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE task_id=#{taskId} AND dispatch_version=#{dispatchVersion}
|
||||||
|
AND status='PENDING' AND next_retry_at>CURRENT_TIMESTAMP(3)
|
||||||
|
""")
|
||||||
|
int expeditePendingRetry(@Param("taskId") long taskId, @Param("dispatchVersion") int dispatchVersion);
|
||||||
|
|
||||||
@Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL WHERE status='CLAIMED' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 50")
|
@Update("UPDATE file_export_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL WHERE status='CLAIMED' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 50")
|
||||||
int releaseExpiredClaims();
|
int releaseExpiredClaims();
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ public interface FileExportTaskMapper extends BaseMapper<FileExportTask> {
|
|||||||
""")
|
""")
|
||||||
int manualRetry(@Param("id") long id);
|
int manualRetry(@Param("id") long id);
|
||||||
|
|
||||||
|
@Update("""
|
||||||
|
UPDATE file_export_task SET next_retry_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id=#{id} AND status='PENDING' AND dispatch_version=#{dispatchVersion}
|
||||||
|
AND attempt_count>0 AND next_retry_at>CURRENT_TIMESTAMP(3)
|
||||||
|
""")
|
||||||
|
int expeditePendingRetry(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion);
|
||||||
|
|
||||||
@Update("UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),failure_message=#{reason},lease_owner=NULL,lease_until=NULL WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING'")
|
@Update("UPDATE file_export_task SET status='FAILED',completed_at=CURRENT_TIMESTAMP(3),failure_message=#{reason},lease_owner=NULL,lease_until=NULL WHERE id=#{id} AND dispatch_version=#{dispatchVersion} AND status='PENDING'")
|
||||||
int failQuarantined(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
int failQuarantined(@Param("id") long id, @Param("dispatchVersion") int dispatchVersion,
|
||||||
@Param("reason") String reason);
|
@Param("reason") String reason);
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ public class ExportOutboxRepository {
|
|||||||
public boolean claim(long id, String owner, int leaseSeconds) { return mapper.claim(id, owner, leaseSeconds) == 1; }
|
public boolean claim(long id, String owner, int leaseSeconds) { return mapper.claim(id, owner, leaseSeconds) == 1; }
|
||||||
public boolean sent(long id, String owner) { return mapper.sent(id, owner) == 1; }
|
public boolean sent(long id, String owner) { return mapper.sent(id, owner) == 1; }
|
||||||
public boolean sendFailed(long id, String owner, int seconds, String error) { return mapper.sendFailed(id, owner, seconds, error) == 1; }
|
public boolean sendFailed(long id, String owner, int seconds, String error) { return mapper.sendFailed(id, owner, seconds, error) == 1; }
|
||||||
|
public boolean expeditePendingRetry(long taskId, int dispatchVersion) {
|
||||||
|
return mapper.expeditePendingRetry(taskId, dispatchVersion) == 1;
|
||||||
|
}
|
||||||
public int releaseExpiredClaims() { return mapper.releaseExpiredClaims(); }
|
public int releaseExpiredClaims() { return mapper.releaseExpiredClaims(); }
|
||||||
public int redispatchSent(int staleSeconds, int graceSeconds) { return mapper.redispatchSent(staleSeconds, graceSeconds); }
|
public int redispatchSent(int staleSeconds, int graceSeconds) { return mapper.redispatchSent(staleSeconds, graceSeconds); }
|
||||||
public boolean quarantine(String eventId, long taskId, int version, String reason) { return mapper.quarantine(eventId, taskId, version, reason) == 1; }
|
public boolean quarantine(String eventId, long taskId, int version, String reason) { return mapper.quarantine(eventId, taskId, version, reason) == 1; }
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ public class ExportTaskRepository {
|
|||||||
return mapper.fail(id, dispatchVersion, owner, message) == 1;
|
return mapper.fail(id, dispatchVersion, owner, message) == 1;
|
||||||
}
|
}
|
||||||
public boolean manualRetry(long id) { return mapper.manualRetry(id) == 1; }
|
public boolean manualRetry(long id) { return mapper.manualRetry(id) == 1; }
|
||||||
|
public boolean expeditePendingRetry(long id, int dispatchVersion) {
|
||||||
|
return mapper.expeditePendingRetry(id, dispatchVersion) == 1;
|
||||||
|
}
|
||||||
public boolean failQuarantined(long id, int dispatchVersion, String reason) { return mapper.failQuarantined(id, dispatchVersion, reason) == 1; }
|
public boolean failQuarantined(long id, int dispatchVersion, String reason) { return mapper.failQuarantined(id, dispatchVersion, reason) == 1; }
|
||||||
public boolean recoverStale(long id, int dispatchVersion) { return mapper.recoverStale(id, dispatchVersion) == 1; }
|
public boolean recoverStale(long id, int dispatchVersion) { return mapper.recoverStale(id, dispatchVersion) == 1; }
|
||||||
public boolean failStale(long id, int dispatchVersion) { return mapper.failStale(id, dispatchVersion) == 1; }
|
public boolean failStale(long id, int dispatchVersion) { return mapper.failStale(id, dispatchVersion) == 1; }
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */
|
||||||
|
enum BusinessFileCategory {
|
||||||
|
TRUCK_APPOINTMENT_ATTACHMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OCEAN_BOOKING_DOCUMENT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
CONTAINER_CLEARANCE_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
LOADING_CUSTOMS_DECLARATION(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
CONTAINER_DELIVERY_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
CONTAINER_PICKUP_ORDER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_UNLOAD_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
OVERSEAS_RELABEL_PHOTO(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
LOADING_LIST_CUSTOMS_SOURCE(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
LOADING_LIST_CUSTOMS_RESULT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG),
|
||||||
|
ORDER_BATCH_IMPORT_SOURCE(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO),
|
||||||
|
ORDER_BATCH_IMPORT_ERROR_REPORT(true, FormatPolicy.XLSX, Limits.ORDER_IMPORT, BusinessIdPolicy.ORDER_BATCH_NO);
|
||||||
|
|
||||||
|
static final long STANDARD_MAX_SIZE = 10L * 1024 * 1024;
|
||||||
|
static final long CUSTOMS_MAX_SIZE = 50L * 1024 * 1024;
|
||||||
|
static final long ORDER_IMPORT_MAX_SIZE = 5L * 1024 * 1024;
|
||||||
|
|
||||||
|
private final boolean customerOwned;
|
||||||
|
private final FormatPolicy formatPolicy;
|
||||||
|
private final long maxSizeBytes;
|
||||||
|
private final BusinessIdPolicy businessIdPolicy;
|
||||||
|
|
||||||
|
BusinessFileCategory(boolean customerOwned, FormatPolicy formatPolicy, long maxSizeBytes,
|
||||||
|
BusinessIdPolicy businessIdPolicy) {
|
||||||
|
this.customerOwned = customerOwned;
|
||||||
|
this.formatPolicy = formatPolicy;
|
||||||
|
this.maxSizeBytes = maxSizeBytes;
|
||||||
|
this.businessIdPolicy = businessIdPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BusinessFileCategory require(String value) {
|
||||||
|
return Arrays.stream(values()).filter(item -> item.name().equals(value)).findFirst()
|
||||||
|
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void validateOwner(long tenantId) {
|
||||||
|
if (customerOwned && tenantId <= 0) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long maxSizeBytes() {
|
||||||
|
return maxSizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
FormatPolicy formatPolicy() {
|
||||||
|
return formatPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
String storageBusinessId(String businessId) {
|
||||||
|
if (businessIdPolicy == BusinessIdPolicy.ORDER_BATCH_NO) {
|
||||||
|
if (businessId == null || !businessId.matches("OBI-[0-9A-F]{20}")) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "批量导入业务编号格式无效");
|
||||||
|
}
|
||||||
|
return businessId;
|
||||||
|
}
|
||||||
|
final long value;
|
||||||
|
try {
|
||||||
|
value = Long.parseLong(businessId);
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "业务ID格式无效");
|
||||||
|
}
|
||||||
|
if (value <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须有效");
|
||||||
|
return Long.toString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FormatPolicy { STANDARD, ARBITRARY, XLSX }
|
||||||
|
private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO }
|
||||||
|
private static final class Limits {
|
||||||
|
private static final long STANDARD = 10L * 1024 * 1024;
|
||||||
|
private static final long CUSTOMS = 50L * 1024 * 1024;
|
||||||
|
private static final long ORDER_IMPORT = 5L * 1024 * 1024;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import jakarta.annotation.PostConstruct;
|
import jakarta.annotation.PostConstruct;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ import java.util.UUID;
|
|||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class BusinessFileStorage {
|
public class BusinessFileStorage {
|
||||||
private static final long MAX_SIZE = 10L * 1024 * 1024;
|
private static final String XLSX_TYPE =
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
private static final Map<String, String> TYPES = Map.of(
|
private static final Map<String, String> TYPES = Map.of(
|
||||||
"text/plain", ".txt", "application/pdf", ".pdf",
|
"text/plain", ".txt", "application/pdf", ".pdf",
|
||||||
"image/jpeg", ".jpg", "image/png", ".png");
|
"image/jpeg", ".jpg", "image/png", ".png");
|
||||||
@@ -35,13 +37,33 @@ public class BusinessFileStorage {
|
|||||||
root = configuredRoot.toAbsolutePath().normalize();
|
root = configuredRoot.toAbsolutePath().normalize();
|
||||||
}
|
}
|
||||||
|
|
||||||
Stored storeLoading(long tenantId, long businessId, MultipartFile file) {
|
Stored store(long tenantId, long businessId, MultipartFile file, long maxSize, boolean arbitraryFormat) {
|
||||||
|
return store(tenantId, Long.toString(businessId), file, maxSize,
|
||||||
|
arbitraryFormat ? BusinessFileCategory.FormatPolicy.ARBITRARY : BusinessFileCategory.FormatPolicy.STANDARD);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stored store(long tenantId, String businessId, MultipartFile file, BusinessFileCategory category) {
|
||||||
|
return store(tenantId, businessId, file, category.maxSizeBytes(), category.formatPolicy());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Stored store(long tenantId, String businessId, MultipartFile file, long maxSize,
|
||||||
|
BusinessFileCategory.FormatPolicy formatPolicy) {
|
||||||
if (file == null || file.isEmpty()) throw bad("附件不能为空");
|
if (file == null || file.isEmpty()) throw bad("附件不能为空");
|
||||||
if (file.getSize() > MAX_SIZE) throw bad("附件不能超过10MB");
|
if (file.getSize() > maxSize) throw bad("附件不能超过" + maxSize / 1024 / 1024 + "MB");
|
||||||
String type = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
if (businessId == null || !businessId.matches("[A-Za-z0-9-]+")) throw bad("业务ID格式无效");
|
||||||
String extension = TYPES.get(type);
|
String type = safeContentType(file.getContentType());
|
||||||
|
String extension;
|
||||||
|
if (formatPolicy == BusinessFileCategory.FormatPolicy.ARBITRARY) {
|
||||||
|
extension = "";
|
||||||
|
} else if (formatPolicy == BusinessFileCategory.FormatPolicy.XLSX) {
|
||||||
|
if (!XLSX_TYPE.equals(type)) throw bad("附件仅支持XLSX");
|
||||||
|
verifyXlsx(file);
|
||||||
|
extension = ".xlsx";
|
||||||
|
} else {
|
||||||
|
extension = TYPES.get(type);
|
||||||
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
|
if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG");
|
||||||
verifyContent(file, type);
|
verifyContent(file, type);
|
||||||
|
}
|
||||||
String name = safeName(file);
|
String name = safeName(file);
|
||||||
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
|
String objectKey = "business-files/" + tenantId + "/" + businessId + "/" + UUID.randomUUID() + extension;
|
||||||
Path target = root.resolve(objectKey).normalize();
|
Path target = root.resolve(objectKey).normalize();
|
||||||
@@ -55,6 +77,27 @@ public class BusinessFileStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void verifyXlsx(MultipartFile file) {
|
||||||
|
try (InputStream input = file.getInputStream()) {
|
||||||
|
byte[] prefix = input.readNBytes(4);
|
||||||
|
if (prefix.length < 4 || prefix[0] != 0x50 || prefix[1] != 0x4b
|
||||||
|
|| prefix[2] != 0x03 || prefix[3] != 0x04) {
|
||||||
|
throw bad("上传内容不是有效XLSX");
|
||||||
|
}
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw bad("附件内容读取失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safeContentType(String contentType) {
|
||||||
|
if (contentType == null || contentType.isBlank()) return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||||
|
try {
|
||||||
|
return MediaType.parseMediaType(contentType).toString().toLowerCase(Locale.ROOT);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
return MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
byte[] read(String objectKey) {
|
byte[] read(String objectKey) {
|
||||||
Path target = root.resolve(objectKey).normalize();
|
Path target = root.resolve(objectKey).normalize();
|
||||||
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
|
if (!target.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "文件路径越界");
|
||||||
@@ -91,7 +134,14 @@ public class BusinessFileStorage {
|
|||||||
|
|
||||||
private static String safeName(MultipartFile file) {
|
private static String safeName(MultipartFile file) {
|
||||||
String name = file.getOriginalFilename();
|
String name = file.getOriginalFilename();
|
||||||
return name == null || name.isBlank() ? "attachment" : Path.of(name).getFileName().toString();
|
if (name == null || name.isBlank()) return "attachment";
|
||||||
|
String normalized = name.replace('\\', '/');
|
||||||
|
normalized = normalized.substring(normalized.lastIndexOf('/') + 1)
|
||||||
|
.replaceAll("[\\p{Cntrl}]", "").trim();
|
||||||
|
if (normalized.isBlank()) return "attachment";
|
||||||
|
int codePoints = normalized.codePointCount(0, normalized.length());
|
||||||
|
if (codePoints <= 255) return normalized;
|
||||||
|
return normalized.substring(normalized.offsetByCodePoints(0, codePoints - 255));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String sha256(Path path) throws IOException {
|
private static String sha256(Path path) throws IOException {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.baiyue.file.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||||
|
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class ExportSchemaContracts {
|
||||||
|
static final String BUY_DECLARATION_ITEMS = "LOADING_LIST_BUY_DECLARATION_ITEMS";
|
||||||
|
static final String DOMESTIC_WAREHOUSE_ORDER_LIST = "DOMESTIC_WAREHOUSE_ORDER_LIST";
|
||||||
|
static final List<ExpectedColumn> BUY_DECLARATION_COLUMNS = List.of(
|
||||||
|
column("customerName", "客户名称", ExportColumnType.STRING),
|
||||||
|
column("orderNo", "订单号", ExportColumnType.STRING),
|
||||||
|
column("inboundCode", "入仓码", ExportColumnType.STRING),
|
||||||
|
column("sku", "SKU", ExportColumnType.STRING),
|
||||||
|
column("chineseName", "中文品名", ExportColumnType.STRING),
|
||||||
|
column("englishName", "英文品名", ExportColumnType.STRING),
|
||||||
|
column("cartonCount", "箱数", ExportColumnType.INTEGER),
|
||||||
|
column("productQuantity", "产品数量", ExportColumnType.INTEGER),
|
||||||
|
column("grossWeight", "毛重", ExportColumnType.DECIMAL),
|
||||||
|
column("netWeight", "净重", ExportColumnType.DECIMAL),
|
||||||
|
column("volume", "体积", ExportColumnType.DECIMAL),
|
||||||
|
column("mark", "唛头", ExportColumnType.STRING),
|
||||||
|
column("customerRemark", "客户备注", ExportColumnType.STRING));
|
||||||
|
static final List<ExpectedColumn> DOMESTIC_WAREHOUSE_ORDER_COLUMNS = List.of(
|
||||||
|
column("inboundCode", "入仓码", ExportColumnType.STRING),
|
||||||
|
column("orderNo", "订单号", ExportColumnType.STRING),
|
||||||
|
column("mainStatus", "订单主状态", ExportColumnType.STRING),
|
||||||
|
column("customerName", "客户", ExportColumnType.STRING),
|
||||||
|
column("channelName", "渠道", ExportColumnType.STRING),
|
||||||
|
column("channelChanged", "曾变更渠道", ExportColumnType.BOOLEAN),
|
||||||
|
column("receivingWarehouseName", "定位仓库", ExportColumnType.STRING),
|
||||||
|
column("destinationWarehouseName", "交货仓", ExportColumnType.STRING),
|
||||||
|
column("expectedShipDate", "预计出运日期", ExportColumnType.DATE),
|
||||||
|
column("expectedArrivalAt", "预计到货时间", ExportColumnType.DATETIME),
|
||||||
|
column("expectedVolumeCbm", "预计体积(m³)", ExportColumnType.DECIMAL),
|
||||||
|
column("totalPackageCount", "箱数", ExportColumnType.INTEGER),
|
||||||
|
column("totalQuantity", "产品数量", ExportColumnType.INTEGER),
|
||||||
|
column("actualGrossWeightKg", "实测毛重(kg)", ExportColumnType.DECIMAL),
|
||||||
|
column("actualVolumeCbm", "实测体积(m³)", ExportColumnType.DECIMAL),
|
||||||
|
column("chargeableWeightKg", "计费重量(kg)", ExportColumnType.DECIMAL),
|
||||||
|
column("chargeableVolumeCbm", "计费体积(m³)", ExportColumnType.DECIMAL),
|
||||||
|
column("feeReviewStatus", "费用审核状态", ExportColumnType.STRING),
|
||||||
|
column("skuNos", "SKU", ExportColumnType.STRING),
|
||||||
|
column("taxMode", "报关方式", ExportColumnType.STRING),
|
||||||
|
column("containerMode", "装柜方式", ExportColumnType.STRING),
|
||||||
|
column("customsInspection", "是否商检", ExportColumnType.BOOLEAN),
|
||||||
|
column("containerNo", "集装箱号", ExportColumnType.STRING),
|
||||||
|
column("cabinetNo", "柜次号", ExportColumnType.STRING),
|
||||||
|
column("vesselName", "船名", ExportColumnType.STRING),
|
||||||
|
column("voyageNo", "航次", ExportColumnType.STRING),
|
||||||
|
column("createdAt", "创建时间", ExportColumnType.DATETIME));
|
||||||
|
|
||||||
|
private ExportSchemaContracts() { }
|
||||||
|
|
||||||
|
static void validate(String exportType, List<ExportColumn> columns) {
|
||||||
|
if (BUY_DECLARATION_ITEMS.equals(exportType)) {
|
||||||
|
validate(columns, BUY_DECLARATION_COLUMNS, "买单报关导出列定义不匹配");
|
||||||
|
} else if (DOMESTIC_WAREHOUSE_ORDER_LIST.equals(exportType)) {
|
||||||
|
validate(columns, DOMESTIC_WAREHOUSE_ORDER_COLUMNS, "国内仓订单导出列定义不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validate(List<ExportColumn> columns, List<ExpectedColumn> expectedColumns,
|
||||||
|
String failureMessage) {
|
||||||
|
if (columns.size() != expectedColumns.size()) throw ExportFailure.permanent(failureMessage);
|
||||||
|
for (int index = 0; index < columns.size(); index++) {
|
||||||
|
ExportColumn actual = columns.get(index);
|
||||||
|
ExpectedColumn expected = expectedColumns.get(index);
|
||||||
|
if (!expected.key.equals(actual.key()) || !expected.title.equals(actual.title())
|
||||||
|
|| expected.type != actual.type()) {
|
||||||
|
throw ExportFailure.permanent(failureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpectedColumn column(String key, String title, ExportColumnType type) {
|
||||||
|
return new ExpectedColumn(key, title, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
record ExpectedColumn(String key, String title, ExportColumnType type) { }
|
||||||
|
}
|
||||||
@@ -65,6 +65,14 @@ public class ExportStateService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@Transactional
|
@Transactional
|
||||||
|
public boolean expeditePendingRetry(FileExportTask task) {
|
||||||
|
if (!outbox.expeditePendingRetry(task.getId(), task.getDispatchVersion())) return false;
|
||||||
|
if (!tasks.expeditePendingRetry(task.getId(), task.getDispatchVersion())) {
|
||||||
|
throw new IllegalStateException("Unable to expedite pending export task");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
public void quarantine(String eventId, long taskId, int version, String reason) {
|
public void quarantine(String eventId, long taskId, int version, String reason) {
|
||||||
FileExportTask current = tasks.find(taskId);
|
FileExportTask current = tasks.find(taskId);
|
||||||
if (current == null || current.getDispatchVersion() != version || !"PENDING".equals(current.getStatus())) return;
|
if (current == null || current.getDispatchVersion() != version || !"PENDING".equals(current.getStatus())) return;
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ public class ExportTaskService {
|
|||||||
if (!user.permissions().contains(permission)) {
|
if (!user.permissions().contains(permission)) {
|
||||||
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
|
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
|
||||||
}
|
}
|
||||||
validateParameters(request.parameters());
|
validateParameters(type, request.parameters());
|
||||||
|
validateTypeParameters(type, request.parameters());
|
||||||
String parametersJson = parametersJson(request.parameters());
|
String parametersJson = parametersJson(request.parameters());
|
||||||
long id = IdWorker.getId();
|
long id = IdWorker.getId();
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
@@ -102,21 +103,27 @@ public class ExportTaskService {
|
|||||||
|
|
||||||
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
|
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
|
||||||
FileExportTask task = ownedTask(user, id);
|
FileExportTask task = ownedTask(user, id);
|
||||||
if (!"FAILED".equals(task.getStatus())) {
|
|
||||||
throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试");
|
|
||||||
}
|
|
||||||
var definition = registry.require(task.getExportType());
|
var definition = registry.require(task.getExportType());
|
||||||
if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用");
|
if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用");
|
||||||
String permission = definition.getRequiredPermission();
|
String permission = definition.getRequiredPermission();
|
||||||
if (!user.permissions().contains(permission)) {
|
if (!user.permissions().contains(permission)) {
|
||||||
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
|
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
|
||||||
}
|
}
|
||||||
|
if ("PENDING".equals(task.getStatus()) && task.getAttemptCount() != null && task.getAttemptCount() > 0) {
|
||||||
|
if (!stateService.expeditePendingRetry(task)) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "任务已进入执行队列,无需重复重试");
|
||||||
|
}
|
||||||
|
return response(repository.find(id));
|
||||||
|
}
|
||||||
|
if (!"FAILED".equals(task.getStatus())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试");
|
||||||
|
}
|
||||||
if (!storage.delete(task.getObjectKey())) {
|
if (!storage.delete(task.getObjectKey())) {
|
||||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "上次导出文件清理失败,请稍后重试");
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "上次导出文件清理失败,请稍后重试");
|
||||||
}
|
}
|
||||||
boolean retried = stateService.manualRetry(task);
|
boolean retried = stateService.manualRetry(task);
|
||||||
if (!retried) {
|
if (!retried) {
|
||||||
throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试");
|
throw new BizException(ResultCode.CONFLICT, "只有等待重试或失败的导出任务可以重试");
|
||||||
}
|
}
|
||||||
return response(repository.find(id));
|
return response(repository.find(id));
|
||||||
}
|
}
|
||||||
@@ -139,15 +146,23 @@ public class ExportTaskService {
|
|||||||
catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); }
|
catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateParameters(Map<String, Object> parameters) {
|
private void validateParameters(String type, Map<String, Object> parameters) {
|
||||||
Map<String, Object> values = parameters == null ? Map.of() : parameters;
|
Map<String, Object> values = parameters == null ? Map.of() : parameters;
|
||||||
if (values.size() > 20) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件最多 20 项");
|
int maxItems = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type) ? 32 : 20;
|
||||||
|
if (values.size() > maxItems) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件最多 " + maxItems + " 项");
|
||||||
|
}
|
||||||
values.forEach((key, value) -> {
|
values.forEach((key, value) -> {
|
||||||
if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
|
if (key == null || key.isBlank() || key.length() > 64) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选键无效");
|
||||||
validateParameterValue(value, 1);
|
validateParameterValue(value, 1);
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (objectMapper.writeValueAsBytes(values).length > 4096) throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选条件不能超过 4KB");
|
int maxBytes = ExportSchemaContracts.DOMESTIC_WAREHOUSE_ORDER_LIST.equals(type)
|
||||||
|
? 512 * 1024 : 4096;
|
||||||
|
if (objectMapper.writeValueAsBytes(values).length > maxBytes) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||||
|
"导出筛选条件不能超过 " + (maxBytes / 1024) + "KB");
|
||||||
|
}
|
||||||
} catch (JsonProcessingException exception) {
|
} catch (JsonProcessingException exception) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化");
|
throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化");
|
||||||
}
|
}
|
||||||
@@ -176,11 +191,27 @@ public class ExportTaskService {
|
|||||||
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效");
|
throw new BizException(ResultCode.VALIDATION_FAILED, "导出筛选值类型无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void validateTypeParameters(String type, Map<String, Object> parameters) {
|
||||||
|
if (!ExportSchemaContracts.BUY_DECLARATION_ITEMS.equals(type)) return;
|
||||||
|
Map<String, Object> values = parameters == null ? Map.of() : parameters;
|
||||||
|
if (!values.keySet().equals(java.util.Set.of("loadingPlanId"))) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED, "买单报关导出仅接受 loadingPlanId");
|
||||||
|
}
|
||||||
|
Object raw = values.get("loadingPlanId");
|
||||||
|
try {
|
||||||
|
if (!(raw instanceof String text) || !text.matches("[1-9][0-9]*")
|
||||||
|
|| Long.parseLong(text) <= 0) throw new NumberFormatException();
|
||||||
|
} catch (NumberFormatException exception) {
|
||||||
|
throw new BizException(ResultCode.VALIDATION_FAILED, "loadingPlanId 必须为正整数字符串");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ExportTaskResponse response(FileExportTask task) {
|
private ExportTaskResponse response(FileExportTask task) {
|
||||||
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
|
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
|
||||||
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
|
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
|
||||||
task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(),
|
task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(),
|
||||||
task.getContentType(), task.getSizeBytes(), task.getFailureMessage(),
|
task.getContentType(), task.getSizeBytes(), task.getFailureMessage(),
|
||||||
|
task.getAttemptCount() == null ? 0 : task.getAttemptCount(), task.getMaxAttempts(), task.getNextRetryAt(),
|
||||||
String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(),
|
String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(),
|
||||||
task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt());
|
task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ public class GenericXlsxExportRunner {
|
|||||||
if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) {
|
if (batch.schemaVersion() != 1 || blank(batch.schemaHash()) || blank(batch.snapshotToken()) || batch.snapshotToken().length() > 512) {
|
||||||
throw ExportFailure.permanent("Provider 首批契约无效");
|
throw ExportFailure.permanent("Provider 首批契约无效");
|
||||||
}
|
}
|
||||||
columns = validateColumns(batch.columns()); columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
|
columns = validateColumns(batch.columns());
|
||||||
|
ExportSchemaContracts.validate(task.getExportType(), columns);
|
||||||
|
columnKeys = columns.stream().map(ExportColumn::key).collect(java.util.stream.Collectors.toUnmodifiableSet()); schemaHash = batch.schemaHash(); snapshot = batch.snapshotToken();
|
||||||
sheet = newSheet(workbook, 1, columns);
|
sheet = newSheet(workbook, 1, columns);
|
||||||
} else if (!schemaHash.equals(batch.schemaHash())) {
|
} else if (!schemaHash.equals(batch.schemaHash())) {
|
||||||
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
|
throw ExportFailure.permanent("Provider 导出列定义在执行期间发生变化");
|
||||||
|
|||||||
@@ -10,22 +10,8 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class InternalBusinessFileService {
|
public class InternalBusinessFileService {
|
||||||
private static final Set<String> BUSINESS_TYPES = Set.of(
|
|
||||||
"TRUCK_APPOINTMENT_ATTACHMENT",
|
|
||||||
"CUSTOMS_DOCUMENT",
|
|
||||||
"CUSTOMS_RECEIPT",
|
|
||||||
"FINANCE_VOUCHER",
|
|
||||||
"CONTAINER_CLEARANCE_DOCUMENT",
|
|
||||||
"LOADING_CUSTOMS_DECLARATION",
|
|
||||||
"CONTAINER_DELIVERY_ORDER",
|
|
||||||
"CONTAINER_PICKUP_ORDER",
|
|
||||||
"OVERSEAS_UNLOAD_PHOTO",
|
|
||||||
"OVERSEAS_RELABEL_PHOTO");
|
|
||||||
|
|
||||||
@Resource private FileObjectMapper files;
|
@Resource private FileObjectMapper files;
|
||||||
@Resource private BusinessFileStorage storage;
|
@Resource private BusinessFileStorage storage;
|
||||||
|
|
||||||
@@ -34,9 +20,10 @@ public class InternalBusinessFileService {
|
|||||||
if (tenantId < 0 || operatorId <= 0) {
|
if (tenantId < 0 || operatorId <= 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
|
throw new BizException(ResultCode.BAD_REQUEST, "归属和操作人必须有效");
|
||||||
}
|
}
|
||||||
validateBusinessType(businessType);
|
BusinessFileCategory category = BusinessFileCategory.require(businessType);
|
||||||
long parsedBusinessId = parseBusinessId(businessId);
|
category.validateOwner(tenantId);
|
||||||
var stored = storage.storeLoading(tenantId, parsedBusinessId, file);
|
String storageBusinessId = category.storageBusinessId(businessId);
|
||||||
|
var stored = storage.store(tenantId, storageBusinessId, file, category);
|
||||||
FileObjectEntity value = new FileObjectEntity();
|
FileObjectEntity value = new FileObjectEntity();
|
||||||
value.setTenantId(tenantId);
|
value.setTenantId(tenantId);
|
||||||
value.setBusinessType(businessType);
|
value.setBusinessType(businessType);
|
||||||
@@ -89,33 +76,18 @@ public class InternalBusinessFileService {
|
|||||||
if (tenantId < 0 || fileId <= 0) {
|
if (tenantId < 0 || fileId <= 0) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
|
throw new BizException(ResultCode.BAD_REQUEST, "归属和文件ID必须有效");
|
||||||
}
|
}
|
||||||
validateBusinessType(businessType);
|
BusinessFileCategory category = BusinessFileCategory.require(businessType);
|
||||||
parseBusinessId(businessId);
|
category.validateOwner(tenantId);
|
||||||
}
|
category.storageBusinessId(businessId);
|
||||||
|
|
||||||
private static void validateBusinessType(String businessType) {
|
|
||||||
if (!BUSINESS_TYPES.contains(businessType)) {
|
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long parseBusinessId(String businessId) {
|
|
||||||
final long value;
|
|
||||||
try {
|
|
||||||
value = Long.parseLong(businessId);
|
|
||||||
} catch (NumberFormatException exception) {
|
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "业务ID格式无效");
|
|
||||||
}
|
|
||||||
if (value <= 0) throw new BizException(ResultCode.BAD_REQUEST, "业务ID必须有效");
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Metadata metadata(FileObjectEntity value) {
|
private static Metadata metadata(FileObjectEntity value) {
|
||||||
return new Metadata(value.getId(), value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
|
return new Metadata(value.getId(), value.getTenantId() == 0 ? "PLATFORM" : "CUSTOMER",
|
||||||
|
value.getTenantId(), value.getBusinessType(), value.getBusinessId(),
|
||||||
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes());
|
value.getOriginalName(), value.getSha256(), value.getContentType(), value.getSizeBytes());
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
public record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId,
|
||||||
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
String fileName, String sha256, String contentType, Long sizeBytes) {}
|
||||||
public record Download(String fileName, String contentType, byte[] content) {}
|
public record Download(String fileName, String contentType, byte[] content) {}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user