Appearance
User 向导链(前端调用视角 · 7 端点)
本文只回答「哪个页面在什么时机调、响应从哪一层读、失败时前端做什么」。 字段类型 / 可空 / 错误码码值 / curl 一律见 前端联调 API 手册 · user —— 本文零复述(分工边界见 总览)。 协议:全部
POST /api/v1/user+{action, payload},失败形态见文末「两套契约」。 封装:src/api/user.ts· 消费:components/qm-profile-setup-popup(新用户向导)· pages/mine-info-personal-form(个人资料页)
调用链总览(两条入口,共用同一批 action)
入口 A · 新用户向导(登录响应 user.isNewUser === true 当次弹出)
page1 基础资料四维 → updateBasicProfile ──成功→ listTrainingPlans → page2
page2 生活节奏 5 题 → submitOnboarding(四维重发 + habits)→ onboardingSuggestion → page3
page3 选方案卡 → adoptPlanOption → completeOnboarding → 映射模板 → training/enroll → 关弹窗
跳过(任一页 X / 暂不设置)→ 不发任何请求,弹窗关闭
入口 B · 个人资料页「保存」(老用户日常编辑)
myProfile(onLoad 回显意向+备注)→ updateBasicProfile(四维)→ updateProfile(意向/备注)→ 本地缓存
头像单独一条:uni.uploadFile 拿 COS URL → updateProfile({ avatarUrl })7 个 action 是同一批用例,两个入口的调用顺序不同(向导 page1 先落库四维再进下一页;资料页保存时四维与意向分两次提交)。
一、updateBasicProfile(updateBasicProfile)
一句话:基础四维(性别/年龄/身高/体重)落库 —— 向导 page1 的「下一步」与资料页「保存」都调它。
前端函数签名
| 项 | 值 |
|---|---|
| 封装 | src/api/user.ts:30-36 → updateBasicProfile({ gender, age, height, weight }) |
| 触发时机 | 向导 page1「下一步」(qm-profile-setup-popup.vue:388)· 资料页「保存」(personal-form.vue:370) |
| 鉴权 | Bearer access |
必需 payload 键
| 键 | 从哪来 | 必填 |
|---|---|---|
gender | 向导性别单选(male / female) | ✔ |
age / height / weight | 三个 picker;资料页还有 nickname/birthday 等只在本地校验、不随本 action 提交 | ✔ |
响应读法
ts
await updateBasicProfile({ gender, age, height, weight });
// 中文注解:响应回显的就是刚提交的四维(身高会被后端取整后再返回),页面不复用 → 无需读取
templates.value = (await listTrainingPlans())?.data ?? []; // 中文注解:向导紧接着拉训练模板,为 page3 的映射做准备失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 参数越界 / 未登录(HTTP 400/401 + 码) | request 层统一 toast,停在 page1 可重试 —— 码值见 手册 · updateBasicProfile |
⚠️ 坑位
- 资料页把 nickname / birthday 校验为必填,但这两个字段不走本 action 也不走 updateProfile(详见 七、updateProfile)—— 当前实现里它们只进本地缓存。
二、submitOnboarding(submitOnboarding)
一句话:向导 page2 提交生活习惯问卷(+ 四维重发)→ 返回画像摘要(人群分型 / BMI / BMR / 标签)。
前端函数签名
| 项 | 值 |
|---|---|
| 封装 | src/api/user.ts:52-66 → submitOnboarding({ gender, age, height, weight, habits }) |
| 触发时机 | 向导 page2「提交」(qm-profile-setup-popup.vue:408) |
| 鉴权 | Bearer access |
必需 payload 键(habits 是嵌套对象,字段名复数是硬约定)
| 键 | 从哪来 | 必填 |
|---|---|---|
gender / age / height / weight | 四维重发(page1 已落库,此处幂等再发一次) | ✔ |
habits.exerciseFreq | ⚠️ 不要传(2026-09-20 起后端计算)—— 后端缺省 0 并按打卡数据后续校正。向导里那个「0-7 次/周」按钮组可以删掉。取值语义(0 / ≥3 / ≥4 各触发什么)见 取值速查 · user | ✖ |
habits.exercisePrefs | 运动意向多选数组(walk / run / cycling / strength / other) | ✔ |
habits.exercisePrefRemark | 备注(选了 other 时必填;未选 other 时前端显式传 null 清残留) | ✖ |
habits.takeoutFreq / sugarDrink / lateDinner | 外卖 / 含糖饮料 / 晚晚餐三题(前端 canSubmitHabits 强制三项都答) | ✔ 前端强制 |
响应读法
ts
profile.value = (await submitOnboarding({ gender, age, height, weight, habits }))?.data;
// 中文注解:page3 的数据源在下一个 action,这里拿到的 profile 只用于展示/调试| 路径 | 含义 |
|---|---|
data.audience | 人群分型(A 减脂新手 / B 减脂进阶 / C 塑形维持)—— 决定 page3 有几张方案卡 |
data.bmiLabel / data.bmi / data.bmr | 展示口径(旧稿漏过 bmiLabel,实为 7 键之一) |
data.minorMode | 未成年模式(true 时页面上要弱化减脂口径) |
data.summary / data.tags | 摘要文案 / 标签数组(页面按数组渲染) |
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
选了 other 没填备注 / 参数越界 | toast,停在 page2 可重试(前端也会先拦:canSubmitHabits) —— 码值见 手册 · submitOnboarding |
⚠️ 坑位
- ❌
habits.exercisePref(少一个 s 的单数写法)—— 已证伪:真值是数组exercisePrefs,另一个独立字段叫exercisePrefRemark。旧文档把「数组」写成标量,前端照抄会直接传错类型。 - 前端自己还限了「1~4 项 + 最多选 4 项 toast」,比资料页的 2 项上限更宽 —— 同一份数据两个页面口径不同,改动时别只看一处。
三、onboardingSuggestion(getOnboardingSuggestion)
一句话:向导 page3 拉「健康目标 + 方案建议」—— 决定渲染几张方案卡。
前端函数签名
| 项 | 值 |
|---|---|
| 封装 | src/api/user.ts:95-97 → getOnboardingSuggestion()(无入参,身份取自 token) |
| 触发时机 | page2 提交成功后立即(qm-profile-setup-popup.vue:426) |
| 鉴权 | Bearer access |
必需 payload 键
| 键 | 从哪来 | 必填 |
|---|---|---|
payload | {}(user_id 来自 token,不要传本地 uid) | ✔ 自动 |
响应读法
ts
suggestion.value = (await getOnboardingSuggestion())?.data;
const cards = suggestion.value?.plans ?? []; // 中文注解:方案卡数量随人群变,不要假设 3 张
const goal = suggestion.value?.goal; // 中文注解:可能为 null → 整块隐藏,不是"数据没加载出来"
const note = suggestion.value?.safetyNote; // 中文注解:同样可能为 null| 路径 | 含义 |
|---|---|
data.plans[] | 方案卡数组 —— 张数随分型收敛(见下) |
data.plans[].id | gentle / standard / intense —— adoptPlanOption 就回传这个值 |
data.plans[].recommended | 默认选中卡(各人群不是同一张) |
data.goal | 健康目标区间;null = 不给减脂目标(minorMode 或 BMI<18.5) |
data.safetyNote | 安全提示(未成年 / 偏瘦 / 肥胖三档) |
方案卡张数(源码口径,别再写「恒 3 套 gentle/standard/intense」)
| 条件 | 张数 |
|---|---|
未成年(minorMode)或 BMI<18.5 | 1 张(gentle,无热量缺口) |
| 人群 A(减脂新手) | 2 张(gentle + standard) |
| 人群 B(减脂进阶) | 3 张(standard + intense + gentle) |
| 人群 C(塑形维持) | 2 张(standard + gentle) |
⇒ 硬编码
intense会在人群 C 崩;一律从plans[].id里取。
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 任意失败 | toast,停在 page2 可重试(page3 不进)—— 码值见 手册 · onboardingSuggestion |
四、adoptPlanOption(adoptPlanOption)
一句话:用户点选方案卡 → 服务端重算热量缺口并落库(防前端伪造 deficitKcal)。
前端函数签名
| 项 | 值 |
|---|---|
| 封装 | src/api/user.ts:100-104 → adoptPlanOption(planId)(传字符串 planKey) |
| 触发时机 | 向导 page3 点「完成」(qm-profile-setup-popup.vue:474,仅当用户选了卡) |
| 鉴权 | Bearer access |
必需 payload 键
| 键 | 从哪来 | 必填 |
|---|---|---|
planId | plans[].id(gentle / standard / intense)—— 不要硬编码,可用集合随人群收敛 | ✔ |
响应读法
ts
await adoptPlanOption(selectedPlan.value);
// 中文注解:响应 {planOption, dailyDeficitKcal, sessionsPerWeek} 是服务端重算后的口径
// 页面不展示这些值(旧稿未给字段名,真值为上述三键)失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 未选卡 | 前端直接跳过本 action(if (selectedPlan.value)),不是"失败" |
| 任意失败 | catch 内 setError(extractErrMsg(...)) → 弹窗内红字提示,可重试 —— 码值见 手册 · adoptPlanOption |
五、completeOnboarding(completeOnboarding)
一句话:向导收尾 —— 置 onboardingDone 语义位(下次登录不再弹向导)。
前端函数签名
| 项 | 值 |
|---|---|
| 封装 | src/api/user.ts:107-108 → completeOnboarding() |
| 触发时机 | finishWizard() 内、adoptPlanOption 之后(qm-profile-setup-popup.vue:476) |
| 鉴权 | Bearer access |
必需 payload 键
| 键 | 从哪来 | 必填 |
|---|---|---|
payload | {} | ✔ 自动 |
响应读法
ts
await completeOnboarding(); // 中文注解:{ok:true} —— 置位成功,后续 enroll 失败也不回滚本步(不回退向导)失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 任意失败 | catch → 弹窗内红字提示,可重试(此时 adoptPlanOption 已生效,重试只补下一步) |
六、myProfile(myProfile)
一句话:读全量画像 —— 个人资料页 onLoad 回显运动意向/备注(昵称头像另走本地缓存)。
前端函数签名
| 项 | 值 |
|---|---|
| 封装 | src/api/user.ts:111-112 → myProfile()(无入参) |
| 触发时机 | 资料页 setup 顶层即调(personal-form.vue:248)—— 当前全仓唯一调用点(api/user.ts 的注释还提到"运动页",但无调用点,属声明未落地) |
| 鉴权 | Bearer access |
必需 payload 键
| 键 | 从哪来 | 必填 |
|---|---|---|
payload | {}(身份取自 token) | ✔ 自动 |
响应读法(⚠️ 动态 dict —— 没有固定 schema,只能按需取并逐项兜底)
ts
const p = res?.data ?? {};
// 中文注解:先 Array.isArray 再过滤 —— 值域外的历史缓存会被前端过滤掉,别直接塞进 UI
if (Array.isArray(p.exercisePrefs)) form.intentions = p.exercisePrefs.filter(/* 值域内 */);
if (p.exercisePrefRemark) form.exercisePrefRemark = p.exercisePrefRemark;
// 中文注解:页面刻意不读 nickname/avatarUrl(走本地缓存 personal_profile),inertia 上要留意| 路径 | 含义 |
|---|---|
data.exercisePrefs / data.exercisePrefRemark | 运动意向数组 / 备注(资料页回显实际用到的两个) |
data.userId / data.nickname / data.avatarUrl / data.signature / data.signatureSource | 画像行基础键(值可为 null;signatureSource 区分"用户手写"与"AI 生成") |
data.profileTags | 标签数组(jsonb → list) |
data.gender / data.age / data.height / data.weight / data.habits / data.fitness | 画像行其余维度(可能整片为 null —— agent 尚未分析) |
data.updatedAt / data.agentAnalyzedAt | 更新时间 —— ⚠️ 只有 PG 实现有,InMemory 无此键 |
data.points / data.totalPointsEarned / data.memberExpireAt / data.growthLevel / data.onboardingDone | account 段(积分/会员)—— ⚠️ user_core 异常或未装配时这 5 键整体消失,读它就 undefined |
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 任意失败 | .catch(() => {}) 静默:页面保留本地缓存值(回显降级,但不打扰用户) |
⚠️ 坑位
- 不要按固定 schema 解构(
const { a, b } = res.data)—— 字段是动态拼的(画像行 + account 段),且双仓储字段集不一致,按需取值 + 逐项兜底。
七、updateProfile(updateProfile)
一句话:个人资料页的写入总口 —— 头像、运动意向、备注(以及后端支持的昵称/签名)。
前端函数签名
| 项 | 值 |
|---|---|
| 封装 | src/api/user.ts:115-131 → updateProfile({ nickname?, avatarUrl?, signature?, exercisePrefs?, exercisePrefRemark? }) |
| 触发时机 | 资料页「保存」(personal-form.vue:389)· 头像上传成功后(personal-form.vue:337) |
| 鉴权 | Bearer access |
必需 payload 键(全字段可选:只提交要改的维度)
| 键 | 从哪来 | 必填 |
|---|---|---|
avatarUrl | 头像:uni.uploadFile 拿到的 COS URL(原生上传,不走 request 层) | ✖ |
exercisePrefs | 页面多选结果 —— 空数组不传(传空数组会被判参数非法,前端用 undefined 规避) | ✖ |
exercisePrefRemark | 备注(页面只在选了 other 时传) | ✖ |
nickname / signature | 当前调用点不传(后端支持,见坑位) | ✖ |
响应读法
ts
const res: any = await updateProfile({ avatarUrl: url });
form.avatarUrl = res?.data?.user?.avatarUrl || url; // 中文注解:有 user 包裹键;取不到就回落到刚上传的 URL路径(data.user 之下) | 含义 |
|---|---|
nickname / avatarUrl / signature | 写入后的回显(可能为 null) |
signatureSource | custom(用户手写)/ agent(AI 生成)—— 手写后 agent 每日分析不再覆盖 |
exercisePrefs / exercisePrefRemark | 意向数组 / 备注 |
profileTags | 标签数组(AI 分析产出,本 action 不写) |
完整字段表 / 错误码见 手册 · updateProfile。
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
含 other 未填备注 / 意向超限 / 参数非法 | toast,留在页面可重试 —— 码值见 手册 · updateProfile |
上传层失败(Error 而非后端 body) | 额外 toast 一次上传错误文案(request 层管不到原生上传) |
⚠️ 坑位
- 传
null无法清空字段:后端语义是「仅非None字段写」,null会被静默忽略 —— 想清空签名/头像时传null会看起来"保存成功但没变"。当前调用点用undefined(不传)规避,没踩到不等于没坑。 nickname双写(2026-09-20 起):提交nickname会同时写user_profile与 oauth 用户主表(登录响应读源),否则会出现「改完昵称重新登录又是旧昵称」。 ⚠️ 矩阵 ep14 仍记「只写画像表不同步主表」—— 矩阵未随源码修复同步(profile.py的UpdateProfileUseCase已注释标明该修复)。- 意向项数三个口径不一致:后端 1~4 · 向导 ≤4 · 资料页
≤2(personal-form.vue的toggleIntention上限写死 2)。改文案/改上限前先确认以哪一处为准。 - 头像上传不走
request层:uni.uploadFile手写 header(含X-Env)+ 手写code === '0'判定 —— 双库标识漏传会导致头像落到另一个库。
关键前端语义(跨端点)
- action 协议:全部
POST /api/v1/user,{action, payload};user_id一律取自 token,本地 uid 传了也会被忽略。 - 两套失败契约:本域失败多为 HTTP 400/401/404 + 码(与 oauth 域的「HTTP 200 + 码」不同),request 层统一 toast,页面只管
catch兜底 —— 见 手册 G4。 - 业务码段
40301xxx覆盖参数/状态错误;排障时先看 toast 文案,码值一律查手册。 - 血泪坑(2026-09-18 真机实锤):向导三连 bug = 三个函数漏 import +
extractErrMsg未定义 + 响应漏?.data解包 →list.filter远端 TypeError → enroll 从未发出。新页面提交前 grep 对照 import 清单。 finishWizard的模板映射(跨到 training 域,见 api/training):取exercisePrefs里首个非other项决定 kind(cycling/strength/ 其余走跑步),再按所选方案的强度取该 kind 下周数最短(gentle)或最长的模板 →enroll的planId是数字模板 id,不是gentle这类 planKey。 全other→ 映射返回null→ 直接跳过 enroll(不是失败提示)。
后端链接
- 契约(权威):手册 · user · 通用约定 G1-G7
- 场景说明:components/qm-profile-setup-popup · pages/mine-info-personal-form · 训练侧衔接 api/training