feat: 完成短剧项目空间与结构化资产创作闭环 (#38)

* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

* feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环

Refs #30

* fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置

* feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-27 10:45:11 +08:00
committed by GitHub
parent 431b9e3294
commit d2fbc413b3
208 changed files with 22655 additions and 4234 deletions

View File

@@ -17,6 +17,7 @@ var (
ErrInsufficientCredits = errors.New("insufficient credits")
ErrRedeemCodeInvalid = errors.New("redeem code invalid")
ErrActiveTaskLimit = errors.New("active task limit reached")
ErrProjectTaskLimit = errors.New("project active task limit reached")
ErrTaskNotRetryable = errors.New("task is not retryable")
ErrBillingStateConflict = errors.New("billing state conflict")
)
@@ -73,6 +74,12 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo
return items, query.Find(&items).Error
}
func (r *Repository) ChannelModelsIncludingDeleted(channelID string) ([]model.ChannelModel, error) {
var items []model.ChannelModel
err := r.db.Unscoped().Where("channel_id = ?", channelID).Order("created_at asc").Find(&items).Error
return items, err
}
func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) {
var item model.ChannelModel
if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil {
@@ -93,12 +100,39 @@ func (r *Repository) SaveChannelModel(item *model.ChannelModel) error {
return r.db.Save(item).Error
}
func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.ChannelModel{}).
Where("id = ? AND channel_id = ?", id, channelID).
Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
if err := tx.Where("id = ? AND channel_id = ?", id, channelID).Delete(&model.ChannelModel{}).Error; err != nil {
return err
}
channelResult := tx.Model(&model.ModelChannel{}).
Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem).
Updates(map[string]any{"models_json": modelsJSON, "updated_at": now})
if channelResult.Error != nil {
return channelResult.Error
}
if channelResult.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) {
if len(items) == 0 {
return 0, nil
}
// 拉取目录可能与其他管理员操作并发,唯一键冲突时保留已有定价配置。
result := r.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "channel_id"}, {Name: "model_key"}}, DoNothing: true}).Create(&items)
result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items)
return result.RowsAffected, result.Error
}
@@ -153,11 +187,14 @@ func (r *Repository) CreditLedgerReferenceExists(referenceKey string) (bool, err
return count > 0, err
}
func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int) error {
func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *model.BillingOrder, activeTaskLimit int, domainProjectID string, projectTaskLimit int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
return err
}
if err := enforceProjectTaskLimit(tx, task.UserID, domainProjectID, projectTaskLimit); err != nil {
return err
}
if err := reserveBillingOrder(tx, order); err != nil {
return err
}
@@ -165,21 +202,27 @@ func (r *Repository) CreateTaskWithCreditReservation(task *model.Task, order *mo
})
}
func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int) error {
func (r *Repository) CreateTaskWithActiveLimit(task *model.Task, activeTaskLimit int, domainProjectID string, projectTaskLimit int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := enforceActiveTaskLimit(tx, task.UserID, activeTaskLimit); err != nil {
return err
}
if err := enforceProjectTaskLimit(tx, task.UserID, domainProjectID, projectTaskLimit); err != nil {
return err
}
return tx.Create(task).Error
})
}
func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder, activeTaskLimit int) (*model.Task, error) {
func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *model.BillingOrder, activeTaskLimit int, domainProjectID string, projectTaskLimit int) (*model.Task, error) {
var task model.Task
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := enforceActiveTaskLimit(tx, userID, activeTaskLimit); err != nil {
return err
}
if err := enforceProjectTaskLimit(tx, userID, domainProjectID, projectTaskLimit); err != nil {
return err
}
if order != nil {
if err := reserveBillingOrder(tx, order); err != nil {
return err
@@ -217,6 +260,23 @@ func enforceActiveTaskLimit(tx *gorm.DB, userID string, activeTaskLimit int) err
return nil
}
func enforceProjectTaskLimit(tx *gorm.DB, userID string, projectID string, limit int) error {
if projectID == "" || limit <= 0 {
return nil
}
canvasIDs := tx.Model(&model.CanvasProject{}).Select("id").Where("user_id = ? AND project_id = ?", userID, projectID)
var count int64
if err := tx.Model(&model.Task{}).
Where("user_id = ? AND status IN ? AND (project_id = ? OR project_id IN (?))", userID, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}, projectID, canvasIDs).
Count(&count).Error; err != nil {
return err
}
if count >= int64(limit) {
return ErrProjectTaskLimit
}
return nil
}
func (r *Repository) ReserveBillingOrder(order *model.BillingOrder) error {
return r.db.Transaction(func(tx *gorm.DB) error {
return reserveBillingOrder(tx, order)

View File

@@ -0,0 +1,143 @@
package repository
import (
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (r *Repository) ProjectCharacterAsset(userID string, projectID string, assetID string) (*model.Asset, error) {
var asset model.Asset
err := r.db.Table("assets").Select("assets.*").
Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").
Where("assets.id = ? AND assets.user_id = ? AND assets.category = ? AND project_asset_links.project_id = ?", assetID, userID, model.AssetCategoryCharacter, projectID).
First(&asset).Error
if err != nil {
return nil, err
}
return &asset, nil
}
func (r *Repository) AssetVersion(id string) (*model.AssetVersion, error) {
var version model.AssetVersion
if err := r.db.First(&version, "id = ?", id).Error; err != nil {
return nil, err
}
return &version, nil
}
func (r *Repository) AssetRepresentations(assetVersionID string) ([]model.AssetRepresentation, error) {
var representations []model.AssetRepresentation
err := r.db.Where("asset_version_id = ?", assetVersionID).Order("role asc, created_at asc").Find(&representations).Error
return representations, err
}
func (r *Repository) CharacterVoiceBinding(assetVersionID string) (*model.CharacterVoiceBinding, error) {
var binding model.CharacterVoiceBinding
if err := r.db.First(&binding, "asset_version_id = ?", assetVersionID).Error; err != nil {
return nil, err
}
return &binding, nil
}
func (r *Repository) VoiceProfileForUser(userID string, id string) (*model.VoiceProfile, error) {
var profile model.VoiceProfile
if err := r.db.First(&profile, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &profile, nil
}
func (r *Repository) VoiceProfiles(userID string) ([]model.VoiceProfile, error) {
var profiles []model.VoiceProfile
err := r.db.Where("user_id = ? AND status = ?", userID, "active").Order("created_at asc").Find(&profiles).Error
return profiles, err
}
func (r *Repository) EnsureVoiceProfiles(profiles []model.VoiceProfile) error {
if len(profiles) == 0 {
return nil
}
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "provider"}, {Name: "voice_key"}},
DoNothing: true,
}).Create(&profiles).Error
}
// CreateProjectCharacter 将角色身份、首版本和项目关联放入同一事务。
func (r *Repository) CreateProjectCharacter(projectID string, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(asset).Error; err != nil {
return err
}
if err := tx.Create(version).Error; err != nil {
return err
}
if err := tx.Create(link).Error; err != nil {
return err
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": asset.UpdatedAt}).Error
})
}
// SaveCharacterVersion 通过不可变版本替换角色当前状态,旧镜头仍可继续引用历史版本。
func (r *Repository) SaveCharacterVersion(projectID string, asset *model.Asset, version *model.AssetVersion, representations []model.AssetRepresentation, voice *model.CharacterVoiceBinding) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := saveCharacterVersion(tx, asset, version, representations, voice); err != nil {
return err
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
}
// ConfirmProjectCharacterCandidate 保证角色归并版本与候选确认同时生效,失败时不会留下孤立版本。
func (r *Repository) ConfirmProjectCharacterCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, representations []model.AssetRepresentation, voice *model.CharacterVoiceBinding) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := saveCharacterVersion(tx, asset, version, representations, voice); err != nil {
return err
}
result := tx.Model(&model.ProjectAssetCandidate{}).
Where("id = ? AND project_id = ? AND status = ?", candidate.ID, candidate.ProjectID, "pending_confirmation").
Updates(map[string]any{"status": candidate.Status, "resolved_asset_id": candidate.ResolvedAssetID, "updated_at": candidate.UpdatedAt})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
return tx.Model(&model.Project{}).Where("id = ?", candidate.ProjectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": candidate.UpdatedAt}).Error
})
}
func saveCharacterVersion(tx *gorm.DB, asset *model.Asset, version *model.AssetVersion, representations []model.AssetRepresentation, voice *model.CharacterVoiceBinding) error {
if err := tx.Create(version).Error; err != nil {
return err
}
if len(representations) > 0 {
if err := tx.Create(&representations).Error; err != nil {
return err
}
}
if voice != nil {
if err := tx.Create(voice).Error; err != nil {
return err
}
}
result := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{
"kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID,
"title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return nil
}

View File

@@ -2,6 +2,7 @@ package repository
import (
"errors"
"sort"
"strings"
"time"
@@ -387,7 +388,7 @@ func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnl
if limit <= 0 || limit > 100 {
limit = 50
}
query := r.db.Select("id", "session_id", "project_id", "type", "status", "stage", "progress", "prompt", "operation", "provider", "model", "billing_order_id", "attempts", "started_at", "completed_at", "created_at", "updated_at").
query := r.db.Select("id", "session_id", "project_id", "type", "status", "stage", "progress", "prompt", "operation", "provider", "model", "result_json", "billing_order_id", "attempts", "started_at", "completed_at", "created_at", "updated_at").
Where("user_id = ?", userID)
if strings.TrimSpace(projectID) != "" {
query = query.Where("project_id = ?", strings.TrimSpace(projectID))
@@ -460,6 +461,12 @@ func (r *Repository) SystemChannels(includeDisabled bool) ([]model.ModelChannel,
return channels, err
}
func (r *Repository) HistoricalSystemChannelReferences() ([]model.ModelChannel, error) {
var channels []model.ModelChannel
err := r.db.Unscoped().Select("id", "name").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error
return channels, err
}
func (r *Repository) AdminSystemChannels(keyword string, interfaceType string, status string, limit int, offset int) ([]model.ModelChannel, int64, error) {
var channels []model.ModelChannel
var total int64
@@ -507,6 +514,28 @@ func (r *Repository) AdminSystemChannel(id string) (*model.ModelChannel, error)
return &channel, nil
}
func (r *Repository) DeleteSystemChannel(id string) error {
now := time.Now()
return r.db.Transaction(func(tx *gorm.DB) error {
channelResult := tx.Model(&model.ModelChannel{}).
Where("id = ? AND scope = ?", id, model.ChannelScopeSystem).
Updates(map[string]any{"api_key": "", "enabled": false, "updated_at": now})
if channelResult.Error != nil {
return channelResult.Error
}
if channelResult.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
if err := tx.Model(&model.ChannelModel{}).Where("channel_id = ?", id).Updates(map[string]any{"enabled": false, "updated_at": now}).Error; err != nil {
return err
}
if err := tx.Where("channel_id = ?", id).Delete(&model.ChannelModel{}).Error; err != nil {
return err
}
return tx.Where("id = ? AND scope = ?", id, model.ChannelScopeSystem).Delete(&model.ModelChannel{}).Error
})
}
func (r *Repository) ApiCallLogs(userID string, admin bool, limit int) ([]model.ApiCallLog, error) {
var logs []model.ApiCallLog
if limit <= 0 || limit > 200 {
@@ -634,7 +663,7 @@ func (r *Repository) Assets(userID string) ([]model.Asset, error) {
func (r *Repository) AssetSummaries(userID string) ([]model.Asset, error) {
var assets []model.Asset
err := r.db.Select("id", "kind", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error
err := r.db.Select("id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error
return assets, err
}
@@ -649,7 +678,7 @@ func (r *Repository) AssetForUser(userID string, id string) (*model.Asset, error
func (r *Repository) UpsertAsset(asset *model.Asset) error {
result := r.db.Model(&model.Asset{}).
Where("id = ? AND user_id = ?", asset.ID, asset.UserID).
Updates(map[string]any{"kind": asset.Kind, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt})
Updates(map[string]any{"kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt})
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
@@ -657,7 +686,19 @@ func (r *Repository) UpsertAsset(asset *model.Asset) error {
}
func (r *Repository) DeleteAsset(userID string, id string) error {
return r.db.Delete(&model.Asset{}, "id = ? AND user_id = ?", id, userID).Error
return r.db.Transaction(func(tx *gorm.DB) error {
versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", id)
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.CharacterVoiceBinding{}).Error; err != nil {
return err
}
if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil {
return err
}
if err := tx.Where("asset_id = ?", id).Delete(&model.AssetVersion{}).Error; err != nil {
return err
}
return tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", id, userID).Error
})
}
func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error {
@@ -695,7 +736,7 @@ func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.Canv
func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error {
result := r.db.Model(&model.CanvasProject{}).
Where("id = ? AND user_id = ?", project.ID, project.UserID).
Updates(map[string]any{"title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt})
Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt})
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
@@ -706,6 +747,623 @@ func (r *Repository) DeleteCanvasProject(userID string, id string) error {
return r.db.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", id, userID).Error
}
func (r *Repository) Projects(userID string) ([]model.Project, error) {
var projects []model.Project
err := r.db.Where("user_id = ?", userID).Order("updated_at desc").Find(&projects).Error
return projects, err
}
func (r *Repository) ProjectForUser(userID string, id string) (*model.Project, error) {
var project model.Project
if err := r.db.First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &project, nil
}
func (r *Repository) CreateProject(project *model.Project) error {
return r.db.Create(project).Error
}
func (r *Repository) UpdateProject(project *model.Project) error {
return r.db.Model(&model.Project{}).Where("id = ? AND user_id = ?", project.ID, project.UserID).Updates(map[string]any{
"name": project.Name, "type": project.Type, "aspect_ratio": project.AspectRatio, "source_type": project.SourceType,
"description": project.Description, "style_preset_id": project.StylePresetID, "active_task_limit": project.ActiveTaskLimit,
"status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt,
}).Error
}
func (r *Repository) DeleteProject(userID string, id string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil {
return err
}
shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ?", id)
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.Shot{}).Error; err != nil {
return err
}
instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ?", id)
stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs)
if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil {
return err
}
if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.WorkflowInstance{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetLink{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ?", id).Delete(&model.ProjectUnit{}).Error; err != nil {
return err
}
return tx.Delete(&model.Project{}, "id = ? AND user_id = ?", id, userID).Error
})
}
func (r *Repository) BumpProjectRevision(projectID string) error {
return r.db.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
}
func (r *Repository) ProjectUnits(projectID string) ([]model.ProjectUnit, error) {
var units []model.ProjectUnit
err := r.db.Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error
return units, err
}
func (r *Repository) ProjectUnitSummaries(projectID string) ([]model.ProjectUnit, error) {
var units []model.ProjectUnit
err := r.db.Select("id", "project_id", "kind", "title", "status", "position", "created_at", "updated_at").Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error
return units, err
}
func (r *Repository) ProjectAssetCount(projectID string) (int64, error) {
var count int64
err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ?", projectID).Count(&count).Error
return count, err
}
func (r *Repository) CreateProjectUnit(unit *model.ProjectUnit) error {
return r.db.Create(unit).Error
}
func (r *Repository) ImportProjectUnits(projectID string, units []model.ProjectUnit) error {
return r.db.Transaction(func(tx *gorm.DB) error {
// 分批写入仍处于同一事务,避免两千章导入超过 SQLite/PostgreSQL 单语句参数上限。
if err := tx.CreateInBatches(&units, 100).Error; err != nil {
return err
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
}
func (r *Repository) ReorderProjectUnits(projectID string, unitIDs []string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
for position, unitID := range unitIDs {
result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unitID, projectID).Updates(map[string]any{"position": position, "updated_at": now})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": now}).Error
})
}
func (r *Repository) ProjectUnit(projectID string, id string) (*model.ProjectUnit, error) {
var unit model.ProjectUnit
if err := r.db.First(&unit, "id = ? AND project_id = ?", id, projectID).Error; err != nil {
return nil, err
}
return &unit, nil
}
func (r *Repository) UpdateProjectUnit(unit *model.ProjectUnit) error {
return r.db.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unit.ID, unit.ProjectID).Updates(map[string]any{
"parent_id": unit.ParentID, "title": unit.Title, "source_text": unit.SourceText, "status": unit.Status, "position": unit.Position, "updated_at": unit.UpdatedAt,
}).Error
}
func (r *Repository) DeleteProjectUnit(projectID string, id string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.CanvasUnitLink{}).Error; err != nil {
return err
}
shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id)
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.Shot{}).Error; err != nil {
return err
}
instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id)
stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs)
if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil {
return err
}
if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.WorkflowInstance{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
return err
}
result := tx.Delete(&model.ProjectUnit{}, "id = ? AND project_id = ?", id, projectID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
}
func (r *Repository) CanvasUnitLink(projectID string, canvasID string, unitID string) (*model.CanvasUnitLink, error) {
var link model.CanvasUnitLink
if err := r.db.First(&link, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID).Error; err != nil {
return nil, err
}
return &link, nil
}
func (r *Repository) UpsertCanvasUnitLink(link *model.CanvasUnitLink) error {
result := r.db.Model(&model.CanvasUnitLink{}).Where("project_id = ? AND canvas_id = ? AND unit_id = ?", link.ProjectID, link.CanvasID, link.UnitID).Updates(map[string]any{"role": link.Role})
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
return r.db.Create(link).Error
}
func (r *Repository) ProjectCanvasSummaries(userID string, projectID string) ([]model.CanvasProject, error) {
var canvases []model.CanvasProject
err := r.db.Select("id", "user_id", "project_id", "title", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error
return canvases, err
}
func (r *Repository) ProjectCanvasDocuments(userID string, projectID string) ([]model.CanvasProject, error) {
var canvases []model.CanvasProject
err := r.db.Select("id", "title", "payload_json").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error
return canvases, err
}
func (r *Repository) ProjectCanvasUnitLinks(projectID string) ([]model.CanvasUnitLink, error) {
var links []model.CanvasUnitLink
err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&links).Error
return links, err
}
func (r *Repository) DeleteCanvasUnitLink(projectID string, canvasID string, unitID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
result := tx.Delete(&model.CanvasUnitLink{}, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
}
func (r *Repository) AssignCanvasToProject(userID string, canvasID string, projectID string) error {
return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Update("project_id", projectID).Error
}
func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("project_id = ? AND canvas_id = ?", projectID, canvasID).Delete(&model.CanvasUnitLink{}).Error; err != nil {
return err
}
result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ?", canvasID, userID, projectID).Updates(map[string]any{
"project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error
})
}
func (r *Repository) ProjectAssets(userID string, projectID string) ([]model.Asset, error) {
var assets []model.Asset
err := r.db.Table("assets").Select("assets.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").Where("assets.user_id = ? AND project_asset_links.project_id = ?", userID, projectID).Order("assets.updated_at desc").Scan(&assets).Error
return assets, err
}
// LinkProjectAsset 将首版本、素材领域字段、项目引用和修订号原子提交,避免产生半关联资产。
func (r *Repository) LinkProjectAsset(asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) (bool, error) {
createdLink := false
err := r.db.Transaction(func(tx *gorm.DB) error {
created := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "project_id"}, {Name: "asset_id"}}, DoNothing: true}).Create(link)
if created.Error != nil {
return created.Error
}
if created.RowsAffected == 0 {
return nil
}
createdLink = true
if version != nil {
if err := tx.Create(version).Error; err != nil {
return err
}
}
result := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{
"category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrRecordNotFound
}
return tx.Model(&model.Project{}).Where("id = ?", link.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
return createdLink, err
}
func (r *Repository) DeleteProjectAssetLink(projectID string, assetID string) error {
return r.db.Delete(&model.ProjectAssetLink{}, "project_id = ? AND asset_id = ?", projectID, assetID).Error
}
func (r *Repository) ProjectAssetShotReferenceCount(projectID string, assetID string) (int64, error) {
var count int64
err := r.db.Table("shot_asset_references").
Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").
Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").
Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID).
Count(&count).Error
return count, err
}
func (r *Repository) ProjectAssetLinked(projectID string, assetID string) (bool, error) {
var count int64
err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND asset_id = ?", projectID, assetID).Count(&count).Error
return count > 0, err
}
func (r *Repository) AssetReferenceCount(assetID string) (int64, error) {
var projectLinks int64
if err := r.db.Model(&model.ProjectAssetLink{}).Where("asset_id = ?", assetID).Count(&projectLinks).Error; err != nil {
return 0, err
}
var shotLinks int64
err := r.db.Table("shot_asset_references").Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").Where("asset_versions.asset_id = ?", assetID).Count(&shotLinks).Error
return projectLinks + shotLinks, err
}
func (r *Repository) UpdateAssetDomain(asset *model.Asset) error {
return r.db.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{"category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt}).Error
}
func (r *Repository) AssetVersions(assetID string) ([]model.AssetVersion, error) {
var versions []model.AssetVersion
err := r.db.Where("asset_id = ?", assetID).Order("version desc").Find(&versions).Error
return versions, err
}
func (r *Repository) ProjectAssetUsageRoles(projectID string, assetID string) ([]string, error) {
var shotRoles []string
if err := r.db.Table("shot_asset_references").
Distinct("shot_asset_references.role").
Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").
Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").
Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID).
Order("shot_asset_references.role asc").
Pluck("shot_asset_references.role", &shotRoles).Error; err != nil {
return nil, err
}
var representationRoles []string
if err := r.db.Table("asset_representations").
Distinct("asset_representations.role").
Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id").
Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id").
Where("project_asset_links.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID).
Pluck("asset_representations.role", &representationRoles).Error; err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(shotRoles)+len(representationRoles))
for _, role := range append(shotRoles, representationRoles...) {
if role != "" {
seen[role] = struct{}{}
}
}
roles := make([]string, 0, len(seen))
for role := range seen {
roles = append(roles, role)
}
sort.Strings(roles)
return roles, nil
}
func (r *Repository) AssetVersionForProject(projectID string, versionID string) (*model.AssetVersion, error) {
var version model.AssetVersion
err := r.db.Table("asset_versions").Select("asset_versions.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id").Where("project_asset_links.project_id = ? AND asset_versions.id = ?", projectID, versionID).First(&version).Error
if err != nil {
return nil, err
}
return &version, nil
}
func (r *Repository) CreateAssetVersion(version *model.AssetVersion) error {
return r.db.Create(version).Error
}
func (r *Repository) ProjectShots(projectID string) ([]model.Shot, error) {
var shots []model.Shot
err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, position asc").Find(&shots).Error
return shots, err
}
func (r *Repository) SaveShot(shot *model.Shot, create bool) error {
if create {
return r.db.Create(shot).Error
}
return r.db.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{
"unit_id": shot.UnitID, "title": shot.Title, "description": shot.Description, "position": shot.Position,
"duration_ms": shot.DurationMs, "status": shot.Status, "updated_at": shot.UpdatedAt,
}).Error
}
func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot) error {
return r.db.Transaction(func(tx *gorm.DB) error {
shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, unitID)
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ? AND shot_id IN (?)", projectID, shotIDs).Delete(&model.ProjectAssetCandidate{}).Error; err != nil {
return err
}
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.Shot{}).Error; err != nil {
return err
}
if err := tx.Create(&shots).Error; err != nil {
return err
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error
})
}
func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Shot, error) {
var shot model.Shot
if err := r.db.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil {
return nil, err
}
return &shot, nil
}
func (r *Repository) UpsertShotAssetReference(reference *model.ShotAssetReference) error {
result := r.db.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status})
if result.Error != nil || result.RowsAffected > 0 {
return result.Error
}
return r.db.Create(reference).Error
}
func (r *Repository) ProjectShotAssetReferences(projectID string) ([]model.ShotAssetReference, error) {
var references []model.ShotAssetReference
err := r.db.Table("shot_asset_references").Select("shot_asset_references.*").
Joins("JOIN shots ON shots.id = shot_asset_references.shot_id").
Where("shots.project_id = ?", projectID).
Order("shot_asset_references.created_at asc").Scan(&references).Error
return references, err
}
func (r *Repository) ProjectAssetCandidates(projectID string) ([]model.ProjectAssetCandidate, error) {
var candidates []model.ProjectAssetCandidate
err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&candidates).Error
return candidates, err
}
func (r *Repository) ProjectAssetCandidate(projectID string, candidateID string) (*model.ProjectAssetCandidate, error) {
var candidate model.ProjectAssetCandidate
if err := r.db.First(&candidate, "id = ? AND project_id = ?", candidateID, projectID).Error; err != nil {
return nil, err
}
return &candidate, nil
}
func (r *Repository) CreateProjectAssetCandidates(candidates []model.ProjectAssetCandidate) error {
if len(candidates) == 0 {
return nil
}
return r.db.Create(&candidates).Error
}
// ConfirmProjectAssetCandidate 将正式资产身份、首版本、项目引用和候选状态放在同一事务中,避免出现半确认数据。
func (r *Repository) ConfirmProjectAssetCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink, createAsset bool) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if createAsset {
if err := tx.Create(asset).Error; err != nil {
return err
}
if err := tx.Create(version).Error; err != nil {
return err
}
} else if err := tx.First(&model.Asset{}, "id = ? AND user_id = ?", asset.ID, asset.UserID).Error; err != nil {
return err
}
if err := tx.Where("project_id = ? AND asset_id = ?", link.ProjectID, link.AssetID).FirstOrCreate(link).Error; err != nil {
return err
}
result := tx.Model(&model.ProjectAssetCandidate{}).
Where("id = ? AND project_id = ? AND status = ?", candidate.ID, candidate.ProjectID, "pending_confirmation").
Updates(map[string]any{"status": candidate.Status, "resolved_asset_id": candidate.ResolvedAssetID, "updated_at": candidate.UpdatedAt})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return gorm.ErrInvalidData
}
return tx.Model(&model.Project{}).Where("id = ?", candidate.ProjectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": candidate.UpdatedAt}).Error
})
}
func (r *Repository) WorkflowTemplateVersion(templateKey string, version int) (*model.WorkflowTemplateVersion, error) {
var template model.WorkflowTemplateVersion
if err := r.db.First(&template, "template_key = ? AND version = ?", templateKey, version).Error; err != nil {
return nil, err
}
return &template, nil
}
func (r *Repository) CreateWorkflowTemplateVersion(template *model.WorkflowTemplateVersion) error {
return r.db.Create(template).Error
}
func (r *Repository) ProjectWorkflowInstances(projectID string) ([]model.WorkflowInstance, error) {
var instances []model.WorkflowInstance
err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&instances).Error
return instances, err
}
func (r *Repository) WorkflowInstanceForScope(projectID string, unitID string, templateVersionID string) (*model.WorkflowInstance, error) {
var instance model.WorkflowInstance
if err := r.db.First(&instance, "project_id = ? AND unit_id = ? AND template_version_id = ?", projectID, unitID, templateVersionID).Error; err != nil {
return nil, err
}
return &instance, nil
}
func (r *Repository) WorkflowInstance(id string) (*model.WorkflowInstance, error) {
var instance model.WorkflowInstance
if err := r.db.First(&instance, "id = ?", id).Error; err != nil {
return nil, err
}
return &instance, nil
}
func (r *Repository) WorkflowSteps(instanceID string) ([]model.WorkflowStepInstance, error) {
var steps []model.WorkflowStepInstance
err := r.db.Where("workflow_instance_id = ?", instanceID).Order("position asc").Find(&steps).Error
return steps, err
}
func (r *Repository) NextWorkflowStep(instanceID string, position int) (*model.WorkflowStepInstance, error) {
var step model.WorkflowStepInstance
if err := r.db.Where("workflow_instance_id = ? AND position > ?", instanceID, position).Order("position asc").First(&step).Error; err != nil {
return nil, err
}
return &step, nil
}
func (r *Repository) CreateWorkflowInstance(instance *model.WorkflowInstance, steps []model.WorkflowStepInstance) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(instance).Error; err != nil {
return err
}
if len(steps) == 0 {
return nil
}
return tx.Create(&steps).Error
})
}
func (r *Repository) WorkflowStepForProject(projectID string, stepID string) (*model.WorkflowStepInstance, error) {
var step model.WorkflowStepInstance
err := r.db.Table("workflow_step_instances").Select("workflow_step_instances.*").Joins("JOIN workflow_instances ON workflow_instances.id = workflow_step_instances.workflow_instance_id").Where("workflow_instances.project_id = ? AND workflow_step_instances.id = ?", projectID, stepID).First(&step).Error
if err != nil {
return nil, err
}
return &step, nil
}
func (r *Repository) UpdateWorkflowStep(step *model.WorkflowStepInstance) error {
return r.db.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{"status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt}).Error
}
// UpdateWorkflowProgress 原子保存当前步骤、下一步骤和实例状态,确保刷新后流程依赖仍可恢复。
func (r *Repository) UpdateWorkflowProgress(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{
"status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt,
"completed_at": step.CompletedAt, "updated_at": step.UpdatedAt,
}).Error; err != nil {
return err
}
if next != nil {
if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID).
Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt}).Error; err != nil {
return err
}
}
if err := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID).
Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt}).Error; err != nil {
return err
}
return tx.Model(&model.Project{}).Where("id = ?", projectID).
Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt}).Error
})
}
// RegisterWorkflowTaskOutput 将成功任务、流程步骤和产物表示写入同一事务,重复回填使用任务与用途唯一键幂等。
func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("workflow_step_id = ? AND task_id = ?", link.WorkflowStepID, link.TaskID).FirstOrCreate(link).Error; err != nil {
return err
}
if representation != nil {
if err := tx.Where("task_id = ? AND role = ?", representation.TaskID, representation.Role).FirstOrCreate(representation).Error; err != nil {
return err
}
}
stepResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{
"status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt,
"completed_at": step.CompletedAt, "updated_at": step.UpdatedAt,
})
if stepResult.Error != nil {
return stepResult.Error
}
if stepResult.RowsAffected != 1 {
return gorm.ErrInvalidData
}
if next != nil {
nextResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID).Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt})
if nextResult.Error != nil {
return nextResult.Error
}
if nextResult.RowsAffected != 1 {
return gorm.ErrInvalidData
}
}
instanceResult := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID).Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt})
if instanceResult.Error != nil {
return instanceResult.Error
}
if instanceResult.RowsAffected != 1 {
return gorm.ErrInvalidData
}
projectResult := tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt})
if projectResult.Error != nil {
return projectResult.Error
}
if projectResult.RowsAffected != 1 {
return gorm.ErrInvalidData
}
return nil
})
}
func (r *Repository) CanvasShareForProject(userID string, projectID string) (*model.CanvasShare, error) {
var share model.CanvasShare
if err := r.db.First(&share, "user_id = ? AND project_id = ?", userID, projectID).Error; err != nil {