---
url: /docs/api-docs/v1/frontend/training.md
---
# 前端联调 · 训练中心（training.ts）

> 归属：[前端联调 API 手册](./) → `src/api/training.ts`
> 端点：**5 个** · **REST 风格（非 action 协议）**
> 错误码段：`40406xxx`（全表见 [错误码注册表](/ai-contexts/error-code-registry)）
> 错误契约：**HTTP 400 / 401 / 404 + `{code, msg, data}`**；⚠️ **但业务失败（如计划冲突）走 HTTP 200 + code "0" + `{ok:false}`** —— 见下
> 取值语义：**[取值速查 · training](./values#training)** —— `kind` 三类计划的种子差异、`data.ok=false` 时**哪些键不存在**、`tasks[].status` 实际只产两值、`completeSource` 两条写入路径
> 深入说明：[training API Docs](/api-docs/v1/training/)

## 本页 curl 变量

```bash
BASE_URL=https://api.qingmulife.cn   # 本地：http://localhost:3000
ACCESS=<access_token>
ENV=Prd                              # Dev | Prd —— 全程必须一致
```

> ⚠️ **身份一律以 token 为准**（2026-09-18 越权修复）：`body.userId` / `?user_id=` **一律被后端忽略**。
> 传它们是死参数 —— **前端本地 uid 与 token 用户不一致时（换号残留），返回的仍是 token 用户的数据，且前端无法感知**。

## 端点一览

| # | 方法 | 路径 | 鉴权 | 场景 |
|---|---|---|---|---|
| [1](#listtrainingplans) | GET | `/api/v1/training/plans` | **公开** | 计划模板列表（向导推荐用） |
| [2](#enrolltrainingplan) | POST | `/api/v1/training/enroll` | access | 报名计划（生成任务 + 提醒） |
| [3](#listmytasks) | GET | `/api/v1/training/tasks` | access | 我的打卡任务（日历/计划页） |
| [4](#checkintask) | POST | `/api/v1/training/task/checkin` | access | 任务打卡 |
| [5](#listmyreminders) | GET | `/api/v1/training/reminders` | access | 我的提醒列表 |

***

## listTrainingPlans

**一句话**：计划模板列表 —— 向导推荐 / 计划选择页展示用（**免登录**）。

### curl

```bash
curl -s "$BASE_URL/api/v1/training/plans" \
  -H 'X-Env: Prd'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| — | — | — | **无任何入参、免登录** |

### 响应 `data` —— **顶层是数组**

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `[].id` | number | 否 | int —— 原样回传给 `enroll` 的 `planId` |
| `[].planKey` | string | 否 | 计划键（英文机器名） |
| `[].name` / `[].description` | string | 否 | 展示文案 |
| `[].kind` | string | 否 | 种子值 `running` | `cycling` | `strength`（前端类型为三值联合 ✓） |
| `[].targetKm` | number | 否 | float |
| `[].targetSessions` | number | 否 | int |
| `[].durationWeeks` | number | 否 | int |

### 错误码

无业务错误码（公开只读接口）。服务不可用 → HTTP 5xx。

***

## enrollTrainingPlan

**一句话**：报名训练计划 —— 服务端生成打卡任务 + 提醒。

> 🔴 **这是本页最大的坑**：**业务失败也返回 HTTP 200 + `code: "0"`**，只是 `data.ok = false`。**不是幂等 upsert** —— 后端规则是「**一次只允许一个进行中的计划**」。

### curl

```bash
curl -s "$BASE_URL/api/v1/training/enroll" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"planId":1}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `planId` | number | ✔ | 来自 `listTrainingPlans` 的 `[].id` |
| `userId` | string | ✖ | ⚠️ **死参数 —— 后端忽略**（越权修复）。传了无害，但别据此断言身份 |
| `Authorization` / `X-Env` | header | ✔ | |

### 响应 `data`（**两条分支**）

**成功路径**：

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `ok` | boolean | 否 | `true` |
| `created` | number | 否 | **仅成功路径有** —— 创建的任务数 |
| `reminders` | number | 否 | **仅成功路径有** —— 创建的提醒数 |
| `enrollmentId` | number | 否 | 后端返回第 4 键，前端未声明（不消费无影响） |

**失败路径**（⚠️ HTTP 200 + code `"0"`）：

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `ok` | boolean | 否 | `false` |
| `reason` | string | 否 | 已知取值：`"计划不存在或未发布"` · `"已有进行中的计划（一次一个）"` |
| ~~`created`~~ / ~~`reminders`~~ | — | — | **失败路径不返回这两个键**（前端 TS 声明为必填 `number` → 读到 `undefined`） |

> 🔴 **前端必做**：**await 之后先查 `res.data.ok`**，再读 `created`。
> 直接读 `er.created` 会得到 `undefined`；直接按 HTTP/code 判成功会**误报报名成功**。
> `reason` 可直接 toast（中文文案后端已给）。

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40406002` | 401 | 未登录 / token 无效 | 走拦截器 401 分档 |
| `40406001` | 400 | `planId` 缺失（`body.get("planId", 0)` → 0） | 检查调用点 |
| `40406005` | 400 | 计划已下架（PLAN\_ARCHIVED） | toast「计划已下架」+ 刷新列表 |
| — | **200** | **计划冲突 / 计划不存在** | ⚠️ **不是错误码** —— 查 `data.ok === false` + `data.reason` |

***

## listMyTasks

**一句话**：我的打卡任务列表（按计划日排序）—— 计划页 / 日历页。

### curl

```bash
curl -s "$BASE_URL/api/v1/training/tasks?cacheTime=$(date +%s)" \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS"
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `user_id` (query) | string | ✖ | ⚠️ **死参数 —— 后端忽略**（身份以 token 为准）。前端目前传本地 storage uid |
| `cacheTime` (query) | number | ✖ | 前端自动附加（防缓存击穿），后端忽略 |
| `Authorization` / `X-Env` | header | ✔ | |

### 响应 `data` —— **顶层是数组**

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `[].id` | number | 否 | int —— 打卡时回传 |
| `[].weekIndex` / `[].sessionIndex` | number | 否 | int，周序 / 课序 |
| `[].plannedDate` | string | 否 | `'YYYY-MM-DD'` |
| `[].kind` | string | 否 | 来自所属 `plan.kind` |
| `[].status` | string | 否 | 实际只产生 `'pending'` | `'done'` —— **`'missed'` 后端无生成逻辑**（前端类型更宽 ✓） |
| `[].completeSource` | string | null | 是 | `manual` | `auto_sport` |

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40406002` | 401 | 未登录 / token 无效 | 走拦截器 401 分档 |

***

## checkinTask

**一句话**：任务打卡（幂等：已完成直接返回 `status='done'`）。

> 🔴 **坑位**：task 不存在 / 非本人时返回 **`data: null`（HTTP 200，不报错不 404）**。
> 前端现状是 await 后**本地**把 `task.status = 'done'` → **null 时显示「打卡成功」= 假成功**。

### curl

```bash
curl -s "$BASE_URL/api/v1/training/task/checkin" \
  -H 'Content-Type: application/json' \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS" \
  -d '{"taskId":1}'
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `taskId` | number | ✔ | 来自 `listMyTasks` 的 `[].id` |
| `userId` | string | ✖ | ⚠️ **死参数 —— 后端忽略** |
| `Authorization` / `X-Env` | header | ✔ | |

### 响应 `data`

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| （整个 `data`） | object | **null** | **是** | 🔴 **null = task 不存在或非本人** |
| `taskId` | number | 是 | 回显 |
| `status` | string | 是 | `'done'` |

> 🔴 **前端必做**：`if (!res.data) { /* 打卡失败，别置本地 done */ }`。
> 正常路径 `taskId` 来自任务列表，不易触发；但**任何"离线补打卡"/"从通知进打卡"的场景都会踩**。

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| — | **200** | **task 不存在 / 非本人** | ⚠️ **不是错误码** —— **判 `data === null`**，勿本地置 done |
| `40406002` | 401 | 未登录 | 走拦截器 401 分档 |
| `40406004` | 404 | 计划/任务不存在（NOT\_FOUND） | toast + 刷新任务列表 |

***

## listMyReminders

**一句话**：我的提醒列表（App 内提醒）—— 计划页提醒区块。

### curl

```bash
curl -s "$BASE_URL/api/v1/training/reminders?cacheTime=$(date +%s)" \
  -H 'X-Env: Prd' \
  -H "Authorization: Bearer $ACCESS"
```

### 请求

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `user_id` (query) | string | ✖ | ⚠️ 死参数（同 tasks） |
| `Authorization` / `X-Env` | header | ✔ | |

### 响应 `data` —— **顶层是数组**

| 字段 | 类型 | 可空 | 说明 |
|---|---|---|---|
| `[].id` | number | 否 | int |
| `[].taskId` | number | 否 | 关联任务 |
| `[].remindAt` | string ISO | 否 | 提醒时刻 |
| `[].status` | string | 否 | `pending` 等 |

### 错误码

| 业务码 | HTTP | 触发条件 | 前端建议处置 |
|---|---|---|---|
| `40406002` | 401 | 未登录 | 走拦截器 401 分档 |

***

## 本页合同要点速记

| 要点 | 值 |
|---|---|
| 身份来源 | **token**（`body.userId` / `?user_id` 全是死参数） |
| 业务失败表达 | `enroll` → `data.ok=false`；`checkin` → `data=null`（**都不是错误码**） |
| 列表响应形态 | `plans` / `tasks` / `reminders` 顶层**都是数组**（直接 `res.data.map`） |
| 缺 `missed` | `tasks[].status` 后端只产 `pending` / `done` |
