- 素材组未建成(两个 ID 均为空)的失败 binding 一律允许重试,网络中断、 区域配错、套餐未开通不再永久卡死资源;组已建时仅重试创建素材阶段失败 - 重试时复用 binding 上已建成的素材组,避免配置清空 DefaultGroupID 后重复建组 - 控制面 HTTP 非 2xx 时解析响应体 ResponseMetadata.Error,透传方舟原始 Code/Message(如 SubscriptionRequired),不再误报为模型服务鉴权失败 - 管理页 Region 输入补充 cn-beijing 与仅北京区域开放的说明
479 lines
18 KiB
Go
479 lines
18 KiB
Go
package app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/model"
|
||
|
||
"github.com/volcengine/volc-sdk-golang/base"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
const (
|
||
arkPrivateAssetAPIVersion = "2024-01-01"
|
||
arkPrivateAssetGroupType = "AIGC"
|
||
arkPrivateAssetStatusNew = "creating"
|
||
arkPrivateAssetStatusWait = "processing"
|
||
arkPrivateAssetStatusLive = "active"
|
||
arkPrivateAssetStatusFail = "failed"
|
||
arkPrivateAssetPollLimit = 3 * time.Minute
|
||
)
|
||
|
||
// 测试可以注入本地控制面服务;生产环境必须根据管理员显式配置的 Region 推导方舟控制面地址,
|
||
// 不能接受客户端传入任意控制面 URL。
|
||
var arkPrivateAssetAPIBaseURLOverride string
|
||
|
||
type taskExecutionIDContextKey struct{}
|
||
|
||
func withTaskExecutionID(ctx context.Context, taskID string) context.Context {
|
||
return context.WithValue(ctx, taskExecutionIDContextKey{}, strings.TrimSpace(taskID))
|
||
}
|
||
|
||
func taskExecutionID(ctx context.Context) string {
|
||
id, _ := ctx.Value(taskExecutionIDContextKey{}).(string)
|
||
return strings.TrimSpace(id)
|
||
}
|
||
|
||
func withoutProviderAnalytics(ctx context.Context) context.Context {
|
||
return context.WithValue(ctx, providerAnalyticsKey{}, providerAnalyticsContext{})
|
||
}
|
||
|
||
func (s *Service) prepareArkPrivateAssetReferences(ctx context.Context, userID string, input *canvasGenerationInput) error {
|
||
if input == nil || !isArkPrivateAssetVideoConfig(input.Config) || !parseBool(input.Config.ArkPrivateAssetUpload, true) {
|
||
return nil
|
||
}
|
||
hasOwnedReference := false
|
||
for _, reference := range input.ReferenceImages {
|
||
if arkPrivateAssetResourceID(reference) != "" {
|
||
hasOwnedReference = true
|
||
break
|
||
}
|
||
}
|
||
if !hasOwnedReference {
|
||
return nil
|
||
}
|
||
settingRecord, setting, err := s.readArkPrivateAssetSetting()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
automaticSyncEnabled, err := arkPrivateAssetAutomaticSyncEnabled(setting)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !automaticSyncEnabled {
|
||
return nil
|
||
}
|
||
if taskID := taskExecutionID(ctx); taskID != "" {
|
||
_ = s.repo.UpdateTaskProgress(taskID, "同步方舟可信素材", 36)
|
||
}
|
||
for index := range input.ReferenceImages {
|
||
reference := &input.ReferenceImages[index]
|
||
if strings.HasPrefix(strings.TrimSpace(reference.URL), "asset://") {
|
||
continue
|
||
}
|
||
resourceID := arkPrivateAssetResourceID(*reference)
|
||
if resourceID == "" {
|
||
continue
|
||
}
|
||
resource, err := s.Resource(userID, resourceID)
|
||
if err != nil {
|
||
return fmt.Errorf("读取方舟参考素材失败:%w", err)
|
||
}
|
||
if resource.Status != model.ResourceStatusReady {
|
||
return errors.New("方舟参考素材尚未上传完成")
|
||
}
|
||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(resource.MimeType)), "image/") {
|
||
return errors.New("方舟可信素材库当前只支持上传图片参考素材")
|
||
}
|
||
assetID, err := s.ensureArkPrivateAsset(ctx, userID, resource, settingRecord, &setting)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
reference.URL = "asset://" + assetID
|
||
reference.DataURL = ""
|
||
}
|
||
if taskID := taskExecutionID(ctx); taskID != "" {
|
||
_ = s.repo.UpdateTaskProgress(taskID, "调用生成模型", 40)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func isArkPrivateAssetVideoConfig(config providerConfig) bool {
|
||
return config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) || isArkPlanVideoConfig(config)
|
||
}
|
||
|
||
func arkPrivateAssetAutomaticSyncEnabled(setting arkPrivateAssetSettingValue) (bool, error) {
|
||
// 可信素材同步是可选增强能力。管理员未启用时保持原始参考 URL,
|
||
// 继续执行常规火山方舟视频请求。
|
||
if !setting.Enabled {
|
||
return false, nil
|
||
}
|
||
if setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
|
||
return false, errors.New("方舟可信素材库尚未配置,请由管理员在方舟素材库设置中填写启用的 IAM AK/SK")
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
func arkPrivateAssetResourceID(reference providerMedia) string {
|
||
if !strings.HasPrefix(reference.StorageKey, "resource:") {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(strings.TrimPrefix(reference.StorageKey, "resource:"))
|
||
}
|
||
|
||
type ArkPrivateAssetSyncResult struct {
|
||
ResourceID string `json:"resourceId"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
// SyncResourceToArkPrivateAsset 是用户显式触发的参考图预同步入口。
|
||
// 它与任务 worker 共用团队资源归属、就绪状态和审核规则,客户端不能借此向方舟提交任意 URL。
|
||
func (s *Service) SyncResourceToArkPrivateAsset(ctx context.Context, actor *model.User, resourceID string) (*ArkPrivateAssetSyncResult, error) {
|
||
resourceID = strings.TrimSpace(resourceID)
|
||
if resourceID == "" {
|
||
return nil, BadAuthRequest("请选择要同步的图片素材")
|
||
}
|
||
resource, err := s.Resource(actor.ID, resourceID)
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, NotFound("图片素材不存在或无权访问")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if resource.Status != model.ResourceStatusReady {
|
||
return nil, BadAuthRequest("图片素材尚未上传完成")
|
||
}
|
||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(resource.MimeType)), "image/") {
|
||
return nil, BadAuthRequest("方舟可信素材库当前只支持上传图片素材")
|
||
}
|
||
settingRecord, setting, err := s.readArkPrivateAssetSetting()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !setting.Enabled || setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
|
||
return nil, BadAuthRequest("方舟可信素材库尚未配置,请由管理员在方舟素材库设置中填写启用的 IAM AK/SK")
|
||
}
|
||
if _, err := s.ensureArkPrivateAsset(ctx, actor.ID, resource, settingRecord, &setting); err != nil {
|
||
return nil, err
|
||
}
|
||
return &ArkPrivateAssetSyncResult{ResourceID: resource.ID, Status: arkPrivateAssetStatusLive}, nil
|
||
}
|
||
|
||
func (s *Service) ensureArkPrivateAsset(ctx context.Context, userID string, resource *model.Resource, settingRecord *model.SystemSetting, setting *arkPrivateAssetSettingValue) (string, error) {
|
||
binding, err := s.repo.ArkPrivateAssetBinding(resource.ID, setting.ProjectName)
|
||
if err == nil {
|
||
if shouldResumeArkPrivateAssetPolling(binding) {
|
||
// 素材已创建成功,只是旧版 GetAsset 参数错误导致轮询失败;恢复轮询,
|
||
// 不重新上传素材,也不会创建重复的方舟资产。
|
||
binding.Status = arkPrivateAssetStatusWait
|
||
binding.Error = ""
|
||
if err := s.repo.SaveArkPrivateAssetBinding(binding); err != nil {
|
||
return "", err
|
||
}
|
||
return s.waitForArkPrivateAsset(ctx, binding, setting)
|
||
}
|
||
if !shouldRetryArkPrivateAssetBinding(binding) {
|
||
return s.waitForArkPrivateAsset(ctx, binding, setting)
|
||
}
|
||
// 早期字段解析未识别方舟返回的 Id。仅重试尚未创建素材或素材组的记录,
|
||
// 审核拒绝等真实业务失败仍保持终态,避免重复上传。
|
||
binding.Status = arkPrivateAssetStatusNew
|
||
binding.Error = ""
|
||
if err := s.repo.SaveArkPrivateAssetBinding(binding); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
if err != nil {
|
||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return "", err
|
||
}
|
||
binding = &model.ArkPrivateAssetBinding{
|
||
ID: newID(),
|
||
UserID: userID,
|
||
ResourceID: resource.ID,
|
||
ProjectName: setting.ProjectName,
|
||
Status: arkPrivateAssetStatusNew,
|
||
}
|
||
created, err := s.repo.CreateArkPrivateAssetBinding(binding)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if !created {
|
||
binding, err = s.repo.ArkPrivateAssetBinding(resource.ID, setting.ProjectName)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return s.waitForArkPrivateAsset(ctx, binding, setting)
|
||
}
|
||
}
|
||
|
||
// 重试时复用已建成的素材组,避免管理员调整配置清空 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))
|
||
if err != nil {
|
||
return "", s.failArkPrivateAssetBinding(binding, fmt.Errorf("生成方舟素材临时地址失败:%w", err))
|
||
}
|
||
response, err := callArkPrivateAssetAPI(ctx, *setting, "CreateAsset", map[string]interface{}{
|
||
"GroupId": groupID,
|
||
"URL": resourceURL,
|
||
"AssetType": "Image",
|
||
"Name": "ark-private-asset-" + resource.ID,
|
||
"ProjectName": setting.ProjectName,
|
||
})
|
||
if err != nil {
|
||
return "", s.failArkPrivateAssetBinding(binding, fmt.Errorf("上传方舟可信素材失败:%w", err))
|
||
}
|
||
assetID := arkPrivateAssetResponseField(response, "AssetId", "AssetID", "asset_id", "Id", "ID", "id")
|
||
if assetID == "" {
|
||
return "", s.failArkPrivateAssetBinding(binding, errors.New("方舟素材库没有返回素材 ID"))
|
||
}
|
||
binding.ArkAssetID = assetID
|
||
binding.Status = arkPrivateAssetStatusWait
|
||
binding.Error = ""
|
||
if err := s.repo.SaveArkPrivateAssetBinding(binding); err != nil {
|
||
return "", err
|
||
}
|
||
return s.waitForArkPrivateAsset(ctx, binding, setting)
|
||
}
|
||
|
||
func (s *Service) ensureArkPrivateAssetGroup(ctx context.Context, settingRecord *model.SystemSetting, setting *arkPrivateAssetSettingValue) (string, error) {
|
||
if strings.TrimSpace(setting.DefaultGroupID) != "" {
|
||
return setting.DefaultGroupID, nil
|
||
}
|
||
response, err := callArkPrivateAssetAPI(ctx, *setting, "CreateAssetGroup", map[string]interface{}{
|
||
"Name": "ark-private-assets",
|
||
"Description": "自动导入的方舟可信素材",
|
||
"GroupType": arkPrivateAssetGroupType,
|
||
"ProjectName": setting.ProjectName,
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("创建方舟素材组失败:%w", err)
|
||
}
|
||
groupID := arkPrivateAssetResponseField(response, "GroupId", "GroupID", "group_id", "Id", "ID", "id")
|
||
if groupID == "" {
|
||
return "", errors.New("方舟素材库没有返回素材组 ID")
|
||
}
|
||
setting.DefaultGroupID = groupID
|
||
if settingRecord == nil {
|
||
settingRecord = &model.SystemSetting{Key: arkPrivateAssetSettingKey}
|
||
}
|
||
if err := s.saveArkPrivateAssetSetting(settingRecord, *setting); err != nil {
|
||
return "", err
|
||
}
|
||
return groupID, nil
|
||
}
|
||
|
||
// shouldRetryArkPrivateAssetBinding 判断失败的素材绑定能否安全重试。
|
||
// 素材组尚未建成(两个 ID 均为空)时任何失败都可重试:网络中断、区域配错、
|
||
// 套餐未开通都发生在建组之前,且绝无重复上传风险。素材组已建、素材未建时仅重试
|
||
// 创建素材阶段的失败,最坏情况是组内多出一份素材。审核拒绝发生在拿到素材 ID 之后,
|
||
// ArkAssetID 非空,天然不会进入重试路径。
|
||
func shouldRetryArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) bool {
|
||
if binding == nil || strings.ToLower(strings.TrimSpace(binding.Status)) != arkPrivateAssetStatusFail || binding.ArkAssetID != "" {
|
||
return false
|
||
}
|
||
if binding.AssetGroupID == "" {
|
||
return true
|
||
}
|
||
return strings.Contains(binding.Error, "上传方舟可信素材失败") || strings.Contains(binding.Error, "方舟素材库没有返回素材 ID")
|
||
}
|
||
|
||
func shouldResumeArkPrivateAssetPolling(binding *model.ArkPrivateAssetBinding) bool {
|
||
if binding == nil || strings.ToLower(strings.TrimSpace(binding.Status)) != arkPrivateAssetStatusFail || binding.AssetGroupID == "" || binding.ArkAssetID == "" {
|
||
return false
|
||
}
|
||
return strings.Contains(binding.Error, "MissingParameter.Id")
|
||
}
|
||
|
||
func (s *Service) waitForArkPrivateAsset(ctx context.Context, binding *model.ArkPrivateAssetBinding, setting *arkPrivateAssetSettingValue) (string, error) {
|
||
deadline := time.Now().Add(arkPrivateAssetPollLimit)
|
||
for time.Now().Before(deadline) {
|
||
switch strings.ToLower(strings.TrimSpace(binding.Status)) {
|
||
case arkPrivateAssetStatusLive:
|
||
if strings.TrimSpace(binding.ArkAssetID) == "" {
|
||
return "", errors.New("方舟可信素材记录缺少素材 ID")
|
||
}
|
||
return binding.ArkAssetID, nil
|
||
case arkPrivateAssetStatusFail:
|
||
return "", fmt.Errorf("方舟可信素材审核失败:%s", defaultString(binding.Error, "请更换拥有使用权的虚拟人物素材后重试"))
|
||
}
|
||
if binding.ArkAssetID != "" {
|
||
response, err := callArkPrivateAssetAPI(ctx, *setting, "GetAsset", map[string]interface{}{
|
||
"Id": binding.ArkAssetID,
|
||
"ProjectName": setting.ProjectName,
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("查询方舟可信素材审核状态失败:%w", err)
|
||
}
|
||
status := strings.ToLower(arkPrivateAssetResponseField(response, "Status", "status"))
|
||
switch status {
|
||
case "active":
|
||
binding.Status = arkPrivateAssetStatusLive
|
||
binding.Error = ""
|
||
if err := s.repo.SaveArkPrivateAssetBinding(binding); err != nil {
|
||
return "", err
|
||
}
|
||
return binding.ArkAssetID, nil
|
||
case "failed":
|
||
message := arkPrivateAssetResponseField(response, "ErrorMessage", "Message", "message")
|
||
binding.Status = arkPrivateAssetStatusFail
|
||
binding.Error = defaultString(message, "方舟未通过素材审核")
|
||
if err := s.repo.SaveArkPrivateAssetBinding(binding); err != nil {
|
||
return "", err
|
||
}
|
||
return "", fmt.Errorf("方舟可信素材审核失败:%s", binding.Error)
|
||
case "":
|
||
return "", errors.New("方舟可信素材状态缺失")
|
||
}
|
||
}
|
||
if err := sleepContext(ctx, 2*time.Second); err != nil {
|
||
return "", err
|
||
}
|
||
latest, err := s.repo.ArkPrivateAssetBinding(binding.ResourceID, binding.ProjectName)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
binding = latest
|
||
}
|
||
return "", errors.New("方舟可信素材审核超时,请稍后重新提交视频任务")
|
||
}
|
||
|
||
func (s *Service) failArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding, reason error) error {
|
||
binding.Status = arkPrivateAssetStatusFail
|
||
binding.Error = reason.Error()
|
||
if err := s.repo.SaveArkPrivateAssetBinding(binding); err != nil {
|
||
return err
|
||
}
|
||
return reason
|
||
}
|
||
|
||
func callArkPrivateAssetAPI(ctx context.Context, setting arkPrivateAssetSettingValue, action string, payload map[string]interface{}) (map[string]interface{}, error) {
|
||
// 素材库控制面调用不是模型生成;不能继承视频任务的请求审计或账单上下文,
|
||
// 否则创建素材组、上传素材和审核查询会被误记为可计费的视频调用。
|
||
ctx = withoutProviderAnalytics(ctx)
|
||
data, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
baseURL, err := arkPrivateAssetControlPlaneURL(setting.Region)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
endpoint, err := url.Parse(baseURL)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
query := endpoint.Query()
|
||
query.Set("Action", action)
|
||
query.Set("Version", arkPrivateAssetAPIVersion)
|
||
endpoint.RawQuery = query.Encode()
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(data))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Accept", "application/json")
|
||
credentials := base.Credentials{
|
||
AccessKeyID: setting.AccessKeyID,
|
||
SecretAccessKey: setting.AccessKeySecret,
|
||
Region: setting.Region,
|
||
Service: "ark",
|
||
}
|
||
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 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
|
||
}
|
||
region = strings.ToLower(strings.TrimSpace(region))
|
||
if region == "" {
|
||
return "", errors.New("方舟素材库未配置 Region")
|
||
}
|
||
for _, char := range region {
|
||
if !(char >= 'a' && char <= 'z') && !(char >= '0' && char <= '9') && char != '-' {
|
||
return "", errors.New("方舟素材库 Region 格式无效")
|
||
}
|
||
}
|
||
return "https://ark." + region + ".volcengineapi.com", nil
|
||
}
|
||
|
||
func arkPrivateAssetResponseField(response map[string]interface{}, keys ...string) string {
|
||
for _, source := range arkPrivateAssetResponseMaps(response) {
|
||
for _, key := range keys {
|
||
if value := strings.TrimSpace(fmt.Sprint(source[key])); value != "" && value != "<nil>" {
|
||
return value
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func arkPrivateAssetResponseMaps(response map[string]interface{}) []map[string]interface{} {
|
||
result := []map[string]interface{}{response}
|
||
for _, key := range []string{"Result", "Asset", "Group", "Data"} {
|
||
if value, ok := response[key].(map[string]interface{}); ok {
|
||
result = append(result, value)
|
||
for _, nestedKey := range []string{"Asset", "Group", "Data"} {
|
||
if nested, ok := value[nestedKey].(map[string]interface{}); ok {
|
||
result = append(result, nested)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|