Files
lingxi/prisma/schema.prisma
T
Stev_Wang 4d9eb980af
ci / 质量门禁 (push) Canceled after 0s
feat(identity): 完成 A1 身份认证与用户管理全栈落地
背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
2026-11-03 00:03:33 +08:00

191 lines
7.6 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[]
@@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")
}
/// 刷新令牌: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")
}