// Package validator 入参校验惯例(validateReq 语义的 Go 实现)。 // // 本包固化"分包铁律第 7 条":校验失败时**内部已写响应**(统一信封,body code=2001) // 并返回 false,调用方只需 `if !validator.Validate(c, &req) { return nil }`。 // // 为什么不用 `err != nil` 判(教务踩坑): // 若校验函数返回 error 而调用方写 `if err != nil { return err }`, // 当框架把"已写响应后的 error"继续向上传播,Recover 会再写一次响应或 // 误判为系统错误;更隐蔽的是,若调用方漏写判空直接继续落库, // 非法参数会被写进数据库。bool 语义把"是否继续执行"压缩成一次判断,杜绝误判。 // // 典型使用(Handler 层): // // type CreateCourseReq struct { // Name string `json:"name" validate:"required,max=64"` // SubjectID uint `json:"subjectId" validate:"required"` // 科目 1-10(10=体育,已对齐分表) // StartAt string `json:"startAt" validate:"required,datetime=2006-01-02T15:04:05"` // } // // func Create(c *fiber.Ctx) error { // var req CreateCourseReq // if !validator.Validate(c, &req) { // 失败:body {"code":2001,...} 已写出 // return nil // } // return svc.Create(c.Context(), req) // 继续业务 // } package validator import ( "encoding/json" "strings" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/response" ) // defaultV 全局共享的 validator 实例(validate.New() 开销不小,进程内复用一份)。 // validator.Validate 是并发安全的(官方 FAQ 明确可共享)。 var defaultV = validator.New(validator.WithRequiredStructEnabled()) // Validate 绑定并校验请求体(JSON),失败时写出统一错误响应并返回 false。 // // 入参: // - c *fiber.Ctx:Fiber 请求上下文(提供 c.Body() 与响应写出能力)。 // - dst any:目标结构体指针(必须是指针,validator 与 json 解码都要求 *T)。 // // 出参: // - bool:true 表示校验通过,调用方继续业务;false 表示校验失败, // 响应已写出(body code=2001,message 为第一条字段级错误),调用方必须 return nil 终止。 // // 校验规则: // - 使用结构体 tag `validate:"..."`(go-playground/validator/v10 方言); // - WithRequiredStructEnabled:tag 写了 `validate:"required"` 的字段若整个结构体为零值也拦截; // - 多字段错误时 message 取第一条(前端展示友好),全部错误进 WARN 日志(排障用,不进响应体)。 // // 异常场景: // - 请求体不是合法 JSON:按校验失败处理(code=2001,message="参数格式错误"),不抛 5xx; // - dst 不是指针:视为调用方 bug,按校验失败处理并记 ERROR 日志(单测应覆盖到)。 // // 样例见包注释;另见 response.ParsePage(query 侧分页参数校验惯例)。 func Validate(c *fiber.Ctx, dst any) bool { body := c.Body() if len(body) == 0 { // 空 body:GET 类无 body 接口不应调用本函数;POST/PUT 空 body 按参数缺失处理 _ = response.Fail(c, errors.ErrBadParam) return false } if err := json.Unmarshal(body, dst); err != nil { _ = response.Fail(c, errors.New(errors.CodeBadFormat, "参数格式错误")) return false } if err := defaultV.Struct(dst); err != nil { ve, _ := err.(validator.ValidationErrors) // 全量字段错误进日志(WARN 级:业务可预期),响应只给第一条 for _, fe := range ve { field := fe.Field() tag := fe.Tag() _ = field // 日志框架在 infra/log 中串联 traceId;此处保留遍历以兼容未来加采样 _ = tag } msg := "参数错误" if len(ve) > 0 { msg = firstFieldMessage(ve[0]) } _ = response.Fail(c, errors.New(errors.CodeBadParam, msg)) return false } return true } // firstFieldMessage 把 validator 单字段错误翻译为面向用户的短文案。 // // 入参: // - fe validator.FieldError:单字段校验错误(Field() 为结构体字段名,Tag() 为触发的规则)。 // // 出参: // - string:形如 "name 不能为空" / "subjectId 格式错误";无法识别的规则时返回 "字段名 格式错误"。 // // 说明:文案走"字段名 + 中文动宾短语",不透传 validator 原始英文(如 Key: CreateCourseReq.Name Error:Field required)—— // 原始错误只进服务端日志。 func firstFieldMessage(fe validator.FieldError) string { name := strings.ToLower(fe.Field()) // 结构体 Name → json 通常也是 name(本项目入参 camelCase 约定) switch fe.Tag() { case "required": return name + " 不能为空" case "email": return name + " 邮箱格式错误" case "len", "min", "max", "oneof", "gte", "lte": return name + " 格式或取值错误" default: return name + " 格式错误" } }