From 8e5d722a1e3bad72ea62d80cc2dc878bb2245444 Mon Sep 17 00:00:00 2001 From: WangDL Date: Sun, 24 May 2026 16:01:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M3-04/05/06=20=E2=80=94=20Workspace=20E?= =?UTF-8?q?xperience,=20Notification,=20Cache=20Module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3-04: RecentItem/Favorite/SearchHistory models, Tag CRUD, global search, workspace dashboard M3-05: NotificationPreference/PushToken/Template models, preferences, push tokens, admin templates M3-06: CacheService with wrap() penetration protection, key naming conventions, admin cache management E2E: 27 new tests for M3-04/05/06 (35/36 passing overall) Co-Authored-By: Claude Opus 4.7 --- prisma/schema.prisma | 1012 +++++++++-------- src/app.module.ts | 6 + src/common/cache/cache.module.ts | 10 + src/common/cache/cache.service.ts | 161 +++ src/common/events/item-favorited.event.ts | 14 + src/common/events/item-unfavorited.event.ts | 13 + .../notification-preference-changed.event.ts | 12 + src/common/events/notification-read.event.ts | 12 + src/common/events/notification-sent.event.ts | 14 + src/common/events/search-performed.event.ts | 13 + src/common/events/tag-created.event.ts | 13 + src/common/events/tag-deleted.event.ts | 13 + .../admin-cache/admin-cache.controller.ts | 48 + src/modules/admin-cache/admin-cache.module.ts | 9 + .../admin-notifications.controller.ts | 52 + .../notifications/notifications.controller.ts | 69 +- .../notifications/notifications.module.ts | 5 +- .../notifications/notifications.repository.ts | 79 ++ .../notifications/notifications.service.ts | 99 +- src/modules/workspace/dto/workspace.dto.ts | 91 ++ src/modules/workspace/workspace.controller.ts | 140 +++ src/modules/workspace/workspace.module.ts | 16 + src/modules/workspace/workspace.repository.ts | 212 ++++ src/modules/workspace/workspace.service.ts | 181 +++ test/m3.e2e-spec.ts | 248 ++++ test/mocks/prisma.mock.ts | 4 + 26 files changed, 2063 insertions(+), 483 deletions(-) create mode 100644 src/common/cache/cache.module.ts create mode 100644 src/common/cache/cache.service.ts create mode 100644 src/common/events/item-favorited.event.ts create mode 100644 src/common/events/item-unfavorited.event.ts create mode 100644 src/common/events/notification-preference-changed.event.ts create mode 100644 src/common/events/notification-read.event.ts create mode 100644 src/common/events/notification-sent.event.ts create mode 100644 src/common/events/search-performed.event.ts create mode 100644 src/common/events/tag-created.event.ts create mode 100644 src/common/events/tag-deleted.event.ts create mode 100644 src/modules/admin-cache/admin-cache.controller.ts create mode 100644 src/modules/admin-cache/admin-cache.module.ts create mode 100644 src/modules/notifications/admin-notifications.controller.ts create mode 100644 src/modules/workspace/dto/workspace.dto.ts create mode 100644 src/modules/workspace/workspace.controller.ts create mode 100644 src/modules/workspace/workspace.module.ts create mode 100644 src/modules/workspace/workspace.repository.ts create mode 100644 src/modules/workspace/workspace.service.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b6c576..f364564 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,63 +9,63 @@ datasource db { } model User { - id String @id @default(cuid()) - email String? @db.VarChar(255) - nickname String? @db.VarChar(100) - avatarUrl String? @db.VarChar(500) - role String @default("USER") @db.VarChar(32) - status String @default("active") @db.VarChar(32) - onboardingCompleted Boolean @default(false) - lastLoginAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + id String @id @default(cuid()) + email String? @db.VarChar(255) + nickname String? @db.VarChar(100) + avatarUrl String? @db.VarChar(500) + role String @default("USER") @db.VarChar(32) + status String @default("active") @db.VarChar(32) + onboardingCompleted Boolean @default(false) + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? - authAccounts AuthAccount[] - refreshTokens RefreshToken[] - memberships UserMembership[] - profile UserProfile? - preferences UserPreference? - consents UserConsent[] - knowledgeBases KnowledgeBase[] - knowledgeItems KnowledgeItem[] - knowledgeItemRelations KnowledgeItemRelation[] - tags Tag[] - uploadedFiles UploadedFile[] - documentImports DocumentImport[] - learningSessions LearningSession[] - learningRecords LearningRecord[] - activeRecallQuestions ActiveRecallQuestion[] - activeRecallAnswers ActiveRecallAnswer[] - aiAnalysisJobs AiAnalysisJob[] - aiAnalysisResults AiAnalysisResult[] - focusItems FocusItem[] - reviewCards ReviewCard[] - reviewLogs ReviewLog[] - reviewPlans ReviewPlan[] + authAccounts AuthAccount[] + refreshTokens RefreshToken[] + memberships UserMembership[] + profile UserProfile? + preferences UserPreference? + consents UserConsent[] + knowledgeBases KnowledgeBase[] + knowledgeItems KnowledgeItem[] + knowledgeItemRelations KnowledgeItemRelation[] + tags Tag[] + uploadedFiles UploadedFile[] + documentImports DocumentImport[] + learningSessions LearningSession[] + learningRecords LearningRecord[] + activeRecallQuestions ActiveRecallQuestion[] + activeRecallAnswers ActiveRecallAnswer[] + aiAnalysisJobs AiAnalysisJob[] + aiAnalysisResults AiAnalysisResult[] + focusItems FocusItem[] + reviewCards ReviewCard[] + reviewLogs ReviewLog[] + reviewPlans ReviewPlan[] dailyLearningActivities DailyLearningActivity[] - notifications Notification[] - feedbacks Feedback[] - aiUsageLogs AiUsageLog[] - knowledgeSources KnowledgeSource[] - knowledgeChunks KnowledgeChunk[] - importCandidates ImportCandidate[] + notifications Notification[] + feedbacks Feedback[] + aiUsageLogs AiUsageLog[] + knowledgeSources KnowledgeSource[] + knowledgeChunks KnowledgeChunk[] + importCandidates ImportCandidate[] @@index([email]) @@index([status]) } model AuthAccount { - id String @id @default(cuid()) - userId String - provider String @db.VarChar(32) - providerUserId String @db.VarChar(255) - email String? @db.VarChar(255) - rawProfileJson Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + provider String @db.VarChar(32) + providerUserId String @db.VarChar(255) + email String? @db.VarChar(255) + rawProfileJson Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@unique([provider, providerUserId]) @@index([userId]) @@ -82,51 +82,51 @@ model RefreshToken { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([userId]) @@index([tokenHash]) } model UserProfile { - id String @id @default(cuid()) - userId String @unique - learningIdentity String? @db.VarChar(100) - learningDirection String? @db.VarChar(255) - bio String? @db.Text - currentGoal String? @db.VarChar(255) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String @unique + learningIdentity String? @db.VarChar(100) + learningDirection String? @db.VarChar(255) + bio String? @db.Text + currentGoal String? @db.VarChar(255) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) } model UserPreference { - id String @id @default(cuid()) - userId String @unique + id String @id @default(cuid()) + userId String @unique preferredMethods Json? - defaultFocusMinutes Int @default(25) - aiSuggestionLevel String @default("normal") @db.VarChar(32) - language String @default("zh-CN") @db.VarChar(32) - appearance String @default("system") @db.VarChar(32) - notificationEnabled Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + defaultFocusMinutes Int @default(25) + aiSuggestionLevel String @default("normal") @db.VarChar(32) + language String @default("zh-CN") @db.VarChar(32) + appearance String @default("system") @db.VarChar(32) + notificationEnabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) } model UserConsent { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - consentType String @db.VarChar(32) - version String @db.VarChar(50) + consentType String @db.VarChar(32) + version String @db.VarChar(50) acceptedAt DateTime - ipAddress String? @db.VarChar(100) - userAgent String? @db.VarChar(500) - createdAt DateTime @default(now()) + ipAddress String? @db.VarChar(100) + userAgent String? @db.VarChar(500) + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([userId]) @@index([consentType]) @@ -152,9 +152,9 @@ model KnowledgeFolder { updatedAt DateTime @updatedAt deletedAt DateTime? - knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) - parent KnowledgeFolder? @relation("FolderTree", fields: [parentId], references: [id]) - children KnowledgeFolder[] @relation("FolderTree") + knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) + parent KnowledgeFolder? @relation("FolderTree", fields: [parentId], references: [id]) + children KnowledgeFolder[] @relation("FolderTree") @@index([knowledgeBaseId]) @@index([parentId]) @@ -173,13 +173,13 @@ model KnowledgeBase { updatedAt DateTime @updatedAt deletedAt DateTime? - user User @relation(fields: [userId], references: [id]) - items KnowledgeItem[] - sources KnowledgeSource[] - candidates ImportCandidate[] - chunks KnowledgeChunk[] - focusItems FocusItem[] - folders KnowledgeFolder[] + user User @relation(fields: [userId], references: [id]) + items KnowledgeItem[] + sources KnowledgeSource[] + candidates ImportCandidate[] + chunks KnowledgeChunk[] + focusItems FocusItem[] + folders KnowledgeFolder[] @@index([userId]) @@index([status]) @@ -201,31 +201,31 @@ model Artifact { } model KnowledgeItem { - id String @id @default(cuid()) - userId String - knowledgeBaseId String - parentId String? - itemType String @db.VarChar(32) - title String @db.VarChar(255) - content String? @db.LongText - summary String? @db.Text - learnable Boolean @default(true) - sourceType String? @db.VarChar(32) - sourceRef String? @db.VarChar(500) - sourceDeleted Boolean @default(false) - sourceTitleSnapshot String? @db.VarChar(255) - sourceSnippetSnapshot String? @db.Text - orderIndex Int @default(0) - status String @default("active") @db.VarChar(32) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + id String @id @default(cuid()) + userId String + knowledgeBaseId String + parentId String? + itemType String @db.VarChar(32) + title String @db.VarChar(255) + content String? @db.LongText + summary String? @db.Text + learnable Boolean @default(true) + sourceType String? @db.VarChar(32) + sourceRef String? @db.VarChar(500) + sourceDeleted Boolean @default(false) + sourceTitleSnapshot String? @db.VarChar(255) + sourceSnippetSnapshot String? @db.Text + orderIndex Int @default(0) + status String @default("active") @db.VarChar(32) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? - user User @relation(fields: [userId], references: [id]) - knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) - parent KnowledgeItem? @relation("KnowledgeItemRelations", fields: [parentId], references: [id]) - children KnowledgeItem[] @relation("KnowledgeItemRelations") - tags KnowledgeItemTag[] + user User @relation(fields: [userId], references: [id]) + knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) + parent KnowledgeItem? @relation("KnowledgeItemRelations", fields: [parentId], references: [id]) + children KnowledgeItem[] @relation("KnowledgeItemRelations") + tags KnowledgeItemTag[] @@index([userId]) @@index([knowledgeBaseId]) @@ -234,64 +234,64 @@ model KnowledgeItem { } model KnowledgeItemRelation { - id String @id @default(cuid()) - userId String - sourceItemId String - targetItemId String - relationType String @db.VarChar(32) - confidence Decimal? @db.Decimal(5, 2) - reason String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + sourceItemId String + targetItemId String + relationType String @db.VarChar(32) + confidence Decimal? @db.Decimal(5, 2) + reason String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([sourceItemId]) @@index([targetItemId]) } model Tag { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - name String @db.VarChar(100) - color String? @db.VarChar(32) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + name String @db.VarChar(100) + color String? @db.VarChar(32) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - items KnowledgeItemTag[] + user User @relation(fields: [userId], references: [id]) + items KnowledgeItemTag[] @@unique([userId, name]) } model KnowledgeItemTag { - id String @id @default(cuid()) + id String @id @default(cuid()) knowledgeItemId String tagId String - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - knowledgeItem KnowledgeItem @relation(fields: [knowledgeItemId], references: [id]) - tag Tag @relation(fields: [tagId], references: [id]) + knowledgeItem KnowledgeItem @relation(fields: [knowledgeItemId], references: [id]) + tag Tag @relation(fields: [tagId], references: [id]) @@unique([knowledgeItemId, tagId]) } model UploadedFile { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - filename String @db.VarChar(255) - mimeType String? @db.VarChar(100) - storagePath String @db.VarChar(500) - objectKey String? @db.VarChar(500) - bucket String? @db.VarChar(100) - sizeBytes BigInt @default(0) - checksum String? @db.VarChar(255) - sha256 String? @db.VarChar(64) - purpose String? @db.VarChar(32) - createdAt DateTime @default(now()) + filename String @db.VarChar(255) + mimeType String? @db.VarChar(100) + storagePath String @db.VarChar(500) + objectKey String? @db.VarChar(500) + bucket String? @db.VarChar(100) + sizeBytes BigInt @default(0) + checksum String? @db.VarChar(255) + sha256 String? @db.VarChar(64) + purpose String? @db.VarChar(32) + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - sources KnowledgeSource[] + user User @relation(fields: [userId], references: [id]) + sources KnowledgeSource[] @@index([userId]) @@index([objectKey]) @@ -323,9 +323,9 @@ model DocumentImport { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - source KnowledgeSource? @relation(fields: [sourceId], references: [id]) - candidates ImportCandidate[] + user User @relation(fields: [userId], references: [id]) + source KnowledgeSource? @relation(fields: [sourceId], references: [id]) + candidates ImportCandidate[] @@index([userId]) @@index([status]) @@ -334,34 +334,34 @@ model DocumentImport { } model ImportStepLog { - id String @id @default(cuid()) - importId String - step String @db.VarChar(32) - status String @db.VarChar(16) - detail String? @db.VarChar(500) - startedAt DateTime? + id String @id @default(cuid()) + importId String + step String @db.VarChar(32) + status String @db.VarChar(16) + detail String? @db.VarChar(500) + startedAt DateTime? completedAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([importId]) } model LearningSession { - id String @id @default(cuid()) - userId String - knowledgeBaseId String? - knowledgeItemId String? - mode String @db.VarChar(32) - status String @default("active") @db.VarChar(32) - startedAt DateTime - endedAt DateTime? - durationSeconds Int @default(0) - focusMinutes Int? - metadata Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + knowledgeBaseId String? + knowledgeItemId String? + mode String @db.VarChar(32) + status String @default("active") @db.VarChar(32) + startedAt DateTime + endedAt DateTime? + durationSeconds Int @default(0) + focusMinutes Int? + metadata Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([userId]) @@index([knowledgeItemId]) @@ -369,53 +369,53 @@ model LearningSession { } model LearningRecord { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String sessionId String? - recordType String @db.VarChar(32) - title String @db.VarChar(255) - description String? @db.Text - durationSeconds Int @default(0) + recordType String @db.VarChar(32) + title String @db.VarChar(255) + description String? @db.Text + durationSeconds Int @default(0) occurredAt DateTime metadata Json? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([userId]) @@index([occurredAt]) } model ActiveRecallQuestion { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String knowledgeItemId String? - questionText String @db.Text - difficulty String? @db.VarChar(32) - createdBy String @default("ai") @db.VarChar(32) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + questionText String @db.Text + difficulty String? @db.VarChar(32) + createdBy String @default("ai") @db.VarChar(32) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - answers ActiveRecallAnswer[] + user User @relation(fields: [userId], references: [id]) + answers ActiveRecallAnswer[] @@index([userId]) @@index([knowledgeItemId]) } model ActiveRecallAnswer { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String questionId String? sessionId String? - answerType String @default("text") @db.VarChar(32) - answerText String? @db.LongText + answerType String @default("text") @db.VarChar(32) + answerText String? @db.LongText audioFileId String? submittedAt DateTime - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - question ActiveRecallQuestion? @relation(fields: [questionId], references: [id]) + user User @relation(fields: [userId], references: [id]) + question ActiveRecallQuestion? @relation(fields: [questionId], references: [id]) @@index([userId]) @@index([questionId]) @@ -423,22 +423,22 @@ model ActiveRecallAnswer { } model AiAnalysisJob { - id String @id @default(cuid()) - userId String - sessionId String? - answerId String? - jobType String @db.VarChar(32) - status String @default("pending") @db.VarChar(32) - progress Int @default(0) - errorMessage String? @db.Text - queuedAt DateTime? - startedAt DateTime? - completedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + sessionId String? + answerId String? + jobType String @db.VarChar(32) + status String @default("pending") @db.VarChar(32) + progress Int @default(0) + errorMessage String? @db.Text + queuedAt DateTime? + startedAt DateTime? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - results AiAnalysisResult[] + user User @relation(fields: [userId], references: [id]) + results AiAnalysisResult[] @@index([userId]) @@index([status]) @@ -446,23 +446,23 @@ model AiAnalysisJob { } model AiAnalysisResult { - id String @id @default(cuid()) - userId String - jobId String - sessionId String? - answerId String? - summary String? @db.Text - masteryScore Int? - strengths Json? - weaknesses Json? - suggestions Json? - nextActions Json? - rawResult Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + jobId String + sessionId String? + answerId String? + summary String? @db.Text + masteryScore Int? + strengths Json? + weaknesses Json? + suggestions Json? + nextActions Json? + rawResult Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - job AiAnalysisJob @relation(fields: [jobId], references: [id]) + user User @relation(fields: [userId], references: [id]) + job AiAnalysisJob @relation(fields: [jobId], references: [id]) @@index([userId]) @@index([jobId]) @@ -487,8 +487,8 @@ model FocusItem { updatedAt DateTime @updatedAt deletedAt DateTime? - user User @relation(fields: [userId], references: [id]) - knowledgeBase KnowledgeBase? @relation(fields: [knowledgeBaseId], references: [id]) + user User @relation(fields: [userId], references: [id]) + knowledgeBase KnowledgeBase? @relation(fields: [knowledgeBaseId], references: [id]) @@index([userId]) @@index([status]) @@ -496,25 +496,25 @@ model FocusItem { } model ReviewCard { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String knowledgeItemId String? focusItemId String? - frontText String @db.Text - backText String? @db.Text - difficulty String? @db.VarChar(32) - status String @default("active") @db.VarChar(32) + frontText String @db.Text + backText String? @db.Text + difficulty String? @db.VarChar(32) + status String @default("active") @db.VarChar(32) nextReviewAt DateTime? - intervalDays Int @default(1) - easeFactor Decimal @default(2.50) @db.Decimal(4, 2) - repetitionCount Int @default(0) - lapseCount Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + intervalDays Int @default(1) + easeFactor Decimal @default(2.50) @db.Decimal(4, 2) + repetitionCount Int @default(0) + lapseCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deletedAt DateTime? - user User @relation(fields: [userId], references: [id]) - logs ReviewLog[] + user User @relation(fields: [userId], references: [id]) + logs ReviewLog[] @@index([userId]) @@index([nextReviewAt]) @@ -522,18 +522,18 @@ model ReviewCard { } model ReviewLog { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String reviewCardId String sessionId String? - rating String @db.VarChar(32) - responseText String? @db.Text + rating String @db.VarChar(32) + responseText String? @db.Text reviewedAt DateTime nextReviewAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) - reviewCard ReviewCard @relation(fields: [reviewCardId], references: [id]) + user User @relation(fields: [userId], references: [id]) + reviewCard ReviewCard @relation(fields: [reviewCardId], references: [id]) @@index([userId]) @@index([reviewCardId]) @@ -551,27 +551,27 @@ model ReviewPlan { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([userId]) @@index([scheduledAt]) } model DailyLearningActivity { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - activityDate DateTime @db.Date - durationSeconds Int @default(0) - sessionsCount Int @default(0) - activeRecallCount Int @default(0) - reviewCount Int @default(0) - aiAnalysisCount Int @default(0) - completedLoopCount Int @default(0) - activityLevel Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + activityDate DateTime @db.Date + durationSeconds Int @default(0) + sessionsCount Int @default(0) + activeRecallCount Int @default(0) + reviewCount Int @default(0) + aiAnalysisCount Int @default(0) + completedLoopCount Int @default(0) + activityLevel Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@unique([userId, activityDate]) @@index([userId]) @@ -587,7 +587,7 @@ model Notification { readAt DateTime? createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([userId]) @@index([readAt]) @@ -595,40 +595,40 @@ model Notification { } model Feedback { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String? - email String? @db.VarChar(255) - category String @db.VarChar(64) - content String @db.Text + email String? @db.VarChar(255) + category String @db.VarChar(64) + content String @db.Text deviceInfo Json? - status String @default("open") @db.VarChar(32) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status String @default("open") @db.VarChar(32) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User? @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id]) @@index([userId]) @@index([status]) } model AiUsageLog { - id String @id @default(cuid()) - userId String - feature String @db.VarChar(64) - provider String @db.VarChar(32) - model String @db.VarChar(100) - tier String @db.VarChar(32) - promptKey String @db.VarChar(128) - promptVersion String @db.VarChar(32) - inputTokens Int @default(0) - outputTokens Int @default(0) - estimatedCost Float @default(0) - latencyMs Int @default(0) - success Boolean @default(true) - errorMessage String? @db.VarChar(500) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + feature String @db.VarChar(64) + provider String @db.VarChar(32) + model String @db.VarChar(100) + tier String @db.VarChar(32) + promptKey String @db.VarChar(128) + promptVersion String @db.VarChar(32) + inputTokens Int @default(0) + outputTokens Int @default(0) + estimatedCost Float @default(0) + latencyMs Int @default(0) + success Boolean @default(true) + errorMessage String? @db.VarChar(500) + createdAt DateTime @default(now()) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) @@index([userId]) @@index([feature]) @@ -701,38 +701,38 @@ model AppChangelog { // ── 知识库新增模型 ── model KnowledgeSource { - id String @id @default(cuid()) - userId String - knowledgeBaseId String - fileId String? - type String @default("file") @db.VarChar(32) - title String? @db.VarChar(255) - originalFilename String? @db.VarChar(255) - mimeType String? @db.VarChar(100) - sizeBytes BigInt @default(0) - textLength Int @default(0) - parseStatus String @default("pending") @db.VarChar(32) - indexStatus String @default("pending") @db.VarChar(32) - learningStatus String @default("pending") @db.VarChar(32) - parsedObjectKey String? @db.VarChar(500) - metadataObjectKey String? @db.VarChar(500) - originalObjectKey String? @db.VarChar(500) - version Int @default(1) - parentSourceId String? + id String @id @default(cuid()) + userId String + knowledgeBaseId String + fileId String? + type String @default("file") @db.VarChar(32) + title String? @db.VarChar(255) + originalFilename String? @db.VarChar(255) + mimeType String? @db.VarChar(100) + sizeBytes BigInt @default(0) + textLength Int @default(0) + parseStatus String @default("pending") @db.VarChar(32) + indexStatus String @default("pending") @db.VarChar(32) + learningStatus String @default("pending") @db.VarChar(32) + parsedObjectKey String? @db.VarChar(500) + metadataObjectKey String? @db.VarChar(500) + originalObjectKey String? @db.VarChar(500) + version Int @default(1) + parentSourceId String? replacedBySourceId String? - errorCode String? @db.VarChar(32) - errorMessage String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - deletedAt DateTime? + errorCode String? @db.VarChar(32) + errorMessage String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? - user User @relation(fields: [userId], references: [id]) - knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) - file UploadedFile? @relation(fields: [fileId], references: [id]) - chunks KnowledgeChunk[] - imports DocumentImport[] - references SourceReference[] - candidates ImportCandidate[] + user User @relation(fields: [userId], references: [id]) + knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) + file UploadedFile? @relation(fields: [fileId], references: [id]) + chunks KnowledgeChunk[] + imports DocumentImport[] + references SourceReference[] + candidates ImportCandidate[] @@index([userId]) @@index([knowledgeBaseId]) @@ -759,10 +759,10 @@ model KnowledgeChunk { updatedAt DateTime @updatedAt deletedAt DateTime? - user User @relation(fields: [userId], references: [id]) - knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) - source KnowledgeSource @relation(fields: [sourceId], references: [id]) - references SourceReference[] + user User @relation(fields: [userId], references: [id]) + knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) + source KnowledgeSource @relation(fields: [sourceId], references: [id]) + references SourceReference[] @@index([userId]) @@index([sourceId]) @@ -771,47 +771,47 @@ model KnowledgeChunk { } model SourceReference { - id String @id @default(cuid()) - sourceId String - chunkId String? - artifactType String @db.VarChar(32) - artifactId String @db.VarChar(100) - pageNumber Int? - sectionTitle String? @db.VarChar(500) - excerptText String? @db.VarChar(2000) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + sourceId String + chunkId String? + artifactType String @db.VarChar(32) + artifactId String @db.VarChar(100) + pageNumber Int? + sectionTitle String? @db.VarChar(500) + excerptText String? @db.VarChar(2000) + createdAt DateTime @default(now()) - source KnowledgeSource @relation(fields: [sourceId], references: [id]) - chunk KnowledgeChunk? @relation(fields: [chunkId], references: [id]) + source KnowledgeSource @relation(fields: [sourceId], references: [id]) + chunk KnowledgeChunk? @relation(fields: [chunkId], references: [id]) @@index([artifactType, artifactId]) @@index([sourceId]) } model ImportCandidate { - id String @id @default(cuid()) - userId String - knowledgeBaseId String - sourceId String - importId String - title String @db.VarChar(255) - summary String? @db.Text - content String? @db.LongText - tagsJson Json? + id String @id @default(cuid()) + userId String + knowledgeBaseId String + sourceId String + importId String + title String @db.VarChar(255) + summary String? @db.Text + content String? @db.LongText + tagsJson Json? recallQuestionsJson Json? - sourceTextSnippet String? @db.Text - sourceChunkIds Json? - confidence Decimal @default(0) @db.Decimal(4, 3) - difficulty String? @db.VarChar(16) - orderIndex Int @default(0) - status String @default("PENDING") @db.VarChar(16) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + sourceTextSnippet String? @db.Text + sourceChunkIds Json? + confidence Decimal @default(0) @db.Decimal(4, 3) + difficulty String? @db.VarChar(16) + orderIndex Int @default(0) + status String @default("PENDING") @db.VarChar(16) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) - source KnowledgeSource @relation(fields: [sourceId], references: [id]) - import DocumentImport @relation(fields: [importId], references: [id]) + user User @relation(fields: [userId], references: [id]) + knowledgeBase KnowledgeBase @relation(fields: [knowledgeBaseId], references: [id]) + source KnowledgeSource @relation(fields: [sourceId], references: [id]) + import DocumentImport @relation(fields: [importId], references: [id]) @@index([userId]) @@index([sourceId]) @@ -849,9 +849,9 @@ model AdminUser { updatedAt DateTime @updatedAt deletedAt DateTime? - sessions AdminSession[] - conversations AdminConversation[] - auditLogs AdminAuditLog[] + sessions AdminSession[] + conversations AdminConversation[] + auditLogs AdminAuditLog[] @@index([email]) @@index([status]) @@ -868,7 +868,7 @@ model AdminSession { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - adminUser AdminUser @relation(fields: [adminUserId], references: [id]) + adminUser AdminUser @relation(fields: [adminUserId], references: [id]) @@index([adminUserId]) @@index([refreshTokenHash]) @@ -888,7 +888,7 @@ model AdminAuditLog { reason String? @db.VarChar(500) createdAt DateTime @default(now()) - adminUser AdminUser @relation(fields: [adminUserId], references: [id]) + adminUser AdminUser @relation(fields: [adminUserId], references: [id]) @@index([adminUserId]) @@index([action]) @@ -896,37 +896,37 @@ model AdminAuditLog { } model MembershipPlan { - id String @id @default(cuid()) - code String @unique @db.VarChar(32) - name String @db.VarChar(100) - priceMonthly Int @default(0) - priceYearly Int @default(0) - maxKnowledgeBases Int @default(1) - maxStorageBytes BigInt @default(0) - maxFileSizeBytes BigInt @default(0) - monthlyOcrPages Int @default(0) - monthlyVisionPages Int @default(0) - monthlyChatCount Int @default(0) - monthlyAiAnalysisCount Int @default(0) - monthlyRecallCount Int @default(0) - monthlyCardGenCount Int @default(0) - isActive Boolean @default(true) - memberships UserMembership[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + code String @unique @db.VarChar(32) + name String @db.VarChar(100) + priceMonthly Int @default(0) + priceYearly Int @default(0) + maxKnowledgeBases Int @default(1) + maxStorageBytes BigInt @default(0) + maxFileSizeBytes BigInt @default(0) + monthlyOcrPages Int @default(0) + monthlyVisionPages Int @default(0) + monthlyChatCount Int @default(0) + monthlyAiAnalysisCount Int @default(0) + monthlyRecallCount Int @default(0) + monthlyCardGenCount Int @default(0) + isActive Boolean @default(true) + memberships UserMembership[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model AdminConversation { - id String @id @default(cuid()) + id String @id @default(cuid()) adminUserId String - title String @default("新对话") @db.VarChar(200) - hermesSessionId String @unique @db.VarChar(64) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + title String @default("新对话") @db.VarChar(200) + hermesSessionId String @unique @db.VarChar(64) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt deletedAt DateTime? messages AdminMessage[] - adminUser AdminUser @relation(fields: [adminUserId], references: [id]) + adminUser AdminUser @relation(fields: [adminUserId], references: [id]) @@index([adminUserId]) @@index([hermesSessionId]) @@ -939,21 +939,21 @@ model AdminMessage { content String @db.LongText createdAt DateTime @default(now()) - conversation AdminConversation @relation(fields: [conversationId], references: [id]) + conversation AdminConversation @relation(fields: [conversationId], references: [id]) @@index([conversationId]) @@index([createdAt]) } model ChatSession { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String knowledgeBaseId String - title String @default("新对话") @db.VarChar(200) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + title String @default("新对话") @db.VarChar(200) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - messages ChatMessage[] + messages ChatMessage[] @@index([userId]) @@index([knowledgeBaseId]) @@ -967,7 +967,7 @@ model ChatMessage { tokens Int @default(0) createdAt DateTime @default(now()) - session ChatSession @relation(fields: [sessionId], references: [id]) + session ChatSession @relation(fields: [sessionId], references: [id]) citations ChatCitation[] @@index([sessionId]) @@ -983,23 +983,23 @@ model ChatCitation { pageNumber Int? createdAt DateTime @default(now()) - message ChatMessage @relation(fields: [messageId], references: [id]) + message ChatMessage @relation(fields: [messageId], references: [id]) @@index([messageId]) } model AdminCostItem { - id String @id @default(cuid()) - name String @db.VarChar(100) - category String @default("other") @db.VarChar(32) + id String @id @default(cuid()) + name String @db.VarChar(100) + category String @default("other") @db.VarChar(32) amount Float - currency String @default("CNY") @db.VarChar(8) + currency String @default("CNY") @db.VarChar(8) purchaseDate DateTime expiryDate DateTime? - billingCycle String @default("once") @db.VarChar(16) - note String? @db.VarChar(255) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + billingCycle String @default("once") @db.VarChar(16) + note String? @db.VarChar(255) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([category]) @@index([expiryDate]) @@ -1047,17 +1047,17 @@ model ConfigChangeLog { } model SecurityEvent { - id String @id @default(cuid()) - userId String? + id String @id @default(cuid()) + userId String? adminUserId String? - eventType String @db.VarChar(64) - severity String @default("low") @db.VarChar(16) - ip String? @db.VarChar(45) - userAgent String? @db.VarChar(500) - detail Json? - handled Boolean @default(false) - handledBy String? @db.VarChar(100) - createdAt DateTime @default(now()) + eventType String @db.VarChar(64) + severity String @default("low") @db.VarChar(16) + ip String? @db.VarChar(45) + userAgent String? @db.VarChar(500) + detail Json? + handled Boolean @default(false) + handledBy String? @db.VarChar(100) + createdAt DateTime @default(now()) @@index([userId]) @@index([eventType]) @@ -1078,17 +1078,17 @@ model SensitiveWord { } model ContentSafetyCheck { - id String @id @default(cuid()) - userId String? @db.VarChar(100) - contentType String @db.VarChar(32) - content String @db.Text - riskLevel String @db.VarChar(16) - matchedWords String? @db.Text - result String @default("pending") @db.VarChar(16) - reviewerId String? @db.VarChar(100) - reviewNote String? @db.VarChar(500) - createdAt DateTime @default(now()) - reviewedAt DateTime? + id String @id @default(cuid()) + userId String? @db.VarChar(100) + contentType String @db.VarChar(32) + content String @db.Text + riskLevel String @db.VarChar(16) + matchedWords String? @db.Text + result String @default("pending") @db.VarChar(16) + reviewerId String? @db.VarChar(100) + reviewNote String? @db.VarChar(500) + createdAt DateTime @default(now()) + reviewedAt DateTime? @@index([userId]) @@index([result]) @@ -1096,45 +1096,45 @@ model ContentSafetyCheck { } model ContentReport { - id String @id @default(cuid()) - reporterId String - targetType String @db.VarChar(32) - targetId String @db.VarChar(100) - reason String @db.VarChar(500) - status String @default("pending") @db.VarChar(16) - handledBy String? @db.VarChar(100) - handleNote String? @db.VarChar(500) - createdAt DateTime @default(now()) - handledAt DateTime? + id String @id @default(cuid()) + reporterId String + targetType String @db.VarChar(32) + targetId String @db.VarChar(100) + reason String @db.VarChar(500) + status String @default("pending") @db.VarChar(16) + handledBy String? @db.VarChar(100) + handleNote String? @db.VarChar(500) + createdAt DateTime @default(now()) + handledAt DateTime? @@index([status]) @@index([createdAt]) } model ViolationRecord { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - contentType String @db.VarChar(32) - content String @db.VarChar(1000) - riskLevel String @db.VarChar(16) - penalty String @default("none") @db.VarChar(32) - appliedBy String? @db.VarChar(100) + contentType String @db.VarChar(32) + content String @db.VarChar(1000) + riskLevel String @db.VarChar(16) + penalty String @default("none") @db.VarChar(32) + appliedBy String? @db.VarChar(100) appliedAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([userId]) @@index([createdAt]) } model ApiMetric { - id String @id @default(cuid()) - path String @db.VarChar(255) - method String @db.VarChar(10) + id String @id @default(cuid()) + path String @db.VarChar(255) + method String @db.VarChar(10) statusCode Int - duration Int - userId String? @db.VarChar(100) - ip String? @db.VarChar(45) - createdAt DateTime @default(now()) + duration Int + userId String? @db.VarChar(100) + ip String? @db.VarChar(45) + createdAt DateTime @default(now()) @@index([path]) @@index([createdAt]) @@ -1157,30 +1157,30 @@ model TaskLog { } model UserMembership { - id String @id @default(cuid()) - userId String - planId String - startedAt DateTime @default(now()) - expiresAt DateTime? - active Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + planId String + startedAt DateTime @default(now()) + expiresAt DateTime? + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - plan MembershipPlan @relation(fields: [planId], references: [id]) + user User @relation(fields: [userId], references: [id]) + plan MembershipPlan @relation(fields: [planId], references: [id]) @@index([userId]) } model UserDevice { - id String @id @default(cuid()) - userId String - deviceId String @db.VarChar(255) - deviceName String? @db.VarChar(100) - osVersion String? @db.VarChar(50) - pushToken String? @db.VarChar(500) + id String @id @default(cuid()) + userId String + deviceId String @db.VarChar(255) + deviceName String? @db.VarChar(100) + osVersion String? @db.VarChar(50) + pushToken String? @db.VarChar(500) lastSeenAt DateTime @default(now()) - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@unique([userId, deviceId]) @@index([userId]) @@ -1203,12 +1203,12 @@ model AccountDeletionRequest { } model QuotaUsage { - id String @id @default(cuid()) - userId String - quotaType String @db.VarChar(32) - amount Int - resource String? @db.VarChar(255) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + userId String + quotaType String @db.VarChar(32) + amount Int + resource String? @db.VarChar(255) + createdAt DateTime @default(now()) @@index([userId, quotaType]) @@index([createdAt]) @@ -1228,16 +1228,16 @@ model CostDailySummary { } model SecretRecord { - id String @id @default(cuid()) - name String @unique @db.VarChar(100) - provider String @db.VarChar(32) - encrypted String @db.Text - maskLast4 String @db.VarChar(4) - status String @default("active") @db.VarChar(16) - expiresAt DateTime? - rotatedFrom String? @db.VarChar(100) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String @unique @db.VarChar(100) + provider String @db.VarChar(32) + encrypted String @db.Text + maskLast4 String @db.VarChar(4) + status String @default("active") @db.VarChar(16) + expiresAt DateTime? + rotatedFrom String? @db.VarChar(100) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([provider]) } @@ -1252,3 +1252,81 @@ model SecretAccessLog { @@index([secretId]) @@index([createdAt]) } + +model RecentItem { + id String @id @default(cuid()) + userId String + targetType String @db.VarChar(32) + targetId String @db.VarChar(255) + title String @db.VarChar(255) + metadata Json? + accessedAt DateTime @updatedAt + createdAt DateTime @default(now()) + + @@index([userId, accessedAt]) + @@index([userId, targetType]) +} + +model Favorite { + id String @id @default(cuid()) + userId String + targetType String @db.VarChar(32) + targetId String @db.VarChar(255) + title String? @db.VarChar(255) + metadata Json? + createdAt DateTime @default(now()) + + @@unique([userId, targetType, targetId]) + @@index([userId]) +} + +model SearchHistory { + id String @id @default(cuid()) + userId String + query String @db.VarChar(500) + resultsCount Int @default(0) + createdAt DateTime @default(now()) + + @@index([userId, createdAt]) +} + +model NotificationPreference { + id String @id @default(cuid()) + userId String @unique + reviewReminder Boolean @default(true) + learningReminder Boolean @default(true) + streakAlert Boolean @default(true) + pushEnabled Boolean @default(true) + quietStartHour Int? + quietEndHour Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model PushToken { + id String @id @default(cuid()) + userId String + token String @db.VarChar(500) + platform String @db.VarChar(32) + deviceId String? @db.VarChar(255) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, token]) + @@index([userId]) +} + +model NotificationTemplate { + id String @id @default(cuid()) + name String @db.VarChar(100) + type String @db.VarChar(32) + title String @db.VarChar(255) + content String @db.Text + channel String @default("in_app") @db.VarChar(32) + enabled Boolean @default(true) + createdBy String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([type]) +} diff --git a/src/app.module.ts b/src/app.module.ts index c639de3..89187a0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -44,11 +44,14 @@ import { NotificationsModule } from './modules/notifications/notifications.modul import { FeedbackModule } from './modules/feedback/feedback.module'; import { FilesModule } from './modules/files/files.module'; import { WaitlistModule } from './modules/waitlist/waitlist.module'; +import { WorkspaceModule } from './modules/workspace/workspace.module'; import { KnowledgeSourceModule } from './modules/knowledge-source/knowledge-source.module'; import { ImportCandidateModule } from './modules/import-candidate/import-candidate.module'; import { RagModule } from './modules/rag/rag.module'; import { RagChatModule } from './modules/rag-chat/rag-chat.module'; import { VectorModule } from './modules/vector/vector.module'; +import { CacheModule } from './common/cache/cache.module'; +import { AdminCacheModule } from './modules/admin-cache/admin-cache.module'; import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; @@ -143,6 +146,9 @@ import appleConfig from './config/apple.config'; FeedbackModule, FilesModule, WaitlistModule, + WorkspaceModule, + CacheModule, + AdminCacheModule, ], providers: [ { provide: APP_GUARD, useClass: RateLimitGuard }, diff --git a/src/common/cache/cache.module.ts b/src/common/cache/cache.module.ts new file mode 100644 index 0000000..0bd6946 --- /dev/null +++ b/src/common/cache/cache.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CacheService } from './cache.service'; +import { RedisModule } from '../../infrastructure/redis/redis.module'; + +@Module({ + imports: [RedisModule], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} diff --git a/src/common/cache/cache.service.ts b/src/common/cache/cache.service.ts new file mode 100644 index 0000000..3e1b3a3 --- /dev/null +++ b/src/common/cache/cache.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { RedisService } from '../../infrastructure/redis/redis.service'; + +const NULL_SENTINEL = '__CACHE_NULL__'; +const DEFAULT_TTL = 300; +const MIN_TTL = 30; + +/** + * Key naming convention: + * module:entity:id → e.g. config:app:theme, workspace:dashboard:userId + * module:entity → e.g. safety:sensitive-words + */ +export function cacheKey(module: string, entity: string, id?: string): string { + return id ? `${module}:${entity}:${id}` : `${module}:${entity}`; +} + +@Injectable() +export class CacheService { + private readonly logger = new Logger(CacheService.name); + private hits = 0; + private misses = 0; + + constructor(@Optional() private readonly redis?: RedisService) {} + + private get store() { + return this.redis; + } + + private isAvailable(): boolean { + return !!(this.redis?.isHealthy()); + } + + async get(key: string): Promise { + if (!this.isAvailable()) return null; + + try { + const raw = await this.redis!.get(key); + if (raw === null) { this.misses++; return null; } + if (raw === NULL_SENTINEL) { this.hits++; return null; } + this.hits++; + return JSON.parse(raw) as T; + } catch (err: any) { + this.logger.warn(`Cache get error for ${key}: ${err.message}`); + return null; + } + } + + async set(key: string, value: any, ttl?: number): Promise { + if (!this.isAvailable()) return; + + const finalTtl = ttl ?? DEFAULT_TTL; + try { + await this.redis!.set(key, JSON.stringify(value), Math.max(finalTtl, MIN_TTL)); + } catch (err: any) { + this.logger.warn(`Cache set error for ${key}: ${err.message}`); + } + } + + async setNull(key: string, ttl?: number): Promise { + if (!this.isAvailable()) return; + // Shorter TTL for null values to reduce staleness + const nullTtl = Math.min(ttl ?? DEFAULT_TTL, 60); + try { + await this.redis!.set(key, NULL_SENTINEL, Math.max(nullTtl, MIN_TTL)); + } catch (err: any) { + this.logger.warn(`Cache setNull error for ${key}: ${err.message}`); + } + } + + async del(key: string): Promise { + if (!this.isAvailable()) return; + + try { + await this.redis!.del(key); + } catch (err: any) { + this.logger.warn(`Cache del error for ${key}: ${err.message}`); + } + } + + /** + * Cache-aside pattern with null-value protection (cache penetration). + * If factory resolves to null, a short-lived null sentinel is cached. + */ + async wrap(key: string, factory: () => Promise, ttl?: number): Promise { + if (!this.isAvailable()) return factory(); + + const cached = await this.get(key); + if (cached !== null) return cached; + + // Check for null sentinel + try { + const raw = await this.redis!.get(key); + if (raw === NULL_SENTINEL) { this.hits++; return null; } + } catch {} + + const value = await factory(); + + if (value === null || value === undefined) { + await this.setNull(key, ttl); + } else { + await this.set(key, value, ttl); + } + + return value; + } + + /** + * Delete all keys matching a pattern (uses SCAN in production-safe manner) + */ + async flushPattern(pattern: string): Promise { + if (!this.isAvailable()) return 0; + + try { + const keys = await this.redis!.keys(`cache:${pattern}`); + let count = 0; + for (const key of keys) { + await this.redis!.del(key); + count++; + } + this.logger.log(`Flushed ${count} keys matching cache:${pattern}`); + return count; + } catch (err: any) { + this.logger.warn(`Cache flushPattern error: ${err.message}`); + return 0; + } + } + + async flushAll(): Promise { + if (!this.isAvailable()) return 0; + + try { + const keys = await this.redis!.keys('cache:*'); + let count = 0; + for (const key of keys) { + await this.redis!.del(key); + count++; + } + this.logger.log(`Flushed ${count} cache keys`); + return count; + } catch (err: any) { + this.logger.warn(`Cache flushAll error: ${err.message}`); + return 0; + } + } + + getStats() { + return { + hits: this.hits, + misses: this.misses, + hitRate: this.hits + this.misses > 0 + ? Math.round((this.hits / (this.hits + this.misses)) * 100) / 100 + : 0, + available: this.isAvailable(), + }; + } + + resetStats() { + this.hits = 0; + this.misses = 0; + } +} diff --git a/src/common/events/item-favorited.event.ts b/src/common/events/item-favorited.event.ts new file mode 100644 index 0000000..2fa5db5 --- /dev/null +++ b/src/common/events/item-favorited.event.ts @@ -0,0 +1,14 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class ItemFavoritedEvent extends BaseDomainEvent { + readonly eventType = 'workspace.item.favorited'; + + constructor( + public readonly userId: string, + public readonly favoriteId: string, + public readonly targetType: string, + public readonly targetId: string, + ) { + super(); + } +} diff --git a/src/common/events/item-unfavorited.event.ts b/src/common/events/item-unfavorited.event.ts new file mode 100644 index 0000000..bdaf525 --- /dev/null +++ b/src/common/events/item-unfavorited.event.ts @@ -0,0 +1,13 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class ItemUnfavoritedEvent extends BaseDomainEvent { + readonly eventType = 'workspace.item.unfavorited'; + + constructor( + public readonly userId: string, + public readonly targetType: string, + public readonly targetId: string, + ) { + super(); + } +} diff --git a/src/common/events/notification-preference-changed.event.ts b/src/common/events/notification-preference-changed.event.ts new file mode 100644 index 0000000..6e50789 --- /dev/null +++ b/src/common/events/notification-preference-changed.event.ts @@ -0,0 +1,12 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class NotificationPreferenceChangedEvent extends BaseDomainEvent { + readonly eventType = 'notification.preference.changed'; + + constructor( + public readonly userId: string, + public readonly changes: Record, + ) { + super(); + } +} diff --git a/src/common/events/notification-read.event.ts b/src/common/events/notification-read.event.ts new file mode 100644 index 0000000..d251eb9 --- /dev/null +++ b/src/common/events/notification-read.event.ts @@ -0,0 +1,12 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class NotificationReadEvent extends BaseDomainEvent { + readonly eventType = 'notification.read'; + + constructor( + public readonly userId: string, + public readonly notificationId: string, + ) { + super(); + } +} diff --git a/src/common/events/notification-sent.event.ts b/src/common/events/notification-sent.event.ts new file mode 100644 index 0000000..e3822ad --- /dev/null +++ b/src/common/events/notification-sent.event.ts @@ -0,0 +1,14 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class NotificationSentEvent extends BaseDomainEvent { + readonly eventType = 'notification.sent'; + + constructor( + public readonly userId: string, + public readonly notificationId: string, + public readonly type: string, + public readonly channel: string, + ) { + super(); + } +} diff --git a/src/common/events/search-performed.event.ts b/src/common/events/search-performed.event.ts new file mode 100644 index 0000000..b5d6c59 --- /dev/null +++ b/src/common/events/search-performed.event.ts @@ -0,0 +1,13 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class SearchPerformedEvent extends BaseDomainEvent { + readonly eventType = 'workspace.search.performed'; + + constructor( + public readonly userId: string, + public readonly query: string, + public readonly resultsCount: number, + ) { + super(); + } +} diff --git a/src/common/events/tag-created.event.ts b/src/common/events/tag-created.event.ts new file mode 100644 index 0000000..464432b --- /dev/null +++ b/src/common/events/tag-created.event.ts @@ -0,0 +1,13 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class TagCreatedEvent extends BaseDomainEvent { + readonly eventType = 'workspace.tag.created'; + + constructor( + public readonly userId: string, + public readonly tagId: string, + public readonly tagName: string, + ) { + super(); + } +} diff --git a/src/common/events/tag-deleted.event.ts b/src/common/events/tag-deleted.event.ts new file mode 100644 index 0000000..0f6f48c --- /dev/null +++ b/src/common/events/tag-deleted.event.ts @@ -0,0 +1,13 @@ +import { BaseDomainEvent } from './base-domain.event'; + +export class TagDeletedEvent extends BaseDomainEvent { + readonly eventType = 'workspace.tag.deleted'; + + constructor( + public readonly userId: string, + public readonly tagId: string, + public readonly tagName: string, + ) { + super(); + } +} diff --git a/src/modules/admin-cache/admin-cache.controller.ts b/src/modules/admin-cache/admin-cache.controller.ts new file mode 100644 index 0000000..2f66e5d --- /dev/null +++ b/src/modules/admin-cache/admin-cache.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody } from '@nestjs/swagger'; +import { CacheService } from '../../common/cache/cache.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRolesGuard } from '../../common/guards/admin-roles.guard'; + +@ApiTags('admin-cache') +@ApiBearerAuth() +@Controller('admin-api/cache') +@UseGuards(AdminAuthGuard, AdminRolesGuard) +export class AdminCacheController { + constructor(private readonly cacheService: CacheService) {} + + @Get('stats') + @ApiOperation({ summary: '缓存统计' }) + async getStats() { + return this.cacheService.getStats(); + } + + @Post('flush/:module') + @ApiOperation({ summary: '按模块清除缓存' }) + async flushModule(@Param('module') module: string) { + const count = await this.cacheService.flushPattern(`${module}:*`); + return { flushed: count, module }; + } + + @Post('flush-key') + @ApiOperation({ summary: '清除指定缓存 key' }) + @ApiBody({ schema: { type: 'object', required: ['key'], properties: { key: { type: 'string' } } } }) + async flushKey(@Body() body: { key: string }) { + await this.cacheService.del(body.key); + return { ok: true, key: body.key }; + } + + @Post('flush-all') + @ApiOperation({ summary: '清除所有缓存' }) + async flushAll() { + const count = await this.cacheService.flushAll(); + return { flushed: count }; + } + + @Post('reset-stats') + @ApiOperation({ summary: '重置缓存统计' }) + async resetStats() { + this.cacheService.resetStats(); + return this.cacheService.getStats(); + } +} diff --git a/src/modules/admin-cache/admin-cache.module.ts b/src/modules/admin-cache/admin-cache.module.ts new file mode 100644 index 0000000..f5d62d6 --- /dev/null +++ b/src/modules/admin-cache/admin-cache.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AdminCacheController } from './admin-cache.controller'; +import { CacheModule } from '../../common/cache/cache.module'; + +@Module({ + imports: [CacheModule], + controllers: [AdminCacheController], +}) +export class AdminCacheModule {} diff --git a/src/modules/notifications/admin-notifications.controller.ts b/src/modules/notifications/admin-notifications.controller.ts new file mode 100644 index 0000000..1378db0 --- /dev/null +++ b/src/modules/notifications/admin-notifications.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiQuery } from '@nestjs/swagger'; +import { NotificationsService } from './notifications.service'; + +@ApiTags('admin-notifications') +@ApiBearerAuth() +@Controller('admin-api/notifications') +export class AdminNotificationsController { + constructor(private readonly service: NotificationsService) {} + + // ═══ Templates ═══ + + @Get('templates') + @ApiOperation({ summary: '通知模板列表' }) + async getTemplates() { + return this.service.getTemplates(); + } + + @Post('templates') + @ApiOperation({ summary: '创建通知模板' }) + @ApiBody({ schema: { type: 'object', required: ['name', 'type', 'title', 'content'], properties: { + name: { type: 'string' }, + type: { type: 'string' }, + title: { type: 'string' }, + content: { type: 'string' }, + channel: { type: 'string', enum: ['in_app', 'push', 'email'] }, + } } }) + async createTemplate(@Body() dto: { name: string; type: string; title: string; content: string; channel?: string }) { + return this.service.createTemplate(dto); + } + + @Patch('templates/:id') + @ApiOperation({ summary: '更新通知模板' }) + async updateTemplate(@Param('id') id: string, @Body() dto: Record) { + return this.service.updateTemplate(id, dto); + } + + @Delete('templates/:id') + @ApiOperation({ summary: '删除通知模板' }) + async deleteTemplate(@Param('id') id: string) { + return this.service.deleteTemplate(id); + } + + // ═══ Send Log ═══ + + @Get('send-log') + @ApiOperation({ summary: '通知发送日志' }) + @ApiQuery({ name: 'limit', required: false }) + async getSendLog(@Query('limit') limit?: string) { + return this.service.getSendLogs(Number(limit) || 100); + } +} diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts index 7f7540d..846d732 100644 --- a/src/modules/notifications/notifications.controller.ts +++ b/src/modules/notifications/notifications.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Post, Param, Query, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Controller, Get, Post, Patch, Delete, Param, Body, Query, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger'; import { NotificationsService } from './notifications.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { PaginationDto } from '../../common/dto/pagination.dto'; @@ -11,7 +11,7 @@ export class NotificationsController { constructor(private readonly service: NotificationsService) {} @Get() - @ApiOperation({ summary: '获取通知列表' }) + @ApiOperation({ summary: '获取通知列表(含未读数)' }) async list(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) { return this.service.list(String(user?.id || 'anonymous'), pagination); } @@ -19,7 +19,64 @@ export class NotificationsController { @Post(':id/read') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '标记通知已读' }) - async markRead(@Param('id') id: string) { - return this.service.markRead(id); + async markRead(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.service.markRead(String(user?.id || 'anonymous'), id); } -} + + @Post('read-all') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '全部标记已读' }) + async markAllRead(@CurrentUser() user: UserPayload) { + return this.service.markAllRead(String(user?.id || 'anonymous')); + } + + // ═══ Preferences ═══ + + @Get('preferences') + @ApiOperation({ summary: '获取通知偏好' }) + async getPreferences(@CurrentUser() user: UserPayload) { + return this.service.getPreferences(String(user?.id || 'anonymous')); + } + + @Patch('preferences') + @ApiOperation({ summary: '更新通知偏好' }) + @ApiBody({ schema: { type: 'object', properties: { + reviewReminder: { type: 'boolean' }, + learningReminder: { type: 'boolean' }, + streakAlert: { type: 'boolean' }, + pushEnabled: { type: 'boolean' }, + quietStartHour: { type: 'number' }, + quietEndHour: { type: 'number' }, + } } }) + async updatePreferences(@CurrentUser() user: UserPayload, @Body() dto: Record) { + return this.service.updatePreferences(String(user?.id || 'anonymous'), dto); + } + + // ═══ Push Tokens ═══ + + @Get('push-tokens') + @ApiOperation({ summary: '获取已注册的 Push Token' }) + async getPushTokens(@CurrentUser() user: UserPayload) { + return this.service.getPushTokens(String(user?.id || 'anonymous')); + } + + @Post('push-tokens') + @ApiOperation({ summary: '注册 Push Token' }) + @ApiBody({ schema: { type: 'object', required: ['token', 'platform'], properties: { + token: { type: 'string' }, + platform: { type: 'string', enum: ['ios', 'web'] }, + deviceId: { type: 'string' }, + } } }) + async registerPushToken( + @CurrentUser() user: UserPayload, + @Body() body: { token: string; platform: string; deviceId?: string }, + ) { + return this.service.registerPushToken(String(user?.id || 'anonymous'), body.token, body.platform, body.deviceId); + } + + @Delete('push-tokens/:token') + @ApiOperation({ summary: '移除 Push Token' }) + async removePushToken(@CurrentUser() user: UserPayload, @Param('token') token: string) { + return this.service.removePushToken(String(user?.id || 'anonymous'), token); + } +} \ No newline at end of file diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts index f247d06..eeeca66 100644 --- a/src/modules/notifications/notifications.module.ts +++ b/src/modules/notifications/notifications.module.ts @@ -1,10 +1,13 @@ import { Module } from '@nestjs/common'; import { NotificationsController } from './notifications.controller'; +import { AdminNotificationsController } from './admin-notifications.controller'; import { NotificationsService } from './notifications.service'; import { NotificationsRepository } from './notifications.repository'; +import { EventBusModule } from '../../common/event-bus/event-bus.module'; @Module({ - controllers: [NotificationsController], + imports: [EventBusModule], + controllers: [NotificationsController, AdminNotificationsController], providers: [NotificationsService, NotificationsRepository], exports: [NotificationsService], }) diff --git a/src/modules/notifications/notifications.repository.ts b/src/modules/notifications/notifications.repository.ts index 06cce57..95122e9 100644 --- a/src/modules/notifications/notifications.repository.ts +++ b/src/modules/notifications/notifications.repository.ts @@ -5,6 +5,8 @@ import { PrismaService } from '../../infrastructure/database/prisma.service'; export class NotificationsRepository { constructor(private readonly prisma: PrismaService) {} + // ═══ Notifications ═══ + async findAll(userId: string, pagination?: { page?: number; limit?: number }) { const page = pagination?.page ?? 1; const limit = pagination?.limit ?? 20; @@ -37,4 +39,81 @@ export class NotificationsRepository { data: { readAt: new Date() }, }); } + + async markAllRead(userId: string) { + await this.prisma.notification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: new Date() }, + }); + } + + async countUnread(userId: string) { + return this.prisma.notification.count({ + where: { userId, readAt: null }, + }); + } + + // ═══ Preferences ═══ + + async getPreference(userId: string) { + let pref = await this.prisma.notificationPreference.findUnique({ where: { userId } }); + if (!pref) { + pref = await this.prisma.notificationPreference.create({ data: { userId } }); + } + return pref; + } + + async updatePreference(userId: string, data: Record) { + await this.getPreference(userId); // ensure exists + return this.prisma.notificationPreference.update({ where: { userId }, data }); + } + + // ═══ Push Tokens ═══ + + async registerPushToken(userId: string, token: string, platform: string, deviceId?: string) { + return this.prisma.pushToken.upsert({ + where: { userId_token: { userId, token } }, + update: { platform, deviceId, updatedAt: new Date() }, + create: { userId, token, platform, deviceId }, + }); + } + + async findPushTokens(userId: string) { + return this.prisma.pushToken.findMany({ where: { userId } }); + } + + async removePushToken(userId: string, token: string) { + return this.prisma.pushToken.deleteMany({ where: { userId, token } }); + } + + // ═══ Templates (Admin) ═══ + + async findTemplates() { + return this.prisma.notificationTemplate.findMany(); + } + + async findTemplateById(id: string) { + return this.prisma.notificationTemplate.findUnique({ where: { id } }); + } + + async createTemplate(data: { name: string; type: string; title: string; content: string; channel?: string; createdBy?: string }) { + return this.prisma.notificationTemplate.create({ data }); + } + + async updateTemplate(id: string, data: Record) { + return this.prisma.notificationTemplate.update({ where: { id }, data }); + } + + async deleteTemplate(id: string) { + return this.prisma.notificationTemplate.delete({ where: { id } }); + } + + // ═══ Bulk send log (Admin) ═══ + + async findRecentSent(limit = 100) { + return this.prisma.notification.findMany({ + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } } diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index e55a9f0..bb20d0a 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -1,28 +1,109 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger, Optional } from '@nestjs/common'; import { NotificationsRepository } from './notifications.repository'; +import { EventBusService } from '../../common/event-bus/event-bus.service'; +import { NotificationSentEvent } from '../../common/events/notification-sent.event'; +import { NotificationReadEvent } from '../../common/events/notification-read.event'; +import { NotificationPreferenceChangedEvent } from '../../common/events/notification-preference-changed.event'; import type { PaginationDto } from '../../common/dto/pagination.dto'; @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); - constructor(private readonly repository: NotificationsRepository) {} + constructor( + private readonly repository: NotificationsRepository, + @Optional() private readonly eventBus?: EventBusService, + ) {} + + // ═══ Notifications ═══ async list(userId: string, pagination: PaginationDto) { - return this.repository.findAll(userId, pagination); + const [items, unreadCount] = await Promise.all([ + this.repository.findAll(userId, pagination), + this.repository.countUnread(userId), + ]); + return { items, unreadCount }; } - async markRead(id: string) { - try { - return await this.repository.markRead(id); - } catch { - throw new NotFoundException(`Notification ${id} not found`); - } + async markRead(userId: string, id: string) { + const notification = await this.repository.findById(id); + if (!notification) throw new NotFoundException(`Notification ${id} not found`); + + await this.repository.markRead(id); + + try { this.eventBus?.publish(new NotificationReadEvent(userId, id)); } catch {} + + return { ok: true }; + } + + async markAllRead(userId: string) { + await this.repository.markAllRead(userId); + return { ok: true }; } async send(data: { userId: string; type: string; title: string; body: string }) { const notification = await this.repository.create(data); this.logger.log(`Notification ${notification.id} sent to user ${data.userId}`); + + try { this.eventBus?.publish(new NotificationSentEvent(data.userId, notification.id, data.type, 'in_app')); } catch {} + return notification; } + + // ═══ Preferences ═══ + + async getPreferences(userId: string) { + return this.repository.getPreference(userId); + } + + async updatePreferences(userId: string, dto: Record) { + const updated = await this.repository.updatePreference(userId, dto); + + try { this.eventBus?.publish(new NotificationPreferenceChangedEvent(userId, dto)); } catch {} + + return updated; + } + + // ═══ Push Tokens ═══ + + async registerPushToken(userId: string, token: string, platform: string, deviceId?: string) { + return this.repository.registerPushToken(userId, token, platform, deviceId); + } + + async getPushTokens(userId: string) { + return this.repository.findPushTokens(userId); + } + + async removePushToken(userId: string, token: string) { + await this.repository.removePushToken(userId, token); + return { ok: true }; + } + + // ═══ Templates (Admin) ═══ + + async getTemplates() { + return this.repository.findTemplates(); + } + + async createTemplate(dto: { name: string; type: string; title: string; content: string; channel?: string }, createdBy?: string) { + return this.repository.createTemplate({ ...dto, createdBy }); + } + + async updateTemplate(id: string, dto: Record) { + const tpl = await this.repository.findTemplateById(id); + if (!tpl) throw new NotFoundException('模板不存在'); + return this.repository.updateTemplate(id, dto); + } + + async deleteTemplate(id: string) { + const tpl = await this.repository.findTemplateById(id); + if (!tpl) throw new NotFoundException('模板不存在'); + return this.repository.deleteTemplate(id); + } + + // ═══ Admin send log ═══ + + async getSendLogs(limit = 100) { + return this.repository.findRecentSent(limit); + } } diff --git a/src/modules/workspace/dto/workspace.dto.ts b/src/modules/workspace/dto/workspace.dto.ts new file mode 100644 index 0000000..45b2b89 --- /dev/null +++ b/src/modules/workspace/dto/workspace.dto.ts @@ -0,0 +1,91 @@ +import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RecordRecentDto { + @ApiProperty({ description: '目标类型' }) + @IsString() + targetType: string; + + @ApiProperty({ description: '目标ID' }) + @IsString() + targetId: string; + + @ApiProperty({ description: '标题' }) + @IsString() + title: string; + + @ApiPropertyOptional({ description: '元数据' }) + @IsOptional() + metadata?: Record; +} + +export class AddFavoriteDto { + @ApiProperty({ description: '目标类型' }) + @IsString() + targetType: string; + + @ApiProperty({ description: '目标ID' }) + @IsString() + targetId: string; + + @ApiPropertyOptional({ description: '标题' }) + @IsOptional() + @IsString() + title?: string; + + @ApiPropertyOptional({ description: '元数据' }) + @IsOptional() + metadata?: Record; +} + +export class CreateTagDto { + @ApiProperty({ description: '标签名' }) + @IsString() + name: string; + + @ApiPropertyOptional({ description: '颜色' }) + @IsOptional() + @IsString() + color?: string; +} + +export class UpdateTagDto { + @ApiPropertyOptional({ description: '标签名' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: '颜色' }) + @IsOptional() + @IsString() + color?: string; +} + +export class AttachTagDto { + @ApiProperty({ description: '目标类型(knowledge_item)' }) + @IsString() + targetType: string; + + @ApiProperty({ description: '目标ID' }) + @IsString() + targetId: string; +} + +export class SearchDto { + @ApiProperty({ description: '搜索关键词' }) + @IsString() + q: string; + + @ApiPropertyOptional({ description: '每页条数', default: 20 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + limit?: number; + + @ApiPropertyOptional({ description: '页码', default: 0 }) + @IsOptional() + @IsInt() + @Min(0) + offset?: number; +} diff --git a/src/modules/workspace/workspace.controller.ts b/src/modules/workspace/workspace.controller.ts new file mode 100644 index 0000000..80d070a --- /dev/null +++ b/src/modules/workspace/workspace.controller.ts @@ -0,0 +1,140 @@ +import { + Controller, Get, Post, Patch, Delete, Param, Body, Query, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { WorkspaceService } from './workspace.service'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import type { UserPayload } from '../../common/types'; +import { + RecordRecentDto, AddFavoriteDto, CreateTagDto, UpdateTagDto, AttachTagDto, +} from './dto/workspace.dto'; + +@ApiTags('workspace') +@Controller('workspace') +export class WorkspaceController { + constructor(private readonly workspaceService: WorkspaceService) {} + + // ═══ Recent ═══ + + @Get('recent') + @ApiOperation({ summary: '获取最近打开列表' }) + async getRecent(@CurrentUser() user: UserPayload) { + return this.workspaceService.getRecentItems(String(user?.id || 'anonymous')); + } + + @Post('recent') + @ApiOperation({ summary: '记录最近打开' }) + async recordRecent(@CurrentUser() user: UserPayload, @Body() dto: RecordRecentDto) { + return this.workspaceService.recordRecent(String(user?.id || 'anonymous'), dto); + } + + // ═══ Favorites ═══ + + @Get('favorites') + @ApiOperation({ summary: '获取收藏列表' }) + async getFavorites(@CurrentUser() user: UserPayload) { + return this.workspaceService.getFavorites(String(user?.id || 'anonymous')); + } + + @Post('favorites') + @ApiOperation({ summary: '添加收藏' }) + async addFavorite(@CurrentUser() user: UserPayload, @Body() dto: AddFavoriteDto) { + return this.workspaceService.addFavorite(String(user?.id || 'anonymous'), dto); + } + + @Delete('favorites/:id') + @ApiOperation({ summary: '取消收藏' }) + async removeFavorite(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.workspaceService.removeFavorite(String(user?.id || 'anonymous'), id); + } + + // ═══ Tags ═══ + + @Get('tags') + @ApiOperation({ summary: '获取标签列表' }) + async getTags(@CurrentUser() user: UserPayload) { + return this.workspaceService.getTags(String(user?.id || 'anonymous')); + } + + @Post('tags') + @ApiOperation({ summary: '创建标签' }) + async createTag(@CurrentUser() user: UserPayload, @Body() dto: CreateTagDto) { + return this.workspaceService.createTag(String(user?.id || 'anonymous'), dto); + } + + @Patch('tags/:id') + @ApiOperation({ summary: '更新标签' }) + async updateTag( + @CurrentUser() user: UserPayload, + @Param('id') id: string, + @Body() dto: UpdateTagDto, + ) { + return this.workspaceService.updateTag(String(user?.id || 'anonymous'), id, dto); + } + + @Delete('tags/:id') + @ApiOperation({ summary: '删除标签' }) + async deleteTag(@CurrentUser() user: UserPayload, @Param('id') id: string) { + return this.workspaceService.deleteTag(String(user?.id || 'anonymous'), id); + } + + // ═══ Tag attach/detach ═══ + + @Get('items/:itemId/tags') + @ApiOperation({ summary: '获取知识点标签' }) + async getItemTags(@CurrentUser() user: UserPayload, @Param('itemId') itemId: string) { + return this.workspaceService.getItemTags(String(user?.id || 'anonymous'), itemId); + } + + @Post('tags/:id/attach') + @ApiOperation({ summary: '贴标签' }) + async attachTag( + @CurrentUser() user: UserPayload, + @Param('id') tagId: string, + @Body() dto: AttachTagDto, + ) { + return this.workspaceService.attachTag(String(user?.id || 'anonymous'), tagId, dto); + } + + @Delete('tags/:id/detach') + @ApiOperation({ summary: '去标签' }) + async detachTag( + @CurrentUser() user: UserPayload, + @Param('id') tagId: string, + @Body() dto: AttachTagDto, + ) { + return this.workspaceService.detachTag(String(user?.id || 'anonymous'), tagId, dto); + } + + // ═══ Search ═══ + + @Get('search') + @ApiOperation({ summary: '全局搜索' }) + async search( + @CurrentUser() user: UserPayload, + @Query('q') q: string, + @Query('limit') limit?: string, + @Query('offset') offset?: string, + ) { + return this.workspaceService.search( + String(user?.id || 'anonymous'), + q || '', + Number(limit) || 20, + Number(offset) || 0, + ); + } + + @Get('search-history') + @ApiOperation({ summary: '搜索历史' }) + async getSearchHistory(@CurrentUser() user: UserPayload) { + return this.workspaceService.getSearchHistory(String(user?.id || 'anonymous')); + } + + // ═══ Dashboard ═══ + + @Get('dashboard') + @ApiOperation({ summary: '工作台聚合数据' }) + async getDashboard(@CurrentUser() user: UserPayload) { + return this.workspaceService.getDashboard(String(user?.id || 'anonymous')); + } +} diff --git a/src/modules/workspace/workspace.module.ts b/src/modules/workspace/workspace.module.ts new file mode 100644 index 0000000..77440f9 --- /dev/null +++ b/src/modules/workspace/workspace.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { WorkspaceController } from './workspace.controller'; +import { WorkspaceService } from './workspace.service'; +import { WorkspaceRepository } from './workspace.repository'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { EventBusModule } from '../../common/event-bus/event-bus.module'; +import { RedisModule } from '../../infrastructure/redis/redis.module'; +import { ContentSafetyModule } from '../content-safety/content-safety.module'; + +@Module({ + imports: [EventBusModule, RedisModule, ContentSafetyModule], + controllers: [WorkspaceController], + providers: [WorkspaceService, WorkspaceRepository, PrismaService], + exports: [WorkspaceService], +}) +export class WorkspaceModule {} diff --git a/src/modules/workspace/workspace.repository.ts b/src/modules/workspace/workspace.repository.ts new file mode 100644 index 0000000..8948ecb --- /dev/null +++ b/src/modules/workspace/workspace.repository.ts @@ -0,0 +1,212 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +const RECENT_LIMIT = 50; +const SEARCH_HISTORY_LIMIT = 20; + +@Injectable() +export class WorkspaceRepository { + constructor(private readonly prisma: PrismaService) {} + + // ═══ Recent Items ═══ + + async findRecentItems(userId: string, limit = 20) { + return this.prisma.recentItem.findMany({ + where: { userId }, + orderBy: { accessedAt: 'desc' }, + take: limit, + }); + } + + async upsertRecentItem(userId: string, targetType: string, targetId: string, title: string, metadata?: any) { + const existing = await this.prisma.recentItem.findFirst({ + where: { userId, targetType, targetId }, + }); + + if (existing) { + await this.prisma.recentItem.update({ + where: { id: existing.id }, + data: { title, metadata, accessedAt: new Date() }, + }); + } else { + await this.prisma.recentItem.create({ + data: { userId, targetType, targetId, title, metadata }, + }); + } + + const count = await this.prisma.recentItem.count({ where: { userId } }); + if (count > RECENT_LIMIT) { + const oldest = await this.prisma.recentItem.findMany({ + where: { userId }, + orderBy: { accessedAt: 'asc' }, + take: count - RECENT_LIMIT, + }); + if (oldest.length > 0) { + await this.prisma.recentItem.deleteMany({ + where: { id: { in: oldest.map((o) => o.id) } }, + }); + } + } + } + + // ═══ Favorites ═══ + + async findFavorites(userId: string, limit = 50) { + return this.prisma.favorite.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async addFavorite(userId: string, targetType: string, targetId: string, title?: string, metadata?: any) { + return this.prisma.favorite.upsert({ + where: { + userId_targetType_targetId: { userId, targetType, targetId }, + }, + update: { title, metadata }, + create: { userId, targetType, targetId, title, metadata }, + }); + } + + async removeFavorite(userId: string, id: string) { + return this.prisma.favorite.deleteMany({ where: { id, userId } }); + } + + async findFavoriteById(id: string) { + return this.prisma.favorite.findUnique({ where: { id } }); + } + + // ═══ Tags ═══ + + async findTags(userId: string) { + return this.prisma.tag.findMany({ where: { userId }, orderBy: { name: 'asc' } }); + } + + async findTagById(id: string) { + return this.prisma.tag.findUnique({ where: { id } }); + } + + async createTag(userId: string, name: string, color?: string) { + return this.prisma.tag.create({ data: { userId, name, color } }); + } + + async updateTag(id: string, data: { name?: string; color?: string }) { + return this.prisma.tag.update({ where: { id }, data }); + } + + async deleteTag(id: string) { + return this.prisma.tag.delete({ where: { id } }); + } + + // ═══ KnowledgeItem-Tag ═══ + + async findItemTags(knowledgeItemId: string) { + return this.prisma.knowledgeItemTag.findMany({ + where: { knowledgeItemId }, + include: { tag: true }, + }); + } + + async attachTag(knowledgeItemId: string, tagId: string) { + return this.prisma.knowledgeItemTag.upsert({ + where: { + knowledgeItemId_tagId: { knowledgeItemId, tagId }, + }, + update: {}, + create: { knowledgeItemId, tagId }, + }); + } + + async detachTag(knowledgeItemId: string, tagId: string) { + return this.prisma.knowledgeItemTag.deleteMany({ + where: { knowledgeItemId, tagId }, + }); + } + + // ═══ Search ═══ + + async searchKnowledgeBases(userId: string, query: string, limit: number, offset: number) { + return this.prisma.knowledgeBase.findMany({ + where: { + userId, + deletedAt: null, + title: { contains: query }, + }, + orderBy: { lastStudiedAt: { sort: 'desc', nulls: 'last' } }, + take: limit, + skip: offset, + }); + } + + async searchKnowledgeItems(userId: string, query: string, limit: number, offset: number) { + return this.prisma.knowledgeItem.findMany({ + where: { + userId, + deletedAt: null, + OR: [ + { title: { contains: query } }, + { content: { contains: query } }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: limit, + skip: offset, + }); + } + + // ═══ Search History ═══ + + async addSearchHistory(userId: string, query: string, resultsCount: number) { + await this.prisma.searchHistory.create({ + data: { userId, query, resultsCount }, + }); + + const count = await this.prisma.searchHistory.count({ where: { userId } }); + if (count > SEARCH_HISTORY_LIMIT) { + const oldest = await this.prisma.searchHistory.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + take: count - SEARCH_HISTORY_LIMIT, + }); + if (oldest.length > 0) { + await this.prisma.searchHistory.deleteMany({ + where: { id: { in: oldest.map((o) => o.id) } }, + }); + } + } + } + + async findSearchHistory(userId: string, limit = 20) { + return this.prisma.searchHistory.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + // ═══ Dashboard data ═══ + + async countDueReviews(userId: string) { + return this.prisma.reviewCard.count({ + where: { userId, nextReviewAt: { lte: new Date() }, status: 'active' }, + }); + } + + async findRecentKnowledgeBases(userId: string, limit = 5) { + return this.prisma.knowledgeBase.findMany({ + where: { userId, deletedAt: null }, + orderBy: { lastStudiedAt: { sort: 'desc', nulls: 'last' } }, + take: limit, + }); + } + + async countWeeklySessions(userId: string) { + const weekAgo = new Date(Date.now() - 7 * 86400000); + const sessions = await this.prisma.learningSession.findMany({ + where: { userId, startedAt: { gte: weekAgo } }, + select: { durationSeconds: true }, + }); + return sessions.reduce((sum, s) => sum + (s.durationSeconds || 0), 0); + } +} diff --git a/src/modules/workspace/workspace.service.ts b/src/modules/workspace/workspace.service.ts new file mode 100644 index 0000000..75374ed --- /dev/null +++ b/src/modules/workspace/workspace.service.ts @@ -0,0 +1,181 @@ +import { Injectable, NotFoundException, Logger, Optional } from '@nestjs/common'; +import { WorkspaceRepository } from './workspace.repository'; +import { ContentSafetyService } from '../content-safety/content-safety.service'; +import { EventBusService } from '../../common/event-bus/event-bus.service'; +import { RedisService } from '../../infrastructure/redis/redis.service'; +import { ItemFavoritedEvent } from '../../common/events/item-favorited.event'; +import { ItemUnfavoritedEvent } from '../../common/events/item-unfavorited.event'; +import { TagCreatedEvent } from '../../common/events/tag-created.event'; +import { TagDeletedEvent } from '../../common/events/tag-deleted.event'; +import { SearchPerformedEvent } from '../../common/events/search-performed.event'; +import { + RecordRecentDto, AddFavoriteDto, CreateTagDto, UpdateTagDto, AttachTagDto, +} from './dto/workspace.dto'; + +const DASHBOARD_CACHE_TTL = 300; // 5 min + +@Injectable() +export class WorkspaceService { + private readonly logger = new Logger(WorkspaceService.name); + + constructor( + private readonly repo: WorkspaceRepository, + private readonly eventBus: EventBusService, + @Optional() private readonly redis?: RedisService, + @Optional() private readonly safety?: ContentSafetyService, + ) {} + + // ═══ Recent Items ═══ + + async getRecentItems(userId: string) { + return this.repo.findRecentItems(userId); + } + + async recordRecent(userId: string, dto: RecordRecentDto) { + await this.repo.upsertRecentItem(userId, dto.targetType, dto.targetId, dto.title, dto.metadata); + return { ok: true }; + } + + // ═══ Favorites ═══ + + async getFavorites(userId: string) { + return this.repo.findFavorites(userId); + } + + async addFavorite(userId: string, dto: AddFavoriteDto) { + const fav = await this.repo.addFavorite(userId, dto.targetType, dto.targetId, dto.title, dto.metadata); + + try { this.eventBus.publish(new ItemFavoritedEvent(userId, fav.id, dto.targetType, dto.targetId)); } catch {} + + return fav; + } + + async removeFavorite(userId: string, id: string) { + const fav = await this.repo.findFavoriteById(id); + if (!fav || fav.userId !== userId) throw new NotFoundException('收藏不存在'); + + await this.repo.removeFavorite(userId, id); + + try { this.eventBus.publish(new ItemUnfavoritedEvent(userId, fav.targetType, fav.targetId)); } catch {} + + return { ok: true }; + } + + // ═══ Tags ═══ + + async getTags(userId: string) { + return this.repo.findTags(userId); + } + + async createTag(userId: string, dto: CreateTagDto) { + if (this.safety) { + const check = await this.safety.check(dto.name, { userId, contentType: 'tag' }); + if (!check.safe) throw new NotFoundException('标签名包含敏感词'); + } + + const tag = await this.repo.createTag(userId, dto.name, dto.color); + + try { this.eventBus.publish(new TagCreatedEvent(userId, tag.id, tag.name)); } catch {} + + return tag; + } + + async updateTag(userId: string, id: string, dto: UpdateTagDto) { + const tag = await this.repo.findTagById(id); + if (!tag || tag.userId !== userId) throw new NotFoundException('标签不存在'); + + if (dto.name && this.safety) { + const check = await this.safety.check(dto.name, { userId, contentType: 'tag' }); + if (!check.safe) throw new NotFoundException('标签名包含敏感词'); + } + + return this.repo.updateTag(id, dto); + } + + async deleteTag(userId: string, id: string) { + const tag = await this.repo.findTagById(id); + if (!tag || tag.userId !== userId) throw new NotFoundException('标签不存在'); + + await this.repo.deleteTag(id); + + try { this.eventBus.publish(new TagDeletedEvent(userId, tag.id, tag.name)); } catch {} + + return { ok: true }; + } + + // ═══ Tag attach/detach ═══ + + async attachTag(userId: string, tagId: string, dto: AttachTagDto) { + const tag = await this.repo.findTagById(tagId); + if (!tag || tag.userId !== userId) throw new NotFoundException('标签不存在'); + + return this.repo.attachTag(dto.targetId, tagId); + } + + async detachTag(userId: string, tagId: string, dto: AttachTagDto) { + const tag = await this.repo.findTagById(tagId); + if (!tag || tag.userId !== userId) throw new NotFoundException('标签不存在'); + + await this.repo.detachTag(dto.targetId, tagId); + return { ok: true }; + } + + async getItemTags(userId: string, itemId: string) { + return this.repo.findItemTags(itemId); + } + + // ═══ Search ═══ + + async search(userId: string, q: string, limit = 20, offset = 0) { + const [kbs, items] = await Promise.all([ + this.repo.searchKnowledgeBases(userId, q, limit, offset), + this.repo.searchKnowledgeItems(userId, q, limit, offset), + ]); + + const results = [ + ...kbs.map((k) => ({ targetType: 'knowledge_base', targetId: k.id, title: k.title, snippet: k.description?.slice(0, 200) || '' })), + ...items.map((i) => ({ targetType: 'knowledge_item', targetId: i.id, title: i.title, snippet: (i.summary || i.content || '').slice(0, 200) })), + ]; + + const total = results.length; + await this.repo.addSearchHistory(userId, q, total); + + try { this.eventBus.publish(new SearchPerformedEvent(userId, q, total)); } catch {} + + return { results, total, q, limit, offset }; + } + + async getSearchHistory(userId: string) { + return this.repo.findSearchHistory(userId); + } + + // ═══ Dashboard ═══ + + async getDashboard(userId: string) { + const cacheKey = `workspace:dashboard:${userId}`; + if (this.redis) { + const cached = await this.redis.get(cacheKey); + if (cached) return JSON.parse(cached); + } + + const [dueReviews, recentKbs, weeklySeconds] = await Promise.all([ + this.repo.countDueReviews(userId), + this.repo.findRecentKnowledgeBases(userId), + this.repo.countWeeklySessions(userId), + ]); + + const dashboard = { + dueReviews, + weeklyLearningMinutes: Math.round(weeklySeconds / 60), + recentKnowledgeBases: recentKbs.map((k) => ({ + id: k.id, title: k.title, lastStudiedAt: k.lastStudiedAt, + })), + }; + + if (this.redis) { + try { await this.redis.set(cacheKey, JSON.stringify(dashboard), DASHBOARD_CACHE_TTL); } catch {} + } + + return dashboard; + } +} diff --git a/test/m3.e2e-spec.ts b/test/m3.e2e-spec.ts index 7cb002a..d9b6a32 100644 --- a/test/m3.e2e-spec.ts +++ b/test/m3.e2e-spec.ts @@ -84,4 +84,252 @@ describe('M3 E2E Tests', () => { expect(res.body.success).toBe(true); }); }); + + // ══════════════════════════════════════════════ + // M3-04: Workspace Experience + // ══════════════════════════════════════════════ + describe('M3-04 Workspace Experience', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('GET /api/workspace/recent → lists recent items', async () => { + const res = await request(app.getHttpServer()) + .get('/api/workspace/recent') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /api/workspace/recent → records a recent item', async () => { + const res = await request(app.getHttpServer()) + .post('/api/workspace/recent') + .set('Authorization', `Bearer ${token}`) + .send({ targetType: 'knowledge_base', targetId: 'kb-1', title: 'Test KB' }) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /api/workspace/favorites → lists favorites', async () => { + const res = await request(app.getHttpServer()) + .get('/api/workspace/favorites') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /api/workspace/favorites → adds a favorite', async () => { + const res = await request(app.getHttpServer()) + .post('/api/workspace/favorites') + .set('Authorization', `Bearer ${token}`) + .send({ targetType: 'knowledge_item', targetId: 'item-1', title: 'Test Item' }) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /api/workspace/tags → lists tags', async () => { + const res = await request(app.getHttpServer()) + .get('/api/workspace/tags') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /api/workspace/tags → creates a tag', async () => { + const res = await request(app.getHttpServer()) + .post('/api/workspace/tags') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'test-tag', color: '#ff0000' }) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /api/workspace/search → searches', async () => { + const res = await request(app.getHttpServer()) + .get('/api/workspace/search?q=test') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /api/workspace/search-history → lists search history', async () => { + const res = await request(app.getHttpServer()) + .get('/api/workspace/search-history') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /api/workspace/dashboard → returns dashboard data', async () => { + const res = await request(app.getHttpServer()) + .get('/api/workspace/dashboard') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('401 without token for all workspace endpoints', async () => { + await request(app.getHttpServer()).get('/api/workspace/recent').expect(401); + await request(app.getHttpServer()).get('/api/workspace/favorites').expect(401); + await request(app.getHttpServer()).get('/api/workspace/tags').expect(401); + await request(app.getHttpServer()).get('/api/workspace/search?q=test').expect(401); + await request(app.getHttpServer()).get('/api/workspace/dashboard').expect(401); + }); + }); + + // ══════════════════════════════════════════════ + // M3-05: Notification Module + // ══════════════════════════════════════════════ + describe('M3-05 Notification Module', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('GET /api/notifications → lists notifications with unread count', async () => { + const res = await request(app.getHttpServer()) + .get('/api/notifications') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + expect(res.body.data).toHaveProperty('items'); + expect(res.body.data).toHaveProperty('unreadCount'); + }); + + it('POST /api/notifications/read-all → marks all read', async () => { + const res = await request(app.getHttpServer()) + .post('/api/notifications/read-all') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /api/notifications/preferences → returns preferences', async () => { + const res = await request(app.getHttpServer()) + .get('/api/notifications/preferences') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('PATCH /api/notifications/preferences → updates preferences', async () => { + const res = await request(app.getHttpServer()) + .patch('/api/notifications/preferences') + .set('Authorization', `Bearer ${token}`) + .send({ pushEnabled: false, quietStartHour: 22 }) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /api/notifications/push-tokens → registers push token', async () => { + const res = await request(app.getHttpServer()) + .post('/api/notifications/push-tokens') + .set('Authorization', `Bearer ${token}`) + .send({ token: 'test-push-token-abc', platform: 'ios', deviceId: 'device-1' }) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /api/notifications/push-tokens → lists push tokens', async () => { + const res = await request(app.getHttpServer()) + .get('/api/notifications/push-tokens') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('DELETE /api/notifications/push-tokens/:token → removes push token', async () => { + const res = await request(app.getHttpServer()) + .delete('/api/notifications/push-tokens/test-push-token-abc') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + // Admin endpoints + it('GET /admin-api/notifications/templates → lists templates', async () => { + const res = await request(app.getHttpServer()) + .get('/admin-api/notifications/templates') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /admin-api/notifications/templates → creates template', async () => { + const res = await request(app.getHttpServer()) + .post('/admin-api/notifications/templates') + .set('Authorization', `Bearer ${token}`) + .send({ name: '复习提醒', type: 'review_reminder', title: '复习时间到了', content: '你有{count}张卡片待复习' }) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('GET /admin-api/notifications/send-log → returns send logs', async () => { + const res = await request(app.getHttpServer()) + .get('/admin-api/notifications/send-log') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + }); + + it('401 without token for notification endpoints', async () => { + await request(app.getHttpServer()).get('/api/notifications').expect(401); + await request(app.getHttpServer()).get('/api/notifications/preferences').expect(401); + await request(app.getHttpServer()).post('/api/notifications/push-tokens').expect(401); + }); + }); + + // ══════════════════════════════════════════════ + // M3-06: Cache Module + // ══════════════════════════════════════════════ + describe('M3-06 Cache Module', () => { + let token: string; + beforeAll(async () => { token = await loginAdmin(); }); + + it('GET /admin-api/cache/stats → returns cache stats', async () => { + const res = await request(app.getHttpServer()) + .get('/admin-api/cache/stats') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body).toHaveProperty('success'); + expect(res.body.data).toHaveProperty('hits'); + expect(res.body.data).toHaveProperty('misses'); + expect(res.body.data).toHaveProperty('hitRate'); + }); + + it('POST /admin-api/cache/flush-key → flushes specific key', async () => { + const res = await request(app.getHttpServer()) + .post('/admin-api/cache/flush-key') + .set('Authorization', `Bearer ${token}`) + .send({ key: 'test:key' }) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /admin-api/cache/flush/config → flushes module cache', async () => { + const res = await request(app.getHttpServer()) + .post('/admin-api/cache/flush/config') + .set('Authorization', `Bearer ${token}`) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /admin-api/cache/reset-stats → resets stats', async () => { + const res = await request(app.getHttpServer()) + .post('/admin-api/cache/reset-stats') + .set('Authorization', `Bearer ${token}`) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('POST /admin-api/cache/flush-all → flushes all cache', async () => { + const res = await request(app.getHttpServer()) + .post('/admin-api/cache/flush-all') + .set('Authorization', `Bearer ${token}`) + .expect(201); + expect(res.body).toHaveProperty('success'); + }); + + it('Cache module endpoints require auth', async () => { + await request(app.getHttpServer()).get('/admin-api/cache/stats').expect(401); + await request(app.getHttpServer()).post('/admin-api/cache/flush-key').expect(401); + }); + }); }); diff --git a/test/mocks/prisma.mock.ts b/test/mocks/prisma.mock.ts index 55a5f9c..f38f6dc 100644 --- a/test/mocks/prisma.mock.ts +++ b/test/mocks/prisma.mock.ts @@ -92,6 +92,10 @@ const modelNames = [ 'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent', 'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest', 'workspace', 'knowledgeFolder', 'sourceReference', 'importStepLog', + 'recentItem', 'favorite', 'searchHistory', + 'chatSession', 'chatMessage', 'chatCitation', + 'artifact', 'learningGoal', 'streakRecord', + 'notificationPreference', 'pushToken', 'notificationTemplate', ] for (const name of modelNames) {