feat(auth): 微信登录与手机号绑定 / 占用冲突处理(M2 + M3 §10) (#139)
## 背景 / 需求 新增「微信登录」链路:用户用微信授权登录 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
This commit was merged in pull request #139.
This commit is contained in:
+275
-1
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.deps import CurrentUser, DbSession
|
||||
from app.core import test_account
|
||||
@@ -22,14 +23,25 @@ from app.core.ratelimit import (
|
||||
enforce_rate_limit,
|
||||
record_rate_limits,
|
||||
)
|
||||
from app.core.security import TokenError, decode_token, issue_token_pair
|
||||
from app.core.security import (
|
||||
TokenError,
|
||||
create_bind_ticket,
|
||||
create_conflict_ticket,
|
||||
decode_bind_ticket,
|
||||
decode_conflict_ticket,
|
||||
decode_token,
|
||||
issue_token_pair,
|
||||
)
|
||||
from app.integrations import wxpay
|
||||
from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone
|
||||
from app.integrations.sms import SmsError, send_code, verify_code
|
||||
from app.repositories import onboarding as onboarding_repo
|
||||
from app.repositories import phone_rebind as rebind_repo
|
||||
from app.repositories import user as user_repo
|
||||
from app.schemas.auth import (
|
||||
JverifyLoginRequest,
|
||||
LogoutResponse,
|
||||
OccupiedAccountInfo,
|
||||
RefreshRequest,
|
||||
SmsLoginRequest,
|
||||
SmsSendRequest,
|
||||
@@ -37,6 +49,13 @@ from app.schemas.auth import (
|
||||
TokenPair,
|
||||
TokenWithUser,
|
||||
UserOut,
|
||||
WechatBindPhoneJverifyRequest,
|
||||
WechatBindPhoneSmsRequest,
|
||||
WechatBindResultResponse,
|
||||
WechatConflictContinueRequest,
|
||||
WechatConflictRebindRequest,
|
||||
WechatLoginRequest,
|
||||
WechatLoginResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("shagua.auth")
|
||||
@@ -175,6 +194,261 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit
|
||||
return _login_response(user, onboarding_completed=completed)
|
||||
|
||||
|
||||
# ===================== 微信登录 =====================
|
||||
|
||||
@router.post(
|
||||
"/wechat-login",
|
||||
response_model=WechatLoginResponse,
|
||||
summary="微信登录(openid 命中即登入,否则发绑号令牌)",
|
||||
)
|
||||
def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse:
|
||||
from app.core.config import settings # 局部 import,避免循环
|
||||
|
||||
# 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。
|
||||
if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET):
|
||||
raise HTTPException(status_code=503, detail="wechat login not configured")
|
||||
|
||||
try:
|
||||
info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
openid = info["openid"]
|
||||
user = user_repo.get_user_by_wechat_openid(db, openid)
|
||||
if user is not None:
|
||||
# openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑)
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
user_repo.touch_last_login(db, user)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed)
|
||||
return WechatLoginResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
# 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建)
|
||||
ticket = create_bind_ticket(
|
||||
openid=openid,
|
||||
wechat_nickname=info["nickname"],
|
||||
wechat_avatar_url=info["avatar_url"],
|
||||
)
|
||||
logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6])
|
||||
return WechatLoginResponse(
|
||||
status="need_bind_phone",
|
||||
bind_ticket=ticket,
|
||||
wechat_nickname=info["nickname"],
|
||||
wechat_avatar_url=info["avatar_url"],
|
||||
)
|
||||
|
||||
|
||||
def _finish_wechat_bind(
|
||||
db,
|
||||
*,
|
||||
openid: str,
|
||||
wechat_nickname: str | None,
|
||||
wechat_avatar_url: str | None,
|
||||
phone: str,
|
||||
device_id: str,
|
||||
) -> WechatBindResultResponse:
|
||||
"""绑手机建号的公共尾段:手机号被占用 → 返回 phone_occupied(M2 处理 3 选 1);
|
||||
未占用 → 新建微信账号(channel=wechat,昵称头像取微信)→ 签 token 登入。"""
|
||||
existing = user_repo.get_user_by_phone(db, phone)
|
||||
if existing is not None:
|
||||
from app.core.config import settings # 局部 import,避免循环
|
||||
|
||||
ticket = create_conflict_ticket(
|
||||
openid=openid,
|
||||
wechat_nickname=wechat_nickname,
|
||||
wechat_avatar_url=wechat_avatar_url,
|
||||
phone=phone,
|
||||
)
|
||||
blocked = rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
||||
logger.info(
|
||||
"wechat bind phone occupied phone=%s by user_id=%d has_wechat=%s",
|
||||
mask_phone(phone), existing.id, bool(existing.wechat_openid),
|
||||
)
|
||||
return WechatBindResultResponse(
|
||||
status="phone_occupied",
|
||||
occupied_account=OccupiedAccountInfo(
|
||||
nickname=existing.nickname,
|
||||
avatar_url=existing.avatar_url,
|
||||
created_at=existing.created_at,
|
||||
has_wechat=bool(existing.wechat_openid),
|
||||
),
|
||||
conflict_ticket=ticket,
|
||||
rebind_available=not blocked,
|
||||
rebind_blocked_days=(
|
||||
rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
||||
if blocked else 0
|
||||
),
|
||||
)
|
||||
user = user_repo.create_wechat_user(
|
||||
db,
|
||||
phone=phone,
|
||||
openid=openid,
|
||||
wechat_nickname=wechat_nickname,
|
||||
wechat_avatar_url=wechat_avatar_url,
|
||||
)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=device_id)
|
||||
logger.info("wechat bind ok user_id=%d phone=%s openid=%s*** onboarded=%s",
|
||||
user.id, mask_phone(phone), openid[:6], completed)
|
||||
return WechatBindResultResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechat/bind-phone/sms",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信登录·其他手机号(短信)绑定",
|
||||
)
|
||||
def wechat_bind_phone_sms(
|
||||
req: WechatBindPhoneSmsRequest, request: Request, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
try:
|
||||
claims = decode_bind_ticket(req.bind_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
|
||||
|
||||
# 防刷:同 sms/login,按 设备+IP 每小时限流(放在验证码校验之前,失败也计数)
|
||||
enforce_rate_limit(
|
||||
request,
|
||||
scope="wechat-bind-sms-device",
|
||||
subject=req.device_id,
|
||||
limit=SMS_LOGIN_MAX_PER_HOUR,
|
||||
window_sec=3600,
|
||||
detail="登录尝试过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
if not verify_code(req.phone, req.code):
|
||||
raise HTTPException(status_code=400, detail="invalid sms code")
|
||||
|
||||
return _finish_wechat_bind(
|
||||
db,
|
||||
openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"],
|
||||
wechat_avatar_url=claims["wav"],
|
||||
phone=req.phone,
|
||||
device_id=req.device_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechat/bind-phone/jverify",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信登录·本机号(极光)绑定",
|
||||
)
|
||||
def wechat_bind_phone_jverify(
|
||||
req: WechatBindPhoneJverifyRequest, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
try:
|
||||
claims = decode_bind_ticket(req.bind_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="授权已过期,请重新用微信登录") from e
|
||||
|
||||
try:
|
||||
phone = verify_and_get_phone(req.login_token)
|
||||
except JiguangError as e:
|
||||
logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e
|
||||
|
||||
return _finish_wechat_bind(
|
||||
db,
|
||||
openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"],
|
||||
wechat_avatar_url=claims["wav"],
|
||||
phone=phone,
|
||||
device_id=req.device_id,
|
||||
)
|
||||
|
||||
|
||||
# ===================== 微信占用冲突(M2) =====================
|
||||
|
||||
@router.post(
|
||||
"/wechat/conflict/continue",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信占用冲突·继续绑定(登录老账号,能绑就绑)",
|
||||
)
|
||||
def wechat_conflict_continue(
|
||||
req: WechatConflictContinueRequest, request: Request, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
try:
|
||||
claims = decode_conflict_ticket(req.conflict_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
||||
|
||||
enforce_rate_limit(
|
||||
request, scope="wechat-conflict-device", subject=req.device_id,
|
||||
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
user = user_repo.get_user_by_phone(db, claims["phone"])
|
||||
if user is None:
|
||||
# P 期间被腾空(老账号改号/注销)→ 前提已变,让前端重走
|
||||
raise HTTPException(status_code=409, detail="账号状态已变化,请重新登录")
|
||||
if user.status != "active":
|
||||
raise HTTPException(status_code=403, detail="account disabled")
|
||||
|
||||
if user.wechat_openid is None:
|
||||
try:
|
||||
user_repo.attach_wechat_to_user(
|
||||
db, user, openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
|
||||
)
|
||||
except IntegrityError:
|
||||
db.rollback() # openid 被别处绑走 → 只登入不绑
|
||||
user_repo.touch_last_login(db, user)
|
||||
else:
|
||||
user_repo.touch_last_login(db, user) # X 已绑别的微信 → 只登入,丢弃本次 openid
|
||||
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("wechat conflict continue user_id=%d openid=%s***", user.id, claims["openid"][:6])
|
||||
return WechatBindResultResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/wechat/conflict/rebind",
|
||||
response_model=WechatBindResultResponse,
|
||||
summary="微信占用冲突·换绑(注销老账号+用该号重建全新账号)",
|
||||
)
|
||||
def wechat_conflict_rebind(
|
||||
req: WechatConflictRebindRequest, request: Request, db: DbSession
|
||||
) -> WechatBindResultResponse:
|
||||
from app.core.config import settings # 局部 import,避免循环
|
||||
|
||||
try:
|
||||
claims = decode_conflict_ticket(req.conflict_ticket)
|
||||
except TokenError as e:
|
||||
raise HTTPException(status_code=401, detail="操作超时,请重新用微信登录") from e
|
||||
|
||||
enforce_rate_limit(
|
||||
request, scope="wechat-conflict-device", subject=req.device_id,
|
||||
limit=SMS_LOGIN_MAX_PER_HOUR, window_sec=3600, detail="操作过于频繁,请稍后再试",
|
||||
)
|
||||
|
||||
phone = claims["phone"]
|
||||
if rebind_repo.rebound_within_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS):
|
||||
days = rebind_repo.remaining_block_days(db, phone, settings.PHONE_REBIND_LIMIT_DAYS)
|
||||
raise HTTPException(status_code=409, detail=f"该手机号 {days} 天内已换绑过,暂不能再次换绑")
|
||||
|
||||
user = user_repo.rebind_account(
|
||||
db, phone=phone, openid=claims["openid"],
|
||||
wechat_nickname=claims["wnk"], wechat_avatar_url=claims["wav"],
|
||||
)
|
||||
completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id)
|
||||
logger.info("wechat conflict rebind new_user_id=%d phone=%s openid=%s***",
|
||||
user.id, mask_phone(phone), claims["openid"][:6])
|
||||
return WechatBindResultResponse(
|
||||
status="logged_in",
|
||||
token=_login_response(user, onboarding_completed=completed),
|
||||
)
|
||||
|
||||
|
||||
# ===================== Refresh =====================
|
||||
|
||||
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
|
||||
|
||||
Reference in New Issue
Block a user