7. DTO 与参数校验
入参继承实体、出参继承实体后改写,用 Mapster 的 Adapt 做转换,校验交给 DataAnnotations + 框架自定义特性,脱敏交给 JsonConverter。
1. DTO 关系图
BaseFilter
└─ BasePageInput { Page=1, PageSize=20, Field, Order, DescStr="descending" }
└─ PageXxxInput # 分页查询条件
BaseIdInput { [Required] long Id }
├─ DeleteXxxInput # 删除入参
├─ BaseStatusInput { Status } # 改状态入参
└─ XxxInput(详情入参,如 ConfigInput)
实体 SysXxx
└─ AddXxxInput : SysXxx # 继承全部字段 + override 加校验
└─ UpdateXxxInput : AddXxxInput # 通常为空壳
实体 SysXxx
└─ XxxOutput : SysXxx # override 敏感字段加 [JsonConverter]2. 内置基类(真实源码)
// Admin.NET.Core/Utils/System/BasePageInput.cs
public class BasePageInput : BaseFilter
{
[Range(1, int.MaxValue, ErrorMessage = "当前页码必须大于0")]
public virtual int Page { get; set; } = 1;
[Range(1, int.MaxValue, ErrorMessage = "页码容量必须大于0")]
public virtual int PageSize { get; set; } = 20;
public virtual string Field { get; set; } // 排序字段
public virtual string Order { get; set; } // 排序方向
public virtual string DescStr { get; set; } = "descending";
}
// Admin.NET.Core/Utils/System/BaseInput.cs
public class BaseIdInput
{
[Required(ErrorMessage = "Id不能为空")]
public virtual long Id { get; set; }
}
// Admin.NET.Core/Utils/System/BaseStatusInput.cs
public class BaseStatusInput : BaseIdInput { public StatusEnum Status { get; set; } }3. 标准四个 Input + 一个 Output
namespace Admin.NET.Core.Service;
/// <summary>分页查询入参</summary>
public class PageUserInput : BasePageInput
{
public string Account { get; set; }
public string RealName { get; set; }
public string PosName { get; set; }
public string Phone { get; set; }
public long OrgId { get; set; }
}
/// <summary>新增入参:继承实体,override 需要强校验的字段</summary>
public class AddUserInput : SysUser
{
[Required(ErrorMessage = "账号不能为空")]
public override string Account { get; set; }
[Required(ErrorMessage = "真实姓名不能为空")]
public override string RealName { get; set; }
/// <summary>域用户(实体上没有的扩展字段)</summary>
public string DomainAccount { get; set; }
public List<long> RoleIdList { get; set; }
public List<SysUserExtOrg> ExtOrgIdList { get; set; }
}
/// <summary>更新入参</summary>
public class UpdateUserInput : AddUserInput { }
/// <summary>删除入参</summary>
public class DeleteUserInput : BaseIdInput { public long OrgId { get; set; } }
/// <summary>修改密码入参(独立 DTO,不继承实体)</summary>
public class ChangePwdInput
{
[Required(ErrorMessage = "当前密码不能为空")]
public string PasswordOld { get; set; }
[Required(ErrorMessage = "新密码不能为空"), MinLength(5, ErrorMessage = "密码需要大于5个字符")]
public string PasswordNew { get; set; }
}关键技巧:
AddXxxInput : SysXxx会继承实体字段上已写的[Required]/[MaxLength](它们在实体里就写了),DTO 里只需要override那些要改错误提示或加强校验的字段——注意override才会让新特性生效,普通new不行。
4. Mapster Adapt 转换
global using Mapster; 已在 GlobalUsings.cs,直接用扩展方法:
var user = input.Adapt<SysUser>(); // DTO → 实体(同名同类型自动映射)
var output = user.Adapt<UserOutput>(); // 实体 → 出参
var list = entities.Adapt<List<XxxOutput>>(); // 集合
// 映射后再加工(框架里最常见的写法)
var user = input.Adapt<SysUser>();
user.Password = CryptogramHelper.Encrypt(password);
var newUser = await _sysUserRep.AsInsertable(user).ExecuteReturnEntityAsync();带额外赋值的写法(映射同时指定目标成员):
var config = input.Adapt<SysConfig>();
await _sysConfigRep.InsertAsync(config);字段映射不上时优先检查:属性名是否一致、
virtual/override是否写对、目标是否是只读属性。需要自定义规则用TypeAdapterConfig(参考RepositoryExtension.OrderBuilder里的写法)。
5. 内置校验特性(System.ComponentModel.DataAnnotations)
| 特性 | 示例 |
|---|---|
[Required(ErrorMessage = "...")] | 非空 |
[MaxLength(64)] / [MinLength(5)] / [StringLength(64)] | 长度 |
[Range(1, 100)] | 数值区间 |
[EmailAddress] / [Phone] / [Url] | 格式 |
[RegularExpression("...")] | 正则 |
Furion 额外提供(来自 Furion.DataValidation):
[DataValidation(ValidationTypes.PhoneNumber, ErrorMessage = "手机号码不正确")]
public string Phone { get; set; }6. 框架自定义校验特性(Admin.NET.Core/Attribute/)
| 特性 | 作用 | 用法 |
|---|---|---|
[Dict("dict_code")] | 值必须在该字典的取值范围内;multiple: true 支持逗号分隔或集合 | [Dict("leave_type")] public string LeaveType { get; set; } |
[IdCardNo] | 身份证号格式(IdCardHelper.CheckIdCard) | [IdCardNo] public string IdCardNum { get; set; } |
[MaxValue(9999)] / [MinValue(0)] | 数值上下界,null 直接放行 | [MaxValue(365)] public decimal Days { get; set; } |
[RequiredIF(nameof(Type), 1)] | 当 Type == 1 时本字段必填 | [RequiredIF(nameof(Status), 2, Operator.Equal)] |
[DateTimeRange(MinInterval = 30, MaxInterval = 1440)] | List<DateTime> 时间区间校验 | 见下方示例 |
[ImportDict("code", nameof(Target))] | Excel 导入时字典文本→值 | 导入 DTO |
public class AddLeaveInput : Leave
{
// 值必须存在于 leave_type 字典(单选)
[Dict("leave_type")]
public override string LeaveType { get; set; }
// 天数上限 365
[MaxValue(365, ErrorMessage = "请假天数不能超过365天")]
public decimal Days { get; set; }
// 起止时间:最少 30 分钟、最多 1440 分钟
[DateTimeRange(MinInterval = 30, MaxInterval = 1440, AllowEqual = false)]
public List<DateTime> Range { get; set; }
// Status == 2(已通过)时审批意见必填
[RequiredIF(nameof(Status), 2, Operator.Equal)]
public string WorkflowComment { get; set; }
}自定义校验特性写法(照抄 MaxValueAttribute):
namespace Admin.NET.Core;
public class MaxValueAttribute : ValidationAttribute
{
private double MaxValue { get; }
public MaxValueAttribute(double value) => MaxValue = value;
public override bool IsValid(object value)
=> value == null || Convert.ToDouble(value) <= MaxValue;
public override string FormatErrorMessage(string name) => base.FormatErrorMessage(name);
}需要访问数据库/服务的特性继承 ValidationAttribute 并实现 IsValid(object, ValidationContext),用 validationContext.GetService(typeof(T)) 取服务(见 DictAttribute)。
7. Output 脱敏
Admin.NET.Core/Service/User/Dto/UserOutput.cs(真实源码):
public class UserOutput : SysUser
{
/// <summary>手机号码</summary>
[JsonConverter(typeof(MaskConverter))]
public override string? Phone { get; set; }
/// <summary>身份证号</summary>
[JsonConverter(typeof(MaskIdCardConverter))]
public override string? IdCardNum { get; set; }
/// <summary>邮箱</summary>
[JsonConverter(typeof(MaskEmailConverter))]
public override string? Email { get; set; }
/// <summary>机构名称(联表补出来的展示字段)</summary>
public string OrgName { get; set; }
/// <summary>职位名称</summary>
public string PosName { get; set; }
/// <summary>角色名称(逗号分隔,见 SysUserService.Page 的 SelectStringJoin)</summary>
public string RoleName { get; set; }
/// <summary>域用户</summary>
public string DomainAccount { get; set; }
}可用转换器(Admin.NET.Core/Attribute/JsonConverterSystemText.cs):MaskConverter(通用)、MaskNameConverter(姓名)、MaskIdCardConverter(身份证)、MaskEmailConverter(邮箱)。
另一条脱敏路线是字段贴 [DataMask(3, 4)],配合 ToPagedListDataMaskAsync() 在查询后统一处理:
return await _rep.AsQueryable()
.Where(u => u.Status == StatusEnum.Enable)
.ToPagedListDataMaskAsync(input.Page, input.PageSize);实体里不想输出的字段直接
[JsonIgnore](如SysUser.Password、EntityTenant.Tenant导航属性)。
8. 步骤清单
- 建
Service/Xxx/Dto/XxxInput.cs,依次写PageXxxInput : BasePageInput、AddXxxInput : Xxx、UpdateXxxInput : AddXxxInput、DeleteXxxInput : BaseIdInput - 需要强校验的字段在
AddXxxInput里override并贴[Required]等 - 建
Service/Xxx/Dto/XxxOutput.cs,继承实体,override敏感字段贴[JsonConverter(typeof(MaskConverter))] - 服务里用
input.Adapt<Xxx>()转实体,用.Adapt<XxxOutput>()转出参 - 分页时把
PageXxxInput直接传给OrderBuilder(input)+ToPagedListAsync(input.Page, input.PageSize) - 复杂/跨表字段用
Select((u, a) => new XxxOutput { ... }, true)填充
⚠️ 注意事项 / 常见坑
override才生效:AddXxxInput里重写实体字段必须写public override string Name { get; set; },写成public new string Name特性不生效。UpdateXxxInput继承AddXxxInput,所以两者的校验完全一致;如果更新场景要放宽,单独 override。- 更新默认
IgnoreColumns(true)(忽略 null 列),前端不传的字段不会被置空,需要显式清空请用UpdateColumns(u => new { u.X }, true)。 long超过 17 位时会被序列化成字符串(AddLongConverters(overMaxLengthOf17: true)),雪花 Id 19 位——前端拿到的是字符串,比较时别用===数字。[Dict]校验会查库(带缓存),字典编码写错会提示"不在字典【(code)】允许的取值范围内"。[DateTimeRange]只支持List<DateTime>,传别的类型直接报"必须是日期时间列表类型"。- 分页默认
PageSize=20,前端传 0 会被[Range(1, int.MaxValue)]拦下返回 400。
小结
Add/Update 继承实体 + override 加校验,Page 继承 BasePageInput,Delete 继承 BaseIdInput,Output 继承实体 + [JsonConverter] 脱敏;转换一律 Adapt。校验用 [Required]/[MaxLength] 打底,[Dict]/[IdCardNo]/[MaxValue]/[RequiredIF]/[DateTimeRange] 覆盖业务规则。