diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 4525d46..6c77a22 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -40,6 +40,7 @@ from app.schemas.auth import ( TokenPair, TokenWithUser, UserOut, + WechatBindPhoneJverifyRequest, WechatBindPhoneSmsRequest, WechatBindResultResponse, WechatLoginRequest, @@ -300,6 +301,35 @@ def wechat_bind_phone_sms( ) +@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, + ) + + # ===================== Refresh ===================== @router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对") diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 4162444..1353479 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -143,3 +143,9 @@ class WechatBindPhoneSmsRequest(BaseModel): phone: str = Field(..., min_length=11, max_length=11, pattern=r"^1\d{10}$") code: str = Field(..., min_length=4, max_length=8) device_id: str = Field("", max_length=64) + + +class WechatBindPhoneJverifyRequest(BaseModel): + bind_ticket: str = Field(..., min_length=1) + login_token: str = Field(..., min_length=1, description="客户端 loginAuth 拿到的 loginToken") + device_id: str = Field("", max_length=64) diff --git a/tests/test_wechat_login.py b/tests/test_wechat_login.py index 9c479b9..d98b768 100644 --- a/tests/test_wechat_login.py +++ b/tests/test_wechat_login.py @@ -8,6 +8,7 @@ from __future__ import annotations import pytest +from app.api.v1 import auth from app.core import security from app.integrations import wxpay @@ -138,3 +139,31 @@ def test_wechat_bind_sms_expired_ticket_returns_401(client, monkeypatch) -> None json={"bind_ticket": expired, "phone": "13900139009", "code": "123456", "device_id": "devD"}, ) assert r.status_code == 401, r.text + + +# ===== Task 4: bind-phone/jverify ===== + +def test_wechat_bind_jverify_creates_account(client, monkeypatch) -> None: + """本机号(极光)绑定路径:verify_and_get_phone 拦掉,未占用 → 建号登入。""" + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_jv_5", "极光用户", None)) + phone = "13900139005" + # 极光 loginToken→手机号 在 auth 模块命名空间打桩(auth.py 顶部 from ...jiguang import verify_and_get_phone) + monkeypatch.setattr(auth, "verify_and_get_phone", lambda token: phone) + + ticket = client.post( + "/api/v1/auth/wechat-login", json={"code": "c", "device_id": "devE"} + ).json()["bind_ticket"] + + r = client.post( + "/api/v1/auth/wechat/bind-phone/jverify", + json={"bind_ticket": ticket, "login_token": "jgtoken", "device_id": "devE"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "logged_in" + user = body["token"]["user"] + assert user["phone"] == phone + assert user["register_channel"] == "wechat" + assert user["nickname"] == "极光用户" + # 微信 userinfo 隐私脱敏 avatar=None → 头像为空(客户端兜底默认头像) + assert user["avatar_url"] is None