Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	scripts/admin-test-env-modules/phase4-overseas-pda.mjs
#	scripts/agents-index-contract.ps1
This commit is contained in:
2026-09-02 15:21:20 +08:00
33 changed files with 147 additions and 171 deletions
+3 -3
View File
@@ -22,13 +22,13 @@
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 客户建档默认尺寸初始化与补偿 | `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/` 定位;业务事件生产者回到所属服务 |
| 订单、货物、快照、状态、幂等、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` |
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
| 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 最终完成前存在 |
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、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/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
@@ -44,7 +44,7 @@
| 海外 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` |
| 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 |
| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合全柜进度与 `allPlansReady`,详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 |
| 柜级入仓计划进度与整柜转换 | `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 已移除 |
| 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 |
| 五期海外排车执行、PDA 出库、派送与一件代发 | `lastleg-service/` | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`OutboundPdaController.java`、`MerchantDeliveryController.java`,一件代发库存归 `dropship/`;统一经 `gateway-overseas/`,设备端是否已有正式入口继续查 `../apps/pda-overseas-uniapp/AGENTS.md` |
+14
View File
@@ -33,6 +33,20 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
## 容器日志
所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外 Compose 统一使用 Docker `local` 日志驱动;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。
单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖:
| 环境 | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 |
|---|---:|---:|---|
| 开发 | `20m` | `2` | 不要求 |
| 测试 | `50m` | `3` | 不要求 |
| 生产 | `100m` | `5` | 必须由集中日志系统负责,Docker 本机日志只作短期缓冲 |
调整日志策略后必须重新创建容器;只重启既有容器不会改变其日志驱动。历史 `/data/logs/baiyue-firstleg` 文件和旧容器日志不自动删除,确认无需保留后再单独清理。
## 网关跨域配置
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
@@ -40,14 +40,3 @@ baiyue:
rocketmq:
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
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}"
-11
View File
@@ -52,14 +52,3 @@ baiyue.clients:
url: ${IAM_SERVICE_URL:}
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}"
-3
View File
@@ -50,9 +50,6 @@ spring:
client.config.default:
connect-timeout: 3000
read-timeout: 5000
logging:
file:
path: ${LOG_PATH:/app/logs}
server:
shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
-3
View File
@@ -47,9 +47,6 @@ spring:
client.config.default:
connect-timeout: 3000
read-timeout: 5000
logging:
file:
path: ${LOG_PATH:./logs}
server:
shutdown: graceful
max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB}
+4 -8
View File
@@ -24,13 +24,13 @@ x-overseas-common: &overseas-common
DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas}
DATA_RESIDENCY: OVERSEAS
SPRING_SQL_INIT_MODE: never
LOG_PATH: /app/logs
TZ: Asia/Shanghai
logging:
driver: json-file
# 应用只写 stdout/stderr;local 驱动负责单份本机日志与轮转。
driver: local
options:
max-size: 100m
max-file: "5"
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services:
gateway-overseas:
@@ -38,7 +38,6 @@ services:
image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas
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:
<<: *overseas-environment
SERVER_PORT: 9300
@@ -55,7 +54,6 @@ services:
image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-lastleg
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PORT:-9303}:9303"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/lastleg-service:/app/logs"]
environment:
<<: *overseas-environment
SERVER_PORT: 9303
@@ -73,7 +71,6 @@ services:
image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas
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:
<<: *overseas-environment
SERVER_PORT: 9305
@@ -87,7 +84,6 @@ services:
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas
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:
<<: *overseas-environment
SERVER_PORT: 9312
+4 -16
View File
@@ -26,13 +26,13 @@ x-common: &common
REGION_CODE: ${REGION_CODE:-CN}
DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn}
DATA_RESIDENCY: ${DATA_RESIDENCY:-CN}
LOG_PATH: /app/logs
TZ: Asia/Shanghai
logging:
driver: json-file
# 应用只写 stdout/stderr;local 驱动负责单份本机日志与轮转。
driver: local
options:
max-size: 100m
max-file: "5"
max-size: ${DOCKER_LOG_MAX_SIZE:-50m}
max-file: "${DOCKER_LOG_MAX_FILE:-3}"
services:
gateway:
@@ -40,7 +40,6 @@ services:
image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-gateway
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PORT:-9000}:9000"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/gateway:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9000
@@ -53,7 +52,6 @@ services:
image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-auth
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PORT:-9100}:9100"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/auth:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9100
@@ -72,7 +70,6 @@ services:
image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-iam
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PORT:-9201}:9201"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/iam:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9201
@@ -86,7 +83,6 @@ services:
image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-business
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PORT:-9202}:9202"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/firstleg:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9202
@@ -112,7 +108,6 @@ services:
image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-finance
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PORT:-9203}:9203"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/finance:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9203
@@ -125,7 +120,6 @@ services:
image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-order
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PORT:-9204}:9204"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/order:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9204
@@ -138,7 +132,6 @@ services:
image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-warehouse
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PORT:-9205}:9205"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/warehouse:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9205
@@ -153,7 +146,6 @@ services:
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PORT:-9206}:9206"]
volumes:
- "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files"
- "${LOG_ROOT:-/data/logs/baiyue-firstleg}/file:/app/logs"
environment:
<<: *common-environment
SERVER_PORT: 9206
@@ -175,7 +167,6 @@ services:
image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-print
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PORT:-9207}:9207"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/print:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9207
@@ -186,7 +177,6 @@ services:
image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-integration
ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PORT:-9210}:9210"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/integration-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9210
@@ -236,7 +226,6 @@ services:
image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge
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:
<<: *common-environment
SERVER_PORT: 9312
@@ -256,7 +245,6 @@ services:
image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG}
container_name: firstleg-${DEPLOY_ENV:-test}-audit
expose: ["9700"]
volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/audit-service:/app/logs"]
environment:
<<: *common-environment
SERVER_PORT: 9700
+1 -1
View File
@@ -15,7 +15,7 @@ RUN addgroup -S app \
WORKDIR /app
# 预建镜像内运行时数据和日志目录并归属 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。
COPY --from=extractor --chown=app:app /workspace/dependencies/ ./
+2 -1
View File
@@ -10,7 +10,7 @@
| 目标 | 入口 |
|---|---|
| 商户/平台资质 | `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/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` |
@@ -27,6 +27,7 @@
## 修改约束
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 客户单文件资质入口只接受 `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、跨客户读取或把内部存储路径返回页面。
- 公共导出只接受 `ExportDefinitionRegistry` 明确登记的 `exportType`,由 File 调用所属业务服务的内部 Provider 分批取数并生成文件;业务页面不得直连 Provider。新增或修改导出类型时同步检查业务服务 Provider、File Feign 客户端/响应上限、权限、Admin 下载中心标签与重试映射及契约测试。
-11
View File
@@ -201,14 +201,3 @@ baiyue:
max-attempts: 3
batch-size: 500
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}"
@@ -26,7 +26,3 @@ spring:
namespace: ${NACOS_NAMESPACE:public}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
+1 -1
View File
@@ -34,7 +34,7 @@
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
| PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` |
| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 |
| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` |
| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` |
@@ -61,14 +61,3 @@ firstleg:
reservation-ttl: ${PALLET_RESERVATION_TTL:30m}
warehouse-resource-tenant-id: ${WAREHOUSE_RESOURCE_TENANT_ID:0}
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}"
@@ -230,14 +230,3 @@ springdoc:
url: /event-bridge-service/v3/api-docs
- name: finance
url: /finance-service/v3/api-docs
logging:
file:
name: "${LOGGING_FILE_NAME:${LOG_PATH:./logs}/${spring.application.name}.log}"
logback:
rollingpolicy:
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
-11
View File
@@ -39,14 +39,3 @@ baiyue:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
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}"
@@ -104,14 +104,3 @@ management:
health:
probes:
enabled: true
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}"
+1 -1
View File
@@ -12,7 +12,7 @@
| 功能 | 入口 |
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、计划数、未操作数和 `allPlansReady`,分页前排除 `allPlansReady=true`,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码和有效计划,分页前排除全部入仓码均已生成有效独立计划的柜,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -21,12 +21,9 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
"<if test='q.createdFrom != null'>AND o.created_at &gt;=#{q.createdFrom} </if>" +
"<if test='q.createdTo != null'>AND o.created_at &lt;=#{q.createdTo} </if>" +
"GROUP BY o.source_container_booking_id " +
"HAVING SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)&gt;0 " +
"OR SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL " +
"OR NULLIF(TRIM(p.status),'') IS NULL THEN 1 ELSE 0 END)&gt;0 " +
"OR COUNT(DISTINCT p.id)&lt;&gt;COUNT(*) " +
"OR COUNT(NULLIF(o.container_no,''))&lt;&gt;COUNT(*) " +
"OR COUNT(DISTINCT NULLIF(o.container_no,''))&lt;&gt;1 ";
"HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " +
"AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) " +
"&lt; COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) ";
String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " +
@@ -290,7 +290,7 @@ public class LoadingCompletedConsumer {
try { warehouseId = Long.parseLong(payload.warehouseId()); }
catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); }
dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId,
event.eventId() + ":DROPSHIP", dropshipPallets));
event.eventId() + ":DROPSHIP:" + source.sourceInboundId(), dropshipPallets));
}
}
@@ -253,7 +253,7 @@ paths:
/api/platform/v1/last-leg/orders/container-summaries:
get:
summary: 分页查询柜级入库计划进度
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,仅返回 allPlansReady=false 的待处理柜,已全部生成有效独立计划的柜在分页前排除。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,仅返回仍有入仓码待生成有效独立计划的柜,全部入仓码均已生成有效独立计划的柜在分页前排除。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -38,8 +38,8 @@ class ContainerPlanProgressContractTest {
String sql = String.join("", rows.value()).toLowerCase();
assertTrue(sql.contains("left join ll_inbound_plan"));
assertTrue(sql.contains("o.estimated_warehouse_at"));
assertTrue(sql.contains("having sum(case when nullif(trim(o.inbound_code),'') is null then 1 else 0 end)&gt;0"));
assertTrue(sql.contains("or count(distinct p.id)&lt;&gt;count(*)"));
assertTrue(sql.contains("having count(distinct case when p.id is not null"));
assertTrue(sql.contains("&lt; count(distinct nullif(trim(o.inbound_code),''))"));
assertTrue(sql.contains("progress.plannedarrivalat asc"));
assertTrue(sql.contains("progress.sourcecontainerbookingid asc"));
assertTrue(sql.contains("limit #{limit} offset #{offset}"));
@@ -56,7 +56,7 @@ class ContainerPlanProgressContractTest {
assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:"));
assertTrue(openApi.contains("ContainerPlanProgressSummary:"));
assertTrue(openApi.contains("allPlansReady:"));
assertTrue(openApi.contains("仅返回 allPlansReady=false 的待处理柜"));
assertTrue(openApi.contains("仅返回仍有入仓码待生成有效独立计划的柜"));
assertTrue(openApi.contains("ll_order.estimated_warehouse_at"));
assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)"));
@@ -75,7 +75,8 @@ class ContainerPlanProgressContractTest {
assertTrue(sql.contains("source_container_booking_id<>''"));
assertTrue(sql.contains("LEFT JOIN ll_inbound_plan"));
assertTrue(sql.contains("HAVING SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)>0"));
assertTrue(sql.contains("HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL"));
assertTrue(sql.contains("< COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),''))"));
assertTrue(!sql.contains("&lt;"));
}
}
@@ -26,7 +26,7 @@ class LoadingCompletedConsumerTest {
assertTrue(table.contains("customer_id")); assertFalse(table.contains("tenant_id"));
}
@Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","Customer 100","ONE_CLICK_DROPSHIP",List.of(pallet),"FBA"); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","MSCU6639870","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor<DropshipRequests.Receive> captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); verify(inventory,never()).registerInTransit(anyString(),anyString(),any()); assertEquals("100",captor.getValue().customerId()); assertEquals("123e4567-e89b-12d3-a456-426614174010:DROPSHIP:in-d",captor.getValue().idempotencyKey()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); }
@Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){
var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2"));
var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));
+1 -1
View File
@@ -12,7 +12,7 @@
| 目标 | 入口 |
|---|---|
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
| 统一订单查询与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导 |
| 统一订单查询、审核优先状态与稳定渠道编码 | `service/UnifiedOrderQueryService.java`、`dto/UnifiedOrderResponse.java`、`src/main/resources/openapi/order-v1.yaml`;`channelCode` 直接来自 Order/Firstleg 权威快照,只用于业务校验,不得由 `channelName` 推导。Order 为 `SUBMITTED` 时即使已有 Firstleg 入库投影,统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER` |
| 平台版本化修改客户原始申报 | `controller/PlatformUnifiedOrderController.java` 的 `PUT /api/platform/v1/orders/{orderId}/declaration`、`service/OrderDeclarationUpdateService.java`、`dto/OrderDeclarationDtos.java`;权限 `platform:order:declaration:edit`,货物以订单内 `itemNo` 定位并保持行集合完整,契约 `src/main/resources/openapi/order-v1.yaml` |
| 申报同步、渠道履约/计价门禁 | `service/OrderFulfillmentChannelChangeGuard.java`、`OrderPricingChannelGuard.java`、`OrderInboundOutboxPublisher.java`,内部客户端 `FirstlegInboundClient.java`、`FirstlegPricingClient.java`;审核后通过版本化 `ORDER_DECLARATION_UPDATED_V1` 同步 Firstleg |
| 入仓码业务关系摘要 | `service/InboundCodeAssociationService.java`、`dto/InboundCodeAssociationSummary.java`、平台/客户 `FirstLegOrderController` 的 `/inbound-code-associations/{inboundCode}`;客户入口必须按认证客户隔离 |
-10
View File
@@ -37,13 +37,3 @@ baiyue:
url: ${FILE_SERVICE_URL:}
order:
inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO}
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}"
-11
View File
@@ -36,14 +36,3 @@ print:
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
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}"
+1
View File
@@ -133,6 +133,7 @@ node scripts/create-pending-review-orders.mjs
| Mapper 注册静态合同 | `node scripts/mapper-scan-contract.mjs`、`node --test scripts/mapper-scan-contract.test.mjs`;自动核对全部持久化模块的 `@Mapper`、服务根包注解过滤扫描和唯一扫描入口,不启动 Spring、不连接数据库;`backend-full-regression.ps1` 在 Maven 前强制执行 |
| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 |
| 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 |
| 容器日志单写合同 | `scripts/container-logging-contract.test.mjs`;离线确认应用只写标准输出、Compose 使用有界 `local` 驱动且不挂载应用日志目录 |
Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境:
@@ -195,12 +195,16 @@ export async function run(context, matrix = context.createdResources.manualState
await verify(context, OCEAN_BASE, status, resourceId)
}
const releasedOcean = matrix.pages.oceanBookings.fixtures.RELEASED
if (!releasedOcean.id || !releasedOcean.warehouseId) {
throw new Error('RELEASED 订舱缺少拖车所需的仓库归属')
}
matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} }
for (const [index, status] of TRUCK_STATUSES.entries()) {
const released = await buildOcean(context, template, 'RELEASED', index + 100)
const releasedOcean = {
id: id(released?.oceanBooking?.id),
warehouseId: id(released?.oceanBooking?.warehouseId),
}
if (!releasedOcean.id || !releasedOcean.warehouseId) {
throw new Error(`拖车状态样本 ${index} 缺少独立 RELEASED 订舱`)
}
const value = await buildTruck(context, releasedOcean, status, index)
const resourceId = id(value?.id)
matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` }
@@ -43,7 +43,7 @@ async function loginMerchant(context) {
const password = account?.password || process.env.MERCHANT_TEST_PASSWORD || ''
if (!username || !password) throw new Error('缺少客户登录凭据')
const response = await context.request('POST', '/api/auth/login', {
username, password,
loginId: username, password,
}, '')
const token = success(response) ? response.body?.data?.accessToken : null
if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`)
@@ -134,6 +134,16 @@ function routes(context, merchantToken) {
export async function prepare(context) {
const outerMarker = context.marker
const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures']
const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, {
present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name],
}]))
const restoreIsolatedResources = () => {
for (const [name, snapshot] of isolatedResourceSnapshot) {
if (snapshot.present) context.createdResources[name] = snapshot.value
else delete context.createdResources[name]
}
}
const restore = await context.preparePhase3LoadingFixture('-CUSTOMS-MIXED', false, {
primary: 'BUY_DECLARATION', incremental: 'TAX_REFUND',
})
@@ -154,6 +164,7 @@ export async function prepare(context) {
if (!source?.loadingPlanId || !source?.containerBookingId || source?.finalStatus !== 'LOADED') {
context.marker = outerMarker
restore()
restoreIsolatedResources()
throw new Error(`混合柜未进入LOADED: planId=${id(source?.loadingPlanId) || 'missing'}, status=${source?.finalStatus || 'missing'}`)
}
const platform = await context.request('GET', `${PLATFORM}/${encodeURIComponent(id(source.loadingPlanId))}`)
@@ -169,6 +180,7 @@ export async function prepare(context) {
if (!buy.length || !refund.length) {
context.marker = outerMarker
restore()
restoreIsolatedResources()
throw new Error(`正式装柜API未形成混合柜: buy=${buy.length}, tax=${refund.length}`)
}
const merchantToken = await loginMerchant(context)
@@ -208,5 +220,6 @@ export async function prepare(context) {
delete context.context.phase3ContainerCustomsRoutes
context.marker = outerMarker
restore()
restoreIsolatedResources()
}
}
@@ -48,6 +48,8 @@ test('outbound-created query supports automated or manual carrier then reaches P
assert.match(pda, /convertReceivingContainers\(context, producers\)/)
assert.match(pda, /last-leg\/orders\/container-summaries/)
assert.match(pda, /last-leg\/inbound-plans\/convert-container/)
assert.match(pda, /Number\(summary\?\.missingInboundCodeCount\) === 0/)
assert.doesNotMatch(pda, /summary\?\.eligible/)
assert.match(pda, /item\?\.shippingReady === true/)
assert.match(pda, /nonBlank\(item\?\.carrierScheduleQueryId\)/)
assert.match(pda, /targetCorrelated/)
+26 -2
View File
@@ -171,6 +171,7 @@ Assert-Contains $firstlegAgents '/loading-plans/{planId}/lifecycle' 'Firstleg
$orderOpenApi = Read-RequiredText (Join-Path $backendRoot 'order/src/main/resources/openapi/order-v1.yaml')
$firstlegOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/firstleg-v1.yaml')
$orderDeclarationService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/OrderDeclarationUpdateService.java')
$unifiedOrderQueryService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java')
$firstlegDeclarationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java')
$adminDeclarationEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
Assert-Contains $rootAgents '平台修改客户原始申报与履约渠道' '根订单申报快速路由'
@@ -186,6 +187,11 @@ Assert-Contains $orderDeclarationService 'ORDER_DECLARATION_UPDATED_V1' 'Order
Assert-Contains $firstlegDeclarationService 'mapper.byEvent(event.tenantId(), event.eventId())' 'Firstleg 申报 eventId 幂等'
Assert-Contains $firstlegDeclarationService 'event.version() < current.orderVersion()' 'Firstleg 申报版本单调性'
Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' 'Admin 申报事实边界提示'
Assert-Contains $rootAgents '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态'
Assert-Contains $orderAgents '统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER`' 'Order 待审核优先状态'
Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBMITTED' 'Order 待审核状态判断'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : summary.mainStatus()' 'Order 待审核主状态优先'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "APPROVE_ORDER" : summary.nextAction()' 'Order 待审核动作优先'
# 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。
$authAgents = Read-RequiredText (Join-Path $backendRoot 'auth/AGENTS.md')
@@ -209,9 +215,18 @@ Assert-Contains $adminCustomersView '不会修改企业管理员登录手机号'
Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机号' 'Client 公司联系电话提示'
Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示'
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
$qualificationOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/qualification-required-package-v1.yaml')
Assert-Contains $rootAgents '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义'
Assert-Contains $fileAgents '法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换' 'File 法人身份证单独上传入口'
Assert-Contains $qualificationOpenApi '/api/merchant/v1/files/qualifications:' '客户单文件资质 OpenAPI'
Assert-Contains $qualificationOpenApi 'LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换' '客户法人身份证单独上传契约'
Assert-Contains $qualificationOpenApi 'BUSINESS_LICENSE 不允许走此入口' '营业执照整包上传边界'
Assert-Contains $clientCompanyProfile '法人身份证已保存,尚未提交审核' 'Client 法人身份证单独上传反馈'
Assert-NotContains $clientCompanyProfile '当前文件服务尚未开放法人身份证单独上传或替换' 'Client 已移除法人身份证旧阻断'
$truckOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/truck-appointments-v1.yaml')
$loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml')
$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js')
$cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java')
Assert-Contains $rootAgents '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
Assert-Contains $rootAgents '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
Assert-Contains $adminAgents '订舱级拖车预约' 'Admin 拖车快速入口'
@@ -236,6 +251,11 @@ Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'
Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API'
Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API'
Assert-Contains $rootAgents '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界'
Assert-Contains $firstlegAgents '完全重放返回当前视图且不得再次上传 File' 'Firstleg 装柜证据重放边界'
Assert-Contains $cabinetEvidenceService 'uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart)' '装柜证据上传请求哈希'
Assert-Contains $cabinetEvidenceService 'digest.update(multipart.getBytes())' '装柜证据文件内容哈希'
Assert-Matches $cabinetEvidenceService '(?s)mapper\.byOperation\(taskId, key\).{0,500}?return view\(scope\);.{0,500}?files\.upload' '装柜证据上传先查重放再调用 File'
$mapperModuleAgents = @(
@{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' },
@{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' },
@@ -501,7 +521,11 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/contai
Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $adminAgents 'allPlansReady' 'Admin 柜级计划就绪事实'
Assert-Contains $rootAgents '只分页返回仍有入仓码待生成有效独立计划的柜' '根柜级计划待处理筛选'
Assert-Contains $backendAgents '分页前排除全部入仓码均已生成有效独立计划的柜' '后端柜级计划待处理筛选'
Assert-Contains $adminAgents '返回仍有待生成计划入仓码的柜' 'Admin 柜级计划待处理筛选'
Assert-Contains $lastlegAgents '分页前排除全部入仓码均已生成有效独立计划的柜' 'Lastleg 柜级计划待处理筛选'
Assert-Contains $lastlegBusinessOpenApi '仅返回仍有入仓码待生成有效独立计划的柜' 'Lastleg 柜级计划待处理 OpenAPI'
Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选'
Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口'
Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用'
@@ -837,5 +861,5 @@ if ($failures.Count -gt 0) {
exit 1
}
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、脚本目录治理、企业联系信息与登录标识解耦、订单申报版本同步与渠道门禁、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、脚本目录治理、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、四/五期范围边界、四期柜级待处理计划筛选与范围漂移、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green
exit 0
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
const applicationConfigs = [
'auth/src/main/resources/application.yml',
'audit-service/src/main/resources/application.yml',
'event-bridge-service/src/main/resources/application.yml',
'file/src/main/resources/application.yml',
'finance/src/main/resources/application.yml',
'firstleg/src/main/resources/application.yml',
'gateway/src/main/resources/application.yml',
'gateway-overseas/src/main/resources/application.yml',
'iam/src/main/resources/application.yml',
'integration-service/src/main/resources/application.yml',
'lastleg-service/src/main/resources/application.yml',
'order/src/main/resources/application.yml',
'print/src/main/resources/application.yml',
'warehouse/src/main/resources/application.yml',
'warehouse-overseas-service/src/main/resources/application.yml',
'config/nacos/cn/baiyue-common.yml',
'config/nacos/local/baiyue-common.yml',
'config/nacos/overseas/baiyue-common.yml',
];
const composeFiles = [
'deploy/docker-compose.yml',
'deploy/docker-compose.overseas.yml',
];
test('deployable applications log only to stdout and stderr', () => {
for (const path of applicationConfigs) {
const source = readFileSync(path, 'utf8');
assert.doesNotMatch(source, /LOG_PATH|LOGGING_FILE_NAME|LOGGING_ROLLING/, path);
}
});
test('Compose uses one bounded local log stream without application log mounts', () => {
for (const path of composeFiles) {
const source = readFileSync(path, 'utf8');
assert.match(source, /driver:\s+local/, path);
assert.match(source, /DOCKER_LOG_MAX_SIZE/, path);
assert.match(source, /DOCKER_LOG_MAX_FILE/, path);
assert.doesNotMatch(source, /LOG_PATH|LOG_ROOT|\/app\/logs/, path);
}
const domesticCompose = readFileSync(composeFiles[0], 'utf8');
assert.match(domesticCompose, /FILE_UPLOADS_HOST_DIR[^\n]+:\/data\/files/);
});
test('runtime image does not prepare an application log directory', () => {
const dockerfile = readFileSync('deploy/docker/Dockerfile', 'utf8');
assert.doesNotMatch(dockerfile, /\/app\/logs/);
});
@@ -27,14 +27,3 @@ baiyue:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
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}"