ASP.NET MVC C#优惠券领取小程序:并发控制与接口设计详解
简介一份面向 .NET 与微信小程序开发者的优惠券领取类源码包。前端为微信小程序后台采用 ASP.NET MVC C# 编写并调用阿里妈妈淘宝客 API实现商品搜索、优惠券展示与领取跳转标签涉及的 .NET 微信小程序、C# 小程序方向均可在此找到落地范例。前台源码与后台 TBKAdmin.zip 分离后台还预置内容管理、会员、订单、微信及 WAP 模块便于二次扩展。整个压缩包约 22.8MB共 76 个文件以 js、json、wxss、wxml 等小程序前端文件为主分别承担逻辑、配置、样式与页面结构另含后台 zip 压缩包和说明 txt项目基于 Visual Studio 2010 / SQL Server 2008 / .NET 4.0附带数据库脚本与默认管理员账号下载后按说明修改连接字符串即可运行调试。目前已有 1784 人学习下载适合想快速理解淘宝客 API 接入流程、或搭建优惠券导购小程序并做二次开发的学习者参考。1. 优惠券领取小程序ASP.NET MVC C# 后端和微信小程序端的完整链路2019 年前后的优惠券领取小程序是微信生态里营销拉新最常见的一套组合用户在小程序里点「领取」前端把微信登录凭证 code 交给后端ASP.NET MVC 上用 C# 写的接口拿 code 换 openid再查库、扣库存、生成一条领取记录。这套源码完整版通常分成小程序前端工程、ASP.NET MVC 后端工程、数据库脚本三块。这篇笔记把「ASP.NET MVC C# 优惠券领取微信小程序源码」从头拆一遍数据模型怎么建、小程序端怎么调用、后端接口怎么写防超发、部署后会撞上哪些坑。适合手里有 ASP.NET 基础、想快速做出能上线的发券系统的开发者也适合要维护这种老源码的同学对照。整套系统的核心不在页面多好看而在「领券」这个动作背后的并发控制和数据一致性。2. 先理清数据模型优惠券表与领取记录表的设计以及并发扣减方案2.1 两张核心表与字段选择为什么领取记录要单独建表要支撑「领取优惠券」这个动作数据库里至少要两张表一张放券本身一张放谁在什么时候领了哪张券。常见做法是一张 Coupon 主表加一张 CouponReceive 领取记录表两张表通过 CouponId 关联。设计上很多人会偷懒把「已领取数量」直接存在 Coupon 表里然后不建领取记录表——这么做后台对账时完全说不清楚用户说没领到、数据库里却查不到痕迹所以领取记录表必须有。Coupon 表的字段我一般这样设计字段类型说明Idint 自增主键券的唯一标识Namenvarchar(50)券名称展示给用户CouponTypeint1 满减券2 折扣券Amountdecimal(18,2)满减金额或折扣值按类型解释Thresholddecimal(18,2)使用门槛金额TotalStockint总库存ReceivedCountint已领取数量StartTimedatetime领取开始时间EndTimedatetime领取结束时间Statusint0 下线1 上线CouponReceive 表的字段字段类型说明Idbigint 自增主键记录标识用 bigint 防量大溢出CouponIdint关联 Coupon 表OpenIdnvarchar(64)用户在小程序里的唯一标识ReceiveTimedatetime领取时间Statusint0 未使用1 已使用2 已过期OrderIdnvarchar(32)核销时关联的订单号允许空OpenId 用 nvarchar(64) 是从容做法微信 openid 本身是 28 位左右但不同小程序、不同场景下返回的字符串可能有差异留出余量避免以后改表结构。ReceivedCount 和 TotalStock 两个字段一起构成库存判断的依据这是整个系统最容易出错的地方后面章节专门展开。建表脚本直接用最普通的 SQL 就行注意给 CouponReceive 加一个联合唯一索引CREATE TABLE Coupon ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, CouponType INT NOT NULL DEFAULT 1, Amount DECIMAL(18,2) NOT NULL DEFAULT 0, Threshold DECIMAL(18,2) NOT NULL DEFAULT 0, TotalStock INT NOT NULL DEFAULT 0, ReceivedCount INT NOT NULL DEFAULT 0, StartTime DATETIME NOT NULL, EndTime DATETIME NOT NULL, Status INT NOT NULL DEFAULT 1 ); CREATE TABLE CouponReceive ( Id BIGINT IDENTITY(1,1) PRIMARY KEY, CouponId INT NOT NULL, OpenId NVARCHAR(64) NOT NULL, ReceiveTime DATETIME NOT NULL DEFAULT GETDATE(), Status INT NOT NULL DEFAULT 0, OrderId NVARCHAR(32) NULL ); CREATE UNIQUE INDEX UX_CouponReceive_User ON CouponReceive(CouponId, OpenId);最后一行唯一索引的用意是防重复领取不管业务代码写得多烂同一个用户对同一张券最多只能有一条领取记录。数据库层面的这个兜底在并发场景下比任何 if 判断都可靠。2.2 库存扣减先查再扣为什么翻车原子更新怎么写库存扣减是第一道并发门槛。很多第一版代码写成先查库存再判断再更新的三步int stock db.Coupon.Where(c c.Id couponId).Select(c c.TotalStock).First(); int received db.Coupon.Where(c c.Id couponId).Select(c c.ReceivedCount).First(); if (received stock) { return Fail(库存不足); } db.Database.ExecuteSqlCommand( UPDATE Coupon SET ReceivedCount ReceivedCount 1 WHERE Id p0, couponId);这套逻辑在单用户测试时什么问题都没有一旦两个用户同时抢最后一张券两个请求同时在第一步读到同一个 received 值都通过判断结果两张都发出去了。数据库里总库存 100实际发出去 105后台对账直接对不上。这类问题在活动上线当天出现运营电话会打到爆。正确的做法是把「判断库存」和「扣减库存」合并成一个原子操作让数据库自己保证条件成立才更新using (var tx new TransactionScope()) { string updateSql UPDATE Coupon SET ReceivedCount ReceivedCount 1 WHERE Id couponId AND ReceivedCount TotalStock; int affected db.Database.ExecuteSqlCommand(updateSql, new SqlParameter(couponId, couponId)); if (affected ! 1) { return Fail(库存不足); } string insertSql INSERT INTO CouponReceive(CouponId, OpenId, ReceiveTime, Status) VALUES (couponId, openId, GETDATE(), 0); db.Database.ExecuteSqlCommand(insertSql, new SqlParameter(couponId, couponId), new SqlParameter(openId, openId)); tx.Complete(); return Success(); }这段代码的关键在 UPDATE 语句的 WHERE 条件ReceivedCount TotalStock 不成立时数据库不会更新任何行affected 就是 0直接返回库存不足。判断和扣减在一条语句内完成不存在两个请求同时读到同一个旧值的窗口。事务把扣减和插入记录包在一起避免「库存扣了但记录没生成」这种尴尬的中间状态。这里有两个细节要提一下。第一业务代码里仍然要先用一次查询判断「这个用户是不是已经领过了」因为唯一索引会在并发插入时抛异常虽然能兜底但让用户看到系统繁忙的体验很差先查一次能挡掉绝大部分重复点击。第二TransactionScope 默认的隔离级别是 Serializable并发量大的时候可能拉长锁持有时间如果压测发现死锁频繁可以把 UPDATE 拆出来先执行再在事务里插入记录让库存行的锁尽早释放。提示使用 TransactionScope 时记得在文件顶部 using System.Transactions老项目如果没引用这个命名空间编译时会直接报错。3. 微信小程序端领取页从 wx.login 到 wx.request 的完整调用链3.1 code 换 openid为什么不能在小程序端直接拿 openid小程序端没有一个接口可以让你直接拿到 openid官方设计是前端先用 wx.login 拿一个临时凭证 code把 code 交给自己后端后端拿着 appid、secret、code 去调微信的 jscode2session 接口换取 openid。为什么绕这一圈因为 secret 是服务端机密绝不能出现在小程序代码包和请求里否则任何人反编译小程序就能拿到你的 secret可以冒用你的小程序身份调微信接口。后端换 openid 的 C# 代码在 2019 年老项目里通常长这样public string Code2Session(string code) { string appid ConfigurationManager.AppSettings[WxAppId]; string secret ConfigurationManager.AppSettings[WxAppSecret]; string url string.Format( https://api.weixin.qq.com/sns/jscode2session?appid{0}secret{1}js_code{2}grant_typeauthorization_code, appid, secret, code); var client new WebClient(); client.Encoding Encoding.UTF8; string response client.DownloadString(url); // response 里包含 openid、session_key以及可能的 errcode/errmsg return response; }这里有两个重点。code 是一次性的5 分钟内有效用过一次就作废前端不能缓存 code 反复提交。session_key 不要持久化到业务表里它是用来解密手机号、做数据签名校验的和优惠券领取没有直接关系保存它反而留下安全隐患。调用微信接口时用 HttpClient 还是 WebClient 都可以老项目里 WebClient 最省事但要注意编码必须指定 UTF-8否则解析中文报错。拿到 response 之后要判断是否包含 openid 字段以及 errcode 是否为 0。常见的错误码 40029 表示 code 无效40163 表示 code 已被使用这两种情况都应该让前端重新走一遍 wx.login。提示微信接口必须在后端调用不要在 wx.request 里直接请求 api.weixin.qq.com否则 secret 会暴露在请求参数里。3.2 领取按钮与 wx.request最小可用的页面代码小程序端领取页不需要复杂框架原生 WXML 加 JS 就能搞定。页面上一个按钮、一个剩余库存文案加上领取后的状态反馈就够用。view classcoupon-card text classcoupon-name{{coupon.Name}}/text text classcoupon-desc满 {{coupon.Threshold}} 减 {{coupon.Amount}}/text text classcoupon-stock wx:if{{stockText}}仅剩 {{stockText}} 张/text button classbtn-receive disabled{{disabled}} bindtaponReceive {{buttonText}} /button /view按钮的 disabled 和 buttonText 都由 JS 里的状态驱动。点击按钮后先调 wx.login 拿新的 code再把 couponId 和 code 一起通过 wx.request 发给后端onReceive: function () { if (this.data.loading) return; this.setData({ loading: true }); wx.login({ success: res { wx.request({ url: https://yourdomain.com/coupon/receive, method: POST, header: { content-type: application/x-www-form-urlencoded }, data: { couponId: this.data.coupon.id, code: res.code }, success: resp { this.setData({ loading: false }); this.handleResult(resp.data); }, fail: () { this.setData({ loading: false }); wx.showToast({ title: 网络异常请重试, icon: none }); } }); } }); }这段代码里 loading 标志是为了防止用户连点它在 wx.request 成功或失败时必须复位否则按钮会卡死在加载状态。url 要在微信公众平台的小程序后台配置到 request 合法域名里开发调试时可以勾选「不校验合法域名」但真机预览和上线前必须关掉这个选项。另一个容易被忽略的点是 header 的 content-type。老式 ASP.NET MVC 的模型绑定默认只认表单格式所以这里用 application/x-www-form-urlencoded如果前端改成 application/json后端 Action 的参数会绑不上拿到 couponId 和 code 都是 0 或空返回的永远是参数错误。3.3 页面状态处理已领取、库存不足、活动未结束的业务码约定前后端的业务码约定是整个调用链最容易扯皮的地方。我在这类项目里习惯用一个统一的返回结构bizCode 表示业务结果message 表示给人看的提示data 放业务数据。领券这个场景里业务码固定下来bizCode含义前端行为0领取成功弹成功提示按钮变已领取1参数错误弹参数错误不改变按钮2已领取过按钮变已领取并禁用3库存不足弹库存不足按钮保持可点4活动未开始弹活动未开始提示5活动已结束弹活动已结束提示6系统繁忙弹请稍后重试前端收到 bizCode 为 2 时直接 setData 把 buttonText 改成「已领取」disabled 改为 true同时把本地缓存里的领取状态也写一份下次进入页面先读缓存减少一次无意义的请求。收到 3 时不要禁用按钮因为其他用户可能退券、库存可能被释放用户过一会儿再点一次还能领。不要偷懒把后端 message 原样弹给用户。后端返回的「库存扣减失败」「UPDATE 影响行数为 0」这类文字是给开发看的用户看到会觉得这个系统没做完。前端的弹窗文案要写成人话比如「手慢了优惠券被抢完啦」这个细节直接影响活动转化率和用户对系统的信任感。4. ASP.NET MVC C# 后端优惠券领取接口的完整实现与防超发校验4.1 从 Action 到 JsonResult老 MVC 项目的接口长什么样2019 年的 ASP.NET MVC 项目里没有 Web API 和 ControllerBase 这些后来的概念接口就是一个普通 Controller 里的 Action返回类型写成 JsonResult用 Newtonsoft.Json 做序列化。前端请求进来时ASP.NET MVC 默认的 model binder 会自动把表单里的 couponId 和 code 映射到 Action 参数上省掉手写解析的功夫。一个最简的领券 Action 骨架像这样[HttpPost] public JsonResult Receive(int couponId, string code) { if (couponId 0 || string.IsNullOrEmpty(code)) { return Json(new { bizCode 1, message 参数错误 }); } try { // 业务逻辑换 openid、查券、扣库存、插记录 var result _couponService.Receive(couponId, code); return Json(result); } catch (Exception ex) { LogHelper.WriteLog(领券异常, ex); return Json(new { bizCode 6, message 系统繁忙 }); } }注意 catch 块里要写日志而且不要返回 ex.Message 给前端。老项目里最常见的翻车现场就是把异常消息直接拼进 JsonResult用户那边看到一堆英文堆栈等于把内部实现细节暴露给了外部。Service 层抛出的业务异常可以用自定义的 BusinessException 类包装让 Controller 在 catch 里区分「业务失败」和「系统错误」。返回的 JSON 里不要包含数据库自增 Id 以外的敏感字段比如库里如果存了运营成本、渠道来源这类字段序列化时要用匿名对象或 DTO 过滤掉。用匿名对象返回时字段名会按属性名原样输出小程序端拿到的是什么字段名就是什么字段名不要依赖 C# 的驼峰转换——2019 老项目通常不开启全局驼峰配置前后端字段名不一致会导致小程序端取不到数据。4.2 领取核心逻辑登录校验、活动校验、原子扣减、记录插入把前面几章拆开的零件拼起来。Service 层的 Receive 方法按顺序做五件事拿 code 换 openid查用户是否领过查券是否存在且活动进行中原子扣减库存插入领取记录。public object Receive(int couponId, string code) { // 1. code 换 openid string openId GetOpenIdByCode(code); if (string.IsNullOrEmpty(openId)) { return new { bizCode 1, message 登录失效请重试 }; } // 2. 查券确认存在、上线、且在活动时间内 var coupon db.Coupon.Where(c c.Id couponId).FirstOrDefault(); if (coupon null || coupon.Status 0) { return new { bizCode 1, message 优惠券不存在或已下线 }; } if (DateTime.Now coupon.StartTime) { return new { bizCode 4, message 活动未开始 }; } if (DateTime.Now coupon.EndTime) { return new { bizCode 5, message 活动已结束 }; } // 3. 查重已领取过直接返回不走扣减 bool received db.CouponReceive.Any(r r.CouponId couponId r.OpenId openId); if (received) { return new { bizCode 2, message 已领取过 }; } // 4. 原子扣减 5. 插入记录放在一个事务里 using (var tx new TransactionScope()) { int affected db.Database.ExecuteSqlCommand( UPDATE Coupon SET ReceivedCount ReceivedCount 1 WHERE Id p0 AND ReceivedCount TotalStock, couponId); if (affected ! 1) { return new { bizCode 3, message 库存不足 }; } db.Database.ExecuteSqlCommand( INSERT INTO CouponReceive(CouponId, OpenId, ReceiveTime, Status) VALUES (p0, p1, GETDATE(), 0), couponId, openId); tx.Complete(); } return new { bizCode 0, message 领取成功 }; }这段代码的执行顺序是有讲究的。查重放在扣减之前是为了让绝大多数重复请求在数据库写操作之前就被拦下来减少对 Coupon 表那行数据的锁竞争。但查重和扣减之间存在时间窗口两个请求可能同时通过查重所以真正的兜底仍然是 2.2 节里的那几行原子 UPDATE。如果插入记录时唯一索引抛了异常事务回滚会连带撤销库存扣减这时 catch 到异常后返回 bizCode 2 比返回 6 更友好因为这说明用户确实已经领过了。4.3 接口参数与签名校验除了 couponId 和 code还要带什么上面章节里的领券代码只处理了 couponId 和 code如果要上真实活动至少要补上签名校验。领券接口直接暴露在公网上除了业务参数还要加一层最基础的防重放和防伪造。常见做法是让小程序端每次请求带上时间戳和签名后端用同样的规则验签拒绝签名错误和超过 X 分钟的请求。参数类型说明couponIdint要领取的券 Idcodestringwx.login 生成的临时凭证timestamplong客户端当前时间戳毫秒noncestring随机字符串signstring以上参数拼接后按约定规则计算的签名签名的约定规则不需要复杂能挡住随手抓包改参数就够了。我一般用固定 secretKey 把 couponId、timestamp、nonce 拼起来做一次 MD5后端同样的算法算一遍不一致就拒绝。注意 MD5 只能防篡改和简单重放不要用它替代真正的 HTTPS。老项目如果还在用明文 HTTP 传输小程序端在微信后台强制要求 HTTPS2019 年的源码按当时规范也是配的 HTTPS 域名这点上线前要检查一遍。验签的同时建议对同一 openid 加一个单位时间内的领取频率限制。最简单的实现是查 CouponReceive 里最近 N 分钟内该用户的领取记录数但这样查库压力不小老项目里没有 Redis 的话用一个静态的 ConcurrentDictionary 做内存限流也能顶住中小活动缺点是多机部署时各机器计数的口径不一致活动结束后记得清理。5. 优惠券系统常见问题排查5 个必踩的坑与解决记录5.1 返回 JSON 时循环引用接口直接 500现象领券接口在本地调试时好好的部署到测试环境后一调用就返回 500日志里是 Newtonsoft.Json 的 JsonSerializationException错误信息里能看到 Self referencing loop 之类的字样。原因EF 实体导航属性双向引用。Coupon 里有 CouponReceive 的集合CouponReceive 里有 Coupon 的引用序列化时两边互相引用Json.NET 默认会报循环引用错误。解决把项目的 JsonResult 换成基于 Newtonsoft.Json 的 JsonNetResult在构造函数里传入这套 settings。纯 MVC 默认的 Json() 用的是 JavaScriptSerializer直接设置 ReferenceLoopHandling 不会生效很多老项目其实早就引用了 Newtonsoft只是 JsonResult 没替换。var settings new JsonSerializerSettings { ReferenceLoopHandling ReferenceLoopHandling.Ignore, DateFormatString yyyy-MM-dd HH:mm:ss };更推荐的做法是给接口专门建 DTO只输出前端需要的字段导航属性根本不进入序列化范围。但这要求对每个接口都做一层映射老项目如果接口多、时间紧先 Ignore 循环引用再配合字段过滤是折中方案。5.2 并发超发的隐性场景总库存 100实际发出 105 张现象活动第一天领了 105 张券出去数据库里总库存 100运营找上门来。原因这个坑往往不是出在业务代码的原子更新上而是出现在「补库存」「退券」这些边缘操作里。比如运营后台直接把 ReceivedCount 清零、或者用户退券时只改了记录状态没回补库存下一次领取的原子 UPDATE 又通过了。解决补库存和退券必须走和领取相同的原子逻辑。退券时先 UPDATE CouponReceive 把状态改回未使用再执行 UPDATE Coupon SET ReceivedCount ReceivedCount - 1 WHERE ReceivedCount 0同样用事务包起来。运营后台的任何库存修改都要记操作日志出问题才能追踪到是人改的还是程序扣的。给 CouponReceive 加 CouponId OpenId 唯一索引这块是排查这类问题最后的底牌有了它至少不会出现同一用户重复领取的脏数据。5.3 DateTime 格式iOS 上日期解析失败安卓正常现象小程序端展示优惠券有效期安卓上显示正常iOS 上显示 NaN 或者直接空白控制台报一个日期解析错误。原因后端返回给前端的日期格式是 2019-07-01 00:00:00iOS 的 JavaScript 引擎对这种用空格分隔的日期字符串解析能力很弱WebKit 内核只认 ISO 8601 格式。解决统一让后端输出 ISO 8601 格式也就是时间中间用 T 而不是空格2019-07-01T00:00:00。在小程序端再转一次const date new Date(2019-07-01T00:00:00);这是在优惠券系统里踩得最狠的一个兼容性坑。后端不管用 Newtonsoft 还是 System.Text.Json都要把日期序列化格式固定成 ISO 8601不要依赖默认行为。前端也不要自己去拼 2019/07/01 这种格式因为 iOS 上斜杠格式的行为在不同版本里也不一致。5.4 code 换 openid 报 40029前端缓存了登录凭证现象用户第一次进小程序点领取成功退出再进第二次点领取时后端报 invalid code 或者 code had been used。原因前端代码里把 wx.login 返回的 code 存在了全局变量里后端的登录接口也复用了这个 code没有在每次领券前重新调用 wx.login。微信的 code 是一次性的用第二次必然失败。解决在 3.2 节的代码里wx.request 之前每次都先调 wx.login 生成新 code不要把这个 code 存进 storage 或全局变量。后端捕获到 40029 错误时返回一个专门的 bizCode前端收到后自动重新 wx.login 再发起一次请求。同时要在后端记录一下 openid 换取失败的日志因为这个错误码也经常出现在有人用抓包工具重放旧请求的场景里。5.5 本机正常、部署 IIS 就 500程序集复制和应用程序池现象代码在开发机上跑得通发布到 Windows Server 的 IIS 上访问领券接口直接 500浏览器显示一个无法识别的错误页。原因2019 老项目最常见的两个原因一个是 bin 目录发布时没有把全部程序集带上去另一个是服务器上 .NET Framework 版本比项目目标版本低。开发机上 Visual Studio 会自动补齐引用的 DLL服务器上只有你在发布包里放了什么才有什么。解决发布时选择「在发布前删除所有现有文件」确保 bin 是干净的。到服务器上看事件查看器里的错误日志里面会明确写哪个程序集加载失败。应用程序池的 .NET CLR 版本要选 v4.0启用 32 位应用程序按项目目标平台选。还要检查日志目录和数据库连接字符串使用的账号是否有写入权限领券接口的日志写不进去也会表现为 500。6. 把 2019 老源码接到新项目先用抓包验证链路再决定要不要升级6.1 验证整条领取链路的最快方法拿到这套源码后别急着改代码先跑通再动手。我的习惯是先在小程序开发工具里打开前端工程把后端跑起来然后用抓包工具比如 Charles配好 HTTPS 证书实际点一次领取看请求体里传的参数、响应里返回的 bizCode 和字段名。这样能一次看清前端和后端实际交换的数据长什么样比对着文档猜字段靠谱得多。确认领券链路通顺后再用 Postman 重放一遍请求测一下 couponId 传 0、code 传空字符串这些边界参数后端必须返回业务码而不是直接抛异常。6.2 老项目升级 ASP.NET Core 的改造点如果这套 2019 老源码要接到新项目上我不建议在原工程里打补丁。先把 Web.config 里的 appSettings 迁到 appsettings.json把 Controller 改成 ControllerBase 并用 [ApiController] 标记DbContext 注册改成依赖注入。数据库脚本和核心 SQL 不用动原子 UPDATE 的写法在两个框架下完全一致。微信接口调用从 WebClient 换到 IHttpClientFactory顺手把 code2session 的 URL 和错误码处理封装成一个服务类。升级过程中唯一索引、ISO 日期格式、业务码约定这三件事保持原样其余代码按新项目的规范重写。我自己接手过好几个这样的老项目踩完这一圈后养成的习惯是开工第一天先查数据库脚本里有没有 CouponReceive 的唯一索引再查返回给小程序端的日期字段是不是 ISO 格式最后才打开控制器看业务逻辑。这两件事没确认之前我不会让任何新功能动工。这套 2019 完整版源码的价值不在代码本身有多精巧而在于它保留了当年微信生态里优惠券玩法最朴素的一版实现——把数据模型、接口约定和前端状态处理拆清楚你就拿到了改造成任何新活动系统的底子。希望帮到你。本文还有配套的精品资源点击获取