简介:这是一套基于C#与NetCore构建的小程序商城系统,前端采用原生微信小程序,后端由C#语言实现,面向需要快速搭建多店铺电商平台的技术团队与.NET开发者。系统整合完整商城业务闭环,覆盖多店铺管理、三级分销、促销、优惠券、积分、物流配送及插件化管理模块,同时提供小程序端与管理后台,整体达到商用标准。压缩包共1962个文件,大小约10.33MB,以cs、cshtml、js、json等源代码与配置资源为主,配套wxml、wxss小程序前端文件以及sql、xml数据库与部署配置,目录结构清晰。订单、用户、商品、插件管理、导出管理等核心业务均有对应控制器与处理服务,可从源码层面理解小程序与NetCore前后端交互方式、商城权限体系及营销模块的落地细节,也能作为直接运行的基础工程进行二次开发与功能扩展。目前已有1278人学习下载,适合需要完整商城参考项目或希望快速迭代上线的团队与个人开发者。
1. 这套 zip 到底装了什么:C# 与原生小程序搭出来的商城骨架
收到「基于C#的小程序商城,原生微信小程序+NetCore技术构建.zip」这类压缩包时,先别急着解压跑起来。它本质上是一条完整的业务链路:微信小程序负责商品展示、购物车、下单支付这些用户能摸到的界面,NetCore WebApi 负责商品库存、订单状态、用户身份这类业务逻辑和数据落库。两端通过 HTTPS 上的 JSON 通信,登录态靠微信的 code2Session 换取 openid,再在服务端签发 JWT 维护会话。对于手里只有 C# 技术栈、又想快速上线微信小程序商城的团队,这套组合比换 Java 或 Node 后端的学习成本低得多,也比用第三方 SaaS 商城多一层可控性。适合的人:能写 .NET 接口、愿意啃一遍小程序生命周期,但不打算引入跨端框架的开发者。
2. 拆开工程包:原生小程序与 NetCore 的项目边界
2.1 前端 pages 目录与后端 Controllers 的一一对应关系
一个规范的小程序商城工程,前端目录基本长这样:pages下按业务模块分子目录,utils里放 request 封装和公共方法,static或images存静态资源。后端 NetCore 项目则按经典三层拆:Controllers暴露 REST 接口,Services写业务逻辑,Repositories或DbContext直接操纵数据库。解压后先别急着跑,第一件事是核对前端每个wx.request的 URL 前缀和后端 Controller 的路由能不能对得上。
// utils/request.js 前端请求封装 const BASE_URL = 'https://api.yourdomain.com'; // 正式环境 // const BASE_URL = 'http://127.0.0.1:5000'; // 本地联调时用,但小程序真机不能访问 localhost function request(path, method, data, header = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method || 'GET', data: data || {}, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') ? 'Bearer ' + wx.getStorageSync('token') : '', ...header }, success: (res) => { if (res.statusCode === 200) { if (res.data.code === 0) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } } else if (res.statusCode === 401) { // token 过期或无效,跳回登录页 wx.removeStorageSync('token'); wx.reLaunch({ url: '/pages/login/login' }); reject(res); } else { wx.showToast({ title: '服务器开小差了', icon: 'none' }); reject(res); } }, fail: (err) => { reject(err); } }); }); }这段封装解决的是小程序端最基础的三个问题:BASE_URL 环境切换、Authorization 头的统一注入、状态码 401 时的登录态失效跳转。注意小程序真机调试时不能访问127.0.0.1,必须用局域网 IP 或者已备案且配好 HTTPS 的域名;开发者工具里勾选了「不校验合法域名」才能本地联调,但这只是开发期的救命手段,上线前一定要关掉。res.data.code === 0是前后端约定好的业务码,0 代表成功,非 0 是业务错误,HTTP 层只表达传输状态,业务错误码统一放 body 里。
后端对应这个请求的 Controller 大致长这样,路由命名建议直接按微信小程序的习惯走 RESTful 风格,省得前端维护一份 URL 映射表。
// Controllers/ProductController.cs [ApiController] [Route("api/[controller]")] public class ProductController : ControllerBase { private readonly IProductService _productService; public ProductController(IProductService productService) { _productService = productService; } [HttpGet("list")] public async Task<IActionResult> GetList(int page = 1, int pageSize = 20, int? categoryId = null) { var items = await _productService.GetPagedListAsync(page, pageSize, categoryId); return Ok(new { code = 0, data = items }); } [HttpGet("{id}")] public async Task<IActionResult> GetDetail(int id) { var item = await _productService.GetDetailAsync(id); if (item == null) return NotFound(new { code = 404, msg = "商品不存在" }); return Ok(new { code = 0, data = item }); } }[ApiController]特性会自动做模型校验和参数绑定,[Route("api/[controller]")]这种占位符路由让控制器名直接映射进 URL,api/product/list对应前端请求路径。GetPagedListAsync里的分页参数page和pageSize要在前端 request 里用小程序的data传过来,后端做了默认值兜底但前端不应依赖,因为一旦漏传,用户会直接看到第 1 页第 20 条之后乱掉。控制器不写业务逻辑,只做参数接收和结果包装,所有规则放在 Service 层,这样后续加缓存、加消息队列都不需要动接口签名。
2.2 为什么选原生微信小程序而不上 uni-app 或 Taro
可能你拿到这套源码时会疑惑:都 2025 年了,为什么不直接上 uni-app 一份代码双端复用?答案很现实:商城类小程序对微信生态的依赖极重,原生框架能拿到最完整的 API 能力和最少的兼容层损耗。原生wx.login、wx.getPhoneNumber、wx.requestPayment这些接口在跨端框架里往往要包一层桥接,桥接层一旦跟不上微信的更新节奏,支付和手机号这类核心链路的稳定性就要打折扣。C# 后端开发者通常不是专职前端,少学一套 Vue 语法和框架编译规则,直接写 WXML 反而更容易定位问题。
另外原生小程序在「微信小程序单选框」「微信小程序顶部导航栏高度」这类细节上可以直接用官方组件和wx.getSystemInfoSync()拿真实值,不需要通过框架的抽象层猜。虽然 uni-app 也能编译到微信端,但 H5 端和小程序端的渲染差异会让你在调试「原生微信小程序+NetCore技术构建」这套架构时多出一层不确定因素。如果你确定未来还要做支付宝小程序或 H5,再考虑跨端;如果只做微信生态,原生就是成本最低的选型。
2.3 NetCore 后端的模块划分:从商品到订单的状态流转
后端建议按商城业务域拆模块,而不是按技术层拆。商品域、库存域、购物车域、订单域、支付域各自独立,域之间只通过接口交互。这种划分在工程里落地时一般用文件夹结构体现,配合AddScoped注册每个域的 Service,数据库上下文统一在启动项目里配置。
// Program.cs NetCore 6+ 最小 API 托管方式 var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers().AddNewtonsoftJson(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddDbContext<MallDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default"))); builder.Services.AddScoped<IProductService, ProductService>(); builder.Services.AddScoped<IOrderService, OrderService>(); builder.Services.AddScoped<ICartService, CartService>(); builder.Services.AddScoped<IWeChatService, WeChatService>(); builder.Services.AddScoped<IPaymentService, PaymentService>(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();依赖注入在这里管住了全局:任何 Controller 的构造函数里声明IProductService,容器就自动把实现类塞进来,要换数据库或加中间件只需改到这里,不用改业务代码。UseAuthentication和UseAuthorization的顺序不能颠倒,前者识别「你是谁」,后者决定「你能不能做」,顺序反了在后续做 JWT 鉴权时会出现 401 和 403 混在一起的诡异现象。数据库先选 SQL Server 还是 MySQL 取决于团队,但连接字符串要单独放在appsettings.json里,并且上线后用环境变量覆盖,千万别把带口令的连接串提交进 Git 历史。
3. 登录与手机号链路:NetCore 后端如何接管微信身份
3.1 小程序 wx.login 到 JWT 签发:code2Session 的两次网络请求
商城小程序的核心前提是知道「这个人是谁」。微信小程序登录获取手机号之前,必须先用wx.login()拿到临时 code,再把 code 发到自己的 NetCore 后端。后端拿着 code 去调微信的https://api.weixin.qq.com/sns/jscode2session,换回openid、session_key和unionid。这里有个常见的认知错误:session_key 的有效期微信不直接给出,但 code 只能一次性使用,且 5 分钟后过期,所以后端必须在收到 code 后立刻发起换 session 的请求,不能缓存 code 供后续重试。
// Services/WeChatService.cs public async Task<WeChatSession> Code2SessionAsync(string code) { var appId = _config["WeChat:AppId"]; var appSecret = _config["WeChat:AppSecret"]; var url = $"https://api.weixin.qq.com/sns/jscode2session?appid={appId}&secret={appSecret}&js_code={code}&grant_type=authorization_code"; using var httpClient = _httpClientFactory.CreateClient("WeChat"); var response = await httpClient.GetStringAsync(url); var result = JsonSerializer.Deserialize<JsonElement>(response); if (result.TryGetProperty("errcode", out var errcode) && errcode.GetInt32() != 0) { // 记录微信原始错误码和消息,便于排查 var errmsg = result.GetProperty("errmsg").GetString(); throw new WeChatApiException($"code2session failed: {errcode} {errmsg}"); } var session = new WeChatSession { OpenId = result.GetProperty("openid").GetString(), SessionKey = result.GetProperty("session_key").GetString(), UnionId = result.TryGetProperty("unionid", out var u) ? u.GetString() : null }; return session; }这里必须用IHttpClientFactory来管 HttpClient 的生命周期,而不是在方法里new HttpClient()。原因:HttpClient 的 socket 资源不会随对象释放而立即回收,高并发下会出现端口耗尽,这在 .NET Core 的异步场景里是高频故障。code2session 接口不需要静态 Token,每次带 AppId 和 AppSecret 请求即可,AppSecret 绝不能出现在小程序前端代码里。拿到 openid 后,检查用户表里是否已有该 openid,有就直接发 JWT,没有就先创建默认用户再发。JWT 的有效期建议 2 小时,刷新令牌另发一个 7 天的 refresh_token,避免用户每两小时重新登录一次,但 refresh_token 需要落库存状态以备吊销。
// Services/AuthService.cs 签发 JWT 的核心参数 private string GenerateJwtToken(string openId, string userId) { var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, userId), new Claim("openid", openId) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:SecretKey"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, expires: DateTime.Now.AddHours(2), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); }JWT 的 SecretKey 至少 32 字节,放appsettings.json的Jwt:SecretKey节点,上线后通过环境变量注入。Issuer 和 Audience 是签发方和接收方标识,小程序端不需要关心这两个值,但后端在Program.cs配置AddAuthentication().AddJwtBearer()时,必须把验证参数里的ValidateIssuer、ValidateAudience、ValidateLifetime全部设为 true,否则任意拿一个同 SecretKey 签名的 token 就能冒充整个系统的所有用户,这个坑一旦踩上,账号体系直接失去意义。
3.2 获取用户手机号:加密数据解密与偏移量的算法细节
用户在小程序端点击「获取手机号」按钮后,微信返回code,这个 code 换手机号的方式在 2023 年后改成了动态令牌模式:后端拿这个 code 调用https://api.weixin.qq.com/wxa/business/getuserphonenumber接口,传入access_token和 code,直接拿手机号,不再需要自己解密encryptedData。这一点很多老教程还停留在自己解密 session_key 的阶段,需要注意区分:新接口一次调用就能拿 phone_info,不需要小程序端把 encryptedData 和 iv 传过来,这大幅减少了传输链路里的数据暴露。
// Services/PhoneService.cs public async Task<PhoneInfo> GetPhoneNumberAsync(string phoneCode) { var accessToken = await GetAccessTokenAsync(); // 全局缓存,提前几分钟刷新 var requestBody = new { code = phoneCode }; var requestUrl = $"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={accessToken}"; using var httpClient = _httpClientFactory.CreateClient("WeChat"); var json = JsonSerializer.Serialize(requestBody); var content = new StringContent(json, Encoding.UTF8, "application/json"); var response = await httpClient.PostAsync(requestUrl, content); var result = await response.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(result); if (doc.RootElement.GetProperty("errcode").GetInt32() != 0) throw new WeChatApiException("getphone failed"); return doc.RootElement.GetProperty("phone_info").Deserialize<PhoneInfo>(); }GetAccessTokenAsync必须做缓存,微信的 access_token 有效期 7200 秒且每日获取次数有限(接口频率限制在文档写明),每次调 getuserphonenumber 都去拿新 token 会很快触发频率限制。常见做法是放到内存缓存或 Redis,过期前 5 分钟主动刷新。手机号属于敏感个人信息,后端拿到后要做脱敏存储,比如只存138****1234,完整号码单独加密放在另一列或另一张表,并在日志里禁止打印完整手机号。商城场景下手机号通常用于登录后的二次身份绑定,不一定要和 openid 一一对应,需要自己设计用户表结构来存多个手机号或换绑历史。
3.3 NetCore 过滤器实现登录态校验:ActionFilter 与 AuthorizationFilter 的取舍
鉴权不能在每个 Controller 里复制粘贴校验代码,社区的标准做法是用 AuthorizationFilter 或自定义中间件统一拦。.NETCore 过滤器里,IAsyncAuthorizationFilter在 Controller Action 执行之前运行,适合做 token 校验和用户信息填充;IAsyncActionFilter在 Action 执行前后包一层,适合做参数日志和性能埋点。对于商城这种绝大多数接口都需要登录态的场景,不如直接做全局过滤器,再在匿名接口上打[AllowAnonymous]。
// Filters/JwtAuthorizeFilter.cs public class JwtAuthorizeFilter : IAsyncAuthorizationFilter { private readonly ITokenValidator _validator; public JwtAuthorizeFilter(ITokenValidator validator) { _validator = validator; } public async Task OnAuthorizationAsync(AuthorizationFilterContext context) { var hasAllowAnonymous = context.ActionDescriptor.EndpointMetadata .Any(e => e is AllowAnonymousAttribute); if (hasAllowAnonymous) return; // 登录接口、支付回调不能拦 var authHeader = context.HttpContext.Request.Headers["Authorization"].ToString(); if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ")) { context.Result = new UnauthorizedResult(); return; } var token = authHeader.Substring("Bearer ".Length).Trim(); var principal = await _validator.ValidateTokenAsync(token); if (principal == null) { context.Result = new UnauthorizedResult(); return; } context.HttpContext.User = principal; // 后续 Action 里用 User.Identity.Name 取用户 } }注意检查 AllowAnonymous 的逻辑必须在 token 校验之前,否则微信支付回调这类需要匿名访问的接口会被误伤成 401。支付回调尤其特殊:微信服务器的回调不带你的 JWT,只带签名,所以整个回调接口都不能过自定义鉴权过滤器,改为回调内部自己验签。Validator 内部解析 JWT 时,要处理 token 过期、签名错误、用户被禁用三种异常,并且分别记录不同日志级别。这里「返回 401 而不是 403」是一个重要的语义区分:401 表示未认证,前端收到后跳登录页;403 表示已认证但无权限,前端需要提示「无权限」而不是重新登录。
4. 小程序商城的页面与接口联调:从商品列表到支付回调
4.1 顶部导航栏与页面布局:拿到真实高度再渲染
商城首页的布局比普通页面更敏感,因为顶部往往有搜索框、分类 tab、轮播图,任何像素偏移都会让用户觉得「这个商城是坏的」。原生小程序里,wx.getSystemInfoSync()可以拿到状态栏高度,但胶囊按钮的位置和高度在不同机型(尤其是 iPhone 刘海屏和 Android 全面屏)上差异明显。「微信小程序顶部导航栏高度」这个问题的标准解法是:自定义导航栏,用胶囊按钮的top和height算出导航栏总高度,而不是硬编码 44px 或 64px。
// utils/navigation.js function getNavBarHeight() { const win = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); const capsule = wx.getMenuButtonBoundingClientRect(); if (!capsule || !capsule.height) return { statusBarHeight: 20, navBarHeight: 44 }; const statusBarHeight = win.statusBarHeight || 20; const navBarHeight = (capsule.top - statusBarHeight) * 2 + capsule.height; return { statusBarHeight, navBarHeight }; }wx.getMenuButtonBoundingClientRect()是拿胶囊按钮位置的关键 API,(capsule.top - statusBarHeight) * 2 + capsule.height这个公式算出的是导航栏从状态栏底部到导航栏底部的总高,因为胶囊按钮垂直居中于导航栏,所以顶部留白的两倍加胶囊自身高度就是导航栏完整高度。这个值在页面onLoad里取一次存入全局即可,设计稿里不要写死。首页 swiper 的高度如果用比例算,通常用750 / 设计稿轮播图宽度 * 设计稿轮播图高度得到 rpx 转 px 后的值,但要注意rpx转px在不同宽度设备上会变,直接image的mode="widthFix"更省心。
4.2 购物车与订单的数据流:本地缓存、服务端同步和状态机
购物车在商城里的实现有两条路线:纯服务端保存(每次增删都请求接口)或本地缓存 + 服务端校验。纯本地缓存的隐患是换设备或清缓存后购物车丢失,用户会直接骂人;纯服务端的隐患是网络慢时用户频繁点击加购会感觉到卡。折中做法是本地用wx.setStorageSync存一份购物车数据的快照,进入购物车页面时调后端/api/cart/sync做合并,以服务端数据为准,把本地差异提交上去。合并逻辑要考虑同一个 SKU 的数量叠加,而不是直接把本地覆盖服务端,否则会出现用户 A 在 Web 端加购的商品被小程序端的空购物车冲掉。
// Services/CartService.cs public async Task<CartMergeResult> MergeCartAsync(string userId, List<CartItemDto> localItems) { var serverItems = await _db.CartItems.Where(c => c.UserId == userId).ToListAsync(); foreach (var local in localItems) { var existing = serverItems.FirstOrDefault(s => s.SkuId == local.SkuId); if (existing != null) { existing.Quantity = Math.Max(existing.Quantity, local.Quantity); // 取较大值,不简单相加 } else { _db.CartItems.Add(new CartItem { UserId = userId, SkuId = local.SkuId, Quantity = local.Quantity }); } } await _db.SaveChangesAsync(); return new CartMergeResult { Items = await GetCartListAsync(userId) }; }合并策略里的Math.Max是故意设计的:用户在购物车页面把数量改成 1,但本地缓存里是 3,如果直接覆盖会把用户的修改吞掉,如果相加则会莫名多出数量。取较大值是一个折中方案,真实项目里应该在用户的每次数量变更操作时立即同步服务端,让本地只作为弱网时的临时存储。订单状态机则严格很多:待付款 → 已付款 → 待发货 → 已发货 → 已完成 / 已取消,每个状态流转都要有对应的操作接口,不允许直接改状态字段。Enum用 int 落库,但接口里用字符串枚举名称传输,避免前端看到魔法数字 3 还要去猜是什么状态。
4.3 微信支付与回调验签:NetCore 接口中的签名防重
支付这块最容易出事故的环节不在「调起支付」,而在「回调通知」。小程序端wx.requestPayment成功后,后端收到微信支付的异步通知,必须做两件事:验签和幂等处理。验签用微信支付平台证书,按文档用Wechatpay-Serial、Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce四个头构造验签串,用证书公钥做 RSA-SHA256 验签。验签失败直接返回{ "code": "FAIL", "message": "签名错误" },微信会停止重试但不影响已支付成功的订单,只是后台不会自动更新状态。
// Controllers/PaymentController.cs [HttpPost("callback")] [AllowAnonymous] public async Task<IActionResult> PayCallback() { var headers = Request.Headers; var body = await new StreamReader(Request.Body).ReadToEndAsync(); var isValid = _wechatPayService.VerifyCallbackSignature(headers, body); if (!isValid) return Ok(new { code = "FAIL", message = "签名错误" }); var result = JsonSerializer.Deserialize<PayCallbackDto>(body); if (result.event_type != "TRANSACTION.SUCCESS") return Ok(new { code = "FAIL", message = "忽略非支付事件" }); var outTradeNo = result.resource.OutTradeNo; var transactionId = result.resource.TransactionId; var amount = result.resource.Amount.Total; // 幂等:检查订单状态是否已经是已支付,是则直接返回成功,不再处理 var order = await _db.Orders.FirstOrDefaultAsync(o => o.OrderNo == outTradeNo); if (order == null) return Ok(new { code = "FAIL", message = "订单不存在" }); if (order.Status == OrderStatus.Paid) return Ok(new { code = "SUCCESS", message = "成功" }); order.Status = OrderStatus.Paid; order.TransactionId = transactionId; order.PaidAmount = amount; await _db.SaveChangesAsync(); // 通知业务模块:扣库存、发消息、记流水 await _orderService.AfterPaidAsync(order.Id); return Ok(new { code = "SUCCESS", message = "成功" }); }幂等这一步是血泪教训:微信支付回调在网络抖动或服务重启时可能发送多次,如果不做状态判断,就会出现订单状态被覆盖为已支付后再次触发扣库存,库存瞬间变负。TRANSACTION.SUCCESS是微信支付 v3 版本的事件类型,老的 v2 版本用的是SUCCESS字符串,字段结构也不一样,实现前先确认你集成的是 v3 还是 v2。回调响应要在拿到结果后立即返回,不能把业务处理放在异步线程里再返回 SUCCESS,否则微信认为没收到回执继续重试,直到几小时后服务端才真正处理完,用户已经发起退款。回调接口里可以用中间件记录完整请求头和 body,排查问题时这是唯一线索。
5. 避坑与排查:这套商城方案最常见的 5 个翻车现场
5.1 AppSecret 泄漏在小程序端代码里被反编译
现象:上线后微信公众平台安全体检提示 AppSecret 疑似泄漏,或者有外部用户调用你的 code2session 接口刷出大量 openid。 原因:开发时图省事把 AppSecret 直接写在app.js里当全局变量,小程序代码包解包后字符串一搜就能看到,AppSecret 一旦泄露任何人都能拿它换 session_key,进而伪造身份。 解决:后端所有请求微信接口的数据都只存appsettings.json并部署时用环境变量覆盖,前端只存 AppId。一个合理的习惯是每周轮换一次 AppSecret,并对 code2session 接口做调用频率限制(比如同一个 IP 每分钟最多 100 次),这两个动作同时做,能把风险压到可接受范围。
5.2 code2Session 返回 40029 或 45011 错误吗
现象:测试登录一切正常,上线后大量用户登录报errcode 40029(invalid code),偶发45011(api minute-quota limit hit)。 原因:40029 通常是 code 使用过一次后又被重复提交,或者前端在短时间把两个不同 code 发到了后端,可能是点击登录时按钮没禁点,用户连点了两次。45011 出现在高并发冷启动时,后端大量请求同时打向微信的 code2session 接口,触发分钟级频率上限。 解决:前端提交登录请求前加防抖,5 秒内只允许发送一次;后端收到 code 后用 Redis 做一次性校验,同一个 code 第二次出现直接返回客户端错误;并将 code2session 请求串行化或用内存队列削峰,不要每来一个请求就立刻向外发。真实打开率高的商城,上午十点和晚上八点各有一个秒杀峰,最好提前把 access_token 也预热好。
5.3 获取手机号接口解密后手机号少一位
现象:调用getuserphonenumber返回成功,但拿到的手机号中间缺了数字,或者格式变成+86 138...的开头。 原因:新接口返回的phone_info里purePhoneNumber才是纯 11 位号码,phoneNumber字段带国家区号,直接把phoneNumber拿来存库会出现脏数据。还有一个隐蔽坑:接口返回 JSON 中手机号字段名拼写是purePhoneNumber,有的老代码用purephonenumber去取,JObject 索引大小写不敏感可能没事,但用 System.Text.Json 默认大小写敏感就直接取不到。 解决:反序列化时明确指定[JsonPropertyName("purePhoneNumber")],存库前用正则^1[3-9]\d{9}$校验,不通过直接拒绝存入并打印告警日志。入库后再次查询返回给前端前做脱敏处理,不要在接口响应里返回完整手机号。
5.4 支付成功但订单还是待付款
现象:用户微信支付弹窗显示已扣款,但小程序订单详情页状态还是「待付款」,用户开始投诉。 原因:支付回调没有正确返回 SUCCESS 给微信,或者回调处理抛了异常没捕获。异常可能来自数据库写入失败(字段长度不够、外键约束),也可能来自 AfterPaidAsync 里发优惠券时外部接口超时。 解决:回调接口最外层套 try-catch,只要验签通过就返回 SUCCESS,把订单更新放进一个可靠队列或单独补偿任务去处理;数据库操作拆小事务,订单状态更新先 Commit,再发券扣库存,后者失败不能回滚前者,而是记录失败标记由定时任务重试。按这个思路,支付成功的订单状态最迟 30 秒内会正确,不需要等用户投诉再来修。
5.5 真机请求全部失败但开发者工具正常
现象:开发者工具里所有接口都能通,预览到真机上全部 request 失败,network 面板显示request:fail url not in domain list。 原因:小程序真机强制校验合法域名,开发者工具里「不校验合法域名」的开关只对工具内调试生效,真机不会理会。如果你用的域名没备案、没配 HTTPS、或没在小程序后台 request 合法域名列表里,都会直接拦截;NetCore 默认没有启用 HTTPS 重定向时也会在真机上被拦。 解决:上线前把https://api.yourdomain.com加入微信公众平台的「服务器域名」配置,并保证证书链完整,NetCore 的UseHttpsRedirection确保用户访问 http 时 302 跳 https。本地联调时如果一定要真机预览,用内网穿透工具把本机 5000 端口映射成临时 https 域名,再暂时加入合法域名列表,但这只是权宜之计,正式版本必须走正式域名。
6. 进阶技巧:把库存并发压下来和把工程化做上去
6.1 Redis 缓存与防超卖:一锁二查三更新
商城的高并发瓶颈几乎都死在库存上,秒杀场景下数据库行锁扛不住。常见做法是商品详情和库存数量放 Redis,用 String 类型存 SKU 剩余量,下单时用 Lua 脚本做原子扣减,扣成功才允许创建订单,扣失败直接提示库存不足。Lua 脚本能保证检查和扣减两步的原子性,避免两个线程同时读到库存 1 后都下单成功。
-- lua/stock_deduct.lua local key = KEYS[1] -- 库存 key,形如 sku:stock:{skuId} local quantity = tonumber(ARGV[1]) local current = tonumber(redis.call('GET', key) or '0') if current < quantity then return -1 end redis.call('DECRBY', key, quantity) return 1NetCore 侧调用这个脚本时用StackExchange.Redis的ScriptEvaluate方法,把 SKU ID 作为 KEYS,扣减数量作为 ARGV,返回值是 1 才继续走创建订单流程。数据库里的库存表和 Redis 之间需要一个对账任务:每五分钟把所有 SKU 的 Redis 剩余量回写数据库,同时记录扣减流水。这个方案的前提是 Redis 不能丢数据,生产环境必须开启 AOF 持久化,否则 Redis 重启后库存恢复到满量,用户用的优惠券也全作废,运营那边会以为系统出了灵异事件。
6.2 环境隔离与配置管理:appsettings 多环境自动切换
NetCore 在ASPNETCORE_ENVIRONMENT环境变量下会自动加载appsettings.{Environment}.json,这套机制在小程序商城的多人协作里很有用。本地开发用Development,联调用Staging(连测试数据库),生产用Production。小程序端则用wx.getAccountInfoSync().miniProgram.envVersion判断当前运行环境是开发版、体验版还是正式版,动态切 BASE_URL,这样测试体验版时不会把数据写到生产库。
// appsettings.Production.json 关键配置 { "ConnectionStrings": { "Default": "Server=10.0.0.5;Database=MallProd;User Id=appuser;Password=********;TrustServerCertificate=True" }, "WeChat": { "AppId": "wx1234567890", "AppSecret": "从环境变量注入,不要写这里" }, "Jwt": { "SecretKey": "从环境变量注入,至少32位随机串", "Issuer": "MallServer", "Audience": "MallMiniProgram" }, "Redis": { "ConnectionString": "10.0.0.6:6379,password=********,abortConnect=false" }, "Aes": { "Key": "从环境变量注入", "Iv": "从环境变量注入" } }生产环境的密码全部由 CI/CD 流水线的环境变量注入,不落appsettings.json,也不打进去镜像。我见过一个团队把生产数据库密码提交到 Git 私有仓库,后面仓库权限泄露导致整个库被脱机加密勒索,这就是把 AppSecret 写进代码的放大版。CI 流水线至少做四件事:dotnet build -c Release、dotnet test、dotnet publish、用Dockerfile构建镜像推到私有仓库。小程序端虽不能直接走这些构建产物,但可以用微信 CI 机器人做预览版上传和自动化测试,把人工发版这一步省掉。
最后说一个我自己踩过的坑:有一版上线把WeChat:AppSecret误写进appsettings.Development.json并提交了仓库,当时没事,三个月后收到安全告警才发现。从那以后所有秘钥一律要求裸奔式管理——代码里只留占位符,部署环境里必须给到完整值,少一个环境变量服务就起不来,而不是启动后再偷偷读一个默认值。这看起来很麻烦,但它能逼着团队把敏感配置的入口收窄到一个可控的点上。小程序商城的链路本身就长,前端、网关、后端、DB、Redis、微信开放平台、支付平台,每一环都可能出问题,配置管理整齐一点,排查问题时就能少一个变量。希望帮到你。
本文还有配套的精品资源,点击获取