ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ASP.NET MVC与微信小程序优惠券系统实战:并发扣减与核销

ASP.NET MVC与微信小程序优惠券系统实战:并发扣减与核销 简介一份结合ASP.NET MVC后台与微信小程序前台的淘宝客优惠券自助搜索领取系统源码适合具备C#/.NET基础、希望快速搭建电商导购类小程序的开发者。后台已完成阿里妈妈淘宝客API对接并内置内容管理、会员、订单、微信、WAP等模块具备扩展为完整网站的能力。压缩包共76个文件含22个js、17个json、17个wxss、16个wxml等小程序前端页面与逻辑文件另有一个内置zip为Visual Studio 2010开发的MVC后台工程附带SQLServer 2008数据库脚本整体约22.8MB。已有1784人学习下载。包内提供默认管理员账号与数据库连接修改说明目录将前后台明确分离前台覆盖首页、优惠券、搜索、分类等页面并集成wxParse用于富文本内容展示后台则可直接对接淘宝客接口便于二次开发、学习C#.NET MVC与微信小程序整合及电商导购系统实现。1. 一张优惠券的完整生命周期ASP.NET MVC与微信小程序能否真正接上我接过的优惠券类小程序十套里有六套只有前端页面、后端用静态数组模拟领券还有三套把核心逻辑写在按钮的点击事件里真正把 ASP.NET MVC、C#、微信小程序串成一个完整工程的极少。这套源码难得在于它把领券、用户绑定、券码生成、库存扣减都放进了后端事务里小程序端只负责登录、调接口、渲染结果——这正是企业项目里最常规的分工。对于想学 .NET 微信小程序开发的初级工程师以及接了企业小程序单子、需要一套可快速改造原型的开发者都值得拆一遍。下面我按工程结构→数据表→领券链路→小程序对接→排错→进阶的顺序把它讲透。2. 先拆工程与数据表优惠券库存字段是怎么被设计出来的拿到源码第一步不是打开 Visual Studio 按 F5而是先花半小时把目录结构、数据表、配置文件三样东西理清楚。很多初学者翻车都是因为直接跑起来后不知道怎么改一改就崩。2.1 工程结构MVC项目里三层各自干什么这套源码的解决方案里典型的 MVC 项目分层大致如下CouponMvc/ ├── Controllers/ # 页面控制器与WebApi接口 │ ├── HomeController.cs │ ├── CouponApiController.cs │ └── WechatApiController.cs ├── Models/ # 实体类与DbContext │ ├── CouponTemplate.cs │ ├── CouponRecord.cs │ └── AppUser.cs ├── Views/ # 服务端渲染页面后台管理端 │ ├── Home/Index.cshtml │ └── Coupon/List.cshtml ├── Scripts/ ├── Content/ ├── App_Start/ ├── Web.config └── miniprogram/ # 微信小程序端源码 ├── pages/ ├── utils/ └── app.jsControllers 里同时存在两类东西一类是返回 View 的 MVC 页面控制器给内部后台用另一类是返回 JsonResult 的 WebApi 风格接口给微信小程序用。这是很多 .NET 小程序的常见做法——mvc 框架本身控制页面跳转接口独立出去两者共存于一个项目里。Models 层比较干净三个核心实体类对应三张业务表优惠券模板、领取记录、用户。DbContext 用的是 EF 的 Database First 或 Code First 取决于你手上的版本但不管哪种实体类的命名跟表名基本一致。我有意识地提醒一句改实体类前先看数据库脚本别只改代码不迁移表结构。2.2 数据库券模板表与领取记录表源码包里通常会带一个 .sql 脚本核心就两张表。我把建表语句整理如下CREATE TABLE dbo.CouponTemplate ( Id INT IDENTITY(1,1) PRIMARY KEY, Title NVARCHAR(100) NOT NULL, -- 券名称 TotalCount INT NOT NULL DEFAULT 0, -- 发行总量 RemainCount INT NOT NULL DEFAULT 0, -- 剩余库存 StartTime DATETIME NOT NULL, -- 可领取开始时间 EndTime DATETIME NOT NULL, -- 可领取结束时间 Status TINYINT NOT NULL DEFAULT 1 -- 1未启用 2进行中 3已结束 ); CREATE TABLE dbo.CouponRecord ( Id INT IDENTITY(1,1) PRIMARY KEY, UserId INT NOT NULL, -- 用户ID关联AppUser表 TemplateId INT NOT NULL, -- 券模板ID Code VARCHAR(32) NOT NULL, -- 16位券码 Status TINYINT NOT NULL DEFAULT 0, -- 0未使用 1已使用 2已过期 ReceiveTime DATETIME NOT NULL, UseTime DATETIME NULL, CONSTRAINT UX_CouponRecord_Code UNIQUE(Code) -- 券码唯一约束 );重点说明两个字段RemainCount 是库存控制的关键所有并发问题最后都落在它身上CouponRecord.Status 是这张券全生命周期里的状态机变量后面核销逻辑全部围绕它展开。券码上加唯一约束是我的习惯也是源码里一个重要保障——哪怕业务代码写错了重复券码也会在数据库层被拦下来。这套设计适用于单商户、单门店的优惠券场景。如果做多门店需要再拆门店表和模板门店关联表后面进阶部分我会讲怎么改。2.3 web.config里四个关键配置跑通这个工程Web.config 里最需要关注四段配置connectionStrings add nameCouponDbContext connectionStringServer.;DatabaseCouponDb;User Idsa;Password***;MultipleActiveResultSetstrue; providerNameSystem.Data.SqlClient / /connectionStrings appSettings add keyWechatAppId value你的AppId/ add keyWechatAppSecret value你的AppSecret/ add keyTokenExpireHours value24/ /appSettings配置项作用推荐值ServerSQL Server 实例地址本机开发用.或localhostMultipleActiveResultSets允许一个连接上并发执行多个结果集true否则EF报错WechatAppId / Secret微信小程序凭证从微信公众平台获取TokenExpireHours服务端登录令牌有效期24小时比较稳妥AppSecret 是敏感信息我自己的习惯是放开发环境配置里生产环境必须挪到环境变量或密钥管理服务里不要把带真实 Secret 的 Web.config 提交到 Git。有过一次同事把 Secret 推到公开仓库、当天就被机器人刷走的经历这条是最深刻的。这里提示一下如果源码里自带一个默认的明文 Secret记得先替换掉再启动调试。提示真机调试时微信小程序要求请求域名必须备案且 HTTPS但开发阶段可以在小程序后台勾选不校验合法域名。这个开关只对开发版生效上线前必须关闭。3. 领券与核销一次请求里的库存扣减和三种并发策略领券接口是整个源码里含金量最高的一段也是面试时最容易被追问的部分。很多 Demo 写领券就是三行代码查库存、减库存、插记录。看似没问题并发一起来就库超发。这套源码里用了事务和行锁来控制下面拆开看。3.1 领券接口的服务端流程先查后写还是先锁后写我先给你看一套最直观但有问题的写法很多改过这套源码的人第一版都这么写[HttpPost] public ActionResult Receive(int templateId, int userId) { using (var db new CouponDbContext()) { var template db.CouponTemplates.Find(templateId); if (template null || template.RemainCount 0) return Json(new { ok false, msg 库存不足 }); if (DateTime.Now template.StartTime || DateTime.Now template.EndTime) return Json(new { ok false, msg 不在领取时间范围内 }); var record new CouponRecord { UserId userId, TemplateId templateId, Code GenerateCouponCode(), Status 0, ReceiveTime DateTime.Now }; db.CouponRecords.Add(record); template.RemainCount--; // 直接改实体 db.SaveChanges(); return Json(new { ok true, msg 领取成功 }); } }逻辑本身没错先校验模板、再校验时间、插入记录、扣库存最后 SaveChanges 一起提交。EF 的 SaveChanges 默认就是一个隐式事务所有操作要么全部成功要么全部回滚这个没问题。问题出在并发上。两个用户同时执行 Find(templateId)读到的 RemainCount 可能都是 1都判定库存充足然后都执行减一最终库存变成 -1。为什么因为 Find 读到的是快照减库存的操作基于旧值覆盖写而不是原子更新。有一种办法是把整个方法包进 TransactionScope 并加隔离级别但在高并发下锁范围太大、性能差。实际项目更常用的是下面这种先锁后写。3.2 防超发的行锁方案把校验塞进UPDATE语句真正稳妥的做法是把校验库存 扣减合并成一条 UPDATE 语句利用数据库行锁保证原子性[HttpPost] public ActionResult Receive(int templateId, int userId) { var code GenerateCouponCode(); using (var db new CouponDbContext()) { using (var tx db.Database.BeginTransaction()) { // 原子扣减只有剩余库存大于0时才扣减 var affected db.Database.ExecuteSqlCommand( UPDATE CouponTemplate SET RemainCount RemainCount - 1 WHERE Id p0 AND RemainCount 0, templateId); if (affected 0) { tx.Rollback(); return Json(new { ok false, msg 手慢了已领完 }); } db.CouponRecords.Add(new CouponRecord { UserId userId, TemplateId templateId, Code code, Status 0, ReceiveTime DateTime.Now }); db.SaveChanges(); tx.Commit(); } return Json(new { ok true, msg 领取成功, code code }); } }关键逻辑是 ExecuteSqlCommand 的返回值UPDATE 影响行数。当 RemainCount 已经为 0 时WHERE 条件不成立数据库会返回影响行数 0事务回滚整单取消。这比先查再减安全因为行锁在数据库层面保证同一时间只有一个事务能修改这行数据。参数说明p0 对应 templateIdExecuteSqlCommand 的参数占位符用 p0、p1 依次排列BeginTransaction 和 SaveChanges 必须在同一个 DbContext 实例里。如果你手上的源码还是旧版先查后写强烈建议改成这个方案改一行核心代码少一次线上事故。3.3 券码生成与核销状态机三个状态的流转券码生成不是简单地 new Random 拼数字我见过好几套源码因为随机算法碰撞导致重复券码。这套源码里用时间戳 随机数组合再用数据库唯一索引兜底private string GenerateCouponCode() { var ticks DateTime.Now.ToString(yyyyMMddHHmmss); var rand new Random().Next(100000, 999999).ToString(); return Q ticks rand; // 示例Q20250216153045123456 }券码格式是前缀 时间戳 六位随机数。时间戳保证秒级唯一随机数降低同秒碰撞概率数据库唯一索引负责最后兜底。如果同秒并发超过随机数空间唯一索引会抛异常这时在接口里捕获异常返回请重试即可不能直接把异常抛给小程序。核销接口把状态从 0 改为 1 时也要带条件更新var affected db.Database.ExecuteSqlCommand( UPDATE CouponRecord SET Status 1, UseTime p0 WHERE Code p1 AND Status 0, DateTime.Now, code); if (affected 0) return Json(new { ok false, msg 券码无效或已使用 });这段防的就是同一张券被扫码两次。状态机的流转关系如下值状态触发时机0未使用领取成功时写入1已使用核销接口成功时流转2已过期定时任务或查询时按 EndTime 判定这套状态机是整个优惠券系统的地基改任何流程前先画一张状态流转图。我踩过的最大坑就是已使用还能再次核销追根到底是 UPDATE 少带了 Status 0 条件。现在每写一个核销接口都强制自己把条件写进 WHERE不依赖业务层二次判断。4. 小程序端登录与领券从code2session到token过期重新领取后端接口就绪后再看小程序端。这套源码的小程序端不是独立的 uni-app 工程而是标准的微信小程序原生结构WXML、WXSS、JS。微信小程序的导航栏、登录态、领券交互都在这里实现。4.1 微信登录流程code2Session在小程序端与服务端的职责切分微信小程序登录不能直接拿着用户名密码登录而是先 wx.login 拿 code再让后端拿 code 去微信服务端换 openid。职责切分如下小程序端调用wx.login拿到 codewx.login({ success: (res) { if (res.code) { wx.request({ url: https://你的域名/api/wechat/login, data: { code: res.code }, success: (r) { const { token, openid } r.data.data; wx.setStorageSync(token, token); wx.setStorageSync(openid, openid); } }); } } });C# 后端收到 code 后去微信接口换 openid[HttpPost] public async TaskActionResult Login(string code) { var url $https://api.weixin.qq.com/sns/jscode2session?appid{appId}secret{appSecret}js_code{code}grant_typeauthorization_code; using (var client new HttpClient()) { var response await client.GetStringAsync(url); var result JsonConvert.DeserializeObjectWechatSession(response); if (result.openid null) return Json(new { ok false, msg result.errmsg }); var token Guid.NewGuid().ToString(N); // 保存 token 与 openid 的映射到 Redis 或数据库 return Json(new { ok true, data new { token, openid result.openid } }); } }code 是一次性的有效时间很短后端换到 openid 后必须立刻返回。注意 code2Session 接口返回的 errmsg 经常会因为小程序 AppSecret 填错而报错调试时看到 invalid code 或 appid mismatch 先检查这两个值。4.2 领券页WXML与按钮防重复点击一次disable两次领券页的 WXML 里最核心的是按钮状态控制。我用简化版展示这套源码里的逻辑view classcoupon-card text classtitle{{template.Title}}/text text classcount剩余 {{template.RemainCount}} 张/text button classreceive-btn disabled{{btnDisabled}} bindtaponReceive {{btnDisabled ? 领取中... : 立即领取}} /button /viewPage({ data: { template: {}, btnDisabled: false }, onReceive() { if (this.data.btnDisabled) return; this.setData({ btnDisabled: true }); // 先锁按钮 wx.request({ url: https://你的域名/api/coupon/receive, method: POST, data: { templateId: this.data.template.Id }, header: { Authorization: Bearer wx.getStorageSync(token) }, success: (res) { if (res.data.ok) { wx.showToast({ title: 领取成功, icon: success }); } else { wx.showToast({ title: res.data.msg, icon: none }); } }, complete: () this.setData({ btnDisabled: false }) }); } });按钮防重复的关键是 setData({ btnDisabled: true }) 放在 wx.request 之前而不是放到 success 回调里。原因很简单微信小程序的 setData 在请求发出前同步生效UI 会立刻变成领取中...用户没有机会二次点击。如果写在 success 里请求发出到返回之间用户已经可以疯狂点击前后端并发压力瞬间翻倍。4.3 领券结果处理弹窗提示与本地缓存刷新领取成功后建议做两件事弹窗提示 更新本地已领列表。源码里的成功回调可以这样扩展// 领取成功回调 this.setData({ template.RemainCount: this.data.template.RemainCount - 1, receivedCode: res.data.code }); wx.setStorageSync(receivedCodes, [...oldCodes, res.data.code]);这里有个参数细节setData 里用template.RemainCount路径语法修改嵌套对象比this.data.template.RemainCount ...后再整体 setData 更高效性能开销小很多。本地缓存 receivedCodes 的作用是让我的券包页面不用每次打开都请求接口减少一次网络往返。哪天服务端数据有变清掉本地缓存重新拉取就行。5. 真机调试与部署避坑5条真实排错记录这一章写我从这套源码以及类似项目里踩过的五个真实坑。每一条都是现象→原因→解决的结构你能直接对照排查。5.1 现象1真机预览一直 request:fail开发时后端在电脑上跑 IIS Express小程序开发者工具没问题一扫码真机预览就所有请求全部失败。原因不是代码问题而是手机和电脑不在同一网段或者 Windows 防火墙挡了端口。解决方法是后端启动时监听局域网 IP而不是 localhost同时把小程序 request 的 url 改成http://192.168.x.x:8080并且确保手机和电脑连同一个 WiFi。再不行就检查防火墙入站规则放行 IIS Express 对应的端口。这条坑几乎人人都会踩一遍属于环境玄学但排查路径非常固定。5.2 现象2JSON序列化报错 Self referencing loop detected领券接口返回模板数据时EF 导航属性导致 Newtonsoft.Json 序列化抛错。因为 CouponTemplate 和 CouponRecord 之间有外键关系序列化时两边互相引用死循环。解决是在 WebApiConfig 里配置config.Formatters.JsonFormatter.SerializerSettings.ReferenceLoopHandling ReferenceLoopHandling.Ignore;或者更推荐的做法不给前端返回 EF 实体而是返回一个 DTO 匿名对象只带前端需要的字段。少返回一个导航属性就少一类序列化问题。加完配置记得重启 IIS Express配置只在启动时加载一次。5.3 现象3iOS端日期显示 NaN后端返回的领取时间是2025-02-16T15:30:00这种 ISO 格式Android 端正常iOS 端 new Date 解析后显示 NaN。原因是 iOS 的 JavaScriptCore 对带 T 的日期字符串支持不完整。解决方法是后端格式化后再返回var timeStr DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss);把格式统一成yyyy-MM-dd HH:mm:ss两端都能解析。这算是微信小程序跨端兼容的老问题了遇到日期显示异常第一反应就是查后端返回的日期格式。5.4 现象4并发测试时库存扣成负数用 JMeter 或 Postman 同时发 20 个领券请求日志里有些请求成功了但数据库库存变成负数。原因就是前文说的先查后写并发漏洞多个请求同时读到剩余库存为 1都通过校验各自减一。解决方法是换成 3.2 里的 UPDATE 行锁方案并且在 UPDATE 执行后检查 affected 是否为 0。我改完这套写法后20 并发压测库存稳定停在 0没有一条超发。5.5 现象5小程序token失效后页面卡死后端给小程序签发的 token 设置了 24 小时过期用户第二天打开小程序领券接口返回 401但前端没有处理失败态页面一直停在领取中...。原因是我最开始只在 success 回调里处理 okfalse 的情况没处理 HTTP 状态码非 200 的情况。正确的做法是在 wx.request 的 fail 回调里重置按钮状态并在 HTTP 401 时强制跳转重新登录fail: () { this.setData({ btnDisabled: false }); wx.showToast({ title: 网络异常请重试, icon: none }); }, statusCode: (res) { if (res.statusCode 401) { wx.removeStorageSync(token); wx.navigateTo({ url: /pages/login/login }); } }这个坑提醒我一件事小程序端处理接口返回时永远要把HTTP 非 200和网络层失败当成常态来写不能假设服务端永远正常。从那以后我写 wx.request 都会强制把 fail 和 statusCode 分支写好再写成功逻辑。6. 进阶把券码状态机延伸到线下核销场景的改造方法线上领券做通之后最常接到的需求是把这套体系改造成线下门店核销。新增一个店员扫码角色核销入口从用户点击变成店员扫用户的二维码。改造核心就三件事第一新增门店表和门店核销员表给 CouponTemplate 加 StoreId 字段第二小程序端提供我的券码页面把当前用户的未使用券生成二维码第三核销接口增加 storeId 和 operatorId 参数核销时先校验该券是否属于当前门店再走状态更新。用店员扫码的核销请求为例{ code: Q20250216153045123456, storeId: 101, operatorId: 5 }后端核销前先查券码归属如果模板的 StoreId 与请求的 storeId 不一致直接返回该券不属于此门店。我一般还会在核销接口里加一个幂等键——用 code storeId operatorId 拼一个唯一请求号防止店员重复扫码导致二次核销尽管 UPDATE 条件已经兜底幂等键仍然能让问题在业务层提前暴露。另外一个值得改的细节是券码生成规则。线上发放时时间戳加随机数的组合够用但线下量大时可能出现同秒重复。我的做法是在生成规则里引入门店前缀Q storeId.ToString(D3) 时间戳 随机数。这样不同门店的券码天然隔离数据库索引更快排查问题时也能一眼看出券是从哪个门店发出去的。这套源码的价值不在于开箱即用而在于它的状态机、事务边界、并发控制思路可以完整移植到任何优惠券业务里。从那以后我每次接优惠券类小程序都强制先画一张券状态流转图把未使用→已使用→已过期的每条路径标清楚写完代码再对着图过一遍所有可能的分支。希望帮到你。本文还有配套的精品资源点击获取
返回列表