第一次把积分制零食自选平台 026 这套源码完整跑起来的时候,我其实有点不以为然——积分商城嘛,听起来就是商品表、订单表、积分表三板斧。但真正把业务流程走完两遍,又用压测工具模拟了 200 人同时兑换的动作之后,我发现这个项目最值钱的部分根本不在“商城”这两个字,而在积分余额、库存水位、订单状态这三者之间的强一致协调。这篇文章不打算做泛泛的功能罗列,而是把我梳理 026 版本源码时的完整思路、数据库设计、并发扣减方案、防刷治理以及本地部署步骤一次性讲透,适合正准备做积分商城、企业福利平台、零食自选柜系统的后端开发同学拿来当参考。
1. 业务模型拆解:积分从哪来,自选流程怎么走
1.1 积分来源与消耗的完整闭环
积分制系统的第一课,是先把积分的“来龙去脉”定义清楚。026 版本里的积分来源分为四类:每日签到、运营任务、消费返利、活动赠送。每一条来源都会走统一的 points_rule规则表,规则表里存着积分类型、分值、每日次数上限、启用状态。这样设计的好处是,运营想改积分策略时不用改代码,后台改一条配置即可。
积分消耗的出口则相对集中:零食自选兑换。用户用积分余额在下单时充当“虚拟货币”,整单不涉及现金支付,所以这个平台的订单表比电商订单简单不少,省掉了支付单、退款单那一大套,但也正因如此,积分扣减的准确性就成了整个系统最不能出错的地方,因为积分一旦多发或重扣,用户立刻就能感知到,不像现金支付还能走退款流程兜底。
1.2 “自选”不是普通商品兑换
很多新手会把“自选平台”理解成“商品列表加购物车”,其实 026 版本的产品逻辑要更细一层。它的核心是:平台先给用户一个积分预算额度(比如本周福利 50 积分),用户在这个额度内自由组合零食,系统实时计算已选商品的总积分,实时校验是否超预算,最终一次性生成一张“自选兑换单”。
这里的自选单和购物车订单的差异在于,自选单本身不涉及商品价格,只涉及积分价格,而且同一个商品可能在不同活动周期里有不同的积分定价,所以商品和积分价格是分开管理的。源码里通过goods表和goods_points_config表解耦,积分价单独存一张表,改价不影响商品主数据,这个设计在实际运营中非常实用,因为零食的积分定价经常随库存和效期波动。
1.3 026 版本的迭代重心
从版本号能看出来,026 至少经历了二十六轮迭代。前十几版基本都在加功能、调前端样式,而从 020 之后,代码层面明显把重心转向了稳定性:从 commit 记录里能看到“修复高并发下重复扣减积分”“新增每日对账任务”“兑换接口幂等改造”这类提交。这其实也反映了大多数业务系统的真实演进路径——功能永远是最容易做的,难的是在并发上来之后还能保持数据一致。你在读源码时会发现service层里有大量的synchronized、update ... where balance >=、request_no唯一索引,这些都是后来补进去的“补丁”,而恰恰是这些“补丁”才是这套源码的精华。
2. 技术选型逻辑:为什么是单体 Spring Boot 而不是微服务
2.1 后端框架与核心组件
026 版本的技术栈是 Spring Boot 3.2 + MyBatis-Plus 3.5 + MySQL 8.0 + Redis 6.2 + RabbitMQ 3.12,JDK 要求 17。选 Spring Boot 单体而不是微服务,理由非常实际:这个平台的业务体量撑不起微服务的复杂度,单体应用一个事务就能搞定订单和积分扣减,拆成微服务反而要引入分布式事务,增加成倍的故障点。
选 MyBatis-Plus 而不是 JPA,是因为项目里有大量复杂的库存更新 SQL 和对账 SQL,MyBatis 的 SQL 可控性更好。积分扣减那段经典代码:
@Update("UPDATE points_wallet SET balance = balance - #{points}, " + "total_consumed = total_consumed + #{points}, update_time = NOW() " + "WHERE user_id = #{userId} AND balance >= #{points}") int deductPoints(@Param("userId") Long userId, @Param("points") Integer points);这种带条件判断的原子更新是防超扣的核心,MyBatis 写起来比 JPA 直观得多。
2.2 前端与端侧实现
管理后台用的是 Vue 3 + Element Plus + Pinia,用户端则是 uni-app 打包成 H5 和小程序。为什么要用 uni-app?因为零食自选这种场景天然适合在微信小程序里跑,用户不用下载 App,点开就能选零食、看积分余额、生成兑换码去自提柜领取。同时平台方需要一个管理后台来维护商品、积分规则和订单,Vue 3 生态成熟,招聘成本低,和后台接口联调效率也高。
2.3 源码目录结构与模块职责
整套源码分为snack-server(后端)、snack-admin-web(管理后台)、snack-uniapp(用户端)三大块。后端内部按业务模块分包,而不是按技术分层分包,这一点值得借鉴:
snack-server/ ├── snack-common/ # 通用工具、异常、常量 ├── snack-framework/ # 框架配置:Redis、MQ、线程池 ├── snack-system/ # 用户、权限、登录 ├── snack-points/ # 积分规则、积分流水、积分钱包 ├── snack-goods/ # 商品、库存、积分定价 ├── snack-order/ # 自选单、兑换单、领取记录 ├── snack-job/ # 定时任务:对账、过期释放、库存同步 └── snack-admin/ # 后台管理接口模块按业务域分包的好处是,改积分相关代码时只需要进snack-points,不会像传统三层架构那样,pojo、mapper、service、controller 满屏都是,找一段逻辑要翻七八个目录。
2.4 关键依赖版本参考
| 组件 | 版本 | 用途 |
|---|---|---|
| Spring Boot | 3.2.5 | 基础框架 |
| MyBatis-Plus | 3.5.7 | ORM 与分页 |
| MySQL | 8.0.x | 主存储 |
| Redis | 6.2.x | 缓存、限流、分布式锁 |
| RabbitMQ | 3.12.x | 异步通知、积分到账 |
| Sa-Token | 1.38.0 | 登录认证与权限 |
| Hutool | 5.8.25 | 工具集 |
| vue-element-plus-admin | 2.x | 后台管理前端模板 |
这里单独说明为什么选 Sa-Token 而不是 Shiro 或 Spring Security。Sa-Token 胜在轻量和易上手,一个注解就能完成登录校验和权限拦截,对于这个项目规模完全够用,而且它对多端登录(后台管理员 + 用户端)支持得比较自然。
3. 数据库设计核心:积分钱包与流水必须分离
3.1 积分钱包表:余额冗余,但不作为唯一事实
积分钱包表points_wallet是每个用户一条记录,核心字段包括user_id、balance(当前可用余额)、frozen_points(冻结积分)、total_earned(累计获得)、total_consumed(累计消耗)、version(乐观锁版本号)。这里把累计获得和累计消耗都冗余在钱包表里,是为了让个人中心页面展示“总积分”时不需要走聚合查询,直接读这一行就行。
但要注意,这张表只承担读优化和扣减入口的职责,真正的“事实来源”是积分流水表points_detail。这是账务系统里非常经典的做法——像银行一样,每一笔余额变动都对应一条流水,流水表一旦缺失,整个系统的对账就没法做。026 版本里有一个每日定时任务,专门校验钱包余额和流水汇总是否一致,不一致立刻告警,这就是冗余设计加上对账机制的配合。
3.2 积分流水表:唯一约束是防重扣的生命线
points_detail流水表的设计是整个数据库里最有讲究的一张表,我截取核心字段:
CREATE TABLE `points_detail` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID', `biz_type` tinyint NOT NULL COMMENT '1签到 2任务 3兑换扣减 4过期 5活动赠送', `change_points` int NOT NULL COMMENT '变动积分,正负表示增扣', `balance_after` int NOT NULL COMMENT '变动后余额,用于快速回溯', `biz_order_no` varchar(64) DEFAULT NULL COMMENT '关联业务单号', `request_no` varchar(64) NOT NULL COMMENT '请求流水号,唯一', `remark` varchar(255) DEFAULT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_request_no` (`request_no`), KEY `idx_user_id_time` (`user_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分流水表';request_no的唯一索引是整个幂等方案的地基。无论是签到回调、任务完成事件还是兑换请求,业务方在发起时都会生成一个全局唯一请求号,写入流水时如果遇到唯一键冲突,说明这个请求已经处理过了,直接返回成功即可。这个方法比查表再判断要可靠得多,因为查表判断在并发下是存在竞态窗口的,而数据库唯一约束是从底层兜底的。
balance_after这个字段也很容易被忽略。有了它,当线上出现积分不一致时,你可以从某一条流水开始顺着查,快速定位是在哪一笔操作之后余额开始对不上的,这是排查历史问题的关键抓手。
3.3 商品与自选单的表结构
商品侧的表设计相对常规:goods存商品基础信息,goods_sku存规格(比如不同口味的薯片是不同 SKU),goods_stock存库存。但有一点需要注意,库存表不是简单的stock一个字段,而是拆成了total_stock(总库存)、available_stock(可用库存)、locked_stock(锁定库存)三个字段。用户在自选单确认后,系统先冻结库存,等领取动作完成后才真正扣减,这样避免了用户选完商品但迟迟不兑换导致库存被别人抢光的尴尬。
自选单则拆成exchange_order主表和exchange_order_item子表。主表记录用户、总积分消耗、状态、核销码、领取方式,子表记录每个商品的积分单价和数量。核销码的设计值得一提,用户兑换成功后生成一个 12 位随机码,自提柜或前台扫码核销,源码里用的是Hutool的RandomUtil,核销时走状态机校验,防止同一个码被核销两次。
3.4 不用外键的理由
整库没有任何外键约束,这是刻意为之。外键在低并发、小数据量时很省事,但到了高并发插入场景会成为锁竞争的放大器,而且后期做分库分表时外键会变成灾难。这个项目通过应用层的事务来保证一致性,数据库只保留索引和唯一约束,把所有关系维护逻辑都收敛到service层,这也是绝大多数互联网级项目的通用做法。
4. 并发控制链路:一次自选兑换如何做到不超卖、不重扣
4.1 兑换请求的完整链路
一次兑现在前端的操作是:用户勾选零食 → 点击兑换 → 确认弹窗 → 后端返回兑换成功。但这个看起来简单的流程,落到后端要经历六步:
- 接口层校验登录态和请求幂等号
- 读取最近一次自选单快照,校验积分总价
- 事务内校验并冻结积分余额
- 事务内校验并锁定商品库存
- 写入订单主表和订单明细表
- 事务提交后发送 MQ 消息,触发后续通知和领取码生成
4.2 积分扣减与库存锁定的原子 SQL
积分扣减用的是前面提到的那条UPDATE points_wallet SET balance = balance - #{points} WHERE user_id = #{userId} AND balance >= #{points},这里的关键是balance >= #{points}条件。它不是先查询余额再在应用层判断,而是把余额判断下推到 SQL 层,利用数据库行锁保证同一时刻只有一个请求能成功扣减。在并发 200 个请求同时兑换时,这条 SQL 天然起到了“串行化”的效果,而且不需要手动加锁,性能远好于select ... for update。
库存侧同理:
UPDATE goods_stock SET locked_stock = locked_stock + #{count}, available_stock = available_stock - #{count}, update_time = NOW() WHERE sku_id = #{skuId} AND available_stock >= #{count}只要available_stock >= #{count}不成立,更新影响行数就是 0,应用层据此抛出“库存不足”,回滚整个事务。这套条件更新方案比先查后改再 update 的常规写法在并发安全上有质的提升,也是这套源码里最值得抄走的一段。
4.3 事务边界:积分、库存、订单必须在同一个事务里
积分冻结、库存锁定、订单插入这三步操作必须放在同一个事务内,要么全部成功,要么全部回滚。026 版本的事务边界很清晰,@Transactional(rollbackFor = Exception.class)标注在createExchangeOrder方法上,方法内部不包含任何远程调用和 MQ 发送,因为远程调用的不可控时间会拉长事务锁的持有时间,导致数据库连接池被占满。
MQ 消息的发送放在事务提交之后,通过TransactionSynchronizationManager.registerSynchronization注册事务同步回调来实现。如果事务提交成功但消息发送失败,则由定时任务扫描订单表里“已兑换但未通知”的记录做补偿,这个设计兼顾了实时性和最终一致性,比 MQ 事务消息要简单得多。
4.4 幂等防重:请求流水号贯穿始终
前端在用户点击兑换按钮时就会生成一个requestNo(时间戳加随机数),后端拿到这个号后先在exchange_order表的request_no唯一索引上尝试插入,如果插入报唯一键冲突,直接返回原有订单数据,用户不会看到重复下单。这个方案的思路是:与其用 Redis 锁来判断“这个请求是不是处理过”,不如让数据库自己说话,唯一索引就是最可靠的幂等器。
实测下来,这个方案在重复请求场景下非常稳,但有一个细节要注意:request_no必须在订单主表创建时就确定,而不能等订单号生成之后再回填,否则并发重复请求可能在订单号生成阶段产生两笔不同的订单,兜底失效。
4.5 失败回滚与超时释放
用户兑换成功但不去领取,库存就会一直被锁定。026 版本里有一个定时任务,每 5 分钟扫描一次状态为“已兑换待领取”且超过 24 小时未核销的订单,自动将订单状态置为“已过期”,同时回补可用库存和冻结积分。回补同样使用条件更新并用状态字段作为判断条件,例如UPDATE exchange_order SET status = 5 WHERE id = ? AND status = 2,确保并发场景下不会重复回补。这段逻辑虽然不起眼,但它是系统长期稳定运行的保险丝。
5. 防刷治理与对账保障:线上运营的隐形防线
5.1 签到和任务接口的刷量问题
积分系统最怕的不是并发超卖,而是被脚本刷积分。签到接口如果只做“当天是否已签到”校验,脚本完全可以注册一万个账号批量签到。026 版本的防刷方案分三层:第一层是同一个 IP 的接口限流,用 RedisINCR加过期时间实现;第二层是同一个设备指纹限频,前端在请求头里带上设备的 canvas 指纹,后端针对指纹限流;第三层是行为风控,如果某账号在短时间内连续签到超过正常速度(比如 1 秒内完成 3 次签到),判定为异常账号,自动触发人工复核和积分冻结。
这三层方案单看都不复杂,难的是组合使用的时机。以我实际运行的经验来说,IP 限流最容易误伤公司同一出口 IP 下的真实员工,所以限流阈值要设置得比正常使用高 3 到 5 倍,重点还是依赖设备指纹这一层。
5.2 积分异常波动检测
除了接口层的防刷,026 版本里还做了一个积分的“异常波动预警”功能。它并不是实时拦截,而是每天凌晨对用户积分异动进行统计,把“当天积分净增超过 2000”或“一周内积分来源全部集中在单一任务”的用户筛选出来,生成名单推给运营人员和制后台。这样做的原因是,有些刷量行为分散在几天内,网关限流根本拦不住,必须依靠事后分析来收敛。
如果你要在这个基础上继续扩展,可以接入更复杂的规则引擎,但前期用一个凌晨 Job 加规则表就够了,不要一上来就上流计算,成本高且收益有限。
5.3 每日对账任务
对账是积分系统绝对不能省的一环。026 版本里的PointsReconcileJob每天凌晨 2 点执行,核心校验逻辑分三步:
- 汇总
points_detail中当天的正向流水和逆向流水,计算出所有用户的理论余额变动 - 对比
points_wallet.balance的实际变动值 - 用 MySQL 的临时表 +
HAVING语句一次性找出不一致的用户,写入告警表并通过邮件/企业微信通知
这个 Job 不是简单地“查一遍”,它必须能支持“流水多、余额不对”这类敏感场景。实现上有个小技巧:因为points_detail里有balance_after字段,可以通过窗口函数LAG(balance_after)对比相邻两条流水的连续性,任何断档都意味着丢流水,这是只对总数察觉不到的问题。
5.4 性能瓶颈与热点处理
小体量的积分商城并不需要太复杂的性能优化,但有一个问题在零食自选场景里会特别明显:某些热门零食(比如某品牌的薯片)被大量用户同时加进自选清单,库存查询压力会非常集中。026 版本的处理方式是把热门 SKU 的库存信息缓存到 Redis,缓存 key 为stock:sku:{skuId},更新时先更新数据库,再删缓存,下单扣减时先用 Lua 脚本扣 Redis 缓存库存,再异步落库。这样热商品的查询压力被 Redis 扛住了,数据库只处理写入。
这个方案有个一致性问题:Redis 缓存和数据库库存可能在短时间内不一致。但因为下单后的校验最终还是会走数据库条件更新,所以不会真的超卖,最多是缓存里显示“有货”但实际下单时提示“库存不足”的尴尬,不过在零食自选这种场景下,完全可以接受。
6. 本地部署与二次开发指南
6.1 环境准备
把源码跑起来需要提前装好这些环境:JDK 17、Maven 3.8+、MySQL 8.0、Redis 6.2+、RabbitMQ 3.12+、Node.js 18+。如果你只是看后端逻辑,RabbitMQ 可以先不启动,代码里通过spring.rabbitmq.listener.auto-startup=false临时关掉监听,后续会漏掉 MQ 相关功能,但不影响核心兑换流程的调试。
需要注意 JDK 版本必须是 17 或以上,因为 Spring Boot 3.x 底层依赖了 Jakarta EE 9,JDK 8 编译运行会直接报错,这一步卡住了不少第一次接触这个项目的开发者。
6.2 后端启动步骤
数据库初始化在snack-server/sql/目录下,有两个脚本:init.sql建库建表,seed.sql写入演示数据。连接配置在application-dev.yml里改,包括 MySQL 账号密码、Redis 地址、RabbitMQ 连接信息。启动命令如下:
cd snack-server mvn clean package -DskipTests java -jar snack-admin/target/snack-admin.jar --spring.profiles.active=dev启动成功后访问http://localhost:8080/doc.html能看到整合了 Knife4j 的接口文档,所有接口都可以在线调试,这对二次开发非常友好。管理后台前端启动:
cd snack-admin-web npm install npm run dev默认管理员账号是admin,密码admin123;用户端测试账号是user01,密码123456,演示数据里预置了 100 积分,可以直接拿来体验完整兑换流程。
6.3 二次开发的三个建议
第一个建议是接真实积分系统时,把“积分扣减”做成可配置的实现。当前PointsDeductService是本地钱包直接扣减,如果业务方要求对接 SAP、CRM 或第三方积分平台,建议通过 Spring 策略模式新增一个实现类,把本地扣减和远程扣减解耦,不要改动上层订单服务。第二个建议是给points_detail表增加创建时间的MONTH分区,虽然现在数据量不大,但如果要做长期运营,流水表很快就会上千万行,提前分区能避免后期迁移的阵痛。第三个建议是自选规则的可配置化,目前 026 版本的自选额度是后端硬编码在配置中心的,建议把规则落到数据库表里,支持“每人每周限额”“不同等级用户不同额度”“指定品类不可兑换”等扩展,运营自由度会大很多。
6.4 上线前必须验证的三个场景
如果打算把这套源码拿到真实业务环境里,建议在上线前重点验证三个场景。第一个是用户重复点击兑换按钮,网络抖动导致同一个请求发了两遍,看最终是否只生成一笔订单、只扣一次积分,验证幂等逻辑是否生效。第二个是库存只剩 1 件时,10 个用户同时下单,看最终是不是只有一个人成功、其他请求全部返回库存不足且积分原路退回。第三个是模拟 RabbitMQ 挂掉的场景,看兑换主流程是否不受影响、待领取通知有没有延迟补偿。这三个场景跑通了,系统的核心稳定性就有保障了。
我个人的实际体会是,这套源码最值钱的部分不是精美的前端页面,也不是花哨的功能,而是那些写在 SQL 条件里的、写在事务边界里的、写在定时任务里的“防御性设计”。积分系统一旦上线,任何一笔多扣或少扣都会直接演变成客诉,而 026 版本用最朴素的数据库手段,把这些风险都控制在了可控范围内。如果你正在做类似的积分商城项目,把第四章的条件更新和第三章的幂等设计吃透,至少能少踩一半的坑。