feat(*): 创作平台 - 升级短剧分镜工作流并完善模型计费与界面状态

This commit is contained in:
ddCat
2026-08-29 13:19:01 +08:00
parent a8b5f11100
commit d66c7adc2c
33 changed files with 2849 additions and 239 deletions

View File

@@ -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"),

View File

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

View File

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

View File

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

View File

@@ -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(&currentVersion).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(&currentVersion).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(&currentVersion).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,

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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