
客服后台里最常见的一类工单是这样的:买家在订单列表看到「已支付」,点进详情却发现只发了一半货;仓库说另一半正在等补货,支付那边显示全额到账,退款那边又有一条「申请中」的记录。如果订单只有一个 status 字段,产品经理迟早会要求它同时表达「已支付」「部分发货」「退款申请中」这三件事。直接后果是这个字段的取值表一路膨胀成几十个组合,组合之间的顺序关系没人能一次讲清楚。这一篇要处理的问题是:订单到底应该由几个字段描述状态,这些字段之间如何互相约束,以及谁有权改写它们。
先把结论放前面。订单是交易事实的聚合,不是当前页面状态的一行记录。交易、支付、履约三条进度各自推进,订单聚合只裁决交易这一条,支付和履约的进度是从对应域同步过来的镜像;给用户看的「状态」是从三条进度派生出来的只读视图,不单独存储,也不允许直接修改。
上游是第 04 篇提交校验通过之后交给订单域的那一包东西:重算过的结算快照、库存预留的 reservation_id、券占用的 redemption_id,以及作为建单幂等键的 submission_token。下面从这一包开始,不重新实现结算校验。
从「已支付但部分发货」开始
单一 status 字段膨胀的根本原因是,它同时被三类需求拉扯:业务状态(这笔交易本身进展到哪一步)、展示状态(列表页给用户看的一句话摘要)、下游处理状态(库存、履约、财务各自跑到哪一步)。这三类需求变化的节奏完全不同。履约状态可能一天更新十次(拣货、出库、在途、签收),而交易状态可能整个生命周期只变化两三次。把它们塞进同一个字段,结果是这个字段既要为履约的每一次微小进展新增取值,又要保证交易层面的判断(能不能再次支付、能不能申请售后)不受这些新增取值影响。
Spree 的主分支给了一个可核对的样本。2026 年 9 月读取的 Order 模型显式声明了三个独立常量:STATUSES 只有 draft、placed、canceled 三个值,PAYMENT_STATUSES 和 FULFILLMENT_STATUSES 各自一组,代码注释写明后两者由 Orders::UpdateStatuses 这一个服务派生并作为唯一写入者。同一个仓库里的 6.0 决策记录写着要移除 Order、Payment、Fulfillment 上的全部状态机。而面向公众的 Orders 文档描述的还是 5.x 那套从 cart 到 complete 的六步结算状态机,外加单独的 payment_state。6.0 还没发布,文档写旧模型不算错,但主分支的方向说明了一件事:用一条状态机同时描述结算流程和交易进度,走到一定规模就会拆。
要解决这个问题,不能只是「多加几个字段」,还要明确谁能改这些字段、订单聚合内部由哪些对象分别承载。
订单聚合模型
订单聚合内部至少拆成五类对象。主订单表示买家一次提交的完整交易意图,持有交易状态、支付镜像和应付、实付、已退三个金额。子订单按商家拆分,每个子订单持有自己的履约镜像、运费和小计,多商家平台上一次提交里的商品来自不同店铺,运费、结算周期和售后都以店铺为边界,「一半发货一半等补货」自然落在两个子订单上,而不是主订单状态里多出一个「部分发货」的特殊值。订单项对应一个 SKU 的成交数量、成交价格和优惠分摊,是不可变的行项目。操作日志记录每一次状态变化的来龙去脉。发件箱记录建单和状态变化要发出去的事件,和订单在同一个事务里落库。
拆单在本系列里分两层,两个域各拆一次、依据不同。订单域只按商家拆子订单,因为结算、分账和售后以商家为边界,第 03 篇跨店切分出来的每家店的优惠份额,正好落在子订单上。仓库、品类混装、跨境清关这些是物理边界,由履约域在子订单之下拆履约单,第 10 篇讲。附属单------家电带一张安装服务单、促销带一张赠品------不是拆单依据,是订单项的类型。第 04 篇结尾留下的那个问题在这里定下来:拆子订单在订单域,拆履约单在履约域。
订单类型来自第 01 篇的商品类型:实物订单走「支付、发货、签收」,虚拟订单(充值、卡密)没有履约轨道,服务订单要等核销才算完成。类型在创建时确定,决定这笔订单走哪台状态机。购物车里混着实物和虚拟商品时,按类型拆成不同的子订单,每个子订单走自己的履约轨道,主订单的交易状态不变。硬塞进同一套状态机,结果是为每种特例加判断分支,最后没人敢动那张转换表。
订单项要落的快照来自前四篇。第 01 篇的商品标题、规格和 spu_revision;第 02 篇的成交单价、price_rule_id 和 price_version;第 03 篇的行级分摊、promotion_rule_id、promotion_rule_version 和出资方;收货地址和发票信息也在建单时复制成快照,不引用用户域的实时数据。原因第 01 篇说过:商品改价、改名、下架,用户改地址,几个月后打开历史订单,买家看到的必须还是下单那一刻的东西。Medusa 的 Order Module 把订单项、运费方式、促销调整行、税费行都做成订单聚合自己的子实体,不是对商品域或价格域记录的引用;订单创建后的退货、编辑、换货走带版本号的变更记录,同样不改历史行。
快照之外还要落一串时间节点:下单、支付、发货、签收、完成、取消,各自在对应转换发生时写入。它们有三个用处:详情页按当前状态决定展示哪几个节点;售后判断「七天无理由」从哪一刻起算;对账核对各系统间的时间差。放在操作日志里也能查到,但日志是流水,时间戳是订单上的字段,查询和排序的成本差一个量级。
#mermaid-svg-xOscE4hr3An5qiaL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xOscE4hr3An5qiaL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xOscE4hr3An5qiaL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xOscE4hr3An5qiaL .error-icon{fill:#552222;}#mermaid-svg-xOscE4hr3An5qiaL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xOscE4hr3An5qiaL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xOscE4hr3An5qiaL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xOscE4hr3An5qiaL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xOscE4hr3An5qiaL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xOscE4hr3An5qiaL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xOscE4hr3An5qiaL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xOscE4hr3An5qiaL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xOscE4hr3An5qiaL .marker.cross{stroke:#333333;}#mermaid-svg-xOscE4hr3An5qiaL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xOscE4hr3An5qiaL p{margin:0;}#mermaid-svg-xOscE4hr3An5qiaL .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-xOscE4hr3An5qiaL .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-xOscE4hr3An5qiaL .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-xOscE4hr3An5qiaL .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-xOscE4hr3An5qiaL .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-xOscE4hr3An5qiaL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-xOscE4hr3An5qiaL .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-xOscE4hr3An5qiaL .node rect,#mermaid-svg-xOscE4hr3An5qiaL .node circle,#mermaid-svg-xOscE4hr3An5qiaL .node ellipse,#mermaid-svg-xOscE4hr3An5qiaL .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xOscE4hr3An5qiaL .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-xOscE4hr3An5qiaL .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-xOscE4hr3An5qiaL .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xOscE4hr3An5qiaL .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-xOscE4hr3An5qiaL .edgeLabel .label text{fill:#333;}#mermaid-svg-xOscE4hr3An5qiaL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} splits_by_merchant
contains
records
emits
ORDER
string
id
PK
string
buyer_id
string
order_type
string
trade_status
string
payment_status
int
amount_payable_minor
int
amount_paid_minor
int
amount_refunded_minor
string
currency
string
submission_token
UK
string
reservation_id
string
redemption_id
string
address_snapshot
int
version
datetime
placed_at
datetime
paid_at
datetime
cancelled_at
datetime
completed_at
SUB_ORDER
string
id
PK
string
order_id
FK
string
merchant_id
string
fulfillment_status
int
subtotal_minor
int
shipping_minor
int
platform_discount_minor
datetime
shipped_at
datetime
delivered_at
ORDER_ITEM
string
id
PK
string
sub_order_id
FK
string
sku_id
int
spu_revision
string
title_snapshot
string
options_snapshot
int
quantity
int
unit_amount_minor
string
price_rule_id
int
price_version
int
discount_minor
string
promotion_rule_id
int
promotion_rule_version
string
funding_party
ORDER_LOG
string
id
PK
string
order_id
FK
string
idempotency_key
string
command
string
actor_type
string
actor_id
string
reason
string
from_trade
string
to_trade
string
sub_order_id
int
version
datetime
at
OUTBOX
string
id
PK
string
order_id
FK
string
event_type
string
payload
string
status
datetime
created_at
几个字段解释一下。ORDER.payment_status 和 SUB_ORDER.fulfillment_status 是镜像,下一节说它们由谁写。submission_token 上建唯一约束,同一个提交意图重放两次只会建出一张订单,这是第 04 篇防重复提交在订单侧的落点。reservation_id 和 redemption_id 记在主订单上,建单事件发出后库存域用前者绑定 order_id,支付确认后促销域用后者核销。SUB_ORDER.platform_discount_minor 是第 03 篇跨店切分给这家店的平台补贴份额,商家结算与分账域从这里读。ORDER_LOG.actor_type 分人工、系统、补偿三类,后面有一节专讲。
产品视角:产品怎样认识订单
技术上订单是一个聚合、三条进度、一张转换表。产品得先想清楚这张单据对三方各是什么,否则「加个部分发货状态」这类需求就没法评审。
买家把订单当成承诺的凭证。他不关心交易、支付、履约三条轨道,他要一句话:我的东西现在怎么样了,还能不能取消,钱什么时候回来。商家把订单当成待办和结算单据,看的是自己那部分------多商家平台上一张主订单下面每家店只看自己的子订单,备货、发货、结算都以它为单位。平台把订单当成证据链:纠纷来了,下单那一刻的商品、价格、优惠、地址是什么,谁在什么时候改了状态、为什么改,都要能从订单和日志里翻出来。
落到技术对象上,买家看的是派生视图,商家操作的是子订单,平台维护的是快照、日志和转换规则。
对买家起作用
一句话状态派生自三条进度,不单独存,所以它不会和实际进度脱节。取消中的订单不会显示成「待发货」,发了一半的订单会说「部分发货」而不是「已发货」。取消、确认收货这些买家能做的动作,前提写在转换表里,按钮灰不灰由同一张表决定,不会出现「按钮能点、点了报错」。
快照保证他三个月后打开订单看到的还是当时的东西,这一条第 01 篇讲过,这里只是落地。
对商家起作用
子订单按商家拆,商家后台只看自己的,运费、小计、平台补贴份额都在子订单上,月底结算单从这里出,不用从主订单里再切一次。发货作用在子订单上,商家发了自己的货,不必等另一家店。
操作日志对商家的意义是免责:买家说「我早就取消了」,日志里有没有那条取消命令、是谁在什么时候发的,一查便知。
产品在订单域要做的决定
| 策略点 | 产品在决定什么 | 影响谁 |
|---|---|---|
| 支付时限 | 买家犹豫时间与库存锁定 | 买家、商家、库存 |
| 未支付能否建单 | 第 00 篇已列,本篇状态机以「先建单再支付」为默认 | 订单、库存 |
| 0 元订单是否免支付直接确认 | 积分全抵、赠品单的路径 | 买家、支付 |
| 已支付未发货能否自助取消、是否需商家同意 | 买家自由度与商家备货成本 | 买家、商家 |
| 确认收货的自动时限 | 签收后多久自动完成 | 买家、结算 |
| 各角色看什么状态 | 状态映射表的每一列 | 买家、商家、客服、财务 |
| 拆单是否对买家可见 | 一单多包裹还是多张订单 | 买家、客服 |
| 混合类型购物车拆子订单还是拒绝 | 实物与虚拟混买的体验 | 买家、履约 |
这些参数进配置,不进代码。支付时限和自动确认收货时限尤其如此,大促前后经常要调。
产品不应该向技术要什么
- 「加一个『部分发货』状态」------那是两个子订单的履约进度不同,派生视图会算出来。加进主状态,下一个需求就是「部分发货且退款中」。
- 「客服后台加个下拉框直接改订单状态,修数据方便」------改状态只能走命令,带操作者和原因,进日志。没有日志的改动,下一次纠纷就是平台的责任。
- 「支付回调直接把订单表改成已支付,别绕一圈」------支付状态归支付域,订单收到的是事实同步,聚合决定交易状态怎么响应。绕过这一层,迟到的支付回调会把已取消的订单改成已支付。
- 「订单列表显示最新的商品名和图,好看」------历史订单显示的是快照。商品改名不该改变买家的购买记录。
三条进度,一个派生视图
交易状态收窄为五个值:待支付(pending_payment)、已确认(confirmed)、取消中(cancelling)、已取消(cancelled)、已完成(completed)。取消中是已支付取消专用的:订单域决定取消,向支付域请求退款,退款成功前买家看到的是「取消中」而不是「待发货」。它需要被独立查询、停留时长要监控,够得上一个状态。
支付进度和履约进度不由订单聚合裁决。第 00 篇的所有权表把支付单和资金状态划给支付域,履约单划给履约域,并且明确禁止「支付回调直接更新订单表」。所以订单聚合里的 payment_status 和每个子订单的 fulfillment_status 是镜像:只由支付域、履约域的事件处理器通过「事实同步命令」写入,聚合自己的命令不碰它们。镜像的用处有两个,一是守卫可以读它们(没付款不能发货),二是派生视图要用它们。镜像和权威状态之间的同步靠带 event_id 的事件,第 13 篇讲可靠投递。
这个拆分不是唯一答案。Saleor 的 OrderStatus 把履约进度编码进了主状态(UNFULFILLED、PARTIALLY_FULFILLED、FULFILLED、PARTIALLY_RETURNED、RETURNED 和 CANCELED、EXPIRED、DRAFT、UNCONFIRMED 在一个枚举里),但它的 Order 对象仍然单独保留了 paymentStatus、authorizeStatus、chargeStatus 三个字段。一个真实的生产系统选择把履约并入主状态,支付这条维度依然独立,这是本篇坚持三条都拆开的理由。
三条进度独立之后,买家看到的「一句话状态」是按需计算的派生视图:
go
// DisplayStatus is the buyer-facing one-line status. It is derived on read
// and never stored, so it cannot drift from the underlying progress.
func DisplayStatus(o *Order) string {
switch o.Trade {
case TradeCancelled:
return "已取消"
case TradeCancelling:
return "取消中,退款处理中"
case TradeCompleted:
return "已完成"
case TradePendingPayment:
return "待支付"
}
shipped, delivered := 0, 0
for _, sub := range o.SubOrders {
switch sub.Fulfillment {
case FulfillmentShipped:
shipped++
case FulfillmentDelivered:
delivered++
}
}
switch {
case shipped+delivered == 0:
return "待发货"
case shipped+delivered < len(o.SubOrders):
return "部分发货"
default:
return "已发货"
}
}
派生视图不只是买家视角。同一份进度,商家后台看履约视角(待接单、备货中、已发货),客服看售后视角(取消中、退货待签收),财务看资金视角(已收款、已结算)。实践中维护一张状态映射表,键是「交易状态 × 支付镜像 × 履约镜像」,每个角色一列,新增角色只加一列,不动订单聚合:
go
type Role string
// roleView maps a derived key to what each role sees. Adding a role adds a
// column here, never a field on the order.
var roleView = map[Role]map[string]string{
"buyer": {"confirmed/paid/pending": "待发货", "cancelling/paid/pending": "取消中"},
"merchant": {"confirmed/paid/pending": "待接单", "cancelling/paid/pending": "买家已取消,停止备货"},
"finance": {"confirmed/paid/pending": "已收款未结算", "cancelling/paid/pending": "退款中"},
}
状态机与不变量
三条进度独立不代表可以随意组合。状态机的作用是写下每个命令在什么前提下才允许执行,执行后各维度分别落到哪个值。一个完整的状态机由五样东西组成:状态、事件(本文的命令)、转换、守卫(转换前必须成立的前提)、动作(转换成功后的副作用)。前四样决定能不能转,第五样决定转完之后要做什么。
守卫和动作必须分开看,这是最容易混的一处。守卫是纯判断:交易状态是不是待支付、退款金额是不是等于余额。它不改变任何东西,可以随时重放。动作是副作用:写日志、发事件、请求退款,有外部影响,重复执行一遍通常就出事。本文的实现把守卫放在 Apply 的前置检查里,把动作收敛到「改状态、版本号加一、追加一条日志、写一条发件箱记录」这四件确定性的事,其余副作用(通知、绑定预留、请求退款)由发出去的事件触发。即使事件重投,也只是下游做幂等去重,不会把订单状态改坏。
另一个方向上的坑是状态爆炸。为了「更精确」,有人会把每个中间动作都做成状态:支付中、发货中、签收中。状态一多,转换表从十几行涨到几十上百行,其中大部分是「支付中→支付失败」这种只在异常路径出现的边。判断标准:一个值只有当它需要被独立查询、需要在上面挂不同权限、或者停留时长需要单独监控时,才配成为一个状态;纯粹「某个动作正在执行中」的中间态,用一条带时间戳的日志或一个独立字段表达就够了。上一节的「取消中」过了这三关,「支付中」没过。
命令分两类。本域命令是订单域自己裁决的事:建单、未支付取消、已支付取消。事实同步命令是别的域已经发生的事实,聚合只决定自己的交易状态怎么响应,不能否认事实本身:支付域说钱到了,订单可以拒绝「确认」(比如已经取消),但钱到了这件事记录在支付域,随后走退款。转换表把两类分开列:
| 命令 | 类型 | 前提 | 结果 |
|---|---|---|---|
create |
本域 | 未创建;应付 ≥ 0;至少一个子订单 | 应付 > 0:交易=待支付,支付=未支付;应付 = 0:交易=已确认,支付=无需支付。各子订单履约=待处理 |
cancel |
本域 | 交易=待支付 | 交易=已取消 |
cancel_after_payment |
本域 | 交易=已确认;支付=已支付;所有子订单履约=待处理 | 交易=取消中,发出退款请求事件 |
payment_confirmed |
事实(支付域) | 交易=待支付;金额等于应付 | 交易=已确认,支付=已支付 |
shipped |
事实(履约域,带子订单 ID) | 交易=已确认;支付=已支付或无需支付;该子订单履约=待处理 | 该子订单履约=已发货 |
delivered |
事实(履约域签收或买家确认收货,带子订单 ID) | 交易=已确认;该子订单履约=已发货 | 该子订单履约=已送达;全部子订单送达时交易=已完成 |
refund_settled |
事实(支付域) | 交易=取消中;支付=已支付;金额等于实付减已退 | 支付=已退款,交易=已取消 |
三条不变量直接体现在这张表里。已取消订单不能再次确认:payment_confirmed 的前提是交易处于待支付,取消之后前提不再满足,迟到的支付只能走退款。已支付订单不能跳过退款直接取消:cancel 只接受待支付,已支付的要走 cancel_after_payment 进入取消中,等支付域的 refund_settled 才变已取消。退款金额不能超过实付:refund_settled 要求金额等于实付减已退,这条是防御性校验,裁决在支付域。本篇只处理全额退款,也就是已支付未发货的整单取消;发货后的退货退款属于售后域,第 11 篇展开,订单聚合到时只接收售后结果事实。
并发安全靠版本号而不是「先查后改」。订单每次成功应用一个命令,版本号加一;每个命令带上调用方读到的版本号,只有与当前值一致才允许继续,否则拒绝并要求重新读取后重试。AWS 的 DynamoDBMapper 乐观锁文档把这个契约描述得很清楚:读取时记录版本号,更新时只有服务端版本号未变才允许写入,不一致意味着别人已经先改过,更新失败而不是覆盖。落到关系库上是一条条件更新:
sql
-- 示意:只在版本号匹配时生效,受影响行数为 0 即版本冲突。
-- trade_status 归订单聚合;payment_status 是镜像,由支付事实的处理器
-- 通过 payment_confirmed / refund_settled 命令写入,不在本域命令的 UPDATE 里出现。
UPDATE orders
SET trade_status = 'cancelling', version = version + 1
WHERE order_id = ? AND version = ?;
下面的 Go 代码是转换表的可执行版本。Apply 是聚合唯一的写入路径,三道检查按顺序:幂等键是否重放或冲突,期望版本是否一致,命令是否满足当前状态下的前提。三道都过才改状态、加版本、写日志。
go
// Apply is the aggregate's only write path. It checks the command's
// idempotency key for a duplicate or conflicting replay, checks
// ExpectedVersion against the current version, checks the command against
// the transition table for the order's current state, and only if all three
// pass, mutates state, bumps the version and appends an operation-log entry.
//
// Commands come in two kinds. Domain commands (create, cancel,
// cancel_after_payment) are decisions this aggregate owns. Fact commands
// (payment_confirmed, shipped, delivered, refund_settled) carry facts already
// decided by the payment or fulfillment domain; the aggregate only decides how
// Trade responds and mirrors the fact into Payment / Fulfillment.
func (o *Order) Apply(cmd Command) error {
o.mu.Lock()
defer o.mu.Unlock()
if cmd.IdempotencyKey == "" {
return ErrMissingIdempotencyKey
}
if rec, ok := o.applied[cmd.IdempotencyKey]; ok {
if rec.kind != cmd.Kind {
return ErrIdempotencyConflict
}
return nil // replay of an already-applied command: no-op success.
}
if cmd.ExpectedVersion != o.Version {
return ErrVersionConflict
}
fromTrade, fromPayment := o.Trade, o.Payment
var fromFulfillment, toFulfillment FulfillmentStatus
if sub, ok := o.SubOrders[cmd.SubOrderID]; ok {
fromFulfillment = sub.Fulfillment
}
switch cmd.Kind {
// ---- domain commands: decided here ----
case CmdCreate:
if o.created {
return ErrAlreadyCreated
}
if cmd.AmountMinor < 0 || len(cmd.SubOrders) == 0 {
return ErrInvalidCreate
}
o.created = true
o.AmountPayable = cmd.AmountMinor
o.SubOrders = make(map[string]*SubOrder, len(cmd.SubOrders))
for _, s := range cmd.SubOrders {
o.SubOrders[s.ID] = &SubOrder{ID: s.ID, MerchantID: s.MerchantID, Fulfillment: FulfillmentPending}
}
o.PlacedAt = cmd.Now
if cmd.AmountMinor == 0 {
o.Trade, o.Payment = TradeConfirmed, PaymentNotRequired
} else {
o.Trade, o.Payment = TradePendingPayment, PaymentUnpaid
}
case CmdCancel:
if o.Trade != TradePendingPayment {
return ErrInvalidTransition
}
o.Trade = TradeCancelled
o.CancelledAt = cmd.Now
case CmdCancelAfterPayment:
if o.Trade != TradeConfirmed || o.Payment != PaymentPaid || !o.allSubOrders(FulfillmentPending) {
return ErrInvalidTransition
}
o.Trade = TradeCancelling // the refund itself is requested from the payment domain by event
// ---- fact commands: decided elsewhere, mirrored here ----
case CmdPaymentConfirmed:
if o.Trade != TradePendingPayment {
return ErrInvalidTransition // e.g. paid after cancel: order stays cancelled, caller refunds
}
if cmd.AmountMinor != o.AmountPayable {
return ErrInvalidAmount
}
o.Trade, o.Payment = TradeConfirmed, PaymentPaid
o.AmountPaid = cmd.AmountMinor
o.PaidAt = cmd.Now
case CmdShipped:
sub, ok := o.SubOrders[cmd.SubOrderID]
if !ok {
return ErrUnknownSubOrder
}
if o.Trade != TradeConfirmed ||
(o.Payment != PaymentPaid && o.Payment != PaymentNotRequired) ||
sub.Fulfillment != FulfillmentPending {
return ErrInvalidTransition
}
sub.Fulfillment = FulfillmentShipped
sub.ShippedAt = cmd.Now
toFulfillment = sub.Fulfillment
case CmdDelivered:
sub, ok := o.SubOrders[cmd.SubOrderID]
if !ok {
return ErrUnknownSubOrder
}
if o.Trade != TradeConfirmed || sub.Fulfillment != FulfillmentShipped {
return ErrInvalidTransition
}
sub.Fulfillment = FulfillmentDelivered
sub.DeliveredAt = cmd.Now
toFulfillment = sub.Fulfillment
if o.allSubOrders(FulfillmentDelivered) {
o.Trade = TradeCompleted
o.CompletedAt = cmd.Now
}
case CmdRefundSettled:
if o.Trade != TradeCancelling || o.Payment != PaymentPaid {
return ErrInvalidTransition
}
if cmd.AmountMinor != o.AmountPaid-o.AmountRefunded {
return ErrRefundAmountMismatch // this article handles full refunds only
}
o.AmountRefunded += cmd.AmountMinor
o.Payment = PaymentRefunded
o.Trade = TradeCancelled
o.CancelledAt = cmd.Now
default:
return ErrUnknownCommand
}
o.Version++
o.applied[cmd.IdempotencyKey] = appliedRecord{kind: cmd.Kind, version: o.Version}
o.Log = append(o.Log, LogEntry{
IdempotencyKey: cmd.IdempotencyKey,
ActorType: cmd.ActorType,
ActorID: cmd.ActorID,
Reason: cmd.Reason,
Command: cmd.Kind,
SubOrderID: cmd.SubOrderID,
FromTrade: fromTrade,
ToTrade: o.Trade,
FromPayment: fromPayment,
ToPayment: o.Payment,
FromFulfillment: fromFulfillment,
ToFulfillment: toFulfillment,
Version: o.Version,
At: cmd.Now,
})
return nil
}
func (o *Order) allSubOrders(s FulfillmentStatus) bool {
for _, sub := range o.SubOrders {
if sub.Fulfillment != s {
return false
}
}
return len(o.SubOrders) > 0
}
payment_confirmed 里的金额取自命令,也就是支付域事件里的实付,不从 AmountPayable 抄;不等直接拒绝,这是第二条防御性校验,裁决同样在支付域。shipped 允许支付镜像为「无需支付」,0 元订单也要发货。
下面的状态图把三条进度并排画出。交易进度由本篇的订单聚合拥有并写入;支付和履约两条的权威状态分别在支付域和履约域,图里画的是它们在订单聚合里的镜像,箭头上的命令名就是写入镜像的事实同步命令。看两处:取消中是交易进度上唯一一个「等别人」的状态,它等的是支付域的退款事实;履约进度画的是一个子订单,主订单的已完成由全部子订单送达派生。
#mermaid-svg-6iwAbJyffDVuYDjT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6iwAbJyffDVuYDjT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6iwAbJyffDVuYDjT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6iwAbJyffDVuYDjT .error-icon{fill:#552222;}#mermaid-svg-6iwAbJyffDVuYDjT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6iwAbJyffDVuYDjT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6iwAbJyffDVuYDjT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6iwAbJyffDVuYDjT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6iwAbJyffDVuYDjT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6iwAbJyffDVuYDjT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6iwAbJyffDVuYDjT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6iwAbJyffDVuYDjT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6iwAbJyffDVuYDjT .marker.cross{stroke:#333333;}#mermaid-svg-6iwAbJyffDVuYDjT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6iwAbJyffDVuYDjT p{margin:0;}#mermaid-svg-6iwAbJyffDVuYDjT defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-6iwAbJyffDVuYDjT g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-6iwAbJyffDVuYDjT g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-6iwAbJyffDVuYDjT g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-6iwAbJyffDVuYDjT g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-6iwAbJyffDVuYDjT g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-6iwAbJyffDVuYDjT .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-6iwAbJyffDVuYDjT .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-6iwAbJyffDVuYDjT .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-6iwAbJyffDVuYDjT .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-6iwAbJyffDVuYDjT .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-6iwAbJyffDVuYDjT .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-6iwAbJyffDVuYDjT .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-6iwAbJyffDVuYDjT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6iwAbJyffDVuYDjT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6iwAbJyffDVuYDjT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6iwAbJyffDVuYDjT .edgeLabel .label text{fill:#333;}#mermaid-svg-6iwAbJyffDVuYDjT .label div .edgeLabel{color:#333;}#mermaid-svg-6iwAbJyffDVuYDjT .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-6iwAbJyffDVuYDjT .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-6iwAbJyffDVuYDjT .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-6iwAbJyffDVuYDjT .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-6iwAbJyffDVuYDjT .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-6iwAbJyffDVuYDjT .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6iwAbJyffDVuYDjT .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6iwAbJyffDVuYDjT #statediagram-barbEnd{fill:#333333;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6iwAbJyffDVuYDjT .cluster-label,#mermaid-svg-6iwAbJyffDVuYDjT .nodeLabel{color:#131300;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-6iwAbJyffDVuYDjT .note-edge{stroke-dasharray:5;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-note text{fill:black;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram-note .nodeLabel{color:black;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagram .edgeLabel{color:red;}#mermaid-svg-6iwAbJyffDVuYDjT #dependencyStart,#mermaid-svg-6iwAbJyffDVuYDjT #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-6iwAbJyffDVuYDjT .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6iwAbJyffDVuYDjT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 交易 Trade(订单聚合拥有)
create
create,应付为 0
payment_confirmed
cancel
cancel_after_payment
refund_settled
delivered,全部子订单送达
pending_payment
confirmed
cancelled
cancelling
completed
支付 Payment(支付域的镜像)
应付为 0
payment_confirmed
refund_settled
unpaid
not_required
paid
refunded
履约 Fulfillment(履约域的镜像,每个子订单一份)
shipped
delivered
pending
shipped
delivered
订单创建
订单号生成、写入主订单、子订单和订单项、保存商品价格优惠地址快照、记录 reservation_id 与 redemption_id、写第一条日志和第一条发件箱记录,这几步在同一个数据库事务内完成。事务的职责边界很窄:只保证订单聚合自身在库里完整一致,不多不少。它不负责调用支付网关发起收单,不负责让库存域把预留绑到订单上,不负责通知。这些下游动作在事务提交之后由「订单已创建」事件触发:库存域收到后按第 00 篇确认预留并绑定 order_id,这一步和订单创建之间的半成功怎么处理是第 07 篇的主题,库存自己的数量模型在第 06 篇;促销域在支付确认后收到事件才 RedeemCoupon;履约域收到「订单已确认」才生成履约单。一旦把这些调用塞进建单事务,提交时间被最慢的下游拖累,任何一个下游失败还会让本该成功的订单被回滚。
create 命令的幂等键就是第 04 篇的 submission_token,ORDER.submission_token 上的唯一约束是它的数据库表达。结算域重试提交、网关重试请求,落到订单库的都是同一个键,第二次插入撞唯一约束,返回第一次的订单。
「提交后发事件」这句话藏着一个经典故障:事务提交成功了,事件没发出去(进程崩溃、消息中间件抖动),订单建好了,库存永远不会被绑定。可靠的做法是把事件写进订单库的发件箱表,和订单在同一个事务里落库,再由独立的投递进程读表发送,成功标记已投递,失败按退避重试,重试到上限进死信表并告警。订单与事件的边界一致:要么都在,要么都不在。投递可能延迟,不会丢;下游收到的事件可能重复或乱序,由幂等键和前提检查来守。第 13 篇专讲这一套。
状态变更之后的异步任务
上一节把「提交后发事件」讲成了建单的特例,其实每一次成功的状态转换后面都跟着一串异步任务:建单后要让库存域把预留绑到订单上、要给超时关单排一个闹钟;支付确认后要核销券、要让每个子订单生成履约单;取消后要释放预留、释放券、把钱退回去;签收后要起算结算和售后期、发积分。这些事没有一件该在 Apply 的事务里做,也没有一件可以不做。这一节讲它们怎么被触发、由谁执行、失败了怎么办。
先立一条原则:状态先变,任务后跟,任务失败不回滚状态。转换事务只做四件确定的事------改状态、加版本、写日志、写发件箱------事务提交那一刻订单就处于新状态了,后面的任务用重试和补偿把世界追平。这条原则的代价是订单会短暂停在「状态已变、任务未完」的窗口里,所以前面监控一节要看每个状态的停留时长;收益是任何一个下游的抖动都不会让一次合法的状态转换失败。
每个转换发什么事件,谁来干什么
把转换和它触发的任务写成一张表,和转换表放在一起评审。表里的「消费方」就是任务的执行者,都是别的域或订单域自己的后续动作,没有一项是消费方回写订单表:
| 转换 | 发出的事件 | 消费方与任务 | 幂等依据 | 失败处理 |
|---|---|---|---|---|
create → 待支付 |
OrderCreated |
库存域:确认预留并绑定 order_id;订单域:安排超时关单延迟任务(第 09 篇);读模型:写买家与商家列表投影 |
event_id;(reservation_id, order_id) |
绑定失败重试到上限转差异单,订单不回滚,第 07 篇 |
payment_confirmed → 已确认 |
OrderConfirmed |
促销域:RedeemCoupon;履约域:为每个子订单生成履约单;商家侧通知新订单;读模型 |
redemption_id;(order_id, sub_order_id) |
履约单生成失败重试;核销失败告警并进对账,钱已收不回滚 |
cancel → 已取消 |
OrderCancelled |
库存域:释放预留;促销域:ReleaseCoupon;读模型 |
reservation_id;redemption_id |
释放失败靠预留 TTL 和券占用超时兜底 |
cancel_after_payment → 取消中 |
RefundRequested |
支付域:发起退款;履约域:撤销尚未开始的履约单;库存域:释放预留 | (order_id, refund_no) |
退款失败重试;超限转人工差异单,订单停在取消中并告警 |
refund_settled → 已取消 |
OrderCancelled(含退款) |
促销域:按条款回券或消费;分账域:冲抵待结算;积分域:回退已发放积分;通知买家 | event_id |
冲抵失败进对账,第 14 篇 |
shipped → 子订单已发货 |
SubOrderShipped |
通知买家;安排自动确认收货延迟任务;读模型 | (sub_order_id, shipment_id) |
通知失败重试有限次后放弃,不告警 |
delivered → 子订单已送达 / 订单已完成 |
SubOrderDelivered、OrderCompleted |
积分域:发放;评价资格开放(第 23 篇);售后期起算;读模型 | (order_id, sub_order_id) |
发放失败重试;进对账 |
三点说明。分账的计时起点第 00 篇定在履约域的签收事实上,订单的 OrderCompleted 不重复承担这件事,它服务的是积分、评价和售后期。「失败处理」那一列分三档:能靠别的机制兜住的(预留 TTL),只重试不告警;钱和货相关的,重试到上限转差异单并告警;纯通知类,重试几次就放弃。差异单是第 14 篇对账的输入,人工介入时用的是前面说的「补偿触发」操作者类型,带上差异单号。
事件从发件箱到消费者
事件的生命周期是一条闭环:命令进 Apply,事务里落状态和发件箱记录,投递进程读发件箱发消息,消费方执行任务,任务产生的结果作为事实回到订单域,又是一条事实同步命令进 Apply。
#mermaid-svg-NYMXg1CM1j64RTbo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-NYMXg1CM1j64RTbo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-NYMXg1CM1j64RTbo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-NYMXg1CM1j64RTbo .error-icon{fill:#552222;}#mermaid-svg-NYMXg1CM1j64RTbo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-NYMXg1CM1j64RTbo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-NYMXg1CM1j64RTbo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-NYMXg1CM1j64RTbo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-NYMXg1CM1j64RTbo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-NYMXg1CM1j64RTbo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-NYMXg1CM1j64RTbo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-NYMXg1CM1j64RTbo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-NYMXg1CM1j64RTbo .marker.cross{stroke:#333333;}#mermaid-svg-NYMXg1CM1j64RTbo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-NYMXg1CM1j64RTbo p{margin:0;}#mermaid-svg-NYMXg1CM1j64RTbo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-NYMXg1CM1j64RTbo .cluster-label text{fill:#333;}#mermaid-svg-NYMXg1CM1j64RTbo .cluster-label span{color:#333;}#mermaid-svg-NYMXg1CM1j64RTbo .cluster-label span p{background-color:transparent;}#mermaid-svg-NYMXg1CM1j64RTbo .label text,#mermaid-svg-NYMXg1CM1j64RTbo span{fill:#333;color:#333;}#mermaid-svg-NYMXg1CM1j64RTbo .node rect,#mermaid-svg-NYMXg1CM1j64RTbo .node circle,#mermaid-svg-NYMXg1CM1j64RTbo .node ellipse,#mermaid-svg-NYMXg1CM1j64RTbo .node polygon,#mermaid-svg-NYMXg1CM1j64RTbo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-NYMXg1CM1j64RTbo .rough-node .label text,#mermaid-svg-NYMXg1CM1j64RTbo .node .label text,#mermaid-svg-NYMXg1CM1j64RTbo .image-shape .label,#mermaid-svg-NYMXg1CM1j64RTbo .icon-shape .label{text-anchor:middle;}#mermaid-svg-NYMXg1CM1j64RTbo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-NYMXg1CM1j64RTbo .rough-node .label,#mermaid-svg-NYMXg1CM1j64RTbo .node .label,#mermaid-svg-NYMXg1CM1j64RTbo .image-shape .label,#mermaid-svg-NYMXg1CM1j64RTbo .icon-shape .label{text-align:center;}#mermaid-svg-NYMXg1CM1j64RTbo .node.clickable{cursor:pointer;}#mermaid-svg-NYMXg1CM1j64RTbo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-NYMXg1CM1j64RTbo .arrowheadPath{fill:#333333;}#mermaid-svg-NYMXg1CM1j64RTbo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-NYMXg1CM1j64RTbo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-NYMXg1CM1j64RTbo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NYMXg1CM1j64RTbo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-NYMXg1CM1j64RTbo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NYMXg1CM1j64RTbo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-NYMXg1CM1j64RTbo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-NYMXg1CM1j64RTbo .cluster text{fill:#333;}#mermaid-svg-NYMXg1CM1j64RTbo .cluster span{color:#333;}#mermaid-svg-NYMXg1CM1j64RTbo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-NYMXg1CM1j64RTbo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-NYMXg1CM1j64RTbo rect.text{fill:none;stroke-width:0;}#mermaid-svg-NYMXg1CM1j64RTbo .icon-shape,#mermaid-svg-NYMXg1CM1j64RTbo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-NYMXg1CM1j64RTbo .icon-shape p,#mermaid-svg-NYMXg1CM1j64RTbo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-NYMXg1CM1j64RTbo .icon-shape .label rect,#mermaid-svg-NYMXg1CM1j64RTbo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-NYMXg1CM1j64RTbo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-NYMXg1CM1j64RTbo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-NYMXg1CM1j64RTbo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命令
Apply:守卫、版本、幂等
同一事务:状态 + 日志 + 发件箱
投递进程读发件箱
消息,按 order_id 分区
消费方执行任务
库存 / 促销 / 履约 / 支付 / 积分 / 读模型
结果事实
shipped、refund_settled ......
几条约定让这个环跑得稳。同一订单的事件按 order_id 分区,保证一张订单上的事件有序;跨订单不保序,消费方也不该依赖。消费方只依赖事件里带的事实和自己域的状态,不回查订单聚合当前状态来决定要不要干------事件说「订单已确认」,履约域就生成履约单,哪怕此刻订单已经进入取消中,随后到达的 RefundRequested 会让它撤销;两个事件各自幂等、各自前提检查,比一次回查更可靠,因为回查读到的状态在它干活的时候也可能又变了。消费方永远不写订单表,结果只能以事实同步命令的形式回来,这是第 00 篇所有权表里「支付回调直接更新订单表」那条禁令在异步侧的形式。
延迟任务是这个环里的一个特例:超时关单、自动确认收货、自动完成都是某次转换「安排」出来的,到点触发时它们不是直接改状态,而是变成一条命令进 Apply,前提检查决定它还合不合法。超时关单到点时订单已支付,cancel 的前提不成立,空转;自动确认收货到点时买家已经点了,delivered 幂等键撞上,无操作。延迟任务丢了怎么办、扫描兜底怎么做,第 09 篇讲。
代码:反应表和幂等消费
反应表像转换表一样是数据,不是散在处理器里的代码路径。Apply 成功后按转换查表,把要发的事件写进发件箱,和状态在同一个事务里:
go
// Reaction declares one asynchronous task that a successful transition
// triggers. Consumer is the owning domain of the task, never the order
// aggregate itself; results come back as fact commands.
type Reaction struct {
Event string
Consumer string
Task string
}
// reactions is reviewed and tested together with the transition table.
var reactions = map[CommandKind][]Reaction{
CmdCreate: {
{"OrderCreated", "inventory", "bind_reservation"},
{"OrderCreated", "order-scheduler", "schedule_payment_timeout"},
{"OrderCreated", "read-model", "project_order"},
},
CmdPaymentConfirmed: {
{"OrderConfirmed", "promotion", "redeem_coupon"},
{"OrderConfirmed", "fulfillment", "create_fulfillment_per_sub_order"},
{"OrderConfirmed", "notification", "notify_merchant_new_order"},
},
CmdCancel: {
{"OrderCancelled", "inventory", "release_reservation"},
{"OrderCancelled", "promotion", "release_coupon"},
},
CmdCancelAfterPayment: {
{"RefundRequested", "payment", "refund_full"},
{"RefundRequested", "fulfillment", "cancel_pending_fulfillment"},
{"RefundRequested", "inventory", "release_reservation"},
},
CmdRefundSettled: {
{"OrderCancelled", "promotion", "return_or_consume_coupon"},
{"OrderCancelled", "settlement", "reverse_pending_payout"},
{"OrderCancelled", "points", "revert_points"},
},
CmdShipped: {
{"SubOrderShipped", "notification", "notify_buyer_shipped"},
{"SubOrderShipped", "order-scheduler", "schedule_auto_confirm"},
},
CmdDelivered: {
{"SubOrderDelivered", "points", "grant_points"},
{"OrderCompleted", "review", "open_review_window"},
},
}
// OutboxRecord is written in the same transaction as the state change.
type OutboxRecord struct {
EventID string // ULID; consumers dedupe on it
OrderID string // partition key: one order's events stay ordered
Event string
Consumer string
Task string
Payload []byte
Version int64 // order version after the transition
CreatedAt time.Time
}
func outboxFor(o *Order, cmd Command) []OutboxRecord {
var out []OutboxRecord
for _, r := range reactions[cmd.Kind] {
out = append(out, OutboxRecord{
EventID: newULID(),
OrderID: o.ID,
Event: r.Event,
Consumer: r.Consumer,
Task: r.Task,
Payload: payloadFor(o, cmd, r),
Version: o.Version,
CreatedAt: cmd.Now,
})
}
return out
}
消费方一侧的形状都一样:先看 event_id 见过没有,没见过就干活,干完记下 event_id。库存域绑定预留的处理器是个样本:
go
// BindReservationHandler runs in the inventory domain. It is idempotent on
// event_id and the underlying Bind is idempotent on (reservation_id,
// order_id), so a redelivery after a crash between Bind and Record is safe.
func (h *BindReservationHandler) Handle(ev OutboxRecord) error {
seen, err := h.seen.Exists(ev.EventID)
if err != nil || seen {
return err
}
var p OrderCreatedPayload
if err := json.Unmarshal(ev.Payload, &p); err != nil {
return h.deadLetter(ev, err) // malformed: never succeeds on retry
}
if err := h.inventory.Bind(p.ReservationID, p.OrderID); err != nil {
return err // relay retries with backoff; over the limit it dead-letters and alerts
}
return h.seen.Record(ev.EventID)
}
两处值得看。Bind 失败直接返回错误,不吞掉,让投递进程按退避重试;解析失败走死信,因为重试不会让它变好。Exists 和 Record 之间有一个窗口,进程在 Bind 之后、Record 之前崩溃,事件会重投一次,所以 Bind 本身必须按 (reservation_id, order_id) 幂等------消费方的幂等是两层,event_id 挡重复投递,业务键挡重投窗口。这一套在第 12、13 篇展开,本篇要求的只是:每个转换的反应写成表,每个任务有幂等依据和失败档位。
命令、查询与操作日志
订单聚合对外只暴露命令接口,每个命令是一次独立的、带幂等键的写入请求,不允许调用方拿到订单对象后随意改字段。查询侧完全独立于命令侧:买家订单列表按买家筛选,只要交易状态和派生视图;商家订单列表按子订单和履约镜像筛选;详情页拉完整的订单项、金额和日志。读写分离让列表页的索引、缓存、只读副本不受命令侧强一致要求的牵连。
操作日志是订正历史的唯一依据。每条记录包含操作者、原因、变更前后的状态、生效后的版本号、触发这次变更的幂等键,作用在子订单上的命令还记子订单 ID。没有这份日志,客服排查「这笔订单为什么在取消中卡了三天」只能靠猜;有了日志,查最后一条记录的操作者和原因就知道卡在哪个环节------多半是支付域的退款事实还没到。
操作者要再拆一层,因为「谁触发的」决定了出问题时找谁。按触发源分三类:人工触发(买家在前台点取消、客服在后台强制取消、运营改地址)、系统触发(超时关单任务、支付事实的处理器、履约事件消费)、补偿触发(对账发现不一致后的自动订正、人工工单驱动的数据修复)。三类分别记录,因为它们的责任边界和可重放性完全不同:人工操作一般不可自动重放,系统触发的必须可重放且幂等,补偿触发的要额外记录原始证据(对账批次号或工单号),否则事后无法证明这次订正有依据。
并发与异常
两组典型的竞态:支付事实与超时关单的竞争,仓库发货与已支付取消的竞争。处理方式相同:两个并发命令读到同一个版本号,谁先提交成功谁加一,后到的因为版本不匹配被拒绝,重新读取最新状态后再决定是否重试。
超时关单任务 订单聚合 支付事实处理器 超时关单任务 订单聚合 支付事实处理器 #mermaid-svg-EU98pNoPHgjPef0e{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EU98pNoPHgjPef0e .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EU98pNoPHgjPef0e .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EU98pNoPHgjPef0e .error-icon{fill:#552222;}#mermaid-svg-EU98pNoPHgjPef0e .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EU98pNoPHgjPef0e .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EU98pNoPHgjPef0e .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EU98pNoPHgjPef0e .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EU98pNoPHgjPef0e .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EU98pNoPHgjPef0e .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EU98pNoPHgjPef0e .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EU98pNoPHgjPef0e .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EU98pNoPHgjPef0e .marker.cross{stroke:#333333;}#mermaid-svg-EU98pNoPHgjPef0e svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EU98pNoPHgjPef0e p{margin:0;}#mermaid-svg-EU98pNoPHgjPef0e .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EU98pNoPHgjPef0e text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-EU98pNoPHgjPef0e .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-EU98pNoPHgjPef0e .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-EU98pNoPHgjPef0e .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-EU98pNoPHgjPef0e .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-EU98pNoPHgjPef0e #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-EU98pNoPHgjPef0e .sequenceNumber{fill:white;}#mermaid-svg-EU98pNoPHgjPef0e #sequencenumber{fill:#333;}#mermaid-svg-EU98pNoPHgjPef0e #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-EU98pNoPHgjPef0e .messageText{fill:#333;stroke:none;}#mermaid-svg-EU98pNoPHgjPef0e .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EU98pNoPHgjPef0e .labelText,#mermaid-svg-EU98pNoPHgjPef0e .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-EU98pNoPHgjPef0e .loopText,#mermaid-svg-EU98pNoPHgjPef0e .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-EU98pNoPHgjPef0e .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-EU98pNoPHgjPef0e .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-EU98pNoPHgjPef0e .noteText,#mermaid-svg-EU98pNoPHgjPef0e .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-EU98pNoPHgjPef0e .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EU98pNoPHgjPef0e .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EU98pNoPHgjPef0e .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-EU98pNoPHgjPef0e .actorPopupMenu{position:absolute;}#mermaid-svg-EU98pNoPHgjPef0e .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-EU98pNoPHgjPef0e .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-EU98pNoPHgjPef0e .actor-man circle,#mermaid-svg-EU98pNoPHgjPef0e line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-EU98pNoPHgjPef0e :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 交易已是已确认,cancel 前提不成立,放弃 交易已是已取消,钱到了这件事记在支付域,转退款流程 alt 支付事实先到 超时任务先到 payment_confirmed(expected_version=1) cancel(expected_version=1) 比较 expected_version 与当前版本 成功,版本 2,交易=已确认 版本冲突,拒绝取消 重新读取 成功,版本 2,交易=已取消 版本冲突,拒绝确认 重新读取
版本冲突只解决「谁先谁后」,不解决「状态本身是否允许这个操作」。所以前提检查独立于版本检查:版本检查先拦住并发写入的其中一个,前提检查再拦住版本刷新之后依然不合法的操作。迟到的支付事实在订单已取消之后,即使带着最新版本号,payment_confirmed 也会因为交易不是待支付而被拒绝。
重复命令通过幂等键去重:同一个键重复到达且命令类型一致,直接返回成功,不重新执行转换;同一个键配上不同命令类型,是误用,直接拒绝。下游事件乱序(签收先于发货到达)不由订单聚合兼容:聚合只按转换表判断当前命令是否合法,乱序事件因为前提不满足被自然拒绝,调用方自己重试或补齐前置事件。
超时关单和支付事实迟到是第 08、09 篇的正题,这里只留一个观点:两者的兜底机制(延迟消息加定时扫描、主动查询加对账补偿)都依赖本篇的前提检查,所以重复执行是安全的------已经取消或已确认的订单,cancel 的前提不成立,扫到也只是空转;补偿命令同样带幂等键和期望版本,重复补偿不会把状态改坏。
订单中心的技术结构
把上面几节放到一张图里。接入方里把结算、支付、履约、售后四个域合成一格,它们是订单域最重要的调用方:结算域发 create,其他三个域发事实同步命令。接口层按命令的性质分成两个入口------本域命令和事实同步------不是为了技术上分开部署,是为了鉴权和审计分开:事实入口只接受来自对应域的事件处理器,人不能调。核心层四块:建单与拆单负责把第 04 篇那一包东西落成聚合并按商家拆子订单;订单聚合是 Apply 和转换表;派生视图与映射是读侧的纯函数;事件发布是发件箱。存储里读模型单独一块,买家列表和商家列表各有自己的投影。
#mermaid-svg-FQP04fyK5gNDngto{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FQP04fyK5gNDngto .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FQP04fyK5gNDngto .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FQP04fyK5gNDngto .error-icon{fill:#552222;}#mermaid-svg-FQP04fyK5gNDngto .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FQP04fyK5gNDngto .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FQP04fyK5gNDngto .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FQP04fyK5gNDngto .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FQP04fyK5gNDngto .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FQP04fyK5gNDngto .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FQP04fyK5gNDngto .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FQP04fyK5gNDngto .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FQP04fyK5gNDngto .marker.cross{stroke:#333333;}#mermaid-svg-FQP04fyK5gNDngto svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FQP04fyK5gNDngto p{margin:0;}#mermaid-svg-FQP04fyK5gNDngto .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FQP04fyK5gNDngto .cluster-label text{fill:#333;}#mermaid-svg-FQP04fyK5gNDngto .cluster-label span{color:#333;}#mermaid-svg-FQP04fyK5gNDngto .cluster-label span p{background-color:transparent;}#mermaid-svg-FQP04fyK5gNDngto .label text,#mermaid-svg-FQP04fyK5gNDngto span{fill:#333;color:#333;}#mermaid-svg-FQP04fyK5gNDngto .node rect,#mermaid-svg-FQP04fyK5gNDngto .node circle,#mermaid-svg-FQP04fyK5gNDngto .node ellipse,#mermaid-svg-FQP04fyK5gNDngto .node polygon,#mermaid-svg-FQP04fyK5gNDngto .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FQP04fyK5gNDngto .rough-node .label text,#mermaid-svg-FQP04fyK5gNDngto .node .label text,#mermaid-svg-FQP04fyK5gNDngto .image-shape .label,#mermaid-svg-FQP04fyK5gNDngto .icon-shape .label{text-anchor:middle;}#mermaid-svg-FQP04fyK5gNDngto .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FQP04fyK5gNDngto .rough-node .label,#mermaid-svg-FQP04fyK5gNDngto .node .label,#mermaid-svg-FQP04fyK5gNDngto .image-shape .label,#mermaid-svg-FQP04fyK5gNDngto .icon-shape .label{text-align:center;}#mermaid-svg-FQP04fyK5gNDngto .node.clickable{cursor:pointer;}#mermaid-svg-FQP04fyK5gNDngto .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FQP04fyK5gNDngto .arrowheadPath{fill:#333333;}#mermaid-svg-FQP04fyK5gNDngto .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FQP04fyK5gNDngto .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FQP04fyK5gNDngto .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FQP04fyK5gNDngto .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FQP04fyK5gNDngto .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FQP04fyK5gNDngto .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FQP04fyK5gNDngto .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FQP04fyK5gNDngto .cluster text{fill:#333;}#mermaid-svg-FQP04fyK5gNDngto .cluster span{color:#333;}#mermaid-svg-FQP04fyK5gNDngto div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FQP04fyK5gNDngto .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FQP04fyK5gNDngto rect.text{fill:none;stroke-width:0;}#mermaid-svg-FQP04fyK5gNDngto .icon-shape,#mermaid-svg-FQP04fyK5gNDngto .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FQP04fyK5gNDngto .icon-shape p,#mermaid-svg-FQP04fyK5gNDngto .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FQP04fyK5gNDngto .icon-shape .label rect,#mermaid-svg-FQP04fyK5gNDngto .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FQP04fyK5gNDngto .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FQP04fyK5gNDngto .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FQP04fyK5gNDngto :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储
订单域核心:聚合、转换表与派生视图
接口层
接入方
结算 / 支付 / 履约 / 售后域
建单与事实同步
买家端
列表、详情、取消、确认收货
商家后台
子订单列表与备货
运营与客服后台
订正与日志查询
命令 API
create、cancel、cancel_after_payment
事实同步入口
payment_confirmed、shipped、delivered、refund_settled
查询 API
列表、详情、派生视图
运营 API
补偿命令与日志
建单与拆单
快照落库、按商家拆子订单、submission_token 幂等
事件发布
反应表、Outbox 同事务、按 order_id 分区
订单聚合
Apply、守卫、版本号、幂等键、日志
派生视图与映射
DisplayStatus、角色映射表
订单主库
order、sub_order、item、log、outbox
事件投递
Outbox relay 与消息队列
读模型
买家列表、商家列表、只读副本
图里没有支付域、履约域、售后域的内部,也没有库存域。它们和订单域的关系全是事件:订单发出 OrderCreated、OrderConfirmed、RefundRequested,收回 payment_confirmed、shipped、delivered、refund_settled 四个事实。订单域不存它们的任何权威状态,只存镜像。
测试与监控
核心测试场景在 order_test.go 里:建单(含 0 元订单直接确认)、全流程支付发货签收、支付前取消、取消后拒绝确认(不变量)、已支付取消进入取消中再由退款事实关闭、退款金额不等于余额被拒绝、重复命令回放为无操作、幂等键复用于不同命令报冲突、版本过期被拒绝、两个子订单一发一未发时派生视图为「部分发货」、乱序的签收先于发货被拒绝、每个成功转换写出的发件箱记录与反应表逐条一致,以及一张覆盖八种非法转换的表驱动测试。两个并发测试用 go test -race 驱动:支付事实与超时取消的竞争,发货与已支付取消的竞争,断言并发的两个命令里恰好一个成功、一个收到版本冲突。
监控指标覆盖状态机本身的健康度,不只是接口错误率:
- 非法状态转换的次数和类型分布。持续走高通常说明客户端或下游对订单当前状态的假设是错的。
- 订单在每个交易状态上的停留时长分布。「取消中」停留过久意味着支付域的退款事实没有及时到达,「已确认」停留过久意味着履约域没有响应
OrderConfirmed。 - 建单事务失败的原因分布。
submission_token冲突、金额校验失败、数据库连接失败分开统计,不笼统计入「创建失败」。 - 版本冲突的频率。持续增高多半是某个下游在用过期的订单快照反复重试,不是真实的高并发。
小结
回到开头那张工单。买家看到的「已支付」是支付镜像,「只发了一半货」是两个子订单的履约镜像不同,「退款申请中」是售后域的事实还没同步过来。三件事在一张表里打架,是因为它们本来就不该在一张表里:订单聚合只裁决交易状态,支付和履约是镜像,给人看的那一句话是算出来的。订单不是一张表,是交易事实的聚合加一张转换表,再加一份能说清「谁在什么时候为什么改了它」的日志。下一篇进入库存:预留怎么建模,确认和释放怎么做,超卖怎么防。
参考资料
- Spree Order 模型源码(主分支,2026 年 9 月):
STATUSES、PAYMENT_STATUSES、FULFILLMENT_STATUSES三组常量与Orders::UpdateStatuses唯一写入者。 - Spree 6.0 决策记录:normalize state to status:移除 Order、Payment、Fulfillment 上的状态机。
- Spree Orders 文档:5.x 的结算状态机与
payment_state。 - Medusa Order Module:订单项、调整行、税费行作为聚合子实体;带版本号的订单变更。
- Saleor OrderStatus 枚举:履约进度编码进主状态的样本。
- Saleor Order 对象:
paymentStatus、authorizeStatus、chargeStatus独立于status。 - AWS DynamoDBMapper 乐观锁:版本号契约。