feat: M3-04/05/06 — Workspace Experience, Notification, Cache Module
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:
WangDL 2026-05-24 16:01:34 +08:00
parent 4be418ef4a
commit 8e5d722a1e
26 changed files with 2063 additions and 483 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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;
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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 {}

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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],
}) })

View File

@ -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,
});
}
} }

View File

@ -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);
}
} }

View 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;
}

View 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'));
}
}

View 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 {}

View 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);
}
}

View 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;
}
}

View File

@ -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);
});
});
}); });

View File

@ -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) {