diff --git a/backend/internal/repository/project_character.go b/backend/internal/repository/project_character.go index cb53c35c7..4b9c0f5e9 100644 --- a/backend/internal/repository/project_character.go +++ b/backend/internal/repository/project_character.go @@ -35,6 +35,21 @@ func (r *Repository) AssetRepresentations(assetVersionID string) ([]model.AssetR return representations, err } +func (r *Repository) AssetRepresentationsForTask(taskID string) ([]model.AssetRepresentation, error) { + var representations []model.AssetRepresentation + err := r.db.Where("task_id = ?", taskID).Order("role asc, created_at asc").Find(&representations).Error + return representations, err +} + +func (r *Repository) UnboundCharacterTurnaroundTasks(userID string, projectID string) ([]model.Task, error) { + var tasks []model.Task + err := r.db.Where("user_id = ? AND project_id = ? AND status = ? AND type = ?", userID, projectID, model.TaskStatusSucceeded, "canvas_image"). + Where("input_json LIKE ?", "%character_turnaround%"). + Where("NOT EXISTS (SELECT 1 FROM asset_representations WHERE asset_representations.task_id = tasks.id)"). + Order("created_at asc").Limit(100).Find(&tasks).Error + return tasks, err +} + func (r *Repository) CharacterVoiceBinding(assetVersionID string) (*model.CharacterVoiceBinding, error) { var binding model.CharacterVoiceBinding if err := r.db.First(&binding, "asset_version_id = ?", assetVersionID).Error; err != nil { diff --git a/backend/internal/service/project.go b/backend/internal/service/project.go index bb96d5a72..f0412f3e4 100644 --- a/backend/internal/service/project.go +++ b/backend/internal/service/project.go @@ -112,6 +112,13 @@ func (s *Service) ProjectDetail(userID string, id string) (ProjectDetail, error) if err != nil { return ProjectDetail{}, err } + // 项目读取允许降级修复:旧任务可能已成功持久化图片,但浏览器刷新中断了角色版本绑定。 + if s.reconcileCharacterTurnaroundTasks(userID, project.ID) { + project, err = s.repo.ProjectForUser(userID, id) + if err != nil { + return ProjectDetail{}, err + } + } // 项目工作台只返回章节摘要,长篇小说正文由单章接口按需读取。 units, err := s.repo.ProjectUnitSummaries(project.ID) if err != nil { diff --git a/backend/internal/service/project_character.go b/backend/internal/service/project_character.go index 57c6209d2..783fa799b 100644 --- a/backend/internal/service/project_character.go +++ b/backend/internal/service/project_character.go @@ -3,6 +3,8 @@ package service import ( "encoding/json" "errors" + "fmt" + "log" "strings" "time" @@ -60,6 +62,19 @@ type CharacterVoiceSummary struct { Instructions string `json:"instructions"` } +type characterTurnaroundTaskInput struct { + Metadata struct { + Operation string `json:"operation"` + CharacterAssetID string `json:"characterAssetId"` + } `json:"metadata"` +} + +type characterTurnaroundTaskResult struct { + Images []struct { + ResourceID string `json:"resourceId"` + } `json:"images"` +} + type CharacterCardSummary struct { VersionID string `json:"versionId"` Version int `json:"version"` @@ -192,6 +207,113 @@ func (s *Service) ReplaceProjectCharacterRepresentations(userID string, projectI return s.ProjectCharacter(userID, projectID, asset.ID) } +// 三视图生成是强校验写路径:任务只有在资源已绑定到新角色版本后才能对外显示成功。 +func (s *Service) finalizeCharacterTurnaroundTask(task model.Task, result map[string]interface{}) (bool, error) { + if !strings.Contains(task.InputJSON, "character_turnaround") { + return false, nil + } + decrypted, err := s.decryptTaskInputJSON(task.InputJSON) + if err != nil { + return false, err + } + var input characterTurnaroundTaskInput + if err := json.Unmarshal([]byte(decrypted), &input); err != nil { + return false, err + } + if input.Metadata.Operation != "character_turnaround" { + return false, nil + } + projectID := strings.TrimSpace(task.ProjectID) + assetID := strings.TrimSpace(input.Metadata.CharacterAssetID) + if projectID == "" || assetID == "" { + return false, errors.New("三视图任务缺少项目或角色标识") + } + encodedResult, err := json.Marshal(result) + if err != nil { + return false, err + } + var output characterTurnaroundTaskResult + if err := json.Unmarshal(encodedResult, &output); err != nil { + return false, err + } + if len(output.Images) == 0 || strings.TrimSpace(output.Images[0].ResourceID) == "" { + return false, errors.New("三视图任务没有返回已持久化的图片资源") + } + + s.characterTaskMu.Lock() + defer s.characterTaskMu.Unlock() + bound, err := s.characterTurnaroundTaskBound(task.ID) + if err != nil || bound { + return false, err + } + asset, err := s.repo.ProjectCharacterAsset(task.UserID, projectID, assetID) + if err != nil { + return false, err + } + resourceID := strings.TrimSpace(output.Images[0].ResourceID) + resource, err := s.repo.ResourceForUser(task.UserID, resourceID) + if err != nil || resource.Kind != "image" || resource.Status != model.ResourceStatusReady { + return false, BadAuthRequest("三视图任务生成的图片资源不可用") + } + sheetMetadata, _ := json.Marshal(map[string]any{"prompt": task.Prompt, "source": "character_turnaround"}) + primaryMetadata, _ := json.Marshal(map[string]any{"source": "turnaround_sheet"}) + now := time.Now() + representations := []model.AssetRepresentation{ + {ID: newID(), TaskID: task.ID, ResourceID: resourceID, MediaType: "image", Role: "turnaround_sheet", MetadataJSON: string(sheetMetadata), CreatedAt: now}, + {ID: newID(), TaskID: task.ID, ResourceID: resourceID, MediaType: "image", Role: "primary", MetadataJSON: string(primaryMetadata), CreatedAt: now}, + } + if _, err := s.createNextCharacterVersion(projectID, asset, asset.Title, "", representations, nil, false); err != nil { + // 多实例同时收尾时,唯一任务视角约束只允许一个事务成功;其余实例按已绑定处理。 + if bound, checkErr := s.characterTurnaroundTaskBound(task.ID); checkErr == nil && bound { + return false, nil + } + return false, err + } + return true, nil +} + +func (s *Service) characterTurnaroundTaskBound(taskID string) (bool, error) { + representations, err := s.repo.AssetRepresentationsForTask(taskID) + if err != nil { + return false, err + } + roles := make(map[string]bool, len(representations)) + for _, representation := range representations { + roles[representation.Role] = true + } + if len(representations) > 0 && (!roles["turnaround_sheet"] || !roles["primary"]) { + return false, fmt.Errorf("三视图任务 %s 的角色表现绑定不完整", taskID) + } + return roles["turnaround_sheet"] && roles["primary"], nil +} + +// 项目页读取时只修复旧版前端刷新造成的断点;单个异常任务记录告警,不阻断项目展示。 +func (s *Service) reconcileCharacterTurnaroundTasks(userID string, projectID string) bool { + tasks, err := s.repo.UnboundCharacterTurnaroundTasks(userID, projectID) + if err != nil { + log.Printf("reconcile character turnaround tasks failed: user=%s project=%s error=%v", userID, projectID, err) + return false + } + recovered := false + for _, task := range tasks { + var result map[string]interface{} + if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil { + log.Printf("restore character turnaround result failed: user=%s project=%s task=%s error=%v", userID, projectID, task.ID, err) + continue + } + applied, err := s.finalizeCharacterTurnaroundTask(task, result) + if err != nil { + log.Printf("restore character turnaround task failed: user=%s project=%s task=%s error=%v", userID, projectID, task.ID, err) + continue + } + if applied { + recovered = true + _ = s.log(userID, task.ID, "info", "已将历史三视图任务恢复到角色卡", "") + } + } + return recovered +} + func (s *Service) BindProjectCharacterVoice(userID string, projectID string, assetID string, req BindCharacterVoiceRequest) (ProjectCharacterDetail, error) { asset, err := s.repo.ProjectCharacterAsset(userID, projectID, strings.TrimSpace(assetID)) if err != nil { diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 530ff9e0e..ffdcbe34d 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -21,18 +21,19 @@ import ( ) type Service struct { - repo *repository.Repository - dataDir string - cancelMu sync.Mutex - registrationMu sync.Mutex - emailCodeMu sync.Mutex - redeemBatchMu sync.Mutex - storageMu sync.Mutex - activeCancels map[string]context.CancelFunc - pendingStorage map[string]int64 - coordinator *runtimeCoordinator - runtimeErr error - workerID string + repo *repository.Repository + dataDir string + cancelMu sync.Mutex + registrationMu sync.Mutex + emailCodeMu sync.Mutex + redeemBatchMu sync.Mutex + storageMu sync.Mutex + characterTaskMu sync.Mutex + activeCancels map[string]context.CancelFunc + pendingStorage map[string]int64 + coordinator *runtimeCoordinator + runtimeErr error + workerID string } const taskWorkerConcurrency = 3 @@ -781,6 +782,9 @@ func (s *Service) processClaimedTask(task *model.Task) error { if err == nil { result, err = s.persistGeneratedMediaResult(task.UserID, result) } + if err == nil { + _, err = s.finalizeCharacterTurnaroundTask(*task, result) + } if err != nil { channelSlotFailedBeforeRequest := false if code, _ := ChannelSlotFailureDetails(err); code != "" { diff --git a/web/src/pages/projects/detail/assets.tsx b/web/src/pages/projects/detail/assets.tsx index 18c137b63..ec338cbfb 100644 --- a/web/src/pages/projects/detail/assets.tsx +++ b/web/src/pages/projects/detail/assets.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useMutationState, useQuery } from "@tanstack/react-query"; import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd"; import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react"; @@ -11,6 +11,7 @@ import { confirmProjectAssetCandidate, createProjectAssetVersion, createProjectCharacter, + getProjectCharacter, linkProjectAsset, listVoiceProfiles, replaceProjectCharacterRepresentations, @@ -66,6 +67,11 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet const visibleAssets = useMemo(() => category === "all" ? detail.assets : detail.assets.filter((asset) => asset.category === category), [category, detail.assets]); const categoryCounts = categories.map((value) => ({ value, count: value === "all" ? detail.assets.length : detail.assets.filter((asset) => asset.category === value).length })); const voices = useQuery({ queryKey: ["voice-profiles"], queryFn: listVoiceProfiles, enabled: Boolean(voiceAsset) }); + const generatingAssets = useMutationState({ + filters: { mutationKey: ["project-character-turnaround", detail.project.id], status: "pending" }, + select: (mutation) => mutation.state.variables as ProjectAsset | undefined, + }); + const generatingAssetIds = new Set(generatingAssets.map((asset) => asset?.id).filter((id): id is string => Boolean(id))); const done = (content: string) => { refreshProject(); message.success(content); }; const failed = (fallback: string) => (error: unknown) => message.error(error instanceof Error ? error.message : fallback); @@ -87,13 +93,14 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet onError: failed("角色保存失败"), }); const generateMutation = useMutation({ + mutationKey: ["project-character-turnaround", detail.project.id], mutationFn: async (asset: ProjectAsset) => { if (!asset.character) throw new Error("角色版本信息不完整"); const model = effectiveConfig.imageModel || effectiveConfig.model; const config = { ...effectiveConfig, model }; if (!isAiConfigReady(config, model)) throw new Error("请先在设置中配置可用的图片模型"); - const representations = await generateCharacterTurnaround({ projectId: detail.project.id, assetId: asset.id, versionId: asset.character.versionId, name: asset.title, definition: asset.character.definition, config }); - return replaceProjectCharacterRepresentations(detail.project.id, asset.id, representations); + await generateCharacterTurnaround({ projectId: detail.project.id, assetId: asset.id, versionId: asset.character.versionId, name: asset.title, definition: asset.character.definition, config }); + return getProjectCharacter(detail.project.id, asset.id); }, onSuccess: (result) => { syncPersonalCharacterProjection(result.asset); done("三视图已生成并绑定到新角色版本"); }, onError: failed("三视图生成失败"), @@ -149,7 +156,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet