Appearance
运动记录 API 三件套(列表 / 详情 / 轨迹图)— 接口说明 + 调用 + 真实样例
路由:
POST /api/v1/device(action 协议:{"action":"...", "payload":{...}}) action:sportRecords(列表)·sportRecordDetail(详情)·sportRecordShareImage(轨迹图) 鉴权:Bearer(未登录40403002) 错误码段:40403xxx 后端契约权威:src/app/device/interface/api/device_actions.py·application/usecase/sport_records.py·sport_record_share.py⭐ 本文所有样例均为生产真实数据(账号
u000023· 青沐手环 · 2026-10-07 记录), 非手写示意 —— 可直接拿字段名与数值对拍。
一句话
「运动数据页 → 点记录 → 详情页」是一条三接口链:
运动数据页(列表)
└ sportRecords → 按月分组 + 累计统计
↓ 点某条记录(拿列表项 id)
详情页
├ sportRecordDetail → 数据 + 轨迹点 + 六曲线 + 区间/分段
└ sportRecordShareImage → 轨迹图 URL(后端出图,前端 <image> 直显)三个 action 同端点、同鉴权、同一份错误码段 —— 前端按 action 分发即可。
通用约定
| 项 | 值 |
|---|---|
| 端点 | POST https://api.qingmulife.cn/api/v1/device |
| 请求头 | Content-Type: application/json + Authorization: Bearer <access_token> + X-Env: Prd |
| 响应包装 | {"code": "0", "msg": "ok", "data": {...}} — 业务字段一律在 data 之下 |
| 记录 id | 列表项 id(= externalId),详情与轨迹图都回传它 |
⚠️
X-Env必带(Dev/Prd,大小写不敏感)。缺省 = Dev 库 —— 生产用户不带该头 → 查的是测试库 → 永远空列表(2026-09-22 真机事故根因)。 Prd 用户必须带Prd。
① sportRecords — 运动记录列表
请求
json
{ "action": "sportRecords", "payload": {} }无入参。
响应(生产真实 · u000023 · 24 条记录)
json
{
"code": "0", "msg": "ok",
"data": {
"total": 24,
"summary": {
"totalDistanceKm": 109.21,
"totalCalories": 9054,
"totalCount": 24,
"totalDurationSec": 43343
},
"groups": [
{
"month": "2026年10月",
"summary": "54.80 公里 4,544 千卡",
"records": [
{
"id": "qmb-2026-10-07-1",
"type": "户外跑步",
"symbol": "跑",
"time": "10/7 下午8:00",
"value": "4.57",
"unit": "公里",
"detail": "用时 32:42 平均心率 123",
"calories": 379,
"distanceKm": 4.57,
"durationSec": 1962,
"avgHr": 123,
"startTime": "2026-10-07T12:00:00"
}
// …同月其余 11 条(按时间倒序)
]
}
// { "month": "2026年9月", … 9 条 }, { "month": "2026年8月", … 3 条 }
]
}
}字段表
summary(页面顶部累计)
| 字段 | 类型 | 说明 |
|---|---|---|
totalDistanceKm | number | 累计里程(公里,2 位小数) |
totalCalories | int | 累计卡路里 |
totalDurationSec | int | 累计运动用时(秒)— 列表页「累计运动用时」用此字段(此前显示 -- 即缺它) |
totalCount | int | 记录条数 |
groups[](按月分组)
| 字段 | 类型 | 说明 |
|---|---|---|
month | string | "2026年10月"(东八区月) |
summary | string | 组内小计文案(已格式化:「54.80 公里 4,544 千卡」,全角空格分隔) |
records[] | array | 该月记录(时间倒序),字段见下 |
records[](列表项)
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 记录 id — 详情/轨迹图回传此值 |
type | string | 中文类型:户外跑步 / 户外步行 / 户外骑行 / 其他运动 |
symbol | string | 徽标单字:跑 / 走 / 骑 / 动 |
time | string | 展示时间 "10/7 下午8:00"(12 小时制) |
value / unit | string | 主数值 + 单位(当前恒 公里) |
detail | string | 副行文案「用时 32:42 平均心率 123」 |
calories | int | null | 卡路里 |
distanceKm | number | 结构化里程(聚合计算用) |
durationSec | int | 结构化时长(秒) |
avgHr | int | null | 平均心率 |
startTime | string | ISO 时间(naive UTC,如 2026-10-07T12:00:00)— 前端需自行转本地时区 |
💡 结构化四件套(
distanceKm/durationSec/avgHr/startTime)是 2026-09-23 新增 —— 此前前端只能解析detail字符串做聚合。「运动数据页」汇总计算用这四个,别解析文案。
② sportRecordDetail — 单条运动详情
请求
json
{ "action": "sportRecordDetail", "payload": { "externalId": "qmb-2026-10-07-1" } }| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
externalId | string | ✅ | 列表项 id 原样回传 |
响应(生产真实 · 同一条记录,截断展示)
json
{
"code": "0", "msg": "ok",
"data": {
"record": {
"id": "qmb-2026-10-07-1",
"type": "户外跑步",
"symbol": "跑",
"time": "10/7 下午8:00",
"durationText": "32:42",
"durationSec": 1962,
"distanceKm": 4.57,
"calories": 379,
"avgHr": 123
},
"track": [
[28.228019, 112.943245, 1791374400000],
[28.229, 112.944148, 1791374454500],
[28.230323, 112.944603, 1791374509000]
// …共 36 个点,[纬度, 经度, 毫秒时间戳]
],
"trackCenter": { "latitude": 28.228209, "longitude": 112.938809 },
"vendor": "qingmu_band",
"detail": {
"metrics": {
"avgHr": 104, "maxHr": 125, "steps": 2807,
"avgStride": 73, "maxStride": 78,
"avgCadence": 88, "maxCadence": 94,
"avgPaceSec": 426, "maxPaceSec": 369,
"totalAscent": 78, "totalDescent": 78,
"maxElevation": 70, "minElevation": 44,
"totalCalories": 530,
"hrRecoveryStart": 12, "hrRecoveryEnd": 15, "hrRecovery": -3.0
},
"series": {
"pace": [437, 393, 387, 370, 438, 441, 482, 369, 399, 471, 429, 443],
"heart": [27, 55, 80, 103, 121, 121, 119, 123, 124, 125, 119, 122],
"stride": [ /* 逐分钟步幅 */ ],
"cadence": [ /* 逐分钟步频 */ ],
"recovery": [ /* 逐分钟恢复心率 */ ],
"elevation": [ /* 逐分钟海拔 */ ]
},
"zones": [
{ "time": "00:07:09", "label": "热身", "percent": 22 },
{ "time": "00:02:03", "label": "燃脂", "percent": 6 },
{ "time": "00:15:20", "label": "有氧", "percent": 47 },
{ "time": "00:08:10", "label": "无氧", "percent": 25 }
],
"laps": [
{ "label": "<1", "value": "7'49\"", "percent": 79 },
{ "label": "<2", "value": "6'28\"", "percent": 95 },
{ "label": "<3", "value": "7'02\"", "percent": 87 },
{ "label": "<4", "value": "6'26\"", "percent": 96 }
]
}
}
}字段表
顶层
| 字段 | 类型 | 说明 |
|---|---|---|
record | object | 概要(同上表结构 + vendor/sourceLabel 来源标识 — 2026-10-10 增) |
track | array | 轨迹点 [[lat, lng, ts_ms], …](清洗抽稀后)— 地图 polyline 直接用;打卡类记录为 null |
trackCenter | object | null | 轨迹中心(纬度均值/经度均值,6 位小数)— 地图初始镜头用;无轨迹时 null |
vendor | string | 数据来源(app_run="App 记录" / qingmu_band / honor / xiaomi / zepp / default_template) |
location | object | null | 开跑位置(v3 — App 记录有,随 startRun 上报落库):{province, city, area, address, lng, lat} —— 详情地址条数据源;同时是轨迹图卡片地址行的兜底(调 sportRecordShareImage 可不传 location) |
imageUrl | string | null | ⭐ 轨迹图地址(2026-10-10 增):https://cos-cdn.qingmulife.cn/share/sport/{externalId}.png —— 详情页可直接 <image :src> 显示(未生成时地址也在,配合 imageReady 判断) |
imageReady | boolean | 图是否已生成(服务端 COS 探测):true → 直接显示;false → 显示占位图并调 sportRecordShareImage 现生成(或加 onerror 兜底) |
detail | object | 富详情(六曲线 + 指标 + 区间 + 分段) |
⭐ App 跑步记录(
app_run)的 metrics 特有键(清洗管线自述 — 2026-10-10):reportedDistanceM(上报口径)/cleanedDistanceM(去漂移披露)/removedWildPoints(剔除野点数)/simplifiedFrom→simplifiedTo(DP 抽稀前后点数)。设备记录无这些键(诚实缺席)。
detail.metrics(汇总指标 — 数值全为运动全程聚合)
| 键 | 含义 | 键 | 含义 |
|---|---|---|---|
avgHr / maxHr | 平均 / 最大心率 | avgPaceSec / maxPaceSec | 平均 / 最快配速(秒/公里) |
steps | 步数 | avgStride / maxStride | 平均 / 最大步幅(cm) |
avgCadence / maxCadence | 平均 / 最大步频 | totalAscent / totalDescent | 累计爬升 / 下降(米) |
maxElevation / minElevation | 最高 / 最低海拔 | totalCalories | 卡路里(运动全程口径) |
hrRecoveryStart / hrRecoveryEnd / hrRecovery | 心率恢复(起 / 止 / 差值) |
detail.series(六条逐分钟曲线)
| 键 | 含义 | 单位 | 绘图 |
|---|---|---|---|
pace | 配速 | 秒/公里 | 折线(注意 Y 轴反向:越小越快) |
heart | 心率 | bpm | 折线 |
stride | 步幅 | cm | 折线 |
cadence | 步频 | 步/分 | 折线 |
recovery | 恢复心率 | bpm | 折线 |
elevation | 海拔 | 米 | 面积图 |
每条是纯数字数组(逐分钟采样,第 i 个 = 第 i 分钟);长度 ≈ 运动分钟数 (样例 32 分钟 → 32 个值),与
track点数(GPS 采样,36 个)不是一一对应 —— 分开画。
detail.zones[](心率区间 — Karvonen 法)
| 字段 | 说明 |
|---|---|
label | 区间名:热身 / 燃脂 / 有氧 / 无氧 |
time | 该区间时长 "00:15:20" |
percent | 占全程百分比 |
detail.laps[](每公里分段)
| 字段 | 说明 |
|---|---|
label | 第几公里 "<1" / "<2" … |
value | 该公里配速 "7'49\"" |
percent | 相对最快分段的比例(bar 宽度用) |
⚠️
detail四键恒存在,但 v1 旧数据可能为空态(metrics: {}/series: {}/zones: []/laps: [])—— 前端画图前判空,别假设有值。
③ sportRecordShareImage — 轨迹图(详情页地图区数据源)
请求
json
{
"action": "sportRecordShareImage",
"payload": {
"externalId": "qmb-2026-10-07-1",
"name": "周日夜跑 · 麓松路沿线",
"location": {
"provice": "湖南省", "city": "长沙市", "area": "岳麓区",
"address": "麓松路沿线", "lng": "112.9388", "lat": "28.2282"
}
}
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
externalId | string | ✅ | 列表项 id 原样回传 |
name | string | ⬜ | 记录名(卡片首行);不给 → 该行不显示 |
location | object | ⬜ | 位置结构;给了 → 卡片显示地址行 |
location.provice | string | ⬜ | 省(⚠️ 沿用前端拼写;后端兼容 province 别名) |
location.city / .area / .address | string | ⬜ | 市 / 区 / 详址(拼接:省 市 区·详址) |
location.lng / .lat | string | ⬜ | 经纬度(当前不参与出图,保留透传) |
响应
json
{
"code": "0", "msg": "ok",
"data": {
"imageUrl": "https://cos-cdn.qingmulife.cn/share/sport/qmb-2026-10-07-1.png",
"pointCount": 36
}
}| 字段 | 类型 | 说明 |
|---|---|---|
imageUrl | string | 图片 CDN 地址(1200×1600 PNG:高德底图 + 红色轨迹线 + 起终点圆点 + 底部数据卡片) |
pointCount | int | 参与出图的轨迹点数 |
⚠️ 必读四条
- 幂等可缓存 — 同
externalId恒返回同一 URL(COS 覆盖写);可放心缓存本地。 - 无轨迹有明确空态 — 打卡类记录 →
40403009「该记录无轨迹数据」→ 显示「本次运动未记录轨迹」,不要重试。 - 生成失败可重试 —
40403008(502)— 给「重试」按钮(幂等无害)。 - 首次生成 1–3 秒(拉高德底图 + 传 COS)→ 给 loading 态;CDN 有缓存 —— 记录更新后需刷图时给 URL 加时间戳参数(
?t=<ts>)。
调用样例
原生 uni.request(可直接粘贴)
js
// 公共请求封装 —— 三个 action 共用
const BASE = 'https://api.qingmulife.cn'
function callDevice(action, payload, token) {
return new Promise((resolve, reject) => {
uni.request({
url: `${BASE}/api/v1/device`,
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Env': 'Prd' // ⚠️ 必带:Prd=生产库 / Dev=测试库;缺省=Dev
},
data: { action, payload },
success: (res) => {
const body = res.data || {}
if (String(body.code) === '0') return resolve(body.data)
reject(body) // 业务失败:body.code / body.msg
},
fail: reject
})
})
}
// ① 列表 —— 运动数据页
const list = await callDevice('sportRecords', {}, token)
list.summary.totalDurationSec // → 43343(累计用时,秒)
list.groups[0].records[0].id // → 'qmb-2026-10-07-1'(拿去查详情)
// ② 详情 —— 详情页
const detail = await callDevice('sportRecordDetail', { externalId: id }, token)
detail.record.distanceKm // → 4.57
detail.track // → [[28.228019, 112.943245, 1791374400000], …]
detail.trackCenter // → { latitude: 28.228209, longitude: 112.938809 }
detail.detail.series.heart // → [27, 55, 80, …](心率曲线)
detail.detail.zones // → [{ label: '热身', time: '00:07:09', percent: 22 }, …]
// ③ 轨迹图 —— 详情页地图区(后端出图,前端 <image> 直显)
try {
const img = await callDevice('sportRecordShareImage',
{ externalId: id, name: '周日夜跑 · 麓松路沿线', location: currentLocation },
token)
// <image :src="img.imageUrl" mode="widthFix" />
} catch (e) {
if (e.code === '40403009') showEmpty('本次运动未记录轨迹') // 空态,勿重试
else if (e.code === '40403008') showRetry('轨迹图生成失败') // 可重试
}curl(后端自测用)
bash
curl -X POST https://api.qingmulife.cn/api/v1/device \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access_token>" \
-H "X-Env: Prd" \
-d '{"action":"sportRecords","payload":{}}'推荐调用时序(详情页)
进入详情页
→ sportRecordDetail {externalId} ← 页面数据(距离/配速/心率曲线/区间/分段)
→ sportRecordShareImage {externalId, name?, location?}
├─ code=0 → <image :src="imageUrl">(轨迹图区)
├─ 40403009 → 「本次运动未记录轨迹」空态
└─ 40403008 → 「生成失败」+ 重试两个请求并行发更好(轨迹图生成 1–3 秒,别阻塞详情数据渲染)。
错误码
| code | HTTP | 场景 | 前端处理 |
|---|---|---|---|
40403001 | 400 | payload 校验失败 / unknown action | 检查传参 |
40403002 | 401 | 未登录 | 跳登录 |
40403004 | 404 | 记录不存在(或非本人 —— 跨用户查不到) | 提示记录不存在 |
40403008 | 502 | 轨迹图生成失败(高德/COS) | 可重试按钮 |
40403009 | 400 | 轨迹图:该记录无轨迹数据(打卡类) | 「未记录轨迹」空态,勿重试 |
常见坑(真机联调实录)
| 坑 | 现象 | 正解 |
|---|---|---|
丢了 X-Env | 列表永远空、累计全 0 | 生产必带 X-Env: Prd(缺省按 Dev 库查) |
startTime 当本地时间用 | 显示差 8 小时 | startTime 是 naive UTC —— 前端 +8 再格式化 |
解析 detail 字符串做聚合 | 改版即碎 | 用结构化四件套(distanceKm/durationSec/avgHr/startTime) |
series 与 track 混用索引 | 曲线对不上地图 | 两者采样率不同(逐分钟 vs GPS 点),各自独立画 |
| 空态当异常 | v1 旧记录 detail.series 为 {} → 画图报错 | 画前判空,显示「暂无曲线数据」 |
| 轨迹图当实时接口刷 | 重复请求慢 | 幂等 URL —— 拉到后本地缓存 |
2026-10-10 运动数据闭环增强(v3)
📐 完整方案设计(前端讨论版 v4 · 实施定稿):sport-loop-design.html ——时序图 / 契约对照 / 兼容矩阵 / 常见坑,一页讲透(可直接转发前端同事)
App 跑步(startRun → submitRun)提交后自动出现在本列表(vendor='app_run'):
| 增强点 | 说明 |
|---|---|
records[].vendor + sourceLabel | 数据来源标识("App 记录"/"青沐手环",后端统一文案)—— 纯增量,不接不碍事 |
summary.bySource | {app:{...}, device:{...}} 分类小计(混合主口径不变) |
| 详情曲线 | App 跑步记录的 detail.series.pace(逐分钟配速)+ laps(每公里分段)后端现算;心率/海拔等无源曲线诚实空态 |
详情 location | 开始运动上报的位置随记录落库 —— 地址条自动填充;轨迹图接口可不传 location |
distance 主口径 | 列表/详情距离 = 前端上报口径(与跑步时实时显示一致);cleanedDistanceKm 披露去漂移值 |
新增姊妹接口(闭环配套):
myActiveRun(sport 域,零 payload)→{sessionId, startedAt, activityType} | null—— App 重进恢复现场(有活跃会话弹「继续上次运动」)sportRecordDelete(device 域)→{externalId}→{deleted: true}—— 仅 App 记录可删(手环记录40403011提示去设备侧删);清理含轨迹点、 原始留档与 COS 轨迹图(公开 URL 一并焊掉);重删返回40403004(终态一致, 前端把 404 视同删除成功)
相关
- 后端契约权威:API Docs → device — 三 action 完整字段与错误码
- 轨迹图专题(卡片参数细节):api/sport-record-share
- 跑步会话分享图(另一条线 ·
sport域):api/run-share - 设备模块手册:modules/device