680 lines
26 KiB
Go
680 lines
26 KiB
Go
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]
|
||
}
|