docs: add implementation plan for web-admin WeChat QR login.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
ef8b59d23c
commit
b438495b1d
1 file changed
+911
@@ -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`。
|
||||
Reference in new issue
Block a user