From 6dd7a95ce8b31e51d8a86c1d9027ad9fd3a2ea5f Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 10 Aug 2026 15:52:29 +0800 Subject: [PATCH] docs: add mobile-h5 password login API implementation plan Co-authored-by: Cursor --- ...2026-08-10-mobile-h5-password-login-api.md | 570 ++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-mobile-h5-password-login-api.md diff --git a/docs/superpowers/plans/2026-08-10-mobile-h5-password-login-api.md b/docs/superpowers/plans/2026-08-10-mobile-h5-password-login-api.md new file mode 100644 index 00000000..3c6d7a3c --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-mobile-h5-password-login-api.md @@ -0,0 +1,570 @@ +# mobile-h5 账号密码登录对接真实 API 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:** 将 `mobile-h5` 登录页账号密码从 mock 改为调用 backend `/api/web/auth/login`(RSA),微信按钮置灰不可点。 + +**Architecture:** 新增 `config` / `request` / `login-rsa` / `auth`;登录页调 `passwordLogin`;会话写入 `token` + `refreshToken` + `userInfo`;不改后端;不做微信登录与自动 refresh。 + +**Tech Stack:** uni-app Vue3(HBuilderX)、`jsencrypt`、`uni.request`、现有 backend Web 登录 API + +**Spec:** `docs/superpowers/specs/2026-08-10-mobile-h5-password-login-api-design.md` + +--- + +## File map + +| 路径 | 职责 | +|------|------| +| `mobile-h5/utils/config.js` | `API_BASE_URL` | +| `mobile-h5/utils/request.js` | `get` / `post`,解析 `{ code, message, data }` | +| `mobile-h5/utils/request.test.mjs` | 纯函数响应解析单测 | +| `mobile-h5/utils/login-rsa.js` | 拉公钥 + JSEncrypt 加密 | +| `mobile-h5/utils/auth.js` | 会话存取 + `passwordLogin`(替换 `auth-mock.js`) | +| `mobile-h5/package.json` | 增加 `jsencrypt` | +| `mobile-h5/pages/login/index.vue` | 真实登录;微信置灰 | +| `mobile-h5/pages/index/index.vue` | import 改为 `auth.js` | +| `mobile-h5/styles/components/login.scss` | `.login-wechat.is-disabled` | +| `mobile-h5/utils/auth-mock.js` | 删除(调用方改完后) | + +**不修改:** `backend/**`、协议页、微信换票 API。 + +--- + +### Task 1: `config.js` + +**Files:** +- Create: `mobile-h5/utils/config.js` + +- [ ] **Step 1: 写入配置** + +```js +/** + * 后端 API 根路径(含 /api,不含末尾斜杠)。 + * 本地默认;真机 / 体验版请改为可达地址,例如 http://192.168.x.x:8000/api + */ +export const API_BASE_URL = 'http://127.0.0.1:8000/api' + +export const REQUEST_TIMEOUT_MS = 15000 +``` + +- [ ] **Step 2: Commit** + +```bash +cd /Users/wuzilong/work/00code/zkxw +git add mobile-h5/utils/config.js +git commit -m "$(cat <<'EOF' +chore(mobile-h5): add API_BASE_URL config for login + +EOF +)" +``` + +--- + +### Task 2: `request.js` + 响应解析单测 + +**Files:** +- Create: `mobile-h5/utils/request.js` +- Create: `mobile-h5/utils/request.test.mjs` + +- [ ] **Step 1: 写入可测的解析函数与 request 封装** + +`mobile-h5/utils/request.js`: + +```js +import { API_BASE_URL, REQUEST_TIMEOUT_MS } from './config.js' + +/** + * 解析后端统一响应。成功返回 data;失败抛 Error(message 为业务文案)。 + * @param {{ code?: number, message?: string, data?: any }} body + * @param {number} [httpStatus] + */ +export function unwrapResponse(body, httpStatus) { + if (body == null || typeof body !== 'object') { + const err = new Error('网络异常,请稍后重试') + err.code = 'BAD_RESPONSE' + throw err + } + const code = body.code + if (code === 200) { + return body.data + } + const msg = + (typeof body.message === 'string' && body.message.trim()) || + '请求失败' + const err = new Error(msg) + err.code = code + err.httpStatus = httpStatus + throw err +} + +function joinUrl(path) { + const base = (API_BASE_URL || '').replace(/\/$/, '') + const p = path.startsWith('/') ? path : `/${path}` + return `${base}${p}` +} + +/** + * @param {{ url: string, method?: string, data?: any, header?: Record }} options + * @returns {Promise} data 字段 + */ +export function request(options) { + const method = (options.method || 'GET').toUpperCase() + const url = joinUrl(options.url) + const header = Object.assign( + { 'Content-Type': 'application/json' }, + options.header || {} + ) + + return new Promise((resolve, reject) => { + uni.request({ + url, + method, + data: options.data, + header, + timeout: REQUEST_TIMEOUT_MS, + success(res) { + try { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(unwrapResponse(res.data, res.statusCode)) + return + } + // HTTP 非 2xx:若 body 仍是业务包,优先展示 message + try { + resolve(unwrapResponse(res.data, res.statusCode)) + } catch (bizErr) { + reject(bizErr) + } + } catch (e) { + reject(e) + } + }, + fail() { + const err = new Error('网络异常,请稍后重试') + err.code = 'NETWORK' + reject(err) + } + }) + }) +} + +export function get(url, header) { + return request({ url, method: 'GET', header }) +} + +export function post(url, data, header) { + return request({ url, method: 'POST', data, header }) +} +``` + +- [ ] **Step 2: 写入单测** + +`mobile-h5/utils/request.test.mjs`: + +```js +import test from 'node:test' +import assert from 'node:assert/strict' +import { unwrapResponse } from './request.js' + +test('unwrapResponse returns data when code is 200', () => { + const data = unwrapResponse({ code: 200, message: 'success', data: { token: 't' } }) + assert.deepEqual(data, { token: 't' }) +}) + +test('unwrapResponse throws with backend message when code is not 200', () => { + assert.throws( + () => unwrapResponse({ code: 401, message: '用户名或密码错误', data: null }), + (e) => e instanceof Error && e.message === '用户名或密码错误' && e.code === 401 + ) +}) + +test('unwrapResponse throws network message on null body', () => { + assert.throws( + () => unwrapResponse(null), + (e) => e instanceof Error && e.message === '网络异常,请稍后重试' + ) +}) +``` + +- [ ] **Step 3: 跑单测** + +```bash +cd /Users/wuzilong/work/00code/zkxw/mobile-h5 +node --test utils/request.test.mjs +``` + +Expected: 3 passed。若因 `uni` 未定义导致 `request.js` 加载失败:把 `unwrapResponse` 抽到 `utils/unwrap-response.js` 再测该文件(保持行为不变)。优先保持单文件;仅在加载失败时拆分。 + +- [ ] **Step 4: Commit** + +```bash +cd /Users/wuzilong/work/00code/zkxw +git add mobile-h5/utils/request.js mobile-h5/utils/request.test.mjs +git commit -m "$(cat <<'EOF' +feat(mobile-h5): add thin uni.request wrapper for API calls + +EOF +)" +``` + +--- + +### Task 3: 安装 `jsencrypt` + `login-rsa.js` + +**Files:** +- Modify: `mobile-h5/package.json` +- Create: `mobile-h5/utils/login-rsa.js` + +- [ ] **Step 1: 安装依赖** + +```bash +cd /Users/wuzilong/work/00code/zkxw/mobile-h5 +npm install jsencrypt@^3.3.2 --save +``` + +Expected: `package.json` / `package-lock.json` 含 `jsencrypt`;`node_modules/jsencrypt` 存在。 + +HBuilderX:若项目需「工具 → 插件安装 / 使用 npm 模块」,按既有流程执行一次 npm 构建,确保小程序能 resolve `jsencrypt`。 + +- [ ] **Step 2: 写入 `login-rsa.js`** + +```js +import JSEncrypt from 'jsencrypt' +import { get } from './request.js' + +/** + * 对齐 web-admin `buildWebEncryptedLoginBody`: + * 拉公钥后加密 { username, password, ts }。 + * @returns {Promise<{ encryptedLogin: string }>} + */ +export async function buildEncryptedLoginBody(username, password) { + const data = await get('/web/auth/login-rsa-public-key') + const publicKeyPem = data && data.publicKeyPem + if (!publicKeyPem) { + throw new Error('获取登录公钥失败') + } + const crypt = new JSEncrypt() + crypt.setPublicKey(publicKeyPem) + const payload = JSON.stringify({ + username, + password, + ts: Date.now() + }) + const encryptedLogin = crypt.encrypt(payload) + if (!encryptedLogin) { + throw new Error('登录加密失败,请重试') + } + return { encryptedLogin } +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/wuzilong/work/00code/zkxw +git add mobile-h5/package.json mobile-h5/package-lock.json mobile-h5/utils/login-rsa.js +git commit -m "$(cat <<'EOF' +feat(mobile-h5): add RSA login body builder with jsencrypt + +EOF +)" +``` + +--- + +### Task 4: `auth.js` 替换 `auth-mock.js` + +**Files:** +- Create: `mobile-h5/utils/auth.js` +- Modify: `mobile-h5/pages/login/index.vue`(仅改 import 与 `onPasswordLogin` 调用名,微信改动放 Task 5) +- Modify: `mobile-h5/pages/index/index.vue`(import 路径) +- Delete: `mobile-h5/utils/auth-mock.js` + +- [ ] **Step 1: 写入 `auth.js`** + +```js +import { validateCredentials } from './validate-credentials.js' +import { buildEncryptedLoginBody } from './login-rsa.js' +import { post } from './request.js' + +export { validateCredentials } + +const TOKEN_KEY = 'zkxw_mobile_token' +const REFRESH_TOKEN_KEY = 'zkxw_mobile_refresh_token' +const USER_KEY = 'zkxw_mobile_user' + +export function getToken() { + try { + return uni.getStorageSync(TOKEN_KEY) || '' + } catch (e) { + return '' + } +} + +export function getRefreshToken() { + try { + return uni.getStorageSync(REFRESH_TOKEN_KEY) || '' + } catch (e) { + return '' + } +} + +export function isLoggedIn() { + return !!getToken() +} + +export function getUser() { + try { + const raw = uni.getStorageSync(USER_KEY) + return raw ? JSON.parse(raw) : null + } catch (e) { + return null + } +} + +export function clearSession() { + try { + uni.removeStorageSync(TOKEN_KEY) + uni.removeStorageSync(REFRESH_TOKEN_KEY) + uni.removeStorageSync(USER_KEY) + } catch (e) { + /* ignore */ + } +} + +function saveSession({ token, refreshToken, userInfo }) { + uni.setStorageSync(TOKEN_KEY, token || '') + uni.setStorageSync(REFRESH_TOKEN_KEY, refreshToken || '') + uni.setStorageSync(USER_KEY, JSON.stringify(userInfo || null)) +} + +/** + * 可选:清除历史 mock_ 前缀假会话,避免误判已登录。 + */ +export function clearLegacyMockSessionIfNeeded() { + const token = getToken() + if (typeof token === 'string' && token.indexOf('mock_') === 0) { + clearSession() + } +} + +/** + * 真实账号密码登录。 + * @param {{ username: string, password: string }} params + */ +export async function passwordLogin({ username, password }) { + const v = validateCredentials(username, password) + if (!v.ok) { + const err = new Error(v.message) + err.code = v.code + throw err + } + const body = await buildEncryptedLoginBody( + (username || '').trim(), + password || '' + ) + const data = await post('/web/auth/login', body) + if (!data || !data.token) { + throw new Error('登录失败') + } + saveSession({ + token: data.token, + refreshToken: data.refreshToken, + userInfo: data.userInfo + }) + return data +} +``` + +- [ ] **Step 2: 更新 `pages/index/index.vue` import** + +将: + +```js +import { clearSession, getUser } from '../../utils/auth-mock.js' +``` + +改为: + +```js +import { clearSession, getUser } from '../../utils/auth.js' +``` + +- [ ] **Step 3: 更新 `pages/login/index.vue` 的 script import 与登录调用** + +将 import: + +```js +import { + isLoggedIn, + mockPasswordLogin, + mockWechatLogin +} from '../../utils/auth-mock.js' +``` + +改为: + +```js +import { + isLoggedIn, + passwordLogin, + clearLegacyMockSessionIfNeeded +} from '../../utils/auth.js' +``` + +在 `onShow` 开头增加: + +```js +clearLegacyMockSessionIfNeeded() +``` + +将 `onPasswordLogin` 内: + +```js +mockPasswordLogin({ + username: this.username, + password: this.password +}) +``` + +改为: + +```js +await passwordLogin({ + username: this.username, + password: this.password +}) +``` + +(本 Task 暂保留 `onWechatLogin` 方法体;Task 5 再置灰并移除。) + +- [ ] **Step 4: 删除 `auth-mock.js`** + +```bash +rm /Users/wuzilong/work/00code/zkxw/mobile-h5/utils/auth-mock.js +``` + +确认无残留引用: + +```bash +rg "auth-mock" /Users/wuzilong/work/00code/zkxw/mobile-h5 +``` + +Expected: 无匹配。 + +- [ ] **Step 5: Commit** + +```bash +cd /Users/wuzilong/work/00code/zkxw +git add mobile-h5/utils/auth.js mobile-h5/pages/login/index.vue mobile-h5/pages/index/index.vue +git add -u mobile-h5/utils/auth-mock.js +git commit -m "$(cat <<'EOF' +feat(mobile-h5): replace mock auth with real password login API + +EOF +)" +``` + +--- + +### Task 5: 微信按钮置灰 + 登录页收尾 + +**Files:** +- Modify: `mobile-h5/styles/components/login.scss` +- Modify: `mobile-h5/pages/login/index.vue` + +- [ ] **Step 1: 增加禁用样式** + +在 `login.scss` 的 `.login-wechat` 块后追加: + +```scss +.login-wechat.is-disabled { + opacity: 0.45; + pointer-events: none; +} +``` + +- [ ] **Step 2: 模板置灰并去掉点击** + +将: + +```html + +``` + +改为: + +```html + +``` + +删除 `methods` 中的 `onWechatLogin` 整段方法。 + +- [ ] **Step 3: Commit** + +```bash +cd /Users/wuzilong/work/00code/zkxw +git add mobile-h5/styles/components/login.scss mobile-h5/pages/login/index.vue +git commit -m "$(cat <<'EOF' +feat(mobile-h5): disable WeChat login button until API is ready + +EOF +)" +``` + +--- + +### Task 6: 联调验收 + +**前置:** 本地 backend 已启动(如 `./start-local.sh`),`API_BASE_URL` 指向该实例。 + +- [ ] **Step 1: 公钥接口可达** + +```bash +curl -sS http://127.0.0.1:8000/api/web/auth/login-rsa-public-key | head -c 200 +``` + +Expected: JSON 含 `"code":200` 与 `publicKeyPem`。 + +- [ ] **Step 2: 微信开发者工具 / HBuilderX 运行 `mobile-h5`** + +1. 开发设置关闭「校验合法域名」(或已配置 request 合法域名)。 +2. 使用后台已有启用且有角色的账号登录 → 应进首页;Storage 中 `zkxw_mobile_token` 为 JWT(非 `mock_` 前缀)。 +3. 错误密码 → Toast「用户名或密码错误」(或后端原文)。 +4. 微信按钮置灰、点击无反应。 +5. 退出登录(若首页有出口)后会话清除,回到登录页。 + +- [ ] **Step 3: 若联调失败,按清单排查** + +| 现象 | 检查 | +|------|------| +| 网络异常 | `API_BASE_URL`、后端是否启动、真机是否应用局域网 IP | +| 登录加密失败 / 凭证过期 | 设备时间、后端 RSA 是否正常 | +| jsencrypt 找不到模块 | HBuilderX npm 构建;必要时 vendor `jsencrypt.min.js` 到 `utils/vendor/` 并改 import | +| CORS(仅 H5) | 后端 CORS;小程序端一般无 CORS 问题 | + +- [ ] **Step 4:(可选)文档交叉引用** + +若方便,在 `docs/superpowers/specs/2026-08-10-miniprogram-login-ui-design.md` 的非目标中注明「账号密码已对接真实 API,见 `2026-08-10-mobile-h5-password-login-api-design.md`」;无则跳过。 + +--- + +## Spec coverage checklist + +| Spec 项 | Task | +|---------|------| +| 复用 login + RSA | 3、4 | +| `API_BASE_URL` 可改 | 1 | +| 存 token / refreshToken / userInfo | 4 | +| 微信置灰不可点 | 5 | +| 不做 refresh / 微信接口 / 后端改动 | 全计划遵守 | +| 错误 Toast / 网络文案 | 2、4 | +| 清除 mock_ 假会话 | 4 `clearLegacyMockSessionIfNeeded` | +| 验收标准 | 6 |