Files
ttstd-infinite-canvas/backend/internal/app/provider_ark_private_assets.go

485 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"yingce/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 {
// 可信素材 asset:// 仅方舟视频协议支持;Agent Plan Seedream 等图片渠道不能上传或改写。
if input == nil || input.Mode != "video" || !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, "正在准备参考素材", 0)
}
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, "作品创作中", 0)
}
return nil
}
func isArkPrivateAssetVideoConfig(config providerConfig) bool {
iface := strings.TrimSpace(config.InterfaceType)
// Agent Plan 图片与视频共用 /api/plan/v3;图片协议不得进入可信素材同步。
if iface == string(model.ChannelInterfaceVolcengineArkImage) || iface == "volcengine-ark-agent-plan-image" {
return false
}
return iface == string(model.ChannelInterfaceVolcengineArkVideo) || iface == "volcengine-ark-agent-plan-video" || 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.providerResourceURL(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
}