首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >教培消课怎么保证课时不重复扣、不扣成负数:一套幂等账本设计

教培消课怎么保证课时不重复扣、不扣成负数:一套幂等账本设计

原创
作者头像
用户5598620
发布2026-09-16 08:51:33
发布2026-09-16 08:51:33
1090
举报

导读:教培机构按课时计费,学员每签到一次就要消一次课。老师重复点击、网络重试、多端同时操作、请假后补课,任何一个环节重复执行,都可能把课时多扣、扣成负数,最后和家长对不上账。本文给一套可直接落地的课时幂等账本:账户与流水分离、唯一键消课、乐观锁扣减、红冲处理请假,看完能照抄表结构与核心代码。

一、课时为什么总会“算错”:重复消课的四个来源

消课本质上是一次“额度账户扣减”,它天然会遇到重复请求,来源通常有四类:

  • 人为重复:老师手机端点了没反应又点一次,或前台和老师两端同时给同一节课消课。
  • 网络与框架重试:请求超时后客户端、网关、消息队列各自重试,同一条消课指令到达服务端多次。
  • 消息重复投递:签到走 MQ 异步消课时,消息中间件只保证至少投递一次,消费端可能重复收到。
  • 业务回调重复:请假、调课、补课触发的撤销与重消,没有和原始消课做关联,导致退了又扣、扣了又退。

只在前端把按钮置灰挡不住后三类,幂等必须做在服务端,并且要落到数据库约束上。

二、课时账户和消课流水为什么要分开

很多系统直接在学员表上放一个“剩余课时”字段,消课就 remain = remain - 1。这样余额会变,但“哪一节课、什么时候、谁操作扣的”没有留痕,请假退课和家长对账时根本查不清。

正确做法是账户表管余额、流水表管每一次变动,流水只追加、不做物理删除,余额可由流水重算。

代码语言:sql
复制
-- 课时账户:一个学员购买的一个课时包一行
CREATE TABLE lesson_account (
  id            BIGINT PRIMARY KEY,
  student_id    BIGINT NOT NULL,
  package_id    BIGINT NOT NULL,
  total_lesson  INT NOT NULL,
  remain_lesson INT NOT NULL,
  version       INT NOT NULL DEFAULT 0,
  UNIQUE KEY uk_student_package (student_id, package_id),
  CONSTRAINT chk_remain CHECK (remain_lesson >= 0)
);

-- 消课流水:只追加,请假用红冲流水,不删除原记录
CREATE TABLE lesson_consume_flow (
  id          BIGINT PRIMARY KEY,
  biz_id      VARCHAR(64) NOT NULL,
  lesson_id   BIGINT NOT NULL,
  student_id  BIGINT NOT NULL,
  account_id  BIGINT NOT NULL,
  cost        INT NOT NULL,
  flow_type   TINYINT NOT NULL,   -- 1消课 2红冲 3补课
  status      TINYINT NOT NULL,   -- 0处理中 1成功 2已撤销
  create_time DATETIME NOT NULL,
  UNIQUE KEY uk_biz_type (biz_id, flow_type)
);

幂等键 biz_id 由“课节ID_学员ID”拼成:同一节课给同一名学员消课,无论请求多少次,只会有一条消课流水。

三、单次消课怎么做幂等:唯一键 + 状态机 + 乐观锁

消课分四步,且放在同一个事务里:先查流水做幂等返回,再校验课节状态,插入流水(唯一索引兜底并发),最后带条件扣减余额。

代码语言:java
复制
@Transactional(rollbackFor = Exception.class)
public ConsumeResult consumeLesson(ConsumeCmd cmd) {
    String bizId = cmd.getLessonId() + "_" + cmd.getStudentId();

    LessonConsume exist = flowMapper.selectByBizIdAndType(bizId, FLOW_CONSUME);
    if (exist != null) {
        return ConsumeResult.idempotent(exist.getStatus()); // 已处理过,原样返回
    }

    Lesson lesson = lessonMapper.selectByIdForUpdate(cmd.getLessonId());
    if (lesson == null || !lesson.canConsume()) {
        throw new BizException("课节状态不允许消课");
    }

    try {
        flowMapper.insert(buildConsumeFlow(cmd, bizId));
    } catch (DuplicateKeyException dup) {
        LessonConsume again = flowMapper.selectByBizIdAndType(bizId, FLOW_CONSUME);
        return ConsumeResult.idempotent(again.getStatus()); // 并发重复,被唯一索引拦下
    }

    int rows = accountMapper.deduct(cmd.getAccountId(), cmd.getCost(), cmd.getVersion());
    if (rows == 0) {
        throw new BizException("课时不足或数据已变更,请刷新重试");
    }
    return ConsumeResult.ok();
}

扣减语句同时带“余额足够”和“版本号”两个条件,余额不足或已被其它事务改过,影响行数就是 0,事务回滚,从 SQL 层杜绝扣成负数:

代码语言:sql
复制
UPDATE lesson_account
SET remain_lesson = remain_lesson - #{cost},
    version = version + 1
WHERE id = #{accountId}
  AND remain_lesson >= #{cost}
  AND version = #{version};

课节状态建议用明确状态机:待上课 → 已签到 → 已消课;请假走 已请假(不消课);只有已消课允许红冲。状态流转在代码里收敛成一个方法,不要在各处随意 set。

四、班课批量消课,怎么保证全成或全不成

班课一次要给几十名学员消课,把每个学员的消课放进同一个事务,逐条调用上面的幂等方法,任一失败整体回滚,避免出现“消了一半”的课。

代码语言:java
复制
@Transactional(rollbackFor = Exception.class)
public void consumeClass(Long lessonId, List<Long> studentIds) {
    List<Long> failed = new ArrayList<>();
    for (Long sid : studentIds) {
        try {
            consumeLesson(new ConsumeCmd(lessonId, sid));
        } catch (BizException e) {
            failed.add(sid); // 课时不足等业务异常记录下来
        }
    }
    if (!failed.isEmpty()) {
        throw new BizException("以下学员消课失败:" + failed);
    }
}

人数特别多的大课,单事务持锁过久会拖慢接口,可改为按批提交、失败清单落库后异步重试,由于每条都有幂等键,重试不会重复扣,最终再用流水重算对账即可。

五、请假、调课、补课怎么红冲才不留糊涂账

请假退课不能物理删除原消课流水,否则审计断链、补课时还可能重复扣。正确做法是追加一条红冲流水把课时加回,补课时再生成一条新的消课流水:

代码语言:java
复制
@Transactional(rollbackFor = Exception.class)
public void revokeConsume(String consumeBizId) {
    LessonConsume origin = flowMapper.selectByBizIdAndType(consumeBizId, FLOW_CONSUME);
    if (origin == null || origin.getStatus() != SUCCESS) {
        throw new BizException("原消课不存在或不可撤销");
    }
    flowMapper.insert(buildReverseFlow(origin));   // uk 兜底,重复红冲只生效一次
    accountMapper.refund(origin.getAccountId(), origin.getCost());
    flowMapper.markStatus(origin.getId(), REVOKED);
}

这样一名学员的课时来龙去脉在流水表里完整可查:购课增加、消课扣减、请假红冲加回、补课再扣,每一笔都有对应的课节和类型。

踩坑清单

  • 只靠前端按钮置灰防重复提交:挡不住超时重试和 MQ 重复投递,幂等必须落服务端与数据库唯一索引。
  • 扣减只写 remain = remain - 1:没有 remain >= cost 和版本号,并发下会多扣、扣成负数。
  • 只维护余额不记流水:请假退课、家长对账、客诉都查不清,必须账户与流水分离。
  • 请假直接删除消课记录:审计断链、补课容易重复扣,一律走红冲流水。
  • 幂等键用时间戳或请求随机号:同一节课同一学员会生成多个键,必须用“课节ID+学员ID”。
  • 多个课时包(正式包、赠送包)扣减顺序随意:剩余课时统计口径会乱,要约定固定扣减顺序并在流水里记录来源包。

工程落地建议

中小机构规模用关系型数据库单表、唯一索引加乐观锁即可承载,不必默认上分布式锁;只有统一时间大规模签到形成热点时,再考虑队列削峰与串行化。无论自研还是选型,都建议配一个每日对账任务:按流水重算每个账户余额,与账户表比对,差异即告警。这类排课、签到消课与课时账本能力,在面向教培机构的商业 SaaS 产品(如乔拓云的教育系统)中通常作为内置模块提供;自研则需要持续维护课节状态机、幂等账本与对账任务。同一套“账户 + 幂等流水 + 乐观锁”模型,把课时换成次卡、券、积分等额度型资源同样适用。

上线复盘清单

  • 幂等键“课节ID+学员ID”(红冲再加流水类型)已建唯一索引
  • 扣减 SQL 带余额非负条件与版本号,影响 0 行有回滚与提示
  • 账户表有 remain >= 0 约束做最后兜底
  • 请假/撤销走红冲流水,系统中无物理删除消课记录的路径
  • 班课批量消课在事务内,大课有分批、失败清单与重试
  • 已上线每日流水重算余额的对账任务与差异告警
  • 已压测重复提交、并发消课、课时不足、红冲后补课四类用例

常见问题

Q:老师重复点消课、网络卡顿时多点了几次,会多扣课时吗?

A:不会。每次消课以“课节ID+学员ID”为幂等键并建唯一索引,重复请求要么命中已有流水直接返回原结果,要么被唯一索引拦下,扣减只执行一次。

Q:学员临时请假,已经排好的课怎么处理才不会错扣?

A:请假课节不做消课;若已误消,不删除记录,而是追加一条红冲流水把课时加回,实际补课时再生成新的消课流水,全程留痕可查。

Q:剩余课时会不会被扣成负数?

A:扣减语句带 remain_lesson >= cost 条件并配合版本号,余额不足时更新影响行数为 0、事务回滚,数据库层还有 CHECK 约束兜底,两层保证不会为负。

Q:班课一次给几十名学员消课,中途失败怎么办?

A:放在同一事务内逐条幂等消课,任一失败整体回滚;人数很多时按批提交、记录失败清单并重试,最终用流水重算余额完成对账。

结语

以上是教培消课场景下幂等账本的一套落地写法,仅作技术分享,表结构与状态机请结合自身班型、课时包规则取舍,具体功能以所用系统官方实时信息为准。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、课时为什么总会“算错”:重复消课的四个来源
  • 二、课时账户和消课流水为什么要分开
  • 三、单次消课怎么做幂等:唯一键 + 状态机 + 乐观锁
  • 四、班课批量消课,怎么保证全成或全不成
  • 五、请假、调课、补课怎么红冲才不留糊涂账
  • 踩坑清单
  • 工程落地建议
  • 上线复盘清单
  • 常见问题
    • Q:老师重复点消课、网络卡顿时多点了几次,会多扣课时吗?
    • Q:学员临时请假,已经排好的课怎么处理才不会错扣?
    • Q:剩余课时会不会被扣成负数?
    • Q:班课一次给几十名学员消课,中途失败怎么办?
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档