Skip to content

前端联调 · 取值速查(字段值语义) ​

归属:前端联调 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

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

请求 · habits 对象(submitOnboarding) ​

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

字段取值每个取值的含义缺省 / 不传时
habits.exerciseFreq0 ~ 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 / otherrun → 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.takeoutFreq0 ~ 21 整数≥7 → tag「外卖偏多」(onboarding_service.py:218);0~6 → 不打标签。⚠️ 语义是「每周外卖次数」,7 次/周 ≈ 每天一顿 → 判据线在这里✖ 不传 → 走 or 0 兜底(onboarding_service.py:421)→ 等价于传 0 → 无标签。前端 canSubmitHabits 强制必答(UI 强制 ≠ API 必填)
habits.sugarDrink0 ~ 20 整数≥1 → tag「有含糖饮料习惯」(onboarding_service.py:220):边界就在 0/1 之间 —— 只要「有喝」就上标签,没有「偶尔喝」的中间档✖ 不传 → 兜底 0 → 无标签。与显式传 0 在此标签上等价(语义上后端不区分「没填」与「不喝」)。前端强制必答
habits.lateDinnertruetag「晚餐偏晚」✖ 不传 / 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
bmifloat(1 位小数)cz_round1(weight / 身高m²) —— JS Math.round 语义(0.5 向上,非银行家舍入)—
bmrintMifflin-St Jeor:10w + 6.25h − 5a + 偏移(male +5 / female −161 / unknown −78)—
tags数组,逐条独立由上面各字段分别触发(可多条叠加):久坐少动 | 偶尔运动 | 运动规律 | BMI X 偏瘦/正常/偏胖/肥胖 | 体脂 X% | 外卖偏多 | 有含糖饮料习惯 | 晚餐偏晚 | 偏好跑步/骑行/散步/力量训练 | other 的 remark 原文无任何习惯标签时仍至少有 BMI ... 一条
minorModetrueage < 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.5A 类 / C 类 —— 每周最多减体重的 0.5%—
1.0B 类(有基础才给 1.0 上限)
goal.weeks恒 12目标周期固定 12 周(onboarding_service.py:276)—
plans[].recommendedtrue恰好一档为推荐(未成年分支只有一档且它就是推荐)—
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)
agentAI 生成的签名(画像 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.authTypeoauth走授权页 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.dataWindowDays30可回溯 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[].statusbound用户有 active 绑定 → 「已连接」+ 可解绑无缺省 —— 优先级 bound > available > error > reviewing(vendor_center.py:78-88),前端必须按此顺序判,别用 if-else 乱序
availableconfigured && health.ok → 「去授权」可点
errorconfigured && !health.ok → 置灰 + 稍后再试
reviewing未配置(planned 占位或已实现但 env 缺凭据)→ 「正在审核中」。⚠️ 12 平台全部展示(2026-09-19 决策回滚了更早的「隐藏」口径)—— 整行可点,点击 toast 引导预期
vendors[].health.ok / .configured / .serviceReadyboolean 三键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)androidZepp Android 端凭据✖ 不传 → 归一为 web(zepp/application/ports/services.py:27-36:非 android/ios 一律 web)。非 zepp 厂商忽略本字段
iosZepp 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 不是 stepsnull = 该块无数据(不是 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)。

域端点维度键键名的来源(改这里就要改文档)
devicemyTodayHealthsteps / hr / sleepdevice_queries.py 的 "sources": {...} —— 键名 = sources 的键
health_archivearchiveactivity / sleep / hrv / restHr / vo2max / heartRateZoneshealth_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[].idint原样回传给 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[].statuspending待完成(唯一由报名生成的初始值 — training_orm.py:59)—
done已完成(两条写入路径:手动 task/checkin,或 auto_sport 自动轨)
missed⚠️ DB 注释声明了三值,但当前无任何写入逻辑 —— 实际不会出现。(TS 类型更宽,前端已容错)
tasks[].completeSourcemanual用户手动打卡完成null(未完成时)
auto_sport当天有同 kind 的运动打卡 → 自动完成(cz_auto_complete_by_date,T2 自动轨)
tasks[].kindrunning / cycling / strength来自所属 plan.kind—
taskId(task/checkin)int来自 tasks[].id✖ 缺省 0 → 找不到任务 → data: null(HTTP 200,不报错不 404)。🔴 前端必须 if (!res.data) 判空 —— 否则 await 后本地置 done = 假成功
data(task/checkin)nulltask 不存在 / 非本人—
{taskId, status: 'done'}打卡成功(幂等:已完成直接返回 done)
reminders[].statuspending待发送(唯一由报名生成的初始值 — training_orm.py:73)—
sent / dismissed⚠️ DB 注释声明了这两个值,但当前无写入路径(同 missed 情形)—— 前端按「可能有」容错即可
reminders[].remindAtISO 串提醒时刻(报名时按 planned_date 生成)恒非空

splash ​

开机信息屏 · /api/v1/splash-screen · 2 端点 · 免登录 · 手册 splash

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

字段取值每个取值的含义缺省 / 不传时
ad.mediaTypeimage广告是图片 → 前端用 <image> 渲染 ad.imageUrlDB 缺省 "image"(splash_screen.py:41)—— 历史行没有该列值时按 image
video广告是视频 → 前端需换视频播放器(imageUrl 是视频封面/源)
ad.linkTypenone不可点击 → 点广告无跳转DB 缺省 "none"(splash_screen.py:42)
webview点击 → webview 加载 ad.linkUrlwebview 时 linkUrl 才有意义;none 时 linkUrl 可能为 null
notice.levelinfo普通运营提醒(蓝色/常规样式)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)—
[].versionint版本号,admin 每次更新内容自增(version 自增 — 用于前端判断本地缓存是否过期)首建为 1
[].updated_atISO 串🔴 snake_case —— 全项目唯一的 snake_case 响应字段组,与前端 AgreementContent 声明一致 ✓,勿"顺手修正"成 camelCase恒非空
data(getAgreement)object | nullnull = 非法 type 或无记录(HTTP 200 + code: "0",不 500)—

stats ​

数据统计 · POST /api/v1/stats · 本手册涉及 1 端点(getTodayOverview)· 手册 page-direct · 错误段 40422xxx

字段取值每个取值的含义缺省 / 不传时
calorie.profileCompletetrue画像完整(age / height / weight 三者齐 且 gender ∈ {male, female})→ 预算基于真实身体数据—
false画像缺项 → 后端用默认值算预算:30 岁 / 165cm / 60kg / gender 非男女时偏移 −78(onboarding_service.py:140 _CZ_BMR_DEFAULTS)。⚠️ 前端必须据此引导补全 —— 否则用户看到的是一个与自己无关的默认热量目标无画像行时同样是 false
calorie.budgetKcalint热量预算 = TDEE − 400,下限保护:male 1500 / female·unknown 1200(stats/CLAUDE.md 热量引擎)恒非空(int)
calorie.remainingKcalintbudget + burn − intake(可为负 —— 超额摄入时是负数,前端别 Math.max(0, x) 掩盖真实赤字)恒非空
calorie.intakeKcal / calorie.burnKcalfloat无数据时为 0(不是 null)0
checkin.count / checkin.distanceKmint / float当日打卡数与里程;无打卡为 00
dateYYYY-MM-DDCN 今日(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_archiveCOROS 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.days7积分兑换 7 天会员 —— 扣 100 分✔ 必传(Literal[7, 30])—— 其他值(含 30.0 浮点 / "7" 字符串)→ 40301001
30积分兑换 30 天会员 —— 扣 300 分
device · listDeviceDailyActivity.days1 ~ 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.strengthMinutesfloat力量训练分钟数 → 走 MET6 消耗源(0.105 × weight × 分钟)缺省 0.0(不传 = 无力量消耗)

待确认清单(本页标注不确定的条目 —— 未编造) ​

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

关联阅读 ​