diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 9aadae1..0f3ab4c 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -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 对") diff --git a/app/repositories/user.py b/app/repositories/user.py index e10f529..40ce500 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -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, *, diff --git a/tests/test_wechat_conflict.py b/tests/test_wechat_conflict.py index 81c41fe..c3ead0e 100644 --- a/tests/test_wechat_conflict.py +++ b/tests/test_wechat_conflict.py @@ -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