diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 0f3ab4c..94b7d70 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -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 对") diff --git a/app/repositories/user.py b/app/repositories/user.py index 40ce500..2ff768d 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -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 diff --git a/tests/test_wechat_conflict.py b/tests/test_wechat_conflict.py index c3ead0e..7ca6d01 100644 --- a/tests/test_wechat_conflict.py +++ b/tests/test_wechat_conflict.py @@ -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