fix(canvas): 方舟可信素材 - 失败缓存支持安全重试并透传方舟原始错误
- 素材组未建成(两个 ID 均为空)的失败 binding 一律允许重试,网络中断、 区域配错、套餐未开通不再永久卡死资源;组已建时仅重试创建素材阶段失败 - 重试时复用 binding 上已建成的素材组,避免配置清空 DefaultGroupID 后重复建组 - 控制面 HTTP 非 2xx 时解析响应体 ResponseMetadata.Error,透传方舟原始 Code/Message(如 SubscriptionRequired),不再误报为模型服务鉴权失败 - 管理页 Region 输入补充 cn-beijing 与仅北京区域开放的说明
This commit is contained in:
1 parent
32ae91a35b
commit
bcfec0347d
3 files changed
+150
-13
No files matched your search
@@ -215,9 +215,13 @@ func (s *Service) ensureArkPrivateAsset(ctx context.Context, userID string, reso
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
groupID, err := s.ensureArkPrivateAssetGroup(ctx, settingRecord, setting)
|
// 重试时复用已建成的素材组,避免管理员调整配置清空 DefaultGroupID 后重复建组。
|
||||||
if err != nil {
|
groupID := strings.TrimSpace(binding.AssetGroupID)
|
||||||
return "", s.failArkPrivateAssetBinding(binding, err)
|
if groupID == "" {
|
||||||
|
groupID, err = s.ensureArkPrivateAssetGroup(ctx, settingRecord, setting)
|
||||||
|
if err != nil {
|
||||||
|
return "", s.failArkPrivateAssetBinding(binding, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
binding.AssetGroupID = groupID
|
binding.AssetGroupID = groupID
|
||||||
resourceURL, err := s.directResourceURL(resource, time.Now().Add(time.Hour))
|
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
|
return groupID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldRetryArkPrivateAssetBinding 判断失败的素材绑定能否安全重试。
|
||||||
|
// 素材组尚未建成(两个 ID 均为空)时任何失败都可重试:网络中断、区域配错、
|
||||||
|
// 套餐未开通都发生在建组之前,且绝无重复上传风险。素材组已建、素材未建时仅重试
|
||||||
|
// 创建素材阶段的失败,最坏情况是组内多出一份素材。审核拒绝发生在拿到素材 ID 之后,
|
||||||
|
// ArkAssetID 非空,天然不会进入重试路径。
|
||||||
func shouldRetryArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) bool {
|
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 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 {
|
func shouldResumeArkPrivateAssetPolling(binding *model.ArkPrivateAssetBinding) bool {
|
||||||
@@ -384,18 +396,45 @@ func callArkPrivateAssetAPI(ctx context.Context, setting arkPrivateAssetSettingV
|
|||||||
}
|
}
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
if err := doJSON(credentials.Sign(req), &response); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if metadata, ok := response["ResponseMetadata"].(map[string]interface{}); ok {
|
if detail := arkPrivateAssetUpstreamDetail(response); detail != "" {
|
||||||
if upstream, ok := metadata["Error"].(map[string]interface{}); ok {
|
return nil, errors.New(detail)
|
||||||
code := stringField(upstream, "Code")
|
|
||||||
message := stringField(upstream, "Message")
|
|
||||||
return nil, errors.New(defaultString(strings.TrimSpace(strings.Trim(strings.Join([]string{code, message}, " "), " ")), "方舟素材库请求失败"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return response, nil
|
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) {
|
func arkPrivateAssetControlPlaneURL(region string) (string, error) {
|
||||||
if override := strings.TrimSpace(arkPrivateAssetAPIBaseURLOverride); override != "" {
|
if override := strings.TrimSpace(arkPrivateAssetAPIBaseURLOverride); override != "" {
|
||||||
return override, nil
|
return override, nil
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"infinite-canvas/backend/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestArkPrivateAssetUsesRegionalArkControlPlane(t *testing.T) {
|
func TestArkPrivateAssetUsesRegionalArkControlPlane(t *testing.T) {
|
||||||
@@ -157,3 +159,99 @@ func TestArkPrivateAssetResponseFieldReadsCreateGroupID(t *testing.T) {
|
|||||||
t.Fatalf("group ID = %q", got)
|
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"} />}
|
status={<AdminStatusBadge label={prerequisitesReady ? "已配置" : "待配置"} tone={prerequisitesReady ? "success" : "neutral"} />}
|
||||||
>
|
>
|
||||||
<div className="admin-ark-form-grid">
|
<div className="admin-ark-form-grid">
|
||||||
<Form.Item name="region" label="Region">
|
<Form.Item name="region" label="Region" extra="方舟控制面当前仅 cn-beijing 区域开放,其他区域连接会被直接重置。">
|
||||||
<Input autoComplete="off" placeholder="例如:cn-beijing" />
|
<Input autoComplete="off" placeholder="cn-beijing" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="projectName" label="Ark ProjectName">
|
<Form.Item name="projectName" label="Ark ProjectName">
|
||||||
<Input autoComplete="off" placeholder="方舟项目名称" />
|
<Input autoComplete="off" placeholder="方舟项目名称" />
|
||||||
|
|||||||
Reference in new issue
Block a user