Article

2026-06-30-襷の系譜-选手关系-v0.1-技术方案

个人项目

襷の系譜 选手关系 V0.1 技术方案

日期:2026-06-30

关联文档:

1. 目标

本方案用于落地 选手关系 V0.1

目标不是建设完整关系系统,而是在当前站点架构内,以最小改动上线一个稳定、可缓存、可迭代的选手关系模块。

本方案固定实现以下能力:

  1. 在选手页提供 与他关系最深的选手 汇总模块
  2. 汇总 3 类关系来源:
    • 直接对抗
    • 并肩作战
    • 同阶段高频相遇
  3. 使用现有事实数据推导关系,不新增核心关系业务表
  4. 使用 预计算 + 懒计算 + 缓存表 完成第一版性能闭环

2. 现状判断

当前项目已有以下基础:

  1. Person
  2. Membership
  3. RaceResult
  4. Race
  5. CompetitionEdition
  6. Competition

当前选手页已经具备一个轻量关系模块雏形:

  1. 基于同校 Membership 查找关联人物
  2. 基于相同 raceId 查找同场人物
  3. 在页面请求中实时查询并拼接标签

当前限制也很明确:

  1. 关系计算完全在页面实时执行
  2. 没有可复用的关系缓存层
  3. 没有关系汇总排序逻辑
  4. 没有区分核心选手预热与普通选手懒计算
  5. 当前“同场”只停留在简单结果数统计,没有形成稳定关系摘要

因此第一版技术重点不是“再查更多数据”,而是把关系从“页面临时拼接”升级为“可复用的派生结果”。

3. 设计原则

本方案固定采用以下原则:

3.1 不改主事实模型

第一版不新增核心业务关系表,不改变当前主数据模型的职责。

关系是派生结果,不是源事实。

3.2 结果缓存,不缓存原始事实

关系缓存只保存:

  1. 汇总后的相关选手列表
  2. 每个选手的理由摘要
  3. 用于排序的内部结果
  4. 基础元信息

不复制整份比赛明细到缓存层。

3.3 页面读取缓存,缓存缺失时触发计算

选手页不再直接承担完整关系计算逻辑。

页面只做两件事:

  1. 读取缓存
  2. 缓存未命中时触发补算,并读取结果

3.4 第一版固定采用缓存表,不引入额外缓存基础设施

当前项目没有线上 Redis、队列、KV 等现成基础设施,也还没有正式选定缓存技术栈。

因此第一版缓存技术选型直接固定为:

  1. Postgres 缓存表 作为主缓存方案
  2. 不引入 Redis、Valkey、KV 等外部缓存服务
  3. 不使用 Next.js cache 作为业务主缓存
  4. 不新增复杂任务系统

第一版采用:

  1. 数据库缓存表
  2. 脚本预计算
  3. 页面访问触发的同步懒计算

先把闭环跑通,后续访问规模上来后,再评估是否升级到 Redis / Queue。

4. 总体架构

第一版关系链路固定如下:

  1. 数据源:

    • Person
    • Membership
    • RaceResult
    • Race
    • CompetitionEdition
    • Competition
  2. 关系计算层:

    • 直接对抗计算器
    • 并肩作战计算器
    • 同阶段高频相遇计算器
    • 汇总排序器
  3. 缓存层:

    • 按选手维度写入 Postgres 缓存表,持久化关系汇总结果
  4. 页面读取层:

    • 选手页读取关系缓存并展示
  5. 预热层:

    • maintenance script 批量预计算核心选手

5. 数据源定义

第一版关系计算只依赖当前已有事实数据。

5.1 直接对抗数据源

使用:

  • RaceResult.personId
  • RaceResult.raceId
  • Race
  • CompetitionEdition
  • Competition

判定规则固定为:

两名选手存在同一个 raceIdRaceResult,即为一次直接对抗。

这与 PRD 中“同一赛事、同一项目、同一组”完全一致,因为当前模型中的 Race 已经承载这个语义。

5.2 并肩作战数据源

使用:

  • Membership
  • Organization
  • RaceResult

判定依赖两类事实:

  1. 是否同组织且时间重叠
  2. 是否同组织且共同参加过同一届重要赛事

5.3 同阶段高频相遇数据源

使用:

  • RaceResult
  • Race
  • CompetitionEdition
  • Competition
  • Membership

判定目标不是“同一 race”,而是“长期出现在同一阶段的重要赛事语境中”。

第一版只做轻量规则,不做复杂聚类。

6. 缓存模型

虽然第一版不新增核心业务表,但需要新增一个 关系缓存表 作为派生结果存储。

这是缓存层,不是事实层。

本版本缓存主方案已经确定为:

使用 Postgres 缓存表 存储选手关系派生结果。

6.1 新增表:PlayerRelationCache

建议新增一张轻量持久化缓存表:

model PlayerRelationCache {
  id          String   @id @default(cuid())
  personId    String   @unique
  payload     Json
  generatedAt DateTime @default(now())
  sourceHash  String?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
}

说明:

  1. personId 一人一份汇总缓存
  2. payload 保存前台直接可用的关系结果
  3. generatedAt 用于判断缓存新旧
  4. sourceHash 用于后续做内容变更检测,第一版可以先留空或简单实现

这张表的定位是:

  • 页面缓存
  • 派生结果缓存
  • 未来可反复覆盖重算

它不是主关系表,也不承载源事实。

6.2 payload 结构

payload 建议固定为:

type PlayerRelationCachePayload = {
  personId: string;
  generatedAt: string;
  topRelations: Array<{
    relatedPersonId: string;
    relationScore: number;
    reasons: Array<{
      type: "direct_matchup" | "teammate_overlap" | "frequent_same_stage";
      label: string;
      priority: number;
    }>;
    summary: {
      directMatchupCount: number;
      teammateOverlapYears: number | null;
      sameStageMeetCount: number;
    };
  }>;
};

说明:

  1. relationScore 仅内部使用,不在前台展示
  2. reasons 是前台直接渲染的短标签来源
  3. summary 用于后续继续扩展详情,不要求第一版完整展示

7. 关系计算规则

第一版关系计算采用固定规则,不开放配置化。

7.1 直接对抗计算

7.1.1 基础算法

输入:某选手的全部 RaceResult

过程:

  1. 取出该选手所有 raceId
  2. 查询这些 raceId 下的全部其他选手 RaceResult
  3. relatedPersonId 聚合
  4. 统计同 raceId 对位次数
  5. 提取对应赛事名称、届次、阶段信息

输出:

  • 每个相关选手的直接对抗次数
  • 最有代表性的 1 到 2 条事实摘要

7.1.2 第一版展示标签规则

标签按以下优先级生成:

  1. 驿传同区对抗次数
  2. 跨阶段总直接对抗次数
  3. 典型赛事名称

示例:

  • 驿传中 3 次同区直接对抗
  • 高中到大学共 5 次同场直接对位
  • 在箱根与出云中多次正面相遇

7.1.3 分值规则

建议内部得分:

  • 每次直接对抗:+10
  • 若赛事类型属于驿传:额外 +5
  • 若覆盖两个及以上生涯阶段:额外 +8

这是内部排序规则,只用于排序。

7.2 并肩作战计算

7.2.1 基础算法

输入:某选手的全部 Membership

过程:

  1. 找出该选手所有大学、高中、实业团所属
  2. 查询同组织的其他选手 Membership
  3. 判断是否存在时间重叠
  4. 补查是否共同参加过同届驿传或同阶段重要赛事

输出:

  • 同组织且时间重叠的相关选手
  • 同组织且共同出场的相关选手

7.2.2 第一版展示标签规则

优先生成以下标签:

  • 大学时期 4 年同队
  • 101 回、102 回连续共同出场
  • 同一时期共同承担队伍主力区间

7.2.3 分值规则

建议内部得分:

  • 同组织时间重叠:+8
  • 重叠年份每增加 1 年:+2
  • 同届重要驿传共同出场:每次 +4

7.3 同阶段高频相遇计算

7.3.1 基础算法

输入:某选手的全部 RaceResultMembership

过程:

  1. 按生涯阶段切分赛事记录:
    • 高中
    • 大学
    • 实业团
  2. 查找同阶段、同类重点赛事中重复出现的其他选手
  3. 排除已由“直接对抗”强覆盖的重复关系

输出:

  • 作为补充的“长期同阶段人物”

7.3.2 第一版展示标签规则

  • 大学时期多次在全国大赛相遇
  • 同年代长期出现在同一舞台

7.3.3 分值规则

建议内部得分:

  • 同阶段重要赛事重复出现一次:+3
  • 同阶段跨多年持续出现:额外 +4

8. 汇总排序规则

第一版固定采用统一汇总器。

8.1 合并原则

  1. 同一相关选手在多个关系类型中命中时,合并为一个结果
  2. 分值累加
  3. 理由去重
  4. 最多保留 3 条理由

8.2 排序规则

固定按以下优先级排序:

  1. 总分高者优先
  2. 直接对抗次数高者优先
  3. 并肩作战重叠年份更长者优先
  4. 最近一次关系发生时间更近者优先

8.3 截断规则

前台只输出前 6 名。

缓存中建议保留前 12 名,方便后续扩展“展开更多”。

9. 缓存生成策略

第一版缓存生成固定采用 缓存表 + 双轨计算 模式。

9.1 核心选手预计算

新增脚本:

  • scripts/maintenance/recompute-player-relations.ts

职责:

  1. 接收选手范围
  2. 批量计算关系
  3. 覆盖写入 PlayerRelationCache 缓存表

核心选手范围来源第一版采用静态策略:

  1. 重点收录选手名单
  2. 首页出现频率高的选手
  3. 已导入关键驿传出场较多的选手

第一版不做自动热度计算。

9.2 非核心选手懒计算

选手页读取关系缓存时:

  1. 若缓存存在且有效,直接返回
  2. 若缓存不存在,页面请求中同步计算一次并写入缓存表
  3. 写入后立即返回结果

第一版不做异步任务投递。

原因:

  1. 当前没有队列基础设施
  2. 第一版访问量可控
  3. 先保证逻辑闭环和正确性

9.3 失效策略

第一版采用简单失效规则:

  1. 当某选手的 Membership 有变更时,应重算该选手关系缓存
  2. 当某选手新增或修改 RaceResult 时,应重算该选手关系缓存
  3. 批量导入赛事后,统一运行重算脚本更新重点选手

第一版不做精细级联失效。

也就是说:

  • A 的结果变更后,不强求实时联动重算所有与 A 对抗过的人

这一点接受短期最终一致性。

10. 服务层拆分

为避免把逻辑继续堆在页面文件中,第一版新增独立服务层。

建议新增:

  • src/lib/player-relations/types.ts
  • src/lib/player-relations/build-direct-matchups.ts
  • src/lib/player-relations/build-teammate-relations.ts
  • src/lib/player-relations/build-frequent-stage-relations.ts
  • src/lib/player-relations/build-player-relations.ts
  • src/lib/player-relations/get-player-relations.ts

职责划分:

  1. build-*
    • 纯计算函数
  2. build-player-relations
    • 统一汇总器
  3. get-player-relations
    • 页面读取入口
    • 负责查缓存、补算、回写

11. 页面接入方案

当前选手页已存在一个实时关系块。

第一版改造方式固定如下:

  1. 删除页面内临时拼装 relatedPlayers 的逻辑
  2. 改为调用 getPlayerRelations(personId)
  3. 渲染缓存返回的汇总结果

页面层只负责:

  1. 调用服务
  2. 取出相关选手基础资料
  3. 渲染姓名和理由标签

页面层不再负责关系计算。

12. 数据查询优化

第一版需要控制查询开销。

固定策略如下:

12.1 先按 personId 局部计算

所有关系计算都围绕单个 personId 展开,不做全表扫描式构建。

12.2 先取候选集,再做关系聚合

先根据:

  1. 相同 raceId
  2. 相同 organizationId
  3. 同阶段重要赛事

缩小候选人范围,再生成最终关系。

12.3 页面渲染时只补相关 6 到 12 人的基础资料

缓存中只存 relatedPersonId 和标签摘要。

页面拿到缓存后,再一次性查询这批人的:

  1. slug
  2. displayNameJa
  3. 基础展示字段

避免把人物完整信息冗余进缓存。

13. 国际化策略

第一版缓存中保存 reason type + 中文开发侧 label 即可。

前台正式展示时,建议逐步过渡到:

  1. 缓存中保存结构化理由类型和参数
  2. 页面按 locale 做文案渲染

但为了第一版尽快落地,可以先在缓存中写入稳定短标签字符串,再后续重构。

也就是说:

  • 第一版允许先偏向日文/中文开发语境
  • 只要结构可后续替换即可

14. 上线顺序

本版本开发顺序固定如下:

  1. 新增 PlayerRelationCache 模型并迁移
  2. 建立 player-relations 服务层
  3. 实现直接对抗计算器
  4. 实现并肩作战计算器
  5. 实现同阶段高频相遇计算器
  6. 实现汇总排序器
  7. 实现 getPlayerRelations
  8. 改造选手页接入新服务
  9. 编写预计算脚本
  10. 手工选择一批核心选手做预热验证

其中开发优先级固定为:

  1. 直接对抗
  2. 并肩作战
  3. 同阶段高频相遇

如果工期紧张,允许先上线前两者,再补第三者。

15. 第一版可接受的简化

为了保证第一版按时落地,以下简化是明确允许的:

  1. sourceHash 可先不精确,只保留 generatedAt
  2. 同阶段高频相遇可先使用较朴素规则
  3. 核心选手名单可先人工维护
  4. 懒计算允许同步执行
  5. 缓存失效先采用批量重算和被动更新

这些不是技术债失控,而是第一版有意收敛。

16. 不采用的方案

第一版明确不采用以下方案:

  1. 页面实时全量拼接关系,不做缓存
  2. 一开始就上 Redis / Queue / Worker 体系
  3. 一开始就做全站全量关系图预构建
  4. 为关系事实新增复杂主业务表
  5. 直接输出显性算法分数
  6. 在选手页中主动输出胜负结论

17. 风险与应对

17.1 数据不完整导致关系偏弱

应对:

  1. 先保证直接对抗关系成立
  2. 并肩作战补足非明星选手覆盖
  3. 接受部分普通选手结果较少

17.2 懒计算首访变慢

应对:

  1. 核心选手先预热
  2. 第一版只计算单人局部关系
  3. 限制前台展示人数

17.3 导入后缓存陈旧

应对:

  1. 每次重点赛事导入后手动运行重算脚本
  2. 接受短期最终一致性

18. 结论

本方案的核心结论如下:

  1. 第一版不改主模型,只新增一层轻量关系缓存表
  2. 关系计算从页面内联逻辑升级为独立服务层
  3. 直接对抗是第一优先,其次是并肩作战,再是同阶段高频相遇
  4. 上线策略采用 核心选手预计算 + 非核心选手懒计算
  5. 当前阶段不引入额外中间件,先用 Postgres 缓存表和脚本完成闭环

本技术方案作为 选手关系 V0.1 的实现基线,后续迭代在此基础上增强,不再回退为页面实时拼接方案。