01 — Checkout Session 生命周期(主线)
本章讲透 ACP 的中央循环。先建立直觉,再看状态机、数据模型,最后是最容易踩坑的 idempotency 语义。源头:
rfcs/rfc.agentic_checkout.md(定义 Agentic Checkout Specification, ACS)。
1.1 它要解决的小问题
agent 要替用户下单,需要一个有状态的、可来回修改的对象来承载「这一单现在长什么样」:买了啥、寄到哪、选了哪种配送、一共多少钱、能不能付了。这个对象就是 checkout session。
关键设计立场写在 RFC 开头:商家是所有订单/支付/税/合规的 system of record,会话只是商家暴露给 agent 的一个标准化视图(rfcs/rfc.agentic_checkout.md:9-13)。
1.2 思路/直觉:authoritative cart
ACP 不让 agent 自己算价。每一次对会话的操作,商家都回完整的、最新的、权威的购物车状态(§1 目标第 2 条,rfcs/rfc.agentic_checkout.md:19-20)。
为什么这么设计?因为价格/税/库存/配送费只有商家算得准,而且会随地址、数量、时间变。让 agent 缓存计算 = 给自己挖一个「显示价和真实价对不上」的坑。所以契约是:agent 把响应当唯一真相,自己不做算术。
1.3 状态机(五个端点 + 五个状态)
会话有五个核心端点,对应一条状态流转线。
怎么读这张图: 方框是状态,箭头上是触发它的端点调用;authentication_required 是 3DS 触发的旁路(见 04 章)。
POST /checkout_sessions
│
▼
┌──────────────────────┐ 缺地址/缺信息
│ not_ready_for_payment │◀──────────────┐
└──────────┬───────────┘ │
补齐信息 │ │ POST /checkout_sessions/{id}
▼ │ (update:改 items/地址/配送)
┌──────────────────────┐────────────────┘
│ ready_for_payment │
└──────────┬───────────┘
│ POST .../complete
▼
┌───────────────┐ 需 3DS 且没带 result
│ (校验支付) │───────────────────────▶ authentication_required
└───────┬───────┘ (agent 做认证后,带 authentication_result 重试 complete)
▼
┌────────────┐ POST .../cancel(未完成时)
│ completed │ ───────────────────────▶ canceled
└────────────┘
状态枚举:not_ready_for_payment | ready_for_payment | completed | canceled | in_progress(rfcs/rfc.agentic_checkout.md:131、校验规则 :335)。3DS 引入的 authentication_required 状态见 :159-164。
五个端点速查:
| 端点 | 方法 | 成功码 | 干什么 |
|---|---|---|---|
/checkout_sessions | POST | 201 | 从 items + 可选买家/地址建会话 |
/checkout_sessions/{id} | POST | 200 | 改 items / 地址 / 选配送 |
/checkout_sessions/{id} | GET | 200 | 取最新权威状态(404 不存在) |
/checkout_sessions/{id}/complete | POST | 200 | 提交支付,必须建单 |
/checkout_sessions/{id}/cancel | POST | 200 | 取消(已 completed/canceled 则 405) |
端点定义见 rfcs/rfc.agentic_checkout.md:95-168。注意一个不对称设计:update 用 POST 而不是 PATCH/PUT(:144),好让它和 create 共享同一套幂等重放语义(见 §1.6)。
1.4 数据模型(权威购物车的骨架)
一份会话响应的主要部件(完整抽取见 rfcs/rfc.agentic_checkout.md:172-228):
| 字段 | 是什么 |
|---|---|
status | 上面五态之一 |
currency | ISO 4217,小写如 usd |
line_items[] | 每行:base_amount/discount/subtotal/tax/total(全整数分) |
totals[] | 车级别合计,每项一个 type(subtotal/tax/fulfillment/total…)+ 整数 amount |
fulfillment_options[] | 可选配送方式(shipping/digital/pickup/local_delivery),各带 totals 与可选送达窗口 |
selected_fulfillment_options[] | 已选配送(option_id ↔ item_ids 映射) |
capabilities.payment.handlers[] | 支持的支付 handler(见 02 章) |
messages[] | info / warning / error 三类(见 §1.7) |
links[] | 政策链接(条款/隐私/退货) |
order | 仅 complete 后出现:id/checkout_session_id/permalink_url |
一个易混点——total 行的语义。 totals[] 是个数组,里头至少应有一个 type:"total"(校验规则 :336),其余是分项明细。整车响应实例见 rfcs/rfc.agentic_checkout.md:367-473。
fulfillment_details 的演进(踩坑预警)。 v2 把扁平的 fulfillment_address 改成了嵌套的 fulfillment_details(含 name/phone_number/email/address),并把单数 fulfillment_option_id 换成数组 selected_fulfillment_options[](变更记录 rfcs/rfc.agentic_checkout.md:793-797)。看老示例时别被旧字段名带偏。
1.5 complete:真正发生支付的那一步
POST /complete 的请求体带 payment_data(handler_id + instrument + 可选账单地址)、可选 buyer、以及条件性的 authentication_result(rfcs/rfc.agentic_checkout.md:154-156)。响应必须含 status: completed 和一个 order。
payment_data.instrument.credential 里通常是从 delegate_payment 拿到的 SPT——这条链路是 02 章的主题。请求实例见 rfcs/rfc.agentic_checkout.md:573-612,其中 credential 形如 { "type": "spt", "token": "spt_123" }。
1.6 关键细节:idempotency 的精确语义
这是 ACS 里规则最密、最值得逐条读的一节(§6,rfcs/rfc.agentic_checkout.md:232-318),也是写实现最容易出错的地方。RFC 自称这是「ACP 幂等性的权威参考」(:234)。
直觉: 网络会抖,agent 会重试。如果「重试」导致重复扣款,就完蛋了。所以每个 POST 都带一个 Idempotency-Key,服务端用它去重。
核心规则,逐条:
- 必带。 所有 POST(create/update/complete/cancel)都要带
Idempotency-Key,opaque 字符串,≤255 字符,推荐 UUID v4(:238-239)。漏带 →400 idempotency_key_required(:241-249)。 - 作用域。 key 按「认证身份 + 端点路径」隔离;同一个 key 在不同端点互不影响(
:240)。 - 等价判定只看 body。 header 不参与;判定是「请求体的语义 JSON 相等」(
:253)。
这里有一个最容易踩的坑——null 与「键缺失」不等价:
| 变体 | 算等价吗? |
|---|---|
| key 顺序不同 | 是 |
null vs 键不存在 | 否——null=「清空这个字段」,缺失=「别动这个字段」 |
数字尾零(1.0 vs 1) | 是 |
| 数组元素顺序不同 | 否——数组是顺序敏感的 |
(对照表 rfcs/rfc.agentic_checkout.md:255-260。)所以 SDK 序列化器必须保留「null vs 缺失」的区别(实现指南 :318)。
重放行为。 同 key + 完全相同 body → 服务端重放原响应(同 HTTP 状态码),建议带 Idempotent-Replayed: true,且绝不重复执行副作用(扣款、占库存)(:266-269)。
冲突与在途。 同 key 不同 body → 422 idempotency_conflict(永久错误,别重试);同 key 原请求还在跑 → 409 idempotency_in_flight(可按 Retry-After 重试)。错误码表 :275-279。
5xx 不缓存。 5xx 响应不得按 key 缓存——拿同 key 重试 5xx 必须当全新请求处理(:301-302)。这避免「一次抖动把失败永久钉死」。
保留期 ≥24h(:306)。
实现指南里还给了两条工程精华:幂等记录与业务操作应在同一个 ACID 事务里提交(防「鬼 key」/「丢 key」),以及对触发外部副作用(如 PSP 授权)的操作用恢复点(recovery-point)语义让重试能续跑而非重跑(:316-317)。