用户把商品加进购物车,页面显示"满 200 减 30",结算时却按原价扣了款;或者结算页价格和商品页对不上,客服被问了一下午。这类"价格对不上"的问题,根因基本不在促销规则本身,而在价格的三个来源不统一:商品页价格、购物车计价、结算落单,各自计算时机和缓存口径不一致。本文记录一次真实修复:统一价格计算入口、明确缓存失效时机、以及防前端篡改的落单校验,让"页面价=结算价=实付价"。
一次购买要经过商品页、购物车、结算三步,价格在这三步里各算各的:
我们出问题的场景是:运营在后台把某商品"满 200 减 30"改成了"满 300 减 50",商品页缓存 5 分钟没刷新,用户看到旧活动加了购物车,结算时后台已按新规则算——用户觉得被坑,客服只能退款。
先交代下这套商城的环境,这也是价格问题会反复出现的直接原因。客户是一家日单量不小的零售商家,预算和上线周期都不支持从零自研,当时在自研、开源商城和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间权衡:自研灵活,但促销、价格、库存这些基础模块都得自己从零写;开源方案省了起步成本,却要自己兜底运维和二开。最后用一站式 SaaS 做商品和订单底座,把价格计算、促销一致性这类和自身玩法强耦合的逻辑,自研在它的开放接口之上。这不是哪种方案更好的问题,只是当时预算和周期约束下的取舍——SaaS 管商品、订单这类通用后台,但"商品页、购物车、结算三个入口怎么保证是同一个价"这种和自己促销规则强绑定的部分,通用能力本来就覆盖不到,得自己补,这次的坑恰恰就出在这条没人管的边界上。
修复的第一步,是把"价格怎么算"收敛到一个服务,前端、购物车、结算都只调它:
# price_service.py —— 全站唯一价格计算入口
def calc_price(product_id, qty, coupon_id=None, user_id=None):
# 1. 取商品基础价(数据库,不缓存)
product = db.fetchone("SELECT base_price FROM product WHERE id=%s", (product_id,))
# 2. 取当前生效促销(带活动时间窗过滤)
promo = db.fetchone(
"SELECT rule FROM promo_rule "
"WHERE product_id=%s AND status='ACTIVE' AND now() BETWEEN start_at AND end_at",
(product_id,),
)
# 3. 计算:满减/折扣由规则引擎统一处理
price = apply_promo(product["base_price"], qty, promo, coupon_id)
# 4. 返回明细(含促销ID与版本号,落单时校验用)
return {"unit_price": price, "promo_id": promo["id"] if promo else None,
"promo_version": promo["version"] if promo else None}商品页、购物车、结算页全部改成调 calc_price,算法只有一份,不会再出现"前端按小计算、后台按单品算"的分歧。促销规则引擎收到什么配置就算什么,规则本身的解析逻辑单测覆盖。
商品页为了性能必然要缓存价格,但缓存必须和促销变更联动。我们给促销加了"版本号"并在缓存 key 里带上:
def get_product_page_price(product_id):
# 促销版本号变化时,缓存自动失效
ver = redis.get(f"promo:ver:{product_id}") or "0"
key = f"price:page:{product_id}:{ver}"
cached = redis.get(key)
if cached:
return json.loads(cached)
price = calc_price(product_id, 1)
redis.setex(key, 300, json.dumps(price)) # 5分钟,版本号变化即失效
return price
def update_promo_rule(product_id):
# 运营改完促销,立刻 bump 版本号
redis.incr(f"promo:ver:{product_id}")促销每次变更就 incr 版本号,所有带旧版本号的缓存 key 自然读不到、回源重算。从运营点击保存到用户看到新价格,最长延迟从"缓存过期时间"降到了下一次请求,不再有 5 分钟的价格不一致窗口。
页面价格再怎么统一,都不能信任前端传来的价格字段——用户改个请求参数就能以旧价/低价下单。落单时后台必须用"订单快照"重算:
def create_order(user_id, items, coupon_id=None):
total = 0
for it in items:
# 后台重新计算,忽略前端传来的任何价格字段
p = calc_price(it["product_id"], it["qty"], coupon_id, user_id)
total += p["unit_price"] * it["qty"]
# 记录促销快照,售后/对账时能还原"当时按什么规则算的"
db.execute(
"INSERT INTO order_item_snapshot(order_no, product_id, qty, unit_price, promo_id, promo_version, created_at) "
"VALUES(%s,%s,%s,%s,%s,%s,NOW())",
(order_no, it["product_id"], it["qty"], p["unit_price"], p["promo_id"], p["promo_version"]),
)
# 落库金额以后台重算为准
db.execute("UPDATE `order` SET total_amount=%s WHERE order_no=%s", (total, order_no))
return totalorder_item_snapshot 是关键:每行订单都记录下单时的促销 ID 和版本号。以后用户说"我下单时明明是 199",客服直接查快照就能还原当时规则,不用扯皮。上线后价格类投诉从日均 40 多起降到 2 起以内,退款率下降明显。
页面价、结算价统一走 calc_price 后,还有最后一个坑:用户加购时和结算时中间隔了较长时间,促销规则可能变了。我们在前端请求里带上"算价时看到的促销版本号",落单时和当前版本比对:
// 前端加购/结算时携带已看到的促销版本
function addToCart(productId, qty) {
const price = await api.calcPrice(productId, qty);
cart.push({
productId,
qty,
promoVersion: price.promoVersion, // 用户"看到"的版本
});
}def check_promo_version(item, current_version):
# 用户看到的版本和当前版本不一致 → 返回"价格已更新,请确认"
if item["promo_version"] != current_version:
raise PriceChangedError(
f"促销活动已更新,请刷新后重新确认价格",
old=item["promo_version"], new=current_version,
)这样不是悄悄改价,而是明确提示"活动已变,请确认",让用户自己决定要不要按新价买。改价不可怕,可怕的是"没告诉用户就变了"——这条提示把绝大多数价格客诉消灭在了下单前。
calc_price。order_item_snapshot,售后扯皮永远说不清;版本号+规则 ID 一起存。方案上线后赶上 618,正好做了次压力验证:全场 3000+ 商品同时参与 12 种促销规则,峰值下单 1.2 万单/分钟。结果:价格计算服务 P99 78ms,页面价、购物车价、结算价三端一致率 100%,促销变更后版本号失效命中率 100%,全程零"价格对不上"客诉。对比改造前同期大促的价格投诉量,下降超过 95%。
价格对不上的本质,是"价格计算"这个核心逻辑在多个环节各算各的。统一入口、版本号失效、落单重算三件事做完,页面价、结算价、实付价才可能是同一个结果。价格链路的可靠性,说到底就三条:只认一个算价来源、前端传来的价格永远不可信、每次促销变更都能追到版本,其余都是在这三条上打补丁。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。