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={