From 53795c535d0b3596cb02d02458f1adadc8cad887 Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Fri, 18 Sep 2026 11:04:00 +0800 Subject: [PATCH] docs: add miniprogram phone quick login implementation plan. Co-authored-by: Cursor --- ...026-09-18-miniprogram-phone-quick-login.md | 1123 +++++++++++++++++ 1 file changed, 1123 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-miniprogram-phone-quick-login.md 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`(勿提交仓库)。前端联调优先真机。