项目:nestjs-admin | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
系统采用 "数据库即服务私有物"(database-per-service) 的存储拆分,两套 Prisma 工程各自管理自己的库与迁移:
| 维度 | user 域 | system 域 |
|---|---|---|
| 数据库 | user_db(PostgreSQL 15/16) | system_db(同一 PostgreSQL 实例的独立库) |
| 连接串变量 | DATABASE_URL(postgresql://nestjs:nestjs123@127.0.0.1:5432/user_db) | SYSTEM_DATABASE_URL(同实例 system_db) |
| Schema 源 | user/prisma/schema.prisma | system/prisma/schema.prisma |
| 迁移目录 | user/prisma/migrations/(9 个迁移:initial→role→base_v1→admin_v1→dept→user_role_status→user_profile→user_avatar→user_settings) | system/prisma/migrations/(4 个:init→dict_log→add_notices→system_logs) |
| 种子数据 | user/prisma/seed.sql(默认角色 user(id=1)、permissions 4 条、policy can:read:user、role_policies) | system/prisma/seed.sql(33 条菜单/权限码种子 + role_menus 全量授权 + notices 演示 4 条) |
| ORM 配置 | Prisma 7 新 prisma-client generator(output=../src/generated/prisma-client,runtime=nodejs,moduleFormat=cjs);datasource db 不写 url,由 prisma.config.ts 提供给 CLI,运行时由 database.service.ts 传驱动适配器 | 同左 |
| 运行时访问 | user/src/database/database.service.ts:new PrismaClient({ adapter: new PrismaPg({ connectionString }) }) | system/src/database/database.service.ts:注意取 `SYSTEM_DATABASE_URL |
| 生成物 | src/generated/prisma-client/(模型类在 models/*.ts,如 User.ts/Dept.ts/Role.ts) | models/DictItem.ts、Menu.ts、Notice.ts 等 |
对象存储不落库文件本体:头像等上传文件存 MinIO(S3 兼容,默认桶 nestjs-demo,由 gateway/src/common/upload/upload.service.ts 的 bucketExists/makeBucket 兜底建桶),数据库中仅存 /uploads/<objectName> 相对 URL 字符串。网关内存(非持久化)还维护一份会话数据(SessionService 三张 Map),进程重启即清空——在线用户/强退状态因此不构成持久数据模型。
命名约定:Prisma 模型通过 @@map 映射到蛇形复数表名(User→users、UserRole→user_roles、Policy→policy 等);主键一律 Int @id @default(autoincrement());布尔语义用 Int(1 启用/0 停用);时间只有 createdAt DateTime @default(now()),无 updatedAt 字段;种子用显式 ID 插入后必须 setval 推进序列(seed.sql 内注释:"否则后续 CREATE 撞主键")。
这套模型有几个"反教科书"但深思熟虑的取舍,接手前应理解其意图而非急着"修正"。没有软删除:全库没有 deletedAt 之类字段,用户、角色、菜单都是硬删除,靠服务层前置校验(部门删前查子部门与在编用户、用户删除连带清理 user_roles)保护引用完整性——对管理后台而言,回收站带来的查询复杂度与权限边界问题远超其价值,且操作日志/登录日志天然提供了"删了什么"的审计线索。没有 updatedAt:所有表的变更时间审计实际由 oper_logs 承担(记录了谁在何时对哪个 URL 做了什么),表内时间戳只保留创建时刻,避免了双时间戳维护不同步的问题。布尔用 Int:与前后端整体约定一致(status=1/0 在 proto、JSON、前端枚举三处同形),省去序列化层的布尔转换;代价是 SQL 层失去布尔语义的可读性,这是有意识的取舍。关联约束选择性缺失:role_menus.roleId 不设外键(角色在另一个库)、日志表的用户名是弱引用,这些"缺约束"都是 database-per-service 拆分的必然结果,补约束的正确位置在服务层校验与网关聚合逻辑,而不是数据库层。JSON 字段仅两处(policy 的 fields/conditions 与 users.preferences),前者是 CASL 规则的结构化载荷、后者是低价值偏好快照,其余数据全部关系化——项目刻意避免"JSON 什么都塞"的反模式,保证任何字段都能被 SQL 精确过滤。
User)| 字段 | 类型 | 约束/默认 | 说明 |
|---|---|---|---|
| id | Int | PK, autoincrement | 用户 ID(signin 返回的 userId) |
| username | String | unique | 登录名;注册/建用户冲突时 Prisma P2002 → gRPC ALREADY_EXISTS → HTTP 409 |
| password | String | 必填 | argon2id 哈希(写入侧 argon2.hash(..., {type: argon2id}),校验侧 argon2.verify) |
| nickname / phone / email / avatar | String | default "" | 资料;avatar 存 /uploads/... 相对 URL;phone 支持模糊查询(ListUsers contains) |
| status | Int | default 1 | 1 启用 / 0 停用(UpdateUserStatus 切换) |
| deptId | Int? | FK→depts.id | 可空外键;部门删除前校验 user.findFirst({deptId}) |
| preferences | String | default "{}" | 偏好设置 JSON 串(如 {msgNotify,sysNotify,todoNotify},schema 注释) |
| securityQuestion / securityAnswer / backupEmail | String | default "" | 密保三件套(M9) |
| createdAt | DateTime | default now() | — |
Dept)自关联树:parentId Int? → parent Dept? / children Dept[](relation "DeptTree")。字段:name、order Int @default(0)(排序)、status Int @default(1)、users User[] 反向关系。删除约束在服务层:有子部门(findFirst({parentId:id}))或部门下有用户时抛 FAILED_PRECONDITION(网关映射 400)。
roles:id、name String @unique(重名 409)、description String?、status Int @default(1);反向关系 users UserRole[]、RolePermissions[]、RolePolicy[]。种子 id=1 为 user 默认注册角色(ROLE_ID 环境变量引用)。user_roles:复合主键 @@id([userId, roleId]),双 FK。分配角色采用全量覆盖语义:事务内先 deleteMany({userId}) 再 createMany(UpdateUserRoles),并预校验角色存在性避免外键 500。删除用户时同事务清理 userRole.deleteMany。这是历史沿用的 course 课程权限模型(user/prisma/seed.sql 注释"course 权限模型"),当前实际生效的是 policy 链路:
permissions:name @unique(形如 user:create)、action(READ/CREATE/UPDATE/DELETE/MANAGE)、description。与 roles 经 role_permissions(复合 PK)多对多——模型层存在但运行时鉴权未消费(GetUserRules 只走 RolePolicy 链)。policy:CASL 规则本体——type Int(0-json/1-mongo/2-function)、effect String('can'/'cannot')、action String(CRUD+manage)、subject String、fields Json?、conditions Json?、args Json?、encode String @unique(base64 编码字段,幂等种子的唯一键,如 can:read:user)。role_policies:roleId+policyId 复合 PK。GetUserRules RPC 用四层 include(UserRole→Role→RolePolicy→Policy)把规则拍平输出给网关(user/src/app.controller.ts 的 getUserRules)。permission_polices:permissionId+policyId(注意表名拼写就是 permission_polices,schema 原样),路由级策略预留,当前无写入方。menus:自关联树(parentId、relation "MenuTree");name、type Int @default(1)(0 目录 / 1 菜单 / 2 按钮,schema 注释;前端扩展语义 3=iframe、4=外链仅是同一字段的约定用法,见 vue-admin/src/api/routes.ts 的 toRoute)、path(菜单路由路径,iframe/外链时存完整 URL)、component(视图路径约定 system/user/index)、permission(权限码,按钮型必填)、icon(种子用 setting/user/tree-table/dict/log/form 等短名,前端 ICON_MAP 映射为 ep:*)、order、status、createdAt、roleMenus RoleMenu[]。
role_menus:复合 PK @@id([roleId, menuId]),只有 menu 侧 FK(roleId 不设 FK,角色在 user_db,跨库无法约束——这是 database-per-service 的直接体现)。种子授予 INSERT INTO role_menus SELECT 1, id FROM menus。授权接口语义同为全量覆盖(AssignRoleMenus)。权限码即菜单记录:网关 GetPermissionCodes 按角色聚合 permission 非空记录,因此"新增按钮权限 = 插一条 type=2 菜单 + 授权"。
dict_types:name @unique、code @unique、status;items DictItem[]。dict_items:dictTypeId FK→dict_types、label、value、order、status。按 code 查项时网关先 listDictTypes 解析 code→typeId(createDictItemByCode),查询侧 ListDictItems({code}) 服务端 join 解析。| 表 | 字段 | 写入方 | 特点 |
|---|---|---|---|
oper_logs 操作日志 | id、traceId(default "")、userId?、username(default "")、module(default "")、method、url、ip、statusCode、durationMs、createdAt | 网关中间件对 POST/PUT/DELETE/PATCH 自动写(RequestLoggingMiddleware) | 每个写请求都产生一条(含日志接口自身的删除,HANDOFF 提示冒烟断言按"目标 id 消失"而非数量归零);支持 username/module 模糊、status、时间范围、page/pageSize 真分页 |
login_logs 登录日志 | id、username、ip、success Boolean、message、createdAt | 网关中间件对 `/api/auth/signin | signup` 自动写(success=statusCode<400) |
system_logs 系统日志 | id、level(default "info",info/warn/error)、module(default "")、message、detail(default "")、createdAt | 网关 AllExceptionsFilter 对 ≥500 错误写(module 固定 'gateway') | 无分页参数(前端切片);detail 存截断后的堆栈/JSON;无删除接口 |
category Int @default(1)(1 通知 / 2 消息 / 3 待办,schema 注释与前端 lay-notice 三 Tab 对应)、title、description、avatar、extra、status String @default("")(存前端 el-tag type:primary/success/warning/info/danger)、read Boolean @default(false)、createdAt。已读支持单条(MarkNoticeRead)与按类全量(MarkAllNoticesRead)。种子含 4 条演示数据(HANDOFF M8)。
这张表值得单独展开说几句,因为它在两处体现了"数据模型服务于组件形状"的实用主义取向。一是 status 字段直接存储前端 el-tag 组件的类型枚举值——按教科书做法这里应该存语义枚举(如严重级别)再由前端映射样式,但本项目选择了"后端直出渲染参数",好处是通知的展示形态可以随数据调整而无需发版,代价是数据层与特定 UI 库产生了耦合,若前端换组件库需要一次数据迁移;二是"未读数"不在表里持久化,而是 listNotices 每次聚合时按 read=false 实时统计(前端铃铛角标与三 Tab 的 unread 均来自该接口响应),这对当前的数据量级完全够用,也避免了"已读动作要同步更新计数器"的一致性负担。此外,read-all 支持按 category 局部已读(传 {category})或全量已读(空 body),这个接口形状是按前端铃铛"逐 Tab 标已读"的交互倒推设计的——读 04 篇的读者今后设计表结构时,不妨借鉴这种"先看消费方的组件树,再定字段"的顺序。
两库之间的逻辑关联只有两条,都不存在数据库外键:① role_menus.roleId ↔ user_db roles.id(授权页由前端先拉角色列表再操作);② 网关鉴权时按 username 先查 user 库角色、再查 system 库权限码(authorization.service.ts)。日志表的 username 也只是弱引用(登录日志允许记录不存在用户的失败尝试,writeLoginLog 的 username 可为 'anonymous')。
从 ER 图还能读出几条数据访问的典型路径,排障时非常有用。鉴权路径是全系统最热的读路径:网关按 username 命中 users(unique 索引)→ 沿 user_roles 到 roles → 两路分叉,一路经 role_policies 到 policy(user 库),另一路跨库查 system 的 menus.permission 聚合权限码——得益于 60 秒 Ability 缓存,这条四表链路每用户每分钟至多走一次。菜单路径有三个消费方:菜单管理页要全量(listMenus 不带角色过滤)、侧边栏要按角色过滤后的子集(my-menus,服务端按 role_menus 做过滤而非全查后过滤)、授权页要"全量树 + 某角色已选集合"两个接口拼装;三种读法共享同一张表,靠 type 与 status 字段区分可见性。日志路径的特点是只增不改、写多读少:oper_logs 的写入方是网关中间件(每个写请求一条),读取方是监控页的真分页查询,二者通过独立的连接池互不阻塞;日志表没有索引优化(除主键外无显式索引),因此监控页的筛选查询在数据量大后主要依赖时间范围条件缩小扫描面,这也是 06 篇运维章节建议定期清空日志的原因。通知路径最简单:铃铛组件进入页面拉一次全量,已读状态是唯一会被更新的字段,未来如果要按用户隔离通知(当前 notices 表没有 user_id 字段,是全局广播模型),需要在表上加接收者维度并同步改造 listNotices 的过滤逻辑——这是一个明确的待扩展点。
数据生命周期管理的总体思路是"配置数据长存、审计数据可控、会话数据易失、对象数据旁路"。下面按表逐一展开,重点是每类数据的增长控制手段与运维责任边界。
| 数据 | 产生 | 读取 | 归档/删除 | 增长控制 |
|---|---|---|---|---|
| 用户/角色/部门/字典/菜单 | 管理页 CRUD | 高频读(列表/树/鉴权) | 硬删除(部门有约束校验;用户删除连带 user_roles) | 低速增长,无清理机制 |
| 权限码(菜单 type=2) | seed + 菜单管理页 | 每用户 60s 缓存周期拉取 | 随菜单删除 | 几十条量级 |
| oper_logs | 每个写请求自动 1 条 | 监控页真分页查询 | 批量删 :ids / clean 清空(权限码控制) | 高速增长(与写流量线性),需运维定期清空 |
| login_logs | 每次 signin/signup 1 条 | 监控页 + 个人中心安全日志 | 同上 | 中速增长 |
| system_logs | 网关 5xx 异常 | 系统日志页 | 无删除接口(页面按钮明确"暂不支持") | 与故障率相关,通常低速 |
| notices | 管理员创建 + seed | 铃铛轮询/进入时拉取 | 仅标记已读,无删除接口 | 低速;⚠️ 待确认是否有清理计划 |
| 会话(SessionService) | signin | 每请求 has/touch;在线页 listOnline | 登出/强退/8 天陈旧清扫(30min 周期) | 内存 Map,容量受清扫兜底 |
| 头像文件(MinIO) | 上传接口 | /uploads/:key 回读 | 无删除链路(旧文件覆盖引用但不删对象) | 随上传次数单调增长 |
这张生命周期表背后有两个隐性风险需要运维自觉管理。第一是oper_logs 的无限增长:网关每处理一个写请求就插一行,其中还包括对日志接口自身的删除操作(删 30 条会先写 1 条),在有人常态化使用后台的场景下日增数百行是正常速率,而表上只有主键索引,监控页按用户名模糊筛选会随数据量线性变慢——建议在日志总量达到十万行级之前建立清空节奏(页面上的"清空"按钮或直接 DELETE /api/monitor/oper-logs/clean),或自行补充按 createdAt 的定期清理任务(目前代码没有,属于安全的纯运维动作)。第二是 MinIO 对象的孤儿化:用户每次换头像都会写入一个带时间戳的新对象,users.avatar 只保留最新 URL,旧对象永远留在桶里且无任何清理入口;桶内还有上传失败中断的残片。若该系统长期运行,应考虑加一个"按 users.avatar 反查、删除无引用对象"的周期任务,或在 UploadService 里补"更新头像前先删旧对象"的逻辑——后者改动极小(Service 已有 remove 方法,只是没有调用方)。这两项都不影响当前功能正确性,但都属于"数据模型文档应向接手者交接的债务清单"。
CREATE DATABASE,compose 中由 system-db-init 任务负责)→ npx prisma migrate deploy(user 导出 DATABASE_URL、system 导出 SYSTEM_DATABASE_URL,Prisma 7 经 prisma.config.ts 读取)→ psql -f prisma/seed.sql。HANDOFF 陷阱 31 记录了真实事故:换机后只跑迁移没重放 seed,live 库缺 seed id23-32 全部菜单,导致字典修改/删除、通知管理等权限点全部 403。prisma migrate dev 后必须显式 prisma generate(Prisma 7 不保证自动执行,HANDOFF 陷阱 7);模型字段类型在 generated/prisma-client/models/*.ts 而非 client.ts。.github/workflows/ci.yml):起 postgres 容器后 pnpm --filter @toimc/user exec prisma migrate deploy,再跑单测与 e2e;compose 的 migrate/migrate-system 是一次性任务(service_completed_successfully 门控 user/system 启动)。prisma.config.ts(读 SYSTEM_DATABASE_URL),运行时走 database.service.ts(SYSTEM_DATABASE_URL || DATABASE_URL),任一指向错误库都会复现"relation does not exist → 权限码 403/500"事故。迁移目录本身就是一份模型演化的编年史,读它能避免许多"为什么这个字段长这样"的困惑。user 库的九个迁移记录了三个时代:2024 年 5 月的四个迁移(initial、role、base_v1、admin_v1)搭起了用户/角色/权限模型的骨架,是课程时代的产物;2026 年 9 月 7 日的 dept 迁移与同日的两个迁移(user_role_status、user_profile)对应 M2/M6 的部门管理与个人中心浪潮——nickname/phone/email/avatar/profile 等资料字段、UserRole 状态位都是这一波加入的;9 月 9 日的 user_settings 迁移则对应 M9 的偏好与密保需求。system 库的四个迁移更紧凑:init 建菜单与角色-菜单,dict_log 同日补字典与两类日志,9 月 9 日一天内连发 add_notices 与 system_logs 两个迁移,分别对应 M8 的通知需求和"系统日志页无数据源"的补课。理解这条时间线的实际意义在于:新增字段时应当续写迁移而不是改老迁移(老迁移在其他环境已执行过,改动会导致校验和失配),且每个迁移都应在对应 schema 同步更新后跑 prisma generate——HANDOFF 陷阱 7 明确记录过 Prisma 7 在 migrate dev 后不保证自动生成客户端。另一个从迁移史里读出的运维事实是:两库迁移节奏各自独立(时间戳成对出现纯属巧合,如 20260907190954_dept 与 20260907190955_dict_log),部署时必须分别对两库执行 migrate deploy,compose 里也正是这样安排的(migrate 与 migrate-system 两个一次性任务)。