Appearance
Training 训练计划(前端调用视角)
契约权威 → 手册 · training:字段表 / 类型 / 可空 / 错误码码值 / curl 一律在那,本页零复述。 本页回答:哪个页面在什么时机调、响应从哪一层读、失败时前端做什么。 封装
src/api/training.ts(api 层 6 文件之一)· 消费页 pages/my-plan · 向导 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。
二、端点明细
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。
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
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。 联调时以 手册 为准,注释待修。 - 报名后必须重拉列表:
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。
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
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/ 总览。