feat(auth): M2 POST /wechat/conflict/rebind(换绑=注销X重建Y,单事务)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -402,6 +402,44 @@ def wechat_conflict_continue(
|
||||
)
|
||||
|
||||
|
||||
@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 对")
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories import phone_rebind
|
||||
|
||||
|
||||
# ===== 创建时分配的标识:用户名(对外展示账号 ID)+ 默认昵称 =====
|
||||
@@ -117,7 +118,7 @@ def attach_wechat_to_user(
|
||||
return user
|
||||
|
||||
|
||||
def create_wechat_user(
|
||||
def _build_wechat_user(
|
||||
db: Session,
|
||||
*,
|
||||
phone: str,
|
||||
@@ -125,12 +126,8 @@ def create_wechat_user(
|
||||
wechat_nickname: str | None,
|
||||
wechat_avatar_url: str | None,
|
||||
) -> User:
|
||||
"""微信登录新建账号:register_channel='wechat';展示昵称/头像取微信(§10 新注册),
|
||||
昵称缺失(微信隐私脱敏返回 None)时退默认昵称。同时保存 wechat_* 源字段。
|
||||
|
||||
openid 唯一约束是并发/重复绑定的最终防线(极罕见,openid 在 wechat-login 刚查过为空)。
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
"""构造并 db.add 一个微信账号行(register_channel='wechat',展示昵称头像取微信,缺则默认),
|
||||
**不 commit**。create_wechat_user 与 rebind_account 共用,保证建号逻辑单一来源。"""
|
||||
user = User(
|
||||
phone=phone,
|
||||
username=_gen_unique_username(db),
|
||||
@@ -140,9 +137,28 @@ def create_wechat_user(
|
||||
wechat_openid=openid,
|
||||
wechat_nickname=wechat_nickname,
|
||||
wechat_avatar_url=wechat_avatar_url,
|
||||
last_login_at=now,
|
||||
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
|
||||
@@ -216,3 +232,44 @@ def soft_delete_account(db: Session, user: User) -> 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
|
||||
|
||||
@@ -151,3 +151,61 @@ def test_continue_expired_ticket_returns_401(client, monkeypatch) -> None:
|
||||
json={"conflict_ticket": expired, "device_id": "devC3"},
|
||||
)
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
|
||||
# ===== Task 5: 换绑 =====
|
||||
|
||||
def test_rebind_creates_new_account_and_binds_openid(client, monkeypatch) -> None:
|
||||
"""换绑 → 建全新微信账号 Y(≠X)+ openid 落到 Y;老账号 X 被注销(手机号归 Y)。"""
|
||||
phone = "13900139301"
|
||||
x_id = _sms_occupy(client, phone)
|
||||
body = _occupy_via_conflict(client, monkeypatch, "openid_rb_301", phone, "devR1")
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/auth/wechat/conflict/rebind",
|
||||
json={"conflict_ticket": body["conflict_ticket"], "device_id": "devR1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
y = r.json()["token"]["user"]
|
||||
assert r.json()["status"] == "logged_in"
|
||||
assert y["phone"] == phone
|
||||
assert y["register_channel"] == "wechat"
|
||||
assert y["id"] != x_id # 是全新账号,不是老账号
|
||||
|
||||
# openid 落到 Y:再走 wechat-login 命中 Y
|
||||
r = client.post("/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devR1"})
|
||||
assert r.json()["status"] == "logged_in"
|
||||
assert r.json()["token"]["user"]["id"] == y["id"]
|
||||
|
||||
|
||||
def test_rebind_blocked_within_30_days(client, monkeypatch) -> None:
|
||||
"""同一手机号 30 天内二次换绑 → 409;占用响应 rebind_available=False。"""
|
||||
phone = "13900139302"
|
||||
_sms_occupy(client, phone)
|
||||
body = _occupy_via_conflict(client, monkeypatch, "openid_rb_302a", phone, "devR2")
|
||||
assert client.post(
|
||||
"/api/v1/auth/wechat/conflict/rebind",
|
||||
json={"conflict_ticket": body["conflict_ticket"], "device_id": "devR2"},
|
||||
).status_code == 200
|
||||
|
||||
# 第二次:新 openid 撞同号 → 占用响应此时 rebind_available=False
|
||||
body2 = _occupy_via_conflict(client, monkeypatch, "openid_rb_302b", phone, "devR2b")
|
||||
assert body2["rebind_available"] is False
|
||||
assert body2["rebind_blocked_days"] >= 1
|
||||
r = client.post(
|
||||
"/api/v1/auth/wechat/conflict/rebind",
|
||||
json={"conflict_ticket": body2["conflict_ticket"], "device_id": "devR2b"},
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_rebind_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="13900139309"
|
||||
)
|
||||
r = client.post(
|
||||
"/api/v1/auth/wechat/conflict/rebind",
|
||||
json={"conflict_ticket": expired, "device_id": "devR3"},
|
||||
)
|
||||
assert r.status_code == 401, r.text
|
||||
|
||||
Reference in New Issue
Block a user