fix(canvas): 方舟可信素材 - 失败缓存支持安全重试并透传方舟原始错误

- 素材组未建成(两个 ID 均为空)的失败 binding 一律允许重试,网络中断、
  区域配错、套餐未开通不再永久卡死资源;组已建时仅重试创建素材阶段失败
- 重试时复用 binding 上已建成的素材组,避免配置清空 DefaultGroupID 后重复建组
- 控制面 HTTP 非 2xx 时解析响应体 ResponseMetadata.Error,透传方舟原始
  Code/Message(如 SubscriptionRequired),不再误报为模型服务鉴权失败
- 管理页 Region 输入补充 cn-beijing 与仅北京区域开放的说明
This commit is contained in:
VicoHu
2026-09-11 13:58:27 +08:00
committed by ddCat
parent 32ae91a35b
commit bcfec0347d
3 changed files with 150 additions and 13 deletions

View File

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

View File

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

View File

@@ -285,8 +285,8 @@ export default function ArkPrivateAssetsSettingsPage() {
status={<AdminStatusBadge label={prerequisitesReady ? "已配置" : "待配置"} tone={prerequisitesReady ? "success" : "neutral"} />}
>
<div className="admin-ark-form-grid">
<Form.Item name="region" label="Region">
<Input autoComplete="off" placeholder="例如:cn-beijing" />
<Form.Item name="region" label="Region" extra="方舟控制面当前仅 cn-beijing 区域开放,其他区域连接会被直接重置。">
<Input autoComplete="off" placeholder="cn-beijing" />
</Form.Item>
<Form.Item name="projectName" label="Ark ProjectName">
<Input autoComplete="off" placeholder="方舟项目名称" />