资讯详情 .NET 6 WebAPI 与 SqlServer JWT 增删改查实战
📅 2026/11/6 4:24:56
简介面向已掌握C#基础、希望进阶ASP.NET Core Web API开发的读者这套示例工程围绕.NET 6、SQL Server与JWT搭建可运行的增删改查服务覆盖Swagger文档、数据库迁移、全局异常处理、RESTful设计与HTTPS安全等实践点适合课程设计、毕业项目或企业接口原型参考。压缩包共665个文件、约34.81MB以286个dll运行依赖、96个cs源码、44个json配置、58个pdb调试符号及csproj、sln解决方案文件为主另含数据库备份源码、依赖与配置层次清晰便于直接还原项目结构。目前已有1064人学习下载。读者可据此梳理控制器、服务层与数据访问层的调用链理解登录签发令牌、请求携带令牌到接口鉴权的完整闭环并参考Swagger调试与错误日志定位接口问题也能在现有CRUD骨架上扩展分页、权限与部署配置减少从零搭建环境的时间。1. 一串增删改查串起的三件套.NET 6 WebAPI 接 SqlServer 用 JWT 守门真正让新手卡住的从来不是那五行 CRUD 代码而是把 .NET 6 WebAPI、SqlServer、JWT 串成一条能跑的最小闭环项目起得来、库连得上、令牌签得出、接口拦得住、Swagger 里还能带着 Token 点着测。这份资源就是围绕一张用户表做的增删改查示例认证走 JWT Bearer数据访问用 EF Core 对接 SqlServer。适合刚接手 .NET 6 后端的人也适合从旧框架迁过来、想快速看清最小托管模型写法的熟手。下面按「先搭骨架、再签令牌、最后写接口」的顺序拆中间夹杂我实际翻车过的地方。2. 骨架与数据层EF Core 接 SqlServer 的注册姿势和迁移流程先把地基打平。这一章要做完三件事建出干净的项目骨架、把连接字符串和 DbContext 挂到容器上、跑通第一次迁移让表真正落进 SqlServer。顺序不能乱很多后面报的错根子都在这里埋下了。2.1 环境清单与项目创建本机需要装的只有两样.NET SDK 6.0.x6.0 的生命周期早已结束但存量项目大量停留在这个版本接手时基本绕不开以及一个能连的 SqlServer 实例。开发机上用 LocalDB 或者容器里的实例都行差别只在连接字符串。工具层面额外加一个dotnet-ef全局工具做迁移要用。# 确认 SDK 版本必须能看到 6.0.x 这一行 dotnet --list-sdks # 用 webapi 模板建项目显式锁到 net6.0 dotnet new webapi -n UserApi -f net6.0 cd UserApi # 数据访问三件套 JWT 承载包 口令哈希库 dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 6.0.* dotnet add package Microsoft.EntityFrameworkCore.Design --version 6.0.* dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 6.0.* dotnet add package BCrypt.Net-Next # 迁移命令行工具装全局一次即可 dotnet tool install --global dotnet-ef这里有几个参数值得说。-f net6.0不能省模板默认可能装到你机器上更高版本的 SDK生成出来的Program.cs和项目文件会带新语法回头对照示例就全是错位。EntityFrameworkCore.Design是设计时包运行时不参与但dotnet ef命令必须靠它。JwtBearer包的版本号要和 SDK 主版本对齐混用 7.x 的包在 6.0 运行时上可能触发依赖解析告警。BCrypt 用来存口令哈希别自己搓 MD5 加盐那套东西现在等于裸奔。建完之后先别急着写业务dotnet run跑一次确认模板自带的 WeatherForecast 能出结果说明 SDK、端口、证书这条链路是通的。这一步看着多余实际能省掉后面「到底是我的代码错了还是环境坏了」的扯皮。2.2 连接字符串的写法与 DbContext 注册连接字符串是这份示例里最容易出玄学的地方因为它默认开启了加密传输而开发机上的 SqlServer 通常用的是自签证书两边一碰就报证书链不可信。配置文件里我是这么写的{ ConnectionStrings: { Default: Serverlocalhost,1433;DatabaseUserApiDb;User Idsa;PasswordYour_Strong_Pwd1;EncryptTrue;TrustServerCertificateTrue;MultipleActiveResultSetsFalse;Connect Timeout15 }, Jwt: { Issuer: userapi-demo, Audience: userapi-client, SigningKey: 换成你自己的至少32字节随机串_不要直接用这句, ExpiresMinutes: 60 } }逐项解释Serverlocalhost,1433里逗号后面是端口命名实例则改用Serverlocalhost\\SQLEXPRESSEncryptTrue在较新的驱动里已经是默认值写出来是为了显式提醒你它开着TrustServerCertificateTrue只该出现在开发和内网自签场景生产环境换成正规证书后必须把它去掉否则等于自己关掉了中间人防护。MultipleActiveResultSets默认关掉更好EF Core 的异步查询不需要它开着反而容易掩盖同一 DbContext 上并发执行的问题。Connect Timeout15把默认的 30 秒缩短本地调试时连不上能更快看到异常而不是干等。注册 DbContext 走AddDbContext它的默认生命周期是 Scoped也就是一个 HTTP 请求一个实例。这一点必须记住后面第 5 章有一条坑就跟它直接相关。// Program.cs 片段注册数据访问 var connectionString builder.Configuration.GetConnectionString(Default) ?? throw new InvalidOperationException(缺少连接字符串 Default); builder.Services.AddDbContextAppDbContext(options { options.UseSqlServer(connectionString, sql { // 失败重试应对容器启动时数据库尚未就绪 sql.EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorNumbersToAdd: null); // 迁移历史表放到独立 schema避免和业务表混在一起 sql.MigrationsHistoryTable(__EFMigrationsHistory, dbo); }); #if DEBUG // 只在调试构建里打开敏感数据日志生产别开会把参数值打进日志 options.EnableSensitiveDataLogging(); options.LogTo(Console.WriteLine, LogLevel.Information); #endif });EnableRetryOnFailure是给容器化部署准备的数据库容器比应用容器晚起来几秒是常态没有重试就会在启动瞬间抛连接异常日志里一片红。EnableSensitiveDataLogging一定要包在#if DEBUG里它会把 SQL 参数的真实值打到控制台生产环境开着等于把口令哈希往日志里写。2.3 实体、模型配置与首次迁移实体我刻意做得朴素一张用户表带自增主键、唯一用户名、口令哈希、角色、余额和一个并发令牌。余额用decimal并发令牌用byte[]映射到 SqlServer 的rowversion这两处都是新手最容易漏配精度和并发控制的地方。// Models/AppUser.cs public class AppUser { public int Id { get; set; } public string UserName { get; set; } string.Empty; public string PasswordHash { get; set; } string.Empty; public string Role { get; set; } User; public decimal Balance { get; set; } public DateTime CreatedAt { get; set; } DateTime.UtcNow; public byte[]? RowVersion { get; set; } // 映射为 rowversion做乐观并发 }// Data/AppDbContext.cs public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetAppUser Users SetAppUser(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityAppUser(e { e.ToTable(AppUsers); e.HasKey(x x.Id); e.Property(x x.UserName).HasMaxLength(64).IsRequired(); e.HasIndex(x x.UserName).IsUnique(); e.Property(x x.PasswordHash).HasMaxLength(256).IsRequired(); e.Property(x x.Role).HasMaxLength(32).IsRequired(); // 不写这一行EF 会按最小精度建列并抛警告金额算着算着就对不上 e.Property(x x.Balance).HasPrecision(18, 2); e.Property(x x.RowVersion).IsRowVersion(); }); base.OnModelCreating(modelBuilder); } }HasPrecision(18, 2)这行不是可选项。默认情况下 EF Core 对decimal会给出精度未指定的警告落到 SqlServer 上可能变成decimal(18,2)也可能保留默认跨环境不一致时对账会非常难受。IsRowVersion()让这一列由数据库自己维护每次更新自增EF 在UPDATE的WHERE里带上它影响行数为 0 就抛并发异常比自己写版本号字段省事。配置齐了就跑迁移dotnet ef migrations add InitialCreate -o Migrations dotnet ef database update第一条命令生成迁移类第二条把变更真正执行到数据库。看到的表结构里应该有自增主键、用户名唯一索引和rowversion列。如果报登录失败先回到连接字符串检查账号密码如果报证书问题看TrustServerCertificate这一项。迁移成功之后后面的接口才有东西可操作。3. JWT 从签到验口令存储、令牌签发与中间件顺序认证这块的错误分两类一类是签不出令牌另一类是签出来了但接口不认。前者好查后者往往要翻半天因为涉及中间件顺序和声明的映射规则。这一章把签发端和验证端一起讲透中间那点默认行为必须提前知道。3.1 令牌结构与密钥、有效期的取值JWT 是三段式用点号分隔头部、载荷、签名。头部声明算法载荷放声明签名是前两段加上密钥算出来的摘要。前两段只是 Base64Url 编码不是加密任何人都能解开看内容所以载荷里绝不能放敏感信息。真正起保护作用的是签名验证方拿同样的密钥重算一遍对得上才认。算法选 HMAC-SHA256对称密钥签发方和验证方共用同一个串。仓库里常见的翻车是密钥太短——HS256 要求密钥长度不小于 256 位也就是 32 字节。写个abc123上去某些实现会直接抛异常某些实现则默默弱化两边行为不一致时排查成本极高。我的习惯是在启动时做一次长度校验短了就直接让进程起不来。// 配置绑定类 public class JwtOptions { public string Issuer { get; set; } string.Empty; public string Audience { get; set; } string.Empty; public string SigningKey { get; set; } string.Empty; public int ExpiresMinutes { get; set; } 60; }有效期取值是个纯业务判断。管理后台类的接口60 分钟够用且不用频繁重登对外 API 建议压到 15 分钟以内再配刷新令牌。这个值不要设成天级别令牌一旦泄漏在过期前谁拿到都能用服务端无法单方面作废——这是无状态令牌的固有代价不是缺陷。3.2 口令存储与登录签发接口口令永远不存明文也不能用可逆算法。BCrypt 每次哈希自带随机盐同一个口令两次算出的结果不同验证时从哈希串里解出盐再算。工作因子给 11 到 12代价是单次登录多花几十毫秒换的是离线爆破成本指数级上升。// Services/TokenService.cs public class TokenService { private readonly JwtOptions _opt; public TokenService(IOptionsJwtOptions opt) _opt opt.Value; public string CreateToken(AppUser user) { var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_opt.SigningKey)); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var now DateTime.UtcNow; // 必须用 UTC本地时间会让 exp 整体偏移 var claims new ListClaim { new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(ClaimTypes.Name, user.UserName), new(ClaimTypes.Role, user.Role), new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString(N)) }; var token new JwtSecurityToken( issuer: _opt.Issuer, audience: _opt.Audience, claims: claims, notBefore: now, expires: now.AddMinutes(_opt.ExpiresMinutes), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } }// Controllers/AuthController.cs [ApiController] [Route(api/auth)] public class AuthController : ControllerBase { private readonly AppDbContext _db; private readonly TokenService _tokenService; public AuthController(AppDbContext db, TokenService tokenService) { _db db; _tokenService tokenService; } [HttpPost(login)] [AllowAnonymous] public async TaskIActionResult Login([FromBody] LoginRequest req, CancellationToken ct) { var user await _db.Users.AsNoTracking() .FirstOrDefaultAsync(u u.UserName req.UserName, ct); // 用户不存在也走一次哈希验证避免响应时间差暴露账号是否存在 var hash user?.PasswordHash ?? BCrypt.Net.BCrypt.HashPassword(dummy-placeholder); var ok BCrypt.Net.BCrypt.Verify(req.Password, hash); if (user is null || !ok) return Unauthorized(new { message 用户名或口令错误 }); var token _tokenService.CreateToken(user); return Ok(new { accessToken token, tokenType Bearer, expiresIn 60 * 60 // 秒前端据此安排续期 }); } }AsNoTracking()用在纯读场景省掉变更跟踪器的开销。那句 dummy 哈希是为了抹平「用户不存在」和「口令错误」两条路径的时间差属于顺手就做的防护。返回体里带expiresIn而不是让前端自己解令牌是因为解 Base64Url 在各家库里的实现细节不一致交给服务端算更稳。3.3 Program.cs 注册验证参数与中间件顺序验证端的配置比签发端更容易踩坑因为默认行为不止一处。TokenValidationParameters里每一项都对应一个验证环节关掉哪一项就等于放弃哪一道检查。// Program.cs 片段认证与授权 var jwtSection builder.Configuration.GetSection(Jwt); builder.Services.ConfigureJwtOptions(jwtSection); var jwt jwtSection.GetJwtOptions()!; if (Encoding.UTF8.GetByteCount(jwt.SigningKey) 32) throw new InvalidOperationException(SigningKey 至少需要 32 字节); builder.Services.AddSingletonTokenService(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer jwt.Issuer, ValidateAudience true, ValidAudience jwt.Audience, ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)), ValidateLifetime true, // 默认 5 分钟容差本地前后端时钟稍有差异就会出现刚签发就过期 ClockSkew TimeSpan.FromSeconds(30), NameClaimType ClaimTypes.Name, RoleClaimType ClaimTypes.Role }; }); builder.Services.AddAuthorization();这里最值钱的一行是ClockSkew。它默认给 5 分钟容差听起来宽松实际会带来反直觉的现象令牌过期后 5 分钟内接口照样放行测试时按秒卡过期时间会怀疑人生。把容差压到 30 秒属于比较务实的折中既能容忍正常的时钟漂移又不会让「已经过期」的判断偏离太远。中间件顺序是另一半。UseAuthentication负责解析请求头里的令牌并写入HttpContext.UserUseAuthorization才能基于这个身份做判断。写反了的结果是授权中间件拿到一个匿名身份所有带[Authorize]的接口一律 401而且日志上毫无波澜。var app builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthentication(); // 必须在前解析出身份 app.UseAuthorization(); // 必须在后基于身份判权限 app.MapControllers(); app.Run();3.4 Swagger 上带令牌调试验证接口除了 curl最顺手的还是 Swagger 的那个 Authorize 按钮。配置分两块安全定义描述怎么带凭据安全需求说明哪些接口要用。builder.Services.AddSwaggerGen(c { c.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Type SecuritySchemeType.Http, // 关键交给 UI 自动拼前缀 Scheme bearer, BearerFormat JWT, In ParameterLocation.Header, Name Authorization, Description 直接粘贴 accessToken不要自己加 Bearer 前缀 }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { [new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }] Array.Emptystring() }); });Type SecuritySchemeType.Http配合Scheme bearer插件会自动补Bearer前缀。如果写成ApiKeyUI 就只做原样透传你得在输入框里手动敲Bearer eyJ...敲错了就是 401。这两种写法在网上的示例里混着出现抄错一次能排查很久。4. 五个端点的落地写法DTO 拆分、分页查询与状态码约定数据层和认证都通了剩下的是业务接口。这一章的重点不在 CRUD 本身而在三件事为什么不让实体直接出门、分页用两次查询而不是一次、每个动作该回什么状态码。4.1 为什么实体不能直接当出入参实体上有PasswordHash有RowVersion直接序列化返回等于把口令哈希和内部版本号一起送给前端。反过来从请求体直接绑实体更危险——攻击者可以在 JSON 里塞一个Role: Admin或Balance: 99999模型绑定会老老实实赋上去属于教科书级的越权。// 出参只暴露该露的字段 public record UserListItemDto(int Id, string UserName, string Role, DateTime CreatedAt); public record UserDetailDto(int Id, string UserName, string Role, decimal Balance); public record PagedResultT(int Total, int Page, int Size, IReadOnlyListT Items); // 入参字段白名单 public class CreateUserRequest { [Required, StringLength(64, MinimumLength 3)] public string UserName { get; set; } string.Empty; [Required, StringLength(64, MinimumLength 8)] public string Password { get; set; } string.Empty; [RegularExpression(^(User|Admin)$)] public string Role { get; set; } User; } public class UpdateUserRequest { [Required, StringLength(64, MinimumLength 3)] public string UserName { get; set; } string.Empty; [Range(0, 9999999)] public decimal Balance { get; set; } }控制器上标了[ApiController]之后这些数据注解会在模型绑定阶段自动执行不通过就直接返回 400并且响应体里带字段级的错误详情不用自己写一行校验代码。代价是错误格式被框架固定了想统一成自家的响应结构就得接管InvalidModelStateResponseFactory这在第 4.4 节处理。4.2 分页查询两次往返换来的确定性分页的标准写法是先CountAsync拿总数再Skip/Take拿当页数据。两次往返看着多但它保证了总数和当前页是同一时刻的快照。用一次查询加窗口函数硬凑的做法在小表上没问题表一大执行计划会明显变差。[HttpGet] public async TaskActionResultPagedResultUserListItemDto List( [FromQuery] int page 1, [FromQuery] int size 20, [FromQuery] string? keyword null, CancellationToken ct default) { // 参数兜底防止 size100000 把库拖死 page Math.Max(page, 1); size Math.Clamp(size, 1, 100); var query _db.Users.AsNoTracking(); if (!string.IsNullOrWhiteSpace(keyword)) query query.Where(u u.UserName.Contains(keyword)); var total await query.CountAsync(ct); var items await query .OrderBy(u u.Id) .Skip((page - 1) * size) .Take(size) .Select(u new UserListItemDto(u.Id, u.UserName, u.Role, u.CreatedAt)) .ToListAsync(ct); return Ok(new PagedResultUserListItemDto(total, page, size, items)); }三个细节值得单独说。Math.Clamp(size, 1, 100)是防御性的不加的话别人传size100000就能让你的应用一次性从库里拉全表、再序列化出一个巨大的 JSON内存监控曲线会非常好看。OrderBy不能省SqlServer 在没有明确排序时返回的行顺序不保证稳定翻页会出现重复或漏行这个现象很隐蔽。Select直接投影到 DTO生成的 SQL 只查四列比查出整个实体再映射要轻。4.3 新增、修改、删除的返回码状态码不是装饰是契约的一部分。新增成功返回 201 加Location头客户端才能知道新资源在哪修改成功返回 204 无内容删除成功同样 204。这些约定俗成的写法能让前端少写一堆判断。[HttpPost] public async TaskActionResultUserDetailDto Create([FromBody] CreateUserRequest req, CancellationToken ct) { var exists await _db.Users.AnyAsync(u u.UserName req.UserName, ct); if (exists) return Conflict(new { message 用户名已存在 }); var user new AppUser { UserName req.UserName, PasswordHash BCrypt.Net.BCrypt.HashPassword(req.Password, workFactor: 12), Role req.Role, Balance 0m, CreatedAt DateTime.UtcNow }; _db.Users.Add(user); await _db.SaveChangesAsync(ct); var dto new UserDetailDto(user.Id, user.UserName, user.Role, user.Balance); return CreatedAtAction(nameof(GetById), new { id user.Id }, dto); } [HttpPut({id:int})] public async TaskIActionResult Update(int id, [FromBody] UpdateUserRequest req, CancellationToken ct) { var user await _db.Users.FirstOrDefaultAsync(u u.Id id, ct); if (user is null) return NotFound(); user.UserName req.UserName; user.Balance req.Balance; try { await _db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException) { // rowversion 不匹配说明别人抢先改了 return Conflict(new { message 数据已被其他会话修改请刷新后重试 }); } return NoContent(); } [HttpDelete({id:int})] [Authorize(Roles Admin)] public async TaskIActionResult Delete(int id, CancellationToken ct) { var user await _db.Users.FirstOrDefaultAsync(u u.Id id, ct); if (user is null) return NotFound(); _db.Users.Remove(user); await _db.SaveChangesAsync(ct); return NoContent(); }CreatedAtAction会自动生成Location: /api/users/123需要目标方法有对应的路由名。DbUpdateConcurrencyException那个 catch 只有在实体配了rowversion时才会被触发它是乐观并发的最后一道闸。删除接口上单独标了[Authorize(Roles Admin)]控制器级的[Authorize]负责「必须登录」方法级的角色约束负责「必须是管理员」两层叠加不冲突。4.4 统一响应包装与全局异常业务接口漏抛异常是常态尤其是数据库超时和空引用。加一层全局处理把异常收敛成统一结构前端就不用为每个接口写一套错误解析。// Middleware/ExceptionMiddleware.cs public class ExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILoggerExceptionMiddleware _logger; public ExceptionMiddleware(RequestDelegate next, ILoggerExceptionMiddleware logger) { _next next; _logger logger; } public async Task InvokeAsync(HttpContext ctx) { try { await _next(ctx); } catch (Exception ex) { _logger.LogError(ex, 未处理异常 {Path}, ctx.Request.Path); ctx.Response.StatusCode StatusCodes.Status500InternalServerError; ctx.Response.ContentType application/json; // 生产环境不要回传 ex.Message异常细节会泄漏表结构和路径 await ctx.Response.WriteAsJsonAsync(new { code 500, message 服务器内部错误 }); } } }注册时把它放在管道最前面app.UseMiddlewareExceptionMiddleware()紧跟Build()之后这样后面任何中间件抛出的异常都能被接住。日志里记完整的异常栈响应里只回一句人话两者的用途不同不能混。5. 排查清单五条踩过的坑和它们的排查路径有些问题看代码永远看不出必须知道框架在背后做了什么。下面五条都是我或者身边人真实卡过一小时以上的按「现象、原因、解决」写直接对着现象找。5.1 认证三条401、重复前缀与莫名其妙的过期现象一请求头里明明带了Authorization: Bearer eyJ...接口依然返回 401响应头里还有个WWW-Authenticate: Bearer。原因管道里缺了UseAuthentication()或者它被放在了UseAuthorization()后面。授权中间件执行时HttpContext.User还是匿名的自然拒绝。另一种情况是控制器上要求了某个认证方案名而启动配置注册的是另一个名字两边对不上。解决打开Program.cs按UseRouting → UseAuthentication → UseAuthorization → MapControllers的顺序核对一遍。再把日志等级调到Information认证失败时会有具体原因输出比如签名不匹配、受众不符比盲猜快得多。现象二在 Swagger 的输入框里老老实实敲了Bearer eyJ...点执行还是 401但用 curl 一样的头就能过。原因安全定义写成了SecuritySchemeType.ApiKeyUI 原样透传加上你手敲的前缀最终发出去的头是Authorization: Bearer Bearer eyJ...验证方解析出来的令牌串前面多了一段签名必然对不上。解决把Type改成SecuritySchemeType.Http并配Scheme bearer输入框里只粘贴令牌本体。或者在保持 ApiKey 的前提下把手敲前缀的习惯改掉二选一别混着来。现象三本地调试时令牌刚签发就报过期换台机器又正常。原因签发端用了DateTime.Now而不是DateTime.UtcNowexp声明按本地时区算出来验证端按 UTC 解读时区差几个小时就直接判过期。另一种是两端时钟本身不同步而ClockSkew又被调成了 0。解决所有时间计算统一走 UTC签发和验证两端保持一致。ClockSkew不要设成零给 30 秒到 1 分钟的容差容忍正常的时钟漂移。5.2 数据两条DbContext 并发与迁移踩空现象四接口偶发A second operation was started on this context instance before a previous operation completed本地压测时更容易复现。原因DbContext默认注册为 Scoped一个请求一个实例正常用不会出问题。但这个实例不是线程安全的一旦在同一个请求里用Task.WhenAll并行发起两个查询或者把 DbContext 注入到单例服务里长期持有就会撞上。另一种常见诱因是在查询里忘了await前一个操作还没结束就发了下一个。解决同一个 DbContext 上的操作严格串行。确实需要并行查询的注入IDbContextFactoryAppDbContext每个并行分支自己CreateDbContext()用完Dispose。另外别把 DbContext 注入到Singleton生命周期的服务里那是把请求级对象泄漏成了全局对象。现象五代码里改了实体字段重新跑dotnet ef database update数据库表结构纹丝不动但接口查询新字段时报列不存在。原因改完实体忘了执行dotnet ef migrations add。database update只负责把已有的迁移文件应用到库上没有新迁移文件它自然什么都不做。还有一种情况是项目启动时配了别的程序集做迁移导致生成的迁移文件落到了预期之外的位置。解决养成「改实体 → add 迁移 → 看一眼生成的迁移文件 → update」四步走的习惯。生成迁移之后一定要打开文件扫一眼确认里面确实有你期待的AddColumn别闭着眼往下跑。迁移文件也是代码能进版本库就进版本库。6. 上线前的最后一公里刷新令牌与一套能直接跑的验证命令访问令牌做得再规范一旦设得短用户体验就会崩设得长泄漏风险又上去了。折中方案是短访问令牌加刷新令牌。刷新令牌存库只存哈希不存原文字段包括所属用户、过期时间、是否已用。刷新时做轮换旧的作废、新的签发同一枚刷新令牌被用第二次就判定为疑似泄漏把该用户的所有刷新令牌全部清掉。这套机制听着复杂落到代码上就是刷新接口里多做一次状态检查。[HttpPost(refresh)] [AllowAnonymous] public async TaskIActionResult Refresh([FromBody] RefreshRequest req, CancellationToken ct) { var hash Sha256(req.RefreshToken); var record await _db.RefreshTokens.FirstOrDefaultAsync(t t.TokenHash hash, ct); if (record is null || record.RevokedAt is not null) return Unauthorized(new { message 刷新令牌无效 }); if (record.ExpiresAt DateTime.UtcNow) return Unauthorized(new { message 刷新令牌已过期 }); if (record.UsedAt is not null) { // 已使用过的令牌再次出现判定为泄漏清空该用户全部刷新令牌 await _db.RefreshTokens .Where(t t.UserId record.UserId t.RevokedAt null) .ExecuteUpdateAsync(s s.SetProperty(t t.RevokedAt, DateTime.UtcNow), ct); return Unauthorized(new { message 检测到重复使用请重新登录 }); } record.UsedAt DateTime.UtcNow; var user await _db.Users.AsNoTracking().FirstAsync(u u.Id record.UserId, ct); var newToken _tokenService.CreateToken(user); await _db.SaveChangesAsync(ct); return Ok(new { accessToken newToken, expiresIn 3600 }); }ExecuteUpdateAsync是 EF Core 7 才有的批量更新6.0 上要换成先查出集合再逐条改或者直接写原生 SQL。写代码前先确认手上的 EF Core 版本否则编译这一关就过不去。验证环节我习惯用一段脚本从头到尾跑一遍比在 Swagger 里手点可靠也方便回归。#!/usr/bin/env bash set -euo pipefail BASEhttp://localhost:5080 # 1. 登录拿令牌 TOKEN$(curl -s -X POST $BASE/api/auth/login \ -H Content-Type: application/json \ -d {userName:admin,password:Passw0rd!2024} | jq -r .accessToken) [ $TOKEN ! null ] || { echo 登录失败; exit 1; } # 2. 不带令牌访问预期 401 echo -n 无令牌: ; curl -s -o /dev/null -w %{http_code}\n $BASE/api/users # 3. 带令牌新增预期 201 echo -n 新增: ; curl -s -o /dev/null -w %{http_code}\n -X POST $BASE/api/users \ -H Authorization: Bearer $TOKEN -H Content-Type: application/json \ -d {userName:tester01,password:Passw0rd!2024,role:User} # 4. 分页查询校验总数 curl -s $BASE/api/users?page1size10 -H Authorization: Bearer $TOKEN | jq {total, page, size} # 5. 删除一个不存在的 id预期 404 echo -n 删除不存在: ; curl -s -o /dev/null -w %{http_code}\n -X DELETE $BASE/api/users/999999 \ -H Authorization: Bearer $TOKEN脚本里的jq -r .accessToken依赖返回字段名改接口时记得同步改这里。状态码断言用-w %{http_code}比解析响应体更稳。第五步我特意留了个越权场景如果当前登录的是普通用户而不是管理员这里应当返回 403 而不是 404两种结果对应的是权限没生效和资源真不存在含义完全不同。我最早做这类接口时吃过一次大亏ClockSkew没改测试环境一直正常上线切到另一批机器后时钟差了几十秒令牌在临界点上集体失效前端只看到满屏重登录。从那以后我每次配认证都会先把时间源和容差在启动日志里打一行出来跑通验证脚本再交付。希望这套顺序能帮到你。本文还有配套的精品资源点击获取