From b3c32b02c75ded265af3ff8868c7ec2e2e2dea0b Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 11 Aug 2026 12:07:34 +0800 Subject: [PATCH 1/3] feat(workflow-editor): create character on template confirmation New WorkflowRuns can confirm a character template before any Character asset exists. Create the bound Character, seed its default outfit, and return it to the editor before advancing the template node. Keep subsequent action generation on the real Character and cover the flow with runtime and page regressions. --- .../src/pages/workflow-editor/index.test.tsx | 35 +++++++ frontend/src/pages/workflow-editor/index.tsx | 12 ++- .../src/pages/workflow-editor/runtime.test.ts | 96 +++++++++++++++++++ frontend/src/pages/workflow-editor/runtime.ts | 56 ++++++++++- 4 files changed, 195 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/workflow-editor/index.test.tsx b/frontend/src/pages/workflow-editor/index.test.tsx index 40c5f83e..b23fe0dd 100644 --- a/frontend/src/pages/workflow-editor/index.test.tsx +++ b/frontend/src/pages/workflow-editor/index.test.tsx @@ -117,6 +117,35 @@ describe('WorkflowEditorPage real runtime boundary', () => { expect(screen.queryByRole('button', { name: /选择角色候选/ })).toBeNull() }) + it('确认身份母版后采用会话创建的 Character 继续动作流程', async () => { + const session = createSession(selectingTemplateWorkflow(3, 'character-task'), { + generationApis: generationApisFixture({ + get: vi.fn().mockResolvedValue(characterGeneration('character')), + }), + }) + const confirmCharacterTemplate = vi.fn(async (nodeId: string, imageUrl: string) => { + await session.controller.confirmCharacterTemplate(nodeId, imageUrl) + return characterFixture() + }) + session.confirmCharacterTemplate = confirmCharacterTemplate + defaultSessionLoader.mockResolvedValue(session) + renderEditor('/workflow-editor/42') + + fireEvent.click(await screen.findByRole('button', { name: '选择角色候选 1' })) + fireEvent.click(screen.getByRole('button', { name: '确认身份母版' })) + + await waitFor(() => + expect(confirmCharacterTemplate).toHaveBeenCalledWith( + 'character-template', + 'https://assets.windup.test/character.png', + ), + ) + fireEvent.click(await screen.findByRole('button', { name: '添加动作分支' })) + expect((screen.getByRole('button', { name: '生成动作 ›' }) as HTMLButtonElement).disabled).toBe( + false, + ) + }) + it('切换 WorkflowRun 时清空上一条任务的临时动作菜单', async () => { defaultSessionLoader .mockResolvedValueOnce(createSession(completedTemplateWorkflow('42'))) @@ -633,6 +662,10 @@ function createSession( controller, project: projectFixture(), character: options.character ?? null, + confirmCharacterTemplate: async (nodeId, selectedImageUrl) => { + await controller.confirmCharacterTemplate(nodeId, selectedImageUrl) + return options.character ?? characterFixture() + }, publishReviewedAction: options.publishReviewedAction ?? (() => Promise.reject(new Error('资产发布未装配'))), subscribeErrors: () => () => undefined, @@ -856,6 +889,7 @@ function createGenerationRaceSession( controller, project: projectFixture(), character: null, + confirmCharacterTemplate: vi.fn(async () => characterFixture()), publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), subscribeErrors: () => () => undefined, dispose: () => controller.dispose(), @@ -912,6 +946,7 @@ function createRestartSelectionSession(options: { status?: Generation['status'] controller, project: projectFixture(), character: null, + confirmCharacterTemplate: vi.fn(async () => characterFixture()), publishReviewedAction: vi.fn(async () => Promise.reject(new Error('资产发布未装配'))), subscribeErrors: () => () => undefined, dispose: () => controller.dispose(), diff --git a/frontend/src/pages/workflow-editor/index.tsx b/frontend/src/pages/workflow-editor/index.tsx index cab7bd7a..eadf3579 100644 --- a/frontend/src/pages/workflow-editor/index.tsx +++ b/frontend/src/pages/workflow-editor/index.tsx @@ -265,6 +265,7 @@ export function WorkflowEditorPage({ loadSession }: WorkflowEditorPageProps = {} ? projectCanvas({ run, controller: session.controller, + confirmCharacterTemplate: session.confirmCharacterTemplate, publishReviewedAction: session.publishReviewedAction, project: session.project, character, @@ -435,6 +436,10 @@ function FitViewOnNodeSetChange({ nodeIds }: { nodeIds: string[] }) { interface ProjectionInput { run: WorkflowRun controller: WorkflowController + confirmCharacterTemplate( + nodeId: CharacterTemplateWorkflowNode['id'], + selectedImageUrl: string, + ): Promise publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise project: Project character: Character | null @@ -635,9 +640,10 @@ function CharacterTemplateContent({ className={CARD_BUTTON} disabled={!selectedImageUrl || branchBusy} onClick={() => - input.runCommand(branchKey, () => - input.controller.confirmCharacterTemplate(node.id, selectedImageUrl!), - ) + input.runCommand(branchKey, async () => { + const character = await input.confirmCharacterTemplate(node.id, selectedImageUrl!) + input.setCharacter(character) + }) } > 确认身份母版 diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts index 1a1187b0..97afd3c3 100644 --- a/frontend/src/pages/workflow-editor/runtime.test.ts +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -43,6 +43,7 @@ describe('createRealWorkflowEditorSession', () => { page: 2, pageSize: 100, }), + create: vi.fn(), update: vi.fn(), } @@ -80,6 +81,7 @@ describe('createRealWorkflowEditorSession', () => { page: 1, pageSize: 100, }), + create: vi.fn(), update: vi.fn(), } @@ -126,6 +128,7 @@ describe('createRealWorkflowEditorSession', () => { page: 1, pageSize: 100, }), + create: vi.fn(), update: vi.fn(), }, onAsyncError, @@ -146,6 +149,67 @@ describe('createRealWorkflowEditorSession', () => { expect(pageError).toHaveBeenCalledWith(expect.objectContaining({ message: '异步保存回调失败' })) }) + it('确认身份母版时为尚未绑定角色的 WorkflowRun 创建 Character 和默认造型', async () => { + const workflow = selectingCharacterTemplateWorkflowFixture() + const create = vi.fn().mockResolvedValue(characterFixture()) + const update = vi.fn(async (character: Character) => structuredClone(character)) + const session = await createRealWorkflowEditorSession('42', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + }, + projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + characterApis: { + listByProject: vi.fn().mockResolvedValue({ + items: [], + total: 0, + page: 1, + pageSize: 100, + }), + create, + update, + }, + onAsyncError: vi.fn(), + }) + + const character = await session.confirmCharacterTemplate( + 'template', + 'https://assets.windup.test/master.png', + ) + + expect(create).toHaveBeenCalledWith({ + projectId: '1', + workflowRunId: '42', + description: '冒险家', + referenceImageUrl: 'https://assets.windup.test/master.png', + }) + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + id: '9', + outfits: [ + expect.objectContaining({ + id: 'outfit-default', + characterId: '9', + name: '常态造型', + previewUrl: 'https://assets.windup.test/master.png', + actions: [], + }), + ], + }), + ) + expect(character.outfits).toHaveLength(1) + expect( + session.controller.getWorkflow().nodes.find((node) => node.id === 'template'), + ).toMatchObject({ status: 'passed', phase: 'completed' }) + }) + it('发布 Character 动作资产后由调用方单独推进审核节点', async () => { const events: string[] = [] const workflow = reviewingWorkflowFixture() @@ -172,6 +236,7 @@ describe('createRealWorkflowEditorSession', () => { page: 1, pageSize: 100, }), + create: vi.fn(), update: vi.fn(async (character) => { events.push('publish') return structuredClone(character) @@ -278,6 +343,37 @@ function characterWithOutfitFixture(): Character { } } +function selectingCharacterTemplateWorkflowFixture(): WorkflowRun { + return { + id: '42', + projectId: '1', + version: 4, + storageStatus: 'active', + nodes: [ + { + id: 'setup', + type: 'character-setup', + status: 'passed', + phase: 'completed', + dependsOnNodeIds: [], + generations: [], + error: null, + input: { prompt: '冒险家', referenceMedia: [] }, + }, + { + id: 'template', + type: 'character-template', + status: 'active', + phase: 'selecting', + dependsOnNodeIds: ['setup'], + generations: [{ taskId: 'character-task', role: 'character_template' }], + error: null, + selectedImageUrl: null, + }, + ], + } +} + function reviewingWorkflowFixture(): WorkflowRun { return { id: '42', diff --git a/frontend/src/pages/workflow-editor/runtime.ts b/frontend/src/pages/workflow-editor/runtime.ts index d709bc62..4de4b6fa 100644 --- a/frontend/src/pages/workflow-editor/runtime.ts +++ b/frontend/src/pages/workflow-editor/runtime.ts @@ -4,6 +4,7 @@ import type { GenerationApis, Project, ProjectApis, + CharacterTemplateWorkflowNode, ReviewWorkflowNode, WorkflowRunApis, } from '@/entities' @@ -16,6 +17,11 @@ export interface WorkflowEditorSession { project: Project /** 后端用 workflow_run_id 建立的唯一角色;尚未产出正式角色时为 null。 */ character: Character | null + /** 确认身份母版,并在首次确认时创建可继续生成动作的 Character。 */ + confirmCharacterTemplate( + nodeId: CharacterTemplateWorkflowNode['id'], + selectedImageUrl: string, + ): Promise /** 幂等发布动作资产;审核节点仍由页面随后通过 Controller 推进。 */ publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise subscribeErrors(listener: (error: Error) => void): () => void @@ -26,7 +32,7 @@ export interface RealWorkflowEditorDependencies { workflowRunApis: WorkflowRunApis generationApis: GenerationApis projectApis: Pick - characterApis: Pick + characterApis: Pick onAsyncError(error: Error): void } @@ -71,6 +77,54 @@ export async function createRealWorkflowEditorSession( controller, project, character: loadedCharacter, + async confirmCharacterTemplate(nodeId, selectedImageUrl) { + const imageUrl = selectedImageUrl.trim() + if (!imageUrl) throw new Error('必须选择角色母版') + const currentWorkflow = controller.getWorkflow() + const templateNode = currentWorkflow.nodes.find((node) => node.id === nodeId) + if ( + !templateNode || + templateNode.type !== 'character-template' || + templateNode.status !== 'active' || + templateNode.phase !== 'selecting' + ) { + throw new Error('角色母版节点当前不能确认') + } + const setupNode = currentWorkflow.nodes.find( + (node) => + templateNode.dependsOnNodeIds.includes(node.id) && node.type === 'character-setup', + ) + if (!setupNode || setupNode.type !== 'character-setup') { + throw new Error('角色母版缺少角色设定') + } + + if (!currentCharacter) { + currentCharacter = await dependencies.characterApis.create({ + projectId: currentWorkflow.projectId, + workflowRunId: currentWorkflow.id, + description: setupNode.input.prompt, + referenceImageUrl: imageUrl, + }) + } + if (currentCharacter.outfits.length === 0) { + currentCharacter = await dependencies.characterApis.update({ + ...currentCharacter, + outfits: [ + { + id: 'outfit-default', + characterId: currentCharacter.id, + name: '常态造型', + description: null, + previewUrl: imageUrl, + actions: [], + }, + ], + }) + } + + await controller.confirmCharacterTemplate(nodeId, imageUrl) + return currentCharacter + }, async publishReviewedAction(reviewNodeId) { if (!currentCharacter) throw new Error('当前 WorkflowRun 尚未关联 Character') const currentWorkflow = controller.getWorkflow() From f4a06e465530fc02a5605958d5cb6552b40fa955 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 11 Aug 2026 14:55:38 +0800 Subject: [PATCH 2/3] test(workflow-editor): cover character template confirmation branches Template confirmation has validation and idempotent paths that lacked behavior coverage. Exercise invalid selection inputs and the existing Character with Outfit path. Keep patch coverage focused on observable session behavior. --- .../src/pages/workflow-editor/runtime.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/frontend/src/pages/workflow-editor/runtime.test.ts b/frontend/src/pages/workflow-editor/runtime.test.ts index 97afd3c3..1c81d59f 100644 --- a/frontend/src/pages/workflow-editor/runtime.test.ts +++ b/frontend/src/pages/workflow-editor/runtime.test.ts @@ -210,6 +210,54 @@ describe('createRealWorkflowEditorSession', () => { ).toMatchObject({ status: 'passed', phase: 'completed' }) }) + it('拒绝用空图片确认身份母版', async () => { + const { session, create } = await createCharacterTemplateSession() + + await expect(session.confirmCharacterTemplate('template', ' ')).rejects.toThrow( + '必须选择角色母版', + ) + expect(create).not.toHaveBeenCalled() + }) + + it('拒绝确认当前不可选择的身份母版节点', async () => { + const { session, create } = await createCharacterTemplateSession() + + await expect( + session.confirmCharacterTemplate('missing', 'https://assets.windup.test/master.png'), + ).rejects.toThrow('角色母版节点当前不能确认') + expect(create).not.toHaveBeenCalled() + }) + + it('拒绝确认缺少角色设定依赖的身份母版', async () => { + const workflow = selectingCharacterTemplateWorkflowFixture() + workflow.nodes = workflow.nodes.filter((node) => node.type !== 'character-setup') + const { session, create } = await createCharacterTemplateSession({ workflow }) + + await expect( + session.confirmCharacterTemplate('template', 'https://assets.windup.test/master.png'), + ).rejects.toThrow('角色母版缺少角色设定') + expect(create).not.toHaveBeenCalled() + }) + + it('已有 Character 和造型时只推进身份母版节点', async () => { + const existing = characterWithOutfitFixture() + const { session, create, update } = await createCharacterTemplateSession({ + characters: [existing], + }) + + const character = await session.confirmCharacterTemplate( + 'template', + 'https://assets.windup.test/master.png', + ) + + expect(character).toEqual(existing) + expect(create).not.toHaveBeenCalled() + expect(update).not.toHaveBeenCalled() + expect( + session.controller.getWorkflow().nodes.find((node) => node.id === 'template'), + ).toMatchObject({ status: 'passed', phase: 'completed' }) + }) + it('发布 Character 动作资产后由调用方单独推进审核节点', async () => { const events: string[] = [] const workflow = reviewingWorkflowFixture() @@ -276,6 +324,44 @@ describe('createUnavailableGenerationApis', () => { }) }) +async function createCharacterTemplateSession( + options: { + workflow?: WorkflowRun + characters?: Character[] + } = {}, +) { + const workflow = options.workflow ?? selectingCharacterTemplateWorkflowFixture() + const characters = options.characters ?? [] + const create = vi.fn().mockResolvedValue(characterFixture()) + const update = vi.fn(async (character: Character) => structuredClone(character)) + const session = await createRealWorkflowEditorSession('42', { + workflowRunApis: { + create: vi.fn(), + get: vi.fn().mockResolvedValue(workflow), + update: vi.fn(async (run) => ({ ...structuredClone(run), version: run.version + 1 })), + remove: vi.fn(), + }, + generationApis: { + create: vi.fn() as GenerationApis['create'], + get: vi.fn(), + subscribe: vi.fn(() => () => undefined), + }, + projectApis: { get: vi.fn().mockResolvedValue(projectFixture()) }, + characterApis: { + listByProject: vi.fn().mockResolvedValue({ + items: characters, + total: characters.length, + page: 1, + pageSize: 100, + }), + create, + update, + }, + onAsyncError: vi.fn(), + }) + return { session, create, update } +} + function workflowFixture(): WorkflowRun { return { id: '42', From 6a6a3353538230c488b80b41b784f8019a551026 Mon Sep 17 00:00:00 2001 From: huyan Date: Tue, 11 Aug 2026 14:55:56 +0800 Subject: [PATCH 3/3] fix(character): make workflow character creation atomic Concurrent editor sessions could persist multiple Characters for one WorkflowRun. Enforce database uniqueness and recover conflicts through a project-scoped get-or-create path. Keep duplicate creation idempotent without exposing Characters across projects. --- .../windup_app/server/character/interface.py | 10 +++++- .../src/windup_app/server/character/model.py | 16 +++++++-- .../windup_app/server/character/service.py | 8 +++++ .../app/src/windup_app/web/api/character.py | 28 +++++++++++----- backend/tests/test_character_api.py | 33 +++++++++++++++++++ 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/backend/packages/app/src/windup_app/server/character/interface.py b/backend/packages/app/src/windup_app/server/character/interface.py index 37341353..ba3f4548 100644 --- a/backend/packages/app/src/windup_app/server/character/interface.py +++ b/backend/packages/app/src/windup_app/server/character/interface.py @@ -32,6 +32,14 @@ def create_character(self, session: Session, **fields) -> Character: def get_character(self, session: Session, character_id: int) -> Character | None: """按 ID 查询角色。""" + @abstractmethod + def get_character_by_workflow_run( + self, + session: Session, + workflow_run_id: int, + ) -> Character | None: + """按 WorkflowRun ID 查询唯一角色。""" + @abstractmethod def list_characters( self, session: Session, *, project_id: int, page: int, page_size: int, @@ -47,4 +55,4 @@ def update_character(self, session: Session, character_id: int, **fields) -> Cha @abstractmethod def delete_character(self, session: Session, character_id: int) -> bool: - """删除角色并返回是否找到。""" \ No newline at end of file + """删除角色并返回是否找到。""" diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py index 626336cb..449d06e4 100644 --- a/backend/packages/app/src/windup_app/server/character/model.py +++ b/backend/packages/app/src/windup_app/server/character/model.py @@ -35,7 +35,16 @@ from datetime import datetime, timezone from pydantic import BaseModel, Field -from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, String, Text +from sqlalchemy import ( + BigInteger, + DateTime, + Integer, + JSON, + SmallInteger, + String, + Text, + UniqueConstraint, +) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -49,6 +58,9 @@ class Character(Base): """角色资产表。""" __tablename__ = "windup_character" + __table_args__ = ( + UniqueConstraint("workflow_run_id", name="uq_windup_character_workflow_run"), + ) # Postgres 上 BigInteger 自增;variant 到 Integer 让 SQLite(测试库)走 # INTEGER PRIMARY KEY 自增。 @@ -129,4 +141,4 @@ class CharacterData(BaseModel): """角色完整数据(造型→动作→帧)。""" version: int = Field(default=1, description="结构版本") - outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表") \ No newline at end of file + outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表") diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py index e1ff22d4..707a924c 100644 --- a/backend/packages/app/src/windup_app/server/character/service.py +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -27,6 +27,14 @@ def create_character(self, session: Session, **fields) -> Character: def get_character(self, session: Session, character_id: int) -> Character | None: return session.get(Character, character_id) + def get_character_by_workflow_run( + self, + session: Session, + workflow_run_id: int, + ) -> Character | None: + stmt = select(Character).where(Character.workflow_run_id == workflow_run_id) + return session.scalar(stmt) + def list_characters( self, session: Session, *, project_id: int, page: int, page_size: int, ) -> tuple[list[Character], int]: diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py index e125f4d6..0455e71d 100644 --- a/backend/packages/app/src/windup_app/web/api/character.py +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from windup_common.enums.biz_code import BizCode @@ -131,15 +132,24 @@ def create_character( ) -> Response[CharacterOut]: user_id = request.state.current_user.id _get_project_or_raise(session, body.project_id, user_id) - character = character_service.create_character( - session, - project_id=body.project_id, - workflow_run_id=body.workflow_run_id, - name=body.name, - description=body.description, - reference_image_url=body.reference_image_url, - character_data=body.character_data.model_dump(), - ) + try: + character = character_service.create_character( + session, + project_id=body.project_id, + workflow_run_id=body.workflow_run_id, + name=body.name, + description=body.description, + reference_image_url=body.reference_image_url, + character_data=body.character_data.model_dump(), + ) + except IntegrityError: + session.rollback() + character = character_service.get_character_by_workflow_run( + session, + body.workflow_run_id, + ) + if character is None or character.project_id != body.project_id: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) from None return Response.success(CharacterOut.model_validate(character), message="创建成功") diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py index 13500201..fe799cb5 100644 --- a/backend/tests/test_character_api.py +++ b/backend/tests/test_character_api.py @@ -58,6 +58,21 @@ def test_create_name_roundtrip(auth_client): assert resp.json()["data"]["name"] == "小精灵" +def test_create_same_workflow_run_returns_existing_character(auth_client): + project = _create_project(auth_client) + payload = _payload(project["id"], workflow_run_id=42) + + first = auth_client.post("/characters", json=payload).json() + second = auth_client.post("/characters", json=payload).json() + listed = auth_client.get("/characters", params={"project_id": project["id"]}).json() + + assert first["code"] == 200 + assert second["code"] == 200 + assert second["data"]["id"] == first["data"]["id"] + assert listed["total"] == 1 + assert [character["id"] for character in listed["data"]] == [first["data"]["id"]] + + # -- 跨用户权限校验 ------------------------------------------------------------- @@ -70,6 +85,24 @@ def test_create_under_other_users_project_returns_404(auth_client, auth_client_b assert resp.json()["message"] == "项目不存在" +def test_create_same_workflow_run_under_another_project_returns_404( + auth_client, auth_client_b, +): + project_a = _create_project(auth_client, "用户 A 项目") + project_b = _create_project(auth_client_b, "用户 B 项目") + created = auth_client.post( + "/characters", json=_payload(project_a["id"], workflow_run_id=42), + ).json()["data"] + + resp = auth_client_b.post( + "/characters", json=_payload(project_b["id"], workflow_run_id=42), + ) + + assert resp.json()["code"] == 404 + assert resp.json()["data"] is None + assert auth_client.get(f"/characters/{created['id']}").json()["code"] == 200 + + def test_list_other_users_project_characters_returns_404(auth_client, auth_client_b): """用户 B 不能列出用户 A 项目下的角色。""" project = _create_project(auth_client)