|
|
@@ -0,0 +1,911 @@
|
|
|
+# web-admin 微信扫码登录(手机号匹配)Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 山峡 web-admin 登录页默认微信扫码(小程序码 + 手机号匹配 `sys_user.phone_no`),并可切换账号密码登录。
|
|
|
+
|
|
|
+**Architecture:** 后端 Redis(无 Redis 则进程内存)保存短时扫码会话;`create` 用 `WECHAT_MP_*` 生成小程序码;手机打开 `miniprogram/pages/login/web-qr` 授权后 `confirm` 复用 `resolve_wechat_login_user` + 同步的 `issue_web_login_tokens`;web-admin 轮询 `status` 一次性取走 JWT。UI 保留山峡全屏背景 + 右侧毛玻璃卡,卡内 Tab。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI、Redis、微信 `getwxacodeunlimit`、Vue 3(web-admin)、uni-app(miniprogram)
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-09-18-web-admin-wx-qr-phone-login-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| 路径 | 职责 |
|
|
|
+|------|------|
|
|
|
+| `backend/app/core/config.py` | `WX_QR_*`;`API_ENCRYPT_EXCLUDE_PATHS` 追加 |
|
|
|
+| `backend/.env.example` | 文档化 `WX_QR_*` |
|
|
|
+| `backend/app/services/wx_qr_session.py` | 会话状态机、短码映射、一次性消费 |
|
|
|
+| `backend/app/services/wechat_mini_auth.py` | 新增 `get_unlimited_wxacode` |
|
|
|
+| `backend/app/api/public.py` | `POST/GET /web/auth/wx-qr/create\|status` |
|
|
|
+| `backend/app/api/mini_auth.py` | `web-qr-scanned` / `web-qr-confirm` |
|
|
|
+| `backend/tests/test_wx_qr_session.py` | 会话单测 |
|
|
|
+| `backend/tests/test_wxacode.py` | 小程序码 mock 单测 |
|
|
|
+| `backend/tests/test_wx_qr_api.py` | create/status/confirm 接口测 |
|
|
|
+| `web-admin/src/api/auth.ts` | `fetchWxQrCreate` / `fetchWxQrStatus` |
|
|
|
+| `web-admin/.env` / `.env.development` / `.env.production` | 前端加密排除路径 |
|
|
|
+| `web-admin/src/views/auth/login/index.vue` | Tab + 扫码轮询;密码逻辑保留 |
|
|
|
+| `miniprogram/pages/login/web-qr/index.vue` | 授权落地页 |
|
|
|
+| `miniprogram/pages.json` | 注册新页 |
|
|
|
+| `miniprogram/config/api.js` | `WEB_QR_SCANNED` / `WEB_QR_CONFIRM` |
|
|
|
+| `miniprogram/stores/user.js` | `webQrScanned` / `webQrConfirm`(不写本地 JWT) |
|
|
|
+| `miniprogram/utils/request.js` | 加密排除路径追加 |
|
|
|
+
|
|
|
+**不修改:** 支付 `WXPAY_*`、开放平台网站应用、自动注册、密码登录签发结构。
|
|
|
+
|
|
|
+**山峡差异(相对 zkxw):** 发票函数是同步 `issue_web_login_tokens`,不是 `await issue_tokens_for_user`。凭证用 `WECHAT_MP_APPID` / `WECHAT_MP_SECRET`。确认页在 `miniprogram/`。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 配置项与加密排除路径
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/core/config.py`
|
|
|
+- Modify: `backend/.env.example`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 `Settings` 中 `WECHAT_MP_SECRET` 之后增加**
|
|
|
+
|
|
|
+```python
|
|
|
+ # web-admin 微信扫码登录(小程序码)
|
|
|
+ WX_QR_SESSION_TTL_SEC: int = 180
|
|
|
+ WX_QR_ENV_VERSION: str = "release" # release | trial | develop
|
|
|
+ WX_QR_PAGE: str = "pages/login/web-qr/index"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 `API_ENCRYPT_EXCLUDE_PATHS` 的 `/api/mini/auth/wechat-login,` 之后追加**
|
|
|
+
|
|
|
+```text
|
|
|
+"/api/web/auth/wx-qr/create,"
|
|
|
+"/api/web/auth/wx-qr/status,"
|
|
|
+"/api/mini/auth/web-qr-scanned,"
|
|
|
+"/api/mini/auth/web-qr-confirm,"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 `backend/.env.example` 的 `WECHAT_MP_SECRET=` 注释块后追加**
|
|
|
+
|
|
|
+```text
|
|
|
+# web-admin 扫码登录(小程序码 page 须已发布到对应 env_version)
|
|
|
+# WX_QR_SESSION_TTL_SEC=180
|
|
|
+# WX_QR_ENV_VERSION=release
|
|
|
+# WX_QR_PAGE=pages/login/web-qr/index
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/core/config.py backend/.env.example
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+chore: add wx-qr login config and encrypt exclude paths.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 扫码会话服务(TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `backend/tests/test_wx_qr_session.py`
|
|
|
+- Create: `backend/app/services/wx_qr_session.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败单测 `backend/tests/test_wx_qr_session.py`**
|
|
|
+
|
|
|
+```python
|
|
|
+"""wx_qr_session 状态机与一次性消费。"""
|
|
|
+import time
|
|
|
+
|
|
|
+from app.services import wx_qr_session as s
|
|
|
+
|
|
|
+
|
|
|
+def setup_function():
|
|
|
+ s._memory_clear_for_tests()
|
|
|
+
|
|
|
+
|
|
|
+def test_create_pending_and_get():
|
|
|
+ sid, short = s.create_session(ttl_sec=60)
|
|
|
+ assert sid and short and len(short) <= 32
|
|
|
+ data = s.get_session(sid)
|
|
|
+ assert data["status"] == "pending"
|
|
|
+ assert s.resolve_session_id(short) == sid
|
|
|
+
|
|
|
+
|
|
|
+def test_mark_scanned():
|
|
|
+ sid, _ = s.create_session(ttl_sec=60)
|
|
|
+ assert s.mark_scanned(sid) is True
|
|
|
+ assert s.get_session(sid)["status"] == "scanned"
|
|
|
+
|
|
|
+
|
|
|
+def test_mark_success_consume_once():
|
|
|
+ sid, _ = s.create_session(ttl_sec=60)
|
|
|
+ payload = {"token": "t", "refreshToken": "r", "expiresIn": 1, "userInfo": {}}
|
|
|
+ assert s.mark_success(sid, payload) is True
|
|
|
+ first = s.consume_success(sid)
|
|
|
+ assert first["token"] == "t"
|
|
|
+ assert s.consume_success(sid) is None
|
|
|
+ assert s.get_session(sid)["status"] == "consumed"
|
|
|
+
|
|
|
+
|
|
|
+def test_mark_failed():
|
|
|
+ sid, _ = s.create_session(ttl_sec=60)
|
|
|
+ assert s.mark_failed(sid, "未找到员工") is True
|
|
|
+ assert s.get_session(sid)["status"] == "failed"
|
|
|
+ assert s.get_session(sid)["message"] == "未找到员工"
|
|
|
+
|
|
|
+
|
|
|
+def test_expire_old_session():
|
|
|
+ sid, _ = s.create_session(ttl_sec=1)
|
|
|
+ time.sleep(1.1)
|
|
|
+ data = s.get_session(sid)
|
|
|
+ assert data is None or data["status"] == "expired"
|
|
|
+
|
|
|
+
|
|
|
+def test_invalidate():
|
|
|
+ sid, _ = s.create_session(ttl_sec=60)
|
|
|
+ s.invalidate(sid)
|
|
|
+ data = s.get_session(sid)
|
|
|
+ assert data is None or data["status"] == "expired"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败**
|
|
|
+
|
|
|
+Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v`
|
|
|
+Expected: FAIL(`No module named app.services.wx_qr_session` 或 import error)
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现 `backend/app/services/wx_qr_session.py`**
|
|
|
+
|
|
|
+从 zkxw `backend/app/services/wx_qr_session.py` **原样复制**(已核对与山峡 `settings.REDIS_*` 字段兼容)。公开函数签名固定为:
|
|
|
+
|
|
|
+- `create_session(*, ttl_sec=None) -> tuple` 返回 `(session_id, short)`
|
|
|
+- `resolve_session_id(short_or_id) -> Optional[str]`
|
|
|
+- `get_session(session_id) -> Optional[dict]`(过期时 `status=expired`)
|
|
|
+- `mark_scanned` / `mark_success` / `mark_failed` / `consume_success` / `invalidate`
|
|
|
+- `_memory_clear_for_tests()`
|
|
|
+
|
|
|
+Redis key:`wxqr:sess:{id}`、`wxqr:short:{short}`。无 `REDIS_HOST` 时走进程内存。
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑测确认通过**
|
|
|
+
|
|
|
+Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v`
|
|
|
+Expected: PASS(6 tests)
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/services/wx_qr_session.py backend/tests/test_wx_qr_session.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(auth): add wx-qr session state machine for web login.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 小程序码生成(TDD)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/services/wechat_mini_auth.py`
|
|
|
+- Create: `backend/tests/test_wxacode.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败单测 `backend/tests/test_wxacode.py`**
|
|
|
+
|
|
|
+```python
|
|
|
+from unittest.mock import MagicMock, patch
|
|
|
+
|
|
|
+from app.services.wechat_mini_auth import WechatMiniAuthError, get_unlimited_wxacode
|
|
|
+
|
|
|
+
|
|
|
+def test_get_unlimited_wxacode_returns_png_bytes():
|
|
|
+ token_resp = MagicMock()
|
|
|
+ token_resp.raise_for_status = MagicMock()
|
|
|
+ token_resp.json.return_value = {"access_token": "tok"}
|
|
|
+
|
|
|
+ code_resp = MagicMock()
|
|
|
+ code_resp.raise_for_status = MagicMock()
|
|
|
+ code_resp.headers = {"Content-Type": "image/jpeg"}
|
|
|
+ code_resp.content = b"\xff\xd8fake"
|
|
|
+
|
|
|
+ with patch(
|
|
|
+ "app.services.wechat_mini_auth._require_mini_credentials",
|
|
|
+ return_value=("app", "sec"),
|
|
|
+ ):
|
|
|
+ with patch("app.services.wechat_mini_auth.httpx.Client") as Client:
|
|
|
+ client = MagicMock()
|
|
|
+ Client.return_value.__enter__.return_value = client
|
|
|
+ client.get.return_value = token_resp
|
|
|
+ client.post.return_value = code_resp
|
|
|
+ raw = get_unlimited_wxacode(scene="abc", page="pages/login/web-qr/index")
|
|
|
+ assert raw.startswith(b"\xff\xd8")
|
|
|
+
|
|
|
+
|
|
|
+def test_get_unlimited_wxacode_json_error():
|
|
|
+ token_resp = MagicMock()
|
|
|
+ token_resp.raise_for_status = MagicMock()
|
|
|
+ token_resp.json.return_value = {"access_token": "tok"}
|
|
|
+
|
|
|
+ code_resp = MagicMock()
|
|
|
+ code_resp.raise_for_status = MagicMock()
|
|
|
+ code_resp.headers = {"Content-Type": "application/json"}
|
|
|
+ code_resp.content = b'{"errcode":40001,"errmsg":"fail"}'
|
|
|
+ code_resp.json.return_value = {"errcode": 40001, "errmsg": "fail"}
|
|
|
+
|
|
|
+ with patch(
|
|
|
+ "app.services.wechat_mini_auth._require_mini_credentials",
|
|
|
+ return_value=("app", "sec"),
|
|
|
+ ):
|
|
|
+ with patch("app.services.wechat_mini_auth.httpx.Client") as Client:
|
|
|
+ client = MagicMock()
|
|
|
+ Client.return_value.__enter__.return_value = client
|
|
|
+ client.get.return_value = token_resp
|
|
|
+ client.post.return_value = code_resp
|
|
|
+ try:
|
|
|
+ get_unlimited_wxacode(scene="abc", page="pages/login/web-qr/index")
|
|
|
+ assert False, "expected error"
|
|
|
+ except WechatMiniAuthError:
|
|
|
+ pass
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败**
|
|
|
+
|
|
|
+Run: `cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v`
|
|
|
+Expected: FAIL(`get_unlimited_wxacode` 不存在)
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 `wechat_mini_auth.py` 文件末尾追加**
|
|
|
+
|
|
|
+```python
|
|
|
+_WX_WXACODE_UNLIMITED = "https://api.weixin.qq.com/wxa/getwxacodeunlimit"
|
|
|
+
|
|
|
+
|
|
|
+def get_unlimited_wxacode(
|
|
|
+ *,
|
|
|
+ scene: str,
|
|
|
+ page: str,
|
|
|
+ env_version: Optional[str] = None,
|
|
|
+) -> bytes:
|
|
|
+ """获取不限制小程序码 PNG/JPEG 字节;失败抛 WechatMiniAuthError。"""
|
|
|
+ sc = (scene or "").strip()
|
|
|
+ if not sc or len(sc) > 32:
|
|
|
+ raise WechatMiniAuthError("scene 无效或超过 32 字符")
|
|
|
+ pg = (page or "").strip()
|
|
|
+ if not pg:
|
|
|
+ raise WechatMiniAuthError("page 不能为空")
|
|
|
+ ver = (env_version or getattr(settings, "WX_QR_ENV_VERSION", None) or "release").strip()
|
|
|
+ if ver not in ("release", "trial", "develop"):
|
|
|
+ ver = "release"
|
|
|
+ token = _get_access_token()
|
|
|
+ with httpx.Client(timeout=20.0) as client:
|
|
|
+ r = client.post(
|
|
|
+ f"{_WX_WXACODE_UNLIMITED}?access_token={token}",
|
|
|
+ json={
|
|
|
+ "scene": sc,
|
|
|
+ "page": pg,
|
|
|
+ "check_path": False,
|
|
|
+ "env_version": ver,
|
|
|
+ "width": 280,
|
|
|
+ },
|
|
|
+ )
|
|
|
+ r.raise_for_status()
|
|
|
+ ctype = (r.headers.get("Content-Type") or "").lower()
|
|
|
+ if "application/json" in ctype:
|
|
|
+ data = r.json()
|
|
|
+ logger.warning("getwxacodeunlimit failed: %s", data)
|
|
|
+ raise WechatMiniAuthError(
|
|
|
+ "生成小程序码失败",
|
|
|
+ wx_errcode=data.get("errcode"),
|
|
|
+ )
|
|
|
+ if not r.content:
|
|
|
+ raise WechatMiniAuthError("生成小程序码失败:空响应")
|
|
|
+ return r.content
|
|
|
+```
|
|
|
+
|
|
|
+`Optional` 已在该文件 import。`_get_access_token` 已使用 `WECHAT_MP_*`,不要改成 `WXPAY_*`。
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑测确认通过**
|
|
|
+
|
|
|
+Run: `cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v`
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/services/wechat_mini_auth.py backend/tests/test_wxacode.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(wechat): generate unlimited mini program codes for web QR login.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: Web 公开端点 create / status
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/public.py`
|
|
|
+- Create: `backend/tests/test_wx_qr_api.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 `public.py` 顶部 import 增加**
|
|
|
+
|
|
|
+```python
|
|
|
+import base64
|
|
|
+
|
|
|
+from app.services import wx_qr_session
|
|
|
+from app.services.wechat_mini_auth import WechatMiniAuthError, get_unlimited_wxacode
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在现有 `web_login` 路由之后插入**
|
|
|
+
|
|
|
+```python
|
|
|
+@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})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 写 `backend/tests/test_wx_qr_api.py` 的 create 用例**
|
|
|
+
|
|
|
+```python
|
|
|
+"""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"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑测**
|
|
|
+
|
|
|
+Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py::test_wx_qr_create_and_poll_pending -v`
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/public.py backend/tests/test_wx_qr_api.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(api): add web wx-qr create and status endpoints.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 小程序 scanned / confirm
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/mini_auth.py`
|
|
|
+- Modify: `backend/tests/test_wx_qr_api.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 `mini_auth.py` 增加 import**
|
|
|
+
|
|
|
+```python
|
|
|
+from app.services import wx_qr_session
|
|
|
+```
|
|
|
+
|
|
|
+`code2session`、`get_phone_number`、`resolve_wechat_login_user`、`issue_web_login_tokens`、`datetime` 已存在。
|
|
|
+
|
|
|
+- [ ] **Step 2: 在文件末尾追加两个路由**
|
|
|
+
|
|
|
+注意:`issue_web_login_tokens` 是**同步**函数,不要 `await`。
|
|
|
+
|
|
|
+```python
|
|
|
+@router.post("/web-qr-scanned", response_model=dict)
|
|
|
+def web_qr_scanned(body: dict):
|
|
|
+ raw = (body.get("sessionId") or body.get("scene") or "").strip()
|
|
|
+ sid = wx_qr_session.resolve_session_id(raw) or raw
|
|
|
+ data = wx_qr_session.get_session(sid)
|
|
|
+ if not data or data.get("status") in ("expired", "consumed"):
|
|
|
+ return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
|
|
|
+ wx_qr_session.mark_scanned(sid)
|
|
|
+ return success_response({"ok": True})
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/web-qr-confirm", response_model=dict)
|
|
|
+def web_qr_confirm(body: dict, db: Session = Depends(get_db)):
|
|
|
+ raw = (body.get("sessionId") or body.get("scene") or "").strip()
|
|
|
+ sid = wx_qr_session.resolve_session_id(raw) or raw
|
|
|
+ data = wx_qr_session.get_session(sid)
|
|
|
+ if not data or data.get("status") in ("expired", "consumed", "failed"):
|
|
|
+ return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
|
|
|
+ if data.get("status") == "success":
|
|
|
+ return success_response({"ok": True, "message": "电脑端即将登录"})
|
|
|
+
|
|
|
+ wx_qr_session.mark_scanned(sid)
|
|
|
+
|
|
|
+ login_code = (body.get("loginCode") or "").strip()
|
|
|
+ phone_code = (body.get("phoneCode") or "").strip()
|
|
|
+ if not login_code or not phone_code:
|
|
|
+ wx_qr_session.mark_failed(sid, "需要授权手机号才能完成扫码登录")
|
|
|
+ return error_response(400, "需要授权手机号才能完成扫码登录")
|
|
|
+
|
|
|
+ try:
|
|
|
+ sess = code2session(login_code)
|
|
|
+ phone = get_phone_number(phone_code)
|
|
|
+ except WechatMiniAuthError as exc:
|
|
|
+ wx_qr_session.mark_failed(sid, str(exc))
|
|
|
+ return error_response(400, str(exc))
|
|
|
+
|
|
|
+ user, err_msg, err_code = resolve_wechat_login_user(
|
|
|
+ db, openid=sess["openid"], phone=phone
|
|
|
+ )
|
|
|
+ if err_msg:
|
|
|
+ wx_qr_session.mark_failed(sid, err_msg)
|
|
|
+ return error_response(err_code or 400, err_msg)
|
|
|
+
|
|
|
+ unionid = (sess.get("unionid") or "").strip()
|
|
|
+ if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
|
|
|
+ user.wx_unionid = unionid
|
|
|
+ user.update_time = datetime.now()
|
|
|
+ db.add(user)
|
|
|
+ db.commit()
|
|
|
+ db.refresh(user)
|
|
|
+
|
|
|
+ try:
|
|
|
+ tokens = issue_web_login_tokens(db, user)
|
|
|
+ except ValueError as exc:
|
|
|
+ wx_qr_session.mark_failed(sid, str(exc))
|
|
|
+ return error_response(403, str(exc))
|
|
|
+
|
|
|
+ if not wx_qr_session.mark_success(sid, tokens):
|
|
|
+ return error_response(400, "二维码已过期,请在电脑上刷新后重扫")
|
|
|
+ return success_response({"ok": True, "message": "电脑端即将登录,可关闭本页"})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 `test_wx_qr_api.py` 追加 confirm 用例**
|
|
|
+
|
|
|
+patch 目标是 `issue_web_login_tokens`(同步 `MagicMock`),不是 `issue_tokens_for_user`。
|
|
|
+
|
|
|
+```python
|
|
|
+from types import SimpleNamespace
|
|
|
+from unittest.mock import patch
|
|
|
+
|
|
|
+
|
|
|
+def test_web_qr_confirm_success_sets_session():
|
|
|
+ fake_user = SimpleNamespace(
|
|
|
+ id="u1", phone_no="13800138000", wx_openid=None, wx_unionid=None, status="1"
|
|
|
+ )
|
|
|
+ tokens = {
|
|
|
+ "token": "access",
|
|
|
+ "refreshToken": "refresh",
|
|
|
+ "expiresIn": 3600,
|
|
|
+ "userInfo": {"id": "u1"},
|
|
|
+ }
|
|
|
+ with patch("app.services.wx_qr_session._redis", return_value=None):
|
|
|
+ wx_qr_session._memory_clear_for_tests()
|
|
|
+ sid, _short = wx_qr_session.create_session(ttl_sec=60)
|
|
|
+ with patch(
|
|
|
+ "app.api.mini_auth.code2session",
|
|
|
+ return_value={"openid": "oid1", "unionid": ""},
|
|
|
+ ), patch(
|
|
|
+ "app.api.mini_auth.get_phone_number", return_value="13800138000"
|
|
|
+ ), patch(
|
|
|
+ "app.api.mini_auth.resolve_wechat_login_user",
|
|
|
+ return_value=(fake_user, None, None),
|
|
|
+ ), patch(
|
|
|
+ "app.api.mini_auth.issue_web_login_tokens", return_value=tokens
|
|
|
+ ):
|
|
|
+ r = client.post(
|
|
|
+ "/api/mini/auth/web-qr-confirm",
|
|
|
+ json={"sessionId": sid, "loginCode": "lc", "phoneCode": "pc"},
|
|
|
+ )
|
|
|
+ assert r.status_code == 200
|
|
|
+ body = r.json()
|
|
|
+ assert body.get("code") == 200
|
|
|
+ assert body["data"].get("ok") is True
|
|
|
+ assert "token" not in (body.get("data") or {})
|
|
|
+ consumed = wx_qr_session.consume_success(sid)
|
|
|
+ assert consumed and consumed["token"] == "access"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑测**
|
|
|
+
|
|
|
+Run: `cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py tests/test_wx_qr_session.py tests/test_wxacode.py -v`
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/mini_auth.py backend/tests/test_wx_qr_api.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(api): confirm web QR login from mini program phone auth.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: web-admin API 与加密排除
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/auth.ts`
|
|
|
+- Modify: `web-admin/.env`、`web-admin/.env.development`、`web-admin/.env.production`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 `fetchLogin` 函数之后插入**
|
|
|
+
|
|
|
+```typescript
|
|
|
+export function fetchWxQrCreate() {
|
|
|
+ return request.post<{
|
|
|
+ sessionId: string
|
|
|
+ expiresIn: number
|
|
|
+ qrImageBase64: string
|
|
|
+ }>({
|
|
|
+ url: '/web/auth/wx-qr/create',
|
|
|
+ data: {},
|
|
|
+ headers: { 'X-Api-Crypto': 'off' },
|
|
|
+ showErrorMessage: true
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+export function fetchWxQrStatus(sessionId: string) {
|
|
|
+ return request.get<{
|
|
|
+ status: string
|
|
|
+ message?: string
|
|
|
+ token?: string
|
|
|
+ refreshToken?: string
|
|
|
+ expiresIn?: number
|
|
|
+ userInfo?: {
|
|
|
+ id: string
|
|
|
+ username: string
|
|
|
+ realName: string
|
|
|
+ phone: string
|
|
|
+ role: string
|
|
|
+ roles?: string[]
|
|
|
+ regionId: string
|
|
|
+ regionName: string
|
|
|
+ avatarUrl?: string | null
|
|
|
+ }
|
|
|
+ }>({
|
|
|
+ url: '/web/auth/wx-qr/status',
|
|
|
+ params: { sessionId },
|
|
|
+ headers: { 'X-Api-Crypto': 'off' },
|
|
|
+ showErrorMessage: false
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`userInfo.roles` 对齐山峡现有 `fetchLogin`(不是 zkxw 的 `roleNames`)。
|
|
|
+
|
|
|
+- [ ] **Step 2: 三份 env 的 `VITE_API_ENCRYPT_EXCLUDE_PATHS` 追加**
|
|
|
+
|
|
|
+`,/web/auth/wx-qr/create,/web/auth/wx-qr/status`
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/auth.ts web-admin/.env web-admin/.env.development web-admin/.env.production
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): add wx-qr create and status API clients.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: web-admin 登录页 Tab + 轮询
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/views/auth/login/index.vue`
|
|
|
+
|
|
|
+**约束:** 保留现有 `.login` 全屏背景、`.login-form` 毛玻璃卡、页脚版权。不要改成 zkxw 左右分栏。参考 Demo:`docs/demos/web-admin-login-wx-qr-demo.html`。
|
|
|
+
|
|
|
+- [ ] **Step 1: 模板**
|
|
|
+
|
|
|
+在现有 `<el-form class="login-form">` 内、`title-box` 之后,用 `el-tabs` 包住两块:
|
|
|
+
|
|
|
+- `name="wechat"` label `微信扫码`:`.qr-frame`(200×200)展示 `qrImage`;`.qr-status`;刷新按钮(`Refresh` 图标 + 「刷新二维码」)
|
|
|
+- `name="password"` label `账号密码`:把现有用户名/密码/`登录` 按钮原样移入此 pane
|
|
|
+
|
|
|
+`v-model="loginTab"` 默认 `'wechat'`。副标题放在 `title-box` 下:
|
|
|
+
|
|
|
+```vue
|
|
|
+<p class="login-subtitle">
|
|
|
+ {{ loginTab === 'wechat' ? '微信扫一扫,授权手机号后自动登录' : '请输入账号密码登录系统' }}
|
|
|
+</p>
|
|
|
+```
|
|
|
+
|
|
|
+登录按钮按规范带 `<el-icon><Check /></el-icon>`。
|
|
|
+
|
|
|
+- [ ] **Step 2: script**
|
|
|
+
|
|
|
+从 `@/api/auth` 增加 `fetchWxQrCreate, fetchWxQrStatus`。图标增加 `Refresh, Check`。
|
|
|
+
|
|
|
+状态机与轮询逻辑与 zkxw `login/index.vue` 一致:
|
|
|
+
|
|
|
+- `loginTab` 默认 `'wechat'`
|
|
|
+- `qrStatus`: `creating | pending | scanned | failed | expired | success`
|
|
|
+- `qrStatusText` 文案与 spec §5.1 一致
|
|
|
+- `refreshQr` → `fetchWxQrCreate` → `startPoll`(`setInterval` 1500ms)
|
|
|
+- `pollStatus`:`success` 且带 `token` 时 `stopPoll` 后走与密码登录相同的 `setToken` / `setUserInfo` / `ElNotification` / `resetRouteInitState` / `resolveSafeRedirectTarget`(抽成 `finishLogin`,密码提交也调用)
|
|
|
+- `handleLoginTabChange`:切到 password 停轮询;切回 wechat 时失败/过期/无 session 则 `refreshQr`,否则恢复轮询
|
|
|
+- `onMounted`:`preloadBackgroundImage()` + `refreshQr()`
|
|
|
+- `onUnmounted`:`stopPoll()`
|
|
|
+
|
|
|
+- [ ] **Step 3: scoped 样式**
|
|
|
+
|
|
|
+在现有 `.login-form` 内追加(主色用 `var(--el-color-primary)`,不要写 `#0a9f6d`):
|
|
|
+
|
|
|
+```scss
|
|
|
+.login-subtitle {
|
|
|
+ margin: -18px 0 16px;
|
|
|
+ text-align: center;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #64748b;
|
|
|
+}
|
|
|
+
|
|
|
+.qr-login {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ padding-bottom: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.qr-frame {
|
|
|
+ width: 200px;
|
|
|
+ height: 200px;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #fff;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.qr-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ object-fit: contain;
|
|
|
+}
|
|
|
+
|
|
|
+.qr-status {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #64748b;
|
|
|
+ text-align: center;
|
|
|
+
|
|
|
+ &.error {
|
|
|
+ color: #dc2626;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`:deep(.el-tabs__item.is-active)` 颜色跟现有主色即可,不必硬编码色值。
|
|
|
+
|
|
|
+- [ ] **Step 4: 手工核对**
|
|
|
+
|
|
|
+启动 web-admin 后打开 `/#/auth/login`:默认扫码 Tab;可切到密码并提交(密码回归);离开页不再轮询(Network 无持续 status)。无微信凭证时刷新按钮应显示失败文案而不是白屏。
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/views/auth/login/index.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): default login to WeChat QR with password tab.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: 小程序授权落地页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `miniprogram/pages/login/web-qr/index.vue`
|
|
|
+- Modify: `miniprogram/pages.json`
|
|
|
+- Modify: `miniprogram/config/api.js`
|
|
|
+- Modify: `miniprogram/stores/user.js`
|
|
|
+- Modify: `miniprogram/utils/request.js`
|
|
|
+
|
|
|
+- [ ] **Step 1: `AUTH_API` 增加**
|
|
|
+
|
|
|
+```javascript
|
|
|
+ WEB_QR_SCANNED: '/mini/auth/web-qr-scanned',
|
|
|
+ WEB_QR_CONFIRM: '/mini/auth/web-qr-confirm'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: `request.js` 的 `API_ENCRYPT_EXCLUDE_PATHS` 追加**
|
|
|
+
|
|
|
+`,/mini/auth/web-qr-scanned,/mini/auth/web-qr-confirm`
|
|
|
+
|
|
|
+- [ ] **Step 3: `stores/user.js` 增加两个方法并 export**
|
|
|
+
|
|
|
+`webQrScanned` / `webQrConfirm` **不得**调用 `setLoginInfo`。
|
|
|
+
|
|
|
+```javascript
|
|
|
+ async function webQrScanned(sessionId) {
|
|
|
+ const res = await request({
|
|
|
+ url: AUTH_API.WEB_QR_SCANNED,
|
|
|
+ method: 'POST',
|
|
|
+ data: { sessionId }
|
|
|
+ })
|
|
|
+ const result = parseResponseJson(res)
|
|
|
+ return !!(result && result.code === 200)
|
|
|
+ }
|
|
|
+
|
|
|
+ async function webQrConfirm(params) {
|
|
|
+ const res = await request({
|
|
|
+ url: AUTH_API.WEB_QR_CONFIRM,
|
|
|
+ method: 'POST',
|
|
|
+ data: {
|
|
|
+ sessionId: params.sessionId,
|
|
|
+ loginCode: params.loginCode,
|
|
|
+ phoneCode: params.phoneCode
|
|
|
+ }
|
|
|
+ })
|
|
|
+ const result = parseResponseJson(res)
|
|
|
+ if (result && result.code === 200) {
|
|
|
+ return { ok: true, message: (result.data && result.data.message) || '电脑端即将登录,可关闭本页' }
|
|
|
+ }
|
|
|
+ return { ok: false, message: (result && result.message) || '授权失败' }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+在 return 对象中加入 `webQrScanned, webQrConfirm`。
|
|
|
+
|
|
|
+- [ ] **Step 4: `pages.json` 在 `pages/login/privacy-policy` 之后插入**
|
|
|
+
|
|
|
+```json
|
|
|
+ {
|
|
|
+ "path": "pages/login/web-qr/index",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "网页登录授权",
|
|
|
+ "navigationBarBackgroundColor": "#f3f7f5",
|
|
|
+ "navigationBarTextStyle": "black"
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 新建 `miniprogram/pages/login/web-qr/index.vue`**
|
|
|
+
|
|
|
+行为:
|
|
|
+
|
|
|
+1. `onLoad`:从 `options.scene`(需 `decodeURIComponent`)或 `options.sessionId` 取码;无码则展示「请从电脑登录页重新扫码」。
|
|
|
+2. 有码则调 `userStore.webQrScanned(sessionId)`。
|
|
|
+3. 协议勾选复用登录页模式(用户协议 / 隐私政策已有页:`/pages/login/user-agreement`、`/pages/login/privacy-policy`)。
|
|
|
+4. 按钮 `open-type="getPhoneNumber"`;回调里 `uni.login` 取 `loginCode`,再 `webQrConfirm`。
|
|
|
+5. 成功:文案「电脑端即将登录,可关闭本页」。不 `setLoginInfo`、不 `reLaunch` 工作台。
|
|
|
+6. 失败:展示返回的 `message`。
|
|
|
+7. 主按钮色用三墙绿 `#0a9f6d`,不要用 `#10b981`。
|
|
|
+
|
|
|
+页面结构可精简自 zkxw `mobile-h5/pages/login/web-qr/index.vue`(去掉 zkxw 专有 `ConfirmDialog` 组件名时,用本仓库登录页已有的协议交互,避免引入不存在的组件)。
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add miniprogram/pages/login/web-qr/index.vue miniprogram/pages.json miniprogram/config/api.js miniprogram/stores/user.js miniprogram/utils/request.js
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(miniprogram): add web QR phone confirm page for admin login.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: 图谱与手工验收
|
|
|
+
|
|
|
+**Files:** 无新业务文件(图谱脚本)
|
|
|
+
|
|
|
+- [ ] **Step 1: 更新图谱**
|
|
|
+
|
|
|
+```bash
|
|
|
+./scripts/update-graph.sh all
|
|
|
+```
|
|
|
+
|
|
|
+若脚本失败,分别对 `backend` 与 `web-admin` 执行项目约定的 graphify 更新,并说明 miniprogram 是否在脚本覆盖范围内。
|
|
|
+
|
|
|
+- [ ] **Step 2: 手工验收(对照 spec §7)**
|
|
|
+
|
|
|
+1. 登录页默认扫码 Tab;切到账号密码仍可登录。
|
|
|
+2. 配置 `WECHAT_MP_APPID` / `WECHAT_MP_SECRET` 与 `WX_QR_ENV_VERSION`(开发用 `develop` 或 `trial`)后,create 返回真实小程序码。
|
|
|
+3. 真机扫码 → 授权手机号 → PC 数秒内进入系统;手机不进工作台。
|
|
|
+4. 拒绝授权、过期、刷新二维码:两端文案正确;旧 session 不再成功。
|
|
|
+5. 离开登录页后 status 轮询停止。
|
|
|
+
|
|
|
+未配置微信凭证时,只验证失败提示与密码 Tab,并在交付说明中写明未做真机扫码。
|
|
|
+
|
|
|
+- [ ] **Step 3: 若图谱有变更则单独提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/graphify-out web-admin/graphify-out miniprogram/graphify-out
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+chore: update graphify after wx-qr web login.
|
|
|
+
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+仅当这些目录确有 diff 时提交;没有 diff 则跳过。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage
|
|
|
+
|
|
|
+| Spec | Task |
|
|
|
+|------|------|
|
|
|
+| §2 主流程 | Task 4、5、7、8 |
|
|
|
+| §3 状态机 / 一次性消费 / 短码 | Task 2、4、5 |
|
|
|
+| §4 API | Task 4、5 |
|
|
|
+| §4.5 复用 `resolve_wechat_login_user` + `issue_web_login_tokens` + `WECHAT_MP_*` | Task 3、5 |
|
|
|
+| §5.1 山峡壳 + Tab + 轮询 | Task 6、7 |
|
|
|
+| §5.2 小程序确认页 | Task 8 |
|
|
|
+| §5.3 失败文案 | Task 5(后端 message)+ Task 7/8 展示 |
|
|
|
+| §6 配置与加密排除 | Task 1、6、8 |
|
|
|
+| §7 验收 | Task 9 |
|
|
|
+| 无新业务表 | 不建表 |
|
|
|
+
|
|
|
+## Self-review notes
|
|
|
+
|
|
|
+- 发票函数统一为同步 `issue_web_login_tokens`,测试不使用 zkxw 的 `AsyncMock` + `issue_tokens_for_user`。
|
|
|
+- 前端 `userInfo` 字段用山峡的 `roles`,不用 `roleNames`。
|
|
|
+- 确认页目录是 `miniprogram/pages/login/web-qr/index.vue`,不是 `mobile-h5`。
|