【文字三国志:第三篇】天命重构,数据模型设计



数据模型设计
本章将详细介绍项目的数据模型设计。我选择了 Prisma ORM 配合 PostgreSQL 作为持久化方案,所有业务实体统一在 prisma/schema.prisma 中声明,并通过 prisma generate 自动生成 TypeScript 类型——这意味着在编写业务代码时,数据库操作会获得完整的类型提示和编译时检查。
简单说:模型即代码——表结构变了,类型自动跟着变,避免了手写 SQL 时常见的字段名拼写错误。


1. 介绍之前
在开始逐个介绍模型之前,不妨先思考一个问题:一个典型的游戏服务器需要记录哪些数据?
- 谁在玩?(用户账户)
- 玩家长什么样?(昵称、等级、头像等公开信息)
- 玩到哪了?(当前的游戏状态,用于断线重连)
- 游戏里有哪些角色/势力?(由策划配置)
- 玩家之间有哪些关系?(联盟、帮会)
- 做了哪些值得记录的事?(成就、战斗日志)
基于这些问题,抽象出 9 个核心模型,它们之间的关系可以用下图表示:
2. 核心模型详解
2.1 玩家账户与档案
| Model | 主要字段 | 说明 |
|---|---|---|
| User | id, username, email, createdAt, updatedAt |
玩家账户信息,关联 Profile 与 GameState |
| Profile | id, userId, nickname, avatarUrl, level, experience |
玩家公开资料,展示在 UI 中 |
把账户和档案分开
User 存储的是认证相关的敏感信息(邮箱、密码哈希等),而 Profile 存储的是展示相关的公开信息。分开有两个好处:
- 查询排行榜时,不需要触碰
User表,减少敏感数据暴露风险。 - 昵称、头像等频繁修改的字段不会影响认证表的主键索引。
2.2 游戏状态与日志
| Model | 主要字段 | 说明 |
|---|---|---|
| GameState | id, userId, currentTurn, lastSavedAt |
保存玩家当前的游戏状态快照(JSON 列),用于断线恢复 |
| CombatLog | id, gameStateId, payload (JSON), createdAt |
每回合的战斗日志,供回放与审计使用 |
断线恢复的工作原理:
** CombatLog 单独存**
GameState只保留当前状态,体积小、读得快。CombatLog保留完整历史,用于:- 战斗回放功能
- 玩家争议审计(判定是否作弊)
- 数据分析(哪些技能使用频率高)
2.3 配置类数据
| Model | 主要字段 | 说明 |
|---|---|---|
| Character | id, name, faction, attributes (JSON) |
游戏中的角色/势力定义,属性可扩展 |
| SeasonEvent | id, name, startAt, endAt, config (JSON) |
每季活动配置 |
| Policy | id, name, effect (JSON) |
游戏内政策/决策,即时生效或延迟生效 |
表共同点
它们都是由策划配置、玩家不可写的数据。设计成数据库表而非代码常量的原因是:
- 热更新:修改活动时间无需重新部署服务
- 多环境共享:开发、测试、生产可以用不同配置
- 非技术人员可编辑:配合后台管理系统,策划可以直接修改
2.4 成就与联盟
| Model | 主要字段 | 说明 |
|---|---|---|
| Alliance | id, name, members (relation) |
联盟/帮会实体,关联多名 User |
| Achievement | id, code, title, description, reward |
成就系统定义 |
| UserAchievement | id, userId, achievementId, unlockedAt |
玩家已解锁的成就记录(联结表) |
多对多关系的处理:
UserAchievement 是一个联结表,它不仅仅记录"谁完成了什么成就",还带有一个额外的 unlockedAt 字段——这很常见,因为多对多关系本身往往带有关系属性(完成时间、完成时的快照等)。
3. 关联关系速查
| 关系类型 | 涉及模型 | 实现方式 |
|---|---|---|
| 一对一 | User ↔ Profile | profile.userId 外键 |
| 一对一 | User ↔ GameState | gamestate.userId 外键 |
| 多对多 | User ↔ Alliance | AllianceMember 中间表 |
| 多对多 | User ↔ Achievement | UserAchievement 联结表 |
| 一对多 | GameState → CombatLog | combatLog.gameStateId 外键 |
4. 数据库索引与查询优化
索引是性能的生命线,但不是越多越好——每个额外的索引都会拖慢写入速度。我们根据实际查询场景,有针对性地建立索引:
4.1 唯一索引(用于快速查找用户)
-- 登录时根据邮箱或用户名查找
CREATE UNIQUE INDEX idx_user_email ON "User"(email);
CREATE UNIQUE INDEX idx_user_username ON "User"(username);
4.2 复合索引(用于范围查询)
-- 查找最近活跃的玩家(用于运营活动推送)
CREATE INDEX idx_gamestate_turn_saved ON "GameState"(currentTurn, lastSavedAt);
这个复合索引的字段顺序很重要:
currentTurn在前:如果你经常查询"回合数大于 N 的玩家"lastSavedAt在后:在筛选完回合数后,再按时间排序
4.3 外键索引(用于关联查询)
-- 回放战斗日志时,根据 gameStateId 快速过滤
CREATE INDEX idx_combatlog_gameStateId ON "CombatLog"(gameStateId);
4.4 JSON 字段索引(PostgreSQL 特有的 jsonb 索引)
-- 假设我们需要查询"生命值大于 100"的角色
CREATE INDEX idx_character_attributes ON "Character" USING GIN (attributes);
-- 配合查询:SELECT * FROM "Character" WHERE attributes->>'health' > '100';
💡
jsonb的 GIN 索引可以加速任意键值查询,但会占用额外存储空间。仅在确实需要按 JSON 内部字段过滤时使用。
索引决策流程图
5. 迁移与种子数据
5.1 迁移流程
代码即架构。当模型发生变化时,遵循以下流程:
关键原则:
- 迁移文件必须提交到版本控制,保证团队和环境一致
- 生产环境的迁移由 CI/CD 流程执行,不要在服务器上手动改表
5.2 种子数据
prisma/seed.ts 负责初始化两类数据:
| 类型 | 内容 | 目的 |
|---|---|---|
| 基础配置 | Character、Policy、SeasonEvent | 让新环境能直接运行游戏逻辑 |
| 测试数据 | 演示用的 User、GameState | 本地开发时能看到非空界面 |
执行方式:
npx prisma db seed
6. 兼容性与演进
6.1 命名映射
使用 @@map 和 @map 将 Prisma 模型名映射到实际的数据库表名:
model User {
id Int @id @map("user_id")
username String @map("user_name")
@@map("t_user") // 表名统一加前缀
}
注意
- PostgreSQL 默认大小写敏感,
User和user是不同的表 - 不同语言的命名习惯不同(TypeScript 喜欢 PascalCase,SQL 喜欢 snake_case)
6.2 类型安全的业务代码
// ✅ 直接从 prisma/client 导入类型
import { User, GameState } from '@prisma/client';
async function saveGame(userId: number, state: GameState) {
// state 参数有完整的类型提示
return prisma.gameState.upsert({
where: { userId },
update: { ...state, lastSavedAt: new Date() },
create: { userId, ...state },
});
}
6.3 添加新字段的注意事项
model User {
id Int @id @default(autoincrement())
username String
// 新加字段:必须提供默认值或标记为可选
vipLevel Int @default(0) // ✅ 有默认值,旧数据自动补0
inviteCode String? // ✅ 可选字段,旧数据为 null
// inviteCode String // ❌ 不行!旧数据没有这个字段
}
注 当执行 migrate dev 时,Prisma 会生成 ALTER TABLE ... ADD COLUMN 语句。如果新字段没有默认值且不为空,数据库不知道如何填充已有的 100 万行记录,迁移就会失败。
7. 本章小结
| 要点 | 说明 |
|---|---|
| 模型即代码 | Prisma 模型 → TypeScript 类型 + SQL 表,三者同步 |
| 索引按需创建 | 根据实际查询场景选择单列、复合或 JSON 索引 |
| 种子数据分离 | 配置数据与测试数据分开,方便不同环境使用 |
| 迁移要可回放 | 所有迁移文件提交 Git,保证环境一致性 |
| 新增字段要兜底 | 提供 @default 或标记为可选,避免旧数据迁移失败 |
后续章节将基于本章的数据模型,展开业务逻辑的实现细节。理解这些模型之间的关系和设计意图,是阅读后续内容的基础。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)