d8358a6816
- delete_account 端点: 有可提现现金余额 / 在审提现单(reviewing)时拒绝注销(409), 防余额锁死在 deleted 行无法退出; pending 提现已在途不拦(对账 worker 不依赖 user.status) - soft_delete_account: 注销连带清 wechat_openid/nickname/avatar(释放微信绑定槽, 否则该微信永久无法再被任何账号绑定→提现通道锁死) + invite_code(释放邀请码唯一槽) - wallet 新增 get_cash_balance_cents(只读, 不建账户) 供注销前置校验; 复用 has_reviewing_withdraw - 新增 tests/test_delete_account.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
4.6 KiB
Python
138 lines
4.6 KiB
Python
"""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_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)
|
|
|
|
|
|
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_<id>` 释放手机号唯一约束,允许同号码重新注册成全新账号;
|
|
清空 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()
|