---
url: /docs/guide/uniapp/api/user.md
---
# User 向导链（前端调用视角 · 7 端点）

> 本文只回答「哪个页面在什么时机调、响应从哪一层读、失败时前端做什么」。
> **字段类型 / 可空 / 错误码码值 / curl 一律见 [前端联调 API 手册 · user](/api-docs/v1/frontend/user)** —— 本文零复述（分工边界见 [总览](./index.md)）。
> 协议：**全部 `POST /api/v1/user`** + `{action, payload}`，失败形态见文末「两套契约」。
> 封装：`src/api/user.ts` · 消费：[components/qm-profile-setup-popup](../components/qm-profile-setup-popup.md)（新用户向导）· [pages/mine-info-personal-form](../pages/mine-info-personal-form.md)（个人资料页）

## 调用链总览（两条入口，共用同一批 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](/api-docs/v1/frontend/user#updatebasicprofile) |

**⚠️ 坑位**

* 资料页把 nickname / birthday 校验为必填，但**这两个字段不走本 action 也不走 updateProfile**（详见 [七、updateProfile](#七、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](/api-docs/v1/frontend/values#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](/api-docs/v1/frontend/user#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](/api-docs/v1/frontend/user#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](/api-docs/v1/frontend/user#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](/api-docs/v1/frontend/user#updateprofile)。

**失败时的前端动作**
| 情形 | 前端动作 |
|---|---|
| 含 `other` 未填备注 / 意向超限 / 参数非法 | toast，留在页面可重试 —— 码值见 [手册 · updateProfile](/api-docs/v1/frontend/user#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](/api-docs/v1/frontend/#g4-两套错误契约并存)。
* **业务码段 `40301xxx`** 覆盖参数/状态错误；排障时先看 toast 文案，码值一律查手册。
* **血泪坑（2026-09-18 真机实锤）**：向导三连 bug = 三个函数漏 import + `extractErrMsg` 未定义 + 响应漏 `?.data` 解包 → `list.filter` 远端 TypeError → **enroll 从未发出**。新页面提交前 grep 对照 import 清单。
* **`finishWizard` 的模板映射**（跨到 training 域，见 [api/training](./training.md)）：取 `exercisePrefs` 里**首个非 `other`** 项决定 kind（`cycling` / `strength` / 其余走跑步），再按所选方案的强度取该 kind 下**周数最短（gentle）或最长**的模板 → `enroll` 的 `planId` 是**数字模板 id**，**不是** `gentle` 这类 planKey。
  全 `other` → 映射返回 `null` → **直接跳过 enroll**（不是失败提示）。

## 后端链接

* 契约（权威）：[手册 · user](/api-docs/v1/frontend/user) · [通用约定 G1-G7](/api-docs/v1/frontend/#通用约定-7-条-·-所有端点共用)
* 场景说明：[components/qm-profile-setup-popup](../components/qm-profile-setup-popup.md) · [pages/mine-info-personal-form](../pages/mine-info-personal-form.md) · 训练侧衔接 [api/training](./training.md)
