Files
ttstd-infinite-canvas/backend/internal/service/project_workflow.go
ddcat666 d2fbc413b3 feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP

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

Refs #30

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

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

Refs #30

---------

Co-authored-by: ddCat <ddcat666@88.com>
2026-07-27 10:45:11 +08:00

350 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"errors"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
const builtinShortDramaWorkflowKey = "short-drama-production"
const builtinShortDramaWorkflowVersion = 1
type workflowStepDefinition struct {
Key string `json:"key"`
Name string `json:"name"`
}
var builtinShortDramaSteps = []workflowStepDefinition{
{Key: "style", Name: "选择画风"},
{Key: "story", Name: "输入故事"},
{Key: "storyboard", Name: "生成分镜"},
{Key: "assets", Name: "准备资产"},
{Key: "video", Name: "生成视频"},
{Key: "review", Name: "审核与重试"},
{Key: "delivery", Name: "合并成片"},
}
type ProjectWorkflowDetail struct {
Instance model.WorkflowInstance `json:"instance"`
Steps []model.WorkflowStepInstance `json:"steps"`
}
type UpdateWorkflowStepRequest struct {
Status string `json:"status"`
OutputJSON string `json:"outputJson"`
Error string `json:"error"`
}
type RegisterTaskOutputRequest struct {
TaskID string `json:"taskId"`
AssetVersionID string `json:"assetVersionId"`
ResourceID string `json:"resourceId"`
MediaType string `json:"mediaType"`
Role string `json:"role"`
MetadataJSON string `json:"metadataJson"`
OutputJSON string `json:"outputJson"`
}
func (s *Service) EnsureBuiltinProjectWorkflowTemplate() error {
if _, err := s.repo.WorkflowTemplateVersion(builtinShortDramaWorkflowKey, builtinShortDramaWorkflowVersion); err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
definition, err := json.Marshal(map[string]any{"scope": []string{"project", "unit"}, "steps": builtinShortDramaSteps})
if err != nil {
return err
}
template := model.WorkflowTemplateVersion{ID: newID(), TemplateKey: builtinShortDramaWorkflowKey, Name: "短剧标准制作流程", Version: builtinShortDramaWorkflowVersion, DefinitionJSON: string(definition), CreatedAt: time.Now()}
return s.repo.CreateWorkflowTemplateVersion(&template)
}
func (s *Service) createProjectWorkflow(projectID string, unitID string, scope string) (ProjectWorkflowDetail, error) {
template, err := s.repo.WorkflowTemplateVersion(builtinShortDramaWorkflowKey, builtinShortDramaWorkflowVersion)
if err != nil {
return ProjectWorkflowDetail{}, err
}
if existing, existingErr := s.repo.WorkflowInstanceForScope(projectID, unitID, template.ID); existingErr == nil {
steps, stepsErr := s.repo.WorkflowSteps(existing.ID)
return ProjectWorkflowDetail{Instance: *existing, Steps: steps}, stepsErr
} else if !errors.Is(existingErr, gorm.ErrRecordNotFound) {
return ProjectWorkflowDetail{}, existingErr
}
now := time.Now()
instance := model.WorkflowInstance{ID: newID(), ProjectID: projectID, UnitID: unitID, TemplateVersionID: template.ID, Scope: scope, Status: model.WorkflowStatusActive, Revision: 1, CreatedAt: now, UpdatedAt: now}
steps := make([]model.WorkflowStepInstance, 0, len(builtinShortDramaSteps))
for index, definition := range builtinShortDramaSteps {
status := model.WorkflowStepStatusPending
if index == 0 {
status = model.WorkflowStepStatusReady
}
steps = append(steps, model.WorkflowStepInstance{ID: newID(), WorkflowInstanceID: instance.ID, StepKey: definition.Key, Name: definition.Name, Position: index, Status: status, InputJSON: "{}", OutputJSON: "{}", CreatedAt: now, UpdatedAt: now})
}
if err := s.repo.CreateWorkflowInstance(&instance, steps); err != nil {
return ProjectWorkflowDetail{}, err
}
if err := s.repo.BumpProjectRevision(projectID); err != nil {
return ProjectWorkflowDetail{}, err
}
return ProjectWorkflowDetail{Instance: instance, Steps: steps}, nil
}
func (s *Service) ProjectWorkflows(projectID string) ([]ProjectWorkflowDetail, error) {
instances, err := s.repo.ProjectWorkflowInstances(projectID)
if err != nil {
return nil, err
}
result := make([]ProjectWorkflowDetail, 0, len(instances))
for _, instance := range instances {
steps, stepsErr := s.repo.WorkflowSteps(instance.ID)
if stepsErr != nil {
return nil, stepsErr
}
result = append(result, ProjectWorkflowDetail{Instance: instance, Steps: steps})
}
return result, nil
}
func (s *Service) CreateUnitWorkflow(userID string, projectID string, unitID string) (ProjectWorkflowDetail, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return ProjectWorkflowDetail{}, err
}
if _, err := s.repo.ProjectUnit(projectID, unitID); err != nil {
return ProjectWorkflowDetail{}, err
}
return s.createProjectWorkflow(projectID, unitID, "unit")
}
func (s *Service) UpdateWorkflowStep(userID string, projectID string, stepID string, req UpdateWorkflowStepRequest) (model.WorkflowStepInstance, error) {
if _, err := s.repo.ProjectForUser(userID, projectID); err != nil {
return model.WorkflowStepInstance{}, err
}
step, err := s.repo.WorkflowStepForProject(projectID, stepID)
if err != nil {
return model.WorkflowStepInstance{}, err
}
status := model.WorkflowStepStatus(strings.TrimSpace(req.Status))
if !validWorkflowStepStatus(status) {
return model.WorkflowStepInstance{}, BadAuthRequest("不支持的工作流步骤状态")
}
if !canTransitionWorkflowStep(step.Status, status) {
return model.WorkflowStepInstance{}, BadAuthRequest("当前工作流步骤不能直接切换到目标状态")
}
now := time.Now()
step.Status = status
step.OutputJSON = req.OutputJSON
if strings.TrimSpace(step.OutputJSON) == "" {
step.OutputJSON = "{}"
}
step.Error = strings.TrimSpace(req.Error)
if status == model.WorkflowStepStatusRunning && step.StartedAt == nil {
step.StartedAt = &now
}
if status == model.WorkflowStepStatusCompleted || status == model.WorkflowStepStatusSkipped {
step.CompletedAt = &now
} else {
step.CompletedAt = nil
}
step.UpdatedAt = now
instance, err := s.repo.WorkflowInstance(step.WorkflowInstanceID)
if err != nil {
return model.WorkflowStepInstance{}, err
}
instance.Status = model.WorkflowStatusActive
instance.Revision++
instance.UpdatedAt = now
var next *model.WorkflowStepInstance
if status == model.WorkflowStepStatusCompleted || status == model.WorkflowStepStatusSkipped {
next, err = s.repo.NextWorkflowStep(step.WorkflowInstanceID, step.Position)
if errors.Is(err, gorm.ErrRecordNotFound) {
next = nil
instance.Status = model.WorkflowStatusCompleted
} else if err != nil {
return model.WorkflowStepInstance{}, err
} else if next.Status == model.WorkflowStepStatusPending {
next.Status = model.WorkflowStepStatusReady
next.UpdatedAt = now
}
} else if status == model.WorkflowStepStatusFailed {
instance.Status = model.WorkflowStatusFailed
}
if err := s.repo.UpdateWorkflowProgress(step, next, instance, projectID); err != nil {
return model.WorkflowStepInstance{}, err
}
return *step, nil
}
func (s *Service) RegisterTaskOutput(userID string, projectID string, stepID string, req RegisterTaskOutputRequest) (model.WorkflowStepInstance, error) {
if _, err := s.repo.ProjectForUser(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
}
if task.ProjectID != projectID {
return model.WorkflowStepInstance{}, BadAuthRequest("任务不属于当前项目")
}
if task.Status != model.TaskStatusSucceeded {
return model.WorkflowStepInstance{}, BadAuthRequest("只有成功任务才能登记产物")
}
step, err := s.repo.WorkflowStepForProject(projectID, stepID)
if err != nil {
return model.WorkflowStepInstance{}, err
}
if step.Status == model.WorkflowStepStatusFailed {
return model.WorkflowStepInstance{}, BadAuthRequest("失败步骤不能登记成功产物")
}
if versionID := strings.TrimSpace(req.AssetVersionID); versionID != "" {
if _, err := s.repo.AssetVersionForProject(projectID, versionID); err != nil {
return model.WorkflowStepInstance{}, err
}
}
if resourceID := strings.TrimSpace(req.ResourceID); resourceID != "" {
if _, err := s.repo.ResourceForUser(userID, resourceID); err != nil {
return model.WorkflowStepInstance{}, err
}
}
metadata := strings.TrimSpace(req.MetadataJSON)
if metadata == "" {
metadata = "{}"
}
if !json.Valid([]byte(metadata)) {
return model.WorkflowStepInstance{}, BadAuthRequest("产物元数据必须是有效 JSON")
}
now := time.Now()
step.Status = model.WorkflowStepStatusCompleted
step.OutputJSON = strings.TrimSpace(req.OutputJSON)
if step.OutputJSON == "" {
step.OutputJSON = task.ResultJSON
}
if strings.TrimSpace(step.OutputJSON) == "" {
step.OutputJSON = "{}"
}
step.Error = ""
step.CompletedAt = &now
step.UpdatedAt = now
instance, err := s.repo.WorkflowInstance(step.WorkflowInstanceID)
if err != nil {
return model.WorkflowStepInstance{}, err
}
instance.Revision++
instance.Status = model.WorkflowStatusActive
instance.UpdatedAt = now
next, nextErr := s.repo.NextWorkflowStep(step.WorkflowInstanceID, step.Position)
if errors.Is(nextErr, gorm.ErrRecordNotFound) {
instance.Status = model.WorkflowStatusCompleted
next = nil
} else if nextErr != nil {
return model.WorkflowStepInstance{}, nextErr
} else if next.Status == model.WorkflowStepStatusPending {
next.Status = model.WorkflowStepStatusReady
next.UpdatedAt = now
}
var representation *model.AssetRepresentation
if strings.TrimSpace(req.AssetVersionID) != "" {
role := strings.TrimSpace(req.Role)
if role == "" {
role = "output"
}
if !validShotAssetRole(role) {
return model.WorkflowStepInstance{}, BadAuthRequest("不支持的产物用途")
}
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 {
return model.WorkflowStepInstance{}, err
}
return *step, nil
}
func (s *Service) RegisterTaskOutputFromTask(task model.Task) error {
if strings.TrimSpace(task.ProjectID) == "" || task.Status != model.TaskStatusSucceeded {
return nil
}
if strings.TrimSpace(task.InputJSON) == "" {
return nil
}
decrypted, err := s.decryptTaskInputJSON(task.InputJSON)
if err != nil {
return err
}
var input struct {
WorkflowStepID string `json:"workflowStepId"`
DomainProjectID string `json:"domainProjectId"`
AssetVersionID string `json:"assetVersionId"`
ResourceID string `json:"resourceId"`
MediaType string `json:"mediaType"`
Role string `json:"role"`
Metadata map[string]any `json:"metadata"`
}
if err := json.Unmarshal([]byte(decrypted), &input); err != nil {
return err
}
if input.Metadata != nil {
if input.WorkflowStepID == "" {
input.WorkflowStepID, _ = input.Metadata["workflowStepId"].(string)
}
if input.DomainProjectID == "" {
input.DomainProjectID, _ = input.Metadata["domainProjectId"].(string)
}
if input.AssetVersionID == "" {
input.AssetVersionID, _ = input.Metadata["assetVersionId"].(string)
}
if input.ResourceID == "" {
input.ResourceID, _ = input.Metadata["resourceId"].(string)
}
if input.MediaType == "" {
input.MediaType, _ = input.Metadata["mediaType"].(string)
}
if input.Role == "" {
input.Role, _ = input.Metadata["role"].(string)
}
}
if strings.TrimSpace(input.WorkflowStepID) == "" {
return nil
}
projectID := strings.TrimSpace(input.DomainProjectID)
if projectID == "" {
if _, projectErr := s.repo.ProjectForUser(task.UserID, task.ProjectID); projectErr == nil {
projectID = task.ProjectID
}
}
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})
return err
}
func validWorkflowStepStatus(status model.WorkflowStepStatus) bool {
switch status {
case model.WorkflowStepStatusPending, model.WorkflowStepStatusReady, model.WorkflowStepStatusRunning, model.WorkflowStepStatusReview, model.WorkflowStepStatusCompleted, model.WorkflowStepStatusFailed, model.WorkflowStepStatusSkipped:
return true
default:
return false
}
}
func canTransitionWorkflowStep(current model.WorkflowStepStatus, next model.WorkflowStepStatus) bool {
if current == next {
return true
}
allowed := map[model.WorkflowStepStatus]map[model.WorkflowStepStatus]bool{
model.WorkflowStepStatusPending: {model.WorkflowStepStatusReady: true, model.WorkflowStepStatusSkipped: true},
model.WorkflowStepStatusReady: {model.WorkflowStepStatusRunning: true, model.WorkflowStepStatusSkipped: true},
model.WorkflowStepStatusRunning: {model.WorkflowStepStatusReview: true, model.WorkflowStepStatusCompleted: true, model.WorkflowStepStatusFailed: true},
model.WorkflowStepStatusReview: {model.WorkflowStepStatusRunning: true, model.WorkflowStepStatusCompleted: true, model.WorkflowStepStatusFailed: true},
model.WorkflowStepStatusFailed: {model.WorkflowStepStatusReady: true, model.WorkflowStepStatusRunning: true},
model.WorkflowStepStatusCompleted: {model.WorkflowStepStatusRunning: true},
model.WorkflowStepStatusSkipped: {model.WorkflowStepStatusReady: true},
}
return allowed[current][next]
}