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) 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..1c81d59f 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,115 @@ 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('拒绝用空图片确认身份母版', 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() @@ -172,6 +284,7 @@ describe('createRealWorkflowEditorSession', () => { page: 1, pageSize: 100, }), + create: vi.fn(), update: vi.fn(async (character) => { events.push('publish') return structuredClone(character) @@ -211,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', @@ -278,6 +429,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()