061f6baaf1
## 背景 / 需求 新增「微信登录」链路:用户用微信授权登录 App。openid 已绑账号即直接登入; 未绑则走「绑手机号」建号,并处理「手机号已被别的账号占用」的冲突(M2), 以及绑微信后展示昵称/头像的替换策略(M3 §10)。 关键取舍:微信登录**不套用**钱包提现里 bind-wechat 的「撞号即 409」逻辑 ——登录场景 openid 命中就应登入,不能因撞号把用户挡在门外。 ## 改动概览:新增 5 个端点(前缀 `/api/v1/auth`) | 方法 | 路径 | 作用 | |---|---|---| | POST | `/wechat-login` | code→openid。命中已绑用户→直接登入;未命中→签发 `bind_ticket`,返回 `need_bind_phone`(**此刻不建号**);未配 APP_ID/SECRET→503 | | POST | `/wechat/bind-phone/sms` | 持 `bind_ticket` + 手机号 + 短信码绑号 | | POST | `/wechat/bind-phone/jverify` | 持 `bind_ticket` + 极光本机号一键取号绑号 | | POST | `/wechat/conflict/continue` | 占用冲突·继续绑定=**登录老账号**(老号没绑微信则把 openid 绑上;已绑别的微信则只登入、丢弃本次 openid) | | POST | `/wechat/conflict/rebind` | 占用冲突·换绑=**单事务**注销老账号 X(腾号)+ 用该号重建新账号 Y + 写换绑台账;受 30 天限制 | 绑号两条取号路径(sms / jverify)尾段共用 `_finish_wechat_bind`: 手机号未注册→建微信账号(channel=wechat,昵称头像取微信)登入; 已被占用→返回 `phone_occupied`(带占用账号昵称/头像/注册时间/`has_wechat`、 `conflict_ticket`、`rebind_available`、`rebind_blocked_days`),交前端冲突页。 冲突页三选一,后端提供 continue / rebind 两个动作(「取消」为前端本地行为)。 ## 关键设计 **两种短时令牌(`core/security.py`,复用 `JWT_SECRET_KEY`,靠 `typ` 区分):** - `bind_ticket`(typ=wechat_bind,sub=openid,附微信昵称/头像,TTL 10min): openid 未命中时下发,覆盖「授权→输手机号→收码→验码」整个绑定流程。 - `conflict_ticket`(typ=wechat_conflict):比 bind_ticket **多编码已验证的手机号**; 换绑 / 继续绑定只认票里的 phone,堵住「拿别人手机号去夺号」的接管漏洞。 **30 天换绑限制:** 新增台账表 `phone_rebind_log`(手机号级、渠道无关), 记录「腾号重建」这一破坏性事件,靠 `rebound_at` 算窗口; 阈值走配置 `PHONE_REBIND_LIMIT_DAYS`。命中限制的换绑请求返回 409。 **M3 §10 昵称/头像替换:** 绑微信默认用微信昵称/头像替换展示, 抽成共享 helper,登录绑号路径与钱包绑微信路径两处共用 (故 `wallet.py`、`tests/test_withdraw.py` 一并有改动)。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #139
289 lines
10 KiB
Python
289 lines
10 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
|
|
from app.repositories import phone_rebind
|
|
|
|
|
|
# ===== 创建时分配的标识:用户名(对外展示账号 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 apply_wechat_display_identity(
|
|
user: User, *, wechat_nickname: str | None, wechat_avatar_url: str | None
|
|
) -> None:
|
|
"""§10:用已有账号绑微信时,仅当展示字段仍为默认才用微信昵称/头像替换(两规则独立);
|
|
自定义(改过昵称/传过头像)则保留。只改内存对象,由调用方 commit。"""
|
|
if is_default_nickname(user.nickname) and wechat_nickname:
|
|
user.nickname = wechat_nickname
|
|
if user.avatar_url is None and wechat_avatar_url:
|
|
user.avatar_url = wechat_avatar_url
|
|
|
|
|
|
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 get_user_by_wechat_openid(db: Session, openid: str) -> User | None:
|
|
stmt = select(User).where(User.wechat_openid == openid)
|
|
return db.execute(stmt).scalar_one_or_none()
|
|
|
|
|
|
def touch_last_login(db: Session, user: User) -> User:
|
|
"""openid 命中登录时更新 last_login_at(手机号登录在 upsert_user_for_login 里已更新)。"""
|
|
user.last_login_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
def attach_wechat_to_user(
|
|
db: Session, user: User, *, openid: str, wechat_nickname: str | None, wechat_avatar_url: str | None
|
|
) -> User:
|
|
"""继续绑定:把微信 openid + 微信源字段并入已存在账号(调用方保证 user.wechat_openid 为空)。
|
|
|
|
写 wechat_openid / wechat_nickname / wechat_avatar_url,并按 §10 规则回填展示字段:
|
|
仅当昵称仍为默认值(is_default_nickname)时用微信昵称替换,仅当头像为 null 时用微信头像替换;
|
|
用户已自定义的展示昵称/头像始终保留,两规则相互独立。
|
|
撞 openid 唯一约束(O 期间被别处绑走,极罕见)时由调用方捕获 IntegrityError 兜底降级为"只登入不绑"。
|
|
"""
|
|
user.wechat_openid = openid
|
|
user.wechat_nickname = wechat_nickname
|
|
user.wechat_avatar_url = wechat_avatar_url
|
|
user.last_login_at = datetime.now(timezone.utc)
|
|
apply_wechat_display_identity(user, wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url)
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
def _build_wechat_user(
|
|
db: Session,
|
|
*,
|
|
phone: str,
|
|
openid: str,
|
|
wechat_nickname: str | None,
|
|
wechat_avatar_url: str | None,
|
|
) -> User:
|
|
"""构造并 db.add 一个微信账号行(register_channel='wechat',展示昵称头像取微信,缺则默认),
|
|
**不 commit**。create_wechat_user 与 rebind_account 共用,保证建号逻辑单一来源。"""
|
|
user = User(
|
|
phone=phone,
|
|
username=_gen_unique_username(db),
|
|
nickname=wechat_nickname or _gen_nickname(),
|
|
avatar_url=wechat_avatar_url,
|
|
register_channel="wechat",
|
|
wechat_openid=openid,
|
|
wechat_nickname=wechat_nickname,
|
|
wechat_avatar_url=wechat_avatar_url,
|
|
last_login_at=datetime.now(timezone.utc),
|
|
)
|
|
db.add(user)
|
|
return user
|
|
|
|
|
|
def create_wechat_user(
|
|
db: Session,
|
|
*,
|
|
phone: str,
|
|
openid: str,
|
|
wechat_nickname: str | None,
|
|
wechat_avatar_url: str | None,
|
|
) -> User:
|
|
"""微信登录新建账号(未占用分支)。见 _build_wechat_user。
|
|
|
|
openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid 在 wechat-login 刚查过为空)。
|
|
"""
|
|
user = _build_wechat_user(
|
|
db, phone=phone, openid=openid,
|
|
wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url,
|
|
)
|
|
db.commit()
|
|
db.refresh(user)
|
|
return user
|
|
|
|
|
|
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()
|
|
|
|
|
|
def rebind_account(
|
|
db: Session,
|
|
*,
|
|
phone: str,
|
|
openid: str,
|
|
wechat_nickname: str | None,
|
|
wechat_avatar_url: str | None,
|
|
source: str = "wechat_conflict",
|
|
) -> User:
|
|
"""换绑:**单事务内**注销老账号 X(腾出手机号)+ 用该号建全新微信账号 Y + 写换绑台账。
|
|
|
|
- 老账号可能已不存在(P 被腾空)→ old_user_id=None,直接建 Y(幂等更稳)。
|
|
- 手机号唯一约束靠时序:先把 X.phone 改名并 flush 腾号,再插 Y。
|
|
- 全程不中途 commit,任一步失败整体回滚,绝不出现"X 删了 Y 没建"。
|
|
X 的字段变更等价 soft_delete_account(软删 + 匿名化 + 释放 openid/邀请码唯一槽),但不在此 commit。
|
|
"""
|
|
old = get_user_by_phone(db, phone)
|
|
old_id = old.id if old is not None else None
|
|
if old is not None:
|
|
old.status = "deleted"
|
|
old.phone = f"deleted_{old.id}"
|
|
old.nickname = None
|
|
old.avatar_url = None
|
|
old.wechat_openid = None
|
|
old.wechat_nickname = None
|
|
old.wechat_avatar_url = None
|
|
old.invite_code = None
|
|
db.flush() # 先落 phone 改名,腾出手机号唯一约束,才能给 Y 用
|
|
new_user = _build_wechat_user(
|
|
db, phone=phone, openid=openid,
|
|
wechat_nickname=wechat_nickname, wechat_avatar_url=wechat_avatar_url,
|
|
)
|
|
db.flush() # 拿 new_user.id
|
|
phone_rebind.add_rebind_log(
|
|
db, phone=phone, old_user_id=old_id, new_user_id=new_user.id, source=source
|
|
)
|
|
db.commit()
|
|
db.refresh(new_user)
|
|
return new_user
|