Skip to content

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[].idgentle / standard / intense —— adoptPlanOption 就回传这个值
data.plans[].recommended默认选中卡(各人群不是同一张)
data.goal健康目标区间;null = 不给减脂目标(minorMode 或 BMI<18.5)
data.safetyNote安全提示(未成年 / 偏瘦 / 肥胖三档)

方案卡张数(源码口径,别再写「恒 3 套 gentle/standard/intense」)

条件张数
未成年(minorMode)或 BMI<18.51 张(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 键

键从哪来必填
planIdplans[].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.onboardingDoneaccount 段(积分/会员)—— ⚠️ 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)
signatureSourcecustom(用户手写)/ 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(不是失败提示)。

后端链接 ​