feat: M3-04/05/06 — Workspace Experience, Notification, Cache Module
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 44s
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 <noreply@anthropic.com>
This commit is contained in:
parent
4be418ef4a
commit
8e5d722a1e
@ -1252,3 +1252,81 @@ model SecretAccessLog {
|
|||||||
@@index([secretId])
|
@@index([secretId])
|
||||||
@@index([createdAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@ -44,11 +44,14 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
|
|||||||
import { FeedbackModule } from './modules/feedback/feedback.module';
|
import { FeedbackModule } from './modules/feedback/feedback.module';
|
||||||
import { FilesModule } from './modules/files/files.module';
|
import { FilesModule } from './modules/files/files.module';
|
||||||
import { WaitlistModule } from './modules/waitlist/waitlist.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 { KnowledgeSourceModule } from './modules/knowledge-source/knowledge-source.module';
|
||||||
import { ImportCandidateModule } from './modules/import-candidate/import-candidate.module';
|
import { ImportCandidateModule } from './modules/import-candidate/import-candidate.module';
|
||||||
import { RagModule } from './modules/rag/rag.module';
|
import { RagModule } from './modules/rag/rag.module';
|
||||||
import { RagChatModule } from './modules/rag-chat/rag-chat.module';
|
import { RagChatModule } from './modules/rag-chat/rag-chat.module';
|
||||||
import { VectorModule } from './modules/vector/vector.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 { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from './common/guards/roles.guard';
|
import { RolesGuard } from './common/guards/roles.guard';
|
||||||
@ -143,6 +146,9 @@ import appleConfig from './config/apple.config';
|
|||||||
FeedbackModule,
|
FeedbackModule,
|
||||||
FilesModule,
|
FilesModule,
|
||||||
WaitlistModule,
|
WaitlistModule,
|
||||||
|
WorkspaceModule,
|
||||||
|
CacheModule,
|
||||||
|
AdminCacheModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
{ provide: APP_GUARD, useClass: RateLimitGuard },
|
||||||
|
|||||||
10
src/common/cache/cache.module.ts
vendored
Normal file
10
src/common/cache/cache.module.ts
vendored
Normal file
@ -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 {}
|
||||||
161
src/common/cache/cache.service.ts
vendored
Normal file
161
src/common/cache/cache.service.ts
vendored
Normal file
@ -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<T>(key: string): Promise<T | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<T>(key: string, factory: () => Promise<T | null>, ttl?: number): Promise<T | null> {
|
||||||
|
if (!this.isAvailable()) return factory();
|
||||||
|
|
||||||
|
const cached = await this.get<T | null>(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<number> {
|
||||||
|
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<number> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/common/events/item-favorited.event.ts
Normal file
14
src/common/events/item-favorited.event.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/common/events/item-unfavorited.event.ts
Normal file
13
src/common/events/item-unfavorited.event.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/common/events/notification-preference-changed.event.ts
Normal file
12
src/common/events/notification-preference-changed.event.ts
Normal file
@ -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<string, any>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/common/events/notification-read.event.ts
Normal file
12
src/common/events/notification-read.event.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/common/events/notification-sent.event.ts
Normal file
14
src/common/events/notification-sent.event.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/common/events/search-performed.event.ts
Normal file
13
src/common/events/search-performed.event.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/common/events/tag-created.event.ts
Normal file
13
src/common/events/tag-created.event.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/common/events/tag-deleted.event.ts
Normal file
13
src/common/events/tag-deleted.event.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/modules/admin-cache/admin-cache.controller.ts
Normal file
48
src/modules/admin-cache/admin-cache.controller.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/modules/admin-cache/admin-cache.module.ts
Normal file
9
src/modules/admin-cache/admin-cache.module.ts
Normal file
@ -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 {}
|
||||||
52
src/modules/notifications/admin-notifications.controller.ts
Normal file
52
src/modules/notifications/admin-notifications.controller.ts
Normal file
@ -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<string, any>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get, Post, Param, Query, HttpCode, HttpStatus } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Delete, Param, Body, Query, HttpCode, HttpStatus } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBody } from '@nestjs/swagger';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
@ -11,7 +11,7 @@ export class NotificationsController {
|
|||||||
constructor(private readonly service: NotificationsService) {}
|
constructor(private readonly service: NotificationsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: '获取通知列表' })
|
@ApiOperation({ summary: '获取通知列表(含未读数)' })
|
||||||
async list(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) {
|
async list(@CurrentUser() user: UserPayload, @Query() pagination: PaginationDto) {
|
||||||
return this.service.list(String(user?.id || 'anonymous'), pagination);
|
return this.service.list(String(user?.id || 'anonymous'), pagination);
|
||||||
}
|
}
|
||||||
@ -19,7 +19,64 @@ export class NotificationsController {
|
|||||||
@Post(':id/read')
|
@Post(':id/read')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: '标记通知已读' })
|
@ApiOperation({ summary: '标记通知已读' })
|
||||||
async markRead(@Param('id') id: string) {
|
async markRead(@CurrentUser() user: UserPayload, @Param('id') id: string) {
|
||||||
return this.service.markRead(id);
|
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<string, any>) {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { NotificationsController } from './notifications.controller';
|
import { NotificationsController } from './notifications.controller';
|
||||||
|
import { AdminNotificationsController } from './admin-notifications.controller';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
import { NotificationsRepository } from './notifications.repository';
|
import { NotificationsRepository } from './notifications.repository';
|
||||||
|
import { EventBusModule } from '../../common/event-bus/event-bus.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [NotificationsController],
|
imports: [EventBusModule],
|
||||||
|
controllers: [NotificationsController, AdminNotificationsController],
|
||||||
providers: [NotificationsService, NotificationsRepository],
|
providers: [NotificationsService, NotificationsRepository],
|
||||||
exports: [NotificationsService],
|
exports: [NotificationsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { PrismaService } from '../../infrastructure/database/prisma.service';
|
|||||||
export class NotificationsRepository {
|
export class NotificationsRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ═══ Notifications ═══
|
||||||
|
|
||||||
async findAll(userId: string, pagination?: { page?: number; limit?: number }) {
|
async findAll(userId: string, pagination?: { page?: number; limit?: number }) {
|
||||||
const page = pagination?.page ?? 1;
|
const page = pagination?.page ?? 1;
|
||||||
const limit = pagination?.limit ?? 20;
|
const limit = pagination?.limit ?? 20;
|
||||||
@ -37,4 +39,81 @@ export class NotificationsRepository {
|
|||||||
data: { readAt: new Date() },
|
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<string, any>) {
|
||||||
|
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<string, any>) {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { 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';
|
import type { PaginationDto } from '../../common/dto/pagination.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
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) {
|
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) {
|
async markRead(userId: string, id: string) {
|
||||||
try {
|
const notification = await this.repository.findById(id);
|
||||||
return await this.repository.markRead(id);
|
if (!notification) throw new NotFoundException(`Notification ${id} not found`);
|
||||||
} catch {
|
|
||||||
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 }) {
|
async send(data: { userId: string; type: string; title: string; body: string }) {
|
||||||
const notification = await this.repository.create(data);
|
const notification = await this.repository.create(data);
|
||||||
this.logger.log(`Notification ${notification.id} sent to user ${data.userId}`);
|
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;
|
return notification;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══ Preferences ═══
|
||||||
|
|
||||||
|
async getPreferences(userId: string) {
|
||||||
|
return this.repository.getPreference(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePreferences(userId: string, dto: Record<string, any>) {
|
||||||
|
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<string, any>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/modules/workspace/dto/workspace.dto.ts
Normal file
91
src/modules/workspace/dto/workspace.dto.ts
Normal file
@ -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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
140
src/modules/workspace/workspace.controller.ts
Normal file
140
src/modules/workspace/workspace.controller.ts
Normal file
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/modules/workspace/workspace.module.ts
Normal file
16
src/modules/workspace/workspace.module.ts
Normal file
@ -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 {}
|
||||||
212
src/modules/workspace/workspace.repository.ts
Normal file
212
src/modules/workspace/workspace.repository.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/modules/workspace/workspace.service.ts
Normal file
181
src/modules/workspace/workspace.service.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,4 +84,252 @@ describe('M3 E2E Tests', () => {
|
|||||||
expect(res.body.success).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -92,6 +92,10 @@ const modelNames = [
|
|||||||
'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent',
|
'secretAccessLog', 'modelRoute', 'providerConfig', 'fallbackEvent',
|
||||||
'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest',
|
'violationRecord', 'contentReport', 'userDevice', 'accountDeletionRequest',
|
||||||
'workspace', 'knowledgeFolder', 'sourceReference', 'importStepLog',
|
'workspace', 'knowledgeFolder', 'sourceReference', 'importStepLog',
|
||||||
|
'recentItem', 'favorite', 'searchHistory',
|
||||||
|
'chatSession', 'chatMessage', 'chatCitation',
|
||||||
|
'artifact', 'learningGoal', 'streakRecord',
|
||||||
|
'notificationPreference', 'pushToken', 'notificationTemplate',
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const name of modelNames) {
|
for (const name of modelNames) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user