数据校验
数据校验
校验分三层,不要混用:
| 层次 | 手段 | 负责什么 | 放哪里 |
|---|---|---|---|
| 格式校验 | DataAnnotations 特性 | 必填、长度、范围、正则等单字段规则 | DTO 属性上 |
| 复杂校验 | FluentValidation | 跨字段、条件校验、集合、组合规则 | *Validator 类 |
| 业务规则 | Service 内判断 + CustomException | 唯一性、状态流转、权限、配额(要查库的) | Service 方法 |
前两层都在进入 Action 之前完成,校验不通过时 Action 不会执行。
一、模型特性(DataAnnotations)
项目里的 DTO 大量使用这种写法,简单直接:
public class RegisterDto
{
[Required(ErrorMessage = "用户名不能为空")]
public string Username { get; set; }
[Required(ErrorMessage = "密码不能为空")]
public string Password { get; set; }
[Required(ErrorMessage = "确认密码不能为空")]
public string ConfirmPassword { get; set; }
public string Code { get; set; }
public string Uuid { get; set; } = "";
}
常用特性:
| 特性 | 用途 | 说明 |
|---|---|---|
[Required] | 必填 | 空字符串也视为未通过 |
[StringLength(max, MinimumLength = n)] | 长度 | 比 MinLength + MaxLength 更好用 |
[Range(min, max)] | 数值 / 日期范围 | 配合 [Range(typeof(decimal), "0", "999")] |
[RegularExpression] | 格式 | 手机号、邮编等 |
[EmailAddress] | 邮箱格式 | 只校验格式,不校验可达性 |
[Compare(nameof(Xxx))] | 两字段一致 | 确认密码场景 |
嵌套对象默认会递归校验;集合元素不会自动校验,需要集合内类型也带特性并配合
[ValidateNever]/手动校验,复杂场景建议直接上 FluentValidation。
二、FluentValidation(复杂规则推荐)
依赖
项目用的是新组合,不是 FluentValidation.AspNetCore(该包从 11.x 起已停止维护):
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<!-- 自动把校验结果写入 ModelState,无需手写 ModelState.IsValid -->
<PackageReference Include="SharpGrip.FluentValidation.AutoValidation.Mvc" Version="2.0.0" />
注册(Program.cs)
builder.Services.AddFluentValidationAutoValidation(options =>
{
// 保留 DataAnnotations 校验,两种方式共存
options.DisableBuiltInModelValidation = false;
});
builder.Services.AddValidatorsFromAssemblies(new[]
{
typeof(Program).Assembly, // WebApi
typeof(PagerInfo).Assembly, // ZR.Model
typeof(ZR.Mall.Model.Product).Assembly, // ZR.Mall
typeof(ZR.Workflow.Model.WfFlowDefinition).Assembly // ZR.Workflow
});
关键点:只有位于上面这 4 个程序集里的 AbstractValidator<T> 才会被自动发现。 新建模块如果单独成程序集,必须把它的 Assembly 加进这个数组,否则校验器写了也不生效。
示例
简单规则(ZR.Model/System/Tenant/SysTenantPlanValidator.cs):
public class SysTenantPlanValidator : AbstractValidator<SysTenantPlan>
{
public SysTenantPlanValidator()
{
RuleFor(x => x.PlanCode).NotEmpty().WithMessage("套餐编码不能为空");
RuleFor(x => x.PlanName).NotEmpty().WithMessage("套餐名称不能为空");
}
}
自定义逻辑(ZR.Workflow/Model/Dto/WfBatchApproveInputValidator.cs):
public class WfBatchApproveInputValidator : AbstractValidator<WfBatchApproveInput>
{
public WfBatchApproveInputValidator()
{
RuleFor(x => x.TaskIds)
.NotEmpty().WithMessage("任务Id不能为空")
.Must(ContainValidId).WithMessage("任务Id格式不正确,至少包含一个有效的任务Id");
}
private static bool ContainValidId(string taskIds)
{
if (string.IsNullOrWhiteSpace(taskIds)) return false;
return taskIds.SplitByComma().Any(s => long.TryParse(s, out _));
}
}
常用规则速查:
RuleFor(x => x.Name).NotEmpty().MaximumLength(50);
RuleFor(x => x.Age).InclusiveBetween(18, 60);
RuleFor(x => x.Phone).Matches(@"^1[3-9]\d{9}$").When(x => x.LoginType == "phone");
RuleFor(x => x.Items).NotEmpty().ForEach(i => i.SetValidator(new ItemValidator()));
RuleFor(x => x).Must(x => x.End > x.Start).WithMessage("结束时间必须晚于开始时间"); // 跨字段
校验器里不要查数据库:会带来 N 次查询、且可能读到事务未提交的数据。唯一性、状态合法性放在 Service。
三、校验失败返回什么(重点)
项目注册了全局过滤器 GlobalActionMonitor,它在 Action 执行前读取 ModelState:
// ZR.ServiceCore/Filters/GlobalActionMonitor.cs
var messages = context.ModelState.Values
.SelectMany(x => x.Errors)
.Select(x => x.ErrorMessage)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct()
.ToList();
string msg = string.Join(" | ", messages);
if (!string.IsNullOrEmpty(msg))
{
logger.Info($"请求参数错误,{msg}");
context.Result = new JsonResult(new ApiResult((int)ResultCode.PARAM_ERROR, msg));
}
所以实际响应是 HTTP 200 + 业务码 101,而不是 ASP.NET Core 默认的 400 ValidationProblemDetails:
{
"code": 101,
"msg": "用户名不能为空 | 密码不能为空",
"data": null
}
| 要点 | 说明 |
|---|---|
| 业务码 | ResultCode.PARAM_ERROR = 101(不是 400) |
| 多条错误 | 合并去重后用竖线分隔,前端一次性展示即可 |
| DataAnnotations 与 FluentValidation 的错误 | 同一次响应里合并返回 |
| 日志 | 请求参数错误,{msg},便于排查脏请求 |
| 前端 | axios 拦截器按 code != 200 弹 msg,无需解析 errors 字段 |
想让校验失败返回标准 HTTP 400 +
ProblemDetails,需要调整或移除该过滤器;本项目前后端都按code约定处理,不建议单独改。
四、执行链路
请求 → 模型绑定 → DataAnnotations 校验
→ SharpGrip 自动验证(FluentValidation)写入 ModelState
→ GlobalActionMonitor 检查 ModelState
├── 有错误 → 200 + {code:101, msg:"..."},Action 不执行
└── 无错误 → 进入 Action
补充:
[FromBody]、[FromQuery]绑定的对象都会参与校验;- 文件上传、
IFormFile不参与特性校验,需在 Action 内判断; - 授权(401/403)与限流(429)发生在更早的中间件/过滤器,与本文无关。
五、实践建议
- DTO 只为"格式"负责,不要在校验器里塞业务语义(如"该手机号已被注册");
- 跨字段规则优先用 FluentValidation,比在 Action 里手写
if干净; - 错误消息写用户能看懂的中文,当前项目未接入多语言,不要写成英文再指望前端翻译;
- 新增模块校验器时,第一件事是把程序集加进
AddValidatorsFromAssemblies; - 需要区分"参数错误"与"业务失败"时:前者靠特性自动返回 101,后者在 Service 抛
CustomException(返回 110)。
六、常见问题
写了 Validator 但不生效
- 校验器所在程序集没有被
AddValidatorsFromAssemblies包含(最常见,尤其是新建的独立模块); - 类型没有继承
AbstractValidator<T>,或泛型参数与 Action 参数类型不一致; - 规则写在构造函数之外(
RuleFor必须在构造函数里调用)。
为什么返回 200 而不是 400
GlobalActionMonitor 在 Action 前把 ModelState 错误转成了 ApiResult(101, msg),覆盖了框架默认的 400。前端按 code 判断即可。
只提示了一条错误
检查是否只有一条错误信息:过滤器会 Distinct() 后全部拼接;如果两条消息文本完全相同(例如两个字段都配了同一句"不能为空"),会被去重成一条。
嵌套对象 / 集合没被校验
集合元素默认不递归校验。改用 FluentValidation 的 ForEach + SetValidator,或在校验器里对每个元素单独 RuleFor。
想在 Service 里复用同一份规则
注入 IValidator<T>(已随 AddValidatorsFromAssemblies 注册),调用 ValidateAsync;不要直接 new XxxValidator(),否则构造函数依赖无法注入。
