Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
"""删除角色并返回是否找到。"""
"""删除角色并返回是否找到。"""
16 changes: 14 additions & 2 deletions backend/packages/app/src/windup_app/server/character/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 自增。
Expand Down Expand Up @@ -129,4 +141,4 @@ class CharacterData(BaseModel):
"""角色完整数据(造型→动作→帧)。"""

version: int = Field(default=1, description="结构版本")
outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表")
outfits: list[CharacterOutfit] = Field(default_factory=list, description="造型列表")
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
28 changes: 19 additions & 9 deletions backend/packages/app/src/windup_app/web/api/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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="创建成功")


Expand Down
33 changes: 33 additions & 0 deletions backend/tests/test_character_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]]


# -- 跨用户权限校验 -------------------------------------------------------------


Expand All @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions frontend/src/pages/workflow-editor/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/pages/workflow-editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export function WorkflowEditorPage({ loadSession }: WorkflowEditorPageProps = {}
? projectCanvas({
run,
controller: session.controller,
confirmCharacterTemplate: session.confirmCharacterTemplate,
publishReviewedAction: session.publishReviewedAction,
project: session.project,
character,
Expand Down Expand Up @@ -435,6 +436,10 @@ function FitViewOnNodeSetChange({ nodeIds }: { nodeIds: string[] }) {
interface ProjectionInput {
run: WorkflowRun
controller: WorkflowController
confirmCharacterTemplate(
nodeId: CharacterTemplateWorkflowNode['id'],
selectedImageUrl: string,
): Promise<Character>
publishReviewedAction(reviewNodeId: ReviewWorkflowNode['id']): Promise<Character>
project: Project
character: Character | null
Expand Down Expand Up @@ -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)
})
}
>
确认身份母版
Expand Down
Loading
Loading