diff --git a/.env.example b/.env.example index d3804bf..4317888 100644 --- a/.env.example +++ b/.env.example @@ -82,8 +82,9 @@ INTERNAL_API_SECRET= # 逗号分隔,生产留空(只让 app 调,不开放 web)。本地开发可加 http://localhost:5173 之类 CORS_ALLOW_ORIGINS= -# ===== 微信支付(商家转账到零钱 / 提现)===== -# appid/secret 来自微信开放平台移动应用;mch/序列号/公钥ID 来自微信支付商户平台。 +# ===== 微信开放平台 App + 微信支付(登录 / 商家转账到零钱 / 提现)===== +# WECHAT_APP_ID/WECHAT_APP_SECRET 来自微信开放平台移动应用,用于 App 微信一键登录(code 换 openid)。 +# mch/序列号/公钥ID 来自微信支付商户平台,用于提现/商家转账。 # 证书 .pem 放 secrets/(已 gitignore)。 WECHAT_APP_ID=wxxxxxxxxxxxxxxxxx WECHAT_APP_SECRET=your_app_secret diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 3c4f4bf..c81c1af 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -2,6 +2,7 @@ 路由前缀 `/api/v1/auth`,包含: POST /jverify-login 极光一键登录(loginToken → 手机号 → 注册即登录 → 签 JWT) + POST /wechat-login 微信 App 授权登录(code → openid → 注册即登录 → 签 JWT) POST /sms/send 发短信验证码(mock 阶段任意 6 位通过) POST /sms/login 手机号 + 验证码登录 POST /refresh 用 refresh_token 换新的 token 对 @@ -20,6 +21,7 @@ from app.core.ratelimit import enforce_rate_limit, rate_limit from app.core.security import TokenError, 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.wechat_login import WechatLoginError, code_to_userinfo from app.repositories import onboarding as onboarding_repo from app.repositories import user as user_repo from app.schemas.auth import ( @@ -32,6 +34,7 @@ from app.schemas.auth import ( TokenPair, TokenWithUser, UserOut, + WechatLoginRequest, ) logger = logging.getLogger("shagua.auth") @@ -84,6 +87,35 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: return _login_response(user, onboarding_completed=completed) +# ===================== 微信 App 登录 ===================== + +@router.post( + "/wechat-login", + response_model=TokenWithUser, + summary="微信一键登录", + dependencies=[Depends(rate_limit(20, 60, "wechat-login"))], +) +def wechat_login(req: WechatLoginRequest, db: DbSession) -> TokenWithUser: + try: + info = code_to_userinfo(req.code) + except WechatLoginError as e: + logger.error("[WECHAT] login code exchange failed: %s", e, exc_info=True) + raise HTTPException(status_code=502, detail=str(e)) from e + + user = user_repo.upsert_user_for_wechat_login( + db, + openid=info["openid"], + nickname=info.get("nickname"), + avatar_url=info.get("avatar_url"), + ) + if user.status != "active": + raise HTTPException(status_code=403, detail="account disabled") + + completed = onboarding_repo.is_completed(db, user_id=user.id, device_id=req.device_id) + logger.info("wechat_login ok user_id=%d onboarded=%s", user.id, completed) + return _login_response(user, onboarding_completed=completed) + + # ===================== 短信登录 ===================== @router.post( diff --git a/app/integrations/wechat_login.py b/app/integrations/wechat_login.py new file mode 100644 index 0000000..e241635 --- /dev/null +++ b/app/integrations/wechat_login.py @@ -0,0 +1,87 @@ +"""微信开放平台 App 登录 OAuth 封装。 + +客户端只传微信 SDK 返回的临时 code;AppSecret、access_token、refresh_token 都只留在服务端。 +""" +from __future__ import annotations + +from typing import Any + +import httpx + +from app.core.config import settings + +WECHAT_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token" +WECHAT_USERINFO_URL = "https://api.weixin.qq.com/sns/userinfo" + + +class WechatLoginError(Exception): + """微信登录链路失败,由 auth endpoint 转成 502/503。""" + + +def _ensure_configured() -> None: + if not settings.WECHAT_APP_ID or not settings.WECHAT_APP_SECRET: + raise WechatLoginError("wechat app not configured") + + +def _json(resp: httpx.Response) -> dict[str, Any]: + try: + data = resp.json() + except ValueError as e: + raise WechatLoginError("微信授权响应格式异常") from e + if not isinstance(data, dict): + raise WechatLoginError("微信授权响应格式异常") + return data + + +def code_to_userinfo(code: str) -> dict[str, Any]: + """用客户端授权 code 换 openid/unionid,并尽力拉取微信昵称头像。""" + _ensure_configured() + try: + with httpx.Client(timeout=8.0) as client: + token_resp = client.get( + WECHAT_TOKEN_URL, + params={ + "appid": settings.WECHAT_APP_ID, + "secret": settings.WECHAT_APP_SECRET, + "code": code, + "grant_type": "authorization_code", + }, + ) + token_data = _json(token_resp) + if token_data.get("errcode"): + raise WechatLoginError(f"微信授权失败: {token_data.get('errmsg', token_data)}") + + access_token = token_data.get("access_token") + openid = token_data.get("openid") + if not access_token or not openid: + raise WechatLoginError("微信授权响应缺少 openid") + + info: dict[str, Any] = { + "openid": openid, + "unionid": token_data.get("unionid"), + "nickname": None, + "avatar_url": None, + "raw": {"token": token_data}, + } + + # userinfo 失败不阻断登录:登录主身份是 openid,昵称头像只是展示增强。 + userinfo_resp = client.get( + WECHAT_USERINFO_URL, + params={ + "access_token": access_token, + "openid": openid, + "lang": "zh_CN", + }, + ) + userinfo = _json(userinfo_resp) + if not userinfo.get("errcode"): + info["unionid"] = userinfo.get("unionid") or info["unionid"] + info["nickname"] = userinfo.get("nickname") + info["avatar_url"] = userinfo.get("headimgurl") + info["raw"]["userinfo"] = userinfo + + return info + except WechatLoginError: + raise + except httpx.HTTPError as e: + raise WechatLoginError("微信授权请求失败,请稍后重试") from e diff --git a/app/repositories/user.py b/app/repositories/user.py index 7397d44..45528ae 100644 --- a/app/repositories/user.py +++ b/app/repositories/user.py @@ -4,6 +4,7 @@ """ from __future__ import annotations +import hashlib import secrets import string from datetime import datetime, timezone @@ -70,6 +71,16 @@ 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 _wechat_placeholder_phone(openid: str) -> str: + # user.phone 当前是非空唯一字段;微信纯登录用户用内部占位值满足旧表结构。 + return "wx_" + hashlib.sha256(openid.encode("utf-8")).hexdigest()[:16] + + def upsert_user_for_login( db: Session, *, @@ -99,6 +110,43 @@ def upsert_user_for_login( return user +def upsert_user_for_wechat_login( + db: Session, + *, + openid: str, + nickname: str | None = None, + avatar_url: str | None = None, +) -> User: + """微信登录入口:按 openid 复用/创建用户,并同步微信头像昵称。""" + user = get_user_by_wechat_openid(db, openid) + now = datetime.now(timezone.utc) + if user is None: + user = User( + phone=_wechat_placeholder_phone(openid), + username=_gen_unique_username(db), + nickname=nickname or _gen_nickname(), + avatar_url=avatar_url, + register_channel="wechat", + wechat_openid=openid, + wechat_nickname=nickname, + wechat_avatar_url=avatar_url, + last_login_at=now, + ) + db.add(user) + else: + user.last_login_at = now + user.wechat_nickname = nickname or user.wechat_nickname + user.wechat_avatar_url = avatar_url or user.wechat_avatar_url + if nickname and not user.nickname: + user.nickname = nickname + if avatar_url and not user.avatar_url: + user.avatar_url = avatar_url + # 被禁用的账号被尝试登录时,不自动启用,直接抛(在调用方处理) + db.commit() + db.refresh(user) + return user + + def update_nickname(db: Session, user: User, *, nickname: str) -> User: user.nickname = nickname db.commit() diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 3870a52..40f4064 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -92,6 +92,16 @@ class SmsLoginRequest(BaseModel): ) +# ===== 微信 App 登录 ===== + +class WechatLoginRequest(BaseModel): + code: str = Field(..., min_length=1, description="微信 SDK SendAuth.Resp 返回的一次性 code") + device_id: str = Field( + "", max_length=64, + description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", + ) + + # ===== Refresh ===== class RefreshRequest(BaseModel): diff --git a/tests/test_auth.py b/tests/test_auth.py index 4968a73..0cd7528 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -68,6 +68,68 @@ def test_sms_login_and_me_flow(client) -> None: assert r.json()["ok"] is True +def test_wechat_login_creates_and_reuses_user(client, monkeypatch) -> None: + def fake_code_to_userinfo(code: str) -> dict: + return { + "openid": "openid-login-create", + "unionid": "union-login-create", + "nickname": "微信昵称", + "avatar_url": "https://wx.example/avatar.png", + "raw": {"code": code}, + } + + monkeypatch.setattr("app.api.v1.auth.code_to_userinfo", fake_code_to_userinfo) + + r = client.post("/api/v1/auth/wechat-login", json={"code": "code-a", "device_id": "dev-wx"}) + assert r.status_code == 200, r.text + first = r.json() + assert first["user"]["register_channel"] == "wechat" + assert first["user"]["phone"].startswith("wx_") + assert first["user"]["nickname"] == "微信昵称" + + r = client.post("/api/v1/auth/wechat-login", json={"code": "code-b", "device_id": "dev-wx"}) + assert r.status_code == 200, r.text + second = r.json() + assert second["user"]["id"] == first["user"]["id"] + assert second["user"]["phone"] == first["user"]["phone"] + + +def test_wechat_login_uses_existing_bound_user(client, monkeypatch) -> None: + from app.db.session import SessionLocal + from app.repositories import user as user_repo + + db = SessionLocal() + try: + user = user_repo.upsert_user_for_login( + db, + phone="13600136000", + register_channel="sms", + ) + user.wechat_openid = "openid-login-bound" + user.wechat_nickname = "已绑定昵称" + db.commit() + user_id = user.id + finally: + db.close() + + def fake_code_to_userinfo(code: str) -> dict: + return { + "openid": "openid-login-bound", + "unionid": "union-login-bound", + "nickname": "新微信昵称", + "avatar_url": None, + "raw": {"code": code}, + } + + monkeypatch.setattr("app.api.v1.auth.code_to_userinfo", fake_code_to_userinfo) + + r = client.post("/api/v1/auth/wechat-login", json={"code": "code-bound", "device_id": "dev-wx"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["user"]["id"] == user_id + assert body["user"]["phone"] == "13600136000" + + def test_sms_send_too_frequent(client) -> None: phone = "13900139000" assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200 diff --git a/tests/test_health.py b/tests/test_health.py index 38cb737..375e16e 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -14,6 +14,7 @@ def test_openapi_loads(client) -> None: paths = resp.json()["paths"] # auth endpoints 都注册了 assert "/api/v1/auth/jverify-login" in paths + assert "/api/v1/auth/wechat-login" in paths assert "/api/v1/auth/sms/send" in paths assert "/api/v1/auth/sms/login" in paths assert "/api/v1/auth/refresh" in paths