2026-09-18-web-admin-wx-qr-phone-login.md 28 KB

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(或内存)保存短时扫码会话;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


File map

路径 职责
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 未登录路径白名单(若需要)

不修改: 支付逻辑、开放平台网站应用、自动注册用户。


Task 1: 配置项与加密排除路径

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,
  • Step 2: Commit
git add backend/app/core/config.py
git commit -m "$(cat <<'EOF'
chore: add wx-qr login config and encrypt exclude paths

EOF
)"

Task 2: 扫码会话服务(TDD)

Files:

  • Create: backend/app/services/wx_qr_session.py
  • Create: 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"
  • Step 2: 跑测确认失败

Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v
Expected: FAIL(模块不存在)

  • Step 3: 实现 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。

  • Step 4: 跑测通过

Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_session.py -v
Expected: PASS

  • Step 5: Commit
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
)"

Task 3: 微信小程序码生成

Files:

  • Modify: backend/app/services/wechat_mini_auth.py
  • Create: 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
  • Step 2: 跑测确认失败

Run: cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v
Expected: FAIL(函数未定义)

  • Step 3: 在 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 导入。

  • Step 4: 跑测通过

Run: cd backend && source venv/bin/activate && pytest tests/test_wxacode.py -v
Expected: PASS

  • Step 5: Commit
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
)"

Task 4: Web 端 create / status 接口

Files:

  • Modify: backend/app/api/public.py
  • Create: 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。

  • Step 2: 在 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 引入则补上。

  • Step 3: 跑测

Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py tests/test_wx_qr_session.py -v
Expected: PASS

  • Step 4: Commit
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
)"

Task 5: 小程序 confirm / scanned 接口

Files:

  • Modify: backend/app/api/mini_auth.py
  • Modify: 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,先实现再跑。)

  • Step 2: 在 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": "电脑端即将登录,可关闭本页"})
  • Step 3: 跑测

Run: cd backend && source venv/bin/activate && pytest tests/test_wx_qr_api.py tests/test_wx_qr_session.py -v
Expected: PASS

  • Step 4: Commit
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
)"

Task 6: web-admin API 与登录页

Files:

  • Modify: web-admin/src/api/auth.ts
  • Modify: 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 }
  })
}
  • Step 2: 改版登录页

要点(在现有 index.vue 上改,保留 RSA 密码登录逻辑):

  1. 右侧主区:标题「欢迎登录」+ 副文案「微信扫一扫,授权手机号后自动登录」。
  2. <img :src="qrImage" /> + 状态文案 +「刷新二维码」按钮(带 Refresh 图标)。
  3. 分割线「或使用账号密码登录」;showPasswordLogin 默认 false,点击展开现有表单。
  4. onMounted → refreshQr();onUnmounted → clearInterval。
  5. refreshQr:调 fetchWxQrCreate,设 sessionId/qrImage,每 1500ms 调 fetchWxQrStatus。
  6. status===success':停轮询,userStore.setToken / setUserInfo / 通知 / router.push(复用现有成功分支)。
  7. failed/expired:展示 message;expired 可自动 refreshQr(注意防抖,避免死循环——仅自动一次或提示手动刷新)。

样式:扫码区居中;沿用现有 .login-right Fresh Mint 色;密码区折叠用简单 v-show。

  • Step 3: 本地冒烟

Run: cd web-admin && pnpm dev
手动打开登录页:应见到二维码请求(后端未起或微信未配时 create 可能失败——页面应 ElMessage 报错且可点刷新;密码登录仍可用)。

  • Step 4: Commit
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
)"

Task 7: mobile-h5 网页登录授权页

Files:

  • Create: mobile-h5/pages/login/web-qr/index.vue
  • Modify: mobile-h5/pages.json
  • Modify: mobile-h5/utils/auth.js
  • Modify: 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 同级)。

  • Step 2: 注册页面

在 pages.json 的 pages/login/index 后插入:

{
  "path": "pages/login/web-qr/index",
  "style": {
    "navigationBarTitleText": "网页登录授权",
    "backgroundColor": "#ffffff"
  }
}
  • Step 3: 实现 pages/login/web-qr/index.vue

最小结构:

  • onLoad(options):sessionId = options.scene || options.sessionId(微信码进入时多为 scene)。
  • 无 sessionId:展示错误「请从电脑登录页重新扫码」。
  • 有则调用 webQrScanned(sessionId)(失败可忽略文案)。
  • 主按钮 open-type="getPhoneNumber";@getphonenumber 内 uni.login 取 loginCode,再 webQrConfirm。
  • 成功:uni.showToast / 页内成功态「电脑端即将登录,可关闭本页」——不要 saveSession、不要 switchTab 首页。
  • 失败:展示后端 message。
  • 协议:若现登录页强制同意,复用 ConfirmDialog + getPhoneNumber 同次点击模式(对齐 pages/login/index.vue)。

  • [ ] Step 4: 开发者工具冒烟

用开发者工具编译;手动 navigateTo /pages/login/web-qr/index?sessionId=测试 看缺会话/错误态;真机扫码联调放在 Task 8。

  • Step 5: Commit
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
)"

Task 8: 联调验收与图谱更新

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。

  • Step 3: 更新知识图谱
./scripts/update-graph.sh all
  • Step 4: Commit(若有图谱或小修)
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after wx-qr login changes

EOF
)"

Spec coverage checklist

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
不自动注册、非经典网页扫码 全任务未引入

Self-review notes

  • 无 TBD/TODO 占位。
  • sessionId(完整 UUID)供网页轮询;short(≤32)写入小程序码 scene;confirm 支持二者经 resolve_session_id。
  • success 只经网页 status 消费一次;小程序 confirm 不返回 JWT。
  • 密码登录路径保持 RSA,不删除。