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 09425e92..a9c7e435 100644 --- a/backend/packages/app/src/windup_app/server/project/interface.py +++ b/backend/packages/app/src/windup_app/server/project/interface.py @@ -36,12 +36,6 @@ def create_project(self, session: Session, **fields) -> Project: :class:`Project` 后持久化。 """ - @abstractmethod - def project_name_exists( - self, session: Session, *, user_id: int, project_name: str - ) -> bool: - """判断用户下的项目名称是否已存在。""" - @abstractmethod def get_project( self, session: Session, project_id: int, *, for_update: bool = False 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 669f301c..7f81fca6 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,16 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, Boolean, DateTime, Integer, SmallInteger, String, Text, UniqueConstraint, true +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + Integer, + SmallInteger, + String, + Text, + true, +) from sqlalchemy.orm import Mapped, mapped_column from windup_framework.db import Base @@ -12,10 +21,6 @@ class Project(Base): """项目表,保存生成所需的全局约束。""" __tablename__ = "windup_project" - __table_args__ = ( - UniqueConstraint("user_id", "project_name", name="uq_windup_project_user_name"), - ) - # Postgres 上 BigInteger 自增;variant 到 Integer 让 SQLite(测试库)走 # INTEGER PRIMARY KEY 自增(SQLite 仅对该声明自动分配 rowid)。 id: Mapped[int] = mapped_column( @@ -36,8 +41,13 @@ class Project(Base): ) sprite_sample_url: Mapped[str | None] = mapped_column(Text, nullable=True) create_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), ) update_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), ) diff --git a/backend/packages/app/src/windup_app/server/project/naming.py b/backend/packages/app/src/windup_app/server/project/naming.py index 10aae900..78198ad6 100644 --- a/backend/packages/app/src/windup_app/server/project/naming.py +++ b/backend/packages/app/src/windup_app/server/project/naming.py @@ -40,15 +40,3 @@ def resolve_project_name( if cleaned_description: return _clip(cleaned_description) return FALLBACK_NAME - - -def numbered_project_name(base_name: str, sequence: int) -> str: - """为重名自动标题追加可读编号,同时守住数据库 20 字上限。""" - if sequence <= 1: - return _clip(base_name) - suffix = f" {sequence}" - max_base_length = NAME_MAX_LEN - len(suffix) - characters = list(base_name) - if len(characters) > max_base_length: - characters = characters[: max_base_length - 1] + ["…"] - return f"{''.join(characters)}{suffix}" diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py index 54e80b53..4472e6a6 100644 --- a/backend/packages/app/src/windup_app/server/project/service.py +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -49,16 +49,6 @@ 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: - 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, *, for_update: bool = False ) -> Project | None: diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py index 8eae826c..0e554a8a 100644 --- a/backend/packages/app/src/windup_app/web/api/project.py +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -25,7 +25,7 @@ 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.interface import UNSET -from windup_app.server.project.naming import numbered_project_name, resolve_project_name +from windup_app.server.project.naming import resolve_project_name from windup_app.server.project.service import service logger = logging.getLogger("windup.project.api") @@ -158,36 +158,6 @@ class ProjectListOut(ProjectOut): preview_url: str | None -#: 项目名唯一约束的名字。判别 ``IntegrityError`` 到底是不是重名,靠它而不是靠 -#: "反正这一段只可能重名"。 -_NAME_UNIQUE = "uq_windup_project_user_name" - - -def _is_duplicate_name(exc: IntegrityError) -> bool: - """这个完整性错误是不是**项目重名**。 - - 不是就返回 False,让它原样抛出去 —— 原先这两处一律当重名,于是**任何**约束违约 - 都被伪装成「项目名称已存在」。实测的形态(#676 评审):某一列是 ``NOT NULL`` 且无 - 默认值,INSERT 不点名它就违约,用户看到的是「项目名称已存在」而那个名字是全新的, - 日志里刷 100 条「创建并发重名」,真实原因不在任何一条日志里 —— - **比静默更糟:它给出一个自信且错误的诊断。** - - 判别取约束名 + 驱动的 SQLSTATE。取不到 ``orig`` 时保守当成重名:那是原先的行为, - 在判别不了的场合不改变既有语义。 - """ - orig = getattr(exc, "orig", None) - if orig is None: - return True - sqlstate = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None) - if sqlstate is not None: - # 23505 = unique_violation。非唯一约束(23502 not_null / 23503 foreign_key / - # 23514 check)一律不是重名。 - return str(sqlstate) == "23505" - # 没有 SQLSTATE 的驱动(如 SQLite)退回看文本里有没有那个约束名。 - text = f"{orig}".lower() - return _NAME_UNIQUE in text or ("unique" in text and "project_name" in text) - - @router.post("", response_model=Response[ProjectOut]) def create_project( body: ProjectCreate, @@ -195,52 +165,15 @@ def create_project( session: Session = Depends(get_session), ) -> Response[ProjectOut]: user_id = request.state.current_user.id - automatic_name = not (body.project_name or "").strip() - base_name = resolve_project_name( + project_name = resolve_project_name( body.project_name, body.name_context, service._namer ) fields = body.model_dump(exclude={"project_name", "name_context"}) fields["game_style"] = _stored_style(body.game_style) - - for sequence in range(1, 101 if automatic_name else 2): - project_name = numbered_project_name(base_name, sequence) - if service.project_name_exists( - session, user_id=user_id, project_name=project_name - ): - if automatic_name: - continue - logger.warning( - "[WINDUP] 创建拒绝-名称重复 | user_id=%s project_name=%s", - user_id, - project_name, - ) - raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) - try: - project = service.create_project( - session, user_id=user_id, project_name=project_name, **fields - ) - return Response.success( - ProjectOut.model_validate(project), message="创建成功" - ) - except IntegrityError as exc: - session.rollback() - if not _is_duplicate_name(exc): - # 不是重名就别冒充重名。原样抛出去,让它以 500 + 真实堆栈出现 —— - # 一个查得到原因的 500,好过一个查不到原因的 400。 - logger.exception( - "[WINDUP] 创建项目完整性错误(非重名) | user_id=%s project_name=%s", - user_id, - project_name, - ) - raise - logger.warning( - "[WINDUP] 创建并发重名 | user_id=%s project_name=%s", - user_id, - project_name, - ) - if not automatic_name: - break - raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) + project = service.create_project( + session, user_id=user_id, project_name=project_name, **fields + ) + return Response.success(ProjectOut.model_validate(project), message="创建成功") @router.get("", response_model=ListResponse[ProjectListOut]) @@ -297,28 +230,15 @@ def update_project( if project is None or project.user_id != user_id: raise BizException("项目不存在", code=BizCode.NOT_FOUND) rename_to = body.project_name if body.project_name != project.project_name else None - if rename_to is not None and service.project_name_exists( - session, user_id=user_id, project_name=rename_to - ): - raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) - try: - project = service.update_project( - session, - project, - project_name=rename_to, - game_style=( - UNSET if body.game_style is None else _stored_style(body.game_style) - ), - auto_pixelate=(UNSET if body.auto_pixelate is None else body.auto_pixelate), - ) - except IntegrityError as exc: - session.rollback() - if not _is_duplicate_name(exc): - logger.exception( - "[WINDUP] 改项目完整性错误(非重名) | project_id=%s", project_id, - ) - raise - raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None + project = service.update_project( + session, + project, + project_name=rename_to, + game_style=( + UNSET if body.game_style is None else _stored_style(body.game_style) + ), + auto_pixelate=(UNSET if body.auto_pixelate is None else body.auto_pixelate), + ) return Response.success(ProjectOut.model_validate(project), message="修改成功") diff --git a/backend/scripts/migrations/20260829_drop_project_name_unique.sql b/backend/scripts/migrations/20260829_drop_project_name_unique.sql new file mode 100644 index 00000000..8e051fed --- /dev/null +++ b/backend/scripts/migrations/20260829_drop_project_name_unique.sql @@ -0,0 +1,8 @@ +-- 项目名称是显示字段,路由与关联始终使用 project.id;同一用户允许多个同名项目。 +-- DROP CONSTRAINT IF EXISTS 使迁移可以安全重复运行。 +BEGIN; + +ALTER TABLE windup_project + DROP CONSTRAINT IF EXISTS uq_windup_project_user_name; + +COMMIT; diff --git a/backend/tests/test_integrity_not_misdiagnosed.py b/backend/tests/test_integrity_not_misdiagnosed.py index 61d19ce0..7a62850b 100644 --- a/backend/tests/test_integrity_not_misdiagnosed.py +++ b/backend/tests/test_integrity_not_misdiagnosed.py @@ -1,4 +1,4 @@ -"""完整性错误不许一律冒充「项目名称已存在」。 +"""项目写入的完整性错误必须原样暴露。 拦的坏例:某一列是 NOT NULL 且无默认值,INSERT 不点名它就违约 → `IntegrityError` → 被当成并发重名。用户看到「项目名称已存在」,而那个名字是全新的;日志里刷 @@ -10,13 +10,12 @@ 实测是 `smallint NOT NULL default=(无默认)`,而同表的 `auto_pixelate` 是有 `server_default` 的,说明本仓知道该怎么写。 """ + from __future__ import annotations import pytest from sqlalchemy.exc import IntegrityError -from windup_app.web.api.project import _NAME_UNIQUE, _is_duplicate_name - class _Orig(Exception): """模拟 DBAPI 的原始异常。psycopg 把 SQLSTATE 放在 sqlstate/pgcode 上。""" @@ -26,52 +25,14 @@ def __init__(self, text: str, sqlstate: str | None = None): self.sqlstate = sqlstate -def _err(text: str, sqlstate: str | None = None) -> IntegrityError: - return IntegrityError("stmt", {}, _Orig(text, sqlstate)) - - -def test_a_unique_violation_is_still_treated_as_a_duplicate_name(): - """正向:真重名照旧返回 400「项目名称已存在」,行为不变。""" - assert _is_duplicate_name(_err(f'duplicate key value violates "{_NAME_UNIQUE}"', "23505")) - - -@pytest.mark.parametrize( - "sqlstate,label", - [("23502", "not_null"), ("23503", "foreign_key"), ("23514", "check")], -) -def test_a_non_unique_violation_is_not_called_a_duplicate_name(sqlstate, label): - """拦的坏例:NOT NULL / 外键 / CHECK 违约被伪装成重名。 - - 23502 正是 #676 那个形态:列删了但库里还是 NOT NULL 无默认。 - """ - assert not _is_duplicate_name( - _err(f'null value in column "character_perspective" violates {label}', sqlstate) - ) - - -def test_a_driver_without_sqlstate_falls_back_to_the_constraint_name(): - """SQLite 之类没有 SQLSTATE 的驱动,退回看约束名。""" - assert _is_duplicate_name(_err(f"UNIQUE constraint failed: {_NAME_UNIQUE}")) - assert not _is_duplicate_name(_err("NOT NULL constraint failed: windup_project.foo")) - - -def test_an_error_without_orig_keeps_the_old_behaviour(): - """判别不了就保守当重名 —— 那是原先的行为,不在判别不了的场合改变语义。""" - e = IntegrityError("stmt", {}, None) - assert _is_duplicate_name(e) - - -# ── 判别函数被用上了吗 ──────────────────────────────────────────────────── -# -# 上面几条测的是 `_is_duplicate_name` 本身。但它可以完美地正确、而两个调用点 -# 一个都没接 —— 实测:把 `if not _is_duplicate_name(exc)` 拆掉,全量 2008 条测试 -# 依然全绿。判别对了没人用,和没判别是一回事。 - - def _new_project(name: str) -> dict: """建项目的最小合法请求体。缺必填字段会被 422 挡住,那时测的就不是本文件要测的东西了。""" - return {"project_name": name, "directional_movement": 1, - "sprite_width": 256, "sprite_height": 256} + return { + "project_name": name, + "directional_movement": 1, + "sprite_width": 256, + "sprite_height": 256, + } def _fake_integrity(sqlstate: str, text: str) -> IntegrityError: @@ -79,7 +40,8 @@ def _fake_integrity(sqlstate: str, text: str) -> IntegrityError: def test_creating_a_project_does_not_call_a_not_null_violation_a_duplicate( - auth_client, monkeypatch, + auth_client, + monkeypatch, ): """拦的坏例:建项目时 NOT NULL 违约被报成「项目名称已存在」。 @@ -93,7 +55,8 @@ def test_creating_a_project_does_not_call_a_not_null_violation_a_duplicate( def _boom(*a, **k): raise _fake_integrity( - "23502", 'null value in column "character_perspective" violates not-null') + "23502", 'null value in column "character_perspective" violates not-null' + ) monkeypatch.setattr(papi.service, "create_project", _boom) with pytest.raises(IntegrityError): @@ -101,7 +64,8 @@ def _boom(*a, **k): def test_renaming_a_project_does_not_call_other_violations_a_duplicate( - auth_client, monkeypatch, + auth_client, + monkeypatch, ): """改名那一处是第二个调用点。两处都要接 —— 只接一处等于漏了一半。""" from windup_app.web.api import project as papi @@ -119,15 +83,3 @@ def _boom(*a, **k): monkeypatch.setattr(papi.service, "update_project", _boom) with pytest.raises(IntegrityError): auth_client.patch(f"/projects/{pid}", json={"project_name": "新名"}) - - -def test_a_real_duplicate_still_gets_the_friendly_400(auth_client): - """反向对照:**真**重名仍然是那句友好的 400,行为一个字不变。 - - 没有这一条的话,把两个调用点的判别整个删掉(全部原样抛出)也能让上面两条绿 —— - 而那会把一个用户改得动的错变成 500。 - """ - body = _new_project("同名项目") - assert auth_client.post("/projects", json=body).json().get("code") == 200 - again = auth_client.post("/projects", json=body).json() - assert again.get("message") == "项目名称已存在", again diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py index 8f12a933..062e6ebd 100644 --- a/backend/tests/test_project_api.py +++ b/backend/tests/test_project_api.py @@ -119,7 +119,7 @@ def test_create_with_explicit_name_skips_project_namer(auth_client): assert project_api.service._namer.calls == [] -def test_create_automatic_duplicate_uses_readable_sequence(auth_client): +def test_create_automatic_duplicate_keeps_the_same_display_name(auth_client): project_api.service._namer = _FakeProjectNamer(result="雾" * 20) payload = _payload(project_name=None, name_context="同一份角色描述") @@ -127,8 +127,8 @@ def test_create_automatic_duplicate_uses_readable_sequence(auth_client): second = auth_client.post("/projects", json=payload).json() assert first["data"]["project_name"] == "雾" * 20 - assert second["data"]["project_name"] == f"{'雾' * 17}… 2" - assert len(second["data"]["project_name"]) == 20 + assert second["data"]["project_name"] == "雾" * 20 + assert second["data"]["id"] != first["data"]["id"] def test_create_namer_failure_falls_back_to_description(auth_client): @@ -154,15 +154,14 @@ def test_create_without_name_or_context_uses_unnamed_fallback(auth_client): assert resp.json()["data"]["project_name"] == "未命名项目" -def test_create_duplicate_name_returns_400(auth_client): - auth_client.post("/projects", json=_payload(project_name="重名")) - resp = auth_client.post("/projects", json=_payload(project_name="重名")) +def test_create_duplicate_display_names_returns_distinct_projects(auth_client): + first = auth_client.post("/projects", json=_payload(project_name="重名")).json() + second = auth_client.post("/projects", json=_payload(project_name="重名")).json() - assert resp.status_code == 200 - body = resp.json() - assert body["code"] == 400 - assert body["message"] == "项目名称已存在" - assert body["data"] is None + assert first["code"] == 200 + assert second["code"] == 200 + assert first["data"]["project_name"] == second["data"]["project_name"] == "重名" + assert first["data"]["id"] != second["data"]["id"] def test_create_validation_error_returns_400(auth_client): @@ -363,8 +362,10 @@ def test_rename_success_persists_the_new_name(auth_client): assert persisted["project_name"] == "重命名后" -def test_rename_duplicate_name_returns_400(auth_client): - auth_client.post("/projects", json=_payload(project_name="已存在")) +def test_rename_to_an_existing_display_name_is_allowed(auth_client): + existing = auth_client.post( + "/projects", json=_payload(project_name="已存在") + ).json()["data"] created = auth_client.post( "/projects", json=_payload(project_name="待修改") ).json()["data"] @@ -373,10 +374,14 @@ def test_rename_duplicate_name_returns_400(auth_client): f"/projects/{created['id']}", json={"project_name": "已存在"} ) - assert resp.json()["code"] == 400 - assert resp.json()["message"] == "项目名称已存在" + assert resp.json()["code"] == 200 + assert resp.json()["data"]["project_name"] == "已存在" persisted = auth_client.get(f"/projects/{created['id']}").json()["data"] - assert persisted["project_name"] == "待修改" + assert persisted["project_name"] == "已存在" + assert ( + auth_client.get(f"/projects/{existing['id']}").json()["data"]["project_name"] + == "已存在" + ) def test_rename_rejects_another_users_project(auth_client, auth_client_b): diff --git a/frontend/src/pages/project-create/index.tsx b/frontend/src/pages/project-create/index.tsx index 50a3364b..b1dfed3e 100644 --- a/frontend/src/pages/project-create/index.tsx +++ b/frontend/src/pages/project-create/index.tsx @@ -158,9 +158,7 @@ export function ProjectCreatePage() { onChange={(event) => setName(event.target.value)} className="rounded-xl border border-app-line bg-app-surface px-4 py-3 text-sm outline-none focus-visible:border-app-accent" /> - - 最多 {NAME_MAX_LENGTH} 个字,同一账号下不能重名。 - + 最多 {NAME_MAX_LENGTH} 个字。