简介:这是一套基于C#与.NET 8的Web API综合应用示例,面向有一定C#基础、希望掌握分层架构的中高级开发者。资源演示了如何结合SqlSugar ORM完成数据访问,并通过仓储模式、DTO转换、服务层与控制层的明确分工,构建清晰可扩展的API项目。压缩包共137个文件,大小约22.24MB,以cs源码、dll程序集、json配置及项目工程文件为主,附带编译产物和依赖库,便于直接查看或二次开发。已有1676人学习下载。通过学习可直观理解仓储接口与实现、DTO与实体映射、服务层业务封装以及控制器路由绑定等核心环节,同时涵盖SqlSugar配置、依赖注入和基础错误处理思路。适合作为中型API项目的起步模板或架构参考,能帮助开发者快速搭建具有良好分层和可维护性的服务端应用。
1. 为什么 .NET 8 的 WebAPI 项目要把 SqlSugar、仓储模式、DTO 和服务层一起用
很多刚开始做 .NET 8 WebAPI 的开发者会有个直观感受:一个控制器里直接写 SqlSugar 的 CRUD 代码,项目跑起来很快,但等到业务规则变多、表结构调整、接口要复用的时候,控制器变成几百行的"万能类",改一个查询条件可能要翻半天代码。这正是我在这套综合应用里想解决的问题:用仓储模式把数据访问隔离,用 DTO 把接口契约和数据库实体解耦,用服务层把业务规则从控制器里抽出来,再让 SqlSugar 承担真正的数据库操作。这样每个类职责单一,接口层只负责参数接收和结果返回,后续加需求、改表、换数据库都更容易。
这套方案适合的对象很明确:正在做 .NET 8 WebAPI 中小型业务系统的开发者,比如进销存、OA、后台管理系统这类场景。它不追求极致的性能调优,也不涉及微服务拆分,但能让你在一开始就把项目结构搭对方向。全文我会从项目分层讲起,带你把仓储、服务、控制器一层层写出来,再把 DTO 的映射细节和实际开发中高频踩坑点逐个说明。这篇笔记用的结构是我自己常用的标准做法,你可以直接复制到项目里改,也可以按业务需要做取舍——先想明白层与层之间的边界,再动手写代码,这是我最想传达的核心思路。
2. 搭建项目结构:.NET 8 WebAPI 项目的目录划分与依赖注入
2.1 为什么项目要分四层:从控制器直接操作数据库的问题说起
先看一个刚开始写 .NET 8 WebAPI 最常见的写法:控制器里注入 ISqlSugarClient,然后直接调用 _db.Queryable ().ToList()。这个写法在只有一个用户表的 Demo 项目里看不出问题,但一旦业务复杂度上来,控制器会同时做三件事:接收 HTTP 参数、校验参数合法性、拼 SQL 取数据。这意味着如果数据库字段改了,你需要在所有控制器里找用到这个字段的地方逐个改;如果某个查询条件在多个接口里复用,只能复制粘贴。修一个 bug 经常要动到接口返回结构,前端也要跟着调整——这就是典型的耦合。
仓储模式的核心目的就是给数据访问加一道隔离层。控制器不再知道 SqlSugar 的 Queryable、Insertable 这些 API,它只需要告诉仓储层"我要按 ID 查用户",具体怎么拼查询条件、怎么处理事务,都在仓储内部完成。服务层则把业务规则放到控制器之前执行,比如新增用户前检查账号是否重复、更新订单时校验状态是否允许修改。控制器回到它本来该做的事:接收 HTTP 请求、调用服务、返回 IActionResult。
把 DTO(Data Transfer Object)引入的原因更直接:数据库实体类 User 里可能有 PasswordHash、CreatedAt 这类内部字段,直接返回给前端既不安全也没必要。DTO 专门定义接口的输入输出结构,比如 UserCreateDto 只包含用户名、邮箱,UserResponseDto 只包含 ID、用户名、创建时间。这样数据库表结构变化不影响接口契约,前端永远看到的是稳定的 JSON 结构。
2.2 建一个可行的 .NET 8 WebAPI 项目:从创建目录到引入 SqlSugar
我一般会先建好解决方案和项目,再用 NuGet 引入 SqlSugarCore 这个包里最新稳定版即可。项目创建命令如下:
dotnet new sln -n MyApp dotnet new webapi -n MyApp.Api -f net8.0 dotnet sln add MyApp.Api/MyApp.Api.csproj cd MyApp.Api dotnet add package SqlSugarCore第一步创建解决方案文件 MyApp.sln,第二步创建 .NET 8 的 WebAPI 项目模板,第三步把项目加入解决方案,最后在项目里引入 SqlSugarCore 包。运行完这一步,你的 csproj 文件里会多一个 PackageReference 节点。
创建完项目后,我习惯在 MyApp.Api 项目下新建四个目录:Models(数据库实体)、Repositories(仓储实现)、Services(服务实现)、Dtos(数据传输对象)。再用一个 Controllers 目录放控制器,这个模板已经自带。目录结构如下:
MyApp.Api/ Controllers/ Models/ Repositories/ Services/ Dtos/ appsettings.json Program.cs为什么要单独建 Models 目录?因为 SqlSugar 的实体类特性标签(如 SugarTable、SugarColumn)应该集中在模型层,仓储层引用它,服务层通过仓储返回值拿到实体,这样实体变更时只需要改 Models 和一个仓储实现,不会牵连到接口层。
2.3 配置 SqlSugar 连接:appsettings 与 Program.cs 里的 IoC 注册
连接字符串放在 appsettings.json 是常规做法,我这里以 SQL Server 为例。如果你用 MySQL,需要额外引入 SqlSugarCore.MySqlConnector 包,把 DbType 改为 MySql。先写配置:
{ "ConnectionStrings": { "Default": "Server=localhost;Database=MyAppDb;User Id=sa;Password=123456;TrustServerCertificate=true;" }, "SqlSugarConfig": { "IsAutoCloseConnection": true, "InitKeyType": "Attribute" } }然后在 Program.cs 里注册 SqlSugarClient,并配置好单例模式。这里一个关键点是:SqlSugarClient 是线程安全的,但 SqlSugarScope 更适合 WebAPI 这种多线程并发场景,因为它能做到同线程内实例复用、自动跟踪状态,所以实际项目我推荐 SqlSugarScope 而不是直接用 SqlSugarClient。
builder.Services.AddSingleton<ISqlSugarClient>(sp => { var config = builder.Configuration.GetConnectionString("Default"); var sqlSugarConfig = builder.Configuration.GetSection("SqlSugarConfig").Get<SqlSugarConfig>(); var db = new SqlSugarScope(config, dbConfig => { dbConfig.DbType = DbType.SqlServer; dbConfig.IsAutoCloseConnection = sqlSugarConfig.IsAutoCloseConnection; dbConfig.InitKeyType = InitKeyType.Attribute; dbConfig.ConnectionConfig.ConfigureExternalServices = new ConfigureExternalServices { EntityService = (property, column) => { if (column.IsPrimarykey == false && new[] { "CreatedAt", "UpdatedAt" }.Contains(property.Name)) { column.IsOnlyIgnoreInsert = true; } } }; }); return db; });这段注册逻辑解释了三个重要参数:IsAutoCloseConnection 设置为 true 后,每次操作完成会自动释放连接,不需要手动 Close;InitKeyType.Attribute 表示主键信息从实体类的特性标签读取,而不是靠命名约定识别;ConfigureExternalServices 里的 EntityService 是一个全局的实体配置钩子,我这里把 CreatedAt 和 UpdatedAt 字段在插入时自动忽略,让数据库自己填充默认值。
如果你不想写这段扩展配置,也可以在每个实体类上用特性标注。但全局配置的好处是,所有实体统一走一套规则,新加表的时候不用记住每个字段要加什么特性。代码里的 SqlSugarScope 是官方推荐的 WebAPI 单例写法,配合 .NET 8 原生的依赖注入,在项目启动时就完成注册,后续控制器、仓储、服务都可以构造函数注入。
2.4 Program.cs 里注册仓储与服务:Scoped 生命周期与接口绑定的细节
依赖注入的生命周期选择是关键:SqlSugarScope 建议注册成单例,仓储和服务层则注册成 Scoped,也就是每个 HTTP 请求一个实例。这样仓储内部可以安全持有当前请求的状态,而 ISqlSugarClient 因为是单例,又避免了频繁创建数据库连接的开销。注册代码如下:
builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserService, UserService>();这段代码将 IUserRepository 绑定到 UserRepository 实现,将 IUserService 绑定到 UserService 实现,两者都是 Scoped 生命周期。注意一个常见错误:把仓储注册成 Singleton 会引发内存问题,因为仓储被全局共享,如果它不小心保存了某次请求的临时状态,下一个请求拿到的就是脏数据。我遇到过不止一次这种翻车:注册成 Singleton 后,接口第一次查询正常,第二次返回结果就开始串数据,排查半天才意识到是生命周期的问题。
AddControllers 的部分不用改,默认模板已经启用控制器。但要确保在 var app = builder.Build(); 之后调用 app.MapControllers();,这样路由才能生效。如果你用了 .NET 8 的最小 API 和控制器混写,要留意路由冲突,本方案只用控制器,不用最小 API。
3. 仓储模式落地:用 SqlSugar 把数据访问封装成接口
3.1 定义实体类:SugarTable 与 SugarColumn 的用法
先实现一个 User 实体类,对应数据库里的 Users 表。这里要把 SqlSugar 的特性标签用对,否则建表和查询都会出问题。
using SqlSugar; namespace MyApp.Api.Models { [SugarTable("Users")] public class User { [SugarColumn(IsPrimaryKey = true, IsIdentity = true)] public int Id { get; set; } [SugarColumn(Length = 50, IsNullable = false)] public string UserName { get; set; } [SugarColumn(Length = 100, IsNullable = false)] public string Email { get; set; } [SugarColumn(Length = 200, IsNullable = true)] public string PasswordHash { get; set; } [SugarColumn(IsNullable = false)] public bool IsActive { get; set; } = true; [SugarColumn(IsNullable = false)] public DateTime CreatedAt { get; set; } = DateTime.Now; } }SugarTable 标注表名,SugarColumn 标注字段属性。IsPrimaryKey 和 IsIdentity 联合使用表示自增主键;Length 用于指定字符串长度,防止建表时用默认长度导致超大字段;IsNullable 控制是否允许为空。PasswordHash 存的是 BCrypt 或 PBKDF2 生成的哈希值,而不是明文密码,这一点务必注意——任何把密码明文落库的方案都需要打回重做。
CreatedAt 字段不设默认值,而是靠之前配置的 EntityService 在插入时自动忽略,让数据库填入默认值。这样代码里不需要每次手动赋值创建时间,逻辑也更统一。
3.2 仓储接口与实现:一个通用的基础仓储加一个用户仓储
先写一个泛型基础仓储接口 IBaseRepository ,包含常用的同步和异步方法,再把用户仓储接口 IUserRepository 继承它并增加特定方法。
namespace MyApp.Api.Repositories { public interface IBaseRepository<T> where T : class, new() { Task<T> GetByIdAsync(int id); Task<List<T>> GetListAsync(); Task<int> InsertAsync(T entity); Task<int> UpdateAsync(T entity); Task<bool> DeleteAsync(int id); } public interface IUserRepository : IBaseRepository<User> { Task<User> GetByUserNameAsync(string userName); Task<bool> ExistByEmailAsync(string email); Task<List<User>> GetActiveUsersAsync(); } }接口的约束 where T : class, new() 表示 T 必须是引用类型且有无参构造函数,这样 SqlSugar 的泛型操作才能正常实例化。GetByIdAsync 是通用方法,所有实体通用;GetByUserNameAsync 是用户仓储特有的查询,它要按业务字段过滤,所以放在具体接口里。
接下来是通用仓储实现,内部注入 ISqlSugarClient,每个方法都对应 SqlSugar 的链式调用。
public class BaseRepository<T> : IBaseRepository<T> where T : class, new() { protected readonly ISqlSugarClient _db; public BaseRepository(ISqlSugarClient db) { _db = db; } public async Task<T> GetByIdAsync(int id) { return await _db.Queryable<T>().InSingleAsync(id); } public async Task<List<T>> GetListAsync() { return await _db.Queryable<T>().ToListAsync(); } public async Task<int> InsertAsync(T entity) { return await _db.Insertable(entity).ExecuteCommandAsync(); } public async Task<int> UpdateAsync(T entity) { return await _db.Updateable(entity).ExecuteCommandAsync(); } public async Task<bool> DeleteAsync(int id) { var result = await _db.Deleteable<T>().In(id).ExecuteCommandAsync(); return result > 0; } }InSingleAsync 方法适合按主键查单个对象,它内部会先确认主键字段,再生成 WHERE Id=@Id 的 SQL;如果传入的 id 为 0,InSingleAsync 会查全表第一条,这一点要小心。Insertable 返回的是受影响行数,如果插入失败返回 0,调用方需要判断。Updateable 默认按主键更新所有非空字段,如果你只更新部分字段,需要改用 Updateable(entity).UpdateColumns(...)。
Updateable 有个隐藏坑:当实体某字段为 null 时,默认不会更新这个字段,而是保留数据库原值。这会带来一个不太直观的行为——你想把某个字段置空,结果数据库里却还是老值。所以实际项目中我会给更新操作单独定义 DTO,只带允许修改的字段,并进行显式更新。这个点后面避坑章节再展开。
3.3 用户仓储实现:条件查询、存在性检查与筛选活跃用户
用户仓储继承 BaseRepository ,并实现 IUserRepository 里的特定方法。
public class UserRepository : BaseRepository<User>, IUserRepository { public UserRepository(ISqlSugarClient db) : base(db) { } public async Task<User> GetByUserNameAsync(string userName) { return await _db.Queryable<User>() .Where(u => u.UserName == userName) .FirstAsync(); } public async Task<bool> ExistByEmailAsync(string email) { return await _db.Queryable<User>() .AnyAsync(u => u.Email == email); } public async Task<List<User>> GetActiveUsersAsync() { return await _db.Queryable<User>() .Where(u => u.IsActive) .OrderBy(u => u.Id, OrderByType.Desc) .ToListAsync(); } }Where 使用表达式树构造 SQL,SqlSugar 会把 C# 里的 u.UserName == userName 翻译成 WHERE UserName = @param。这和写原生 SQL 相比,能避免字符串拼接带来的注入风险。AnyAsync 在 SQL 层面会翻译成 EXISTS 或者 IF EXISTS,性能优于先查列表再判断 Count 大于 0。GetActiveUsersAsync 里 OrderBy 传了排序方向和字段,这个接口会返回按 ID 倒序的活跃用户列表。
这里你可能会问:既然有基础仓储的 GetListAsync 方法,为什么还要写 GetActiveUsersAsync?因为 GetListAsync 拿的是全部用户,包含已停用和未激活的。如果控制器自己再过滤 IsActive,等于是把数据筛选逻辑放到了业务层以外,后续如果有另一个接口也需要"只看活跃用户",就会重复这段过滤。仓储层多写几个专用查询方法,控制器和服务层会更薄。
4. DTO 与服务层:接口契约设计、AutoMapper 映射和业务规则
4.1 为什么不让控制器直接返回实体:三个直接原因
直接在控制器里返回 User 实体,初看很简洁,但会埋下三个问题。第一个是安全问题——User 实体里有 PasswordHash 字段,如果返回了实体或序列化成 JSON,密码哈希直接暴露给前端。虽然哈希不是明文,但攻击者拿到后可以离线暴力破解,这是绝对要避免的。第二个是接口稳定性问题——前端需要的是 Id、UserName、CreatedAt 这些展示字段,一旦数据库加了内部字段如 UpdatedBy,实体返回结构就变了,前端解析 JSON 会多出无用的字段,如果删了一个字段,前端取不到值还会报 undefined。第三个是文档混乱问题——Swagger 里生成的接口响应结构直接来自实体,内部字段全部暴露出去,接口使用方看到的和实际拿到的完全对不上。
DTO 就是为接口层单独定义的传输模型。UserCreateDto 接收创建用户时的输入,UserResponseDto 定义返回给前端的结构。实体永远只在仓储层和服务层内部流转,控制器入口出口全部使用 DTO。这样接口契约稳定,数据库结构调整时只需要改仓储和服务层的映射逻辑。
4.2 三个 DTO 定义:输入、输出和更新分别设计
先看一下我常用的三个 User 相关 DTO:
namespace MyApp.Api.Dtos { public class UserCreateDto { public string UserName { get; set; } public string Email { get; set; } public string Password { get; set; } } public class UserUpdateDto { public int Id { get; set; } public string UserName { get; set; } public string Email { get; set; } public bool IsActive { get; set; } } public class UserResponseDto { public int Id { get; set; } public string UserName { get; set; } public string Email { get; set; } public bool IsActive { get; set; } public DateTime CreatedAt { get; set; } } }每个 DTO 都只承载特定场景需要的字段。UserCreateDto 不需要 Id 和 CreatedAt,创建接口会自动生成;也不需要 IsActive,默认值为 true。UserUpdateDto 需要 Id 才能找到要更新的记录,且包含 IsActive,因为管理员可能需要禁用某个账号。UserResponseDto 则是整个接口对外契约,任何控制器返回用户信息都用它。
这里注意一个约定:DTO 字段名称和实体字段名称保持一致。这样映射代码简单,AutoMapper 或手动赋值都不容易写错。如果你需要在 DTO 里把 CreatedAt 格式化成字符串,建议用 [JsonConverter] 或自定义格式化,而不是在 DTO 里放一个 string 类型。
4.3 引入 AutoMapper 做实体与 DTO 的映射,以及手动映射的取舍
AutoMapper 是 .NET 生态里最常见的对象映射库,引入方式是:
dotnet add package AutoMapper dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection然后在 Program.cs 里注册:
builder.Services.AddAutoMapper(typeof(Program).Assembly);注册之后,AutoMapper 会扫描程序集里所有继承 Profile 的类并自动加载。接下来定义一个映射配置类:
using AutoMapper; using MyApp.Api.Dtos; using MyApp.Api.Models; namespace MyApp.Api { public class MappingProfile : Profile { public MappingProfile() { CreateMap<User, UserResponseDto>(); CreateMap<UserCreateDto, User>(); CreateMap<UserUpdateDto, User>(); } } }CreateMap<User, UserResponseDto> 会把 User 实体按名称映射到 UserResponseDto,因为两边都有 Id、UserName、Email、IsActive、CreatedAt,所以不需要任何额外配置。CreateMap<UserCreateDto, User> 同理,UserName、Email、Password 映射到 User 的 UserName、Email、PasswordHash——但这里字段名不一样(DTO 叫 Password,实体叫 PasswordHash),AutoMapper 默认不会自动匹配,需要额外配置。你可以用 ForMember 指定映射来源:
CreateMap<UserCreateDto, User>() .ForMember(dest => dest.PasswordHash, opt => opt.MapFrom(src => src.Password));这样从 UserCreateDto 映射到 User 时,DTO 的 Password 会赋值给实体的 PasswordHash 字段。
如果项目里只有两三个实体和 DTO,手动映射其实更省事。一个简单的扩展方法就能搞定,不用引入额外包:
public static class UserMapper { public static UserResponseDto ToDto(this User user) { return new UserResponseDto { Id = user.Id, UserName = user.UserName, Email = user.Email, IsActive = user.IsActive, CreatedAt = user.CreatedAt }; } }这两种方式怎么选?我的经验是,实体字段超过 10 个且需要频繁在不同 DTO 之间转换时,用 AutoMapper 减少手写模板代码;字段少、只有一两个 DTO 使用场景,就用扩展方法直接赋值。AutoMapper 有个调优点:它默认是运行时反射映射,冷启动时首次调用有性能损耗。如果接口对首延迟敏感,可以在启动时调用配置的 AssertConfigurationIsValid() 和映射预热。我在实际项目里更常用的一种折中是:查询场景用手动投影,即 SqlSugar 直接 Select 到 DTO,只在实体与 DTO 字段结构相近时用 AutoMapper。后面讲查询示例时你会看到 SqlSugar 的 Select 可以直接输出成 DTO,那是最省事的路子。
4.4 服务层接口设计与实现:业务校验和密码哈希
服务层是业务规则的家。接口定义如下:
namespace MyApp.Api.Services { public interface IUserService { Task<UserResponseDto> GetUserByIdAsync(int id); Task<List<UserResponseDto>> GetActiveUsersAsync(); Task<UserResponseDto> CreateUserAsync(UserCreateDto createDto); Task<bool> UpdateUserAsync(UserUpdateDto updateDto); Task<bool> DeleteUserAsync(int id); } }实现类注入 IUserRepository 和 IMapper,每个方法先做业务校验,再调用仓储,最后映射返回 DTO:
public class UserService : IUserService { private readonly IUserRepository _userRepository; private readonly IMapper _mapper; public UserService(IUserRepository userRepository, IMapper mapper) { _userRepository = userRepository; _mapper = mapper; } public async Task<UserResponseDto> GetUserByIdAsync(int id) { var user = await _userRepository.GetByIdAsync(id); return user == null ? null : _mapper.Map<UserResponseDto>(user); } public async Task<List<UserResponseDto>> GetActiveUsersAsync() { var users = await _userRepository.GetActiveUsersAsync(); return _mapper.Map<List<UserResponseDto>>(users); } public async Task<UserResponseDto> CreateUserAsync(UserCreateDto createDto) { if (string.IsNullOrWhiteSpace(createDto.UserName)) throw new ArgumentException("用户名不能为空"); if (await _userRepository.ExistByEmailAsync(createDto.Email)) throw new InvalidOperationException("邮箱已被使用"); var user = _mapper.Map<User>(createDto); user.PasswordHash = HashPassword(createDto.Password); user.IsActive = true; user.CreatedAt = DateTime.Now; await _userRepository.InsertAsync(user); return _mapper.Map<UserResponseDto>(user); } public async Task<bool> UpdateUserAsync(UserUpdateDto updateDto) { var existing = await _userRepository.GetByIdAsync(updateDto.Id); if (existing == null) throw new ArgumentException("用户不存在"); existing.UserName = updateDto.UserName; existing.Email = updateDto.Email; existing.IsActive = updateDto.IsActive; await _userRepository.UpdateAsync(existing); return true; } public async Task<bool> DeleteUserAsync(int id) { var existing = await _userRepository.GetByIdAsync(id); if (existing == null) throw new ArgumentException("用户不存在"); return await _userRepository.DeleteAsync(id); } private string HashPassword(string password) { return BCrypt.Net.BCrypt.HashPassword(password); } }HashPassword 方法用了 BCrypt.Net.BCrypt,你需要先用 dotnet add package BCrypt.Net-Next 引入包。BCrypt 的哈希自带随机盐,同一个密码两次生成的哈希值不同,这是正确做法。
CreateUserAsync 里先做了两个校验:用户名非空、邮箱唯一。邮箱唯一检查用的是仓储的 AnyAsync,等价于 SELECT EXISTS(SELECT 1 FROM Users WHERE Email=@Email),效率比先查列表再 Length 判断高。校验通过后把 DTO 映射成实体,再单独给 PasswordHash 赋值,因为 DTO 里的 Password 不能直接映射到同名字段——我们前面已经用 ForMember 处理过映射,所以这里 _mapper.Map (createDto) 会直接把 Password 映射到 PasswordHash。由 ForMember 处理后,Map 出来 PasswordHash 就是明文密码,所以代码里紧接着又调用 HashPassword 覆盖它。
这里要做清楚一个边界:业务校验应该放在服务层还是仓储层?答案是服务层。仓储层只做数据访问,不知道业务规则;控制器更不应该写业务判断,否则你在两个接口里都要做同样的邮箱查重代码。服务层的校验异常会直接抛给控制器,控制器捕捉后返回合适的 HTTP 状态码。
4.5 服务层返回 DTO 而不是实体:控制器如何用
为什么服务层返回 UserResponseDto 而不是 User?最直接的原因是,控制器不需要知道 PasswordHash、CreatedAt 这些细节,也不应该直接把实体序列化。服务层做完整映射后,控制器的职责就一个字:转发。看控制器代码:
[ApiController] [Route("api/[controller]")] public class UsersController : ControllerBase { private readonly IUserService _userService; public UsersController(IUserService userService) { _userService = userService; } [HttpGet("{id:int}")] public async Task<ActionResult<UserResponseDto>> GetById(int id) { var user = await _userService.GetUserByIdAsync(id); return user == null ? NotFound() : Ok(user); } [HttpGet("active")] public async Task<ActionResult<List<UserResponseDto>>> GetActiveUsers() { var users = await _userService.GetActiveUsersAsync(); return Ok(users); } [HttpPost] public async Task<ActionResult<UserResponseDto>> Create([FromBody] UserCreateDto dto) { try { var user = await _userService.CreateUserAsync(dto); return CreatedAtAction(nameof(GetById), new { id = user.Id }, user); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); } } [HttpPut("{id:int}")] public async Task<IActionResult> Update(int id, [FromBody] UserUpdateDto dto) { if (id != dto.Id) return BadRequest(new { message = "ID 不匹配" }); try { var result = await _userService.UpdateUserAsync(dto); return result ? NoContent() : NotFound(); } catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); } } [HttpDelete("{id:int}")] public async Task<IActionResult> Delete(int id) { try { var result = await _userService.DeleteUserAsync(id); return result ? NoContent() : NotFound(); } catch (ArgumentException ex) { return BadRequest(new { message = ex.Message }); } } }控制器里没有一行 SqlSugar 相关代码,也没有业务判断。GetById 接口查询用户,查不到返回 404,查到返回 200 和 DTO。Create 接口遇到业务规则冲突(邮箱已存在)返回 409 Conflict,参数错误返回 400 BadRequest。这个错误处理模式是最常见的 WebAPI 做法。控制器的路由写法 [HttpGet("{id:int}")] 加了类型约束,确保 id 必须是整数。
5. SqlSugar 高级查询与 DTO 直接投影:分页、排序和字段裁剪
5.1 分页查询的标准写法:SqlSugar 的 ToPageListAsync 和分页参数
真实业务里,接口几乎不可能把所有数据一次性返回。SqlSugar 的分页写法很直接:
public async Task<PagedResultDto<UserResponseDto>> GetPagedUsersAsync(int pageIndex, int pageSize) { RefAsync<int> totalCount = 0; var users = await _db.Queryable<User>() .Where(u => u.IsActive) .OrderBy(u => u.Id, OrderByType.Desc) .ToPageListAsync(pageIndex, pageSize, totalCount); var userDtos = _mapper.Map<List<UserResponseDto>>(users); return new PagedResultDto<UserResponseDto> { Items = userDtos, TotalCount = totalCount, PageIndex = pageIndex, PageSize = pageSize, TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) }; }ToPageListAsync 会生成两条 SQL:第一条是 SELECT COUNT(*) FROM Users WHERE IsActive=1,第二条是带 OFFSET FETCH 的分页查询。pageIndex 从 1 开始,pageSize 是每页大小。如果前端传的 pageIndex 是 0 或负数,SqlSugar 会当作第一页处理,但我在服务层会强制校正:
if (pageIndex < 1) pageIndex = 1; if (pageSize < 1) pageSize = 10; if (pageSize > 100) pageSize = 100;分页参数不校验是常见翻车点:pageSize 传 10000 会把整个表拖出来,数据库连接和内存双双告警。100 的上限是常规保护,具体数量按业务调整。
PagedResultDto 是新的 DTO 类型,包含 Items、TotalCount、PageIndex、PageSize、TotalPages。前端拿到这个结构就可以绘制分页组件了。
5.2 SqlSugar 查询直接投影到 DTO:少一层映射、性能更好
有些场景,服务层只需要返回 DTO 的几个字段,不需要把整个 User 实体加载到内存再映射。SqlSugar 的 Select 支持直接投影到 DTO:
public async Task<List<UserListDto>> GetUserListAsync() { return await _db.Queryable<User>() .Where(u => u.IsActive) .Select(u => new UserListDto { Id = u.Id, UserName = u.UserName, Email = u.Email, CreatedAt = u.CreatedAt }) .ToListAsync(); }这段查询生成的 SQL 是 SELECT Id AS Id, UserName AS UserName, Email AS Email, CreatedAt AS CreatedAt FROM Users WHERE IsActive=1,只取需要的字段。相比先查整个实体再用 AutoMapper 映射,少了实体实例化和属性复制的过程,性能更好,尤其在字段多的大表上差异明显。
你也可以用 SqlSugar 的自动映射语法,直接.Select<UserResponseDto>(),它会按名称自动匹配相同字段。但这有个局限:如果需要格式化字段或改变字段名,就必须用表达式形式。我的建议是,字段完全对应就用表达式或自动映射,有字段逻辑处理就用 Select 表达式,不要为了省代码牺牲可读性。
5.3 事务与批量操作的仓储封装:多表写入不翻车
业务里经常出现要同时写多个表、其中一个失败就要整体回滚的场景。SqlSugar 的事务用法有两种:Ado.UseTran 和 Db.Ado.BeginTran。在仓储层封装一个通用事务方法会更方便:
public async Task<bool> ExecuteTransactionAsync(Func<Task> action) { try { _db.Ado.BeginTran(); await action(); _db.Ado.CommitTran(); return true; } catch { _db.Ado.RollbackTran(); throw; } }调用方式举例:创建用户同时写入操作日志,两个操作任何一个失败,整个事务回滚,不会出现用户建了但日志没写的脏数据。
await _userRepository.ExecuteTransactionAsync(async () => { await _userRepository.InsertAsync(user); await _operationLogRepository.InsertAsync(log); });注意:事务必须在同一个 ISqlSugarClient 实例上执行,而且要保证同一个线程用的是同一个 SqlSugarScope 实例。这也是前面注册 SqlSugarScope 为单例的原因之一——同线程内多次获取拿到的都是同一个实例,事务上下文能正确传递。如果用 SqlSugarClient 每次 new 一个,事务直接失效。
6. 把整套项目跑起来:Swagger 调试和接口验证的注意事项
6.1 用 Swagger 跑通五个接口的完整验证顺序
项目发布或直接 dotnet run 后,Swagger 页面会展示所有接口。我按这个顺序验证:
- 先调 POST /api/Users,传入一个 UserCreateDto JSON,返回 201 和创建好的 UserResponseDto。
- 再调 GET /api/Users/{id},确认能查到这个用户。
- 调 GET /api/Users/active,确认返回列表里包含刚才创建的用户且只包含活跃用户。
- 调 PUT /api/Users/{id} 修改用户状态,确认返回 204 后再次查详情看字段更新。
- 调 DELETE /api/Users/{id} 删除用户,确认返回 204 后查详情变成 404。
每个请求的响应时间可以在 Swagger 里直接看到,第一次请求如果偏慢是正常的,那是 JIT 编译和依赖注入初始化;第二次开始才是真实性能。如果某个接口响应超过 1 秒,优先检查是不是忘记建索引了。
Swagger 默认只展示 HTTP 方法和路径,要让它显示 DTO 字段说明,可以在 DTO 属性上加 [Required] 和 [StringLength] 特性。这些特性的另一个作用是开启模型验证:
public class UserCreateDto { [Required(ErrorMessage = "用户名不能为空")] [StringLength(50, MinimumLength = 2, ErrorMessage = "用户名长度需在 2 到 50 之间")] public string UserName { get; set; } [Required(ErrorMessage = "邮箱不能为空")] [EmailAddress(ErrorMessage = "邮箱格式不正确")] public string Email { get; set; } }控制器里加上状态判断:
if (!ModelState.IsValid) return BadRequest(ModelState);这样前端传空的 UserName 或非法格式的 Email 时,会自动返回 400 和错误详情,不用自己在服务层写一遍相同的判断。当然,服务层的空值校验仍然要保留——它保护的是在脱离控制器场景下的调用安全。
6.2 常见数据校验问题:DTO 没生效、日期格式和整型溢出
三个高频问题值得提前知道。第一个是模型验证不生效:检查控制器方法参数有没有写 [FromBody] 特性,以及类上有没有 [ApiController] 特性。加了 [ApiController] 后,模型验证失败会启用自动 400 响应,不需要手动写 if (!ModelState.IsValid)。如果两个都写了,还是返回 200,大概率是 DTO 属性没有加 [Required] 特性。
第二个是日期格式问题:前端传的 CreatedAt 如果带时区偏移如 "2025-01-01T10:00:00+08:00",.NET 的 System.Text.Json 默认能解析,但 SqlSugar 写入 SQL Server 时会把 DateTime 转成字符串,一旦格式不匹配就会报 "Conversion failed" 错误。解决办法是统一在程序启动时配置 JSON 选项:
builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); });第三个是整型溢出问题:前端传的 id 超过 int.MaxValue 时会直接报 400,这是框架自动处理的。如果业务用 long 作主键,要把 DTO 和实体的类型都改成 long,否则到 long 最大值时会出现同样的溢出问题。
7. 仓储与 DTO 的 5 个常见坑:从"能跑"到"能上线"的实战排查
7.1 坑一:ISqlSugarClient 注册成 Transient,导致事务失效和性能下降
现象是使用事务时,仓储内的多个操作不在同一个数据库连接上,默认的嵌套事务不会自动提交,甚至报"当前对象不是事务的一部分"等错误。原因是 Transient 生命周期意味着每次注入都 new 一个 SqlSugarClient,事务上下文基于同一个实例才有效。解决方法是改注册为单例 SqlSugarScope。这可能是 SqlSugar 项目里最常见的翻车点。
7.2 坑二:Updateable 更新 null 字段不生效
现象是调用 UpdateAsync 更新实体,把某字段设为 null,但数据库里的值没变。原因是 SqlSugar 的 Updateable 默认忽略 null 字段,它认为 null 表示"不更新"。解决方法是使用 UpdateColumns 显式指定要更新的字段:
await _db.Updateable<User>() .SetColumns(u => new User { Email = "new@example.com", IsActive = false }) .Where(u => u.Id == id) .ExecuteCommandAsync();或者直接写更新 DTO,把允许更新的字段通过属性传递。注意 UpdateColumns 需要传入完整实体,多余字段会被忽略,所以务必在服务层先校验参数合法性。
7.3 坑三:DTO 属性是 int 但实体属性是 int?,映射后丢失值
现象是 dto.Id 明明有值,映射到实体后变成 null 或者默认 0。原因是 AutoMapper 在 int? 和 int 之间转换时,如果源为 0 且目标为可空类型,会直接赋 null。解决方式是用 ForMember 显式处理,或统一字段类型:
CreateMap<UserUpdateDto, User>() .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id));或者反过来,把 DTO 的 Id 改成 int? 但要加 [Required] 标识约束。这个坑在实体字段从 int 改成 int? 后特别常见,排错要花不少时间。
7.4 坑四:仓储返回实体后,服务层修改了字段再更新,但 SqlSugar 追踪了旧值
现象是同一个请求里先查出了用户,修改了 UserName,再调用 UpdateAsync,但生成的 UPDATE 语句包含所有字段,导致并发覆盖其他字段。原因是 SqlSugarScope 在同一个上下文里对实体的状态有缓存,第二次更新可能基于旧缓存。解决方法是更新前用 AsUpdateable 或者新建实体只插入需要变更的字段:
var updateEntity = new User { Id = existing.Id, UserName = newName, Email = newEmail }; await _db.Updateable(updateEntity) .UpdateColumns(u => new { u.UserName, u.Email }) .ExecuteCommandAsync();这个写法的好处是,UPDATE 语句只包含 UserName 和 Email 两个字段,其他字段完全不动,并发冲突的概率大幅降低。
7.5 坑五:实体字段名与数据库列名不一致,查询报列名不存在错误
现象是 C# 属性叫 UserName,数据库列名叫 Name,查询时 SqlSugar 生成的 SQL 是 SELECT UserName FROM Users,然后数据库报无效列名。原因是实体没有配置 SugarColumn 的 ColumnName 参数。解决办法是显式标注:
[SugarColumn(ColumnName = "Name")] public string UserName { get; set; }如果你有这个检查成本,建议在启动后调用一次配置校验:
db.DbMaintenance.CheckDb();SqlSugar 会把实体和数据库表做对比,输出不一致列表。虽然没有专用于列名映射校验的 API,但 DbMaintenance 的表结构信息能帮你快速定位问题。这一步在手写实体和数据库表时非常有用。
8. 进阶:用 SqlSugar 的代码生成器和全局过滤器,把开发效率再拉高一截
多表查询是 WebAPI 开发里的常客。比如一个订单接口需要同时返回订单信息和所属用户信息,SqlSugar 的导航查询可以用一个查询实现:
var orders = await _db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .Select((o, u) => new OrderResponseDto { Id = o.Id, OrderNo = o.OrderNo, UserName = u.UserName, TotalAmount = o.TotalAmount, CreatedAt = o.CreatedAt }) .ToListAsync();LeftJoin 后面的参数是联表条件,Select 表达式里可以直接引用另一张表的字段。这样避免了一次查询后再循环查用户 N+1 问题。如果你要联三张表,就继续叠 LeftJoin。SqlSugar 的导航属性还有一种用法是在实体上定义 [Navigate] 特性,查询时用 Includes 方法一并加载,适合父子表嵌套的查询。但要注意,Navigage 自动加载有一定的性能损耗,如果只是取单个字段,用 LeftJoin 更高效。
另一个好用的能力是全局过滤器,比如所有查询都要求 IsDeleted=0,你可以在 SqlSugar 全局配置里加:
db.QueryFilter.AddTableFilter<IEntity>(it => it.IsDeleted == false);所有实现了 IEntity 接口的实体,查询时自动带上 IsDeleted=false 条件,不用每个仓储方法都写一遍。这在软删除项目里能避免漏过滤导致的脏数据。注意全局过滤器只对 Queryable 生效,对 Updateable 和 Deleteable 不生效,所以更新和删除操作仍需要自己处理软删除逻辑。
最后要说的是利用 SqlSugar 的代码生成器快速建仓储结构。SqlSugar 自带的 DbScripter 能根据已有数据库表生成实体类,你可以用它生成 User 实体再手工调整,这样省去手动写特性标签的时间。不过我还是建议实体生成后人工过一遍:字段类型是否准确、可空性是否符合业务、主键是否标对。自动生成只是起点,不能直接拿去上线。
我自己在多个项目里用这套结构之后,最大的感受是:项目越到后期,分层清晰带来的维护价值越明显。新同事接手时,只需要约定"控制器不写 SQL,仓储不写业务,服务层做规则"三个边界,就能快速定位和修改代码。第一次做的时候,我也经历过在控制器里写 SqlSugar 然后后期重构的痛苦;把职责拆开之后,接口加字段、换数据库这类改动都变得有迹可循。
希望这篇笔记能帮你把 C# .NET 8 WebAPI 的分层结构搭得顺手。你动手时会发现,SqlSugar 的链式 API 配合仓储、服务、DTO 这套模式,写起来确实能少走不少弯路。如果中途遇到更新空字段、事务不生效这类玄学问题,回到这里翻对应的坑。祝你上线顺利。
本文还有配套的精品资源,点击获取