
简介基于.NET Framework与C#开发的积分消费系统完整项目面向需要设计企业积分平台的开发人员解决积分获取、存储、查询、兑换及规则管理等核心业务问题。系统采用ASP.NET MVC架构分层清晰可在现有用户管理与日志记录基础上快速扩展新规则。压缩包约38.97MB包含2000个文件以206个C#源码、181个ASP.NET页面、145个JavaScript脚本和70个CSS样式为主另有大量图片素材及数据库文件、动态库覆盖前端交互、后端逻辑与数据存储完整链路。已有72人学习下载。项目内集成了用户管理、积分获取、消费、查询、规则管理、日志记录等模块并附带数据库脚本、升级记录和工程文件适合课程设计、毕业设计或企业积分系统二次开发参考。1. net积分消费系统究竟是什么一个源码包背后的核心链路在搜索栏敲下net积分消费系统大部分人是想拿到一个能跑的 C# 积分商城源码解压、改库、上线越快越好。这类 rar 压缩包通常装着用户、积分账户、流水、礼品和订单几张表配一个 ASP.NET 后台再配上积分充值、签到、兑换下单的主流程。它能跑但能跑和耐扣是两回事——并发一上来余额被扣成负数、对账对不平才是真正劝退人的地方。这篇文章会把这类系统最常见的落地路径拆开讲清楚先认业务表和字段再写消费扣减的事务再解决并发防超卖和对账最后把最容易踩的部署与编码坑列出来适合刚拿到源码看不明白、或准备从零自建积分体系的 .NET 开发者。2. 拆业务模型积分账户、流水和兑换订单这五张表怎么设计积分消费在外行眼里就是一个余额加减但真正在生产环境扛过事的系统不会把积分余额直接挂在用户表上随手 UPDATE。把余额、流水、兑换商品和订单拆成独立实体不是为了表多好看而是为了回答三个问题用户还剩多少分、这些分是怎么来的、兑换订单与余额变动如何对账。我一般会先看压缩包里的建表脚本如果只有一张用户表加积分字段这种源码包只适合演示不建议直接拿去上线。2.1 从发积分到花积分一条链上的五个核心实体首先要把用户表和积分账户表分开。用户表只存手机号、昵称、注册时间这类静态信息账户表专门管余额。这样后台运营调积分、用户并发下单、定时任务发积分时锁的粒度只在账户行上而不是把整个用户行锁住后续写高并发接口会轻松很多。账户表至少要有 Balance、TotalIncome、TotalExpense、Version 四个关键字段。Balance 表示当前可用积分两个 Total 字段是冗余汇总用来快速统计总共发了多少、花了多少。冗余的代价是每次写余额都要同步更新汇总但换来的收益是对账时不需要全表扫流水。注意如果业务没有千分之几这类小额积分直接用 BIGINT 存整数更省心避免把简单问题引到浮点精度上去非要支持小数就统一用 DECIMAL(18,2)C# 端用 decimal 对应别混用。然后是流水表。凡是余额变化必须插入一条流水记录变化类型、业务单号、变化金额和变动后余额。这个表是积分系统的黑匣子退款、解纠纷、对账全靠它。没有流水只有余额的系统一旦出现账实不符你连哪笔错了都定位不到。最后是商品表与订单表。商品表存积分价格、库存与上下架状态订单表存用户兑换记录、状态和幂等键。订单表必须有自己的幂等键这是防重复下单的关键。很多老源码包把订单主键直接用自增 ID客户端一重试就会生成两笔订单这个坑在后面的避坑记录里会专门展开。2.2 落地 SQL积分账本与兑换订单的建表脚本数据库选 SQL Server 在这类 .NET 项目里最常见建表脚本可以直接放进项目的 Scripts 目录做首次初始化。下面按从账户到订单的顺序建表。CREATE TABLE PointAccount ( UserId BIGINT PRIMARY KEY, Balance BIGINT NOT NULL DEFAULT 0, TotalIncome BIGINT NOT NULL DEFAULT 0, TotalExpense BIGINT NOT NULL DEFAULT 0, Version INT NOT NULL DEFAULT 0, UpdateTime DATETIME NOT NULL DEFAULT GETDATE() ); CREATE TABLE PointLedger ( LedgerId BIGINT IDENTITY PRIMARY KEY, UserId BIGINT NOT NULL, BizNo VARCHAR(64) NOT NULL, ChangeType TINYINT NOT NULL, Amount BIGINT NOT NULL, BalanceAfter BIGINT NOT NULL, Remark NVARCHAR(200) NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT UQ_Ledger_BizNo UNIQUE (BizNo) ); CREATE TABLE PointGoods ( GoodsId INT PRIMARY KEY, GoodsName NVARCHAR(100) NOT NULL, PointPrice BIGINT NOT NULL, Stock INT NOT NULL DEFAULT 0, Status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE PointOrder ( OrderId BIGINT IDENTITY PRIMARY KEY, OrderNo VARCHAR(32) NOT NULL, UserId BIGINT NOT NULL, GoodsId INT NOT NULL, Quantity INT NOT NULL, PointCost BIGINT NOT NULL, Status TINYINT NOT NULL DEFAULT 1, IdempotentKey VARCHAR(64) NOT NULL, CreateTime DATETIME NOT NULL DEFAULT GETDATE(), CONSTRAINT UQ_Order_OrderNo UNIQUE (OrderNo), CONSTRAINT UQ_Order_IdempotentKey UNIQUE (IdempotentKey) ); CREATE INDEX IX_Ledger_User_Create ON PointLedger(UserId, CreateTime); CREATE INDEX IX_Order_User_Create ON PointOrder(UserId, CreateTime);先解释两个容易误用的字段。ChangeType 建议定义成1 收入、2 消费支出、3 过期清减、4 撤销退回。Amount 永远存正数收入还是支出通过 ChangeType 区分不要在金额里写负号否则 SUM 聚合时很容易把人绕晕。BalanceAfter 是本次变动后的账户余额写入时由事务内最新余额填充这是对账的核心依据也是排查重复扣减时最直观的证据。BizNo 用全局唯一的业务单号。消费流程里直接放订单号取消订单时也复用同一个单号配合唯一约束可以挡住同一订单重复写流水。这里有个容易忽略的点SQL Server 的唯一索引允许多个 NULL所以 BinNo 在应用层必须校验非空不能指望数据库替你拦空值。同样IdempotentKey 的唯一约束只有和应用层必填校验配合才有意义光靠数据库挡不住客户端传空值。再补充一个对账提速的细节。PointLedger 和 PointOrder 都建议加(UserId, CreateTime)复合索引。老系统数据量到几十万条时没有索引的对账 SQL 会全表扫描凌晨定时任务从几分钟拖到几十分钟这个索引几乎是免费的早建早省。建完这五张表业务骨架就立住了。接下来要做的就是把这些表串进 C# 的业务代码里让一次积分消费从接口进来直到流水落库全程只有一个事务边界。这一步写对了后面并发问题至少能挡掉一半。3. 用 C# 编写消费主链路事务、幂等与订单状态表设计好后C# 端的核心不是页面也不是报表而是那个把扣积分、减库存、写订单、写流水包在一个事务里的 Service 方法。很多源码包在这里翻车扣积分一个方法、减库存一个方法、写订单又一个方法每个方法自己开连接自己提交结果线上出现积分扣了订单没生成的事故。处理这类问题的原则只有一个一个用户请求一个数据库事务要么全成功要么全回滚。3.1 Controller 到 Service 的调用链参数先做基础校验我用 ASP.NET Core Web API 加 Dapper 来演示这套写法在 .NET 6/8 和 .NET Framework 4.8 上都适用只是连接字符串的读取位置不同。Controller 层保持轻薄只做参数绑定和状态码返回核心逻辑全部下沉到 Service。[ApiController] [Route(api/points)] public class PointController : ControllerBase { private readonly PointService _service; public PointController(PointService service) _service service; [HttpPost(consume)] public async TaskIActionResult Consume([FromBody] ConsumeRequest req, CancellationToken ct) { if (string.IsNullOrWhiteSpace(req.IdempotentKey)) return BadRequest(new { error idempotentKey 不能为空 }); var result await _service.ConsumeAsync(req, ct); if (result.Duplicated) return Ok(new { code 200, message 重复请求订单已存在 }); if (result.Error ! null) return BadRequest(new { error result.Error }); return Ok(new { orderNo result.OrderNo }); } }ConsumeRequest 里包含 UserId、GoodsId、Quantity、IdempotentKey 四个字段。IdempotentKey 由前端在下单页面初始化时生成一次整个提交流程复用同一个值如果等点击下单再用 Guid.NewGuid 现场生成重试时换了 key幂等就等于没做。这个校验放在 Controller 最前面能挡掉一大部分空值请求数据库唯一约束只作为最后一道防线。为什么 Controller 里要区分 Duplicated 和 Error因为网络超时后客户端重试服务端应该把它当成正常成功返回而不是报错。用户看到的是下单成功订单也只有一个如果返回 500前端就会继续重试造成无意义的数据库压力。3.2 Service 事务边界扣积分、减库存、生成订单必须同生共死消费接口里最忌讳的是用多个 SqlConnection 分别执行操作因为每个连接各自提交根本无法保证原子性。正确做法是用一个连接、一个事务把读商品、插订单、扣账户、减库存、写流水全部串起来。public async TaskConsumeResult ConsumeAsync(ConsumeRequest req, CancellationToken ct) { await using var conn new SqlConnection(_connectionString); await conn.OpenAsync(ct); await using var tx (SqlTransaction)await conn.BeginTransactionAsync(ct); try { var goods await conn.QuerySingleOrDefaultAsyncPointGoods( SELECT GoodsId, PointPrice, Stock, Status FROM PointGoods WHERE GoodsId GoodsId AND Status 1, new { req.GoodsId }, tx); if (goods null) return new ConsumeResult { Error 商品不存在或已下架 }; long totalCost goods.PointPrice * req.Quantity; if (totalCost 0 || req.Quantity 0) return new ConsumeResult { Error 兑换数量或价格非法 }; string orderNo BuildOrderNo(req.UserId); await conn.ExecuteAsync( INSERT INTO PointOrder(OrderNo, UserId, GoodsId, Quantity, PointCost, Status, IdempotentKey) VALUES (OrderNo, UserId, GoodsId, Quantity, PointCost, 1, IdempotentKey), new { OrderNo orderNo, req.UserId, req.GoodsId, req.Quantity, PointCost totalCost, req.IdempotentKey }, tx); int accountRows await conn.ExecuteAsync( UPDATE PointAccount SET Balance Balance - PointCost, TotalExpense TotalExpense PointCost, Version Version 1, UpdateTime GETDATE() WHERE UserId UserId AND Balance PointCost, new { req.UserId, PointCost totalCost }, tx); if (accountRows 0) return new ConsumeResult { Error 积分余额不足 }; int stockRows await conn.ExecuteAsync( UPDATE PointGoods SET Stock Stock - Quantity WHERE GoodsId GoodsId AND Stock Quantity, new { req.GoodsId, req.Quantity }, tx); if (stockRows 0) return new ConsumeResult { Error 商品库存不足 }; long balanceAfter await conn.QuerySingleAsynclong( SELECT Balance FROM PointAccount WHERE UserId UserId, new { req.UserId }, tx); await conn.ExecuteAsync( INSERT INTO PointLedger(UserId, BizNo, ChangeType, Amount, BalanceAfter, Remark) VALUES (UserId, OrderNo, 2, PointCost, BalanceAfter, Remark), new { req.UserId, OrderNo orderNo, PointCost totalCost, BalanceAfter balanceAfter, Remark $兑换 {goods.GoodsName} x {req.Quantity} }, tx); await tx.CommitAsync(ct); return new ConsumeResult { OrderNo orderNo }; } catch (SqlException ex) when (ex.Number is 2601 or 2627) { await tx.RollbackAsync(ct); return new ConsumeResult { Duplicated true }; } catch { await tx.RollbackAsync(ct); throw; } }这个方法的顺序是有讲究的。先插入订单后扣账户是因为幂等冲突会最先暴露在订单表上如果先扣积分再插订单遇到重复请求时虽然事务会回滚但毕竟多跑了一圈 UPDATE在高并发下没必要。订单插入成功后后续任何一步失败整个事务回滚订单也会消失不会出现库存扣了订单没有的中间态。关键在于两个 UPDATE 都带条件校验。账户扣减用Balance PointCost库存扣减用Stock QuantityDapper 的 ExecuteAsync 返回受影响行数返回 0 就说明条件不满足。这样就把先查再扣的窗口期消灭在 SQL 层面后面第 4 章会专门讲这个并发点。注意到balanceAfter的查询没有加 FOR UPDATE 之类的锁。这一步在同一事务中账户行已经被前面的 UPDATE 持有排他锁直到事务结束所以这个 SELECT 读到的一定是本次扣减后的余额不会被其他请求插进来的 UPDATE 干扰。这也是把流水写入放在账户更新之后的另一个原因一定要在拿到准确余额后再写流水否则流水里的 BalanceAfter 就是错的对账时会产生大量假告警。3.3 幂等与状态机重复提交不扣两次退款必须成对幂等键不是拍脑袋加个字段就完事。很多源码包用 OrderId 自增主键判断是否已存在这是典型的错误用法自增 ID 是在插入时才生成的重试请求根本拿不到同一个 ID判断永远失效。正确做法是客户端生成业务幂等键数据库建唯一索引撞键就返回已存在。如果业务上需要取消兑换处理逻辑是另一个高频出错点。取消订单时积分回补与订单状态更新必须在同一个事务里完成而且流水里要写一条 ChangeType4 的撤销记录。public async Task CancelOrderAsync(string orderNo, long userId, CancellationToken ct) { await using var conn new SqlConnection(_connectionString); await conn.OpenAsync(ct); await using var tx (SqlTransaction)await conn.BeginTransactionAsync(ct); try { var order await conn.QuerySingleOrDefaultAsyncPointOrder( SELECT OrderNo, UserId, PointCost, Status FROM PointOrder WHERE OrderNo OrderNo AND UserId UserId, new { OrderNo orderNo, UserId userId }, tx); if (order null || order.Status ! 1) return; // 订单不存在或已变更过状态 await conn.ExecuteAsync( UPDATE PointAccount SET Balance Balance PointCost, TotalIncome TotalIncome PointCost, UpdateTime GETDATE() WHERE UserId UserId, new { order.PointCost, UserId userId }, tx); long balanceAfter await conn.QuerySingleAsynclong( SELECT Balance FROM PointAccount WHERE UserId UserId, new { UserId userId }, tx); await conn.ExecuteAsync( INSERT INTO PointLedger(UserId, BizNo, ChangeType, Amount, BalanceAfter, Remark) VALUES (UserId, OrderNo, 4, PointCost, BalanceAfter, 取消订单退回), new { UserId userId, OrderNo orderNo, order.PointCost, BalanceAfter balanceAfter }, tx); await conn.ExecuteAsync( UPDATE PointOrder SET Status 3 WHERE OrderNo OrderNo AND UserId UserId AND Status 1, new { OrderNo orderNo, UserId userId }, tx); await tx.CommitAsync(ct); } catch { await tx.RollbackAsync(ct); throw; } }这一段代码看起来和消费接口对称但它解决的是部分成功的问题。如果只回补积分不改订单状态用户下次还能再取消一次积分就会被退两次如果只改订单状态不回补积分用户白白损失积分。两件事在同一个事务里执行配合WHERE Status 1保证取消操作只能从已兑换流转到已取消不会重复执行。顺带说下订单状态的取值。我习惯用 1 已兑换、2 已完成、3 已取消、4 已退款状态流转在代码里用常量或枚举管理不要散落魔法数字。小额积分商城不需要太复杂的状态机但至少四条主路径要闭环下单扣积分、取消退积分、发货后完成、退款再退积分。状态改动的每个分支都要写流水这是对账兜底的前提。4. 并发扣减与对账兜底从原子更新到凌晨自动对账积分系统和普通电商最大的不同是积分的钱不经过支付渠道没有第三方帮你扣款所有正确性都要靠数据库约束和代码自己保证。高并发下最常见的两类事故一是余额被扣成负数二是流水和账户对不上。这一章把这两类问题的解法讲透。4.1 把判断余额 扣减合并成一条 UPDATE并发防超扣的核心很多新手写扣积分习惯先 SELECT 余额在 C# 里判断够不够再 UPDATE。这个写法在单用户测试时什么问题都没有一旦两个请求同时进来就会发生丢更新两个请求都读到 100 分各自判断可以扣 90 分然后都执行SET Balance 10结果用户只该扣一次却被扣了两次。更糟的是如果代码里算好newBalance balance - cost再写回去后提交的请求会把先提交的扣减覆盖掉。正确做法是把判断和扣减合并进一条 UPDATE让数据库的行锁去串行化并发操作。UPDATE PointAccount SET Balance Balance - PointCost, TotalExpense TotalExpense PointCost, Version Version 1, UpdateTime GETDATE() WHERE UserId UserId AND Balance PointCost;这条语句利用 WHERE 条件做余额校验Balance 是数据库当前值不是 C# 端读出来的旧值。两个并发请求同时执行时SQL Server 会给账户行加排他锁第二个请求会等待第一个提交后再执行然后因为余额不足而影响 0 行。应用层只需要判断 ExecuteAsync 的返回值0 就是余额不足。库存扣减同理WHERE Stock Quantity。这个方案的缺点是更新期间持有行锁单个账户的吞吐量有限。但积分账户本来就是一个用户一个行同一用户同时发起多笔消费的概率极低这个锁粒度完全可以接受。如果你的业务里存在一个账户同时被几十个请求消费的极端场景那应该先回到产品层面讨论是否有薅羊毛风险而不是先调数据库。4.2 乐观锁与重试适合后台调账不适合用户下单上一节的原子 UPDATE 适合高频的消费接口但还有一类低频操作不适合直接用无条件更新就是后台运营手工调积分。运营同学打开管理页面看到一条账户数据思考两分钟再提交这期间账户余额可能已经被用户消费掉了。如果提交时无脑执行SET Balance Balance Amount就会把用户刚消费的流水覆盖掉。这时候用 Version 字段做乐观锁更合适。更新条件里带上旧版本号影响行数为 0 说明数据已经被别人改过重新读取再试一次。public async Task AdjustPointAsync(long userId, long amount, string remark, string bizNo) { for (int attempt 0; attempt 3; attempt) { await using var conn new SqlConnection(_connectionString); await conn.OpenAsync(); var account await conn.QuerySingleAsyncPointAccount( SELECT UserId, Balance, Version FROM PointAccount WHERE UserId UserId, new { UserId userId }); if (account.Balance amount 0) throw new PointBizException(余额不足); int rows await conn.ExecuteAsync( UPDATE PointAccount SET Balance Balance Amount, Version Version 1 WHERE UserId UserId AND Version Version, new { Amount amount, UserId userId, account.Version }); if (rows 1) { // 此处应和流水插入放在同一事务中代码略写法参考 3.2 节 await conn.ExecuteAsync( INSERT INTO PointLedger(UserId, BizNo, ChangeType, Amount, BalanceAfter, Remark) VALUES (UserId, BizNo, ChangeType, AbsAmount, BalanceAfter, Remark), new { UserId userId, BizNo bizNo, ChangeType amount 0 ? (byte)1 : (byte)2, AbsAmount Math.Abs(amount), BalanceAfter account.Balance amount, Remark remark }); return; } await Task.Delay(50 attempt * 30); } throw new PointBizException(操作人数较多请稍后重试); }这里的重试参数是三个最大重试 3 次、初始退避 50ms、每次追加 30ms。为什么这么设后台调账是低频操作冲突概率本身不高重试 3 次基本能覆盖绝大多数情况退避太短会连续撞在同一个事务上太长又让运营觉得系统卡顿。如果 3 次都失败直接提示稍后重试不要无限循环。有一点要提醒这段代码为了突出乐观锁省去了事务封装实际项目里 UPDATE 和 INSERT 必须放在同一事务里否则流水会丢失。另外乐观锁只适合后台这类低频写入场景用户下单走 4.1 的原子 UPDATE 就好别给用户请求额外加一次先 SELECT 再 UPDATE的往返那是自己给自己制造瓶颈。4.3 对账定时任务把账实不符在凌晨捞出来不管代码写得再严谨线上总会有意想不到的脏数据人工改库、历史迁移错位、定时任务重复入账。所以一个积分系统必须带对账任务每天凌晨跑一遍把账户余额和流水聚合结果比对有差异就告警。常见做法是 BackgroundService 加定时器到点执行一条对账 SQL。SELECT P.UserId, P.Balance, P.TotalIncome, P.TotalExpense, ISNULL(S.Income, 0) AS FlowIncome, ISNULL(S.Expense, 0) AS FlowExpense FROM PointAccount P LEFT JOIN ( SELECT UserId, SUM(CASE WHEN ChangeType IN (1, 3) THEN Amount ELSE 0 END) AS Income, SUM(CASE WHEN ChangeType IN (2, 4) THEN Amount ELSE 0 END) AS Expense FROM PointLedger GROUP BY UserId ) S ON S.UserId P.UserId WHERE P.TotalIncome - P.TotalExpense P.Balance OR P.TotalIncome ISNULL(S.Income, 0) OR P.TotalExpense ISNULL(S.Expense, 0);这条 SQL 同时做两层校验。第一层校验账户表自身的恒等式TotalIncome 减去 TotalExpense 必须等于 Balance只要不等于说明某次写余额和写汇总字段没有成对完成。第二层校验账户表和流水表的一致性账户里的汇总金额必须等于流水聚合出来的金额。任何一行返回都意味着存在脏数据C# 端把结果加载成列表之后发告警通知再人工核对具体订单。这里补充一个和数据迁移有关的坑如果系统需要从旧库导入历史流水很多人会想到 SqlBulkCopy这确实是最快的方式。但 SqlBulkCopy 默认不执行普通 INSERT 的触发器如果你原来用触发器维护汇总字段迁移后汇总会全部错位同时它依赖列名映射旧库加了列而映射没同步数据就会整列写偏。用之前先做一次全量备份导入完立刻跑上面的对账 SQL不要等第二天凌晨才发现差异。对账任务本身也要有幂等性。每天跑一次、每次只读不写天然幂等如果要在对账后自动修复数据修复动作必须写流水并记录执行日志否则越修越乱。我在实际项目里见过半夜对账发现差异、写了自动修复脚本、结果第二天差异翻倍的情况原因就是修复逻辑没写流水把聚合基数又改变了。5. 从源码包到能跑起来环境匹配与五条避坑记录前面四章把业务和代码的骨架讲完了现在回到一个更现实的问题你手里可能只有一个 rar 压缩包解压之后不知道从哪下手。这一章先说拿到源码包后的标准动作再列五条真实环境中最高频的避坑记录每条都是现象、原因、解决三步走。5.1 解压 rar 后的第一件事确认框架版本与数据库类型不要急着双击 .sln 编译。先打开项目里的.csproj或packages.config看 TargetFramework 是 .NET Framework 4.x 还是 .NET 6/8。这两者的部署方式完全不同老框架通常跑在 Windows Server 加 IIS 上新框架可以跨平台跑 Docker。如果压缩包里有.mdf或.accdb文件说明数据库是 SQL Server LocalDB 或 Access后者在 IIS 上部署时需要在应用池里开启启用 32 位应用程序否则会报驱动未注册。连接字符串也会因框架不同而位置不同。老项目在web.config的connectionStrings节点里新项目在appsettings.json里格式大概是下面这样。{ ConnectionStrings: { PointDb: Server.;DatabasePointMall;User Idsa;Password***;EncryptFalse } }这里有两个容易忽略的细节。一是本地开发可以Integrated SecurityTrue用 Windows 身份登录但部署到服务器上建议改用 SQL Server 账号并授权 db_owner别用sa满权限跑业务。二是EncryptFalse在旧版 SQL Server 连接串里经常被省略新版 Microsoft.Data.SqlClient 默认强制加密如果你连的是内网旧实例不显式关掉加密会直接连接失败报证书相关的错。连接串配好之后先编译整个解决方案。老源码包经常缺 NuGet 包编译时报一堆找不到类型的错误第一件事是把 packages 还原好。编译通过后再初始化数据库脚本、跑一个最简单的登录页面确认能从页面查到数据库里的商品数据这就算活起来了。如果页面直接报 500优先看 Windows 事件查看器里的 .NET 运行时错误比猜管用得多。5.2 五条避坑记录现象、原因、解决第一条并发兑换时余额被扣成负数。现象是促销活动刚开始后台就出现余额为负的账户用户还能继续下单。原因是代码先 SELECT 余额在内存里算出新余额再 UPDATE两个请求同时读到同一个旧值后写的覆盖了先写的。解决方法是把校验和扣减合并成一条原子 UPDATE用 WHERE 条件挡余额不足应用层判断影响行数。这是第 4 章的核心方案也是积分系统最容易踩的坑没有之一。第二条同一个幂等键生成了两笔订单。现象是用户双击提交或前端超时重试后订单列表里出现两条相同商品的兑换记录。原因是应用层只做了先查询订单是否存在不存在则插入的判断查询和插入之间没有唯一约束保护并发下两个请求都判断为不存在。解决方法是给 PointOrder.IdempotentKey 建唯一索引让重复键在数据库层面直接失败应用层的先查后插只能减少冲突概率不能当唯一防线。第三条积分流水账实不符对账差几分钱。现象是凌晨对账任务报出几百个差异用户但手工查流水又觉得每笔都对得上。原因一是历史迁移用了 SqlBulkCopy列映射错位导致部分金额写进了别的字段二是定时任务重复入账流水表里同一个业务单号出现两次。解决方法是给 BizNo 建唯一约束数据迁移后立刻跑对账 SQL并且每次改完数据都重新核对一遍账户余额等于流水聚合这条恒等式。第四条老 .NET Framework 项目往 .NET 6 迁移时编译一片红。现象是 ConfigurationManager、HttpContext.Current、WebForms 相关类型全部找不到。原因是老项目大量依赖 System.Web新宿主把这些 API 移除了。解决方法是小项目用 IConfiguration 替代 ConfigurationManager用 IHttpContextAccessor 替代 HttpContext.Current如果页面本身是 WebForms几乎没有平滑迁移路径要么留在 .NET Framework 加 IIS 继续跑要么重写成 Razor Pages 或 MVC。这个决策要早点做别等到一半再回头。第五条积分过期任务在凌晨 0 点前后算错。现象是用户投诉积分提前过期或者该过期的积分第二天还在。原因是数据库存的是 UTC 时间代码用 DateTime.Now 和本地时间比较服务器时区一变就错位。解决方法是时间字段统一用 DateTimeOffset 或 datetime2 加 UTC 存储所有过期判断先转换到业务时区再比较。如果是老库已经用了 datetime至少保证写入和读取都走同一个时区转换函数不要一会儿 Now 一会儿 UtcNow。6. 模拟并发验证防超扣上线后盯余额、流水与订单三个指标系统写完验收阶段不要只点页面。我会先用一个并发脚本对同一个账号打二十个兑换请求每个带不同的 IdempotentKey然后检查三件事余额不为负、成功订单数等于流水支出条数、账户余额等于初始余额减去订单总扣分。这个验证能一次性暴露 90% 的并发问题。var tasks Enumerable.Range(0, 20) .Select(i client.PostAsJsonAsync(/api/points/consume, new { UserId 1001, GoodsId 1, Quantity 1, IdempotentKey Guid.NewGuid().ToString(N) })) .ToArray(); var responses await Task.WhenAll(tasks); var successCount responses.Count(r r.IsSuccessStatusCode);如果成功数大于账户余额可兑换的数量或者余额出现负数说明事务边界有问题回去检查第 3 章的 UPDATE 和事务顺序。压测通过后我再补一组重复提交测试同一个 IdempotentKey 连续发两次第二次必须返回订单已存在且订单表只有一条记录。上线后我只盯三个指标。第一个是账户余额为负数的告警出现即事故立刻查消费接口。第二个是对账差异行数每天凌晨对账任务执行完毕后记录差异数超过阈值就通知开发。第三个是同一幂等键出现重复订单的数量这个值应该是零。三个指标跑满一周数据质量基本就稳定了。我早期做积分系统时把余额校验放在应用层想着代码可读性好结果内测时多线程一开就翻车余额出现负数流水和账户对不上账。后来把所有校验下沉到 SQL 的 WHERE 条件和数据库唯一约束账才对得平。这个教训让我明白积分这种涉及钱的系统数据库层面的约束远比应用层的 if 判断可靠。希望这篇笔记能帮你少走这段弯路拿到压缩包源码后能理直气壮地说这套我能改、能上线、能扛住并发。本文还有配套的精品资源点击获取