Skip to content

Training 训练计划(前端调用视角) ​

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

一、域特征(读端点前先看这五条) ​

#特征对前端意味着什么
1REST 协议路径是 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 tokenbody.userId / ?user_id= 一律被后端忽略(2026-09-18 越权修复)
5GET 的 data 被 request 封装转成 queryrequest({ 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[].kindrunning / 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 键

键从哪来必填
planIdlistTrainingPlans() 的 data[].id(数字)✔
userIdstorage.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[].completeSourcemanual(手动打卡)/ 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 键

键从哪来必填
taskIdlistMyTasks() 的 data[].id(数字)✔
userIdstorage.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[].remindAtISO 时刻字符串
data[].statuspending 等

失败时的前端动作

情形前端动作
任意失败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/planslistTrainingPlans()公开数组HTTP 4xx/5xx
POST /api/v1/training/enrollenrollTrainingPlan()access对象(ok 两分支)HTTP 200 + ok:false 或 4xx
GET /api/v1/training/taskslistMyTasks(uid)access数组HTTP 4xx
POST /api/v1/training/task/checkincheckinTask()access对象 或 nullHTTP 200 + data:null 或 4xx
GET /api/v1/training/reminderslistMyReminders(uid)access数组HTTP 4xx
POST /api/v1/training/leave未封装access{ok}(未审计)未审计

字段表 / 类型 / 可空 / 错误码码值 / curl → 手册 · training。 本页与手册的分工边界 → api/ 总览。