Appearance
ranking —— 多维榜单(C 端 / 3 action)
统一入口:
POST /api/v1/rankingbody ={ "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 |
rawFoodRanking | 生吃食物 | 生吃榜(跨维度扩展) | → §2 |
cityRanking | 城市 + 运动 | 长沙默认城市里程榜 | → §3 |
公共约定
统一信封
- 成功:
{ "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
- ADR-0014(ranking 跨维度扩展):🟢 Internal Wiki
- 前端对接指南(页面流图):🟢 Internal Wiki → 2026-09-29 选择城市 + 生吃排名 前端对接指引