Files
gochat/backend/pkg/validator/validator.go
T
rogee dc5ace9003
Build and publish Docker images / Build and publish images (push) Failing after 25s
fix: localize validation errors and polish delivery
2026-09-15 16:28:02 +08:00

351 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package validator
import (
"errors"
"fmt"
"reflect"
"strings"
"sync"
"github.com/gin-gonic/gin/binding"
playground "github.com/go-playground/validator/v10"
)
// Validate holds the validator instance.
var validate *playground.Validate
// ValidationError keeps the original validator error for errors.As while
// exposing a user-facing message instead of validator's internal English text.
type ValidationError struct {
cause error
}
func (e *ValidationError) Error() string {
return validationMessage(e.cause)
}
func (e *ValidationError) Unwrap() error {
return e.cause
}
// Init initializes the global validator.
func Init() {
validate = newValidator("")
}
// ValidateStruct validates a struct using go-playground/validator tags.
func ValidateStruct(s any) error {
if validate == nil {
Init()
}
err := validate.Struct(s)
return wrapValidationError(err)
}
// Var validates a single variable.
func Var(field any, tag string) error {
if validate == nil {
Init()
}
err := validate.Var(field, tag)
return wrapValidationError(err)
}
// ValidationMessage returns a safe, localized message for a validation error.
func ValidationMessage(err error) string {
if err == nil {
return ""
}
if localized, ok := err.(*ValidationError); ok {
return validationMessage(localized.cause)
}
var localized *ValidationError
if errors.As(err, &localized) {
return validationMessage(localized.cause)
}
return validationMessage(err)
}
// ValidationMessageText removes validator's raw diagnostic format from a
// message that has already been converted to a string by a caller.
func ValidationMessageText(message string) string {
message = strings.TrimSpace(message)
if message == "" {
return message
}
lower := strings.ToLower(message)
for _, prefix := range []string{"validation error:", "validation:"} {
if strings.HasPrefix(lower, prefix) {
message = strings.TrimSpace(message[len(prefix):])
lower = strings.ToLower(message)
break
}
}
if strings.Contains(lower, "field validation for") && strings.Contains(lower, "failed on the '") {
return "请求参数不符合要求"
}
return message
}
// IsValidationError reports whether err came from go-playground validation,
// including errors wrapped by a service.
func IsValidationError(err error) bool {
if err == nil {
return false
}
var localized *ValidationError
if errors.As(err, &localized) {
return true
}
var fieldErrors playground.ValidationErrors
if errors.As(err, &fieldErrors) {
return true
}
var invalidValidation *playground.InvalidValidationError
if errors.As(err, &invalidValidation) {
return true
}
var sliceErrors binding.SliceValidationError
return errors.As(err, &sliceErrors)
}
type ginStructValidator struct {
once sync.Once
validate *playground.Validate
}
var _ binding.StructValidator = (*ginStructValidator)(nil)
// init replaces Gin's default validator so every ShouldBind* call gets the
// same field names and user-facing messages as service-layer validation.
func init() {
binding.Validator = &ginStructValidator{}
}
func (v *ginStructValidator) ValidateStruct(obj any) error {
if obj == nil {
return nil
}
value := reflect.ValueOf(obj)
switch value.Kind() {
case reflect.Ptr:
if value.IsNil() {
return nil
}
if value.Elem().Kind() != reflect.Struct {
return v.ValidateStruct(value.Elem().Interface())
}
return v.validateStruct(obj)
case reflect.Struct:
return v.validateStruct(obj)
case reflect.Slice, reflect.Array:
validationErrors := make(binding.SliceValidationError, 0)
for i := 0; i < value.Len(); i++ {
if err := v.ValidateStruct(value.Index(i).Interface()); err != nil {
validationErrors = append(validationErrors, err)
}
}
if len(validationErrors) > 0 {
return validationErrors
}
}
return nil
}
func (v *ginStructValidator) validateStruct(obj any) error {
v.once.Do(func() {
v.validate = newValidator("binding")
})
return wrapValidationError(v.validate.Struct(obj))
}
func (v *ginStructValidator) Engine() any {
v.once.Do(func() {
v.validate = newValidator("binding")
})
return v.validate
}
func newValidator(tagName string) *playground.Validate {
v := playground.New()
if tagName != "" {
v.SetTagName(tagName)
}
v.RegisterTagNameFunc(func(field reflect.StructField) string {
for _, tagName := range []string{"json", "form", "uri"} {
name, _, _ := strings.Cut(field.Tag.Get(tagName), ",")
if name != "" && name != "-" {
return name
}
}
return field.Name
})
return v
}
func wrapValidationError(err error) error {
if err == nil {
return nil
}
return &ValidationError{cause: err}
}
func validationMessage(err error) string {
if err == nil {
return ""
}
var fieldErrors playground.ValidationErrors
if errors.As(err, &fieldErrors) {
messages := make([]string, 0, len(fieldErrors))
seen := make(map[string]struct{}, len(fieldErrors))
for _, fieldError := range fieldErrors {
message := formatFieldError(fieldError)
if _, ok := seen[message]; ok {
continue
}
seen[message] = struct{}{}
messages = append(messages, message)
}
if len(messages) > 0 {
return strings.Join(messages, "")
}
}
var sliceErrors binding.SliceValidationError
if errors.As(err, &sliceErrors) {
messages := make([]string, 0, len(sliceErrors))
for _, item := range sliceErrors {
if item != nil {
messages = append(messages, ValidationMessage(item))
}
}
if len(messages) > 0 {
return strings.Join(messages, "")
}
return "请求参数不符合要求"
}
var invalidValidation *playground.InvalidValidationError
if errors.As(err, &invalidValidation) {
return "请求参数不符合要求"
}
return ValidationMessageText(err.Error())
}
func formatFieldError(err playground.FieldError) string {
field := fieldLabel(err.Field())
param := err.Param()
switch err.Tag() {
case "required", "required_if", "required_unless", "required_with", "required_with_all", "required_without", "required_without_all":
return "请填写" + field
case "email":
return "请输入有效的" + field
case "min":
if err.Kind() == reflect.String {
return fmt.Sprintf("%s长度不能少于 %s 个字符", field, param)
}
return fmt.Sprintf("%s不能小于 %s", field, param)
case "max":
if err.Kind() == reflect.String {
return fmt.Sprintf("%s长度不能超过 %s 个字符", field, param)
}
return fmt.Sprintf("%s不能大于 %s", field, param)
case "len":
if err.Kind() == reflect.String {
return fmt.Sprintf("%s长度必须为 %s 个字符", field, param)
}
return fmt.Sprintf("%s长度必须为 %s", field, param)
case "gte":
return fmt.Sprintf("%s不能小于 %s", field, param)
case "lte":
return fmt.Sprintf("%s不能大于 %s", field, param)
case "gt":
return fmt.Sprintf("%s必须大于 %s", field, param)
case "lt":
return fmt.Sprintf("%s必须小于 %s", field, param)
case "oneof":
return field + "取值不正确"
case "eqfield":
return field + "与" + fieldLabel(param) + "不一致"
case "nefield":
return field + "不能与" + fieldLabel(param) + "相同"
case "url", "uri", "http_url":
return "请输入有效的" + field
case "uuid", "uuid3", "uuid4", "uuid5", "uuid8", "json":
return field + "格式不正确"
case "numeric", "number", "integer", "boolean":
return field + "格式不正确"
case "excluded", "excluded_if", "excluded_unless", "excluded_with", "excluded_with_all", "excluded_without", "excluded_without_all":
return field + "不允许填写"
default:
return field + "格式不正确"
}
}
var fieldLabels = map[string]string{
"account_id": "账户",
"agent_id": "坐席",
"assignee_id": "负责人",
"avatar_url": "头像地址",
"client_id": "客户端 ID",
"confirmation_token": "确认令牌",
"contact_id": "联系人",
"conversation_id": "会话",
"conversation_ids": "会话",
"content": "内容",
"custom_attributes": "自定义属性",
"email": "邮箱",
"event_id": "事件 ID",
"external_url": "外部地址",
"file": "文件",
"inbox_id": "收件箱",
"identifier": "标识",
"identifier_hash": "标识校验值",
"label": "标签",
"locale": "语言",
"message": "消息",
"message_id": "消息",
"name": "名称",
"password": "密码",
"password_confirmation": "确认密码",
"phone_number": "手机号",
"priority": "优先级",
"refresh_token": "刷新令牌",
"reset_password_token": "重置密码令牌",
"role": "角色",
"source_id": "来源 ID",
"status": "状态",
"team_id": "团队",
"typing_status": "输入状态",
"user_id": "用户",
"user_ids": "用户",
"website_token": "网站令牌",
"widget_token": "组件令牌",
}
func fieldLabel(field string) string {
field = strings.TrimSpace(field)
if dot := strings.LastIndex(field, "."); dot >= 0 {
field = field[dot+1:]
}
if bracket := strings.IndexByte(field, '['); bracket >= 0 {
field = field[:bracket]
}
key := strings.ToLower(field)
if label, ok := fieldLabels[key]; ok {
return label
}
switch {
case strings.Contains(key, "password"):
return "密码"
case strings.Contains(key, "email"):
return "邮箱"
case strings.Contains(key, "token"):
return "令牌"
case strings.Contains(key, "url"):
return "地址"
default:
return "该字段"
}
}