简介:面向微信小程序开发者和.NET技术人员的淘宝客优惠券领取完整源码,基于C# ASP.NET MVC开发,实现小程序前台与后台管理联动,可直接对接阿里妈妈淘宝客API,并支持拓展内容管理、会员、订单、微信等二次开发模块。资源共76个文件,包含22个js业务脚本、17个json配置、17个wxss样式、16个wxml页面结构,另有后台源码压缩包、数据库脚本和必读说明;整体约22.8MB,目录清晰,便于按前台与后台分模块学习。后台默认登录账号admin,数据库连接在Web.config中调整,DataBase文件夹内提供SQLServer脚本,可快速搭建运行环境。目前已有1784人学习下载,适合有一定C#基础、希望快速搭建优惠券小程序或研究淘宝客API对接的开发者参考实践。
1. 这套“优惠券领取微信小程序源码”到底解决什么问题
2019年前后“ASP.NET MVC C# 优惠券领取微信小程序源码源代码”这类打包资源在电商和私域运营圈子里非常抢手,但很多人下载解压后第一反应是“这根本跑不起来”。原因很简单,所谓完整版往往缺数据库脚本、漏了微信小程序配置的appid,或者后端接口根本不对小程序开放。真正能落地的方案,是后端用ASP.NET MVC C#写一套REST接口,前端用微信小程序原生语言做优惠券列表与领取页面,中间通过HTTPS + JSON通信,再配合微信登录拿到openid才能识别用户。这套东西适合两类人:一是小电商团队想快速上线一个券码活动,二是.NET开发者想接小程序外包但还没跑通过一个完整链路。它能解决的核心问题,不是“运行一份源码”,而是让你从零搭出一套可领取、可核销、能防刷的优惠券系统。
2. 数据表设计与ASP.NET MVC后端接口:先把优惠券领取逻辑立住
2.1 三张核心表:优惠券、用户、领取记录如何设计字段
优惠券领取系统的后端核心是数据模型。我一般会设计三张表:Coupon(优惠券模板)、WeChatUser(微信用户)、CouponReceiveRecord(领取记录)。注意不要只设计一个“领取表”,因为你需要区分“券的库存”和“每人限领数量”。下面是最常用的一套建表脚本,兼容SQL Server 2012及以上:
CREATE TABLE [dbo].[Coupon]( [Id] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY, [Name] [nvarchar](50) NOT NULL, [Amount] [decimal](10,2) NOT NULL, -- 优惠金额 [ConditionAmount] [decimal](10,2) NULL, -- 满减门槛,0表示无门槛 [TotalCount] [int] NOT NULL DEFAULT 0, -- 总库存 [ReceivedCount] [int] NOT NULL DEFAULT 0, -- 已领取数 [StartTime] [datetime] NOT NULL, [EndTime] [datetime] NOT NULL, [CreateTime] [datetime] NOT NULL DEFAULT GETDATE() ); CREATE TABLE [dbo].[WeChatUser]( [Id] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY, [OpenId] [nvarchar](100) NOT NULL UNIQUE, [NickName] [nvarchar](100) NULL, [CreateTime] [datetime] NOT NULL DEFAULT GETDATE() ); CREATE TABLE [dbo].[CouponReceiveRecord]( [Id] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY, [CouponId] [int] NOT NULL FOREIGN KEY REFERENCES Coupon(Id), [WeChatUserId] [int] NOT NULL FOREIGN KEY REFERENCES WeChatUser(Id), [ReceiveTime] [datetime] NOT NULL DEFAULT GETDATE(), [Status] [tinyint] NOT NULL DEFAULT 0, -- 0可用,1已核销,2过期 [UseTime] [datetime] NULL );这里有几个关键点:ReceivedCount是冗余字段,目的是在领取时通过UPDATE ... WHERE ReceivedCount < TotalCount来原子地判断库存,避免高并发下超领。Status字段预留了后续核销和过期处理的扩展。OpenId必须加唯一索引,因为微信用户同一人的openid是固定不变的。
2.2 用ASP.NET MVC搭建接口:路由、跨域与模型绑定
2019年常见做法是创建ASP.NET MVC项目,然后添加Web API控制器来提供JSON接口。新建项目时选择“空”模板,然后手动添加GlobalConfiguration.Configure(WebApiConfig.Register)。在App_Start/WebApiConfig.cs中配置路由:
public static class WebApiConfig { public static void Register(HttpConfiguration config) { config.Routes.MapHttpRoute( name: "DefaultApi", routeTemplate: "api/{controller}/{action}/{id?}", defaults: new { id = RouteParameter.Optional } ); } }注意这里用的是api/{controller}/{action}风格,而不是默认的api/{controller}/{id},因为优惠券接口有领取、列表、核销等多个操作,用Action名更直观。同时需要在Global.asax.cs中注册:GlobalConfiguration.Configure(WebApiConfig.Register);。
跨域问题是小程序调用后端时遇到的第一道坎。微信小程序的wx.request要求服务器配置合法域名,同时浏览器跨域也需要后端允许。在XMLHttpRequest层面,需要在响应头加上:
protected void Application_BeginRequest(object sender, EventArgs e) { HttpContext.Current.Response.AddHeader("Access-Control-Allow-Origin", "*"); if (HttpContext.Current.Request.HttpMethod == "OPTIONS") { HttpContext.Current.Response.StatusCode = 200; HttpContext.Current.Response.End(); } }2.3 C#实现领取接口:原子操作、事务与异常处理
优惠券领取接口是整套系统的核心。它的逻辑必须包含:校验活动时间、校验库存、校验每人限领数量、写入领取记录、更新库存。下面是一个完整的C# Web API Action方法,使用Entity Framework 6的DbContext:
[HttpPost] [Route("api/Coupon/Receive")] public HttpResponseMessage Receive([FromBody] ReceiveCouponRequest request) { try { if (request == null || request.CouponId <= 0 || string.IsNullOrEmpty(request.OpenId)) return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "参数错误"); using (var db = new CouponDbEntities()) { // 1. 校验用户是否存在,不存在则注册 var user = db.WeChatUsers.FirstOrDefault(u => u.OpenId == request.OpenId); if (user == null) { user = new WeChatUser { OpenId = request.OpenId, CreateTime = DateTime.Now }; db.WeChatUsers.Add(user); db.SaveChanges(); // 先保存获得用户Id } // 2. 锁行读取优惠券,防止并发超领 var coupon = db.Coupons.FirstOrDefault(c => c.Id == request.CouponId); if (coupon == null) return Request.CreateErrorResponse(HttpStatusCode.NotFound, "优惠券不存在"); var now = DateTime.Now; if (now < coupon.StartTime || now > coupon.EndTime) return Request.CreateErrorResponse(HttpStatusCode.Forbidden, "不在领取时间范围内"); // 3. 检查每人限领,假设每个优惠券模板每人限领1次 var exists = db.CouponReceiveRecords.Any(r => r.CouponId == request.CouponId && r.WeChatUserId == user.Id); if (exists) return Request.CreateErrorResponse(HttpStatusCode.Forbidden, "每人限领一张"); // 4. 原子更新库存:只有更新行数大于0才算成功 var sql = "UPDATE Coupon SET ReceivedCount = ReceivedCount + 1 WHERE Id = @p0 AND ReceivedCount < TotalCount"; var affected = db.Database.ExecuteSqlCommand(sql, request.CouponId); if (affected == 0) return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, "优惠券已被抢光"); // 5. 写入领取记录 var record = new CouponReceiveRecord { CouponId = request.CouponId, WeChatUserId = user.Id, ReceiveTime = now, Status = 0 }; db.CouponReceiveRecords.Add(record); db.SaveChanges(); return Request.CreateResponse(HttpStatusCode.OK, new { success = true, message = "领取成功" }); } } catch (Exception ex) { // 记录异常日志,生产环境建议用Log4Net或NLog return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, "服务器异常"); } }这段代码的核心在于第4步的UPDATE语句。它不是一个简单的ReceivedCount = ReceivedCount + 1,而是带上了WHERE ReceivedCount < TotalCount条件,这样在并发情况下,数据库的行锁会保证只有一个人能把ReceivedCount从99更新到100,另一个人更新时受影响行数为0,从而判断为库存不足。避免先读库存再更新的“检查-更新”竞态。
ExecuteSqlCommand返回的是受影响行数,用它作为并发控制的标尺。如果返回0,说明库存已经不足,不能继续写领取记录。另外,这里把用户检查和写记录分成两步,但并没有用显式事务,因为第4步的原子更新已经保证了关键一致性。如果你需要同时处理用户创建和记录写入的异常回滚,可以用TransactionScope或db.Database.BeginTransaction()包起来。
参数说明:请求体ReceiveCouponRequest是一个简单的DTO,包含CouponId和OpenId两个属性,用[FromBody]标记表示从JSON反序列化。前端小程序必须传这个结构。注意OpenId不是小程序的code,而是真实openid。2019年很多新手把wx.login返回的code直接当openid传上来,这是大坑,后面会专门讲。
3. 微信小程序端:从登录到领取的完整调用链路
3.1 小程序页面布局与导航栏适配
小程序端其实不需要太复杂的UI,优惠券页面通常就是列表。我一般把页面设计成两个场景:一是“可领取列表页”,二是“我的优惠券页”。这里以领取页为例,WXML结构:
<view class="page"> <view class="top-tip">当前有 {{couponList.length}} 张优惠券可领</view> <view class="coupon-card" wx:for="{{couponList}}" wx:key="Id" wx:for-item="coupon"> <view class="left"> <text class="amount">¥{{coupon.Amount}}</text> <text class="condition">满{{coupon.ConditionAmount}}可用</text> </view> <view class="right"> <text class="name">{{coupon.Name}}</text> <text class="time">{{coupon.StartTime}} ~ {{coupon.EndTime}}</text> <button class="btn" bindtap="onReceive">const App = getApp(); function login() { return new Promise((resolve, reject) => { wx.login({ success: async (res) => { if (res.code) { // 将code发送到后端api/auth/login try { const result = await new Promise((resolve2, reject2) => { wx.request({ url: 'https://你的后端域名/api/Auth/Login', method: 'POST', data: { code: res.code }, header: { 'content-type': 'application/json' }, success: (resp) => resolve2(resp.data), fail: (err) => reject2(err) }); }); // 后端返回 { openid: "xxx", session_key: "yyy" } wx.setStorageSync('openid', result.openid); resolve(result.openid); } catch (e) { reject(e); } } else { reject(new Error('登录失败:' + res.errMsg)); } }, fail: reject }); }); }后端AuthController里对应的方法是:
[HttpPost] [Route("api/Auth/Login")] public async Task<HttpResponseMessage> Login([FromBody] LoginRequest loginRequest) { if (loginRequest == null || string.IsNullOrEmpty(loginRequest.code)) return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "code不能为空"); // 用HttpClient调微信接口,注意小程序appid和secret using (var client = new HttpClient()) { var url = $"https://api.weixin.qq.com/sns/jscode2session?appid={AppId}&secret={AppSecret}&js_code={loginRequest.code}&grant_type=authorization_code"; var response = await client.GetAsync(url); var json = await response.Content.ReadAsStringAsync(); // json里包含openid, session_key, expires_in dynamic result = Newtonsoft.Json.JsonConvert.DeserializeObject(json); if (result.errcode != null) return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "code无效"); var openid = (string)result.openid; return Request.CreateResponse(HttpStatusCode.OK, new { openid = openid }); } }这里必须注意:微信的code是一次性的,5分钟有效,且只能用一次。后端向jscode2session接口发起网络请求,AppId和AppSecret需要从微信公众平台小程序后台获取,不要写在代码里,建议放在Web.config的appSettings中。另外,2019年时这个接口还返回session_key,但你不需要保存它,因为用不到。你真正要做的是把这个openid当成用户唯一标识存储到数据库表里。
3.3 领取接口的调用与状态处理
前端领取逻辑需要绑定>Page({ data: { couponList: [], receivingId: null // 当前正在领取的券Id }, onLoad: async function () { await login(); // 先完成登录,拿到openid存到storage this.loadCoupons(); }, loadCoupons: function () { const openid = wx.getStorageSync('openid'); wx.request({ url: 'https://你的后端域名/api/Coupon/List', method: 'GET', data: { openid: openid }, success: (res) => { if (res.data.success) { this.setData({ couponList: res.data.list }); } } }); }, onReceive: function (e) { const id = e.currentTarget.dataset.id; if (this.data.receivingId) return; // 防止重复点击 const openid = wx.getStorageSync('openid'); this.setData({ receivingId: id }); wx.request({ url: 'https://你的后端域名/api/Coupon/Receive', method: 'POST', data: { CouponId: id, OpenId: openid }, header: { 'content-type': 'application/json' }, success: (res) => { this.setData({ receivingId: null }); if (res.data.success) { wx.showToast({ title: '领取成功', icon: 'success' }); this.loadCoupons(); // 刷新列表 } else { wx.showToast({ title: res.data.message, icon: 'none' }); } }, fail: () => { this.setData({ receivingId: null }); wx.showToast({ title: '网络错误', icon: 'none' }); } }); } });
这里有一个细节:data: { CouponId: id, OpenId: openid }中的属性名首字母大写,因为C#后端DTO的属性名是CouponId和OpenId,而前端传JSON时默认保留了属性名大小写。很多人习惯写小写,导致模型绑定失败,返回参数错误。如果不想纠结大小写,可以在C#的DTO上加[JsonProperty(PropertyName = "couponId")],但我个人建议前后端都用首字母大写,省得映射混乱。
receivingId字段的作用是同时在按钮上禁用,例如在WXML里把按钮的disabled绑定到receivingId == coupon.Id,这样用户狂点也不会连续发请求。
4. 避坑与排查:2019年这套系统最容易翻车的五个地方
4.1 跨域与TLS:小程序要求HTTPS,本地调试走不通
现象:真机预览时wx.request报url not in domain list,开发者工具中打开“不校验合法域名”能用,但手机一测就废。 原因:微信小程序要求所有请求域名必须是HTTPS,且域名要配置在公众平台后台的“request合法域名”里,不能使用IP和端口。 解决:开发阶段在开发者工具右上角“详情-本地设置”勾选“不校验合法域名”,但真机预览必须填真实的HTTPS域名并配置证书。后端部署到IIS后,用https://访问接口。2019年时阿里云/腾讯云提供免费DV证书,IIS绑定443端口并导入证书即可。你自己做测试时还可以用内网穿透工具,但生产环境绝对不能裸奔。
4.2 微信code换openid时的过期与重复使用
现象:用户点“领取”时提示“code无效”,或者两次进入页面后首次登录失效。 原因:wx.login返回的code只能使用一次,如果前端因为网络重试向后端发了两次,第二次必然失败;另外后端缓存了旧code,超过5分钟后也会失效。 解决:确保前端login()函数只执行一次,成功后把openid保存到wx.setStorageSync,后续请求不再重复调用wx.login。后端接口要做异常判断:如果jscode2session返回errcode为40029或40163,直接返回“登录过期,请重新进入小程序”。可以给用户一个重新登录的引导,不要静默失败。
4.3 并发领取导致超发或数据库死锁
现象:活动开始瞬间,库存明明设了100张,实际发出去了120张;或者数据库偶尔报死锁错误。 原因:库存判断和更新不是原子的,两个请求同时读到ReceivedCount=99,都认为还能领,然后各自+1,导致超发。或者使用了SELECT ... FOR UPDATE但没有正确锁行,在EF中查询时未加with(UPDLOCK)。 解决:采用我前面讲的UPDATE ... WHERE ReceivedCount < TotalCount原子操作。如果遇到死锁,多半是其他更新语句与这条语句的锁顺序不同。保证所有更新都从Coupon表开始,不要先更新领取记录再更新库存。另外,可以在OnModelCreating中对Coupon.ReceivedCount设置乐观并发标记:
modelBuilder.Entity<Coupon>() .Property(c => c.ReceivedCount) .IsConcurrencyToken();这样EF在更新时会自动带上WHERE ReceivedCount = 原值,仍然可能失败,但不会死锁。实际生产环境中我更推荐原生SQL更新,因为它性能最高、意图最清晰。
4.4 JSON时间格式导致小程序端解析失败
现象:coupon.StartTime在小程序里显示为/Date(1577808000000)/,无法直接展示。 原因:ASP.NET Web API默认序列化器是Json.NET,但2019年的MVC默认配置有时还会用JavaScriptSerializer,日期序列化格式是奇怪的那一串。 解决:在Global.asax.cs里显式配置:
GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings.DateTimeZoneHandling = Newtonsoft.Json.DateTimeZoneHandling.Local; GlobalConfiguration.Configuration.Formatters.JsonFormatter.SerializerSettings.DateFormatString = "yyyy-MM-dd HH:mm";这样输出就是2020-01-01 08:00。小程序端可以直接显示。如果你要传递时间戳给图表组件,可以单独加一个字段返回UnixTime,但普通列表页直接用字符串最方便。
4.5 IIS部署后API路径404,MVC路由与Web API路由冲突
现象:本地Visual Studio调试没问题,发布到服务器后访问/api/Coupon/List返回404。 原因:没有在IIS中启用“HTTP 重写”模块,或者项目配置中routes.MapHttpRoute在RouteConfig.RegisterRoutes之后注册,导致MVC路由先匹配,但找不到对应Controller。 解决:在Global.asax.cs中先注册Web API,再注册MVC路由:
protected void Application_Start() { GlobalConfiguration.Configure(WebApiConfig.Register); RouteConfig.RegisterRoutes(RouteTable.Routes); }另外检查IIS应用池是否设置为“集成”模式,并确认安装了ASP.NET 4.5和URL Rewrite模块。如果接口请求路径带api却走到MVC,可以确认Web API路由是否被RouteConfig的{*pathInfo}吞掉。在RouteConfig.cs中不要添加catch-all路由。
5. 进阶验证与防刷技巧:让优惠券系统在活动高并发下不翻车
当你把基础链路跑通后,最担心的是活动一开始就爆掉。我习惯先做三件事:用Fiddler或Postman模拟并发请求,查看日志确认没有超发;压测接口观察QPS和错误率;然后根据结果调整限流策略。下面是三个值得你直接抄走的技巧。
第一,用SemaphoreSlim做进程内限流,防止单台服务器被瞬时流量打满。在Receive接口方法外包裹一个静态信号量:
private static readonly SemaphoreSlim Semaphore = new SemaphoreSlim(1, 1); [HttpPost] [Route("api/Coupon/Receive")] public async Task<HttpResponseMessage> Receive(...) { await Semaphore.WaitAsync(); try { // 原有领取逻辑 } finally { Semaphore.Release(); } }这个信号量会把同一时间进入该方法的请求串行化,自然解决了单机并发问题。但如果部署多台服务器,需要改成分布式锁,比如使用Redis的SETNX。2019年常见做法是引用StackExchange.Redis,用LockTake实现请求级别的互斥,注意设置锁过期时间防止死锁。
第二,验证领取记录的正确性。不要只写日志,我建议在每笔记录里写入服务器时间、用户IP、渠道参数。这样活动结束后可以统计“每个用户领取了几张”“同一IP领取了几张”。如果发现同一IP半小时内领取超过10张,直接拉黑IP。代码实现很简单,在WeChatUser表加一个字段LastIp,接收接口里通过Request.GetOwinContext()或HttpContext.Current.Request.UserHostAddress获取IP并更新。更稳妥的是用Redis做滑动窗口计数:
var key = $"receive:{ip}"; var count = redis.StringIncrement(key); redis.KeyExpire(key, TimeSpan.FromMinutes(30)); if (count > 5) return Request.CreateErrorResponse(HttpStatusCode.Forbidden, "操作过于频繁");第三,用断言式日志排查“领取成功但记录没写”这类怪问题。当时我遇到过一次:库存更新成功,但SaveChanges时因为主键冲突报错,导致用户以为自己领到了但数据库没有记录。后来在所有写操作后加一个if (db.SaveChanges() == 0) throw new Exception("数据库写入异常"),确保每个关键步骤都有明确的结果。这个教训后来变成我的习惯:不轻易吞异常,小步提交,每步检查返回值。
最后,别忘了在接口里加一个“核销”功能。优惠券领了总要核销,可以是门店收银系统调用另一个接口,或者在小程序里展示核销二维码。至少要保证CouponReceiveRecord.Status能从0变成1,并且核销时需要校验券的归属和有效期。核销接口可以这样写:
[HttpPost] [Route("api/Coupon/Use")] public HttpResponseMessage Use([FromBody] UseCouponRequest request) { using (var db = new CouponDbEntities()) { var record = db.CouponReceiveRecords .Include(r => r.Coupon) .FirstOrDefault(r => r.Id == request.RecordId && r.WeChatUserId == request.UserId); if (record == null || record.Status != 0) return Request.CreateErrorResponse(HttpStatusCode.BadRequest, "券不可用"); if (DateTime.Now > record.Coupon.EndTime) return Request.CreateErrorResponse(HttpStatusCode.Forbidden, "券已过期"); record.Status = 1; record.UseTime = DateTime.Now; db.SaveChanges(); return Request.CreateResponse(HttpStatusCode.OK, new { success = true }); } }回看整个方案,最让我自己受益的习惯是“一切以数据为准”。每次活动开始前,先写一个脚本把库存和已领数量做成快照;活动结束后再对比快照和领取记录总数,确认没有差异。这样才能把“完整版源码”真正变成可控的系统。希望这篇笔记里的实现顺序和避坑经验,能帮你少走几趟弯路,把2019年的这套老掉牙组合跑得更稳。
本文还有配套的精品资源,点击获取