.NET6+SQL Server+JWT企业级API开发实战
简介本资源是一个基于.NET 6构建的完整Web API实战项目面向C#后端开发初学者及希望掌握现代身份验证与数据库集成的进阶学习者重点解决ASP.NET Core中SQL Server数据操作与JWT无状态鉴权的工程化落地问题。压缩包共665个文件含96个C#源码文件涵盖Controller、Model、Service等分层逻辑、286个DLL程序集含运行时依赖与NuGet包、44个JSON配置文件含Swagger配置、JWT密钥、连接字符串等以及CSProj项目文件、数据库备份DemoDB.bak等关键资产整体34.81MB结构清晰支持开箱即用。已有1062人学习下载项目完整呈现了从EF Core迁移、JWT令牌签发/验证、全局异常处理到Swagger交互式文档集成的全流程代码规范、注释充分并包含多项目解耦设计如BLL、Service、SqlSugarDB等独立模块便于理解企业级分层架构与可维护性实践。1. 为什么一个“增删改查”要动用 .NET6 SQL Server JWT——这不是练手是交付底线你可能刚在教程里跑通了第一个GET /api/values但真拿到需求文档时会发现用户登录必须带权限校验、管理员能删数据而普通用户只能查、密码不能明文存、接口调用要带有效期、前端刷新页面后 token 还得续期……这些不是“锦上添花”而是上线前甲方/导师/测试同事盯着问的硬性门槛。这套方案不讲“Hello World”它直接按企业级 Web API 的交付标准来搭用 .NET6 做底层骨架性能稳、LTS 支持长、泛型控制器写起来不啰嗦SQL Server 做数据底座事务强、索引可控、SSMS 调试直观JWT 做身份链路无状态、可扩展、和前端 Cookie Authorization 头配合成熟。它适合三类人正在赶毕设答辩的本科生3 天能跑通写清设计文档、做课设要交完整工程包的研究生含数据库脚本、Postman 集合、Swagger 文档、或刚接手遗留系统需要快速补全认证模块的初级工程师。别再用InMemoryDatabase模拟数据交差了——这份方案里每张表都有主键约束每个 token 都带exp和role声明每次删除都走软删逻辑所有代码都能进 Git 仓库当模板用。2. 总体架构设计分层清晰的五层结构与 JWT 全链路嵌入点这套方案不是把Controller-Service-Repository拉通就完事。我实际带过两个类似项目发现学生最容易在“认证该放哪层”上翻车有人把 JWT 解析塞进 Controller 里硬写结果每个 Action 都重复校验也有人试图在 Repository 层加权限过滤导致数据访问逻辑和业务规则混在一起。我们采用更可持续的五层结构API 层 → 认证授权中间件 → Controller → Application Service → Data Access含 Unit of Work。关键区别在于JWT 的解析、验证、用户上下文注入全部由自定义中间件完成Controller 只管业务路由Service 层通过IHttpContextAccessor获取当前用户 ID 和角色Repository 层完全无感——这样既符合关注点分离又方便后续替换成 OAuth2 或 IdentityServer。2.1 数据库设计从 ER 图到可执行 SQL 脚本的落地细节数据库不是画完图就结束。我们用 SQL Server Management StudioSSMS反向生成的建表语句为基础做了三项关键增强所有业务表统一加IsDeleted BIT NOT NULL DEFAULT 0和CreatedAt DATETIME2 NOT NULL DEFAULT GETUTCDATE()为软删和审计留底用户表Users中PasswordHash字段设为NVARCHAR(256)预留 PBKDF2 或 Argon2 哈希空间.NET6 默认用Rfc2898DeriveBytes盐值长度 16 字节迭代次数 100000角色表Roles与用户表通过UserRoles关联表实现多对多且UserRoles主键为复合主键(UserId, RoleId)避免重复绑定。以下是核心用户表的可执行建表语句SQL Server 2019CREATE TABLE [dbo].[Users] ( [Id] INT IDENTITY(1,1) PRIMARY KEY, [Username] NVARCHAR(50) NOT NULL UNIQUE, [Email] NVARCHAR(255) NOT NULL UNIQUE, [PasswordHash] NVARCHAR(256) NOT NULL, [Salt] NVARCHAR(64) NOT NULL, [IsDeleted] BIT NOT NULL DEFAULT 0, [CreatedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(), [UpdatedAt] DATETIME2 NULL ); GO CREATE TABLE [dbo].[Roles] ( [Id] INT IDENTITY(1,1) PRIMARY KEY, [Name] NVARCHAR(50) NOT NULL UNIQUE -- e.g., Admin, User ); GO CREATE TABLE [dbo].[UserRoles] ( [UserId] INT NOT NULL, [RoleId] INT NOT NULL, PRIMARY KEY ([UserId], [RoleId]), FOREIGN KEY ([UserId]) REFERENCES [dbo].[Users]([Id]) ON DELETE CASCADE, FOREIGN KEY ([RoleId]) REFERENCES [dbo].[Roles]([Id]) ); GO提示GETUTCDATE()比GETDATE()更可靠——避免服务器时区配置差异导致时间戳错乱。所有DateTime类型字段在 C# 实体中均声明为DateTime?或DateTime并启用 EF Core 的HasDefaultValueSql(GETUTCDATE())映射确保数据库和 ORM 行为一致。2.2 JWT 中间件设计从 Token 解析到 HttpContext 注入的完整闭环JWT 不是加个[Authorize]就完事。我们自定义JwtAuthenticationMiddleware在管道早期介入完成三件事提取 Bearer Token → 验证签名与有效期 → 构建ClaimsPrincipal并注入HttpContext.User。关键点在于验证失败不抛异常而是设置HttpContext.Response.StatusCode 401并写入 JSON 错误体避免全局异常过滤器干扰调试。public class JwtAuthenticationMiddleware { private readonly RequestDelegate _next; private readonly IConfiguration _configuration; public JwtAuthenticationMiddleware(RequestDelegate next, IConfiguration configuration) { _next next; _configuration configuration; } public async Task InvokeAsync(HttpContext context) { var token context.Request.Headers[Authorization] .FirstOrDefault()?.Split( ).Skip(1).FirstOrDefault(); if (string.IsNullOrEmpty(token)) { await _next(context); return; } var key _configuration[Jwt:Key]; var issuer _configuration[Jwt:Issuer]; var audience _configuration[Jwt:Audience]; var tokenHandler new JwtSecurityTokenHandler(); try { var validationParameters new TokenValidationParameters { ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), ValidateIssuer true, ValidIssuer issuer, ValidateAudience true, ValidAudience audience, ClockSkew TimeSpan.Zero // 关键禁用默认5分钟宽容避免时间不同步导致验证飘移 }; var principal tokenHandler.ValidateToken(token, validationParameters, out SecurityToken validatedToken); context.User principal; } catch (SecurityTokenExpiredException) { context.Response.StatusCode StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(new { error Token expired }); return; } catch (Exception) { context.Response.StatusCode StatusCodes.Status401Unauthorized; await context.Response.WriteAsJsonAsync(new { error Invalid token }); return; } await _next(context); } }这段中间件必须注册在Program.cs的app.UseRouting()之后、app.UseAuthorization()之前。注意ClockSkew TimeSpan.Zero——这是血泪经验某次部署到 Docker 容器时宿主机和容器时间差 3 分钟导致大量 token 被误判过期。关掉宽容后问题立刻暴露也倒逼我们统一用 NTP 同步所有环境时间。2.3 接口权限控制基于角色的细粒度策略注册与 Controller 应用.NET6的策略授权Policy-based Authorization比[Authorize(RolesAdmin)]更灵活、更易测试。我们定义三个策略RequireAdminRole仅 Admin、RequireUserRoleUser 或 Admin、RequireSameUserOrAdmin操作自己数据或管理员。策略在Program.cs中集中注册builder.Services.AddAuthorization(options { options.AddPolicy(RequireAdminRole, policy policy.RequireRole(Admin)); options.AddPolicy(RequireUserRole, policy policy.RequireRole(User, Admin)); options.AddPolicy(RequireSameUserOrAdmin, policy policy.Requirements.Add(new SameUserOrAdminRequirement())); }); // 自定义 Requirement 和 Handler用于 /api/users/{id} PUT/DELETE builder.Services.AddSingletonIAuthorizationHandler, SameUserOrAdminHandler();SameUserOrAdminHandler的核心逻辑是从路由参数取id从User.FindFirstValue(ClaimTypes.NameIdentifier)取当前用户 ID若相等或当前用户是 Admin则context.Succeed(requirement)。Controller 中直接使用[HttpPut({id})] [Authorize(Policy RequireSameUserOrAdmin)] public async TaskActionResultUserDto UpdateUser(int id, UserUpdateDto dto) { // dto.Id 已被路由绑定无需再从 body 解析 var result await _userService.UpdateAsync(id, dto); return Ok(result); }这种写法让权限逻辑和业务逻辑彻底解耦单元测试时只需 MockIAuthorizationService即可覆盖所有分支。3. 核心模块实现用户管理、JWT 签发、软删与分页查询的实战代码这一章不贴“能跑就行”的代码只给生产可用的片段每个方法都带事务边界、空值防护、领域异常映射且所有 DTO 与 Entity 严格分离。比如用户注册绝不是User user new User { ... }; _context.Users.Add(user);了事——密码必须哈希、用户名邮箱需唯一性校验、注册成功后必须签发 token 并返回精简信息。3.1 用户注册与登录密码哈希、唯一性校验与 Token 签发三步闭环注册接口/api/auth/register的核心是RegisterAsync方法。它不直接操作DbContext而是通过IUserService封装所有业务规则public async Task(bool Success, string Error) RegisterAsync(UserRegisterDto dto) { // 1. 基础校验非空、邮箱格式 if (string.IsNullOrWhiteSpace(dto.Username) || string.IsNullOrWhiteSpace(dto.Email) || !IsValidEmail(dto.Email)) return (false, Invalid username or email); // 2. 唯一性检查数据库层面防并发插入 if (await _context.Users.AnyAsync(u u.Username dto.Username || u.Email dto.Email)) return (false, Username or email already exists); // 3. 密码哈希使用 .NET6 内置 Rfc2898DeriveBytes var salt Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); var hash Convert.ToBase64String(KeyDerivation.Pbkdf2( password: dto.Password, salt: Convert.FromBase64String(salt), prf: KeyDerivationPrf.HMACSHA256, iterationCount: 100000, numBytesRequested: 256 / 8)); var user new User { Username dto.Username, Email dto.Email, PasswordHash hash, Salt salt, CreatedAt DateTime.UtcNow }; // 4. 插入并分配默认角色User await _context.Users.AddAsync(user); await _context.SaveChangesAsync(); var roleId await _context.Roles .Where(r r.Name User) .Select(r r.Id) .FirstOrDefaultAsync(); if (roleId ! 0) { await _context.UserRoles.AddAsync(new UserRole { UserId user.Id, RoleId roleId }); await _context.SaveChangesAsync(); } return (true, null); }登录接口/api/auth/login则负责比对哈希、签发 JWTpublic async Task(string Token, string Error) LoginAsync(UserLoginDto dto) { var user await _context.Users .Include(u u.UserRoles) .ThenInclude(ur ur.Role) .FirstOrDefaultAsync(u u.Email dto.Email u.IsDeleted false); if (user null || !VerifyPassword(dto.Password, user.PasswordHash, user.Salt)) return (null, Invalid credentials); // 构建 Claims含角色 var claims new ListClaim { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.Email, user.Email) }; foreach (var userRole in user.UserRoles) { claims.Add(new Claim(ClaimTypes.Role, userRole.Role.Name)); } var key new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration[Jwt:Key])); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: _configuration[Jwt:Issuer], audience: _configuration[Jwt:Audience], claims: claims, expires: DateTime.UtcNow.AddMinutes(30), // 生产环境建议 15~30 分钟 signingCredentials: creds); return (new JwtSecurityTokenHandler().WriteToken(token), null); }注意VerifyPassword方法必须严格复现注册时的哈希逻辑包括iterationCount100000和numBytesRequested32256/8。少一个参数token 就永远验不过。3.2 软删与恢复Delete/Restore 接口的事务一致性保障软删不是UPDATE SET IsDeleted1就完事。它必须满足1关联数据不级联删除如订单仍指向已软删用户2查询默认过滤IsDeleted03恢复操作需原子性。我们在UserRepository中封装SoftDeleteAsyncpublic async Taskbool SoftDeleteAsync(int id) { var user await _context.Users.FindAsync(id); if (user null || user.IsDeleted) return false; // 使用 ExecuteSqlRaw 执行原子更新避免先查后更引发并发问题 var rows await _context.Database.ExecuteSqlRawAsync( UPDATE Users SET IsDeleted 1, UpdatedAt GETUTCDATE() WHERE Id {0} AND IsDeleted 0, id); return rows 0; } public async Taskbool RestoreAsync(int id) { var rows await _context.Database.ExecuteSqlRawAsync( UPDATE Users SET IsDeleted 0, UpdatedAt GETUTCDATE() WHERE Id {0} AND IsDeleted 1, id); return rows 0; }Controller 中调用时包裹事务[HttpDelete({id})] [Authorize(Policy RequireAdminRole)] public async TaskActionResult DeleteUser(int id) { using var transaction await _context.Database.BeginTransactionAsync(); try { var success await _userRepository.SoftDeleteAsync(id); if (!success) { return NotFound(User not found or already deleted); } await transaction.CommitAsync(); return NoContent(); } catch { await transaction.RollbackAsync(); throw; } }这样即使SoftDeleteAsync内部有其他 DB 操作也能保证整体回滚。3.3 分页查询基于 Skip/Take 的高效实现与前端适配规范分页不是OrderBy(xx.Id).Skip((page-1)*size).Take(size)就完事。它必须1强制要求OrderBy否则 SQL Server 报错2返回总条数供前端渲染页码3避免OFFSET FETCH在大数据量下的性能衰减。我们封装PagedListTpublic class PagedListT { public ListT Items { get; set; } public int PageNumber { get; set; } public int PageSize { get; set; } public int TotalCount { get; set; } public int TotalPages (int)Math.Ceiling(TotalCount / (double)PageSize); } public static class QueryableExtensions { public static async TaskPagedListT ToPagedListAsyncT( this IQueryableT source, int pageNumber, int pageSize) { var totalCount await source.CountAsync(); var items await source .Skip((pageNumber - 1) * pageSize) .Take(pageSize) .ToListAsync(); return new PagedListT { Items items, PageNumber pageNumber, PageSize pageSize, TotalCount totalCount }; } }Controller 中使用[HttpGet] [Authorize(Policy RequireUserRole)] public async TaskActionResultPagedListUserDto GetUsers( [FromQuery] int page 1, [FromQuery] int pageSize 10) { var users _context.Users .Where(u u.IsDeleted false) .Select(u new UserDto { Id u.Id, Username u.Username, Email u.Email, CreatedAt u.CreatedAt }); var paged await users.OrderBy(u u.Id).ToPagedListAsync(page, pageSize); return Ok(paged); }提示OrderBy(u u.Id)是必须的。如果业务需要按创建时间排序应建IX_Users_CreatedAt索引。前端调用时page和pageSize必须作为 Query 参数传入响应体中TotalPages字段让前端知道是否还有下一页。4. 避坑指南.NET6 SQL Server JWT 组合下最常翻车的 5 个点这 5 条全是真实项目里踩过的坑不是理论推测。每一条都对应一次线上故障或答辩被问住的瞬间。4.1 现象JWT 在 Postman 测试正常但前端 Axios 请求 401原因前端未在请求头设置Authorization: Bearer token或设置了但 token 字符串前后有空格常见于localStorage.getItem(token)取出后未trim()。更隐蔽的是Chrome 扩展如某些广告拦截器会自动删除Authorization头。解决在JwtAuthenticationMiddleware的InvokeAsync开头加日志Console.WriteLine($Auth header: {context.Request.Headers[Authorization]});前端 Axios 请求拦截器中强制config.headers.Authorization Bearer token.trim();服务端中间件捕获空 token 后返回明确错误error: Missing or malformed Authorization header而非笼统的Invalid token。4.2 现象SQL Server 连接字符串在本地开发机正常发布到 IIS 后报“登录失败”原因连接字符串中用了Integrated SecuritytrueWindows 身份验证但 IIS 应用池默认运行在ApplicationPoolIdentity账户下该账户无权访问 SQL Server。解决生产环境必须改用 SQL Server 账户认证。在 SQL Server 中新建登录名如webapi_user赋予db_datareader和db_datawriter角色连接字符串改为Serverlocalhost;DatabaseMyDb;User Idwebapi_user;PasswordStrongPass123!;并在Program.cs中用builder.Configuration.GetConnectionString(DefaultConnection)读取禁止硬编码。4.3 现象用户注册时密码哈希值存入数据库后登录永远失败原因注册时用KeyDerivation.Pbkdf2生成哈希但登录时用PasswordHasherTASP.NET Core Identity 默认验证两者算法不兼容或注册时iterationCount设为 1000登录时却用 100000 验证。解决全程只用KeyDerivation.Pbkdf2。注册和登录的iterationCount、numBytesRequested、prf必须完全一致salt存为 Base64 字符串Convert.ToBase64String()验证时用Convert.FromBase64String()还原写单元测试用固定 salt 和 password 生成哈希断言两次哈希值相等。4.4 现象EF Core 迁移命令dotnet ef migrations add Init报错 “The term dotnet is not recognized”原因目标机器未安装 .NET6 SDK只有 Runtime或 PATH 环境变量未包含 SDK 路径或项目文件.csproj中TargetFramework写成了net6.0-windows带平台标识而迁移工具要求纯net6.0。解决在目标机器运行dotnet --list-sdks确认 SDK 已安装检查.csproj文件确保TargetFrameworknet6.0/TargetFramework若用 Visual Studio右键项目 → “编辑项目文件”手动修正迁移前先执行dotnet restore。4.5 现象Swagger UI 中点击 Authorize 按钮输入 Bearer Token 后所有接口请求头仍无Authorization原因Swagger 配置中未正确设置AddSecurityDefinition和AddSecurityRequirement或UseSwaggerUI未启用Oauth2RedirectUrl。解决在Program.cs中添加builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c { c.SwaggerDoc(v1, new OpenApiInfo { Title My API, Version v1 }); c.AddSecurityDefinition(Bearer, new OpenApiSecurityScheme { Description JWT Authorization header using the Bearer scheme., Name Authorization, In ParameterLocation.Header, Type SecuritySchemeType.ApiKey, Scheme Bearer }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference new OpenApiReference { Type ReferenceType.SecurityScheme, Id Bearer } }, new string[] {} } }); });然后在app.UseSwaggerUI中指定OAuthClientId和OAuthClientSecret即使不用 OAuth也要填占位符app.UseSwaggerUI(c { c.SwaggerEndpoint(/swagger/v1/swagger.json, My API V1); c.OAuthClientId(swagger); c.OAuthClientSecret(swagger-secret); });5. 验收与答辩用三张表、一个 Postman 集合、一份 Swagger 文档搞定交付验收不是“能跑就行”而是让导师/甲方/测试同学能独立验证每一个需求点。我带过的项目里最常被卡住的不是代码而是“你说你实现了我怎么信”。所以这套方案的交付物必须自带验证路径——不需要你解释对方点几下就能确认功能完整、逻辑正确、安全合规。5.1 功能验收清单按角色拆解的 12 项可验证动作这张表是答辩时的“免答金牌”。打印出来挨个打钩导师问到哪一项你就指哪一项“您看第 7 条管理员删除用户我们已验证软删标记生效、关联角色保留、接口返回 204”。表格按角色组织覆盖所有核心路径序号角色操作预期结果验证方式1游客GET /api/users返回 401 UnauthorizedPostman 直接请求2普通用户POST /api/auth/login成功返回 JWT token含role: UserPostman 查看响应体3普通用户GET /api/users返回分页用户列表不含已软删用户Postman 查看 Items 数量4普通用户GET /api/users/1若 id1 是自己返回 200若 id2 是他人返回 403 ForbiddenPostman 修改 URL 和 token5普通用户DELETE /api/users/1返回 403 Forbidden无权限Postman 发送 DELETE 请求6管理员POST /api/auth/login成功返回 JWT token含role: AdminPostman 查看响应体7管理员DELETE /api/users/2返回 204 No Content数据库中IsDeleted1SSMS 查询 Users 表8管理员GET /api/users?page1pageSize5返回TotalPages正确计算如总 12 条应为 3 页Postman 查看响应体 TotalPages9管理员POST /api/auth/register新用户插入成功PasswordHash非空Salt非空SSMS 查询 Users 表10所有用户POST /api/auth/login错密码返回 401错误体为{error:Invalid credentials}Postman 查看响应体11所有用户GET /api/users带过期 token返回 401错误体为{error:Token expired}Postman 修改 token exp 时间12所有用户GET /swagger/index.htmlSwagger UI 正常加载Authorize 按钮可用接口文档完整浏览器打开这张表不是摆设。我在某高校毕设答辩现场导师随机抽了第 4、7、11 项A同学当场用自己笔记本打开 Postman 和 SSMS 演示5 分钟内全部通过导师直接说“这部分没问题了”。5.2 Postman 集合预置 15 个请求覆盖全链路不要让验收者自己拼 URL、填 Header、构造 Body。我们提供一个完整的 Postman CollectionJSON 文件导入即用。集合结构如下Folder: AuthPOST /api/auth/register预置测试账号{ username:testuser, email:testexample.com, password:Pssw0rd123 }POST /api/auth/login自动提取响应中的 token存为环境变量{{token}}Folder: Users (User)GET /api/users自动在 Header 加Authorization: Bearer {{token}}GET /api/users/1用环境变量{{currentUserId}}替换Folder: Users (Admin)DELETE /api/users/2用管理员 tokenGET /api/users?page1pageSize10验证分页Folder: Edge CasesGET /api/users无 token→ 验证 401GET /api/users/999不存在 ID→ 验证 404POST /api/auth/login错密码→ 验证错误体提示Collection 中所有请求都启用了Tests脚本例如登录请求的 Tests 会自动提取 token 并设置环境变量const response pm.response.json(); if (response.token) { pm.environment.set(token, response.token); }5.3 Swagger 文档不只是接口说明更是安全契约Swagger 不是装饰品。我们强制开启 XML 注释并在Program.cs中注入builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c { var xmlFile ${Assembly.GetExecutingAssembly().GetName().Name}.xml; var xmlPath Path.Combine(AppContext.BaseDirectory, xmlFile); if (File.Exists(xmlPath)) { c.IncludeXmlComments(xmlPath); } // ... 其他配置含 JWT 安全定义 });然后在 Controller 方法上写三行注释/// summary /// 获取用户分页列表仅登录用户可访问 /// /summary /// param namepage页码默认为 1/param /// param namepageSize每页数量默认为 10/param /// returns分页用户列表/returns [HttpGet] [Authorize(Policy RequireUserRole)] public async TaskActionResultPagedListUserDto GetUsers(...)生成的 Swagger 页面中每个接口的Description、Parameters、Responses都清晰可见且Authorize按钮会自动出现在所有受保护接口旁。甲方/导师点开就能看到“哦这个接口需要登录且返回的是分页结构总条数在TotalCount字段里”。从那以后我每次交付 API 项目都强制走一遍这三步1对照验收表打钩2用 Postman 集合跑通所有链路3打开 Swagger 确认文档与代码零偏差。不是为了应付而是因为——当你的代码能被任何人独立验证时你才真正掌控了它。希望帮到你。本文还有配套的精品资源点击获取