feat(user): username 对外展示 + 默认昵称 + 存量回填
代提工作区既有的他人在制品(非本次 CPS);CPS 迁移链 cps_tables 依赖其 b3f1a2c4d5e6 迁移,需一并提交。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+13
-5
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user