


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


1. 介绍之前
在开始逐个介绍模型之前,不妨先思考一个问题:一个典型的游戏服务器需要记录哪些数据?
- 谁在玩?(用户账户)
- 玩家长什么样?(昵称、等级、头像等公开信息)
- 玩到哪了?(当前的游戏状态,用于断线重连)
- 游戏里有哪些角色/势力?(由策划配置)
- 玩家之间有哪些关系?(联盟、帮会)
- 做了哪些值得记录的事?(成就、战斗日志)
基于这些问题,抽象出 9 个核心模型,它们之间的关系可以用下图表示:
#mermaid-svg-Yg9RlPgxgVVQDJ0k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .error-icon{fill:#552222;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .marker.cross{stroke:#333333;}#mermaid-svg-Yg9RlPgxgVVQDJ0k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Yg9RlPgxgVVQDJ0k p{margin:0;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-Yg9RlPgxgVVQDJ0k .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .node rect,#mermaid-svg-Yg9RlPgxgVVQDJ0k .node circle,#mermaid-svg-Yg9RlPgxgVVQDJ0k .node ellipse,#mermaid-svg-Yg9RlPgxgVVQDJ0k .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-Yg9RlPgxgVVQDJ0k .edgeLabel .label text{fill:#333;}#mermaid-svg-Yg9RlPgxgVVQDJ0k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} has
has
unlocks
unlocked_by
belongs_to
produces
activates
participates
User
Profile
GameState
UserAchievement
Achievement
Alliance
CombatLog
SeasonEvent
Policy
Character
2. 核心模型详解
2.1 玩家账户与档案
| Model | 主要字段 | 说明 |
|---|---|---|
| User | id, username, email, createdAt, updatedAt |
玩家账户信息,关联 Profile 与 GameState |
| Profile | id, userId, nickname, avatarUrl, level, experience |
玩家公开资料,展示在 UI 中 |
把账户和档案分开
User 存储的是认证相关 的敏感信息(邮箱、密码哈希等),而 Profile 存储的是展示相关的公开信息。分开有两个好处:
- 查询排行榜时,不需要触碰
User表,减少敏感数据暴露风险。 - 昵称、头像等频繁修改的字段不会影响认证表的主键索引。
#mermaid-svg-c6O6nyOXXSPderPo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-c6O6nyOXXSPderPo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-c6O6nyOXXSPderPo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-c6O6nyOXXSPderPo .error-icon{fill:#552222;}#mermaid-svg-c6O6nyOXXSPderPo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-c6O6nyOXXSPderPo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-c6O6nyOXXSPderPo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-c6O6nyOXXSPderPo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-c6O6nyOXXSPderPo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-c6O6nyOXXSPderPo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-c6O6nyOXXSPderPo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-c6O6nyOXXSPderPo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-c6O6nyOXXSPderPo .marker.cross{stroke:#333333;}#mermaid-svg-c6O6nyOXXSPderPo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-c6O6nyOXXSPderPo p{margin:0;}#mermaid-svg-c6O6nyOXXSPderPo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-c6O6nyOXXSPderPo .cluster-label text{fill:#333;}#mermaid-svg-c6O6nyOXXSPderPo .cluster-label span{color:#333;}#mermaid-svg-c6O6nyOXXSPderPo .cluster-label span p{background-color:transparent;}#mermaid-svg-c6O6nyOXXSPderPo .label text,#mermaid-svg-c6O6nyOXXSPderPo span{fill:#333;color:#333;}#mermaid-svg-c6O6nyOXXSPderPo .node rect,#mermaid-svg-c6O6nyOXXSPderPo .node circle,#mermaid-svg-c6O6nyOXXSPderPo .node ellipse,#mermaid-svg-c6O6nyOXXSPderPo .node polygon,#mermaid-svg-c6O6nyOXXSPderPo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-c6O6nyOXXSPderPo .rough-node .label text,#mermaid-svg-c6O6nyOXXSPderPo .node .label text,#mermaid-svg-c6O6nyOXXSPderPo .image-shape .label,#mermaid-svg-c6O6nyOXXSPderPo .icon-shape .label{text-anchor:middle;}#mermaid-svg-c6O6nyOXXSPderPo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-c6O6nyOXXSPderPo .rough-node .label,#mermaid-svg-c6O6nyOXXSPderPo .node .label,#mermaid-svg-c6O6nyOXXSPderPo .image-shape .label,#mermaid-svg-c6O6nyOXXSPderPo .icon-shape .label{text-align:center;}#mermaid-svg-c6O6nyOXXSPderPo .node.clickable{cursor:pointer;}#mermaid-svg-c6O6nyOXXSPderPo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-c6O6nyOXXSPderPo .arrowheadPath{fill:#333333;}#mermaid-svg-c6O6nyOXXSPderPo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-c6O6nyOXXSPderPo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-c6O6nyOXXSPderPo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c6O6nyOXXSPderPo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-c6O6nyOXXSPderPo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c6O6nyOXXSPderPo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-c6O6nyOXXSPderPo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-c6O6nyOXXSPderPo .cluster text{fill:#333;}#mermaid-svg-c6O6nyOXXSPderPo .cluster span{color:#333;}#mermaid-svg-c6O6nyOXXSPderPo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-c6O6nyOXXSPderPo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-c6O6nyOXXSPderPo rect.text{fill:none;stroke-width:0;}#mermaid-svg-c6O6nyOXXSPderPo .icon-shape,#mermaid-svg-c6O6nyOXXSPderPo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-c6O6nyOXXSPderPo .icon-shape p,#mermaid-svg-c6O6nyOXXSPderPo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-c6O6nyOXXSPderPo .icon-shape .label rect,#mermaid-svg-c6O6nyOXXSPderPo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-c6O6nyOXXSPderPo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-c6O6nyOXXSPderPo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-c6O6nyOXXSPderPo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 展示域
认证域
1:1
User
id, email, password_hash
Profile
nickname, avatar, level, exp
2.2 游戏状态与日志
| Model | 主要字段 | 说明 |
|---|---|---|
| GameState | id, userId, currentTurn, lastSavedAt |
保存玩家当前的游戏状态快照(JSON 列),用于断线恢复 |
| CombatLog | id, gameStateId, payload (JSON), createdAt |
每回合的战斗日志,供回放与审计使用 |
断线恢复的工作原理:
PostgreSQL 服务器 客户端 PostgreSQL 服务器 客户端 #mermaid-svg-m2AtBgYaCIA5z5QG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-m2AtBgYaCIA5z5QG .error-icon{fill:#552222;}#mermaid-svg-m2AtBgYaCIA5z5QG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-m2AtBgYaCIA5z5QG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-m2AtBgYaCIA5z5QG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-m2AtBgYaCIA5z5QG .marker.cross{stroke:#333333;}#mermaid-svg-m2AtBgYaCIA5z5QG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-m2AtBgYaCIA5z5QG p{margin:0;}#mermaid-svg-m2AtBgYaCIA5z5QG .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m2AtBgYaCIA5z5QG text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-m2AtBgYaCIA5z5QG .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-m2AtBgYaCIA5z5QG .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-m2AtBgYaCIA5z5QG .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-m2AtBgYaCIA5z5QG .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-m2AtBgYaCIA5z5QG #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-m2AtBgYaCIA5z5QG .sequenceNumber{fill:white;}#mermaid-svg-m2AtBgYaCIA5z5QG #sequencenumber{fill:#333;}#mermaid-svg-m2AtBgYaCIA5z5QG #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-m2AtBgYaCIA5z5QG .messageText{fill:#333;stroke:none;}#mermaid-svg-m2AtBgYaCIA5z5QG .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m2AtBgYaCIA5z5QG .labelText,#mermaid-svg-m2AtBgYaCIA5z5QG .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-m2AtBgYaCIA5z5QG .loopText,#mermaid-svg-m2AtBgYaCIA5z5QG .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-m2AtBgYaCIA5z5QG .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-m2AtBgYaCIA5z5QG .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-m2AtBgYaCIA5z5QG .noteText,#mermaid-svg-m2AtBgYaCIA5z5QG .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-m2AtBgYaCIA5z5QG .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m2AtBgYaCIA5z5QG .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m2AtBgYaCIA5z5QG .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-m2AtBgYaCIA5z5QG .actorPopupMenu{position:absolute;}#mermaid-svg-m2AtBgYaCIA5z5QG .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-m2AtBgYaCIA5z5QG .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-m2AtBgYaCIA5z5QG .actor-man circle,#mermaid-svg-m2AtBgYaCIA5z5QG line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-m2AtBgYaCIA5z5QG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 正常游戏流程 断线重连 GameState 存的是整个游戏的压缩快照,不是增量。 行动指令计算新状态更新 GameState (JSON)插入 CombatLog返回结果重连请求 + userIdSELECT * FROM GameState WHERE userId = ?最新快照 (JSON)恢复现场
** 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 |
玩家已解锁的成就记录(联结表) |
多对多关系的处理:
#mermaid-svg-XJp9RkYnZdKOVy4d{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XJp9RkYnZdKOVy4d .error-icon{fill:#552222;}#mermaid-svg-XJp9RkYnZdKOVy4d .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XJp9RkYnZdKOVy4d .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XJp9RkYnZdKOVy4d .marker.cross{stroke:#333333;}#mermaid-svg-XJp9RkYnZdKOVy4d svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XJp9RkYnZdKOVy4d p{margin:0;}#mermaid-svg-XJp9RkYnZdKOVy4d .entityBox{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-XJp9RkYnZdKOVy4d .relationshipLabelBox{fill:hsl(80, 100%, 96.2745098039%);opacity:0.7;background-color:hsl(80, 100%, 96.2745098039%);}#mermaid-svg-XJp9RkYnZdKOVy4d .relationshipLabelBox rect{opacity:0.5;}#mermaid-svg-XJp9RkYnZdKOVy4d .labelBkg{background-color:rgba(248.6666666666, 255, 235.9999999999, 0.5);}#mermaid-svg-XJp9RkYnZdKOVy4d .edgeLabel .label{fill:#9370DB;font-size:14px;}#mermaid-svg-XJp9RkYnZdKOVy4d .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XJp9RkYnZdKOVy4d .edge-pattern-dashed{stroke-dasharray:8,8;}#mermaid-svg-XJp9RkYnZdKOVy4d .node rect,#mermaid-svg-XJp9RkYnZdKOVy4d .node circle,#mermaid-svg-XJp9RkYnZdKOVy4d .node ellipse,#mermaid-svg-XJp9RkYnZdKOVy4d .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XJp9RkYnZdKOVy4d .relationshipLine{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-XJp9RkYnZdKOVy4d .marker{fill:none!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-XJp9RkYnZdKOVy4d .edgeLabel{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XJp9RkYnZdKOVy4d .edgeLabel .label rect{fill:rgba(232,232,232, 0.8);}#mermaid-svg-XJp9RkYnZdKOVy4d .edgeLabel .label text{fill:#333;}#mermaid-svg-XJp9RkYnZdKOVy4d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} User
UserAchievement
int
id
PK
int
userId
FK
int
achievementId
FK
datetime
unlockedAt
Achievement
UserAchievement 是一个联结表 ,它不仅仅记录"谁完成了什么成就",还带有一个额外的 unlockedAt 字段------这很常见,因为多对多关系本身往往带有关系属性(完成时间、完成时的快照等)。
#mermaid-svg-B6I1V6XF9ex8ONQ4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .error-icon{fill:#552222;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .marker.cross{stroke:#333333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 p{margin:0;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .cluster-label text{fill:#333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .cluster-label span{color:#333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .cluster-label span p{background-color:transparent;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .label text,#mermaid-svg-B6I1V6XF9ex8ONQ4 span{fill:#333;color:#333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .node rect,#mermaid-svg-B6I1V6XF9ex8ONQ4 .node circle,#mermaid-svg-B6I1V6XF9ex8ONQ4 .node ellipse,#mermaid-svg-B6I1V6XF9ex8ONQ4 .node polygon,#mermaid-svg-B6I1V6XF9ex8ONQ4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .rough-node .label text,#mermaid-svg-B6I1V6XF9ex8ONQ4 .node .label text,#mermaid-svg-B6I1V6XF9ex8ONQ4 .image-shape .label,#mermaid-svg-B6I1V6XF9ex8ONQ4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .rough-node .label,#mermaid-svg-B6I1V6XF9ex8ONQ4 .node .label,#mermaid-svg-B6I1V6XF9ex8ONQ4 .image-shape .label,#mermaid-svg-B6I1V6XF9ex8ONQ4 .icon-shape .label{text-align:center;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .node.clickable{cursor:pointer;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .arrowheadPath{fill:#333333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-B6I1V6XF9ex8ONQ4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-B6I1V6XF9ex8ONQ4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-B6I1V6XF9ex8ONQ4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .cluster text{fill:#333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .cluster span{color:#333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-B6I1V6XF9ex8ONQ4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .icon-shape,#mermaid-svg-B6I1V6XF9ex8ONQ4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .icon-shape p,#mermaid-svg-B6I1V6XF9ex8ONQ4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .icon-shape .label rect,#mermaid-svg-B6I1V6XF9ex8ONQ4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-B6I1V6XF9ex8ONQ4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-B6I1V6XF9ex8ONQ4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-B6I1V6XF9ex8ONQ4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 联盟关系
成员
成员
成员
User A
Alliance 青龙帮
User B
User C
3. 关联关系速查
| 关系类型 | 涉及模型 | 实现方式 |
|---|---|---|
| 一对一 | User ↔ Profile | profile.userId 外键 |
| 一对一 | User ↔ GameState | gamestate.userId 外键 |
| 多对多 | User ↔ Alliance | AllianceMember 中间表 |
| 多对多 | User ↔ Achievement | UserAchievement 联结表 |
| 一对多 | GameState → CombatLog | combatLog.gameStateId 外键 |
4. 数据库索引与查询优化
索引是性能的生命线,但不是越多越好------每个额外的索引都会拖慢写入速度。我们根据实际查询场景,有针对性地建立索引:
4.1 唯一索引(用于快速查找用户)
sql
-- 登录时根据邮箱或用户名查找
CREATE UNIQUE INDEX idx_user_email ON "User"(email);
CREATE UNIQUE INDEX idx_user_username ON "User"(username);
4.2 复合索引(用于范围查询)
sql
-- 查找最近活跃的玩家(用于运营活动推送)
CREATE INDEX idx_gamestate_turn_saved ON "GameState"(currentTurn, lastSavedAt);
这个复合索引的字段顺序很重要:
currentTurn在前:如果你经常查询"回合数大于 N 的玩家"lastSavedAt在后:在筛选完回合数后,再按时间排序
4.3 外键索引(用于关联查询)
sql
-- 回放战斗日志时,根据 gameStateId 快速过滤
CREATE INDEX idx_combatlog_gameStateId ON "CombatLog"(gameStateId);
4.4 JSON 字段索引(PostgreSQL 特有的 jsonb 索引)
sql
-- 假设我们需要查询"生命值大于 100"的角色
CREATE INDEX idx_character_attributes ON "Character" USING GIN (attributes);
-- 配合查询:SELECT * FROM "Character" WHERE attributes->>'health' > '100';
💡
jsonb的 GIN 索引可以加速任意键值查询,但会占用额外存储空间。仅在确实需要按 JSON 内部字段过滤时使用。
索引决策流程图
#mermaid-svg-Dzt76gozeQwbdqfk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Dzt76gozeQwbdqfk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Dzt76gozeQwbdqfk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Dzt76gozeQwbdqfk .error-icon{fill:#552222;}#mermaid-svg-Dzt76gozeQwbdqfk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Dzt76gozeQwbdqfk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Dzt76gozeQwbdqfk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Dzt76gozeQwbdqfk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Dzt76gozeQwbdqfk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Dzt76gozeQwbdqfk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Dzt76gozeQwbdqfk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Dzt76gozeQwbdqfk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Dzt76gozeQwbdqfk .marker.cross{stroke:#333333;}#mermaid-svg-Dzt76gozeQwbdqfk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Dzt76gozeQwbdqfk p{margin:0;}#mermaid-svg-Dzt76gozeQwbdqfk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Dzt76gozeQwbdqfk .cluster-label text{fill:#333;}#mermaid-svg-Dzt76gozeQwbdqfk .cluster-label span{color:#333;}#mermaid-svg-Dzt76gozeQwbdqfk .cluster-label span p{background-color:transparent;}#mermaid-svg-Dzt76gozeQwbdqfk .label text,#mermaid-svg-Dzt76gozeQwbdqfk span{fill:#333;color:#333;}#mermaid-svg-Dzt76gozeQwbdqfk .node rect,#mermaid-svg-Dzt76gozeQwbdqfk .node circle,#mermaid-svg-Dzt76gozeQwbdqfk .node ellipse,#mermaid-svg-Dzt76gozeQwbdqfk .node polygon,#mermaid-svg-Dzt76gozeQwbdqfk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Dzt76gozeQwbdqfk .rough-node .label text,#mermaid-svg-Dzt76gozeQwbdqfk .node .label text,#mermaid-svg-Dzt76gozeQwbdqfk .image-shape .label,#mermaid-svg-Dzt76gozeQwbdqfk .icon-shape .label{text-anchor:middle;}#mermaid-svg-Dzt76gozeQwbdqfk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Dzt76gozeQwbdqfk .rough-node .label,#mermaid-svg-Dzt76gozeQwbdqfk .node .label,#mermaid-svg-Dzt76gozeQwbdqfk .image-shape .label,#mermaid-svg-Dzt76gozeQwbdqfk .icon-shape .label{text-align:center;}#mermaid-svg-Dzt76gozeQwbdqfk .node.clickable{cursor:pointer;}#mermaid-svg-Dzt76gozeQwbdqfk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Dzt76gozeQwbdqfk .arrowheadPath{fill:#333333;}#mermaid-svg-Dzt76gozeQwbdqfk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Dzt76gozeQwbdqfk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Dzt76gozeQwbdqfk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dzt76gozeQwbdqfk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Dzt76gozeQwbdqfk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dzt76gozeQwbdqfk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Dzt76gozeQwbdqfk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Dzt76gozeQwbdqfk .cluster text{fill:#333;}#mermaid-svg-Dzt76gozeQwbdqfk .cluster span{color:#333;}#mermaid-svg-Dzt76gozeQwbdqfk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Dzt76gozeQwbdqfk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Dzt76gozeQwbdqfk rect.text{fill:none;stroke-width:0;}#mermaid-svg-Dzt76gozeQwbdqfk .icon-shape,#mermaid-svg-Dzt76gozeQwbdqfk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Dzt76gozeQwbdqfk .icon-shape p,#mermaid-svg-Dzt76gozeQwbdqfk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Dzt76gozeQwbdqfk .icon-shape .label rect,#mermaid-svg-Dzt76gozeQwbdqfk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Dzt76gozeQwbdqfk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Dzt76gozeQwbdqfk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Dzt76gozeQwbdqfk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
是
是
否
高
低
有查询慢的问题?
暂不建索引
该列是否经常作为 WHERE 条件?
是否多个列一起作为条件?
建复合索引
注意列顺序
建单列索引
写入频率高吗?
平衡读写,
评估是否值得
放心建索引
5. 迁移与种子数据
5.1 迁移流程
代码即架构。当模型发生变化时,遵循以下流程:
#mermaid-svg-45Y8F583jEOVjzQM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-45Y8F583jEOVjzQM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-45Y8F583jEOVjzQM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-45Y8F583jEOVjzQM .error-icon{fill:#552222;}#mermaid-svg-45Y8F583jEOVjzQM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-45Y8F583jEOVjzQM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-45Y8F583jEOVjzQM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-45Y8F583jEOVjzQM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-45Y8F583jEOVjzQM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-45Y8F583jEOVjzQM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-45Y8F583jEOVjzQM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-45Y8F583jEOVjzQM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-45Y8F583jEOVjzQM .marker.cross{stroke:#333333;}#mermaid-svg-45Y8F583jEOVjzQM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-45Y8F583jEOVjzQM p{margin:0;}#mermaid-svg-45Y8F583jEOVjzQM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-45Y8F583jEOVjzQM .cluster-label text{fill:#333;}#mermaid-svg-45Y8F583jEOVjzQM .cluster-label span{color:#333;}#mermaid-svg-45Y8F583jEOVjzQM .cluster-label span p{background-color:transparent;}#mermaid-svg-45Y8F583jEOVjzQM .label text,#mermaid-svg-45Y8F583jEOVjzQM span{fill:#333;color:#333;}#mermaid-svg-45Y8F583jEOVjzQM .node rect,#mermaid-svg-45Y8F583jEOVjzQM .node circle,#mermaid-svg-45Y8F583jEOVjzQM .node ellipse,#mermaid-svg-45Y8F583jEOVjzQM .node polygon,#mermaid-svg-45Y8F583jEOVjzQM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-45Y8F583jEOVjzQM .rough-node .label text,#mermaid-svg-45Y8F583jEOVjzQM .node .label text,#mermaid-svg-45Y8F583jEOVjzQM .image-shape .label,#mermaid-svg-45Y8F583jEOVjzQM .icon-shape .label{text-anchor:middle;}#mermaid-svg-45Y8F583jEOVjzQM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-45Y8F583jEOVjzQM .rough-node .label,#mermaid-svg-45Y8F583jEOVjzQM .node .label,#mermaid-svg-45Y8F583jEOVjzQM .image-shape .label,#mermaid-svg-45Y8F583jEOVjzQM .icon-shape .label{text-align:center;}#mermaid-svg-45Y8F583jEOVjzQM .node.clickable{cursor:pointer;}#mermaid-svg-45Y8F583jEOVjzQM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-45Y8F583jEOVjzQM .arrowheadPath{fill:#333333;}#mermaid-svg-45Y8F583jEOVjzQM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-45Y8F583jEOVjzQM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-45Y8F583jEOVjzQM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-45Y8F583jEOVjzQM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-45Y8F583jEOVjzQM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-45Y8F583jEOVjzQM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-45Y8F583jEOVjzQM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-45Y8F583jEOVjzQM .cluster text{fill:#333;}#mermaid-svg-45Y8F583jEOVjzQM .cluster span{color:#333;}#mermaid-svg-45Y8F583jEOVjzQM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-45Y8F583jEOVjzQM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-45Y8F583jEOVjzQM rect.text{fill:none;stroke-width:0;}#mermaid-svg-45Y8F583jEOVjzQM .icon-shape,#mermaid-svg-45Y8F583jEOVjzQM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-45Y8F583jEOVjzQM .icon-shape p,#mermaid-svg-45Y8F583jEOVjzQM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-45Y8F583jEOVjzQM .icon-shape .label rect,#mermaid-svg-45Y8F583jEOVjzQM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-45Y8F583jEOVjzQM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-45Y8F583jEOVjzQM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-45Y8F583jEOVjzQM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 修改 schema.prisma
npx prisma migrate dev --name 描述
自动生成 SQL 迁移文件
应用到开发数据库
重新生成 Prisma Client
提交 migration 到 Git
关键原则:
- 迁移文件必须提交到版本控制,保证团队和环境一致
- 生产环境的迁移由 CI/CD 流程执行,不要在服务器上手动改表
5.2 种子数据
prisma/seed.ts 负责初始化两类数据:
| 类型 | 内容 | 目的 |
|---|---|---|
| 基础配置 | Character、Policy、SeasonEvent | 让新环境能直接运行游戏逻辑 |
| 测试数据 | 演示用的 User、GameState | 本地开发时能看到非空界面 |
执行方式:
bash
npx prisma db seed
6. 兼容性与演进
6.1 命名映射
使用 @@map 和 @map 将 Prisma 模型名映射到实际的数据库表名:
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 类型安全的业务代码
typescript
// ✅ 直接从 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 添加新字段的注意事项
prisma
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 或标记为可选,避免旧数据迁移失败 |
后续章节将基于本章的数据模型,展开业务逻辑的实现细节。理解这些模型之间的关系和设计意图,是阅读后续内容的基础。