---
url: /docs/api-docs/v1/frontend/page-direct.md
---
# 前端联调 · 页面直调（3 个端点）

> 归属：[前端联调 API 手册](./) → **页面直调**（不走 `src/api/*.ts`，无 TS 泛型锚）
> 端点：**3 个** —— `myTodayHealth` · `getTodayOverview` · `upload?type=avatar`
> 为什么单列：这 3 条是自动化提取器的盲区（生成器只扫 `src/api/*.ts`），2026-09-20 人工全审矩阵补录
> ⚠️ **无 TS 泛型锚** → 本页字段表**以后端源码为契约基准**，前端 TS 核对后如有偏差请回填
> 取值语义：**[取值速查 · stats](./values#stats)**（`profileComplete=false` 意味着预算是**默认画像**算出来的）· **[取值速查 · upload](./values#upload)**（`type` 非白名单**静默兜底 `misc`**、`localFallback` 与 `source` 的降级链）· **[取值速查 · device](./values#device)**（`myTodayHealth` 块状结构）

## 本页 curl 变量

```bash
BASE_URL=https://api.qingmulife.cn   # 本地：http://localhost:3000
ACCESS=<access_token>
ENV=Prd                              # Dev | Prd —— 全程必须一致
```

## 端点一览

| # | 方法 | 路径 | action | 页面 | 鉴权 |
|---|---|---|---|---|---|
| [1](#mytodayhealth) | POST | `/api/v1/device` | `myTodayHealth` | 首页健康卡（`index.vue`） | access |
| [2](#gettodayoverview) | POST | `/api/v1/stats` | `getTodayOverview` | 首页热量 / 连续天数卡 | access |
| [3](#upload) | POST | `/api/v1/upload?type=avatar` | — | 头像上传（**multipart**） | access（手动注入） |

***

## myTodayHealth

**一句话**：首页健康卡数据 —— 步数 / 活动 / 心率 / 血氧 / 睡眠一屏聚合（**块状结构**）。

### curl

```bash
curl -s "$BASE_URL/api/v1/device" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"action":"myTodayHealth","payload":{}}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"myTodayHealth"`（device 端点 6+2 action 分发） |
| `payload` | object | ✖ | 忽略（无入参） |
| `Authorization` / `X-Env` | header | ✔ | |

### 响应 `data` ⚠️ **块状结构（不是扁平字段）**

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `date` | string | 否 | CN 今日 `'YYYY-MM-DD'` |
| `steps` | `{value: number, date: string}` | **null** | **是** | 步数块 |
| `todayActivity` | `{totalCalories: number, totalDistanceKm: number}` | **null** | **是** | 当日活动块 |
| `hr` | `{value: number, date: string}` | **null** | **是** | 心率块 |
| `spo2` | **`null`（恒 null）** | 是 | 血氧块 —— V2 sync 暂无血氧来源；前端按 `?? undefined` 容忍 |
| `sleep` | `{durationHours, deepHours, lightHours, remHours, score: null, date}` | **null** | **是** | 睡眠块 |
| `sources` | `{steps?: string, hr?: string, sleep?: string}` | 否 | 厂商来源映射（「数据来自：XX」小字）；**无数据时为 `{}`**，键按有无数据出现 |

> ⚠️ **顶层就这 7 个键**（`date` / `steps` / `todayActivity` / `hr` / `spo2` / `sleep` / `sources`）——
> 后端**没有** `sleepXiaomi` 键。前端 `const sleep = h.sleepXiaomi ?? h.sleep` 里的
> `sleepXiaomi` 是 V1 遗留分支，**永远取不到**（`??` 右侧恒生效）。无害但误导 ——
> 见 KB-04 台账，接线时勿照抄。

> ⚠️ **待确认 #2 —— 粒度差异**：前端矩阵文档口径写的是简写 `{steps, distance, hr, spo2, sleep…}`，但**后端顶层没有 `distance`** ——
> 对应物是 `todayActivity.totalDistanceKm`。同样 `steps` / `hr` 都是 `{value, date}` 对象而非数字。
> **前端 TS 核对后如有偏差请升级为契约差异回填台账。**

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40403002` | 401 | 未登录 / token 无效 | 走拦截器 401 分档 |
| `40403001` | 400 | 未知 action | 检查 action 名 |

***

## getTodayOverview

**一句话**：首页热量 / 连续天数卡 —— 今日热量预算 + 摄入 + 消耗 + 打卡统计。

> 🔴 **坑位（历史漂移，已修，留档防回归）**：响应是**嵌套的**。
> 后端自始返回 `data.calorie.budgetKcal`，前端曾读**顶层** `data.budgetKcal` → **首页热量目标恒为默认值**。
> 前端已在 `api` 分支 `24902e9` 修正为按嵌套路径消费。**别再写回顶层。**

### curl

```bash
curl -s "$BASE_URL/api/v1/stats" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"action":"getTodayOverview","payload":{}}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `action` | string | ✔ | 固定 `"getTodayOverview"`（stats 端点 15 action 分发） |
| `payload` | object | ✖ | 忽略（无入参） |
| `Authorization` / `X-Env` | header | ✔ | |

### 响应 `data` —— 🔴 **两层嵌套**

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `date` | string | 否 | CN 今日 `'YYYY-MM-DD'` |
| `calorie.budgetKcal` | number | **✔** | int —— 热量预算（BMR Mifflin-St Jeor × 1.3 − 400，下限 1500 / 女性 1200）。**画像缺失 = `null`** |
| `calorie.intakeKcal` | number | 否 | float —— 摄入（当日无记录 = `0`） |
| `calorie.burnKcal` | number | 否 | float —— 消耗 = **跑步 + 力量**（无记录 = `0`） |
| `calorie.remainingKcal` | number | **✔** | int —— 剩余额度（`budget + burn − intake`，**向零截断**）。**画像缺失 = `null`** |
| `calorie.profileComplete` | boolean | 否 | `false` = 画像缺失 |
| `checkin.count` | number | 否 | int —— 当日打卡数 |
| `checkin.distanceKm` | number | 否 | float —— 无打卡为 `0` |

> 🔴 **`profileComplete:false` 时后端 `budgetKcal` / `remainingKcal` 一律返回 `null`** ——
> **不编数**（2026-09-20 用户拍板）。此前返的是「默认资料 30 岁 / 165cm / 60kg 算出来的值」，
> 看着像真数据，前端**无法区分有无**。看到 `null` 请渲染 `--`，**不要再兜一个默认值**
> （本仓 KB-06 的教训：`?? 0` 把「不知道」变成可证伪的假数）。

> ✅ **正确读法**：`res.data.calorie.budgetKcal`
> ❌ **错误读法**：`res.data.budgetKcal`（恒 `undefined` → 前端回退默认值 → 需求「热量目标」静默失效）

> ⚠️ **本端点没有 `basis` 字段**（2026-09-22 按源码核实并移除 —— 本表此前误列）。
> `basis` 属于 **`stats.calorieBalance`**（另一个 action），与 `getTodayOverview` 无关。
> 首页 17 个端点的完整入参 / 出参表见 **[首页 API 接口参考](./home-api-reference)**。

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40422002` | 401 | 未登录 / token 无效 | 走拦截器 401 分档 |
| `40422001` | 400 | 入参非法 | 检查调用点 |
| `40422005` | **200** | 样本不足（关联分析不下结论） | 按缺省文案展示 |

> ⚠️ **待确认 #1 —— 错误码段归属**：stats 源码用 **`40422xxx`**（`src/app/stats/domain/exceptions.py`），
> 但[错误码注册表](/ai-contexts/error-code-registry)把 `40422xxx` 登记为 **ranking** 段 —— **疑似撞段**。
> 本页按源码写实际值；段归属待确认后回填。

***

## upload

**一句话**：头像上传 —— 🔴 **multipart，不是 action 协议**，header 要**手动注入** token + `X-Env`。

> 🔴 **坑位**：这条**不走 `request.ts` 拦截器** → **没有自动 rotate**（token 过期时不会自动续期，直接失败）。
> 上传前请自查 token 有效性；失败重试需自行处理。

### curl

```bash
# --form 让 curl 自动生成 multipart boundary + filename
curl -s "$BASE_URL/api/v1/upload?type=avatar" \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -F 'file=@/path/to/avatar.jpg'

# 强制本地 fallback（应急 / 调试）
curl -s "$BASE_URL/api/v1/upload?type=avatar&localFallback=1" \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -F 'file=@/path/to/avatar.jpg'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `type` (query) | string | ✔ | 头像传 `'avatar'`。净化正则 `/^[a-z0-9_-]{1,32}$/`（**下划线合法**）→ 不符**静默兜底 `'misc'`**（不报错） |
| `file` (multipart) | File | ✔ | **字段名必须叫 `file`**；stdlib 解析单 file 字段 |
| `Authorization` | header | ✔ | `Bearer <access>` —— **页面手动注入** |
| `X-Env` | header | ✔ | **页面手动注入**（漏了会落到默认库） |
| `localFallback` (query) | string | ✖ | `'1'` = 强制本地存储（应急/调试）；缺省 COS 优先 |
| `x-upload-password` | header | ✖ | 仅 `xiaomi_zip` 用（不进 URL 日志），头像不需要 |

**约束**：

| 项 | 值 |
|---|---|
| 大小上限 | **50MB**（`CZ_UPLOAD_MAX_SIZE`） |
| MIME 白名单 | `image/jpeg` · `image/png` · `image/webp` · `application/zip` · `application/octet-stream` |
| 存储派发 | COS 配置齐全 → COS（优先 CDN `cos-cdn.qingmulife.cn`）；配置缺失 / putObject 抛错 → **静默 fallback 本地** |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `url` | string | 否 | COS/CDN URL **或**本地 `/uploads/{type}/...` 路径（依环境配置）—— **前端消费这个做头像回显** |
| `size` | number | 否 | int，字节数（后端返回，前端未声明） |
| `mime` | string | 否 | 回显上传 MIME（后端返回，前端未声明） |
| `source` | string | 否 | `'cos'` | `'local'`（后端返回，前端未声明） |
| `uploadRecordId` | string | **条件出现** | **仅 `type ∈ RECORD_TYPES`（7 个数据包/截图 type）且 `source='cos'`** —— **`avatar` 不出现** |

### 错误码（段 `40419xxx`）

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40419003` | 400 | multipart 里没有 `file` 字段（NO\_FILE） | 检查字段名是否叫 `file` |
| `40419004` | 400 | MIME 不在白名单（UNSUPPORTED\_MIME） | 前置校验图片类型 |
| `40419005` | **413** | 文件 > 50MB（FILE\_TOO\_LARGE） | 前置压缩 / 提示超限 |
| `40419002` | 401 | 未登录 / token 无效 | **无自动 rotate** —— 手动刷新 token 后重试 |
| `40419001` | 400 | 参数非法 | 检查 `type` |
| `40419006` | 404 | UploadRecord 不存在（worker 侧） | 用户无感 |

***

## 本页合同要点速记

| 端点 | 最容易踩的点 |
|---|---|
| `myTodayHealth` | 块状结构 —— `steps.value` / `todayActivity.totalDistanceKm`；**顶层无 `distance`**；`spo2` 恒 null |
| `getTodayOverview` | **嵌套两层** —— `data.calorie.budgetKcal`，不是 `data.budgetKcal` |
| `upload` | **multipart** + 手动 header + **无自动 rotate** + 字段名必须 `file` |
