简介:商城积分系统是一套面向课程设计、毕业设计与.NET WebForms入门练习的完整源码包,围绕会员积分获取、查询、兑换、商品销售与后台管理展开,适合需要理解积分规则、数据库设计与Web页面交互的开发者参考。压缩包共93个文件、约2.31MB,以35个C#源文件、13个aspx页面、12个ascx用户控件为主,并含master母版、config配置、SQL Server数据库文件、界面图片和一份PPT说明,目录按前台、后台、控件与数据层组织,便于对照阅读。已有3096人学习下载。读者可从中获得会员注册登录、积分信息维护、积分兑换规则、商品销售记录、会员查询与后台管理等模块的实现样本,也能借鉴积分有效期、等级特权、兑换流程与数据库表结构的设计思路,适合做功能拆解、二次开发或答辩展示的基础项目。
1. 商城积分系统到底在解决什么问题:一笔订单背后的三次账户变动
用户下单付款 199 元,按规则返 199 积分;一个月后他拿 200 积分抵了 2 块钱;再后来这笔积分到期被系统静默回收。看起来只是三个数字的变化,落到工程上却是三套完全不同的链路:发放要算规则、要防重复,抵扣要抢并发、要防超扣,回收要定时扫、要按先进先出精确扣。商城积分系统做的就是把这三次变动记清楚、算准确、查得到。它适合正在给电商后台补会员能力的中小团队,也适合想拿一个完整业务闭环练手事务、并发和消息幂等的开发者。别被“积分”两个字骗了,它本质是一个轻量级的账户系统,难点从来不在加法,而在余额和流水永远对得上。
2. 技术选型与工程结构:从账户表到调度任务的一次落地拆分
2.1 为什么是 Spring Boot + MySQL + Redis + 消息队列这套组合
积分系统的读写比大概是 20:1,查询远多于写入,但写入的每一次都要求强一致——余额算错一分钱,客服第二天就得加班。所以存储层不能省:MySQL 承账户余额和流水明细,Redis 只做两件事,缓存用户积分概览页的展示数据、以及抵扣时的并发预扣。常见做法是 Spring Boot 打底,MyBatis-Plus 做单表 CRUD,RocketMQ 或 RabbitMQ 承接下单成功事件,XXL-Job 或 Spring 自带的@Scheduled跑过期回收。
这里有个取舍要讲清楚:为什么不把余额只放 Redis?因为 Redis 的持久化在极端情况下是有窗口的,积分可以当钱花,丢了就是资损。所以我一般把 Redis 定位成“加速层和限流层”,真正的账在 MySQL 里,Redis 里的值允许通过重算恢复。缓存这块也踩过坑,早期把balance和frozen合成一个 key 存,抵扣时先减 Redis 再减库,中间服务重启一次,缓存和数据库就对不上了,后来老老实实改成“库为准、缓存带过期时间”。
消息队列的作用不是解耦那么简单,它解决的是“下单主链路不能被积分逻辑拖死”。下单服务只发一条order.paid事件就返回,积分服务自己消费、自己算,慢一点没关系,但绝对不能因为积分服务挂了导致用户付不了款。
2.2 目录结构:按业务动作切包,不按技术层切
这个项目的代码结构我倾向于按“动作”分包,而不是传统的 controller / service / mapper 三层平铺,因为积分系统的业务动作非常明确,平铺之后找代码全靠全局搜索。
point-system ├── point-api # 对外暴露的接口层,含 DTO 和参数校验 │ ├── PointQueryController.java │ └── PointDeductController.java ├── point-application # 业务流程编排 │ ├── grant/ # 发放:规则计算、流水写入、余额变更 │ ├── deduct/ # 抵扣:预扣、确认、回滚 │ ├── expire/ # 过期:扫描、FIFO 核销 │ └── query/ # 查询:余额、流水分页、可用积分 ├── point-domain # 领域对象与规则 │ ├── entity/ # PointAccount、PointFlow、PointRule │ └── rule/ # 规则解析器,按场景分发 ├── point-infrastructure # 存储与外部依赖 │ ├── mapper/ │ ├── cache/ │ └── mq/ └── point-job # 定时任务入口 └── PointExpireJob.java这么切的直接好处是:新人进来问“积分怎么扣的”,你只要指deduct包就行,不用在三个层之间来回跳。代价是包之间会有一些重复的 DTO 转换,我认为这笔账划算。
2.3 三张核心表:账户、流水、规则
积分系统的表设计原则是“流水是事实,账户是快照”。账户表可以被流水分毫不差地重算出来,这条原则决定了后面所有的对账逻辑。
-- 积分账户:一个用户一条,balance 是冗余快照 CREATE TABLE point_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '用户ID', balance INT NOT NULL DEFAULT 0 COMMENT '当前可用余额', frozen INT NOT NULL DEFAULT 0 COMMENT '冻结中,抵扣未确认', total_earned INT NOT NULL DEFAULT 0 COMMENT '累计获得,只增不减', total_used INT NOT NULL DEFAULT 0 COMMENT '累计消耗', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user (user_id) ) COMMENT '积分账户'; -- 积分流水:每一条变动都留痕,永不物理删除 CREATE TABLE point_flow ( id BIGINT PRIMARY KEY AUTO_INCREMENT, flow_no VARCHAR(64) NOT NULL COMMENT '流水号,业务侧生成', user_id BIGINT NOT NULL, change_amount INT NOT NULL COMMENT '正数入账,负数出账', flow_type TINYINT NOT NULL COMMENT '1获得 2消耗 3过期 4回滚', biz_no VARCHAR(64) NOT NULL COMMENT '业务单号,用于幂等', balance_after INT NOT NULL COMMENT '变动后余额快照', expire_at DATETIME NULL COMMENT '该笔积分的过期时间', status TINYINT NOT NULL DEFAULT 0 COMMENT '0有效 1已核销', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_no, flow_type), KEY idx_user_expire (user_id, status, expire_at) ) COMMENT '积分流水'; -- 积分规则:按场景配置,支持热更新 CREATE TABLE point_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scene VARCHAR(32) NOT NULL COMMENT '场景:ORDER_PAID/REVIEW/SIGN_IN', ratio DECIMAL(10,4) NOT NULL COMMENT '每元返多少分', max_limit INT NOT NULL DEFAULT 0 COMMENT '单次上限,0 表示不限', valid_days INT NOT NULL DEFAULT 365 COMMENT '有效期天数', enabled TINYINT NOT NULL DEFAULT 1 ) COMMENT '积分规则配置';三张表的分工很清晰:point_rule决定“给多少”,point_flow记录“给了没、什么时候过期”,point_account提供“还剩多少”的快速读取。注意uk_biz这个唯一索引,它是后面所有幂等逻辑的最终兜底,也是防止重复发放的最后一道闸门。
2.4 配置项:把容易写死的数字挪出来
启动前要确认的几项配置,建议全部外置到配置中心或application.yml,不要硬编码在代码里:
| 配置项 | 说明 | 建议值 |
|---|---|---|
point.grant.max-retry | 消息消费失败重试次数 | 3 |
point.expire.batch-size | 过期任务单批扫描条数 | 500 |
point.expire.cron | 过期任务执行时间 | 每天凌晨 2 点 |
point.deduct.freeze-minutes | 抵扣冻结超时自动释放 | 15 |
point.cache.ttl-seconds | 账户缓存过期时间 | 60 |
freeze-minutes这个值需要留意,它大于下单页面的会话超时时间即可。设得太短,用户还在付款页犹豫,冻结已经释放,回头再抵扣就容易出现“明明显示够、点下去不够”的体验问题。
3. 核心链路拆解:积分发放、抵扣扣减与过期回收的代码实现
3.1 积分发放:先写流水,再改余额
发放是积分系统里最常被调用的方法,也是重复发放事故的高发区。我的写法固定为“先插流水、再动账户”,因为流水有唯一索引,冲突会直接抛异常,天然挡住重复请求;反过来先改余额,一旦插流水失败,余额就多出来了,只能靠对账兜。
@Service public class PointGrantService { @Resource private PointRuleResolver ruleResolver; @Resource private PointFlowMapper flowMapper; @Resource private PointAccountMapper accountMapper; /** * 发放积分,整个过程在一个本地事务里 * @param cmd 包含用户ID、业务单号、订单金额、场景 */ @Transactional(rollbackFor = Exception.class) public void grant(GrantCmd cmd) { // 1. 按场景解析规则,算出本次应发积分 PointRule rule = ruleResolver.resolve(cmd.getScene()); int points = rule.calc(cmd.getAmount()); if (points <= 0) { return; // 金额太小或规则关闭,直接返回,不产生流水 } // 2. 写入流水,uk_biz 唯一索引负责幂等 // 重复消费时这里会抛 DuplicateKeyException,由上层捕获后忽略 PointFlow flow = new PointFlow(); flow.setFlowNo(IdGen.next("G")); flow.setUserId(cmd.getUserId()); flow.setChangeAmount(points); flow.setFlowType(FlowType.EARN.getCode()); flow.setBizNo(cmd.getBizNo()); flow.setBalanceAfter(0); // 先占位,改完余额再回填 flow.setExpireAt(LocalDateTime.now().plusDays(rule.getValidDays())); flowMapper.insert(flow); // 3. 账户可能还不存在,先保证有一行 accountMapper.initIfAbsent(cmd.getUserId()); // 4. 原子累加余额,不读出来再写回去 int rows = accountMapper.increase(cmd.getUserId(), points); if (rows != 1) { throw new IllegalStateException("积分入账失败, userId=" + cmd.getUserId()); } // 5. 回填变动后余额,方便前端按流水展示 int balanceAfter = accountMapper.selectBalance(cmd.getUserId()); flowMapper.updateBalanceAfter(flow.getId(), balanceAfter); } }rule.calc()里只做乘法和上限截断,不碰数据库,这样规则可以被单元测试完整覆盖。第 4 步是关键:increase用的是balance = balance + #{points}这种数据库内计算,而不是应用层先查后写,避免了典型的丢失更新。第 5 步的回填在同一个事务里,如果回填失败整体回滚,不会出现“有流水、余额对不上”的中间态。
3.2 幂等控制:唯一索引兜底,业务层只做友好提示
消息队列至少投递一次,重复消费是常态。我的处理是让数据库唯一索引当最终裁判,业务层只负责把它翻译成可读的结果。
@Component public class OrderPaidListener implements MessageListener { @Resource private PointGrantService grantService; @Override public void onMessage(OrderPaidEvent event) { GrantCmd cmd = new GrantCmd(); cmd.setUserId(event.getUserId()); cmd.setBizNo(event.getOrderNo()); // 订单号作为幂等键 cmd.setAmount(event.getPayAmount()); cmd.setScene("ORDER_PAID"); try { grantService.grant(cmd); } catch (DuplicateKeyException e) { // 已经发过了,直接确认消费,不要重试 log.info("重复的积分发放事件, orderNo={}", event.getOrderNo()); } } }这里有个细节:DuplicateKeyException必须捕获后正常返回,不能往外抛。一旦抛出去,MQ 会认为消费失败并重试,重试还是冲突,几轮之后消息进死信队列,需要人工处理,纯属给自己找麻烦。另外幂等键要选业务上唯一的字段,订单号、评价 ID 都是好选择,别用时间戳或者 UUID,那等于没有幂等。
3.3 积分抵扣:冻结先行,确认后扣,超时自动回滚
抵扣的并发风险比发放高一个量级,用户频繁点击、页面重试、多端同时下单,都会撞在一起。直接balance - points加balance >= points条件虽然能防超扣,但挡不住“冻结了却没付款”的悬挂。所以流程拆成三步:冻结、确认、取消。
@Service public class PointDeductService { @Resource private PointAccountMapper accountMapper; @Resource private PointFlowMapper flowMapper; /** * 第一步:冻结积分,不真正扣减 */ @Transactional(rollbackFor = Exception.class) public String freeze(FreezeCmd cmd) { // CAS 更新:余额足够才冻,条件写在 WHERE 里,由数据库保证原子性 int rows = accountMapper.freeze(cmd.getUserId(), cmd.getPoints()); if (rows == 0) { throw new BizException("可用积分不足"); } String freezeNo = IdGen.next("F"); flowMapper.insertFreezeFlow(freezeNo, cmd.getUserId(), cmd.getPoints(), cmd.getBizNo()); return freezeNo; // 返回给前端,后续凭此确认或取消 } /** * 第二步:支付成功后确认扣减 */ @Transactional(rollbackFor = Exception.class) public void confirm(String freezeNo) { FreezeRecord record = flowMapper.selectByFreezeNo(freezeNo); if (record == null || record.getStatus() != 0) { return; // 已处理过,幂等返回 } // 冻结转实扣:frozen 减、balance 减,一次搞定 accountMapper.confirmDeduct(record.getUserId(), record.getPoints()); flowMapper.markConfirmed(freezeNo); } }配合下面这条 SQL,frozen + points <= balance这个条件保证了并发下不会出现冻结金额超过余额的情况:
-- 冻结:余额足够才允许冻结 UPDATE point_account SET frozen = frozen + #{points}, version = version + 1 WHERE user_id = #{userId} AND balance - frozen >= #{points};冻结记录需要一个超时释放机制,用延迟消息或者定时任务扫status = 0 且 create_time < now - 15min的记录,把frozen减回去。这条兜底链路千万不能省,否则用户取消订单后积分会被永久锁死,客服工单会堆成山。
3.4 过期回收:分批扫描 + 按流水逐条核销
过期不能按账户总额一刀切,因为用户的积分是分批进来的,有效期各不相同,必须按流水先进先出核销。实现上分两步:先扫出到期的有效流水,再逐条扣减并标记已核销。
@Component public class PointExpireJob { @Resource private PointFlowMapper flowMapper; @Resource private PointAccountMapper accountMapper; /** 每天凌晨执行,单批 500 条,循环直到扫完 */ @Scheduled(cron = "0 0 2 * * ?") public void expire() { while (true) { // 按过期时间升序取一批,先到期的先处理 List<PointFlow> batch = flowMapper.selectExpired(batchSize(500)); if (batch.isEmpty()) { break; } for (PointFlow flow : batch) { // 只扣用户当前还剩的部分,余额不足时扣到 0 为止 int deduct = Math.min(flow.getChangeAmount(), accountMapper.selectAvailable(flow.getUserId())); if (deduct > 0) { accountMapper.decrease(flow.getUserId(), deduct); flowMapper.insertExpireFlow(flow, deduct); } flowMapper.markUsed(flow.getId()); // 标记已核销,不再重复扫 } } } }selectExpired的 SQL 要带上status = 0和expire_at <= NOW(),走idx_user_expire索引避免全表扫。分批的意义在于控制单次事务时长,一批 500 条大概几十毫秒,不会长时间持锁影响正常抵扣。另外过期也要写一条flow_type = 3的负向流水,这样账户余额随时可以由流水重算验证,不用担心哪天对不上账查不出原因。
4. 排查与避坑:积分系统上线后最容易翻车的五处
4.1 余额和流水对不上,差几分钱
现象:运营对账时发现某个用户的balance比流水汇总多出 3 分,用户自己也说不清什么时候多的。
原因:早期有一版代码在发放时先select出余额、在 Java 里加完再update回写,两个并发请求同时读到旧值,后写的覆盖了先写的。这种丢失更新在低并发下几乎测不出来,上线后大促一压就暴露。
解决:所有余额变更一律走数据库内计算(balance = balance + #{delta}),禁止应用层读出再写回。同时写一个对账脚本,每天扫point_account和point_flow的汇总差异,差值超过阈值的用户进告警列表。这个脚本我建议做成可以随时手动触发,出问题的时候它是唯一的黑匣子。
4.2 并发抵扣出现超扣,余额被扣成负数
现象:两个浏览器标签同时提交订单,都用了 100 积分,账户里只有 150,结果两单都抵扣成功,余额变成 -50。
原因:扣减前先查了一次余额判断“够不够”,然后才执行更新。查询和更新之间存在时间窗,两个请求都通过了检查。
解决:把余额判断塞进WHERE条件里,让数据库的行锁来保证原子性,UPDATE ... WHERE balance - frozen >= #{points},受影响行数为 0 就说明不够。别在应用层做这个判断,也别指望 Redis 的分布式锁能救你,锁粒度一大性能就垮,而且锁续期又是新问题。
4.3 过期任务把刚到账的积分提前吃掉了
现象:用户昨天刚获得的积分,今天登录发现被清零,投诉说系统偷积分。
原因:expire_at字段在发放时没有赋值,数据库默认 NULL,而扫描 SQL 写的是expire_at <= NOW(),某些数据库对 NULL 的比较行为不符合直觉,加上任务里没过滤status,把新流水也扫进去了。
解决:发放时强制写入expire_at,落在point_rule.valid_days上;扫描 SQL 显式加expire_at IS NOT NULL AND status = 0。任务上线前先在测试库造一批历史数据跑一遍,把处理前后的余额和流水条数都打日志,确认无误再上生产。这类任务一旦误删数据,基本没有后悔药。
4.4 改了规则比例,历史订单重算结果全变了
现象:运营把返积分比例从 1% 调到 2%,结果有开发同学重跑了一遍历史发放任务,老订单全部按新比例补发了积分。
原因:规则只存了当前值,没有版本概念,重算时读到的永远是配置表里的最新一行。
解决:point_rule加version和effective_time,发放时把当时用的rule_id或比例快照写进流水表备注字段。重算只针对没有流水的订单,已有流水的一律跳过——流水的唯一索引本来就该挡住这种重放,前提是幂等键没写错。
4.5 消息堆积,积分延迟到账
现象:大促期间用户付款半小时后积分还没到,客服电话被打爆。
原因:积分消费线程池太小,单条消费里还夹了一次远程调用查规则,吞吐上不去;加上消费失败重试没有上限,个别毒消息把队列堵了。
解决:规则解析结果本地缓存 5 分钟,消费逻辑里只留数据库操作;重试次数配成 3 次,超过就落库到异常表异步补偿,不要死磕。监控上加消费延迟和堆积量两个指标,超过阈值直接告警到值班群,别等用户投诉才知道。
5. 二次开发:把返积分比例换掉只是开始
拿到这套代码之后,最常见的改造需求是“我们的积分规则不一样”。如果只是想改比例和有效期,直接改point_rule表就行,PointRuleResolver会按scene取到新配置,不用动代码。但如果你要做的是阶梯返积分、会员等级加成、积分+现金混合支付,那就得动point-domain/rule这一层。
我的做法是定义一个规则接口,把“算多少”和“能不能用”拆开:
public interface PointRuleHandler { /** 该处理器负责的场景 */ String scene(); /** 计算本次应发积分 */ int calc(GrantContext ctx); /** 校验本次抵扣是否合法,比如最低消费门槛 */ boolean checkDeduct(DeductContext ctx); }阶梯返积分就新写一个实现,按订单金额分档返回不同积分,注册进 Spring 容器后由scene分发,原来的代码一行不用改。会员等级加成同理,在calc里读一下用户的等级倍率即可。
改造时有三点要盯住。第一,新规则上线前用灰度开关控制,先放 1% 的订单走新逻辑,对比新旧两条链路的流水差异,差值必须为 0 才算通过。第二,任何涉及“补发”或“重算”的脚本,必须先在从库上跑一遍,把预计影响的用户数和积分总额导出来人工核对,确认无误再在主库执行。第三,改造完毕后跑一次全量对账,sum(point_flow.change_amount)和point_account.balance必须严格相等,一个用户都不能放过。
我印象最深的一次是给一个活动加了“积分翻倍”,规则写得没问题,但忘了在扣减侧同步上限,结果用户用翻倍得来的积分抵扣时超出了单笔上限,订单直接卡在支付回调里。从那以后我每次改规则都会把发放和抵扣两条链路一起过一遍,并且强制跑一遍对账脚本才允许上线。积分这东西,用户平时不关注,一旦出错就是信任问题。希望这套结构能帮到你,少走几段弯路。
本文还有配套的精品资源,点击获取