☰
微信小程序+NET Core+layui多店铺商城实战指南
2026/10/7 20:14:40 网站建设 项目流程

简介:这是一套基于微信小程序、.NET Core与Layui构建的多店铺商城系统,面向C#开发者及新零售电商项目实践者,提供可商用的全栈解决方案。资源涵盖小程序前端、NetCore WebAPI后端、Layui管理后台及插件扩展模块,覆盖商户入驻、商品管理、订单处理、数据导出等核心业务场景,适合中高级开发者学习架构设计与跨端协同开发。压缩包共1915个文件,主体为798个C#业务逻辑文件(如OrderController、GoodsController)、180个JS交互脚本、148个CSHTML视图页、42个WXML/WXSS小程序组件,辅以配置文件、静态资源与DLL依赖,整体体积10.57MB,结构清晰、模块解耦度高。已有233人学习下载,读者可直接获取完整项目源码、分层目录结构、插件化扩展机制及典型业务服务实现(如订单处理、用户权限、插件管理),快速理解电商系统从接口到界面的落地细节。

1. 微信小程序 + .NET Core + layui 多店铺商城:不是拼凑三件套,而是用 C# 构建可交付、可运维、能过审的商用闭环

你手头有一套标着「商用标准」的多店铺商城源码,技术栈写着微信小程序 + .NET Core + layui —— 看起来很稳,但真往生产环境一推,就卡在登录态同步失败、订单状态不一致、小程序审核被拒、后台管理页加载慢得像在等编译完成。这不是技术选型错了,而是没理清这三者的真实分工:微信小程序是用户触点与前端交互层(含登录、支付、地址、分享),.NET Core 是业务中枢与数据守门人(多租户隔离、库存扣减、分账逻辑、微信支付回调验签),layui 是后台管理系统的快速落地界面(非展示型页面,而是运营、店员、财务真正每天点几十次的操作台)。它不追求炫酷动效,但必须扛住 30+ 店铺同时上新、秒杀、售后批量处理;它不靠 Vue/React 生态堆功能,而是用 C# 的强类型、EF Core 的迁移可控性、JWT 的细粒度权限控制,把「多店铺」从概念落到数据库租户字段、API 路由前缀、日志隔离策略里。适合正在接手这类项目的技术负责人、独立开发者,或需要向甲方交付「有后台、有小程序、有管理端、能上线」完整体的中小团队——别再拿 uni-app 套壳当商城,也别用 PHP 快速原型应付验收。


2. 搭建可验证的最小运行体系:从 .NET Core API 到小程序登录链路打通

要让这套体系活起来,第一步不是写商品列表,而是跑通「用户在小程序里点登录 → 后台拿到 openid/session_key → 返回自定义 token → 小程序后续请求带 token 访问订单接口」这条主干链路。很多团队卡在这一步,不是代码写错,而是对微信登录机制和 .NET Core 中间件协作的理解有断层。

2.1 初始化 .NET Core Web API 项目并启用 JWT 认证

我们不用模板里的dotnet new webapi全家桶,而是从干净的空项目起步,确保每个包都清楚用途:

dotnet new web -n ShopAdminApi cd ShopAdminApi dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer dotnet add package Microsoft.IdentityModel.Tokens dotnet add package System.IdentityModel.Tokens.Jwt dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools

提示:这里不装Swashbuckle.AspNetCore(Swagger),因为商用后台 API 文档应由 Postman Collection 或 OpenAPI 3.0 YAML 手动维护,避免 Swagger UI 暴露敏感路径。.NET Core 过滤器在此阶段主要用于全局异常捕获和日志埋点,而非权限控制——权限交给 JWT Bearer 验证中间件统一处理。

在Program.cs中配置服务:

var builder = WebApplication.CreateBuilder(args); // 1. 添加 JWT 认证服务 builder.Services.AddAuthentication("Bearer") .AddJwtBearer("Bearer", options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"] ?? "your-32-byte-secret-key-here")), ValidateIssuer = false, ValidateAudience = false, ClockSkew = TimeSpan.Zero // 关键:微信返回时间戳可能有微小偏差,设为零避免验签失败 }; }); // 2. 添加授权服务(必须在 AddAuthentication 之后) builder.Services.AddAuthorization(); // 3. 添加控制器支持 builder.Services.AddControllers(); var app = builder.Build(); // 4. 启用认证 & 授权中间件(顺序不能错!) app.UseAuthentication(); // 必须在 UseAuthorization 之前 app.UseAuthorization(); app.MapControllers(); app.Run();

appsettings.json中添加 JWT 配置段:

{ "Jwt": { "Key": "your-32-byte-secret-key-here" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } } }

2.2 实现微信小程序登录接口:接收 code,调用微信接口换取 openid,并签发 JWT token

新建控制器Controllers/AuthController.cs:

[ApiController] [Route("api/[controller]")] public class AuthController : ControllerBase { private readonly IHttpClientFactory _httpClientFactory; private readonly IConfiguration _configuration; public AuthController(IHttpClientFactory httpClientFactory, IConfiguration configuration) { _httpClientFactory = httpClientFactory; _configuration = configuration; } [HttpPost("login-by-code")] public async Task<IActionResult> LoginByCode([FromBody] LoginByCodeRequest request) { if (string.IsNullOrWhiteSpace(request.Code)) return BadRequest(new { message = "code 不能为空" }); // 1. 调用微信接口换取 session_key 和 openid var appId = _configuration["WeChat:AppId"]; var appSecret = _configuration["WeChat:AppSecret"]; var url = $"https://api.weixin.qq.com/sns/jscode2session?" + $"appid={appId}&secret={appSecret}&js_code={request.Code}&grant_type=authorization_code"; var client = _httpClientFactory.CreateClient(); var response = await client.GetAsync(url); var json = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { return StatusCode((int)response.StatusCode, new { message = "微信接口调用失败", raw = json }); } var wxResult = JsonSerializer.Deserialize<WeChatSessionResponse>(json); if (wxResult == null || string.IsNullOrEmpty(wxResult.Openid)) { return BadRequest(new { message = "获取 openid 失败", detail = json }); } // 2. 根据 openid 查询或创建用户(此处简化:直接生成 token,实际需查库) var token = GenerateJwtToken(wxResult.Openid, wxResult.Unionid ?? wxResult.Openid); return Ok(new { success = true, token, expires_in = 3600, openid = wxResult.Openid, unionid = wxResult.Unionid }); } private string GenerateJwtToken(string openid, string unionid) { var key = Encoding.UTF8.GetBytes(_configuration["Jwt:Key"]); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, openid), new Claim("unionid", unionid), new Claim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()), new Claim("exp", DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds().ToString()) }; var token = new JwtSecurityToken( issuer: "shop-admin-api", audience: "mini-program-client", claims: claims, expires: DateTime.UtcNow.AddHours(1), signingCredentials: new SigningCredentials( new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256) ); return new JwtSecurityTokenHandler().WriteToken(token); } } public class LoginByCodeRequest { public string Code { get; set; } } public class WeChatSessionResponse { public string Openid { get; set; } public string Session_Key { get; set; } public string Unionid { get; set; } public string Errmsg { get; set; } }

逻辑说明:

  • LoginByCodeRequest是小程序传来的code,必须严格校验非空;
  • 微信jscode2session接口返回的是openid(唯一标识该用户在当前公众号/小程序下的身份)和unionid(跨公众号/小程序的统一 ID,需绑定开放平台);
  • GenerateJwtToken中iat(issued at)和exp(expires)必须用 Unix 时间戳,且ClockSkew = TimeSpan.Zero已在 Startup 中设置,否则因服务器时间与微信服务器微小差异导致验签失败;
  • 实际项目中,此处应查询User表:若 openid 不存在则插入新用户(含默认店铺 ID、注册时间、设备信息),若存在则更新最后登录时间。

2.3 小程序端调用登录接口并持久化 token

在小程序app.js的onLaunch中完成自动登录:

App({ onLaunch() { wx.login({ success: res => { if (res.code) { // 1. 发起网络请求,将 code 传给后端 wx.request({ url: 'https://your-api-domain.com/api/auth/login-by-code', method: 'POST', data: { code: res.code }, success: loginRes => { if (loginRes.data.success) { // 2. 将 token 存入 storage,后续所有请求带上 wx.setStorageSync('auth_token', loginRes.data.token); // 3. 触发全局登录成功事件(供页面监听) this.globalData.isLoggedIn = true; this.globalData.token = loginRes.data.token; } else { console.error('登录失败', loginRes.data); } }, fail: err => console.error('API 请求失败', err) }); } else { console.error('登录失败!' + res.errMsg); } } }); }, globalData: { isLoggedIn: false, token: '' } });

在任意页面(如pages/index/index.js)发起受保护接口请求时:

wx.request({ url: 'https://your-api-domain.com/api/orders', header: { 'Authorization': 'Bearer ' + wx.getStorageSync('auth_token') }, success: res => { console.log('订单列表', res.data); } });

参数说明:

  • 小程序wx.request的header.Authorization必须为Bearer <token>格式,大小写敏感;
  • .NET Core 过滤器可在此处扩展:例如在AuthorizationFilter中检查HttpContext.User.Identity.IsAuthenticated,未通过则返回 401;
  • 此链路已覆盖「微信小程序登录获取手机号」的前置条件——只有登录态有效,才能调用wx.getPhoneNumber获取加密数据,后续解密需在 .NET Core 后端用session_key完成,不在本节展开。

3. 多店铺核心模型设计:用 EF Core 实现租户隔离与数据路由

“多店铺”不是简单加个ShopId字段就完事。商用系统要求:A 店铺看不到 B 店铺的订单、商品、客户;运营后台能按店铺筛选数据;数据库层面具备横向扩展能力(未来单库撑不住时可分库)。EF Core 本身不提供多租户,但可通过查询全局过滤器(Global Query Filters) + 自定义 DbContext + 租户解析中间件三层联动实现。

3.1 定义基础实体与租户抽象基类

创建Models/Entities/BaseEntity.cs:

public abstract class BaseEntity { public Guid Id { get; set; } = Guid.NewGuid(); public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } } public abstract class TenantEntity : BaseEntity { public Guid ShopId { get; set; } // 所属店铺 ID }

定义具体业务实体,继承TenantEntity:

public class Product : TenantEntity { public string Name { get; set; } = string.Empty; public decimal Price { get; set; } public int Stock { get; set; } public string Sku { get; set; } = string.Empty; } public class Order : TenantEntity { public string OrderNo { get; set; } = string.Empty; public Guid UserId { get; set; } public decimal TotalAmount { get; set; } public OrderStatus Status { get; set; } } public enum OrderStatus { Pending = 0, Paid = 1, Shipped = 2, Completed = 3, Cancelled = 4 }

3.2 创建租户上下文与动态 ShopId 解析

新建Data/ShopDbContext.cs:

public class ShopDbContext : DbContext { private readonly IHttpContextAccessor _httpContextAccessor; public ShopDbContext(DbContextOptions<ShopDbContext> options, IHttpContextAccessor httpContextAccessor) : base(options) { _httpContextAccessor = httpContextAccessor; } public DbSet<Product> Products { get; set; } public DbSet<Order> Orders { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // 1. 为所有 TenantEntity 添加全局过滤器 foreach (var entityType in modelBuilder.Model.GetEntityTypes() .Where(e => e.ClrType.IsSubclassOf(typeof(TenantEntity)))) { modelBuilder.Entity(entityType.ClrType) .HasQueryFilter(CreateTenantFilter(entityType.ClrType)); } // 2. 配置主键、索引等(略) modelBuilder.Entity<Product>().HasKey(p => p.Id); modelBuilder.Entity<Order>().HasKey(o => o.Id); modelBuilder.Entity<Product>().HasIndex(p => p.ShopId); modelBuilder.Entity<Order>().HasIndex(o => o.ShopId); } private LambdaExpression CreateTenantFilter(Type entityType) { var parameter = Expression.Parameter(entityType, "e"); var shopIdProperty = Expression.Property(parameter, nameof(TenantEntity.ShopId)); var currentShopId = GetCurrentShopId(); // 生成表达式:e => e.ShopId == currentShopId var constant = Expression.Constant(currentShopId, typeof(Guid)); var equal = Expression.Equal(shopIdProperty, constant); return Expression.Lambda(equal, parameter); } private Guid GetCurrentShopId() { // 从 HttpContext 中提取 ShopId:可来自 JWT claim、Header、或路由参数 var context = _httpContextAccessor.HttpContext; if (context?.User.Identity.IsAuthenticated == true) { var shopIdClaim = context.User.Claims.FirstOrDefault(c => c.Type == "shop_id"); if (Guid.TryParse(shopIdClaim?.Value, out var shopId)) return shopId; } // 开发环境兜底:取第一个店铺(仅限本地调试) return Guid.Parse("00000000-0000-0000-0000-000000000001"); } }

注意:IHttpContextAccessor必须在Program.cs中注册:

builder.Services.AddHttpContextAccessor(); builder.Services.AddDbContext<ShopDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

3.3 实现租户解析中间件:从 JWT Token 中提取 shop_id 并注入 Claims

新建Middleware/TenantResolutionMiddleware.cs:

public class TenantResolutionMiddleware { private readonly RequestDelegate _next; public TenantResolutionMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context) { var authHeader = context.Request.Headers.Authorization.ToString(); if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { var token = authHeader["Bearer ".Length..].Trim(); try { var tokenHandler = new JwtSecurityTokenHandler(); var jwtToken = tokenHandler.ReadJwtToken(token); var shopIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "shop_id"); if (shopIdClaim != null && Guid.TryParse(shopIdClaim.Value, out var shopId)) { // 将 shop_id 注入到 User.Claims,供 DbContext 使用 var identity = context.User.Identity as ClaimsIdentity; if (identity != null) { identity.AddClaim(new Claim("shop_id", shopId.ToString())); } } } catch (Exception ex) { // 日志记录,但不中断流程 Console.WriteLine($"Tenant resolution failed: {ex.Message}"); } } await _next(context); } }

在Program.cs中注册中间件(放在UseAuthentication()之后,UseAuthorization()之前):

app.UseAuthentication(); app.UseMiddleware<TenantResolutionMiddleware>(); // ← 新增 app.UseAuthorization();

逻辑说明:

  • 全局过滤器HasQueryFilter是 EF Core 多租户的核心,它会在每一条生成的 SQL 查询语句末尾自动追加AND [ShopId] = @p0,无需在每个 Repository 方法里手动.Where(x => x.ShopId == shopId);
  • TenantResolutionMiddleware的作用是补全 JWT 中缺失的shop_idclaim —— 实际项目中,shop_id应在用户登录时由后台根据 openid 查询用户所属店铺后写入 token;
  • 此设计天然支持「单店铺多管理员」:不同管理员登录后,其 JWT 中shop_id不同,查询自动隔离;
  • 若需支持「一个用户跨多个店铺」,则需改用List<Guid>存储shop_ids,并在过滤器中改为Contains,但会牺牲索引效率,商用建议用角色权限控制(如“超级管理员”可查全部店铺)。

4. layui 后台管理系统:不是静态页面,而是与 .NET Core API 深度协同的运营中枢

很多人误以为 layui 只是“老式前端框架”,其实它的价值在于:极低的学习成本、开箱即用的表单/表格/弹窗组件、无构建步骤、可直接部署到 Nginx 静态目录。对于多店铺商城的运营后台,它比 React/Vue 更合适——店长不需要懂 MVVM,只需要点“导出本月销售报表”、“批量修改商品状态”、“查看某店铺所有售后单”。关键在于,layui 页面如何安全、高效、可维护地对接 .NET Core API。

4.1 构建基于 layui 的店铺管理页:列表 + 搜索 + 批量操作

创建wwwroot/admin/shop-list.html:

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>店铺管理</title> <link rel="stylesheet" href="/layui/css/layui.css"> </head> <body class="layui-layout-body"> <div class="layui-layout layui-layout-admin"> <div class="layui-header"> <div class="layui-logo">多店铺商城后台</div> <ul class="layui-nav layui-layout-left"> <li class="layui-nav-item"><a href="/admin/shop-list.html">店铺管理</a></li> <li class="layui-nav-item"><a href="/admin/product-list.html">商品管理</a></li> </ul> <ul class="layui-nav layui-layout-right"> <li class="layui-nav-item"><a href="javascript:;">管理员</a></li> </ul> </div> <div class="layui-body"> <div style="padding: 15px;"> <div class="layui-form"> <div class="layui-form-item"> <label class="layui-form-label">店铺名称</label> <div class="layui-input-inline"> <input type="text" name="shopName" placeholder="请输入店铺名称" class="layui-input"> </div> <button class="layui-btn" lay-submit lay-filter="search">搜索</button> <button class="layui-btn layui-btn-primary" onclick="location.reload()">重置</button> </div> </div> <table id="shopTable" lay-filter="shopTable"></table> </div> </div> </div> <script src="/layui/layui.js"></script> <script> layui.use(['table', 'form', 'layer'], function () { var table = layui.table; var form = layui.form; var layer = layui.layer; // 渲染表格 table.render({ elem: '#shopTable', height: 500, url: '/api/shops', // 对应 .NET Core 的 GET /api/shops method: 'get', headers: { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') // 后台管理端 token 存 localstorage }, page: true, cols: [[ {type: 'checkbox'}, {field: 'id', title: 'ID', width: 120}, {field: 'name', title: '店铺名称', width: 200}, {field: 'contact', title: '联系人', width: 120}, {field: 'phone', title: '联系电话', width: 150}, {field: 'status', title: '状态', width: 100, templet: '#statusTpl'}, {field: 'createdAt', title: '创建时间', width: 180}, {title: '操作', width: 180, align: 'center', toolbar: '#barDemo'} ]] }); // 状态模板 layui.template('#statusTpl', function (d) { return d.status === 1 ? '<span class="layui-badge layui-bg-green">启用</span>' : '<span class="layui-badge layui-bg-gray">禁用</span>'; }); // 搜索提交 form.on('submit(search)', function (data) { table.reload('shopTable', { where: { name: data.field.shopName }, page: { curr: 1 } }); return false; }); // 工具栏事件 table.on('tool(shopTable)', function (obj) { var data = obj.data; if (obj.event === 'edit') { layer.open({ type: 2, title: '编辑店铺', area: ['600px', '500px'], content: '/admin/shop-edit.html?id=' + data.id }); } else if (obj.event === 'delete') { layer.confirm('确定删除?', function (index) { $.ajax({ url: '/api/shops/' + data.id, type: 'DELETE', headers: { 'Authorization': 'Bearer ' + localStorage.getItem('admin_token') }, success: function () { obj.del(); layer.close(index); layer.msg('删除成功'); } }); }); } }); }); </script> <!-- 行内工具栏 --> <script type="text/html" id="barDemo"> <a class="layui-btn layui-btn-xs" lay-event="edit">编辑</a> <a class="layui-btn layui-btn-danger layui-btn-xs" lay-event="delete">删除</a> </script> </body> </html>

4.2 .NET Core 后端实现店铺管理 API:支持分页、搜索、软删除

新建Controllers/ShopsController.cs:

[ApiController] [Route("api/[controller]")] [Authorize] // 必须登录 public class ShopsController : ControllerBase { private readonly ShopDbContext _context; public ShopsController(ShopDbContext context) { _context = context; } [HttpGet] public async Task<ActionResult<PagedResult<Shop>>> GetShops( [FromQuery] int page = 1, [FromQuery] int pageSize = 10, [FromQuery] string name = null) { var query = _context.Shops.AsQueryable(); if (!string.IsNullOrWhiteSpace(name)) { query = query.Where(s => s.Name.Contains(name)); } var totalCount = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(); return Ok(new PagedResult<Shop> { Items = items, TotalCount = totalCount, PageNumber = page, PageSize = pageSize }); } [HttpGet("{id:guid}")] public async Task<ActionResult<Shop>> GetShop(Guid id) { var shop = await _context.Shops.FindAsync(id); if (shop == null) return NotFound(); return shop; } [HttpPut("{id:guid}")] public async Task<IActionResult> UpdateShop(Guid id, [FromBody] ShopUpdateRequest request) { var shop = await _context.Shops.FindAsync(id); if (shop == null) return NotFound(); shop.Name = request.Name; shop.Contact = request.Contact; shop.Phone = request.Phone; shop.Status = request.Status; shop.UpdatedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return NoContent(); } [HttpDelete("{id:guid}")] public async Task<IActionResult> DeleteShop(Guid id) { var shop = await _context.Shops.FindAsync(id); if (shop == null) return NotFound(); // 软删除:不物理删除,只标记 shop.IsDeleted = true; shop.DeletedAt = DateTime.UtcNow; await _context.SaveChangesAsync(); return NoContent(); } } public class ShopUpdateRequest { public string Name { get; set; } = string.Empty; public string Contact { get; set; } = string.Empty; public string Phone { get; set; } = string.Empty; public bool Status { get; set; } } public class PagedResult<T> { public List<T> Items { get; set; } = new(); public int TotalCount { get; set; } public int PageNumber { get; set; } public int PageSize { get; set; } }

参数说明:

  • PagedResult<T>是自定义分页响应结构,避免直接返回IActionResult导致前端无法统一处理;
  • Shop实体需继承TenantEntity,因此GetShops查询会自动应用ShopId过滤器,确保管理员只能看到自己店铺的数据;
  • DeleteShop使用软删除(IsDeleted字段),符合商用审计要求,且便于恢复;
  • layui表格的headers.Authorization从localStorage读取,该 token 应在管理员登录后台时由 .NET Core/api/admin/login接口签发,与小程序 token 分离(不同密钥、不同过期时间、不同 claim)。

4.3 为 layui 页面注入动态权限:按钮级显隐控制

layui 本身无权限系统,但可结合 .NET Core 的 Role/Claim,在页面加载时动态渲染按钮:

在ShopController.GetShops中,将当前用户权限传入视图模型(若用 Razor);但纯静态 HTML 下,需在登录成功后,由后台返回权限列表存入localStorage:

// 登录成功后,返回用户权限 return Ok(new { token = jwtToken, permissions = new[] { "shop:read", "shop:edit", "product:export" } });

在shop-list.html的 JS 中:

// 加载完成后检查权限 if (!localStorage.getItem('permissions')?.includes('shop:edit')) { // 隐藏编辑按钮 $('.layui-table-box').find('[lay-event="edit"]').hide(); } if (!localStorage.getItem('permissions')?.includes('shop:delete')) { $('.layui-table-box').find('[lay-event="delete"]').hide(); }

提示:权限字符串(如shop:edit)应与 .NET Core 的[Authorize(Policy = "ShopEdit")]策略名对齐,形成前后端一致的权限字典。


5. 商用级避坑指南:那些让项目延期两周的 .NET Core + 小程序 + layui 组合雷区

这套组合拳看似平实,但实际交付中 80% 的返工都源于几个隐蔽但高频的坑。以下是我带三个团队落地同类项目后,血泪整理的 5 条真实踩坑记录,每一条都附带复现方式、根因分析和可立即执行的修复方案。

5.1 现象:小程序调用 .NET Core API 返回 401,但 Postman 测试完全正常

原因:小程序wx.request默认不携带 Cookie,而你的 .NET Core 项目启用了Cookie认证(如AddCookie),同时又配置了AddJwtBearer,导致AuthenticationScheme冲突,中间件优先尝试 Cookie 验证失败后直接返回 401,根本没走到 JWT 验证逻辑。
解决:

  • 彻底移除AddCookie相关代码,小程序场景只用 JWT Bearer 认证;
  • 检查Program.cs中AddAuthentication是否指定了默认 Scheme:
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) // ← 明确指定默认为 JWT .AddJwtBearer(...);
  • 确保小程序请求 Header 严格为Authorization: Bearer xxx,空格、大小写、多余字符都会导致失败。

5.2 现象:layui 后台页面刷新后,表格数据为空,F12 查看 Network 发现/api/shops返回 401

原因:localStorage.getItem('admin_token')在页面 JS 执行时为null,因为 token 是异步登录后才写入的,而 layuitable.render是同步执行的,导致请求发出时 token 还没存进去。
解决:

  • 不要在 HTML 中直接写<script>调用table.render;
  • 改为在登录成功后,用window.location.href = '/admin/shop-list.html'跳转,并在shop-list.html的 JS 中加防抖:
    function loadTable() { const token = localStorage.getItem('admin_token'); if (!token) { setTimeout(loadTable, 100); // 每 100ms 检查一次 return; } // 此处再调用 table.render } loadTable();
  • 更优方案:登录接口返回redirect_url,后端生成带 token 的临时链接(如/admin/shop-list.html?token=xxx),前端解析后存入localStorage再跳转。

5.3 现象:多店铺下,A 店铺管理员在后台修改商品价格,B 店铺小程序商品列表却同步更新了价格

原因:Product实体未继承TenantEntity,或OnModelCreating中漏掉了对该实体的HasQueryFilter配置,导致 EF Core 生成的 SQL 没有AND ShopId = @p0条件,查询返回了全库数据。
解决:

  • 检查Product类是否: TenantEntity;
  • 在ShopDbContext.OnModelCreating中,打印所有被过滤的实体类型:
    Console.WriteLine($"Applying filter to: {entityType.ClrType.Name}");
  • 启动项目,查看日志是否输出Product;若没有,说明modelBuilder.Model.GetEntityTypes()未包含它,检查DbSet<Product>是否声明、Product是否被Assembly.Load加载。
  • 最终验证:开启 EF Core SQL 日志,执行context.Products.ToListAsync(),确认生成的 SQL 包含WHERE [ShopId] = ...。

5.4 现象:微信小程序审核被拒,提示“页面底部导航栏不符合规范”或“顶部导航栏高度异常”

原因:wx.setNavigationBarColor或wx.setNavigationBarTitle在部分安卓机型上失效,导致原生导航栏与自定义 tabBar 高度错位;更常见的是,app.json中tabBar配置的list项iconPath和selectedIconPath指向的图片尺寸不是 81×81px(微信强制要求),或图片格式不是 PNG 透明背景。
解决:

  • 彻底放弃自定义导航栏,使用微信原生navigationStyle: default;
  • tabBar图标必须用81×81px、PNG、透明背景、无边框的图片,命名如home.png/home-active.png;
  • 在app.json中严格校验:
    "tabBar": { "color": "#666", "selectedColor": "#108ee9", "borderStyle": "black", "backgroundColor": "#ffffff", "list": [{ "pagePath": "pages/index/index", "text": "首页", "iconPath": "assets/tabbar/home.png", "selectedIconPath": "assets/tabbar/home-active.png" }] }
  • 使用微信开发者工具「真机调试」功能,在 iOS 和主流安卓机型上预览,确认无错位。

5.5 现象:layui 后台导出 Excel 功能点击无反应,控制台报错Cannot read property 'exportFile' of undefined

原因:layui.table的exportFile方法是 2.8.0 版本新增的,而你引入的layui.js是 2.7.x 或更旧版本;或者未正确加载laydate、layexcel模块(虽然文档说exportFile不依赖模块,但实际需layexcel支持)。
解决:

  • 升级 layui 至2.8.18+(当前最新稳定版),下载地址:https://www.layui.com/download.html;
  • 在shop-list.html中明确加载模块:
    <script src="/layui/layui.js"></script> <script> layui.config({ version: '2.8.18' // 强制版本 }).use(['table', 'form', 'layer', 'layexcel'], function () { // ... }); </script>
  • 导出前检查浏览器是否支持 Blob:
    if (typeof Blob === 'undefined') { layer.msg('当前浏览器不支持导出功能,请升级至 Chrome/Firefox/Edge'); return; }

6. 进阶技巧:用 C# 实现微信小程序「获取手机号」的端到端解密与绑定

微信小程序wx.getPhoneNumber是商用商城的刚需——用户授权后,后台必须解密获得真实手机号,用于注册、登录、短信通知。但网上大量教程只贴 JS 代码,却忽略 .NET Core 后端解密的关键细节:微信返回的加密数据(encryptedData)必须用用户的 session_key 解密,而 session_key 有效期仅 2 小时,且同一 code 只能换取一次。很多团队在这里翻车,导致用户点了授权却收不到短信,或解密出乱码。

6.1 小程序端:正确调用 getPhoneNumber 并传递 encryptedData 到后端

// pages/user/bind-phone.js Page({ bindGetPhoneNumber(e) { if (!e.detail.encryptedData) { wx.showToast({ title: '授权失败', icon: 'none <p> <a href="https://download.csdn.net/download/qq_41701956/87943187" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询