简介本资源是一套基于 .NET6 构建的 Web API 实战示例面向具备一定 C# 基础、希望掌握 ASP.NET Core 后端开发的开发者重点演示如何结合 SQL Server 与 JWT 完成增删改查及身份验证。项目涵盖 Web API 接口设计、Swagger 交互式文档、Entity Framework Core 数据库迁移、JWT 无状态鉴权、全局异常处理与错误日志、RESTful 设计规范及 HTTPS 安全传输等关键知识点并涉及 CI/CD 流程思路适合作为学习 .NET6 全栈接口开发的参考案例。压缩包共 665 个文件以 dll 依赖库、cs 源码、pdb 调试符号、json 配置、csproj 项目文件及少量 exe、sln 解决方案为主整体约 34.81MB目录按 Model、Service、BLL 等分层组织便于对照阅读。目前已有 1060 人学习下载读者可借此理解从数据库交互到权限控制的完整链路快速上手企业级 Web 服务搭建。1. 从一张“能跑通”的接口清单说起很多团队在起步一个后台服务时最先卡住的不是业务逻辑而是“怎么让一套 .NET6 WebAPI 用 SqlServer 存数据、用 JWT 管住谁能调”。标题里的三个词——.NET6 WebAPI、SqlServer、JWT——恰好对应了服务端最基础的三件事宿主框架、持久化、鉴权。把这三件事串成一条增删改查的闭环是绝大多数管理后台、内部工具、移动端后端的最小可用形态。我见过不少项目接口能返回数据但 Token 一过期就整站 401或者删改接口谁都能调。问题不在框架而在没把“认证”和“授权”分开设计。这篇笔记就按我实际搭一套最小服务的顺序走先定项目骨架和依赖再落 SqlServer 的实体与迁移接着把 JWT 的签发与校验写死最后用一组受保护的增删改查接口验证整条链路。适合刚接触 .NET6 的后端也适合从 .NET Framework 迁过来、想看清 JWT 落地细节的人。2. 项目骨架与依赖先把“能编译”这件事做扎实2.1 为什么选 .NET6 而不是更早的版本.NET6 是长期支持版本WebAPI 模板默认走最小宿主模型Program.cs里用WebApplication.CreateBuilder就能把配置、日志、依赖注入一次性挂上。相比 .NET Core 3.1它在System.IdentityModel.Tokens.Jwt和Microsoft.EntityFrameworkCore.SqlServer上的包版本更统一不会出现运行时找不到程序集的玄学问题。我一般会先确认本机 SDK 版本再建项目避免模板生成的 TargetFramework 和实际 SDK 对不上。# 确认本机 .NET SDK 版本.NET6 对应 6.x dotnet --list-sdks # 新建 WebAPI 项目指定框架为 net6.0 dotnet new webapi -n DemoApi -f net6.0 # 进入项目目录 cd DemoApidotnet new webapi会生成一个带 WeatherForecast 示例的控制器这个示例可以直接删掉它只是模板占位。-f net6.0明确锁定框架防止本机装了更高版本 SDK 时默认生成 net7 或 net8 的工程。建完先dotnet build跑一次确认模板本身能编译再动任何代码。2.2 三个必须装的 NuGet 包增删改查加 JWT最少需要三个包EF Core 的 SqlServer 提供程序、JWT 的生成与校验库、以及 Swagger 在带鉴权时的 UI 支持。我习惯用命令行装版本让 NuGet 自己解析到与 net6.0 兼容的最新稳定版。# EF Core 的 SqlServer 驱动负责连库和迁移 dotnet add package Microsoft.EntityFrameworkCore.SqlServer # EF Core 设计时工具用于执行迁移命令 dotnet add package Microsoft.EntityFrameworkCore.Design # JWT 的生成与校验 dotnet add package System.IdentityModel.Tokens.Jwt # Swagger 的 JWT 支持方便在 UI 里填 Token dotnet add package Swashbuckle.AspNetCoreMicrosoft.EntityFrameworkCore.Design是设计时包只在执行dotnet ef命令时需要运行时不会加载。System.IdentityModel.Tokens.Jwt负责把 Claims 编码成 Token也负责反向解析。Swashbuckle 默认模板里已经带了但如果你手动建的空项目需要补上否则没有接口文档页调试时只能靠 Postman。提示装包时如果提示版本冲突优先看Microsoft.EntityFrameworkCore.SqlServer的主版本是否和Microsoft.EntityFrameworkCore.Design一致两者必须同主版本否则迁移命令会报“找不到设计时服务”。2.3 目录结构怎么分才不返工我见过把实体、上下文、控制器全塞在根目录的项目后期加一个功能要翻半天。最小服务也建议按职责分四个文件夹Models放实体和 DTOData放 DbContextServices放 JWT 签发逻辑Controllers放接口。这个结构不重但能让后面每一步都有明确落点。DemoApi/ ├── Controllers/ │ └── UsersController.cs ├── Data/ │ └── AppDbContext.cs ├── Models/ │ ├── User.cs │ └── LoginRequest.cs ├── Services/ │ └── TokenService.cs ├── appsettings.json └── Program.csModels里实体和 DTO 可以放一起但建议用不同后缀区分比如User是数据库实体LoginRequest是登录入参。Data只放 DbContext 和迁移文件。Services里放不依赖 HTTP 上下文的纯逻辑TokenService 就是典型。控制器只做参数校验和调用不写业务细节。3. SqlServer 落库实体、上下文与迁移的完整链路3.1 实体设计主键、时间戳和密码字段怎么定增删改查的“增”和“改”最终都落到表结构上。用户表最少要有自增主键、唯一用户名、密码哈希、创建时间。密码绝对不能存明文我一般用 BCrypt 或 PBKDF2这里为了聚焦 JWT先用一个占位哈希字段实际项目必须换成真正的哈希库。// Models/User.cs using System.ComponentModel.DataAnnotations; namespace DemoApi.Models; public class User { [Key] public int Id { get; set; } [Required] [MaxLength(50)] public string UserName { get; set; } string.Empty; // 实际项目存 BCrypt 哈希这里用字符串占位 [Required] public string PasswordHash { get; set; } string.Empty; public DateTime CreatedAt { get; set; } DateTime.UtcNow; }[Key]标记主键EF Core 默认会把Id识别为自增主键显式写出来是为了后面换主键类型时不遗漏。CreatedAt用DateTime.UtcNow而不是Now避免服务器时区变化导致排序错乱。PasswordHash字段长度不设MaxLength因为不同哈希算法输出长度不同设短了会截断。3.2 DbContext 与连接字符串的写法DbContext 是 EF Core 和数据库之间的桥。构造函数注入DbContextOptionsOnModelCreating里可以加唯一索引保证用户名不重复。连接字符串放appsettings.json不要硬编码在代码里。// Data/AppDbContext.cs using DemoApi.Models; using Microsoft.EntityFrameworkCore; namespace DemoApi.Data; public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetUser Users SetUser(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // 用户名唯一索引防止重复注册 modelBuilder.EntityUser() .HasIndex(u u.UserName) .IsUnique(); } }DbSetUser Users用表达式体 SetUser()是 .NET6 的推荐写法比字段声明更简洁。HasIndex加IsUnique会在数据库层生成唯一约束比在应用层查重更可靠并发插入时不会出现两个同名用户。// appsettings.json 片段 { ConnectionStrings: { Default: Serverlocalhost;DatabaseDemoDb;Trusted_ConnectionTrue;TrustServerCertificateTrue; }, Jwt: { Key: 请替换为至少32位的随机字符串用于签名, Issuer: DemoApi, Audience: DemoClient, ExpireMinutes: 60 } }Trusted_ConnectionTrue走 Windows 集成认证本地开发最省事。如果 SqlServer 装在容器里或用 SQL 账号换成User Id...;Password...。TrustServerCertificateTrue在本地自签证书时避免握手失败。Jwt.Key必须够长HMAC-SHA256 要求密钥至少 256 位也就是 32 个字符以上短了会在签发时抛异常。3.3 迁移命令与数据库生成实体和上下文写完用 EF Core 迁移把表建出来。迁移命令要在项目根目录执行且项目里必须有Microsoft.EntityFrameworkCore.Design。# 生成迁移文件名称用 AddUsers 描述本次变更 dotnet ef migrations add AddUsers # 把迁移应用到数据库自动建库建表 dotnet ef database updatemigrations add会在项目下生成Migrations文件夹里面是 C# 代码记录了本次表结构变更。database update会连到连接字符串指定的 SqlServer如果数据库不存在会先建库。执行前确认 SqlServer 服务已启动否则会报“无法连接”。如果报“找不到 dotnet ef”先执行dotnet tool install --global dotnet-ef安装全局工具。注意迁移文件要提交到版本控制它是数据库结构的唯一真相。不要手动改数据库表结构否则下次迁移会和实际结构对不上出现“模型有变更但迁移没生成”的假象。3.4 在 Program.cs 里注册 DbContext.NET6 的Program.cs是顶层语句注册服务直接写在builder.Services上。DbContext 默认注册为 Scoped每个请求一个实例适合 WebAPI。// Program.cs 片段 using DemoApi.Data; using Microsoft.EntityFrameworkCore; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Default))); var app builder.Build(); app.MapControllers(); app.Run();AddDbContext的第二个参数是配置委托UseSqlServer从配置里读连接字符串。GetConnectionString(Default)对应appsettings.json里ConnectionStrings:Default的值。如果读出来是 null检查 json 层级和键名大小写配置系统默认不区分大小写但键名拼错会静默返回 null。4. JWT 签发与校验把“谁能调”这件事写死4.1 JWT 的三段结构和 Claims 放什么JWT 由 Header、Payload、Signature 三段用点号连接。Header 声明算法Payload 放 ClaimsSignature 用密钥对前两段签名。服务端校验时重新计算签名对不上就拒绝。Claims 里我一般放用户 Id、用户名和过期时间不放敏感信息因为 Payload 只是 Base64 编码不是加密。// Services/TokenService.cs using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using DemoApi.Models; using Microsoft.IdentityModel.Tokens; namespace DemoApi.Services; public class TokenService { private readonly IConfiguration _config; public TokenService(IConfiguration config) _config config; public string CreateToken(User user) { // 从配置读密钥、签发者、受众 var key new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config[Jwt:Key]!)); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); // Claims 放用户标识不放密码 var claims new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) }; var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.UtcNow.AddMinutes( double.Parse(_config[Jwt:ExpireMinutes]!)), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } }SymmetricSecurityKey用同一个密钥签名和校验适合单体服务。Jti是 Token 唯一标识后面做黑名单或防重放时用得上。expires用 UTC 时间和CreatedAt保持一致。WriteToken把对象序列化成三段字符串返回给客户端。4.2 在 Program.cs 里配置认证与授权中间件认证是“你是谁”授权是“你能不能调”。JWT 的校验参数必须和签发时完全一致Issuer、Audience、Key 任何一项不同都会导致 401。// Program.cs 片段接在 AddControllers 之后 using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; builder.Services.AddScopedTokenService(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidateAudience true, ValidateLifetime true, ValidateIssuerSigningKey true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidAudience builder.Configuration[Jwt:Audience], IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:Key]!)) }; }); builder.Services.AddAuthorization(); var app builder.Build(); app.UseAuthentication(); // 必须在 UseAuthorization 之前 app.UseAuthorization();ValidateLifetime true会检查exp字段过期直接 401。UseAuthentication负责解析 Token 并填充HttpContext.UserUseAuthorization负责检查[Authorize]特性。顺序反了会导致授权时拿不到用户身份表现为所有带[Authorize]的接口都 401。4.3 登录接口签发 Token 的最小闭环登录接口接收用户名密码查库比对哈希成功则返回 Token。这里为了聚焦流程密码比对先用明文占位实际必须换成哈希校验。// Controllers/AuthController.cs using DemoApi.Data; using DemoApi.Models; using DemoApi.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace DemoApi.Controllers; [ApiController] [Route(api/[controller])] public class AuthController : ControllerBase { private readonly AppDbContext _db; private readonly TokenService _tokenService; public AuthController(AppDbContext db, TokenService tokenService) { _db db; _tokenService tokenService; } [HttpPost(login)] public async TaskIActionResult Login([FromBody] LoginRequest req) { var user await _db.Users .FirstOrDefaultAsync(u u.UserName req.UserName); // 实际项目用 BCrypt.Verify 比对哈希 if (user null || user.PasswordHash ! req.Password) return Unauthorized(用户名或密码错误); var token _tokenService.CreateToken(user); return Ok(new { token }); } }[FromBody]让框架从请求体反序列化 JSON。FirstOrDefaultAsync是异步查询避免阻塞线程。返回Unauthorized时不要区分“用户不存在”和“密码错误”统一提示防止攻击者枚举用户名。Token 放在响应体的token字段客户端后续请求放在Authorization: Bearer token头里。4.4 受保护的增删改查接口增删改查四个动作分别对应 POST、GET、PUT、DELETE。给控制器加[Authorize]整个控制器下的接口都需要有效 Token。// Controllers/UsersController.cs using DemoApi.Data; using DemoApi.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace DemoApi.Controllers; [ApiController] [Route(api/[controller])] [Authorize] public class UsersController : ControllerBase { private readonly AppDbContext _db; public UsersController(AppDbContext db) _db db; // 查列表 [HttpGet] public async TaskIActionResult GetAll() Ok(await _db.Users.AsNoTracking().ToListAsync()); // 查单个 [HttpGet({id})] public async TaskIActionResult GetById(int id) { var user await _db.Users.FindAsync(id); return user null ? NotFound() : Ok(user); } // 增 [HttpPost] public async TaskIActionResult Create(User user) { _db.Users.Add(user); await _db.SaveChangesAsync(); return CreatedAtAction(nameof(GetById), new { id user.Id }, user); } // 改 [HttpPut({id})] public async TaskIActionResult Update(int id, User input) { if (id ! input.Id) return BadRequest(); _db.Entry(input).State EntityState.Modified; await _db.SaveChangesAsync(); return NoContent(); } // 删 [HttpDelete({id})] public async TaskIActionResult Delete(int id) { var user await _db.Users.FindAsync(id); if (user null) return NotFound(); _db.Users.Remove(user); await _db.SaveChangesAsync(); return NoContent(); } }AsNoTracking用于只读查询不跟踪实体变化减少内存开销。CreatedAtAction返回 201 并在 Location 头里带上新资源地址。Update里先比对路由 id 和实体 id防止改错对象。EntityState.Modified让 EF Core 生成 UPDATE 语句但会更新所有字段如果只想改部分字段应先查再改。提示[Authorize]加在控制器上所有接口都需要 Token。如果某个接口要匿名访问单独加[AllowAnonymous]比如登录接口所在的控制器就不加[Authorize]。5. 避坑与排查那些让接口 401 和迁移失败的细节5.1 现象所有带 [Authorize] 的接口都返回 401Token 明明没过期原因通常是UseAuthentication和UseAuthorization顺序反了或者TokenValidationParameters里的 Issuer、Audience 和签发时不一致。还有一种情况是Jwt:Key在签发和校验时读到了不同的值比如一个从环境变量读、一个从 json 读。解决先确认Program.cs里UseAuthentication在UseAuthorization之前。再打印签发和校验时用的 Issuer、Audience、Key 长度逐项比对。如果用了环境变量覆盖配置注意Jwt__Key双下划线是层级分隔符写成Jwt:Key在环境变量里不生效。5.2 现象dotnet ef migrations add 报“无法创建类型为 DbContext 的对象”原因是Program.cs里注册 DbContext 时用了运行时才有的配置设计时工具无法构造。比如连接字符串从HttpContext读或者 DbContext 构造函数有额外参数没在 DI 里注册。解决确保AddDbContext的配置只依赖IConfiguration不依赖请求上下文。如果 DbContext 有额外构造函数参数在设计时工厂里显式提供或者把参数改成从配置读。最省事的做法是让 DbContext 只有一个DbContextOptions参数。5.3 现象迁移执行成功但表里没有唯一索引原因是OnModelCreating里加了索引但迁移文件是在加索引之前生成的database update只应用已有迁移不会重新扫描模型。解决加完索引后重新执行dotnet ef migrations add AddUniqueIndex再database update。不要手动去数据库加索引否则模型和数据库不一致下次迁移会生成重复索引或报冲突。5.4 现象PUT 接口返回 400但请求体看起来没问题原因是路由 id 和请求体里的 Id 不一致代码里if (id ! input.Id) return BadRequest()触发了。客户端可能只传了部分字段input.Id默认是 0。解决要么在请求体里带上 Id要么改成从路由 id 查实体再更新字段。我一般推荐后者先FindAsync查出实体再逐字段赋值避免客户端传空值覆盖数据库。5.5 现象Token 签发成功但客户端带上后仍然 401且没有任何错误信息原因是客户端把 Token 放错了位置比如放在查询字符串或自定义头里。JWT 默认从Authorization头读格式必须是Bearer tokenBearer 和 token 之间有一个空格。解决用浏览器开发者工具或抓包看请求头确认Authorization: Bearer eyJ...格式正确。如果用了 Swagger在右上角 Authorize 按钮里填 Token 时不要自己加 Bearer 前缀Swagger 会自动加。6. 进阶让这套骨架扛住真实项目的三个技巧第一个技巧是给 Token 加刷新机制。访问 Token 设短一点比如 15 分钟另发一个刷新 Token 存库过期后用刷新 Token 换新的访问 Token。这样即使访问 Token 泄露窗口期也短。刷新 Token 要能撤销存库时记一个Revoked字段用户登出时置为 true。第二个技巧是用策略授权替代角色硬编码。[Authorize(Roles Admin)]在角色少时够用但角色一多就乱。改成[Authorize(Policy CanDeleteUser)]在AddAuthorization里注册策略策略里可以组合角色、Claims 甚至查库判断。这样权限规则集中在一处改起来不用翻每个控制器。第三个技巧是给 SqlServer 加连接重试。网络抖动或数据库短暂不可用时默认会直接抛异常。在UseSqlServer里加EnableRetryOnFailureEF Core 会自动重试几次。builder.Services.AddDbContextAppDbContext(options options.UseSqlServer( builder.Configuration.GetConnectionString(Default), sql sql.EnableRetryOnFailure( maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorNumbersToAdd: null)));maxRetryCount是重试次数maxRetryDelay是每次重试最大等待。这个配置对瞬时故障有效但对连接字符串写错这种持续性错误没用重试完还是会抛。我一般只在生产环境开本地开发关掉否则调试时看不到真实错误。验证这套骨架是否真的可用我习惯用两个动作先用 Swagger 走一遍登录拿 Token再带着 Token 调一遍增删改查确认 401 和 200 的边界都对。然后把 Token 的过期时间改成 1 分钟等它过期再调一次确认返回 401 而不是 500。这两个动作能覆盖大部分配置错误。我自己踩过最深的坑是早期把Jwt:Key写在代码里换环境时忘了改结果测试环境的 Token 拿到生产环境校验不过排查了半天才发现是密钥不一致。从那以后所有和签名、连接相关的配置一律走配置文件或环境变量代码里只留读取逻辑。希望帮到你。本文还有配套的精品资源点击获取