diff --git a/docs/superpowers/plans/2026-08-10-mobile-h5-wechat-login.md b/docs/superpowers/plans/2026-08-10-mobile-h5-wechat-login.md new file mode 100644 index 00000000..7dfdca91 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-mobile-h5-wechat-login.md @@ -0,0 +1,543 @@ +# mobile-h5 微信授权登录 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:** 启用登录页微信授权:openid 已绑定直接换 JWT;未绑定用手机号匹配员工并绑定;密码登录成功后补绑。 + +**Architecture:** `sys_user.wx_openid` + 后端 `wechat_mini_auth` 调微信 `jscode2session` / 手机号接口;`POST /api/mini/auth/wechat-login` 与 `wechat-bind`;前端 `getPhoneNumber` 按钮 + `uni.login`;复用现有 JWT 签发逻辑。 + +**Tech Stack:** FastAPI、SQLAlchemy、MySQL、uni-app、微信小程序 API;配置复用 `WXPAY_MINI_APPID` / `WXPAY_SECRET` + +**Spec:** `docs/superpowers/specs/2026-08-10-mobile-h5-wechat-login-design.md` + +--- + +## File map + +| 路径 | 职责 | +|------|------| +| `docs/sql/alter-sys-user-wx-openid.sql` | 增列 + 唯一索引 | +| `backend/app/models/models.py` | `SysUser.wx_openid` | +| `backend/app/services/wechat_mini_auth.py` | code2session、get_phone_number、normalize_phone | +| `backend/app/services/issue_user_tokens.py` | 从 SysUser 签发 token 包(抽自 public 登录) | +| `backend/app/api/mini_auth.py` | wechat-login / wechat-bind 路由 | +| `backend/app/api/__init__.py` + `main.py` | 注册路由 | +| `backend/app/core/config.py` | EXCLUDE 增加 mini wechat 路径 | +| `backend/app/crud/user.py` | `get_by_wx_openid` / `list_by_phone_no` | +| `backend/tests/test_wechat_mini_auth_phone.py` | 手机号规范化单测 | +| `mobile-h5/utils/auth.js` | `wechatLogin` / `bindWechatAfterPassword` | +| `mobile-h5/utils/request.js` | 可选:带 Authorization 的 post | +| `mobile-h5/pages/login/index.vue` | 启用微信按钮 | +| `mobile-h5/styles/components/login.scss` | button 重置样式 | +| `mobile-h5/manifest.json` | appid(已配可核对) | + +**不修改:** 支付逻辑、web-admin 登录、静默冷启动微信登录。 + +--- + +### Task 1: SQL + ORM `wx_openid` + +**Files:** +- Create: `docs/sql/alter-sys-user-wx-openid.sql` +- Modify: `backend/app/models/models.py`(`SysUser` 类) + +- [ ] **Step 1: 写 SQL** + +```sql +-- docs/sql/alter-sys-user-wx-openid.sql +ALTER TABLE `sys_user` + ADD COLUMN `wx_openid` varchar(64) DEFAULT NULL COMMENT '微信小程序 openid' AFTER `phone_no`; + +ALTER TABLE `sys_user` + ADD UNIQUE INDEX `uk_sys_user_wx_openid` (`wx_openid`); +``` + +- [ ] **Step 2: ORM 增加字段** + +在 `SysUser` 的 `phone_no` 后增加: + +```python +wx_openid = Column(String(64), nullable=True, comment="微信小程序 openid") +``` + +- [ ] **Step 3: 在目标库执行 SQL**(本地/测试库) + +```bash +# 使用项目实际 mysql 客户端或图形工具执行 alter 脚本 +``` + +Expected: 列存在;多行 `NULL` 的 `wx_openid` 可共存。 + +- [ ] **Step 4: Commit** + +```bash +git add docs/sql/alter-sys-user-wx-openid.sql backend/app/models/models.py +git commit -m "$(cat <<'EOF' +feat(db): add sys_user.wx_openid for WeChat mini login + +EOF +)" +``` + +--- + +### Task 2: 手机号规范化 + 微信 HTTP 服务(可单测) + +**Files:** +- Create: `backend/app/services/wechat_mini_auth.py` +- Create: `backend/tests/test_wechat_mini_auth_phone.py` + +- [ ] **Step 1: 写失败单测** + +```python +# backend/tests/test_wechat_mini_auth_phone.py +from app.services.wechat_mini_auth import normalize_cn_mobile + + +def test_normalize_strips_plus_86(): + assert normalize_cn_mobile("+8613800138000") == "13800138000" + + +def test_normalize_strips_spaces(): + assert normalize_cn_mobile(" 138 0013 8000 ") == "13800138000" + + +def test_normalize_empty(): + assert normalize_cn_mobile("") == "" + assert normalize_cn_mobile(None) == "" +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v +``` + +Expected: FAIL import / not found + +- [ ] **Step 3: 实现服务** + +```python +# backend/app/services/wechat_mini_auth.py +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 _require_mini_credentials() -> tuple: + appid = (settings.WXPAY_MINI_APPID or "").strip() + secret = (settings.WXPAY_SECRET or "").strip() + if not appid or not secret: + raise WechatMiniAuthError("未配置小程序凭证(WXPAY_MINI_APPID / WXPAY_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 +``` + +确认 `httpx` 已在 `requirements.txt`;若无则加入 `httpx`。 + +- [ ] **Step 4: 跑测通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/wechat_mini_auth.py backend/tests/test_wechat_mini_auth_phone.py backend/requirements.txt +git commit -m "$(cat <<'EOF' +feat(backend): add WeChat mini code2session and phone helpers + +EOF +)" +``` + +--- + +### Task 3: 用户查询 + 签发 token 辅助 + +**Files:** +- Modify: `backend/app/crud/user.py` +- Create: `backend/app/services/issue_user_tokens.py` + +- [ ] **Step 1: crud 增加查询** + +在 `user_crud` 同类或模块级增加: + +```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: + phone = (phone_no or "").strip() + if not phone: + return [] + return ( + db.query(SysUser) + .filter( + SysUser.phone_no == phone, + SysUser.del_flag == 0, + SysUser.status == "1", + ) + .all() + ) +``` + +(若 `UserCRUD` 类名不同,挂到现有 class 上。) + +- [ ] **Step 2: `issue_user_tokens.py`** + +抽取与 `public.web_login` 相同的签发逻辑,入参 `user: SysUser`,返回 dict:`token/refreshToken/expiresIn/userInfo`。内部调用 `user_has_active_roles`、停用检查可在调用方先做,或本函数内统一: + +```python +async def issue_tokens_for_user(db: Session, user: SysUser) -> dict: + # 校验 status、roles;否则 raise ValueError 带中文 message + # 复制 public.py web_login 中 session/jwt 创建与 _build_user_info_payload + ... +``` + +为避免循环导入,可将 `_build_user_info_payload` / `_jwt_user_payload` 小幅复制到本模块,或从 `public` 导入纯函数(若已模块级)。优先:在 `issue_user_tokens.py` 内复制最小必要 payload 构建,与 public 字段保持一致。 + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/crud/user.py backend/app/services/issue_user_tokens.py +git commit -m "$(cat <<'EOF' +feat(backend): add wx openid/phone user lookups and token issuer + +EOF +)" +``` + +--- + +### Task 4: mini auth 路由 + +**Files:** +- Create: `backend/app/api/mini_auth.py` +- Modify: `backend/app/api/__init__.py`、`backend/app/main.py` +- Modify: `backend/app/core/config.py`(EXCLUDE_PATHS) +- Modify: 本地 `backend/.env` 的 `API_ENCRYPT_EXCLUDE_PATHS` 若覆盖了默认值,追加: + `/api/mini/auth/wechat-login` + +- [ ] **Step 1: 实现路由** + +```python +# backend/app/api/mini_auth.py — 要点 +router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"]) + +@router.post("/wechat-login") +async def wechat_login(body: dict, db: Session = Depends(get_db)): + login_code = (body.get("loginCode") or "").strip() + phone_code = (body.get("phoneCode") or "").strip() or None + try: + sess = code2session(login_code) + except WechatMiniAuthError as e: + return error_response(400, str(e)) + openid = sess["openid"] + user = user_crud.get_by_wx_openid(db, openid) + if user: + try: + data = await issue_tokens_for_user(db, user) + except ValueError as e: + return error_response(403, str(e)) + return success_response(data) + if not phone_code: + return error_response(400, "请授权手机号登录,或使用账号密码登录") + try: + phone = get_phone_number(phone_code) + except WechatMiniAuthError as e: + return error_response(400, str(e)) + users = user_crud.list_active_by_phone_no(db, phone) + if not users: + return error_response(401, "未找到与该手机号匹配的员工账号,请使用账号密码登录") + if len(users) > 1: + return error_response(400, "手机号对应多个账号,请使用账号密码登录") + user = users[0] + # 冲突:openid 已被他人占用不会走到这;检查该 openid 是否写入冲突 + other = user_crud.get_by_wx_openid(db, openid) + if other and other.id != user.id: + return error_response(409, "该微信已绑定其他账号") + if user.wx_openid and user.wx_openid != openid: + return error_response(409, "该账号已绑定其他微信,请联系管理员") + user.wx_openid = openid + db.add(user) + db.commit() + db.refresh(user) + try: + data = await issue_tokens_for_user(db, user) + except ValueError as e: + return error_response(403, str(e)) + return success_response(data) + + +@router.post("/wechat-bind") +async def wechat_bind(request: Request, body: dict, db: Session = Depends(get_db)): + # Bearer 解析同 public._resolve_current_user_from_request + # code2session → 绑定到 current user + ... +``` + +- [ ] **Step 2: 注册 router**;EXCLUDE 增加 `/api/mini/auth/wechat-login`(bind 需加密层外的 Bearer,若全局加密开启则 bind 也需按现有鉴权接口惯例处理——与 current-user 相同,通常不在 exclude,走加密+Token)。 + +说明:`wechat-login` 必须 exclude;`wechat-bind` 需登录,与 `/web/auth/profile` 一样走正常加密策略。若 `API_ENCRYPT_ENABLED=false`(本地常见),两者都明文即可。 + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/mini_auth.py backend/app/api/__init__.py backend/app/main.py backend/app/core/config.py +git commit -m "$(cat <<'EOF' +feat(backend): add mini WeChat login and bind APIs + +EOF +)" +``` + +--- + +### Task 5: mobile-h5 auth + 登录页 + +**Files:** +- Modify: `mobile-h5/utils/request.js`(增加可选 header 传 token) +- Modify: `mobile-h5/utils/auth.js` +- Modify: `mobile-h5/pages/login/index.vue` +- Modify: `mobile-h5/styles/components/login.scss` +- Verify: `mobile-h5/manifest.json` appid + +- [ ] **Step 1: auth.js 增加** + +```js +export async function wechatLogin({ loginCode, phoneCode }) { + const data = await post('/mini/auth/wechat-login', { + loginCode, + phoneCode: phoneCode || undefined + }) + if (!data || !data.token) throw new Error('登录失败') + saveSession({ + token: data.token, + refreshToken: data.refreshToken, + userInfo: data.userInfo + }) + return data +} + +export async function bindWechatQuietly() { + try { + const loginRes = await new Promise((resolve, reject) => { + uni.login({ provider: 'weixin', success: resolve, fail: reject }) + }) + const loginCode = loginRes.code + if (!loginCode) return + const token = getToken() + if (!token) return + await post('/mini/auth/wechat-bind', { loginCode }, { + Authorization: 'Bearer ' + token + }) + } catch (e) { + console.warn('wechat bind skipped', e && e.message) + } +} +``` + +`post` 需支持第三参 header(已有则用)。 + +`passwordLogin` 成功 `saveSession` 之后调用 `bindWechatQuietly()`(勿 await 阻塞 Toast,或 void 即可)。 + +- [ ] **Step 2: 登录页模板** + +- 去掉微信 `is-disabled`。 +- 协议已同意时渲染: + +```html + +``` + +- 未同意时渲染普通 `view.login-wechat`,点击 → `ensureAgree` → 同意后 `agreed=true`(用户再点一次授权按钮;或同意后提示再点微信登录)。 + +推荐:未同意点击 view 走 ensureAgree;同意后显示 button。 + +- [ ] **Step 3: `onGetPhoneNumber`** + +```js +async onGetPhoneNumber(e) { + if (this.loading) 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 + } + this.loading = true + try { + const loginRes = await new Promise((resolve, reject) => { + uni.login({ provider: 'weixin', success: resolve, fail: reject }) + }) + await wechatLogin({ loginCode: loginRes.code, phoneCode }) + uni.showToast({ title: '登录成功', icon: 'success' }) + setTimeout(() => this.goHome(), 400) + } catch (err) { + uni.showToast({ title: (err && err.message) || '登录失败', icon: 'none' }) + } finally { + this.loading = false + } +} +``` + +- [ ] **Step 4: SCSS** 重置 `button.login-wechat` 默认边框/背景,保持原视觉;可保留 `.is-disabled` 供 loading。 + +- [ ] **Step 5: Commit** + +```bash +git add mobile-h5/utils/auth.js mobile-h5/utils/request.js mobile-h5/pages/login/index.vue mobile-h5/styles/components/login.scss mobile-h5/manifest.json +git commit -m "$(cat <<'EOF' +feat(mobile-h5): enable WeChat phone+openid login on login page + +EOF +)" +``` + +--- + +### Task 6: 验收 + +- [ ] 后端启动;确认 `.env` 中 `WXPAY_MINI_APPID` / `WXPAY_SECRET` 已填。 +- [ ] 真机或开发者工具:密码登录 → 查库 `wx_openid` 有值(补绑)。 +- [ ] 清会话后再点微信登录应直接成功(若 getPhoneNumber 仍弹窗,授权后仍走 openid 优先)。 +- [ ] 错误密码号 / 拒绝授权:Toast 引导密码登录。 +- [ ] 回复用户 `.env` 两项清单(已配置则确认即可)。 + +--- + +## Spec coverage + +| Spec | Task | +|------|------| +| wx_openid 列 | 1 | +| code2session / 手机号 | 2 | +| wechat-login / bind | 4 | +| 方案 1 按钮 | 5 | +| 密码后补绑 | 5 | +| 无静默冷启动 | 默认保持 | +| EXCLUDE 路径 | 4 | +| 验收 / env 清单 | 6 |