
商品详情页显示 99 元,购物车里变成 109 元,结算时又弹出一张「价格已更新」的提示。这通常不是页面格式化错了,而是页面、购物车和订单用了不同的价格输入:渠道、会员层级、币种或生效时间在这几步之间变了。
下面沿着一个 SKU 的询价过程,把价格维护、单 SKU 实时试算和订单成交价快照分开,最后落到一个可以运行的 Go 选择器。读完应该能回答一个问题:在什么条件下、对谁、在什么时间、以什么币种卖多少钱,以及这个答案怎么被复查。
同一个 SKU 为什么会有多个价格
SKU 说的是卖什么,价格说的是在什么条件下卖多少钱,两者不是一对一。第 01 篇那件基础款 T 恤的 color=blue|size=m,在同一时刻可能挂着这些价:
| 价格类型 | 金额 | 条件 | 归谁 |
|---|---|---|---|
| 吊牌价(划线价) | ¥129 | 展示参考,必须有来源与记录时刻 | 价格域的参考价 |
| 销售价 | ¥99 | 无额外条件 | 价格域的基础规则 |
| App 渠道价 | ¥95 | 渠道 = app | 价格域的规则 |
| 会员价 | ¥89 | 会员层级 = gold | 价格域的规则 |
| 阶梯价 | ¥90 | 数量 ≥ 3 | 价格域的规则 |
| 区域价 | JPY 1,500 | 地区 = JP,币种 = JPY | 价格域的规则 |
| 双 11 活动价 | ¥79 | 活动期间,商家或平台出资 | 促销域,不在本篇 |
价目表保存的是可维护的候选项。基础价是没有额外条件时的价格。渠道价和会员价各自再加一层条件:买家从 App 还是小程序来,属于哪个会员层级。时间窗口规定这些候选项何时有效。一个 SKU 同时拥有这些候选项并不矛盾,矛盾出现在系统没有记录选择依据的时候。
Spree 5.3 起的定价文档把 Variant 的每币种基础 Price 与有条件的 Price List 分开;价目表按优先级、状态、日期和规则命中,未命中时回退基础价。Medusa 把价格放进独立的 Pricing Module,用 Price Set、Price List 和上下文计算匹配价格。Saleor 把 ProductVariantChannelListing 作为 (variant, channel) 的唯一关系,记录 price、prior_price、discounted_price,并关联产生折扣价的规则(源码)。三个项目字段名不同,但都在说同一件事:价格不能只是一列挂在 SPU 上,SKU、渠道和展示用参考价要分开表达。
价格域的职责边界
第 00 篇的所有权表里只有一个「价格与促销域」。它在本系列拆成两篇讲:本篇讲价格,维护候选价格、按上下文返回报价、让每次发布形成可被订单引用的不可变版本;跨商品优惠、优惠券和整单分摊留给第 03 篇。下文说「价格域」「促销域」,指的是这一个域的两半。库存域决定 SKU 是否有可售数量,价格域只接收它们已经确认的输入,不替它们背状态。
动手建表之前,先把一个 SKU 身上会出现的价分清归属。我习惯按用途分四类,多商家平台上维护者和放置位置各不相同:
| 类别 | 典型价格 | 谁维护 | 放在哪里 |
|---|---|---|---|
| 显示类 | 吊牌价、划线价 | 商家维护,平台按合规规则审核 | 价格域的参考价,带来源与记录时刻 |
| 管理类 | 采购价、成本价 | 商家自己的进销存系统 | 平台不保存,也不接收 |
| 销售类 | 销售价、渠道价、会员价、阶梯价 | 商家定价,平台限定可用维度 | 价格域的候选规则,本文的主角 |
| 营销类 | 活动价、补贴价 | 商家或平台按活动配置 | 促销域按规则计算,不落成商品字段 |
分类分错了,后面每一张表都会跟着难受:营销价一旦写成商品表字段,每场活动都要回头改商品行;采购价和成本价跟着商家的采购批次走,平台既拿不到准数,也没有用它的场景。本文的选择器只处理销售类。
单 SKU 报价与整单试算是两个接口。价格域只负责每个 SKU 的价格候选和规则依据,整单金额由结算域组合,接口形状放到后面「接口契约与降级」一节。整单优惠的行级分摊留给下一篇,这里只预告一条硬约束:各行分摊的优惠之和,必须与整单优惠逐分相等。这个约束比直觉难,三个 SKU 各 4 元,满 10 减 2,2 元优惠摊到 3 行就除不尽:行价写成 3.33,三行合计 9.99,平台少收一分;写成 3.34,合计 10.02,买家多付两分。出路是让其中一行吃掉尾差,使各行之和精确回到整单应付金额。算法细节留给第 03 篇,对账约束从现在就要记住。
一次询价的输入至少包含:商家、SKU、数量、渠道、地区、会员层级、币种和 priced_at。priced_at 不能隐含读取服务机器的当前时间,否则重试可能跨过生效边界。生产接口可以把它限制为服务端时间附近的窗口,避免调用方伪造很久以前的价格;本文示例要求显式传入,零值直接拒绝。
产品视角:产品怎样认识价格
技术上价格是一组候选规则加一个选择器。产品得先回答另一个问题:这个数字对商家、买家和平台各自意味着什么,否则规则维度开多少、TTL 取几分钟这些事就没有依据。
商家把价格当成经营杠杆。同一件 T 恤,App 渠道让 4 元、金卡会员再让 10 元、三件起走阶梯价,商家想要的是这些杠杆开得够多、改起来够快,改了以后还能查到「这一单当时为什么是这个价」。买家把价格当成承诺:详情页写 99,购物车就该是 99,结算时如果变成 109,必须有人说清楚为什么。平台夹在中间,它不定价,但它定规则:哪些维度商家可以用,改价多久生效,划线价能不能这么标,补贴的钱谁出。
落到技术对象上,商家操作的是价格规则和版本,买家看到的是报价,平台维护的是维度开关和合规约束。
对商家起作用
规则维度是给商家的工具箱。渠道、会员层级、地区、数量阶梯、时间窗口,每开放一个维度,商家多一种玩法,平台多一类要审的东西。多商家平台通常先开渠道和阶梯。会员层级要先想清楚指的是平台会员还是店铺会员,两套体系用同一个字段,价格规则会互相踩。
改价生效时间是商家最敏感的一条。改了价立刻生效,商家高兴,但正在结算的买家会撞上价格变更;按 starts_at 定时发布,商家可以提前排活动,代价是要理解「发布」和「生效」是两件事。版本让这件事可查:商家后台每一次发布都能回看当时的金额、范围和操作人,客服处理「为什么这单卖了 89」时不用猜。
对买家起作用
买家在意的是一致和可解释。详情页、购物车、结算页三处价格来自同一次报价才会一致;报价有 TTL,过期后价格可能变,这时候产品能做的不是保证不变,而是把变化说清楚:涨了还是降了,因为会员失效还是活动结束,要不要继续下单。静默换成新价格、让买家在支付页才发现,是价格类投诉的主要来源。
划线价是另一件买家会较真的事。「原价 129 现价 99」如果 129 从来没卖过,在不少地区是违法的。参考价要有来源和记录时刻,不能把上一轮报价自动当成划线价。
产品在价格域要做的决定
| 策略点 | 产品在决定什么 | 影响谁 |
|---|---|---|
| 向商家开放哪些规则维度 | 灵活度与审核成本 | 商家、平台治理 |
| 会员层级指平台会员还是店铺会员 | 两套体系是否共用字段 | 商家、买家、技术 |
| 改价生效时间与是否审核 | 商家自由度与结算稳定性 | 商家、买家 |
| 报价 TTL | 展示一致性与价格变化窗口 | 买家、技术 |
| 结算时价格变化怎么处理 | 拦下确认、静默更新还是拒绝 | 买家、转化率 |
| 划线价规则 | 来源、有效期、展示条件 | 买家、合规 |
| 平台补贴与商家让利的出资方 | 分账时谁承担差额 | 商家结算与分账 |
| 价格服务不可用时能否下单 | 风险与可用性 | 平台风控 |
| 未选规格时的价格区间展示 | 「99 元起」还是不显示 | 买家 |
倒数第三行出现「商家结算与分账」是有意的:价格域输出的是商家成交价,平台补贴属于促销域并且要记录出资方,否则分账时算不清商家该拿多少。这条线留给第 03 篇,但产品从定价格策略那天起就要知道它在。
产品不应该向技术要什么
- 「有多个价格时直接选最低的给买家」------低价不参与排序。规则语义由
priority和特异性决定,让金额决定选择,运营改一个数就可能让会员价失效。 - 「活动价直接写到 SKU 表,详情页读起来快」------那是促销域的输出。写进商品或价格表以后,活动结束要回头改行,分摊和退款也失去来源。
- 「退款按现在的价格算」------退款用订单里的成交金额和当时的规则版本,今天的价格和历史订单没有关系。
- 「保证详情页和结算价永远一样」------做不到。报价有 TTL,规则会发布新版本。能做的是让两处来自同一次报价,变了就解释。
数据模型:金额、范围与版本
本系列金额字段统一使用 amount_minor 语义,即币种的最小货币单位整数,并同时保存货币代码。不同币种可能有 0、2 或 3 位小数;以 Adyen 为例,CLP、CVE、IDR、ISK 在它的通道里使用的指数与 ISO 4217 不同。不能把所有币种都乘 100,也不能用 float64 保存金额。
货币指数是元数据,不属于 Money 的计算结果。价格规则应保存 currency、amount_minor、starts_at、ends_at、版本和发布状态;支付适配器再依据自己的货币表把整数交给支付渠道。若两个金额货币不一致,运算直接失败;汇率换算必须是显式的另一项业务操作,不能在 Money.Add 里偷偷完成。
适用范围用可选字段表达:空的 Channel 表示所有渠道,填写 app 表示只命中 App;Region 和 MemberTier 同理。数量阶梯用 MinQuantity 与 MaxQuantity,时间用半开区间 [starts_at, ends_at)。半开区间让相邻价目可以写成 [12:00, 13:00) 和 [13:00, 14:00),边界上不会两条同时命中。
版本号不是更新时间的替代品。每次发布改变金额或适用范围,都生成一个可审计版本;报价返回选中的规则 ID 和版本。订单项再复制报价输入摘要、报价明细和成交金额,目录后续修改不会回写订单历史。Saleor 的订单记录也保留商品名称、SKU 等历史字段,即使目录对象后来被删除。
#mermaid-svg-wISlECbuadtVcVhb{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-wISlECbuadtVcVhb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wISlECbuadtVcVhb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wISlECbuadtVcVhb .error-icon{fill:#552222;}#mermaid-svg-wISlECbuadtVcVhb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wISlECbuadtVcVhb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wISlECbuadtVcVhb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wISlECbuadtVcVhb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wISlECbuadtVcVhb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wISlECbuadtVcVhb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wISlECbuadtVcVhb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wISlECbuadtVcVhb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wISlECbuadtVcVhb .marker.cross{stroke:#333333;}#mermaid-svg-wISlECbuadtVcVhb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wISlECbuadtVcVhb p{margin:0;}#mermaid-svg-wISlECbuadtVcVhb .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-wISlECbuadtVcVhb .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-wISlECbuadtVcVhb .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-wISlECbuadtVcVhb .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-wISlECbuadtVcVhb .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-wISlECbuadtVcVhb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wISlECbuadtVcVhb .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-wISlECbuadtVcVhb .node rect,#mermaid-svg-wISlECbuadtVcVhb .node circle,#mermaid-svg-wISlECbuadtVcVhb .node ellipse,#mermaid-svg-wISlECbuadtVcVhb .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wISlECbuadtVcVhb .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-wISlECbuadtVcVhb .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-wISlECbuadtVcVhb .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wISlECbuadtVcVhb .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-wISlECbuadtVcVhb .edgeLabel .label text{fill:#333;}#mermaid-svg-wISlECbuadtVcVhb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} priced_by
versions
publishes
compared_with
referenced_by
PRODUCT_SKU
PRICE_RULE
string
id
PK
string
merchant_id
string
sku_id
FK
string
currency
int
current_version
datetime
created_at
PRICE_RULE_VERSION
string
rule_id
FK
int
version
string
status
string
channel
string
region
string
member_tier
int
min_quantity
int
max_quantity
int
priority
int
amount_minor
datetime
starts_at
datetime
ends_at
int
release_seq
FK
datetime
published_at
string
published_by
PRICE_RELEASE
int
seq
PK
string
merchant_id
datetime
published_at
string
note
REFERENCE_PRICE
string
id
PK
string
sku_id
FK
string
currency
string
channel
string
region
string
kind
int
amount_minor
datetime
recorded_at
int
version
ORDER_ITEM
string
order_id
FK
string
sku_id
int
quantity
int
unit_amount_minor
string
currency
string
price_rule_id
int
price_version
string
price_context_digest
datetime
priced_at
几个字段解释一下。PRICE_RULE 只承载身份:属于哪个商家、哪个 SKU、哪个币种。所有会变的东西------范围、金额、时间窗、优先级、状态------都在 PRICE_RULE_VERSION 上,版本号只增不改,status 取 published 或 withdrawn,撤回的版本不参加候选。PRICE_RELEASE 是一次发布动作,seq 全局递增,一次发布可以带多条规则版本;它的用途在缓存那一节会讲。REFERENCE_PRICE 是划线价,kind 区分吊牌价和近期最低价,recorded_at 是合规要用的记录时刻。ORDER_ITEM 归订单域,画在这里只为了说明它引用的是哪几个字段:规则 ID、版本、上下文摘要和成交时刻,订单域拿它们解释历史,不回查价表。
基础价、渠道价和会员价的层次要在模型里写清楚。基础价是可回退的候选,渠道价和会员价是可覆盖的候选,它们不是三次相加。若业务要表达「会员在 App 再减 10 元」,应交给促销域形成优惠明细;写成一条最终价规则,最终价格就有了多个来源,后续的优惠分摊和退款失去独立依据。价格域输出单价,结算域保存数量乘积和整单计算过程。
划线价的来源也要说清楚。Spree 把 compare_at_amount 与当前销售价分开;Saleor 的 channel listing 同时有 prior_price 和 discounted_price。本文的选择器不处理参考价,因为参考价展示受地区法规和渠道策略约束,它有自己的表和查询接口:返回参考金额、来源版本和记录时刻,不能把上一轮报价自动当作合规的最低价。
选择顺序必须固定
候选过滤和候选排序是两件事。过滤阶段检查商家、SKU、币种、发布状态、渠道、地区、会员层级、数量和有效时间;任何一项不满足就丢弃。排序阶段的四个键是本系列自己的约定:priority 降序、约束维度数量降序、版本号降序、规则 ID 升序。前两项表达业务优先级和条件特异性,后两项解决同一输入下的稳定性。
会员价同时约束渠道和会员层级,因此比只约束渠道的价格更具体。在相同优先级下,会员价会先被选中;若两条规则仍然完全相同,版本号和 ID 给出可复现的结果。价格更低不能单独成为选择条件,否则运营只改金额就可能改变规则语义,比较价和成交价也会出现不可预期的切换。
Medusa 的 calculatePrices 接收一个或多个 price set 和计算上下文,返回每个集合的最佳匹配价,文档分别说明了默认、精确、部分规则匹配和阶梯价。Spree 把 Price List 的位置、状态、日期和规则匹配列为解析条件。本文没有把任一项目的排序算法当作行业标准,而是把排序键写入接口说明和测试。
价格选择决策表把规则写成可以评审的契约:
| 阶段 | 检查或排序键 | 通过条件 / 结果 | 失败或并列处理 |
|---|---|---|---|
| 过滤 1 | 商家、SKU、规则币种、金额币种 | 都与 PriceContext 一致 |
丢弃候选;没有候选返回 ErrNoPrice |
| 过滤 2 | 发布状态 | status = published |
撤回或草稿版本丢弃 |
| 过滤 3 | 渠道、地区、会员层级 | 空字段表示通配,非空字段必须相等 | 丢弃候选 |
| 过滤 4 | 数量与时间 | 数量在阶梯内;时间满足 [StartsAt, EndsAt),起点含、终点不含 |
丢弃候选 |
| 排序 1 | Priority |
数值越大越优先 | 相同进入下一键 |
| 排序 2 | 约束维度数 | 渠道、地区、会员、数量、时间各算一个维度 | 时间有起止两个边界仍只算一个维度 |
| 排序 3 | Version |
整数降序 | 相同进入 ID |
| 排序 4 | ID |
字典序升序 | 得到唯一结果 |
这张表明确了两个容易遗漏的边界:时间区间是一个维度,数量的最小值和最大值也是一个维度;它们的两个边界用于过滤,不把特异性加倍。Priority 表达业务覆盖关系,低价只作为被选规则的结果,不参与排序。
#mermaid-svg-I6Ch3kBH0B2AfBA6{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-I6Ch3kBH0B2AfBA6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .error-icon{fill:#552222;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .marker.cross{stroke:#333333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 p{margin:0;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .cluster-label text{fill:#333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .cluster-label span{color:#333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .cluster-label span p{background-color:transparent;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .label text,#mermaid-svg-I6Ch3kBH0B2AfBA6 span{fill:#333;color:#333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .node rect,#mermaid-svg-I6Ch3kBH0B2AfBA6 .node circle,#mermaid-svg-I6Ch3kBH0B2AfBA6 .node ellipse,#mermaid-svg-I6Ch3kBH0B2AfBA6 .node polygon,#mermaid-svg-I6Ch3kBH0B2AfBA6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .rough-node .label text,#mermaid-svg-I6Ch3kBH0B2AfBA6 .node .label text,#mermaid-svg-I6Ch3kBH0B2AfBA6 .image-shape .label,#mermaid-svg-I6Ch3kBH0B2AfBA6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .rough-node .label,#mermaid-svg-I6Ch3kBH0B2AfBA6 .node .label,#mermaid-svg-I6Ch3kBH0B2AfBA6 .image-shape .label,#mermaid-svg-I6Ch3kBH0B2AfBA6 .icon-shape .label{text-align:center;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .node.clickable{cursor:pointer;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .arrowheadPath{fill:#333333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-I6Ch3kBH0B2AfBA6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I6Ch3kBH0B2AfBA6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-I6Ch3kBH0B2AfBA6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .cluster text{fill:#333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .cluster span{color:#333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 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-I6Ch3kBH0B2AfBA6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-I6Ch3kBH0B2AfBA6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .icon-shape,#mermaid-svg-I6Ch3kBH0B2AfBA6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .icon-shape p,#mermaid-svg-I6Ch3kBH0B2AfBA6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .icon-shape .label rect,#mermaid-svg-I6Ch3kBH0B2AfBA6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I6Ch3kBH0B2AfBA6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-I6Ch3kBH0B2AfBA6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-I6Ch3kBH0B2AfBA6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 匹配
不匹配
匹配
不匹配
有效
过期
SKU + PriceContext
价格候选集
商家 / SKU / 币种 / 状态
渠道 / 地区 / 会员
无价格错误
数量与时间窗口
priority 与特异性排序
PriceQuote: 金额 / 规则 ID / 版本
结算域提交时重新询价
订单项成交快照
输入是 SKU 加上完整的询价上下文,价格域筛选并排序,结算域在提交前重新询价,订单只接收计算之后的结果。
试算、版本与订单快照
PriceQuote 是短期结果,不是订单。它包含 priced_at 和 expires_at,还包含命中的规则 ID 和版本。页面用它展示金额,购物车用它做初始计算。提交订单时结算域一律重新询价,过期检查和摘要比较只是快速失败的前置步骤,不替代重新计算;同一时刻还要按第 01 篇的做法核对商品修订号。
报价过期不代表一定涨价。可能是商家发布了更低的价格,也可能是会员身份变化、地区变化或 SKU 停用。接口可以返回 QUOTE_EXPIRED、PRICE_CHANGED、SKU_UNAVAILABLE 等可恢复错误,让前端展示新金额和差异原因。不要把旧的金额作为创建订单的参数传回来;服务端重新计算并把最终值写入订单事务。
订单项至少保存 SKU ID、商品快照、数量、成交金额、币种、价格规则 ID、价格版本、价格计算的上下文摘要和成交时刻。商品标题变更不会改变旧订单,价格规则撤回也不会让财务记录失去解释依据。规则原文是否要完整复制,要在合规、隐私和存储成本之间选择;引用不可变版本通常足够,但版本归档策略必须明确。
订单域 价格域 结算域 买家端 订单域 价格域 结算域 买家端 #mermaid-svg-sz3kit7rl8w6OTHq{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-sz3kit7rl8w6OTHq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sz3kit7rl8w6OTHq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sz3kit7rl8w6OTHq .error-icon{fill:#552222;}#mermaid-svg-sz3kit7rl8w6OTHq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sz3kit7rl8w6OTHq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sz3kit7rl8w6OTHq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sz3kit7rl8w6OTHq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sz3kit7rl8w6OTHq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sz3kit7rl8w6OTHq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sz3kit7rl8w6OTHq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sz3kit7rl8w6OTHq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sz3kit7rl8w6OTHq .marker.cross{stroke:#333333;}#mermaid-svg-sz3kit7rl8w6OTHq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sz3kit7rl8w6OTHq p{margin:0;}#mermaid-svg-sz3kit7rl8w6OTHq .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sz3kit7rl8w6OTHq text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-sz3kit7rl8w6OTHq .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sz3kit7rl8w6OTHq .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-sz3kit7rl8w6OTHq .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-sz3kit7rl8w6OTHq .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-sz3kit7rl8w6OTHq #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-sz3kit7rl8w6OTHq .sequenceNumber{fill:white;}#mermaid-svg-sz3kit7rl8w6OTHq #sequencenumber{fill:#333;}#mermaid-svg-sz3kit7rl8w6OTHq #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-sz3kit7rl8w6OTHq .messageText{fill:#333;stroke:none;}#mermaid-svg-sz3kit7rl8w6OTHq .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sz3kit7rl8w6OTHq .labelText,#mermaid-svg-sz3kit7rl8w6OTHq .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-sz3kit7rl8w6OTHq .loopText,#mermaid-svg-sz3kit7rl8w6OTHq .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-sz3kit7rl8w6OTHq .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-sz3kit7rl8w6OTHq .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-sz3kit7rl8w6OTHq .noteText,#mermaid-svg-sz3kit7rl8w6OTHq .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-sz3kit7rl8w6OTHq .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sz3kit7rl8w6OTHq .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sz3kit7rl8w6OTHq .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sz3kit7rl8w6OTHq .actorPopupMenu{position:absolute;}#mermaid-svg-sz3kit7rl8w6OTHq .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-sz3kit7rl8w6OTHq .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sz3kit7rl8w6OTHq .actor-man circle,#mermaid-svg-sz3kit7rl8w6OTHq line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-sz3kit7rl8w6OTHq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 请求 SKU 报价(渠道、会员、币种、priced_at) Quote(PriceContext, rules, ttl) PriceQuote(金额、规则 ID、版本、expires_at) 页面与购物车展示报价 提交 checkout_token 按提交时刻重新 Quote 新报价或价格变更错误 写入 order item 成交金额与价格快照 order_id 订单创建结果
时序图里的两次 Quote 不是重复劳动:第一次服务于展示,第二次服务于交易边界。订单域不直接查询当前价表来补字段,而是接收结算域已经验证的报价快照。后续退款按订单中的成交金额处理,不能把今日价格重新套在历史订单上。
Go 示例
下面的实现只做单 SKU 规则选择,不带数据库、缓存和 HTTP 适配。读的时候盯住三处:applicable 的时间边界、specificity 的上下文计数、排序器的四个键。PriceRule 是一条规则当前生效版本的展开,对应上面 ER 图里 PRICE_RULE 加 PRICE_RULE_VERSION 的一行。
go
// Package pricing demonstrates a deterministic, single-SKU price quote.
package pricing
import (
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
)
var (
ErrNoPrice = errors.New("no applicable price")
ErrCurrencyMismatch = errors.New("currency mismatch")
)
// Money stores an amount in the smallest unit of Currency. Minor is never a
// decimal value; the currency metadata and payment provider define its scale.
type Money struct {
Currency string
Minor int64
}
func (m Money) Add(other Money) (Money, error) {
if !sameCurrency(m.Currency, other.Currency) {
return Money{}, ErrCurrencyMismatch
}
if (other.Minor > 0 && m.Minor > math.MaxInt64-other.Minor) ||
(other.Minor < 0 && m.Minor < math.MinInt64-other.Minor) {
return Money{}, errors.New("money addition overflow")
}
return Money{Currency: normalizeCurrency(m.Currency), Minor: m.Minor + other.Minor}, nil
}
// Multiply scales a unit price by a positive quantity. Refunds use the
// recorded order amounts, so negative quantities are not a pricing concern.
func (m Money) Multiply(quantity int64) (Money, error) {
if quantity <= 0 {
return Money{}, errors.New("quantity must be positive")
}
if m.Minor > math.MaxInt64/quantity || m.Minor < math.MinInt64/quantity {
return Money{}, errors.New("money multiplication overflow")
}
return Money{Currency: normalizeCurrency(m.Currency), Minor: m.Minor * quantity}, nil
}
type RuleStatus string
const (
RulePublished RuleStatus = "published"
RuleWithdrawn RuleStatus = "withdrawn"
)
// PriceContext is the complete input used to resolve one SKU quote. PricedAt
// is explicit so a retry or checkout re-quote can use the same instant.
type PriceContext struct {
MerchantID string
SKU string
Currency string
Channel string
Region string
MemberTier string
Quantity int64
PricedAt time.Time
}
// PriceRule is the expanded current version of one maintained candidate.
// Empty scope fields mean that the rule applies to every value in that
// dimension. Time windows are UTC and follow [StartsAt, EndsAt).
type PriceRule struct {
ID string
MerchantID string
Version int64
Status RuleStatus
SKU string
Currency string
Channel string
Region string
MemberTier string
MinQuantity int64
MaxQuantity int64
Priority int
Amount Money
StartsAt *time.Time
EndsAt *time.Time
}
// PriceQuote is an immutable result suitable for passing to checkout. RuleIDs
// and Version preserve the exact maintained data used by the result. The
// request digest is computed at the API layer and is omitted here.
type PriceQuote struct {
SKU string
Price Money
RuleIDs []string
Version int64
PricedAt time.Time
ExpiresAt time.Time
}
// Quote selects one applicable rule using a stable ordering:
// priority, number of constrained dimensions, version, then rule ID.
func Quote(ctx PriceContext, rules []PriceRule, ttl time.Duration) (PriceQuote, error) {
if strings.TrimSpace(ctx.MerchantID) == "" {
return PriceQuote{}, errors.New("merchant_id is required")
}
if strings.TrimSpace(ctx.SKU) == "" {
return PriceQuote{}, errors.New("sku is required")
}
if strings.TrimSpace(ctx.Currency) == "" {
return PriceQuote{}, errors.New("currency is required")
}
if ctx.Quantity < 0 {
return PriceQuote{}, errors.New("quantity cannot be negative")
}
if ctx.PricedAt.IsZero() {
return PriceQuote{}, errors.New("priced_at is required")
}
pricedAt := ctx.PricedAt.UTC()
currency := normalizeCurrency(ctx.Currency)
candidates := make([]PriceRule, 0, len(rules))
for _, rule := range rules {
if applicable(ctx, currency, pricedAt, rule) {
rule.Currency = currency
rule.Amount.Currency = currency
candidates = append(candidates, rule)
}
}
if len(candidates) == 0 {
return PriceQuote{}, ErrNoPrice
}
sort.SliceStable(candidates, func(i, j int) bool {
left, right := candidates[i], candidates[j]
if left.Priority != right.Priority {
return left.Priority > right.Priority
}
if specificity(left) != specificity(right) {
return specificity(left) > specificity(right)
}
if left.Version != right.Version {
return left.Version > right.Version
}
return left.ID < right.ID
})
selected := candidates[0]
return PriceQuote{
SKU: ctx.SKU,
Price: selected.Amount,
RuleIDs: []string{selected.ID},
Version: selected.Version,
PricedAt: pricedAt,
ExpiresAt: pricedAt.Add(ttl),
}, nil
}
func applicable(ctx PriceContext, currency string, at time.Time, rule PriceRule) bool {
if rule.MerchantID != ctx.MerchantID || rule.SKU != ctx.SKU {
return false
}
if rule.Status != RulePublished {
return false
}
if normalizeCurrency(rule.Currency) != currency ||
normalizeCurrency(rule.Amount.Currency) != currency {
return false
}
if rule.Channel != "" && rule.Channel != ctx.Channel ||
rule.Region != "" && rule.Region != ctx.Region ||
rule.MemberTier != "" && rule.MemberTier != ctx.MemberTier {
return false
}
if rule.MinQuantity > 0 && ctx.Quantity < rule.MinQuantity ||
rule.MaxQuantity > 0 && ctx.Quantity > rule.MaxQuantity {
return false
}
if rule.StartsAt != nil && at.Before(rule.StartsAt.UTC()) ||
rule.EndsAt != nil && !at.Before(rule.EndsAt.UTC()) {
return false
}
return true
}
func specificity(rule PriceRule) int {
count := 0
if rule.Channel != "" {
count++
}
if rule.Region != "" {
count++
}
if rule.MemberTier != "" {
count++
}
if rule.MinQuantity > 0 || rule.MaxQuantity > 0 {
count++
}
if rule.StartsAt != nil || rule.EndsAt != nil {
count++
}
return count
}
func normalizeCurrency(currency string) string { return strings.ToUpper(strings.TrimSpace(currency)) }
func sameCurrency(left, right string) bool {
return normalizeCurrency(left) == normalizeCurrency(right)
}
Quote 先复制候选,再排序,调用方传入的规则切片不会被重排。RuleIDs 用切片承载来源,即使当前示例只选一条,后续扩展为基础价加独立税规则也不需要改变结果形状。TTL 为零用于测试和即时结果;生产服务应传入小于业务允许等待时间的值。
生产实现还要在存储入口做一次规范化:currency 转成大写,时间转换为 UTC,空字符串与缺失值用同一语义;(merchant_id, rule_id, version) 在数据库中建唯一约束。示例配套的测试覆盖了这些场景:半开区间两端各一条、通配与精确条件并存、同优先级下特异性决胜、同规则两个版本、撤回版本不被选中、priced_at 缺失和币种不一致的拒绝路径、Multiply 的溢出边界。
价格中心的技术结构
把上面几节放到一张图里,价格中心也是四层。接口层的四个入口各管一类调用方:定价 Admin API 给商家和运营,带商家范围和版本校验;批量报价 API 给买家端和结算域;参考价查询单独一个入口,因为它的合规约束和缓存策略都和成交价不同;发布接口负责把一批规则版本变成一次 release_seq。核心层四块:规则与版本是维护侧,询价链是读侧,过滤、排序、签发三步在一条链上;Money 与参考价合成一块,因为币种指数和划线价都是「金额怎么解释」的元数据;事件发布单独成块,PriceReleased 和规则版本写入同一事务,展示缓存和搜索的价格筛选项靠它失效。
#mermaid-svg-5ebM3Sl1KOzn7yRN{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-5ebM3Sl1KOzn7yRN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5ebM3Sl1KOzn7yRN .error-icon{fill:#552222;}#mermaid-svg-5ebM3Sl1KOzn7yRN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5ebM3Sl1KOzn7yRN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .marker.cross{stroke:#333333;}#mermaid-svg-5ebM3Sl1KOzn7yRN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5ebM3Sl1KOzn7yRN p{margin:0;}#mermaid-svg-5ebM3Sl1KOzn7yRN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .cluster-label text{fill:#333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .cluster-label span{color:#333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .cluster-label span p{background-color:transparent;}#mermaid-svg-5ebM3Sl1KOzn7yRN .label text,#mermaid-svg-5ebM3Sl1KOzn7yRN span{fill:#333;color:#333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .node rect,#mermaid-svg-5ebM3Sl1KOzn7yRN .node circle,#mermaid-svg-5ebM3Sl1KOzn7yRN .node ellipse,#mermaid-svg-5ebM3Sl1KOzn7yRN .node polygon,#mermaid-svg-5ebM3Sl1KOzn7yRN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5ebM3Sl1KOzn7yRN .rough-node .label text,#mermaid-svg-5ebM3Sl1KOzn7yRN .node .label text,#mermaid-svg-5ebM3Sl1KOzn7yRN .image-shape .label,#mermaid-svg-5ebM3Sl1KOzn7yRN .icon-shape .label{text-anchor:middle;}#mermaid-svg-5ebM3Sl1KOzn7yRN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5ebM3Sl1KOzn7yRN .rough-node .label,#mermaid-svg-5ebM3Sl1KOzn7yRN .node .label,#mermaid-svg-5ebM3Sl1KOzn7yRN .image-shape .label,#mermaid-svg-5ebM3Sl1KOzn7yRN .icon-shape .label{text-align:center;}#mermaid-svg-5ebM3Sl1KOzn7yRN .node.clickable{cursor:pointer;}#mermaid-svg-5ebM3Sl1KOzn7yRN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .arrowheadPath{fill:#333333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5ebM3Sl1KOzn7yRN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5ebM3Sl1KOzn7yRN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5ebM3Sl1KOzn7yRN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5ebM3Sl1KOzn7yRN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5ebM3Sl1KOzn7yRN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5ebM3Sl1KOzn7yRN .cluster text{fill:#333;}#mermaid-svg-5ebM3Sl1KOzn7yRN .cluster span{color:#333;}#mermaid-svg-5ebM3Sl1KOzn7yRN 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-5ebM3Sl1KOzn7yRN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5ebM3Sl1KOzn7yRN rect.text{fill:none;stroke-width:0;}#mermaid-svg-5ebM3Sl1KOzn7yRN .icon-shape,#mermaid-svg-5ebM3Sl1KOzn7yRN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5ebM3Sl1KOzn7yRN .icon-shape p,#mermaid-svg-5ebM3Sl1KOzn7yRN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5ebM3Sl1KOzn7yRN .icon-shape .label rect,#mermaid-svg-5ebM3Sl1KOzn7yRN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5ebM3Sl1KOzn7yRN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5ebM3Sl1KOzn7yRN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5ebM3Sl1KOzn7yRN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 存储
价格域核心:候选、选择与版本
接口层
接入方
商家后台
维护规则与发布
运营后台
维度开关与参考价审核
买家端
详情与购物车展示
结算域
提交时重新询价
定价 Admin API
商家范围与版本校验
发布接口
生成 release_seq
参考价查询
划线价与记录时刻
批量报价 API
SKU 列表 + 统一上下文
规则与版本
范围、半开时间窗、不可变版本、撤回
事件发布
PriceReleased 同事务
Money 与参考价
amount_minor、币种指数、划线价来源
询价链
候选过滤 → 排序选择 → 报价签发
价格主库
规则、版本、发布、参考价
币种元数据
指数与支付通道差异
报价缓存
全维度键 + release_seq
图里没有促销域、库存域和订单域。促销域消费价格域的单价输出,库存域和价格域互不调用,订单域只从结算域拿快照,这三条关系在第 00 篇的时序图里已经画过。
接口契约与降级
商品详情可以提供 POST /v1/price-quotes,请求含 SKU 列表和统一上下文,响应按 SKU 返回金额、币种、命中规则 ID、版本、priced_at、expires_at 和摘要。批量接口要保持输入顺序或返回明确的 SKU 键,不能让数组位置成为唯一关联方式。详情页还有一种特殊询价:买家没选完规格时,页面要展示价格区间。做法是按 SPU 下全部在售且启用的 SKU 的报价取最小和最大值,展示成「99 元起」,规格选定的那一刻再换成确定报价;它只是批量报价接口的一种用法,不值得单开接口。购物车试算返回每一行的单价和数量乘积,整单服务再叠加运费、税和促销结果。
请求和响应保持机器可读,金额不放带货币符号的字符串。请求体字段与 Go 的 PriceContext 一一对应,priced_at 由调用方显式传入;响应字段与 PriceQuote 对应,多出一个接口层计算的 digest。下面是单个 SKU 询价的接口形状示意,不代表某个开源项目的原生 API。
请求:
json
{
"merchant_id": "m-1024",
"sku": "sku-tshirt-blue-m",
"currency": "CNY",
"channel": "app",
"region": "CN-GD",
"member_tier": "gold",
"quantity": 1,
"priced_at": "2026-09-01T12:00:00Z"
}
响应:
json
{
"sku": "sku-tshirt-blue-m",
"price": {"currency": "CNY", "amount_minor": 8900},
"rule_ids": ["member-gold-app"],
"price_version": 3,
"priced_at": "2026-09-01T12:00:00Z",
"expires_at": "2026-09-01T12:05:00Z",
"digest": "sha256:3f9a..."
}
调用方拿到这个响应后,展示层依据货币指数格式化金额,结算域继续使用整数。不要让一个展示字符串在多个服务之间往返;CNY 89.00 是给人看的格式,currency=CNY, amount_minor=8900 才是跨服务交互应该遵守的约定。
digest 适合做提交时的快速检查。输入包括商家、SKU、数量、渠道、地区、会员层级、币种、priced_at、规则 ID、版本和最终金额;服务端根据当前购物车重新生成并比较。摘要相同只说明输入与结果一致,不提供长期锁价保证,因此仍要检查 expires_at 和规则撤回状态,之后照样重新询价。需要锁价的业务应创建具名报价单并记录锁价期限,不能把普通详情页报价当作锁价单。
提交 checkout 时,服务端用 checkout_token 取回购物车内容和上下文,再向价格域重新询价。若价格域返回新报价,结算返回差异明细,等待买家确认;若 SKU 没有可用价格、币种不支持或规则版本撤回,则拒绝订单创建。价格失败不应使用另一币种的价,也不应把缓存中的旧价直接用于支付。
展示降级和成交降级要分开。价格服务短暂超时,详情页可以展示带时间戳的缓存报价,但要标注它可能过期;下单必须重新询价。是否允许在服务不可用时继续下单属于风险策略,低价商品、闪购和受监管市场通常需要更严格的拒绝规则。
缓存键至少包含商家、SKU、渠道、地区、会员层级、币种和 release_seq。前六项缺一个就可能把别人的价展示出来;release_seq 是该商家最近一次发布的序号,由 PriceReleased 事件推给缓存层,发布一次序号加一,旧键自然失效。不能拿规则版本做缓存键,版本是询价的输出,查缓存的时候还不知道。
小结
回到开头那张「价格已更新」的提示。它出现的原因,要么是三处页面用了不同的询价输入,要么是报价过期后规则发布了新版本。价格域能做的是把候选规则、选择顺序和版本都固定下来,让每一次报价都能回答「在什么条件下、对谁、在什么时间、以什么币种卖多少钱」;结算域在提交时重新问一次,订单把答案连同规则 ID 和版本一起存下来。下一篇讲优惠:整单优惠怎么分摊到行上,尾差怎么处理,平台补贴和商家让利怎么记出资方。
参考资料
- Medusa Pricing Module:价格维护、多币种、地区、规则、价目表和上下文计算能力。
- Medusa Prices Calculation:
calculatePrices、匹配上下文、默认/精确/部分匹配和阶梯价。 - Spree Pricing:基础
Price、compare_at_amount、Price List 的位置、状态、日期和规则匹配(Price List 自 Spree 5.3 起)。 - Saleor ProductVariantChannelListing:渠道维度的
price、prior_price、discounted_price与促销规则关系。 - Saleor Historical Data Preservation:目录变更或删除后订单保留历史商品信息的说明。
- Adyen Currency codes and minor units:支付 API 的 minor units、币种小数位和通道差异。