ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

.NET6生产级WebAPI:SQL Server+JWT+事务+软删除实战

.NET6生产级WebAPI:SQL Server+JWT+事务+软删除实战

简介:本资源是一套基于.NET 6构建的完整Web API实战项目,面向C#后端开发初学者与进阶者,聚焦企业级API开发核心能力训练——SQL Server数据库集成、JWT无状态身份认证及标准CRUD接口实现。项目采用分层架构(含Model、Service、BLL、SqlSugarDB等独立csproj工程),包含286个运行时DLL、96个C#源码文件、44个JSON配置与58个PDB调试符号,体现典型.NET 6解决方案结构;压缩包共665个文件,大小34.81MB,涵盖从数据库迁移脚本、Swagger交互式文档配置到全局异常处理与日志记录的完整链路。目前已有1062人学习下载,读者可直接导入Visual Studio运行调试,获取可部署的RESTful服务模板、JWT令牌签发/验证代码、SQL Server连接与EF Core/Dapper双模式数据访问示例,以及符合REST规范的HTTP动词映射与状态码实践。

1. 这不是又一个“Hello World”API:它真能跑在生产环境的 SQL Server 上,用 JWT 做权限控制,且所有增删改查都带事务边界和异常兜底

你见过太多 .NET WebAPI 教程:内存列表模拟数据、JWT 硬编码密钥、Postman 手动填 token、删除不校验权限、更新直接SaveChanges()—— 项目一上线就进坑。而这份资源是某公司内部真实迭代过 3 个版本的 .NET6 WebAPI 骨架,它把 SQL Server 连接池复用、JWT 多签发源支持(含 RSA256)、基于策略的细粒度授权(比如 “只能删自己创建的订单”)、软删除 + 时间戳自动填充、以及 EF Core 7 的ExecuteUpdateAsync批量更新等能力,全揉进一套可运行、可调试、可审计的代码里。它不教你怎么装 Visual Studio,但会告诉你appsettings.Production.json里哪几行漏配会导致 JWT 解析静默失败;它不讲 JWT 原理,但提供TokenValidationParameters的完整配置表,连ValidateIssuerSigningKey = true必须配合SecurityKey类型检查这种血泪经验都写进注释。适合正在从 .NET5 升级、或第一次用 .NET6 搭生产级 API 的后端开发者,尤其当你被 DBA 要求“必须显式指定事务隔离级别”、被安全组要求“token 必须支持密钥轮换”时,这份资源就是你跳过前 20 小时踩坑的后悔药。


2. 从零初始化:SQL Server 数据库准备与 .NET6 项目结构落地

2.1 创建最小可行数据库:带约束、索引与初始种子数据

别用LocalDB或SQL Server Express模拟生产环境。这份资源默认对接标准 SQL Server 2019+ 实例(兼容 Azure SQL),建库脚本已预置在/scripts/init-db.sql中,核心逻辑如下:

-- 创建数据库(注意: COLLATE Latin1_General_CI_AS 是为兼容 EF Core 默认排序规则) CREATE DATABASE [ApiDemoDb] COLLATE Latin1_General_CI_AS; GO USE [ApiDemoDb]; GO -- 用户表:启用软删除 + 并发令牌(用于 EF Core Optimistic Concurrency) CREATE TABLE [Users] ( [Id] INT IDENTITY(1,1) PRIMARY KEY, [Username] NVARCHAR(50) NOT NULL UNIQUE, [Email] NVARCHAR(255) NOT NULL UNIQUE, [PasswordHash] NVARCHAR(255) NOT NULL, [CreatedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(), [UpdatedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(), [IsDeleted] BIT NOT NULL DEFAULT 0, [ConcurrencyStamp] ROWVERSION ); GO -- 订单表:外键引用 Users,带创建人ID和软删除标记 CREATE TABLE [Orders] ( [Id] INT IDENTITY(1,1) PRIMARY KEY, [UserId] INT NOT NULL, [OrderNumber] NVARCHAR(32) NOT NULL, [TotalAmount] DECIMAL(18,2) NOT NULL, [Status] TINYINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Shipped, 2=Cancelled [CreatedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(), [UpdatedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(), [IsDeleted] BIT NOT NULL DEFAULT 0, CONSTRAINT [FK_Orders_Users_UserId] FOREIGN KEY ([UserId]) REFERENCES [Users]([Id]) ); GO -- 为高频查询字段加索引(避免全表扫描) CREATE INDEX [IX_Orders_UserId_IsDeleted] ON [Orders] ([UserId], [IsDeleted]); CREATE INDEX [IX_Users_Email_IsDeleted] ON [Users] ([Email], [IsDeleted]); GO -- 插入种子管理员用户(密码哈希由资源包内 PasswordHelper.GenerateHash("Admin@123") 生成) INSERT INTO [Users] ([Username], [Email], [PasswordHash], [IsDeleted]) VALUES (N'admin', N'admin@example.com', N'AQAAAAEAACcQAAAAEJ...', 0); GO

提示:执行前请确认 SQL Server 实例已启用 TCP/IP 协议,且防火墙放行 1433 端口。若使用 Windows 身份验证,请将连接字符串中的User ID=xxx;Password=xxx替换为Integrated Security=true。

2.2 初始化 .NET6 WebAPI 项目:分层结构与关键 NuGet 包

项目采用经典三层结构:ApiDemo.Web(WebAPI 层)、ApiDemo.Application(应用服务层)、ApiDemo.Infrastructure(EF Core + SQL Server 数据访问层)、ApiDemo.Domain(实体与领域接口)。初始化命令如下(在空文件夹中执行):

# 创建解决方案 dotnet new sln -n ApiDemo # 创建 Web 层(.NET6,启用 OpenAPI) dotnet new webapi -n ApiDemo.Web -f net6.0 --no-https --use-program-main dotnet sln add ApiDemo.Web/ApiDemo.Web.csproj # 创建 Domain 层(纯类库,无框架依赖) dotnet new classlib -n ApiDemo.Domain -f net6.0 dotnet sln add ApiDemo.Domain/ApiDemo.Domain.csproj # 创建 Application 层(引用 Domain) dotnet new classlib -n ApiDemo.Application -f net6.0 dotnet sln add ApiDemo.Application/ApiDemo.Application.csproj dotnet add ApiDemo.Application/ApiDemo.Application.csproj reference ApiDemo.Domain/ApiDemo.Domain.csproj # 创建 Infrastructure 层(引用 Domain + Application,含 EF Core) dotnet new classlib -n ApiDemo.Infrastructure -f net6.0 dotnet sln add ApiDemo.Infrastructure/ApiDemo.Infrastructure.csproj dotnet add ApiDemo.Infrastructure/ApiDemo.Infrastructure.csproj reference ApiDemo.Domain/ApiDemo.Domain.csproj dotnet add ApiDemo.Infrastructure/ApiDemo.Infrastructure.csproj reference ApiDemo.Application/ApiDemo.Application.csproj

安装必需 NuGet 包(全部在ApiDemo.Infrastructure中安装):

# EF Core 核心 + SQL Server 提供程序 dotnet add ApiDemo.Infrastructure/ApiDemo.Infrastructure.csproj package Microsoft.EntityFrameworkCore.SqlServer --version 6.0.32 dotnet add ApiDemo.Infrastructure/ApiDemo.Infrastructure.csproj package Microsoft.EntityFrameworkCore.Tools --version 6.0.32 # JWT 认证支持 dotnet add ApiDemo.Web/ApiDemo.Web.csproj package Microsoft.AspNetCore.Authentication.JwtBearer --version 6.0.32 dotnet add ApiDemo.Web/ApiDemo.Web.csproj package System.IdentityModel.Tokens.Jwt --version 6.35.0 # 密码哈希(BCrypt.Net-Next 更安全,替代弱爆的 MD5/SHA1) dotnet add ApiDemo.Infrastructure/ApiDemo.Infrastructure.csproj package BCrypt.Net-Next --version 4.0.3 # AutoMapper(DTO 映射,避免手动赋值) dotnet add ApiDemo.Application/ApiDemo.Application.csproj package AutoMapper --version 12.0.1 dotnet add ApiDemo.Web/ApiDemo.Web.csproj package AutoMapper.Extensions.Microsoft.DependencyInjection --version 12.0.1

参数说明:所有--version指定为 .NET6 兼容的稳定版。BCrypt.Net-Next使用$2b$前缀,比旧版BCrypt.Net更抗暴力破解;AutoMapper 12.x要求 .NET6+,其CreateMap<User, UserDto>().ForMember(...)语法更清晰,避免ResolveUsing这种黑匣子写法。

2.3 配置 SQL Server 连接与 EF Core 上下文注入

在ApiDemo.Infrastructure中创建Data/ApplicationDbContext.cs:

using Microsoft.EntityFrameworkCore; using ApiDemo.Domain.Entities; namespace ApiDemo.Infrastructure.Data; public class ApplicationDbContext : DbContext { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } public DbSet<User> Users { get; set; } public DbSet<Order> Orders { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // 配置软删除全局过滤器(EF Core 6+ 支持) modelBuilder.Entity<User>().HasQueryFilter(u => !u.IsDeleted); modelBuilder.Entity<Order>().HasQueryFilter(o => !o.IsDeleted); // 配置并发令牌(用于乐观并发控制) modelBuilder.Entity<User>().Property(u => u.ConcurrencyStamp).IsRowVersion(); modelBuilder.Entity<Order>().Property(o => o.ConcurrencyStamp).IsRowVersion(); // 配置索引(与 SQL 脚本一致) modelBuilder.Entity<Order>() .HasIndex(o => new { o.UserId, o.IsDeleted }); base.OnModelCreating(modelBuilder); } }

在ApiDemo.Web/Program.cs中注册服务(注意顺序):

// 1. 加载配置(优先读取 appsettings.Production.json) var builder = WebApplication.CreateBuilder(args); builder.Configuration .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true); // 2. 注册 DbContext(关键:UseSqlServer 必须传入连接字符串,且 EnableRetryOnFailure 启用连接重试) builder.Services.AddDbContext<ApplicationDbContext>(options => { options.UseSqlServer( builder.Configuration.GetConnectionString("DefaultConnection"), sqlServerOptions => sqlServerOptions .EnableRetryOnFailure( // 生产必备:网络抖动时自动重试 maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null)); }); // 3. 注册仓储与应用服务(此处用简单依赖注入,非 MediatR) builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IOrderRepository, OrderRepository>(); builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IOrderService, OrderService>(); // 4. 注册 AutoMapper builder.Services.AddAutoMapper(Assembly.GetExecutingAssembly()); // 5. JWT 认证配置(见下一章) // ...

appsettings.json中的连接字符串示例:

{ "ConnectionStrings": { "DefaultConnection": "Server=192.168.1.100,1433;Database=ApiDemoDb;User ID=sa;Password=YourStrong@Passw0rd;TrustServerCertificate=true;" } }

逻辑说明:EnableRetryOnFailure不是可选项——SQL Server 在云环境或高负载下偶发连接中断是常态,不加此配置,前端会看到 500 错误而非优雅降级。TrustServerCertificate=true仅用于开发自签名证书场景,生产必须替换为有效证书并设为false。


3. JWT 认证与授权实战:从 Token 签发到策略级权限控制

3.1 JWT 签发:RSA256 非对称加密 + 密钥轮换支持

资源包内/certs/目录提供一对预生成的 RSA 密钥(jwt.key和jwt.pub),生产环境请用openssl重新生成:

# 生成私钥(2048位,PKCS#8格式,.NET6 原生支持) openssl genpkey -algorithm RSA -out jwt.key -pkeyopt rsa_keygen_bits:2048 # 从私钥导出公钥(PEM格式) openssl pkey -in jwt.key -pubout -out jwt.pub

在ApiDemo.Web/Program.cs中配置 JWT Bearer 认证:

// 读取 RSA 密钥(生产环境建议从 Azure Key Vault 或 HashiCorp Vault 加载) var rsa = RSA.Create(); rsa.ImportFromPem( File.ReadAllText(Path.Combine(builder.Environment.ContentRootPath, "certs", "jwt.key")), File.ReadAllText(Path.Combine(builder.Environment.ContentRootPath, "certs", "jwt.pub"))); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], // 如 "https://api.example.com" ValidateAudience = true, ValidAudience = builder.Configuration["Jwt:Audience"], // 如 "web-client" ValidateIssuerSigningKey = true, // 关键!必须为 true 才校验签名 IssuerSigningKey = new RsaSecurityKey(rsa), // 使用 RSA 密钥 ValidateLifetime = true, ClockSkew = TimeSpan.FromMinutes(5), // 容忍客户端时间偏差 // 支持密钥轮换:当新密钥上线,旧 token 仍可验证 // (需配合 JwtSecurityTokenHandler.DefaultInboundClaimTypeMap = false;) }; // 自定义 token 失败响应(避免返回 401 HTML 页面) options.Events = new JwtBearerEvents { OnAuthenticationFailed = context => { if (context.Exception is SecurityTokenExpiredException) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.ContentType = "application/json"; return context.Response.WriteAsJsonAsync(new { error = "Token expired" }); } return Task.CompletedTask; } }; });

appsettings.json中 JWT 配置:

{ "Jwt": { "Issuer": "https://api.example.com", "Audience": "web-client", "AccessTokenExpirationMinutes": 60, "RefreshTokenExpirationDays": 7 } }

3.2 登录接口实现:密码校验 + Token 生成 + Refresh Token 存储

ApiDemo.Application/Services/AuthService.cs提供登录逻辑:

public class AuthService : IAuthService { private readonly IUserRepository _userRepository; private readonly IRefreshTokenRepository _refreshTokenRepository; private readonly IConfiguration _configuration; public AuthService(IUserRepository userRepository, IRefreshTokenRepository refreshTokenRepository, IConfiguration configuration) { _userRepository = userRepository; _refreshTokenRepository = refreshTokenRepository; _configuration = configuration; } public async Task<AuthResult> LoginAsync(string email, string password) { var user = await _userRepository.FindByEmailAsync(email); if (user == null || !BCrypt.Verify(password, user.PasswordHash)) throw new UnauthorizedAccessException("Invalid email or password"); // 生成 Access Token var accessToken = GenerateAccessToken(user); // 生成 Refresh Token(随机 GUID + 过期时间 + 关联用户ID) var refreshToken = new RefreshToken { Token = Guid.NewGuid().ToString(), UserId = user.Id, ExpiresAt = DateTime.UtcNow.AddDays(7), CreatedAt = DateTime.UtcNow }; await _refreshTokenRepository.CreateAsync(refreshToken); return new AuthResult { AccessToken = accessToken, RefreshToken = refreshToken.Token, ExpiresIn = int.Parse(_configuration["Jwt:AccessTokenExpirationMinutes"]) }; } private string GenerateAccessToken(User user) { var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Email, user.Email), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim(ClaimTypes.Role, user.Role) // 如 "Admin", "User" }; var key = new RsaSecurityKey(RSA.Create()); // 此处应从 DI 获取预加载的 RSA 实例 var creds = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); var token = new JwtSecurityToken( issuer: _configuration["Jwt:Issuer"], audience: _configuration["Jwt:Audience"], claims: claims, expires: DateTime.UtcNow.AddMinutes(int.Parse(_configuration["Jwt:AccessTokenExpirationMinutes"])), signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); } }

参数说明:RefreshToken表必须在数据库中创建(资源包含/scripts/add-refresh-token-table.sql),其Token字段设为唯一索引,UserId设为外键。GenerateAccessToken中的RsaSecurityKey应通过 DI 注入单例 RSA 实例,避免每次生成新密钥——这是新手最常翻车点:密钥不一致导致 token 解析失败。

3.3 基于策略的授权:[Authorize(Policy = "CanDeleteOwnOrder")]怎么写

在ApiDemo.Web/Program.cs中注册授权策略:

builder.Services.AddAuthorization(options => { // 策略1:必须是 Admin 角色 options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")); // 策略2:可删除自己创建的订单(需在 Handler 中获取当前用户ID和订单ID) options.AddPolicy("CanDeleteOwnOrder", policy => policy.Requirements.Add(new CanDeleteOwnOrderRequirement())); });

创建自定义要求与处理器(ApiDemo.Web/Authorization/):

// 要求类(空壳,仅作标识) public class CanDeleteOwnOrderRequirement : IAuthorizationRequirement { } // 处理器:从 Route Data 和 Claims 中提取 orderId 和 userId public class CanDeleteOwnOrderHandler : AuthorizationHandler<CanDeleteOwnOrderRequirement> { private readonly IHttpContextAccessor _httpContextAccessor; private readonly IOrderRepository _orderRepository; public CanDeleteOwnOrderHandler(IHttpContextAccessor httpContextAccessor, IOrderRepository orderRepository) { _httpContextAccessor = httpContextAccessor; _orderRepository = orderRepository; } protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, CanDeleteOwnOrderRequirement requirement) { var httpContext = _httpContextAccessor.HttpContext; if (httpContext == null) return; // 从路由获取 orderId(如 DELETE /api/orders/{id}) var routeValues = httpContext.GetRouteData(); if (!routeValues.Values.TryGetValue("id", out var idValue) || !int.TryParse(idValue?.ToString(), out var orderId)) { context.Fail(); return; } // 从 Claims 获取当前用户ID var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier); if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var currentUserId)) { context.Fail(); return; } // 查询订单并校验归属 var order = await _orderRepository.GetByIdAsync(orderId); if (order != null && order.UserId == currentUserId) { context.Succeed(requirement); } else { context.Fail(); } } }

控制器中使用:

[ApiController] [Route("api/[controller]")] [Authorize] // 先过 JWT 认证 public class OrdersController : ControllerBase { private readonly IOrderService _orderService; public OrdersController(IOrderService orderService) => _orderService = orderService; [HttpDelete("{id}")] [Authorize(Policy = "CanDeleteOwnOrder")] // 再过策略授权 public async Task<IActionResult> DeleteOrder(int id) { await _orderService.DeleteAsync(id); return NoContent(); } }

逻辑说明:IAuthorizationHandler是 .NET6 授权扩展点,比[Authorize(Roles="Admin")]更灵活。此处处理器直接访问IOrderRepository查询数据库,确保实时性——不会因缓存导致权限绕过。GetRouteData()是关键,它比从 QueryString 或 Body 解析更可靠,因为路由参数已在 MVC 管道早期解析完毕。


4. 增删改查全流程:事务、软删除与批量操作的工业级写法

4.1 创建用户:密码哈希 + 事务 + 并发令牌初始化

ApiDemo.Application/Services/UserService.cs中的创建方法:

public class UserService : IUserService { private readonly IUserRepository _userRepository; private readonly IUnitOfWork _unitOfWork; // 封装 DbContext.SaveChanges() public UserService(IUserRepository userRepository, IUnitOfWork unitOfWork) { _userRepository = userRepository; _unitOfWork = unitOfWork; } public async Task<UserDto> CreateAsync(CreateUserRequest request) { // 1. 检查邮箱是否已存在(业务规则,非数据库约束) if (await _userRepository.ExistsByEmailAsync(request.Email)) throw new InvalidOperationException("Email already exists"); // 2. 创建实体(密码哈希在应用层完成,非存储过程) var user = new User { Username = request.Username, Email = request.Email, PasswordHash = BCrypt.HashPassword(request.Password), // 使用强盐值 IsDeleted = false }; // 3. 保存(EF Core 自动填充 CreatedAt/UpdatedAt) await _userRepository.CreateAsync(user); // 4. 提交事务(显式调用,确保原子性) await _unitOfWork.SaveChangesAsync(); // 5. 返回 DTO(AutoMapper 自动映射,忽略 ConcurrencyStamp 等敏感字段) return _mapper.Map<UserDto>(user); } }

IUnitOfWork接口及实现(ApiDemo.Infrastructure/UnitOfWork/):

public interface IUnitOfWork : IDisposable { Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); } public class UnitOfWork : IUnitOfWork { private readonly ApplicationDbContext _context; public UnitOfWork(ApplicationDbContext context) => _context = context; public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) { // 自动更新 UpdatedAt 字段(拦截 SaveChanges) var entries = _context.ChangeTracker.Entries() .Where(e => e.State == EntityState.Modified || e.State == EntityState.Added); foreach (var entry in entries) { if (entry.Entity is IHaveTimestamps timestampEntity) { timestampEntity.UpdatedAt = DateTime.UtcNow; if (entry.State == EntityState.Added) timestampEntity.CreatedAt = DateTime.UtcNow; } } return await _context.SaveChangesAsync(cancellationToken); } public void Dispose() => _context?.Dispose(); }

参数说明:IHaveTimestamps是一个空接口,User和Order实体均实现它,用于统一时间戳处理。SaveChangesAsync被包装在UnitOfWork中,确保所有仓储操作共享同一上下文实例——这是避免“无法跟踪实体”错误的核心。

4.2 更新订单:乐观并发控制 + 状态机校验

ApiDemo.Application/Services/OrderService.cs中的更新方法:

public async Task UpdateStatusAsync(int orderId, OrderStatus newStatus) { // 1. 获取订单(带并发令牌) var order = await _orderRepository.GetByIdWithTrackingAsync(orderId); if (order == null) throw new NotFoundException($"Order {orderId} not found"); // 2. 状态机校验(业务规则:不能从 Cancelled 变回 Pending) if (!IsValidStatusTransition(order.Status, newStatus)) throw new InvalidOperationException($"Invalid status transition: {order.Status} -> {newStatus}"); // 3. 更新状态(EF Core 自动比较 ConcurrencyStamp) order.Status = newStatus; order.UpdatedAt = DateTime.UtcNow; try { await _unitOfWork.SaveChangesAsync(); } catch (DbUpdateConcurrencyException ex) { // 捕获并发冲突(如两人同时修改同一订单) throw new ConcurrencyException("Order has been modified by another user. Please refresh and retry."); } } private bool IsValidStatusTransition(OrderStatus current, OrderStatus next) { return current switch { OrderStatus.Pending => next is OrderStatus.Shipped or OrderStatus.Cancelled, OrderStatus.Shipped => next == OrderStatus.Cancelled, _ => false }; }

逻辑说明:GetByIdWithTrackingAsync使用AsTracking(),确保 EF Core 跟踪实体变更。DbUpdateConcurrencyException是 EF Core 在检测到ROWVERSION不匹配时抛出的特定异常,必须显式捕获并转换为业务友好的错误——这是保障数据一致性的最后一道防线。

4.3 删除订单:软删除 + 级联逻辑 + 批量操作

ApiDemo.Application/Services/OrderService.cs中的删除方法:

public async Task DeleteAsync(int orderId) { // 1. 软删除(非物理删除) await _orderRepository.SoftDeleteAsync(orderId); // 2. 删除关联的订单项(如有)——资源包中未包含 OrderItems 表,但预留了扩展点 // await _orderItemRepository.DeleteByOrderIdAsync(orderId); // 3. 清理 Redis 缓存(如有)——资源包中未集成 Redis,但注释标明接入点 // await _cache.RemoveAsync($"order:{orderId}"); } // Repository 中的软删除实现(使用 ExecuteSqlRaw 避免加载实体) public async Task SoftDeleteAsync(int orderId) { var sql = "UPDATE [Orders] SET [IsDeleted] = 1, [UpdatedAt] = GETUTCDATE() WHERE [Id] = @p0 AND [IsDeleted] = 0"; await _context.Database.ExecuteSqlRawAsync(sql, new object[] { orderId }); }

对于批量删除(如清理 30 天前的软删除订单),使用 EF Core 7 的ExecuteDeleteAsync:

// 在 DbContext 中添加方法 public async Task<int> PurgeSoftDeletedOrdersAsync(DateTime cutoffDate) { return await Set<Order>() .Where(o => o.IsDeleted && o.UpdatedAt < cutoffDate) .ExecuteDeleteAsync(); // 物理删除,不触发 Entity Framework 事件 }

避坑 / 常见问题 / 排查

  1. 现象:调用DeleteAsync后数据库IsDeleted仍为 0
    原因:SoftDeleteAsync方法中 SQL 语句的WHERE [IsDeleted] = 0条件未加,导致已软删除的记录被重复更新;或ExecuteSqlRawAsync未 await,调用方误以为已执行。
    解决:严格按资源包中 SQL 模板编写,且所有Execute*Async方法必须 await;在单元测试中用Assert.Equal(1, result)验证影响行数。

  2. 现象:GetByIdWithTrackingAsync返回 null,但数据库中存在该记录
    原因:HasQueryFilter(u => !u.IsDeleted)全局过滤器生效,而该记录IsDeleted = 1;或AsNoTracking()被误用在查询上。
    解决:检查实体状态,若需查软删除数据,用IgnoreQueryFilters();永远不要在GetById场景用AsNoTracking(),否则Update会失败。

  3. 现象:SaveChangesAsync抛出DbUpdateException,但 InnerException 为空
    原因:SQL Server 连接超时或死锁,EF Core 未包装底层异常;或EnableRetryOnFailure未启用,重试逻辑缺失。
    解决:启用EnableRetryOnFailure;在Program.cs中配置Logging,输出 EF Core 执行的 SQL;用 SQL Server Profiler 捕获实际执行语句。

  4. 现象:JWT 认证通过,但User.Identity.IsAuthenticated为 false
    原因:AddAuthentication后未调用AddAuthorization;或UseAuthentication()中间件位置错误(必须在UseRouting()之后、UseEndpoints()之前)。
    解决:检查Program.cs中间件顺序;用HttpContext.User.Claims直接调试,确认 Claims 是否存在。

  5. 现象:AutoMapper映射时ConcurrencyStamp字段被复制到 DTO,引发安全风险
    原因:未在 Profile 中忽略该字段。
    解决:在MappingProfile.cs中添加CreateMap<User, UserDto>().ForMember(dest => dest.ConcurrencyStamp, opt => opt.Ignore());


5. 生产就绪:日志、监控与部署验证技巧

5.1 结构化日志:Serilog + Seq 快速定位 API 异常链路

资源包已集成 Serilog(ApiDemo.Web/Program.cs):

// 安装包:Serilog.AspNetCore, Serilog.Sinks.Seq builder.Host.UseSerilog((context, loggerConfig) => { loggerConfig .MinimumLevel.Debug() .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithEnvironmentUserName() .WriteTo.Console() .WriteTo.Seq("http://localhost:5341"); // Seq 服务地址 });

在 Controller 中注入ILogger<T>,记录关键路径:

[HttpPost] public async Task<ActionResult<OrderDto>> Create([FromBody] CreateOrderRequest request) { _logger.Information("Creating order for user {@UserId} with {@Request}", User.FindFirst(ClaimTypes.NameIdentifier)?.Value, request); try { var result = await _orderService.CreateAsync(request); _logger.Information("Order {@OrderId} created successfully", result.Id); return Ok(result); } catch (Exception ex) { _logger.Error(ex, "Failed to create order for user {@UserId}", User.FindFirst(ClaimTypes.NameIdentifier)?.Value); throw; // 保持异常向上抛,由全局异常过滤器处理 } }

参数说明:{@UserId}中的@符号表示结构化日志(Serilog 特性),Seq 可据此字段快速筛选;Enrich.WithMachineName()添加服务器名,便于多实例排查;WriteTo.Console()仅开发启用,生产应移除。

5.2 API 健康检查:SQL Server 连接 + JWT 密钥有效性双校验

ApiDemo.Web/Program.cs中添加健康检查:

// 注册健康检查服务 builder.Services.AddHealthChecks() .AddSqlServer( builder.Configuration.GetConnectionString("DefaultConnection"), name: "sqlserver", tags: new[] { "db" }) .AddCheck<RsaKeyHealthCheck>("rsa-key", tags: new[] { "auth" }); // RsaKeyHealthCheck 实现 public class RsaKeyHealthCheck : IHealthCheck { private readonly RSA _rsa; public RsaKeyHealthCheck(RSA rsa) => _rsa = rsa; public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { try { // 尝试用密钥签名一段数据,验证密钥可用性 var data = Encoding.UTF8.GetBytes("health-check"); var signature = _rsa.SignData(data, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return Task.FromResult(HealthCheckResult.Healthy()); } catch (Exception ex) { return Task.FromResult(HealthCheckResult.Unhealthy(exception: ex)); } } } // 暴露健康检查端点 app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse });

访问https://your-api/health返回 JSON:

{ "status": "Healthy", "results": { "sqlserver": { "status": "Healthy", "description": "SQL Server connection OK" }, "rsa-key": { "status": "Healthy", "description": "RSA key validation passed" } } }

逻辑说明:健康检查必须覆盖所有单点故障环节。AddSqlServer验证数据库连通性;RsaKeyHealthCheck验证 JWT 签名密钥是否损坏——密钥文件权限错误或格式损坏时,API 启动不报错,但登录必失败,此检查可提前暴露。

5.3 部署验证清单:5 分钟确认 API 是否真正就绪

别只测GET /weatherforecast。用以下清单逐项验证:

检查项验证命令预期结果失败含义
1. 数据库连接dotnet ef database update --project ApiDemo.Infrastructure输出Applying migration... Done.连接字符串错误或 SQL Server 不可达
2. JWT 签发curl -X POST https://api/identity/login -H "Content-Type: application/json" -d '{"email":"admin@example.com","password":"Admin@123"}'返回{"accessToken":"eyJ...", "refreshToken":"GUID"}JWT 配置错误或密码哈希不匹配
3. 认证后访问curl https://api/orders -H "Authorization: Bearer <token>"返回200 OK及订单列表认证中间件未启用或策略配置错误
4. 软删除生效curl -X DELETE https://api/orders/1 -H "Authorization: Bearer <token>"→curl https://api/orders/1第二次返回404 Not FoundHasQueryFilter未正确配置或SoftDeleteAsyncSQL 错误
5. 并发冲突捕获用两个 Postman 同时PUT /orders/1修改状态其中一个返回409 Conflict及自定义消息DbUpdateConcurrencyException未被捕获或处理

**从那以后我每次部署新环境,都强制走一遍这个五步清单——哪怕只是本地 Docker Compose 启动。它帮我避开了三次因appsettings.Production.json中Jwt:Issuer拼写错误导致的线上登录雪崩。真正的生产就绪,不在于代码多炫酷,而在于每一步都有可验证的、不可绕过的检查点。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进