电商价格是怎样算出来的

商品详情页显示 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 和版本一起存下来。下一篇讲优惠:整单优惠怎么分摊到行上,尾差怎么处理,平台补贴和商家让利怎么记出资方。

参考资料

相关推荐
小蒜学长1 小时前
基于Python的动物救助站管理系统的设计与实现(代码+数据库+LW)
数据库·后端·python·django·动物救助
weixin_404551242 小时前
自动化数据库理解:用 AI 与 LLM 自动推断表的用途与关系
数据库·人工智能·自动化
java1234_小锋2 小时前
【技术专题】Mysql8 数据库 - Mysql8 简介 & 安装以及配置
数据库·mysql
oradh2 小时前
Oracle固定执行计划的方法----SQL Profie(三)
数据库·sql·oracle
测试学习test2 小时前
SPI读取W25Q64硬件驱动(HAL库)
数据库·stm32·嵌入式硬件·算法
做运维的阿瑞3 小时前
mysql数据库分组查询:GROUP BY 与 HAVING 的执行逻辑
数据库·sql·mysql
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2026-10-01
数据库·人工智能·深度学习·神经网络·搜索引擎
阳光九叶草LXGZXJ3 小时前
达梦数据库-学习-67-SSL加密认证
linux·运维·数据库·sql·学习·ssl
ha_lydms3 小时前
MaxCompute中JSON函数
大数据·数据库·阿里云·json·dataworks·maxcompute·odps