fix(canvas): 角色三视图 - 隔离并发状态并恢复刷新后任务结果
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
Reference in New Issue
Block a user