Compare commits

...

1 Commits

Author SHA1 Message Date
lowmaster-chen 1913dbcc6e 接入微信一键登录服务端接口
改动内容:新增微信 code 换 openid 集成、/api/v1/auth/wechat-login 登录接口、微信用户创建/复用逻辑和测试覆盖。

验证方式:python -m pytest tests\\test_auth.py tests\\test_health.py -q 通过。
2026-06-29 22:21:33 +08:00
7 changed files with 243 additions and 2 deletions
+3 -2
View File
@@ -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
+32
View File
@@ -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(
+87
View File
@@ -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
+48
View File
@@ -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()
+10
View File
@@ -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):
+62
View File
@@ -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
+1
View File
@@ -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