Article

2026-06-30-襷の系譜-v0.2-数据模型与功能改造技术方案

个人项目

襷の系譜 V0.2 数据模型与功能改造技术方案

日期:2026-06-30

关联文档:

1. 背景与目标

V0.1 已经完成了以选手为中心的基础资料站骨架,但随着箱根、出云、全日本大学駅伝数据的引入,现有模型开始出现三个明显限制:

  • Person 页面语义过度绑定选手,无法自然承接教练与 staff
  • 驿传只有“个人区间成绩”,没有“队伍整届结果”和“队伍分段累计状态”
  • Competition.type 仍是字符串,无法稳定驱动驿传类页面和查询

V0.2 的目标不是做复杂数据分析,而是完成一轮 事实层升级,让后续组织页、人物页、届次页都能基于更合理的模型继续增长。

本版目标:

  1. 把站点从“选手中心”升级为“人物中心”
  2. 正式支持驿传队伍级结果
  3. 把模型改造对应地体现在页面功能上
  4. 先不做荣誉摘要、图表分析、复杂聚合

2. 本版范围

本版纳入:

  • Person.type 收敛为三类:athlete / coach / staff
  • Membership.role 正式入模,并与 Person.type 使用同一组枚举
  • Competition.type 从字符串改为正式枚举
  • 新增驿传队伍级结果模型:
    • TeamCompetitionResult
    • TeamCompetitionLegSnapshot
  • 组织页支持展示 athlete / coach / staff
  • 组织页支持展示已导入驿传届次成绩
  • 届次页支持展示队伍最终成绩和分段累计状态表
  • 人物页支持非 athlete 人物成立

本版不纳入:

  • 荣誉模型
  • 组织赛事摘要实体
  • 驿传折线图、名次变化图等分析型可视化
  • manager 独立枚举
  • 監督 / 主務 / 学生スタッフ 等更细岗位枚举
  • 复杂头衔体系

3. 核心建模原则

3.1 Person 是统一人物实体

Person 不再被视作“选手表”,而是统一人物实体。
选手、教练、staff 共用同一实体层。

3.2 Membership 承载组织中的具体身份

一个人是否是选手、教练还是 staff,真实事实以 Membership.role 为准。

Person.type 保留,但它是:

  • 站内人物主分类字段
  • 派生字段
  • 不要求人工维护

3.3 驿传分为个人层与队伍层

驿传相关事实必须拆成三层:

  • CompetitionEdition:整届赛事
  • Race:单区
  • 队伍结果层:
    • TeamCompetitionResult
    • TeamCompetitionLegSnapshot

规则:

  • 个人区间成绩挂在 RaceResult
  • 队伍整届最终结果挂在 CompetitionEdition
  • 队伍每段结束后的累计状态也挂在 CompetitionEdition 语义之下

3.4 先补事实层,再做摘要层

V0.2 只补齐事实模型,不引入荣誉、摘要、分析等二次聚合层。
组织页后续若要扩展摘要,应基于本版新增的队伍结果事实层派生。

4. 数据模型改造

4.1 枚举调整

4.1.1 PersonType

PersonType 收敛为:

enum PersonType {
  athlete
  coach
  staff
}

说明:

  • 现有 manager 并入 staff
  • 现有 coach 保留
  • 现有 athlete 保留

4.1.2 MembershipRole

新增:

enum MembershipRole {
  athlete
  coach
  staff
}

说明:

  • V0.2 中 Membership.rolePerson.type 保持同一组值
  • 语义上两者不同:
    • Person.type 是主类型
    • Membership.role 是该段组织关系中的身份事实

4.1.3 CompetitionType

新增:

enum CompetitionType {
  university_ekiden
  high_school_ekiden
  corporate_ekiden
  mixed_ekiden
  track_meet
  road_race
  marathon
}

说明:

  • Competition.typeString 改为 CompetitionType
  • 保留 Race.discipline.ekiden_leg
  • 两者分别承担赛事级和比赛单元级语义

4.2 现有模型字段变更

4.2.1 Person

保留现有主结构,type 调整为新的三值枚举:

model Person {
  id               String      @id @default(cuid())
  slug             String      @unique
  displayNameJa    String
  displayNameKana  String?
  displayNameRoman String?
  displayNameZh    String?
  displayNameEn    String?
  birthDate        DateTime?
  hometown         String?
  nationality      String?
  heightCm         Int?
  weightKg         Int?
  type             PersonType  @default(athlete)
  status           DataStatus  @default(pending)
  notes            String?
  createdAt        DateTime    @default(now())
  updatedAt        DateTime    @updatedAt
}

设计结论:

  • 不新增“当前角色”冗余字段
  • type 继续保留
  • type 统一由 membership 推导并回填

4.2.2 Membership

在现有 Membership 基础上增加 role

model Membership {
  id             String          @id @default(cuid())
  personId       String
  organizationId String
  type           MembershipType
  role           MembershipRole  @default(athlete)
  startDate      DateTime?
  endDate        DateTime?
  startYear      Int?
  endYear        Int?
  faculty        String?
  department     String?
  status         DataStatus      @default(pending)
  notes          String?
  sourceId       String?
  createdAt      DateTime        @default(now())
  updatedAt      DateTime        @updatedAt
}

设计结论:

  • Membership.type 保留,继续表示 origin / enrolled / affiliated / graduated / staff 这类关系类型
  • Membership.role 新增,表示该段关系中的组织身份
  • 这样可以同时表达:
    • type = enrolled, role = athlete
    • type = affiliated, role = coach
    • type = staff, role = staff

4.3 Competition 模型变更

Competition.type 从字符串调整为枚举:

model Competition {
  id          String           @id @default(cuid())
  slug        String           @unique
  nameJa      String
  nameKana    String?
  nameRoman   String?
  nameZh      String?
  nameEn      String?
  type        CompetitionType?
  organizer   String?
  region      String?
  websiteUrl  String?
  notes       String?
  createdAt   DateTime         @default(now())
  updatedAt   DateTime         @updatedAt
}

说明:

  • 允许暂时为 null,便于兼容旧数据和分批回填
  • 完成历史数据回填后,可在后续版本评估是否改为必填

4.4 新增驿传队伍级结果模型

4.4.1 TeamCompetitionResult

表示某支队伍在某届赛事中的最终结果。

model TeamCompetitionResult {
  id                   String              @id @default(cuid())
  competitionEditionId String
  organizationId       String
  finalRank            Int?
  finalMark            String?
  finalMarkMillis      Int?
  status               DataStatus          @default(pending)
  notes                String?
  sourceId             String?
  createdAt            DateTime            @default(now())
  updatedAt            DateTime            @updatedAt

  competitionEdition   CompetitionEdition  @relation(fields: [competitionEditionId], references: [id], onDelete: Cascade)
  organization         Organization        @relation(fields: [organizationId], references: [id], onDelete: Cascade)
  source               Source?             @relation(fields: [sourceId], references: [id])
  legSnapshots         TeamCompetitionLegSnapshot[]

  @@unique([competitionEditionId, organizationId])
  @@index([competitionEditionId, finalRank])
  @@index([organizationId, competitionEditionId])
}

设计结论:

  • 只负责整届最终结果
  • 不放区间跑者、区间成绩等字段
  • competitionEditionId + organizationId 必须唯一

4.4.2 TeamCompetitionLegSnapshot

表示某支队伍在某届赛事跑完第 N 区时的累计状态。

model TeamCompetitionLegSnapshot {
  id                      String                 @id @default(cuid())
  teamCompetitionResultId String
  leg                     Int
  cumulativeRank          Int?
  cumulativeMark          String?
  cumulativeMarkMillis    Int?
  gapFromLeader           String?
  gapFromLeaderMillis     Int?
  status                  DataStatus             @default(pending)
  notes                   String?
  sourceId                String?
  createdAt               DateTime               @default(now())
  updatedAt               DateTime               @updatedAt

  teamCompetitionResult   TeamCompetitionResult  @relation(fields: [teamCompetitionResultId], references: [id], onDelete: Cascade)
  source                  Source?                @relation(fields: [sourceId], references: [id])

  @@unique([teamCompetitionResultId, leg])
  @@index([leg, cumulativeRank])
}

设计结论:

  • 只负责累计状态
  • 不冗余存储区间跑者和区间个人成绩
  • 页面要看“这一段谁跑”,通过 RaceResult 联查

4.4.3 与旧字段的关系

RaceResult.teamRank 先保留,但定位改为:

  • 兼容字段
  • 历史数据过渡字段
  • 不再作为驿传队伍结果的核心事实来源

后续新功能应优先使用:

  • TeamCompetitionResult.finalRank
  • TeamCompetitionLegSnapshot.cumulativeRank

5. 迁移与回填策略

V0.2 先改模型,再逐步接功能,兼容以“可平滑过渡”为目标,不以长期保留旧语义为目标。

5.1 PersonType 收敛

迁移规则:

  • 现有 athlete 保持不变
  • 现有 coach 保持不变
  • 现有 manager 映射为 staff
  • 现有 staff 保持不变

5.2 Membership.role 回填

由于当前库里的主体人物几乎都是选手,首轮迁移可采用保守策略:

  • 所有现有 membership 默认回填为 role = athlete

后续新增教练、staff 数据时,再按导入源写入真实 role。

5.3 Person.type 重算

新增一份统一的 Person.type 重算逻辑,规则如下:

  1. 优先取当前有效 membership
  2. 如果没有当前有效 membership,取结束时间最近的一段 membership
  3. 如果仍无法判断,则保留现值;若现值为空,则回退为 athlete

“当前有效 membership” 判定:

  • startDate 为空或早于当前日期
  • endDate 为空或晚于当前日期

若同一人物存在多个当前有效 membership,优先级:

  1. coach
  2. staff
  3. athlete

说明:

  • 此优先级用于推导站内主类型
  • 不改变真实历史关系事实

5.4 Competition.type 回填

已知赛事优先回填:

  • 箱根駅伝:university_ekiden
  • 出雲駅伝:university_ekiden
  • 全日本大学駅伝:university_ekiden

后续可按新增赛事逐步补齐。

5.5 驿传队伍结果回填

V0.2 上线前,不要求一次性把所有历史届次的队伍级结果回填完整。

建议策略:

  1. 先为已有完整箱根届次回填 TeamCompetitionResult
  2. 再逐届补 TeamCompetitionLegSnapshot
  3. 出雲、全日本大学駅伝在正式导入时直接走新模型

允许阶段性存在:

  • RaceResult 已完整
  • TeamCompetitionResult 部分完成
  • TeamCompetitionLegSnapshot 尚未完成

页面功能应基于“有数据才展示”的策略渐进启用。

6. 功能改造方案

6.1 组织页

组织页从“成员列表页”升级为“组织资料页”。

6.1.1 Current People

展示当前有效 membership,按 role 分组:

  • Athletes
  • Coaches
  • Staff

展示字段:

  • 人物名
  • 所属起止时间
  • 人物详情页入口

6.1.2 Former People

展示非当前 membership,同样按 role 分组:

  • Athletes
  • Coaches
  • Staff

6.1.3 Ekiden Results

仅展示当前已有数据、且所属赛事 Competition.type 为驿传类型的结果。

首版展示字段:

  • 赛事名
  • 届次名
  • 最终排名
  • 总成绩
  • 届次详情页入口

展示来源:

  • TeamCompetitionResult

不做:

  • 历史摘要
  • 冠军次数
  • 趋势图

6.2 人物页

人物页不再默认假设所有人都是 athlete。

6.2.1 athlete 页面

保留并增强现有内容:

  • 所属履历
  • PB
  • 比赛结果
  • 相关人物

6.2.2 coach / staff 页面

首版保持克制,重点展示:

  • 人物主信息
  • 所属履历
  • 关联组织
  • 相关赛事痕迹,若已有可查询数据

不做:

  • 独立“执教战绩分析”
  • 复杂教练专题模块

6.2.3 路由策略

V0.2 不强制把 /players 全量改名为 /people

建议:

  • 保留现有 /players 路由,优先保证兼容上线
  • 文案层逐步从“选手”过渡到“人物 / 选手”
  • 后续若决定做 /people,再单独规划 SEO 和路径迁移

6.3 届次页 / 驿传详情页

如果届次属于驿传类型,应支持展示队伍级结果。

6.3.1 队伍最终结果表

展示来源:

  • TeamCompetitionResult

首版字段:

  • 学校
  • 最终排名
  • 总成绩

6.3.2 队伍分段累计状态表

展示来源:

  • TeamCompetitionLegSnapshot

首版字段:

  • 第几区
  • 累计名次
  • 累计成绩
  • 与首位差

不做:

  • 图表
  • 折线图
  • 多队对比分析

7. 查询层与接口建议

7.1 组织页查询

组织页建议拆成三组查询:

  1. memberships 按当前/历史分组
  2. membershipsrole 分组
  3. teamCompetitionResults 取已导入驿传成绩

建议不要继续只按“current players / former players”查询。

7.2 人物页查询

人物页查询需支持:

  • Person.type
  • memberships.role
  • 非 athlete 页面在没有 PB 时正常成立

PB 和比赛结果区块可以根据 Person.type 与是否有数据决定展示。

7.3 驿传届次页查询

建议按 CompetitionEdition 聚合:

  1. 拉取 CompetitionEdition
  2. 拉取关联 TeamCompetitionResult
  3. 为每个 TeamCompetitionResult 拉取 legSnapshots
  4. 如需联查某段个人成绩,再从 Race -> RaceResult 查询

8. 实施顺序

建议按以下顺序推进:

Phase 1:Schema 改造

  • 调整 PersonType
  • 新增 MembershipRole
  • 新增 CompetitionType
  • 修改 Competition.type
  • 新增 Membership.role
  • 新增:
    • TeamCompetitionResult
    • TeamCompetitionLegSnapshot

Phase 2:数据迁移与重算脚本

  • 回填 Membership.role
  • 回填 Person.type
  • 回填 Competition.type
  • 为已有箱根届次补一批队伍结果

Phase 3:查询层改造

  • 组织页查询改造
  • 人物页查询改造
  • 驿传届次页查询落地

Phase 4:页面改造

  • 组织页
  • 人物页
  • 届次页 / 驿传详情页

9. 风险与约束

9.1 历史数据会阶段性不完整

V0.2 上线后,一段时间内可能出现:

  • 个人区间成绩齐全
  • 队伍结果层尚未补齐

这是允许的。页面按有数据展示即可。

9.2 Person.type 是派生字段,不应变成人工心智负担

一旦仍要求导入时手工维护 Person.type,模型升级价值会大打折扣。
因此必须配套统一重算逻辑。

9.3 RaceResult.teamRank 会进入过渡期

短期保留它有助于平滑迁移,但长期不应继续让它承担队伍结果层职责。

10. 本版交付结果

V0.2 完成后,站点应具备以下能力:

  • 正式支持 athlete / coach / staff
  • 组织页可以展示不同角色的人物关系
  • 驿传可以展示队伍整届结果
  • 驿传可以展示每段累计状态表
  • 人物页不再被选手语义绑死
  • 后续图表、摘要、荣誉、分析都能基于更稳定的事实层继续扩展