fix(canvas): 模型生成 - 保留安全的上游失败原因并展示到用户卡片

This commit is contained in:
ddCat
2026-09-22 10:48:25 +08:00
parent 5ea9466bb4
commit c8a91fd2e9
8 changed files with 186 additions and 21 deletions

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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", "<html>502 private gateway</html>", ""},
{"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()
}
}