diff --git a/docs/superpowers/plans/2026-09-18-miniprogram-phone-quick-login.md b/docs/superpowers/plans/2026-09-18-miniprogram-phone-quick-login.md
new file mode 100644
index 000000000..cbe3583b1
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-18-miniprogram-phone-quick-login.md
@@ -0,0 +1,1123 @@
+# 山峡小程序「手机号快捷登录」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:** 在山峡 `miniprogram` 登录页增加「手机号快捷登录」,后端用微信 `getuserphonenumber` + `phone_no` 匹配员工并绑定 `wx_openid`,签发与密码登录相同的 JWT;密码登录保持不变。
+
+**Architecture:** 移植 zkxw 的自建后端链路(非云开发):前端 `getPhoneNumber` + `uni.login` → `POST /api/mini/auth/wechat-login` → `jscode2session` + `getuserphonenumber` → 匹配/绑号 → 复用 Web 登录发 JWT。凭证使用独立 `WECHAT_MP_APPID` / `WECHAT_MP_SECRET`,不改动 `WXPAY_*`。本期不做密码登录后静默绑号、不做仅 openid 静默换票。
+
+**Tech Stack:** FastAPI + SQLAlchemy + httpx;uni-app Vue3 小程序;MySQL 5.7
+
+**Spec:** `docs/superpowers/specs/2026-09-18-miniprogram-phone-quick-login-design.md`
+**参考:** `/Users/zlwu/work/00code/zkxw/backend/app/services/wechat_mini_auth.py`、`wechat_login_service.py`、`api/mini_auth.py`;`/Users/zlwu/work/00code/zkxw/mobile-h5/pages/login/index.vue`、`styles/components/login.scss`
+
+---
+
+## 文件结构
+
+| 路径 | 职责 |
+|------|------|
+| `docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql` | `sys_user` 增列增量 DDL |
+| `docs/sql/shanxia2.0.sql` | 基线 `CREATE TABLE sys_user` 同步两列 + 索引(仅改表结构定义段,勿重导全量数据) |
+| `backend/app/models/models.py` | `SysUser.wx_openid` / `wx_unionid` |
+| `backend/app/core/config.py` | `WECHAT_MP_*`;加密排除路径 |
+| `backend/.env.example` | 占位说明(无真实 Secret) |
+| `backend/app/crud/user.py` | `get_by_wx_openid`、`list_active_by_phone_no` |
+| `backend/app/services/wechat_mini_auth.py` | code2session、get_phone_number、规范化 |
+| `backend/app/services/wechat_login_service.py` | 匹配/绑号(仅 phoneCode 路径) |
+| `backend/app/services/web_login_tokens.py` | 从 `public.py` 抽出的签发 JWT 公共函数 |
+| `backend/app/api/public.py` | 密码登录改为调用 `issue_web_login_tokens` |
+| `backend/app/api/mini_auth.py` | `POST /mini/auth/wechat-login` |
+| `backend/app/api/__init__.py` + `backend/app/main.py` | 注册路由 |
+| `backend/tests/test_wechat_mini_auth_phone.py` | 规范化 / phones_match |
+| `backend/tests/test_wechat_login_resolve.py` | resolve 分支单测 |
+| `miniprogram/config/api.js` | `AUTH_API.WECHAT_LOGIN` |
+| `miniprogram/utils/request.js` | 排除路径含 wechat-login(加密开启时) |
+| `miniprogram/stores/user.js` | `wechatLogin` |
+| `miniprogram/pages/login/login.vue` | UI + getPhoneNumber + 协议门禁 |
+
+---
+
+### Task 1: DDL + ORM 字段
+
+**Files:**
+- Create: `docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql`
+- Modify: `docs/sql/shanxia2.0.sql`(`CREATE TABLE \`sys_user\`` 段,约 35650–35674 行)
+- Modify: `backend/app/models/models.py`(`SysUser`)
+
+- [ ] **Step 1: 写增量迁移 SQL**
+
+`docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql`:
+
+```sql
+-- 山峡小程序手机号快捷登录:绑定微信 openid
+ALTER TABLE `sys_user`
+ ADD COLUMN `wx_openid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信小程序 openid' AFTER `avatar`,
+ ADD COLUMN `wx_unionid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信 unionid' AFTER `wx_openid`;
+
+ALTER TABLE `sys_user`
+ ADD UNIQUE KEY `uk_sys_user_wx_openid` (`wx_openid`);
+```
+
+- [ ] **Step 2: 同步基线 CREATE TABLE**
+
+在 `docs/sql/shanxia2.0.sql` 的 `sys_user` 建表语句中,于 `` `avatar` `` 后增加:
+
+```sql
+ `wx_openid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信小程序 openid',
+ `wx_unionid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信 unionid',
+```
+
+并将表尾 KEY 段改为包含:
+
+```sql
+ UNIQUE KEY `uk_sys_user_wx_openid` (`wx_openid`),
+ KEY `idx_user_dept` (`dept_id`,`del_flag`) USING BTREE
+```
+
+(勿改动该文件中已有 INSERT 数据行。)
+
+- [ ] **Step 3: ORM 增列**
+
+在 `SysUser` 中 `avatar` 后增加:
+
+```python
+ wx_openid = Column(String(64), nullable=True, comment="微信小程序 openid")
+ wx_unionid = Column(String(64), nullable=True, comment="微信 unionid")
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql docs/sql/shanxia2.0.sql backend/app/models/models.py
+git commit -m "$(cat <<'EOF'
+feat(db): add sys_user wx_openid columns for mini phone login.
+
+EOF
+)"
+```
+
+---
+
+### Task 2: 配置项 `WECHAT_MP_*` + 加密排除
+
+**Files:**
+- Modify: `backend/app/core/config.py`
+- Modify: `backend/.env.example`
+
+- [ ] **Step 1: 在 `config.py` 增加配置**
+
+在 `# 微信小程序配置` / `WXPAY_SECRET` 附近(支付块之后)新增独立门户小程序配置:
+
+```python
+ # 门户微信小程序登录(与 WXPAY_* 支付凭证分离)
+ WECHAT_MP_APPID: str = "" # 门户小程序 AppID,须与 miniprogram manifest 一致
+ WECHAT_MP_SECRET: str = "" # 门户小程序 AppSecret(仅后端)
+```
+
+在 `API_ENCRYPT_EXCLUDE_PATHS` 字符串中,紧接 `/api/web/auth/login,` 后加入:
+
+```text
+/api/mini/auth/wechat-login,
+```
+
+- [ ] **Step 2: `.env.example` 占位**
+
+追加(无真实 Secret):
+
+```bash
+# 门户小程序登录(AppID 与 miniprogram/manifest.json mp-weixin.appid 一致)
+# WECHAT_MP_APPID=wx7db1ddb2a90c9425
+# WECHAT_MP_SECRET=
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/app/core/config.py backend/.env.example
+git commit -m "$(cat <<'EOF'
+feat(config): add WECHAT_MP credentials for mini wechat login.
+
+EOF
+)"
+```
+
+---
+
+### Task 3: CRUD 查询 + 手机号规范化单测(TDD)
+
+**Files:**
+- Create: `backend/tests/test_wechat_mini_auth_phone.py`
+- Create: `backend/app/services/wechat_mini_auth.py`
+- Modify: `backend/app/crud/user.py`
+
+- [ ] **Step 1: 写失败单测(规范化)**
+
+`backend/tests/test_wechat_mini_auth_phone.py`:
+
+```python
+from app.services.wechat_mini_auth import normalize_cn_mobile, phones_match
+
+
+def test_normalize_strips_plus86():
+ assert normalize_cn_mobile("+8613800138000") == "13800138000"
+
+
+def test_normalize_strips_86_prefix_13_digits():
+ assert normalize_cn_mobile("8613800138000") == "13800138000"
+
+
+def test_phones_match_equal():
+ assert phones_match("13800138000", "13800138000") is True
+
+
+def test_phones_match_normalizes():
+ assert phones_match("13800138000", "+8613800138000") is True
+
+
+def test_phones_match_mismatch():
+ assert phones_match("13800138000", "13900139000") is False
+
+
+def test_phones_match_empty_fails():
+ assert phones_match("", "13800138000") is False
+ assert phones_match(None, "13800138000") is False
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v
+```
+
+Expected: `ModuleNotFoundError` 或 import 失败。
+
+- [ ] **Step 3: 实现 `wechat_mini_auth.py`**
+
+从 zkxw 移植,但凭证改为 `WECHAT_MP_APPID` / `WECHAT_MP_SECRET`:
+
+```python
+"""微信小程序登录:code2session、手机号快速验证。"""
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any, Dict, Optional
+
+import httpx
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+_WX_CODE2SESSION = "https://api.weixin.qq.com/sns/jscode2session"
+_WX_TOKEN = "https://api.weixin.qq.com/cgi-bin/token"
+_WX_PHONE = "https://api.weixin.qq.com/wxa/business/getuserphonenumber"
+
+
+class WechatMiniAuthError(Exception):
+ def __init__(self, message: str, *, wx_errcode: Optional[int] = None):
+ super().__init__(message)
+ self.wx_errcode = wx_errcode
+
+
+def normalize_cn_mobile(raw: Optional[str]) -> str:
+ if raw is None:
+ return ""
+ s = re.sub(r"\s+", "", str(raw).strip())
+ if s.startswith("+86"):
+ s = s[3:]
+ elif s.startswith("86") and len(s) == 13:
+ s = s[2:]
+ return s
+
+
+def phones_match(account_phone: Optional[str], authorized_phone: Optional[str]) -> bool:
+ a = normalize_cn_mobile(account_phone)
+ b = normalize_cn_mobile(authorized_phone)
+ return bool(a) and bool(b) and a == b
+
+
+def _require_mini_credentials() -> tuple:
+ appid = (settings.WECHAT_MP_APPID or "").strip()
+ secret = (settings.WECHAT_MP_SECRET or "").strip()
+ if not appid or not secret:
+ raise WechatMiniAuthError("未配置小程序凭证(WECHAT_MP_APPID / WECHAT_MP_SECRET)")
+ return appid, secret
+
+
+def code2session(login_code: str) -> Dict[str, Any]:
+ code = (login_code or "").strip()
+ if not code:
+ raise WechatMiniAuthError("loginCode 不能为空")
+ appid, secret = _require_mini_credentials()
+ with httpx.Client(timeout=15.0) as client:
+ r = client.get(
+ _WX_CODE2SESSION,
+ params={
+ "appid": appid,
+ "secret": secret,
+ "js_code": code,
+ "grant_type": "authorization_code",
+ },
+ )
+ r.raise_for_status()
+ data = r.json()
+ if data.get("errcode"):
+ logger.warning("code2session failed: %s", data)
+ raise WechatMiniAuthError(
+ "微信登录凭证无效或已过期", wx_errcode=data.get("errcode")
+ )
+ openid = data.get("openid")
+ if not openid:
+ raise WechatMiniAuthError("微信未返回 openid")
+ return {
+ "openid": openid,
+ "session_key": data.get("session_key"),
+ "unionid": data.get("unionid"),
+ }
+
+
+def _get_access_token() -> str:
+ appid, secret = _require_mini_credentials()
+ with httpx.Client(timeout=15.0) as client:
+ r = client.get(
+ _WX_TOKEN,
+ params={
+ "grant_type": "client_credential",
+ "appid": appid,
+ "secret": secret,
+ },
+ )
+ r.raise_for_status()
+ data = r.json()
+ token = data.get("access_token")
+ if not token:
+ logger.warning("get access_token failed: %s", data)
+ raise WechatMiniAuthError(
+ "获取微信 access_token 失败", wx_errcode=data.get("errcode")
+ )
+ return token
+
+
+def get_phone_number(phone_code: str) -> str:
+ code = (phone_code or "").strip()
+ if not code:
+ raise WechatMiniAuthError("phoneCode 不能为空")
+ token = _get_access_token()
+ with httpx.Client(timeout=15.0) as client:
+ r = client.post(
+ f"{_WX_PHONE}?access_token={token}",
+ json={"code": code},
+ )
+ r.raise_for_status()
+ data = r.json()
+ if data.get("errcode") not in (0, None):
+ logger.warning("getuserphonenumber failed: %s", data)
+ raise WechatMiniAuthError(
+ "获取手机号失败,请改用账号密码登录",
+ wx_errcode=data.get("errcode"),
+ )
+ info = data.get("phone_info") or {}
+ phone = normalize_cn_mobile(
+ info.get("purePhoneNumber") or info.get("phoneNumber")
+ )
+ if not phone:
+ raise WechatMiniAuthError("微信未返回手机号")
+ return phone
+```
+
+- [ ] **Step 4: CRUD 方法**
+
+在 `_UserCRUDCompat` 中增加:
+
+```python
+ def get_by_wx_openid(self, db: Session, wx_openid: str) -> Optional[SysUser]:
+ oid = (wx_openid or "").strip()
+ if not oid:
+ return None
+ return (
+ db.query(SysUser)
+ .filter(SysUser.wx_openid == oid, SysUser.del_flag == 0)
+ .first()
+ )
+
+ def list_active_by_phone_no(self, db: Session, phone_no: str) -> List[SysUser]:
+ from app.services.wechat_mini_auth import normalize_cn_mobile
+
+ phone = normalize_cn_mobile(phone_no)
+ if not phone:
+ return []
+ rows = (
+ db.query(SysUser)
+ .filter(SysUser.del_flag == 0, SysUser.status == "1")
+ .all()
+ )
+ return [u for u in rows if normalize_cn_mobile(getattr(u, "phone_no", None)) == phone]
+```
+
+说明:`sys_user.phone_no` 为 varchar(11),多数已是 11 位;用规范化过滤可兼容库内偶发空格。若后续数据量大,可改为 SQL `phone_no == phone` 精确匹配并保证入库规范化。
+
+- [ ] **Step 5: 跑通单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/services/wechat_mini_auth.py backend/app/crud/user.py backend/tests/test_wechat_mini_auth_phone.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add wechat mini auth helpers and user phone lookups.
+
+EOF
+)"
+```
+
+---
+
+### Task 4: `resolve_wechat_login_user` + 单测(TDD)
+
+**Files:**
+- Create: `backend/tests/test_wechat_login_resolve.py`
+- Create: `backend/app/services/wechat_login_service.py`
+
+- [ ] **Step 1: 写失败单测**
+
+`backend/tests/test_wechat_login_resolve.py`:
+
+```python
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from app.services.wechat_login_service import resolve_wechat_login_user
+
+
+def test_openid_bound_phone_mismatch_rejects():
+ bound = SimpleNamespace(id="u1", phone_no="13800138000", wx_openid="oid-a")
+ db = MagicMock()
+ with patch(
+ "app.services.wechat_login_service.user_crud.get_by_wx_openid",
+ return_value=bound,
+ ):
+ user, err, code = resolve_wechat_login_user(
+ db, openid="oid-a", phone="13900139000"
+ )
+ assert user is None
+ assert code == 401
+ assert "不一致" in err or "不匹配" in err
+
+
+def test_openid_bound_phone_match_ok():
+ bound = SimpleNamespace(id="u1", phone_no="13800138000", wx_openid="oid-a")
+ db = MagicMock()
+ with patch(
+ "app.services.wechat_login_service.user_crud.get_by_wx_openid",
+ return_value=bound,
+ ):
+ user, err, code = resolve_wechat_login_user(
+ db, openid="oid-a", phone="+8613800138000"
+ )
+ assert user is bound
+ assert err is None
+ assert code is None
+
+
+def test_unbound_no_user_rejects():
+ db = MagicMock()
+ with patch(
+ "app.services.wechat_login_service.user_crud.get_by_wx_openid",
+ return_value=None,
+ ), patch(
+ "app.services.wechat_login_service.user_crud.list_active_by_phone_no",
+ return_value=[],
+ ):
+ user, err, code = resolve_wechat_login_user(
+ db, openid="oid-new", phone="13800138000"
+ )
+ assert user is None
+ assert code == 401
+
+
+def test_unbound_multi_user_rejects():
+ db = MagicMock()
+ u1 = SimpleNamespace(id="a", phone_no="13800138000", wx_openid=None)
+ u2 = SimpleNamespace(id="b", phone_no="13800138000", wx_openid=None)
+ with patch(
+ "app.services.wechat_login_service.user_crud.get_by_wx_openid",
+ return_value=None,
+ ), patch(
+ "app.services.wechat_login_service.user_crud.list_active_by_phone_no",
+ return_value=[u1, u2],
+ ):
+ user, err, code = resolve_wechat_login_user(
+ db, openid="oid-new", phone="13800138000"
+ )
+ assert user is None
+ assert code == 400
+ assert "多个" in err
+
+
+def test_unbound_unique_binds_openid():
+ db = MagicMock()
+ u = SimpleNamespace(id="a", phone_no="13800138000", wx_openid=None, update_time=None)
+ with patch(
+ "app.services.wechat_login_service.user_crud.get_by_wx_openid",
+ side_effect=[None, None],
+ ), patch(
+ "app.services.wechat_login_service.user_crud.list_active_by_phone_no",
+ return_value=[u],
+ ):
+ user, err, code = resolve_wechat_login_user(
+ db, openid="oid-new", phone="13800138000"
+ )
+ assert err is None
+ assert user is u
+ assert u.wx_openid == "oid-new"
+ db.commit.assert_called()
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_wechat_login_resolve.py -v
+```
+
+Expected: import 失败。
+
+- [ ] **Step 3: 实现 `wechat_login_service.py`**
+
+```python
+"""微信小程序登录业务:openid + 手机号一致性校验(仅快捷登录绑号)。"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional, Tuple
+
+from sqlalchemy.orm import Session
+
+from app.crud.user import user_crud
+from app.models.models import SysUser
+from app.services.wechat_mini_auth import normalize_cn_mobile, phones_match
+
+
+def resolve_wechat_login_user(
+ db: Session, *, openid: str, phone: str
+) -> Tuple[Optional[SysUser], Optional[str], Optional[int]]:
+ oid = (openid or "").strip()
+ if not oid:
+ return None, "微信未返回 openid", 400
+ if not normalize_cn_mobile(phone):
+ return None, "请授权手机号登录,或使用账号密码登录", 400
+
+ bound = user_crud.get_by_wx_openid(db, oid)
+ if bound:
+ if getattr(bound, "status", "1") != "1":
+ return None, "账号已被停用,请联系管理员", 403
+ if not phones_match(getattr(bound, "phone_no", None), phone):
+ return (
+ None,
+ "微信与账号不匹配,请联系管理员",
+ 401,
+ )
+ return bound, None, None
+
+ users = user_crud.list_active_by_phone_no(db, phone)
+ if not users:
+ return (
+ None,
+ "未找到对应员工账号(请确认手机号已在系统维护)",
+ 401,
+ )
+ if len(users) > 1:
+ return None, "手机号对应多个账号,请联系管理员", 400
+
+ user = users[0]
+ other = user_crud.get_by_wx_openid(db, oid)
+ if other and other.id != user.id:
+ return None, "微信与账号不匹配,请联系管理员", 409
+ existing_oid = (getattr(user, "wx_openid", None) or "").strip()
+ if existing_oid and existing_oid != oid:
+ return None, "微信与账号不匹配,请联系管理员", 409
+
+ user.wx_openid = oid
+ user.update_time = datetime.now()
+ db.add(user)
+ db.commit()
+ db.refresh(user)
+ return user, None, None
+```
+
+**不做** `resolve_wechat_login_by_openid_only`(spec 明确本期不做静默换票)。
+
+- [ ] **Step 4: 跑通单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_wechat_login_resolve.py tests/test_wechat_mini_auth_phone.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/wechat_login_service.py backend/tests/test_wechat_login_resolve.py
+git commit -m "$(cat <<'EOF'
+feat(backend): resolve wechat login user by phone and bind openid.
+
+EOF
+)"
+```
+
+---
+
+### Task 5: 抽出 JWT 签发 + `mini_auth` 路由
+
+**Files:**
+- Create: `backend/app/services/web_login_tokens.py`
+- Modify: `backend/app/api/public.py`
+- Create: `backend/app/api/mini_auth.py`
+- Modify: `backend/app/api/__init__.py`
+- Modify: `backend/app/main.py`
+
+- [ ] **Step 1: 抽出 `issue_web_login_tokens`**
+
+`backend/app/services/web_login_tokens.py`:
+
+```python
+"""Web / 小程序共用:为 sys_user 签发 access + refresh JWT。"""
+from __future__ import annotations
+
+from typing import Any, Dict
+
+from sqlalchemy.orm import Session
+
+from app.core.auth import (
+ create_access_token,
+ create_refresh_token,
+ store_refresh_token,
+ store_session,
+)
+from app.core.config import settings
+from app.core.utils import generate_uuid
+from app.models.models import SysUser
+from app.services.web_auth_roles import resolve_web_role_for_user
+
+
+def _avatar_url_field(user: SysUser):
+ raw = getattr(user, "avatar", None)
+ if not raw:
+ return None
+ s = str(raw).strip()
+ return s if s else None
+
+
+def _web_role_list(web_role: str) -> list:
+ role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
+ return [role]
+
+
+def build_user_info_payload(user: SysUser, web_role: str) -> dict:
+ phone = (getattr(user, "phone_no", None) or "").strip() or None
+ role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
+ return {
+ "id": user.id,
+ "username": user.user_no,
+ "realName": user.nick_name,
+ "phone": phone,
+ "email": (getattr(user, "email", None) or "").strip() or None,
+ "role": role,
+ "roles": _web_role_list(role),
+ "regionId": user.dept_id,
+ "regionName": None,
+ "avatarUrl": _avatar_url_field(user),
+ }
+
+
+def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
+ tenant_no = (getattr(user, "tenant_no", None) or "").strip() or settings.DEFAULT_TENANT_NO
+ return {
+ "sub": user.id,
+ "username": user.user_no,
+ "role": web_role,
+ "region_id": user.dept_id,
+ "real_name": user.nick_name,
+ "tenant_no": tenant_no,
+ }
+
+
+def issue_web_login_tokens(db: Session, user: SysUser) -> Dict[str, Any]:
+ if getattr(user, "status", "1") != "1":
+ raise ValueError("账号已被停用,请联系管理员")
+
+ web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
+ session_id = generate_uuid()
+ access_jti = generate_uuid()
+ refresh_jti = generate_uuid()
+
+ access_token = create_access_token(
+ data=_jwt_user_payload(user, web_role),
+ session_id=session_id,
+ token_jti=access_jti,
+ )
+ refresh_token = create_refresh_token(
+ data={
+ "sub": user.id,
+ "username": user.user_no,
+ "role": web_role,
+ },
+ session_id=session_id,
+ refresh_jti=refresh_jti,
+ )
+
+ store_session(session_id=session_id, user_id=user.id)
+ store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
+
+ return {
+ "token": access_token,
+ "refreshToken": refresh_token,
+ "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
+ "userInfo": build_user_info_payload(user, web_role),
+ }
+```
+
+- [ ] **Step 2: 精简 `public.py` 密码登录发 token**
+
+将 `_build_user_info_payload` / `_jwt_user_payload` / `_avatar_url_field` / `_web_role_list` 改为从 `web_login_tokens` 导入(或让 `public.py` 仅调用 `issue_web_login_tokens`,其它引用 `build_user_info_payload` 的地方同步改 import)。
+
+`web_login` 成功分支替换为:
+
+```python
+ try:
+ data = issue_web_login_tokens(db, user)
+ except ValueError as exc:
+ return error_response(403, str(exc))
+ return success_response(data)
+```
+
+(停用检查可保留在登录前;`issue_web_login_tokens` 内再校验一次无妨。)
+
+`current-user` / `mp-webview-exchange` 若仍用 `_build_user_info_payload`,改为:
+
+```python
+from app.services.web_login_tokens import build_user_info_payload, issue_web_login_tokens
+```
+
+- [ ] **Step 3: 实现 `mini_auth.py`(仅 wechat-login,无 bind)**
+
+```python
+"""小程序鉴权:微信手机号快捷登录。"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from app.core.response import error_response, success_response
+from app.services.web_login_tokens import issue_web_login_tokens
+from app.services.wechat_login_service import resolve_wechat_login_user
+from app.services.wechat_mini_auth import (
+ WechatMiniAuthError,
+ code2session,
+ get_phone_number,
+)
+
+router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])
+
+
+@router.post("/wechat-login", response_model=dict)
+def wechat_login(body: dict, db: Session = Depends(get_db)):
+ """手机号快捷登录:须 loginCode + phoneCode;匹配 phone_no 并绑定 wx_openid。"""
+ login_code = (body.get("loginCode") or "").strip()
+ phone_code = (body.get("phoneCode") or "").strip()
+ if not phone_code:
+ return error_response(400, "请授权手机号登录,或使用账号密码登录")
+
+ try:
+ sess = code2session(login_code)
+ except WechatMiniAuthError as exc:
+ return error_response(400, str(exc))
+
+ try:
+ phone = get_phone_number(phone_code)
+ except WechatMiniAuthError as 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:
+ 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:
+ data = issue_web_login_tokens(db, user)
+ except ValueError as exc:
+ return error_response(403, str(exc))
+ return success_response(data)
+```
+
+- [ ] **Step 4: 注册路由**
+
+`app/api/__init__.py`:
+
+```python
+from app.api.mini_auth import router as mini_auth_router
+# __all__ 增加 "mini_auth_router"
+```
+
+`app/main.py`:
+
+```python
+from app.api import (..., mini_auth_router)
+# ...
+app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX)
+```
+
+- [ ] **Step 5: 冒烟(无需真微信)**
+
+```bash
+cd backend && source venv/bin/activate && python -c "from app.main import app; print([r.path for r in app.routes if 'wechat' in getattr(r,'path','')])"
+```
+
+Expected: 含 `/api/mini/auth/wechat-login`(或带 prefix 的等价路径)。
+
+```bash
+pytest tests/test_wechat_login_resolve.py tests/test_wechat_mini_auth_phone.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/services/web_login_tokens.py backend/app/api/public.py backend/app/api/mini_auth.py backend/app/api/__init__.py backend/app/main.py
+git commit -m "$(cat <<'EOF'
+feat(api): add mini wechat-login and share JWT issuance with web login.
+
+EOF
+)"
+```
+
+---
+
+### Task 6: 小程序 store + API 常量
+
+**Files:**
+- Modify: `miniprogram/config/api.js`
+- Modify: `miniprogram/utils/request.js`
+- Modify: `miniprogram/stores/user.js`
+
+- [ ] **Step 1: API 常量**
+
+`AUTH_API` 增加:
+
+```js
+ WECHAT_LOGIN: '/mini/auth/wechat-login'
+```
+
+- [ ] **Step 2: request 排除路径**
+
+`API_ENCRYPT_EXCLUDE_PATHS` 字符串追加:`,/mini/auth/wechat-login`
+
+(当前 `API_ENCRYPT_ENABLED = false`,仍先对齐后端排除列表,避免日后开启加密踩坑。)
+
+- [ ] **Step 3: `user.js` 增加 `wechatLogin`**
+
+在 `login` 函数旁增加:
+
+```js
+ async function wechatLogin(params) {
+ isLoading.value = true
+ try {
+ const res = await request({
+ url: AUTH_API.WECHAT_LOGIN,
+ method: 'POST',
+ data: {
+ loginCode: params.loginCode,
+ phoneCode: params.phoneCode
+ }
+ })
+ const result = parseResponseJson(res)
+ if (result && result.code === 200 && result.data) {
+ setLoginInfo({
+ token: result.data.token,
+ refreshToken: result.data.refreshToken,
+ userInfo: result.data.userInfo
+ })
+ await fetchMenuCodes()
+ return true
+ }
+ uni.showToast({ title: (result && result.message) || '登录失败', icon: 'none' })
+ return false
+ } catch (e) {
+ uni.showToast({ title: (e && e.message) || '登录失败', icon: 'none' })
+ return false
+ } finally {
+ isLoading.value = false
+ }
+ }
+```
+
+在 `return` 中导出 `wechatLogin`。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add miniprogram/config/api.js miniprogram/utils/request.js miniprogram/stores/user.js
+git commit -m "$(cat <<'EOF'
+feat(miniprogram): add wechatLogin API client on user store.
+
+EOF
+)"
+```
+
+---
+
+### Task 7: 登录页 UI + getPhoneNumber
+
+**Files:**
+- Modify: `miniprogram/pages/login/login.vue`
+
+- [ ] **Step 1: 模板 — 协议下方增加按钮**
+
+在现有 `login-footer`(协议行)**之后**、`protocol-dialog` **之前**插入(对齐 zkxw 结构):
+
+```html
+ 或
+
+
+
+ 手机号快捷登录
+
+```
+
+- [ ] **Step 2: 协议弹窗支持 getPhoneNumber 确认键**
+
+将快捷登录门禁弹窗的确认按钮改为:当 `protocolDialogForWechat === true` 时使用:
+
+```html
+
+```
+
+密码登录门禁仍用原「是,继续登录」普通按钮(`protocolDialogForWechat === false`)。
+
+实现要点:
+- `protocolDialogForWechat` ref,默认 `false`
+- `onWechatGate`:`protocolDialogForWechat = true`,打开弹窗
+- 密码登录未勾选协议:保持现有 Promise 弹窗,`protocolDialogForWechat = false`
+- `onAgreeGetPhoneNumber(e)`:设 `agreeProtocol = true`,关弹窗,调用 `onGetPhoneNumber(e)`
+
+- [ ] **Step 3: 脚本 — 授权登录**
+
+```js
+const onWechatGate = () => {
+ if (isLoading.value) return
+ protocolDialogForWechat.value = true
+ protocolDialogVisible.value = true
+}
+
+const onAgreeGetPhoneNumber = (e) => {
+ agreeProtocol.value = true
+ protocolDialogVisible.value = false
+ protocolDialogForWechat.value = false
+ onGetPhoneNumber(e)
+}
+
+const onGetPhoneNumber = async (e) => {
+ if (isLoading.value) return
+ if (!agreeProtocol.value) {
+ onWechatGate()
+ return
+ }
+ const detail = (e && e.detail) || {}
+ const errMsg = detail.errMsg || ''
+ if (errMsg && errMsg.indexOf('ok') === -1) {
+ uni.showToast({ title: '需要授权手机号,或请使用账号密码登录', icon: 'none' })
+ return
+ }
+ const phoneCode = detail.code
+ if (!phoneCode) {
+ uni.showToast({ title: '需要授权手机号,或请使用账号密码登录', icon: 'none' })
+ return
+ }
+ isLoading.value = true
+ try {
+ const loginRes = await new Promise((resolve, reject) => {
+ uni.login({ provider: 'weixin', success: resolve, fail: reject })
+ })
+ const ok = await userStore.wechatLogin({
+ loginCode: loginRes.code,
+ phoneCode
+ })
+ if (ok) {
+ userStore.navigateToHome()
+ }
+ } catch (err) {
+ uni.showToast({ title: (err && err.message) || '登录失败', icon: 'none' })
+ } finally {
+ isLoading.value = false
+ }
+}
+```
+
+注意:`userStore.wechatLogin` 内部已切 `isLoading`;页面层再设一层时避免双重 loading 打架——若 store 已管理,页面可不再置 `isLoading`,或让 store 的 `wechatLogin` 不改全局 isLoading、仅返回结果。推荐:**页面控制 `isLoading`,store 的 `wechatLogin` 不要再置 `isLoading`**(或抽内部 `_applyLoginSuccess`)。实现时二选一保持单一 loading 源。
+
+- [ ] **Step 4: 样式(对齐 zkxw)**
+
+使用三墙主色,结构对齐 zkxw:
+
+```scss
+.login-divider {
+ display: flex;
+ align-items: center;
+ gap: 24rpx;
+ margin: 44rpx 0 28rpx;
+ width: 100%;
+ font-size: 24rpx;
+ color: #94a3b8;
+}
+
+.login-divider::before,
+.login-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: #e2e8f0;
+}
+
+.login-wechat {
+ height: 88rpx;
+ border-radius: 24rpx;
+ border: 1px solid #e2e8f0;
+ background: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ margin: 0;
+ padding: 0;
+ line-height: normal;
+ box-sizing: border-box;
+ font-size: 28rpx;
+ font-weight: 500;
+ color: #1e293b;
+}
+
+button.login-wechat::after {
+ border: none;
+}
+
+.login-wechat__label {
+ font-size: 28rpx;
+ font-weight: 500;
+ color: #1e293b;
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add miniprogram/pages/login/login.vue miniprogram/stores/user.js
+git commit -m "$(cat <<'EOF'
+feat(miniprogram): add phone quick login button on login page.
+
+EOF
+)"
+```
+
+---
+
+### Task 8: 运维清单 + 图谱更新 + 人工验收说明
+
+**Files:**
+- (可选注释)`backend/.env` / `.env.production` — **仅人工填写**,Agent 勿把真实 Secret 写入仓库
+- Run: `./scripts/update-graph.sh backend` 与 `./scripts/update-graph.sh`(或项目约定的 miniprogram 图谱脚本)
+
+- [ ] **Step 1: 交付运维检查清单(写入 PR / 回复用户,勿提交真实 Secret)**
+
+```text
+1. 在服务器执行 docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql
+2. 配置 WECHAT_MP_APPID=wx7db1ddb2a90c9425
+3. 配置 WECHAT_MP_SECRET=<公众平台 AppSecret>
+4. 微信公众平台:企业认证 + 手机号快速验证已开通
+5. request 合法域名含 API 主机
+6. 真机:勾选协议 → 手机号快捷登录 → 进工作台
+7. 真机:密码登录仍可用
+8. 员工 sys_user.phone_no 已维护且唯一
+```
+
+- [ ] **Step 2: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh backend
+# 若有 miniprogram 目标:
+./scripts/update-graph.sh # 或按 AGENTS.md / scripts 说明
+```
+
+- [ ] **Step 3: Commit 图谱(若有 diff)**
+
+```bash
+git add backend/graphify-out miniprogram/graphify-out graphify-out 2>/dev/null || true
+git status
+# 仅提交本功能相关 graphify 变更
+git commit -m "$(cat <<'EOF'
+chore: update graphify after mini phone quick login.
+
+EOF
+)"
+```
+
+---
+
+## Spec 覆盖自检
+
+| Spec 要求 | Task |
+|-----------|------|
+| 协议下方按钮 + zkxw 样式 | Task 7 |
+| getPhoneNumber + uni.login + wechat-login | Task 6–7 |
+| phone_no 匹配、绑 openid、不自动注册 | Task 4–5 |
+| 不做密码登录静默绑 / 不做 openid-only | Task 5(无 bind 路由) |
+| WECHAT_MP_* 独立、WXPAY 不动 | Task 2–3 |
+| JWT 与密码登录一致 | Task 5 |
+| DDL + ORM | Task 1 |
+| 加密排除 | Task 2 + Task 6 |
+| 失败文案 | Task 4–5、Task 7 |
+| 真机验收清单 | Task 8 |
+
+---
+
+## 执行说明
+
+实现前请在目标库执行迁移 SQL,并在本地/生产 `.env` 填入 `WECHAT_MP_SECRET`(勿提交仓库)。前端联调优先真机。