fix: localize validation errors and polish delivery
Build and publish Docker images / Build and publish images (push) Failing after 25s

This commit is contained in:
2026-09-15 16:28:02 +08:00
parent f79c80519d
commit dc5ace9003
10 changed files with 421 additions and 14 deletions
+332 -11
View File
@@ -1,29 +1,350 @@
package validator
import (
"github.com/go-playground/validator/v10"
"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 *validator.Validate
// Validate holds the validator instance.
var validate *playground.Validate
// Init initializes the global validator
// 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 = validator.New()
validate = newValidator("")
}
// ValidateStruct validates a struct using go-playground/validator tags
func ValidateStruct(s interface{}) error {
// ValidateStruct validates a struct using go-playground/validator tags.
func ValidateStruct(s any) error {
if validate == nil {
Init()
}
return validate.Struct(s)
err := validate.Struct(s)
return wrapValidationError(err)
}
// Var validates a single variable
func Var(field interface{}, tag string) error {
// Var validates a single variable.
func Var(field any, tag string) error {
if validate == nil {
Init()
}
return validate.Var(field, tag)
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 "该字段"
}
}