---
url: /docs/guide/uniapp/api/sport-records.md
---
# 运动记录 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 | 参与出图的轨迹点数 |

### ⚠️ 必读四条

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 秒，别阻塞详情数据渲染）。

***

## 错误码

| 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](/docs/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](/api-docs/v1/device/) — 三 action 完整字段与错误码
* 轨迹图专题（卡片参数细节）：[api/sport-record-share](./sport-record-share)
* 跑步会话分享图（另一条线 · `sport` 域）：[api/run-share](./run-share)
* 设备模块手册：[modules/device](/modules/device)
