不少商城项目一开始盯着页面:商品详情怎么展示,购物车放在哪,下单按钮用什么颜色。真正决定系统以后会不会乱的,却是页面背后的几本账有没有分开。SKU 是“卖什么”的账,购物车记的是“用户暂时想买什么”,订单保存“这次交易谈妥了什么”,支付记录“钱到底怎么走”。四个对象混在一起,业务刚上线可能看不出问题,等优惠、退款、多仓、秒杀和财务对账接进来,旧账很快就对不上新规则。
先别急着画页面,先把四本账分清
一个顾客把黑色、256GB 的手机放进购物车,半小时后提交订单,再用微信支付。看起来是一条顺着走到底的流程,系统里其实发生了四件性质不同的事。
SKU 确定他买的是哪一个可销售、可计价、可管库存的具体规格;购物车只保存他的选择,商品价格和库存仍可能变化;订单在提交那一刻留下商品、规格、成交价、优惠、运费和收货信息的快照;支付则要记录向哪个渠道发起过几次支付、每次结果如何、渠道返回了什么编号,后来有没有退款。
这四件事之间有关联,但不能共用一条记录,更不能靠一个 status 从头管到尾。订单已经取消,支付渠道仍可能晚到一笔成功通知;订单已付款,仓库也可能还没发货;退款申请已经提交,钱却还在渠道处理中。把这些状态压成“待处理、已完成、已关闭”,客服查不清,财务也无法对账。

商品选择、交易确认和资金流转彼此衔接,但各自保留独立记录。
SKU 要落到能卖、能算、能发货的那一件
商品页上看到的“基础款手机”可以当作一个商品主体,也就是很多团队习惯说的 SPU。颜色、容量组合之后,黑色 256GB、白色 512GB 才是实际出售的 SKU。价格、库存、重量、条码、上下架状态,通常都落在 SKU 上,而不是只挂在商品主体上。
这里有一个很实用的判断:只要两个规格在售价、库存、采购、发货或退换货上需要分开处理,就不该共用同一个 SKU。反过来,如果某个属性只用于页面介绍,不影响这些动作,就没有必要把它做成库存维度。把“材质说明”“适用人群”一股脑塞进规格组合,最后很容易生成大量永远不会卖的 SKU。
SKU 编码应当稳定、唯一。商品改名、换主图、调整类目,不应顺便把 SKU 编码也换掉。已经进过订单的 SKU 也不宜物理删除,停用即可;历史订单还需要靠它查库存流水、售后和财务记录。
库存也别只留一个数字。至少要分清实物在库、可售、已占用和已售出的关系。Shopify 目前公开的库存模型就把 on_hand、available、committed、reserved 等数量状态分开管理。业务名称可以不同,关键是系统要能回答:仓库里实际有多少,眼下还能卖多少,哪些已经被订单占住,取消订单后该放回多少。

同一商品的不同规格对应独立 SKU,价格和库存才能各自核算。
购物车记选择,不替订单做决定
购物车可以保存用户选中的 SKU、数量、是否勾选,以及必要的加购来源。为了让页面打开得快,也可以缓存商品名、图片和当时看到的价格,但这些都只是展示信息,不是成交依据。
真正进入结算时,服务端需要重新检查一遍:SKU 是否仍在售,购买数量是否超过限购,库存是否足够,当前价格是多少,优惠券还能不能用,活动是否结束,收货地址能否配送,运费怎么算。前端传回来的总价只能作为核对信息,不能直接写进订单。否则改一个请求参数,就可能用一元钱买走原价商品。
购物车里的价格也不适合永久锁定。用户昨天加购,今天商品调价,通常应以结算页重新计算的价格为准;如果业务承诺保价,就把保价期限和价格来源单独写成规则,别让“购物车价格”暗中承担这份责任。
游客购物车与登录账户合并,也要先定规则。相同 SKU 是合并数量还是保留两行,超过限购怎样提示,失效商品是否继续展示,选中状态以哪一端为准,这些看起来细小,却会直接影响结算结果。没有统一规则,网页、App 和小程序很容易各算各的。
下单这一刻,要把将来可能争议的内容留下来
订单不是商品表的一个引用集合。用户付款后,商品可以改名、调价、换图,优惠活动也会结束,但历史订单不能跟着变。因此,订单明细除了保存 SKU ID,还要保存下单时的商品名、规格文本、主图、单价、购买数量、优惠分摊、税费和应付金额。收货人、地址、配送方式、发票信息同样需要形成订单快照。
金额建议使用最小货币单位的整数保存,例如人民币用“分”,不要用浮点数计算。订单金额也要能逐项算回去:商品小计减去商品优惠和订单优惠,再加运费、税费或其他明确费用,得到应付金额。优惠如果涉及多个商品,提交订单时就确定分摊结果;后来部分退款时,系统才知道每件商品最多能退多少。
一张订单至少要把支付、履约和售后分开看。比较稳妥的做法是分别维护订单状态、支付状态、发货/履约状态和售后状态。主订单可以给用户一个容易理解的展示状态,但底层不能丢掉这些真实状态。付款成功不等于交易完成,发货完成也不等于售后结束。
库存在哪一步占用,要看商品特点。普通现货商城常见的做法是提交订单时占用可售库存,给用户一段支付时间;支付成功后转为已售,超时未支付或取消订单则释放。高并发抢购还需要在库存更新时做原子扣减或条件更新,不能先查“还有一件”,再让两个请求各自扣一次。预售、到店自提、虚拟商品和按需生产的规则不同,但都应该把“占用、确认、释放”写成明确动作,而不是散落在订单代码里。

提交订单先确认价格并占用库存,支付或超时结果再决定确认与释放。
订单号和支付单号不是一回事
业务订单回答的是“买了什么”,支付单回答的是“这笔钱怎样付”。两者通常是一对多:同一张订单第一次支付失败,用户换一个渠道再付,会产生新的支付尝试;订单做部分退款,还会继续产生退款记录。把支付字段直接塞进订单主表,只能应付最简单的一次支付,后面很难解释重复支付和多次退款。Stripe 的 Payment Intents 文档也把支付定义成一个从创建、结账到完成、状态持续变化的独立生命周期。
每次支付至少需要自己的支付单号、关联订单号、支付渠道、发起金额、渠道订单号、当前状态、发起时间、成功时间和原始通知编号。支付金额必须由服务端从订单读取,前端不能决定。业务订单号、商户支付单号和渠道交易号也不要互相替代。
微信支付商户文档明确区分了商户订单号 out_trade_no 和微信支付订单号 transaction_id,查询结果还会返回 SUCCESS、NOTPAY、CLOSED、REFUND 等交易状态。这正说明业务系统不能只存一个“已支付”布尔值。至少要把自己的支付记录和渠道侧状态都留下,出现差错时才能顺着编号查到源头。
支付结果也不能以前端跳回“支付成功页”为准。用户可能付完钱就关掉页面,也可能伪造跳转参数。可靠做法是接收支付渠道的服务端通知,验签、解密、核对商户号、支付单号和金额,再以幂等方式更新支付单和订单。微信支付当前文档要求商户对回调验签,并在解密后的资源中读取交易状态和渠道订单号;对超时未支付订单,商户还需要执行关单。
所谓幂等,说人话就是同一条成功通知来两次,系统只能认一次。支付平台会重发通知,网络超时也会让调用方重复请求。Stripe 的公开文档建议用唯一幂等键识别重试,并明确提醒 Webhook 可能重复送达,事件顺序也不保证与产生顺序一致。所以,回调处理不能写成“收到成功就加一次余额、扣一次库存”,而应当核对当前状态,只允许合法的一次状态变化。
还要提前处理一个麻烦场景:本地订单已经超时关闭,支付成功通知却到了。系统不能把它静默丢掉。先向渠道查询确认真实支付结果,再按业务规则恢复订单、原路退款或转人工处理,并留下异常记录。资金问题最怕的不是有异常,而是发生过却查不到。

支付通知先验签和核对,再更新本地状态;日终账单负责发现漏单与金额差异。
一条能落地的交易流程
把前面的关系放回真实操作,一次普通现货购买可以这样走:
- 用户选择具体 SKU 加入购物车。购物车保存选择和数量,不承诺最终价格与库存。
- 进入结算时,服务端重新计算商品价、优惠、运费和应付金额,同时检查库存、限购和配送条件。
- 用户提交订单。系统生成订单与订单明细快照,以唯一请求号防止重复下单,并占用库存。
- 系统创建一笔支付记录,用该记录的商户支付单号向支付渠道下单;金额来自订单,不接收前端改价。
- 渠道通知支付结果。系统验签、解密并核对金额,以幂等方式把支付单改为成功,再推动订单进入已支付状态。
- 仓储或履约系统根据已支付订单发货。订单超时未付则关闭支付渠道订单,并释放库存。
- 每天用渠道交易账单与本地支付、退款记录对账。发现渠道成功而本地未成功、金额不一致或退款状态不一致时,进入差错处理。
这里特意把“支付成功”和“发货”拆开,是因为支付回调应该尽快确认,拣货、发短信、发积分这些耗时动作更适合通过消息或任务异步处理。回调接口先把钱的事实记准,再让后续流程各自完成;某个下游服务暂时故障,不该让支付渠道以为商户没有收到通知。
开发到什么程度,取决于业务,不取决于功能清单
只有少量现货、单仓、一次性付款的商城,第一版不必照搬大型平台的全部复杂度,但底线不能省:SKU 唯一、订单快照、服务端计价、库存占用和释放、支付记录独立、回调验签与幂等、退款记录、交易对账。这些不是“大厂设计”,而是钱货一致所需的基本条件。
准备做多商户、多仓、拆单发货、跨境币种、组合商品、储值余额或订阅扣款时,方案会明显变化。多商户要考虑结算主体和分账,多仓要确定库存分配与履约拆单,跨境交易要处理币种、税费和汇率,组合商品则要说明库存扣的是套装还是组成件。需求评审时不把这些边界说清,技术团队只能按最简单的情况开发,后面每加一种业务都要碰订单核心。
红数科技在梳理商城交易方案时,通常会先让业务方确认几件事:商品最小销售单位是什么,什么时候承诺价格,什么时候占用库存,一张订单允许几次支付,部分退款怎么分摊,渠道账与业务账不一致时由谁处理。答案不一定复杂,但必须明确。数据库和接口怎么设计,应当跟着这些答案走。
几个常见问题
SKU 和 SPU 到底有什么区别?
SPU 更接近前台展示和归类的一组商品,SKU 是实际售卖、计价和管理库存的具体规格。是否需要拆成不同 SKU,不看属性名称有多少,而看价格、库存、采购、发货或售后是否需要分开。
购物车里的价格要不要锁定?
一般不锁。购物车保存用户选择,结算时按当前有效规则重新计价。确有保价承诺时,再单独保存保价金额、适用期限和来源,避免与普通加购价格混在一起。
库存应该下单扣,还是付款后扣?
普通现货更常见的是下单占用、付款确认、超时释放,这样能减少用户付了钱却没货的情况。若商品供应充足、允许超卖或属于预售,也可以付款后处理。关键不是选一个行业口号,而是明确每次状态变化对应哪一次库存动作。
为什么订单状态和支付状态不能合成一个?
因为钱和货并不同步。待支付订单可以关闭,已支付订单可能待发货,退款中的订单可能已经签收。拆开状态后,系统才能准确回答顾客、客服、仓库和财务各自的问题。
明明已经付款,订单为什么还显示未支付?
常见原因包括回调未到、验签或解密失败、金额核对不通过、重复通知被错误处理,或者本地更新失败。处理时应先用商户订单号或渠道交易号主动查询真实结果,再补偿本地状态;不能让用户重复付款来碰运气。
参考资料
文中公开资料截至 2026 年 7 月 22 日。
[1] Shopify Developers, Manage inventory quantities and states
[2] 微信支付商户文档中心,商户订单号查询订单,页面标注更新时间 2024-12-27
[3] 微信支付商户文档中心,支付成功回调通知
[4] 微信支付商户文档中心,关闭订单,页面标注更新时间 2024-12-11
[5] Stripe Documentation, The Payment Intents API
[6] Stripe Documentation, Idempotent requests
[7] Stripe Documentation, Receive Stripe events in your webhook endpoint