diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index bbea356e..50ce41a8 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -19,8 +19,10 @@ from windup_app.server.project.model import Project # noqa: F401 from windup_app.server.user.model import User # noqa: F401 from windup_app.web.api.auth import router as auth_router +from windup_app.web.api.character import router as character_router from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router +from windup_app.web.api.project import router as project_router from windup_app.web.handler.exception_handlers import register_exception_handlers from windup_app.web.middleware.auth import AuthMiddleware from windup_app.web.middleware.ratelimit import RateLimitMiddleware @@ -81,6 +83,8 @@ def create_app() -> FastAPI: app.add_middleware(AuthMiddleware) app.add_middleware(RateLimitMiddleware) app.include_router(auth_router) + app.include_router(project_router) + app.include_router(character_router) app.include_router(media_router) app.include_router(generation_router) register_exception_handlers(app) 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 a635e698..626336cb 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,7 @@ from datetime import datetime, timezone from pydantic import BaseModel, Field -from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, Text +from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -60,7 +60,9 @@ class Character(Base): project_id: Mapped[int] = mapped_column(BigInteger, nullable=False) - workflow_run_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + workflow_run_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + + name: Mapped[str | None] = mapped_column(String(20), nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py new file mode 100644 index 00000000..e1ff22d4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -0,0 +1,69 @@ +"""角色领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyCharacterService` 继承 :class:`CharacterService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from windup_app.server.character.interface import CharacterService +from windup_app.server.character.model import Character + + +class SqlAlchemyCharacterService(CharacterService): + """基于 SQLAlchemy session 的角色 CRUD 实现。""" + + def create_character(self, session: Session, **fields) -> Character: + character = Character(**fields) + session.add(character) + session.flush() + return character + + def get_character(self, session: Session, character_id: int) -> Character | None: + return session.get(Character, character_id) + + def list_characters( + self, session: Session, *, project_id: int, page: int, page_size: int, + ) -> tuple[list[Character], int]: + count_stmt = ( + select(func.count()) + .select_from(Character) + .where(Character.project_id == project_id) + ) + stmt = ( + select(Character) + .where(Character.project_id == project_id) + .order_by(Character.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + total = session.scalar(count_stmt) or 0 + items = list(session.scalars(stmt)) + return items, total + + def update_character( + self, session: Session, character_id: int, **fields, + ) -> Character | None: + character = session.get(Character, character_id) + if character is None: + return None + for key, value in fields.items(): + setattr(character, key, value) + session.flush() + return character + + def delete_character(self, session: Session, character_id: int) -> bool: + character = session.get(Character, character_id) + if character is None: + return False + session.delete(character) + session.flush() + return True + + +service = SqlAlchemyCharacterService() diff --git a/backend/packages/app/src/windup_app/server/project/interface.py b/backend/packages/app/src/windup_app/server/project/interface.py index f9b89de3..1bacf4fd 100644 --- a/backend/packages/app/src/windup_app/server/project/interface.py +++ b/backend/packages/app/src/windup_app/server/project/interface.py @@ -2,10 +2,16 @@ 项目 API 只依赖本模块定义的抽象接口。数据库、缓存或其他具体实现应在 应用装配层继承 :class:`ProjectService` 后通过依赖注入提供。 + +约定为 session-per-call:``session`` 由调用方(FastAPI 的 ``get_session`` 依赖) +按请求传入,具体实现(如 :mod:`windup_app.server.project.service`)保持无状态, +可作为模块级单例。 """ from abc import ABC, abstractmethod +from sqlalchemy.orm import Session + from windup_app.server.project.model import Project @@ -13,23 +19,27 @@ class ProjectService(ABC): """项目 CRUD 用例的抽象边界。""" @abstractmethod - def create_project(self, project: Project) -> Project: - """创建项目。""" + def create_project(self, session: Session, **fields) -> Project: + """创建项目。 + + ``fields`` 为项目字段(对齐 ``ProjectCreate`` 的字段集),由实现组装成 + :class:`Project` 后持久化。 + """ @abstractmethod - def project_name_exists(self, *, user_id: int, project_name: str) -> bool: + def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool: """判断用户下的项目名称是否已存在。""" @abstractmethod - def get_project(self, project_id: int) -> Project | None: + def get_project(self, session: Session, project_id: int) -> Project | None: """按 ID 查询项目。""" @abstractmethod def list_projects( - self, *, page: int, page_size: int, user_id: int | None = None + self, session: Session, *, page: int, page_size: int, user_id: int | None = None ) -> tuple[list[Project], int]: - """分页查询项目。""" + """分页查询项目,返回 (当前页数据, 总数)。""" @abstractmethod - def delete_project(self, project_id: int) -> bool: + def delete_project(self, session: Session, project_id: int) -> bool: """删除项目并返回是否找到。""" diff --git a/backend/packages/app/src/windup_app/server/project/model.py b/backend/packages/app/src/windup_app/server/project/model.py index 440cafc5..50080a0e 100644 --- a/backend/packages/app/src/windup_app/server/project/model.py +++ b/backend/packages/app/src/windup_app/server/project/model.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, DateTime, SmallInteger, String, Text, UniqueConstraint +from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from windup_framework.db import Base @@ -16,7 +16,13 @@ class Project(Base): UniqueConstraint("user_id", "project_name", name="uq_windup_project_user_name"), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + # Postgres 上 BigInteger 自增;variant 到 Integer 让 SQLite(测试库)走 + # INTEGER PRIMARY KEY 自增(SQLite 仅对该声明自动分配 rowid)。 + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) workflow_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) project_name: Mapped[str] = mapped_column(String(20), nullable=False) diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py new file mode 100644 index 00000000..85cee08a --- /dev/null +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -0,0 +1,60 @@ +"""项目领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyProjectService` 继承 :class:`ProjectService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from windup_app.server.project.interface import ProjectService +from windup_app.server.project.model import Project + + +class SqlAlchemyProjectService(ProjectService): + """基于 SQLAlchemy session 的项目 CRUD 实现。""" + + def create_project(self, session: Session, **fields) -> Project: + project = Project(**fields) + session.add(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: + stmt = ( + select(Project.id) + .where(Project.user_id == user_id, Project.project_name == project_name) + .limit(1) + ) + return session.scalar(stmt) is not None + + def get_project(self, session: Session, project_id: int) -> Project | None: + return session.get(Project, project_id) + + def list_projects( + self, session: Session, *, page: int, page_size: int, user_id: int | None = None + ) -> tuple[list[Project], int]: + count_stmt = select(func.count()).select_from(Project) + stmt = select(Project) + if user_id is not None: + 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) + items = list(session.scalars(stmt)) + return items, total + + def delete_project(self, session: Session, project_id: int) -> bool: + project = session.get(Project, project_id) + if project is None: + return False + session.delete(project) + session.flush() + return True + + +service = SqlAlchemyProjectService() diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py new file mode 100644 index 00000000..e125f4d6 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -0,0 +1,215 @@ +"""角色 CRUD API。""" + +import logging + +from fastapi import APIRouter, Depends, Query, Request +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.config.storage import settings as storage_settings +from windup_framework.db import get_session + +from windup_app.server.character.model import Character, CharacterData +from windup_app.server.character.service import service as character_service +from windup_app.server.media.service import service as media_service +from windup_app.server.project.model import Project + +logger = logging.getLogger("windup.character.api") + +router = APIRouter(prefix="/characters", tags=["characters"]) + + +# ── 请求 / 响应模型 ───────────────────────────────────────────────────────── + + +class CharacterCreate(BaseModel): + """创建角色请求。""" + + project_id: int = Field(gt=0) + workflow_run_id: int = Field(gt=0) + name: str | None = Field(default=None, max_length=20) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData = Field(default_factory=CharacterData) + + +class CharacterUpdate(BaseModel): + """更新角色请求——所有字段可选。""" + + name: str | None = Field(default=None, max_length=20) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData | None = None + + +class CharacterOut(BaseModel): + """角色响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + workflow_run_id: int + name: str | None = None + description: str | None = None + reference_image_url: str | None = None + character_data: dict + status: int + + +# ── 辅助函数 ───────────────────────────────────────────────────────────────── + + +def _extract_object_keys(character: Character) -> list[str]: + """从角色中提取所有对象存储 key,用于删除时清理资源。 + + URL 格式: ``{download_base}/{object_key}`` + """ + prefix = storage_settings.download_base + "/" + keys: list[str] = [] + + # 参考图 + url = character.reference_image_url + if url and url.startswith(prefix): + keys.append(url[len(prefix):]) + + # character_data 内的 URL + data = character.character_data or {} + for outfit in data.get("outfits", []): + url = outfit.get("preview_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix):]) + for action in outfit.get("actions", []): + for frame in action.get("frames", []): + url = frame.get("image_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix):]) + + return keys + + +# ── 归属校验 ───────────────────────────────────────────────────────────────── + + +def _get_project_or_raise( + session: Session, project_id: int, user_id: int, +) -> Project: + """校验项目存在且属于当前用户,否则抛 BizException。""" + project = session.get(Project, project_id) + if project is None or project.user_id != user_id: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + return project + + +def _get_character_with_auth( + session: Session, character_id: int, user_id: int, +) -> Character: + """获取角色并校验其所属项目属于当前用户。 + + 无论角色不存在还是无权访问,统一返回"角色不存在",避免信息泄露。 + """ + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + project = session.get(Project, character.project_id) + if project is None or project.user_id != user_id: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return character + + +# ── 端点 ───────────────────────────────────────────────────────────────────── + + +@router.post("", response_model=Response[CharacterOut]) +def create_character( + body: CharacterCreate, + request: Request, + session: Session = Depends(get_session), +) -> 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(), + ) + return Response.success(CharacterOut.model_validate(character), message="创建成功") + + +@router.get("", response_model=ListResponse[CharacterOut]) +def list_characters( + project_id: int = Query(..., gt=0), + request: Request = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + session: Session = Depends(get_session), +) -> ListResponse[CharacterOut]: + user_id = request.state.current_user.id + _get_project_or_raise(session, project_id, user_id) + items, total = character_service.list_characters( + session, project_id=project_id, page=page, page_size=page_size, + ) + return ListResponse.success( + [CharacterOut.model_validate(c) for c in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{character_id}", response_model=Response[CharacterOut]) +def get_character( + character_id: int, + request: Request, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + user_id = request.state.current_user.id + character = _get_character_with_auth(session, character_id, user_id) + return Response.success(CharacterOut.model_validate(character)) + + +@router.patch("/{character_id}", response_model=Response[CharacterOut]) +def update_character( + character_id: int, + body: CharacterUpdate, + request: Request, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + user_id = request.state.current_user.id + _get_character_with_auth(session, character_id, user_id) + fields = body.model_dump(exclude_unset=True) + character = character_service.update_character(session, character_id, **fields) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return Response.success(CharacterOut.model_validate(character), message="更新成功") + + +@router.delete("/{character_id}", response_model=Response[None]) +def delete_character( + character_id: int, + request: Request, + session: Session = Depends(get_session), +) -> Response[None]: + user_id = request.state.current_user.id + character = _get_character_with_auth(session, character_id, user_id) + + # 先提取对象 key,再删 DB 记录 + object_keys = _extract_object_keys(character) + + character_service.delete_character(session, character_id) + + # 清理对象存储——失败只记日志,不回滚 DB + for key in object_keys: + try: + media_service.delete(key) + except Exception: + logger.warning("[WINDUP] 媒体清理失败(已跳过) | key=%s", key, exc_info=True) + + return Response.success(None, message="删除成功") diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py new file mode 100644 index 00000000..ead71016 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -0,0 +1,123 @@ +"""项目 CRUD API。""" + +import logging +from datetime import datetime + +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 +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.db import get_session + +from windup_app.server.project.service import service + +logger = logging.getLogger("windup.project.api") + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +class ProjectCreate(BaseModel): + """创建项目请求。""" + + workflow_id: int | None = None + project_name: str = Field(min_length=1, max_length=20) + character_perspective: int = Field(ge=1, le=3) + directional_movement: int = Field(ge=1, le=3) + sprite_width: int = Field(ge=32, le=2048) + sprite_height: int = Field(ge=32, le=2048) + game_style: str | None = None + sprite_sample_url: str | None = None + + +class ProjectOut(BaseModel): + """项目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + workflow_id: int | None + project_name: str + character_perspective: int + directional_movement: int + sprite_width: int + sprite_height: int + game_style: str | None + sprite_sample_url: str | None + create_at: datetime + update_at: datetime + + +@router.post("", response_model=Response[ProjectOut]) +def create_project( + body: ProjectCreate, + request: Request, + session: Session = Depends(get_session), +) -> Response[ProjectOut]: + user_id = request.state.current_user.id + if service.project_name_exists( + session, user_id=user_id, project_name=body.project_name + ): + logger.warning( + "[WINDUP] 创建拒绝-名称重复 | user_id=%s project_name=%s", + user_id, body.project_name, + ) + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) + try: + project = service.create_project(session, user_id=user_id, **body.model_dump()) + except IntegrityError: + logger.warning( + "[WINDUP] 创建拒绝-并发冲突 | user_id=%s project_name=%s", + user_id, body.project_name, + ) + session.rollback() + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None + return Response.success(ProjectOut.model_validate(project), message="创建成功") + + +@router.get("", response_model=ListResponse[ProjectOut]) +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]: + user_id = request.state.current_user.id + projects, total = service.list_projects( + session, page=page, page_size=page_size, user_id=user_id + ) + return ListResponse.success( + [ProjectOut.model_validate(item) for item in projects], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{project_id}", response_model=Response[ProjectOut]) +def get_project( + project_id: int, + request: Request, + session: Session = Depends(get_session), +) -> Response[ProjectOut]: + project = service.get_project(session, project_id) + if project is None or project.user_id != request.state.current_user.id: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + return Response.success(ProjectOut.model_validate(project)) + + +@router.delete("/{project_id}", response_model=Response[None]) +def delete_project( + project_id: int, + request: Request, + session: Session = Depends(get_session), +) -> Response[None]: + project = service.get_project(session, project_id) + if project is None or project.user_id != request.state.current_user.id: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + service.delete_project(session, project_id) + return Response.success(None, message="删除成功") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aa420c0a..a3d471e8 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -101,3 +101,29 @@ def override_get_session(): yield client app.dependency_overrides.clear() + + +@pytest.fixture() +def auth_client_b(engine): + """另一个用户的认证 TestClient(user_id=2),用于跨用户权限测试。""" + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + session = session_local() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + + token = create_access_token(2, "other@example.com") + client = TestClient(app, headers={"Authorization": f"Bearer {token}"}) + + yield client + app.dependency_overrides.clear() diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py new file mode 100644 index 00000000..13500201 --- /dev/null +++ b/backend/tests/test_character_api.py @@ -0,0 +1,122 @@ +"""角色 CRUD API 集成测试。""" + + +def _create_project(auth_client, name: str = "默认项目") -> dict: + """创建一个项目并返回响应 data。""" + return auth_client.post("/projects", json={ + "project_name": name, + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + }).json()["data"] + + +def _payload(project_id: int, **overrides): + """构造合法的创建角色请求体。""" + base = { + "project_id": project_id, + "workflow_run_id": 1, + "name": "勇者", + "description": "主角", + } + base.update(overrides) + return base + + +# -- POST /characters -------------------------------------------------------- + + +def test_create_with_name(auth_client): + project = _create_project(auth_client) + resp = auth_client.post("/characters", json=_payload(project["id"])) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["data"]["name"] == "勇者" + assert body["data"]["description"] == "主角" + assert body["data"]["project_id"] == project["id"] + + +def test_create_without_name(auth_client): + project = _create_project(auth_client) + resp = auth_client.post("/characters", json=_payload(project["id"], name=None)) + + assert resp.json()["code"] == 200 + assert resp.json()["data"]["name"] is None + + +def test_create_name_roundtrip(auth_client): + """名称持久化后可通过 GET 读回。""" + project = _create_project(auth_client) + created = auth_client.post( + "/characters", json=_payload(project["id"], name="小精灵"), + ).json()["data"] + + resp = auth_client.get(f"/characters/{created['id']}") + assert resp.json()["data"]["name"] == "小精灵" + + +# -- 跨用户权限校验 ------------------------------------------------------------- + + +def test_create_under_other_users_project_returns_404(auth_client, auth_client_b): + """用户 B 不能在用户 A 的项目下创建角色。""" + project = _create_project(auth_client) + resp = auth_client_b.post("/characters", json=_payload(project["id"])) + + assert resp.json()["code"] == 404 + assert resp.json()["message"] == "项目不存在" + + +def test_list_other_users_project_characters_returns_404(auth_client, auth_client_b): + """用户 B 不能列出用户 A 项目下的角色。""" + project = _create_project(auth_client) + auth_client.post("/characters", json=_payload(project["id"])) + + resp = auth_client_b.get("/characters", params={"project_id": project["id"]}) + + assert resp.json()["code"] == 404 + assert resp.json()["message"] == "项目不存在" + + +def test_get_other_users_character_returns_404(auth_client, auth_client_b): + """用户 B 不能查看用户 A 的角色。""" + project = _create_project(auth_client) + created = auth_client.post( + "/characters", json=_payload(project["id"]), + ).json()["data"] + + resp = auth_client_b.get(f"/characters/{created['id']}") + + assert resp.json()["code"] == 404 + assert resp.json()["message"] == "角色不存在" + + +def test_update_other_users_character_returns_404(auth_client, auth_client_b): + """用户 B 不能修改用户 A 的角色。""" + project = _create_project(auth_client) + created = auth_client.post( + "/characters", json=_payload(project["id"]), + ).json()["data"] + + resp = auth_client_b.patch( + f"/characters/{created['id']}", json={"name": "黑化"}, + ) + + assert resp.json()["code"] == 404 + assert resp.json()["message"] == "角色不存在" + + +def test_delete_other_users_character_returns_404(auth_client, auth_client_b): + """用户 B 不能删除用户 A 的角色。""" + project = _create_project(auth_client) + created = auth_client.post( + "/characters", json=_payload(project["id"]), + ).json()["data"] + + resp = auth_client_b.delete(f"/characters/{created['id']}") + + assert resp.json()["code"] == 404 + assert resp.json()["message"] == "角色不存在" diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py index 5ee2aa39..957ddb75 100644 --- a/backend/tests/test_project_api.py +++ b/backend/tests/test_project_api.py @@ -5,15 +5,10 @@ ``timestamp`` 默认省略)与 400/404 业务码路径。 """ -import pytest - -pytestmark = pytest.mark.skip(reason="project router 未实现,待后续补全") - def _payload(**overrides): """构造合法的创建请求体(对齐 ``ProjectCreate``)。""" base = { - "user_id": 10001, "project_name": "像素游戏", "character_perspective": 1, "directional_movement": 2, @@ -92,18 +87,17 @@ def test_list_empty(auth_client): assert body["page_size"] == 20 -def test_list_paginates_and_filters(auth_client): +def test_list_paginates(auth_client): for i in range(3): - auth_client.post("/projects", json=_payload(user_id=10001, project_name=f"a{i}")) - auth_client.post("/projects", json=_payload(user_id=20002, project_name="other")) + auth_client.post("/projects", json=_payload(project_name=f"a{i}")) - resp = auth_client.get("/projects", params={"page": 1, "page_size": 2, "user_id": 10001}) + resp = auth_client.get("/projects", params={"page": 1, "page_size": 2}) body = resp.json() assert body["total"] == 3 assert len(body["data"]) == 2 assert [item["project_name"] for item in body["data"]] == ["a2", "a1"] - assert all(item["user_id"] == 10001 for item in body["data"]) + assert all(item["user_id"] == 1 for item in body["data"]) # -- DELETE /projects/{id} ---------------------------------------------------