一句话说明
unique: true 被物化成不带租户列的全局唯一索引,与平台自己按租户分裂的 autonumber 序列直接矛盾:多租户部署下,各租户从 1 起发号 → 撞进全局唯一索引 → SQLITE_CONSTRAINT_UNIQUE。不需要用户做错任何事,平台左手发的号被平台右手拒掉。
(下游项目实测记录:steedos-labs/os-project-titanwind-ehr#599,排查「导入用户报错」时定位到)
两段平台代码互相打架
| 子系统 | 行为 | 位置(16.1.0 dist) |
|---|
| 发号 | _objectstack_sequences 主键含 tenant_id(object, tenant_id, field, scope),每个租户各自从 1 起 | driver-sql 序列表 |
| unique 物化 | 只建单列唯一索引,永不掺租户列 | driver-sql/dist/index.js 两处,见下 |
建表路径(createTable):
if(field.unique)col.unique();// index.js:2860 — 单列 UNIQUE
重建路径(schema-drift rebuild):
if(field?.unique&&keptSet.has(name)){constidx=`uniq_${table}_${name}`;awaitthis.knex.raw("CREATE UNIQUE INDEX IF NOT EXISTS ?? ON ?? (??)",[idx,table,name]);// index.js:1993}两条路径都无视对象的 tenancy: { enabled, tenantField } 元数据。租户隔离的承诺在读侧、写侧、发号器三个环节都兑现了,唯独 unique 物化不兑现。
影响面
- 功能:全平台所有多租户部署的所有 unique 字段。任何
unique: true 字段(编号、编码、工号、名称…)在多租户下变成「全平台抢注」:租户 A 占了 PROD-00001,其他所有租户永远插不进同值。配合租户分裂的 autonumber,是必然撞而非偶然撞。 - 安全:跨租户存在性探测 oracle。租户 B 插入时收到 UNIQUE 冲突 = 平台告诉 B「某个别的租户已有这个值」。拿邮箱/编码/名称批量试探即可枚举其他租户的数据存在性——多租户平台的经典泄露口。
- 语义:与业界 SaaS 口径相反。Salesforce 等平台 unique 默认是 org 内唯一,全局唯一才是显式特例(如微信 openid、平台级账号标识)。当前默认恰好反了,且没有任何语法能表达「租户内唯一」。
建议修法
- driver 物化 unique 时(建表 + rebuild + declared indexes 三条路径),对
tenancy.enabled 的对象自动升为 (tenantField, field) 复合唯一。 - 真需要全局唯一的字段给显式语法,例如
unique: 'global'(openid 这类平台级身份)。unique: true 默认 = 租户内唯一。 - 单租户部署零影响:租户列恒定,复合唯一退化等价于单列唯一。
存量迁移:纯放松,零数据处理
旧「全局唯一」严格于新「租户内唯一」,存量数据天然满足新约束。迁移只需 drop 旧 uniq_<table>_<field> 单列索引 → create 复合索引,无需查重、无需清洗、不可能失败。建议进 os migrate 的 plan/apply。
验证方式
- 开多租户,两个租户各建同编码记录:修前第二个租户报
SQLITE_CONSTRAINT_UNIQUE,修后各自成功。 - 同一租户内建同编码记录:修前修后都应被拒(租户内唯一不放松)。
unique: 'global' 字段跨租户同值:仍被拒。
一句话说明
unique: true被物化成不带租户列的全局唯一索引,与平台自己按租户分裂的 autonumber 序列直接矛盾:多租户部署下,各租户从 1 起发号 → 撞进全局唯一索引 →SQLITE_CONSTRAINT_UNIQUE。不需要用户做错任何事,平台左手发的号被平台右手拒掉。(下游项目实测记录:steedos-labs/os-project-titanwind-ehr#599,排查「导入用户报错」时定位到)
两段平台代码互相打架
_objectstack_sequences主键含tenant_id(object, tenant_id, field, scope),每个租户各自从 1 起driver-sql/dist/index.js两处,见下建表路径(
createTable):重建路径(schema-drift rebuild):
两条路径都无视对象的
tenancy: { enabled, tenantField }元数据。租户隔离的承诺在读侧、写侧、发号器三个环节都兑现了,唯独 unique 物化不兑现。影响面
unique: true字段(编号、编码、工号、名称…)在多租户下变成「全平台抢注」:租户 A 占了PROD-00001,其他所有租户永远插不进同值。配合租户分裂的 autonumber,是必然撞而非偶然撞。建议修法
tenancy.enabled的对象自动升为(tenantField, field)复合唯一。unique: 'global'(openid 这类平台级身份)。unique: true默认 = 租户内唯一。存量迁移:纯放松,零数据处理
旧「全局唯一」严格于新「租户内唯一」,存量数据天然满足新约束。迁移只需 drop 旧
uniq_<table>_<field>单列索引 → create 复合索引,无需查重、无需清洗、不可能失败。建议进os migrate的 plan/apply。验证方式
SQLITE_CONSTRAINT_UNIQUE,修后各自成功。unique: 'global'字段跨租户同值:仍被拒。