feat(ark-assets): 方舟素材库 - 支持私域素材授权 (#294)

This commit is contained in:
kinoko
2026-08-23 08:27:55 +08:00
committed by GitHub
parent 1bb5d1423a
commit d77432693e
25 changed files with 1072 additions and 4 deletions

View File

@@ -39,6 +39,7 @@ func Models() []any {
&model.AdminAuditEvent{},
&model.UserDailyActivity{},
&model.SystemSetting{},
&model.ArkPrivateAssetBinding{},
&model.UserOSSSetting{},
&model.UserDailyUploadUsage{},
&model.Skill{},

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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 {

View 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
}

View 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)
}
}

View 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
}

View File

@@ -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" {