Appearance
前端联调 · 取值速查(字段值语义)
归属:前端联调 API 手册 → 取值速查 覆盖:手册 30 端点的请求字段 + 响应枚举字段 · 按域分组(oauth / user / device / training / splash / agreement / stats / upload) 本页是「值」的语义层 —— 手册各页回答「字段叫什么、什么类型、必填吗」;本页回答「填这个值会发生什么」。 事实源:后端源码(
src/app/<domain>/interface/api/*.py的 Pydantic 定义 +domain/的分档判据)—— 不是转抄手册。 错误码:错误码注册表 · 契约矩阵: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 · 错误段 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 · 错误段 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 · 错误段 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)
| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
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 · 错误段 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
⚠️ 本模块不定义错误码段 —— 业务异常抛的是
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
| 字段 | 取值 | 每个取值的含义 | 缺省 / 不传时 |
|---|---|---|---|
{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 · 错误段 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 · 错误段 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 调用视角文档 —— 「哪个页面何时调、响应从哪一层读、失败做什么」(消费视角,取值语义指回本页)
- 错误码权威注册表 —— 全量业务码
- 联调问题台账 —— 差异 + 决策 + 状态