From 38b11cce3546e2076c136613969e921af8201271 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 18 Sep 2026 13:53:40 +0800 Subject: [PATCH] feat: add web auth wx-qr create and status endpoints Co-authored-by: Cursor --- backend/app/api/public.py | 46 +++++++++++++++++++++++++++++++++ backend/tests/test_wx_qr_api.py | 30 +++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 backend/tests/test_wx_qr_api.py diff --git a/backend/app/api/public.py b/backend/app/api/public.py index e7b093eb..8b1860cd 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -1,4 +1,5 @@ """公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)""" +import base64 import os from datetime import datetime from typing import Optional @@ -39,6 +40,8 @@ from app.services.web_auth_roles import ( from app.schemas.auth import ProfileUpdateRequest from app.crud.user import update_user, change_password from app.services.issue_user_tokens import build_user_info_payload +from app.services import wx_qr_session +from app.services.wechat_mini_auth import get_unlimited_wxacode, WechatMiniAuthError router = APIRouter(prefix="", tags=["公共接口"]) @@ -155,6 +158,49 @@ async def web_login( ) +@router.post("/web/auth/wx-qr/create", response_model=dict) +async def wx_qr_create(): + session_id, short = wx_qr_session.create_session() + page = (settings.WX_QR_PAGE or "pages/login/web-qr/index").strip() + try: + raw = get_unlimited_wxacode( + scene=short, + page=page, + env_version=settings.WX_QR_ENV_VERSION, + ) + except WechatMiniAuthError as exc: + wx_qr_session.invalidate(session_id) + return error_response(400, str(exc)) + b64 = base64.b64encode(raw).decode("ascii") + mime = "image/jpeg" if raw[:2] == b"\xff\xd8" else "image/png" + return success_response( + { + "sessionId": session_id, + "expiresIn": settings.WX_QR_SESSION_TTL_SEC, + "qrImageBase64": f"data:{mime};base64,{b64}", + } + ) + + +@router.get("/web/auth/wx-qr/status", response_model=dict) +async def wx_qr_status(sessionId: str = ""): + sid = (sessionId or "").strip() + data = wx_qr_session.get_session(sid) + if not data: + return success_response({"status": "expired", "message": "二维码已过期,请刷新"}) + status = data.get("status") or "expired" + if status == "success": + tokens = wx_qr_session.consume_success(sid) + if not tokens: + return success_response({"status": "expired", "message": "登录凭证已失效,请刷新二维码"}) + return success_response({"status": "success", **tokens}) + if status == "failed": + return success_response({"status": "failed", "message": data.get("message") or "登录失败"}) + if status in ("expired", "consumed"): + return success_response({"status": "expired", "message": "二维码已过期,请刷新"}) + return success_response({"status": status}) + + @router.post("/web/auth/refresh-token", response_model=dict) async def refresh_token(body: dict, db: Session = Depends(get_db)): """刷新 Token""" diff --git a/backend/tests/test_wx_qr_api.py b/backend/tests/test_wx_qr_api.py new file mode 100644 index 00000000..38539d5e --- /dev/null +++ b/backend/tests/test_wx_qr_api.py @@ -0,0 +1,30 @@ +"""web wx-qr create/status API。""" +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from app.main import app +from app.services import wx_qr_session + +client = TestClient(app) + + +def setup_function(): + wx_qr_session._memory_clear_for_tests() + + +def test_wx_qr_create_and_poll_pending(): + with patch("app.services.wx_qr_session._redis", return_value=None), patch( + "app.api.public.get_unlimited_wxacode", + return_value=b"\x89PNG\r\n\x1a\n", + ): + r = client.post("/api/web/auth/wx-qr/create") + assert r.status_code == 200 + body = r.json() + assert body.get("code") == 200 + data = body["data"] + sid = data["sessionId"] + assert data["qrImageBase64"].startswith("data:image/") + st = client.get("/api/web/auth/wx-qr/status", params={"sessionId": sid}) + assert st.status_code == 200 + assert st.json()["data"]["status"] == "pending"