---
url: /docs/api-docs/v1/frontend.md
---
# 前端联调 API 手册（v1）

> 归属：API Docs → v1 → **前端联调手册**
> 覆盖：**30 个端点** — 前端 `src/api/*.ts`（6 文件）+ `src/utils/request.ts` 拦截器 + 3 条页面直调
> 字段事实源：`tests/reports/contract-matrix.json`（2026-09-20 前后端逐字段比对）
> 错误码权威：[错误码注册表](/ai-contexts/error-code-registry)
> 配套文档：[API 联通矩阵](/internal-wiki/operations/frontend-api-matrix) · [联调问题台账](/internal-wiki/operations/frontend-integration-issues)

本手册**按前端源码文件分组** —— 你打开哪个 `.ts`，就看哪一页。每个端点四块：摘要 / curl / 请求字段表 / 响应字段表 + 错误码表。

## 30 端点总表

| 页 | 对应前端文件 | 端点数 |
|---|---|---|
| [login.md](./login) | `src/api/login.ts` + `src/utils/request.ts` | 7 |
| [user.md](./user) | `src/api/user.ts` | 7 |
| [device.md](./device) | `src/api/device.ts` | 4 |
| [training.md](./training) | `src/api/training.ts` | 5 |
| [splash.md](./splash) | `src/api/splash.ts` | 2 |
| [agreement.md](./agreement) | `src/api/agreement.ts` | 2 |
| [page-direct.md](./page-direct) | 页面直调（不走 `src/api/*.ts`） | 3 |
| | **合计** | **30** |

> 🔢 **字段「值」速查**：[**values.md · 取值速查**](./values) —— 本手册各页回答「字段叫什么、什么类型、必填吗」；
> 那个页回答「**填这个值会发生什么**」（每个枚举/边界值的业务后果 + 缺省行为）。
> **起因**：2026-09-20 真实联调 BUG —— `habits.exerciseFreq` 只写「0–7」，前端不知 `0` 是**有效值**（写 0 就通了），卡住联调。**根因不是代码，是文档只给范围不给语义。**

***

## ⭐ 另有 19 个新接口（不在上面 30 个内，但你可能要用）

2026-09-20 新增 —— 为**当时还是硬编码假数据**的页面补的数据源。
**接线前必读**：[**接线必读 · 7 条兜底**](./wiring-guide) —— 不做兜底**会白屏**（`withDefaults` 只补 `undefined` 不补 `null`）。

| 批次 | 数量 | 端点 | 覆盖页面 |
|---|---|---|---|
| **device 健康详情** | 10 | `POST /api/v1/device`：`sleepDetail` / `heartRateDetail` / `stressDetail` / `oxygenDetail` / `stepsDetail` / `caloriesDetail` / `strengthDetail` / `weightDetail` / `activitySummary` / `recordDates` | `detail/` 14 页 + `index/` 9 组件 |
| **sport 运动会话** | 6 | `POST /api/v1/sport`：`startRun` / `reportRunCoordinate` / `finishRun` / `myRunRecords` / `runSessionDetail` / `addWeightRecord` | `run/` + `mine/sports/` + 加体重表单 |
| **health\_archive 健康档案** | 3 | `POST /api/v1/health-archive`：`archive` / `saveHealthIncident` / `saveHeartRateZones` | `mine/info/` 7 组件 |
| **simulation 仿真数据** | 3 | `POST /api/v1/simulation`：`simulateData` / `clearSimulatedData` / `status` | **Dev 库专用**（Prd 会拒） |

> 📖 **Dev / Prd 两个库有什么区别** → [**双库对照**](./dual-env)（结论：**契约完全一致**，区别只在 Dev 有仿真数据）
> 📋 逐字段契约证据 → `tests/reports/contract-audit-*.md`（1036 行，19 个 action 每个字段的对照）

***

| # | 方法 | 路径 | action | 所在页 |
|---|---|---|---|---|
| 1 | POST | `/api/v1/auth/oauth` | `captcha` | [login](./login#captcha) |
| 2 | POST | `/api/v1/auth/oauth` | `sms-send` | [login](./login#sms-send) |
| 3 | POST | `/api/v1/auth/oauth` | `sms-login` | [login](./login#sms-login) |
| 4 | POST | `/api/v1/auth/oauth` | `rotate` | [login](./login#rotate) |
| 5 | POST | `/api/v1/auth/oauth` | `mySessions` | [login](./login#mysessions) |
| 6 | POST | `/api/v1/auth/oauth` | `revokeSession` | [login](./login#revokesession) |
| 7 | POST | `/api/v1/auth/oauth` | `logout` | [login](./login#logout) |
| 8 | POST | `/api/v1/user` | `updateBasicProfile` | [user](./user#updatebasicprofile) |
| 9 | POST | `/api/v1/user` | `submitOnboarding` | [user](./user#submitonboarding) |
| 10 | POST | `/api/v1/user` | `onboardingSuggestion` | [user](./user#onboardingsuggestion) |
| 11 | POST | `/api/v1/user` | `adoptPlanOption` | [user](./user#adoptplanoption) |
| 12 | POST | `/api/v1/user` | `completeOnboarding` | [user](./user#completeonboarding) |
| 13 | POST | `/api/v1/user` | `myProfile` | [user](./user#myprofile) |
| 14 | POST | `/api/v1/user` | `updateProfile` | [user](./user#updateprofile) |
| 15 | GET | `/api/v1/device/vendors` | — | [device](./device#listvendors) |
| 16 | POST | `/api/v1/device/vendors/{id}/auth-url` | — | [device](./device#getauthurl) |
| 17 | POST | `/api/v1/device` | `myBindings` | [device](./device#mybindings) |
| 18 | POST | `/api/v1/device` | `unbind` | [device](./device#unbind) |
| 19 | GET | `/api/v1/training/plans` | — | [training](./training#listtrainingplans) |
| 20 | POST | `/api/v1/training/enroll` | — | [training](./training#enrolltrainingplan) |
| 21 | GET | `/api/v1/training/tasks` | — | [training](./training#listmytasks) |
| 22 | POST | `/api/v1/training/task/checkin` | — | [training](./training#checkintask) |
| 23 | GET | `/api/v1/training/reminders` | — | [training](./training#listmyreminders) |
| 24 | GET | `/api/v1/splash-screen` | — | [splash](./splash#getsplashscreen) |
| 25 | POST | `/api/v1/splash-screen/track` | — | [splash](./splash#tracksplashevent) |
| 26 | GET | `/api/v1/content/agreements` | — | [agreement](./agreement#listagreements) |
| 27 | GET | `/api/v1/content/agreements/{type}` | — | [agreement](./agreement#getagreement) |
| 28 | POST | `/api/v1/device` | `myTodayHealth` | [page-direct](./page-direct#mytodayhealth) |
| 29 | POST | `/api/v1/stats` | `getTodayOverview` | [page-direct](./page-direct#gettodayoverview) |
| 30 | POST | `/api/v1/upload?type=avatar` | — | [page-direct](./page-direct#upload) |

***

## 通用约定（7 条 · 所有端点共用）

### G1 · 统一包络 —— `request.ts` **不剥壳**

```json
{ "code": "0", "msg": "ok", "data": { /* 业务字段全在这里 */ } }
```

* 前端 `resolve(body)` 拿到的是**完整 body**（含 `code` / `msg` / `data`），业务字段一律在 **`body.data`** 之下 → 调用点写 `res.data.xxx`。
* **业务码是字符串**：`"0"` 成功、`"40101012"` 失败。前端用 `String(body?.code) === '0'` 判断，**不要用 `=== 0`**。

### G2 请求头

| header | 值 | 说明 |
|---|---|---|
| `X-Env` | `Dev` | `Prd`（大小写不敏感） | 双库路由。**全程必须一致** —— token 绑 X-Env，跨库即失效。主请求 + `rotate` 都要带 |
| `Authorization` | `Bearer <access>` | 默认档位；`rotate` 例外（见下） |
| `Content-Type` | `application/json` | action 协议与 REST body 都走 JSON；**`upload` 例外**（multipart） |

> ⚠️ **本手册所有示例统一用 `X-Env: Prd`** —— 因为 App 产线包就是 Prd
> （`qm-frontend-api/src/config/index.ts` 的 `X_ENV = "Prd"`，`build-prod.sh` 构建期硬断言）。
> **照着示例调 = 和 App 打同一个库**，排查时不会出现「我调的结果和 App 不一样」的环境错位。
> 两个值都合法：`Dev` 用于联调准备期的独立库（数据与 Prd 隔离），`Prd` 是 App 实际用的。

* GET 请求前端会自动附加 `cacheTime` query（防缓存击穿）—— 后端忽略，无影响。

### G3 · 鉴权档位

| 档位 | 端点 |
|---|---|
| **无（免登录）** | `captcha` · `sms-send` · `sms-login` · `training/plans` · `splash-screen` 全部 · `content/agreements` 全部 |
| `Bearer <access>` | 其余全部（含 3 条页面直调） |
| **`Bearer <refresh>`** ⚠️ | **只有 `rotate`** —— 传 access 会 `40101011` |

### G4 两套错误契约并存

| 域 | HTTP | 业务码 | 说明 |
|---|---|---|---|
| `oauth` | **200** | `40101xxx` | 业务失败也是 HTTP 200（例外：`40101013` 封禁 → 403、`40101011/12/14/15` → 401） |
| `user` / `device` / `training` / `upload` / `stats` | **400 / 401 / 404（+413）** | `40301xxx` / `40403xxx` / `40406xxx` / `40419xxx` / `40422xxx` | HTTP 状态直接表达错误类别；`msg` 可直接 toast |
| `agreement` | 恒 200 | 无业务码 | 非法入参走 `data: null`，不报错 |

**前端 401 分档**（`request.ts` 现行策略）：

| 业务码 | 处理 |
|---|---|
| `40101015` | 业务 401 → **toast，不续期**（目标会话不存在） |
| `40101012` / `40101014` | **强登出**（重放 / 设备信任过期） |
| 其他 401 | 触发 `rotate` 单飞重放一次 |

### G5 · `rotate` 由拦截器自动调用 —— 不是业务直调

`request.ts` 的 `rotateOnce` 单飞拦截器在 token 将过期 / 收到 401 时**自动发起**；curl 调试时要自己带 **refresh** token。详见 [login.md · 4 rotate](./login#rotate)。

### G6 · 命名风格

| 域 | 风格 |
|---|---|
| `user` / `device` / `training` / `splash` | camelCase |
| **`agreement`** | **snake\_case**（`updated_at` —— 全项目唯一，前端已对齐，勿改） |
| `oauth` | camelCase，**孤儿键 `token_expiring` 例外** |

### G7 · 响应侧几乎无 schema（**重要的排障前提**）

30 个端点里**只有 `captcha` 有 Pydantic 响应模型**（`CaptchaOutput`）。其余 29 个的响应是手组 dict / dataclass 序列化 / repo dict 直出 ——
**OpenAPI 看不到响应结构、字段改动没有任何守卫挡**。所以本手册的字段表就是事实防线，字段以本手册为准。

***

## 🔴 头号坑位速查（联调前必读，踩了会浪费半天）

| # | 坑 | 症状 | 详见 |
|---|---|---|---|
| **1** | **`clientId` 复合格式只允许一个冒号**（`{类型}:{设备UUID}`） | 写成 `android:a:b` 被拒，且返回的是**通用异常码 `40201007`（"服务异常"）而非参数错误** → 排障方向被误导 | [login](./login#sms-login) · [台账 #3](/internal-wiki/operations/frontend-integration-issues) |
| **2** | **`X-Env` 全程必须一致** | token 绑 X-Env，跨库即失效；`rotate` 漏带 → 查不到绑定行 | [G2](#g2-请求头) |
| **3** | **两套错误契约并存** | 拦截器只处理 HTTP 200 或只处理 HTTP 4xx，会漏一半 | [G4](#g4-两套错误契约并存) |
| **4** | **`rotate` 用 refresh token 做 Bearer** | 传 access → `40101011` | [login#4](./login#rotate) |
| **5** | **`enroll` 失败也返回 HTTP 200 + code 0** | `data: {ok:false, reason}` 不是幂等 upsert；后端规则「一次只允许一个进行中的计划」。**必须自查 `data.ok`** | [training#2](./training#enrolltrainingplan) |
| **6** | **`checkin` 的 taskId 不存在/非本人 → `data: null`**（HTTP 200 不报错） | 前端 await 后本地置 `status='done'` → **假成功** | [training#4](./training#checkintask) |
| **7** | **`logout` 有已知缺陷** | 前端现状不传 `clientId` → 后端吊销 `oauth:default`（不存在的会话）→ **真实会话未吊销，refresh 仍可 rotate**。决策：联调期再修 | [login#7](./login#logout) · [台账](/internal-wiki/operations/frontend-integration-issues) |
| **8** | **`upload?type=avatar` 是 multipart** | 不走 action 协议、**不走 `request.ts` 拦截器** → token + `X-Env` 要手动注入，且**无自动 rotate** | [page-direct#3](./page-direct#upload) |
| **9** | **`stats/getTodayOverview` 响应是嵌套的** | 读顶层 `data.budgetKcal` → 恒 `undefined`（历史漂移：首页热量目标恒为默认值）。正解 `data.calorie.budgetKcal` | [page-direct#2](./page-direct#gettodayoverview) |
| **10** | **`device/vendors` 的 `health.detail` 后端不存在** | 前端声明了 `detail`（optional）→ 展示时恒 `undefined`。对应物是 `health.message` | [device#1](./device#listvendors) |

### 已修复（2026-09-20 · v2.0.49 生产验证）

| 项 | 原问题 | 现状 |
|---|---|---|
| `rotate` 缺 `expiresAt` | 前端 `tokenExpires` 恒被覆盖为 `{}`（与 `sms-login` 路径不对称） | ✅ **已补** —— rotate 现返回 `expiresAt.{access,refresh}`，与 sms-login 同口径 |
| `updateProfile.nickname` | 只写画像表，不同步 oauth 主表 → 重登显示旧昵称 | ✅ **已双写** —— 提交 nickname 时同步 users 主表 |

***

## 错误码段总览（本手册涉及）

| 段 | 域 | 覆盖端点 |
|---|---|---|
| `40101xxx` | oauth | login 全部 7 个 |
| `40001xxx` | oauth 参数段 | 同上（`40001001` = HTTP 400 入参校验失败） |
| `40301xxx` | user | user 全部 7 个 + splash track（复用 `UserError`） |
| `40403xxx` | device | device 全部 4 个 + `myTodayHealth` |
| `40406xxx` | training | training 全部 5 个 |
| `40419xxx` | upload | `upload?type=avatar` |
| `40422xxx` | stats（**注册表当前登记为 ranking —— 撞段待确认**，见下） | `getTodayOverview` |
| `50001001` | oauth 兜底 | 所有 oauth 端点的服务异常 |

全量码表见 → [错误码权威注册表](/ai-contexts/error-code-registry)。

***

## 关联阅读

* [OAuth 登录认证（v1）](/api-docs/v1/oauth/) —— 认证链的**深入说明**（验证码三定律、限流三层、clientId 语义、状态机）。本手册只留接口级摘要，认证细节请点过去
* [OAuth 9 Action 字段表](/api-docs/v1/oauth/actions) · [OAuth 错误码矩阵](/api-docs/v1/oauth/error-codes)
* [User API](/api-docs/v1/user/actions) · [Device 设备线](/api-docs/v1/device/) · [training API](/api-docs/v1/training/) · [upload API](/api-docs/v1/upload/)
* [联调问题台账](/internal-wiki/operations/frontend-integration-issues) —— 差异 + 决策 + 状态的单一台账
* [开发测试台（免登录观测台）](/internal-wiki/operations/dev-console)
* Swagger 调试：`{BASE_URL}/docs`

## 待确认清单（本手册中标注不确定的条目）

| # | 条目 | 待确认内容 |
|---|---|---|
| 1 | `stats/getTodayOverview` 错误码段 | ✅ **已确认**（2026-09-20 契约审计）：`40422xxx` 是 **ranking 与 stats 的事实共享段**，两域 `40422001/002/004` 逐码重号**且语义完全相同** → 前端按码分支行为一致，**实际无危害**。遗留风险：任一方将来**新增码**会真冲突（建议下次动这两个域的错误码时把一方迁走）。已据实登记进[注册表](/ai-contexts/error-code-registry) |
| 2 | `device/myTodayHealth` 响应粒度 | 前端矩阵文档口径是简写 `{steps,distance,hr,spo2,sleep…}`，后端实际是**块状结构**（`steps={value,date}`、`todayActivity={totalCalories,totalDistanceKm}`）；**顶层 `distance` 后端不存在**。前端 TS 核对后如有偏差需升级处理 |
| 3 | `device/myTodayHealth` 的 `spo2` | 后端**恒返回 `null`**（V2 sync 暂无血氧来源），前端按 `?? undefined` 容忍；接血氧源后自动转有值 |
| 4 | `user/myProfile` 完整键集 | 动态 dict（画像行 24 键 + account 5 键），**无 schema 无法静态枚举**；且双仓储字段集不一致（PG 有 `updatedAt`，InMemory 无）。前端类型是 `Record<string, any>` —— 只能按字段**存在性**消费，不要全等断言 |
| 5 | `user/updateProfile` 响应 `user` 行 | 画像行 dict 直出**全列**（远超前端声明的 7 键）。前端声明的 7 键都在，其余键未定义 |
| 6 | `logout` 修复归属 | 前端补 `clientId` / 后端改语义（无 clientId → 吊销全部会话）—— 两条路线联调期决策，见台账 |
