Files
ttstd-infinite-canvas/backend/internal/app/project_workflow.go

680 lines
26 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 app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
)
const builtinShortDramaWorkflowKey = "short-drama-production"
const builtinShortDramaWorkflowVersion = 2
type workflowStepDefinition struct {
Key string `json:"key"`
Name string `json:"name"`
}
var builtinShortDramaSteps = []workflowStepDefinition{
{Key: "story", Name: "剧情与章节"},
{Key: "assets", Name: "资产拆分"},
{Key: "storyboard", Name: "分镜脚本"},
{Key: "previz", Name: "黑白动作预演"},
{Key: "video", 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"`
CanvasID string `json:"canvasId"`
UnitID string `json:"unitId"`
ShotID string `json:"shotId"`
ShotRevisionID string `json:"shotRevisionId"`
ArtifactType string `json:"artifactType"`
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.activeProjectForUser(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.activeProjectForUser(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
}
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
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.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 {
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("只有成功任务才能登记产物")
}
step, err := s.repo.WorkflowStepForProject(projectID, stepID)
if err != nil {
return model.WorkflowStepInstance{}, err
}
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
}
}
shotRevisionID := strings.TrimSpace(req.ShotRevisionID)
if shot != nil {
if shotRevisionID == "" {
shotRevisionID = shot.CurrentRevisionID
} else if _, revisionErr := s.repo.ShotRevisionForShot(shot.ID, shotRevisionID); revisionErr != nil {
return model.WorkflowStepInstance{}, revisionErr
}
} else if shotRevisionID != "" {
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}
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: shotRevisionID, 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
}
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"`
CanvasID string `json:"canvasId"`
UnitID string `json:"unitId"`
ShotID string `json:"shotId"`
ShotRevisionID string `json:"shotRevisionId"`
ArtifactType string `json:"artifactType"`
AssetVersionID string `json:"assetVersionId"`
ResourceID string `json:"resourceId"`
MediaType string `json:"mediaType"`
Role string `json:"role"`
MetadataJSON string `json:"metadataJson"`
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.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.ShotRevisionID == "" {
input.ShotRevisionID, _ = input.Metadata["shotRevisionId"].(string)
}
if input.ArtifactType == "" {
input.ArtifactType, _ = input.Metadata["artifactType"].(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 input.MetadataJSON == "" {
if artifactMetadata, ok := input.Metadata["artifactMetadata"]; ok {
if encoded, encodeErr := json.Marshal(artifactMetadata); encodeErr == nil {
input.MetadataJSON = string(encoded)
}
}
}
}
if strings.TrimSpace(input.ResourceID) == "" {
input.ResourceID, input.MediaType = taskOutputResource(task.ResultJSON, task.Type)
}
if strings.TrimSpace(input.MediaType) == "" && strings.TrimSpace(input.ResourceID) != "" {
input.MediaType = taskOutputMediaType(task.Type)
}
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,无法登记产物")
}
if strings.TrimSpace(input.ResourceID) != "" && strings.TrimSpace(input.AssetVersionID) == "" && strings.TrimSpace(input.ShotID) != "" {
assetVersionID, assetErr := s.ensureGeneratedProjectAsset(task, projectID, input.ShotID, input.ResourceID, input.MediaType)
if assetErr != nil {
return assetErr
}
input.AssetVersionID = assetVersionID
}
_, err = s.RegisterTaskOutput(task.UserID, projectID, input.WorkflowStepID, RegisterTaskOutputRequest{TaskID: task.ID, CanvasID: input.CanvasID, UnitID: input.UnitID, ShotID: input.ShotID, ShotRevisionID: input.ShotRevisionID, ArtifactType: input.ArtifactType, AssetVersionID: input.AssetVersionID, ResourceID: input.ResourceID, MediaType: input.MediaType, Role: input.Role, MetadataJSON: input.MetadataJSON, OutputJSON: task.ResultJSON})
return err
}
func taskOutputMediaType(taskType string) string {
if strings.Contains(strings.ToLower(taskType), "video") {
return "video"
}
return "image"
}
func taskOutputResource(raw string, taskType string) (string, string) {
if strings.TrimSpace(raw) == "" {
return "", ""
}
var value any
if json.Unmarshal([]byte(raw), &value) != nil {
return "", ""
}
return findTaskOutputResource(value, taskOutputMediaType(taskType))
}
func findTaskOutputResource(value any, mediaType string) (string, string) {
switch item := value.(type) {
case []any:
for _, child := range item {
if id, kind := findTaskOutputResource(child, mediaType); id != "" {
return id, kind
}
}
case map[string]any:
for _, key := range []string{"resourceId", "storageKey", "url", "dataUrl", "resultUrl", "outputUrl"} {
if raw, ok := item[key].(string); ok {
text := strings.TrimSpace(raw)
if strings.HasPrefix(text, "resource:") {
return strings.TrimPrefix(text, "resource:"), mediaType
}
if id := resourceIDFromFileURL(text); id != "" {
return id, mediaType
}
}
}
for _, child := range item {
if id, kind := findTaskOutputResource(child, mediaType); id != "" {
return id, kind
}
}
}
return "", ""
}
func (s *Service) ensureGeneratedProjectAsset(task model.Task, projectID string, shotID string, resourceID string, mediaType string) (string, error) {
representations, err := s.repo.AssetRepresentationsForTask(task.ID)
if err != nil {
return "", err
}
for _, representation := range representations {
if representation.Role == "output" && representation.ResourceID == resourceID && representation.AssetVersionID != "" {
return representation.AssetVersionID, nil
}
}
resource, err := s.repo.ResourceForUser(task.UserID, resourceID)
if err != nil {
return "", err
}
if resource.Status != model.ResourceStatusReady {
return "", BadAuthRequest("生成资源尚未就绪,无法登记项目素材")
}
shot, err := s.repo.ShotForProject(projectID, shotID)
if err != nil {
return "", err
}
now := time.Now()
assetID := workflowGeneratedEntityID("asset", task.ID)
versionID := workflowGeneratedEntityID("version", task.ID)
label := "图片"
if mediaType == "video" {
label = "视频"
}
title := strings.TrimSpace(shot.Title)
if title == "" {
title = "镜头产物"
}
title += " · " + label
width := resource.Width
height := resource.Height
if width <= 0 {
width = 1
}
if height <= 0 {
height = 1
}
resourceURL := "/api/resources/" + resourceID + "/file"
data := map[string]any{
"storageKey": "resource:" + resourceID,
"mimeType": resource.MimeType,
"bytes": resource.Size,
"width": width,
"height": height,
}
if mediaType == "image" {
data["dataUrl"] = resourceURL
} else {
data["url"] = resourceURL
if resource.DurationMs > 0 {
data["durationMs"] = resource.DurationMs
}
}
payload, _ := json.Marshal(map[string]any{
"id": assetID, "kind": mediaType, "category": model.AssetCategoryMaterial, "status": model.AssetVersionStatusConfirmed,
"primaryVersionId": versionID, "title": title, "coverUrl": resourceURL, "tags": []string{},
"createdAt": now.UTC().Format(time.RFC3339Nano), "updatedAt": now.UTC().Format(time.RFC3339Nano),
"data": data, "metadata": map[string]any{"source": "short-drama-workflow", "taskId": task.ID, "shotId": shot.ID, "projectIds": []string{projectID}},
})
asset := &model.Asset{ID: assetID, UserID: task.UserID, Kind: mediaType, Category: model.AssetCategoryMaterial, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: title, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now}
version := &model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: "{}", Prompt: task.Prompt, Note: "工作流生成产物", CreatedAt: now, UpdatedAt: now}
folderID, err := s.resolveProjectAssetFolderID(projectID, nil)
if err != nil {
return "", err
}
position, err := s.repo.NextProjectAssetPosition(projectID, folderID)
if err != nil {
return "", err
}
link := &model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now}
if err := s.repo.UpsertAsset(asset); err != nil {
return "", err
}
if _, err := s.repo.LinkProjectAsset(asset, version, link); err != nil {
return "", err
}
return versionID, nil
}
// 工作流产物重试必须复用同一实体 ID,同时需要满足素材版本及外键的 varchar(36) 约束。
func workflowGeneratedEntityID(namespace string, taskID string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(namespace) + ":" + strings.TrimSpace(taskID)))
return hex.EncodeToString(sum[:16])
}
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]
}