---
url: /docs/api-docs/v1/frontend/values.md
---
# 前端联调 · 取值速查（字段值语义）

> 归属：[前端联调 API 手册](./) → **取值速查**
> 覆盖：手册 **30 端点**的**请求字段** + **响应枚举字段** · 按域分组（oauth / user / device / training / splash / agreement / stats / upload）
> **本页是「值」的语义层** —— 手册各页回答「字段叫什么、什么类型、必填吗」；本页回答「**填这个值会发生什么**」。
> 事实源：**后端源码**（`src/app/<domain>/interface/api/*.py` 的 Pydantic 定义 + `domain/` 的分档判据）—— **不是转抄手册**。
> 错误码：[错误码注册表](/ai-contexts/error-code-registry) · 契约矩阵：`tests/reports/contract-matrix.json`

## 为什么有这一页（真实事故）

2026-09-20 真实联调 BUG：前端调 `submitOnboarding` 收到 `40301001 habits.exerciseFreq: Field required`，卡住联调。
手册当时那一行写的是：

```
| `habits.exerciseFreq` | number | ✔ | int，0–7（每周运动次数） |
```

前端看到「0–7」**不知道 0 是什么意思**，**也不知道 0 是有效值**（常智原话：「可以取值为"0"就通了」）—— 它以为 0 可能表示「未填/无效」。
**根因不是代码，是文档：只给了范围，没给取值语义。**

### 三条铁律（本页存在的理由）

| # | 铁律 | 事故形态 |
|---|---|---|
| 1 | **范围 ≠ 语义** | 「0–7」不告诉你 `0` 是「不运动（久坐）」，也不告诉你**它是合法值** |
| 2 | **边界值有业务后果** | `exerciseFreq = 0` → 打「久坐少动」标签 + 人群分型走 **A 类新手**；`≥3` 才有「运动基础」判据 |
| 3 | **缺省 ≠ 不传** | 若干字段「不传」与「显式传 `0` / `null`」语义**不同** —— 见每张表的「缺省 / 不传时」列 |

### 图例与阅读约定

* `✔` = **API 必填**（不传 → 400 + 字段路径消息）· `✖` = API 可选
* **「前端 UI 强制」≠「API 必填」**：有些字段后端 `✖` 但前端表单 `canSubmitHabits` 之类强制填 —— 本页会分别标注，**别把前端行为当后端契约**
* **待确认** = 源码读不出确定结论，已写清卡在哪 —— **不编造**（汇总见文末）
* 所有「不传」的判定均以 **Pydantic 默认值**或**路由层 `.get()` 兜底**为准（源码行号附在每条事实后）

***

## oauth

**登录认证** · `POST /api/v1/auth/oauth` · **7 端点** · [手册 login](./login) · 错误段 `40101xxx` / 参数段 `40001001`

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `action` | `captcha` | 取图形验证码（唯一有 Pydantic 响应模型的端点） | **无缺省 —— 必传**。缺失 → `40001001`（`normalizer.py:52` `raise ValueError("missing action")`） |
| | `sms-send` | 发短信验证码（需图形码 + 60s 冷却） | |
| | `sms-login` | 手机号登录/注册（号码不存在**自动建号**） | |
| | `rotate` | 用 refresh 换新 access（**拦截器自动调**，非业务直调） | |
| | `mySessions` | 当前账号活跃设备会话列表 | |
| | `revokeSession` | 踢掉指定设备会话 | |
| | `logout` | 退出登录（access 本体进 jti denylist 即时失效） | |
| | 其他任意串 | 不匹配任何 `case` → 落到路由末尾兜底 | 抛 `40001001`（非静默） |
| `clientId`（sms-send / sms-login） | **复合键** `{类型}:{设备UUID}`如 `android:550e8400-...` | **设备级会话**（v2 新口径）—— 同一账号每台设备一条独立会话，`mySessions` 逐台列出。正则 `^[A-Za-z0-9_-]{1,20}:[A-Za-z0-9-]{1,40}$`：类型段 ≤20、UUID 段 ≤40、**只允许一个冒号** | **`"default"`**（路由层 `payload.get("clientId", "default")`，`oauth.py:84/100`）→ **类型级会话**，所有设备共用一条 → `mySessions` 只剩一行、`logout` 会吊销错目标 |
| | **简单键** `android` / `ios` / web… | **类型级会话**（v1 旧口径，正则 `^[A-Za-z0-9_-]{1,32}$`）。**无 `:` 时回退类型级** —— 会话共享 | |
| | `android:a:b`（两个冒号） | 两条正则**都不匹配** → 被拒 | ⚠️ 返回的是**通用异常码 `40201007`「服务异常」而非参数错误** → 排障方向被误导（[坑位 #1](./#🔴-头号坑位速查-联调前必读-踩了会浪费半天)） |
| `agreementAccepted`（sms-login） | `true`（**字面量**） | 通过；协议已同意 | **`false`** → `40101008 AGREEMENT_NOT_ACCEPTED`。**必须字面 `true`**（`captcha_id.py:25` 校验）—— 前端写死 `true` |
| `nickname`（sms-login） | 任意 ≤32 串 | 新用户注册时的昵称 | 不传 → 自动生成「用户XXXX」（真机可见） |
| `deviceName`（sms-login） | 任意串（如 `常智的 Android`） | 写入会话元数据 → `mySessions[].deviceName` 展示用 | 不传 → `mySessions[].deviceName` 为 **`null`** |
| `scope`（logout） | `device` | **只吊销 `clientId` 对应的那一台**会话（`logout.py:55`） | **默认 `device`**（`oauth.py:178` `payload.get("scope","device")`） |
| | `all` | 吊销该用户**全部** oauth 会话（`cz_revoke_all_oauth`）—— 会**误踢其他设备** | |
| | 其他（如 `devices`） | 非法 | → `40001001`（`logout.py:49` 白名单卡控，**不是静默**） |
| `userId`（logout） | 任意串 | ⚠️ **近乎死参数**：仅当 `Authorization` 缺失/解码失败时用作兜底（`oauth.py:160` `payload.get("userId","anonymous")`）；正常路径身份**以 token 为准** | 不传 → `"anonymous"`（无 token 时会吊销不到任何东西） |

**响应侧枚举**

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `user.isNewUser`（sms-login） | `true` | **本次请求刚建号** —— 前端弹「完善基础资料」向导的**唯一判据**（`oauth.py:120`） | ⚠️ **仅本次响应为 `true`**；同一账号再登录即 `false` → **不要持久化这个标志**（下次登录读不到） |
| | `false` | 已存在账号，直接进首页 | |
| `token_expiring`（sms-login / rotate） | `false` | **当前恒 `false`** —— 全仓 6 处赋值均为 `False`（`sms_login.py:117` / `rotate.py:94,145` / `callback.py:138` / `bind_phone.py:135`），是**预留位**（计划用于「token 即将过期，建议提前续期」预警） | — |
| `sessions[].current`（mySessions） | `true` | **当前设备**（token `cid` 比对命中） | — |
| | `false` | 其他在线设备 | |
| `revoked`（logout） | `1` | ⚠️ **`scope=device` 时恒 `1`** —— **即使没吊销到任何行**（`logout.py:57` 硬编码）→ 显示「成功吊销 1 个」会误导 | — |
| | ≥0 整数 | `scope=all` 时为**实际**吊销行数 | — |
| `scope`（logout） | 回显 `device` / `all` | 回显请求值 | — |
| `expiresAt.access` / `expiresAt.refresh` | ISO 串 | 签发时刻 + TTL。**rotate 已于 2026-09-20 补齐**（此前缺 → 前端 `tokenExpires` 被覆盖成 `{}`） | — |
| `user.phone`（sms-login） | 脱敏串 `138****8000` | **脱敏**，前端不消费（回显完整号请用 `myProfile`） | — |
| `user`（rotate） | `{id}` **仅一键** | ⚠️ **与 sms-login 的 `user` 不同构**（无 `nickname` / `phone` / `isNewUser`）—— 共享 user 类型的调用点会读到 `undefined` | — |

***

## user

**用户资料 / 新用户向导** · `POST /api/v1/user` · **7 端点** · [手册 user](./user) · 错误段 `40301xxx`

### 请求 · 四维基础资料（`updateBasicProfile` / `submitOnboarding`）

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `gender`（updateBasicProfile） | `male` | BMR 公式偏移 **+5**；`cz_compute_bmr` 的 `complete=true` | **无缺省 —— ✔ 必传**。其他值（含 `unknown`）→ `40301001`（Literal 只两值 — `user_actions.py:155`） |
| | `female` | BMR 偏移 **−161**；`complete=true` | |
| `gender`（submitOnboarding） | `male` / `female` | 同上 | **无缺省 —— ✔ 必传** |
| | `unknown` | ⚠️ **`submitOnboarding` 独有的第三值**（后端比前端更宽 —— `user_actions.py:121`）。BMR 偏移 **−78（中性）**，且 `complete` 判定**只认 male/female** → `profileComplete=false` → 前端应引导补全。**分型 / 计划不受影响**（只用 BMI + 习惯） | |
| `age` | 5 ~ 120 整数 | **5–17 → `minorMode = true`**（未成年模式）：① `onboardingSuggestion.goal` **返 `null`**（不给减脂目标）② 计划**收敛为唯一一档** `gentle`「健康习惯型」（`deficitKcal=0`，文案「不设热量缺口」）③ 安全提示改为未成年劝导文案**18–120 → 成人链路**（BMI<18.5 另有偏瘦保护，见下） | ✔ 必传。越界 → `40301001`（另有专用码 `40301013` 年龄非法） |
| `height` | 50 ~ 250（float，**cm**） | **写入时 `cz_js_round` 取整**（`onboarding.py:270/369`）—— 传 `175.4` 落库 `175`，响应回显的也是**取整后的值**。参与 BMI（`/100` 换成米）与 BMR（`6.25×身高cm`） | ✔ 必传。超出 → `40301001` |
| `weight` | 10 ~ 300（float，**kg**） | 参与 BMI（`weight/身高m²`，`cz_round1` 一位小数）、BMR（`10×体重`）、目标区间（`maxWeeklyLossPct` 反推） | ✔ 必传。超出 → `40301001` |
| `bodyFat` | 0 ~ 80（float，**%**） | 非 `null` → 画像 tags **追加「体脂 X%」**。⚠️ **`0` 是合法值且会被打成「体脂 0%」标签**（`onboarding_service.py:216` `is not None` 判定，不是 truthy 判定）—— 不想打标签就**别传**，别传 `0` | ✖ 不传 / 传 `null` → **不打体脂标签**（与显式传 `0` **语义不同** —— 这是本页第 3 条铁律的实例） |
| `zodiac` | 12 值：`白羊座` `金牛座` `双子座` `巨蟹座` `狮子座` `处女座` `天秤座` `天蝎座` `射手座` `摩羯座` `水瓶座` `双鱼座` | 脱敏画像（**只存星座名，不存出生日期** — `user_actions.py:53`）。非白名单值 → `40301001`。⚠️ 写入口径是 **truthy 才写** → 传空串等于没传 | ✖ 不传 → 不作画像素材（`onboarding.py:263`） |
| `nickname`（submitOnboarding） | ≤32 串 | 同上 | ✖ 不传 → 不改主表昵称（自动生成名保留） |

### 请求 · `habits` 对象（`submitOnboarding`）

> ⚠️ **`habits` 本身是 `✔` 必传对象**（`CzSubmitOnboardingPayload.habits` 无默认值）—— 不传 → `40301001 habits: Field required`。**这就是本次事故的前半段。**

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `habits.exerciseFreq` | `0` ~ `7` 整数 | **`0` = 不运动（久坐）→ 画像 tag「久坐少动」+ 人群分型走 A 类新手**（「先习惯后强度」最稳）**`1`~`3` = 偶尔运动 → tag「偶尔运动」****`≥4` = 运动规律 → tag「运动规律」****且 `≥3` 是分型判据 `has_base`**（`onboarding_service.py:188`）：BMI ≥ 24 时 `≥3` 才走 **B 类减脂进阶**，否则 A 类；BMI < 24 时 `≥3` 走 **C 类塑形维持****`0` 是有效值**（本次事故点）—— 它表达「我确实不运动」，不是「我没填」 | **缺省 `0`**（`user_actions.py:109` `Field(default=0, ge=0, le=7)`）—— 2026-09-20 起**改可选**：常智决策「前端反馈的运动频率不要前端采集，后端按打卡数据计算」。**不传 = 传 0 = 久坐少动**，向导里那个「0-7 次/周」按钮组可以删。越界（<0 / >7）→ `40301001` |
| `habits.exercisePrefs` | 数组，值域 `run` / `cycling` / `walk` / `strength` / `other` | `run` → tag「偏好跑步」· `cycling` → 「偏好骑行」· `walk` → 「偏好散步」· `strength` → 「偏好力量训练」· `other` → **不产固定标签**，改为把 `exercisePrefRemark` 的**原文**当标签（`onboarding_service.py:225-236`）**`strength` 是分型判据**：BMI < 24 时，含 `strength` **或** `freq≥3` → **C 类**（否则 A）—— 「想练力量」本身就构成塑形维持理由 | **`✔` 必传**（无默认值）。⚠️ **但传 `[]` 合法** —— 本字段**没有 min\_length**（对比 `updateProfile` 的 1–4 限制）→ 空数组 = 无偏好，不报错。含非白名单值 → `40301001` |
| `habits.exercisePrefRemark` | ≤200 串 | 自定义运动说明。**含 `other` 且本字段非空（strip 后）→ 原文进 tags** | ✖ 不传 / 空串 —— **但 `exercisePrefs` 含 `other` 时变成硬必填**：空 → `40301001 exercisePrefRemark: 选择 other 时必填`（`user_actions.py:195-204` 跨字段校验，**先于 use case**） |
| `habits.takeoutFreq` | `0` ~ `21` 整数 | **`≥7` → tag「外卖偏多」**（`onboarding_service.py:218`）；**`0`~`6` → 不打标签**。⚠️ 语义是「每周外卖**次数**」，7 次/周 ≈ 每天一顿 → 判据线在这里 | ✖ 不传 → 走 `or 0` 兜底（`onboarding_service.py:421`）→ **等价于传 `0`** → 无标签。**前端 `canSubmitHabits` 强制必答**（UI 强制 ≠ API 必填） |
| `habits.sugarDrink` | `0` ~ `20` 整数 | **`≥1` → tag「有含糖饮料习惯」**（`onboarding_service.py:220`）：**边界就在 0/1 之间** —— 只要「有喝」就上标签，没有「偶尔喝」的中间档 | ✖ 不传 → 兜底 `0` → **无标签**。与显式传 `0` **在此标签上等价**（语义上后端不区分「没填」与「不喝」）。**前端强制必答** |
| `habits.lateDinner` | `true` | **tag「晚餐偏晚」** | ✖ 不传 / `false` → **无标签**（就是普通 truthy 判定） |
| | `false` | 无标签 | |

### 请求 · `updateProfile`（个人资料编辑）

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `nickname` | ≤32 串 | **双写**：2026-09-20 起同步 oauth 主表 `users`（此前只写画像表 → 重登显示旧昵称） | ✖ 不传 → 不改 |
| `avatarUrl` | 非空串 | 写头像 URL（先 `upload?type=avatar` 拿 URL 再提交） | 🔴 **传 `null` = 不修改**（后端「仅非 None 字段写」）→ **无法置空头像**。TS 类型写 `string \| null` 暗示可清空 —— **那是假的** |
| `signature` | ≤60 串 | 写签名，并把 `signatureSource` 置为 `'custom'` | 🔴 传 `null` = 不修改 —— **无法清空签名**（同上）。不传 → 不动 |
| `exercisePrefs` | 数组 **1–4 项**，同 `habits` 值域 | 整体替换运动意向 | ✖ 不传 → 不改。⚠️ **空数组 `[]` → `40301001`**（`min_length=1`，`user_actions.py:170`）—— **与 `submitOnboarding` 的 `[]` 合法行为不同，别混用**。含 `other` 无 remark → `40301001` |
| `exercisePrefRemark` | ≤200 串 | 同上跨字段规则 | ✖ 不传 → 不改 |

### 请求 · 方案采纳

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `planId`（adoptPlanOption） | `gentle` | 温和 / 缓和 / 维持 / 健康习惯型（**实际档位由人群决定**，见下） | ✔ 必传。不在**本次服务端重算**的 `plans[].id` 集合内 → `40301011` |
| | `standard` | 标准型（A/C 类）/ 标准减脂型（B 类）/ 塑形增肌型（C 类） | |
| | `intense` | 运动强化型（**仅 B 类返回**） | |
| | 其他串 | 不在重算集合 → `40301011` | |

> 🔴 **`planId` 的合法集是人群相关的，不是固定三值**（`onboarding_service.py:319-402`）—— 服务端**重算** suggestion 再校验，**不信前端传的 deficit**（M4-15 防伪造）：

| 人群 | 服务端实际给出的 `plans[].id` |
|---|---|
| 未成年（age<18）**或** BMI<18.5 | **仅 `gentle`**（「健康习惯型」，`deficitKcal=0`）→ 传 `standard`/`intense` 必 `40301011` |
| A 类（减脂新手） | `gentle`（推荐）· `standard` |
| B 类（减脂进阶） | `standard`（推荐）· `intense` · `gentle` |
| C 类（塑形维持） | `standard`（推荐）· `gentle` |

### 响应枚举

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `audience`（submitOnboarding / suggestion / myProfile） | `A` | **减脂新手** —— BMI ≥ 24 且无运动基础（`freq<3`），或 BMI < 24 且既无基础又无力量偏好 → 文案「先习惯后强度」，周降幅上限 **0.5%** | 画像未分析时 `myProfile` 中**可能为 `null`** |
| | `B` | **减脂进阶** —— BMI ≥ 24 **且** `freq≥3`（`has_base`）→ 可上强度，周降幅上限 **1.0%** | |
| | `C` | **塑形维持** —— BMI < 24 **且**（`freq≥3` **或** 偏好含 `strength`）→ 目标是体型而非减重（`deficitKcal=0`） | |
| `bmiLabel` | `偏瘦` | BMI < 18.5 | — |
| | `正常` | 18.5 ≤ BMI < 24 | |
| | `偏胖` | 24 ≤ BMI < 28 | |
| | `肥胖` | BMI ≥ 28 | |
| `bmi` | float（1 位小数） | `cz_round1(weight / 身高m²)` —— **JS `Math.round` 语义**（0.5 向上，非银行家舍入） | — |
| `bmr` | int | Mifflin-St Jeor：`10w + 6.25h − 5a + 偏移`（male +5 / female −161 / **unknown −78**） | — |
| `tags` | 数组，逐条独立 | **由上面各字段分别触发**（可多条叠加）：`久坐少动` | `偶尔运动` | `运动规律` | `BMI X 偏瘦/正常/偏胖/肥胖` | `体脂 X%` | `外卖偏多` | `有含糖饮料习惯` | `晚餐偏晚` | `偏好跑步/骑行/散步/力量训练` | **`other` 的 remark 原文** | 无任何习惯标签时仍至少有 `BMI ...` 一条 |
| `minorMode` | `true` | `age < 18` —— 前端**必须**据此隐藏减脂目标区块（后端也返 `goal: null`） | — |
| | `false` | 成人链路 | |
| `goal`（suggestion） | object | 减脂目标区间 `{targetWeightMin, targetWeightMax, weeks: 12, maxWeeklyLossPct, note}` | **`null` 的两条件**：① `minorMode=true` ② `BMI < 18.5`（偏瘦不提供减脂目标 — `onboarding_service.py:270`）→ 前端**必须兜底展示**，不能直接读 `goal.weeks` |
| `goal.maxWeeklyLossPct`（suggestion） | `0.5` | **A 类** / **C 类** —— 每周最多减体重的 0.5% | — |
| | `1.0` | **B 类**（有基础才给 1.0 上限） | |
| `goal.weeks` | 恒 `12` | 目标周期固定 12 周（`onboarding_service.py:276`） | — |
| `plans[].recommended` | `true` | **恰好一档**为推荐（未成年分支只有一档且它就是推荐） | — |
| | `false` | 备选档 | |
| `safetyNote`（suggestion） | `null` | **成人（≥18）且 18.5 ≤ BMI < 28** → 无提示，前端**不展示该区块** | — |
| | 未成年文案 | 「你未满 18 岁,我们不提供减脂目标与热量缺口计划…」（**半角逗号**是 V1 逐字保真，勿"顺手"修正） | |
| | 偏瘦文案 | BMI < 18.5 → 劝导增肌 / 建议咨询医生 | |
| | 肥胖文案 | BMI ≥ 28 → 建议体检（血压/血糖/血脂）+ 低冲击起步 | |
| `planOption`（adoptPlanOption / myProfile） | `gentle` / `standard` / `intense` | 已采纳档位回显 | 未采纳时 `myProfile` 中为 `null`（`sessionPerWeek` / `dailyDeficitKcal` 同理） |
| `signatureSource`（myProfile / updateProfile） | `custom` | 用户**自己写的**签名 | PG `server_default` 保证非 null（`agent` 或 `custom`） |
| | `agent` | **AI 生成的**签名（画像 agent 凌晨 2 点任务写入） | |
| `growthLevel`（myProfile account 段） | `free` | 累计**赚取**积分 < 100 | 阈值见 `growth_level.py:25`（V1 逐字对齐）。⚠️ **只累计「赚取类」type**（`signup_bonus` / `checkin` / `share` / `invite` / `invited`）—— `admin_adjust` / `redeem_member` 不计入。account 段缺席时为**键不存在**（非 `null`） |
| | `bronze` / `silver` / `gold` / `diamond` | ≥100 / ≥500 / ≥2000 / ≥5000 分 | |
| `memberLevel`（updateProfile 的 `user` 段） | `free` | 非会员 | `redeemMember` 兑换后 `free` → `member`（`inmemory_user_repositories.py:217`）；**不覆盖已付费等级** |
| | `member` | 会员期内（真值判据是 `memberExpireAt > now`，**不是本字段**） | |
| `onboardingDone`（myProfile account 段） | `true` | 向导已收尾（`completeOnboarding` 写入）→ 下次登录**不再弹向导** | account 段在 user\_core 未装配 / 异常时**5 键整体消失** —— 按存在性消费 |
| | `false` | 未完成 → 弹向导（`resetOnboarding` 可重置回 `false`） | |

***

## device

**设备线 / 授权中心** · `GET /api/v1/device/vendors` · `POST .../auth-url` · `POST /api/v1/device` · **4 端点** · [手册 device](./device) · 错误段 `40403xxx`

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `vendors[].meta.id` | 已接入 4 家：`honor_oauth` · `zepp` · `xiaomi` · `suunto` | **机器键**（`unbind` / `auth-url` 都传这个值，**不是中文名**）。⚠️ 荣耀的绑定标识是 **`honor_oauth`**，不是 `honor`（后者是数据表 vendor 名 — `device_binding.py:29-33`） | 厂商清单由 registry 动态组出（12 家）；`meta.id` 恒非空 |
| | 占位 8 家：`apple_health` · `garmin_cn` · `garmin_intl` · `coros` · `huawei` · `oppo` · `polar` · `werun` | **预登记**厂家 —— `status` 恒 `reviewing`，授权尝试抛 `40403006`（msg「该设备厂家正在审核中，敬请期待」+ `data.vendor`） | |
| `vendors[].meta.authType` | `oauth` | 走**授权页 webview**（`auth-url` → `authUrl` → webview 加载）。实际值：`honor_oauth` / `zepp` / `xiaomi` / `suunto` / `oppo` / `polar` | 恒非空（每家都声明） |
| | `import` | **用户导出数据包上传**（不授权，走 `upload`）。实际值：`coros` / `huawei` —— ⚠️ 这 2 家**当前都是占位**（未接入）→ 对它们调 `auth-url` 抛的是 **`40403006`（审核中）**，**不是 `40403007`** | 恒非空。`40403007`（形态不支持）的触发者是**已实现厂商**上不属于其形态的方法（如对 `honor_oauth` 调 `cz_import_file`）—— **不在前端 30 端点的调用路径上** |
| | `sync` | **平台侧同步**（无交互授权）。实际值：`apple_health` / `garmin_cn` / `garmin_intl` / `werun` | |
| | `ble` | **蓝牙直连** —— 能力位已定义（`vendor.py:56`），**当前 12 家全部不是此值** | |
| `vendors[].meta.dataWindowDays` | `30` | 可回溯 **30 天**：`honor_oauth` / `xiaomi` / `suunto` + 8 家占位 | 恒非空（每家都声明）。⚠️ **不是「全表一致」** —— 见右侧 |
| | `29` | ⚠️ **Zepp 是 29 天**（`DEFAULT_LEGACY_DAYS = 29`，刻意压在官方 30 天窗口内 — `zepp_legacy_sync.py:43`）。**别把前端日期选择器的上限硬编码成 30** | |
| `vendors[].meta.capabilities.*` | `sportStat` / `sampleSets` / `healthRecord` / `realtime` 各 boolean | 能力位 —— 前端按此决定展示哪些数据入口（**不代表当前有数据**） | 缺省 `false`（dataclass 默认） |
| `vendors[].status` | `bound` | 用户有 **active** 绑定 → 「已连接」+ 可解绑 | **无缺省** —— 优先级 `bound > available > error > reviewing`（`vendor_center.py:78-88`），前端**必须按此顺序判**，别用 if-else 乱序 |
| | `available` | `configured && health.ok` → 「去授权」可点 | |
| | `error` | `configured && !health.ok` → 置灰 + 稍后再试 | |
| | `reviewing` | **未配置**（planned 占位**或**已实现但 env 缺凭据）→ 「正在审核中」。⚠️ **12 平台全部展示**（2026-09-19 决策回滚了更早的「隐藏」口径）—— 整行可点，点击 toast 引导预期 | |
| `vendors[].health.ok` / `.configured` / `.serviceReady` | boolean 三键 | `ok` = 厂商服务当前可用；`configured` = 凭据/env 齐备；`serviceReady` = 服务就绪。荣耀 / Zepp 的 `health_check` = `cz_is_configured()` → **凭据齐 = `ok:true`**。三键**后端返回、前端未声明**（不消费无影响） | 厂商未注册健康检查时兜底 `ok=false` / `configured=false` / `serviceReady=false`（`vendor_center.py:62-66`） |
| `vendors[].health.message` | 串 | `null` | 健康状态说明 —— **要展示文案就用这个** | 🔴 前端声明的 `health.detail` **后端不存在**（对应物是 `message`）→ 读 `detail` 恒 `undefined` |
| `platform`（auth-url） | `android` | Zepp **Android** 端凭据 | ✖ 不传 → 归一为 **`web`**（`zepp/application/ports/services.py:27-36`：非 `android`/`ios` 一律 `web`）。**非 zepp 厂商忽略本字段** |
| | `ios` | Zepp **iOS** 端凭据 | |
| | 其他任意串 / 不传 | → **`web`**（**静默归一，不报错** —— 不会因为写 `iOS` 大写而失败，但也不会用 ios 凭据） | |
| `payload.vendor`（unbind） | 串，**1–40 字符** | 传 `vendors[].meta.id` 或 `myBindings[].vendor`（同值域）→ **精确匹配，无模糊** | ✔ 必传；缺失 / 超 40 → `40403001`。未绑定该厂商 → `40403005` |
| `list[].status`（myBindings） | `active` | 绑定有效 | — |
| | `expired` | 绑定失效（`device_binding.py:35-36` 定义） | |

### `myTodayHealth`（`POST /api/v1/device` · 首页健康卡 — [手册 page-direct](./page-direct#mytodayhealth)）

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `steps` / `hr` | 块对象 `{value, date}` | **有数据**：`value` 是该指标的当日值，`date` 是数据日。⚠️ **不是裸数字** —— 前端读 `steps.value` 不是 `steps` | **`null`** = 该块**无数据**（不是 0！）—— 前端必须逐块判空，别直接读 `steps.value` |
| `sleep` | 块对象 `{durationHours, deepHours, lightHours, remHours, score, date}` | 各分期小时数（`round(秒/3600, 1)`）；**某分期为 `0` 秒时该项是 `null` 不是 `0`**；`durationHours` 在无分期数据时回退用 `sleep_start/end` 差值算 | `null`（无数据） |
| `todayActivity` | `{totalCalories, totalDistanceKm}` | 当日活动块（**顶层没有 `distance`** —— 对应物是 `totalDistanceKm`） | `null`（无数据） |
| `spo2` | **恒 `null`** | 血氧 —— **V2 sync 暂无血氧来源**，接血氧源后自动转有值 | 恒 `null`（字段存在但值恒空） |
| `sleep.score` | **恒 `null`** | 睡眠评分 —— V2 sync 暂无评分来源 | 恒 `null`（同 spo2 情形） |
| `sources` | `{steps?, hr?, sleep?}` → 来源键 | 「数据来自：XX」小字。值是**来源键**：`honor_oauth` / `zepp` / `xiaomi` / `suunto`（⚠️ 荣耀是 **`honor_oauth`** 不是 `honor` —— 写入侧统一用绑定渠道名，`VENDOR_PRIORITY` 同口径）。多厂商同日有数据时按 `VENDOR_PRIORITY` 取优先级最高的一家（`honor_oauth > zepp > xiaomi > suunto > …`）。⚠️ **新增第 5 个取值 `default_template`** = 该维度是**默认数据模板**（用户还没绑设备 → 后端按四维画像算的示例数据）—— 见下方「数据来源标记」 | **无任何数据时是 `{}`** —— **键按有无数据出现**，不是三键恒在。前端用可选链读 |
| `dataSource` | **只有这一个取值**：`default_template` | ⭐ **整块是示例数据**（降级标记）。**该键只在降级块出现** —— 有真实数据的响应**不带**它。前端据此挂「示例数据，绑定设备后替换为你自己的」提示 | **绝大多数响应没有这个键** —— 判断用 `'dataSource' in data`（**不要**用 `data.dataSource != null` 之外的模糊判据）|
| `defaultDims` | 字符串数组，如 `["sleep","steps"]` | ⭐ **列出「哪几个维度」来自模板** —— 只有**按维度聚合**的端点会带（目前两个：device 的 `myTodayHealth` 与 health\_archive 的 `archive`）。「绑了手表 → `hr` 是真数据、`steps`/`sleep` 仍是模板」时这里是 `["sleep","steps"]`。**与 `dataSource` 同时出现**（有 `defaultDims` 必有 `dataSource`）。⚠️ **两个域的键名不同** —— 见下方「维度键词表」，别照抄一套 | 不分维度的端点不带该键；全维度都是真实数据时也不带 |

### 数据来源标记（`dataSource`）—— 降级数据的识别与处理

**背景**：用户**没绑穿戴设备、没报名运动计划**时，页面本会全空。后端改为按
**四维画像（性别/年龄/身高/体重）算出 7 天示例数据**垫底（落库，来源记为
`default_template`）。真实数据一到（绑设备）自动压过它 —— **查询路径零改动**，
因为 `default_template` **不在** `VENDOR_PRIORITY` 表内，天然垫底。

**前端要做的判断**（就一条）：

```ts
// data 是响应信封里的 data 字段
const isSample = data?.dataSource === 'default_template'
```

| 情形 | 响应表现 | 前端该做什么 |
|---|---|---|
| **无设备无计划**（L0） | `dataSource: 'default_template'` | 正常渲染 + **挂「示例数据」提示**（必须 —— 否则用户会以为是自己测的） |
| **绑了设备**（L1/L2） | **无 `dataSource` 键** | 正常渲染，**不挂提示** |
| **部分维度有真数据** | `myTodayHealth` 带 `dataSource` + `defaultDims: ['sleep','steps']` | 整体按示例渲染，但**提示只针对 `defaultDims` 里的维度** |

#### 维度键词表（`defaultDims` 的取值域 —— **两个域各不相同**）

⚠️ 键名由**各自端点的数据源结构**决定，**不是全局统一词汇**。前端做中文映射时
**两套都要登记** —— 漏一套的后果是用户直接看到英文键名（如 `restHr`）。

| 域 | 端点 | 维度键 | 键名的来源（改这里就要改文档） |
|---|---|---|---|
| device | `myTodayHealth` | `steps` / `hr` / `sleep` | `device_queries.py` 的 `"sources": {...}` —— **键名 = `sources` 的键** |
| health\_archive | `archive` | `activity` / `sleep` / `hrv` / `restHr` / `vo2max` / `heartRateZones` | `health_archive.py` 的 `template_dims` —— **键名 = 本页的切片名** |

唯一重叠的是 `sleep` —— 两域语义相同（睡眠时长），标签可共用。
⭐ 前端已登记在 `qm-frontend-api/src/utils/sample-data.ts` 的 `DIM_LABELS`
（未登记的键**原样透出、不吞** —— 让漏登记在联调时就看得见）。

⚠️ **`archive` 的 `defaultDims` 粒度比页面分区更细**：`activity`(活动消耗) / `sleep` /
`hrv` / `restHr`(静息心率) / `vo2max` 是**生理数据 + 心肺能力**两块里的具体指标，
`heartRateZones` 则只在「用户没自存过、由静息心率推导」时才出现
（推导不出阈值时**不列** —— 页面那时压根没有区间可看）。

⚠️⚠️ **健康档案页不是整页假数据**：基础信息（四维画像）、病史（用户自填）、
跑步能力（用户自己的打卡）**都是真的** —— 只挂「以下为示例数据」会让用户连自己的
身高体重都不敢信。**必须用 `defaultDims` 精确到维度**。

⚠️ **标记语义是保守的**：`cz_pick_source` 的规则是「**全部**来源都是模板才标记」。
只要有一处真实来源，整块就**不**标记 —— 因为用户已经有真数据了，再挂「示例」横幅
是误导。（实现在 `src/app/device/domain/services/data_source.py`。）

⚠️ **这个键是后端在响应出口统一注入的**（`device_actions.py:cz__ok`），
覆盖**全部** device 读端点（`myTodayHealth` / `myMetrics` / `mySleep` /
`listDeviceDailyActivity` / 健康详情 10）—— 前端**不要**只处理一两个端点。

⭐ **还有一个域单独注入**：`health_archive` 的 `archive`（健康档案页）。
`cz__inject_data_source` **只存在于 device 域**，所以该端点在**用例层**自行算标记
（`health_archive.py` 的 `template_dims`）。理由是它的**生理数据 / 心肺能力 /
心率区间全部取自 device 五表**，而模板正写那几张表。

> ⚠️ 这一条是 **2026-09-20 补的**：此前该端点**完全不标记** —— 没绑设备的用户在
> 健康档案页看到的「静息心率 / HRV / VO2Max / 睡眠」**全是模板推导值且无任何提示**。
> 健康档案是**医疗性质**的记录，比首页步数严重得多。

⚠️ **sport 域整域不降级**（`sport_checkin` 是用户自己跑出来的，模板不写）——
运动会话 / 跑步记录 / 跑步详情**都不带该键**。前端**不要**在这些页面加提示：
加了就是**永不渲染的死标记**（`api-wiring` 分支上曾犯过这个错，已清理）。

⚠️⚠️ **实测边界：`weightDetail` / `recordDates` 基本不会带 `dataSource`**
（2026-09-20 生产实测确认）—— 因为**完成向导本身就会写一条真实的体重建档行**
（`submitOnboarding` → `body_composition`，`source='manual'`，vendor 归一为 `unknown`）。
按「**有任一真实来源就不标记**」的保守语义，这两个端点的体重维度**永远有真数据** →
整块不标记。**这是刻意的**（那条体重确实是用户自己填的），但意味着：

> 若你在 `weightDetail` 上看到 7 天体重曲线，其中**只有向导那天是真值**，
> 其余天是模板补的 —— **响应不会告诉你这件事**。前端若要精确提示，
> 只能按「用户未绑设备」这一前置条件自行判断（`myBindings` 为空）。

其余 8 个详情端点（睡眠/心率/压力/血氧/步数/卡路里/中高强度/活动环）
**没有这个干扰**，模板态下会正常带 `dataSource`。

⚠️ **`myBindings` 永远不会带 `dataSource`** —— 绑定列表是**事实类**数据，
模板不产生绑定行（假装有绑定就是撒谎）。同理 `training/myActivePlan` 仍为空。

***

## training

**训练中心** · REST（非 action）· **5 端点** · [手册 training](./training) · 错误段 `40406xxx`

> ⚠️ **身份一律以 token 为准**：`body.userId` / `?user_id` **一律被后端忽略** —— 传了无害但**别据此断言身份**（2026-09-18 越权修复）。

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `plans[].kind`（`GET /api/v1/training/plans` 的响应枚举） | `running` | 跑步计划（种子 2 条：`run-5k-4w` 4 周 20km / `run-10k-6w` 6 周 60km） | 恒非空（`training_orm.py:26`）。公开端点（免登录） |
| | `cycling` | 骑行计划（种子 1 条：`cycling-60k-4w`） | |
| | `strength` | 力量计划（种子 1 条：`strength-base-4w`，`targetKm = 0.0` —— **力量计划无跑量目标） | |
| `kind`（myPlans action，**不在本手册 REST 范围**） | `running` / `strength` | 分段过滤：只返该 kind 的 active 计划 | **不传 = 返全部**（`CzMyPlansPayload.kind` 默认 `None`，`training_actions.py:45`）—— 分段是 V0.2.128 新增，旧客户端行为不变 |
| `plans[].id` | int | **原样回传给 `enroll` 的 `planId`** —— 别硬编码 1 | — |
| `planId`（enroll） | int | 来自 `plans[].id` | ✖ **技术上是可选的，但缺省值是毒药**：`body.get("planId", 0)` → `0` → 计划不存在 → `40406001`（400）。**必须传** |
| `data.ok`（enroll） | `true` | 报名成功 → `created` / `reminders` / `enrollmentId` 才有值 | ⚠️ **业务失败也返 HTTP 200 + `code: "0"`** —— **不是错误码**，必须自查 `data.ok` |
| | `false` | 报名失败 → 读 `data.reason`；**`created` / `reminders` 两个键不存在\*\*（TS 声明为必填 `number` → 读到 `undefined`） | |
| `data.reason`（enroll） | `"计划不存在或未发布"` | 计划 id 不存在 / `status != published` | 失败路径才有 |
| | `"已有进行中的计划（一次一个）"` | **后端规则：一次只允许一个进行中的计划** —— `enroll` **不是幂等 upsert** | |
| `enrollmentId`（enroll） | int | 后端返回第 4 键，前端未声明（不消费无影响） | 仅成功路径 |
| `tasks[].status` | `pending` | 待完成（**唯一由报名生成的初始值** — `training_orm.py:59`） | — |
| | `done` | 已完成（两条写入路径：手动 `task/checkin`，或 `auto_sport` 自动轨） | |
| | `missed` | ⚠️ **DB 注释声明了三值，但当前无任何写入逻辑** —— 实际不会出现。（TS 类型更宽，前端已容错） | |
| `tasks[].completeSource` | `manual` | 用户手动打卡完成 | `null`（未完成时） |
| | `auto_sport` | **当天有同 `kind` 的运动打卡 → 自动完成**（`cz_auto_complete_by_date`，T2 自动轨） | |
| `tasks[].kind` | `running` / `cycling` / `strength` | 来自所属 `plan.kind` | — |
| `taskId`（task/checkin） | int | 来自 `tasks[].id` | ✖ 缺省 `0` → 找不到任务 → **`data: null`（HTTP 200，不报错不 404）**。🔴 前端必须 `if (!res.data)` 判空 —— 否则 await 后本地置 `done` = **假成功** |
| `data`（task/checkin） | `null` | **task 不存在 / 非本人** | — |
| | `{taskId, status: 'done'}` | 打卡成功（**幂等**：已完成直接返回 `done`） | |
| `reminders[].status` | `pending` | 待发送（**唯一由报名生成的初始值** — `training_orm.py:73`） | — |
| | `sent` / `dismissed` | ⚠️ DB 注释声明了这两个值，但**当前无写入路径**（同 `missed` 情形）—— 前端按「可能有」容错即可 | |
| `reminders[].remindAt` | ISO 串 | 提醒时刻（报名时按 `planned_date` 生成） | 恒非空 |

***

## splash

**开机信息屏** · `/api/v1/splash-screen` · **2 端点** · **免登录** · [手册 splash](./splash)

> ⚠️ **本模块不定义错误码段** —— 业务异常抛的是 `UserError` → 业务码落在 **user 段 `40301xxx`**。

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `ad.mediaType` | `image` | 广告是**图片** → 前端用 `<image>` 渲染 `ad.imageUrl` | DB 缺省 `"image"`（`splash_screen.py:41`）—— 历史行没有该列值时按 image |
| | `video` | 广告是**视频** → 前端需换视频播放器（`imageUrl` 是视频封面/源） | |
| `ad.linkType` | `none` | **不可点击** → 点广告无跳转 | DB 缺省 `"none"`（`splash_screen.py:42`） |
| | `webview` | 点击 → webview 加载 `ad.linkUrl` | `webview` 时 `linkUrl` 才有意义；`none` 时 `linkUrl` 可能为 `null` |
| `notice.level` | `info` | 普通运营提醒（蓝色/常规样式） | DB 缺省 `"info"`（`splash_screen.py:249` —— 无 notice 行时的兜底块） |
| | `warn` | **警告级**（醒目样式）—— 前端应换配色/图标 | |
| `weather.forecast` | 数组 `[{date, textDay, tempMax, tempMin, icon}]` | 和风接口正常时返回 | ⚠️ **和风失败时降级为 `[]`（空数组，不是 `null`）** —— 前端判空要判 `length`，不是 `== null` |
| `adId`（track） | int，来自 `ad.id` | 埋点目标广告 | ✖ **缺省 0**（`int(body.get("adId", 0))`）→ 广告不存在 → `40301004`（404）。**静默处理**（埋点失败不影响 UI） |
| `event`（track） | `exposure` | **曝光**（广告展示过） | ✔ 必传。其他任意值 → `40301001`（msg「event: 仅 exposure|click」）；body 非合法 JSON → 同样 `40301001` |
| | `click` | **点击**（用户点了广告）→ 计入 CTR 分子（CTR = clicks ÷ exposures） | |
| `data`（track） | 恒 `null` | 固定信封 `{"code":"0","msg":"ok","data":null}` —— 前端类型声明 `void`，忽略即可 | — |

***

## agreement

**用户协议 / 隐私政策** · `/api/v1/content/agreements` · **2 端点** · **免登录** · [手册 agreement](./agreement)

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `{type}`（path） | `user_agreement` | 用户协议 | ✔ 路径参数（不传即 404 路由不匹配）。**白名单只有这两值**（`AGREEMENT_TYPES`，`application/usecase.py:22`） |
| | `privacy` | 隐私政策 | |
| | 其他任意串（`user-privacy` / `privacy_policy` / `user-agreement` / `terms`） | **不是错误** —— `cz_validate_type` 抛 `ValueError` 被路由捕获 → **`data: null` + HTTP 200 + `code: "0"`**（`agreement_api.py:44`）→ 前端页面**静默空白** | 前端必须判 `res?.code === '0' && res.data` 再读 `data.content`（类型声明是非空对象，别直接点） |
| | 大小写 / 空白变体（`Privacy` / `privacy`） | **会被接受** —— 归一化做了 `.strip().lower()`（`application/usecase.py:26`） | — |
| `[].version` | int | 版本号，**admin 每次更新内容自增**（`version 自增` — 用于前端判断本地缓存是否过期） | 首建为 `1` |
| `[].updated_at` | ISO 串 | 🔴 **snake\_case** —— **全项目唯一的 snake\_case 响应字段组**，与前端 `AgreementContent` 声明一致 ✓，**勿"顺手修正"成 camelCase** | 恒非空 |
| `data`（getAgreement） | object | **`null`** | `null` = **非法 type 或无记录**（HTTP 200 + `code: "0"`，**不 500**） | — |

***

## stats

**数据统计** · `POST /api/v1/stats` · 本手册涉及 **1 端点**（`getTodayOverview`）· [手册 page-direct](./page-direct#gettodayoverview) · 错误段 `40422xxx`

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `calorie.profileComplete` | `true` | 画像完整（`age` / `height` / `weight` 三者齐 **且** `gender ∈ {male, female}`）→ 预算基于真实身体数据 | — |
| | `false` | **画像缺项 → 后端用默认值算预算**：30 岁 / 165cm / 60kg / gender 非男女时偏移 −78（`onboarding_service.py:140` `_CZ_BMR_DEFAULTS`）。⚠️ **前端必须据此引导补全** —— 否则用户看到的是一个**与自己无关的默认热量目标** | 无画像行时同样是 `false` |
| `calorie.budgetKcal` | int | 热量预算 = `TDEE − 400`，**下限保护**：male 1500 / female·unknown 1200（`stats/CLAUDE.md` 热量引擎） | 恒非空（int） |
| `calorie.remainingKcal` | int | `budget + burn − intake`（**可为负** —— 超额摄入时是负数，前端别 `Math.max(0, x)` 掩盖真实赤字） | 恒非空 |
| `calorie.intakeKcal` / `calorie.burnKcal` | float | 无数据时为 **`0`**（不是 `null`） | `0` |
| `checkin.count` / `checkin.distanceKm` | int / float | 当日打卡数与里程；无打卡为 `0` | `0` |
| `date` | `YYYY-MM-DD` | **CN 今日**（UTC+8 固定切日，与 ad 事件表对齐） | — |

> 🔴 **读法**：`res.data.calorie.budgetKcal` ✅ —— `res.data.budgetKcal` ❌（恒 `undefined` → 前端回退默认值 → 首页热量目标**静默失效**，历史事故已修）。

***

## upload

**文件上传** · `POST /api/v1/upload` · **1 端点**（`?type=avatar`）· **multipart** · [手册 page-direct](./page-direct#upload) · 错误段 `40419xxx`

> 🔴 这条**不走 `request.ts` 拦截器** → **没有自动 rotate**（token 过期时不会自动续期，直接失败）+ `Authorization` / `X-Env` 要**手动注入**。

| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `type`（query） | `avatar` | 头像上传 —— ⚠️ **不属于 RECORD\_TYPES** → **不建 UploadRecord、不入队解析** → 响应**无 `uploadRecordId`** | ✖ 缺省 / 不符正则 → **静默兜底 `'misc'`**（`upload_rules.py:63`，**不报错！**）—— 大写 `Avatar` / 中文 / 带空格 → 全部落到 `misc` |
| | `xiaomi_zip` | 小米 ZIP 加密包（**需 `x-upload-password` header**，密码不进 URL 日志） | |
| | `coros_fit` / `coros_archive` | COROS FIT / 归档包 | |
| | `garmin_fit` | 佳明 FIT | |
| | `apple_health` | 苹果健康导出 | |
| | `huawei_export` | 华为导出 | |
| | `sport_screenshot` | 运动截图（走解析链路） | |
| | 以上 7 个 = **`RECORD_TYPES`** | 值域正则 `^[a-z0-9_-]{1,32}$`（**下划线合法** —— 支持 `xiaomi_zip` / `coros_fit`）；命中且 `source='cos'` → **建 UploadRecord + 入队异步解析** → 响应含 `uploadRecordId` | |
| `file`（multipart） | 单文件 | **字段名必须叫 `file`**（stdlib email 解析单 file 字段，不引入 `python-multipart`） | ✖ 缺该字段 → `40419003`（400） |
| — MIME 白名单 | `image/jpeg` · `image/png` · `image/webp` · `application/zip` · `application/octet-stream` | 白名单外 → `40419004`（400）。`octet-stream` 是**数据包（fit 等）兜底 MIME** → 扩展名派 `.bin` | — |
| — 大小上限 | ≤ **50MB**（`CZ_UPLOAD_MAX_SIZE`） | 超出 → `40419005`（**HTTP 413**） | — |
| `localFallback`（query） | `1` | **强制本地存储**（应急 / 调试）→ `source: 'local'` | 其他值 / 不传 → **COS 优先** |
| `x-upload-password`（header） | 串 | 仅 `xiaomi_zip` 消费 | 不传 → 加密包解析会失败（用户无感，走记录状态机） |
| `source`（响应） | `cos` | 上到 COS（优先 CDN `cos-cdn.qingmulife.cn`） | 恒返回 |
| | `local` | ⚠️ **COS 配置缺失 或 `putObject` 抛错 → 静默 fallback 本地**（不报错）—— 联调期看到 `/uploads/...` 相对路径不是 bug，是降级 | |
| `uploadRecordId`（响应） | 串 | **仅 `type ∈ RECORD_TYPES`（7 值）且 `source='cos'` 时出现** | **条件键** —— `avatar` 恒不出现（别做全等断言） |
| `url` / `size` / `mime` | 串 / int / 串 | `url` = 前端**消费这个做头像回显**；`size` / `mime` 后端返回、前端未声明 | 恒返回 |

***

## 附 · 同域非手册端点（30 端点之外，速查备用）

这些 action 存在于同一批路由上，前端暂未接入 —— 记录取值语义以免后续踩同样的坑。

| 端点 · 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
| `user` · `redeemMember.days` | `7` | 积分兑换 **7 天**会员 —— 扣 **100 分** | ✔ 必传（`Literal[7, 30]`）—— 其他值（含 `30.0` 浮点 / `"7"` 字符串）→ `40301001` |
| | `30` | 积分兑换 **30 天**会员 —— 扣 **300 分** | |
| `device` · `listDeviceDailyActivity.days` | `1` ~ `180` 整数 | 回溯天数窗口（越大越慢） | **缺省 `30`**（`CzListDailyActivityPayload`） |
| `device` · `myMetrics.metricType` | 串，**1–40 字符** | 指标类型键 —— **值域由指标仓储决定，源码无 Literal 枚举（`metricType: str = Field(min_length=1, max_length=40)`）** | ✔ 必传。**待确认**：合法键集无静态声明，需查 `health_metric` 表实际 `metric_type` 值 |
| `stats` · `track.events[].name` | 串 | 事件名（**超 64 字符会被截断** — `str(...)[:64]`，不是报错） | 单批 ≤ **50** 条（超出部分**静默丢弃**，不报错）；`eventId` 重复 → 计入 `duplicated` 不重复入库 |
| `stats` · `calorieBalance.strengthMinutes` | float | **力量训练分钟数** → 走 MET6 消耗源（`0.105 × weight × 分钟`） | **缺省 `0.0`**（不传 = 无力量消耗） |

***

## 待确认清单（本页标注不确定的条目 —— 未编造）

| # | 条目 | 卡在哪 |
|---|---|---|
| 1 | `habits.exerciseFreq` 的「后端按打卡数据校正」 | 源码注释声称「待用户有打卡数据后由画像侧**按实际频次校正**」（`user_actions.py:107`），但全仓 `exerciseFreq` 只出现在 `user_actions.py` / `onboarding.py` / `onboarding_service.py` / InMemory 种子 —— **画像 agent（`profile_agent.py` / `profile_daily_analyze.py`）没有任何写回 `exerciseFreq` 的逻辑**。**当前：声称的校正未实现** → 不传就是恒 `0`（久坐少动），不会自动变成真实频次 |
| 2 | `myMetrics.metricType` 合法键集 | 后端是裸 `str`（1–40 字符）+ 仓储查表，**无 Literal 声明**。要确定值域需查 PG `health_metric.metric_type` 的实际去重值（本任务只读文档，未连库） |
| 3 | `tasks[].status = 'missed'` / `reminders[].status ∈ {sent, dismissed}` | DB 列注释声明了这些值（`training_orm.py:59,65`），但全仓**无写入路径** —— 声明与实现不一致（**不是文档错**，是代码注释超前）。实际只产生 `pending` / `done` 与 `pending` |
| 4 | `token_expiring` 的真实用途 | 6 处赋值全 `False`，无任何置 `true` 的分支 —— 判定为**预留位**（推测用于「token 即将过期」预警）。TTL 阈值未定义，**待产品明确** |
| 5 | `vendors[].meta.authType = 'ble'` 的落点 | 能力位与 `cz_required_methods(auth_type)` 分支已定义，但 **12 家厂商无一使用** → 该类型的 `auth-url` 行为（是否需要 `platform`、返回什么）**无实现可参照** |

***

## 关联阅读

* [前端联调 API 手册（30 端点）](./) —— 字段表 / curl / 错误码（**契约权威**）
* [uniapp 调用视角文档](/guide/uniapp/api/) —— 「哪个页面何时调、响应从哪一层读、失败做什么」（**消费视角**，取值语义指回本页）
* [错误码权威注册表](/ai-contexts/error-code-registry) —— 全量业务码
* [联调问题台账](/internal-wiki/operations/frontend-integration-issues) —— 差异 + 决策 + 状态
