validator.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. // Package validator 入参校验惯例(validateReq 语义的 Go 实现)。
  2. //
  3. // 本包固化"分包铁律第 7 条":校验失败时**内部已写响应**(统一信封,body code=2001)
  4. // 并返回 false,调用方只需 `if !validator.Validate(c, &req) { return nil }`。
  5. //
  6. // 为什么不用 `err != nil` 判(教务踩坑):
  7. // 若校验函数返回 error 而调用方写 `if err != nil { return err }`,
  8. // 当框架把"已写响应后的 error"继续向上传播,Recover 会再写一次响应或
  9. // 误判为系统错误;更隐蔽的是,若调用方漏写判空直接继续落库,
  10. // 非法参数会被写进数据库。bool 语义把"是否继续执行"压缩成一次判断,杜绝误判。
  11. //
  12. // 典型使用(Handler 层):
  13. //
  14. // type CreateCourseReq struct {
  15. // Name string `json:"name" validate:"required,max=64"`
  16. // SubjectID uint `json:"subjectId" validate:"required"` // 科目 1-10(10=体育,已对齐分表)
  17. // StartAt string `json:"startAt" validate:"required,datetime=2006-01-02T15:04:05"`
  18. // }
  19. //
  20. // func Create(c *fiber.Ctx) error {
  21. // var req CreateCourseReq
  22. // if !validator.Validate(c, &req) { // 失败:body {"code":2001,...} 已写出
  23. // return nil
  24. // }
  25. // return svc.Create(c.Context(), req) // 继续业务
  26. // }
  27. package validator
  28. import (
  29. "encoding/json"
  30. "strings"
  31. "github.com/go-playground/validator/v10"
  32. "github.com/gofiber/fiber/v2"
  33. "yuansiwei/unified-backend/internal/infra/errors"
  34. "yuansiwei/unified-backend/internal/infra/response"
  35. )
  36. // defaultV 全局共享的 validator 实例(validate.New() 开销不小,进程内复用一份)。
  37. // validator.Validate 是并发安全的(官方 FAQ 明确可共享)。
  38. var defaultV = validator.New(validator.WithRequiredStructEnabled())
  39. // Validate 绑定并校验请求体(JSON),失败时写出统一错误响应并返回 false。
  40. //
  41. // 入参:
  42. // - c *fiber.Ctx:Fiber 请求上下文(提供 c.Body() 与响应写出能力)。
  43. // - dst any:目标结构体指针(必须是指针,validator 与 json 解码都要求 *T)。
  44. //
  45. // 出参:
  46. // - bool:true 表示校验通过,调用方继续业务;false 表示校验失败,
  47. // 响应已写出(body code=2001,message 为第一条字段级错误),调用方必须 return nil 终止。
  48. //
  49. // 校验规则:
  50. // - 使用结构体 tag `validate:"..."`(go-playground/validator/v10 方言);
  51. // - WithRequiredStructEnabled:tag 写了 `validate:"required"` 的字段若整个结构体为零值也拦截;
  52. // - 多字段错误时 message 取第一条(前端展示友好),全部错误进 WARN 日志(排障用,不进响应体)。
  53. //
  54. // 异常场景:
  55. // - 请求体不是合法 JSON:按校验失败处理(code=2001,message="参数格式错误"),不抛 5xx;
  56. // - dst 不是指针:视为调用方 bug,按校验失败处理并记 ERROR 日志(单测应覆盖到)。
  57. //
  58. // 样例见包注释;另见 response.ParsePage(query 侧分页参数校验惯例)。
  59. func Validate(c *fiber.Ctx, dst any) bool {
  60. body := c.Body()
  61. if len(body) == 0 {
  62. // 空 body:GET 类无 body 接口不应调用本函数;POST/PUT 空 body 按参数缺失处理
  63. _ = response.Fail(c, errors.ErrBadParam)
  64. return false
  65. }
  66. if err := json.Unmarshal(body, dst); err != nil {
  67. _ = response.Fail(c, errors.New(errors.CodeBadFormat, "参数格式错误"))
  68. return false
  69. }
  70. if err := defaultV.Struct(dst); err != nil {
  71. ve, _ := err.(validator.ValidationErrors)
  72. // 全量字段错误进日志(WARN 级:业务可预期),响应只给第一条
  73. for _, fe := range ve {
  74. field := fe.Field()
  75. tag := fe.Tag()
  76. _ = field // 日志框架在 infra/log 中串联 traceId;此处保留遍历以兼容未来加采样
  77. _ = tag
  78. }
  79. msg := "参数错误"
  80. if len(ve) > 0 {
  81. msg = firstFieldMessage(ve[0])
  82. }
  83. _ = response.Fail(c, errors.New(errors.CodeBadParam, msg))
  84. return false
  85. }
  86. return true
  87. }
  88. // firstFieldMessage 把 validator 单字段错误翻译为面向用户的短文案。
  89. //
  90. // 入参:
  91. // - fe validator.FieldError:单字段校验错误(Field() 为结构体字段名,Tag() 为触发的规则)。
  92. //
  93. // 出参:
  94. // - string:形如 "name 不能为空" / "subjectId 格式错误";无法识别的规则时返回 "字段名 格式错误"。
  95. //
  96. // 说明:文案走"字段名 + 中文动宾短语",不透传 validator 原始英文(如 Key: CreateCourseReq.Name Error:Field required)——
  97. // 原始错误只进服务端日志。
  98. func firstFieldMessage(fe validator.FieldError) string {
  99. name := strings.ToLower(fe.Field()) // 结构体 Name → json 通常也是 name(本项目入参 camelCase 约定)
  100. switch fe.Tag() {
  101. case "required":
  102. return name + " 不能为空"
  103. case "email":
  104. return name + " 邮箱格式错误"
  105. case "len", "min", "max", "oneof", "gte", "lte":
  106. return name + " 格式或取值错误"
  107. default:
  108. return name + " 格式错误"
  109. }
  110. }