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(或内存)保存短时扫码会话;create 生成小程序码;手机打开 pages/login/web-qr 授权后 confirm 复用 resolve_wechat_login_user + issue_tokens_for_user;web-admin 轮询 status 一次性取走 JWT。
Tech Stack: FastAPI、Redis、微信小程序码 API、Vue 3(web-admin)、uni-app(mobile-h5)
Spec: docs/superpowers/specs/2026-09-18-web-admin-wx-qr-phone-login-design.md
| 路径 | 职责 |
|---|---|
backend/app/core/config.py |
WX_QR_* 配置;API_ENCRYPT_EXCLUDE_PATHS 追加路径 |
backend/app/services/wx_qr_session.py |
扫码会话 CRUD、状态机、一次性消费 |
backend/app/services/wechat_mini_auth.py |
新增 get_unlimited_wxacode |
backend/app/api/public.py |
`POST/GET /web/auth/wx-qr/create |
backend/app/api/mini_auth.py |
web-qr-scanned / web-qr-confirm |
backend/tests/test_wx_qr_session.py |
会话状态机单测 |
backend/tests/test_wx_qr_api.py |
create/status/confirm 接口测(mock 微信) |
web-admin/src/api/auth.ts |
fetchWxQrCreate / fetchWxQrStatus |
web-admin/src/views/auth/login/index.vue |
扫码主 UI + 密码折叠 + 轮询 |
mobile-h5/pages/login/web-qr/index.vue |
授权落地页 |
mobile-h5/pages.json |
注册新页 |
mobile-h5/utils/auth.js |
webQrScanned / webQrConfirm |
mobile-h5/utils/request.js |
未登录路径白名单(若需要) |
不修改: 支付逻辑、开放平台网站应用、自动注册用户。
Files:
Modify: backend/app/core/config.py
[ ] Step 1: 增加扫码会话配置并扩展排除路径
在 Settings 中、API_ENCRYPT_EXCLUDE_PATHS 附近增加:
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"
将 API_ENCRYPT_EXCLUDE_PATHS 字符串追加(注意逗号分隔、无多余空格导致匹配失败;与现有风格一致):
/api/web/auth/wx-qr/create,
/api/web/auth/wx-qr/status,
/api/mini/auth/web-qr-scanned,
/api/mini/auth/web-qr-confirm,
git add backend/app/core/config.py
git commit -m "$(cat <<'EOF'
chore: add wx-qr login config and encrypt exclude paths
EOF
)"
Files:
backend/app/services/wx_qr_session.pyCreate: backend/tests/test_wx_qr_session.py
[ ] Step 1: 写失败单测
"""wx_qr_session 状态机与一次性消费。"""
import time
from unittest.mock import patch
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)
assert s.get_session(sid) is None or s.get_session(sid)["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"
Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v
Expected: FAIL(模块不存在)
wx_qr_session.py"""web-admin 扫码登录会话:短码映射 + 状态机;优先 Redis,否则进程内存。"""
from __future__ import annotations
import json
import secrets
import time
import uuid
from typing import Any, Dict, Optional
from app.core.config import settings
_PREFIX = "wxqr:sess:"
_SHORT_PREFIX = "wxqr:short:"
_memory: Dict[str, Dict[str, Any]] = {}
_short_map: Dict[str, str] = {}
def _memory_clear_for_tests() -> None:
_memory.clear()
_short_map.clear()
def _ttl() -> int:
return int(getattr(settings, "WX_QR_SESSION_TTL_SEC", 180) or 180)
def _redis():
"""复用 auth 侧 Redis 客户端模式;无 REDIS_HOST 返回 None。"""
host = (settings.REDIS_HOST or "").strip()
if not host:
return None
try:
import redis
return redis.Redis(
host=host,
port=int(settings.REDIS_PORT or 6379),
db=int(settings.REDIS_DB or 0),
password=settings.REDIS_PASSWORD or None,
decode_responses=True,
socket_timeout=2,
)
except Exception:
return None
def create_session(*, ttl_sec: Optional[int] = None) -> tuple:
ttl = int(ttl_sec if ttl_sec is not None else _ttl())
session_id = str(uuid.uuid4())
short = secrets.token_urlsafe(16)[:32]
now = int(time.time())
data = {
"sessionId": session_id,
"shortCode": short,
"status": "pending",
"message": "",
"createdAt": now,
"expiresAt": now + ttl,
"tokens": None,
}
r = _redis()
if r:
pipe = r.pipeline()
pipe.setex(_PREFIX + session_id, ttl, json.dumps(data, ensure_ascii=False))
pipe.setex(_SHORT_PREFIX + short, ttl, session_id)
pipe.execute()
else:
_memory[session_id] = data
_short_map[short] = session_id
return session_id, short
def resolve_session_id(short_or_id: str) -> Optional[str]:
key = (short_or_id or "").strip()
if not key:
return None
r = _redis()
if r:
sid = r.get(_SHORT_PREFIX + key)
if sid:
return sid
if r.exists(_PREFIX + key):
return key
return None
if key in _short_map:
return _short_map[key]
if key in _memory:
return key
return None
def get_session(session_id: str) -> Optional[Dict[str, Any]]:
sid = (session_id or "").strip()
if not sid:
return None
r = _redis()
raw = None
if r:
raw = r.get(_PREFIX + sid)
if not raw:
return None
data = json.loads(raw)
else:
data = _memory.get(sid)
if not data:
return None
data = dict(data)
if int(time.time()) >= int(data.get("expiresAt") or 0):
data["status"] = "expired"
return data
return data
def _save(data: Dict[str, Any]) -> None:
sid = data["sessionId"]
ttl = max(1, int(data["expiresAt"]) - int(time.time()))
r = _redis()
if r:
r.setex(_PREFIX + sid, ttl, json.dumps(data, ensure_ascii=False))
else:
_memory[sid] = data
def mark_scanned(session_id: str) -> bool:
data = get_session(session_id)
if not data or data["status"] in ("expired", "success", "consumed", "failed"):
return False
if data["status"] == "pending":
data["status"] = "scanned"
_save(data)
return True
def mark_success(session_id: str, tokens: Dict[str, Any]) -> bool:
data = get_session(session_id)
if not data or data["status"] in ("expired", "consumed", "failed"):
return False
data["status"] = "success"
data["tokens"] = tokens
data["message"] = ""
_save(data)
return True
def mark_failed(session_id: str, message: str) -> bool:
data = get_session(session_id)
if not data or data["status"] in ("expired", "consumed", "success"):
return False
data["status"] = "failed"
data["message"] = message or "登录失败"
data["tokens"] = None
_save(data)
return True
def consume_success(session_id: str) -> Optional[Dict[str, Any]]:
data = get_session(session_id)
if not data or data["status"] != "success":
return None
tokens = data.get("tokens")
data["status"] = "consumed"
data["tokens"] = None
_save(data)
return tokens
def invalidate(session_id: str) -> None:
data = get_session(session_id)
if not data:
return
data["status"] = "expired"
data["tokens"] = None
_save(data)
注意:内存模式下 expiresAt 到期时 get_session 返回 status=expired;单测 test_expire_old_session 接受 None 或 expired。
Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v
Expected: PASS
git add backend/app/services/wx_qr_session.py backend/tests/test_wx_qr_session.py
git commit -m "$(cat <<'EOF'
feat: add wx-qr login session store with one-time token consume
EOF
)"
Files:
backend/app/services/wechat_mini_auth.pyCreate: backend/tests/test_wxacode.py
[ ] Step 1: 写失败单测(mock httpx)
from unittest.mock import MagicMock, patch
from app.services.wechat_mini_auth import get_unlimited_wxacode, WechatMiniAuthError
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
Run: cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v
Expected: FAIL(函数未定义)
wechat_mini_auth.py 增加函数在文件末尾追加(复用 _get_access_token):
_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 导入。
Run: cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v
Expected: PASS
git add backend/app/services/wechat_mini_auth.py backend/tests/test_wxacode.py
git commit -m "$(cat <<'EOF'
feat: add WeChat unlimited mini program code generator
EOF
)"
Files:
backend/app/api/public.pyCreate: backend/tests/test_wx_qr_api.py
[ ] Step 1: 写接口测(mock 会话与小程序码)
"""web wx-qr create/status API。"""
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_wx_qr_create_and_poll_pending():
with 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 or body.get("success") is True or "data" in body
data = body.get("data") or body
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
st_data = (st.json().get("data") or st.json())
assert st_data["status"] == "pending"
(若项目 success_response 结构为 {code, msg, data},断言以实际为准;与现有 tests/ 中 TestClient 用法对齐。若无 TestClient 先例,可改为直接调用路由函数 + Depends override。)
先查看现有 API 测试写法;若无 TestClient,本 Task 改为纯函数测:抽 create_wx_qr_payload() / status_payload() 再测。
推荐最小可测路径:在 public.py 内调用 service,单测 mock get_unlimited_wxacode 与 wx_qr_session。
public.py 增加路由import base64
from app.services import wx_qr_session
from app.services.wechat_mini_auth import get_unlimited_wxacode, WechatMiniAuthError
@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
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})
导入 settings 若尚未从 config 引入则补上。
Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py tests/test_wx_qr_session.py -v
Expected: PASS
git add backend/app/api/public.py backend/tests/test_wx_qr_api.py
git commit -m "$(cat <<'EOF'
feat: add web auth wx-qr create and status endpoints
EOF
)"
Files:
backend/app/api/mini_auth.pyModify: backend/tests/test_wx_qr_api.py(追加用例)
[ ] Step 1: 追加单测(mock 微信与发票)
def test_web_qr_confirm_success_sets_session():
from app.services import wx_qr_session
wx_qr_session._memory_clear_for_tests()
sid, short = wx_qr_session.create_session(ttl_sec=60)
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.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_tokens_for_user", return_value=tokens):
r = client.post(
"/api/mini/auth/web-qr-confirm",
json={"sessionId": sid, "loginCode": "lc", "phoneCode": "pc"},
)
assert r.status_code == 200
consumed = wx_qr_session.consume_success(sid)
assert consumed and consumed["token"] == "access"
(SimpleNamespace 从 types 导入;client 同 Task 4。若路由尚未注册导致 404,先实现再跑。)
mini_auth.py 增加路由from app.services import wx_qr_session
@router.post("/web-qr-scanned", response_model=dict)
async 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)
async 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 = await issue_tokens_for_user(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": "电脑端即将登录,可关闭本页"})
Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py tests/test_wx_qr_session.py -v
Expected: PASS
git add backend/app/api/mini_auth.py backend/tests/test_wx_qr_api.py
git commit -m "$(cat <<'EOF'
feat: add mini auth web-qr scanned and confirm endpoints
EOF
)"
Files:
web-admin/src/api/auth.tsModify: web-admin/src/views/auth/login/index.vue
[ ] Step 1: 在 auth.ts 增加接口
export function fetchWxQrCreate() {
return request.post<{
sessionId: string
expiresIn: number
qrImageBase64: string
}>({
url: '/web/auth/wx-qr/create',
data: {}
})
}
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
roleNames?: string[]
regionId: string
regionName: string
avatarUrl?: string | null
}
}>({
url: '/web/auth/wx-qr/status',
params: { sessionId }
})
}
要点(在现有 index.vue 上改,保留 RSA 密码登录逻辑):
<img :src="qrImage" /> + 状态文案 +「刷新二维码」按钮(带 Refresh 图标)。showPasswordLogin 默认 false,点击展开现有表单。onMounted → refreshQr();onUnmounted → clearInterval。refreshQr:调 fetchWxQrCreate,设 sessionId/qrImage,每 1500ms 调 fetchWxQrStatus。status===success':停轮询,userStore.setToken / setUserInfo / 通知 / router.push(复用现有成功分支)。failed/expired:展示 message;expired 可自动 refreshQr(注意防抖,避免死循环——仅自动一次或提示手动刷新)。样式:扫码区居中;沿用现有 .login-right Fresh Mint 色;密码区折叠用简单 v-show。
Run: cd web-admin && pnpm dev
手动打开登录页:应见到二维码请求(后端未起或微信未配时 create 可能失败——页面应 ElMessage 报错且可点刷新;密码登录仍可用)。
git add web-admin/src/api/auth.ts web-admin/src/views/auth/login/index.vue
git commit -m "$(cat <<'EOF'
feat: make web-admin login QR-primary with password fallback
EOF
)"
Files:
mobile-h5/pages/login/web-qr/index.vuemobile-h5/pages.jsonmobile-h5/utils/auth.jsModify: mobile-h5/utils/request.js(若有路径白名单)
[ ] Step 1: auth.js 增加方法
export async function webQrScanned(sessionId) {
return post('/mini/auth/web-qr-scanned', { sessionId })
}
export async function webQrConfirm({ sessionId, loginCode, phoneCode }) {
return post('/mini/auth/web-qr-confirm', {
sessionId,
loginCode,
phoneCode
})
}
在 request.js 的未加密/白名单列表中加入 /mini/auth/web-qr-scanned、/mini/auth/web-qr-confirm(与 wechat-login 同级)。
在 pages.json 的 pages/login/index 后插入:
{
"path": "pages/login/web-qr/index",
"style": {
"navigationBarTitleText": "网页登录授权",
"backgroundColor": "#ffffff"
}
}
pages/login/web-qr/index.vue最小结构:
onLoad(options):sessionId = options.scene || options.sessionId(微信码进入时多为 scene)。webQrScanned(sessionId)(失败可忽略文案)。open-type="getPhoneNumber";@getphonenumber 内 uni.login 取 loginCode,再 webQrConfirm。uni.showToast / 页内成功态「电脑端即将登录,可关闭本页」——不要 saveSession、不要 switchTab 首页。协议:若现登录页强制同意,复用 ConfirmDialog + getPhoneNumber 同次点击模式(对齐 pages/login/index.vue)。
[ ] Step 4: 开发者工具冒烟
用开发者工具编译;手动 navigateTo /pages/login/web-qr/index?sessionId=测试 看缺会话/错误态;真机扫码联调放在 Task 8。
git add mobile-h5/pages/login/web-qr/index.vue mobile-h5/pages.json mobile-h5/utils/auth.js mobile-h5/utils/request.js
git commit -m "$(cat <<'EOF'
feat: add mini program page for web-admin QR phone login
EOF
)"
Files:
无必须代码;按需微调文案/TTL
[ ] Step 1: 环境检查清单
WXPAY_MINI_APPID / WXPAY_SECRET 已配
REDIS_HOST 建议生产开启
WX_QR_ENV_VERSION:开发用 develop/trial,生产 release
小程序已包含并上传 pages/login/web-qr/index
后台用户 phone_no 与微信绑定手机一致
[ ] Step 2: 对照 spec §7 验收
逐条勾选设计文档验收标准 1–7。
./scripts/update-graph.sh all
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after wx-qr login changes
EOF
)"
| Spec 项 | Task |
|---|---|
| 扫码会话状态机 + 一次性消费 | Task 2 |
| 小程序码生成 | Task 3 |
| `/web/auth/wx-qr/create | status` |
/mini/auth/web-qr-* + 复用手机号匹配 |
Task 5 |
| web-admin 主扫码辅密码 | Task 6 |
| mobile-h5 授权页不强制登录工作台 | Task 7 |
| 配置 / 排除路径 / 发版注意 | Task 1、8 |
| 不自动注册、非经典网页扫码 | 全任务未引入 |
sessionId(完整 UUID)供网页轮询;short(≤32)写入小程序码 scene;confirm 支持二者经 resolve_session_id。status 消费一次;小程序 confirm 不返回 JWT。