Skip to content

OAuth SDK 参考(uniapp · 拷入即用) ​

📍 来源:OAuth 文档 sdk.html 位置(参考实现):apps/mp-official/utils/oauth/(uniapp 通用,4 文件共 ~1000 行) 服务端契约与 SDK 由同一次联调对拍收敛(2026-09-09 阶段 F),字段级一致。


1. 文件清单 ​

文件行数职责
client.ts302OAuthHttpClient:统一入口请求 + 9 action 方法 + rotate 单飞 + data 层 token_expiring 检测 + 40101xxx → oauthCode 反查映射
storage.ts260KeyStore 双通道加密(Android KeyStore AES-256-GCM v2 / XOR v1 兼容),token 存取
errors.ts130OAUTH_BIZ_CODE 错误码枚举(40101001-012)+ 转 UI 文案
interceptor.ts~110401 单飞拦截器:rotateOnce() / authFetch() / setForceLogoutHandler() / scheduleIdleRotate()
captcha-image.vue~90图形验证码组件:svg → base64+image 渲染、点击刷新、v-model:captchaId
oauth-demo.vue326完整演示页:登录 + 主页 + 退出 + 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-GCM12 字节随机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-modelcaptchaId 双向绑定
@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. 已知边界(使用前必读) ​

  1. wechatCallback 的 source 固定传 'app'(uniapp Android);'mini' 是小程序渠道预留
  2. KeyStore 通道仅真机 App 生效;HBuilderX 标准基座可能无原生桥 → 自动降级 XOR(控制台 warn),真机自定义基座验证 KeyStore
  3. demo.vue 的图形验证码用 base64 dataURL + <image> 渲染(App 端 rich-text 不支持 SVG);UI 组件依赖 uView
  4. 宿主工程若用 TypeScript 严格模式,deviceInfo 等可选字段类型已内建,无需额外声明
  5. Base URL 环境切换:SDK 内置 __DEV__ === true → http://localhost:3003,否则生产 https://api.qingmulife.cn。可用 new OAuthHttpClient({ baseUrl }) 显式覆盖

10. 与 server-v2 设计的差异 ​

维度uniapp SDK(前端)Python 后端
语言TypeScriptPython 3.12
token 加密Android KeyStore AES-256-GCMAES-256-GCM 库内(SQLModel 字段)
401 单飞inflightRotate Promise 缓存rotate 端点幂等(jti 验证)
验证码渲染base64 dataURL → <image>PNG dataURL 直接生成
错误码OAUTH_BIZ_CODE enumOAuthErrorCode(str, Enum)

设计原则一致,落地各自平台特性。