简介:这是一份基于.NET6的Web API实战项目源码包,服务于需要上手ASP.NET Core Web API、SQL Server数据交互及JWT权限控制的.NET开发者。项目中既有完整CRUD接口实现,也演示了Swagger自动文档生成、Entity Framework/Dapper等ORM使用、数据库迁移操作,以及从用户登录签发Token到请求校验的无状态认证流程。同时涵盖RESTful API规范、全局异常过滤、SQL注入与XSS防护等工程要点。压缩包共665个文件,以dll、cs、pdb、json为主,兼具源码、编译输出、调试符号与配置文件,整体34.81MB,便于直接还原项目与阅读逻辑。资源附带数据库备份和工程说明,已有1060人浏览学习。尤其适合想利用完整示例快速掌握.NET6下分层架构、服务封装与接口安全设计的开发者,可当作课设或生产实践打磨的参照。
1. 看穿这个标题:.NET6 WebAPI + SQL Server + JWT 的增删改查到底指什么
你要做的这件事,用一句话讲清楚就是:用 .NET6 WebAPI 搭一套带 JWT 认证的后端接口,数据落在 SQL Server 里,对外只暴露一组结构清晰的增删改查端点。这个组合是内部管理系统、小程序后端和前后端分离项目的常见起步形态。它解决的核心问题不是“怎么写登录”,而是“接口怎么在不裸奔的前提下把数据安全地交出去”。适合两类人:一类是刚学会 C# 基础语法、想交付一个完整后端的新手;另一类是接外包或做内部工具、需要在最短时间内搭一个可维护基座的开发者。理解这一点后,你会发现真正花时间的地方不在 CRUD 本身,而在 JWT 的签名参数和 SQL Server 上下文生命周期上。
2. 先把地基打正:SqlServer 接入与三层结构怎么搭
2.1 实体、DbContext 怎么摆,才不会越写越乱
在真实工程里,用 EF Core 接入 SQL Server 是默认操作,因为增删改查几乎不用写 SQL;如果团队有 DBA 且要求所有访问走存储过程,Dapper 会是替换项。这个标题只写了 SqlServer,没限定 ORM,我以 EF Core 为主线,因为它能让你在一个下午就把数据访问跑通,而且后续加软删除、分页、迁移都顺手。
先看一个业务实体。这里用 Product 做示例,因为它足够简单,又能覆盖增删改查的典型字段。
public class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public bool IsDeleted { get; set; } }逻辑说明:Name 和 Category 初始化成 string.Empty,是为了避免可空引用警告。IsDeleted 是软删除标志位,内部系统里几乎所有表都值得保留这一列,后面 Delete 接口不会真正删行,而是把标志位置为 true。Price 用 decimal 而不是 float,是为了避免金额精度问题,SQL Server 对应 decimal 类型也更合适。
接下来是 DbContext,它是 EF Core 访问 SQL Server 的入口。
public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Product> Products => Set<Product>(); }逻辑说明:构造函数里的 options 由依赖注入容器传进来,连接字符串在 Program.cs 里通过 AddDbContext 配置。DbSet 暴露给 Service 层查询使用,裸写 SQL 才能满足的需求,在这个规模下不需要引入。
再说分层。Controller 只做 HTTP 语义,Service 做业务逻辑,DbContext 只在 Service 里出现。Controller 直接操作 DbContext 不是不能跑,但几个接口写下来就会出现大量重复查询代码:查列表、查单条、判断是否存在。把这些收进 Service 之后,Controller 每个方法只剩参数校验和状态码返回,后续改查询逻辑也不用动接口层。
注册上下文时要注意生命周期,常见写法是:
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));参数说明:UseSqlServer 指定 SQL Server provider;GetConnectionString 从 appsettings.json 里读取连接串。DbContext 默认生命周期是 Scoped,也就是每个请求新建一个上下文实例,这正好匹配 EF Core 的线程安全规则。不要手痒改成 Singleton,并发场景下会出现一堆莫名其妙的异常。
2.2 Program.cs 最小注册清单与连接串参数
无论你建项目时用的是 Controller 模板还是最小 API 模板,最终落到 Program.cs 的注册清单大同小异。下面是一份够用的最小配置。
var builder = WebApplication.CreateBuilder(args); builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();逻辑说明:这是 Controller 模式的最小启动配置。注意这里还没有 UseAuthentication,因为 JWT 中间件在下一章才加。顺序习惯上要把 UseAuthentication 放在 UseAuthorization 之前,否则认证结果不会带到授权环节,后面排查 401 时会重点讲。
连接串长这样:
"ConnectionStrings": { "DefaultConnection": "Server=.;Database=DemoDb;User Id=sa;Password=YourPassword;TrustServerCertificate=True;Encrypt=False;" }参数说明:Server=. 代表本机默认实例,命名实例要写 .\SQLEXPRESS。Encrypt=True 是 SQL Server 连接加密的默认值,本地开发没装证书时直接会连不上,所以要么 TrustServerCertificate=True,要么 Encrypt=False。生产环境建议 Encrypt=True 并配置受信证书。如果你用 Windows 集成登录,把 User Id 和 Password 删掉,换成 Integrated Security=True。
这里最容易被忽略的是 TrustServerCertificate。明明账号密码都对,报错却提示证书链未被信任,就是它在作怪。本地开发别纠结,先把连接跑通再补加密,这是我踩过几次的坑。
还要确认一点:如果用 EF Core 工具生成数据库,需要装 dotnet-ef 工具,然后执行迁移命令。常见做法是先写实体,再执行 dotnet ef migrations add Init,最后 dotnet ef database update。如果只是做个演示,也可以让 DBA 先把表建好,EF Core 只要表和实体对应上就能正常工作。表名默认会是 Products,如果不想用复数,就在 OnModelCreating 里配置 ToTable("Product")。我一般保持复数,避免和实体名混淆。
3. JWT 不是黑匣子:从签发 Token 到鉴权策略的完整链路
3.1 为什么不用 Cookie:JWT 选型逻辑
前后端分离之后,Cookie 方案会遇到跨域、CSRF、移动端不支持 Cookie 等问题。JWT 把用户身份编码进一个自包含的 Token,请求时在 Authorization 头里带过来,服务端验签即可,不需要查询会话表。这个特性让它在 API 场景里成为默认选项。
但 JWT 不是加密,它的 payload 部分用 Base64 编码,谁都能解码,只是不能伪造。绝不能把密码、身份证号这类敏感信息放进去。很多人第一次写 JWT 时以为别人看不到,实际上放到网上随便一个解析工具就能看到内容。所以 Token 里一般只放 userId、userName、role 这类非敏感标识。
选 Cookie 还是 JWT 不是跟风。如果要求服务端能立即踢人,Session 加 Cookie 其实更顺手,JWT 要踢人就得加黑名单或把过期时间缩短。对标准增删改查 API,我通常默认 JWT,因为客户端无状态、接手的人看代码也容易懂。标题既然锁定了 JWT,就顺着这条路走到底。
3.2 签发一个可用的 Token:代码和关键参数
先装 NuGet 包 Microsoft.AspNetCore.Authentication.JwtBearer,System.IdentityModel.Tokens.Jwt 一般会被带进来。签发 Token 的逻辑通常单独抽一个 TokenService。
using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; public class TokenService { private readonly IConfiguration _config; public TokenService(IConfiguration config) { _config = config; } public string CreateToken(int userId, string userName, string role) { var claims = new List<Claim> { new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Name, userName), new Claim(ClaimTypes.Role, role) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddMinutes(Convert.ToDouble(_config["Jwt:ExpireMinutes"])), signingCredentials: creds ); return new JwtSecurityTokenHandler().WriteToken(token); } }逻辑说明:claims 是 Token 自带的身份声明,Sub 是用户 Id,Name 是用户名,ClaimTypes.Role 会映射成标准角色声明。SymmetricSecurityKey 是对称签名密钥,HS256 算法要求密钥长度至少 128 位,建议直接配 32 个以上字符。expires 用 UTC 时间,避免服务器时区导致 Token 过期时间混乱。
参数说明:Issuer 和 Audience 在 appsettings 里配置,客户端和 API 两边要对得上。ExpireMinutes 建议设 120 分钟,内部系统可以更长,但超过 1 天对安全不利。
配置长这样:
"Jwt": { "Issuer": "Demo.Api", "Audience": "Demo.Client", "Key": "replace-with-a-random-string-at-least-32-chars", "ExpireMinutes": "120" }提示:密钥别写死在 Git 仓库里。常见做法是 appsettings 放开发值,生产环境用环境变量覆盖,比如在 IIS 或容器里配置 Jwt__Key。有的项目把默认 Key 发到代码库,结果整个系统都能被伪造 Token,这个教训很深刻。
3.3 让请求带上身份:认证中间件与鉴权策略配置
签发 Token 只是前半段,后半段是让 API 在收到请求时验证令牌。这部分写在 Program.cs 里。
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"], ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30) }; }); builder.Services.AddAuthorization();逻辑说明:AddAuthentication 注册认证服务,AddJwtBearer 配置 JWT 验证细节。TokenValidationParameters 里 ValidateIssuer 和 ValidateAudience 必须开启,否则别的系统拿着相同密钥签的 Token 也能访问。ValidateIssuerSigningKey 确保用同一个密钥验签。ValidateLifetime 默认开启,负责检查过期时间。
参数说明:ClockSkew 默认是 5 分钟,意思是即使 Token 过期,系统也允许前后 5 分钟的时钟偏移,结果就是 Token 过期后还能再访问一段不长的时间。对内网 API 我直接压到 30 秒。NameClaimType 和 RoleClaimType 这两项,如果不做设置默认也能工作,出现自定义 Claim 取不到的时候再去改。
构建应用之后,中间件顺序是关键:
app.UseAuthentication(); app.UseAuthorization();逻辑说明:UseAuthentication 会解析请求头里的 Token 并填充用户身份,UseAuthorization 才检查 [Authorize] 标签。顺序反了,身份信息还没建立,授权自然失败。
控制器侧这样用:
[ApiController] [Route("api/[controller]")] [Authorize] public class ProductsController : ControllerBase { }逻辑说明:类上标注 [Authorize],整个控制器都要求登录。登录接口本身要标注 [AllowAnonymous],否则没人能拿到 Token。角色限制用 [Authorize(Roles = "admin")],并且 Token 里要包含对应角色 Claim。别把所有接口都允许匿名后再在代码里手动判断,那样很容易漏。
4. 把增删改查写成不挨骂的接口:DTO、Service 与状态码选择
4.1 Service 层 + DTO:到底谁碰 DbContext
Controller 里直接用 DbContext 不是不能跑,但会让代码越到后期越乱。常见做法是加一个 Service 层,Controller 只负责接收 HTTP 请求、做参数校验、返回合适状态码,数据访问全在 Service。Controller 瘦下来之后,接口的增删改查语义一眼就看懂。
先定义 DTO。DTO 的作用是隔离外部输入和数据库实体,避免把 EF 跟踪元数据和实体结构直接暴露给前端。
public class ProductDto { public int Id { get; set; } public string Name { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } } public class CreateProductRequest { public string Name { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public decimal Price { get; set; } public int Stock { get; set; } } public class UpdateProductRequest { public string Name { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public decimal Price { get; set; } public int Stock { get; set; } }逻辑说明:Create 和 Update 的输入模型几乎一样,区别在于 Update 不带 Id,Id 从路由里取。如果你把实体直接当输入模型,[ApiController] 会把 EF 元数据牵扯进来,还容易暴露多余字段。
Service 接口与实现:
public interface IProductService { Task<List<ProductDto>> GetPageAsync(int page, int pageSize); Task<ProductDto?> GetByIdAsync(int id); Task<ProductDto> CreateAsync(CreateProductRequest request); Task<bool> UpdateAsync(int id, UpdateProductRequest request); Task<bool> DeleteAsync(int id); }public class ProductService : IProductService { private readonly AppDbContext _db; public ProductService(AppDbContext db) { _db = db; } public async Task<List<ProductDto>> GetPageAsync(int page, int pageSize) { var items = await _db.Products .Where(p => !p.IsDeleted) .OrderByDescending(p => p.CreatedAt) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(p => new ProductDto { Id = p.Id, Name = p.Name, Category = p.Category, Price = p.Price, Stock = p.Stock, CreatedAt = p.CreatedAt }) .AsNoTracking() .ToListAsync(); return items; } public async Task<ProductDto?> GetByIdAsync(int id) { var entity = await _db.Products .AsNoTracking() .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted); return entity is null ? null : MapToDto(entity); } public async Task<ProductDto> CreateAsync(CreateProductRequest request) { var entity = new Product { Name = request.Name, Category = request.Category, Price = request.Price, Stock = request.Stock, CreatedAt = DateTime.UtcNow, IsDeleted = false }; _db.Products.Add(entity); await _db.SaveChangesAsync(); return MapToDto(entity); } public async Task<bool> UpdateAsync(int id, UpdateProductRequest request) { var entity = await _db.Products .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted); if (entity is null) return false; entity.Name = request.Name; entity.Category = request.Category; entity.Price = request.Price; entity.Stock = request.Stock; await _db.SaveChangesAsync(); return true; } public async Task<bool> DeleteAsync(int id) { var entity = await _db.Products .FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted); if (entity is null) return false; entity.IsDeleted = true; await _db.SaveChangesAsync(); return true; } private static ProductDto MapToDto(Product p) { return new ProductDto { Id = p.Id, Name = p.Name, Category = p.Category, Price = p.Price, Stock = p.Stock, CreatedAt = p.CreatedAt }; } }逻辑说明:GetPageAsync 用 Where 过滤软删除,OrderByDescending 让新数据在前,Skip/Take 会被 EF Core 翻译成 SQL Server 的 OFFSET/FETCH 分页,不是把全部数据拉到内存再分页。Select 手工映射成 DTO,避免 JSON 序列化时把实体导航属性带进去。AsNoTracking 告诉 EF Core 只读,不创建跟踪实体,查询性能更好,但 Update 和 Delete 时不能加它,因为需要跟踪实体状态才能生成 UPDATE 或 DELETE 语句。
Create 和 Update 都先从库里查出来再改,不用 Attach 那种看起来省事的写法。小系统没必要冒脏数据风险,性能差异可以忽略。Delete 用软删除,保留历史记录,这是内部系统通行做法;如果确定要物理删除,把 entity.IsDeleted = true 换成 _db.Products.Remove(entity) 就行。
参数说明:page 从 1 开始,pageSize 建议控制在 10 到 100 之间。分页参数要在 Controller 里做校验,page 小于 1 或者 pageSize 超过上限就返回 400。
4.2 四个接口的落地写法与状态码选择
Controller 代码直接体现 HTTP 语义:
[ApiController] [Route("api/[controller]")] [Authorize] public class ProductsController : ControllerBase { private readonly IProductService _service; public ProductsController(IProductService service) { _service = service; } [HttpGet] public async Task<ActionResult<ApiResult<List<ProductDto>>>> GetPage( [FromQuery] int page = 1, [FromQuery] int pageSize = 10) { var data = await _service.GetPageAsync(page, pageSize); return Ok(ApiResult.Success(data)); } [HttpGet("{id:int}")] public async Task<ActionResult<ApiResult<ProductDto>>> GetById(int id) { var data = await _service.GetByIdAsync(id); return data is null ? NotFound(ApiResult.Fail("数据不存在", 404)) : Ok(ApiResult.Success(data)); } [HttpPost] public async Task<ActionResult<ApiResult<ProductDto>>> Create(CreateProductRequest request) { var data = await _service.CreateAsync(request); return CreatedAtAction(nameof(GetById), new { id = data.Id }, ApiResult.Success(data)); } [HttpPut("{id:int}")] public async Task<ActionResult<ApiResult<bool>>> Update(int id, UpdateProductRequest request) { var updated = await _service.UpdateAsync(id, request); return updated ? Ok(ApiResult.Success(true)) : NotFound(ApiResult.Fail("数据不存在", 404)); } [HttpDelete("{id:int}")] public async Task<ActionResult<ApiResult<bool>>> Delete(int id) { var deleted = await _service.DeleteAsync(id); return deleted ? NoContent() : NotFound(ApiResult.Fail("数据不存在", 404)); } }逻辑说明:每个方法都是异步 Task,用 ActionResult<ApiResult > 作为返回类型。创建成功后返回 CreatedAtAction,状态码是 201,同时带 Location 头指向新资源的 GET 地址。Delete 返回 204,表示成功且无内容,比返回 200 更贴合 REST 语义。如果你团队的前端不习惯处理 204,统一返回 200 也可以,但前后端要约定清楚,不要混合着来。
参数说明:路由约束 {id:int} 避免了字符串 id 进来后再转型出错。[FromQuery] 显式声明分页参数来源,避免跟路由参数混淆。NotFound 和 Ok 里面都用 ApiResult 包裹,出错时前端只需要解析同一个 JSON 结构。
4.3 统一返回结构与分页参数怎么设
统一返回结构的目的是让前端可以用一个拦截器处理所有接口。如果不做,遇到异常时 ASP.NET Core 会返回默认错误体,前端就得兼容两套结构。
public class ApiResult<T> { public bool Success { get; set; } public int Code { get; set; } public string Message { get; set; } = string.Empty; public T? Data { get; set; } public static ApiResult<T> Ok(T data) { return new ApiResult<T> { Success = true, Code = 200, Message = "ok", Data = data }; } public static ApiResult<T> Fail(string message, int code = 400) { return new ApiResult<T> { Success = false, Code = code, Message = message }; } }逻辑说明:静态方法让 Controller 里不用 new 一大堆对象。更工程化的做法是在 Program.cs 里注册全局异常过滤器,把未捕获异常统一转成 ApiResult 格式,这部分我放到最后一章。
分页数据结构:
public class PageResult<T> { public int PageIndex { get; set; } public int PageSize { get; set; } public int Total { get; set; } public List<T> Items { get; set; } = new(); }参数说明:PageIndex 和 PageSize 回传给前端,Total 用于计算总页数。字段命名用 PageIndex 而不是 Page,避免和请求参数混淆。如果你要接前端表格组件,这个结构基本可以直接用。
5. 常见问题排查:JWT 与 SQL Server 场景下五个最容易翻车的位置
5.1 Token 接口一直 401?先查中间件顺序和 Bearer 前缀
现象:接口标了 [Authorize],带着 Token 访问始终返回 401,服务端日志里连 TokenValidated 事件都没触发。
原因:最常见有两个。一个是 Program.cs 里漏了 app.UseAuthentication(),或者把它写在了 UseAuthorization 后面;另一个是请求头格式写成了 Authorization: 一串 Token,少了 Bearer 前缀,JwtBearer 中间件默认只认 Bearer 方案。
解决:确认中间件顺序是 UseAuthentication 在前、UseAuthorization 在后。客户端加请求头时写成 Authorization: Bearer eyJ...,注意 Bearer 和 Token 之间有且只有一个空格。
5.2 明明设置了 2 小时过期,过期后还能访问
现象:Token 的 expires 设置成 2 小时,实际跑了 2 小时 5 分还能正常访问。
原因:JwtBearerOptions 的 ClockSkew 默认是 5 分钟,它允许验证时存在最多 5 分钟时钟偏差,等效于 Token 生命周期比设定长。
解决:在 TokenValidationParameters 里设置 ClockSkew = TimeSpan.FromSeconds(30),或者直接设 TimeSpan.Zero。如果服务器和客户端存在明显时间偏差,设零会导致正常 Token 被判过期,30 秒是个折中值。
5.3 自定义 Claim 取不到值
现象:签发时写 new Claim("permission", "product:edit"),在接口里用 User.FindFirst("permission") 返回 null。
原因:JwtSecurityTokenHandler 默认启用 claim 类型映射,把短名称映射成微软默认的 XML 命名空间长 URI,所以按短名称查找失败。
解决:启动时执行 JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(),同时清理 DefaultOutboundClaimTypeMap。如果不想全局清理,读取时用完整 URI,但代码很难看。我一般全局清理,可读性更好。
5.4 HS256 签名密钥长度踩坑
现象:写了个短的 Key 比如 abc123,调试时抛异常,错误码类似 IDX10653,提示 key size 不足。
原因:HS256 不是随便一个字符串都能当签名密钥,对称密钥至少需要 128 位,也就是 16 字节,很多 Token 库实际要求不低于 256 位。
解决:配置一个至少 32 字符的随机字符串。不要直接写在代码里,放 appsettings,并由部署环境用环境变量覆盖。如果已经报了错,换长 Key 后要清理本地已签发的旧 Token,重新获取再试。
5.5 DbContext 异步与连接池
现象:接口偶尔抛出连接池超时,错误信息类似 Timeout expired,或者并发请求一高页面就卡住。
原因:常见是 DbContext 被注册成 Singleton,或者 Service 里用了 Task.Run 再调 DbContext。EF Core 上下文不是线程安全的,并发请求共用同一个上下文必然出问题。另一种是同步调用 GetAwaiter().GetResult(),在高并发下阻塞线程池,连接池很快耗尽。
解决:DbContext 保持默认 Scoped 生命周期,全链路用 async/await,Controller 和 Service 方法都标 async Task,不要出现 .Result 或 .Wait()。连接串里可以先调大 Max Pool Size,但这只是临时止血,根因在同步调用,治标不治本。
6. 用 Swagger 验证链路:从手动加 Header 到自动带 Token 的收尾技巧
接口写完,不能只靠 Postman 手测。能让团队所有人一键验收,才算真正收尾。先在 Swagger 里配置 JWT Bearer 安全定义,点一下 Authorize 按钮输入 Token,后续所有请求自动带 Authorization 头,不用每次手动拼字符串。
builder.Services.AddSwaggerGen(options => { options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Name = "Authorization", Type = SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", In = ParameterLocation.Header, Description = "输入你的 JWT Token" }); options.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }, Array.Empty<string>() } }); });逻辑说明:SecuritySchemeType.Http 配合 Scheme=bearer,Swagger 会在请求头自动拼上 Bearer 前缀,你只需要在弹窗里粘贴 Token 本体。AddSecurityRequirement 让所有带 [Authorize] 的接口都显示锁图标,未登录时点击调用会直接返回 401。
我给自己定了一个验收顺序:先调登录接口拿到 Token;把 Token 贴进 Swagger 的 Authorize 弹窗;然后依次走 GET、POST、PUT、DELETE,重点看四件事——不带 Token 时是否 401、带 Token 分页是否按页返回、更新不存在的 Id 是否 404、删除后再次查询是否消失。这个顺序跑一遍,这套 JWT 加增删改查基本就能交付了。
最后一个习惯:真正上线前把 Jwt:Key 放到服务器环境变量,代码仓库里只留占位符。某次模拟项目里我们就是因为密钥写死在配置里,被安全评审一票打回,全程返工。把这一段链路跑通并定好规范,后续接权限系统、加日志中间件都会顺很多。希望这些经验帮到你少走这一步。
本文还有配套的精品资源,点击获取