diff --git a/backend/internal/handler/api/v1/conversation_handler.go b/backend/internal/handler/api/v1/conversation_handler.go index e353354c..d613fe64 100644 --- a/backend/internal/handler/api/v1/conversation_handler.go +++ b/backend/internal/handler/api/v1/conversation_handler.go @@ -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) diff --git a/backend/internal/handler/api/v1/message_handler.go b/backend/internal/handler/api/v1/message_handler.go index bc94cc20..1a7e78bb 100644 --- a/backend/internal/handler/api/v1/message_handler.go +++ b/backend/internal/handler/api/v1/message_handler.go @@ -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()) diff --git a/backend/internal/worker/worker.go b/backend/internal/worker/worker.go index 96cc30c8..904ef89d 100644 --- a/backend/internal/worker/worker.go +++ b/backend/internal/worker/worker.go @@ -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( diff --git a/backend/pkg/response/response.go b/backend/pkg/response/response.go index 08e7651f..b1235565 100644 --- a/backend/pkg/response/response.go +++ b/backend/pkg/response/response.go @@ -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{ diff --git a/backend/pkg/response/response_test.go b/backend/pkg/response/response_test.go index 3b0bc4ee..e4bb9b79 100644 --- a/backend/pkg/response/response_test.go +++ b/backend/pkg/response/response_test.go @@ -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() diff --git a/backend/pkg/validator/validator.go b/backend/pkg/validator/validator.go index c637ee91..27b16024 100644 --- a/backend/pkg/validator/validator.go +++ b/backend/pkg/validator/validator.go @@ -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 "该字段" + } } diff --git a/backend/pkg/validator/validator_test.go b/backend/pkg/validator/validator_test.go index b9d0ce20..1121910f 100644 --- a/backend/pkg/validator/validator_test.go +++ b/backend/pkg/validator/validator_test.go @@ -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() diff --git a/frontend/app/javascript/dashboard/components/ConversationItem.vue b/frontend/app/javascript/dashboard/components/ConversationItem.vue index 9ba31133..4bae77d9 100644 --- a/frontend/app/javascript/dashboard/components/ConversationItem.vue +++ b/frontend/app/javascript/dashboard/components/ConversationItem.vue @@ -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 => { diff --git a/swt-remote-after-click.png b/swt-remote-after-click.png new file mode 100644 index 00000000..d3ac8796 Binary files /dev/null and b/swt-remote-after-click.png differ diff --git a/swt-remote-before-open.png b/swt-remote-before-open.png new file mode 100644 index 00000000..4da9d0b5 Binary files /dev/null and b/swt-remote-before-open.png differ