供应商订单安全对账方案

reference
2026年7月20日阅读约 5 分钟960 字

更新于 2026年7月20日

供应商订单安全对账方案

状态:设计基线
适用范围:酒店 B2B 系统向外部供应商创建订单、接收结果并按 clientReference 对账
首要目标:避免重复供应商订单,其次才是自动恢复速度

1. 文档边界

当前仓库实现的是无数据库、无消息队列、无真实供应商调用的确定性决策模块:

  • BookingReconciliationService.afterCreate(...) 处理建单结果。
  • BookingReconciliationService.afterQuery(...) 处理查单结果。
  • Spring MVC 边界负责请求校验、统一异常响应和 X-Trace-Id

本文同时描述生产环境需要补充的持久化、并发、供应商接口、审计和可观测性控制。除“当前实现”章节明确列出的内容外,其余均为生产化目标,不代表仓库已经具备这些能力。

2. 安全目标与威胁模型

2.1 安全不变量

  1. 未收到成功响应不等于供应商没有创建订单。
  2. 未知、冲突或无法证明安全的结果不得触发再次创建。
  3. 已保存的 supplierBookingIdconfirmationCode 不得被不同值自动覆盖。
  4. 多匹配、关键字段缺失、状态矛盾和查询耗尽必须进入人工审核。
  5. 相同事件重复处理不得产生不同业务结果。
  6. 旧事件不得降级已确认订单,但旧事件中的标识冲突仍必须暴露。
  7. 状态变化、外部调用和人工操作必须能通过同一 traceId 追溯。

2.2 需要覆盖的失败和攻击场景

  • 供应商已经建单,但响应超时、断网或 5xx。
  • 同一个创建请求被客户端、网关、任务调度器或人工重复触发。
  • 查询返回零条、多条、错误与结果并存或字段缺失。
  • 重复事件、乱序事件以及相同事件 ID 携带不同内容。
  • 两个线程或节点同时处理同一订单。
  • 请求或响应被重放、篡改,供应商凭据泄露。
  • 审计记录缺失、被修改,或日志泄露敏感数据。

3. 总体架构

flowchart LR
    A[用户或渠道] --> B[Booking API]
    B --> C[订单应用服务]
    C --> D[对账决策服务]
    C --> E[(订单与事件事务库)]
    C --> F[供应商适配器]
    F --> G[酒店供应商]
    E --> H[Outbox 与对账调度]
    H --> C
    C --> I[审计与风险告警]

职责边界:

  • Controller:协议转换、信任边界校验、调用应用服务,不包含业务状态机。
  • 应用服务:事务、持久化、任务编排、审计和供应商调用边界。
  • 领域决策服务:根据订单快照和一次供应商结果返回确定性决策,不访问数据库、网络或系统时间。
  • Repository/Client:分别处理持久化和外部协议细节。

4. 领域模型

4.1 本地订单状态

状态 含义
NEW 尚未发送供应商创建请求
SUBMITTING 已持久化创建意图,正在或即将调用供应商
PENDING_RECONCILIATION 创建结果不确定,禁止重建,必须查单
CONFIRMED 已确认唯一供应商订单且标识完整
FAILED_FINAL 供应商明确保证没有创建订单
MANUAL_REVIEW 自动流程无法安全判断,等待人工处理

当前模型使用一套本地状态即可表达安全决策,不新增第二套“供应商同步状态”。生产环境若确需单独记录同步进度,应将其作为技术字段,并定义与业务状态的合法组合,不能让两套状态独立漂移。

4.2 事件与供应商结果

每个建单或查单结果至少包含:

  • eventId:供应商或接入层生成的稳定事件 ID。
  • clientReference:当前仓库中固定等于 OrderSnapshot.orderId
  • occurredAt:事件发生时间。
  • supplierBookingIdconfirmationCode 和供应商状态。
  • 查单匹配记录的 supplierUpdatedAt,若供应商能够提供。

空字符串和仅空白字符串都按字段缺失处理。事件归属、标识完整性和状态合法性必须在状态转移前校验。

5. 决策规则

5.1 固定判定顺序

每次处理都按以下顺序执行:

  1. 校验调用参数、配置和必填字段。
  2. 校验事件 clientReference 是否属于本地订单。
  3. 保护粘性的 MANUAL_REVIEW,并单独处理 FAILED_FINAL
  4. 检查多匹配、结果与错误并存、供应商标识冲突和状态矛盾。
  5. 识别相同确认结果并返回幂等结果。
  6. 在确认没有安全冲突后,处理旧事件和状态降级。
  7. 最后执行普通状态转移。

标识冲突优先于事件新旧。不能因为事件较旧而隐藏第二张供应商订单。

5.2 建单结果 afterCreate

条件 下一状态 动作 safeToCreateAgain 原因码
SUCCESS 且两个标识完整、无冲突 CONFIRMED SYNC_CONFIRMED false CREATE_CONFIRMED
SUCCESS 但标识缺失 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW false CONFIRMED_RESULT_INCOMPLETE
SUCCESS 与本地标识冲突 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW false SUPPLIER_IDENTIFIER_CONFLICT
精确白名单拒绝且没有任何供应商标识 FAILED_FINAL MARK_FAILED true EXPLICIT_NO_BOOKING_CREATED
其他 EXPLICIT_REJECT PENDING_RECONCILIATION QUERY_BY_CLIENT_REFERENCE false REJECT_WITHOUT_NO_BOOKING_GUARANTEE
TIMEOUTHTTP_5XXNETWORK_ERROR PENDING_RECONCILIATION QUERY_BY_CLIENT_REFERENCE false CREATE_RESULT_UNCERTAIN

允许再次创建的唯一程序条件是:

boolean safeToCreateAgain =
        result.outcome() == EXPLICIT_REJECT
        && "NO_INVENTORY_NO_BOOKING_CREATED".equals(result.errorCode())
        && isBlank(result.supplierBookingId())
        && isBlank(result.confirmationCode());

FAILED_FINAL 状态本身、查不到订单、查询超时或一个模糊的“供应商保证”布尔值都不是充分证据。

5.3 查单结果 afterQuery

条件 下一状态 动作 原因码
多于一条匹配 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW MULTIPLE_SUPPLIER_BOOKINGS
一条匹配同时带查询错误 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW QUERY_RESULT_CONFLICT
事件或匹配记录归属错误 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW CLIENT_REFERENCE_MISMATCH
匹配记录缺少订单号或状态 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW INVALID_EVENT_DATA
任意供应商标识冲突 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW SUPPLIER_IDENTIFIER_CONFLICT
唯一 CONFIRMED,标识完整且无冲突 CONFIRMED SYNC_CONFIRMED QUERY_CONFIRMED
CONFIRMED 缺少确认号 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW CONFIRMED_RESULT_INCOMPLETE
唯一 PENDING 且未耗尽 PENDING_RECONCILIATION RETRY_QUERY_LATER SUPPLIER_BOOKING_PENDING
零匹配或查询错误且未耗尽 PENDING_RECONCILIATION RETRY_QUERY_LATER NO_MATCH_RETRY / QUERY_ERROR_RETRY
唯一 FAILEDCANCELLED MANUAL_REVIEW ESCALATE_MANUAL_REVIEW SUPPLIER_TERMINAL_STATUS_REVIEW
本次查询达到上限 MANUAL_REVIEW ESCALATE_MANUAL_REVIEW QUERY_ATTEMPTS_EXHAUSTED

默认最多查询 3 次。reconciliationAttempts 表示本次查询前已经完成的次数,处理本次后按 attempts + 1 >= 3 判断耗尽。未知建单结果立即查单;需要继续查询时默认延迟 5 分钟。上限和间隔可以配置,但修改后必须同步更新告警与运维说明。

5.4 终态、重复与乱序

  • MANUAL_REVIEW 是粘性状态,自动事件只能记录审计,不得自动离开人工审核。
  • CONFIRMED,再次收到相同订单号和确认号时返回 NOOP / ALREADY_CONFIRMED
  • CONFIRMED,收到不同订单号或确认号时进入 MANUAL_REVIEW,无论事件时间新旧。
  • CONFIRMED,收到更旧的同订单 PENDING 时返回 NOOP / STALE_EVENT_IGNORED
  • CONFIRMED,收到同时或更新的 PENDINGFAILEDCANCELLED 时进入 MANUAL_REVIEW / SUPPLIER_STATUS_CONFLICT
  • FAILED_FINAL,后续出现任何供应商订单证据时进入人工审核。

事件新旧优先使用 supplierUpdatedAt,没有记录级时间时才使用结果的 occurredAt。供应商若提供单调递增的版本号或序列号,应优先使用版本号;时间戳只作为降级方案。

6. 幂等设计

6.1 创建请求幂等

clientReference 必须在首次创建前确定并持久化,不能在每次网络重试时重新生成。当前系统直接使用不可变的 orderId,所有建单结果和查单请求复用同一个值。

生产数据库至少建立:

UNIQUE (supplier, client_reference)
UNIQUE (supplier, supplier_booking_id)  -- supplier_booking_id 非空时

供应商必须确认其创建接口对 clientReference 提供幂等语义。若供应商不提供该保证,创建结果不确定后不得重发创建请求,只能查单或人工处理。

首次调用前保存规范化请求的 request_hash。相同 clientReference

  • 哈希相同:复用原调用记录或原结果。
  • 哈希不同:拒绝发送并进入安全告警或人工审核,不能把不同订单内容当作同一次幂等请求。

6.2 事件处理幂等

优先使用供应商提供的不可变 eventId,不能使用 orderId + status + eventTime 代替可靠事件 ID。若事件 ID 由接入层生成,必须在首次接收时持久化并在后续投递中复用,不能每次重试重新生成。生产表建立:

UNIQUE (supplier, event_id)

首次处理时同时保存规范化事件的 payload_hash 和最终决策。重复事件:

  • eventId 与哈希都相同:返回已保存的决策,不重复更新状态或发送消息。
  • eventId 相同但哈希不同:视为协议冲突或篡改迹象,进入人工审核并触发安全告警。

当前仓库没有事件存储,不使用进程内集合伪装可靠去重;纯决策函数通过本地状态、标识和固定时间输入保持确定性。

7. 持久化与事务边界

7.1 建议数据模型

关键字段与约束
booking_order order_idsupplierclient_reference、两个供应商标识、statelast_supplier_event_timereconciliation_attemptsnext_check_atversion
supplier_request order_idsupplierclient_referencerequest_hash、请求状态和时间;(supplier, client_reference) 唯一
processed_event supplierevent_idpayload_hash、已保存决策和接收时间;(supplier, event_id) 唯一
reconciliation_task order_idretry_countnext_check_atstatusversion
manual_review order_idreason_code、风险等级、处理状态、处理人与结论、version
booking_audit_log 追加式安全审计字段,见第 10 节
outbox_event 与业务事务一同提交的待发布消息

供应商订单号通常只在单个供应商内唯一,因此不能只建立全局 supplier_booking_id UNIQUE

7.2 原子处理流程

一次建单或查单结果应在同一数据库事务中:

  1. 插入 processed_event;唯一键冲突时校验哈希并返回已保存决策。
  2. 读取订单和版本号。
  3. 使用最新快照执行确定性决策。
  4. 通过版本号 CAS 更新订单;失败则重新读取并重新决策,不能盲目覆盖。
  5. 创建或更新对账任务、人工审核记录。
  6. 写入同一 traceId 的追加式审计记录。
  7. 如需发消息,写入 outbox_event
  8. 提交事务。

数据库事务无法与外部供应商 HTTP 调用形成全局原子事务。该间隙必须依靠稳定 clientReference、供应商幂等能力和不确定态查单恢复,不能宣称“恰好一次调用”。

恢复任务发现长时间停留在 SUBMITTING 的订单时,不能猜测请求尚未发送并重新创建;应转入 PENDING_RECONCILIATION,使用原 clientReference 查单。

8. 并发控制

数据库唯一约束和乐观锁是基础控制:

update booking_order
set state = ?, supplier_booking_id = ?, confirmation_code = ?, version = version + 1
where order_id = ? and version = ?;

更新行数为 0 时必须重新读取订单并重新执行决策。不能只重试同一条更新,也不能覆盖新状态。

只有实际测量表明跨资源竞争无法由数据库约束解决时,才增加分布式锁。分布式锁必须具备租约、唯一所有者令牌、安全释放和 fencing token;裸 SETNX 不能作为生产锁,也不能替代数据库约束与事务。

9. 供应商接口与边界安全

9.1 传输、身份与授权

  • 所有供应商通信使用 HTTPS,按风险选择双向 TLS 或供应商认可的应用层签名。
  • Booking API 必须验证调用方身份、租户和订单权限,并限制请求速率;traceId 不参与授权。
  • 跨域只开放实际前端来源,允许请求头 X-Trace-Id 并暴露同名响应头;带凭据请求不得使用通配来源。
  • 供应商凭据存放在 KMS、Vault 或等价密钥系统中,不写入代码、配置仓库、日志和审计载荷。
  • 支持 keyId 和双密钥过渡,以便不中断服务地轮换密钥。

9.2 请求签名与防重放

建议使用 HMAC-SHA256,签名输入采用明确的规范化格式,例如:

HTTP_METHOD + "\n" +
REQUEST_PATH + "\n" +
TIMESTAMP + "\n" +
NONCE + "\n" +
SHA256(CANONICAL_BODY)

协议必须固定方法大小写、路径与查询参数编码,并对实际发送的 UTF-8 请求体字节计算哈希,避免不同 JSON 序列化方式产生签名歧义。

请求头至少包含 X-Key-IdX-TimestampX-NonceX-SignatureX-Trace-Id。验证方必须:

  1. 校验时间窗口并处理允许的时钟偏差。
  2. 通过持久化唯一约束原子占用 (keyId, nonce),并设置与时间窗口匹配的保留期。
  3. 使用相同规范化规则计算签名并执行常量时间比较。
  4. 在验签通过后再解析和处理业务数据。

只有字段而没有规范化、时效校验和 nonce 去重不能防重放。供应商响应也必须通过 TLS 通道身份、响应签名或供应商提供的等价机制验证真实性。

9.3 超时与重试

连接、读取和整体请求都必须有超时;具体数值通过供应商 SLA 和观测数据确定。创建请求遇到超时、5xx 或断网后不得由 HTTP 客户端自动重试创建,只能进入 PENDING_RECONCILIATION 并按相同 clientReference 查单。查询请求可以有限重试,但要使用退避、抖动和整体截止时间。

9.4 响应业务校验

自动同步前至少校验:

  • clientReference 与本地订单一致。
  • 供应商、酒店、入住离店日期、房型/价格计划等订单指纹一致。
  • 金额使用精确十进制比较,并同时校验币种、税费口径和允许偏差规则。
  • supplierBookingIdconfirmationCode 和状态完整且不与本地记录冲突。

任何无法解释的差异进入人工审核,不以本地默认值覆盖供应商数据,也不以供应商数据静默覆盖本地事实。

10. 审计与数据保护

每次状态决策、供应商调用和人工操作写入同一套追加式 booking_audit_log,至少包含:

audit_id
trace_id
event_id
order_id
supplier
operation
old_state
new_state
action
reason_code
actor_type
actor_id
payload_digest
occurred_at
recorded_at
result

要求:

  • 审计记录与对应状态更新在同一事务提交。
  • HTTP 请求、响应、错误日志、审计记录和下游调用沿用同一 X-Trace-Id
  • 审计表只追加,不允许普通业务账号更新或删除。
  • 默认保存结构化差异和脱敏摘要,不直接保存完整 before_json/after_json
  • 个人信息、供应商凭据和支付信息按最小化原则脱敏、加密并设置访问控制与保留期限。
  • 若需要防数据库管理员级篡改,使用独立密钥的 HMAC、哈希链或外部 WORM 存储;普通哈希不能单独提供防篡改证明。

11. 人工审核

MANUAL_REVIEW 不是异常垃圾桶。审核记录至少包含 reasonCode、证据摘要、风险等级、负责人、状态、结论和时间。

人工操作必须:

  • 经过基于角色的授权,并使用乐观锁防止多人覆盖。
  • 不允许直接编辑供应商标识绕过冲突检查。
  • 对取消、确认“供应商无单”或例外重建等高风险操作执行双人复核。
  • 记录操作者、前后状态、依据和 traceId
  • 不得仅通过人工按钮把未知状态改成 safeToCreateAgain=true;仍需取得可审计的“供应商未建单”证据。

12. 消息与任务安全

生产环境需要异步调度时:

  • 使用 transactional outbox 保证业务状态与待发布事件同事务提交。
  • 消费者以稳定 messageIdeventId 建持久化唯一约束。
  • 重试使用有上限的退避和抖动;不可恢复错误进入死信队列并创建人工任务。
  • 消费状态更新、事件去重和审计写入同一事务。
  • 消息乱序仍按“冲突优先、时间其次”的领域规则处理。

13. 可观测性与告警

至少监控:

  • PENDING_RECONCILIATION 数量及最老停留时长。
  • 对账成功率、查询耗尽率和供应商查询错误率。
  • 多匹配、标识冲突、同事件 ID 不同载荷的数量。
  • 人工审核积压量、处理时长和超时量。
  • 供应商创建/查询延迟、超时率和签名校验失败率。

告警同时考虑数量、比例和停留时间,不能只使用固定的“数量大于 100”。订单号、事件 ID 等高基数字段只出现在日志和追踪中,不作为指标标签。

14. 当前实现与生产缺口

14.1 当前已经实现

  • 未知建单结果立即按 clientReference 查单,禁止盲目重建。
  • 仅精确白名单拒绝返回 safeToCreateAgain=true
  • 多匹配、标识冲突、字段缺失、结果矛盾和查询耗尽转人工。
  • 重复确认、人工审核粘性、终态保护和旧事件降级保护。
  • 默认最多查询 3 次、间隔 5 分钟,并支持构造器配置。
  • HTTP 参数校验、统一异常响应、安全响应头和 X-Trace-Id

14.2 上生产前必须补齐

  • 事务化订单、事件、任务和审计存储。
  • 数据库唯一约束、乐观并发控制和事件幂等。
  • 真实供应商 Client、稳定创建幂等键、签名验签和密钥轮换。
  • transactional outbox、任务调度、消费者去重和死信恢复。
  • 持久化人工审核工作流、权限和双人复核。
  • 指标、告警、审计防篡改、敏感数据治理和恢复演练。

15. 验收清单

  • 所有未知建单结果都不会触发自动创建重试。
  • safeToCreateAgain=true 只出现在精确白名单拒绝且无任何供应商标识时。
  • 多匹配和标识冲突优先于旧事件忽略。
  • 相同事件重复处理结果一致;相同事件 ID 不同载荷会告警。
  • 订单、事件、任务、审计和 outbox 的原子边界有集成测试证明。
  • 并发处理不会覆盖新状态或生成重复供应商订单。
  • 请求签名、防重放、响应真实性和密钥轮换经过安全测试。
  • 正常响应、校验失败、业务冲突和未处理异常可通过同一 traceId 关联日志与审计。
  • 人工高风险操作有授权、双人复核和不可抵赖审计。
  • mvn -f backend/pom.xml test 全部通过,生产化缺口与实际实现保持一致。