Skip to content
Closed
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
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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 不要只检查最近六个角色的预览

原来的前端逻辑以每页 6 个角色持续翻页,直到找到预览或遍历完整个项目;这里却在 SQL 层永久丢弃 project_rank > 6 的角色。只要最新六个角色都没有图片、较早角色仍有造型/参考图/帧,列表就会返回 preview_url: null 并错误显示“等待第一份角色资产”。请在单次批量查询中搜索全部角色(例如直接选出每项目首个非空预览),而不是用固定数量截断语义。

.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