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
@@ -17,6 +17,7 @@ import (
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/pagination"
"github.com/gochat/gochat/pkg/response"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
"gorm.io/datatypes"
)
@@ -1159,6 +1160,10 @@ func handleServiceError(c *gin.Context, err error) {
if err == nil {
return
}
if pkgvalidator.IsValidationError(err) {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, pkgvalidator.ValidationMessage(err))
return
}
errMsg := err.Error()
if errors.Is(err, service.ErrCaptainAssistantDisabled) {
response.AbortWithStatusError(c, http.StatusConflict, response.ErrCaptainAssistantDisabled, errMsg)
@@ -18,6 +18,7 @@ import (
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/pagination"
"github.com/gochat/gochat/pkg/response"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
"gorm.io/datatypes"
)
@@ -388,6 +389,10 @@ func (h *MessageHandler) Translate(c *gin.Context) {
}
result, svcErr := h.svc.TranslateInConversation(c.Request.Context(), accountID, conversation.ID, messageID, req)
if svcErr != nil {
if pkgvalidator.IsValidationError(svcErr) {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, pkgvalidator.ValidationMessage(svcErr))
return
}
lower := strings.ToLower(svcErr.Error())
if strings.Contains(lower, "not found") {
response.AbortWithStatusError(c, http.StatusNotFound, response.ErrNotFound, svcErr.Error())
+8 -1
View File
@@ -20,6 +20,8 @@ import (
var ErrWorkerDatabaseRequired = errors.New("worker database is required")
const redisPublishTimeout = 2 * time.Second
type permanentError struct{ err error }
func (e *permanentError) Error() string { return e.err.Error() }
@@ -343,8 +345,13 @@ func (wp *WorkerPool) Publish(ctx context.Context, job *model.BackgroundJob) {
}
// Push to Redis Stream for immediate dispatch if the job is due.
// Scheduled (future) jobs are picked up by the sweep goroutine when they mature.
// Keep Redis delivery bounded: the durable DB row is already committed, and
// a slow/unreachable Redis must not hold the HTTP request until its deadline.
// ponytail: 2s cap; the durable sweep retries Redis delivery.
if wp.rdb != nil && !job.ScheduledAt.After(wp.now()) {
if err := wp.pushToRedis(ctx, job); err != nil {
publishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), redisPublishTimeout)
defer cancel()
if err := wp.pushToRedis(publishCtx, job); err != nil {
// Redis delivery failure does not roll back the DB-committed job.
// The sweep compensation mechanism will re-push it on the next cycle.
applogger.L().Warnf(
+12 -2
View File
@@ -4,6 +4,8 @@ import (
"net/http"
"github.com/gin-gonic/gin"
pkgvalidator "github.com/gochat/gochat/pkg/validator"
)
// APIResponse is the unified response structure for all API endpoints.
@@ -63,18 +65,26 @@ func NoContent(c *gin.Context) {
// AbortWithError sends an error response and aborts the Gin context
func AbortWithError(c *gin.Context, appErr *AppError) {
message, detail := appErr.Message, appErr.Detail
if appErr.Code == ErrValidation || appErr.Code == ErrBadRequest {
message = pkgvalidator.ValidationMessageText(message)
detail = pkgvalidator.ValidationMessageText(detail)
}
c.AbortWithStatusJSON(appErr.Status, APIResponse{
Success: false,
Error: &ErrorBody{
Code: appErr.Code,
Message: appErr.Message,
Detail: appErr.Detail,
Message: message,
Detail: detail,
},
})
}
// AbortWithStatusError sends a generic error with just HTTP status and message
func AbortWithStatusError(c *gin.Context, status int, code ErrorCode, message string) {
if code == ErrValidation || code == ErrBadRequest {
message = pkgvalidator.ValidationMessageText(message)
}
c.AbortWithStatusJSON(status, APIResponse{
Success: false,
Error: &ErrorBody{
+11
View File
@@ -270,6 +270,17 @@ func TestAbortWithStatusError(t *testing.T) {
assert.Empty(t, resp.Error.Detail) // AbortWithStatusError does not set Detail
}
func TestAbortWithStatusError_LocalizesRawValidationMessage(t *testing.T) {
c, w := newTestContext()
raw := "Key: 'LoginRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag"
AbortWithStatusError(c, http.StatusBadRequest, ErrValidation, raw)
var resp APIResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, "请求参数不符合要求", resp.Error.Message)
}
func TestAbortWithStatusError_ForbidsFurtherHandlers(t *testing.T) {
// Verify that abort truly prevents subsequent handlers from running
router := gin.New()
+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 "该字段"
}
}
+46
View File
@@ -1,8 +1,10 @@
package validator
import (
"fmt"
"testing"
"github.com/gin-gonic/gin/binding"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -137,6 +139,50 @@ func TestVar_AutoInit(t *testing.T) {
assert.NotNil(t, validate)
}
func TestValidateStruct_ReturnsLocalizedWrappedError(t *testing.T) {
type request struct {
Password string `json:"password" validate:"min=6"`
}
err := ValidateStruct(request{Password: "123"})
require.Error(t, err)
wrapped := fmt.Errorf("validation error: %w", err)
assert.True(t, IsValidationError(wrapped))
assert.Equal(t, "密码长度不能少于 6 个字符", ValidationMessage(wrapped))
}
func TestValidationMessage_LoginPasswordMin(t *testing.T) {
type loginRequest struct {
Password string `json:"password" binding:"required,min=6"`
}
err := binding.Validator.ValidateStruct(loginRequest{Password: "123"})
require.Error(t, err)
assert.True(t, IsValidationError(err))
assert.Equal(t, "密码长度不能少于 6 个字符", ValidationMessage(err))
assert.NotContains(t, err.Error(), "Field validation")
}
func TestValidationMessage_MultipleFields(t *testing.T) {
type request struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
err := binding.Validator.ValidateStruct(request{})
require.Error(t, err)
message := ValidationMessage(err)
assert.Contains(t, message, "请填写邮箱")
assert.Contains(t, message, "请填写密码")
assert.NotContains(t, message, "Key:")
}
func TestValidationMessageText_RemovesRawValidatorDetails(t *testing.T) {
raw := "Key: 'LoginRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag"
assert.Equal(t, "请求参数不符合要求", ValidationMessageText(raw))
assert.Equal(t, "密码长度不能少于 6 个字符", ValidationMessageText("validation: 密码长度不能少于 6 个字符"))
}
func TestValidateStruct_Pointer(t *testing.T) {
Init()
@@ -155,10 +155,12 @@ const onAssignAgent = agent => {
const onAssignLabel = label => {
assignLabels([label.title], [props.source.id]);
closeContextMenu();
};
const onRemoveLabel = label => {
removeLabels([label.title], [props.source.id]);
closeContextMenu();
};
const onAssignTeam = team => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB