Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1913dbcc6e |
+3
-2
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user