---
url: /docs/api-docs/v1/ranking.md
---
# ranking —— 多维榜单（C 端 / 3 action）

> 统一入口：`POST /api/v1/ranking`
> body = `{ "action": "<3种之一>", "payload": { ... } }`
> 鉴权：**全部 action 需登录** —— `Authorization: Bearer <access_token>`
> 契约权威：`src/app/ranking/interface/api/ranking_actions.py`

ranking 模块定位：**任意维度的榜单**。含三个独立榜单：

* **groupRankingMulti** —— 运动维度（V1 沿用）
* **rawFoodRanking** —— 生吃维度（V2.0.66 新增 — 跨维度扩展决策见 ADR-0014）
* **cityRanking** —— 城市里程维度（V2.0.119 新增 — cityRanking · 城市圈定 + 默认城市长沙）

## 3 个 Action 一览

| Action | 维度 | 触发场景 | 详细 |
|---|---|---|---|
| `groupRankingMulti` | 运动 | 群内多维榜（V1 rankingService.groupRankingMulti） | [→ §1](#1-grouprankingmulti) |
| `rawFoodRanking` | 生吃食物 | 生吃榜（跨维度扩展） | [→ §2](#2-rawfoodranking) |
| `cityRanking` | 城市 + 运动 | 长沙默认城市里程榜 | [→ §3](#3-cityranking) |

## 公共约定

### 统一信封

* **成功**：`{ "code": "0", "msg": "ok", "data": { ... } }`
* **业务失败**：HTTP 200 + `{ "code": "40422xxx", "msg": "<原因>" }`
* **响应 data 全部 camelCase**

### HTTP 状态映射

| 层 | HTTP | 说明 |
|---|---|---|
| 参数校验失败 | 400 | payload 格式 / period / sportType 越界 |
| 未登录 | 401 | `40422002` |
| 资源不存在 | 404 | `40422004` |

### 错误码矩阵（40422xxx · 复用 ranking 段）

| code | 语义 | HTTP |
|---|---|---|
| `40422001` | 请求参数非法 | 400 |
| `40422002` | 未登录 / token 无效 | 401 |
| `40422004` | 资源不存在（如 rawFoodRanking 未装配 / group 不存在） | 404 |

***

## 1. groupRankingMulti

**运动维度** —— 群内多维聚合榜（V1 行为兼容）。

**请求 payload**：

| 字段 | 类型 | 必填 | 默认 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupId` | string | ✅ | — | — | 跑群 ID（V1 兼容字段名） |
| `sportType` | string | ⬜ | `"all"` | `"run"` / `"hike"` / `"ride"` / `"all"` | 运动类型筛选 |
| `period` | string | ⬜ | `"week"` | `"week"` / `"month"` / `"year"` / `"all"` | 统计周期（week=7 / month=30 / year=365 天） |

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `groupId` | string | 群 ID |
| `sportType` | string | 入参回显 |
| `period` | string | 入参回显 |
| `list` | array | 成员聚合结果（最多 100 条） |
| `list[].rank` | int | 名次（1-based） |
| `list[].userId` | string | 用户 ID |
| `list[].nickname` | string | 群内昵称（匿名 → "匿名"） |
| `list[].avatarUrl` | string | null | 头像 |
| `list[].distance` | float | 周期内累计距离 km |
| `list[].checkins` | int | 周期内打卡次数 |
| `myRank` | int | null | 当前用户排名（不在榜 → null） |
| `total` | int | 上榜成员数（≤100） |

**错误码**：`40422001`（sportType/period 越界）· `40422002`（401）

***

## 2. rawFoodRanking

**生吃维度** —— 跨内容榜单（V2.0.66 新增）。50 条手编 seed + D-10 评分公式 + 5min TTL 缓存。

**请求 payload**：

| 字段 | 类型 | 必填 | 默认 | 约束 | 说明 |
|---|---|---|---|---|---|
| `scope` | string | ⬜ | `"national"` | `"national"` / `"city"` | 全国榜 / 城市榜 |
| `limit` | int | ⬜ | `50` | 1~100 | 返回条数（v1 全量 50 条，全量拉满也可） |
| `cityAdcode` | string | ⬜ | — | `^\d{6}$` | **`scope=city` 必传**，6 位城市码（用 `selectCity` 返回的 adcode） |

**响应 data**：

| 字段 | 类型 | 说明 |
|---|---|---|
| `scope` | string | 入参回显 |
| `cityAdcode` | string | null | `scope=city` 时回显；national 恒 `null` |
| `generatedAt` | string | ISO 8601（**5min TTL 刷新** —— 同一进程多次调用命中缓存） |
| `list` | array | 排序后榜单 |

`list[]` 单条结构：

| 字段 | 类型 | 说明 |
|---|---|---|
| `rank` | int | 名次（1-based，按 `rawBenefitScore` 降序，并列时按 name） |
| `foodId` | string | 食物 ID（`rf-001` ~ `rf-050`） |
| `name` | string | 中文名 |
| `category` | string | 分类（vegetable / fruit / nut / fish / etc.） |
| `rawBenefitScore` | int | **生吃益处评分 0~100**（见 D-10 公式） |
| `gi` | float | 升糖指数（v1 seed 提供） |
| `suitableFor` | string\[] | 适配人群标签（`diabetes` / `weight_loss` / `athlete` 等） |
| `badge` | string | null | 展示徽标（如「低 GI」/「花青素」），可空 |
| `imageUrl` | string | null | v1 全 null（图标留前端静态映射或 v2 后置） |

### D-10 评分公式（定版权重）

```
gi_score: <55 → 100 / 55-70 → 60 / >70 → 20   # 三档映射，不做线性
raw_benefit_score = gi_score×0.4 + antioxidant×0.5 + freshness×0.1
                  + raw_safe ? 0 : -30         # 安全优先于营养
封顶 100，地板 0
```

**为什么不乘法**：规划 v1 原写「gi\_inverse × antioxidant × freshness 乘法」→ 乘法对 0 值过敏（一个 0 维度直接清零），与编辑直觉不符，v2 改加权加法。

**rawSafe=false 食物**（木薯/四季豆/生蚝/豆芽/紫薯/山药/三文鱼系）在 seed 低分位 + 警示 badge —— **前端无需再过滤**。

### 缓存策略（D-4）

* **5 分钟 TTL**（进程内 monotonic clock）
* `generatedAt` 每次刷新，list 内容在窗口期内稳定
* 进程重启或 5min 后失效时重新计算 seed 评分（50 条内存计算，<1ms）

### 错误码

| code | 场景 |
|---|---|
| `40422001` | scope 越界 / scope=city 缺 cityAdcode / cityAdcode 非 6 位 |
| `40422002` | 未登录（401） |
| `40422004` | **未装配**（工厂未注入 `rawFoodRanking` —— dev/mock 默认装配，生产缺位时返这条） |

### 示例

```json
// 全国榜请求
{ "action": "rawFoodRanking", "payload": { "scope": "national", "limit": 10 } }

// 全国榜响应（节选）
{
  "code": "0", "msg": "ok", "data": {
    "scope": "national", "cityAdcode": null, "generatedAt": "2026-09-29T11:06:48",
    "list": [
      {"rank":1,"foodId":"rf-016","name":"蓝莓","category":"fruit","rawBenefitScore":98,
       "gi":53,"suitableFor":["diabetes","athlete","elderly"],"badge":"花青素","imageUrl":null},
      {"rank":2,"foodId":"rf-009","name":"菠菜","category":"vegetable","rawBenefitScore":94,
       "gi":15,"suitableFor":["diabetes","weight_loss"],"badge":"低 GI","imageUrl":null},
      ...
    ]
  }
}

// 城市榜请求（按广州用户当前城市）
{ "action": "rawFoodRanking", "payload": { "scope": "city", "cityAdcode": "440100", "limit": 20 } }

// 错误示例：scope=city 缺 cityAdcode
{ "action": "rawFoodRanking", "payload": { "scope": "city" } }
// → { "code": "40422001", "msg": "scope=city 需要 6 位 cityAdcode" }
```

***

## 3. cityRanking

**城市 + 运动维度** —— 城市圈定用户（`user_city_binding` JOIN）按打卡里程聚合排榜。
**默认城市长沙**（`cityAdcode` 缺省 = `"430100"`），由 ranking 域内常量配置。

**核心设计点**（钉死，见 plan v1 §决策表）：

| 维度 | 决策 | 理由 |
|---|---|---|
| 入榜资格 | **绑定才入榜** | 数据干净 + 绑定城市动机 |
| 未绑定调用方 | `myRank = null`（语义 1） | 鼓励去绑定城市 → `selectCity` |
| 跨城查看 | **允许** | 公开榜（业界惯例 KEEP 城市榜同款） |
| 周期窗口 | **滚动** month30=近 30 个 CN 自然日 / all=全部历史（不限下界） | 2026-09-30 由 day/week/month30 收敛为两档 |
| 时区口径 | `cn_today()`（aware UTC 缺省基准） | 防 00:00–08:00 CST 周期差一天 |
| 聚合路径 | SQL 一步 JOIN `sport_checkin ⨝ user_city_binding ⨝ "user"` + 5min 缓存 | 千人规模性能 |
| myRank 范围 | 在**全量聚合集**（≤500）内查找 —— 不受 limit 切片影响 | 排第 200 名 limit=50 也正确 |
| 默认 adcode | ranking 域内常量 `"430100"`（不 import city 域） | 换城改一行 |

### 请求 payload

| 字段 | 类型 | 必填 | 默认 | 约束 | 说明 |
|---|---|---|---|---|---|
| `period` | string | ⬜ | `"month30"` | `"month30"` / `"all"` | 周期（近 30 个 CN 自然日 / 全部历史） |
| `cityAdcode` | string | ⬜ | `"430100"` | `^\d{6}$` | 6 位城市码，缺省长沙 |
| `limit` | int | ⬜ | `50` | 1~100 | 返回条数（myRank 仍能在全量集内查） |

### 响应 data

| 字段 | 类型 | 说明 |
|---|---|---|
| `period` | string | 入参回显 |
| `cityAdcode` | string | 入参回显（缺省时为 `"430100"` 长沙） |
| `list` | array | 榜单（最多 limit 条，limit 切片自 ≤500 全量集） |
| `list[].rank` | int | 名次（1-based，按 `distance DESC, userId ASC`） |
| `list[].userId` | string | 用户 ID |
| `list[].nickname` | string | null | 昵称（user 行缺/未填 → null） |
| `list[].avatarUrl` | string | null | 头像 |
| `list[].distance` | float | 周期内累计里程 km（保留 2 位小数） |
| `list[].checkins` | int | 周期内打卡次数 |
| `myRank` | int | null | 当前用户在该城市榜的排名（**未绑定该城 → null**，不在前 500 也 null） |
| `total` | int | 上榜用户总数（全量聚合集大小） |
| `generatedAt` | string | ISO 8601（**5min TTL 刷新**，key=(adcode, period)） |

### 缓存策略

* **5 分钟 TTL**（进程内 monotonic clock；`cityRankingUseCase.ttl_seconds`）
* 缓存 key = `(cityAdcode, period)` —— 不同城市不同周期独立
* `generatedAt` 在窗口期内稳定，窗口失效后重新聚合

### 错误码

| code | 场景 |
|---|---|
| `40422001` | `period` 越界 / `cityAdcode` 非 6 位数字 |
| `40422002` | 未登录（401） |
| `40422004` | **未装配**（工厂未注入 `cityRanking` —— 当前默认装配 PG 实现） |

### 示例

```json
// 默认长沙 + 近 30 天榜（period 缺省即 month30）
{ "action": "cityRanking", "payload": {} }

// 长沙 + 全部历史榜
{ "action": "cityRanking", "payload": { "period": "all" } }

// 长沙 + 近 30 天 + 自定义条数
{ "action": "cityRanking", "payload": { "period": "month30", "limit": 100 } }

// 切换城市看其他城市榜（公开接口）
{ "action": "cityRanking", "payload": { "period": "month30", "cityAdcode": "440100" } }

// 响应示例（节选）
{
  "code": "0", "msg": "ok", "data": {
    "period": "month30",
    "cityAdcode": "430100",
    "list": [
      {"rank":1, "userId":"u-xxx1", "nickname":"跑者甲", "avatarUrl":"https://cdn/a.png",
       "distance": 158.40, "checkins": 12},
      {"rank":2, "userId":"u-xxx2", "nickname":"跑者乙", "avatarUrl":null,
       "distance": 132.50, "checkins": 9}
    ],
    "myRank": 5,
    "total": 42,
    "generatedAt": "2026-09-30T11:06:48"
  }
}

// 错误示例：period 越界
{ "action": "cityRanking", "payload": { "period": "year" } }
// → { "code": "40422001", "msg": "period 越界: year（month30|all）" }
```

***

## 页面 × API 矩阵

| uniapp 页面 / 组件 | 调用的 action | 调用时机 |
|---|---|---|
| **群内排行页** | `groupRankingMulti` | 群详情页进入 |
| **生吃榜页（健康饮食 Tab）** | `rawFoodRanking {scope:"national"}` | Tab 默认加载 |
| **生吃榜页 · 切换到「我的城市」** | `rawFoodRanking {scope:"city", cityAdcode:<getCurrentCity 返回>}` | 用户切换 |
| **城市里程榜页（默认长沙）** | `cityRanking` | Tab 进入；不传参数走默认长沙 |

## 实现参考

* ADR-0013（city 域）：[🟢 Internal Wiki](/internal-wiki/decisions/0013-city-domain)
* ADR-0014（ranking 跨维度扩展）：[🟢 Internal Wiki](/internal-wiki/decisions/0014-ranking-cross-dimension)
* 前端对接指南（页面流图）：[🟢 Internal Wiki → 2026-09-29 选择城市 + 生吃排名 前端对接指引](/internal-wiki/operations/2026-09-29-city-rawfood-frontend-handoff)
