数据库设计
表结构基准:每张表为什么长这样,以及一批贯穿全部表的约定。列注释一律英文。
本文件与 database/migrations/ 冲突时以 migration 为准——migration 是会被执行的那一份。
1. 约定
这些规则先定下来,后面每张表都照着来,省得一张一张争。
1.1 存储与字符集
ENGINE=InnoDB、CHARSET=utf8mb4、COLLATE=utf8mb4_general_ci。utf8mb4 是唯一选项——emoji、罕用汉字、各种语言的组合字符都要存,utf8 三字节存不下。排序规则用 general_ci 而不是 MySQL 8 的默认 0900_ai_ci,因为后者在 5.7 上不存在,而部署环境的 MySQL 版本还没确认。
1.2 主键
统一 id bigint unsigned AUTO_INCREMENT。理由是 InnoDB 的聚簇索引:主键随机(比如随机字符串)会让每次插入落在 B+ 树的随机位置,页分裂和写放大都跟着来。
但内部主键不对外暴露。 面向 App 用户的实体额外有一个 uid char(16) 唯一键,API 里出现的是它。自增 ID 一旦出现在接口里,别人就能数出你有多少用户,也能顺着枚举。表间关联用 id(user_id bigint),只有对外的载荷用 uid。
后台自己的表不需要这层:管理员数量本来就不是秘密,也没有对外接口。
1.3 时间
create_time / update_time / delete_time,一律 int unsigned,存 Unix 时间戳。
不用 DATETIME / TIMESTAMP 的原因:时间戳没有时区问题(TIMESTAMP 的值取决于连接的 time_zone,同一行在两个连接里读出来可以不一样),比较和排序就是整数比较,跟 plato::timestamp() 直接对得上,common\model\model 的 $_created_at / $_updated_at 默认值也正是这两个名字。int unsigned 上限是 2106 年。
框架 blueprint::timestamps() 生成的是 created_at / updated_at 两个 TIMESTAMP 列,本项目不用它,手写这三列。
1.4 软删除
需要软删除的表用 delete_time int unsigned NOT NULL DEFAULT 0,0 表示未删除,非 0 是删除时刻。
不用可空列,是为了唯一索引:UNIQUE KEY (username, delete_time) 能表达"活着的行里用户名唯一,删掉的行不占名字",而 NULL 在唯一索引里互不相等,同一个用户名可以删掉无数次却每次都占着位置。
不是所有表都需要软删除。日志类表只增不改,删就是真删(按时间清理),别加。
1.5 类型选择
| 用途 | 类型 | 说明 |
|---|---|---|
| 金额 | bigint unsigned,单位分 |
不用 DECIMAL,更不用浮点。整数分做加减不会出现 0.1+0.2 |
| 状态、类型 | tinyint unsigned + PHP backed enum |
不用 MySQL ENUM:加一个取值要 ALTER TABLE,而取值集合是业务的事,属于 common/define/ |
| 布尔 | tinyint unsigned,0/1 |
|
| 结构化数据 | json |
权限清单、日志载荷这类"整体读写、不按内部字段查"的东西。要按内部字段查就说明它该是一张表 |
注意 MySQL 的 JSON 是解析后存的,对象的键顺序不保留(写 {"before":1,"after":2} 读回来可能是 {"after":2,"before":1})。别把顺序当信息,需要有序就存数组 |
||
| IP | varchar(45) |
IPv6 全展开是 45 字符 |
| 国家 | char(2) |
ISO 3166-1 alpha-2 |
| 密码 | varchar(255) |
password_hash() 的输出长度随算法变,bcrypt 现在是 60,留够 |
1.6 NULL
默认不允许 NULL,字符串默认 '',数字默认 0。只有当"没有值"和"值是空"确实是两件事的时候才用可空列,并在注释里说明区别。这样应用层不用到处 ?? '',索引也不用处理三值逻辑。
1.7 索引
- 命名:唯一索引
uk_,普通索引idx_,后面接列名用下划线连 - 复合索引按最左前缀原则排列,注释里写清它服务的是哪个查询
- 不建外键约束。原因是运维:外键让在线 DDL 变复杂,让分库分表变不可能,让删数据的顺序变成一个必须记住的事。引用完整性由应用层保证——这是本项目的选择,不是普遍真理,代价是脏数据不会被数据库挡住
1.8 注释
每张表、每一列都写 COMMENT,英文。状态列的注释必须列出全部取值及其含义,因为那是唯一一份写在数据旁边的取值文档。
1.9 表名
不带 plt_ 前缀写在代码里,一律写 #PB#_xxx,由 DB_PREFIX 在运行时展开。common\model\model::table() 自动加这个占位符,Model 的 $_table 只写裸名。
2. 后台基础域
这一批表和产品形态无关——无论 App 那边做成什么,后台都需要账号、角色、日志、配置。所以先落地它们,产品域的表等需求定稿。
2.1 admin 管理员
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
username |
varchar(50) | 登录名 |
password |
varchar(255) | password_hash(),bcrypt |
nickname |
varchar(50) | 显示名 |
email |
varchar(120) | |
role_id |
bigint unsigned | 所属角色 |
purviews |
json | 角色之外额外授予这个人的权限,[] 表示没有 |
status |
tinyint unsigned | 0=禁用 1=正常 |
must_change_password |
tinyint unsigned | 1=下次登录强制改密 |
otp_secret |
varchar(64) | TOTP 密钥,'' 表示未绑定 |
otp_enabled |
tinyint unsigned | 1=登录需要 TOTP |
safe_ips |
varchar(500) | 允许登录的 IP,逗号分隔,'' 表示不限制 |
expire_time |
int unsigned | 账号失效时刻,0=永不失效 |
last_login_time / last_login_ip |
int unsigned / varchar(45) | 冗余,为了列表页不用连日志表 |
create_time / update_time / delete_time |
int unsigned |
唯一键 uk_username (username, delete_time)。
purviews 是一个人的额外授权清单,整体读写,从不按元素查询,所以是一列 JSON 而不是一张关联表。
2.2 admin_role 角色
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
name |
varchar(50) | 角色名,唯一 |
purviews |
json | 权限清单,["*"] 表示全部 |
remark |
varchar(255) | |
status |
tinyint unsigned | 0=停用 1=启用 |
create_time / update_time / delete_time |
int unsigned |
权限的写法是 ct:ac,和中间件的路由模式同一套写法(order:pay、order:*、*)。用同一套分隔符,是为了权限清单和 config/config.php 里的 middleware 模式可以互相看懂,也让人少记一个规则。
菜单不是权限来源。 权限清单来自控制器的 $actions 声明——那才是"这个系统有哪些动作"的唯一事实;菜单(第 2.8 节的 admin_menu 表)只是导航。
2.3 admin_session 会话
后台用 PHP session 认证,但"在线列表"和"强制下线"需要一份服务端可查、可作废的记录,所以有这张表。
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
session_id |
char(64) | PHP session id,唯一 |
admin_id |
bigint unsigned | |
username |
varchar(50) | 快照,管理员改名或删号后日志仍可读 |
ip / country / user_agent |
varchar(45) / char(2) / varchar(255) | |
login_time / active_time / expire_time |
int unsigned | |
status |
tinyint unsigned | 0=已终止 1=活跃 |
create_time |
int unsigned |
强制下线是把 status 置 0,下一次请求鉴权时发现记录不再活跃就拒绝。
2.4 admin_login_log 登录日志
成功和失败都记。失败的行 admin_id 是 0(账号可能根本不存在),但 username 照记——排查撞库要的正是这个。
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
admin_id |
bigint unsigned | 0=账号不存在 |
username |
varchar(50) | 登录时提交的账号 |
ip / country / user_agent |
||
result |
tinyint unsigned | 0=失败 1=成功 |
reason |
varchar(100) | 失败原因,成功时 '' |
create_time |
int unsigned |
索引 idx_admin_id_create_time、idx_ip_create_time。
2.5 admin_oplog 操作日志
简要说明和完整载荷记在同一张表:分成"简版"和"详版"两张的话,同一次操作在两处各留一半,查的时候两边都要看。
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
admin_id / username |
bigint unsigned / varchar(50) | |
ct / ac |
varchar(50) | 路由,和权限清单同一套标识 |
summary |
varchar(255) | 一句话说明这次操作 |
payload |
json | 参数与变更前后值 |
ip / country |
||
level |
tinyint unsigned | 0=普通 1=需要注意 2=告警 |
is_read |
tinyint unsigned | 告警是否已处理 |
create_time |
int unsigned |
索引 idx_admin_id_create_time、idx_level_is_read_create_time(告警未读列表)、idx_create_time(按时间清理)。
2.6 setting 系统配置
后台可编辑的键值配置。
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
group_name |
varchar(50) | 分组,决定在哪个 tab 下 |
key_name |
varchar(100) | 键名 |
value |
text | 值,一律按字符串存,怎么读由 common\service\setting::DEFINITIONS 决定 |
create_time / update_time |
int unsigned |
唯一键 uk_group_key (group_name, key_name)。
这张表只存值。 建表时还有 type / title / options / remark / sort 五列,20260801_000002 把它们删了:那是"把一张表单存进表里",有两个问题。一是没人读的设置是假的——setting::value_of() 要么在代码里被调用,要么根本没人调,所以从表单新增一行只会造出看着像配置、实际不改变任何行为的记录;有哪些设置因此写在读它的代码旁边,即 common\service\setting::DEFINITIONS,屏幕只改值。二是 title varchar(100) 装不下中英两种,而这个后台是双语的——#PB#_admin_menu 用 titles JSON 解决了同样的问题,那里成立是因为菜单项确实是人加的数据;这里退而求其次存一个语言 key,就成了"代码存进数据库",还会跟读它的代码漂移。设置名写在 common/lang/{locale}/setting.php,按设置名索引。
没被改过的设置没有行。 全新装完这张表是空的,跟 schedule_state 一样——所以没有 seeder 要跟默认值保持同步,某个版本改了默认值,对所有没覆盖过它的部署直接生效。
表名叫 setting 不叫 config:Model 类名要和表名一致,而 common\model\config 会和 plato\config 撞名,每个碰配置的文件都得起别名。config 是文件里的东西,setting 是运营改的东西,分开叫也更准。
这里只放运营要改的东西。密钥、连接串、第三方凭据一律只在 .env,不进这张表——数据库会被导出、会被备份、会被开发环境拉一份,环境文件不会。
2.7 schedule_state 计划任务开关与运行记录
任务定义静态化,但保留后台临时停用的能力。所以这张表存的不是任务定义,只是覆盖状态。任务本身在 admin/app/config/schedule.php 里,后台不能新增、不能改表达式,只能停用和恢复。
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
task_key |
varchar(100) | 与 schedule.php 里的任务标识一致,唯一 |
is_paused |
tinyint unsigned | 1=已停用 |
paused_by |
varchar(50) | 谁停的 |
paused_time |
int unsigned | |
remark |
varchar(255) | 为什么停 |
last_run_time |
int unsigned | 上次执行的开始时刻,0=没跑过 |
last_duration |
int unsigned | 上次执行耗时,毫秒 |
last_status |
tinyint unsigned | 0=没跑过,1=成功,2=失败 |
create_time / update_time |
int unsigned |
这张表现在不是平时空的了(20260801_000003 之前是)。加运行记录之前只有人动过的任务才有行;现在任务跑过一次就有行,只有调度器还没起来过的部署才是空表。is_paused 默认 0,所以「没有记录 = 允许运行」这条不变,may_run() 依赖的就是它。
耗时用毫秒不用秒:这几个任务健康时都在一秒以内跑完,用秒存的话每一行都显示 0,直到某天真的开始要跑几分钟为止——而那正是唯一需要这一列的时候。
last_status 需求里没要,还是加了:只有时间和时长的话,一个每晚都失败的任务和一个每晚都成功的任务在页面上长得一模一样。
「下次执行」不落库。 它是 cron 表达式的纯函数,表达式在 admin/app/config/schedule.php 里,存一份副本的话有人改了配置就会过期——而且要等到该任务下次跑完才更新。页面每次渲染用 cron::next() 现算,见 admin\support\schedule_tasks::next()。
2.8 admin_menu 后台导航
20260731_000003 建的一张表,内置条目由 db:seed 写进去。
它只是导航,不是权限表。 有哪些动作来自每个控制器的 public static $actions(它跟路由器实际会派发的东西不可能不一致),这张表在渲染时被调用者的权限过滤一遍。这里的一行永远只能藏起某个东西,不能授予任何东西。
| 列 | 说明 |
|---|---|
code |
稳定标识,seeder 按它 upsert,活行内唯一 |
parent_id |
0 是一级。列上没有任何东西阻止指回自己的子树,所以 menu::_branch() 有深度上限 |
titles |
JSON,{"zh-cn": "...", "en": "..."} |
ct / ac |
空表示这是分组标题而不是目的地 |
sort |
升序,seeder 按 10 递增留出插入空间 |
status |
0 隐藏 1 显示 |
标题存 JSON 而不是语言包键:这张表存在的理由就是不用发版就能加一条,而键列还是会把加的人赶回 PHP 文件里去写字。只有两种语言,JSON 列比翻译表划算。
admin_menu::put() 的更新列里没有 status——重新跑 seeder 会纠正内置条目的文案和顺序,但不会把谁手动隐藏的条目重新打开。
条目在对应界面存在之后才加进 seeder。 菜单是被当成承诺读的:持 * 的账号会看到每一项,没有控制器的那一项就是应用自己的导航指向 404。管理屏(admin_menu:index)用同一条理由做了同一件事:目的地是从 admin\support\routes 读控制器 $actions 得到的下拉框,不是一个能随便打字的输入框。
管理屏还遵守两条来自这张表本身的规矩:
code只在新建时能填,之后不能改——它是db:seed认条目的依据,改名会让下一次部署在旁边插一份内置条目的副本。- 删除内置条目会被下一次
db:seed原样加回来(put()按codeupsert,而软删的行不占用code)。要让一项一直不出现,用隐藏——status不在put()的更新列里,隐藏才是能扛过部署的那个动作。
3. App 用户域
20260801_000004 建的三张表,服务 api 的登录链路。照本文件第 1 节的约定画:时间列是 int unsigned 的 Unix 时间戳而不是 TIMESTAMP,公开标识是 char(16) 而不是自增 ID。没有 password 列——登录方式定的是邮箱验证码、短信验证码、Google、Apple,没有一条用得上密码,等真要加的时候是一个 migration 加一个分支的事。
3.1 user 用户
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | 只在服务端出现 |
uid |
char(16) | 随机,接口里出现的是它,唯一 |
phone |
varchar(20) 可空 | E.164 去掉 +;登录凭证 |
email |
varchar(255) 可空 | 登录凭证 |
email_verified_time |
int unsigned | 0 表示未验证 |
nickname / avatar / gender |
varchar(50) / varchar(512) / tinyint | 头像存路径不含域名;性别 0 未知 1 男 2 女 |
status |
tinyint unsigned | 0 封禁 / 1 正常 / 2 注销冷静期 / 3 已注销 |
deactivate_time |
int unsigned | 申请注销时刻,0 表示没申请过 |
last_login_time / last_login_ip |
int unsigned / varchar(45) | |
create_time / update_time / delete_time |
int unsigned |
唯一键 uk_uid(uid)、uk_phone(phone, delete_time)、uk_email(email, delete_time)。
phone 和 email 可空是第 1.6 节说的那种例外,不是漏了。 两列都是「唯一 + 可选」:用 Apple 登录的账号两样都没有,如果按约定默认 '',第二个这样的账号就会撞上唯一键。NULL 在唯一索引里互不相等,正好表达「没绑」。
注销分两步而不是一步删:status=2 是冷静期,这期间登录即撤销注销(user::touch_login() 顺手把状态改回 1),比让用户去找一个「取消注销」的按钮更接近他们真实的意图;冷静期满转 status=3,行还留着,为的是手机号和邮箱在数据被清理之前不被别人拿去注册。
3.2 user_openid 第三方绑定
| 列 | 说明 |
|---|---|
user_id |
#PB#_user.id |
provider |
1=google 2=apple,取值在 common\define\user_code |
openid |
提供方的 sub,varchar(191) |
email |
提供方报的地址,可空(Apple 的私密转发地址也存这里) |
唯一键 uk_provider_openid(provider, openid, delete_time),另有 idx_user_id。
单独一张表而不是 user 上的两列:一个人可以手机上用 Apple、平板上用 Google,那是一个账号两条绑定。匹配一律用 sub 不用邮箱——邮箱会被提供方回收、转发、改掉,sub 是他们唯一承诺稳定的东西。
3.3 user_session 刷新会话
| 列 | 说明 |
|---|---|
user_id |
|
refresh_hash |
refresh token 的 sha256,char(64),唯一。明文不入库 |
device_id / os / ip / country / user_agent |
会话列表要显示的东西 |
login_time / active_time / expire_time |
|
status |
0 已终止 1 活跃 |
access token 是 JWT,验签就能用,所以吊销不了;能被拿走的是这一半。退出登录终止这一行,刷新时轮换(旧行终止、开新行),所以一个 refresh token 只能用一次——被复制走的那份第二次来就什么都找不到。存 sha256 而不是明文:这张表被导出也换不来任何一个会话。
4. 文件存储域
4.1 attachment 文件登记表
20260803_000001 建的一张表,登记两个应用写进存储盘的每个文件。盘的定义在 common/config/storage.php:local 是私有盘(只能经 /attachment/download 鉴权取),public 是 nginx 直接服务的那个,s3 是对象存储(配了 S3_BUCKET 才存在)。disk 列存的就是盘名,所以后台改「上传落到哪块盘」只影响之后的文件,已经存下的仍按各自的行读回来。
| 列 | 类型 | 说明 |
|---|---|---|
id |
bigint unsigned | |
disk |
varchar(32) | 存储盘名 |
path |
varchar(500) | 盘内相对路径,正斜杠 |
path_hash |
char(32) | md5(disk:path),唯一性建在它上面 |
name |
varchar(255) | 给人看的文件名 |
ext / mime |
varchar(20) / varchar(128) | 扩展名小写;mime 只有上传时才有 |
size |
bigint unsigned | 字节 |
hash |
char(64) | 内容 sha256,不唯一 |
source |
tinyint unsigned | 0=扫盘发现 1=后台 2=前台用户 |
owner_id / owner_name |
bigint unsigned / varchar(64) | 上传者,名字是当时的快照 |
biz |
varchar(32) | 用途(avatar / content),空表示未分类 |
status |
tinyint unsigned | 0=正常 1=盘上已缺失 |
file_time |
int unsigned | 盘上的 mtime,列表页筛选和排序用的就是它 |
create_time / update_time / delete_time |
int unsigned |
唯一键 uk_path(path_hash, delete_time),另有 idx_file_time、idx_create_time、idx_hash、idx_owner(source, owner_id)。
唯一键建在 path_hash 而不是 (disk, path) 上,理由是索引长度:utf8mb4 一个字符算四字节,两列加起来 2128 字节,在 3072 的上限内,但只要哪天 path 加宽就会撞上去。
delete_time 进唯一键,和 admin 那几张表同样的道理,但这里还多一层:删除是软删,文件先留在盘上,同一个路径在这段时间里被重新写入是很正常的事(重传就是这个形状)——不把 delete_time 放进键的话,重传会撞上一条正在等着被清理的行。
name 上没有索引,是故意的。 页面用 LIKE '%关键词%' 搜它,这种写法用不上索引;建一个只会让每次写入变慢而没有任何读取会用到。真到了扫描扛不住的量,方案是全文索引或前缀搜索,那是一个要拿着行数才能做的决定。
记录是权威,盘是校验。 attachment:reconcile 走一遍每块盘,两个方向都不删东西:盘上有而库里没有的收进来(source=0,没有归属——文件系统不记录是谁写的),库里有而盘上没有的标 status=1。标而不删,是因为文件不见了最常见的原因是挂载没回来,那种情况下删记录会把一次五分钟的故障变成永久的数据丢失。
删除是两段式。 页面上删只盖 delete_time,字节留在盘上;prune:attachment 在 attachment_orphan_days(默认 30 天,下限 7)之后连文件带行一起删。中间这段窗口就是后悔的余地,也是这个页面上没有「彻底删除」按钮的原因——它列的是别人上传的东西。
5. 还没设计的表
以下等需求定稿,不猜:
- App 用户域剩下的部分:设备与推送通道、用户统计。用户是否要分角色、审核是否要独立通道,都属于产品决定
- 业务域:订阅、金币、分类、内容。要先排完后台信息架构