简介这是一份基于ASP.NET的商城积分系统完整项目面向正在学习Web开发、会员营销系统或需要快速搭建积分功能的开发者与学生。项目覆盖会员注册、积分获取、商品兑换、等级特权、后台统计等完整业务闭环可直接运行调试也能作为课程设计或毕业设计参考。积分获取包括消费累计、注册奖励、活动参与、生日福利等管理端提供发放、兑换、查询与异常处理功能积分设有有效期可按商品兑换、折现抵扣或兑换配送售后等增值服务会员按累计积分划分青铜、白银、黄金等级并享有相应特权。压缩包共93个文件大小2.31MB主要包含C#代码文件35个cs、ASP.NET页面13个aspx、用户控件12个ascx、站点配置6个config、1个sitemap、母版页2个master并附带SQL Server数据库文件.mdf/.ldf与一套讲解PPT文件类型覆盖前端界面、业务逻辑、数据存储、页面复用与部署配置结构完整。已有3095人学习适合结合积分规则文档理解从数据库设计到界面交互的完整链路也可在此基础上扩展促销活动、多级会员、积分折现与安全风控等功能。1. 商城积分系统容易翻车的从来不是积分计算而是账务一致性做商城积分系统第一周很多同学容易踩同一个坑把积分当成一张余额表加积分就 UPDATE 一行扣积分再 UPDATE 一行。订单量小的时候看着没问题并发一上来负积分、重复赠送、对不上账全部跟着来最后只能拉数据手动改。商城积分系统的本质是一套账本不是余额。每一笔积分都要回答三个问题从哪来、到哪去、什么时候过期余额只是所有流水的汇总视图。只要账本结构先立住后面的攒积分、扣积分、过期清理都不会跑偏。下面按我实际搭过的方案拆开讲积分账户和流水怎么拆表、下单后怎么异步攒积分、抵扣为什么先冻结再扣减、并发和重复消费怎么防以及上线前怎么用状态机做一轮全链路验证。适合正在给商城加积分功能的同学也适合想把现有积分模块重构成能扛住对账的团队。2. 积分账本先拆表账户表、流水表和唯一约束一次设计到位2.1 为什么积分余额和积分明细必须分成两张表余额是一个“状态”流水是一系列“事件”。状态可以被覆盖、被回滚、被手工调整事件一旦落库就不应该变。积分系统要经得起对账就必须把“当前余额”和“历史流水”分开存。只放一张积分表的做法上线后一定会踩这三个问题一是无法追溯用户问“我上个月那 100 积分哪来的”你答不上来二是无法对账余额只反映当前值减错了找不出证据三是无法做有效期不同批次积分的过期时间不同不把每一笔拆开记录过期任务根本清不动。所以标准结构是两张表搭配使用账户表只管当前的可余额和冻结额度流水表记录每一笔变动。每次加积分或扣积分更新账户和插入流水放在同一个本地事务里两边同时成功或同时失败谁也不会缺谁。2.2 积分账户表和积分流水表建表 SQL 与字段参数先看账户表一个用户一行字段尽量精简CREATE TABLE t_point_account ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL COMMENT 用户ID一个用户一条账户记录, balance BIGINT NOT NULL DEFAULT 0 COMMENT 可用积分冻结时先从可用减去, frozen BIGINT NOT NULL DEFAULT 0 COMMENT 冻结积分下单抵扣时占用, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号每次UPDATE1, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分账户表;这里要特别注意balance和frozen的分工balance是用户当前真正能用的积分frozen是下单抵扣时被临时锁定的积分。冻结动作会把金额从balance减去、加到frozen所以账户表里并不需要再存一个“总积分”字段总积分随时可以用balance frozen算出来省去两处冗余。再看流水表这是整个积分系统里最重要的一张表CREATE TABLE t_point_transaction ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL COMMENT 用户ID, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型EARN攒积分/DEDUCT抵扣/REFUND退款退回/EXPIRE过期, biz_no VARCHAR(64) NOT NULL COMMENT 业务单号订单号、退款单号或调整单号, points BIGINT NOT NULL COMMENT 变动积分正数增加、负数扣减, status VARCHAR(20) NOT NULL DEFAULT AVAILABLE COMMENT AVAILABLE可用/FROZEN冻结中/CONSUMED已核销/EXPIRED已过期/CANCELLED已取消, expire_at datetime DEFAULT NULL COMMENT 该笔积分过期时间NULL表示不过期, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz (biz_type, biz_no), KEY idx_user_time (user_id, create_time), KEY idx_expire (status, expire_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT积分流水表;逐字段过一遍关键点。biz_type和biz_no是幂等防重的基础同一个业务单号只能产生同一类型的一条流水这是后面所有重复消费场景的第一道闸门所以必须建联合唯一索引。points用 BIGINT 是因为积分累计起来很快不建议用 INT正负号统一约定为“正数增加、负数扣减”对账脚本只需要 SUM 这一个字段。status不能省因为不是所有流水都是最终状态冻结中、已核销、已取消都要靠它驱动。expire_at是给过期任务用的有一个细节它应该挂在流水上而不是账户上。用户在不同时间攒的积分过期时间通常不一样只有按流水记录才能做到“先攒的先过期”。如果放在账户上整个账户只有一个过期时间后面清过期时你会发现完全没法按批次处理。2.3 唯一约束和索引幂等在数据库层拦住查询在索引上跑唯一约束uk_biz(user_type, biz_no)解决的是业务幂等它和消息队列的“至少一次”投递配套使用。很多团队在应用层做幂等先查流水存不存在不存在再插入这种做法在并发消息同时到达时依然会穿帮两个消费者都查到“不存在”结果都插进去了。正确姿势是把唯一约束建在数据库上重复插入直接抛DuplicateKeyException消费端捕获后当作幂等返回这才是真正的兜底。索引这边日常查询最频繁的两个方向要提前想清楚。一是“某个用户的积分明细”索引(user_id, create_time)足够支撑二是“扫过期积分”索引(status, expire_at)让WHERE statusAVAILABLE AND expire_at NOW()直接走索引避免定时任务把全表扫一遍。分库分表这件事我的建议是别在前期做。只要两张表都建了合理的索引单库里撑住几百万到上千万的积分流水问题不大积分流水的写入集中在订单完成那一刻读取量也不像订单列表那么夸张真到了单库写瓶颈再按user_id分片也不迟。3. 攒积分链路支付成功到积分入账用异步消息把交易解耦3.1 为什么不能在下单事务里同步加积分最常见的错误是把“加积分”直接写在下单主事务里。这等于把积分的可用性绑死在订单核心链路上积分服务万一慢一点、锁冲突多一点订单就跟着失败积分规则配置读取失败订单也可能被拖累。反过来如果订单事务提交后积分计算逻辑出了异常主事务回滚整个下单都受影响。积分这件事属于典型的“最终一致”场景。我一般这样做订单支付成功后先发一个业务事件由积分服务异步消费。订单主链路只保证交易成功不保证积分立刻入账积分服务处理失败就重试直到成功。用户看到的现象是“支付成功后几秒积分到账”而不是“支付时一起算完”。3.2 支付成功到积分入账事件发布、消息消费、更新账户支付成功回调里往消息队列发一条积分事件。注意事件里要带上算好的积分值而不是只带订单金额public class OrderPaidEvent { private Long userId; private String orderNo; private Long earnPoints; // 直接算好积分值消费端不再计算 private LocalDateTime expireAt; // 过期时间一起算好 } // 支付成功回调里调用发送后立即返回不要在这里写数据库 orderPaidEventPublisher.publish(new OrderPaidEvent( order.getUserId(), order.getOrderNo(), points, expireAt ));为什么要把earnPoints和expireAt提前算好放进事件因为积分规则是可以改的。假设今天做活动“消费 100 送 200 积分”明天活动下线恢复“消费 100 送 10 积分”如果消费端每次都重新读规则同一个订单在不同重试时间消费算出来的积分不一样对账就会出问题。所以规则在事件发出那一刻就固化消费端只负责落账。积分服务消费端这样处理Transactional(rollbackFor Exception.class) public void handleEarn(OrderPaidEvent event) { // 幂等biz_typeEARN、biz_no订单号唯一约束在数据库层拦截重复 try { pointTransactionMapper.insert(new PointTransaction( event.getUserId(), EARN, event.getOrderNo(), event.getEarnPoints(), AVAILABLE, event.getExpireAt() )); } catch (DuplicateKeyException e) { log.info(重复消费订单{}, event.getOrderNo()); return; } // 可用积分增加 accountMapper.addBalance(event.getUserId(), event.getEarnPoints()); }addBalance对应的 SQL 很简单UPDATE t_point_account SET balance balance #{points}, version version 1 WHERE user_id #{userId}这段逻辑要注意两点。一是先插流水、再更新余额两者必须在同一个事务里否则会出现“流水有了、余额没加”或者反过来这里的事务由 Spring 注解控制发生任何异常整体回滚。二是DuplicateKeyException一定要 catch 住再返回否则消息队列重试时会把异常抛出去无限重试且永远不成功。3.3 退款时怎么扣回反向流水而不是修改原流水订单退款时用户已经拿到的积分要回收。很多新手会直接 UPDATE 原流水把金额清零或者删掉这个做法会破坏流水连续性。正确做法是插入一笔新的负向流水来“冲正”Transactional(rollbackFor Exception.class) public void handleRefund(OrderRefundEvent event) { try { pointTransactionMapper.insert(new PointTransaction( event.getUserId(), REFUND, event.getRefundNo(), -event.getEarnPoints(), AVAILABLE, null )); } catch (DuplicateKeyException e) { return; // 同一退款单不会重复扣回 } accountMapper.subtractBalance(event.getUserId(), event.getEarnPoints()); }原 EARN 流水保留不动新插入的 REFUND 负流水把余额扣回来。这样对账时能看到两个动作正积分 100、退款冲回 -100余额变化和流水 SUM 完全闭环。REFUND 流水同样要挂唯一约束防止退款消息重复投递导致积分被扣两次。4. 扣积分链路冻结、确认扣减、解冻三段式搭配定时过期清理4.1 为什么积分抵扣要冻结而不是直接扣用户下单时勾选“用积分抵扣 10 元”如果这时直接把积分扣掉订单支付失败、超时关闭、用户取消订单时又得把积分加回来。一扣一回之间如果断了积分就莫名其妙消失了。直接扣的做法缺少中间态无法回答“这笔积分现在到底算谁的”这个问题。所以要做成三段式先冻结把可用积分转入冻结额度订单支付成功再确认扣减订单关闭或支付超时解冻回补。冻结、确认扣减、解冻对应不同的业务动作也各自留下流水记录。状态机立住了后续所有异常场景都有据可查。4.2 冻结、确认扣减、解冻的最小实现冻结在“提交订单”时触发。先插入一条 FREEZE 流水再更新账户两条语句同一事务Transactional(rollbackFor Exception.class) public void freeze(Long userId, Long points, String orderNo) { // 幂等同一订单不能重复冻结 try { pointTransactionMapper.insert(new PointTransaction( userId, FREEZE, orderNo, -points, FROZEN, null )); } catch (DuplicateKeyException e) { return; } int affected accountMapper.freeze(userId, points); // UPDATE t_point_account // SET balance balance - #{points}, // frozen frozen #{points}, // version version 1 // WHERE user_id #{userId} AND balance #{points} if (affected 0) { throw new InsufficientPointsException(可用积分不足); } }注意这条 UPDATE 的条件balance #{points}这是整个扣积分链路里最容易抄错的点。它保证“先检查、再扣减”在一条 SQL 里原子完成多个订单并发扣同一账户时数据库行锁会让第二个 UPDATE 阻塞等第一个事务提交后再按最新余额判断从根上杜绝超扣。订单支付成功回调里做确认扣减Transactional(rollbackFor Exception.class) public void confirmDeduct(Long userId, String orderNo) { PointTransaction frozenTx pointTransactionMapper.findByBiz(FREEZE, orderNo); if (frozenTx null || !FROZEN.equals(frozenTx.getStatus())) { return; // 没有冻结记录或已处理过 } int affected accountMapper.releaseFrozen(userId, frozenTx.getPoints()); // UPDATE t_point_account // SET frozen frozen - #{points} // WHERE user_id #{userId} AND frozen #{points} if (affected 0) { throw new IllegalStateException(冻结金额不足); } pointTransactionMapper.updateStatus(frozenTx.getId(), CONSUMED); }确认扣减的动作就是把冻结从账户里清掉同时把 FREEZE 流水状态改成 CONSUMED表示这笔额度已经被消费后面不会再退回。如果订单关闭则走解冻Transactional(rollbackFor Exception.class) public void unfreeze(Long userId, String orderNo) { PointTransaction frozenTx pointTransactionMapper.findByBiz(FREEZE, orderNo); if (frozenTx null || !FROZEN.equals(frozenTx.getStatus())) { return; } int affected accountMapper.unfreeze(userId, frozenTx.getPoints()); // UPDATE t_point_account // SET balance balance #{points}, // frozen frozen - #{points} // WHERE user_id #{userId} AND frozen #{points} if (affected 0) { throw new IllegalStateException(冻结金额不足); } pointTransactionMapper.updateStatus(frozenTx.getId(), CANCELLED); pointTransactionMapper.insert(new PointTransaction( userId, UNFREEZE, orderNo, frozenTx.getPoints(), CONSUMED, null )); }解冻时把金额从冻结挪回可用同时把原 FREEZE 流水置为 CANCELLED再插一条正的 UNFREEZE 流水。有人会觉得两条流水麻烦但这是对账的关键FREEZE 负流水解释余额为什么少了UNFREEZE 正流水解释余额为什么又回来了一正一负刚好平账。4.3 过期积分定时扫描标记过期而不是物理删除附参数与批量边界过期处理不删除数据把流水状态从 AVAILABLE 改成 EXPIRED并同步扣减账户余额。删除会让“用户历史积分明细”出现空洞对账、客服、审计全部受影响。定时任务代码Scheduled(cron 0 0 2 * * ?) // 每天凌晨 2 点执行 public void expireScan() { if (!distributedLock.tryLock(POINT_EXPIRE_ LocalDate.now(), 1, TimeUnit.HOURS)) { return; // 今天已经跑过或有其他实例在执行 } while (true) { ListPointTransaction batch pointTransactionMapper.scanExpired(500); // SELECT id, user_id, points FROM t_point_transaction // WHERE statusAVAILABLE AND expire_at NOW() // ORDER BY expire_at // LIMIT #{size} FOR UPDATE SKIP LOCKED if (batch.isEmpty()) { break; } for (PointTransaction tx : batch) { expireOne(tx); // 事务内流水改EXPIRED 账户扣减余额 } } }参数上我建议三处按自己的量级调。第一执行时间选凌晨 2 点前后避开下单高峰和支付回调高峰。第二单批数量 500 不要贪大因为每条明细的处理都涉及一次事务提交批太大反而增加锁持有时间。第三如果 MySQL 版本支持FOR UPDATE SKIP LOCKED多实例部署时也能确保同一条明细不会被两台机器同时扫到不支持的话就老老实实依赖分布式锁串行执行。过期提醒可以单独做不用跟过期处理混在同一个任务里。提前 7 天扫描expire_at在NOW()和NOW()7天之间的 AVAILABLE 流水给用户发通知。提醒和过期是两个任务、两把锁互不干扰。5. 商城积分系统避坑手册并发负数、重复发放、对账不平逐个拆5.1 同一个用户的两个订单同时扣积分余额被扣成负数现象用户积分余额还有 100两个订单同时提交且都勾选了积分抵扣结果两单都抵扣成功余额变成负数。原因并发代码写成“先查询余额再判断余额够不够最后 UPDATE”。两个请求同时读到余额 100各自都判断通过然后按旧值覆盖写入实际上只减了一次。解决把所有扣减都改成带条件的原子 UPDATE也就是WHERE user_id? AND balance ?。这一条 SQL 同时完成检查和扣减数据库行锁保证同一用户的扣减操作串行执行。affected 0时直接抛“可用积分不足”业务层做相应提示而不是继续往下走。version字段在这里是辅助手段用来发现异常覆盖核心防线永远是 SQL 里的条件。5.2 消息队列把同一个支付成功事件投递两次积分就发了两份现象后台流水里同一个订单有两条 EARN 记录用户积分多拿了一倍。原因消息队列的投递语义是“至少一次”网络抖动、消费者重启都会导致重复投递。消费端如果靠“先查有没有再插入”来防重两次消息并行到达时两个查询都返回不存在于是都插入成功。解决把幂等放在数据库层也就是第 2 章建的uk_biz(biz_type, biz_no)唯一索引。消费端直接 INSERT重复投递的那条消息触发DuplicateKeyExceptioncatch 住然后返回。不要用 Redis 做幂等消息堆积重放时 Redis 里的 key 可能已经过期了数据库唯一约束才是持久化兜底。5.3 账户余额和流水 SUM 对不上差了一截怎么都找不到现象对账脚本把流水的 points 按用户求和和账户余额比对发现一部分用户对不上差值还很随机。原因最常见是上线初期为了赶活动加了一个“手工调整积分”的功能直接 UPDATE 账户余额但不写流水。还有早期代码把“先扣余额、后插流水”写在两个事务里流水插入失败但余额已经扣了没人发现。解决两条线同时堵。设计上强制规定任何余额变动必须有对应流水手工调整也生成 ADJUST 流水执行上每天跑一个对账任务低峰期执行如下 SQLSELECT user_id, SUM(points) FROM t_point_transaction GROUP BY user_id把结果和账户表balance比对差异数据落到告警表第二天上班处理。这个任务不用实时跑每天一次足够重点是“不平必须有人看”。5.4 定时任务重复执行把过期积分扫了两遍余额越扣越少现象某天手动触发了一次过期任务但凌晨的自动任务又跑了结果同一批过期流水被处理两次账户余额被扣成负数。原因第一次扫描时流水状态已经改成 EXPIRED第二次扫描的 WHERE 条件理论上应该扫不到但如果第一次事务回滚了状态没改掉或者分布式锁没生效、两台机器同时在跑重复处理就会出现。更隐蔽的情况是批量任务里状态更新和余额扣减不在同一个事务中状态提交了余额没扣下次扫描又把这条捞出来再扣一遍。解决双重保险。第一层扫描条件永远带上statusAVAILABLE流水状态改成 EXPIRED 和余额扣减必须同一个事务第二层任务入口加分布式锁锁 key 带执行日期比如POINT_EXPIRE_2024-12-01确保一天只跑一次。单条处理失败时记录日志并继续不要中断整个批次但必须留出重试入口。6. 用积分状态机做上线前全链路验证把冻结、确认扣减、解冻、过期这些动作收拢在一起其实就是一张状态机。所有积分流水都从 AVAILABLE 出发按业务事件流转当前状态触发事件下一状态AVAILABLE用户下单抵扣FROZENFROZEN订单支付成功CONSUMEDFROZEN订单关闭/超时CANCELLED回补可用积分AVAILABLE过期时间到EXPIRED状态机的核心约束是每个状态变更都必须有对应的业务单号并且必须同时落在流水表上绝不能只改账户余额。只有余额变化没有流水记录的操作等于在对账时埋雷。上线前建议按这三个场景各跑一遍回归正常抵扣链路下单冻结、支付成功确认扣减、查余额和流水是否一致下单后关闭链路下单冻结、订单超时、解冻回补、确认 FREEZE 和 UNFREEZE 一负一正两条流水都在退款冲正链路支付送积分、发起退款、负流水冲回、余额正确减少。我上一个负责的商城项目就是因为最初没有把状态机定清楚各个业务方各自按自己的方式处理积分到后期出现同一个订单被两种操作各处理一次的问题整个区间无法复核。后来强制所有操作都走这套状态流转才真正稳定下来。你上线前如果先把这张状态流转图画出来再结合唯一约束和冻结扣减一起看基本能避开一大半坑。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?