feat(auth): M2 POST /wechat/conflict/continue(继续绑定=登录老账号)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
guke
2026-07-14 17:40:22 +08:00
parent f411e81b07
commit 80652ca78d
3 changed files with 131 additions and 0 deletions
+50
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request, status
from sqlalchemy.exc import IntegrityError
from app.api.deps import CurrentUser, DbSession
from app.core import test_account
@@ -46,6 +47,8 @@ from app.schemas.auth import (
WechatBindPhoneJverifyRequest,
WechatBindPhoneSmsRequest,
WechatBindResultResponse,
WechatConflictContinueRequest,
WechatConflictRebindRequest,
WechatLoginRequest,
WechatLoginResponse,
)
@@ -352,6 +355,53 @@ def wechat_bind_phone_jverify(
)
# ===================== 微信占用冲突(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),
)
# ===================== Refresh =====================
@router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对")
+18
View File
@@ -99,6 +99,24 @@ def touch_last_login(db: Session, user: User) -> 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,不动展示 nickname/avatar_url**
(完整 §10"默认则用微信、改过则保留"规则留 M3)。撞 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)
db.commit()
db.refresh(user)
return user
def create_wechat_user(
db: Session,
*,
+63
View File
@@ -88,3 +88,66 @@ def test_phone_occupied_returns_conflict_ticket_and_flags(client, monkeypatch) -
assert body["rebind_available"] is True # 首次,未换绑过
assert body["rebind_blocked_days"] == 0
assert body["occupied_account"]["has_wechat"] is False # X 是 sms 账号
# ===== Task 4: 继续绑定 =====
def test_continue_attaches_wechat_and_logs_into_existing(client, monkeypatch) -> None:
"""X 无微信 → 继续绑定并入 openid + 登入 X;之后同 openid 登录直接命中 X。"""
phone = "13900139201"
x_id = _sms_occupy(client, phone) # X:sms 账号,无微信
body = _occupy_via_conflict(client, monkeypatch, "openid_cont_201", phone, "devC1")
r = client.post(
"/api/v1/auth/wechat/conflict/continue",
json={"conflict_ticket": body["conflict_ticket"], "device_id": "devC1"},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "logged_in"
assert r.json()["token"]["user"]["id"] == x_id # 登入的是老账号 X
# openid 现已并入 X:再走 wechat-login 直接命中 X
r = client.post("/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC1"})
assert r.json()["status"] == "logged_in"
assert r.json()["token"]["user"]["id"] == x_id
def test_continue_when_existing_has_wechat_logs_in_and_discards_openid(client, monkeypatch) -> None:
"""X 已绑别的微信 → 继续绑定只登入 X、丢弃本次 openid(不覆盖)。"""
phone = "13900139202"
# 先建一个已绑微信 O1 的账号 X(微信登录 O1 + 短信绑号)
monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_o1_202"))
t = client.post("/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC2"}).json()["bind_ticket"]
x = client.post(
"/api/v1/auth/wechat/bind-phone/sms",
json={"bind_ticket": t, "phone": phone, "code": "123456", "device_id": "devC2"},
).json()
x_id = x["token"]["user"]["id"]
# 新 openid O2 撞同号 → 占用(has_wechat=True)→ 继续绑定
body = _occupy_via_conflict(client, monkeypatch, "openid_o2_202", phone, "devC2b")
assert body["occupied_account"]["has_wechat"] is True
r = client.post(
"/api/v1/auth/wechat/conflict/continue",
json={"conflict_ticket": body["conflict_ticket"], "device_id": "devC2b"},
)
assert r.status_code == 200, r.text
assert r.json()["token"]["user"]["id"] == x_id # 登入 X
# O2 被丢弃:再走 wechat-login(O2)→ 仍未命中(need_bind_phone)
monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_o2_202"))
assert client.post(
"/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devC2b"}
).json()["status"] == "need_bind_phone"
def test_continue_expired_ticket_returns_401(client, monkeypatch) -> None:
monkeypatch.setattr(security.settings, "WECHAT_BIND_TICKET_EXPIRE_MINUTES", -1)
expired = security.create_conflict_ticket(
openid="oid", wechat_nickname=None, wechat_avatar_url=None, phone="13900139209"
)
r = client.post(
"/api/v1/auth/wechat/conflict/continue",
json={"conflict_ticket": expired, "device_id": "devC3"},
)
assert r.status_code == 401, r.text