feat(ark-assets): 方舟素材库 - 支持私域素材授权 (#294)
This commit is contained in:
@@ -39,6 +39,7 @@ func Models() []any {
|
||||
&model.AdminAuditEvent{},
|
||||
&model.UserDailyActivity{},
|
||||
&model.SystemSetting{},
|
||||
&model.ArkPrivateAssetBinding{},
|
||||
&model.UserOSSSetting{},
|
||||
&model.UserDailyUploadUsage{},
|
||||
&model.Skill{},
|
||||
|
||||
@@ -489,6 +489,37 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.GET("/admin/settings/ark-private-assets", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.AdminArkPrivateAssetSetting(user)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.PATCH("/admin/settings/ark-private-assets", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
var req service.ArkPrivateAssetSettingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
setting, err := svc.UpdateArkPrivateAssetSetting(user, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"setting": setting})
|
||||
})
|
||||
r.GET("/admin/settings/drawing-engine", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -119,6 +119,23 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"usage": usage})
|
||||
})
|
||||
r.POST("/resources/:id/ark-private-asset", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
policy, available := loadRuntimePolicy(c, svc)
|
||||
if !available || !enforceRateLimit(c, "resources-ark-private-asset:"+user.ID, policy.Request.ResourceImportPerMinute, time.Minute) {
|
||||
return
|
||||
}
|
||||
result, err := svc.SyncResourceToArkPrivateAsset(c.Request.Context(), user, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"sync": result})
|
||||
})
|
||||
r.POST("/resources", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -26,6 +26,21 @@ type SystemSetting struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ArkPrivateAssetBinding caches the approved Ark private-library asset for an
|
||||
// immutable local Resource. Ark asset IDs are scoped to one Ark Project.
|
||||
type ArkPrivateAssetBinding struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36"`
|
||||
ResourceID string `json:"resourceId" gorm:"index;uniqueIndex:idx_ark_private_asset_binding_resource_project,priority:1;size:36"`
|
||||
ProjectName string `json:"projectName" gorm:"uniqueIndex:idx_ark_private_asset_binding_resource_project,priority:2;size:160"`
|
||||
AssetGroupID string `json:"assetGroupId" gorm:"index;size:120"`
|
||||
ArkAssetID string `json:"arkAssetId" gorm:"index;size:120"`
|
||||
Status string `json:"status" gorm:"index;size:24"`
|
||||
Error string `json:"error" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UserOSSSetting struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;index:idx_user_oss_settings_user_created,priority:1"`
|
||||
|
||||
@@ -784,6 +784,32 @@ func (r *Repository) SaveSystemSettings(settings ...*model.SystemSetting) error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) ArkPrivateAssetBinding(resourceID string, projectName string) (*model.ArkPrivateAssetBinding, error) {
|
||||
var binding model.ArkPrivateAssetBinding
|
||||
if err := r.db.Where("resource_id = ? AND project_name = ?", resourceID, projectName).First(&binding).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &binding, nil
|
||||
}
|
||||
|
||||
// CreateArkPrivateAssetBinding establishes a single uploader for a resource
|
||||
// and Ark Project. Other workers can wait for that binding instead of
|
||||
// importing the same image repeatedly.
|
||||
func (r *Repository) CreateArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) (bool, error) {
|
||||
result := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "resource_id"}, {Name: "project_name"}},
|
||||
DoNothing: true,
|
||||
}).Create(binding)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected == 1, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) error {
|
||||
return r.db.Save(binding).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteSystemSetting(key string) error {
|
||||
return r.db.Delete(&model.SystemSetting{}, "key = ?", key).Error
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ type providerConfig struct {
|
||||
VQuality string `json:"vquality"`
|
||||
VideoGenerateAudio string `json:"videoGenerateAudio"`
|
||||
VideoWatermark string `json:"videoWatermark"`
|
||||
ArkPrivateAssetUpload string `json:"videoArkPrivateAssetUpload"`
|
||||
AudioVoice string `json:"audioVoice"`
|
||||
AudioFormat string `json:"audioFormat"`
|
||||
AudioSpeed string `json:"audioSpeed"`
|
||||
@@ -313,6 +314,9 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.prepareArkPrivateAssetReferences(ctx, userID, &input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.Mode == "video" && input.VideoCapability != nil {
|
||||
if err := validateVideoTask(input.VideoCapability, input); err != nil {
|
||||
|
||||
424
backend/internal/service/provider_ark_private_assets.go
Normal file
424
backend/internal/service/provider_ark_private_assets.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package service
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// Tests can inject a local control-plane server. Production derives the Ark
|
||||
// control-plane address from the administrator's explicit Region setting.
|
||||
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
|
||||
}
|
||||
if taskID := taskExecutionID(ctx); taskID != "" {
|
||||
_ = s.repo.UpdateTaskProgress(taskID, "同步方舟可信素材", 36)
|
||||
}
|
||||
settingRecord, setting, err := s.readArkPrivateAssetSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !setting.Enabled || setting.AccessKeyID == "" || setting.AccessKeySecret == "" {
|
||||
return errors.New("方舟可信素材库尚未配置,请由管理员在方舟素材库设置中填写启用的 IAM AK/SK")
|
||||
}
|
||||
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.ResourceForUser(&model.User{ID: 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 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 is the explicit user action for preloading a
|
||||
// reference image. It keeps the same team-scoped ownership and review rules as
|
||||
// the task worker, so clients cannot submit arbitrary URLs to Ark.
|
||||
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.ResourceForUser(actor, 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func shouldRetryArkPrivateAssetBinding(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, "方舟素材库没有返回素材组 ID") || 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 {
|
||||
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}, " "), " ")), "方舟素材库请求失败"))
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
147
backend/internal/service/provider_ark_private_assets_test.go
Normal file
147
backend/internal/service/provider_ark_private_assets_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArkPrivateAssetUsesRegionalArkControlPlane(t *testing.T) {
|
||||
value, err := arkPrivateAssetControlPlaneURL("test-region")
|
||||
if err != nil || value != "https://ark.test-region.volcengineapi.com" {
|
||||
t.Fatalf("arkPrivateAssetControlPlaneURL() = %q, %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallArkPrivateAssetAPISignsAndUsesAssetContract(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", request.Method)
|
||||
}
|
||||
if request.URL.Query().Get("Action") != "CreateAsset" || request.URL.Query().Get("Version") != arkPrivateAssetAPIVersion {
|
||||
t.Errorf("query = %s", request.URL.RawQuery)
|
||||
}
|
||||
if !strings.HasPrefix(request.Header.Get("Authorization"), "HMAC-SHA256") {
|
||||
t.Errorf("missing Volcengine request signature")
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["ProjectName"] != "project-test" || body["AssetType"] != "Image" || body["GroupId"] != "group-test" {
|
||||
t.Errorf("body = %#v", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Result":{"AssetId":"asset-test"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
previousBaseURL := arkPrivateAssetAPIBaseURLOverride
|
||||
arkPrivateAssetAPIBaseURLOverride = server.URL
|
||||
t.Cleanup(func() { arkPrivateAssetAPIBaseURLOverride = previousBaseURL })
|
||||
|
||||
response, err := callArkPrivateAssetAPI(context.Background(), arkPrivateAssetSettingValue{
|
||||
Region: "test-region",
|
||||
ProjectName: "project-test",
|
||||
AccessKeyID: "test-access-key",
|
||||
AccessKeySecret: "test-secret-key",
|
||||
}, "CreateAsset", map[string]interface{}{
|
||||
"GroupId": "group-test", "AssetType": "Image", "URL": "https://media.example.test/reference.png", "ProjectName": "project-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("callArkPrivateAssetAPI() error = %v", err)
|
||||
}
|
||||
if got := arkPrivateAssetResponseField(response, "AssetId"); got != "asset-test" {
|
||||
t.Fatalf("asset ID = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallArkPrivateAssetAPIUsesIDForGetAsset(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if request.URL.Query().Get("Action") != "GetAsset" || body["Id"] != "asset-test" || body["AssetId"] != nil {
|
||||
t.Errorf("GetAsset request = %#v", body)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Result":{"Status":"Processing"}}`))
|
||||
}))
|
||||
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",
|
||||
}, "GetAsset", map[string]interface{}{"Id": "asset-test", "ProjectName": "project-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("callArkPrivateAssetAPI() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArkPrivateAssetControlPlaneDisablesGenerationAnalytics(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), providerAnalyticsKey{}, providerAnalyticsContext{Service: &Service{}, Capability: "video"})
|
||||
ctx = withoutProviderAnalytics(ctx)
|
||||
metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext)
|
||||
if !ok || metadata.Service != nil || metadata.Capability != "" {
|
||||
t.Fatalf("provider analytics = %#v, %v", metadata, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArkPrivateAssetSettingsEncryptSecret(t *testing.T) {
|
||||
svc := &Service{dataDir: t.TempDir()}
|
||||
value, err := arkPrivateAssetSettingFromRequest(ArkPrivateAssetSettingRequest{
|
||||
Enabled: true, Region: "test-region", ProjectName: "project-test", AccessKeyID: "test-access-key", AccessKeySecret: "test-secret-key",
|
||||
}, defaultArkPrivateAssetSetting())
|
||||
if err != nil {
|
||||
t.Fatalf("arkPrivateAssetSettingFromRequest() error = %v", err)
|
||||
}
|
||||
ciphertext, err := svc.encryptSettingSecret(value.AccessKeySecret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(ciphertext, encryptedSettingPrefix) || strings.Contains(ciphertext, value.AccessKeySecret) {
|
||||
t.Fatalf("encrypted secret = %q", ciphertext)
|
||||
}
|
||||
plaintext, err := svc.decryptSettingSecret(ciphertext)
|
||||
if err != nil || plaintext != value.AccessKeySecret {
|
||||
t.Fatalf("decryptSettingSecret() = %q, %v", plaintext, err)
|
||||
}
|
||||
public := publicArkPrivateAssetSetting(nil, value)
|
||||
if !public.HasAccessKeySecret || public.AccessKeyID != "test-access-key" {
|
||||
t.Fatalf("public setting = %#v", public)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArkPrivateAssetResponseFieldReadsNestedAssetStatus(t *testing.T) {
|
||||
response := map[string]interface{}{
|
||||
"Result": map[string]interface{}{
|
||||
"Asset": map[string]interface{}{"Status": "Active", "AssetId": "asset-nested"},
|
||||
},
|
||||
}
|
||||
if got := arkPrivateAssetResponseField(response, "Status"); got != "Active" {
|
||||
t.Fatalf("status = %q", got)
|
||||
}
|
||||
if got := arkPrivateAssetResponseField(response, "AssetId"); got != "asset-nested" {
|
||||
t.Fatalf("asset ID = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArkPrivateAssetResponseFieldReadsCreateGroupID(t *testing.T) {
|
||||
response := map[string]interface{}{
|
||||
"Result": map[string]interface{}{
|
||||
"Group": map[string]interface{}{"Id": "group-created"},
|
||||
},
|
||||
}
|
||||
if got := arkPrivateAssetResponseField(response, "GroupId", "Id"); got != "group-created" {
|
||||
t.Fatalf("group ID = %q", got)
|
||||
}
|
||||
}
|
||||
184
backend/internal/service/settings_ark_assets.go
Normal file
184
backend/internal/service/settings_ark_assets.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const arkPrivateAssetSettingKey = "ark_private_assets"
|
||||
|
||||
type ArkPrivateAssetSettingRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Region string `json:"region"`
|
||||
ProjectName string `json:"projectName"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
}
|
||||
|
||||
type PublicArkPrivateAssetSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Region string `json:"region"`
|
||||
ProjectName string `json:"projectName"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
HasAccessKeySecret bool `json:"hasAccessKeySecret"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type arkPrivateAssetSettingValue struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Region string `json:"region"`
|
||||
ProjectName string `json:"projectName"`
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
AccessKeySecret string `json:"accessKeySecret"`
|
||||
DefaultGroupID string `json:"defaultGroupId"`
|
||||
}
|
||||
|
||||
func (s *Service) AdminArkPrivateAssetSetting(actor *model.User) (*PublicArkPrivateAssetSetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setting, value, err := s.readArkPrivateAssetSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
public := publicArkPrivateAssetSetting(setting, value)
|
||||
return &public, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateArkPrivateAssetSetting(actor *model.User, req ArkPrivateAssetSettingRequest) (*PublicArkPrivateAssetSetting, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currentSetting, current, err := s.readArkPrivateAssetSetting()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next, err := arkPrivateAssetSettingFromRequest(req, current)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if next.Region != current.Region || next.ProjectName != current.ProjectName || next.AccessKeyID != current.AccessKeyID {
|
||||
// Asset groups belong to one Ark Project and credential scope. Recreate
|
||||
// lazily after either boundary changes.
|
||||
next.DefaultGroupID = ""
|
||||
}
|
||||
setting := &model.SystemSetting{Key: arkPrivateAssetSettingKey, UpdatedBy: actor.ID}
|
||||
if currentSetting != nil {
|
||||
setting.CreatedAt = currentSetting.CreatedAt
|
||||
}
|
||||
if err := s.saveArkPrivateAssetSetting(setting, next); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
public := publicArkPrivateAssetSetting(setting, next)
|
||||
return &public, nil
|
||||
}
|
||||
|
||||
func (s *Service) readArkPrivateAssetSetting() (*model.SystemSetting, arkPrivateAssetSettingValue, error) {
|
||||
setting, err := s.repo.SystemSetting(arkPrivateAssetSettingKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, defaultArkPrivateAssetSetting(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, arkPrivateAssetSettingValue{}, err
|
||||
}
|
||||
value := defaultArkPrivateAssetSetting()
|
||||
if strings.TrimSpace(setting.ValueJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(setting.ValueJSON), &value); err != nil {
|
||||
return nil, arkPrivateAssetSettingValue{}, errors.New("方舟素材库配置格式无效")
|
||||
}
|
||||
}
|
||||
needsMigration := value.AccessKeySecret != "" && !strings.HasPrefix(value.AccessKeySecret, encryptedSettingPrefix)
|
||||
secret, err := s.decryptSettingSecret(value.AccessKeySecret)
|
||||
if err != nil {
|
||||
return nil, arkPrivateAssetSettingValue{}, err
|
||||
}
|
||||
value.AccessKeySecret = secret
|
||||
value = normalizeArkPrivateAssetSetting(value)
|
||||
if needsMigration {
|
||||
if err := s.saveArkPrivateAssetSetting(setting, value); err != nil {
|
||||
return nil, arkPrivateAssetSettingValue{}, err
|
||||
}
|
||||
}
|
||||
return setting, value, nil
|
||||
}
|
||||
|
||||
func (s *Service) saveArkPrivateAssetSetting(setting *model.SystemSetting, value arkPrivateAssetSettingValue) error {
|
||||
stored := normalizeArkPrivateAssetSetting(value)
|
||||
var err error
|
||||
stored.AccessKeySecret, err = s.encryptSettingSecret(stored.AccessKeySecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(stored)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setting.ValueJSON = string(encoded)
|
||||
return s.repo.SaveSystemSetting(setting)
|
||||
}
|
||||
|
||||
func arkPrivateAssetSettingFromRequest(req ArkPrivateAssetSettingRequest, current arkPrivateAssetSettingValue) (arkPrivateAssetSettingValue, error) {
|
||||
next := normalizeArkPrivateAssetSetting(arkPrivateAssetSettingValue{
|
||||
Enabled: req.Enabled,
|
||||
Region: req.Region,
|
||||
ProjectName: req.ProjectName,
|
||||
AccessKeyID: req.AccessKeyID,
|
||||
AccessKeySecret: req.AccessKeySecret,
|
||||
})
|
||||
if next.AccessKeySecret == "" && next.AccessKeyID == current.AccessKeyID {
|
||||
next.AccessKeySecret = current.AccessKeySecret
|
||||
}
|
||||
if !next.Enabled {
|
||||
return next, nil
|
||||
}
|
||||
if next.Region == "" {
|
||||
return next, BadAuthRequest("请填写方舟 Region")
|
||||
}
|
||||
if next.ProjectName == "" {
|
||||
return next, BadAuthRequest("请填写方舟 ProjectName")
|
||||
}
|
||||
if next.AccessKeyID == "" {
|
||||
return next, BadAuthRequest("请填写方舟素材库 AccessKey")
|
||||
}
|
||||
if next.AccessKeySecret == "" {
|
||||
return next, BadAuthRequest("请填写方舟素材库 SecretKey")
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func normalizeArkPrivateAssetSetting(value arkPrivateAssetSettingValue) arkPrivateAssetSettingValue {
|
||||
value.Region = strings.TrimSpace(value.Region)
|
||||
value.ProjectName = strings.TrimSpace(value.ProjectName)
|
||||
value.AccessKeyID = strings.TrimSpace(value.AccessKeyID)
|
||||
value.AccessKeySecret = strings.TrimSpace(value.AccessKeySecret)
|
||||
value.DefaultGroupID = strings.TrimSpace(value.DefaultGroupID)
|
||||
return value
|
||||
}
|
||||
|
||||
func defaultArkPrivateAssetSetting() arkPrivateAssetSettingValue {
|
||||
return arkPrivateAssetSettingValue{}
|
||||
}
|
||||
|
||||
func publicArkPrivateAssetSetting(setting *model.SystemSetting, value arkPrivateAssetSettingValue) PublicArkPrivateAssetSetting {
|
||||
result := PublicArkPrivateAssetSetting{
|
||||
Enabled: value.Enabled,
|
||||
Region: value.Region,
|
||||
ProjectName: value.ProjectName,
|
||||
AccessKeyID: value.AccessKeyID,
|
||||
HasAccessKeySecret: value.AccessKeySecret != "",
|
||||
}
|
||||
if setting != nil {
|
||||
result.UpdatedBy = setting.UpdatedBy
|
||||
result.CreatedAt = setting.CreatedAt
|
||||
result.UpdatedAt = setting.UpdatedAt
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func (s *Service) processTask(ctx context.Context, task model.Task) (map[string]
|
||||
return nil, nil, err
|
||||
}
|
||||
task.InputJSON = decryptedInput
|
||||
ctx = withTaskExecutionID(ctx, task.ID)
|
||||
ctx = withProviderAnalytics(ctx, s, task)
|
||||
|
||||
if task.Type == "agent_storyboard_rows" {
|
||||
|
||||
Reference in New Issue
Block a user