# Firstleg-Finance 跨服务契约(技术草案) > 版本:V0.4(费用作废命令与事件回流对齐) > 日期:2026-08-20 > 状态:技术边界和订单与客户产品口径已确认;Firstleg 事件/Relay/快照、Finance Inbox/来源写入及 Finance 发起的业务作废闭环均已实现。 本文是 `firstleg-service` 与已落地 `finance-service` 之间的费用来源技术契约。产品范围以《财务核心五页产品与接口验收契约》为准。 ## 1. 事实依据与当前实现差异 当前 Firstleg 使用 `fl_inbound_outbox` 与业务事务同库记录统一事件信封,Outbox Relay 至少一次投递到 `POST /internal/finance/v1/fee-source-events`;事件只携带入库/柜子 ID、费用 ID 和版本,Finance 必须再调用受保护的来源快照接口,不能把短 payload 当作财务凭证。 Finance 已实现内部事件 Controller、令牌鉴权、Inbox 同事件哈希幂等、Firstleg 快照回查,以及 `fin_fee_source` 新版本写入、旧版本替代和作废保留。非作废事件显式要求 `publicationStatus=PUBLISHED`,否则返回 `FIN-EVT-015`;金额必须 `>0`,零/负值返回 `FIN-EVT-010`。Finance 来源专项 10/10、全量 35/35 BUILD SUCCESS,Firstleg 事件/Relay/快照/柜子费用相关测试 17/17 通过。 ## 2. 事件目录 | 事件名 | 生产时机 | Finance 处理 | 备注 | |---|---|---|---| | `FIRSTLEG_FEE_PUBLISHED` | 入库或柜子费用审核通过后自动发布;由 `aggregateType/payload` 区分来源 | 写入不可变来源版本 | 已实现并验证 | | `FIRSTLEG_FEE_ADJUSTED` | 入库或柜子费用调整产生新版本 | 追加新版本并将旧 `AVAILABLE` 标为 `SUPERSEDED` | 已实现并验证 | | `FIRSTLEG_FEE_VOIDED` | 入库或柜子费用作废 | 将同版本来源标为 `VOIDED`,不物理删除 | 已实现并验证 | 事件不是“已生成应收”通知;Finance 按审核、发布、来源状态、币种和正金额条件决定是否进入可出账池,不保存人工“是否结算”开关。 ## 3. 统一事件信封 ```json { "eventId": "FIRSTLEG_FEE:INBOUND:456:789:2:FIRSTLEG_FEE_PUBLISHED", "eventType": "FIRSTLEG_FEE_PUBLISHED", "eventVersion": 1, "producer": "firstleg-service", "occurredAt": "2026-08-10T10:20:30.123+08:00", "tenantId": "123", "aggregateType": "INBOUND", "aggregateId": "456", "sequence": 2, "idempotencyKey": "FIRSTLEG_FEE:INBOUND:456:789:2:FIRSTLEG_FEE_PUBLISHED", "payloadHash": "sha256...", "payload": { "inboundId": "456", "containerId": null, "feeId": "789", "feeVersion": 2 } } ``` 约束:ID 以字符串传输;`eventId` 全局唯一且不可复用;`eventVersion` 为整数并只向后兼容增加字段;金额/数量不放在短 payload 中,统一从快照查询获取;`payloadHash` 对规范化 JSON 计算 SHA-256;`tenantId` 必须与来源快照客户一致。`sequence` 按同一 `aggregateId` 单调递增,用于识别乱序,但不能替代版本校验。 ## 4. 来源快照内部接口 Finance 使用服务间令牌调用 Firstleg(不经过客户端权限,不共享 Entity): 目标:`GET /internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot` 入库和柜子来源快照接口均已实现并通过 Firstleg 定向测试;Finance Feign Client 使用相同路径和内部令牌。既有 `/internal/firstleg/v1/finance-cost-snapshots/customers/{customerId}/containers/{containerId}` 仍只是体积分摊接口,不能作为费用来源快照使用。 请求头:`X-Internal-Service-Token`。令牌由环境密钥管理;Firstleg Controller 使用恒定时间比较校验,拒绝客户端 JWT 伪造内部调用。 响应 DTO 至少包含: ```text tenantId, sourceType, feeId, feeVersion, inboundId, inboundCode, orderId, containerId, containerBookingId, containerNo, customerId, customerName, feeItemId, feeItemCode, feeItemName, direction, businessDate, currency, totalAmount, reviewStatus, publicationStatus, publishedAt, chargeableQuantity, chargeUnit, actualGrossWeightKg, actualVolumeCbm, supplierId, supplierName, supplierCategory, sourceHash, snapshotVersion ``` Finance 将完整 DTO 和 `sourceHash` 存入自有来源池。非 `FIRSTLEG_FEE_VOIDED` 事件只接受 `publicationStatus=PUBLISHED`;未发布返回 `FIN-EVT-015`。作废事件允许读取对应作废版本,但只能把既有来源标为 `VOIDED`,不得创建新来源。 ### 4.1 Finance 发起业务费用作废 Finance 的客户应收应付页面允许发起 Firstleg 来源作废,但 Finance 不是业务费用事实源,不得先更新 `fin_fee_source`,也不得自行生产 `FIRSTLEG_FEE_VOIDED`。Finance 使用服务间令牌调用: `POST /internal/firstleg/v1/fee-sources/commands/void` 请求必须携带 `Idempotency-Key`、规范化请求的 `X-Request-Hash`,以及 `sourceType/sourceFeeId/sourceVersion/customerId/inboundId|containerId/operatorId/operatorName/reason`。Firstleg 必须重新校验客户、业务归属、稳定来源 ID 和精确版本,在同一事务中作废业务费用、记录 `fl_finance_fee_source_command` 幂等审计并写入 `FIRSTLEG_FEE_VOIDED` Outbox;相同幂等键同参重放原结果,异参冲突。Finance 只有消费该事件后才将本地来源置为 `VOIDED`。 Firstleg 来源的金额编辑和调整仍必须回到入库费用或柜次费用业务明细完成,由 Firstleg 生成新版本并发布 `FIRSTLEG_FEE_ADJUSTED`。Finance 的本地 edit/adjust 接口对 `sourceSystem=FIRSTLEG` 必须拒绝,不能只修改 `fin_fee_source.amount`。 建议附加只读校验接口:`GET /internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/publication`,用于消费重试时快速判断发布状态;该接口不能返回跨客户数据。 ## 5. 消费幂等、乱序与冲突 - Finance 在本地 `fin_event_inbox` 以 `eventId` 唯一;以 `sourceType + sourceFeeId + sourceFeeVersion` 建唯一业务键。 - 首次消费在同一事务内写入 Inbox、来源快照和处理结果;重复事件返回原结果,不重复生成来源/账单。 - 同一业务键的 `payloadHash` 不一致,标记 `CONFLICT`,告警并人工处理,禁止覆盖原快照。 - `feeVersion` 小于已处理版本:标记 `STALE`,保留审计,不回退财务来源。 - 高版本先到:先保存为 `PENDING_SOURCE`,查询快照成功且版本链完整后转 `ACCEPTED`;缺少前序版本不阻塞新版本,但需记录 gap,产品规则决定是否暂停出账。 - `VOIDED` 只能作用于同一来源版本;已出账时生成待处理冲销事件/任务,绝不物理删除。 ## 6. 重试、超时与降级 Firstleg Outbox 发布采用至少一次投递:指数退避(建议 10s、30s、2m、10m、30m,最多 12 次),每次带 `traceId`;超过次数进入 `FAILED/DLQ` 并告警。Finance 消费失败分为: | 类型 | 处理 | |---|---| | 连接/HTTP 408、429、5xx | 可重试;不确认消息 | | 401/403、签名/租户不匹配 | 不重试,标记 `REJECTED` 并告警 | | 来源 404/尚未提交 | 重试查询,超过保留期转 `SOURCE_UNAVAILABLE` | | DTO 校验、哈希或版本冲突 | 不自动重试,进入人工队列 | 快照接口超时建议 3 秒连接、5 秒读取;消费端总处理超时 10 秒。Finance 不因 Firstleg 暂时不可用而伪造金额、跳过来源校验或直接生成应收;仅允许已保存且校验通过的来源继续后续财务流程。 ## 7. 统一错误码(内部契约) | 错误码 | 含义 | 是否重试 | |---|---|---| | `FIN-SRC-001` | 来源不存在/不可见 | 查询型可重试,最终人工 | | `FIN-SRC-002` | 来源状态不满足入池 | 否 | | `FIN-SRC-003` | 客户/主体范围不一致 | 否,安全告警 | | `FIN-SRC-004` | 来源哈希或版本冲突 | 否,人工 | | `FIN-SRC-005` | 快照字段不完整 | 否,修复上游契约后重放 | | `FIN-EVT-001` | 事件重复且内容一致 | 否,返回原结果 | | `FIN-EVT-002` | 事件重复但内容不同 | 否,隔离冲突 | | `FIN-EVT-003` | 事件版本不支持 | 否,进入 DLQ | | `FIN-EVT-016` | 来源版本已进入非作废对账单或结算单,禁止调整、替代或作废 | 否,保留已冻结单据并修正上游费用 | | `FIN-AUTH-001` | 服务令牌无效/过期 | 否 | | `FIN-TIME-001` | 财务期间已关闭 | 否,走调整/冲销 | 业务 API 继续遵循后端统一约定:HTTP 200 + body `code`(成功为字符串 `"200"`);内部认证/基础设施拒绝可使用 401/403/503。 ## 8. 可观测性与审计 所有事件和查询日志必须带 `eventId`、`traceId`、`tenantId`、`aggregateId`、`sourceFeeId/version`、`attempt`、`consumerStatus`。Finance 至少提供 Inbox/来源池指标:待处理数、重试数、DLQ 数、冲突数、快照延迟、事件到财务入池延迟。拒绝、冲突、作废、重放和人工补偿写入财务审计;日志不得记录服务令牌和完整客户敏感凭证。 ## 9. 发布顺序与契约测试 1. 先冻结本文和产品确认中 F0-08/F1-09 的业务口径,补齐 Firstleg 事件版本与快照 OpenAPI。 2. 先上线兼容生产者(保留 `FIRSTLEG_FEE_SENT` 映射),再部署 Finance 消费者并回放历史 Outbox;确认无冲突后切换新事件名。 3. 最后启用调整/作废新事件及 Finance 出账入口;任何一方不支持的 `eventVersion` 必须安全拒绝。 契约测试至少覆盖:信封字段和 JSON Schema、签名/服务令牌、租户隔离、重复事件同结果、同键不同哈希冲突、乱序版本、Outbox 重试/DLQ、快照 404/超时、作废已出账保护、金额精度和三位币种、跨币种核销拒绝。测试可由 Firstleg producer contract test 与 Finance consumer contract test 共享固定 JSON fixtures,但不得共享 Java Entity。 ## 10. 确认边界与风险 技术已确认:独立服务/数据库、DTO/事件通信、Outbox 至少一次、Finance Inbox 幂等、不可变来源快照、无跨库 Join、服务令牌、错误隔离和审计要求。 产品口径已冻结:只有 `APPROVED + PUBLISHED + AVAILABLE + CNY + amount>0` 来源可入池;审核通过后自动成为结算候选。PAYABLE 的 supplierId 必须是 Warehouse `ACTIVE` 供应商,supplierCategory 为空时允许任意启用供应商,非空时严格匹配且 Finance 不从费用名称猜测类别。PAYABLE 还必须携带 `settlementMode=ON_ACCOUNT|DIRECT_PAID`:ON_ACCOUNT 可进入 AP,且已付字段必须为空;DIRECT_PAID 必填 `paidAt`、`paymentMethod`,`paymentReferenceNo` 与付款凭证至少一项,只计成本、不得进入 AP 或待付金额。付款方式只允许 `ONLINE/BANK_TRANSFER/CASH/OTHER`。这些字段必须进入不可变来源快照和 `sourceHash`,RECEIVABLE 必须为空。出账后冻结,调整/作废重开保留版本和原因;不做汇率、月结、钱包抵扣、费用分析和利润表。 成本与资金动作分离:同一来源版本按 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 只识别一次成本。DIRECT_PAID 来源唯一物化为 `fin_cost` 已付成本事实;ON_ACCOUNT 成本仍由来源事实识别,正式 AP 仅承载负债、到期日和付款,不重复确认成本。总成本等于有效 ON_ACCOUNT 来源成本加有效 DIRECT_PAID 成本事实;待付金额只来自非作废正式 AP 的 `outstanding_amount`;已付金额等于 AP `paid_amount` 加 DIRECT_PAID 金额。旧来源版本被替代或作废时,对应 DIRECT_PAID 成本事实同步失效。 主要剩余风险:测试环境尚未完成 Firstleg Relay → Finance Inbox → `fin_fee_source` 的真实 HTTP 冒烟。产品已确认无历史财务数据,不需要历史事件回放方案。 ## 国内拖车离仓应付来源 - 国内拖车由 `TruckAppointmentCompletionService` 消费同库真实离仓事实:订舱全部柜型要求数量必须关联齐全,每个有效货柜的装柜计划已完成,且柜内每个入仓绑定均已确认国内离仓。多柜订舱只在最后一个柜确认后完成;创建、编辑、提交或改约重新核验,覆盖先离仓后提交。 - `TruckAppointmentFeeService` 将拖车总额按已完成装柜明细的体积归属到真实客户、订单、入仓和货柜;重复订单明细先汇总体积,按最大余数分配到分,合计严格等于总额,零分摊不生成来源。零总额不生成费用,缺失有效体积或归属直接拒绝,不均分或丢弃金额。 - 使用现有 `FEE_TYPE_108` 拖车费项目、拖车单承运商(重新校验 ACTIVE/TRUCKING)、`PAYABLE + ON_ACCOUNT`、人民币(拖车单 RMB,来源 CNY);按柜创建分摊组并由系统确认发布。柜次费用备注记录拖车业务编号与原始总额,后续调整/作废沿现有柜次分摊组与来源版本流程。 - 完成状态、`AUTO_COMPLETE` 历史、柜次费用、不可变费用快照与 `FIRSTLEG_FEE_PUBLISHED` Outbox 同事务保存。Finance 复用 `CONTAINER_FEE` 接口和幂等消费,将各来源纳入应付候选,财务按现有应付结算与付款流程处理;不自动登记付款或伪造已付。 - 订舱行锁串行化并发离仓和拖车写入,完成单不再次发布;已完成拖车的订舱不得再创建另一张自动收费单。取消、异常预约不被自动恢复。手工 DEPART/COMPLETE 动作和 USD 拖车费用已移除。