From c8a91fd2e90a4242a870657db021465b421b45d8 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Tue, 22 Sep 2026 10:48:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E6=A8=A1=E5=9E=8B=E7=94=9F?= =?UTF-8?q?=E6=88=90=20-=20=E4=BF=9D=E7=95=99=E5=AE=89=E5=85=A8=E7=9A=84?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0=E5=B9=B6?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=88=B0=E7=94=A8=E6=88=B7=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/analytics.go | 2 +- backend/internal/app/provider.go | 22 +++--- .../internal/app/provider_error_message.go | 55 ++++++++++++++ .../app/provider_error_message_test.go | 71 +++++++++++++++++++ docs/content/docs/backend/http-api.mdx | 2 + docs/content/docs/progress/pending-test.mdx | 6 ++ web/src/lib/generation-error.ts | 25 +++++-- web/test/generation-error-details.test.ts | 24 +++++++ 8 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 backend/internal/app/provider_error_message.go create mode 100644 backend/internal/app/provider_error_message_test.go create mode 100644 web/test/generation-error-details.test.ts diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index 2f1150780..68d28a3f5 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -995,7 +995,7 @@ func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, response Body: string(responseBody), }) detail := strings.TrimSpace(log.Error) - if detail == "" || detail == userMessage { + if detail == "" || detail == userMessage || strings.Contains(userMessage, ";上游:"+detail) { log.Error = userMessage return } diff --git a/backend/internal/app/provider.go b/backend/internal/app/provider.go index ca24c06fc..54e104e07 100644 --- a/backend/internal/app/provider.go +++ b/backend/internal/app/provider.go @@ -128,7 +128,7 @@ type providerError struct { } // providerPayloadError 在进程内保留上游原始原因,供协议兼容分支做机器判断; -// 对调用方只暴露归类后的稳定文案。Provider 正文可能包含密钥或内部诊断, +// 对调用方只暴露经过过滤的错误原因。Provider 正文可能包含密钥或内部诊断, // 禁止原样进入用户错误和日志。 type providerPayloadError struct { raw string @@ -230,6 +230,13 @@ func withProviderRequestKind(ctx context.Context, requestKind string) context.Co } func (e providerHTTPError) Error() string { + if e.StatusCode == http.StatusBadRequest || e.StatusCode == http.StatusUnprocessableEntity { + return providerErrorWithDetail(e.summary(), e.Body) + } + return appendProviderErrorDetail(e.summary(), e.Body) +} + +func (e providerHTTPError) summary() string { switch e.StatusCode { case 524: return "上游网关超时(524):模型请求可能仍在服务端执行并产生费用,请勿立即重试,请先到供应商后台核对任务或账单" @@ -266,14 +273,6 @@ 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 "连接模型服务失败,请检查渠道地址和网络" @@ -316,10 +315,7 @@ func providerPayloadErrorCategory(raw string) (string, bool) { } func providerPayloadErrorMessage(raw string) string { - if message, ok := providerPayloadErrorCategory(raw); ok { - return message - } - return "模型服务返回失败,请检查请求内容或渠道配置" + return providerErrorWithDetail("模型服务返回失败,请检查请求内容或渠道配置", raw) } 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/app/provider_error_message.go b/backend/internal/app/provider_error_message.go new file mode 100644 index 000000000..66f3e0903 --- /dev/null +++ b/backend/internal/app/provider_error_message.go @@ -0,0 +1,55 @@ +package app + +import ( + "encoding/json" + "regexp" + "strings" +) + +var providerErrorURL = regexp.MustCompile(`(?i)https?://[^\s<>"',。;)]+`) +var providerErrorSensitive = regexp.MustCompile(`(?i)余额|额度|配额|欠费|账单|充值|计费|密钥|密码|令牌|balance|quota|billing|credit|payment|funds|wallet|api[ _-]?key|authorization|bearer|cookie|(?:access|refresh|auth|session)[ _-]?token|token\s*[=:]|secret|password|credential|sk-[a-z0-9]|tenant|trace[ _-]?id|request[ _-]?id|internal stack|stack\s*trace|dial tcp|no such host|prompt\s*[=:]`) + +// 只提取错误消息字段,不序列化整个响应,避免回传 headers、请求正文和诊断信息。 +func providerErrorDetail(raw string) string { + raw = strings.TrimSpace(raw) + var payload any + if json.Unmarshal([]byte(raw), &payload) == nil { + raw = providerErrorMessageField(payload) + } else if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") { + return "" + } + // 网关 HTML、敏感账务/凭据诊断整条隐藏;普通消息里的 URL 单独移除。 + if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") || strings.ContainsAny(raw, "<>") || providerErrorSensitive.MatchString(raw) { + return "" + } + raw = providerErrorURL.ReplaceAllString(raw, "[链接已隐藏]") + return truncateRunes(strings.Join(strings.Fields(raw), " "), 1_500) +} + +func providerErrorMessageField(value any) string { + switch value := value.(type) { + case string: + return strings.TrimSpace(value) + case map[string]any: + for _, key := range []string{"error", "message", "msg", "detail"} { + if message := providerErrorMessageField(value[key]); message != "" { + return message + } + } + } + return "" +} + +func providerErrorWithDetail(fallback, raw string) string { + if message, ok := providerPayloadErrorCategory(raw); ok { + fallback = message + } + return appendProviderErrorDetail(fallback, raw) +} + +func appendProviderErrorDetail(fallback, raw string) string { + if detail := providerErrorDetail(raw); detail != "" && detail != fallback { + return fallback + ";上游:" + detail + } + return fallback +} diff --git a/backend/internal/app/provider_error_message_test.go b/backend/internal/app/provider_error_message_test.go new file mode 100644 index 000000000..aab7fe34c --- /dev/null +++ b/backend/internal/app/provider_error_message_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProviderErrorDetail(t *testing.T) { + for _, tt := range []struct{ name, raw, want string }{ + {"moderation", `{"error":{"message":"非常抱歉,生成的图片可能违反了关于裸露、色情或情色内容的防护限制。请重试或修改提示语。"}}`, "非常抱歉,生成的图片可能违反了关于裸露、色情或情色内容的防护限制。请重试或修改提示语。"}, + {"parameter", `{"message":"size must be 1024x1024","request_id":"private","url":"https://private.test"}`, "size must be 1024x1024"}, + {"url", `{"error":"请修改图片尺寸 https://private.test/path?q=value"}`, "请修改图片尺寸 [链接已隐藏]"}, + {"detail", `{"detail":"请上传 PNG 图片"}`, "请上传 PNG 图片"}, + {"plain", "请缩短提示词", "请缩短提示词"}, + {"token limit", `{"message":"max_tokens must be less than 4096"}`, "max_tokens must be less than 4096"}, + {"balance", `{"message":"渠道余额不足,剩余 1.25 元"}`, ""}, + {"billing", `{"message":"insufficient credits: $0.25"}`, ""}, + {"key", `{"message":"invalid api_key=private"}`, ""}, + {"cookie", `{"message":"Cookie: session=private"}`, ""}, + {"token", `{"message":"access_token=private"}`, ""}, + {"html", "502 private gateway", ""}, + {"malformed", `{"message":"partial`, ""}, + {"diagnostics", `{"request_id":"private","headers":{"Authorization":"private"}}`, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := providerErrorDetail(tt.raw); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestTaskFailurePreservesSafeProviderDetail(t *testing.T) { + const detail = "非常抱歉,生成的图片可能违反了关于裸露、色情或情色内容的防护限制。请重试或修改提示语。" + for _, status := range []int{400, 403, 422, 429, 500, 502} { + err := fmt.Errorf("图片生成失败:%w", providerHTTPError{StatusCode: status, Body: `{"error":{"message":"` + detail + `"}}`}) + if got := taskFailureMessage(err); !strings.Contains(got, ";上游:"+detail) { + t.Fatalf("status %d lost detail: %s", status, got) + } + } + if got := taskFailureMessage(errors.New(providerPayloadErrorMessage(detail))); !strings.Contains(got, detail) { + t.Fatalf("business failure lost detail: %s", got) + } +} + +func TestProviderErrorDetailThroughHTTPAndBusinessFailures(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + const detail = "生成的图片可能违反了关于裸露、色情或情色内容的防护限制。请修改提示语。" + for _, status := range []int{http.StatusBadRequest, http.StatusOK} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + fmt.Fprintf(w, `{"error":{"message":%q}}`, detail) + })) + for _, target := range []any{&imageResponse{}, &map[string]interface{}{}} { + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + if err != nil { + t.Fatal(err) + } + err = doJSON(req, target) + if err == nil || !strings.Contains(taskFailureMessage(err), detail) { + t.Errorf("status %d target %T lost upstream detail: %v", status, target, err) + } + } + server.Close() + } +} diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index f900b8416..356371670 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -11,6 +11,8 @@ description: 信封、错误码与 OpenAPI 出站模型服务域名解析失败返回 HTTP / `code: 502`、`reason: upstream_dns_failed`,不归类为用户参数错误。公开消息不包含上游域名、DNS 服务器或请求 URL;原始解析错误仅保留在内部错误链。此分类不会自动重试、切换渠道或放宽 SSRF 校验;上下文取消仍按取消处理。 +模型 HTTP 失败及经过 Provider 错误转换的业务失败,在通用提示后追加经过过滤的 `;上游:具体原因`,供任务错误和画布卡片展示内容安全、提示词、尺寸等可操作信息。只读取上游 `error` / `message` / `msg` / `detail` 消息字段,不回传整个 JSON;消息中的 HTTP(S) URL 隐藏,包含余额、账务、凭据、请求诊断或 HTML 的消息不追加原文,保留通用分类提示。后台响应拦截规则仍对最终用户文案生效;不会修改历史任务错误或改变重试、退款策略。 + ## 素材彻底删除 `DELETE /assets/:id` 表示用户确认后的彻底删除,不依赖服务端当前记录是否已经同步为 `archived`。接口仍校验登录用户和素材归属,但不会因画布、任务、项目或历史快照仍有引用而拒绝;事务会删除素材及对应历史资源索引,并通过删除 Outbox 清理未被其他素材复用的物理对象。其他素材共享的同一物理对象继续保留。移入回收站仍通过素材写入接口把状态更新为 `archived`,不能用 DELETE 代替可恢复归档。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f4b050513..ec563cc2a 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,12 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 模型失败原因在画布卡片展示 + +- [x] 后端 Provider、错误分类和请求日志专项回归通过;本地模拟上游验证 HTTP 400 与 HTTP 200 业务失败均保留安全原因,覆盖图片响应和通用 JSON 响应。前端 4 项专项测试通过,覆盖具体原因、HTTP 状态提示、审核元数据及 URL 隐藏。 +- [ ] 使用内容安全拒绝、参数错误、业务失败和 HTTP 4xx/5xx 的真实模型任务,确认卡片保留上游具体原因;检查长中文消息换行及明暗主题。 +- [ ] 确认 URL 被隐藏,余额、密钥、Cookie 和内部诊断不出现在卡片;后台响应拦截规则仍能替换最终消息。历史任务不会自动补回已丢弃的原因。 + ## 管理后台轻边界与高密度改造(第一轮) - [x] 公共后台外壳统一浅深色表面、弱化重复外框,保留表格行分隔、输入边界和键盘焦点;导航按监控排查、模型资源、用户运营、平台配置组织。表格默认紧凑模式,支持舒适模式,偏好按账号保存。 diff --git a/web/src/lib/generation-error.ts b/web/src/lib/generation-error.ts index 65c5c9be1..8ba19fd1f 100644 --- a/web/src/lib/generation-error.ts +++ b/web/src/lib/generation-error.ts @@ -15,7 +15,7 @@ export function generationFailureMetadata(error: unknown, prompt: string): Gener const raw = rawGenerationError(error); if (!isContentModerationError(raw)) return { errorDetails: generationErrorMessage(error) }; return { - errorDetails: CONTENT_MODERATION_MESSAGE, + errorDetails: generationErrorMessage(error), generationErrorCode: CONTENT_MODERATION_ERROR_CODE, failedPromptFingerprint: generationPromptFingerprint(prompt), }; @@ -23,23 +23,34 @@ export function generationFailureMetadata(error: unknown, prompt: string): Gener export function generationErrorMessage(error: unknown) { const raw = rawGenerationError(error); - if (isContentModerationError(raw)) return CONTENT_MODERATION_MESSAGE; + if (isContentModerationError(raw)) return contentModerationMessage(raw); const providerMessage = extractStructuredProviderMessage(raw) || extractWrappedProviderMessage(raw); const displayMessage = providerMessage || raw; - if (isContentModerationError(displayMessage)) return CONTENT_MODERATION_MESSAGE; + if (isContentModerationError(displayMessage)) return contentModerationMessage(displayMessage); const resourceStorageMessage = resourceStorageFailureMessage(raw) || resourceStorageFailureMessage(displayMessage); if (resourceStorageMessage) return resourceStorageMessage; if (isNetworkFailure(displayMessage)) return NETWORK_ERROR_MESSAGE; if (!providerMessage) { - if (hasHttpStatus(raw, 429)) return "服务当前繁忙,请稍后重试。"; - if (hasHttpStatus(raw, 401, 403)) return "生成服务鉴权失败,请检查渠道配置。"; - if (hasHttpStatus(raw, 404)) return "生成服务地址不可用,请检查渠道配置。"; - if (hasHttpStatus(raw, 500, 502, 503, 504) || containsInfrastructureDetails(raw)) return NETWORK_ERROR_MESSAGE; + if (!displayMessage.includes(";上游:")) { + if (hasHttpStatus(raw, 429)) return "服务当前繁忙,请稍后重试。"; + if (hasHttpStatus(raw, 401, 403)) return "生成服务鉴权失败,请检查渠道配置。"; + if (hasHttpStatus(raw, 404)) return "生成服务地址不可用,请检查渠道配置。"; + if (hasHttpStatus(raw, 500, 502, 503, 504)) return NETWORK_ERROR_MESSAGE; + } + if (containsInfrastructureDetails(raw)) return NETWORK_ERROR_MESSAGE; } return displayMessage || DEFAULT_GENERATION_ERROR_MESSAGE; } +function contentModerationMessage(raw: string) { + const detailIndex = raw.indexOf(";上游:"); + // 保留服务端过滤后的拒绝原因,同时保持平台审核的积分提示及重试保护。 + return detailIndex >= 0 && !containsInfrastructureDetails(raw) + ? CONTENT_MODERATION_MESSAGE + raw.slice(detailIndex) + : CONTENT_MODERATION_MESSAGE; +} + export function generationErrorCode(error: unknown) { if (error && typeof error === "object" && "code" in error && typeof (error as { code?: unknown }).code === "string") { const code = (error as { code: string }).code; diff --git a/web/test/generation-error-details.test.ts b/web/test/generation-error-details.test.ts new file mode 100644 index 000000000..0bbc877ba --- /dev/null +++ b/web/test/generation-error-details.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { CONTENT_MODERATION_MESSAGE, generationErrorMessage, generationFailureMetadata } from "../src/lib/generation-error"; + +describe("safe upstream error details", () => { + const detail = "非常抱歉,生成的图片可能违反了关于裸露、色情或情色内容的防护限制。请重试或修改提示语。"; + test("keeps the specific image rejection on the card", () => { + const message = `模型服务拒绝了请求,请检查模型和参数;上游:${detail}`; + expect(generationErrorMessage(new Error(message))).toBe(message); + }); + test("does not replace a safe upstream message with a generic HTTP hint", () => { + const message = `模型服务暂时不可用(HTTP 500);上游:${detail}`; + expect(generationErrorMessage(message)).toBe(message); + expect(generationErrorMessage("模型服务暂时不可用(HTTP 500)")).toBe("网络异常。"); + }); + test("keeps details when moderation metadata is stored", () => { + const message = `内容审核未通过;上游:${detail}`; + const metadata = generationFailureMetadata(message, "test prompt"); + expect(metadata.errorDetails).toBe(`${CONTENT_MODERATION_MESSAGE};上游:${detail}`); + expect(metadata.generationErrorCode).toBe("sensitive_words_detected"); + }); + test("upstream marker does not bypass URL hiding", () => { + expect(generationErrorMessage("模型服务请求失败;上游:https://private.test")).toBe("网络异常。"); + }); +});