Skip to content
Open
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 @@ -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
Expand Down
24 changes: 17 additions & 7 deletions backend/packages/app/src/windup_app/server/project/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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),
)
12 changes: 0 additions & 12 deletions backend/packages/app/src/windup_app/server/project/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
10 changes: 0 additions & 10 deletions backend/packages/app/src/windup_app/server/project/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
110 changes: 15 additions & 95 deletions backend/packages/app/src/windup_app/web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -158,89 +158,22 @@ 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,
request: Request,
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])
Expand Down Expand Up @@ -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="修改成功")


Expand Down
Original file line number Diff line number Diff line change
@@ -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;
76 changes: 14 additions & 62 deletions backend/tests/test_integrity_not_misdiagnosed.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""完整性错误不许一律冒充「项目名称已存在」
"""项目写入的完整性错误必须原样暴露

拦的坏例:某一列是 NOT NULL 且无默认值,INSERT 不点名它就违约 → `IntegrityError`
→ 被当成并发重名。用户看到「项目名称已存在」,而那个名字是全新的;日志里刷
Expand All @@ -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 上。"""
Expand All @@ -26,60 +25,23 @@ 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:
return IntegrityError("stmt", {}, _Orig(text, sqlstate))


def test_creating_a_project_does_not_call_a_not_null_violation_a_duplicate(
auth_client, monkeypatch,
auth_client,
monkeypatch,
):
"""拦的坏例:建项目时 NOT NULL 违约被报成「项目名称已存在」。

Expand All @@ -93,15 +55,17 @@ 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):
auth_client.post("/projects", json=_new_project("全新的名字-不可能重复"))


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
Expand All @@ -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
Loading
Loading