diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 6e1d85031..88a94673b 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -39,6 +39,7 @@ func Models() []any { &model.AdminAuditEvent{}, &model.UserDailyActivity{}, &model.SystemSetting{}, + &model.ArkPrivateAssetBinding{}, &model.UserOSSSetting{}, &model.UserDailyUploadUsage{}, &model.Skill{}, diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 515ccab18..f7b221f76 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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 { diff --git a/backend/internal/handler/user_data.go b/backend/internal/handler/user_data.go index 295f270c5..9408e6682 100644 --- a/backend/internal/handler/user_data.go +++ b/backend/internal/handler/user_data.go @@ -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 { diff --git a/backend/internal/model/models_platform.go b/backend/internal/model/models_platform.go index 858b83090..c03dccfd3 100644 --- a/backend/internal/model/models_platform.go +++ b/backend/internal/model/models_platform.go @@ -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"` diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index e0e9f9cb0..77985e508 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -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 } diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 5db0533e2..0aa52f4d8 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -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 { diff --git a/backend/internal/service/provider_ark_private_assets.go b/backend/internal/service/provider_ark_private_assets.go new file mode 100644 index 000000000..87219c13b --- /dev/null +++ b/backend/internal/service/provider_ark_private_assets.go @@ -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 != "" { + 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 +} diff --git a/backend/internal/service/provider_ark_private_assets_test.go b/backend/internal/service/provider_ark_private_assets_test.go new file mode 100644 index 000000000..cbb101c42 --- /dev/null +++ b/backend/internal/service/provider_ark_private_assets_test.go @@ -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) + } +} diff --git a/backend/internal/service/settings_ark_assets.go b/backend/internal/service/settings_ark_assets.go new file mode 100644 index 000000000..ef7c83755 --- /dev/null +++ b/backend/internal/service/settings_ark_assets.go @@ -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 +} diff --git a/backend/internal/service/task_execution.go b/backend/internal/service/task_execution.go index 93b3708b6..3be0bb2d5 100644 --- a/backend/internal/service/task_execution.go +++ b/backend/internal/service/task_execution.go @@ -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" { diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 5792dbb52..faf74e151 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -296,6 +296,7 @@ function videoConfigPatch(key: keyof AiConfig, value: string) { if (key === "videoSeconds") return { seconds: value }; if (key === "videoGenerateAudio") return { generateAudio: value }; if (key === "videoWatermark") return { watermark: value }; + if (key === "videoArkPrivateAssetUpload") return { arkPrivateAssetUpload: value }; return { [key]: value }; } diff --git a/web/src/components/canvas/canvas-context-menu.tsx b/web/src/components/canvas/canvas-context-menu.tsx index 626dbd741..fa06ce8dd 100644 --- a/web/src/components/canvas/canvas-context-menu.tsx +++ b/web/src/components/canvas/canvas-context-menu.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, useReducedMotion } from "motion/react"; import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; -import { ArrowLeft, Check, ChevronRight, Clipboard, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react"; +import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react"; import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu"; import { aceternityMotion } from "@/lib/aceternity-motion"; @@ -53,6 +53,7 @@ type CanvasNodeContextMenuProps = { onGenerateImage: () => void; onCopyContent: () => void; onCopyMediaUrl: () => void; + onUploadToArkPrivateAsset: () => void; onSetAssetCategory: (category: CanvasAssetCategory) => void; onToggleFrame: () => void; }; @@ -86,6 +87,7 @@ export function CanvasNodeContextMenu({ onGenerateImage, onCopyContent, onCopyMediaUrl, + onUploadToArkPrivateAsset, onSetAssetCategory, onToggleFrame, }: CanvasNodeContextMenuProps) { @@ -200,6 +202,7 @@ export function CanvasNodeContextMenu({ } label="进入全景预览" disabled={!canOpenPreview} onClick={() => runAction(onViewMedia)} /> } label="设置资产分类" chevron onClick={() => setCategoryOpen(true)} /> + {isImage ? } label="上传到方舟素材库" onClick={() => runAction(onUploadToArkPrivateAsset)} /> : null} } label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} /> diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 20ba0aa4d..f6d8f7003 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -652,6 +652,7 @@ function videoConfigPatch(key: keyof AiConfig, value: string) { if (key === "videoSeconds") return { seconds: value }; if (key === "videoGenerateAudio") return { generateAudio: value }; if (key === "videoWatermark") return { watermark: value }; + if (key === "videoArkPrivateAssetUpload") return { arkPrivateAssetUpload: value }; return { [key]: value }; } diff --git a/web/src/components/video-settings-panel.tsx b/web/src/components/video-settings-panel.tsx index 029288982..9596ad6f3 100644 --- a/web/src/components/video-settings-panel.tsx +++ b/web/src/components/video-settings-panel.tsx @@ -19,7 +19,7 @@ const sizeOptions = [ type VideoSettingsPanelProps = { config: AiConfig; - onConfigChange: (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark", value: string) => void; + onConfigChange: (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark" | "videoArkPrivateAssetUpload", value: string) => void; theme: CanvasTheme; showTitle?: boolean; className?: string; @@ -117,6 +117,8 @@ function SeedanceVideoSettingsPanel({ config, profile, priceTiers, onConfigChang const duration = normalizeSeedanceDuration(config.videoSeconds); const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default); const watermark = boolConfig(config.videoWatermark, profile.watermark.default); + const useArkPrivateAssets = boolConfig(config.videoArkPrivateAssetUpload, true); + const isArkSeedance = resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-ark-video"; return ( @@ -167,6 +169,13 @@ function SeedanceVideoSettingsPanel({ config, profile, priceTiers, onConfigChang {profile.watermark.supported ? onConfigChange("videoWatermark", String(checked))} /> : null} + {isArkSeedance ? ( + +
+ onConfigChange("videoArkPrivateAssetUpload", String(checked))} /> +
+
+ ) : null}
); diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index cc95561c1..167c2753d 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -385,6 +385,7 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u vquality: normalizeVideoResolution(node?.metadata?.vquality ?? config.vquality ?? defaultConfig.vquality), videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio, videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark, + videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload, audioVoice: node?.metadata?.audioVoice ?? config.audioVoice ?? defaultConfig.audioVoice, audioFormat: node?.metadata?.audioFormat ?? config.audioFormat ?? defaultConfig.audioFormat, audioSpeed: node?.metadata?.audioSpeed ?? config.audioSpeed ?? defaultConfig.audioSpeed, diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 8b9a09580..7d54a5dbd 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, BarChart3, BellRing, + CloudUpload, Coins, FileClock, HardDrive, @@ -74,6 +75,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = { path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: }, { path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: }, { path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: }, + { path: "/admin/settings/ark-private-assets", label: "方舟素材库", description: "Seedance 可信参考素材", icon: }, { path: "/admin/settings/response-interception", label: "模型响应拦截", description: "替换用户可见的上游异常", icon: }, { path: "/admin/settings/third-party", label: "第三方参数配置", description: "集中维护第三方平台凭证", icon: }, ], diff --git a/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx b/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx new file mode 100644 index 000000000..5f21aa564 --- /dev/null +++ b/web/src/pages/admin/settings/ark-private-assets-settings-page.tsx @@ -0,0 +1,120 @@ +import { App, Button, Form, Input, Switch } from "antd"; +import { CloudUpload, KeyRound, Save } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { getAdminArkPrivateAssetSetting, updateAdminArkPrivateAssetSetting, type AdminArkPrivateAssetSetting } from "@/services/api/auth"; +import { useAdminContext } from "../admin-context"; +import { AdminPageFrame } from "../components/admin-shell"; +import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui"; + +type ArkPrivateAssetForm = { + enabled: boolean; + region: string; + projectName: string; + accessKeyId: string; + accessKeySecret: string; +}; + +export default function ArkPrivateAssetsSettingsPage() { + const { message } = App.useApp(); + const { references } = useAdminContext(); + const [setting, setSetting] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [form] = Form.useForm(); + const enabled = Form.useWatch("enabled", form) ?? false; + const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]); + + useEffect(() => { + let cancelled = false; + void getAdminArkPrivateAssetSetting() + .then(({ setting: value }) => { + if (cancelled) return; + setSetting(value); + form.setFieldsValue(toFormValues(value)); + }) + .catch((error) => { + if (!cancelled) message.error(error instanceof Error ? error.message : "读取方舟素材库配置失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { cancelled = true; }; + }, [form, message]); + + const save = async () => { + const values = await form.validateFields(); + if (values.enabled && !values.accessKeySecret.trim() && !setting?.hasAccessKeySecret) { + message.error("请填写 IAM SecretKey"); + return; + } + setSaving(true); + try { + const result = await updateAdminArkPrivateAssetSetting({ + enabled: values.enabled, + region: values.region.trim(), + projectName: values.projectName.trim(), + accessKeyId: values.accessKeyId.trim(), + accessKeySecret: values.accessKeySecret.trim(), + }); + setSetting(result.setting); + form.setFieldsValue(toFormValues(result.setting)); + message.success("方舟素材库配置已保存"); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存方舟素材库配置失败"); + } finally { + setSaving(false); + } + }; + + return ( + +
+ } + title="私域虚拟人素材" + description="系统仅在用户启用自动同步且参考图属于当前用户素材库时,才会用 IAM 凭据上传并等待审核。" + status={} + footer={( + <> + {setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "尚未保存方舟素材库配置"} + + + )} + > +
+ + + + + + + + + + + } placeholder="仅保存在服务端" /> + + + + +
+
+
+
+ ); +} + +function toFormValues(setting: AdminArkPrivateAssetSetting): ArkPrivateAssetForm { + return { + enabled: setting.enabled, + region: setting.region || "", + projectName: setting.projectName || "", + accessKeyId: setting.accessKeyId || "", + accessKeySecret: "", + }; +} + +function formatTime(value: string) { + return new Date(value).toLocaleString("zh-CN", { hour12: false }); +} diff --git a/web/src/pages/canvas/canvas-project-context-menu.tsx b/web/src/pages/canvas/canvas-project-context-menu.tsx index 2bcb308b1..a2a4e37c2 100644 --- a/web/src/pages/canvas/canvas-project-context-menu.tsx +++ b/web/src/pages/canvas/canvas-project-context-menu.tsx @@ -34,6 +34,7 @@ type CanvasProjectContextMenuProps = { onGenerateImage: (node: CanvasNodeData) => void; onCopyContent: (node: CanvasNodeData | null) => void; onCopyMediaUrl: (node: CanvasNodeData | null) => void; + onUploadToArkPrivateAsset: (node: CanvasNodeData) => void; onSetAssetCategory: (nodeId: string, category: CanvasAssetCategory) => void; onToggleFrame: (node: CanvasNodeData) => void; }; @@ -92,6 +93,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props }} onCopyContent={() => props.onCopyContent(node)} onCopyMediaUrl={() => props.onCopyMediaUrl(node)} + onUploadToArkPrivateAsset={() => { + if (node?.type === CanvasNodeType.Image) props.onUploadToArkPrivateAsset(node); + }} onSetAssetCategory={(category) => { if (menu.type === "node") props.onSetAssetCategory(menu.nodeId, category); }} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 20a9a0c86..381b8cd6d 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -7,7 +7,9 @@ import { uploadMediaFile } from "@/services/file-storage"; import { readLocalRuntimeBootstrapState } from "@/services/local-runtime-bootstrap"; import { createCanvasGenerationLiveProjectAdapter, registerCanvasGenerationLiveProject } from "@/services/canvas-generation-consumer"; import { getActiveUserScope } from "@/lib/user-scope"; -import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources"; +import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources"; +import { uploadImage } from "@/services/image-storage"; +import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync"; import copyToClipboard from "copy-to-clipboard"; import { nanoid } from "nanoid"; import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme"; @@ -20,7 +22,7 @@ import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import { useThemeStore } from "@/stores/use-theme-store"; import { useUserStore } from "@/stores/use-user-store"; -import { App } from "antd"; +import { App, Modal } from "antd"; import { getNodeSpec } from "@/constant/canvas"; import { CanvasConfigComposer } from "@/components/canvas/canvas-config-composer"; import { CanvasConfigNodePanel } from "@/components/canvas/canvas-config-node-panel"; @@ -201,6 +203,7 @@ function InfiniteCanvasPage() { const [tapNowImportOpen, setTapNowImportOpen] = useState(false); const [nodeSearchOpen, setNodeSearchOpen] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); + const [arkPrivateAssetUploadNodeId, setArkPrivateAssetUploadNodeId] = useState(null); const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); const [dialogNodeId, setDialogNodeId] = useState(null); const [textEditorNodeId, setTextEditorNodeId] = useState(null); @@ -1366,6 +1369,42 @@ function InfiniteCanvasPage() { [message, releaseCopiedNodesPastePriority], ); + const uploadNodeImageToArkPrivateAsset = useCallback(async (node: CanvasNodeData) => { + if (node.type !== CanvasNodeType.Image || !node.metadata?.content) { + message.warning("请选择一张可用图片后再上传"); + return; + } + if (arkPrivateAssetUploadNodeId === node.id) return; + const feedbackKey = `ark-private-asset-${node.id}`; + setArkPrivateAssetUploadNodeId(node.id); + message.loading({ key: feedbackKey, content: "正在保存并上传到方舟素材库...", duration: 0 }); + try { + let resourceID = resourceIdFromStorageKey(node.metadata.storageKey); + if (!resourceID) { + const uploaded = await uploadImage(node.metadata.content); + resourceID = resourceIdFromStorageKey(uploaded.storageKey); + if (!resourceID) throw new Error("图片未能保存到系统素材库,请检查对象存储配置后重试"); + handleConfigNodeChange(node.id, imageMetadata(uploaded)); + } + await syncResourceToArkPrivateAsset(resourceID); + message.success({ key: feedbackKey, content: "已同步到方舟素材库,Seedance 将自动复用该素材", duration: 4 }); + } catch (error) { + message.error({ key: feedbackKey, content: error instanceof Error ? error.message : "上传到方舟素材库失败", duration: 5 }); + } finally { + setArkPrivateAssetUploadNodeId((current) => current === node.id ? null : current); + } + }, [arkPrivateAssetUploadNodeId, handleConfigNodeChange, message]); + + const confirmUploadNodeImageToArkPrivateAsset = useCallback((node: CanvasNodeData) => { + Modal.confirm({ + title: "上传到方舟素材库", + content: "仅可上传你拥有肖像、版权或其他合法使用权的图片。方舟审核通过后,Seedance 会使用受控素材标识生成视频。", + okText: "确认拥有使用权并上传", + cancelText: "取消", + onOk: () => uploadNodeImageToArkPrivateAsset(node), + }); + }, [uploadNodeImageToArkPrivateAsset]); + const handleCanvasContextMenu = useCallback( (event: ReactMouseEvent) => { const target = event.target instanceof Element ? event.target : null; @@ -2224,6 +2263,7 @@ function InfiniteCanvasPage() { onCopyMediaUrl={(node) => { void copyNodeMediaUrlToClipboard(node); }} + onUploadToArkPrivateAsset={confirmUploadNodeImageToArkPrivateAsset} onSetAssetCategory={(nodeId, assetCategory) => handleConfigNodeChange(nodeId, { assetCategory })} onToggleFrame={(node) => handleFrameToggle(node.id)} /> diff --git a/web/src/router.tsx b/web/src/router.tsx index cf76bc264..7be629efb 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -23,6 +23,7 @@ const RedemptionCodesPage = lazy(() => import("@/pages/admin/redemption-codes/re const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runtime-policy-settings-page")); const DrawingEngineSettingsPage = lazy(() => import("@/pages/admin/settings/drawing-engine-settings-page")); const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-settings-page")); +const ArkPrivateAssetsSettingsPage = lazy(() => import("@/pages/admin/settings/ark-private-assets-settings-page")); const ResponseInterceptionSettingsPage = lazy(() => import("@/pages/admin/settings/response-interception-settings-page")); const ThirdPartySettingsPage = lazy(() => import("@/pages/admin/settings/libtv-settings-page")); const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page")); @@ -157,6 +158,7 @@ export const router = createBrowserRouter([ { path: "settings/access", element: deferred() }, { path: "settings/email", element: deferred() }, { path: "settings/storage", element: deferred() }, + { path: "settings/ark-private-assets", element: deferred() }, { path: "settings/response-interception", element: deferred() }, { path: "settings/third-party", element: deferred() }, { path: "settings/libtv", element: }, diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 0290a479d..6309ea089 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -272,6 +272,18 @@ export type AdminOSSSetting = { updatedAt?: string; }; +export type AdminArkPrivateAssetSetting = { + enabled: boolean; + region: string; + projectName: string; + accessKeyId: string; + accessKeySecret?: string; + hasAccessKeySecret: boolean; + updatedBy?: string; + createdAt?: string; + updatedAt?: string; +}; + export type RuntimeResourcePolicy = { resourceUploadMB: number; sessionUploadMB: number; @@ -489,6 +501,14 @@ export function updateAdminOSSSetting(input: Partial) { return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input)); } +export function getAdminArkPrivateAssetSetting() { + return request<{ setting: AdminArkPrivateAssetSetting }>(api.get("/admin/settings/ark-private-assets")); +} + +export function updateAdminArkPrivateAssetSetting(input: Partial) { + return request<{ setting: AdminArkPrivateAssetSetting }>(api.patch("/admin/settings/ark-private-assets", input)); +} + export function getAdminRuntimePolicySetting() { return request<{ setting: RuntimePolicySetting }>(api.get("/admin/settings/runtime-policy")); } diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index a7f4b5269..7110cb505 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -481,6 +481,7 @@ export function backendProviderConfig(config: AiConfig) { vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio, videoWatermark: config.videoWatermark, + videoArkPrivateAssetUpload: config.videoArkPrivateAssetUpload, audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: config.audioSpeed, diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 33de90386..836514ef1 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -46,6 +46,11 @@ export type AccountFileStorageUsage = { totalBytes: number; }; +export type ArkPrivateAssetSync = { + resourceId: string; + status: "active" | string; +}; + const api = apiClient; const resourceCache = new Map(); const resourceRequests = new Map>(); @@ -68,6 +73,11 @@ export async function getAccountFileStorageUsage() { return data.usage; } +export async function syncResourceToArkPrivateAsset(id: string) { + const data = await request<{ sync: ArkPrivateAssetSync }>(api.post(`/resources/${encodeURIComponent(id)}/ark-private-asset`)); + return data.sync; +} + export function resourceIdFromStorageKey(storageKey?: string) { return storageKey?.startsWith("resource:") ? storageKey.slice("resource:".length) : ""; } diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index fb8540774..7860b1ed8 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -81,6 +81,7 @@ export type AiConfig = { vquality: string; videoGenerateAudio: string; videoWatermark: string; + videoArkPrivateAssetUpload: string; systemPrompt: string; models: string[]; imageModels: string[]; @@ -121,6 +122,7 @@ export const defaultConfig: AiConfig = { vquality: "720", videoGenerateAudio: "true", videoWatermark: "false", + videoArkPrivateAssetUpload: "true", systemPrompt: "", models: [], imageModels: [], @@ -338,6 +340,7 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine vquality: normalizeVideoResolution(config.vquality), videoGenerateAudio: config.videoGenerateAudio || "true", videoWatermark: config.videoWatermark || "false", + videoArkPrivateAssetUpload: config.videoArkPrivateAssetUpload || "true", transparentBackground: config.transparentBackground === "true" ? "true" : "false", canvasImageCount: config.canvasImageCount || defaultConfig.canvasImageCount, imageModels, diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 0bd7e6fbb..750c79870 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -283,6 +283,7 @@ export type CanvasNodeMetadata = { }; sessionId?: string; videoEditOperation?: CanvasVideoEditOperation; + arkPrivateAssetUpload?: string; videoCameraMoveId?: string; videoCameraMovePrompt?: string; videoStartFrameNodeId?: string;