资讯动态

ASP.NET MVC C#微信小程序优惠券领取源码:并发库存与部署避坑

发布时间:2026/10/27 13:53:48 来源:尧图企业网站定制
简介面向具备一定 C# 与前端基础的开发者这份 2019 年完整版源码提供了一套淘宝客优惠券领取微信小程序的落地实现前台是小程序页面后台基于 ASP.NET MVC 调用阿里妈妈淘宝客接口并已预置内容管理、会员、订单、微信与 WAP 等可扩展模块适合二次开发练习或搭建自有导购类应用。资源包共 76 个文件、约 22.8MB以 22 个 js、17 个 json、17 个 wxss 和 16 个 wxml 为主分别对应小程序逻辑、页面与全局配置、样式及视图结构另附后台工程压缩包、数据库脚本和使用说明文档。开发环境为 Visual Studio 2010、SQL Server 2008 与 .NET 4.0数据库连接串集中在配置文件里管理员账号与建库脚本的导入方式都有交代便于快速跑通并对照前后台实现。目前已有 1784 人学习下载对理解小程序与 .NET 后端的接口联调、以及在此基础上扩展会员与订单功能都有参考价值。1. 拿到一套 ASP.NET MVC C# 优惠券领取微信小程序源码先别急着按 F5这套源码的定位很明确一份 2019 年的完整工程后端是 ASP.NET MVC C#前端是原生微信小程序业务围绕“领优惠券”这条主链路展开——登录换 openid、券列表、领取、我的券包。适合两类人一类是被安排做营销活动后台的 .NET 开发者想找一套能跑的骨架直接改成自己业务另一类是手上有个类似小项目想对照一份完整的 C# 微信小程序源码看看登录态、库存扣减、领券并发这些环节别人怎么组织。它属于“能跑起来但多半跑不起来”的范畴能跑是因为结构和接口齐全跑不起来是因为 2019 年的运行环境、微信接口、框架默认行为跟今天差了一截。下面按“结构—接入—并发—避坑—验证”的顺序把需要改动、需要确认、需要加保护的地方逐一说明。2. 后端骨架与数据库从 MVC 目录结构到优惠券表怎么设计2.1 一次领券请求要经过哪些层这套工程的典型组织方式是标准的 MVC 分层Controllers放接口与页面控制器Models放实体和视图模型App_Start放路由与过滤器注册Web.config放连接串和微信 AppId/Secret小程序端则是另一个独立目录通过 HTTPS 请求后端。别小看“目录结构”这件事这类 2019 年的源码常见的一个问题是 Controller 里直接 new 数据库上下文、直接拼业务逻辑导致后面加事务、加缓存时无处下手。我一般先把领券这条链路单独抽出来看一眼确认它在哪一层做校验、哪一层碰数据库。一次完整的领券请求链路是这样的小程序wx.login拿到 code → POST 到后端登录接口 → 后端调微信jscode2session换 openid → 生成 token 返回并落缓存 → 小程序把 token 存本地 → 点“领取”时带 token couponId 请求领券接口 → 后端用 token 反查 openid → 校验活动时间、库存、是否已领 → 事务内扣库存并写领取记录 → 返回结果。控制器骨架大致长这样注意这里是 MVC 的Controller返回JsonResult不是 Web API// Controllers/CouponController.cs public class CouponController : Controller { // 依赖具体服务别在 Action 里直接 new DbContext private readonly CouponService _service new CouponService(); [HttpPost] public JsonResult Receive(int couponId, string token) { // 1. 登录态校验token 换 openid换不到直接 401 var openId TokenStore.GetOpenId(token); if (string.IsNullOrEmpty(openId)) return Json(new { code 401, msg 登录已失效请重进小程序 }); // 2. 业务层统一处理库存、重复领取、活动时间 var r _service.Receive(couponId, openId); // 3. 统一返回结构前端只认 code/msg 两个字段 return Json(new { code r.Code, msg r.Msg }); } }couponId走查询串或表单都行但要注意 MVC 默认模型绑定对application/json请求体的处理方式和 Web API 不一样token建议放请求头源码里如果放在 body 里改成 header 更干净也避免日志把 token 打出来。JsonResult在 GET 请求下会抛“此请求已被阻止”的异常所以领券接口必须显式标[HttpPost]这一点后面避坑章节还会提。2.2 优惠券主表与领取记录表数据库这块2019 年的写法多半是 SQL Server Entity Framework 6 的 Code First 或 Database First。不管哪种表结构决定了后面并发能不能兜住。核心就两张表券的库存表、券的领取记录表。库存单独放一列不要用COUNT(领取记录)实时算否则每次领券都要扫全表。-- 优惠券主表库存、面额、有效期都在这里 CREATE TABLE Coupon ( Id INT IDENTITY(1,1) PRIMARY KEY, Title NVARCHAR(100) NOT NULL, Amount DECIMAL(10,2) NOT NULL, -- 面额用 decimal别用 float TotalCount INT NOT NULL, -- 发行总量仅作展示与对账 StockCount INT NOT NULL, -- 剩余库存扣减就看这一列 ValidStart DATETIME NOT NULL, ValidEnd DATETIME NOT NULL, Status TINYINT NOT NULL DEFAULT 1 -- 1 上架 0 下架 ); -- 领取记录表一人一券的唯一性靠这个索引兜 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 TINYINT NOT NULL DEFAULT 1 -- 1 未使用 2 已使用 3 已过期 ); -- 关键唯一索引让数据库替你做一人一张的最后一道判断 CREATE UNIQUE INDEX UX_CouponReceive_Coupon_OpenId ON CouponReceive (CouponId, OpenId);StockCount是扣减目标TotalCount只用来展示“已抢 X%”两者在正常流程下应该满足StockCount TotalCount - 有效领取数可以在后台加个对账查询定期核对。Amount用DECIMAL(10,2)如果业务上按分存储就换成INT存分值两种都行唯独不能用float/double否则对账时会出现一分钱的差额查起来非常难受。OpenId长度给 64 够用索引列不要设成NVARCHAR(MAX)否则建不了唯一索引。2.3 用 code 换 openid 的那段 C#登录接口是整个链路的第一环写错的话后面全废。2019 年的代码常见用WebClient同步请求今天仍然能跑但要加上超时和错误码判断否则微信侧一抖动整个请求线程就挂在那儿。// Services/WxAuthService.cs public static WxSession GetSession(string code) { var appId ConfigurationManager.AppSettings[WxAppId]; var secret ConfigurationManager.AppSettings[WxSecret]; // grant_type 固定 authorization_codecode 只能用一次5 分钟过期 var url https://api.weixin.qq.com/sns/jscode2session $?appid{appId}secret{secret}js_code{code}grant_typeauthorization_code; using (var wc new WebClient()) { wc.Encoding Encoding.UTF8; wc.Headers.Add(User-Agent, Mozilla/5.0); // 某些网络环境下不加会被拦 var json wc.DownloadString(url); // 返回体里 errcode 为 0 或不存在才算成功 var obj JsonConvert.DeserializeObjectWxSession(json); if (obj.errcode ! 0) throw new Exception($jscode2session 失败 {obj.errcode}:{obj.errmsg}); return obj; // 里面有 openid、session_key } }code是一次性的前端如果因为网络重试把同一个 code 发两次第二次必然返回 40163code 已被使用所以登录接口要做幂等同一个 code 在短时间内重复请求直接返回上一次的结果而不是再打一次微信。session_key不要下发给小程序它只应该留在服务端用于解密手机号等敏感数据openid才是业务主键。appId和secret不要写死在代码里放Web.config的appSettings并且确认这个文件没有被提交到公开仓库。3. 小程序端怎么接登录态、请求封装与领券交互3.1 登录时序与 token 落地策略小程序端最常见的问题是“登录写在onLaunch里但页面onLoad比它先跑完”导致第一次进页面拿不到 token请求全部 401。解决办法不是加setTimeout而是把登录包装成 Promise谁要用谁await。// utils/auth.js —— 登录做成单例 Promise避免并发重复登录 let loginPromise null; function login() { if (loginPromise) return loginPromise; // 已有登录在飞直接复用 loginPromise new Promise((resolve, reject) { wx.login({ success(res) { wx.request({ url: getApp().globalData.baseUrl /api/auth/login, method: POST, data: { code: res.code }, success(r) { if (r.data.code 0) { wx.setStorageSync(token, r.data.token); resolve(r.data.token); } else { reject(new Error(r.data.msg)); } }, fail: reject, complete() { loginPromise null; } // 无论成败都释放允许下次重试 }); }, fail: reject }); }); return loginPromise; } module.exports { login };loginPromise这个单例是这套写法的核心页面 A 和页面 B 同时调login()只会发出一次wx.login两个调用方拿到同一个 Promise 结果。complete里把loginPromise置空是为了失败后还能重试如果放在success里一旦某次登录失败就永远卡住。token 存Storage而不是全局变量是因为小程序冷启动后全局变量会丢Storage不会。至于 token 有效期我一般设 7 天服务端缓存里带过期时间过期就重新走一次登录别让用户感知到。3.2 领券按钮背后其实是三次判断用户看到的是一次点击代码里至少要挡三件事重复点击、未登录、以及服务端返回的业务失败。前端的所有校验都只是体验优化真正的幂等和库存判断必须在服务端。// pages/coupon/detail.js const auth require(../../utils/auth); Page({ data: { couponId: 0, submitting: false }, onLoad(options) { // 从二维码或分享进来时id 可能是字符串统一转数字 this.setData({ couponId: Number(options.id || 0) }); }, async onReceive() { if (this.data.submitting) return; // 第一道前端防连点 this.setData({ submitting: true }); try { const token wx.getStorageSync(token) || await auth.login(); // 第二道没登录先登录 wx.request({ url: getApp().globalData.baseUrl /api/coupon/receive, method: POST, header: { content-type: application/json, X-Token: token }, data: { couponId: this.data.couponId }, success: (r) { // 第三道服务端返回码才是最终结论 if (r.data.code 0) { wx.showToast({ title: 领取成功, icon: success }); } else if (r.data.code 409) { wx.showToast({ title: 你已领过这张券, icon: none }); } else { wx.showToast({ title: r.data.msg || 领取失败, icon: none }); } } }); } finally { // 200ms 后再解锁防止动画期间用户狂点 setTimeout(() this.setData({ submitting: false }), 200); } } });submitting标志位不是可有可无的装饰弱网环境下用户连点三下服务端就会收到三个请求虽然唯一索引能挡住重复入库但前两个请求会白白消耗库存判断和数据库连接。finally里延迟 200ms 解锁是个小技巧showToast弹出期间按钮如果立刻恢复可点用户很容易再点一次。X-Token放请求头而不是 body是为了让服务端过滤器统一拦截业务 Action 里就不用每个方法都写一遍取 token 的代码。3.3 分享与二维码进来时的参数传递这类领券小程序一般都有“扫码领券”和“分享给好友”两个入口参数传递是最容易出岔子的地方。普通页面参数长度没限制但小程序码的scene参数有 32 个字符上限而且只支持有限字符集很多源码在这里直接用?id123拼字符串生成二维码时会静默失败。常见做法是页面路径用pages/coupon/detailscene只传id123这种极短键值对进页面后在onLoad里解析// 处理扫码进入options.scene 是 URL 编码过的字符串 onLoad(options) { let id options.id; if (!id options.scene) { // scene 形如 id%3D123需要 decode 后再按 切 const scene decodeURIComponent(options.scene); const kv scene.split(); if (kv.length 2 kv[0] id) id kv[1]; } this.setData({ couponId: Number(id || 0) }); }decodeURIComponent这一步不能省scene传进来是编码状态直接split切不开。另外scene里不要放中文、不要放长参数、不要放之外的特殊符号体验版和正式版生成小程序码的接口是分开的调试时用体验版生成的码扫出来可能拿不到scene这是环境问题不是代码问题。4. 领券并发与库存把“一人一张”和“不超发”写进代码4.1 唯一索引是最后一道防线不是唯一防线很多 2019 年的源码在领券逻辑里写的是“先查有没有领过没有就插入”这段代码在单线程下完全正确一上并发就废两个请求同时查到“没领过”然后都插入成功。唯一索引能兜住数据不脏但会抛DbUpdateException如果没捕获用户看到的就是 500。正确的层次是三层应用层查一次给用户友好提示事务里靠唯一索引兜底捕获特定异常后返回“已领取”。// Services/CouponService.cs public ReceiveResult Receive(int couponId, string openId) { using (var db new CouponDbContext()) using (var tx db.Database.BeginTransaction()) { try { // 第一层先查绝大多数重复请求在这里就被挡掉成本最低 var exist db.CouponReceive .Any(x x.CouponId couponId x.OpenId openId); if (exist) return ReceiveResult.Fail(409, 你已领过这张券); // 第二层原子扣库存影响行数为 0 说明抢光了 var affected db.Database.ExecuteSqlCommand( UPDATE Coupon SET StockCount StockCount - 1 WHERE Id p0 AND StockCount 0 AND Status 1, couponId); if (affected 0) return ReceiveResult.Fail(410, 券已抢光); // 第三层插入记录唯一索引在这里做最终裁决 db.CouponReceive.Add(new CouponReceive { CouponId couponId, OpenId openId, ReceiveTime DateTime.Now }); db.SaveChanges(); tx.Commit(); return ReceiveResult.Ok(领取成功); } catch (DbUpdateException) { // 唯一索引冲突并发下两个请求都过了第一层这里兜住 tx.Rollback(); return ReceiveResult.Fail(409, 你已领过这张券); } } }ExecuteSqlCommand返回的是受影响行数StockCount 0写在WHERE里而不是在 C# 里判断是为了让“判断 扣减”在数据库里一步完成这是防超发的关键。DbUpdateException的捕获范围要窄别把整个方法包在catch (Exception)里否则真正的数据库连接错误也会被吞成“已领取”排查时完全看不出问题。4.2 乐观锁、悲观锁和原子 UPDATE 怎么选三种方案都能防超发差别在冲突概率和锁的粒度。原子UPDATE是乐观思路的极致简化不加锁、不读版本号靠WHERE条件保证只有库存大于 0 才扣得动冲突时直接返回 0 行适合“秒杀型”高并发场景代价是失败请求不重试就丢了。EF 的乐观锁RowVersion字段适合冲突不频繁、需要重试的业务比如后台改库存。悲观锁SELECT ... WITH (UPDLOCK)适合库存少、并发低但要求强一致的场景缺点是把行锁住期间其他请求全部排队。方案实现成本冲突表现适用场景原子 UPDATE低一条 SQL返回 0 行领券、秒杀这类瞬时高并发乐观锁 RowVersion中需加字段和重试抛并发异常后台库存调整、低频写悲观锁 UPDLOCK低改查询语句请求排队等待库存极少、必须强一致我一般默认用原子UPDATE因为它不依赖 EF 的版本管理换成 Dapper 或者裸 SQL 都能用迁移成本最低。要注意的是ExecuteSqlCommand和后面的SaveChanges必须在同一个事务里否则扣了库存但插入失败库存就白白少了一张这种“少卖”比“超卖”更难发现。4.3 用并发脚本压一遍再上线上线前必须自己压一遍不要等运营活动开始才发现超发。压测脚本不需要多复杂开 100 个线程同时打同一个接口最后核对“领取记录数”和“库存减少数”是否一致。# tools/stress_receive.py —— 需要 pip install requests import threading, requests, collections BASE http://127.0.0.1:8080 COUPON_ID 1 RESULT collections.Counter() lock threading.Lock() def worker(idx): token ftest-token-{idx} # 压测时用测试 token别用真实用户 r requests.post(f{BASE}/api/coupon/receive, json{couponId: COUPON_ID}, headers{X-Token: token}, timeout10) with lock: RESULT[r.json().get(code)] 1 threads [threading.Thread(targetworker, args(i,)) for i in range(100)] for t in threads: t.start() for t in threads: t.join() print(RESULT) # 期望只有 0成功和 410抢光不该出现 500压测前把StockCount设成 10跑 100 并发正确结果是 10 个成功、90 个返回抢光且数据库里恰好 10 条记录。如果出现 500说明异常没被捕获如果成功数大于 10说明扣库存那一步没有原子性如果成功数小于 10 但库存已经归零说明事务回滚没生效。timeout10要设否则线程会卡死等响应。5. 五个常见问题的排查手册从 404 到超发挨个过一遍5.1 环境与路由类现象本地 F5 一切正常部署到 IIS 后所有 POST 接口返回 404 或 405。原因多半是应用程序池的托管管道模式是“经典”或者Web.config里runAllManagedModulesForAllRequests没开导致 MVC 的路由模块没接管请求也有一种情况是接口路径带了.之类的字符被 IIS 当成静态文件处理。 解决应用程序池改成“集成”模式.NET CLR 版本选对在system.webServer/modules里加runAllManagedModulesForAllRequeststrue确认Global.asax里的RouteConfig.RegisterRoutes真的被调用了有些源码把这行注释掉调试完忘了恢复。现象GET 请求正常POST 拿到的参数全是 null。原因有两层。一是小程序wx.request默认content-type: application/json而 MVC 的默认模型绑定器对 JSON 请求体的支持不如 Web API参数绑不上二是如果用了[FromBody]一个方法里只能有一个参数能从 body 绑定。 解决要么把参数合并成一个实体类接收要么改成Request.Form手动读要么给接口加[ValidateInput(false)]并把content-type改成application/x-www-form-urlencoded。最稳的做法是把参数收成一个 DTO前端统一按 JSON 发。5.2 微信接口与登录态类现象活动刚开始一部分用户领券报 401重进小程序又好了。原因access_token或 token 缓存被并发请求同时刷新后刷新的把先刷新的顶掉了另一批请求拿着已失效的 token 去用。如果是多台服务器各自缓存access_token问题会更明显微信侧全局只认最新那一个。 解决token 缓存加分布式锁或者统一收口到一个服务刷新本地开发环境把缓存时间设短一点方便复现。access_token有效期 7200 秒刷新要提前别等到过期那一刻才刷。现象jscode2session偶发返回 40163code 已被使用。原因小程序端网络重试导致同一个 code 发了两次或者用户快速杀掉小程序又重新进入旧 code 还在飞。 解决登录接口做幂等同一个 code 在 5 分钟内重复请求直接返回上一次的 openid 和 token不再调用微信接口前端login()用单例 Promise从源头减少重复请求。5.3 数据与并发类现象活动结束后对账券的领取总数比库存少了几张但没有任何报错。原因扣库存和写记录不在同一个事务里或者异常被吞掉后没回滚。另一种可能是StockCount用了INT但代码里某处用double参与计算出现舍入后写回了错误值。 解决把“扣库存 插记录”包进同一个TransactionScope或BeginTransaction金额和库存全部用decimal/int禁止float加一条每日对账 SQL把TotalCount - StockCount和COUNT(CouponReceive)做差差不为 0 就报警。现象压测时接口返回 500日志里是DbUpdateException但用户看到的是“系统繁忙”。原因唯一索引冲突没有被单独捕获落到了全局异常处理里。 解决在业务层catch (DbUpdateException)并判断内部异常是不是唯一索引冲突SQL Server 错误号 2601/2627是的话返回 409 和友好提示不是的话继续往上抛。别在整个方法外面套一层catch (Exception)了事。提示这五条里前两条是环境问题改配置就能好后三条是代码问题不改的话活动量一大必然翻车建议上线前把 4.3 的压测脚本跑一遍。6. 进阶把 2019 的代码放进今天的运行环境我一般这么验2019 年的工程放到今天的机器上最容易出问题的不是业务代码而是运行时和依赖。这套代码用的是 .NET Framework 时代的 MVC部署目标还是 IIS Windows所以第一步是确认服务器上装了对应版本的 .NET Framework应用程序池的 CLR 版本、托管管道模式、32 位开关三项都对齐。第二步是 NuGet 包还原packages.config里的包版本大多锁死在当年直接Update-Package会引入一堆不兼容正确姿势是只还原、不升级除非某个包有明确的安全问题。上线前我会跑一张自检表逐项打勾检查项怎么验不通过的表现数据库连接串换一台机器连一次登录接口直接 500唯一索引是否存在sp_helpindex CouponReceive并发下出现同一 openid 两条记录微信 AppId/Secret用测试 code 调一次登录返回 40013 或 40125接口是否 HTTPS浏览器直接访问接口地址小程序请求被拦报域名不合法事务是否生效压测后核对库存与记录数少卖或多卖最后分享一个我常用的验证技巧写一个“脏数据注入”脚本在压测前手动往CouponReceive插一条(couponId, openId)已存在的记录然后正常调一次领券接口看返回的是 409 还是 500。返回 409 说明唯一索引冲突被正确捕获了返回 500 说明捕获逻辑没写对线上一定会出现用户看到“系统繁忙”却不知道自己已经领过的情况。这个脚本我每次都跑比看代码可靠。从头到尾我最深的教训是这类源码的价值在结构不在细节。它给你一个能跑通的骨架但库存、幂等、事务、异常这四件事2019 年写得松今天就得自己收紧。从那以后我每次接手这类老工程都强制先跑一遍并发脚本再看日志里有没有被吞掉的异常最后才动业务代码。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑