简介:这是一套面向商城积分业务的ASP.NET项目源码与学习资料,适合正在做会员体系或毕业设计的学生、初级开发人员参考。资源围绕积分获取、积分管理、商品兑换、会员等级与后台维护等核心模块展开,配套数据库、页面和配置文件,可帮助理解电商会员积分系统的完整实现流程。包体共93个文件,以cs后端逻辑、aspx/ascx页面与用户控件、config配置文件为主,另含mdf/ldf数据库备份和演示PPT,整体仅2.31MB,便于下载与本地部署。已有3095人浏览学习。通过源码可掌握积分发放与扣减算法、兑换规则设置、会员特权升级、后台数据统计等关键实现,也能借鉴其目录结构与界面设计,直接用于课程设计或小型商城项目改造。
1. 商城积分系统不是记数字:它是一套需要幂等与对账的资产账本
拿到“商城积分系统”需求时,多数人第一反应是把一张user_id, points表做增删改查。可你真的按这个思路上线,等第一个大促节点就会遇到一个诡异现场:用户积分后台显示对账不平,同一笔订单被上游回调通知了三次,积分就发了三份。积分系统本质是一套资产账本,不是记分牌。注册送、签到送、下单返只是入口,真正决定它能跑多久的是账户模型、流水约束、幂等控制和扣减条件。我在拆这套商城积分系统源码时,把完整链路从头到尾过了一遍,这篇笔记就讲清楚这四件事怎么做、参数怎么调、哪里有坑。
2. 先把账本设计想清楚:四张表和两条线的边界约束
积分账本的核心是“账户余额”和“明细流水”两条线。余额线负责快照当前可用值,流水线负责记录每一次变动。两份数据必须能互相校验,而不是靠某一张表单独支撑。我拆这套源码包时,先是把sql/init.sql里的建表语句翻了一遍,整理出的核心表有账户表、明细流水表、规则表和批次表,下面逐个说设计意图。
2.1 账户表:为什么“可用”和“冻结”必须拆成两个字段
积分消费最怕业务做了一半,最后订单取消,积分被扣了又得补回来。如果用“先扣全部,再回补”的做法,流程里一旦中间环节断掉,积分就丢了。源码包里账户表用了可用积分与冻结积分两个字段,把“锁定中”和“可花”分隔开。
CREATE TABLE `t_point_account` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '用户ID,多租户场景建议再联合tenant_id', `available` bigint NOT NULL DEFAULT '0' COMMENT '可用积分,参与扣减判断的唯一余额', `frozen` bigint NOT NULL DEFAULT '0' COMMENT '冻结积分,抵现发起后订单未确认前占用', `total_earn` bigint NOT NULL DEFAULT '0' COMMENT '累计获得积分,只累加不减', `total_spend` bigint NOT NULL DEFAULT '0' COMMENT '累计消耗积分,只累加不减', `status` tinyint NOT NULL DEFAULT '1' COMMENT '账户状态:1正常,0冻结', `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,并发更新时作为安全条件', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user` (`user_id`) ) ENGINE=InnoDB;逻辑说明:所有扣减动作只判断available,冻结部分不参与消费判断。total_earn和total_spend不作为余额计算依据,只给后台审计提供只读指标,每次变动都通过更新 SQL 在同一行完成。version字段在并发更新时参与条件判断,比如更新时带上version = #{oldVersion},确保自己修改的不是别人已经改过的数据。
参数说明:积分字段我建议直接用bigint,虽然看起来浪费,但业务一旦发到上千万,int的 21 亿上限并不遥远。status字段保留 0 值,用于风控冻结用户账户,不要删除字段,否则后续出问题只能靠删数据。
2.2 流水表:不可变记账,用唯一键挡住重复请求
流水是积分系统的审计底座。每次余额变化都要写一条流水,业务单号在流水表里必须唯一,否则重复请求就是漏洞。
CREATE TABLE `t_point_detail` ( `id` bigint NOT NULL AUTO_INCREMENT, `detail_no` varchar(40) NOT NULL COMMENT '流水号,建议雪花ID或日期+自增组合', `user_id` bigint NOT NULL, `biz_type` varchar(20) NOT NULL COMMENT '业务类型:EARN_ORDER/EARN_SIGN/CONSUME_REDEEM/EXPIRE/FREEZE/UNFREEZE', `biz_id` varchar(64) NOT NULL COMMENT '外部业务单号,如订单号、签到日期、活动编号', `change_amount` bigint NOT NULL COMMENT '变化量,正数为增加,负数为扣减', `balance_after` bigint NOT NULL COMMENT '操作完成后可用积分快照', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz` (`biz_type`, `biz_id`), KEY `idx_user_time` (`user_id`, `create_time`) ) ENGINE=InnoDB;逻辑说明:uk_biz唯一索引是幂等控制的第一道闸。同一订单编号重复发送发放消息时,第二次插入直接触发唯一键冲突,业务层捕获后正常返回。balance_after字段记的是变动后的可用积分快照,这是后面写对账脚本时最核心的对照值。流水表不允许做更新操作,如果发现写错了,纠正方式是再写一条冲正流水,而不是改原纪录。这个规矩能保证流水永远是历史事实,不会被事后篡改污染对账结果。
2.3 规则表:返积分比例做成配置,不写死在代码里
商城积分系统的积分规则变化频繁,大促期间运营可能要调整返积分比例,开发不应该为改一个数字发一次版。源码包里把规则拆到了t_point_rule表,并对接了一个简单的规则匹配引擎。
CREATE TABLE `t_point_rule` ( `rule_id` bigint NOT NULL AUTO_INCREMENT, `rule_type` varchar(30) NOT NULL COMMENT '规则类型:SIGN/ORDER/REGISTER/ACTIVITY', `base_line` decimal(10,2) NOT NULL COMMENT '触发基线,例如实付满100元才参与返积分', `rate` decimal(6,4) NOT NULL COMMENT '返积分比例,0.0100代表1%,即100元返1积分', `upper_limit` bigint NOT NULL DEFAULT '0' COMMENT '单笔返积分上限,0表示不限', `status` tinyint NOT NULL DEFAULT '1', `create_time` datetime NOT NULL, PRIMARY KEY (`rule_id`) ) ENGINE=InnoDB;规则匹配伪代码如下,这段逻辑在源码包的rule-engine模块里可以直接看到:
public long computeEarn(long userId, BigDecimal orderAmount) { PointRule rule = ruleRepository.matchRule("ORDER", userId); if (rule == null) { return 0L; } if (orderAmount.compareTo(rule.getBaseLine()) < 0) { return 0L; } BigDecimal earn = orderAmount.multiply(rule.getRate()).setScale(0, RoundingMode.DOWN); if (rule.getUpperLimit() > 0) { return Math.min(earn.longValue(), rule.getUpperLimit()); } return earn.longValue(); }逻辑说明:base_line做门槛过滤,实际返积分计算按订单金额与rate相乘,最终用向下取整。这样做的好处是积分永远是整数,不会出现 100.5 分的情况。upper_limit控制单笔返积分的上限,避免大额订单一次性把预算打穿。
2.4 有效期与批次:积分不是总额,是不同过期时间的资产包
很多系统把过期时间放在账户表上,这会让所有积分享用同一个过期时间,不符合实际业务。常见做法是在账户与流水之间加一张积分批次表,按批次管理有效期,消费时优先消耗最早过期的批次,也就是先进先出。
CREATE TABLE `t_point_batch` ( `batch_id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `available_amount` bigint NOT NULL COMMENT '本批次剩余可用', `total_amount` bigint NOT NULL COMMENT '本批次原始积分总量', `expire_time` datetime NOT NULL COMMENT '本批次过期时间', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1可用,2已过期,3已耗尽', PRIMARY KEY (`batch_id`), KEY `idx_user_expire` (`user_id`, `expire_time`, `status`) ) ENGINE=InnoDB;逻辑说明:每次发放积分,往批次表插入一行;每次消费,按expire_time升序逐批扣减。这种方式能在过期任务里轻松定位“哪些批次已过期但还有可用量”,避免用一条用户级expire_time字段把所有积分一刀切。如果业务量不大,不想引入批次表,也可以在消费时按时间段聚合计算,但代码复杂度会上来不少。
3. 核心链路:下单送积分的幂等,抵现扣减的并发控制
账本结构定好之后,重点落在两条主链路上:积分怎么发出去,积分怎么扣得安全。这两条链路覆盖了商城积分系统里 80% 的并发场景。
3.1 下单送积分:消息驱动,先写流水再更账户
积分发放如果直接放在订单支付回调里同步执行,回调重试时就会重复发放。源码包里用了消费订单支付成功消息的方式,把发放逻辑做成消息消费者,利用流水表的唯一键挡掉重复消息。
@Component @Slf4j public class OrderPaidConsumer { @Autowired private PointDetailMapper pointDetailMapper; @Autowired private PointAccountMapper pointAccountMapper; @Autowired private RuleEngineService ruleEngineService; @Transactional(rollbackFor = Exception.class) public void onOrderPaid(OrderPaidEvent event) { long earn = ruleEngineService.computeEarn( event.getUserId(), event.getOrderAmount()); if (earn <= 0) { return; } PointDetail detail = new PointDetail(); detail.setDetailNo(snowflake.next()); detail.setUserId(event.getUserId()); detail.setBizType("EARN_ORDER"); detail.setBizId(event.getOrderNo()); detail.setChangeAmount(earn); try { pointDetailMapper.insert(detail); } catch (DuplicateKeyException e) { // 同一个订单号已经处理过,重复消息直接忽略 return; } int rows = pointAccountMapper.addAvailableAndTotalEarn( event.getUserId(), earn); if (rows != 1) { throw new IllegalStateException("account update failed"); } } }逻辑说明:核心顺序是先插流水,再更新账户。流水插入成功,代表这条业务单号从未被处理过;账户更新失败时抛异常回滚,消息队列重新投递后,流水插入再次触发唯一键冲突,直接返回,不会重复加积分。如果先更新账户再写流水,账户更新成功但流水插入失败,账就永远对不上了。
参数说明:EARN_ORDER是业务类型,orderNo是幂等键,两者组合成唯一索引。消息队列里的业务 ID 一定要用订单号,不能用消息自身的 messageId,因为上游重试时每次生成的 messageId 都不同,无法做幂等。
3.2 抵现扣减:把“余量足够”写进 update 条件
积分抵现走的是扣减链路,最容易出现的问题是用户在高并发场景下两次请求同时读到余额足够,最后扣成负数。常见做法是不用 select 判断余额再 update,而是把余额判断直接写进 update 语句的条件里。
UPDATE t_point_account SET available = available - #{cost}, total_spend = total_spend + #{cost}, version = version + 1, update_time = NOW() WHERE user_id = #{userId} AND available >= #{cost} AND status = 1;逻辑说明:available >= #{cost}是执行时的真实判断条件,数据库行锁会让第二个并发请求在第一个请求提交后再执行,此时available已经被扣减,条件不满足,影响行数返回 0。代码层拿到返回 0,就知道这次扣减失败,返回“积分不足”即可,不需要分布式锁。
参数说明:cost必须是正整数,业务层要拦截传入负数的情况。如果走冻结逻辑,那么这一步是先把可用积分转入冻结积分,SQL 条件仍然是available >= cost,但扣减字段要换成同步扣available并加frozen。
3.3 并发热点用户的 Redis 预扣:快路径与最终结果分离
积分扣减如果只走数据库,热点用户在秒杀场景下会产生大量行锁等待。进阶做法是把可用余额在 Redis 里放一份,扣减先走 Lua 脚本,再把结果异步落库。源码包的redis-acc模块实现了这段脚本。
-- KEYS[1] 是用户积分账户的Redis Key -- ARGV[1] 是本次要扣减的积分数量 local key = 'point:account:' .. KEYS[1] local available = tonumber(redis.call('HGET', key, 'available')) if not available or available < tonumber(ARGV[1]) then return 0 end redis.call('HINCRBY', key, 'available', -tonumber(ARGV[1])) return 1逻辑说明:Lua 脚本在 Redis 里是原子执行的,不会出现两个请求同时通过判断的情况。扣减成功后,业务再把扣减明细写入数据库,并对账户表做最终扣减。Redis 里的值只作为热路径预扣,最终以 MySQL 流水为准。
参数说明:这个方案适合高净值、高频操作的用户。Redis 与 MySQL 的数据一致性要额外做补偿任务,每 5 分钟扫描一次差异,把 Redis 值与数据库流水汇总结果对齐。如果你们是中小型商城,QPS 没到单用户每秒几次的规模,没有必上 Redis,直接更新数据库反而更可靠。
3.4 过期清理:把过期当成一条扣减流水处理
积分过期不是简单的字段置零,它是一个业务行为,需要产生流水,让用户后台能看到“本期过期xx积分”。源码包的expire-job模块跑一个定时任务,找出已过期批次,按用户维度生成 EXPIRE 流水并扣减余额。
public void doExpire() { List<PointBatch> expiredBatches = batchMapper.listExpired( LocalDateTime.now(), PAGE_SIZE); for (PointBatch batch : expiredBatches) { if (batch.getAvailableAmount() == 0) { batchMapper.markExpired(batch.getBatchId()); continue; } try { expireService.expireBatch(batch); } catch (DuplicateKeyException e) { // 该批次过期流水已生成,跳过 } } }逻辑说明:清理任务跑批时必须分页处理,否则单次查询数据量过大,任务执行时间一长,下一次任务叠上来又把同一批数据处理一遍。每一批数据生成 EXPIRE 流水,biz_id用批次号,靠流水表唯一键保证重复跑批不会重复扣积分。
参数说明:过期时间建议统一用expire_time <= now(),不要用equals或者按自然日截断,避免数据库时间和应用服务器时间存在秒级偏差,导致跨零点边界漏扫。
4. 积分系统的四个常见问题:现象、根因与排查顺序
这一章是从实际运行中踩过的坑整理出来的,四个问题基本覆盖了积分系统上线后最容易翻车的场景。每一条都按“现象、原因、解决”来展开,可以直接对照排查。
4.1 重复发放:同一个订单给用户加了两次积分
现象:用户反馈账户里积分多了,后台按订单号查积分明细,发现同一个订单号存在两条EARN_ORDER流水。
原因:支付回调接口被上游重复通知,或者补偿任务扫到了已经发放过的订单。发放逻辑里没有把订单号做成唯一键,单纯靠判断订单支付状态并不可靠,因为状态在两次通知之间可能都处于“已支付”。
解决:给流水表加(biz_type, biz_id)唯一索引,发放逻辑必须把“写流水”放在最前面。第二次插入触发DuplicateKeyException时直接忽略。补偿任务即使重复扫描,也无法在流水表插进第二条数据。
4.2 并发抵现导致可用积分变负数
现象:一个用户在两台设备同时下单,都选择积分抵现,最后账户available余额变成负数。
原因:代码用了“先查询余额,判断足够,再更新扣减”的三段式逻辑。两个并发请求同时读出可用积分 120,都认为抵现 100 可以执行,先后更新时就出现了一 20。
解决:把余额判断写进 update 语句的条件里,WHERE available >= #{cost}。数据库行锁保证第二个请求执行时重新检查条件,它看到余额不足就返回 0,不会扣成负数。这是一个语法层面的改动,却能彻底解决读改写竞态。
4.3 过期积分像薛定谔的猫:有时能用,有时下一秒才清零
现象:用户看积分有效期当天还可以用,但下单抵扣时提示积分不足;第二天发现积分被清空了,后台流水却没有对应记录。
原因:过期清理任务与用户实时查询不在同一条时间线上。清理任务按expire_time <= now()扫描,但任务运行时用户正好在下单,读取的余额还没来得及被清理任务处理。两边都对,但给用户的感觉就是积分“被偷走”了。
解决:在用户实时积分查询与消费接口里加一道“懒过期”逻辑,先检查最近批次是否已过expire_time,如果是则先执行过期扣减,再返回剩余可用积分。这样清理任务负责批量兜底,实时接口负责精确兜底,两边数据不会打架。
4.4 对账不平:可用余额与流水累计值对不上
现象:每日对账任务发现部分用户available与SUM(change_amount)差值对不上,多一笔或少一笔。
原因:最常见的是事务边界没包住。账户更新成功但流水插入失败,或者反过来。事务只包住了更新语句,流水表插入放在事务外。
解决:将“写流水”和“改余额”收敛到同一个@Transactional方法里,两个操作要么都成功,要么都回滚。对已经脏掉的数据,写一个对账修复脚本,按用户汇总流水,反推账户应得余额,再按差值生成一条ADJUST类型流水修正。这个修正流水要把balance_after设置为修正后的快照值。
5. 把“能用”升级成“稳”:对账脚本与并发压测参数
最后分享一个我每次上线前都会执行的验证习惯。先把积分对账脚本放进定时任务,每天凌晨跑一次,把所有对不上的用户捞出来。这里的关键是 SQL 怎么写得更高效,避免全表关联把库拖垮。
SELECT a.user_id, a.available AS acct_available, COALESCE(d.calc_balance, 0) AS calc_balance FROM t_point_account a LEFT JOIN ( SELECT user_id, SUM(change_amount) AS calc_balance FROM t_point_detail WHERE create_time >= #{startTime} GROUP BY user_id ) d ON a.user_id = d.user_id WHERE a.available <> COALESCE(d.calc_balance, 0) LIMIT 100;逻辑说明:这条 SQL 统计的是时间窗口内的流量差。如果账户是从零开始建号的,窗口起始时间取账户创建时间即可;如果系统迁移过老数据,则要在calc_balance上加上期初导入余额。对账结果不为空时,优先检查最近一小时有没有跑批任务或补偿脚本在执行。
压测参数方面,我用过简单有效的组合:一台 4 核 8G 的服务器节点,并发线程 50,每个线程循环 1000 次扣减接口调用。重点不是看 QPS 有多高,而是看压测结束后数据库里有没有出现负数余额或者流水缺失。压测时不要只并发不同用户,要专门用同一用户反复砸接口,这才是最容易暴露竞态的场景。
如果需要验证幂等效果,可以直接把订单支付消息重复投递两次或三次,观察积分是否只增加了一次。这套源码包里带了一个 mock 订单回调工具,改一下配置就能模拟重复通知。
我第一次做商城积分系统时,图省事把发积分逻辑直接写在支付回调里,没有做消息队列和幂等设计。结果上线第二天就遇到上游重复通知,导致一批订单积分翻倍,只能写脚本批量修正。从那以后,我每次给积分相关代码上线,都强制先跑一遍对账脚本和同一用户的并发压测,两道全绿再切流量。希望帮到你。
本文还有配套的精品资源,点击获取