From 940bd2036d74730f7b962afde659282c48635216 Mon Sep 17 00:00:00 2001 From: ddCat Date: Mon, 27 Jul 2026 16:35:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E8=A7=92=E8=89=B2=E4=B8=89?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=20-=20=E9=9A=94=E7=A6=BB=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=B9=B6=E6=81=A2=E5=A4=8D=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=90=8E=E4=BB=BB=E5=8A=A1=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/repository/project_character.go | 15 +++ backend/internal/service/project.go | 7 + backend/internal/service/project_character.go | 122 ++++++++++++++++++ backend/internal/service/service.go | 28 ++-- web/src/pages/projects/detail/assets.tsx | 15 ++- .../detail/project-character-card.tsx | 4 +- .../detail/project-character-media.ts | 15 +-- 7 files changed, 174 insertions(+), 32 deletions(-) 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
{(category === "all" || category === "character") && pendingCandidates.length ?
剧情识别出的角色
{pendingCandidates.map((candidate) =>
{candidate.name}
待确认角色卡 · 来自章节分析
{characterAssets.length ? ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}> : null}
)}
: null}
{category === "all" ? "全部资产" : categoryLabel(category)}{visibleAssets.length} 项
- {visibleAssets.length ?
{visibleAssets.map((asset) => asset.category === "character" ? openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}
: } + {visibleAssets.length ?
{visibleAssets.map((asset) => asset.category === "character" ? openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}
: }
diff --git a/web/src/pages/projects/detail/project-character-card.tsx b/web/src/pages/projects/detail/project-character-card.tsx index 3b8fa47e6..361e9567a 100644 --- a/web/src/pages/projects/detail/project-character-card.tsx +++ b/web/src/pages/projects/detail/project-character-card.tsx @@ -21,14 +21,14 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe const appearance = textValue(character?.definition.appearance) || textValue(character?.definition.consistencyPrompt) || "角色设定待完善"; const imageStatus = character?.visualStatus === "ready" ? "图片已绑定" : character?.visualStatus === "partial" ? "图片待补全" : "图片未绑定"; const voiceStatus = character?.voiceStatus === "ready" ? `声音已绑定 · ${character.voice?.profile.name}` : character?.voiceStatus === "unavailable" ? "声音需处理" : "声音未绑定"; - const ready = character?.visualStatus === "ready" && character.voiceStatus === "ready"; + const readinessLabel = character?.visualStatus === "ready" ? character.voiceStatus === "ready" ? "可直接用于生成" : "形象已就绪" : "设定进行中"; return (
{cover ? {asset.title} :
}
角色卡 · v{character?.version || 1} - {ready ? "可直接用于生成" : "设定进行中"} + {readinessLabel}
diff --git a/web/src/pages/projects/detail/project-character-media.ts b/web/src/pages/projects/detail/project-character-media.ts index 8f2aed17b..0de595cb7 100644 --- a/web/src/pages/projects/detail/project-character-media.ts +++ b/web/src/pages/projects/detail/project-character-media.ts @@ -1,11 +1,9 @@ import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation"; -import { uploadImage } from "@/services/image-storage"; -import { resourceIdFromStorageKey } from "@/services/api/resources"; import type { AiConfig } from "@/stores/use-config-store"; export async function generateCharacterTurnaround(input: { projectId: string; assetId: string; versionId: string; name: string; definition: Record; config: AiConfig }) { const prompt = characterTurnaroundPrompt(input.name, input.definition); - const result = await runBackendCanvasGenerationTask({ + await runBackendCanvasGenerationTask({ projectId: input.projectId, nodeId: `character-turnaround:${input.assetId}`, mode: "image", @@ -13,17 +11,6 @@ export async function generateCharacterTurnaround(input: { projectId: string; as config: { ...input.config, model: input.config.imageModel || input.config.model, count: "1" }, metadata: { operation: "character_turnaround", characterAssetId: input.assetId, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] }, }); - const image = result.images?.[0]; - if (!image?.dataUrl) throw new Error("三视图任务没有返回图片"); - const sheet = image.storageKey - ? { url: image.dataUrl, storageKey: image.storageKey } - : await uploadImage(image.dataUrl); - const sheetResourceId = resourceIdFromStorageKey(sheet.storageKey); - if (!sheetResourceId) throw new Error("三视图原图尚未保存到后端资源库"); - return [ - { role: "turnaround_sheet", resourceId: sheetResourceId, metadata: { prompt } }, - { role: "primary", resourceId: sheetResourceId, metadata: { source: "turnaround_sheet" } }, - ]; } export function characterTurnaroundPrompt(name: string, definition: Record) {