Skip to content
10 changes: 9 additions & 1 deletion backend/packages/app/src/windup_app/server/project/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ def create_project(self, session: Session, **fields) -> Project:
"""

@abstractmethod
def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool:
def project_name_exists(
self, session: Session, *, user_id: int, project_name: str
) -> bool:
"""判断用户下的项目名称是否已存在。"""

@abstractmethod
Expand All @@ -46,6 +48,12 @@ def list_projects(
) -> tuple[list[Project], int]:
"""分页查询项目,返回 (当前页数据, 总数)。"""

@abstractmethod
def list_project_previews(
self, session: Session, project_ids: list[int], *, character_limit: int
) -> dict[int, str | None]:
"""一次查询当前页项目的资产预览,结果包含所有传入项目 ID。"""

@abstractmethod
def delete_project(self, session: Session, project_id: int) -> bool:
"""删除项目并返回是否找到。"""
76 changes: 74 additions & 2 deletions backend/packages/app/src/windup_app/server/project/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@

from windup_app.server.project.interface import ProjectService
from windup_app.server.project.model import Project
from windup_app.server.character.model import Character


def _character_preview(
reference_image_url: str | None, character_data: dict
) -> str | None:
"""沿用项目中心既有优先级:造型预览、角色参考图、第一张动作帧。"""
outfits = character_data.get("outfits", [])
for outfit in outfits:
preview = outfit.get("preview_url")
if preview:
return preview
if reference_image_url:
return reference_image_url
for outfit in outfits:
for action in outfit.get("actions", []):
for frame in action.get("frames", []):
image_url = frame.get("image_url")
if image_url:
return image_url
return None


class SqlAlchemyProjectService(ProjectService):
Expand All @@ -24,7 +45,9 @@ def create_project(self, session: Session, **fields) -> Project:
session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at)
return project

def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool:
def project_name_exists(
self, session: Session, *, user_id: int, project_name: str
) -> bool:
stmt = (
select(Project.id)
.where(Project.user_id == user_id, Project.project_name == project_name)
Expand All @@ -50,10 +73,59 @@ def list_projects(
count_stmt = count_stmt.where(Project.user_id == user_id)
stmt = stmt.where(Project.user_id == user_id)
total = session.scalar(count_stmt) or 0
stmt = stmt.order_by(Project.id.desc()).offset((page - 1) * page_size).limit(page_size)
stmt = (
stmt.order_by(Project.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = list(session.scalars(stmt))
return items, total

def list_project_previews(
self, session: Session, project_ids: list[int], *, character_limit: int
) -> dict[int, str | None]:
previews = dict.fromkeys(project_ids)
if not project_ids:
return previews

rank = (
func.row_number()
.over(
partition_by=Character.project_id,
order_by=Character.id.desc(),
)
.label("project_rank")
)
ranked = (
select(
Character.project_id.label("project_id"),
Character.reference_image_url.label("reference_image_url"),
Character.character_data.label("character_data"),
rank,
)
.where(Character.project_id.in_(project_ids))
.subquery()
)
stmt = (
select(
ranked.c.project_id,
ranked.c.reference_image_url,
ranked.c.character_data,
ranked.c.project_rank,
)
.where(ranked.c.project_rank <= character_limit)
.order_by(ranked.c.project_id, ranked.c.project_rank)
)
for project_id, reference_image_url, character_data, _rank in session.execute(
stmt
):
if previews[project_id] is None:
previews[project_id] = _character_preview(
reference_image_url,
character_data or {},
)
return previews

def delete_project(self, session: Session, project_id: int) -> bool:
project = session.get(Project, project_id)
if project is None:
Expand Down
27 changes: 23 additions & 4 deletions backend/packages/app/src/windup_app/web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class ProjectOut(BaseModel):
update_at: datetime


class ProjectListOut(ProjectOut):
"""项目列表项;预览是读取投影,不写回 Project。"""

preview_url: str | None


@router.post("", response_model=Response[ProjectOut])
def create_project(
body: ProjectCreate,
Expand Down Expand Up @@ -81,19 +87,30 @@ def create_project(
return Response.success(ProjectOut.model_validate(project), message="创建成功")


@router.get("", response_model=ListResponse[ProjectOut])
@router.get("", response_model=ListResponse[ProjectListOut])
def list_projects(
request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
session: Session = Depends(get_session),
) -> ListResponse[ProjectOut]:
) -> ListResponse[ProjectListOut]:
user_id = request.state.current_user.id
projects, total = service.list_projects(
session, page=page, page_size=page_size, user_id=user_id
)
previews = service.list_project_previews(
session,
[project.id for project in projects],
character_limit=6,
)
return ListResponse.success(
[ProjectOut.model_validate(item) for item in projects],
[
ProjectListOut(
**ProjectOut.model_validate(item).model_dump(),
preview_url=item.sprite_sample_url or previews[item.id],
)
for item in projects
],
total=total,
page=page,
page_size=page_size,
Expand Down Expand Up @@ -127,5 +144,7 @@ def delete_project(
service.delete_project(session, project_id)
except IntegrityError:
session.rollback()
raise BizException("项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST) from None
raise BizException(
"项目下仍有角色,无法删除", code=BizCode.BAD_REQUEST
) from None
return Response.success(None, message="删除成功")
142 changes: 136 additions & 6 deletions backend/tests/test_project_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
``timestamp`` 默认省略)与 400/404 业务码路径。
"""

from sqlalchemy import event


def _payload(**overrides):
"""构造合法的创建请求体(对齐 ``ProjectCreate``)。"""
Expand Down Expand Up @@ -103,6 +105,128 @@ def test_list_paginates(auth_client):
assert all("user_id" not in item for item in body["data"])


def _create_character(auth_client, project_id, workflow_run_id, **overrides):
payload = {
"project_id": project_id,
"workflow_run_id": workflow_run_id,
"name": f"角色 {workflow_run_id}",
"character_data": {"version": 1, "outfits": []},
}
payload.update(overrides)
body = auth_client.post("/characters", json=payload).json()
assert body["code"] == 200
return body["data"]


def test_list_includes_project_preview_fallbacks(auth_client):
outfit_project = auth_client.post(
"/projects", json=_payload(project_name="造型预览")
).json()["data"]
reference_project = auth_client.post(
"/projects", json=_payload(project_name="参考图预览")
).json()["data"]
frame_project = auth_client.post(
"/projects", json=_payload(project_name="帧预览")
).json()["data"]
auth_client.post("/projects", json=_payload(project_name="空项目"))

_create_character(
auth_client,
outfit_project["id"],
601,
reference_image_url="https://cdn.windup.test/reference-unused.png",
character_data={
"version": 1,
"outfits": [
{
"id": "outfit-1",
"name": "常态",
"preview_url": "https://cdn.windup.test/outfit.png",
"actions": [],
}
],
},
)
_create_character(
auth_client,
reference_project["id"],
602,
reference_image_url="https://cdn.windup.test/reference.png",
)
_create_character(
auth_client,
frame_project["id"],
603,
character_data={
"version": 1,
"outfits": [
{
"id": "outfit-3",
"name": "常态",
"preview_url": None,
"actions": [
{
"id": "idle",
"type": "idle",
"name": "待机",
"loop": True,
"fps": 8,
"frame_count": 1,
"frames": [
{
"index": 0,
"image_url": "https://cdn.windup.test/frame.png",
}
],
}
],
}
],
},
)

body = auth_client.get("/projects", params={"page_size": 10}).json()
previews = {item["project_name"]: item["preview_url"] for item in body["data"]}

assert previews == {
"空项目": None,
"帧预览": "https://cdn.windup.test/frame.png",
"参考图预览": "https://cdn.windup.test/reference.png",
"造型预览": "https://cdn.windup.test/outfit.png",
}


def test_list_loads_all_project_previews_with_one_character_query(auth_client, engine):
first = auth_client.post(
"/projects", json=_payload(project_name="固定查询一")
).json()["data"]
second = auth_client.post(
"/projects", json=_payload(project_name="固定查询二")
).json()["data"]
_create_character(auth_client, first["id"], 611)
_create_character(auth_client, second["id"], 612)
statements = []

def record_character_select(
_conn, _cursor, statement, _parameters, _context, _many
):
normalized = statement.lower()
if (
normalized.lstrip().startswith("select")
and "windup_character" in normalized
):
statements.append(statement)

event.listen(engine, "before_cursor_execute", record_character_select)
try:
body = auth_client.get("/projects", params={"page_size": 10}).json()
finally:
event.remove(engine, "before_cursor_execute", record_character_select)

assert body["code"] == 200
assert len(statements) == 1


# -- DELETE /projects/{id} ---------------------------------------------------


Expand All @@ -125,9 +249,9 @@ def test_delete_not_found_returns_404(auth_client):


def test_delete_rejected_when_project_has_characters(auth_client):
created = auth_client.post("/projects", json=_payload(project_name="有角色")).json()[
"data"
]
created = auth_client.post(
"/projects", json=_payload(project_name="有角色")
).json()["data"]
character = auth_client.post(
"/characters",
json={
Expand All @@ -147,10 +271,14 @@ def test_delete_rejected_when_project_has_characters(auth_client):
assert body["message"] == "项目下仍有角色,无法删除"
assert body["data"] is None
assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200
assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200
assert (
auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200
)


def test_delete_rejected_when_character_arrives_after_empty_check(auth_client, monkeypatch):
def test_delete_rejected_when_character_arrives_after_empty_check(
auth_client, monkeypatch
):
"""模拟检查与删除之间插入角色:应用层已看见空项目,数据库仍应拦住删除。"""
created = auth_client.post("/projects", json=_payload(project_name="竞态")).json()[
"data"
Expand All @@ -177,4 +305,6 @@ def test_delete_rejected_when_character_arrives_after_empty_check(auth_client, m
assert body["code"] == 400
assert body["message"] == "项目下仍有角色,无法删除"
assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 200
assert auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200
assert (
auth_client.get(f"/characters/{character['data']['id']}").json()["code"] == 200
)
2 changes: 2 additions & 0 deletions frontend/src/entities/project/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const projectDto = {
sprite_height: 96,
game_style: null,
sprite_sample_url: 'https://cdn.windup.test/style.png',
preview_url: 'https://cdn.windup.test/project-preview.png',
create_at: '2026-08-01T08:00:00Z',
update_at: '2026-08-02T09:30:00Z',
}
Expand Down Expand Up @@ -61,6 +62,7 @@ describe('projectApis', () => {
spriteSize: { width: 64, height: 96 },
gameStyle: null,
sampleImageUrl: 'https://cdn.windup.test/style.png',
previewUrl: 'https://cdn.windup.test/project-preview.png',
createdAt: '2026-08-01T08:00:00Z',
updatedAt: '2026-08-02T09:30:00Z',
},
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/entities/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface Project {
spriteSize: { width: number; height: number }
gameStyle: string | null
sampleImageUrl: string | null
/** 项目列表已经解析好的卡片预览;详情响应不承诺提供。 */
previewUrl?: string | null
createdAt: string
updatedAt: string
}
Expand Down Expand Up @@ -91,6 +93,7 @@ interface ProjectDto {
sprite_height: number
game_style: string | null
sprite_sample_url: string | null
preview_url?: string | null
create_at: string
update_at: string
}
Expand Down Expand Up @@ -152,6 +155,7 @@ function mapProject(dto: ProjectDto): Project {
spriteSize: { width: dto.sprite_width, height: dto.sprite_height },
gameStyle: dto.game_style,
sampleImageUrl: dto.sprite_sample_url,
previewUrl: dto.preview_url ?? dto.sprite_sample_url,
createdAt: dto.create_at,
updatedAt: dto.update_at,
}
Expand Down
Loading
Loading