Skip to content

Phase 2:关系模型设计(MongoDB → PostgreSQL) ​

位置变更(2026-11-02):本文自仓库根 docs/plans/ 迁入 docs 站。状态:【已完结】,后续演进见 ADR 0021 与 数据库与初始化数据。

Status: 已完成(2026-10-25 全部拍板并定稿;实施已随 refactor/postgres-migration 落地,收尾摘要见 ADR 0021 的 Implementation 小节) Date: 2026-10-25 上游决策: ADR-0021(TypeORM 1.x / UUID 主键 / 事务装饰器 API 不变 / 回收站完整保留 / 本地+seed 为数据基准 / PG 18-alpine) Phase 3 输入: 本文的逐表映射与约束清单;Phase 4 输入: 第 7 节 ETL 接口约定。

评审记录 ​

  • 2026-10-25 初稿完成 review,修订完毕:1 P0(sys_menu.pid 改可空)、2 P1(数组 unique 误声明勘误 + ETL 预检、总览 FK 口径对齐推荐)、3 P2(uuidv7 取舍记录、递归 CTE 环保护、role_name 收紧预检)、3 P3(locale 索引计数对齐、排序边界明示、递归 CTE 措辞勘正),全部已合入正文。
  • 2026-10-25 全部拍板:维护者对第 8 节 12 个开放问题逐条定案,结论已合入正文对应小节(含计数回调:log_operate / app_error 增 raw_* 旁路列、app_setting 增 created_by 列、auth_refresh_token.device_id 建 FK),第 8 节转为拍板结果存档形态。
  • 当前状态:已完成(原「已全部拍板,定稿待最终批准」的口径作废 —— 实施已落地)。

范围:24 个 Mongoose 集合 → PostgreSQL 表结构(列 / 类型 / NULL / 默认 / 唯一 / FK / 索引)。 非目标:TypeORM 实体代码(Phase 3)、ETL 实现(Phase 4)。

输入依据(均已逐个核对):

  • 24 个 schema:apps/server/apps/api/src/modules/**/schema/*.schema.ts
  • 集合名 / virtual 名真源:apps/server/libs/db/src/db.const.ts
  • 列表查询模式:Mongo 版 listAggregate($match 字符串转 $regex 模糊、默认 createdAt desc 排序、$facet 分页 + count;该文件已随 B9 双轨退役,PG 版等价物 apps/server/libs/repository/src/pg-list.builder.ts)
  • 代表性数据访问:lang.repo.service.ts(双 $lookup + $arrayToObject)、area.service.ts(按 pcode 查一层 + 应用层建树 + 缓存)、deleted.service.ts / base.repository.ts(快照软删:查原文档 → JSON.stringify 存 sys_deleted → 真删)
  • seed 实际形态:apps/server/db/seed/*.json(Extended JSON,含 schema 未声明的字段,见开放问题 Q4)

1. 总览 ​

MongoDB 侧 24 个集合全部 1:1 映射为表;数组字段(sys_user.roles、sys_role.menus)拆出 2 张关联表,共 26 张表。

计数口径:列数含 id 与时间列(逐表清单见第 4 节,此为唯一判据);索引数含 PK 与唯一索引;FK 数含关联表上的外键。

#MongoDB 集合PG 表列数jsonb 列数索引FK
1sys_usersys_user13041
2sys_rolesys_role7030
3sys_menusys_menu29141
4sys_langsys_lang7020
5sys_localesys_locale6041
6sys_dict_typesys_dict_type7030
7sys_dict_datasys_dict_data10021
8sys_devicesys_device22440
9sys_user_devicesys_user_device12042
10sys_user_identitysys_user_identity14131
11sys_user_locksys_user_lock10121
12sys_user_mfasys_user_mfa16031
13sys_user_oauthsys_user_oauth7031
14sys_user_preferencesys_user_preference12121
15sys_log_authsys_log_auth13040
16sys_log_operatesys_log_operate25440
17sys_deletedsys_deleted8140
18shared_areashared_area4030
19app_settingapp_setting9020
20app_demoapp_demo17130
21app_monitor_userapp_monitor_user10040
22app_errorapp_error16230
23app_keyapp_key11130
24auth_refresh_tokenauth_refresh_token9052
—(由数组字段拆出)sys_user_role2022
—(由数组字段拆出)sys_role_menu2022
合计(26 表)298178217

FK 合计口径(定案后):shared_area.pcode 不建 FK(Q6 定案);auth_refresh_token.device_id 建 FK(Q5 定案)。


2. 通用约定 ​

2.1 命名 ​

  • 表名 = 原集合名(sys_user 等,已是 snake_case,不改名,保留与 db.const.ts 的对应关系)。
  • 列名 snake_case;Mongo 驼峰字段 → snake_case(userName → user_name)。实体属性名到列名的映射由 Phase 3 实体承担,API 出参仍是驼峰,前端无感。
  • 保留字改名(PG 中作为列名需引号或易踩坑,一律改名并在此登记,Phase 3 实体属性名保持原样):
原字段出现于PG 列名理由
ordersys_role / sys_menu / sys_lang / sys_dict_datasort_orderORDER 是 SQL 关键字
showsys_menu.metais_showSHOW 是 SQL 关键字,且语义本就是布尔
leftapp_monitor_useris_leftLEFT 是保留字,语义本就是布尔

2.2 类型映射规则 ​

Mongo 形态PG 类型说明
_id: ObjectIduuid见第 3 节
Types.ObjectId(引用)uuid(+ FK)或 uuid 松引用FK 政策见 2.4
StringtextPG 中 text 与 varchar 性能无差异;不无脑收窄。仅当存在硬长度上限时用 varchar(n) 形态校验:目前只有 shared_area.code(1/2/4/6/9 位,见 schema 注释)
Number + validateOptions.int(age/order/count 类)integer
枚举数值(gender / fontSize / demoEnum)smallint + CHECK
Number(webauthn_counter)bigintWebAuthn 签名计数器语义上无上限
Booleanboolean NOT NULL DEFAULT
Datetimestamptz一律带时区,理由见 5.4
字符串枚举varchar + CHECK不用 PG 原生 ENUM 类型:原生类型的取值变更要走 ALTER TYPE,跨表复用差、报错信息不友好;CHECK 约束随表走 migration,取值即文档
[String](纯字符串数组)text[]ip_history / webauthn_transports / backup_codes_ciphertext / demo 各数组列
[ObjectId](引用数组)关联表sys_user.roles、sys_role.menus
Object / Recordable / Mixedjsonb逐列判断,清单与理由见 5.5;不无脑 jsonb 化

2.3 公共列 ​

除特别说明(shared_area 无时间列;日志 / refresh token / monitor user 是改名或单侧时间戳)外,每表含:

列类型NULL默认说明
iduuidNOgen_random_uuid();sys_log_operate / sys_log_auth / sys_deleted / auth_refresh_token 四表为 uuidv7()(3.3 定案)PK;ETL / seed 行显式给 v5 值,见第 3 节
created_attimestamptzNOnow()原 createdAt
updated_attimestamptzNOnow()原 updatedAt;维护方式见 5.4

以下表的公共列有出入(与 schema 的 timestamps 配置逐一对过):

  • shared_area:timestamps: false → 无时间列。
  • sys_log_auth:只有 authenticated_at(原 createdAt 改名),无 updated_at。
  • sys_log_operate:只有 operated_at,无 updated_at。
  • auth_refresh_token:updatedAt: false → 只有 created_at。
  • sys_deleted:createdAt 改名 deleted_at,无 updated_at。
  • app_error:只有 created_at,无 updated_at。
  • app_monitor_user:时间戳改名为 first_visit_at / last_active_at,无 created_at / updated_at。

2.4 FK 政策(populate → 外键的总原则) ​

  1. 业务从属表(user_device / identity / lock / mfa / oauth / preference / locale / dict_data / refresh_token)→ 建FK,ON DELETE CASCADE。理由:Mongo 侧删除用户后这些从属行已无意义;PG 侧用数据库机制替代原先散在 service 里的级联删除。真正删除用户只发生在「回收站彻底删除」路径(快照仍在 sys_deleted),级联不会绕过回收站。
  2. 可空引用(sys_user.current_role)→ FK ON DELETE SET NULL。角色被删(快照可恢复)后用户回到无当前角色状态,与现业务语义一致。
  3. 日志与审计表(sys_log_auth / sys_log_operate / sys_deleted / app_error / app_monitor_user)→ 一律不建 FK,松引用(保留 user_id uuid 列 + 索引 + 快照列 user_name)。理由:审计记录必须比被引用对象活得久;建 FK 会阻止「用户进回收站后彻底删除」这一既有路径,或在 CASCADE 下静默销毁审计记录——两者都不可接受。
  4. 树/图自引用:sys_menu.pid(自引用,可空——根节点 pid=NULL,见 4.3;ON DELETE RESTRICT,Q6 定案)、shared_area.pcode(不建 FK,Q6 定案,见 4.18)。

2.5 索引策略(来自 listAggregate 的推导) ​

列表查询的统一形态是:WHERE 过滤 ORDER BY 排序 LIMIT/OFFSET + total 计数;字符串过滤一律转 $regex(不锚定)→ PG 侧对应 ILIKE '%kw%',B-tree 只能服务前缀,包含匹配需 trigram。据此:

  • 每张有列表页的表:(常用过滤列, created_at DESC) 或单列 created_at DESC 索引(默认排序)。
  • 前端会模糊搜索的字符串列:默认建 B-tree(服务前缀 + 唯一性);包含匹配的 trigram GIN 索引只建 sys_locale.key(i18n key 搜索高频,Q9 定案;CREATE EXTENSION pg_trgm,PG 18 中为 trusted 扩展),其余模糊列一律不预铺,出现真实慢查询再补。
  • 本仓没有行级软删标志:base.repository.deleteSoftById 的实现是「快照进 sys_deleted → 真删」,因此各表都不存在 is_deleted 过滤列,不存在软删复合索引这一类;回收站自身的查询模式(按 modelName + 时间)在 sys_deleted 上建索引覆盖。
  • virtual populate(populated_*)在 PG 侧改写为子查询 / JOIN,所需索引即各 FK 列索引(PG 的 FK 不自动建索引,逐表显式列出)。

排序边界(明示):以上索引只覆盖各表登记的默认排序与常用过滤组合。listAggregate 允许前端对任意字段排序,PG 侧对非索引列的 ORDER BY 会走内存 Sort 节点——配置/字典类小表可接受;日志、shared_area 量级的表若出现此类排序,按需补索引或在前端限制可排序列,不在本文预铺。


3. ID 策略专节 ​

3.1 两案对比 ​

维度(a) uuid v5 确定性派生(namespace + 原 hex)(b) 随机 v4 + legacy_id text 过渡列
ETL 可重跑天然幂等:同输入恒得同输出,重跑 / 断点续跑 / 双跑对拍都不产生新 id,无需 upsert 逻辑本身不幂等:重跑会生成新 v4;要幂等必须依赖 legacy_id 唯一键做 upsert,即幂等性是由过渡列间接实现的
跨集合引用重写引用列旧值(hex)可直接函数求值为新 uuid,无需查任何表;ETL 各表可并行互不依赖必须先建全量映射表,所有引用列重写都 join 映射表;各表串行依赖映射表就绪,ETL 拓扑复杂一档
调试可追溯单向:hex → uuid 可即时验算(排查「这条旧数据对应哪行」很容易);uuid → hex 不可逆,反向追溯要靠映射表双向:行上自带 legacy_id,双向即查;代价是 24 张表长期多一列
收尾负担无过渡列残留;映射表降级为纯对账/排障表,可归档丢弃legacy_id 一旦进实体就容易长住(「反正查一下也方便」),需要明确的弃用里程碑

3.2 结论(推荐 (a)) ​

推荐 uuid v5 确定性派生:ETL 可重跑与引用重写两个维度都是结构性优势(不依赖纪律),且「调试可追溯」的短板可以用保留 etl_id_map 对账表补齐(见 7.1)——反向追溯是低频运维动作,为此在 24 张表上各加一列不值。落选 (b) 的根因:它的幂等性与可追溯性都押在过渡列上,而过渡列正是它自己制造的技术债。

派生规则:

  • 一个全库统一 namespace(UUID 常量)。不用每集合独立 namespace:ObjectId 本身全局唯一(时戳 + 随机),单一 namespace 无碰撞风险,还使「同一个 hex 在任何集合里都映射到同一个 uuid」,排查跨集合引用时不需要知道来源集合。 取值定稿(2026-10-25,B5):WALNUT_ADMIN_ETL_NAMESPACE = 'c1882c14-e4d4-4f2b-b728-346db51c312e', 常量的唯一真源是 packages/tooling/scripts/src/db/uuid5.ts(本文件只写引用,不写第二份字面值以外的语义; 该文件同时是 v5 派生函数的唯一实现,ETL / seed / 订正脚本共用)。永不可改——改了等于全部存量 id 换世界。
  • name = 原 24 位 hex 字符串本体。
  • 实现放共享工具(@walnut-server/utils 或 ETL 模块内),seed 重建与 ETL 共用同一函数——保证「本地 / demo / CI 由 seed 重建出的 id 彼此一致」,demo 环境的 id 稳定可引用。 (勘误 2026-10-25:实现最终落在工具链 @walnut/scripts 的 src/db/uuid5.ts——server 侧无需在运行时派生 id, B5 的 root 角色守卫已改用等价判据;ETL / seed 与订正脚本都在工具链侧消费同一实现。)

3.3 主键类型与默认值 ​

  • 列类型:原生 uuid(16 字节,比 text 省 60%+ 且有等值比较加速),不用 text 装 uuid。
  • 默认值:DEFAULT gen_random_uuid()(PG 13+ 内置,PG 18 无需扩展)。新行由数据库兜底生成 v4。
  • 应用层不强制生成 id:TypeORM 插入时不传 id 即走 DB 默认。理由:单一生成点;v5 只出现在 ETL 与 seed 两条显式通道,正常业务路径永远拿 v4,两世界不混淆。
  • DTO 校验器从 Types.ObjectId.isValid 换成 uuid 语法校验(ADR-0021 Decision 2 的连带清单,Phase 3 落地)。

uuid v7 定案(原 Q10-d,2026-10-25):PG 18 已内置 uuidv7()(时间有序)。v4 的随机性在追加型大表上意味着每个新行都插到 B-tree 随机位置——写放大与页分裂随表增长恶化;v7 前缀时间有序,新行恒落索引右端,与日志表「只追加」的形态吻合,还利于按时间范围扫描。定案:追加型大表 sys_log_operate / sys_log_auth / sys_deleted / auth_refresh_token 的 PK 默认值用 uuidv7(),其余表维持 gen_random_uuid()。迁移后的 ID 三通道如下,互不混淆:

通道覆盖生成方式
v5 存量ETL / seed 重建的全部存量行deriveUuid(hex)(统一 namespace,幂等可重跑)
v7 新增上述四张追加型表的新行列默认 uuidv7()
v4 新增其余 22 张表的新行列默认 gen_random_uuid()

id 泄漏创建时间的代价结论(维持初评):v7 的 id 会粗略暴露行创建时间——四张表均为日志/回收站/会话性质,无保密诉求,代价可忽略;业务表不采用 v7,不存在对外暴露实体创建时间的问题。


4. 逐表映射 ​

约定:第 4 节各表省略公共列(id / created_at / updated_at,形态见 2.3,列数已计入总览);「NULL 性」未标注 NOT NULL 的列即可 NULL。CHECK 枚举值均核对自 libs/const / @walnut/contract / schema 内 const。

4.1 sys_user(13 列) ​

字段列类型NULL默认说明
userNameuser_nametextYESNULL原 partial unique($type: string 过滤)→ CREATE UNIQUE INDEX ... WHERE user_name IS NOT NULL,语义等价
nickNamenick_nametextYESNULL
ageageintegerYESNULLCHECK (age > 0)(schema 校验 int+positive)
gendergendersmallintNO9CHECK (gender IN (0,1,2,9))(GB 标准,schema const)
avataravatartextYESNULL
descriptiondescriptiontextYESNULL
statusstatusbooleanNOtrue
roles→ sys_user_role 关联表———原 ObjectId[];FK 双向 CASCADE
roleModerole_modevarchar(16)NO'switchable'CHECK (IN ('switchable','combinable'))
currentRolecurrent_role_iduuidYESNULLFK → sys_role(id) ON DELETE SET NULL
mfaSetupmfa_setupbooleanNOfalse

索引:PK;uq_user_name(partial WHERE NOT NULL);idx_user_created_at (created_at DESC)(列表默认排序);idx_user_current_role (current_role_id)。 关联表 sys_user_role(user_id, role_id):PK(user_id, role_id);idx_role_id(role 的 populated_usersCount 反查);FK user_id CASCADE、role_id CASCADE。

勘误记录:user.schema.ts:144-150 在 roles 数组路径上声明了 unique: true——Mongoose 的语义是 multikey 唯一,展开即「同一个 role 全库只能赋给一个用户」,与业务事实(多用户共享角色)直接矛盾,几乎必然是误声明(本意应是数组内去重,而那是插入行为,无需索引)。关联表 PK (user_id, role_id) 不保留该 multikey 语义;误声明与否由 7.3 预检两条核对坐实(旧库索引真实存在性 + 跨用户重复赋值检测)。

4.2 sys_role(7 列) ​

字段列类型NULL默认说明
roleNamerole_nametextNO—UNIQUE(schema unique: true);NOT NULL 属收紧而非对齐,旧库 null 行检测见 7.3
descriptiondescriptiontextYESNULL
ordersort_orderintegerYESNULL保留字改名(2.1)
statusstatusbooleanNOtrue
menus→ sys_role_menu 关联表———FK 双向 CASCADE

索引:PK;uq_role_name;idx_role_created_at。 关联表 sys_role_menu(role_id, menu_id):PK(role_id, menu_id);idx_menu_id;FK role_id CASCADE、menu_id CASCADE。

勘误记录:role.schema.ts:74-80 对 menus 数组的 unique: true 同 4.1 勘误——multikey 展开即「一个 menu 只能属于一个 role」,与多角色共享菜单的授权模型冲突,确定不迁移;7.3 预检做跨角色重复菜单授权检测坐实。

virtual populated_usersCount / populated_menusList(含 meta.status 过滤)→ Phase 3 用 EXISTS 子查询 / JOIN 改写,索引已覆盖(idx_role_id、idx_menu_id、4.3 的 status 索引)。

4.3 sys_menu(29 列,meta 拍平) ​

meta 是字段固定、带默认值的既有类(19 个字段,Swagger 全量文档化),已定案拍平为独立列(Q1,2026-10-25;status / order 是查询与排序键),Phase 3 实体组装回 meta 对象出参,前端无感。

字段列类型NULL默认说明
pidpiduuidYESNULL根节点 pid=NULL(已查实:seed 157 个 _id、仅 156 个 pid——根节点文档完全没有 pid 键,schema 的 required 对 mongoimport 直插不生效)。FK → sys_menu(id) 自引用(FK 允许 NULL);ON DELETE RESTRICT(Q6 定案:防误删静默扩面,子树处理在应用层)
typetypevarchar(8)NO'catalog'CHECK (IN ('catalog','menu','element'))
pathpathtextYESNULL
namenametextYESNULLpartial unique(原 $type: string 过滤)→ WHERE name IS NOT NULL
componentcomponenttextYESNULL
titletitletextYESNULLi18n key 形态
iconicontextYESNULL
meta.ordersort_orderintegerYESNULL
meta.ternalternalvarchar(8)NO'none'CHECK (IN ('none','internal','external'))
meta.urlurltextYESNULL
meta.showis_showbooleanNOtrue保留字改名
meta.cachecachebooleanNOfalse
meta.statusstatusbooleanNOtruerole.virtual 的 match 字段,进索引
meta.affixaffixbooleanNOfalse
meta.permissionpermissiontextYESNULL*:*:* 形态
meta.menuActiveNamemenu_active_nametextYESNULL
meta.menuActiveSameTabmenu_active_same_tabbooleanNOfalse
meta.badgebadgetextYESNULL
meta.activeIconactive_icontextYESNULL
meta.positionpositionbooleanNOfalse
meta.leaveTipleave_tipbooleanNOfalse
meta.maskUrlmask_urlbooleanNOfalse
meta.hijackRefreshhijack_refreshbooleanNOfalse
meta.watermarkwatermarkjsonbYESNULL自由配置对象,jsonb 合理(5.5)
meta.transitiontransitionvarchar(32)NO'fade'
meta.cacheKeyStrategycache_key_strategyvarchar(8)YESNULLCHECK (IN ('name','path'))

索引:PK;uq_menu_name(partial);idx_menu_pid (pid) WHERE pid IS NOT NULL(partial——根节点 pid 为 NULL,全量索引无意义);idx_menu_status_sort_order (status, sort_order)(菜单树检索固定按 status=true + order 排序)。

4.4 sys_lang(7 列) ​

字段列类型NULL默认说明
langlangvarchar(16)NO—取值域 = @walnut/contract Locale(seed 含 en_US / zh_CN);补 UNIQUE(Q2 定案:Mongo 未声明但业务唯一)
descriptiondescriptiontextYESNULL
ordersort_orderintegerYESNULL
statusstatusbooleanNOtrue

索引:PK;uq_lang。表体量个位数,不建 created_at 索引。 virtual 两个 locale 计数 → 子查询改写,见 5.2。

4.5 sys_locale(6 列) ​

字段列类型NULL默认说明
langIdlang_iduuidNO—FK → sys_lang(id) ON DELETE CASCADE
keykeytextNO—i18n key
valuevaluetextYESNULLNULL = 未翻译

索引(4 个,与总览对齐):PK;uq_locale_lang_key (lang_id, key)(Q2/Q11 定案补 UNIQUE:一语言一键一行,不允许重复;ETL 预检保留作装载拦截);idx_locale_key (key)(key 前缀搜索);idx_locale_key_trgm(GIN,key gin_trgm_ops——Q9 定案:全库唯一一处 trgm,pg_trgm)。

4.6 sys_dict_type(7 列) ​

字段列类型NULL默认说明
namenametextNO—实为 i18n key(seed dict.name.status)
typetypetextNO—UNIQUE(schema 声明)
statusstatusbooleanNOtrue
descriptiondescriptiontextYESNULL

索引:PK;uq_dict_type;idx_dict_type_created_at。

4.7 sys_dict_data(10 列) ​

字段列类型NULL默认说明
typeIdtype_iduuidNO—FK → sys_dict_type(id) ON DELETE CASCADE
labellabeltextNO—i18n key 形态
valuevaluetextNO—
ordersort_orderintegerNO0
statusstatusbooleanNOtrue
tagTypetag_typevarchar(8)NO'primary'CHECK (IN ('primary','info','success','warning','error'))
descriptiondescriptiontextYESNULL

索引:PK;idx_dict_data_type_order (type_id, sort_order)(按类型取数即排好序)。

4.8 sys_device(22 列) ​

字段列类型NULL默认说明
deviceNamedevice_nametextNO—UNIQUE
deviceIddevice_idtextNO—UNIQUE(客户端指纹,作为被引用业务键,见 Q5)
deviceInfodevice_infojsonbNO—UA 结构化对象(browser/engine/os/cpu/device 子对象),整体读写、无字段级查询 → jsonb 合理
hardwareInfohardware_infojsonbYESNULL同上
locationInfolocation_infojsonbYESNULL国家/省/市结构体,getLocationString() 整体取用;Q8 定案维持 jsonb 整存(无字段级查询需求)
locationHistorylocation_historyjsonbNO'[]'上限 5 条的 IP-定位历史,整体覆盖写 → jsonb 合理
sr.height / sr.widthsr_height / sr_widthintegerYESNULL拍平:数值列可能进统计
vp.height / vp.widthvp_height / vp_widthintegerYESNULL同上
ipiptextYESNULL保持 text(Q8 定案:本期不收窄,日志侧存在占位串兼容诉求)
ipHistoryip_historytext[]NO—纯字符串数组
activeactivebooleanNOfalse
lockedlockedbooleanNOfalse
bannedbannedbooleanNOfalse
privateprivatebooleanNOfalsePG 非保留字,可原名
geoLocationgeo_longitude / geo_latitudedouble precision ×2YESNULL拍平:可索引可计算;Q8 定案维持 double precision,本期不上 PostGIS
riskScorerisk_scoresmallintNO0CHECK (risk_score BETWEEN 0 AND 100)(schema min/max)

索引:PK;uq_device_name;uq_device_id;idx_device_created_at。

4.9 sys_user_device(12 列) ​

字段列类型NULL默认说明
deviceNamedevice_nametextYESNULL用户可改的显示名
userIduser_iduuidNO—FK → sys_user(id) CASCADE
deviceIddevice_idtextNO—按业务键 FK → sys_device(device_id)(该列已 UNIQUE,FK 合法)CASCADE;理由见 Q5
linklinkbooleanNOfalse
linkTypelink_typevarchar(8)NO'system'CHECK (IN ('user','admin','system'))
lockedlockedbooleanNOfalse
lastActiveAtlast_active_attimestamptzYESNULL
trustedtrustedbooleanNOfalse
trustedExpiredAttrusted_expired_attimestamptzYESNULL

索引:PK;uq_user_device (user_id, device_id)(Q2 定案补 UNIQUE,Mongo 未声明);idx_user_device_device (device_id)(device → users 反查,兼服务 FK);idx_user_device_created_at。

4.10 sys_user_identity(14 列) ​

字段列类型NULL默认说明
userIduser_iduuidNO—FK → sys_user(id) CASCADE
typetypevarchar(16)NO—CHECK (IN ('phoneNumber','emailAddress','idCard','password'))
purposepurposevarchar(8)NO—CHECK (IN ('login','security'))
valuevaluetextNO—AES-256-GCM 密文(schema select: false,Phase 3 实体同样处理)
valueHashvalue_hashtextNO—HMAC-SHA256,查找键
maskedValuemasked_valuetextNO—展示用掩码
verifiedverifiedbooleanNOfalse
verifiedAtverified_attimestamptzYESNULL
isPrimaryis_primarybooleanNOtrue
statusstatusbooleanNOtrue
metadatametadatajsonbNO'{}'schema 注释明说「为未来预留的自由 JSON」→ jsonb 合理

索引:PK;uq_identity (user_id, type, purpose)(schema.index 声明);idx_identity_type_hash (type, value_hash)(schema.index 声明;登录查找路径)。

4.11 sys_user_lock(10 列) ​

字段列类型NULL默认说明
userIduser_iduuidNO—FK → sys_user(id) CASCADE;补 UNIQUE(Q2 定案:一用户一锁配置)
lockRoutelock_routejsonbNO'{}'{name?, query?, params?},query/params 任意对象 → jsonb 合理
lockPwdHashlock_pwd_hashtextYESNULLbcrypt hash(pre-save 逻辑迁到 service,Phase 3 事项)
lockModelock_modevarchar(8)NO'default'CHECK (IN ('default','idle','security'))
lockIdleSeclock_idle_secintegerNO600
lockSecuritySeclock_security_secintegerNO60
lockCrossDevicelock_cross_devicebooleanNOtrue

索引:PK;uq_user_lock_user (user_id)。

4.12 sys_user_mfa(16 列) ​

字段列类型NULL默认说明
userIduser_iduuidNO—FK → sys_user(id) CASCADE
typetypevarchar(8)NO—CHECK (IN ('totp','webauthn'))
deviceIddevice_idtextNO—松引用 sys_device.device_id(不建 FK:MFA 凭据不应随设备行删除而级联消失,Q5)
namenametextNO—
totpSecretCiphertexttotp_secret_ciphertexttextYESNULLselect: false
webauthnCredentialIdwebauthn_credential_idtextYESNULL补 UNIQUE(partial WHERE NOT NULL)(Q2 定案:凭据 id 全局唯一)
webauthnPublicKeywebauthn_public_keytextYESNULLselect: false
webauthnCounterwebauthn_counterbigintNO0
webauthnTransportswebauthn_transportstext[]NO'{}'CHECK 约束可选(IN usb/ble/nfc/internal/hybrid),数组元素 CHECK 见 migration 实现
phoneNumberCiphertextphone_number_ciphertexttextYESNULLselect: false
backupCodesCiphertextbackup_codes_ciphertexttext[]NO'{}'select: false
statusstatusbooleanNOtrue
lastUsedAtlast_used_attimestamptzYESNULL

索引:PK;idx_user_mfa_user (user_id);uq_user_mfa_credential (webauthn_credential_id) WHERE webauthn_credential_id IS NOT NULL。

4.13 sys_user_oauth(7 列) ​

字段列类型NULL默认说明
userIduser_iduuidNO—FK → sys_user(id) CASCADE
providerprovidervarchar(16)NO—CHECK (IN ('google-one-tap','gitee','github'))
providerIdprovider_idtextNO—
statusstatusbooleanNOtrue

索引:PK;uq_oauth (provider, provider_id)(schema.index 声明);idx_oauth_user (user_id)。 勘误记录:schema.index 里的 { userId: 1, enabled: 1 } 引用了不存在的 enabled 字段(实际字段是 status),是 Mongo 侧的死索引,不迁移,Phase 3 顺手在旧库摘除。

4.14 sys_user_preference(12 列) ​

字段列类型NULL默认说明
userIduser_iduuidNO—FK → sys_user(id) CASCADE;补 UNIQUE(Q2 定案:一用户一份偏好)
fontSizefont_sizesmallintNO14CHECK (IN (10,12,14,16,20))
darkdarkbooleanNOfalse
reducedMotionreduced_motionbooleanNOfalse
colorModecolor_modevarchar(20)NO'default'CHECK (IN ('default','cafe','contrast','grayscale','grayscale-inverted','inverted'))
CVDcvdvarchar(20)YESNULLCHECK (IN ('default','protanopia','deuteranopia','tritanopia','achromatopsia'))
localelocalevarchar(16)NO'zh_CN'取值域 = Locale
layoutModelayout_modevarchar(16)NO'left-menu'CHECK (IN ('left-menu','top-menu'))
layoutlayoutjsonbNO'{}'前端布局的自由状态袋 → jsonb 合理

索引:PK;uq_user_preference_user (user_id)。

4.15 sys_log_auth(13 列) ​

公共列只有 id(PK 默认 uuidv7(),3.3 定案);时间列 authenticated_at timestamptz NOT NULL DEFAULT now()(原 createdAt 改名,schema updatedAt: false)。

字段列类型NULL默认说明
ipiptextYESNULL保持 text(Q8 定案:不收窄)
locationlocationtextYESNULL
osostextYESNULL
browserbrowsertextYESNULL
userIduser_iduuidYESNULL松引用(2.4-3),登录失败时为 NULL
userNameuser_nametextYESNULL快照列,用户删除后日志仍可读
identifieridentifiertextYESNULL登录标识(email/phone)
successsuccessbooleanNOfalse
msgmsgtextYESNULL
typetypevarchar(16)NO—CHECK (IN ('opaque','qrcode','otp','emailAddress','phoneNumber','github','gitee','googleFedCM'))
deviceIddevice_idtextYESNULL松引用

索引:PK;idx_log_auth_at (authenticated_at DESC)(列表默认排序);idx_log_auth_user (user_id);idx_log_auth_device (device_id)(virtual populate_device 反查)。

4.16 sys_log_operate(23 列) ​

公共列只有 id(PK 默认 uuidv7(),3.3 定案);时间列 operated_at timestamptz NOT NULL DEFAULT now()。

字段列类型NULL默认说明
titletitletextNO—业务标注;Q10-a 定案随迁移收紧 NOT NULL(旧库空值预检见 7.3)
actionTypeaction_typevarchar(16)NO—CHECK (IN ('OTHER','CREATE','UPDATE','DELETE','DELETE_MANY','IMPORT','EXPORT','CLEAR'))
operationoperationvarchar(16)NO—取值 = WalnutAdminConstDecoratorLogOperateType(读写类枚举,migration 时以 const 为准生成)
methodmethodvarchar(8)YESNULLCHECK (IN ('GET','POST','PUT','PATCH','DELETE','OPTIONS','HEAD'))
urlurltextYESNULL
httpVersionhttp_versiontextYESNULL
locationlocationtextYESNULL
osostextYESNULL
browserbrowsertextYESNULL
statusCodestatus_codeintegerYESNULL
requestDatarequest_datajsonbYESNULLMongo 存 stringify 字符串;Q7 定案:ETL parse 入 jsonb,失败时本列 NULL、原文入 raw_request_data;应用新增直接写 jsonb
responseDataresponse_datajsonbYESNULL同上(旁路 raw_response_data)
(Q7 定案新增)raw_request_datatextYESNULLETL parse 失败时的原文旁路,零丢失
(Q7 定案新增)raw_response_datatextYESNULL同上
correspondingMScorresponding_msintegerYESNULL
userIduser_iduuidYESNULL松引用
userNameuser_nametextYESNULL快照列
deviceIddevice_idtextYESNULL松引用
ipiptextYESNULL
invokingMethodinvoking_methodtextYESNULLservice.method
successsuccessbooleanYESNULLschema 允许 null,保持
snapshotBeforesnapshot_beforejsonbYESNULL脱敏前照(decorator 已 maskSensitive)
snapshotAftersnapshot_afterjsonbYESNULL脱敏后照

索引:PK;idx_log_operate_at (operated_at DESC);idx_log_operate_user (user_id);idx_log_operate_device (device_id)。

4.17 sys_deleted(8 列) ​

公共列只有 id(PK 默认 uuidv7(),3.3 定案);时间列 deleted_at timestamptz(原 createdAt 改名;保持可 NULL 以对齐 schema,未发现必须 NOT NULL 的证据)。

字段列类型NULL默认说明
contentcontentjsonbNO—原 text(JSON.stringify 产物)→ jsonb;理由见 5.3
modelNamemodel_nametextNO—恢复路由键
collectionNamecollection_nametextNO—纯记录字段,保留
deletedIddeleted_iduuidNO—被删文档原 id
deletedBydeleted_byuuidNO—松引用 sys_user(Q3 定案:删除者自己也可能被删,建 FK 会自锁)
logOperateIdlog_operate_iduuidYESNULL松引用

索引:PK;idx_deleted_model_at (model_name, deleted_at DESC)(回收站按类型 + 时间列表);idx_deleted_id (deleted_id)(恢复入口查找);idx_deleted_by (deleted_by)(recoverMine「我的回收站」)。

4.18 shared_area(4 列,66.3 万行) ​

timestamps: false → 无时间列(是否补审计列见 Q12)。

字段列类型NULL默认说明
codecodevarchar(12)NO—UNIQUE(唯一索引即原 schema 声明);位数分布见下方勘误
namenametextNO—
pcodepcodevarchar(12)NO—不建 FK(Q6 定案,总览 area FK=0 按此计);父子完整性由应用层删除校验 + 7.3 孤儿/环检测兜底

索引:PK;uq_area_code;idx_area_pcode (pcode)(树查询主力索引)。

勘误(2026-10-25,Phase 4 ETL 实测):本表 code/pcode 原判 varchar(9)(「位数 1/2/4/6/9」)。存量 66.3 万行的实测分布是 code 2/4/6/9/12(12 位村/居委会级 61.8 万行、占 93%),varchar(9) 装不下 ⇒ 放宽为 varchar(12),migration / 实体同批修正;位数分布对账以 ETL 实测为准,不再按 9 位上限判形。

pcode FK 的形态说明(Q6 定案):顶层省份的 pcode='0' 是约定根值而非行引用(Mongo 侧本就无约束)。定案采 (i) 不建 FK——仅保留唯一约束 + pcode 索引,与现状语义完全一致;不为约束而篡改数据集(伪根行方案需 ETL 凭空造一行,弃用)。完整性由应用层删除校验(「有子节点不许删」现状已存在)+ 7.3 孤儿/环检测兜底。

4.19 app_setting(8 列) ​

字段列类型NULL默认说明
settingNamesetting_nametextNO—
settingKeysetting_keytextNO—UNIQUE
settingValuesetting_valuetextNO—注意:seed 里此列存过角色 id(app.default.role);Q4 定案:保持 text,由 ETL 专项重写内嵌 ObjectId(7.3),不引入 typed 引用列
settingTypesetting_typevarchar(16)NO—CHECK (IN ('auth','global','functional'))
remarkremarktextYESNULL
(Q4 定案新增)created_byuuidYESNULLseed 实存 schema 未声明的字段,补列存证;松引用 sys_user(创建者可能被回收,Q3 同口径),不建 FK、不建索引

索引:PK;uq_setting_key。 seed 中存在 schema 未声明的 createdBy 字段(直接 mongoimport 产物)→ 是否补 created_by uuid 列,见 Q4。

4.20 app_demo(17 列) ​

字段列类型NULL默认说明
demoStringdemo_stringtextYESNULLUNIQUE(schema 声明)
demoPhoneNumberdemo_phone_numbertextYESNULLUNIQUE(schema 声明)
demoStringArraydemo_string_arraytext[]NO—
demoNumberdemo_numberintegerYESNULL
demoNumberArraydemo_number_arrayinteger[]NO—
demoBooleandemo_booleanbooleanNOfalse
demoBooleanArraydemo_boolean_arrayboolean[]NO—
demoDatedemo_datetimestamptzYESNULL
demoDateArraydemo_date_arraytimestamptz[]NO—
demoEnumdemo_enumsmallintNO1CHECK (IN (1,2,3,4))
demoEnumArraydemo_enum_arraysmallint[]NO—
demoMongoIddemo_uuiduuidYESNULL演示用引用形态
demoMongoIdArraydemo_uuid_arrayuuid[]NO—
demoObjectdemo_objectjsonbYESNULL{o1,o2,o3} 演示 Mixed → jsonb(演示表的职责本身就是展示任意对象形态)

索引:PK;uq_demo_string;uq_demo_phone_number。

4.21 app_monitor_user(10 列) ​

时间列 first_visit_at / last_active_at(timestamps 改名,无 created/updated)。

字段列类型NULL默认说明
visitorIdvisitor_idtextNO—补 UNIQUE(Q2 定案:一访客一行)
userIduser_iduuidYESNULL松引用(访客监测独立于用户生命周期)
deviceIddevice_idtextYESNULL松引用
authauthbooleanNOfalse
focusfocusbooleanNOfalse
leftis_leftbooleanNOfalse保留字改名
currentRoutercurrent_routertextYESNULL

索引:PK;uq_monitor_visitor (visitor_id);idx_monitor_device (device_id);idx_monitor_user (user_id)。

4.22 app_error(15 列) ​

公共列只有 id + created_at(schema updatedAt: false)。

字段列类型NULL默认说明
messagemessagetextYESNULL
stackstacktextYESNULL
statusCodestatus_codeintegerYESNULL
pathpathtextYESNULL
methodmethodvarchar(8)YESNULL同 4.16 CHECK
headersheadersjsonbNO'{}'任意请求头 → jsonb
payloadpayloadjsonbYESNULL{body,params,query},各成员为 stringify 字符串;Q7 定案:ETL parse 入 jsonb,失败入旁路 raw_payload
(Q7 定案新增)raw_payloadtextYESNULLETL parse 失败时的原文旁路,零丢失
errorTypeerror_typetextYESNULL
userIduser_iduuidYESNULL松引用
responseCoderesponse_codevarchar(16)YESNULL取值 = AppResponseCode const
responseMsgresponse_msgtextYESNULL
ipiptextYESNULL
envenvvarchar(16)YESNULLCHECK (IN ('development','stage','production'))

索引:PK;idx_error_created_at (created_at DESC);idx_error_user (user_id)。

4.23 app_key(11 列) ​

字段列类型NULL默认说明
keykeytextNO—UNIQUE
typetypevarchar(16)NO—CHECK (IN ('RSA_PAIR','AES_KEY_URL','JWT_AT','JWT_RT'))
versionversionintegerNO—
statusstatusvarchar(16)NO'ACTIVE'CHECK (IN ('ACTIVE','DEPRECATED','DISABLED'))
validStartvalid_starttimestamptzNO—
validEndvalid_endtimestamptzYESNULL
rotateAfterrotate_aftertimestamptzYESNULL
metametajsonbYESNULL必须 jsonb:不同 type 的 key 形态完全不同(RSA 存 PEM 对、AES 存 keyB64+ivB64、JWT 存 secret、另有 bits/cipher),一列多型是 jsonb 的教科书场景

索引:PK;uq_key;idx_app_key_status (status)(取当前可用 key)。

4.24 auth_refresh_token(9 列) ​

公共列只有 id(PK 默认 uuidv7(),3.3 定案)+ created_at(updatedAt: false)。

字段列类型NULL默认说明
jtijtitextNO—UNIQUE(schema 声明)
userIduser_iduuidNO—FK → sys_user(id) CASCADE(用户彻底删除则其会话令牌随之失效,正确语义)
deviceIddevice_idtextNO—FK → sys_device(device_id) ON DELETE CASCADE(Q5 定案:按业务键建 FK;设备被删则会话令牌随之清理)
encryptedTokenencrypted_tokentextNO—
expiredAtexpired_attimestamptzNO—
revokedrevokedbooleanNOfalse
revokeReasonrevoke_reasonvarchar(24)YESNULL取值 = WalnutAdminConstRevokeRTType(12 个值:updatePass / resetPass / updateEmail / updatePhoneNumber / forceQuitOnline / forceQuitOffline / currentRoleBanned / currentUserBanned / deviceBanned / deviceHighRisk / versionUpgrade / userKickOther),migration 以 const 为准

索引:PK;uq_jti;idx_rt_user (user_id)(踢人下线按用户批量 revoke 的路径);idx_rt_device (device_id)(兼服务 FK);idx_rt_expired (expired_at)(purge job 判据——Q10-b 定案纳入 Phase 3,见第 6 节)。


5. 专项设计 ​

5.1 shared_area 树:邻接表 + 递归 CTE ​

现状(area.service.ts):按 pcode 查一层子节点(Redis 缓存按 pcode 粒度)+ 应用层 arrToTree 建树 + 向上逐级收集兄弟节点。虚拟字段 children / parent 只是 populate 形态,实际查询全部走 pcode 单层。

PG 侧两层能力:

日常路径保持单层查询(缓存策略不变,idx_area_pcode 服务):

sql
SELECT code, name, pcode FROM shared_area WHERE pcode = $1 ORDER BY code;

整树 / 子树一次性拉取(替代 MongoDB 侧无法高效表达的 $graphLookup 场景,66 万行下应用层递归循环查询不可接受):

sql
-- 以 $1 为根的整个子树(含深度)
WITH RECURSIVE subtree AS (
  SELECT id, code, name, pcode, 0 AS depth
  FROM shared_area
  WHERE code = $1
  UNION ALL
  SELECT a.id, a.code, a.name, a.pcode, s.depth + 1
  FROM shared_area a
  JOIN subtree s ON a.pcode = s.code
) CYCLE code SET is_cycle USING path  -- PG 14+ 环保护(兜底,见下方说明)
SELECT code, name, pcode, depth, is_cycle FROM subtree;
  • 反向(parent / 祖先链)同一结构,递归项换成 JOIN subtree s ON s.pcode = a.code。
  • 性能判据:递归 CTE 按工作表(work table)机制逐层展开——每轮把上一轮产出读入工作表再驱动下一轮,每层是一次 idx_area_pcode 索引扫描(PG 12+ 起递归项不走 MATERIALIZED 语义,该修饰只作用于非递归 CTE);省级行数(34)毫秒级;全量 66 万行一次拉回是网络问题不是索引问题——按需限制 depth(WHERE depth < $2)。
  • 环保护(两层):UNION ALL 形态在 pcode 数据成环时会无限递归。第一层防线是数据质量——ETL 合法性自检含环检测(7.3,应返回 0 行);第二层是 SQL 兜底——上图已并入 PG 14+ 的 CYCLE code SET is_cycle USING path 子句,成环时自动标记 is_cycle 并终止,不依赖数据先验干净。
  • ETL 装载完成后的合法性自检(5 分钟内可跑):SELECT count(*) FROM shared_area c LEFT JOIN shared_area p ON c.pcode = p.code WHERE c.pcode <> '0' AND p.code IS NULL; 应为 0(孤儿检测)。

5.2 sys_lang / sys_locale:lang.repo.service 最复杂聚合的 SQL 重写 ​

原聚合 1(findByIdWithLocaleCounts):$lookup 两次拉全量数组只为 $size 计数。SQL 版用子查询计数,不再物化明细:

sql
SELECT l.id, l.lang, l.description, l.sort_order, l.status,
       (SELECT count(*) FROM sys_locale t WHERE t.lang_id = l.id) AS populated_locales_total_count,
       (SELECT count(*) FROM sys_locale t
         WHERE t.lang_id = l.id AND t.value IS NOT NULL) AS populated_locales_finished_count
FROM sys_lang l
WHERE l.id = $1;

原聚合 2(findMessagesByLang):$let + $toObjectId + $arrayToObject 把某语言全部 locale 折叠成 {key: value} 动态 map。其中 $toObjectId 是为了绕「langId 历史上存过字符串」的坑——PG 侧 langId 是 uuid FK 强类型,这一整类坑不复存在:

sql
SELECT COALESCE(jsonb_object_agg(t.key, t.value), '{}'::jsonb) AS data
FROM sys_locale t
JOIN sys_lang l ON l.id = t.lang_id
WHERE l.lang = $1;

语义对账点(Phase 4 对账清单用):

  • Mongo $arrayToObject 对 value: null 的条目产出 "key": null;jsonb_object_agg 同样保留 JSON null——缺翻译键行为一致。
  • Mongo 版返回形如 { data: {...} } 的单文档(无匹配语言时为 undefined→上层兜底);SQL 版无匹配时返回单行单列 NULL,外层 COALESCE('{}') 已兜底,Phase 3 组装响应时保持既有 { data } 包裹即可。

5.3 sys_deleted:jsonb 快照 + 按 modelName 路由恢复 ​

content 列选 jsonb(原为 stringify text)的理由:快照本身就是合法 JSON;jsonb 让「回收站预览 / 按条件检索被删内容」成为普通 SQL 而无需应用层 parse;恢复时取回即对象。ETL 侧旧数据本就是 JSON 文本,一次 parse 即完成转换,无信息损失。

恢复路径在 PG 的形态(替换 deleted.service.ts 现有的 db.model(modelName) 动态模型 + 直接 create):

  1. 路由:维护一张显式注册表 Record<modelName, TypeORM Repository>(Nest 模块初始化时装配),替代 Mongo 的运行时动态模型——PG 有强 schema,不允许「来什么存什么」。
  2. 还原:快照 jsonb → 实体。列级映射是显式的(Phase 3 各表恢复器实现),不再依赖 Mongo 的 Mixed 宽容。快照内引用列已是 uuid(v5 派生规则保证);若快照内出现 24 位 hex(理论上仅「迁移前归档的旧快照」),按 v5 规则重算——但 sys_deleted 当前 0 条,此分支只是防御性代码。
  3. 事务:现状 recover 已在 @WalnutDBTransaction() 内执行「目标集合 insert + sys_deleted delete」;PG 版不变(同一本地事务,反而比 Mongo 副本集事务更稳)。
  4. 软删写入侧:base.repository.deleteSoftById 的 JSON.stringify(toObject()) 改为直接写实体/JSON 对象进 content jsonb;deletedId 存被删行 uuid。virtual(populated_user / populated_log_operate)→ JOIN 改写。

5.4 时间戳:TypeORM 列管理,不用触发器 ​

二选一结论:TypeORM CreateDateColumn / UpdateDateColumn(timestamptz)+ 列级 DEFAULT now(),不部署 updated_at 触发器。理由:

  1. 单写者:数据只经应用(TypeORM)写入,没有绕过 ORM 的 SQL 客户端;触发器防的是不存在的威胁。
  2. ETL 成本:66 万行 shared_area 走 COPY,触发器是纯开销,还必须显式 ALTER TABLE DISABLE TRIGGER 绕过——为「防漏」引入的机制反成 ETL 的坑。
  3. 显式性:本仓纪律是行为显式可见(禁隐式全局同理);触发器是数据库里的隐式写路径,排查数据变更时多一处不看代码看不到的魔法。
  4. DEFAULT now() 仍保留:对 raw SQL / psql 手工插入兜底。

配套约束(Phase 3 写进约定):updated_at 由 UpdateDateColumn 维护;任何 raw SQL UPDATE 必须显式 SET updated_at = now()(PG 无 MySQL 式 ON UPDATE,这正是触发器方案唯一的论据,用一条编码约定换掉一个隐式机制)。

5.5 jsonb 列判断清单(17 列,逐列理由) ​

表.列理由(为什么这列必须/适合 jsonb)
sys_menu.watermarkschema 类型即 Recordable(任意配置对象),前端透传
sys_device.device_info / hardware_info / location_info / location_history客户端上报的结构化画像,字段随 UA/硬件演化,只整体读写、无字段级查询条件;拍平收益为负(20+ 低价值列)。sr/vp/geo 已拆出 jsonb——数值形态有统计/计算价值
sys_user_identity.metadataschema 注释明说「未来预留自由 JSON」
sys_user_lock.lock_routequery/params 是任意路由参数对象
sys_user_preference.layout前端布局自由状态袋
sys_log_operate.request_data / response_data / snapshot_before / snapshot_after请求/响应与脱敏前后照,形态随接口演化;审计数据要求「原样保真」,不拍平
sys_deleted.content整文档快照,见 5.3
app_demo.demo_object演示 Mixed 的表,职责如此
app_error.headers / payload任意请求头与请求载荷,审计保真
app_key.meta一列多型(RSA/AES/JWT 各自的密钥材料),jsonb 教科书场景

明确拒绝 jsonb 化的:sys_menu.meta 主体(19 个固定字段拍平成列——Q1 已定案——理由:字段稳定、meta.status 是 role populate 的过滤条件、meta.order 是排序键,jsonb 需要表达式索引且失去默认值与 NOT NULL 表达力);sys_device 的 sr/vp/geo(数值列);各字符串数组(text[] 而非 jsonb 数组:元素类型 homogeneous,可建 GIN,取值语义更准)。


6. Migration 策略(TypeORM) ​

  • synchronize: false 从第一天锁死(TypeORMModule 配置处硬编码,不给环境变量开口子)。
  • migration 文件位置:apps/server/apps/api/migrations/(跟 api app 走,不进 libs/db——migration 属于应用部署物而非共享库)。文件名 1719xxxxxxxx-InitSchema.ts 式 Timestamp 前缀,TypeORM 约定命名。
  • 初始 migration 的生成方式:Phase 3 实体完成后 typeorm migration:generate 产出骨架,但以本文档为准人工复核修正——以下几类 generate 不可靠,必须手写确认:partial unique index(WHERE 子句)、CHECK 约束、pg_trgm GIN、FK 的 on delete 行为、关联表的复合 PK。修正完成的初始 migration 即本设计的可执行形态,评审冲突以此对账。
  • migration 运行:migrationsRun: false,经 package.json script 显式执行(dev / 部署各一条命令),CI 里跑 migration:run + migration:revert 双向验证可回滚。
  • 扩展依赖:初始 migration 首段 CREATE EXTENSION IF NOT EXISTS pg_trgm;(PG 18 trusted extension,无需超级用户)。
  • seed / 事务基线(Phase 0 的对账测试)不进 migration——migration 只管结构,数据一律走 Phase 4 ETL 通道,保持「结构」与「数据」两条通道正交。
  • 过期 refresh token 清理(Q10-b 定案,Phase 3 交付物):Nest schedule(@nestjs/schedule)定时任务按 expired_at < now() 批量 DELETE(走 idx_rt_expired),作为迁移交付物之一列入 Phase 3 实施清单。

7. ETL 接口约定(Phase 4 的输入,定方向不写实现) ​

7.1 ObjectId → uuid 映射的落地 ​

  • 派生函数:deriveUuid(hex: string): uuid(v5,统一 namespace),ETL 与 seed 共用(放在共享工具包,两通道 import 同一份实现)。
  • 映射表 etl_id_map(source_collection text, source_oid text, target_uuid uuid, migrated_at timestamptz),PK (source_collection, source_oid),PG 目标库之外(独立 etl schema 或独立库,不进 24 表清单)。v5 方案下它不是重写的必需品(引用可直接算),保留的唯一价值是对账与排障——迁移验收期用它双向抽查,验收后归档删除,避免 (b) 方案的「过渡列长住」问题在这里重演。

7.2 shared_area 66.3 万条的批量通道 ​

  • COPY(PG 二进制或 text 格式),不经单条 INSERT;分批 5–10 万行/事务。
  • 装载顺序:先 CREATE TABLE(无 FK)→ COPY 装载 → CREATE INDEX(索引后建比先建快)→ ALTER TABLE ADD CONSTRAINT fk_pcode(若 Q6 采纳 FK 方案)→ ANALYZE shared_area。
  • 其余 23 表行数小(个位~万级),同一 COPY 通道复用,无需专门优化。

7.3 对账口径建议 ​

  1. 计数:每表 count(*) vs Mongo estimatedDocumentCount,逐表登记;sys_deleted 按 model_name 分组对。
  2. id 抽查:每表随机 N 行(建议 100),etl_id_map 反查 source_oid 存在且派生函数验算一致。
  3. 字段级 diff:抽样行按 uuid join,逐列比对(timestamptz 注意毫秒精度:Mongo ms 精度,PG 微秒,应无损)。
  4. 专项:
    • shared_area 孤儿检测(5.1 的 SQL 应为 0 行)+ code 位数分布对账(1/2/4/6/9)。

    • shared_area 环检测(沿 pcode 向上走回到自身即成环,应返回 0 行)——环存在时 5.1 的 UNION ALL 递归会死循环,必须在装载后拦截;SQL 层的 CYCLE 子句只是兜底,不是数据脏的借口:

      sql
      WITH RECURSIVE up AS (
        SELECT code AS origin, pcode, false AS hit FROM shared_area WHERE pcode <> '0'
        UNION ALL
        SELECT u.origin, n.pcode, n.code = u.origin
        FROM up u JOIN shared_area n ON n.code = u.pcode
        WHERE NOT u.hit
      )
      SELECT DISTINCT origin FROM up WHERE hit;
    • multikey unique 误声明两条(4.1 / 4.2 勘误的坐实核对):(a) 旧库确认 sys_user.roles、sys_role.menus 的 multikey 唯一索引是否真实存在(autoIndex 关闭时从未建过,不存在则连误伤都谈不上);(b) 跨用户重复角色赋值检测(sys_user_role 按 role_id GROUP BY HAVING count(*) > 1)与跨角色重复菜单授权检测(sys_role_menu 按 menu_id 同法)——检出即证明该唯一从未被业务遵守。

    • sys_role.role_name NULL 行检测——4.2 将其收紧为 NOT NULL,属收紧而非对齐,装载前先验证旧库无 null 行。

    • sys_log_operate.title NULL 行检测——Q10-a 定案随迁移收紧 NOT NULL,装载前先验证旧库无空 title 行。

    • sys_locale 按 (lang, key) 去重预检——Q2/Q11 定案:不允许重复,约束照建;预检保留作装载拦截,检出重复先出报告。

    • app_setting.setting_value 中的 id 引用改写核对(Q4 定案:保持 text + ETL 重写;app.default.role 等 key 清单需从种子数据枚举)。

    • 日志/错误表 stringify 列(request_data / response_data / payload)parse 成功率摸底(Q7 定案):失败行原文落 raw_* 旁路列,成功率作为 ETL 验收指标。

  5. 幂等性验收:ETL 整体重跑一遍,所有表 count 与 checksum 不变(v5 方案的结构性验证)。

8. 拍板结果(原开放问题 · 已全部定案) ​

维护者于 2026-10-25 对全部 12 个开放问题逐条拍板,正文已按定案修订完毕。本节为存档形态:结论 + 一句理由 + 拍板日期。

#定案结论理由(一句)拍板日期
Q1sys_menu.meta 的 19 个字段全部拍平为独立列;Phase 3 实体组装回 meta 对象出参,前端无感status / order 是查询与排序键,拍平才能落约束与索引2026-10-25
Q27 处 Mongo 未声明但业务唯一的约束全部补 UNIQUE(lang、locale(lang_id,key)、preference.user_id、lock.user_id、user_device(user_id,device_id)、monitor.visitor_id、mfa.webauthn_credential_id);7.3 预检全数保留(multikey 索引存在性、跨用户重复角色赋值、roleName null 检测等)业务唯一性落为数据库约束,脏数据靠预检前置拦截2026-10-25
Q3日志/审计/回收站表松引用:user_id / device_id 一律不建 FK,保留 uuid 列 + 索引审计必须比被引用对象活得久,FK 会阻碍用户彻底删除路径2026-10-25
Q4app_setting 补 created_by 列(松引用);setting_value 保持 text,由 ETL 专项重写内嵌 ObjectIdcreated_by 有审计价值;typed 引用列无现实需求2026-10-25
Q5设备引用按业务键 device_id 建 FK:user_device、auth_refresh_token(CASCADE);sys_user_mfa 明确不建MFA 凭据不随设备删除级联消失;会话令牌随设备清理才是正确语义2026-10-25
Q6shared_area 不建 FK(总览口径定案);menu pid 自引用 ON DELETE 用 RESTRICT,子树处理在应用层——reviewer 背书升级定案,如有异议可后续单独翻案防误删静默扩面;不为约束篡改数据集2026-10-25
Q7log_operate / app_error 的 stringify 列收 jsonb + 同表 raw_* text 旁路列;ETL 前 parse 成功率摸底保查询能力的同时原文零丢失2026-10-25
Q8IP 保持 text、坐标保持 double precision,本期不收窄(location_info 维持 jsonb 整存)收窄丢非严格值兼容,PostGIS 无现实需求2026-10-25
Q9模糊索引只建 sys_locale.key 的 pg_trgm其余列无实证高频包含匹配,避免写放大2026-10-25
Q10(a) log_operate.title 随迁移收紧 NOT NULL、(c) 本期不分区——按本文倾向归档(拍板未单独触及,视为无异议);(b) purge job 纳入 Phase 3 交付物(Nest schedule 清理过期 refresh token,见第 6 节);(d) uuidv7() 用于四张追加型表 PK 默认值(3.3 三通道定稿)(b)(d) 显式定案;(a)(c) 未被否决即按倾向落2026-10-25
Q11sys_locale 不允许 (lang_id, key) 重复,随 Q2 补 UNIQUE;去重预检保留作装载拦截一语言一键一行是 i18n 语义本体2026-10-25
Q12shared_area 不补审计列补了全是 ETL 当刻时间,无信息量2026-10-25

附:与上游 ADR-0021 连带清单的对应 ​

ADR Decision本文落点
Decision 2(UUID、不做 24-hex 伪装)第 3 节:uuid 列型 + v5 派生 + gen_random_uuid() 默认
Decision 3(事务 API 不变)5.3-3:recover 事务路径语义保持
Decision 5(回收站完整保留)4.17 + 5.3:jsonb 快照 + modelName 路由恢复器
Decision 6(本地 + seed 通道)3.2(seed 与 ETL 共用派生函数)+ 第 7 节
Consequences-5(seed/工具链重建)7.1–7.3 通道约定,Phase 4 范围

基于 MIT 许可发布