fix: localize validation errors and polish delivery
Build and publish Docker images / Build and publish images (push) Failing after 25s
Build and publish Docker images / Build and publish images (push) Failing after 25s
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 "该字段"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user