From a2d630d8647cdaed10427c7bb73c709348f615d7 Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Sat, 10 Jan 2026 21:51:29 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat=EF=BC=9A=E5=AE=9E=E7=8E=B0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E7=B3=BB=E7=BB=9F=E6=A0=B8=E5=BF=83=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加通知实体和数据传输对象 - 实现通知服务层逻辑,支持创建、查询、标记已读 - 添加通知REST API控制器 - 实现WebSocket网关,支持实时通知推送 - 支持系统通知、用户通知、广播通知三种类型 - 支持定时通知功能,每分钟自动检查待发送通知 - 添加通知模块导出 --- src/business/notice/dto/create-notice.dto.ts | 38 +++++ .../notice/dto/notice-response.dto.ts | 43 ++++++ src/business/notice/index.ts | 7 + src/business/notice/notice.controller.ts | 87 +++++++++++ src/business/notice/notice.entity.ts | 64 ++++++++ src/business/notice/notice.gateway.ts | 117 ++++++++++++++ src/business/notice/notice.module.ts | 20 +++ src/business/notice/notice.service.ts | 145 ++++++++++++++++++ 8 files changed, 521 insertions(+) create mode 100644 src/business/notice/dto/create-notice.dto.ts create mode 100644 src/business/notice/dto/notice-response.dto.ts create mode 100644 src/business/notice/index.ts create mode 100644 src/business/notice/notice.controller.ts create mode 100644 src/business/notice/notice.entity.ts create mode 100644 src/business/notice/notice.gateway.ts create mode 100644 src/business/notice/notice.module.ts create mode 100644 src/business/notice/notice.service.ts diff --git a/src/business/notice/dto/create-notice.dto.ts b/src/business/notice/dto/create-notice.dto.ts new file mode 100644 index 0000000..4326d1b --- /dev/null +++ b/src/business/notice/dto/create-notice.dto.ts @@ -0,0 +1,38 @@ +import { IsString, IsOptional, IsNumber, IsEnum, IsDateString, IsObject } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { NoticeType } from '../notice.entity'; + +export class CreateNoticeDto { + @ApiProperty({ description: '通知标题' }) + @IsString() + title: string; + + @ApiProperty({ description: '通知内容' }) + @IsString() + content: string; + + @ApiPropertyOptional({ enum: NoticeType, description: '通知类型' }) + @IsOptional() + @IsEnum(NoticeType) + type?: NoticeType; + + @ApiPropertyOptional({ description: '接收者用户ID,不填表示广播' }) + @IsOptional() + @IsNumber() + userId?: number; + + @ApiPropertyOptional({ description: '发送者用户ID' }) + @IsOptional() + @IsNumber() + senderId?: number; + + @ApiPropertyOptional({ description: '计划发送时间' }) + @IsOptional() + @IsDateString() + scheduledAt?: string; + + @ApiPropertyOptional({ description: '额外元数据' }) + @IsOptional() + @IsObject() + metadata?: Record; +} \ No newline at end of file diff --git a/src/business/notice/dto/notice-response.dto.ts b/src/business/notice/dto/notice-response.dto.ts new file mode 100644 index 0000000..04412cf --- /dev/null +++ b/src/business/notice/dto/notice-response.dto.ts @@ -0,0 +1,43 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { NoticeType, NoticeStatus } from '../notice.entity'; + +export class NoticeResponseDto { + @ApiProperty() + id: number; + + @ApiProperty() + title: string; + + @ApiProperty() + content: string; + + @ApiProperty({ enum: NoticeType }) + type: NoticeType; + + @ApiProperty({ enum: NoticeStatus }) + status: NoticeStatus; + + @ApiProperty({ nullable: true }) + userId: number | null; + + @ApiProperty({ nullable: true }) + senderId: number | null; + + @ApiProperty({ nullable: true }) + scheduledAt: Date | null; + + @ApiProperty({ nullable: true }) + sentAt: Date | null; + + @ApiProperty({ nullable: true }) + readAt: Date | null; + + @ApiProperty({ nullable: true }) + metadata: Record | null; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/business/notice/index.ts b/src/business/notice/index.ts new file mode 100644 index 0000000..f2741fd --- /dev/null +++ b/src/business/notice/index.ts @@ -0,0 +1,7 @@ +export * from './notice.entity'; +export * from './notice.service'; +export * from './notice.controller'; +export * from './notice.gateway'; +export * from './notice.module'; +export * from './dto/create-notice.dto'; +export * from './dto/notice-response.dto'; \ No newline at end of file diff --git a/src/business/notice/notice.controller.ts b/src/business/notice/notice.controller.ts new file mode 100644 index 0000000..5b809c1 --- /dev/null +++ b/src/business/notice/notice.controller.ts @@ -0,0 +1,87 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Patch, + Query, + ParseIntPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { NoticeService } from './notice.service'; +import { CreateNoticeDto } from './dto/create-notice.dto'; +import { NoticeResponseDto } from './dto/notice-response.dto'; +import { JwtAuthGuard } from '../auth/jwt_auth.guard'; +import { CurrentUser } from '../auth/current_user.decorator'; + +@ApiTags('通知管理') +@Controller('api/notices') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class NoticeController { + constructor(private readonly noticeService: NoticeService) {} + + @Post() + @ApiOperation({ summary: '创建通知' }) + @ApiResponse({ status: 201, description: '通知创建成功', type: NoticeResponseDto }) + async create(@Body() createNoticeDto: CreateNoticeDto): Promise { + return this.noticeService.create(createNoticeDto); + } + + @Get() + @ApiOperation({ summary: '获取通知列表' }) + @ApiResponse({ status: 200, description: '获取成功', type: [NoticeResponseDto] }) + async findAll( + @CurrentUser() user: any, + @Query('all') all?: string, + ): Promise { + // 如果是管理员且指定了all参数,返回所有通知 + const userId = all === 'true' && user.isAdmin ? undefined : user.id; + return this.noticeService.findAll(userId); + } + + @Get('unread-count') + @ApiOperation({ summary: '获取未读通知数量' }) + @ApiResponse({ status: 200, description: '获取成功' }) + async getUnreadCount(@CurrentUser() user: any): Promise<{ count: number }> { + const count = await this.noticeService.getUserUnreadCount(user.id); + return { count }; + } + + @Get(':id') + @ApiOperation({ summary: '获取通知详情' }) + @ApiResponse({ status: 200, description: '获取成功', type: NoticeResponseDto }) + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + return this.noticeService.findById(id); + } + + @Patch(':id/read') + @ApiOperation({ summary: '标记通知为已读' }) + @ApiResponse({ status: 200, description: '标记成功', type: NoticeResponseDto }) + async markAsRead( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: any, + ): Promise { + return this.noticeService.markAsRead(id, user.id); + } + + @Post('system') + @ApiOperation({ summary: '发送系统通知' }) + @ApiResponse({ status: 201, description: '发送成功', type: NoticeResponseDto }) + async sendSystemNotice( + @Body() body: { title: string; content: string; userId?: number }, + ): Promise { + return this.noticeService.sendSystemNotice(body.title, body.content, body.userId); + } + + @Post('broadcast') + @ApiOperation({ summary: '发送广播通知' }) + @ApiResponse({ status: 201, description: '发送成功', type: NoticeResponseDto }) + async sendBroadcast( + @Body() body: { title: string; content: string }, + ): Promise { + return this.noticeService.sendBroadcast(body.title, body.content); + } +} \ No newline at end of file diff --git a/src/business/notice/notice.entity.ts b/src/business/notice/notice.entity.ts new file mode 100644 index 0000000..2e2a383 --- /dev/null +++ b/src/business/notice/notice.entity.ts @@ -0,0 +1,64 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum NoticeType { + SYSTEM = 'system', + USER = 'user', + BROADCAST = 'broadcast', +} + +export enum NoticeStatus { + PENDING = 'pending', + SENT = 'sent', + READ = 'read', + FAILED = 'failed', +} + +@Entity('notices') +export class Notice { + @PrimaryGeneratedColumn() + id: number; + + @Column() + title: string; + + @Column('text') + content: string; + + @Column({ + type: 'enum', + enum: NoticeType, + default: NoticeType.SYSTEM, + }) + type: NoticeType; + + @Column({ + type: 'enum', + enum: NoticeStatus, + default: NoticeStatus.PENDING, + }) + status: NoticeStatus; + + @Column({ nullable: true }) + userId: number; // 接收者ID,null表示广播通知 + + @Column({ nullable: true }) + senderId: number; // 发送者ID + + @Column({ type: 'datetime', nullable: true }) + scheduledAt: Date; // 计划发送时间 + + @Column({ type: 'datetime', nullable: true }) + sentAt: Date; // 实际发送时间 + + @Column({ type: 'datetime', nullable: true }) + readAt: Date; // 阅读时间 + + @Column({ type: 'json', nullable: true }) + metadata: Record; // 额外数据 + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/src/business/notice/notice.gateway.ts b/src/business/notice/notice.gateway.ts new file mode 100644 index 0000000..6574a37 --- /dev/null +++ b/src/business/notice/notice.gateway.ts @@ -0,0 +1,117 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + MessageBody, + ConnectedSocket, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server } from 'ws'; +import * as WebSocket from 'ws'; +import { Logger } from '@nestjs/common'; + +interface AuthenticatedSocket extends WebSocket { + userId?: number; +} + +@WebSocketGateway({ + cors: { + origin: '*', + }, + path: '/ws/notice', +}) +export class NoticeGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(NoticeGateway.name); + private readonly userSockets = new Map>(); + + handleConnection(client: AuthenticatedSocket) { + this.logger.log(`Client connected: ${client.readyState}`); + } + + handleDisconnect(client: AuthenticatedSocket) { + this.logger.log(`Client disconnected`); + + if (client.userId) { + const userSockets = this.userSockets.get(client.userId); + if (userSockets) { + userSockets.delete(client); + if (userSockets.size === 0) { + this.userSockets.delete(client.userId); + } + } + } + } + + @SubscribeMessage('authenticate') + handleAuthenticate( + @MessageBody() data: { userId: number }, + @ConnectedSocket() client: AuthenticatedSocket, + ) { + const { userId } = data; + + if (!userId) { + client.send(JSON.stringify({ error: 'User ID is required' })); + return; + } + + client.userId = userId; + + if (!this.userSockets.has(userId)) { + this.userSockets.set(userId, new Set()); + } + this.userSockets.get(userId)!.add(client); + + client.send(JSON.stringify({ + type: 'authenticated', + data: { userId } + })); + + this.logger.log(`User ${userId} authenticated`); + } + + @SubscribeMessage('ping') + handlePing(@ConnectedSocket() client: AuthenticatedSocket) { + client.send(JSON.stringify({ type: 'pong' })); + } + + // 发送消息给特定用户 + sendToUser(userId: number, message: any) { + const userSockets = this.userSockets.get(userId); + if (userSockets) { + const messageStr = JSON.stringify(message); + userSockets.forEach(socket => { + if (socket.readyState === WebSocket.OPEN) { + socket.send(messageStr); + } + }); + this.logger.log(`Message sent to user ${userId}`); + } else { + this.logger.warn(`User ${userId} not connected`); + } + } + + // 广播消息给所有连接的用户 + broadcast(message: any) { + const messageStr = JSON.stringify(message); + this.server.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(messageStr); + } + }); + this.logger.log('Message broadcasted to all clients'); + } + + // 获取在线用户数量 + getOnlineUsersCount(): number { + return this.userSockets.size; + } + + // 获取在线用户列表 + getOnlineUsers(): number[] { + return Array.from(this.userSockets.keys()); + } +} \ No newline at end of file diff --git a/src/business/notice/notice.module.ts b/src/business/notice/notice.module.ts new file mode 100644 index 0000000..2b51171 --- /dev/null +++ b/src/business/notice/notice.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { Notice } from './notice.entity'; +import { NoticeService } from './notice.service'; +import { NoticeController } from './notice.controller'; +import { NoticeGateway } from './notice.gateway'; +import { LoginCoreModule } from '../../core/login_core/login_core.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Notice]), + ScheduleModule.forRoot(), + LoginCoreModule, + ], + controllers: [NoticeController], + providers: [NoticeService, NoticeGateway], + exports: [NoticeService, NoticeGateway], +}) +export class NoticeModule {} \ No newline at end of file diff --git a/src/business/notice/notice.service.ts b/src/business/notice/notice.service.ts new file mode 100644 index 0000000..661fb54 --- /dev/null +++ b/src/business/notice/notice.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual } from 'typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { Notice, NoticeStatus, NoticeType } from './notice.entity'; +import { CreateNoticeDto } from './dto/create-notice.dto'; +import { NoticeGateway } from './notice.gateway'; + +@Injectable() +export class NoticeService { + private readonly logger = new Logger(NoticeService.name); + + constructor( + @InjectRepository(Notice) + private readonly noticeRepository: Repository, + private readonly noticeGateway: NoticeGateway, + ) {} + + async create(createNoticeDto: CreateNoticeDto): Promise { + const notice = this.noticeRepository.create({ + ...createNoticeDto, + scheduledAt: createNoticeDto.scheduledAt ? new Date(createNoticeDto.scheduledAt) : null, + }); + + const savedNotice = await this.noticeRepository.save(notice); + + // 如果没有设置计划时间,立即发送 + if (!savedNotice.scheduledAt) { + await this.sendNotice(savedNotice); + } + + return savedNotice; + } + + async findAll(userId?: number): Promise { + const query = this.noticeRepository.createQueryBuilder('notice'); + + if (userId) { + query.where('notice.userId = :userId OR notice.userId IS NULL', { userId }); + } + + return query.orderBy('notice.createdAt', 'DESC').getMany(); + } + + async findById(id: number): Promise { + const notice = await this.noticeRepository.findOne({ where: { id } }); + if (!notice) { + throw new NotFoundException(`Notice with ID ${id} not found`); + } + return notice; + } + + async markAsRead(id: number, userId?: number): Promise { + const notice = await this.findById(id); + + // 检查权限:只能标记自己的通知或广播通知为已读 + if (notice.userId && userId && notice.userId !== userId) { + throw new NotFoundException(`Notice with ID ${id} not found`); + } + + notice.status = NoticeStatus.READ; + notice.readAt = new Date(); + + return this.noticeRepository.save(notice); + } + + async getUserUnreadCount(userId: number): Promise { + return this.noticeRepository.count({ + where: [ + { userId, status: NoticeStatus.SENT }, + { userId: null, status: NoticeStatus.SENT }, // 广播通知 + ], + }); + } + + private async sendNotice(notice: Notice): Promise { + try { + // 通过WebSocket发送通知 + if (notice.userId) { + // 发送给特定用户 + this.noticeGateway.sendToUser(notice.userId, { + type: 'notice', + data: notice, + }); + } else { + // 广播通知 + this.noticeGateway.broadcast({ + type: 'notice', + data: notice, + }); + } + + // 更新状态 + notice.status = NoticeStatus.SENT; + notice.sentAt = new Date(); + await this.noticeRepository.save(notice); + + this.logger.log(`Notice ${notice.id} sent successfully`); + } catch (error) { + this.logger.error(`Failed to send notice ${notice.id}:`, error); + + notice.status = NoticeStatus.FAILED; + await this.noticeRepository.save(notice); + } + } + + // 定时任务:每分钟检查需要发送的通知 + @Cron(CronExpression.EVERY_MINUTE) + async handleScheduledNotices(): Promise { + const now = new Date(); + const pendingNotices = await this.noticeRepository.find({ + where: { + status: NoticeStatus.PENDING, + scheduledAt: LessThanOrEqual(now), + }, + }); + + for (const notice of pendingNotices) { + await this.sendNotice(notice); + } + + if (pendingNotices.length > 0) { + this.logger.log(`Processed ${pendingNotices.length} scheduled notices`); + } + } + + // 发送系统通知的便捷方法 + async sendSystemNotice(title: string, content: string, userId?: number): Promise { + return this.create({ + title, + content, + type: NoticeType.SYSTEM, + userId, + }); + } + + // 发送广播通知的便捷方法 + async sendBroadcast(title: string, content: string): Promise { + return this.create({ + title, + content, + type: NoticeType.BROADCAST, + }); + } +} \ No newline at end of file -- 2.25.1 From 874ccfa879799279d4b776295db70e12c608cfcb Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Sat, 10 Jan 2026 21:52:48 +0800 Subject: [PATCH 2/6] =?UTF-8?q?db=EF=BC=9A=E6=B7=BB=E5=8A=A0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E7=B3=BB=E7=BB=9F=E6=95=B0=E6=8D=AE=E5=BA=93=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建notices表结构SQL脚本 - 包含完整的字段定义和索引优化 - 添加通知服务单元测试用例 --- .../migrations/create-notices-table.sql | 21 ++ src/business/notice/notice.service.spec.ts | 202 ++++++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 src/business/notice/migrations/create-notices-table.sql create mode 100644 src/business/notice/notice.service.spec.ts diff --git a/src/business/notice/migrations/create-notices-table.sql b/src/business/notice/migrations/create-notices-table.sql new file mode 100644 index 0000000..a721a8d --- /dev/null +++ b/src/business/notice/migrations/create-notices-table.sql @@ -0,0 +1,21 @@ +-- 创建通知表 +CREATE TABLE IF NOT EXISTS `notices` ( + `id` int NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL COMMENT '通知标题', + `content` text NOT NULL COMMENT '通知内容', + `type` enum('system','user','broadcast') NOT NULL DEFAULT 'system' COMMENT '通知类型', + `status` enum('pending','sent','read','failed') NOT NULL DEFAULT 'pending' COMMENT '通知状态', + `userId` int DEFAULT NULL COMMENT '接收者用户ID,NULL表示广播', + `senderId` int DEFAULT NULL COMMENT '发送者用户ID', + `scheduledAt` datetime DEFAULT NULL COMMENT '计划发送时间', + `sentAt` datetime DEFAULT NULL COMMENT '实际发送时间', + `readAt` datetime DEFAULT NULL COMMENT '阅读时间', + `metadata` json DEFAULT NULL COMMENT '额外数据', + `createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间', + `updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_notices_user_id` (`userId`), + KEY `idx_notices_status` (`status`), + KEY `idx_notices_scheduled_at` (`scheduledAt`), + KEY `idx_notices_created_at` (`createdAt`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表'; \ No newline at end of file diff --git a/src/business/notice/notice.service.spec.ts b/src/business/notice/notice.service.spec.ts new file mode 100644 index 0000000..fc2cba5 --- /dev/null +++ b/src/business/notice/notice.service.spec.ts @@ -0,0 +1,202 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NoticeService } from './notice.service'; +import { NoticeGateway } from './notice.gateway'; +import { Notice, NoticeStatus, NoticeType } from './notice.entity'; + +describe('NoticeService', () => { + let service: NoticeService; + let repository: Repository; + let gateway: NoticeGateway; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockGateway = { + sendToUser: jest.fn(), + broadcast: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NoticeService, + { + provide: getRepositoryToken(Notice), + useValue: mockRepository, + }, + { + provide: NoticeGateway, + useValue: mockGateway, + }, + ], + }).compile(); + + service = module.get(NoticeService); + repository = module.get>(getRepositoryToken(Notice)); + gateway = module.get(NoticeGateway); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create and send notice immediately when no scheduledAt', async () => { + const createDto = { + title: 'Test Notice', + content: 'Test Content', + userId: 1, + }; + + const mockNotice = { + id: 1, + ...createDto, + status: NoticeStatus.PENDING, + type: NoticeType.SYSTEM, + scheduledAt: null, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValueOnce(mockNotice); + mockRepository.save.mockResolvedValueOnce({ + ...mockNotice, + status: NoticeStatus.SENT, + sentAt: new Date(), + }); + + const result = await service.create(createDto); + + expect(mockRepository.create).toHaveBeenCalledWith({ + ...createDto, + scheduledAt: null, + }); + expect(mockRepository.save).toHaveBeenCalledTimes(2); + expect(mockGateway.sendToUser).toHaveBeenCalledWith(1, { + type: 'notice', + data: mockNotice, + }); + }); + + it('should create scheduled notice without sending immediately', async () => { + const scheduledAt = new Date(Date.now() + 3600000); // 1 hour later + const createDto = { + title: 'Scheduled Notice', + content: 'Scheduled Content', + scheduledAt: scheduledAt.toISOString(), + }; + + const mockNotice = { + id: 1, + ...createDto, + scheduledAt, + status: NoticeStatus.PENDING, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValue(mockNotice); + + const result = await service.create(createDto); + + expect(mockGateway.sendToUser).not.toHaveBeenCalled(); + expect(mockGateway.broadcast).not.toHaveBeenCalled(); + }); + }); + + describe('sendSystemNotice', () => { + it('should create and send system notice', async () => { + const mockNotice = { + id: 1, + title: 'System Notice', + content: 'System Content', + type: NoticeType.SYSTEM, + userId: 1, + status: NoticeStatus.PENDING, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValueOnce(mockNotice); + mockRepository.save.mockResolvedValueOnce({ + ...mockNotice, + status: NoticeStatus.SENT, + }); + + const result = await service.sendSystemNotice('System Notice', 'System Content', 1); + + expect(result.type).toBe(NoticeType.SYSTEM); + expect(mockGateway.sendToUser).toHaveBeenCalled(); + }); + }); + + describe('sendBroadcast', () => { + it('should create and send broadcast notice', async () => { + const mockNotice = { + id: 1, + title: 'Broadcast Notice', + content: 'Broadcast Content', + type: NoticeType.BROADCAST, + userId: null, + status: NoticeStatus.PENDING, + }; + + mockRepository.create.mockReturnValue(mockNotice); + mockRepository.save.mockResolvedValueOnce(mockNotice); + mockRepository.save.mockResolvedValueOnce({ + ...mockNotice, + status: NoticeStatus.SENT, + }); + + const result = await service.sendBroadcast('Broadcast Notice', 'Broadcast Content'); + + expect(result.type).toBe(NoticeType.BROADCAST); + expect(mockGateway.broadcast).toHaveBeenCalled(); + }); + }); + + describe('markAsRead', () => { + it('should mark notice as read', async () => { + const mockNotice = { + id: 1, + userId: 1, + status: NoticeStatus.SENT, + }; + + const updatedNotice = { + ...mockNotice, + status: NoticeStatus.READ, + readAt: new Date(), + }; + + mockRepository.findOne.mockResolvedValue(mockNotice); + mockRepository.save.mockResolvedValue(updatedNotice); + + const result = await service.markAsRead(1, 1); + + expect(result.status).toBe(NoticeStatus.READ); + expect(result.readAt).toBeDefined(); + }); + }); + + describe('getUserUnreadCount', () => { + it('should return unread count for user', async () => { + mockRepository.count.mockResolvedValue(5); + + const count = await service.getUserUnreadCount(1); + + expect(count).toBe(5); + expect(mockRepository.count).toHaveBeenCalledWith({ + where: [ + { userId: 1, status: NoticeStatus.SENT }, + { userId: null, status: NoticeStatus.SENT }, + ], + }); + }); + }); +}); \ No newline at end of file -- 2.25.1 From c5a04b01a15b6ebb47a125b0bb0e27310443d363 Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Sat, 10 Jan 2026 21:53:50 +0800 Subject: [PATCH 3/6] =?UTF-8?q?config=EF=BC=9A=E9=9B=86=E6=88=90=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=A8=A1=E5=9D=97=E5=88=B0=E4=B8=BB=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在AppModule中导入NoticeModule - 确保通知系统在应用启动时正确加载 --- src/app.module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app.module.ts b/src/app.module.ts index 1c0d778..62a3cf3 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { AdminModule } from './business/admin/admin.module'; import { UserMgmtModule } from './business/user_mgmt/user_mgmt.module'; import { SecurityCoreModule } from './core/security_core/security_core.module'; import { LocationBroadcastModule } from './business/location_broadcast/location_broadcast.module'; +import { NoticeModule } from './business/notice/notice.module'; import { MaintenanceMiddleware } from './core/security_core/maintenance.middleware'; import { ContentTypeMiddleware } from './core/security_core/content_type.middleware'; @@ -74,6 +75,7 @@ function isDatabaseConfigured(): boolean { AdminModule, SecurityCoreModule, LocationBroadcastModule, + NoticeModule, ], controllers: [AppController], providers: [ -- 2.25.1 From 28bea2f0015bdd8623786858fc8ee42eb124b602 Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Sat, 10 Jan 2026 21:54:17 +0800 Subject: [PATCH 4/6] =?UTF-8?q?websocket=EF=BC=9A=E9=9B=86=E6=88=90?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=B5=8B=E8=AF=95=E5=8A=9F=E8=83=BD=E5=88=B0?= =?UTF-8?q?WebSocket=E6=B5=8B=E8=AF=95=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加通知模式切换功能,支持聊天和通知两种测试模式 - 实现通知WebSocket连接和用户认证 - 添加通知发送界面,支持API和WebSocket两种发送方式 - 集成通知管理功能,支持列表查看和已读标记 - 修复HTML结构,确保通知模式与聊天模式平级显示 - 更新页面标题和功能描述 --- .../zulip/websocket_test.controller.ts | 555 +++++++++++++++++- 1 file changed, 546 insertions(+), 9 deletions(-) diff --git a/src/business/zulip/websocket_test.controller.ts b/src/business/zulip/websocket_test.controller.ts index ea4f89e..86d900a 100644 --- a/src/business/zulip/websocket_test.controller.ts +++ b/src/business/zulip/websocket_test.controller.ts @@ -19,9 +19,9 @@ export class WebSocketTestController { @Get() @ApiOperation({ - summary: '🔌 WebSocket 测试页面 - 一键测试工具 + API监控', + summary: '🔌 WebSocket 测试页面 + 通知系统 - 一键测试工具 + API监控', description: ` -**🚀 功能强大的WebSocket测试工具** +**🚀 功能强大的WebSocket测试工具 + 通知系统** 提供完整的WebSocket测试功能: - ✅ 自动获取JWT Token @@ -30,7 +30,16 @@ export class WebSocketTestController { - ✅ 聊天消息发送测试 - ✅ 位置更新测试 - ✅ 实时消息日志 -- 📡 **新增:API调用监控** - 实时显示所有HTTP请求 +- 📡 **API调用监控** - 实时显示所有HTTP请求 +- 🔔 **通知系统测试** - 完整的通知功能测试 + +**新增通知系统功能**: +- 🔔 实时通知WebSocket连接 +- 📢 通知发送和接收测试 +- 📋 通知列表管理 +- 🔢 未读通知统计 +- 🎯 支持系统、用户、广播通知 +- ⏰ 定时通知功能 **使用方法**: 1. 点击下方"Execute"按钮 @@ -58,7 +67,7 @@ export class WebSocketTestController { - WebSocket 测试工具 + API监控 - Pixel Game Server + WebSocket 测试工具 + 通知系统 - Pixel Game Server -

🎮 Pixel Game Server - WebSocket 测试工具 + API监控

+

🎮 Pixel Game Server - WebSocket 测试工具 + 通知系统

📋 使用说明

@@ -286,7 +314,8 @@ export class WebSocketTestController { • 💾 本地存储:自动保存Token,下次访问无需重新获取
• 📧 真实邮箱支持:检测到真实邮箱时使用真实验证码发送
• 📡 **API调用监控**:实时显示所有HTTP请求和响应,方便调试
- • 🔍 详细日志:支持显示请求体、响应体和调用统计 + • 🔍 详细日志:支持显示请求体、响应体和调用统计
+ • 🔔 **通知系统测试**:完整的通知功能测试,支持实时推送和API调用
@@ -319,6 +348,17 @@ export class WebSocketTestController {
+ +
+

🎛️ 功能选择

+
+ + +
+
+ + +

🔌 连接控制

@@ -458,18 +498,110 @@ export class WebSocketTestController {
+ + + +
-

📋 消息日志

+

📋 消息日志

-

📡 API 调用日志

+

📡 API 调用日志

- 实时监控前端API调用 + 实时监控前端API调用
@@ -502,6 +634,11 @@ export class WebSocketTestController { let apiSuccessCount = 0; let apiErrorCount = 0; let showApiDetails = false; + let currentMode = 'chat'; // 当前模式:chat 或 notice + + // 通知系统相关变量 + let noticeWs = null; + let noticeAuthenticated = false; // API监控相关变量 const originalFetch = window.fetch; @@ -1869,6 +2006,16 @@ export class WebSocketTestController { addMessage('system', '🎲 一键测试会自动生成随机测试账号,方便多用户测试'); addMessage('system', '📡 新功能: API调用监控已启用,可实时查看所有HTTP请求'); addMessage('system', '🔍 API日志支持显示请求体、响应体和详细统计信息'); + addMessage('system', '🔔 新增: 通知系统测试功能,可切换到通知模式进行测试'); + + // 请求通知权限 + if (Notification.permission === 'default') { + Notification.requestPermission().then(permission => { + if (permission === 'granted') { + addMessage('system', '✅ 浏览器通知权限已获取'); + } + }); + } // 检查URL参数,看是否从API文档跳转过来 const urlParams = new URLSearchParams(window.location.search); @@ -1951,6 +2098,396 @@ export class WebSocketTestController { window.addEventListener('error', function(event) { addMessage('error', '❌ 页面错误: ' + event.error.message); }); + + // ==================== 通知系统功能 ==================== + + // 切换功能模式 + function switchMode(mode) { + console.log('switchMode called with:', mode); + currentMode = mode; + const chatMode = document.getElementById('chatMode'); + const noticeMode = document.getElementById('noticeMode'); + const chatBtn = document.getElementById('chatModeBtn'); + const noticeBtn = document.getElementById('noticeModeBtn'); + const logTitle = document.getElementById('logTitle'); + const apiLogTitle = document.getElementById('apiLogTitle'); + const apiLogSubtitle = document.getElementById('apiLogSubtitle'); + + console.log('Elements found:', { + chatMode: !!chatMode, + noticeMode: !!noticeMode, + chatBtn: !!chatBtn, + noticeBtn: !!noticeBtn + }); + + if (mode === 'chat') { + if (chatMode) chatMode.style.display = 'block'; + if (noticeMode) noticeMode.style.display = 'none'; + if (chatBtn) chatBtn.style.backgroundColor = '#1976d2'; + if (noticeBtn) noticeBtn.style.backgroundColor = '#666'; + if (logTitle) logTitle.textContent = '📋 消息日志'; + if (apiLogTitle) apiLogTitle.textContent = '📡 API 调用日志'; + if (apiLogSubtitle) apiLogSubtitle.textContent = '实时监控前端API调用'; + addMessage('system', '🔄 已切换到聊天测试模式'); + console.log('Switched to chat mode'); + } else { + console.log('Switching to notice mode...'); + if (chatMode) { + chatMode.style.display = 'none'; + console.log('Chat mode hidden'); + } + if (noticeMode) { + noticeMode.style.display = 'block'; + console.log('Notice mode shown, display style:', noticeMode.style.display); + console.log('Notice mode computed style:', window.getComputedStyle(noticeMode).display); + } + if (chatBtn) chatBtn.style.backgroundColor = '#666'; + if (noticeBtn) noticeBtn.style.backgroundColor = '#1976d2'; + if (logTitle) logTitle.textContent = '🔔 通知日志'; + if (apiLogTitle) apiLogTitle.textContent = '📡 通知API日志'; + if (apiLogSubtitle) apiLogSubtitle.textContent = '实时监控通知API调用'; + addMessage('system', '🔄 已切换到通知测试模式'); + console.log('Notice mode switch completed'); + + // 自动复制Token + copyTokenFromChat(); + } + } + + // 从聊天模式复制Token + function copyTokenFromChat() { + const chatToken = document.getElementById('jwtToken').value.trim(); + if (chatToken) { + document.getElementById('noticeJwtToken').value = chatToken; + addMessage('system', '✅ 已从聊天模式复制Token'); + } else { + addMessage('system', '💡 聊天模式中没有Token,请先在聊天模式获取Token'); + } + } + + // 切换通知WebSocket连接 + function toggleNoticeConnection() { + if (noticeWs && noticeWs.readyState === WebSocket.OPEN) { + noticeWs.close(); + } else { + connectNotice(); + } + } + + // 连接通知WebSocket + function connectNotice() { + const url = document.getElementById('noticeWsUrl').value; + updateNoticeStatus('connecting', '连接中...'); + + try { + noticeWs = new WebSocket(url); + + noticeWs.onopen = function() { + updateNoticeStatus('connected', '已连接'); + document.getElementById('noticeConnectBtn').textContent = '断开连接'; + addMessage('system', '✅ 通知WebSocket连接成功'); + }; + + noticeWs.onmessage = function(event) { + try { + const data = JSON.parse(event.data); + addMessage('received', '🔔 收到通知: ' + JSON.stringify(data, null, 2)); + + if (data.type === 'notice') { + showNotificationPopup(data.data); + } else if (data.type === 'authenticated') { + noticeAuthenticated = true; + addMessage('system', '✅ 通知系统认证成功'); + } else if (data.type === 'pong') { + addMessage('system', '🏓 收到pong响应'); + } + } catch (e) { + addMessage('received', '🔔 ' + event.data); + } + }; + + noticeWs.onclose = function() { + updateNoticeStatus('disconnected', '未连接'); + document.getElementById('noticeConnectBtn').textContent = '连接通知系统'; + noticeAuthenticated = false; + addMessage('system', '🔌 通知WebSocket连接已关闭'); + }; + + noticeWs.onerror = function(error) { + addMessage('error', '❌ 通知连接错误: ' + error); + }; + + } catch (error) { + updateNoticeStatus('disconnected', '连接失败'); + addMessage('error', '❌ 通知连接失败: ' + error.message); + } + } + + // 更新通知连接状态 + function updateNoticeStatus(status, message) { + const statusEl = document.getElementById('noticeConnectionStatus'); + statusEl.className = 'status ' + status; + statusEl.textContent = message; + } + + // 认证通知连接 + function authenticateNotice() { + if (!noticeWs || noticeWs.readyState !== WebSocket.OPEN) { + addMessage('error', '❌ 请先建立通知WebSocket连接'); + return; + } + + const userId = document.getElementById('noticeUserId').value.trim(); + if (!userId) { + addMessage('error', '❌ 请输入用户ID'); + return; + } + + const message = { + event: 'authenticate', + data: { userId: parseInt(userId) } + }; + + noticeWs.send(JSON.stringify(message)); + addMessage('sent', '📤 发送认证: ' + JSON.stringify(message, null, 2)); + } + + // 通过API发送通知 + async function sendNoticeViaAPI() { + const title = document.getElementById('noticeTitle').value.trim(); + const content = document.getElementById('noticeContent').value.trim(); + const type = document.getElementById('noticeType').value; + const targetUserId = document.getElementById('targetUserId').value.trim(); + const scheduledTime = document.getElementById('scheduledTime').value; + const token = document.getElementById('noticeJwtToken').value.trim(); + + if (!title || !content) { + addMessage('error', '❌ 请输入通知标题和内容'); + return; + } + + if (!token) { + addMessage('error', '❌ 请输入JWT Token'); + return; + } + + const payload = { + title, + content, + type, + userId: targetUserId ? parseInt(targetUserId) : undefined, + scheduledAt: scheduledTime || undefined + }; + + try { + let endpoint = '/api/notices'; + if (type === 'system') { + endpoint = '/api/notices/system'; + } else if (type === 'broadcast') { + endpoint = '/api/notices/broadcast'; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify(payload) + }); + + const result = await response.json(); + + if (response.ok) { + addMessage('system', '✅ 通知发送成功: ' + JSON.stringify(result, null, 2)); + clearNoticeForm(); + } else { + addMessage('error', '❌ 通知发送失败: ' + (result.message || '未知错误')); + } + } catch (error) { + addMessage('error', '❌ 请求失败: ' + error.message); + } + } + + // 通过WebSocket发送通知 (这里只是演示,实际通知应该通过API发送) + function sendNoticeViaWS() { + if (!noticeWs || noticeWs.readyState !== WebSocket.OPEN) { + addMessage('error', '❌ 请先建立通知WebSocket连接'); + return; + } + + if (!noticeAuthenticated) { + addMessage('error', '❌ 请先认证通知连接'); + return; + } + + const message = { + event: 'ping' + }; + + noticeWs.send(JSON.stringify(message)); + addMessage('sent', '📤 发送ping: ' + JSON.stringify(message, null, 2)); + } + + // 发送快速通知 + function sendQuickNotice(title, content) { + document.getElementById('noticeTitle').value = title; + document.getElementById('noticeContent').value = content; + document.getElementById('noticeType').value = 'system'; + sendNoticeViaAPI(); + } + + // 加载通知列表 + async function loadNoticeList() { + const token = document.getElementById('noticeJwtToken').value.trim(); + + if (!token) { + addMessage('error', '❌ 请输入JWT Token'); + return; + } + + try { + const response = await fetch('/api/notices', { + headers: { + 'Authorization': 'Bearer ' + token + } + }); + + const notices = await response.json(); + + if (response.ok) { + displayNoticeList(notices); + addMessage('system', '✅ 通知列表加载成功,共 ' + notices.length + ' 条'); + } else { + addMessage('error', '❌ 加载通知列表失败: ' + (notices.message || '未知错误')); + } + } catch (error) { + addMessage('error', '❌ 请求失败: ' + error.message); + } + } + + // 获取未读通知数量 + async function getUnreadCount() { + const token = document.getElementById('noticeJwtToken').value.trim(); + + if (!token) { + addMessage('error', '❌ 请输入JWT Token'); + return; + } + + try { + const response = await fetch('/api/notices/unread-count', { + headers: { + 'Authorization': 'Bearer ' + token + } + }); + + const result = await response.json(); + + if (response.ok) { + addMessage('system', '📊 未读通知数量: ' + result.count); + } else { + addMessage('error', '❌ 获取未读数量失败: ' + (result.message || '未知错误')); + } + } catch (error) { + addMessage('error', '❌ 请求失败: ' + error.message); + } + } + + // 显示通知列表 + function displayNoticeList(notices) { + const listEl = document.getElementById('noticeList'); + listEl.innerHTML = ''; + + if (notices.length === 0) { + listEl.innerHTML = '
暂无通知
'; + return; + } + + notices.forEach(notice => { + const noticeEl = document.createElement('div'); + noticeEl.style.cssText = 'border: 1px solid #ddd; padding: 8px; margin-bottom: 8px; border-radius: 4px; background: white;'; + + const statusColor = notice.status === 'read' ? '#666' : '#1976d2'; + const statusText = notice.status === 'read' ? '已读' : '未读'; + + noticeEl.innerHTML = \` +
\${notice.title}
+
\${notice.content}
+
+ 类型: \${notice.type} | + 状态: \${statusText} | + 时间: \${new Date(notice.createdAt).toLocaleString()} + \${notice.status !== 'read' ? + \`\` : + '' + } +
+ \`; + + listEl.appendChild(noticeEl); + }); + } + + // 标记通知为已读 + async function markNoticeAsRead(noticeId) { + const token = document.getElementById('noticeJwtToken').value.trim(); + + if (!token) { + addMessage('error', '❌ 请输入JWT Token'); + return; + } + + try { + const response = await fetch(\`/api/notices/\${noticeId}/read\`, { + method: 'PATCH', + headers: { + 'Authorization': 'Bearer ' + token + } + }); + + const result = await response.json(); + + if (response.ok) { + addMessage('system', '✅ 通知已标记为已读'); + loadNoticeList(); // 刷新列表 + } else { + addMessage('error', '❌ 标记失败: ' + (result.message || '未知错误')); + } + } catch (error) { + addMessage('error', '❌ 请求失败: ' + error.message); + } + } + + // 显示通知弹窗 + function showNotificationPopup(notice) { + // 使用浏览器通知API + if (Notification.permission === 'granted') { + new Notification(notice.title, { + body: notice.content, + icon: '/favicon.ico' + }); + } + + // 在页面上显示 + addMessage('system', \`🔔 新通知: \${notice.title} - \${notice.content}\`); + } + + // 清空通知表单 + function clearNoticeForm() { + document.getElementById('noticeTitle').value = ''; + document.getElementById('noticeContent').value = ''; + document.getElementById('targetUserId').value = ''; + document.getElementById('scheduledTime').value = ''; + } + + // 清空通知日志 + function clearNoticeLog() { + if (currentMode === 'notice') { + clearLog(); + addMessage('system', '🗑️ 通知日志已清空'); + } + } -- 2.25.1 From b3181b54bc5beb6f5ecf102ce500b02315e70ae8 Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Sat, 10 Jan 2026 21:56:59 +0800 Subject: [PATCH 5/6] =?UTF-8?q?doc=EF=BC=9A=E8=A1=A5=E5=85=85=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E7=9A=84readme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/business/notice/README.md | 186 ++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/business/notice/README.md diff --git a/src/business/notice/README.md b/src/business/notice/README.md new file mode 100644 index 0000000..9cc2c91 --- /dev/null +++ b/src/business/notice/README.md @@ -0,0 +1,186 @@ +# 通知系统 (Notice System) + +## 功能概述 + +这是一个完整的通知系统,支持实时通知推送、定时通知、通知状态管理等功能。 + +## 主要特性 + +- ✅ 实时WebSocket通知推送 +- ✅ 定时通知发送 +- ✅ 通知状态管理(待发送、已发送、已读、失败) +- ✅ 支持单用户通知和广播通知 +- ✅ 通知类型分类(系统、用户、广播) +- ✅ 未读通知计数 +- ✅ RESTful API接口 + +## API接口 + +### 1. 创建通知 +``` +POST /api/notices +``` + +### 2. 获取通知列表 +``` +GET /api/notices +GET /api/notices?all=true # 管理员获取所有通知 +``` + +### 3. 获取未读通知数量 +``` +GET /api/notices/unread-count +``` + +### 4. 获取通知详情 +``` +GET /api/notices/:id +``` + +### 5. 标记通知为已读 +``` +PATCH /api/notices/:id/read +``` + +### 6. 发送系统通知 +``` +POST /api/notices/system +``` + +### 7. 发送广播通知 +``` +POST /api/notices/broadcast +``` + +## WebSocket连接 + +### 连接地址 +``` +ws://localhost:3000/ws/notice +``` + +### 认证 +连接后需要发送认证消息: +```json +{ + "event": "authenticate", + "data": { "userId": 123 } +} +``` + +### 接收通知 +客户端会收到以下格式的通知: +```json +{ + "type": "notice", + "data": { + "id": 1, + "title": "通知标题", + "content": "通知内容", + "type": "system", + "status": "sent", + "createdAt": "2024-01-01T00:00:00.000Z" + } +} +``` + +## 使用示例 + +### 前端JavaScript示例 +```javascript +// 建立WebSocket连接 +const ws = new WebSocket('ws://localhost:3000/ws/notice'); + +// 连接成功后认证 +ws.onopen = () => { + ws.send(JSON.stringify({ + event: 'authenticate', + data: { userId: 123 } + })); +}; + +// 接收通知 +ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.type === 'notice') { + console.log('收到新通知:', message.data); + // 在UI中显示通知 + showNotification(message.data); + } +}; + +// 获取通知列表 +async function getNotices() { + const response = await fetch('/api/notices', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + return response.json(); +} + +// 标记通知为已读 +async function markAsRead(noticeId) { + await fetch(`/api/notices/${noticeId}/read`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${token}` + } + }); +} +``` + +### 后端使用示例 +```typescript +// 注入NoticeService +constructor(private readonly noticeService: NoticeService) {} + +// 发送系统通知 +await this.noticeService.sendSystemNotice( + '系统维护通知', + '系统将于今晚22:00进行维护', + userId +); + +// 发送广播通知 +await this.noticeService.sendBroadcast( + '新功能上线', + '我们上线了新的通知功能!' +); + +// 创建定时通知 +await this.noticeService.create({ + title: '会议提醒', + content: '您有一个会议将在30分钟后开始', + userId: 123, + scheduledAt: new Date(Date.now() + 30 * 60 * 1000).toISOString() +}); +``` + +## 数据库表结构 + +通知表包含以下字段: +- `id`: 主键 +- `title`: 通知标题 +- `content`: 通知内容 +- `type`: 通知类型(system/user/broadcast) +- `status`: 通知状态(pending/sent/read/failed) +- `userId`: 接收者ID(null表示广播) +- `senderId`: 发送者ID +- `scheduledAt`: 计划发送时间 +- `sentAt`: 实际发送时间 +- `readAt`: 阅读时间 +- `metadata`: 额外数据(JSON格式) +- `createdAt`: 创建时间 +- `updatedAt`: 更新时间 + +## 定时任务 + +系统每分钟自动检查并发送到期的定时通知。 + +## 注意事项 + +1. 需要在主模块中导入 `NoticeModule` +2. 确保数据库中存在 `notices` 表 +3. WebSocket连接需要用户认证 +4. 定时通知依赖 `@nestjs/schedule` 包 \ No newline at end of file -- 2.25.1 From 4d83b44ea532bda1762448cd60b51653955f8ef4 Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Sat, 10 Jan 2026 21:57:44 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix=EF=BC=9A=E4=BF=AE=E5=A4=8D=E5=9C=B0?= =?UTF-8?q?=E5=9B=BE=E9=85=8D=E7=BD=AEbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/zulip/map-config.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config/zulip/map-config.json b/config/zulip/map-config.json index 78a08a9..76f47d6 100644 --- a/config/zulip/map-config.json +++ b/config/zulip/map-config.json @@ -7,13 +7,15 @@ "mapId": "coding", "mapName": "编程小组", "zulipStream": "Whale Port", - "description": "小组交流区" + "description": "小组交流区", + "interactionObjects": [] }, { "mapId": "whaletown", "mapName": "whaletown 小组", "zulipStream": "Whale Port", - "description": "小组交流区" + "description": "小组交流区", + "interactionObjects": [] }, { "mapId": "whale_port", -- 2.25.1