Appearance
OAuth SDK 参考(uniapp · 拷入即用)
📍 来源:OAuth 文档
sdk.html位置(参考实现):apps/mp-official/utils/oauth/(uniapp 通用,4 文件共 ~1000 行) 服务端契约与 SDK 由同一次联调对拍收敛(2026-09-09 阶段 F),字段级一致。
1. 文件清单
| 文件 | 行数 | 职责 |
|---|---|---|
client.ts | 302 | OAuthHttpClient:统一入口请求 + 9 action 方法 + rotate 单飞 + data 层 token_expiring 检测 + 40101xxx → oauthCode 反查映射 |
storage.ts | 260 | KeyStore 双通道加密(Android KeyStore AES-256-GCM v2 / XOR v1 兼容),token 存取 |
errors.ts | 130 | OAUTH_BIZ_CODE 错误码枚举(40101001-012)+ 转 UI 文案 |
interceptor.ts | ~110 | 401 单飞拦截器:rotateOnce() / authFetch() / setForceLogoutHandler() / scheduleIdleRotate() |
captcha-image.vue | ~90 | 图形验证码组件:svg → base64+image 渲染、点击刷新、v-model:captchaId |
oauth-demo.vue | 326 | 完整演示页:登录 + 主页 + 退出 + rotate(依赖 uView UI) |
2. 快速接入(宿主 uniapp 工程)
bash
# 1. 拷贝目录到宿主工程
cp -r apps/mp-official/utils/oauth <你的工程>/utils/oauth
cp apps/mp-official/utils/oauth-demo.vue <你的工程>/pages/oauth-demo.vue
# 2. pages.json 注册 demo 页(可先跑通再拆)
{ "path": "pages/oauth-demo", "style": { "navigationBarTitleText": "OAuth 登录演示" } }
# 3. App 端 manifest.json:勾选微信登录模块
# (App 模块配置 → OAuth 登录 → 微信)
# 填微信开放平台的 AppID(universal link 按微信文档配置)3. client.ts 核心 API(2026-09-11 审查对齐后签名)
typescript
import { OAuthHttpClient } from './oauth/client';
const client = new OAuthHttpClient(); // __DEV__→mock:3003,生产→api.qingmulife.cn
// 也可用 new OAuthHttpClient({ baseUrl }) 覆盖
// 9 个 action 一一对应(与后端字段级一致,11 步 smoke 全绿)
await client.getCaptcha(); // → { captchaId, png, svg }
await client.sendSmsCode(phone, captchaId, captchaCode); // → { ok: true }
await client.verifySmsCode(phone, smsCode); // → { ok: true }(不消费,可重复)
await client.smsLogin({
phone, smsCode,
agreementAccepted: true,
nickname?, deviceInfo?,
});
await client.wechatCallback({
code, source: 'app', agreementAccepted: true,
nickname?, avatarUrl?,
});
await client.rotate(); // 内部自动取本地 refresh → Bearer 头
await client.logout('all'); // 内部自动带 Bearer access → { ok, scope, revoked }
await client.bindPhone(phone, smsCode);
await client.unbind(); // 缺省全解;或 client.unbind('mobile')
// 会话回调
client.onTokenExpiring = () => { /* data.token_expiring=true 时触发:空闲 rotate */ };
client.onAuthLost = (err) => { /* 401 家族:清会话跳登录 */ };⚠️ 旧版方法名(
sendSms/verifySms/smsLogin位置参数 /logout(refreshToken))已废弃 —— 2026-09-11 审查发现其字段名与鉴权头与后端不符,前端如有旧拷贝务必替换为本版。
4. storage.ts 核心 API
typescript
import { saveTokens, loadTokens, clearTokens } from './oauth/storage';
// 登录/rotate 成功后:整个响应的 tokens+expiresAt 对象直传
// (真签名 saveTokens(result: OAuthTokens, user?))
await saveTokens(res); // res = { tokens: {access, refresh}, expiresAt: {access, refresh} }
// 冷启动恢复
const t = await loadTokens(); // 无/解密失败 → null → 进登录页
// 登出
await clearTokens();4.1 KeyStore 双通道
| 通道 | 平台 | 加密 | IV | 格式 |
|---|---|---|---|---|
| v2(默认) | Android 真机 | AndroidKeyStore AES-256-GCM | 12 字节随机 | base64(iv ‖ ciphertext) |
| v1(降级) | 无 KeyStore 环境 | XOR 混淆 | — | 兼容读取旧数据 |
⚠️ HBuilderX 标准基座可能无原生桥 → 自动降级到 v1,真机自定义基座验证 KeyStore。
5. 401 单飞范式(client.ts 已内置,节选原理)
typescript
private inflightRotate: Promise<RotateResult> | null = null;
async rotate(): Promise<RotateResult> {
if (this.inflightRotate) return this.inflightRotate; // 并发请求共享同一续期
this.inflightRotate = this.doRotate().finally(() => (this.inflightRotate = null));
return this.inflightRotate;
}
// doRotate 失败(40101001)→ 上层捕获后 clearTokens + 跳登录,绝不重试完整实现:见 uniapp-dev-guide §2.2 业务请求的 401 单飞。
6. interceptor.ts 业务请求包装
typescript
// utils/oauth/interceptor.ts(完整版)
let inflightRotate: Promise<boolean> | null = null;
let onForceLogout: () => void = () => uni.reLaunch({ url: '/pages/login/index' });
export function setForceLogoutHandler(fn: () => void) { onForceLogout = fn; }
export async function rotateOnce(): Promise<boolean> {
if (inflightRotate) return inflightRotate;
inflightRotate = (async () => {
const t = await loadTokens();
if (!t?.refresh) return false;
try {
const res = await new OAuthHttpClient().rotate();
await saveTokens(res);
return true;
} catch (e: any) {
// 40101001(含重放连坐):唯一正确动作 = 清会话,绝不重试
await clearTokens();
onForceLogout();
return false;
} finally {
inflightRotate = null;
}
})();
return inflightRotate;
}
export async function authFetch<T>(doRequest: (access: string) => Promise<T>): Promise<T | null> {
const t = await loadTokens();
if (!t?.access) { onForceLogout(); return null; }
try {
return await doRequest(t.access);
} catch (e: any) {
if (e?.statusCode !== 401) throw e;
const ok = await rotateOnce();
if (!ok) return null;
const nt = await loadTokens();
return nt?.access ? doRequest(nt.access) : null;
}
}7. captcha-image.vue 图形验证码组件
html
<captcha-image v-model:captchaId="captchaId" @error="showError" />| 特性 | 说明 |
|---|---|
| 渲染 | svg → base64 dataURL → <image>(绕开 App 端 rich-text 不支持 SVG) |
| 刷新 | 点击图片即换一张(无需额外按钮) |
| v-model | captchaId 双向绑定 |
| @error | 图形码加载失败时触发 |
8. errors.ts 错误码 → UI 文案
typescript
import { toClientMessage, OAuthErrorCode } from '@/utils/oauth/errors';
function showError(err: unknown) {
const m = toClientMessage(err);
if (m.oauthCode === OAuthErrorCode.CAPTCHA_WRONG
|| m.oauthCode === OAuthErrorCode.CAPTCHA_EXPIRED) {
loadCaptcha(); // 图形码失败 → 自动刷新,不打断输入
}
uni.showToast({ title: m.advice, icon: 'none' });
}9. 已知边界(使用前必读)
wechatCallback的source固定传'app'(uniapp Android);'mini'是小程序渠道预留- KeyStore 通道仅真机 App 生效;HBuilderX 标准基座可能无原生桥 → 自动降级 XOR(控制台 warn),真机自定义基座验证 KeyStore
demo.vue的图形验证码用 base64 dataURL +<image>渲染(App 端 rich-text 不支持 SVG);UI 组件依赖 uView- 宿主工程若用 TypeScript 严格模式,
deviceInfo等可选字段类型已内建,无需额外声明 - Base URL 环境切换:SDK 内置
__DEV__ === true→http://localhost:3003,否则生产https://api.qingmulife.cn。可用new OAuthHttpClient({ baseUrl })显式覆盖
10. 与 server-v2 设计的差异
| 维度 | uniapp SDK(前端) | Python 后端 |
|---|---|---|
| 语言 | TypeScript | Python 3.12 |
| token 加密 | Android KeyStore AES-256-GCM | AES-256-GCM 库内(SQLModel 字段) |
| 401 单飞 | inflightRotate Promise 缓存 | rotate 端点幂等(jti 验证) |
| 验证码渲染 | base64 dataURL → <image> | PNG dataURL 直接生成 |
| 错误码 | OAUTH_BIZ_CODE enum | OAuthErrorCode(str, Enum) |
设计原则一致,落地各自平台特性。