"""user 表的 CRUD 工具函数。 业务策略 = "注册即登录":phone 不存在则 insert,存在则更新 last_login_at。 """ from __future__ import annotations import secrets import string from datetime import datetime, timezone from sqlalchemy import select 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_PREFIX = "用户" _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 _NICKNAME_PREFIX + "".join( secrets.choice(_NICKNAME_ALPHABET) for _ in range(_NICKNAME_LEN) ) def is_default_nickname(nickname: str | None) -> bool: """是否为创建时自动分配的默认昵称(= "用户" + 9 位字母数字,见 [_gen_nickname])。 这类不是用户主动设置的昵称,展示脱敏时按「无昵称」处理(走 id 规则,见 ops_marquee._mask_real)。 精确匹配生成格式(前缀 + 定长字母数字集),不误伤真人以「用户」开头的昵称(如「用户体验师」含汉字、 长度也不符)。用户改过昵称即不再匹配。""" if not nickname: return False s = nickname.strip() return ( len(s) == len(_NICKNAME_PREFIX) + _NICKNAME_LEN and s.startswith(_NICKNAME_PREFIX) and all(c in _NICKNAME_ALPHABET for c in s[len(_NICKNAME_PREFIX):]) ) 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) def get_user_by_phone(db: Session, phone: str) -> User | None: stmt = select(User).where(User.phone == phone) return db.execute(stmt).scalar_one_or_none() def upsert_user_for_login( db: Session, *, phone: str, register_channel: str, ) -> User: """登录入口统一调:有则更新 last_login_at,无则新建。 返回 commit 后的 user。调用方拿到 user.id 直接签 JWT。 """ user = get_user_by_phone(db, phone) now = datetime.now(timezone.utc) if user is None: user = User( phone=phone, username=_gen_unique_username(db), nickname=_gen_nickname(), register_channel=register_channel, last_login_at=now, ) db.add(user) else: user.last_login_at = now # 被禁用的账号被尝试登录时,不自动启用,直接抛(在调用方处理) db.commit() db.refresh(user) return user def update_nickname(db: Session, user: User, *, nickname: str) -> User: user.nickname = nickname db.commit() db.refresh(user) return user def set_avatar_url(db: Session, user: User, *, avatar_url: str) -> User: user.avatar_url = avatar_url db.commit() db.refresh(user) return user def soft_delete_account(db: Session, user: User) -> None: """注销账号:软删除 + 匿名化 + 释放唯一约束/外部绑定。 把 phone 改成 `deleted_` 释放手机号唯一约束,允许同号码重新注册成全新账号; 清空 PII(昵称/头像),保留行用于审计。status=deleted 后将无法再登录该行 (登录接口校验 status==active)。 ⚠️ 注销必须连同释放 user 上其余唯一约束/外部身份,否则 deleted 行永久占着槽位, 对应资源再也无法被复用: - wechat_openid: 不清 → 这个微信再也无法被任何账号绑定(提现通道彻底锁死); - invite_code: 不清 → 邀请码槽位被占 + 裸查仍命中死号(发奖已被 bind 的 status==active 校验兜住,清掉更干净)。 资金安全(未提现现金 / 在审提现单)由调用方 delete_account 端点前置校验,这里只匿名化。 """ user.status = "deleted" user.phone = f"deleted_{user.id}" user.nickname = None user.avatar_url = None # 等价"解绑微信":释放 openid 唯一槽 + 清微信昵称头像 user.wechat_openid = None user.wechat_nickname = None user.wechat_avatar_url = None # 释放邀请码唯一槽 user.invite_code = None db.commit()