---
url: /docs/api-docs/v1/oauth/sdk.md
---
# 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 单飞](uniapp-dev-guide)。

***

## 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. 已知边界（使用前必读）

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 后端 |
|---|---|---|
| 语言 | 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)` |

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