fix(canvas): 方舟可信素材 - 失败缓存支持安全重试并透传方舟原始错误
- 素材组未建成(两个 ID 均为空)的失败 binding 一律允许重试,网络中断、 区域配错、套餐未开通不再永久卡死资源;组已建时仅重试创建素材阶段失败 - 重试时复用 binding 上已建成的素材组,避免配置清空 DefaultGroupID 后重复建组 - 控制面 HTTP 非 2xx 时解析响应体 ResponseMetadata.Error,透传方舟原始 Code/Message(如 SubscriptionRequired),不再误报为模型服务鉴权失败 - 管理页 Region 输入补充 cn-beijing 与仅北京区域开放的说明
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="方舟项目名称" />
|
||||
|
||||
Reference in New Issue
Block a user