From bcfec0347dab755bbf9bb8f39ac5f41e3c7a5fe4 Mon Sep 17 00:00:00 2001 From: VicoHu Date: Fri, 11 Sep 2026 13:58:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E6=96=B9=E8=88=9F=E5=8F=AF?= =?UTF-8?q?=E4=BF=A1=E7=B4=A0=E6=9D=90=20-=20=E5=A4=B1=E8=B4=A5=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E6=94=AF=E6=8C=81=E5=AE=89=E5=85=A8=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E5=B9=B6=E9=80=8F=E4=BC=A0=E6=96=B9=E8=88=9F=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 素材组未建成(两个 ID 均为空)的失败 binding 一律允许重试,网络中断、 区域配错、套餐未开通不再永久卡死资源;组已建时仅重试创建素材阶段失败 - 重试时复用 binding 上已建成的素材组,避免配置清空 DefaultGroupID 后重复建组 - 控制面 HTTP 非 2xx 时解析响应体 ResponseMetadata.Error,透传方舟原始 Code/Message(如 SubscriptionRequired),不再误报为模型服务鉴权失败 - 管理页 Region 输入补充 cn-beijing 与仅北京区域开放的说明 --- .../app/provider_ark_private_assets.go | 61 +++++++++--- .../app/provider_ark_private_assets_test.go | 98 +++++++++++++++++++ .../ark-private-assets-settings-page.tsx | 4 +- 3 files changed, 150 insertions(+), 13 deletions(-) diff --git a/backend/internal/app/provider_ark_private_assets.go b/backend/internal/app/provider_ark_private_assets.go index 2c9ec1444..e2e8fab99 100644 --- a/backend/internal/app/provider_ark_private_assets.go +++ b/backend/internal/app/provider_ark_private_assets.go @@ -215,9 +215,13 @@ func (s *Service) ensureArkPrivateAsset(ctx context.Context, userID string, reso } } - groupID, err := s.ensureArkPrivateAssetGroup(ctx, settingRecord, setting) - if err != nil { - return "", s.failArkPrivateAssetBinding(binding, err) + // 重试时复用已建成的素材组,避免管理员调整配置清空 DefaultGroupID 后重复建组。 + groupID := strings.TrimSpace(binding.AssetGroupID) + if groupID == "" { + groupID, err = s.ensureArkPrivateAssetGroup(ctx, settingRecord, setting) + if err != nil { + return "", s.failArkPrivateAssetBinding(binding, err) + } } binding.AssetGroupID = groupID resourceURL, err := s.directResourceURL(resource, time.Now().Add(time.Hour)) @@ -274,11 +278,19 @@ func (s *Service) ensureArkPrivateAssetGroup(ctx context.Context, settingRecord return groupID, nil } +// shouldRetryArkPrivateAssetBinding 判断失败的素材绑定能否安全重试。 +// 素材组尚未建成(两个 ID 均为空)时任何失败都可重试:网络中断、区域配错、 +// 套餐未开通都发生在建组之前,且绝无重复上传风险。素材组已建、素材未建时仅重试 +// 创建素材阶段的失败,最坏情况是组内多出一份素材。审核拒绝发生在拿到素材 ID 之后, +// ArkAssetID 非空,天然不会进入重试路径。 func shouldRetryArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) bool { - if binding == nil || strings.ToLower(strings.TrimSpace(binding.Status)) != arkPrivateAssetStatusFail || binding.AssetGroupID != "" || binding.ArkAssetID != "" { + if binding == nil || strings.ToLower(strings.TrimSpace(binding.Status)) != arkPrivateAssetStatusFail || binding.ArkAssetID != "" { return false } - return strings.Contains(binding.Error, "方舟素材库没有返回素材组 ID") || strings.Contains(binding.Error, "方舟素材库没有返回素材 ID") + if binding.AssetGroupID == "" { + return true + } + return strings.Contains(binding.Error, "上传方舟可信素材失败") || strings.Contains(binding.Error, "方舟素材库没有返回素材 ID") } func shouldResumeArkPrivateAssetPolling(binding *model.ArkPrivateAssetBinding) bool { @@ -384,18 +396,45 @@ func callArkPrivateAssetAPI(ctx context.Context, setting arkPrivateAssetSettingV } var response map[string]interface{} if err := doJSON(credentials.Sign(req), &response); err != nil { + // doJSON 对非 2xx 响应只按状态码生成通用提示(401/403 会被说成“模型服务鉴权失败”), + // 方舟把真实原因(如 SubscriptionRequired)放在响应体的 ResponseMetadata.Error 里,必须还原。 + var httpErr providerHTTPError + if errors.As(err, &httpErr) && strings.TrimSpace(httpErr.Body) != "" { + var body map[string]interface{} + if json.Unmarshal([]byte(httpErr.Body), &body) == nil { + if detail := arkPrivateAssetUpstreamDetail(body); detail != "" { + return nil, fmt.Errorf("方舟素材库请求失败(HTTP %d):%s", httpErr.StatusCode, detail) + } + } + } return nil, err } - if metadata, ok := response["ResponseMetadata"].(map[string]interface{}); ok { - if upstream, ok := metadata["Error"].(map[string]interface{}); ok { - code := stringField(upstream, "Code") - message := stringField(upstream, "Message") - return nil, errors.New(defaultString(strings.TrimSpace(strings.Trim(strings.Join([]string{code, message}, " "), " ")), "方舟素材库请求失败")) - } + if detail := arkPrivateAssetUpstreamDetail(response); detail != "" { + return nil, errors.New(detail) } return response, nil } +// arkPrivateAssetUpstreamDetail 提取方舟控制面响应里 ResponseMetadata.Error 的 Code/Message, +// 用于把上游真实失败原因透传给用户;没有上游错误时返回空串。 +func arkPrivateAssetUpstreamDetail(response map[string]interface{}) string { + metadata, _ := response["ResponseMetadata"].(map[string]interface{}) + if metadata == nil { + return "" + } + upstream, _ := metadata["Error"].(map[string]interface{}) + if upstream == nil { + return "" + } + code := stringField(upstream, "Code") + message := stringField(upstream, "Message") + detail := strings.TrimSpace(strings.Trim(strings.Join([]string{code, message}, " "), " ")) + if detail == "" { + return "方舟素材库请求失败" + } + return detail +} + func arkPrivateAssetControlPlaneURL(region string) (string, error) { if override := strings.TrimSpace(arkPrivateAssetAPIBaseURLOverride); override != "" { return override, nil diff --git a/backend/internal/app/provider_ark_private_assets_test.go b/backend/internal/app/provider_ark_private_assets_test.go index d1704a162..c6c890c4f 100644 --- a/backend/internal/app/provider_ark_private_assets_test.go +++ b/backend/internal/app/provider_ark_private_assets_test.go @@ -7,6 +7,8 @@ import ( "net/http/httptest" "strings" "testing" + + "infinite-canvas/backend/internal/model" ) func TestArkPrivateAssetUsesRegionalArkControlPlane(t *testing.T) { @@ -157,3 +159,99 @@ func TestArkPrivateAssetResponseFieldReadsCreateGroupID(t *testing.T) { t.Fatalf("group ID = %q", got) } } + +func TestShouldRetryArkPrivateAssetBinding(t *testing.T) { + cases := []struct { + name string + binding *model.ArkPrivateAssetBinding + want bool + }{ + { + name: "素材组未建成时网络失败可重试", + binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, Error: `创建方舟素材组失败:Post "https://ark.cn-shanghai.volcengineapi.com/": EOF`}, + want: true, + }, + { + name: "素材组未建成时上游业务拒绝也可重试", + binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, Error: "创建方舟素材组失败:方舟素材库请求失败(HTTP 403):SubscriptionRequired"}, + want: true, + }, + { + name: "素材组已建时上传素材失败可重试", + binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", Error: "上传方舟可信素材失败:Post \"https://ark.cn-beijing.volcengineapi.com/\": EOF"}, + want: true, + }, + { + name: "素材组已建时素材 ID 解析失败可重试", + binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", Error: "方舟素材库没有返回素材 ID"}, + want: true, + }, + { + name: "审核拒绝保持终态不重试", + binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", ArkAssetID: "asset-1", Error: "FaceMismatch: Face consistency verification failed."}, + want: false, + }, + { + name: "素材组已建时的其他失败不重试", + binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusFail, AssetGroupID: "group-1", Error: "生成方舟素材临时地址失败:对象存储不可用"}, + want: false, + }, + { + name: "非失败状态不重试", + binding: &model.ArkPrivateAssetBinding{Status: arkPrivateAssetStatusLive, ArkAssetID: "asset-1"}, + want: false, + }, + } + for _, item := range cases { + if got := shouldRetryArkPrivateAssetBinding(item.binding); got != item.want { + t.Fatalf("%s: shouldRetryArkPrivateAssetBinding() = %v, want %v", item.name, got, item.want) + } + } + if shouldRetryArkPrivateAssetBinding(nil) { + t.Fatal("shouldRetryArkPrivateAssetBinding(nil) = true, want false") + } +} + +func TestCallArkPrivateAssetAPIPassesThroughUpstreamHTTPErrors(t *testing.T) { + t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"ResponseMetadata":{"Error":{"Code":"SubscriptionRequired","Message":"This API requires an active subscription."}}}`)) + })) + defer server.Close() + + previousBaseURL := arkPrivateAssetAPIBaseURLOverride + arkPrivateAssetAPIBaseURLOverride = server.URL + t.Cleanup(func() { arkPrivateAssetAPIBaseURLOverride = previousBaseURL }) + + _, err := callArkPrivateAssetAPI(context.Background(), arkPrivateAssetSettingValue{ + Region: "test-region", AccessKeyID: "test-access-key", AccessKeySecret: "test-secret-key", + }, "CreateAssetGroup", map[string]interface{}{"ProjectName": "project-test"}) + if err == nil { + t.Fatal("callArkPrivateAssetAPI() error = nil, want upstream passthrough error") + } + for _, want := range []string{"403", "SubscriptionRequired", "This API requires an active subscription."} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("callArkPrivateAssetAPI() error = %q, want contains %q", err.Error(), want) + } + } +} + +func TestArkPrivateAssetUpstreamDetail(t *testing.T) { + if got := arkPrivateAssetUpstreamDetail(map[string]interface{}{}); got != "" { + t.Fatalf("empty response detail = %q, want empty", got) + } + response := map[string]interface{}{ + "ResponseMetadata": map[string]interface{}{ + "Error": map[string]interface{}{"Code": "FaceMismatch", "Message": "Face consistency verification failed."}, + }, + } + if got := arkPrivateAssetUpstreamDetail(response); got != "FaceMismatch Face consistency verification failed." { + t.Fatalf("detail = %q", got) + } + emptyError := map[string]interface{}{"ResponseMetadata": map[string]interface{}{"Error": map[string]interface{}{}}} + if got := arkPrivateAssetUpstreamDetail(emptyError); got != "方舟素材库请求失败" { + t.Fatalf("empty upstream error detail = %q", got) + } +} diff --git a/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx b/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx index e008af584..5e65d71c4 100644 --- a/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx +++ b/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx @@ -285,8 +285,8 @@ export default function ArkPrivateAssetsSettingsPage() { status={} >
- - + +