Files
lingxi/prisma/schema.prisma
T
Stev_Wang 61894df401 feat(identity): 完成 A2 角色权限与数据范围全栈落地
背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
2026-11-03 12:26:51 +08:00

227 lines
9.3 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 灵犀 Prisma schema(A0 基线)
// 依据:docs/工程规范/05-数据库规范.md、技术架构 §5.3 / §5.4.5
// 约定:表/列 snake_case;主键 char(26) ULID(应用侧生成);时间 timestamptz;JSON 用 JsonB。
// A0 只建平台侧骨架表(outbox_events / idempotency_keys / system_configs);
// 身份、客户、行业、脚本、素材等业务表按里程碑在各自模块内新增,禁止跨模块表。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// 事务性发件箱:领域事件先落库,再由 worker 投递(技术架构 §5.3)
model OutboxEvent {
id String @id @db.Char(26)
aggregateType String @map("aggregate_type") @db.VarChar(64)
aggregateId String @map("aggregate_id") @db.Char(26)
eventType String @map("event_type") @db.VarChar(128)
payloadJson Json @map("payload_json") @db.JsonB
traceId String? @map("trace_id") @db.VarChar(64)
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
attempts Int @default(0)
lastError String? @map("last_error") @db.Text
@@index([publishedAt, occurredAt])
@@index([aggregateType, aggregateId])
@@map("outbox_events")
}
/// 请求幂等键:任务提交必须带 Idempotency-Key(TTL 24h)
model IdempotencyKey {
id String @id @db.Char(26)
scope String @db.VarChar(64)
key String @db.VarChar(128)
requestHash String @map("request_hash") @db.VarChar(64)
responseStatus Int? @map("response_status")
responseBody Json? @map("response_body") @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
@@unique([scope, key])
@@index([expiresAt])
@@map("idempotency_keys")
}
/// 部门:两级组织(一级部门 → 小组),父子用 parent_id 关联(A1 identity)
model Department {
id String @id @db.Char(26)
code String @unique @db.VarChar(64)
name String @db.VarChar(64)
parentId String? @map("parent_id") @db.Char(26)
leaderUserId String? @map("leader_user_id") @db.Char(26)
sortOrder Int @default(0) @map("sort_order")
status String @default("ACTIVE") @db.VarChar(16)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
children Department[] @relation("DepartmentTree")
leader User? @relation("DepartmentLeader", fields: [leaderUserId], references: [id])
users User[] @relation("UserDepartment")
@@unique([parentId, name])
@@index([parentId, sortOrder])
@@map("departments")
}
enum UserStatus {
ACTIVE
DISABLED
}
/// 用户:登录账号唯一、手机号唯一;生命周期只停用不删除(PRD R-02)
model User {
id String @id @db.Char(26)
account String @unique @db.VarChar(64)
email String? @unique @db.VarChar(128)
name String @db.VarChar(64)
phone String @unique @db.VarChar(20)
passwordHash String @map("password_hash") @db.VarChar(255)
departmentId String @map("department_id") @db.Char(26)
jobTitle String? @map("job_title") @db.VarChar(64)
status UserStatus @default(ACTIVE)
mustChangePassword Boolean @default(true) @map("must_change_password")
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(3)
lastLoginIp String? @map("last_login_ip") @db.VarChar(64)
disabledAt DateTime? @map("disabled_at") @db.Timestamptz(3)
remark String? @db.VarChar(200)
createdBy String? @map("created_by") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
department Department @relation("UserDepartment", fields: [departmentId], references: [id])
roles UserRole[]
refreshTokens RefreshToken[]
ledDepartments Department[] @relation("DepartmentLeader")
@@index([departmentId, status])
@@index([status, lastLoginAt])
@@map("users")
}
enum RoleStatus {
ACTIVE
DISABLED
}
/// 角色:A1 只做绑定多选,权限点配置属 A2(此处先存角色-权限点)
model Role {
id String @id @db.Char(26)
code String @unique @db.VarChar(64)
name String @db.VarChar(64)
description String @default("") @db.VarChar(255)
isSystem Boolean @default(false) @map("is_system")
status RoleStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
users UserRole[]
permissions RolePermission[]
dataScopes DataScope[]
@@map("roles")
}
/// 用户-角色绑定(一人多角色)
model UserRole {
userId String @map("user_id") @db.Char(26)
roleId String @map("role_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@index([roleId])
@@map("user_roles")
}
/// 角色-权限点:权限点取 @lingxi/shared PERMISSION_CODES,每请求实时解析(不缓存旧权限)
model RolePermission {
roleId String @map("role_id") @db.Char(26)
permissionCode String @map("permission_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([roleId, permissionCode])
@@index([permissionCode])
@@map("role_permissions")
}
/// 权限点目录(A2-BE-02):唯一事实源在 @lingxi/shared PERMISSION_CATALOG;
/// 启动时幂等 upsert 同步,只增不删;前端权限树由目录生成(不手写迁移)。
model Permission {
code String @id @db.VarChar(64)
module String @db.VarChar(32)
moduleLabel String @map("module_label") @db.VarChar(32)
name String @db.VarChar(64)
description String @default("") @db.VarChar(255)
isSpecial Boolean @default(false) @map("is_special")
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@index([module, sortOrder])
@@map("permissions")
}
/// 角色数据范围(A2-BE-04):六类范围之一 + 可选授权对象(当前只有「指定客户组」用客户组 id)。
/// 一行 = 一个范围类型的一个授权对象;无对象类型(全部 / 本部门 / 负责客户 / 本人创建)只存一行 target_id = NULL。
/// 解析后的可见客户集合由 CustomerDirectory 端口计算,不落库(A3 前为空集)。
model DataScope {
id String @id @db.Char(26)
roleId String @map("role_id") @db.Char(26)
scopeType String @map("scope_type") @db.VarChar(32)
targetId String? @map("target_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([roleId, scopeType, targetId])
@@index([roleId])
@@index([scopeType, targetId])
@@map("data_scopes")
}
/// 刷新令牌:jti 作主键、只存哈希;旋转时旧 token 立刻吊销并记录 replaced_by_id
model RefreshToken {
id String @id @db.Char(26)
userId String @map("user_id") @db.Char(26)
familyId String @map("family_id") @db.Char(26)
tokenHash String @unique @map("token_hash") @db.VarChar(64)
userAgent String? @map("user_agent") @db.VarChar(255)
ip String? @db.VarChar(64)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3)
replacedById String? @map("replaced_by_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, expiresAt])
@@index([familyId])
@@map("refresh_tokens")
}
/// 系统配置:阈值 / 上限类数字唯一存放处(PRD R-26),带版本与变更审计
model SystemConfig {
id String @id @db.Char(26)
configKey String @unique @map("config_key") @db.VarChar(128)
configValue Json @map("config_value") @db.JsonB
valueType String @map("value_type") @db.VarChar(16)
description String @default("") @db.VarChar(255)
version Int @default(1)
isEditable Boolean @default(true) @map("is_editable")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
updatedBy String? @map("updated_by") @db.Char(26)
@@map("system_configs")
}