fix(canvas): 角色三视图 - 隔离并发状态并恢复刷新后任务结果

This commit is contained in:
ddCat
2026-07-27 16:35:14 +08:00
parent f72abe44ea
commit 30d5db050d
7 changed files with 174 additions and 32 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 != "" {

View File

@@ -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
<div className="min-w-0">
{(category === "all" || category === "character") && pendingCandidates.length ? <section className="mb-4"><div className="mb-2 flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-[var(--workspace-accent)]" />剧情识别出的角色</div><div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">{pendingCandidates.map((candidate) => <article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg border border-dashed border-border p-3"><span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span><div className="min-w-0 flex-1"><div className="truncate text-xs font-semibold">{candidate.name}</div><div className="mt-1 text-[10px] text-foreground/42">待确认角色卡 · 来自章节分析</div><div className="mt-2 flex min-w-0 items-center gap-1"><Button type="text" size="small" icon={<Check className="size-3.5" />} loading={confirmMutation.isPending} onClick={() => confirmMutation.mutate({ candidateId: candidate.id })}>确认新角色</Button>{characterAssets.length ? <Dropdown trigger={["click"]} menu={{ items: characterAssets.map((asset) => ({ key: asset.id, label: asset.title })), onClick: ({ key }) => confirmMutation.mutate({ candidateId: candidate.id, targetAssetId: key }) }}><Button type="text" size="small" disabled={confirmMutation.isPending}>归并到角色<ChevronDown className="size-3" /></Button></Dropdown> : null}</div></div></article>)}</div></section> : null}
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项</span></div>
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generateMutation.isPending && generateMutation.variables?.id === asset.id} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => 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)} />)}</div> : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => 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)} />)}</div> : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
</div>
</div>

View File

@@ -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 (
<article className="group overflow-hidden rounded-lg border border-border/80 bg-background shadow-[0_10px_30px_rgba(0,0,0,.04)]">
<div className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] font-medium text-white">角色卡 · v{character?.version || 1}</span>
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] text-white">{ready ? "可直接用于生成" : "设定进行中"}</span>
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[9px] text-white">{readinessLabel}</span>
</div>
</div>
<div className="p-3">

View File

@@ -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<string, unknown>; 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<string, unknown>) {