Files
lingxi/prisma/schema.prisma
T
Stev_Wang 140dc386c1 feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。

改动:
- 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。
- 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。
- 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。
- 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。
- 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。

影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。
回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。
验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
2026-11-03 21:45:38 +08:00

364 lines
15 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)
@@map("system_configs")
}
// ---------------------------------------------------------------------------
// 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[]
@@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")
}