diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index efa6edb5e..98d4c1e54 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -242,25 +242,52 @@ func providerUserFacingErrorMessage(err error) string { } var httpErr providerHTTPError if errors.As(err, &httpErr) { + // 仅对上游参数校验类状态码解析正文。其他状态码的正文可能是网关 HTML、 + // 鉴权诊断或含密钥的内部信息,归类价值低且更容易误判。 + switch httpErr.StatusCode { + case http.StatusBadRequest, http.StatusUnprocessableEntity: + if message, ok := providerPayloadErrorCategory(httpErr.Body); ok { + return message + } + } return httpErr.Error() } return "连接模型服务失败,请检查渠道地址和网络" } -func providerPayloadErrorMessage(raw string) string { +// providerPayloadErrorCategory 把上游失败正文归类为固定的用户可见原因。 +// 第二个返回值为 false 表示正文无法归类,调用方应退回到更通用的提示, +// 不要因为归类失败就把正文本身当作错误信息。 +// 正文可能包含密钥或内部诊断信息,只能参与归类,不得回传用户或写入日志。 +func providerPayloadErrorCategory(raw string) (string, bool) { normalized := strings.ToLower(strings.TrimSpace(raw)) - switch { - case strings.Contains(normalized, "safety"), strings.Contains(normalized, "moderation"), strings.Contains(normalized, "content policy"), strings.Contains(normalized, "blocked"): - return "请求内容未通过模型服务安全审核,请调整后重试" - case strings.Contains(normalized, "quota"), strings.Contains(normalized, "insufficient"), strings.Contains(normalized, "balance"), strings.Contains(normalized, "billing"): - return "模型服务额度不足,请检查渠道余额或配额" - case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")): - return "模型不存在或当前渠道未获得模型权限" - case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"): - return "模型服务拒绝了请求,请检查模型和参数" - default: - return "模型服务返回失败,请检查请求内容或渠道配置" + if normalized == "" { + return "", false } + switch { + // 真人肖像类目只匹配供应商错误码里的稳定标识,不扫描自然语言。 + // 正文常常回显用户提示词,"likeness"、"肖像"这类词单独出现并不能证明 + // 上游是因为真人形象拒绝,按词判断会把普通参数错误误报成肖像问题。 + // 该类目排在安全审核之前:错误码已经足够具体,比通用审核提示更可行动。 + case strings.Contains(normalized, "privacyinformation"), strings.Contains(normalized, "sensitivecontentdetected"): + return "输入素材疑似包含真人形象,该模型拒绝生成,请更换为非真人素材或改用其他模型", true + case strings.Contains(normalized, "safety"), strings.Contains(normalized, "moderation"), strings.Contains(normalized, "content policy"), strings.Contains(normalized, "blocked"): + return "请求内容未通过模型服务安全审核,请调整后重试", true + case strings.Contains(normalized, "quota"), strings.Contains(normalized, "insufficient"), strings.Contains(normalized, "balance"), strings.Contains(normalized, "billing"): + return "模型服务额度不足,请检查渠道余额或配额", true + case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")): + return "模型不存在或当前渠道未获得模型权限", true + case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"): + return "模型服务拒绝了请求,请检查模型和参数", true + } + return "", false +} + +func providerPayloadErrorMessage(raw string) string { + if message, ok := providerPayloadErrorCategory(raw); ok { + return message + } + return "模型服务返回失败,请检查请求内容或渠道配置" } func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string, taskProjectID string, taskType string, fallbackPrompt string, rawInput string) (map[string]interface{}, error) { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index e4075c2a1..0d508579b 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "mime/multipart" "net/http" @@ -385,6 +386,169 @@ func TestProviderPayloadErrorMessageUsesSafeActionableCategories(t *testing.T) { } } +func TestProviderPayloadErrorCategoryFlagsRealPersonRejection(t *testing.T) { + raw := `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"The request failed because the input image 'content[1]' may contain real person. Request id: secret-trace"}}` + message, ok := providerPayloadErrorCategory(raw) + if !ok { + t.Fatalf("providerPayloadErrorCategory() ok = false, want true") + } + if !strings.Contains(message, "真人形象") { + t.Fatalf("providerPayloadErrorCategory() = %q, want 真人形象 category", message) + } + if strings.Contains(message, "secret") || strings.Contains(message, "content[1]") { + t.Fatalf("provider payload detail leaked: %q", message) + } +} + +func TestProviderPayloadErrorCategoryReportsUnclassifiedBodies(t *testing.T) { + for _, raw := range []string{"", " ", "trace_id=private internal stack"} { + if message, ok := providerPayloadErrorCategory(raw); ok { + t.Fatalf("providerPayloadErrorCategory(%q) = %q, want no category", raw, message) + } + } +} + +func TestProviderUserFacingErrorMessageClassifiesRejectedRequestBodies(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + want string + }{ + { + name: "real person rejection", + statusCode: http.StatusBadRequest, + body: `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"input image may contain real person, secret-trace"}}`, + want: "真人形象", + }, + { + name: "moderation rejection", + statusCode: http.StatusBadRequest, + body: `{"error":{"message":"request blocked by content policy, secret-trace"}}`, + want: "安全审核", + }, + { + name: "unprocessable entity is classified too", + statusCode: http.StatusUnprocessableEntity, + body: `{"error":{"message":"insufficient balance, secret-trace"}}`, + want: "额度不足", + }, + { + name: "unclassified body keeps the generic parameter hint", + statusCode: http.StatusBadRequest, + body: `{"error":{"message":"trace_id=secret-trace"}}`, + want: "请检查模型和参数", + }, + { + name: "empty body keeps the generic parameter hint", + statusCode: http.StatusBadRequest, + body: "", + want: "请检查模型和参数", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := providerUserFacingErrorMessage(providerHTTPError{StatusCode: tt.statusCode, Body: tt.body}) + if !strings.Contains(message, tt.want) { + t.Fatalf("providerUserFacingErrorMessage() = %q, want category %q", message, tt.want) + } + if strings.Contains(message, "secret-trace") || strings.Contains(message, `{"error"`) { + t.Fatalf("provider response body leaked: %q", message) + } + }) + } +} + +func TestProviderUserFacingErrorMessageOnlyClassifiesValidationStatuses(t *testing.T) { + // 鉴权失败与网关错误的正文可能是密钥诊断或代理 HTML,不参与归类。 + for _, statusCode := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusBadGateway} { + message := providerUserFacingErrorMessage(providerHTTPError{ + StatusCode: statusCode, + Body: `{"error":{"message":"blocked by content policy, api-key=secret"}}`, + }) + if strings.Contains(message, "安全审核") { + t.Fatalf("status %d classified from response body: %q", statusCode, message) + } + if strings.Contains(message, "secret") || strings.Contains(message, "api-key") { + t.Fatalf("status %d leaked response body: %q", statusCode, message) + } + } +} + +func TestProviderUserFacingErrorMessageClassifiesWrappedHTTPErrors(t *testing.T) { + wrapped := fmt.Errorf("视频任务创建失败:%w", providerHTTPError{ + StatusCode: http.StatusBadRequest, + Body: `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"may contain real person","request_id":"secret-trace"}}`, + }) + message := providerUserFacingErrorMessage(wrapped) + if !strings.Contains(message, "真人形象") { + t.Fatalf("providerUserFacingErrorMessage() = %q, want 真人形象 category", message) + } + if strings.Contains(message, "secret-trace") || strings.Contains(message, `{"error"`) { + t.Fatalf("provider response body leaked through wrapped error: %q", message) + } +} + +// 正文经常回显用户提示词。肖像类词汇本身不能触发真人类目, +// 否则普通的参数错误或安全审核会被误报成肖像问题。 +func TestProviderPayloadErrorCategoryIgnoresEchoedPortraitWording(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + { + name: "echoed chinese portrait prompt stays a parameter error", + raw: `{"error":{"message":"invalid parameter: prompt=生成油画肖像"}}`, + want: "请检查模型和参数", + }, + { + name: "echoed english likeness prompt stays a parameter error", + raw: `{"error":{"message":"invalid argument: style=likeness study"}}`, + want: "请检查模型和参数", + }, + { + name: "moderation wins over echoed real person prompt", + raw: `{"error":{"message":"request blocked by content policy: prompt=real person portrait"}}`, + want: "安全审核", + }, + { + name: "bare real person prose is not classified as likeness", + raw: `{"error":{"message":"this model does not support real people yet"}}`, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message, ok := providerPayloadErrorCategory(tt.raw) + if tt.want == "" { + if ok { + t.Fatalf("providerPayloadErrorCategory() = %q, want no category", message) + } + return + } + if !ok { + t.Fatalf("providerPayloadErrorCategory() ok = false, want category %q", tt.want) + } + if strings.Contains(message, "真人形象") { + t.Fatalf("echoed portrait wording misclassified as likeness: %q", message) + } + if !strings.Contains(message, tt.want) { + t.Fatalf("providerPayloadErrorCategory() = %q, want category %q", message, tt.want) + } + }) + } +} + +// 供应商错误码与安全审核措辞同时出现时,以更具体的错误码为准。 +func TestProviderPayloadErrorCategoryPrefersProviderCodeOverModerationWording(t *testing.T) { + raw := `{"error":{"code":"InputImageSensitiveContentDetected.PrivacyInformation","message":"blocked by content policy"}}` + message, ok := providerPayloadErrorCategory(raw) + if !ok || !strings.Contains(message, "真人形象") { + t.Fatalf("providerPayloadErrorCategory() = %q, ok = %v, want 真人形象 category", message, ok) + } +} + func TestNormalizeNewAPIChannel2ResolutionPreservesDeclaredTiers(t *testing.T) { tests := map[string]string{ "1440": "1440p",