|
|
@@ -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
|
|
|
+<button
|
|
|
+ class="login-wechat"
|
|
|
+ open-type="getPhoneNumber"
|
|
|
+ @getphonenumber="onGetPhoneNumber"
|
|
|
+>
|
|
|
+ <image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
|
|
|
+ <text>微信授权登录</text>
|
|
|
+</button>
|
|
|
+```
|
|
|
+
|
|
|
+- 未同意时渲染普通 `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 |
|