Skip to content

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
rawFoodRanking生吃食物生吃榜(跨维度扩展)→ §2
cityRanking城市 + 运动长沙默认城市里程榜→ §3

公共约定 ​

统一信封 ​

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

HTTP 状态映射 ​

层HTTP说明
参数校验失败400payload 格式 / period / sportType 越界
未登录40140422002
资源不存在40440422004

错误码矩阵(40422xxx · 复用 ranking 段) ​

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

1. groupRankingMulti ​

运动维度 —— 群内多维聚合榜(V1 行为兼容)。

请求 payload:

字段类型必填默认约束说明
groupIdstring✅——跑群 ID(V1 兼容字段名)
sportTypestring⬜"all""run" / "hike" / "ride" / "all"运动类型筛选
periodstring⬜"week""week" / "month" / "year" / "all"统计周期(week=7 / month=30 / year=365 天)

响应 data:

字段类型说明
groupIdstring群 ID
sportTypestring入参回显
periodstring入参回显
listarray成员聚合结果(最多 100 条)
list[].rankint名次(1-based)
list[].userIdstring用户 ID
list[].nicknamestring群内昵称(匿名 → "匿名")
list[].avatarUrlstring | null头像
list[].distancefloat周期内累计距离 km
list[].checkinsint周期内打卡次数
myRankint | null当前用户排名(不在榜 → null)
totalint上榜成员数(≤100)

错误码:40422001(sportType/period 越界)· 40422002(401)


2. rawFoodRanking ​

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

请求 payload:

字段类型必填默认约束说明
scopestring⬜"national""national" / "city"全国榜 / 城市榜
limitint⬜501~100返回条数(v1 全量 50 条,全量拉满也可)
cityAdcodestring⬜—^\d{6}$scope=city 必传,6 位城市码(用 selectCity 返回的 adcode)

响应 data:

字段类型说明
scopestring入参回显
cityAdcodestring | nullscope=city 时回显;national 恒 null
generatedAtstringISO 8601(5min TTL 刷新 —— 同一进程多次调用命中缓存)
listarray排序后榜单

list[] 单条结构:

字段类型说明
rankint名次(1-based,按 rawBenefitScore 降序,并列时按 name)
foodIdstring食物 ID(rf-001 ~ rf-050)
namestring中文名
categorystring分类(vegetable / fruit / nut / fish / etc.)
rawBenefitScoreint生吃益处评分 0~100(见 D-10 公式)
gifloat升糖指数(v1 seed 提供)
suitableForstring[]适配人群标签(diabetes / weight_loss / athlete 等)
badgestring | null展示徽标(如「低 GI」/「花青素」),可空
imageUrlstring | nullv1 全 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场景
40422001scope 越界 / 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 也正确
默认 adcoderanking 域内常量 "430100"(不 import city 域)换城改一行

请求 payload ​

字段类型必填默认约束说明
periodstring⬜"month30""month30" / "all"周期(近 30 个 CN 自然日 / 全部历史)
cityAdcodestring⬜"430100"^\d{6}$6 位城市码,缺省长沙
limitint⬜501~100返回条数(myRank 仍能在全量集内查)

响应 data ​

字段类型说明
periodstring入参回显
cityAdcodestring入参回显(缺省时为 "430100" 长沙)
listarray榜单(最多 limit 条,limit 切片自 ≤500 全量集)
list[].rankint名次(1-based,按 distance DESC, userId ASC)
list[].userIdstring用户 ID
list[].nicknamestring | null昵称(user 行缺/未填 → null)
list[].avatarUrlstring | null头像
list[].distancefloat周期内累计里程 km(保留 2 位小数)
list[].checkinsint周期内打卡次数
myRankint | null当前用户在该城市榜的排名(未绑定该城 → null,不在前 500 也 null)
totalint上榜用户总数(全量聚合集大小)
generatedAtstringISO 8601(5min TTL 刷新,key=(adcode, period))

缓存策略 ​

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

错误码 ​

code场景
40422001period 越界 / 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 返回>}用户切换
城市里程榜页(默认长沙)cityRankingTab 进入;不传参数走默认长沙

实现参考 ​