Article
2026-06-30-襷の系譜-选手关系-v0.1-技术方案
襷の系譜 选手关系 V0.1 技术方案
日期:2026-06-30
关联文档:
1. 目标
本方案用于落地 选手关系 V0.1。
目标不是建设完整关系系统,而是在当前站点架构内,以最小改动上线一个稳定、可缓存、可迭代的选手关系模块。
本方案固定实现以下能力:
- 在选手页提供
与他关系最深的选手汇总模块 - 汇总 3 类关系来源:
- 直接对抗
- 并肩作战
- 同阶段高频相遇
- 使用现有事实数据推导关系,不新增核心关系业务表
- 使用
预计算 + 懒计算 + 缓存表完成第一版性能闭环
2. 现状判断
当前项目已有以下基础:
PersonMembershipRaceResultRaceCompetitionEditionCompetition
当前选手页已经具备一个轻量关系模块雏形:
- 基于同校 Membership 查找关联人物
- 基于相同
raceId查找同场人物 - 在页面请求中实时查询并拼接标签
当前限制也很明确:
- 关系计算完全在页面实时执行
- 没有可复用的关系缓存层
- 没有关系汇总排序逻辑
- 没有区分核心选手预热与普通选手懒计算
- 当前“同场”只停留在简单结果数统计,没有形成稳定关系摘要
因此第一版技术重点不是“再查更多数据”,而是把关系从“页面临时拼接”升级为“可复用的派生结果”。
3. 设计原则
本方案固定采用以下原则:
3.1 不改主事实模型
第一版不新增核心业务关系表,不改变当前主数据模型的职责。
关系是派生结果,不是源事实。
3.2 结果缓存,不缓存原始事实
关系缓存只保存:
- 汇总后的相关选手列表
- 每个选手的理由摘要
- 用于排序的内部结果
- 基础元信息
不复制整份比赛明细到缓存层。
3.3 页面读取缓存,缓存缺失时触发计算
选手页不再直接承担完整关系计算逻辑。
页面只做两件事:
- 读取缓存
- 缓存未命中时触发补算,并读取结果
3.4 第一版固定采用缓存表,不引入额外缓存基础设施
当前项目没有线上 Redis、队列、KV 等现成基础设施,也还没有正式选定缓存技术栈。
因此第一版缓存技术选型直接固定为:
Postgres 缓存表作为主缓存方案- 不引入 Redis、Valkey、KV 等外部缓存服务
- 不使用 Next.js cache 作为业务主缓存
- 不新增复杂任务系统
第一版采用:
- 数据库缓存表
- 脚本预计算
- 页面访问触发的同步懒计算
先把闭环跑通,后续访问规模上来后,再评估是否升级到 Redis / Queue。
4. 总体架构
第一版关系链路固定如下:
-
数据源:
PersonMembershipRaceResultRaceCompetitionEditionCompetition
-
关系计算层:
- 直接对抗计算器
- 并肩作战计算器
- 同阶段高频相遇计算器
- 汇总排序器
-
缓存层:
- 按选手维度写入
Postgres缓存表,持久化关系汇总结果
- 按选手维度写入
-
页面读取层:
- 选手页读取关系缓存并展示
-
预热层:
- maintenance script 批量预计算核心选手
5. 数据源定义
第一版关系计算只依赖当前已有事实数据。
5.1 直接对抗数据源
使用:
RaceResult.personIdRaceResult.raceIdRaceCompetitionEditionCompetition
判定规则固定为:
两名选手存在同一个
raceId的RaceResult,即为一次直接对抗。
这与 PRD 中“同一赛事、同一项目、同一组”完全一致,因为当前模型中的 Race 已经承载这个语义。
5.2 并肩作战数据源
使用:
MembershipOrganizationRaceResult
判定依赖两类事实:
- 是否同组织且时间重叠
- 是否同组织且共同参加过同一届重要赛事
5.3 同阶段高频相遇数据源
使用:
RaceResultRaceCompetitionEditionCompetitionMembership
判定目标不是“同一 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)
}
说明:
personId一人一份汇总缓存payload保存前台直接可用的关系结果generatedAt用于判断缓存新旧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;
};
}>;
};
说明:
relationScore仅内部使用,不在前台展示reasons是前台直接渲染的短标签来源summary用于后续继续扩展详情,不要求第一版完整展示
7. 关系计算规则
第一版关系计算采用固定规则,不开放配置化。
7.1 直接对抗计算
7.1.1 基础算法
输入:某选手的全部 RaceResult
过程:
- 取出该选手所有
raceId - 查询这些
raceId下的全部其他选手RaceResult - 按
relatedPersonId聚合 - 统计同
raceId对位次数 - 提取对应赛事名称、届次、阶段信息
输出:
- 每个相关选手的直接对抗次数
- 最有代表性的 1 到 2 条事实摘要
7.1.2 第一版展示标签规则
标签按以下优先级生成:
- 驿传同区对抗次数
- 跨阶段总直接对抗次数
- 典型赛事名称
示例:
驿传中 3 次同区直接对抗高中到大学共 5 次同场直接对位在箱根与出云中多次正面相遇
7.1.3 分值规则
建议内部得分:
- 每次直接对抗:
+10 - 若赛事类型属于驿传:额外
+5 - 若覆盖两个及以上生涯阶段:额外
+8
这是内部排序规则,只用于排序。
7.2 并肩作战计算
7.2.1 基础算法
输入:某选手的全部 Membership
过程:
- 找出该选手所有大学、高中、实业团所属
- 查询同组织的其他选手
Membership - 判断是否存在时间重叠
- 补查是否共同参加过同届驿传或同阶段重要赛事
输出:
- 同组织且时间重叠的相关选手
- 同组织且共同出场的相关选手
7.2.2 第一版展示标签规则
优先生成以下标签:
大学时期 4 年同队101 回、102 回连续共同出场同一时期共同承担队伍主力区间
7.2.3 分值规则
建议内部得分:
- 同组织时间重叠:
+8 - 重叠年份每增加 1 年:
+2 - 同届重要驿传共同出场:每次
+4
7.3 同阶段高频相遇计算
7.3.1 基础算法
输入:某选手的全部 RaceResult 与 Membership
过程:
- 按生涯阶段切分赛事记录:
- 高中
- 大学
- 实业团
- 查找同阶段、同类重点赛事中重复出现的其他选手
- 排除已由“直接对抗”强覆盖的重复关系
输出:
- 作为补充的“长期同阶段人物”
7.3.2 第一版展示标签规则
大学时期多次在全国大赛相遇同年代长期出现在同一舞台
7.3.3 分值规则
建议内部得分:
- 同阶段重要赛事重复出现一次:
+3 - 同阶段跨多年持续出现:额外
+4
8. 汇总排序规则
第一版固定采用统一汇总器。
8.1 合并原则
- 同一相关选手在多个关系类型中命中时,合并为一个结果
- 分值累加
- 理由去重
- 最多保留 3 条理由
8.2 排序规则
固定按以下优先级排序:
- 总分高者优先
- 直接对抗次数高者优先
- 并肩作战重叠年份更长者优先
- 最近一次关系发生时间更近者优先
8.3 截断规则
前台只输出前 6 名。
缓存中建议保留前 12 名,方便后续扩展“展开更多”。
9. 缓存生成策略
第一版缓存生成固定采用 缓存表 + 双轨计算 模式。
9.1 核心选手预计算
新增脚本:
scripts/maintenance/recompute-player-relations.ts
职责:
- 接收选手范围
- 批量计算关系
- 覆盖写入
PlayerRelationCache缓存表
核心选手范围来源第一版采用静态策略:
- 重点收录选手名单
- 首页出现频率高的选手
- 已导入关键驿传出场较多的选手
第一版不做自动热度计算。
9.2 非核心选手懒计算
选手页读取关系缓存时:
- 若缓存存在且有效,直接返回
- 若缓存不存在,页面请求中同步计算一次并写入缓存表
- 写入后立即返回结果
第一版不做异步任务投递。
原因:
- 当前没有队列基础设施
- 第一版访问量可控
- 先保证逻辑闭环和正确性
9.3 失效策略
第一版采用简单失效规则:
- 当某选手的
Membership有变更时,应重算该选手关系缓存 - 当某选手新增或修改
RaceResult时,应重算该选手关系缓存 - 批量导入赛事后,统一运行重算脚本更新重点选手
第一版不做精细级联失效。
也就是说:
- A 的结果变更后,不强求实时联动重算所有与 A 对抗过的人
这一点接受短期最终一致性。
10. 服务层拆分
为避免把逻辑继续堆在页面文件中,第一版新增独立服务层。
建议新增:
src/lib/player-relations/types.tssrc/lib/player-relations/build-direct-matchups.tssrc/lib/player-relations/build-teammate-relations.tssrc/lib/player-relations/build-frequent-stage-relations.tssrc/lib/player-relations/build-player-relations.tssrc/lib/player-relations/get-player-relations.ts
职责划分:
build-*- 纯计算函数
build-player-relations- 统一汇总器
get-player-relations- 页面读取入口
- 负责查缓存、补算、回写
11. 页面接入方案
当前选手页已存在一个实时关系块。
第一版改造方式固定如下:
- 删除页面内临时拼装
relatedPlayers的逻辑 - 改为调用
getPlayerRelations(personId) - 渲染缓存返回的汇总结果
页面层只负责:
- 调用服务
- 取出相关选手基础资料
- 渲染姓名和理由标签
页面层不再负责关系计算。
12. 数据查询优化
第一版需要控制查询开销。
固定策略如下:
12.1 先按 personId 局部计算
所有关系计算都围绕单个 personId 展开,不做全表扫描式构建。
12.2 先取候选集,再做关系聚合
先根据:
- 相同
raceId - 相同
organizationId - 同阶段重要赛事
缩小候选人范围,再生成最终关系。
12.3 页面渲染时只补相关 6 到 12 人的基础资料
缓存中只存 relatedPersonId 和标签摘要。
页面拿到缓存后,再一次性查询这批人的:
slugdisplayNameJa- 基础展示字段
避免把人物完整信息冗余进缓存。
13. 国际化策略
第一版缓存中保存 reason type + 中文开发侧 label 即可。
前台正式展示时,建议逐步过渡到:
- 缓存中保存结构化理由类型和参数
- 页面按 locale 做文案渲染
但为了第一版尽快落地,可以先在缓存中写入稳定短标签字符串,再后续重构。
也就是说:
- 第一版允许先偏向日文/中文开发语境
- 只要结构可后续替换即可
14. 上线顺序
本版本开发顺序固定如下:
- 新增
PlayerRelationCache模型并迁移 - 建立
player-relations服务层 - 实现直接对抗计算器
- 实现并肩作战计算器
- 实现同阶段高频相遇计算器
- 实现汇总排序器
- 实现
getPlayerRelations - 改造选手页接入新服务
- 编写预计算脚本
- 手工选择一批核心选手做预热验证
其中开发优先级固定为:
直接对抗并肩作战同阶段高频相遇
如果工期紧张,允许先上线前两者,再补第三者。
15. 第一版可接受的简化
为了保证第一版按时落地,以下简化是明确允许的:
sourceHash可先不精确,只保留generatedAt- 同阶段高频相遇可先使用较朴素规则
- 核心选手名单可先人工维护
- 懒计算允许同步执行
- 缓存失效先采用批量重算和被动更新
这些不是技术债失控,而是第一版有意收敛。
16. 不采用的方案
第一版明确不采用以下方案:
- 页面实时全量拼接关系,不做缓存
- 一开始就上 Redis / Queue / Worker 体系
- 一开始就做全站全量关系图预构建
- 为关系事实新增复杂主业务表
- 直接输出显性算法分数
- 在选手页中主动输出胜负结论
17. 风险与应对
17.1 数据不完整导致关系偏弱
应对:
- 先保证直接对抗关系成立
- 并肩作战补足非明星选手覆盖
- 接受部分普通选手结果较少
17.2 懒计算首访变慢
应对:
- 核心选手先预热
- 第一版只计算单人局部关系
- 限制前台展示人数
17.3 导入后缓存陈旧
应对:
- 每次重点赛事导入后手动运行重算脚本
- 接受短期最终一致性
18. 结论
本方案的核心结论如下:
- 第一版不改主模型,只新增一层轻量关系缓存表
- 关系计算从页面内联逻辑升级为独立服务层
- 直接对抗是第一优先,其次是并肩作战,再是同阶段高频相遇
- 上线策略采用
核心选手预计算 + 非核心选手懒计算 - 当前阶段不引入额外中间件,先用
Postgres缓存表和脚本完成闭环
本技术方案作为 选手关系 V0.1 的实现基线,后续迭代在此基础上增强,不再回退为页面实时拼接方案。