2026-08-10-mobile-h5-password-login-api.md 15 KB

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: 写入配置

/**
 * 后端 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
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:

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<string, string> }} options
 * @returns {Promise<any>} 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:

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: 跑单测
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
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: 安装依赖

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
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
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

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

将:

import { clearSession, getUser } from '../../utils/auth-mock.js'

改为:

import { clearSession, getUser } from '../../utils/auth.js'
  • Step 3: 更新 pages/login/index.vue 的 script import 与登录调用

将 import:

import {
  isLoggedIn,
  mockPasswordLogin,
  mockWechatLogin
} from '../../utils/auth-mock.js'

改为:

import {
  isLoggedIn,
  passwordLogin,
  clearLegacyMockSessionIfNeeded
} from '../../utils/auth.js'

在 onShow 开头增加:

clearLegacyMockSessionIfNeeded()

将 onPasswordLogin 内:

mockPasswordLogin({
  username: this.username,
  password: this.password
})

改为:

await passwordLogin({
  username: this.username,
  password: this.password
})

(本 Task 暂保留 onWechatLogin 方法体;Task 5 再置灰并移除。)

  • Step 4: 删除 auth-mock.js
rm /Users/wuzilong/work/00code/zkxw/mobile-h5/utils/auth-mock.js

确认无残留引用:

rg "auth-mock" /Users/wuzilong/work/00code/zkxw/mobile-h5

Expected: 无匹配。

  • Step 5: Commit
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 块后追加:

.login-wechat.is-disabled {
  opacity: 0.45;
  pointer-events: none;
}
  • Step 2: 模板置灰并去掉点击

将:

    <view class="login-wechat" @click="onWechatLogin">
      <image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
      <text>微信授权登录</text>
    </view>

改为:

    <view class="login-wechat is-disabled">
      <image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
      <text>微信授权登录</text>
    </view>

删除 methods 中的 onWechatLogin 整段方法。

  • Step 3: Commit
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: 公钥接口可达
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