Skip to content

运动记录 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(页面顶部累计)

字段类型说明
totalDistanceKmnumber累计里程(公里,2 位小数)
totalCaloriesint累计卡路里
totalDurationSecint累计运动用时(秒)— 列表页「累计运动用时」用此字段(此前显示 -- 即缺它)
totalCountint记录条数

groups[](按月分组)

字段类型说明
monthstring"2026年10月"(东八区月)
summarystring组内小计文案(已格式化:「54.80 公里 4,544 千卡」,全角空格分隔)
records[]array该月记录(时间倒序),字段见下

records[](列表项)

字段类型说明
idstring记录 id — 详情/轨迹图回传此值
typestring中文类型:户外跑步 / 户外步行 / 户外骑行 / 其他运动
symbolstring徽标单字:跑 / 走 / 骑 / 动
timestring展示时间 "10/7 下午8:00"(12 小时制)
value / unitstring主数值 + 单位(当前恒 公里)
detailstring副行文案「用时 32:42 平均心率 123」
caloriesint | null卡路里
distanceKmnumber结构化里程(聚合计算用)
durationSecint结构化时长(秒)
avgHrint | null平均心率
startTimestringISO 时间(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" } }
字段类型必填说明
externalIdstring✅列表项 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 }
      ]
    }
  }
}

字段表 ​

顶层

字段类型说明
recordobject概要(同上表结构 + vendor/sourceLabel 来源标识 — 2026-10-10 增)
trackarray轨迹点 [[lat, lng, ts_ms], …](清洗抽稀后)— 地图 polyline 直接用;打卡类记录为 null
trackCenterobject | null轨迹中心(纬度均值/经度均值,6 位小数)— 地图初始镜头用;无轨迹时 null
vendorstring数据来源(app_run="App 记录" / qingmu_band / honor / xiaomi / zepp / default_template)
locationobject | null开跑位置(v3 — App 记录有,随 startRun 上报落库):{province, city, area, address, lng, lat} —— 详情地址条数据源;同时是轨迹图卡片地址行的兜底(调 sportRecordShareImage 可不传 location)
imageUrlstring | null⭐ 轨迹图地址(2026-10-10 增):https://cos-cdn.qingmulife.cn/share/sport/{externalId}.png —— 详情页可直接 <image :src> 显示(未生成时地址也在,配合 imageReady 判断)
imageReadyboolean图是否已生成(服务端 COS 探测):true → 直接显示;false → 显示占位图并调 sportRecordShareImage 现生成(或加 onerror 兜底)
detailobject富详情(六曲线 + 指标 + 区间 + 分段)

⭐ 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"
    }
  }
}
字段类型必填说明
externalIdstring✅列表项 id 原样回传
namestring⬜记录名(卡片首行);不给 → 该行不显示
locationobject⬜位置结构;给了 → 卡片显示地址行
location.provicestring⬜省(⚠️ 沿用前端拼写;后端兼容 province 别名)
location.city / .area / .addressstring⬜市 / 区 / 详址(拼接:省 市 区·详址)
location.lng / .latstring⬜经纬度(当前不参与出图,保留透传)

响应 ​

json
{
  "code": "0", "msg": "ok",
  "data": {
    "imageUrl": "https://cos-cdn.qingmulife.cn/share/sport/qmb-2026-10-07-1.png",
    "pointCount": 36
  }
}
字段类型说明
imageUrlstring图片 CDN 地址(1200×1600 PNG:高德底图 + 红色轨迹线 + 起终点圆点 + 底部数据卡片)
pointCountint参与出图的轨迹点数

⚠️ 必读四条 ​

  1. 幂等可缓存 — 同 externalId 恒返回同一 URL(COS 覆盖写);可放心缓存本地。
  2. 无轨迹有明确空态 — 打卡类记录 → 40403009「该记录无轨迹数据」→ 显示「本次运动未记录轨迹」,不要重试。
  3. 生成失败可重试 — 40403008(502)— 给「重试」按钮(幂等无害)。
  4. 首次生成 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 秒,别阻塞详情数据渲染)。


错误码 ​

codeHTTP场景前端处理
40403001400payload 校验失败 / unknown action检查传参
40403002401未登录跳登录
40403004404记录不存在(或非本人 —— 跨用户查不到)提示记录不存在
40403008502轨迹图生成失败(高德/COS)可重试按钮
40403009400轨迹图:该记录无轨迹数据(打卡类)「未记录轨迹」空态,勿重试

常见坑(真机联调实录) ​

坑现象正解
丢了 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 视同删除成功)

相关 ​