feat(*): 创作平台 - 升级短剧分镜工作流并完善模型计费与界面状态
This commit is contained in:
@@ -260,11 +260,14 @@ func migrations() []tableMigration {
|
||||
migrateTable[model.ProjectUnit]("project_units"),
|
||||
migrateTable[model.CanvasUnitLink]("canvas_unit_links"),
|
||||
migrateTable[model.Shot]("shots"),
|
||||
migrateTable[model.ShotRevision]("shot_revisions"),
|
||||
migrateTable[model.ShotArtifact]("shot_artifacts"),
|
||||
migrateTable[model.ShotAssetReference]("shot_asset_references"),
|
||||
migrateTable[model.WorkflowTemplateVersion]("workflow_template_versions"),
|
||||
migrateTable[model.WorkflowInstance]("workflow_instances"),
|
||||
migrateTable[model.WorkflowStepInstance]("workflow_step_instances"),
|
||||
migrateTable[model.WorkflowStepTask]("workflow_step_tasks"),
|
||||
migrateTable[model.ProductionTaskLink]("production_task_links"),
|
||||
migrateTable[model.CanvasProject]("canvas_projects"),
|
||||
migrateTable[model.CanvasShare]("canvas_shares"),
|
||||
migrateTable[model.PromptTemplate]("prompt_templates"),
|
||||
|
||||
@@ -62,11 +62,14 @@ func Models() []any {
|
||||
&model.ProjectUnit{},
|
||||
&model.CanvasUnitLink{},
|
||||
&model.Shot{},
|
||||
&model.ShotRevision{},
|
||||
&model.ShotArtifact{},
|
||||
&model.ShotAssetReference{},
|
||||
&model.WorkflowTemplateVersion{},
|
||||
&model.WorkflowInstance{},
|
||||
&model.WorkflowStepInstance{},
|
||||
&model.WorkflowStepTask{},
|
||||
&model.ProductionTaskLink{},
|
||||
&model.CanvasProject{},
|
||||
&model.CanvasShare{},
|
||||
&model.PromptTemplate{},
|
||||
|
||||
@@ -678,6 +678,25 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"shots": shots})
|
||||
})
|
||||
r.POST("/projects/:id/shots/:shotId/revisions", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10)
|
||||
var req service.ShotRevisionInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
shot, revision, err := svc.CreateShotRevision(user.ID, c.Param("id"), c.Param("shotId"), req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"shot": shot, "revision": revision})
|
||||
})
|
||||
r.POST("/projects/:id/shots/:shotId/assets", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -203,16 +203,56 @@ type CanvasUnitLink struct {
|
||||
}
|
||||
|
||||
type Shot struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36"`
|
||||
Title string `json:"title" gorm:"size:240"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Position int `json:"position"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status" gorm:"index;size:24"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36"`
|
||||
CurrentRevisionID string `json:"currentRevisionId,omitempty" gorm:"index;size:36"`
|
||||
Title string `json:"title" gorm:"size:240"`
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
Position int `json:"position"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status" gorm:"index;size:24"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ShotRevision 保存可复现的分镜脚本版本;Shot 只保留稳定身份、排序和当前版本指针。
|
||||
type ShotRevision struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_revisions_version,priority:1"`
|
||||
Version int `json:"version" gorm:"uniqueIndex:idx_shot_revisions_version,priority:2"`
|
||||
PlotDescription string `json:"plotDescription" gorm:"type:text"`
|
||||
Action string `json:"action" gorm:"type:text"`
|
||||
Dialogue string `json:"dialogue" gorm:"type:text"`
|
||||
ShotSize string `json:"shotSize" gorm:"size:80"`
|
||||
CameraAngle string `json:"cameraAngle" gorm:"size:80"`
|
||||
CameraMovement string `json:"cameraMovement" gorm:"size:120"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
ImagePrompt string `json:"imagePrompt" gorm:"type:text"`
|
||||
VideoPrompt string `json:"videoPrompt" gorm:"type:text"`
|
||||
NegativePrompt string `json:"negativePrompt" gorm:"type:text"`
|
||||
ContinuityNotes string `json:"continuityNotes" gorm:"type:text"`
|
||||
ActionBeatsJSON string `json:"actionBeatsJson" gorm:"type:text"`
|
||||
CreatedBy string `json:"createdBy,omitempty" gorm:"index;size:36"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ShotArtifact 是镜头的版本化生产产物。修改分镜或资产引用时只标记 stale,不删除历史。
|
||||
type ShotArtifact struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
UnitID string `json:"unitId" gorm:"index;size:36"`
|
||||
ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_artifacts_version,priority:1"`
|
||||
RevisionID string `json:"revisionId,omitempty" gorm:"index;size:36"`
|
||||
TaskID string `json:"taskId,omitempty" gorm:"index;size:36"`
|
||||
Type string `json:"type" gorm:"index;size:40;uniqueIndex:idx_shot_artifacts_version,priority:2"`
|
||||
Version int `json:"version" gorm:"uniqueIndex:idx_shot_artifacts_version,priority:3"`
|
||||
ResourceID string `json:"resourceId,omitempty" gorm:"index;size:36"`
|
||||
Status string `json:"status" gorm:"index;size:24"`
|
||||
Selected bool `json:"selected" gorm:"index"`
|
||||
MetadataJSON string `json:"metadataJson" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ShotAssetReference struct {
|
||||
@@ -268,6 +308,20 @@ type WorkflowStepTask struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// ProductionTaskLink 显式区分领域项目、画布和镜头上下文,避免继续复用 Task.ProjectID 表达多种身份。
|
||||
type ProductionTaskLink struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_production_task_context,priority:1"`
|
||||
ProjectID string `json:"projectId" gorm:"index;size:36"`
|
||||
CanvasID string `json:"canvasId,omitempty" gorm:"index;size:80"`
|
||||
UnitID string `json:"unitId,omitempty" gorm:"index;size:36"`
|
||||
ShotID string `json:"shotId,omitempty" gorm:"index;size:36;uniqueIndex:idx_production_task_context,priority:2"`
|
||||
WorkflowStepID string `json:"workflowStepId,omitempty" gorm:"index;size:36"`
|
||||
ArtifactType string `json:"artifactType,omitempty" gorm:"index;size:40;uniqueIndex:idx_production_task_context,priority:3"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type CanvasProject struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:80"`
|
||||
UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"`
|
||||
|
||||
@@ -1154,6 +1154,15 @@ func (r *Repository) DeleteProject(userID string, id string, canvasUpdates []mod
|
||||
return fmt.Errorf("项目删除时仍有 %d 个画布关联未处理", remainingCanvasCount)
|
||||
}
|
||||
shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ?", id)
|
||||
if err := tx.Where("project_id = ?", id).Delete(&model.ProductionTaskLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("project_id = ?", id).Delete(&model.ShotArtifact{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1253,10 +1262,27 @@ func (r *Repository) ProjectUnit(projectID string, id string) (*model.ProjectUni
|
||||
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) UpdateProjectUnit(unit *model.ProjectUnit, invalidateWorkflow bool) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.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,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if invalidateWorkflow {
|
||||
if err := tx.Model(&model.ShotArtifact{}).Where("project_id = ? AND unit_id = ? AND status NOT IN ?", unit.ProjectID, unit.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": unit.UpdatedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := invalidateUnitWorkflowTx(tx, unit.ProjectID, unit.ID, "story", unit.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(&model.Project{}).Where("id = ?", unit.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": unit.UpdatedAt}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteProjectUnit(projectID string, id string) error {
|
||||
@@ -1265,6 +1291,15 @@ func (r *Repository) DeleteProjectUnit(projectID string, id string) error {
|
||||
return err
|
||||
}
|
||||
shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id)
|
||||
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProductionTaskLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ShotArtifact{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1609,9 +1644,61 @@ func (r *Repository) SaveShot(shot *model.Shot, create bool) error {
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot) error {
|
||||
// SaveShotWithRevision 原子保存镜头当前值、新版本和下游失效状态。
|
||||
func (r *Repository) SaveShotWithRevision(shot *model.Shot, revision *model.ShotRevision, create bool) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if create {
|
||||
if err := tx.Create(shot).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result := tx.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,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
var currentVersion int
|
||||
if err := tx.Model(&model.ShotRevision{}).Where("shot_id = ?", shot.ID).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
revision.Version = currentVersion + 1
|
||||
if err := tx.Create(revision).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
shot.CurrentRevisionID = revision.ID
|
||||
if err := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{
|
||||
"current_revision_id": revision.ID, "description": shot.Description, "duration_ms": shot.DurationMs,
|
||||
"status": shot.Status, "updated_at": shot.UpdatedAt,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !create {
|
||||
if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shot.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": shot.UpdatedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := invalidateUnitWorkflowTx(tx, shot.ProjectID, shot.UnitID, "storyboard", shot.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Project{}).Where("id = ?", shot.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": shot.UpdatedAt}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot, revisions []model.ShotRevision) 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("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.ShotArtifact{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1624,6 +1711,14 @@ func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, sh
|
||||
if err := tx.Create(&shots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(revisions) > 0 {
|
||||
if err := tx.Create(&revisions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := invalidateUnitWorkflowTx(tx, projectID, unitID, "storyboard", time.Now()); 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
|
||||
})
|
||||
}
|
||||
@@ -1636,6 +1731,48 @@ func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Sho
|
||||
return &shot, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectShotRevisions(projectID string) ([]model.ShotRevision, error) {
|
||||
var revisions []model.ShotRevision
|
||||
err := r.db.Table("shot_revisions").Select("shot_revisions.*").
|
||||
Joins("JOIN shots ON shots.id = shot_revisions.shot_id").
|
||||
Where("shots.project_id = ?", projectID).
|
||||
Order("shots.unit_id asc, shots.position asc, shot_revisions.version asc").Scan(&revisions).Error
|
||||
return revisions, err
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectShotArtifacts(projectID string) ([]model.ShotArtifact, error) {
|
||||
var artifacts []model.ShotArtifact
|
||||
err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, shot_id asc, type asc, version asc").Find(&artifacts).Error
|
||||
return artifacts, err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateShotArtifact(artifact *model.ShotArtifact) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var currentVersion int
|
||||
if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
artifact.Version = currentVersion + 1
|
||||
if artifact.Selected {
|
||||
if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(artifact).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Repository) MarkShotArtifactsStale(shotID string, updatedAt time.Time) error {
|
||||
return r.db.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertProductionTaskLink(link *model.ProductionTaskLink) error {
|
||||
return r.db.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", link.TaskID, link.ShotID, link.ArtifactType).Assign(map[string]any{
|
||||
"project_id": link.ProjectID, "canvas_id": link.CanvasID, "unit_id": link.UnitID, "shot_id": link.ShotID,
|
||||
"workflow_step_id": link.WorkflowStepID, "artifact_type": link.ArtifactType, "updated_at": link.UpdatedAt,
|
||||
}).FirstOrCreate(link).Error
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1644,6 +1781,75 @@ func (r *Repository) UpsertShotAssetReference(reference *model.ShotAssetReferenc
|
||||
return r.db.Create(reference).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpsertShotAssetReferenceAndInvalidate(projectID string, reference *model.ShotAssetReference, updatedAt time.Time) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.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 {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
if err := tx.Create(reference).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", reference.ShotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var shot model.Shot
|
||||
if err := tx.First(&shot, "id = ? AND project_id = ?", reference.ShotID, projectID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func invalidateUnitWorkflowTx(tx *gorm.DB, projectID string, unitID string, fromStepKey string, updatedAt time.Time) error {
|
||||
if strings.TrimSpace(unitID) == "" {
|
||||
return nil
|
||||
}
|
||||
var instances []model.WorkflowInstance
|
||||
if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Find(&instances).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
var steps []model.WorkflowStepInstance
|
||||
if err := tx.Where("workflow_instance_id = ?", instance.ID).Order("position asc").Find(&steps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
fromPosition := -1
|
||||
for _, step := range steps {
|
||||
if step.StepKey == fromStepKey {
|
||||
fromPosition = step.Position
|
||||
break
|
||||
}
|
||||
}
|
||||
if fromPosition < 0 {
|
||||
continue
|
||||
}
|
||||
for _, step := range steps {
|
||||
if step.Position < fromPosition {
|
||||
continue
|
||||
}
|
||||
status := model.WorkflowStepStatusPending
|
||||
if step.Position == fromPosition {
|
||||
status = model.WorkflowStepStatusRunning
|
||||
}
|
||||
if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, instance.ID).Updates(map[string]any{
|
||||
"status": status, "error": "", "completed_at": nil, "updated_at": updatedAt,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.WorkflowInstance{}).Where("id = ?", instance.ID).Updates(map[string]any{"status": model.WorkflowStatusActive, "revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ProjectShotAssetReferences(projectID string) ([]model.ShotAssetReference, error) {
|
||||
var references []model.ShotAssetReference
|
||||
err := r.db.Table("shot_asset_references").Select("shot_asset_references.*").
|
||||
@@ -1802,7 +2008,7 @@ func (r *Repository) UpdateWorkflowProgress(step *model.WorkflowStepInstance, ne
|
||||
}
|
||||
|
||||
// RegisterWorkflowTaskOutput 将成功任务、流程步骤和产物表示写入同一事务,重复回填使用任务与用途唯一键幂等。
|
||||
func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation) error {
|
||||
func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation, productionLink *model.ProductionTaskLink, artifact *model.ShotArtifact) 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
|
||||
@@ -1812,6 +2018,29 @@ func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance
|
||||
return err
|
||||
}
|
||||
}
|
||||
if productionLink != nil {
|
||||
if err := tx.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", productionLink.TaskID, productionLink.ShotID, productionLink.ArtifactType).Assign(map[string]any{
|
||||
"project_id": productionLink.ProjectID, "canvas_id": productionLink.CanvasID, "unit_id": productionLink.UnitID, "shot_id": productionLink.ShotID,
|
||||
"workflow_step_id": productionLink.WorkflowStepID, "artifact_type": productionLink.ArtifactType, "updated_at": productionLink.UpdatedAt,
|
||||
}).FirstOrCreate(productionLink).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if artifact != nil {
|
||||
var currentVersion int
|
||||
if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
artifact.Version = currentVersion + 1
|
||||
if artifact.Selected {
|
||||
if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(artifact).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,
|
||||
|
||||
@@ -161,6 +161,19 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工作流", ID: step.ID, Title: step.Title, PrimaryJSON: step.PrimaryJSON, SecondaryJSON: step.SecondaryJSON})
|
||||
}
|
||||
|
||||
var shotArtifacts []joinedDocument
|
||||
artifactDocumentQuery := r.db.Table("shot_artifacts").
|
||||
Select("shot_artifacts.id, shots.title, shot_artifacts.metadata_json AS primary_json").
|
||||
Joins("JOIN shots ON shots.id = shot_artifacts.shot_id").
|
||||
Joins("JOIN projects ON projects.id = shots.project_id").
|
||||
Where("projects.user_id = ?", userID)
|
||||
if err := artifactDocumentQuery.Scan(&shotArtifacts).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
for _, artifact := range shotArtifacts {
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "镜头产物", ID: artifact.ID, Title: artifact.Title, PrimaryJSON: artifact.PrimaryJSON})
|
||||
}
|
||||
|
||||
type joinedRepresentation struct {
|
||||
ID string
|
||||
Title string
|
||||
@@ -186,6 +199,24 @@ func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID s
|
||||
for _, voice := range voices {
|
||||
snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "声音", ID: voice.ID, Title: voice.Name, ResourceID: voice.SampleResourceID})
|
||||
}
|
||||
|
||||
type joinedShotArtifact struct {
|
||||
ID string
|
||||
Title string
|
||||
ResourceID string
|
||||
}
|
||||
var artifacts []joinedShotArtifact
|
||||
if err := r.db.Table("shot_artifacts").
|
||||
Select("shot_artifacts.id, shots.title, shot_artifacts.resource_id").
|
||||
Joins("JOIN shots ON shots.id = shot_artifacts.shot_id").
|
||||
Joins("JOIN projects ON projects.id = shots.project_id").
|
||||
Where("projects.user_id = ? AND shot_artifacts.resource_id IN ?", userID, resourceIDs).
|
||||
Scan(&artifacts).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
for _, artifact := range artifacts {
|
||||
snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "镜头产物", ID: artifact.ID, Title: artifact.Title, ResourceID: artifact.ResourceID})
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ func TestCustomChannelTaskInputRequiresFeature(t *testing.T) {
|
||||
func TestCreateTaskDoesNotClassifyCustomChannelAsMissingSystemModel(t *testing.T) {
|
||||
svc, _ := newFeatureAvailabilityTestService(t)
|
||||
actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
|
||||
if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: false}); err != nil {
|
||||
if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: false, FrontendModelsEnabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -452,7 +452,9 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st
|
||||
capability = capabilityFromTaskType(task.Type)
|
||||
}
|
||||
scene := firstNonEmpty(strings.TrimSpace(task.Operation), task.Type)
|
||||
return s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability))
|
||||
intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation)
|
||||
priceTierID, _ := config["priceTierId"].(string)
|
||||
return s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelID, modelKey, capability, scene, billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(priceTierID), intent)
|
||||
}
|
||||
|
||||
func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) {
|
||||
@@ -474,7 +476,9 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i
|
||||
capability = capabilityFromTaskType(task.Type)
|
||||
}
|
||||
if logicalModel.PricePolicy == "channel" {
|
||||
order, priceErr := s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"])))
|
||||
intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation)
|
||||
priceTierID, _ := config["priceTierId"].(string)
|
||||
order, priceErr := s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(priceTierID), intent)
|
||||
if priceErr != nil {
|
||||
return nil, priceErr
|
||||
}
|
||||
@@ -560,7 +564,7 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s
|
||||
return s.newBillingOrderWithPriceTier(userID, taskID, idempotencyKey, channelID, modelKey, capability, scene, requestedQuantity, tokenEstimate, "")
|
||||
}
|
||||
|
||||
func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate, priceTierID string) (*model.BillingOrder, error) {
|
||||
func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate, priceTierID string, intents ...ModelRequestIntent) (*model.BillingOrder, error) {
|
||||
item, err := s.repo.ChannelModelByKey(channelID, modelKey)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, BadAuthRequest("当前模型暂时不可用,请重新选择")
|
||||
@@ -568,7 +572,7 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tier := channelModelPriceTierForBilling(*item, priceTierID, capability)
|
||||
tier := channelModelPriceTierForBilling(*item, priceTierID, capability, intents...)
|
||||
if tier == nil {
|
||||
return nil, BadAuthRequest("当前模型尚未配置所选规格的用户积分价格")
|
||||
}
|
||||
@@ -625,7 +629,7 @@ func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, ide
|
||||
}, nil
|
||||
}
|
||||
|
||||
func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string) *model.ChannelModelPriceTier {
|
||||
func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string, intents ...ModelRequestIntent) *model.ChannelModelPriceTier {
|
||||
if priceTierID != "" {
|
||||
for index := range channelModel.PriceTiers {
|
||||
tier := &channelModel.PriceTiers[index]
|
||||
@@ -635,6 +639,13 @@ func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierI
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(intents) > 0 {
|
||||
intent := intents[0]
|
||||
if normalizeCapability(intent.Capability) == "" {
|
||||
intent.Capability = capability
|
||||
}
|
||||
return channelModelPriceTierForIntent(channelModel, intent)
|
||||
}
|
||||
return channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: capability, Options: map[string]any{}})
|
||||
}
|
||||
|
||||
|
||||
54
backend/internal/service/finance_price_tier_test.go
Normal file
54
backend/internal/service/finance_price_tier_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestTaskBillingOrderMatchesSystemImagePriceTierFromRequestedSpec(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelModel := model.ChannelModel{
|
||||
ID: "channel-model-1", ChannelID: "channel-1", ModelKey: "gpt-image-2", ProviderModelKey: "gpt-image-2",
|
||||
Capability: "image", Protocol: model.ChannelInterfaceOpenAIImage, BillingMode: "fixed_request",
|
||||
PriceConfigured: true, Enabled: true, PriceVersion: 1,
|
||||
}
|
||||
if err := db.Create(&channelModel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tier := model.ChannelModelPriceTier{
|
||||
ID: "tier-2k", ChannelModelID: channelModel.ID, SelectorKey: `{"quality":"2k"}`, SelectorJSON: `{"quality":"2k"}`,
|
||||
Resolution: "*", ProviderModelKey: channelModel.ProviderModelKey, BillingMode: "fixed_request",
|
||||
UnitPriceMicrocredits: 4_000_000, PriceConfigured: true, Enabled: true, PriceVersion: 1,
|
||||
}
|
||||
if err := db.Create(&tier).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := New(repository.New(db), t.TempDir())
|
||||
order, err := svc.taskBillingOrder("user-1", &model.Task{ID: "task-1", Type: "canvas_image", Operation: "image"}, map[string]any{
|
||||
"mode": "image",
|
||||
"config": map[string]any{
|
||||
"channelId": "channel-1",
|
||||
"model": "gpt-image-2",
|
||||
"quality": "2K",
|
||||
"size": "*",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("taskBillingOrder() error = %v", err)
|
||||
}
|
||||
if order == nil || order.PriceTierID != tier.ID || order.AmountMicrocredits != tier.UnitPriceMicrocredits {
|
||||
t.Fatalf("taskBillingOrder() = %#v, want tier %s", order, tier.ID)
|
||||
}
|
||||
}
|
||||
@@ -1086,7 +1086,8 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route
|
||||
if capability == "" {
|
||||
capability = capabilityFromTaskType(task.Type)
|
||||
}
|
||||
replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"])))
|
||||
priceTierID, _ := config["priceTierId"].(string)
|
||||
replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ type ProjectDetail struct {
|
||||
AssetFolders []model.ProjectAssetFolder `json:"assetFolders"`
|
||||
Workflows []ProjectWorkflowDetail `json:"workflows"`
|
||||
Shots []model.Shot `json:"shots"`
|
||||
ShotRevisions []model.ShotRevision `json:"shotRevisions"`
|
||||
ShotArtifacts []model.ShotArtifact `json:"shotArtifacts"`
|
||||
ShotReferences []model.ShotAssetReference `json:"shotReferences"`
|
||||
AssetCandidates []model.ProjectAssetCandidate `json:"assetCandidates"`
|
||||
}
|
||||
@@ -177,6 +179,14 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error)
|
||||
if err != nil {
|
||||
return ProjectDetail{}, err
|
||||
}
|
||||
shotRevisions, err := s.repo.ProjectShotRevisions(project.ID)
|
||||
if err != nil {
|
||||
return ProjectDetail{}, err
|
||||
}
|
||||
shotArtifacts, err := s.repo.ProjectShotArtifacts(project.ID)
|
||||
if err != nil {
|
||||
return ProjectDetail{}, err
|
||||
}
|
||||
shotReferences, err := s.repo.ProjectShotAssetReferences(project.ID)
|
||||
if err != nil {
|
||||
return ProjectDetail{}, err
|
||||
@@ -185,7 +195,7 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error)
|
||||
if err != nil {
|
||||
return ProjectDetail{}, err
|
||||
}
|
||||
return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotReferences: shotReferences, AssetCandidates: candidates}, nil
|
||||
return ProjectDetail{Project: *project, Units: units, Canvases: canvases, CanvasUnitLinks: canvasUnitLinks, Assets: assets, AssetFolders: assetFolders, Workflows: workflows, Shots: shots, ShotRevisions: shotRevisions, ShotArtifacts: shotArtifacts, ShotReferences: shotReferences, AssetCandidates: candidates}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateProject(userID string, req CreateProjectRequest) (model.Project, error) {
|
||||
@@ -317,6 +327,17 @@ func (s *Service) DeleteProject(userID string, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) activeProjectForUser(userID string, projectID string) (*model.Project, error) {
|
||||
project, err := s.repo.ProjectForUser(userID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if project.Status == model.ProjectStatusArchived {
|
||||
return nil, BadAuthRequest("项目已归档,不能修改短剧生产数据")
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateProjectUnit(userID string, projectID string, req CreateProjectUnitRequest) (model.ProjectUnit, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
return model.ProjectUnit{}, err
|
||||
@@ -439,6 +460,7 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri
|
||||
if err != nil {
|
||||
return model.ProjectUnit{}, err
|
||||
}
|
||||
sourceChanged := unit.SourceText != req.SourceText
|
||||
if title := strings.TrimSpace(req.Title); title != "" {
|
||||
unit.Title = title
|
||||
}
|
||||
@@ -450,10 +472,7 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri
|
||||
unit.Status = status
|
||||
}
|
||||
unit.UpdatedAt = time.Now()
|
||||
if err := s.repo.UpdateProjectUnit(unit); err != nil {
|
||||
return model.ProjectUnit{}, err
|
||||
}
|
||||
if err := s.repo.BumpProjectRevision(projectID); err != nil {
|
||||
if err := s.repo.UpdateProjectUnit(unit, sourceChanged); err != nil {
|
||||
return model.ProjectUnit{}, err
|
||||
}
|
||||
return *unit, nil
|
||||
|
||||
@@ -31,10 +31,13 @@ func newProjectDeleteTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
&model.Task{},
|
||||
&model.Session{},
|
||||
&model.Shot{},
|
||||
&model.ShotRevision{},
|
||||
&model.ShotArtifact{},
|
||||
&model.ShotAssetReference{},
|
||||
&model.WorkflowInstance{},
|
||||
&model.WorkflowStepInstance{},
|
||||
&model.WorkflowStepTask{},
|
||||
&model.ProductionTaskLink{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -60,8 +63,12 @@ func TestDeleteProjectUnlinksCanvasAndKeepsIndependentRecords(t *testing.T) {
|
||||
&model.ProjectAssetLink{ID: "asset-link-1", ProjectID: project.ID, AssetID: asset.ID},
|
||||
&model.ProjectAssetFolder{ID: "folder-1", ProjectID: project.ID, Name: "角色", NameKey: "角色"},
|
||||
&model.ProjectAssetCandidate{ID: "candidate-1", ProjectID: project.ID, UnitID: "unit-1", Name: "角色", Status: "pending_confirmation"},
|
||||
&model.Shot{ID: "shot-1", ProjectID: project.ID, UnitID: "unit-1", CurrentRevisionID: "revision-1", Title: "镜头一"},
|
||||
&model.ShotRevision{ID: "revision-1", ShotID: "shot-1", Version: 1, PlotDescription: "角色入场"},
|
||||
&model.ShotArtifact{ID: "artifact-1", ProjectID: project.ID, UnitID: "unit-1", ShotID: "shot-1", RevisionID: "revision-1", Type: "storyboard", Version: 1, Status: "ready"},
|
||||
&task,
|
||||
&canvasTask,
|
||||
&model.ProductionTaskLink{ID: "production-link-1", TaskID: task.ID, ProjectID: project.ID, UnitID: "unit-1", ShotID: "shot-1"},
|
||||
&session,
|
||||
&canvasSession,
|
||||
}
|
||||
@@ -105,6 +112,9 @@ func TestDeleteProjectUnlinksCanvasAndKeepsIndependentRecords(t *testing.T) {
|
||||
{name: "asset link", model: &model.ProjectAssetLink{}, where: "project_id = ?"},
|
||||
{name: "asset folder", model: &model.ProjectAssetFolder{}, where: "project_id = ?"},
|
||||
{name: "asset candidate", model: &model.ProjectAssetCandidate{}, where: "project_id = ?"},
|
||||
{name: "shot", model: &model.Shot{}, where: "project_id = ?"},
|
||||
{name: "shot artifact", model: &model.ShotArtifact{}, where: "project_id = ?"},
|
||||
{name: "production task link", model: &model.ProductionTaskLink{}, where: "project_id = ?"},
|
||||
} {
|
||||
var count int64
|
||||
if err := db.Model(check.model).Where(check.where, project.ID).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -9,13 +9,29 @@ import (
|
||||
)
|
||||
|
||||
type CreateProjectShotRequest struct {
|
||||
ID string `json:"id"`
|
||||
UnitID string `json:"unitId"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position int `json:"position"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status"`
|
||||
ID string `json:"id"`
|
||||
UnitID string `json:"unitId"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Position int `json:"position"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status"`
|
||||
Revision ShotRevisionInput `json:"revision"`
|
||||
}
|
||||
|
||||
type ShotRevisionInput struct {
|
||||
PlotDescription string `json:"plotDescription"`
|
||||
Action string `json:"action"`
|
||||
Dialogue string `json:"dialogue"`
|
||||
ShotSize string `json:"shotSize"`
|
||||
CameraAngle string `json:"cameraAngle"`
|
||||
CameraMovement string `json:"cameraMovement"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
ImagePrompt string `json:"imagePrompt"`
|
||||
VideoPrompt string `json:"videoPrompt"`
|
||||
NegativePrompt string `json:"negativePrompt"`
|
||||
ContinuityNotes string `json:"continuityNotes"`
|
||||
ActionBeats []map[string]any `json:"actionBeats"`
|
||||
}
|
||||
|
||||
type ReplaceProjectUnitShotsRequest struct {
|
||||
@@ -40,7 +56,7 @@ type CreateAssetCandidatesRequest struct {
|
||||
}
|
||||
|
||||
func (s *Service) CreateProjectShot(userID string, projectID string, req CreateProjectShotRequest) (model.Shot, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return model.Shot{}, err
|
||||
}
|
||||
unitID := strings.TrimSpace(req.UnitID)
|
||||
@@ -70,6 +86,9 @@ func (s *Service) CreateProjectShot(userID string, projectID string, req CreateP
|
||||
if err != nil {
|
||||
return model.Shot{}, err
|
||||
}
|
||||
if unitID == "" {
|
||||
unitID = existing.UnitID
|
||||
}
|
||||
if status == "" {
|
||||
status = existing.Status
|
||||
}
|
||||
@@ -78,18 +97,21 @@ func (s *Service) CreateProjectShot(userID string, projectID string, req CreateP
|
||||
if !validShotStatus(status) {
|
||||
return model.Shot{}, BadAuthRequest("不支持的镜头状态")
|
||||
}
|
||||
shot := model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, Title: title, Description: strings.TrimSpace(req.Description), Position: req.Position, DurationMs: req.DurationMs, Status: status, CreatedAt: now, UpdatedAt: time.Now()}
|
||||
if err := s.repo.SaveShot(&shot, create); err != nil {
|
||||
updatedAt := time.Now()
|
||||
description := strings.TrimSpace(req.Description)
|
||||
revision, err := newShotRevision(userID, shotID, req.Revision, description, req.DurationMs, updatedAt)
|
||||
if err != nil {
|
||||
return model.Shot{}, err
|
||||
}
|
||||
if err := s.repo.BumpProjectRevision(projectID); err != nil {
|
||||
shot := model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, CurrentRevisionID: revision.ID, Title: title, Description: revision.PlotDescription, Position: req.Position, DurationMs: revision.DurationMs, Status: status, CreatedAt: now, UpdatedAt: updatedAt}
|
||||
if err := s.repo.SaveShotWithRevision(&shot, &revision, create); err != nil {
|
||||
return model.Shot{}, err
|
||||
}
|
||||
return shot, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReplaceProjectUnitShots(userID string, projectID string, unitID string, req ReplaceProjectUnitShotsRequest) ([]model.Shot, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
unitID = strings.TrimSpace(unitID)
|
||||
@@ -101,21 +123,83 @@ func (s *Service) ReplaceProjectUnitShots(userID string, projectID string, unitI
|
||||
}
|
||||
now := time.Now()
|
||||
shots := make([]model.Shot, 0, len(req.Shots))
|
||||
revisions := make([]model.ShotRevision, 0, len(req.Shots))
|
||||
for position, input := range req.Shots {
|
||||
title := strings.TrimSpace(input.Title)
|
||||
description := strings.TrimSpace(input.Description)
|
||||
if title == "" || description == "" || input.DurationMs < 0 {
|
||||
return nil, BadAuthRequest("分镜标题、描述或时长无效")
|
||||
}
|
||||
shots = append(shots, model.Shot{ID: newID(), ProjectID: projectID, UnitID: unitID, Title: title, Description: description, Position: position, DurationMs: input.DurationMs, Status: "draft", CreatedAt: now, UpdatedAt: now})
|
||||
shotID := newID()
|
||||
revision, err := newShotRevision(userID, shotID, input.Revision, description, input.DurationMs, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revision.Version = 1
|
||||
shots = append(shots, model.Shot{ID: shotID, ProjectID: projectID, UnitID: unitID, CurrentRevisionID: revision.ID, Title: title, Description: revision.PlotDescription, Position: position, DurationMs: revision.DurationMs, Status: "draft", CreatedAt: now, UpdatedAt: now})
|
||||
revisions = append(revisions, revision)
|
||||
}
|
||||
// 章节级重生成是一个整体写操作,旧镜头与引用必须和新镜头在同一事务中替换。
|
||||
if err := s.repo.ReplaceProjectUnitShots(projectID, unitID, shots); err != nil {
|
||||
if err := s.repo.ReplaceProjectUnitShots(projectID, unitID, shots, revisions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return shots, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateShotRevision(userID string, projectID string, shotID string, input ShotRevisionInput) (model.Shot, model.ShotRevision, error) {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return model.Shot{}, model.ShotRevision{}, err
|
||||
}
|
||||
shot, err := s.repo.ShotForProject(projectID, strings.TrimSpace(shotID))
|
||||
if err != nil {
|
||||
return model.Shot{}, model.ShotRevision{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
revision, err := newShotRevision(userID, shot.ID, input, shot.Description, shot.DurationMs, now)
|
||||
if err != nil {
|
||||
return model.Shot{}, model.ShotRevision{}, err
|
||||
}
|
||||
shot.Description = revision.PlotDescription
|
||||
shot.DurationMs = revision.DurationMs
|
||||
shot.Status = "draft"
|
||||
shot.UpdatedAt = now
|
||||
if err := s.repo.SaveShotWithRevision(shot, &revision, false); err != nil {
|
||||
return model.Shot{}, model.ShotRevision{}, err
|
||||
}
|
||||
return *shot, revision, nil
|
||||
}
|
||||
|
||||
func newShotRevision(userID string, shotID string, input ShotRevisionInput, fallbackDescription string, fallbackDuration int64, now time.Time) (model.ShotRevision, error) {
|
||||
plotDescription := strings.TrimSpace(input.PlotDescription)
|
||||
if plotDescription == "" {
|
||||
plotDescription = strings.TrimSpace(fallbackDescription)
|
||||
}
|
||||
if plotDescription == "" {
|
||||
return model.ShotRevision{}, BadAuthRequest("镜头画面描述不能为空")
|
||||
}
|
||||
durationMs := input.DurationMs
|
||||
if durationMs == 0 {
|
||||
durationMs = fallbackDuration
|
||||
}
|
||||
if durationMs < 0 {
|
||||
return model.ShotRevision{}, BadAuthRequest("镜头时长不能为负数")
|
||||
}
|
||||
actionBeatsJSON := "[]"
|
||||
if input.ActionBeats != nil {
|
||||
encoded, err := json.Marshal(input.ActionBeats)
|
||||
if err != nil {
|
||||
return model.ShotRevision{}, BadAuthRequest("动作节拍格式无效")
|
||||
}
|
||||
actionBeatsJSON = string(encoded)
|
||||
}
|
||||
return model.ShotRevision{
|
||||
ID: newID(), ShotID: shotID, PlotDescription: plotDescription, Action: strings.TrimSpace(input.Action), Dialogue: strings.TrimSpace(input.Dialogue),
|
||||
ShotSize: strings.TrimSpace(input.ShotSize), CameraAngle: strings.TrimSpace(input.CameraAngle), CameraMovement: strings.TrimSpace(input.CameraMovement), DurationMs: durationMs,
|
||||
ImagePrompt: strings.TrimSpace(input.ImagePrompt), VideoPrompt: strings.TrimSpace(input.VideoPrompt), NegativePrompt: strings.TrimSpace(input.NegativePrompt),
|
||||
ContinuityNotes: strings.TrimSpace(input.ContinuityNotes), ActionBeatsJSON: actionBeatsJSON, CreatedBy: userID, CreatedAt: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validShotStatus(status string) bool {
|
||||
switch status {
|
||||
case "draft", "ready", "running", "review", "completed", "failed":
|
||||
@@ -126,7 +210,7 @@ func validShotStatus(status string) bool {
|
||||
}
|
||||
|
||||
func (s *Service) LinkShotAsset(userID string, projectID string, shotID string, req LinkShotAssetRequest) (model.ShotAssetReference, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return model.ShotAssetReference{}, err
|
||||
}
|
||||
if _, err := s.repo.ShotForProject(projectID, shotID); err != nil {
|
||||
@@ -140,18 +224,16 @@ func (s *Service) LinkShotAsset(userID string, projectID string, shotID string,
|
||||
if !validShotAssetRole(role) {
|
||||
return model.ShotAssetReference{}, BadAuthRequest("不支持的镜头素材用途")
|
||||
}
|
||||
reference := model.ShotAssetReference{ID: newID(), ShotID: shotID, AssetVersionID: versionID, Role: role, Status: "linked", CreatedAt: time.Now()}
|
||||
if err := s.repo.UpsertShotAssetReference(&reference); err != nil {
|
||||
return model.ShotAssetReference{}, err
|
||||
}
|
||||
if err := s.repo.BumpProjectRevision(projectID); err != nil {
|
||||
now := time.Now()
|
||||
reference := model.ShotAssetReference{ID: newID(), ShotID: shotID, AssetVersionID: versionID, Role: role, Status: "linked", CreatedAt: now}
|
||||
if err := s.repo.UpsertShotAssetReferenceAndInvalidate(projectID, &reference, now); err != nil {
|
||||
return model.ShotAssetReference{}, err
|
||||
}
|
||||
return reference, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateProjectAssetCandidates(userID string, projectID string, req CreateAssetCandidatesRequest) ([]model.ProjectAssetCandidate, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(req.Candidates) == 0 || len(req.Candidates) > 100 {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
const builtinShortDramaWorkflowKey = "short-drama-production"
|
||||
const builtinShortDramaWorkflowVersion = 1
|
||||
const builtinShortDramaWorkflowVersion = 2
|
||||
|
||||
type workflowStepDefinition struct {
|
||||
Key string `json:"key"`
|
||||
@@ -20,13 +20,12 @@ type workflowStepDefinition struct {
|
||||
}
|
||||
|
||||
var builtinShortDramaSteps = []workflowStepDefinition{
|
||||
{Key: "style", Name: "选择画风"},
|
||||
{Key: "story", Name: "输入故事"},
|
||||
{Key: "storyboard", Name: "生成分镜"},
|
||||
{Key: "assets", Name: "准备资产"},
|
||||
{Key: "video", Name: "生成视频"},
|
||||
{Key: "review", Name: "审核与重试"},
|
||||
{Key: "delivery", Name: "合并成片"},
|
||||
{Key: "story", Name: "剧情与章节"},
|
||||
{Key: "assets", Name: "资产拆分"},
|
||||
{Key: "storyboard", Name: "分镜脚本"},
|
||||
{Key: "previz", Name: "黑白动作预演"},
|
||||
{Key: "video", Name: "视频生成"},
|
||||
{Key: "delivery", Name: "交付与打包"},
|
||||
}
|
||||
|
||||
type ProjectWorkflowDetail struct {
|
||||
@@ -42,6 +41,10 @@ type UpdateWorkflowStepRequest struct {
|
||||
|
||||
type RegisterTaskOutputRequest struct {
|
||||
TaskID string `json:"taskId"`
|
||||
CanvasID string `json:"canvasId"`
|
||||
UnitID string `json:"unitId"`
|
||||
ShotID string `json:"shotId"`
|
||||
ArtifactType string `json:"artifactType"`
|
||||
AssetVersionID string `json:"assetVersionId"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
MediaType string `json:"mediaType"`
|
||||
@@ -60,7 +63,7 @@ func (s *Service) EnsureBuiltinProjectWorkflowTemplate() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template := model.WorkflowTemplateVersion{ID: newID(), TemplateKey: builtinShortDramaWorkflowKey, Name: "短剧标准制作流程", Version: builtinShortDramaWorkflowVersion, DefinitionJSON: string(definition), CreatedAt: time.Now()}
|
||||
template := model.WorkflowTemplateVersion{ID: newID(), TemplateKey: builtinShortDramaWorkflowKey, Name: "短剧分镜工作流", Version: builtinShortDramaWorkflowVersion, DefinitionJSON: string(definition), CreatedAt: time.Now()}
|
||||
return s.repo.CreateWorkflowTemplateVersion(&template)
|
||||
}
|
||||
|
||||
@@ -111,7 +114,7 @@ func (s *Service) ProjectWorkflows(projectID string) ([]ProjectWorkflowDetail, e
|
||||
}
|
||||
|
||||
func (s *Service) CreateUnitWorkflow(userID string, projectID string, unitID string) (ProjectWorkflowDetail, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return ProjectWorkflowDetail{}, err
|
||||
}
|
||||
if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
|
||||
@@ -121,7 +124,7 @@ func (s *Service) CreateUnitWorkflow(userID string, projectID string, unitID str
|
||||
}
|
||||
|
||||
func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID string, req UpdateWorkflowStepRequest) (model.WorkflowStepInstance, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
step, err := s.repo.WorkflowStepForProject(projectID, stepID)
|
||||
@@ -155,6 +158,11 @@ func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID str
|
||||
if err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
if status == model.WorkflowStepStatusCompleted {
|
||||
if err := s.validateWorkflowStepCompletion(projectID, instance, step); err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
}
|
||||
instance.Status = model.WorkflowStatusActive
|
||||
instance.Revision++
|
||||
instance.UpdatedAt = now
|
||||
@@ -180,15 +188,29 @@ func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID str
|
||||
}
|
||||
|
||||
func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID string, req RegisterTaskOutputRequest) (model.WorkflowStepInstance, error) {
|
||||
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
|
||||
if _, err := s.activeProjectForUser(userID, projectID); err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
task, err := s.repo.TaskForUser(userID, strings.TrimSpace(req.TaskID))
|
||||
if err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
canvasID := strings.TrimSpace(req.CanvasID)
|
||||
if task.ProjectID != projectID {
|
||||
return model.WorkflowStepInstance{}, BadAuthRequest("任务不属于当前项目")
|
||||
canvas, canvasErr := s.repo.CanvasProjectForUser(userID, task.ProjectID)
|
||||
if canvasErr != nil || canvas.ProjectID != projectID {
|
||||
return model.WorkflowStepInstance{}, BadAuthRequest("任务不属于当前项目")
|
||||
}
|
||||
if canvasID != "" && canvasID != canvas.ID {
|
||||
return model.WorkflowStepInstance{}, BadAuthRequest("任务画布与产物画布不一致")
|
||||
}
|
||||
canvasID = canvas.ID
|
||||
}
|
||||
if canvasID != "" {
|
||||
canvas, canvasErr := s.repo.CanvasProjectForUser(userID, canvasID)
|
||||
if canvasErr != nil || canvas.ProjectID != projectID {
|
||||
return model.WorkflowStepInstance{}, BadAuthRequest("画布不属于当前项目")
|
||||
}
|
||||
}
|
||||
if task.Status != model.TaskStatusSucceeded {
|
||||
return model.WorkflowStepInstance{}, BadAuthRequest("只有成功任务才能登记产物")
|
||||
@@ -200,6 +222,24 @@ func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID str
|
||||
if step.Status == model.WorkflowStepStatusFailed {
|
||||
return model.WorkflowStepInstance{}, BadAuthRequest("失败步骤不能登记成功产物")
|
||||
}
|
||||
unitID := strings.TrimSpace(req.UnitID)
|
||||
shotID := strings.TrimSpace(req.ShotID)
|
||||
var shot *model.Shot
|
||||
if shotID != "" {
|
||||
shot, err = s.repo.ShotForProject(projectID, shotID)
|
||||
if err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
if unitID == "" {
|
||||
unitID = shot.UnitID
|
||||
} else if unitID != shot.UnitID {
|
||||
return model.WorkflowStepInstance{}, BadAuthRequest("镜头不属于指定章节")
|
||||
}
|
||||
} else if unitID != "" {
|
||||
if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
}
|
||||
if versionID := strings.TrimSpace(req.AssetVersionID); versionID != "" {
|
||||
if _, err := s.repo.AssetVersionForProject(projectID, versionID); err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
@@ -258,12 +298,112 @@ func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID str
|
||||
representation = &model.AssetRepresentation{ID: newID(), TaskID: task.ID, AssetVersionID: strings.TrimSpace(req.AssetVersionID), ResourceID: strings.TrimSpace(req.ResourceID), MediaType: strings.TrimSpace(req.MediaType), Role: role, MetadataJSON: metadata, CreatedAt: now}
|
||||
}
|
||||
link := &model.WorkflowStepTask{ID: newID(), WorkflowStepID: step.ID, TaskID: task.ID, CreatedAt: now}
|
||||
if err := s.repo.RegisterWorkflowTaskOutput(step, next, instance, projectID, link, representation); err != nil {
|
||||
artifactType := strings.TrimSpace(req.ArtifactType)
|
||||
if artifactType == "" && shotID != "" && strings.TrimSpace(req.ResourceID) != "" {
|
||||
artifactType = workflowArtifactType(step.StepKey)
|
||||
}
|
||||
productionLink := &model.ProductionTaskLink{ID: newID(), TaskID: task.ID, ProjectID: projectID, CanvasID: canvasID, UnitID: unitID, ShotID: shotID, WorkflowStepID: step.ID, ArtifactType: artifactType, CreatedAt: now, UpdatedAt: now}
|
||||
var artifact *model.ShotArtifact
|
||||
if shot != nil && strings.TrimSpace(req.ResourceID) != "" && artifactType != "" {
|
||||
artifact = &model.ShotArtifact{ID: newID(), ProjectID: projectID, UnitID: shot.UnitID, ShotID: shot.ID, RevisionID: shot.CurrentRevisionID, TaskID: task.ID, Type: artifactType, ResourceID: strings.TrimSpace(req.ResourceID), Status: "ready", Selected: true, MetadataJSON: metadata, CreatedAt: now, UpdatedAt: now}
|
||||
}
|
||||
// 单镜产物成功只代表该镜头完成,不能提前放行整个章节阶段。
|
||||
if shot != nil {
|
||||
step.Status = model.WorkflowStepStatusRunning
|
||||
step.CompletedAt = nil
|
||||
instance.Status = model.WorkflowStatusActive
|
||||
next = nil
|
||||
}
|
||||
if err := s.repo.RegisterWorkflowTaskOutput(step, next, instance, projectID, link, representation, productionLink, artifact); err != nil {
|
||||
return model.WorkflowStepInstance{}, err
|
||||
}
|
||||
return *step, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateWorkflowStepCompletion(projectID string, instance *model.WorkflowInstance, step *model.WorkflowStepInstance) error {
|
||||
if instance == nil || strings.TrimSpace(instance.UnitID) == "" {
|
||||
return nil
|
||||
}
|
||||
unit, err := s.repo.ProjectUnit(projectID, instance.UnitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch step.StepKey {
|
||||
case "story":
|
||||
if strings.TrimSpace(unit.SourceText) == "" {
|
||||
return BadAuthRequest("章节正文为空,不能完成剧情阶段")
|
||||
}
|
||||
case "assets":
|
||||
candidates, candidateErr := s.repo.ProjectAssetCandidates(projectID)
|
||||
if candidateErr != nil {
|
||||
return candidateErr
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.UnitID == instance.UnitID && candidate.Status == "pending_confirmation" {
|
||||
return BadAuthRequest("仍有待确认资产,不能完成资产拆分阶段")
|
||||
}
|
||||
}
|
||||
case "storyboard", "previz", "video", "delivery":
|
||||
shots, shotErr := s.repo.ProjectShots(projectID)
|
||||
if shotErr != nil {
|
||||
return shotErr
|
||||
}
|
||||
unitShots := make([]model.Shot, 0, len(shots))
|
||||
for _, shot := range shots {
|
||||
if shot.UnitID == instance.UnitID {
|
||||
unitShots = append(unitShots, shot)
|
||||
}
|
||||
}
|
||||
if len(unitShots) == 0 {
|
||||
return BadAuthRequest("当前章节还没有分镜,不能完成本阶段")
|
||||
}
|
||||
if step.StepKey == "storyboard" {
|
||||
for _, shot := range unitShots {
|
||||
if strings.TrimSpace(shot.CurrentRevisionID) == "" {
|
||||
return BadAuthRequest("存在没有分镜版本的镜头,不能完成分镜阶段")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
artifactType := "action_board"
|
||||
if step.StepKey == "video" || step.StepKey == "delivery" {
|
||||
artifactType = "video"
|
||||
}
|
||||
artifacts, artifactErr := s.repo.ProjectShotArtifacts(projectID)
|
||||
if artifactErr != nil {
|
||||
return artifactErr
|
||||
}
|
||||
readyShots := make(map[string]struct{}, len(unitShots))
|
||||
for _, artifact := range artifacts {
|
||||
if artifact.UnitID == instance.UnitID && artifact.Type == artifactType && artifact.Selected && artifact.Status == "ready" {
|
||||
readyShots[artifact.ShotID] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(readyShots) != len(unitShots) {
|
||||
if artifactType == "action_board" {
|
||||
return BadAuthRequest("仍有镜头缺少已通过的动作预演,不能完成本阶段")
|
||||
}
|
||||
return BadAuthRequest("仍有镜头缺少可交付视频,不能完成本阶段")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func workflowArtifactType(stepKey string) string {
|
||||
switch strings.TrimSpace(stepKey) {
|
||||
case "storyboard":
|
||||
return "storyboard"
|
||||
case "previz":
|
||||
return "action_board"
|
||||
case "video":
|
||||
return "video"
|
||||
case "delivery":
|
||||
return "delivery"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
|
||||
if strings.TrimSpace(task.ProjectID) == "" || task.Status != model.TaskStatusSucceeded {
|
||||
return nil
|
||||
@@ -278,6 +418,10 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
|
||||
var input struct {
|
||||
WorkflowStepID string `json:"workflowStepId"`
|
||||
DomainProjectID string `json:"domainProjectId"`
|
||||
CanvasID string `json:"canvasId"`
|
||||
UnitID string `json:"unitId"`
|
||||
ShotID string `json:"shotId"`
|
||||
ArtifactType string `json:"artifactType"`
|
||||
AssetVersionID string `json:"assetVersionId"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
MediaType string `json:"mediaType"`
|
||||
@@ -297,6 +441,18 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
|
||||
if input.AssetVersionID == "" {
|
||||
input.AssetVersionID, _ = input.Metadata["assetVersionId"].(string)
|
||||
}
|
||||
if input.CanvasID == "" {
|
||||
input.CanvasID, _ = input.Metadata["canvasId"].(string)
|
||||
}
|
||||
if input.UnitID == "" {
|
||||
input.UnitID, _ = input.Metadata["unitId"].(string)
|
||||
}
|
||||
if input.ShotID == "" {
|
||||
input.ShotID, _ = input.Metadata["shotId"].(string)
|
||||
}
|
||||
if input.ArtifactType == "" {
|
||||
input.ArtifactType, _ = input.Metadata["artifactType"].(string)
|
||||
}
|
||||
if input.ResourceID == "" {
|
||||
input.ResourceID, _ = input.Metadata["resourceId"].(string)
|
||||
}
|
||||
@@ -319,7 +475,7 @@ func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
|
||||
if projectID == "" {
|
||||
return errors.New("任务未提供短剧项目 ID,无法登记产物")
|
||||
}
|
||||
_, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, OutputJSON: task.ResultJSON})
|
||||
_, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, CanvasID: input.CanvasID, UnitID: input.UnitID, ShotID: input.ShotID, ArtifactType: input.ArtifactType, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, OutputJSON: task.ResultJSON})
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
235
backend/internal/service/project_workflow_v2_test.go
Normal file
235
backend/internal/service/project_workflow_v2_test.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newProjectWorkflowV2TestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(
|
||||
&model.Project{}, &model.ProjectUnit{}, &model.CanvasProject{}, &model.Asset{}, &model.AssetVersion{}, &model.ProjectAssetLink{},
|
||||
&model.Shot{}, &model.ShotRevision{}, &model.ShotArtifact{}, &model.ShotAssetReference{},
|
||||
&model.WorkflowTemplateVersion{}, &model.WorkflowInstance{}, &model.WorkflowStepInstance{}, &model.WorkflowStepTask{},
|
||||
&model.ProductionTaskLink{}, &model.Task{}, &model.Resource{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{repo: repository.New(db)}, db
|
||||
}
|
||||
|
||||
func seedWorkflowProject(t *testing.T, db *gorm.DB) (model.Project, model.ProjectUnit) {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
project := model.Project{ID: "project-1", UserID: "user-1", Name: "短剧", Status: model.ProjectStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
|
||||
unit := model.ProjectUnit{ID: "unit-1", ProjectID: project.ID, Kind: model.ProjectUnitKindChapter, Title: "第一章", Status: model.ProjectUnitStatusDraft, CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&project).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&unit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return project, unit
|
||||
}
|
||||
|
||||
func TestShortDramaWorkflowV2UsesProductionOrder(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := make([]string, 0, len(workflow.Steps))
|
||||
for _, step := range workflow.Steps {
|
||||
keys = append(keys, step.StepKey)
|
||||
}
|
||||
want := []string{"story", "assets", "storyboard", "previz", "video", "delivery"}
|
||||
if !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("step keys = %v, want %v", keys, want)
|
||||
}
|
||||
if workflow.Steps[0].Status != model.WorkflowStepStatusReady {
|
||||
t.Fatalf("first step status = %s, want ready", workflow.Steps[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowCompletionRequiresStageGate(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
step := workflow.Steps[0]
|
||||
if _, err := service.UpdateWorkflowStep("user-1", project.ID, step.ID, UpdateWorkflowStepRequest{Status: "running"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.UpdateWorkflowStep("user-1", project.ID, step.ID, UpdateWorkflowStepRequest{Status: "completed"}); err == nil {
|
||||
t.Fatal("empty chapter completed story gate")
|
||||
}
|
||||
unit.SourceText = "第一章正文"
|
||||
unit.UpdatedAt = time.Now()
|
||||
if err := db.Save(&unit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.UpdateWorkflowStep("user-1", project.ID, step.ID, UpdateWorkflowStepRequest{Status: "completed"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateShotRevisionInvalidatesExistingArtifacts(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "人物推门进入", DurationMs: 3000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Model(&model.WorkflowStepInstance{}).Where("workflow_instance_id = ? AND step_key IN ?", workflow.Instance.ID, []string{"storyboard", "previz", "video", "delivery"}).Updates(map[string]any{"status": model.WorkflowStepStatusCompleted}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact := model.ShotArtifact{ID: "artifact-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: shot.ID, RevisionID: shot.CurrentRevisionID, Type: "action_board", Version: 1, Status: "ready", Selected: true, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
if err := db.Create(&artifact).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updatedShot, revision, err := service.CreateShotRevision("user-1", project.ID, shot.ID, ShotRevisionInput{PlotDescription: "人物推门后停在门口", Action: "推门、停顿", DurationMs: 3500})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revision.Version != 2 || updatedShot.CurrentRevisionID != revision.ID {
|
||||
t.Fatalf("revision = v%d current=%s, want v2 current revision", revision.Version, updatedShot.CurrentRevisionID)
|
||||
}
|
||||
var storedArtifact model.ShotArtifact
|
||||
if err := db.First(&storedArtifact, "id = ?", artifact.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedArtifact.Status != "stale" || storedArtifact.Selected {
|
||||
t.Fatalf("artifact status=%s selected=%v, want stale false", storedArtifact.Status, storedArtifact.Selected)
|
||||
}
|
||||
var storyboardStep model.WorkflowStepInstance
|
||||
if err := db.First(&storyboardStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "storyboard").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storyboardStep.Status != model.WorkflowStepStatusRunning {
|
||||
t.Fatalf("storyboard step status = %s, want running", storyboardStep.Status)
|
||||
}
|
||||
var videoStep model.WorkflowStepInstance
|
||||
if err := db.First(&videoStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "video").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if videoStep.Status != model.WorkflowStepStatusPending {
|
||||
t.Fatalf("video step status = %s, want pending", videoStep.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateChapterSourceInvalidatesAllUnitArtifacts(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "旧剧情镜头", DurationMs: 3000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact := model.ShotArtifact{ID: "artifact-source-1", ProjectID: project.ID, UnitID: unit.ID, ShotID: shot.ID, RevisionID: shot.CurrentRevisionID, Type: "video", Version: 1, Status: "ready", Selected: true, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
if err := db.Create(&artifact).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.UpdateProjectUnit("user-1", project.ID, unit.ID, UpdateProjectUnitRequest{Title: unit.Title, SourceText: "修改后的章节正文"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var storedArtifact model.ShotArtifact
|
||||
if err := db.First(&storedArtifact, "id = ?", artifact.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedArtifact.Status != "stale" || storedArtifact.Selected {
|
||||
t.Fatalf("artifact status=%s selected=%v, want stale false", storedArtifact.Status, storedArtifact.Selected)
|
||||
}
|
||||
var storyStep model.WorkflowStepInstance
|
||||
if err := db.First(&storyStep, "workflow_instance_id = ? AND step_key = ?", workflow.Instance.ID, "story").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storyStep.Status != model.WorkflowStepStatusRunning {
|
||||
t.Fatalf("story step status = %s, want running", storyStep.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterTaskOutputAcceptsLinkedCanvasAndCreatesShotArtifact(t *testing.T) {
|
||||
service, db := newProjectWorkflowV2TestService(t)
|
||||
project, unit := seedWorkflowProject(t, db)
|
||||
if err := service.EnsureBuiltinProjectWorkflowTemplate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workflow, err := service.CreateUnitWorkflow("user-1", project.ID, unit.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shot, err := service.CreateProjectShot("user-1", project.ID, CreateProjectShotRequest{UnitID: unit.ID, Title: "SC.01", Description: "人物走入画面", DurationMs: 3000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canvas := model.CanvasProject{ID: "canvas-1", UserID: "user-1", ProjectID: project.ID, Title: "探索画布", CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
task := model.Task{ID: "task-1", UserID: "user-1", ProjectID: canvas.ID, Status: model.TaskStatusSucceeded, ResultJSON: `{}`, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
resource := model.Resource{ID: "resource-1", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
for _, item := range []any{&canvas, &task, &resource} {
|
||||
if err := db.Create(item).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
step := workflow.Steps[0]
|
||||
updatedStep, err := service.RegisterTaskOutput("user-1", project.ID, step.ID, RegisterTaskOutputRequest{TaskID: task.ID, UnitID: unit.ID, ShotID: shot.ID, ArtifactType: "storyboard", ResourceID: resource.ID, MediaType: "image"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updatedStep.Status != model.WorkflowStepStatusRunning {
|
||||
t.Fatalf("step status = %s, want running until the unit gate is complete", updatedStep.Status)
|
||||
}
|
||||
var nextStep model.WorkflowStepInstance
|
||||
if err := db.First(&nextStep, "workflow_instance_id = ? AND position = ?", workflow.Instance.ID, 1).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if nextStep.Status != model.WorkflowStepStatusPending {
|
||||
t.Fatalf("next step status = %s, want pending", nextStep.Status)
|
||||
}
|
||||
var productionLink model.ProductionTaskLink
|
||||
if err := db.First(&productionLink, "task_id = ?", task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if productionLink.ProjectID != project.ID || productionLink.CanvasID != canvas.ID || productionLink.ShotID != shot.ID {
|
||||
t.Fatalf("unexpected production link: %+v", productionLink)
|
||||
}
|
||||
var storedArtifact model.ShotArtifact
|
||||
if err := db.First(&storedArtifact, "task_id = ?", task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storedArtifact.Type != "storyboard" || storedArtifact.Status != "ready" || !storedArtifact.Selected {
|
||||
t.Fatalf("unexpected shot artifact: %+v", storedArtifact)
|
||||
}
|
||||
}
|
||||
@@ -127,9 +127,11 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
|
||||
}
|
||||
|
||||
// resolveTaskModelSelection 根据请求实际携带的模型选择决定路由方式。
|
||||
// 显式系统渠道请求不能被全局前台模型开关误判,但仍必须通过渠道、模型和价格校验。
|
||||
// 显式系统渠道和用户自定义渠道请求不能被全局前台模型开关误判;
|
||||
// 它们仍分别进入系统目录校验或自定义渠道的功能、能力与安全校验。
|
||||
func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID string, taskType string, operation string, frontendEnabled bool) (*RoutedModel, map[string]any, error) {
|
||||
if frontendEnabled && !taskInputUsesSystemChannel(input) {
|
||||
customChannelTask := taskInputUsesCustomChannel(input)
|
||||
if frontendEnabled && !taskInputUsesSystemChannel(input) && !customChannelTask {
|
||||
if logicalModelID == "" {
|
||||
return nil, input, InvalidModelSelection("前台模型模式下必须指定 logicalModelId")
|
||||
}
|
||||
@@ -146,7 +148,7 @@ func (s *Service) resolveTaskModelSelection(input map[string]any, logicalModelID
|
||||
}
|
||||
// 自定义渠道没有系统 channelId;它会在后续由自定义渠道功能开关、
|
||||
// 能力校验和 provider 配置校验共同处理,不能误报为“缺少系统渠道”。
|
||||
if !taskInputUsesCustomChannel(input) {
|
||||
if !customChannelTask {
|
||||
if err := s.validateSystemChannelModelSelection(input); err != nil {
|
||||
return nil, input, err
|
||||
}
|
||||
|
||||
@@ -72,6 +72,26 @@ func TestResolveTaskModelSelectionAllowsExplicitSystemChannelWhenFrontendModelsE
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskModelSelectionAllowsCustomImageChannelWhenFrontendModelsEnabled(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "image",
|
||||
"config": map[string]any{
|
||||
"baseUrl": "https://images.example.com/v1",
|
||||
"apiKey": "custom-key",
|
||||
"interfaceType": "openai-image",
|
||||
"model": "custom-image-model",
|
||||
},
|
||||
}
|
||||
|
||||
routed, resolvedInput, err := (&Service{}).resolveTaskModelSelection(input, "", "canvas_image", "image", true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTaskModelSelection() error = %v", err)
|
||||
}
|
||||
if routed != nil || resolvedInput["config"] == nil {
|
||||
t.Fatalf("resolveTaskModelSelection() routed = %#v, input = %#v", routed, resolvedInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskModelSelectionStillRequiresLogicalModelWithoutExplicitSystemChannel(t *testing.T) {
|
||||
_, _, err := (&Service{}).resolveTaskModelSelection(map[string]any{
|
||||
"mode": "video",
|
||||
|
||||
Reference in New Issue
Block a user