diff --git a/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py b/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py new file mode 100644 index 0000000..ba76a60 --- /dev/null +++ b/alembic/versions/b3f1a2c4d5e6_user_username_and_default_nickname.py @@ -0,0 +1,79 @@ +"""user.username(对外展示账号 ID)+ 默认昵称,并回填存量用户 + +Revision ID: b3f1a2c4d5e6 +Revises: 45047b5a884c +Create Date: 2026-06-16 12:00:00.000000 + +加 user.username(11 位纯数字、首位非 1 与手机号天然区分、全局唯一)。存量用户: +生成唯一 username,昵称为空的补 9 位字母数字随机昵称。 +迁移自包含(不 import app 业务代码,逻辑冻结为当时快照)。 +""" +import secrets +import string +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b3f1a2c4d5e6' +down_revision: Union[str, Sequence[str], None] = '45047b5a884c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_USERNAME_FIRST = "23456789" # 首位避前导 0、避 1(手机号都以 1 开头) +_USERNAME_DIGITS = "0123456789" +_NICKNAME_ALPHABET = string.ascii_letters + string.digits + + +def _gen_username() -> str: + return secrets.choice(_USERNAME_FIRST) + "".join( + secrets.choice(_USERNAME_DIGITS) for _ in range(10) + ) + + +def _gen_nickname() -> str: + return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(9)) + + +def upgrade() -> None: + # 1. 先加可空列(存量行此刻还没值) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('username', sa.String(length=11), nullable=True)) + + # 2. 回填存量:每人一个唯一 username;昵称为空的补随机昵称。 + # 表里 username 原本全空,本批用 used 防互撞即可(无既有非空值需规避)。 + # "user" 是 PostgreSQL 保留字,必须加双引号(SQLite 也接受双引号标识符)。 + bind = op.get_bind() + rows = bind.execute(sa.text('SELECT id, nickname FROM "user"')).fetchall() + used = set() + for row in rows: + uid, nick = row[0], row[1] + while True: + uname = _gen_username() + if uname not in used: + used.add(uname) + break + if nick is None or not str(nick).strip(): + bind.execute( + sa.text('UPDATE "user" SET username = :u, nickname = :n WHERE id = :i'), + {"u": uname, "n": _gen_nickname(), "i": uid}, + ) + else: + bind.execute( + sa.text('UPDATE "user" SET username = :u WHERE id = :i'), + {"u": uname, "i": uid}, + ) + + # 3. 收紧:NOT NULL + 唯一索引(对齐模型 unique=True, index=True) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.alter_column('username', existing_type=sa.String(length=11), nullable=False) + batch_op.create_index('ix_user_username', ['username'], unique=True) + + +def downgrade() -> None: + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index('ix_user_username') + batch_op.drop_column('username') diff --git a/app/models/user.py b/app/models/user.py index 67d6600..1161ba1 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -27,6 +27,10 @@ class User(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) phone: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False) + # 对外展示的账号 ID:11 位纯数字、首位非 1(与手机号天然区分——手机号都以 1 开头)、全局唯一、 + # 创建时随机生成、不可变、不参与登录(登录仍走 phone)。生成见 repositories/user._gen_username。 + username: Mapped[str] = mapped_column(String(11), unique=True, index=True, nullable=False) + # 注册渠道:jverify / sms。后续加 wechat / apple 时扩 register_channel: Mapped[str] = mapped_column(String(20), nullable=False, default="jverify") diff --git a/app/repositories/user.py b/app/repositories/user.py index 697d7e8..33c53ae 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +import secrets +import string from datetime import datetime, timezone from sqlalchemy import select @@ -12,6 +14,50 @@ from sqlalchemy.orm import Session from app.models.user import User +# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 ===== + +# 用户名 = 11 位纯数字,首位 2-9(避前导 0、避 1 与手机号区分),后 10 位 0-9。 +# 空间 8×10^10,创建时随机生成 + 查重去重,全局唯一、不可变、不参与登录。 +_USERNAME_FIRST = "23456789" +_USERNAME_DIGITS = "0123456789" +_USERNAME_LEN = 11 + +# 默认昵称 = 9 位大小写字母 + 数字随机串(创建时给,用户可后续改;不要求唯一)。 +_NICKNAME_ALPHABET = string.ascii_letters + string.digits +_NICKNAME_LEN = 9 + + +def _gen_username() -> str: + """随机一个 11 位纯数字、首位非 0/1 的用户名(不保证唯一,唯一性由 _gen_unique_username 兜)。""" + return secrets.choice(_USERNAME_FIRST) + "".join( + secrets.choice(_USERNAME_DIGITS) for _ in range(_USERNAME_LEN - 1) + ) + + +def _gen_nickname() -> str: + """随机一个 9 位字母+数字的默认昵称。""" + return "".join(secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN)) + + +def get_user_by_username(db: Session, username: str) -> User | None: + return db.execute( + select(User).where(User.username == username) + ).scalar_one_or_none() + + +def _gen_unique_username(db: Session) -> str: + """生成一个库里尚不存在的用户名(仿 invite.ensure_code 的碰撞重试)。 + + 8×10^10 空间下碰撞极罕见,查重 + 重试足够;并发下的残留碰撞由 username 唯一约束 + 兜底(commit 抛 IntegrityError,与现有 phone 并发同号同级别,概率 ~ 用户数/8e10)。 + """ + for _ in range(8): + uname = _gen_username() + if get_user_by_username(db, uname) is None: + return uname + raise RuntimeError("生成用户名连续碰撞,请重试") + + def get_user_by_id(db: Session, user_id: int) -> User | None: return db.get(User, user_id) @@ -36,6 +82,8 @@ def upsert_user_for_login( if user is None: user = User( phone=phone, + username=_gen_unique_username(db), + nickname=_gen_nickname(), register_channel=register_channel, last_login_at=now, ) diff --git a/app/schemas/auth.py b/app/schemas/auth.py index a5eb077..600baaf 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -18,6 +18,8 @@ class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) # 允许直接 UserOut.model_validate(orm_user) id: int + # 对外展示的账号 ID:11 位纯数字、首位非 1、全局唯一、创建时分配、不可变。区别于登录用的 phone。 + username: str phone: str nickname: str | None = None avatar_url: str | None = None diff --git a/tests/test_auth.py b/tests/test_auth.py index 3aca97e..f9a6be1 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -206,3 +206,40 @@ def test_sms_gc_purges_stale_only(monkeypatch) -> None: assert "stale" not in sms._codes and "fresh" in sms._codes assert "old" not in sms._last_sent and "recent" in sms._last_sent assert "yesterday" not in sms._daily_count and "today" in sms._daily_count + + +# ============================ 用户名 / 默认昵称 ============================ + +def test_login_assigns_username_and_nickname(client) -> None: + """新用户创建即分配:11 位纯数字 username(首位非 0/1,与手机号天然区分)+ 9 位字母数字默认昵称。""" + phone = "13600136000" + client.post("/api/v1/auth/sms/send", json={"phone": phone}) + r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"}) + assert r.status_code == 200, r.text + user = r.json()["user"] + + uname = user["username"] + assert uname.isdigit() and len(uname) == 11 # 11 位纯数字 + assert uname[0] not in ("0", "1") # 无前导 0、与手机号(均以 1 开头)区分 + + nick = user["nickname"] + assert nick and len(nick) == 9 and nick.isalnum() # 9 位字母+数字 + + +def test_username_stable_and_unique_on_relogin() -> None: + """同号重登 username 不变(不可变标识);不同号 username 不同(唯一)。 + 直连 repository 绕开 HTTP 短信冷却。""" + from app.db.session import SessionLocal + from app.repositories import user as user_repo + + db = SessionLocal() + try: + a1 = user_repo.upsert_user_for_login(db, phone="13522220001", register_channel="sms") + uname_a, id_a = a1.username, a1.id + a2 = user_repo.upsert_user_for_login(db, phone="13522220001", register_channel="sms") + assert a2.id == id_a and a2.username == uname_a # 重登:同一行、username 稳定不变 + + b = user_repo.upsert_user_for_login(db, phone="13522220002", register_channel="sms") + assert b.username != uname_a # 不同用户、username 唯一 + finally: + db.close() diff --git a/tests/test_invite.py b/tests/test_invite.py index 574adad..a578c6a 100644 --- a/tests/test_invite.py +++ b/tests/test_invite.py @@ -300,7 +300,7 @@ def test_bind_no_code_no_fingerprint(client) -> None: # ===================================================================== def test_invitees_basic(client) -> None: - """A 邀 B、C → 列表返 2 条、total=2、没设资料的名字=脱敏手机号、头像 null、金币对。""" + """A 邀 B、C → 列表返 2 条、total=2、名字=被邀请人默认昵称(创建即有)、头像 null、金币对。""" a = _login(client, "13800002050") a_code = _my_code(client, a) for phone in ("13800002051", "13800002052"): @@ -313,9 +313,14 @@ def test_invitees_basic(client) -> None: assert body["total"] == 2 assert body["has_more"] is False assert len(body["items"]) == 2 - # 没设昵称头像 → 名字脱敏手机号、头像 null(不依赖顺序用 set) + # 创建即分配默认昵称 → 名字=被邀请人昵称(取实际值对比)、头像 null(不依赖顺序用 set) + with SessionLocal() as db: + expected_names = { + get_user_by_phone(db, "13800002051").nickname, + get_user_by_phone(db, "13800002052").nickname, + } names = {it["display_name"] for it in body["items"]} - assert names == {"138****2051", "138****2052"} + assert names == expected_names assert all(it["avatar_url"] is None for it in body["items"]) assert all(it["coins"] == INVITE_INVITER_COINS for it in body["items"]) @@ -336,9 +341,12 @@ def test_invitees_order_desc(client) -> None: ).scalar_one() rel_b.created_at = datetime.now(timezone.utc) - timedelta(hours=2) db.commit() + nick_b = ub.nickname + nick_c = get_user_by_phone(db, "13800002062").nickname items = client.get("/api/v1/invite/invitees", headers=_auth(a)).json()["items"] - assert items[0]["display_name"] == "138****2062" # C 刚邀,在前 - assert items[1]["display_name"] == "138****2061" # B 2 小时前,在后 + # 创建即有默认昵称 → display_name=被邀请人昵称;本用例核心验证倒序(C 刚邀在前、B 2h 前在后) + assert items[0]["display_name"] == nick_c + assert items[1]["display_name"] == nick_b def test_invitees_nickname_priority(client) -> None: