Files
lingxi/prisma/schema.prisma
T
Stev_Wang cc6e84cdbc feat(shared): A4 数据字典与系统配置契约
背景:A4 需要字典三层合并、配置版本与命名规则的共享契约。\n改动:新增 dict/config 契约、system-configs 定义与校验、命名模板解析;新增 dict_types/dict_items/system_config_versions 迁移与 13 类/96 条字典、26 项配置种子。\n影响面:shared 新增导出;prisma 新增表与迁移;客户基础字段 DTO 硬上界调整为 50(实际生效上限仍由配置动态校验)。\n回滚:回滚本提交并保留新增表(不删数据)。\n验证:shared typecheck/test 147;seed 幂等;prisma migrate dev 已应用。
2026-11-04 11:48:38 +08:00

445 lines
18 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")
ownedCustomers Customer[] @relation("CustomerOwner")
createdCustomers Customer[] @relation("CustomerCreator")
appliedCustomerFields CustomerField[] @relation("CustomerFieldApplicant")
approvedCustomerFields CustomerField[] @relation("CustomerFieldApprover")
@@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)
versions SystemConfigVersion[]
@@map("system_configs")
}
/// 系统配置版本历史(append-only):每次变更留前后值、原因、操作人(A4-BE-02)
model SystemConfigVersion {
id String @id @db.Char(26)
configKey String @map("config_key") @db.VarChar(128)
version Int
oldValue Json? @map("old_value") @db.JsonB
newValue Json @map("new_value") @db.JsonB
reason String? @db.VarChar(255)
changedBy String? @map("changed_by") @db.Char(26)
changedAt DateTime @default(now()) @map("changed_at") @db.Timestamptz(3)
config SystemConfig @relation(fields: [configKey], references: [configKey], onDelete: Cascade)
@@unique([configKey, version])
@@index([changedAt])
@@map("system_config_versions")
}
// ---------------------------------------------------------------------------
// A3 客户与行业(client 模块;ADR-002 两级行业)
// ---------------------------------------------------------------------------
enum IndustryStatus {
ACTIVE
DISABLED
}
/// 行业两级树:一级 parent_id 为空,二级 parent_id 指向一级;最多两级。
/// 名称唯一性由迁移中的部分唯一索引保证:一级全局唯一、二级同父唯一。
model Industry {
id String @id @db.Char(26)
parentId String? @map("parent_id") @db.Char(26)
level Int @default(1)
name String @db.VarChar(64)
sortOrder Int @default(10) @map("sort_order")
status IndustryStatus @default(ACTIVE)
remark String @default("") @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
parent Industry? @relation("IndustryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Industry[] @relation("IndustryTree")
customers Customer[]
dictItems DictItem[]
@@index([parentId, sortOrder])
@@index([level, status])
@@map("industries")
}
enum CustomerStatus {
ACTIVE
PAUSED
ENDED
}
/// 客户:隔离单元;industry_id 必须指向末级行业;软删除保留历史。
model Customer {
id String @id @db.Char(26)
industryId String @map("industry_id") @db.Char(26)
name String @db.VarChar(128)
shortName String @map("short_name") @db.VarChar(32)
status CustomerStatus @default(ACTIVE)
ownerUserId String @map("owner_user_id") @db.Char(26)
contactName String? @map("contact_name") @db.VarChar(32)
contactPhone String? @map("contact_phone") @db.VarChar(32)
preferences Json @default("{}") @db.JsonB
customFields Json @default("[]") @map("custom_fields") @db.JsonB
remark String? @db.VarChar(500)
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)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
industry Industry @relation(fields: [industryId], references: [id], onDelete: Restrict)
owner User @relation("CustomerOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
creator User? @relation("CustomerCreator", fields: [createdBy], references: [id], onDelete: SetNull)
groupMembers CustomerGroupMember[]
fields CustomerField[]
@@unique([industryId, name])
@@index([ownerUserId, status])
@@index([status, updatedAt])
@@index([deletedAt])
@@map("customers")
}
/// 客户组:授权与数据范围的最小单位。
model CustomerGroup {
id String @id @db.Char(26)
name String @unique @db.VarChar(64)
description String @default("") @db.VarChar(255)
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)
members CustomerGroupMember[]
@@map("customer_groups")
}
/// 客户组 ↔ 客户(复合主键)
model CustomerGroupMember {
groupId String @map("group_id") @db.Char(26)
customerId String @map("customer_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@id([groupId, customerId])
@@index([customerId])
@@map("customer_group_members")
}
enum CustomerFieldStatus {
PENDING
ACTIVE
REJECTED
DISABLED
}
/// 客户级字段:只有 enters_generation=true 的字段需要审批;
/// 基础字段存 customers.custom_fields(JSONB),不进入本表。
model CustomerField {
id String @id @db.Char(26)
customerId String @map("customer_id") @db.Char(26)
name String @db.VarChar(32)
code String @db.VarChar(32)
type String @db.VarChar(16)
options Json @default("[]") @db.JsonB
purpose String @db.VarChar(200)
entersGeneration Boolean @default(false) @map("enters_generation")
status CustomerFieldStatus @default(PENDING)
applicantUserId String @map("applicant_user_id") @db.Char(26)
approverUserId String? @map("approver_user_id") @db.Char(26)
appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(3)
approvedAt DateTime? @map("approved_at") @db.Timestamptz(3)
rejectReason String? @map("reject_reason") @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
applicant User @relation("CustomerFieldApplicant", fields: [applicantUserId], references: [id], onDelete: Restrict)
approver User? @relation("CustomerFieldApprover", fields: [approverUserId], references: [id], onDelete: SetNull)
@@unique([customerId, code])
@@index([status, appliedAt])
@@index([customerId, status])
@@map("customer_fields")
}
// ---------------------------------------------------------------------------
// A4 数据字典(catalog 模块):一张通用枚举表同时表达二级树与词表
// ---------------------------------------------------------------------------
enum DictStatus {
ACTIVE
DISABLED
}
enum DictKind {
TREE
LIST
}
/// 字典类型:编码唯一且创建后不可改;内置类型不可删除
model DictType {
id String @id @db.Char(26)
code String @unique @db.VarChar(64)
name String @db.VarChar(64)
groupKey String @map("group_key") @db.VarChar(32)
kind DictKind @default(LIST)
description String @default("") @db.VarChar(255)
industryScoped Boolean @default(false) @map("industry_scoped")
builtin Boolean @default(false)
status DictStatus @default(ACTIVE)
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)
updatedBy String? @map("updated_by") @db.VarChar(64)
items DictItem[]
@@index([groupKey, sortOrder])
@@map("dict_types")
}
/// 字典条目:industry_id 为空 = 通用层;parent_code 表达二级树;
/// 同 type + code + industry 唯一由迁移中的部分唯一索引保证(NULL 也唯一)。
model DictItem {
id String @id @db.Char(26)
typeId String @map("type_id") @db.Char(26)
code String @db.VarChar(64)
name String @db.VarChar(64)
parentCode String? @map("parent_code") @db.VarChar(64)
industryId String? @map("industry_id") @db.Char(26)
sortOrder Int @default(0) @map("sort_order")
status DictStatus @default(ACTIVE)
remark String @default("") @db.VarChar(255)
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.VarChar(64)
type DictType @relation(fields: [typeId], references: [id], onDelete: Cascade)
industry Industry? @relation(fields: [industryId], references: [id], onDelete: Restrict)
@@index([typeId, industryId, sortOrder])
@@index([typeId, parentCode])
@@map("dict_items")
}