供应商订单安全对账方案
供应商订单安全对账方案
状态:设计基线
适用范围:酒店 B2B 系统向外部供应商创建订单、接收结果并按clientReference对账
首要目标:避免重复供应商订单,其次才是自动恢复速度
1. 文档边界
当前仓库实现的是无数据库、无消息队列、无真实供应商调用的确定性决策模块:
BookingReconciliationService.afterCreate(...)处理建单结果。BookingReconciliationService.afterQuery(...)处理查单结果。- Spring MVC 边界负责请求校验、统一异常响应和
X-Trace-Id。
本文同时描述生产环境需要补充的持久化、并发、供应商接口、审计和可观测性控制。除“当前实现”章节明确列出的内容外,其余均为生产化目标,不代表仓库已经具备这些能力。
2. 安全目标与威胁模型
2.1 安全不变量
- 未收到成功响应不等于供应商没有创建订单。
- 未知、冲突或无法证明安全的结果不得触发再次创建。
- 已保存的
supplierBookingId和confirmationCode不得被不同值自动覆盖。 - 多匹配、关键字段缺失、状态矛盾和查询耗尽必须进入人工审核。
- 相同事件重复处理不得产生不同业务结果。
- 旧事件不得降级已确认订单,但旧事件中的标识冲突仍必须暴露。
- 状态变化、外部调用和人工操作必须能通过同一
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:事件发生时间。supplierBookingId、confirmationCode和供应商状态。- 查单匹配记录的
supplierUpdatedAt,若供应商能够提供。
空字符串和仅空白字符串都按字段缺失处理。事件归属、标识完整性和状态合法性必须在状态转移前校验。
5. 决策规则
5.1 固定判定顺序
每次处理都按以下顺序执行:
- 校验调用参数、配置和必填字段。
- 校验事件
clientReference是否属于本地订单。 - 保护粘性的
MANUAL_REVIEW,并单独处理FAILED_FINAL。 - 检查多匹配、结果与错误并存、供应商标识冲突和状态矛盾。
- 识别相同确认结果并返回幂等结果。
- 在确认没有安全冲突后,处理旧事件和状态降级。
- 最后执行普通状态转移。
标识冲突优先于事件新旧。不能因为事件较旧而隐藏第二张供应商订单。
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 |
TIMEOUT、HTTP_5XX、NETWORK_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 |
唯一 FAILED 或 CANCELLED |
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,收到同时或更新的PENDING、FAILED、CANCELLED时进入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_id、supplier、client_reference、两个供应商标识、state、last_supplier_event_time、reconciliation_attempts、next_check_at、version |
supplier_request |
order_id、supplier、client_reference、request_hash、请求状态和时间;(supplier, client_reference) 唯一 |
processed_event |
supplier、event_id、payload_hash、已保存决策和接收时间;(supplier, event_id) 唯一 |
reconciliation_task |
order_id、retry_count、next_check_at、status、version |
manual_review |
order_id、reason_code、风险等级、处理状态、处理人与结论、version |
booking_audit_log |
追加式安全审计字段,见第 10 节 |
outbox_event |
与业务事务一同提交的待发布消息 |
供应商订单号通常只在单个供应商内唯一,因此不能只建立全局 supplier_booking_id UNIQUE。
7.2 原子处理流程
一次建单或查单结果应在同一数据库事务中:
- 插入
processed_event;唯一键冲突时校验哈希并返回已保存决策。 - 读取订单和版本号。
- 使用最新快照执行确定性决策。
- 通过版本号 CAS 更新订单;失败则重新读取并重新决策,不能盲目覆盖。
- 创建或更新对账任务、人工审核记录。
- 写入同一
traceId的追加式审计记录。 - 如需发消息,写入
outbox_event。 - 提交事务。
数据库事务无法与外部供应商 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-Id、X-Timestamp、X-Nonce、X-Signature 和 X-Trace-Id。验证方必须:
- 校验时间窗口并处理允许的时钟偏差。
- 通过持久化唯一约束原子占用
(keyId, nonce),并设置与时间窗口匹配的保留期。 - 使用相同规范化规则计算签名并执行常量时间比较。
- 在验签通过后再解析和处理业务数据。
只有字段而没有规范化、时效校验和 nonce 去重不能防重放。供应商响应也必须通过 TLS 通道身份、响应签名或供应商提供的等价机制验证真实性。
9.3 超时与重试
连接、读取和整体请求都必须有超时;具体数值通过供应商 SLA 和观测数据确定。创建请求遇到超时、5xx 或断网后不得由 HTTP 客户端自动重试创建,只能进入 PENDING_RECONCILIATION 并按相同 clientReference 查单。查询请求可以有限重试,但要使用退避、抖动和整体截止时间。
9.4 响应业务校验
自动同步前至少校验:
clientReference与本地订单一致。- 供应商、酒店、入住离店日期、房型/价格计划等订单指纹一致。
- 金额使用精确十进制比较,并同时校验币种、税费口径和允许偏差规则。
supplierBookingId、confirmationCode和状态完整且不与本地记录冲突。
任何无法解释的差异进入人工审核,不以本地默认值覆盖供应商数据,也不以供应商数据静默覆盖本地事实。
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 保证业务状态与待发布事件同事务提交。
- 消费者以稳定
messageId或eventId建持久化唯一约束。 - 重试使用有上限的退避和抖动;不可恢复错误进入死信队列并创建人工任务。
- 消费状态更新、事件去重和审计写入同一事务。
- 消息乱序仍按“冲突优先、时间其次”的领域规则处理。
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全部通过,生产化缺口与实际实现保持一致。