简介:基于.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 Task<IActionResult> 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 Task<ConsumeResult> 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.QuerySingleOrDefaultAsync<PointGoods>( "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.QuerySingleAsync<long>( "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 >= @Quantity,Dapper 的 ExecuteAsync 返回受影响行数,返回 0 就说明条件不满足。这样就把"先查再扣"的窗口期消灭在 SQL 层面,后面第 4 章会专门讲这个并发点。
注意到balanceAfter的查询没有加 FOR UPDATE 之类的锁。这一步在同一事务中,账户行已经被前面的 UPDATE 持有排他锁直到事务结束,所以这个 SELECT 读到的一定是本次扣减后的余额,不会被其他请求插进来的 UPDATE 干扰。这也是把流水写入放在账户更新之后的另一个原因:一定要在拿到准确余额后再写流水,否则流水里的 BalanceAfter 就是错的,对账时会产生大量假告警。
3.3 幂等与状态机:重复提交不扣两次,退款必须成对
幂等键不是拍脑袋加个字段就完事。很多源码包用 OrderId 自增主键判断"是否已存在",这是典型的错误用法:自增 ID 是在插入时才生成的,重试请求根本拿不到同一个 ID,判断永远失效。正确做法是客户端生成业务幂等键,数据库建唯一索引,撞键就返回"已存在"。
如果业务上需要取消兑换,处理逻辑是另一个高频出错点。取消订单时,积分回补与订单状态更新必须在同一个事务里完成,而且流水里要写一条 ChangeType=4 的撤销记录。
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.QuerySingleOrDefaultAsync<PointOrder>( "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.QuerySingleAsync<long>( "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.QuerySingleAsync<PointAccount>( "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=.;Database=PointMall;User Id=sa;Password=***;Encrypt=False" } }这里有两个容易忽略的细节。一是本地开发可以Integrated Security=True用 Windows 身份登录,但部署到服务器上建议改用 SQL Server 账号并授权 db_owner,别用sa满权限跑业务。二是Encrypt=False在旧版 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 判断可靠。希望这篇笔记能帮你少走这段弯路,拿到压缩包源码后能理直气壮地说"这套我能改、能上线、能扛住并发"。
本文还有配套的精品资源,点击获取