From 88313cefdf6ab25c4dfa900b04b0f58e2747f896 Mon Sep 17 00:00:00 2001 From: guke Date: Mon, 13 Jul 2026 14:57:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20POST=20/wechat-login(openid=20?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E5=8D=B3=E7=99=BB=E5=85=A5,=E5=90=A6?= =?UTF-8?q?=E5=88=99=E5=8F=91=E7=BB=91=E5=8F=B7=E4=BB=A4=E7=89=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/api/v1/auth.py | 59 +++++++++++++++++++++++++++++++++++++- app/repositories/user.py | 13 +++++++++ app/schemas/auth.py | 19 ++++++++++++ tests/test_wechat_login.py | 22 ++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 6cee047..637cc44 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -17,9 +17,16 @@ from fastapi import APIRouter, HTTPException, Request, status from app.api.deps import CurrentUser, DbSession from app.core import test_account from app.core.ratelimit import enforce_rate_limit -from app.core.security import TokenError, decode_token, issue_token_pair +from app.core.security import ( + TokenError, + create_bind_ticket, + decode_bind_ticket, + decode_token, + issue_token_pair, +) from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_phone from app.integrations.sms import SmsError, send_code, verify_code +from app.integrations import wxpay from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo from app.schemas.auth import ( @@ -32,6 +39,8 @@ from app.schemas.auth import ( TokenPair, TokenWithUser, UserOut, + WechatLoginRequest, + WechatLoginResponse, ) logger = logging.getLogger("shagua.auth") @@ -166,6 +175,54 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit return _login_response(user, onboarding_completed=completed) +# ===================== 微信登录 ===================== + +@router.post( + "/wechat-login", + response_model=WechatLoginResponse, + summary="微信登录(openid 命中即登入,否则发绑号令牌)", +) +def wechat_login(req: WechatLoginRequest, db: DbSession) -> WechatLoginResponse: + from app.core.config import settings # 局部 import,避免循环 + + # 微信登录只需 code→openid(sns/oauth2),不需要商户转账证书;故只校验 APP_ID/SECRET。 + if not (settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET): + raise HTTPException(status_code=503, detail="wechat login not configured") + + try: + info = wxpay.code_to_userinfo(req.code) # {openid, nickname, avatar_url, raw};失败抛 ValueError + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + openid = info["openid"] + user = user_repo.get_user_by_wechat_openid(db, openid) + if user is not None: + # openid 命中 → 直接登入(绝不套用提现 bind-wechat 的"撞号即 409"逻辑) + if user.status != "active": + raise HTTPException(status_code=403, detail="account disabled") + user_repo.touch_last_login(db, user) + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat_login hit user_id=%d openid=%s*** onboarded=%s", user.id, openid[:6], completed) + return WechatLoginResponse( + status="logged_in", + token=_login_response(user, onboarding_completed=completed), + ) + + # 未命中 → 签发短时 bind_ticket,进手机号绑定流程(账号此刻还不建) + ticket = create_bind_ticket( + openid=openid, + wechat_nickname=info["nickname"], + wechat_avatar_url=info["avatar_url"], + ) + logger.info("wechat_login new openid=%s*** issue bind_ticket", openid[:6]) + return WechatLoginResponse( + status="need_bind_phone", + bind_ticket=ticket, + wechat_nickname=info["nickname"], + wechat_avatar_url=info["avatar_url"], + ) + + # ===================== Refresh ===================== @router.post("/refresh", response_model=TokenPair, summary="用 refresh_token 换新 token 对") diff --git a/app/repositories/user.py b/app/repositories/user.py index 7a560ef..20ce84c 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -86,6 +86,19 @@ def get_user_by_phone(db: Session, phone: str) -> User | None: return db.execute(stmt).scalar_one_or_none() +def get_user_by_wechat_openid(db: Session, openid: str) -> User | None: + stmt = select(User).where(User.wechat_openid == openid) + return db.execute(stmt).scalar_one_or_none() + + +def touch_last_login(db: Session, user: User) -> User: + """openid 命中登录时更新 last_login_at(手机号登录在 upsert_user_for_login 里已更新)。""" + user.last_login_at = datetime.now(timezone.utc) + db.commit() + db.refresh(user) + return user + + def upsert_user_for_login( db: Session, *, diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3870a52..9606860 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -102,3 +102,22 @@ class RefreshRequest(BaseModel): class LogoutResponse(BaseModel): ok: bool = True + + +# ===== 微信登录 ===== + +class WechatLoginRequest(BaseModel): + code: str = Field(..., min_length=1, description="微信 App 授权拿到的 code(单次有效)") + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) + + +class WechatLoginResponse(BaseModel): + # status="logged_in" → openid 命中,token 有值;"need_bind_phone" → 未命中,bind_ticket 有值 + status: str + token: TokenWithUser | None = None + bind_ticket: str | None = None + wechat_nickname: str | None = None + wechat_avatar_url: str | None = None diff --git a/tests/test_wechat_login.py b/tests/test_wechat_login.py index f60b158..80effb5 100644 --- a/tests/test_wechat_login.py +++ b/tests/test_wechat_login.py @@ -42,3 +42,25 @@ def test_bind_ticket_expired_rejected(monkeypatch) -> None: token = security.create_bind_ticket(openid="oid", wechat_nickname=None, wechat_avatar_url=None) with pytest.raises(security.TokenError): security.decode_bind_ticket(token) + + +# ===== Task 2: wechat-login ===== + +def test_wechat_login_new_openid_returns_bind_ticket(client, monkeypatch) -> None: + monkeypatch.setattr(wxpay, "code_to_userinfo", _fake_userinfo("openid_new_1", "小明", "http://x/m.png")) + r = client.post("/api/v1/auth/wechat-login", json={"code": "wxcode1", "device_id": "devA"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "need_bind_phone" + assert body["bind_ticket"] + assert body["wechat_nickname"] == "小明" + assert body["wechat_avatar_url"] == "http://x/m.png" + assert body["token"] is None + + +def test_wechat_login_invalid_code_returns_400(client, monkeypatch) -> None: + def _raise(code: str) -> dict: + raise ValueError("微信授权失败: invalid code") + monkeypatch.setattr(wxpay, "code_to_userinfo", _raise) + r = client.post("/api/v1/auth/wechat-login", json={"code": "bad", "device_id": "devA"}) + assert r.status_code == 400, r.text