feat(hub): Prisma schema + DB singleton,对齐 spec/System 契约

按 ADR-0001..0004/0017 重塑 schema,非照搬旧服务:
- ADR-0001: ProjectGroupBinding project↔chat 1:1(chatId @unique 钉单射不变式)
- ADR-0002: ProjectAgentLock projectId @id(at-most-one)+runId @unique;holder=run 非 session
- ADR-0004: PermissionGrant(resource×principal×role)+PermissionSettings(六旋钮)新落;
  PermissionRole=read/edit/manage 与 PlatformRole=admin/teacher 分离(旧 schema 混淆了)
- ADR-0017: AgentSession 无 claudeSessionId,改 provider+model(切 model 即新 session)
- RunState 对齐 spec:加 WAITING_FOR_USER/TIMED_OUT(枚举完整性 OPEN)
- Audit:runId 是 PINNED 关系,其余 OPEN
旧 schema 的 FeishuProjectBinding(user/chat 混表)拆为单一 ProjectGroupBinding。
prisma validate 通过;generate 通过;tsc --noEmit rc=0;
类型级 schema 约束检查全部通过(ADR-0002 主键/唯一、ADR-0017 无 claudeSessionId 等)。
This commit is contained in:
2026-07-06 22:48:28 +08:00
parent a08c33205b
commit b4dd8f09e1
5 changed files with 1406 additions and 6 deletions
+281
View File
@@ -0,0 +1,281 @@
// Prisma schema for Curriculum Project Hub.
//
// Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate:
//
// - No AgentSession.claudeSessionId. ADR-0017: session is provider/model-
// bound, not Claude-bound; `provider`+`model` replace it. Switching model
// = new session, so the unique constraint is on the session id alone.
// - PermissionRole enum = read/edit/manage (ADR-0004 capability lattice),
// distinct from platform UserRole (admin/teacher) — legacy conflated them.
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// completeness OPEN — add states without a schema migration war).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// --- Platform identity ---------------------------------------------------
/// A Feishu user known to the Hub. principal sub-typology is OPEN (ADR-0004).
model User {
id String @id @default(cuid())
feishuOpenId String @unique
displayName String
avatarUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
platformRoles PlatformRoleAssignment[]
createdProjects Project[] @relation("projectCreator")
requestedRuns AgentRun[] @relation("runRequester")
heldLocks ProjectAgentLock[] @relation("lockHolder")
feishuBindings ProjectGroupBinding[] @relation("bindingCreator")
permissionGrants PermissionGrant[] @relation("grantCreator")
auditEntries AuditEntry[] @relation("auditActor")
}
/// Platform-level role (admin/teacher). Distinct from ADR-0004 PermissionRole.
/// `admin` is the only override path for force-release (spec RequiresAdmin).
model PlatformRoleAssignment {
id String @id @default(cuid())
userId String
role PlatformRole
createdAt DateTime @default(now())
revokedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, revokedAt])
@@index([role, revokedAt])
}
enum PlatformRole {
ADMIN
TEACHER
}
// --- Project & Feishu binding (ADR-0001) ---------------------------------
model Project {
id String @id @default(cuid())
name String
workspaceDir String
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
createdBy User? @relation("projectCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
groupBinding ProjectGroupBinding?
agentSessions AgentSession[]
agentRuns AgentRun[]
agentLock ProjectAgentLock?
permissionGrants PermissionGrant[] @relation("projectGrants")
permissionSettings PermissionSettings[] @relation("projectSettings")
@@index([archivedAt])
}
/// ADR-0001: one project ↔ one Feishu chat (1:1). `chatId` is unique ⇒
/// GroupBinding.WellFormed's injectivity half (no two projects bind one chat).
/// The "at most one binding per project" half is enforced by the 1:1 relation.
/// Group dissolution lifecycle is OPEN (ADR-0001 Consequences) — not modeled
/// here; archival is a future policy, not a current column.
model ProjectGroupBinding {
id String @id @default(cuid())
projectId String @unique
chatId String @unique
createdByUserId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdBy User? @relation("bindingCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@index([chatId])
}
// --- AgentRun, session, lock (ADR-0002, 0017) -----------------------------
/// ADR-0017: session is provider/model-bound. No claudeSessionId; `provider`
/// + `model` capture the binding. Same provider+model ⇒ reuse across runs
/// (ADR-0002); a switch ⇒ new session.
model AgentSession {
id String @id @default(cuid())
projectId String
provider String
model String
title String?
metadata Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
archivedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
runs AgentRun[]
@@index([projectId, archivedAt])
@@index([provider, model])
@@index([updatedAt])
}
/// spec RunState: active/waitingForUser/completed/failed/timedOut/canceled.
/// Enum completeness OPEN (Run.lean:12) — adding a state is a value add, not a
/// spec breach. DB mirrors the current enum.
enum AgentRunStatus {
ACTIVE
WAITING_FOR_USER
COMPLETED
FAILED
TIMED_OUT
CANCELED
}
enum AgentEntrypoint {
FEISHU
WEB
CLI
}
model AgentRun {
id String @id @default(cuid())
projectId String
sessionId String?
requestedByUserId String?
entrypoint AgentEntrypoint
status AgentRunStatus @default(ACTIVE)
prompt String
model String
provider String
summary String?
inputTokens Int?
outputTokens Int?
metadata Json
error String?
startedAt DateTime @default(now())
finishedAt DateTime?
updatedAt DateTime @updatedAt
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
projectLock ProjectAgentLock?
@@index([projectId, status])
@@index([sessionId])
@@index([requestedByUserId])
@@index([updatedAt])
}
/// ADR-0002: lock owner = run_id (not session/user/chat). `projectId @id` ⇒
/// at most one lock per project (LockTable exclusivity). `runId @unique` ⇒
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
/// app-level invariant checked on read/write, not a DB constraint.
model ProjectAgentLock {
projectId String @id
runId String @unique
holderUserId String?
acquiredAt DateTime @default(now())
expiresAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
holder User? @relation("lockHolder", fields: [holderUserId], references: [id], onDelete: SetNull)
@@index([expiresAt])
}
// --- Permission grants & settings (ADR-0004) ----------------------------
/// ADR-0004 PermissionRole: read ⊂ edit ⊂ manage (capability lattice).
/// Distinct from PlatformRole. Force-release is admin-only, outside this
/// lattice (spec RequiresAdmin).
enum PermissionRole {
READ
EDIT
MANAGE
}
/// ADR-0004 resource_type: project | artifact | project_group. The resource
/// id's meaning is determined by its type (artifact id semantics align to the
/// Courseware half; OPEN here).
enum PermissionResourceType {
PROJECT
ARTIFACT
PROJECT_GROUP
}
/// ADR-0004 PermissionGrant: resource × principal × role.
/// `principal` is an opaque string (principal sub-typology OPEN, ADR-0004).
/// The compound unique covers "one active grant per (resource, principal, role)"
/// — revoked rows keep `revokedAt` set, so a re-grant after revocation is a new
/// row, not a conflict.
model PermissionGrant {
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
principal String
role PermissionRole
createdByUserId String?
createdAt DateTime @default(now())
revokedAt DateTime?
project Project? @relation("projectGrants", fields: [resourceId], references: [id], onDelete: Cascade)
createdBy User? @relation("grantCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([resourceType, resourceId, principal, role, revokedAt])
@@index([resourceType, resourceId, revokedAt])
@@index([principal, revokedAt])
}
/// ADR-0004 PermissionSettings: six policy knobs, values OPEN. Stored as one
/// opaque string column each; the enum/value-domain is decided by admin policy,
/// not by this schema. `resourceType`+`resourceId` identify the resource.
/// Related to Project only when resourceType=PROJECT (null otherwise).
model PermissionSettings {
id String @id @default(cuid())
resourceType PermissionResourceType
resourceId String
externalShare String
comment String
copyDownload String
collaboratorMgmt String
agentTrigger String
agentCancel String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation("projectSettings", fields: [resourceId], references: [id], onDelete: Cascade)
@@unique([resourceType, resourceId])
@@index([resourceType, resourceId])
}
// --- Audit (ADR Audit, content OPEN) -------------------------------------
/// spec AuditEntry: minimal skeleton — one entry relates to a run. Event type,
/// actor, timestamp, details are OPEN. This table mirrors that: `runId` is the
/// PINNED relation; the rest is open-shaped columns.
model AuditEntry {
id String @id @default(cuid())
runId String?
actorUserId String?
action String
metadata Json
createdAt DateTime @default(now())
actor User? @relation("auditActor", fields: [actorUserId], references: [id], onDelete: SetNull)
@@index([runId])
@@index([actorUserId])
@@index([createdAt])
}