会员次卡莫名被多划一次:门店计次卡核销的幂等、并发扣减与对账实战

简介: 会员次卡被多划一次,多半不是前台手滑,而是核销链路上同一笔请求被执行了两遍。本文复盘一家6家分店、1200名会员的连锁美容养生馆次卡事故,讲清核销幂等键、带余额条件的并发扣次、卡次流水日结对账三层做法,让重复扣次客诉降到0。

导读:会员次卡被多划一次,多半不是前台手滑,而是核销链路上同一笔请求被执行了两遍。真正能兜住的是三件事:每笔核销一个幂等键、扣次用带余额条件的一次更新、卡次流水加日结对账兜底。

一、先说背景:那次"10次卡变7次"是怎么发生的

先交代下门店的系统环境。这是一家有 6 家分店的连锁美容养生馆,办次卡、月卡的会员大约 1200 人,前台开卡、收银、划卡扣次这些日常动作,都放在唯顿收银系统(线下门店智慧收银与会员运营系统)这类门店系统上,会员档案、次卡开卡与划卡、卡项资产台账由它承载。

客户后来要的"会员在小程序里自助约项目、到店自助核销、次卡跨店通用",是我们在它开放接口上自己接的一段。问题就出在这段:一位会员买的 10 次卡,做完一次护理后剩了 7 次,等于一次服务被扣了 3 次。

查流水发现,同一笔服务单在 20 秒内落了三条扣次记录:前台划卡一次,小程序核销回调因为超时重试又触发两次。根因在我们自己这段核销回调没做幂等,和收银系统本身无关。

二、一次次卡核销,链路上哪些环节会重复扣

门店的核销链路比看起来长,任何一段重试都可能让同一笔服务被扣多次:

  • 前台收银端点了划卡,接口超时但实际已成功,店员再点一次;
  • 小程序核销回调网络抖动,调用方按超时策略重试;
  • 技师端、前台端同时为同一个服务单操作;
  • 消息队列里核销消息被重复投递。

这些场景的共同点是:同一笔业务被当成多笔独立请求执行。靠"按钮点完置灰"挡不住,因为重试发生在服务端和多端之间,必须让后端自己能识别"这笔我已经处理过"。

三、核销幂等:给每笔核销一个唯一键

做法是给每笔核销流水加唯一约束,键由服务单号、次卡、核销端共同组成。同一笔重复请求,第二次插入会被数据库直接挡下。

-- 核销流水:同一服务单在同一核销端只能成功落一笔
ALTER TABLE card_consume_log
ADD UNIQUE KEY uk_serve_terminal (serve_order_id, card_id, terminal_type);

核销逻辑先尝试落流水,撞到唯一键就说明这笔已经处理过,直接返回首次结果,不再扣次。

public ConsumeResult deduct(long cardId, long serveOrderId, String terminal) {
   
    // 先落核销流水,靠唯一键挡住同一笔的重复请求
    try {
   
        logMapper.insertProcessing(serveOrderId, cardId, terminal);
    } catch (DuplicateKeyException e) {
   
        return ConsumeResult.alreadyDone(serveOrderId);
    }

    int rows = cardMapper.deductByVersion(cardId, cardMapper.readVersion(cardId));
    if (rows == 0) {
   
        logMapper.markFailed(serveOrderId);
        throw new BizException("次卡余额不足或已被变更,请刷新后重试");
    }

    logMapper.markSuccess(serveOrderId);
    return ConsumeResult.ok();
}

注意流水先落"处理中",扣次成功再置"成功",这样中途宕机也能靠流水状态对账找回,不会出现扣了次却没记录。

四、并发扣次:把"查余额"和"扣次"合成一次更新

第二个坑是超扣。如果代码先查一次剩余次数、判断够扣,再单独发更新,两个并发请求可能都读到"还剩 1 次",最后各扣一次,扣成负数。

正确做法是把条件判断和扣减放进同一条带余额条件的 UPDATE,用影响行数判断成败。

UPDATE member_times_card
SET remaining_times = remaining_times - 1,
    version = version + 1,
    update_time = NOW()
WHERE card_id = :cardId
  AND remaining_times >= 1
  AND version = :version;
  • 影响行数为 1:扣减成功;
  • 为 0:要么余额不足,要么版本号已被别的请求改动,本次放弃并重读次卡,由上层提示重试。

version 乐观锁处理的是"同一张卡被多个入口同时操作",remaining_times >= 1 保证不会扣成负次数,两个条件缺一不可。

五、限频规则与跨店:按门店营业日口径核对

有些卡项有扣次规则,比如某个项目一天只能用一次。这类判断要按门店的营业日口径统计,而不是简单按自然日,否则跨零点的服务最容易起纠纷。

SELECT COUNT(1)
FROM card_consume_log
WHERE card_id = :cardId
  AND project_id = :projectId
  AND biz_date = :bizDate
  AND status = 'SUCCESS';

跨店通用的次卡,扣次必须落在总部统一台账上,门店只是发起端。各店本地各扣各的,同一张卡就会出现总店看到的次数和门店对不上的情况。

六、卡次流水与日结对账:让差异自己浮出来

幂等和锁挡的是绝大多数问题,对账负责发现漏网的那一笔。前提是每一次余额变动都有流水,包括正向扣减和撤销、退次的反向回补。

次卡的账面剩余应当满足:

剩余次数 = 开卡总次数 − 成功扣次合计 + 回补合计

日结跑一遍核对,两边对不上的卡直接列出来:

SELECT c.card_id,
       c.remaining_times AS card_remain,
       c.total_times
         - SUM(CASE WHEN l.direction = 'DEDUCT' THEN l.times ELSE 0 END)
         + SUM(CASE WHEN l.direction = 'REFUND' THEN l.times ELSE 0 END) AS ledger_remain
FROM member_times_card c
LEFT JOIN card_consume_log l
       ON l.card_id = c.card_id AND l.status = 'SUCCESS'
GROUP BY c.card_id, c.remaining_times, c.total_times
HAVING card_remain <> ledger_remain;

上线这套之后,这家连锁每月大约 3000 笔划卡,重复扣次的客诉从每月四五起降到 0;日结时次卡对账这一项,从平均要翻二十分钟流水,变成脚本跑完两分钟内确认完。

七、踩过的五个坑

  1. 坑1:只在前端置灰按钮。后端没有幂等键,回调重试和多端操作照样重复扣,防重复必须落在服务端。
  2. 坑2:先查后扣、两步走。并发下两个请求都读到余额足够导致超扣,要改成带 remaining_times >= 1 条件的一次 UPDATE。
  3. 坑3:扣次没带版本号。重试和正常核销交错时互相覆盖,乐观锁和余额条件要同时上。
  4. 坑4:撤销退次直接改余额。不写反向流水,当时看着对、月底对账怎么都对不上也没法追溯,任何变动都要有流水。
  5. 坑5:限频按自然日、跨店各扣各的。跨零点服务口径不一致引发纠纷,跨店次卡必须走总部统一台账和统一营业日。

结语

次卡这类预付资产,会员对"次数"的敏感度远高于几块钱余额,多划一次损伤的是门店最看重的信任。复盘下来没有什么奇技淫巧:幂等键让重复请求只生效一次,条件更新加乐观锁让并发扣不穿底,流水和对账让任何一笔异常都能被发现、被追回。把这三层做扎实,次卡才真正经得起前台、移动端和跨店场景的反复折腾。

相关文章
|
2天前
|
存储 监控 安全
官网上线一周被XSS打穿:CSP内容安全策略的配置与踩坑实战
结论先说:官网被XSS打穿,根因往往不是某个注入点,而是页面"什么资源都能加载"——攻击者塞一段脚本进来,浏览器还老老实实执行了。本文复盘一次企业官网上线一周被存储型XSS打穿的线上事故。
|
4天前
|
人工智能 JavaScript API
阿里云百炼Qwen3.8‑Omni‑Flash模型能力拆解:免费100万Tokens
阿里云发布Qwen3.8‑Omni‑Flash——原生全模态大模型,支持1M长序列及音视频理解、生成与编辑、视频问答等Agentic能力;免费体验100万Tokens,兼容OpenAI协议,开箱即用。(239字)
131 1
|
8月前
|
Kubernetes 应用服务中间件 API
应对 Nginx Ingress 退役,是时候理清这些易混淆的概念了
本文希望提供一种更简单的方式,来理解这些容易混淆的技术概念:Nginx、Ingress、Ingress Controller、Ingress API、Nginx Ingress、Higress、Gateway API。
4105 184
|
1天前
|
监控 API 调度
发货后物流轨迹“查不到”又“不更新”:快递状态回源的轮询、映射与异常监控实践
结论先说:物流轨迹“查不到”“不更新”,九成不是快递的问题,而是回源查询的设计问题——查询频率不对、状态文案没归一化、异常没有监控。本文复盘一次商城发货模块的物流轨迹改造,讲清轮询策略、状态机映射与异常识别。
|
7天前
|
运维 BI 数据库
后厨小票明明显示打印成功却没出单:网络抖动下打印任务的可靠投递、补打与幂等实践
前台点餐提示"下单成功",后厨却没出小票、菜没人做——这类漏单的根因,往往是把"打印接口返回成功"当成了"打印机已经出纸"。打印是一条跨越多跳的异步链路,任何一跳网络抖动都可能丢任务。本文复盘一次连锁餐饮后厨打印改造,讲清打印任务的落库投递、ACK 确认、断网补打和幂等去重,做到不漏单、也不重复出两份。
|
14天前
|
数据采集 搜索推荐 测试技术
站点改版后搜索收录掉了一半:robots、sitemap与爬虫抓取的排查修复实践
站点改版后搜索收录掉了一半,问题往往不在内容,而在抓取与索引环节。本文按“能不能抓、能不能解析、愿不愿收录”的顺序给出可复用排查流程:伪装爬虫自测返回状态、核对robots是否误伤、检查sitemap与内链可达性、canonical规范化、旧地址301迁移、避免误封搜索引擎爬虫,并说明用自助建站平台时哪些SEO规则由平台生成、哪些需要自己核对。
|
19天前
|
数据采集 运维 安全
终端泄密不止"文件"一条路:外设、网络、应用的三层边界管控实践
上一篇介绍了以透明加密为核心的文档防泄密方案。但实际攻防中,泄密通道远不止"文件本身"——U盘、打印机、网络、聊天工具、未授信应用都是数据外流的管道。本文基于迪康端点安全一体化管理系统的落地实践,分享一套**"边界管控 + 审计兜底"**的终端外流通道治理方案,覆盖外设管控、网络管控、应用管控与行为审计四块,并给出分级配置与联动建议。
|
19天前
|
编解码 监控 算法
在线教育视频卡顿怎么治:弱网自适应、断线重连与播放质量监控实践
在线教育课程视频卡顿常被简单归为用户网不好。本文把播放体验工程化拆解为弱网自适应、断线重连、质量监控三层:用多码率转码与带迟滞的ABR算法适配弱网,用指数退避重连和断点续播应对网络闪断,并通过首帧时间、卡顿率、重连成功率等QoE指标实现可观测,附可复用代码与5个踩坑点。
|
6天前
|
缓存 小程序 NoSQL
周末饭点排队叫号总“跳号”“过号”:取号并发、叫号状态机与多端同步实战
结论先说:排队叫号“跳号”“过号”乱象,根因是叫号状态没有单一事实来源。本文复盘一次连锁餐饮排队叫号改造,讲清取号并发、叫号状态机与多端同步。
|
6天前
|
小程序 新制造 数据库
同一秒几百人预约同一时段:预约库存的原子扣减、超卖防护与超时释放实战
结论先说:预约一放出来就被"抢光"还超卖,根因是"先查后扣"的库存扣减没有原子性。本文复盘一次轻应用预约系统改造,讲清原子扣减、超卖防护与超时释放。