---
url: /docs/guide/uniapp/api/training.md
---
# Training 训练计划（前端调用视角）

> **契约权威** → [手册 · training](/api-docs/v1/frontend/training)：字段表 / 类型 / 可空 / 错误码码值 / curl **一律在那**，本页零复述。
> **本页回答**：哪个页面在什么时机调、响应从哪一层读、失败时前端做什么。
> 封装 `src/api/training.ts`（api 层 6 文件之一）· 消费页 [pages/my-plan](../pages/my-plan.md) · 向导 page3 建议采纳 · 训练日历

## 一、域特征（读端点前先看这五条）

| # | 特征 | 对前端意味着什么 |
|---|---|---|
| 1 | **REST 协议** | 路径是 `api/v1/training/*` —— **不是** oauth 的「单端点 + `{action}`」协议，也不是 user/device 的单端点 action 协议。GET 走 query，POST 走 body |
| 2 | **错误走 HTTP 状态码** | `40406xxx` 段 + **HTTP 400 / 401 / 404** —— ⚠️ **与 oauth 的「HTTP 200 + code」是两套契约**，按 oauth 的写法判成败会**漏掉全部错误** |
| 3 | ⚠️ **两个端点例外：业务失败也是 HTTP 200 + code `"0"`** | `enroll` → `data.ok === false`；`checkin` → `data === null`。**这两个端点不能只靠 `catch` 或 HTTP 状态判成败** —— 见各端点的「失败时的前端动作」 |
| 4 | **身份只认 Bearer token** | `body.userId` / `?user_id=` 一律被后端忽略（2026-09-18 越权修复） |
| 5 | **GET 的 `data` 被 request 封装转成 query** | `request({ method:'get', data:{...} })` → 自动拼 query + 注入 `cacheTime` 防缓存；后端忽略 `cacheTime`，无影响 |

### ⚠️ 死参数：`userId` / `?user_id=`（前端四处仍在传）

`listMyTasks` / `listMyReminders` 仍带 `?user_id=`；`enrollTrainingPlan` / `checkinTask` 仍带 `body.userId`。
**四个都是死参数**：身份一律取自 token，传了不报错、也不生效。

> 🔴 **静默信任陷阱**：本地 `storage.user` 的 uid 与当前 token 用户**不一致**时（换号残留、多账号切换），
> 返回的是 **token 用户**的任务与提醒，**前端完全无法感知** —— 页面会把 A 的计划画给 B 看。
> 排查「数据串了」时**先核 uid 与 token 是否同源**，别先怀疑后端串数据。
> 字段与码值见 [手册 · training](/api-docs/v1/frontend/training)。

***

## 二、端点明细

### listTrainingPlans（`src/api/training.ts` → `listTrainingPlans()`）

**一句话**：**免登录**的计划模板列表 —— 向导 page3「教练建议」与计划选择页用它渲染可选计划。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/training.ts` → `listTrainingPlans()`（**无入参**） |
| 路径 | `GET api/v1/training/plans` |
| 触发时机 | 向导 page3 建议弹窗打开 / 计划选择页 `onShow` |
| 鉴权 | **`none`（公开）** —— 登录前也能拉 |

**必需 payload 键**：无（该端点无入参）。

**响应读法**（顶层直接是数组）

```ts
const res = await listTrainingPlans();
// res 是完整响应体（request 不解包），业务数组在 res.data 之下
const plans = res?.data ?? [];                     // 中文注解：这里 data 就是数组本身，不要再进一层
const picked = plans.find(p => p.planKey === 'running_5k');
const planId = picked?.id;                         // 中文注解：id 是数字，原样回传给 enroll 的 planId
```

**响应真值**

| 路径 | 含义 |
|---|---|
| `data[].id` | 数字 id —— **回传给 `enroll` 的 `planId`**（不是 `planKey`，两者勿混） |
| `data[].planKey` | 英文机器名（匹配/埋点用） |
| `data[].name` / `.description` | 展示文案（卡片标题 / 副标题） |
| `data[].kind` | `running` / `cycling` / `strength` |
| `data[].targetKm` / `.targetSessions` / `.durationWeeks` | 目标距离 / 课次 / 周数 |

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| 任意失败（5xx / 网络） | `catch` 内保空列表 + 提示「计划加载失败，下拉重试」 |
| 返回**空数组** | **正常态**（后端当前无已发布计划）—— 不要渲染成错误页 |

**⚠️ 坑位**

* 该端点**免登录**，但计划页往往与「用户画像」请求并行发出 —— 别把公开端点的失败当成「登录态失效」去触发登出。

***

### enrollTrainingPlan（`src/api/training.ts` → `enrollTrainingPlan({ userId, planId })`）

**一句话**：向导收尾/计划页点「报名」→ 服务端生成打卡任务与提醒；成功后计划页立刻切到「已报名」态。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/training.ts` → `enrollTrainingPlan({ userId, planId })` |
| 路径 | `POST api/v1/training/enroll` |
| 触发时机 | 用户点「加入计划」（向导 page3 采纳 / 计划页报名按钮） |
| 鉴权 | `Bearer <access>` |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `planId` | `listTrainingPlans()` 的 `data[].id`（**数字**） | ✔ |
| `userId` | `storage.user.get()?.id` | ✖ **死参数**（后端忽略，见上「死参数」） |

**响应读法**（⚠️ **成功/失败两条分支，都必须先判 `ok`**）

```ts
const er = await enrollTrainingPlan({ userId: uid, planId });
// res 不解包 → 业务字段在 er.data
if (er?.data?.ok) {
	// 中文注解：只有成功路径才有 created / reminders —— 前端 TS 却把它们声明成必填
	toast(`已生成 ${er.data.created} 个任务、${er.data.reminders} 条提醒`);
	refreshPlanPage();
} else {
	// 中文注解：业务失败同样是 HTTP 200 + code "0"，靠 reason 分支，不是 catch
	toast(er?.data?.reason || '报名失败');
}
```

> ❌ **错误读法**：`await enrollTrainingPlan(...)` 之后直接 toast「报名成功」——
> 后端「一次只允许一个进行中的计划」，重复/换计划报名会返回 `HTTP 200 + code "0" + data.ok === false`：
> **不 reject、不报错、界面却显示报名成功**（假成功）。
> 另：⚠️ `created` / `reminders` **只在成功路径存在** —— 先读会得到 `undefined`。

**响应真值**

| 路径 | 含义 |
|---|---|
| `data.ok` | ✔ 成功 / ✖ 业务失败 —— **前端唯一可靠的成功判据** |
| `data.created` | 生成的任务数（**仅成功路径**） |
| `data.reminders` | 生成的提醒数（**仅成功路径**） |
| `data.reason` | 失败原因（**仅失败路径**，中文，可直接 toast）。已知：`计划不存在或未发布` / `已有进行中的计划（一次一个）` |
| `data.enrollmentId` | 后端返回的报名 id（前端 TS 未声明，不消费无影响） |

> 字段类型/错误码表见 [手册 · enrollTrainingPlan](/api-docs/v1/frontend/training#enrolltrainingplan)。

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| `data.ok === false`（**HTTP 200**） | 用 `reason` 原样 toast；若是「已有进行中的计划」→ 引导去计划页查看，**不要盲目重试** |
| `40406005` 计划已下架 | toast「计划已下架」+ 重拉 `listTrainingPlans` |
| `40406002` 未登录 | 走拦截器 401 分档（rotate 单飞 / 强登出） |
| `40406001` 参数缺失 | 调用点 bug（`planId` 传成了 `undefined`/0）—— 查调用链，不是用户操作问题 |

**⚠️ 坑位**

* **注释 ≠ 契约**：`src/api/training.ts:40` 的注释写「幂等 — 自动生成打卡任务与提醒」，**与实际后端规则不符**。
  后端是「**一次只允许一个进行中的计划**」—— 重复报名会被业务拒绝（`ok:false`），**不是幂等 upsert**。
  联调时以 [手册](/api-docs/v1/frontend/training#enrolltrainingplan) 为准，注释待修。
* **报名后必须重拉列表**：`created` / `reminders` 只是计数，任务正文要用 `listMyTasks` 拿。
* 真机闭环记录（2026-09-18）：新号向导 → `enroll` → 12 任务 + 12 提醒。**任务数是模板决定的**（`targetSessions` × `durationWeeks`），不是常量 —— 别把 12 写死进断言。

***

### listMyTasks（`src/api/training.ts` → `listMyTasks(userId)`）

**一句话**：我的打卡任务列表（按计划日排序）—— 计划页/日历页的任务格；`status` 决定格子画勾还是空心。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/training.ts` → `listMyTasks(userId)` —— 形参是 uid，但**后端忽略** |
| 路径 | `GET api/v1/training/tasks` |
| 触发时机 | 计划页 `onShow`；打卡成功后重拉 |
| 鉴权 | `Bearer <access>` |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `user_id` (query) | `storage.user.get()?.id` —— ⚠️ **死参数** | ✖ |

**响应读法**（顶层直接是数组）

```ts
const res = await listMyTasks(uid);
const tasks = res?.data ?? [];                       // 中文注解：data 就是数组
const today = tasks.filter(t => t.plannedDate === todayStr);   // 中文注解：'YYYY-MM-DD' 字符串比对，别用 Date 解析
const needCheckin = today.filter(t => t.status === 'pending');
```

**响应真值**

| 路径 | 含义 |
|---|---|
| `data[].id` | 任务 id —— **`checkinTask` 的 `taskId` 就是它** |
| `data[].weekIndex` / `.sessionIndex` | 周序 / 课序（日历分组用） |
| `data[].plannedDate` | `'YYYY-MM-DD'` 字符串 |
| `data[].kind` | 来自所属计划的 `kind` |
| `data[].status` | 后端只产 `pending` / `done` —— **`missed` 后端无生成逻辑**（前端类型声明更宽，属良性） |
| `data[].completeSource` | `manual`（手动打卡）/ `auto_sport`（当日完成对应 kind 运动自动 done）/ `null` |

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| 任意失败 | `catch` 内保空 + 「任务加载失败」占位，**不要清空已渲染的旧列表** |
| `data` 为空数组 | 正常态（尚未报名）—— 引导去计划选择页 |

**⚠️ 坑位**

* **双轨打卡**：手动走 `checkinTask`；当日完成对应 `kind` 的运动会被自动置 `done`（`completeSource: auto_sport`）。
  写「未完成」告警前**必须排除 `auto_sport`**，否则用户跑完步还被催打卡。
* `missed` 永远不会出现 —— 用「`plannedDate` < 今天 且 `status === 'pending'`」自行推导「已过期未打卡」。

***

### checkinTask（`src/api/training.ts` → `checkinTask({ userId, taskId })`）

**一句话**：任务格点「打卡」→ 服务端置该任务为完成，返回最新 `status`；页面据返回值更新格子。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/training.ts` → `checkinTask({ userId, taskId })` |
| 路径 | `POST api/v1/training/task/checkin` |
| 触发时机 | 用户点任务格的打卡按钮（手动轨） |
| 鉴权 | `Bearer <access>` |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `taskId` | `listMyTasks()` 的 `data[].id`（**数字**） | ✔ |
| `userId` | `storage.user.get()?.id` | ✖ **死参数** |

**响应读法**（⚠️ **`data` 可能是 `null`，必须先判空再置本地状态**）

```ts
const res = await checkinTask({ userId: uid, taskId });
if (!res?.data) {
	// 中文注解：HTTP 200 + code "0"，但 data 是 null —— task 不存在 / 非本人
	toast('打卡失败，请刷新任务列表后重试');
	await loadTasks();          // 中文注解：以服务端为准刷新，别靠本地推断
	return;
}
const newStatus = res.data.status;   // 中文注解：'done'
// 只有走到这里才能本地置 done
```

> ❌ **错误读法**：`await checkinTask(...)` 之后直接 `task.status = 'done'` ——
> task 不存在/非本人时后端返回 `HTTP 200 + code "0" + data: null`（**不 reject、不 404**），
> 用户看到「打卡成功」而**服务端没有任何记录**（假成功）。
> 正常路径 `taskId` 来自任务列表、不易触发；但**离线补打卡 / 从通知直接进打卡**这类场景必踩。

**响应真值**

| 路径 | 含义 |
|---|---|
| `data`（整体） | **可能是 `null`** —— 唯一且必须判的空值分支 |
| `data.taskId` | 回显的任务 id |
| `data.status` | `'done'`（已完成再点一次不报错，直接返回同样结果） |

> 字段可空性/错误码见 [手册 · checkinTask](/api-docs/v1/frontend/training#checkintask)。

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| `data === null`（**HTTP 200**） | toast + **重拉任务列表**；**禁止**本地置 `done` |
| `40406004` 任务不存在（HTTP 404） | toast + 重拉列表 |
| `40406002` 未登录 | 走拦截器 401 分档 |

**⚠️ 坑位**

* **本地乐观更新是双重陷阱**：既是「假成功」（见上），也会与 `auto_sport` 自动打卡的结果打架。
  推荐口径：**先等服务端返回，再更新 UI**（这页数据量小，不值得做乐观更新）。

***

### listMyReminders（`src/api/training.ts` → `listMyReminders(userId)`）

**一句话**：App 内提醒列表 —— 计划页提醒区块（「今天 19:00 提醒你跑步」）。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/training.ts` → `listMyReminders(userId)` —— uid 同样被忽略 |
| 路径 | `GET api/v1/training/reminders` |
| 触发时机 | 计划页 `onShow` |
| 鉴权 | `Bearer <access>` |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `user_id` (query) | `storage.user.get()?.id` —— ⚠️ **死参数** | ✖ |

**响应读法**（顶层直接是数组）

```ts
const res = await listMyReminders(uid);
const reminders = res?.data ?? [];
const next = reminders.find(r => r.status === 'pending');      // 中文注解：取最近一条未触发的
const at = next?.remindAt;                                     // 中文注解：ISO 字符串，展示前自行格式化
```

**响应真值**

| 路径 | 含义 |
|---|---|
| `data[].id` | 提醒 id |
| `data[].taskId` | 关联任务 —— **可 join `listMyTasks` 的 `data[].id` 拿任务详情** |
| `data[].remindAt` | ISO 时刻字符串 |
| `data[].status` | `pending` 等 |

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| 任意失败 | `catch` 内隐藏提醒区块 —— **提醒是锦上添花，失败不要阻断计划页主体** |

***

### leave（退出计划） —— ⚠️ **前端零封装 · 契约未审计**

**一句话**：退出现有计划（服务端置该报名失效）。**前端当前没有任何调用点**，本条目来自后端路由源码，**不在 30 端点契约矩阵内**。

| 项 | 值 |
|---|---|
| 封装 | **无** —— `src/api/training.ts` 未导出对应函数，页面亦无内联调用（2026-09-20 实查） |
| 后端路由 | `src/app/training/interface/api/training_api.py` 的 `POST api/v1/training/leave`（与 `enroll` 同文件同鉴权） |
| 请求 | `{ planId }` —— `userId` 同样被忽略（身份取 token） |
| 响应 | `{ ok: boolean }` |
| 契约状态 | 🔶 **契约未审计**：未进 `tests/reports/contract-matrix.json`（30 端点），字段未经前后端逐字段对拍，**上前端前必须先补录矩阵** |

**为什么单独标出来**

* ⚠️ **「一次只允许一个进行中的计划」是 `enroll` 的约束，不是 `leave` 的语义** —— 别把两者混为一谈：
  前者决定「能不能再报名」，后者只回答「这次退出成不成功」。
* 计划页当前**没有退出入口**。若产品要补按钮：先补矩阵 → 再按本页模板写消费视角 → 最后接页面。
  **不要照 `enroll` 的读法直接套**（两者的失败形态未经验证是否同构）。

***

## 三、一页速查

| 端点 | 前端函数 | 鉴权 | 响应 `data` 形态 | 失败形态 |
|---|---|---|---|---|
| `GET /api/v1/training/plans` | `listTrainingPlans()` | 公开 | 数组 | HTTP 4xx/5xx |
| `POST /api/v1/training/enroll` | `enrollTrainingPlan()` | access | 对象（`ok` 两分支） | **HTTP 200 + `ok:false`** 或 4xx |
| `GET /api/v1/training/tasks` | `listMyTasks(uid)` | access | 数组 | HTTP 4xx |
| `POST /api/v1/training/task/checkin` | `checkinTask()` | access | 对象 **或 `null`** | **HTTP 200 + `data:null`** 或 4xx |
| `GET /api/v1/training/reminders` | `listMyReminders(uid)` | access | 数组 | HTTP 4xx |
| `POST /api/v1/training/leave` | **未封装** | access | `{ok}`（未审计） | 未审计 |

> 字段表 / 类型 / 可空 / 错误码码值 / curl → [手册 · training](/api-docs/v1/frontend/training)。
> 本页与手册的分工边界 → [api/ 总览](./index.md)。
