feature/notice-system #43

Merged
moyin merged 6 commits from feature/notice-system into main 2026-01-10 21:58:08 +08:00
14 changed files with 1482 additions and 11 deletions

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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`: 接收者IDnull表示广播
- `senderId`: 发送者ID
- `scheduledAt`: 计划发送时间
- `sentAt`: 实际发送时间
- `readAt`: 阅读时间
- `metadata`: 额外数据JSON格式
- `createdAt`: 创建时间
- `updatedAt`: 更新时间
## 定时任务
系统每分钟自动检查并发送到期的定时通知。
## 注意事项
1. 需要在主模块中导入 `NoticeModule`
2. 确保数据库中存在 `notices`
3. WebSocket连接需要用户认证
4. 定时通知依赖 `@nestjs/schedule`

View File

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

View File

@@ -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<string, any> | null;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
}

View File

@@ -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';

View File

@@ -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 '接收者用户IDNULL表示广播',
`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='通知表';

View File

@@ -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<NoticeResponseDto> {
return this.noticeService.create(createNoticeDto);
}
@Get()
@ApiOperation({ summary: '获取通知列表' })
@ApiResponse({ status: 200, description: '获取成功', type: [NoticeResponseDto] })
async findAll(
@CurrentUser() user: any,
@Query('all') all?: string,
): Promise<NoticeResponseDto[]> {
// 如果是管理员且指定了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<NoticeResponseDto> {
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<NoticeResponseDto> {
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<NoticeResponseDto> {
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<NoticeResponseDto> {
return this.noticeService.sendBroadcast(body.title, body.content);
}
}

View File

@@ -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; // 接收者IDnull表示广播通知
@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<string, any>; // 额外数据
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@@ -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<number, Set<AuthenticatedSocket>>();
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());
}
}

View File

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

View File

@@ -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<Notice>;
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>(NoticeService);
repository = module.get<Repository<Notice>>(getRepositoryToken(Notice));
gateway = module.get<NoticeGateway>(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 },
],
});
});
});
});

View File

@@ -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<Notice>,
private readonly noticeGateway: NoticeGateway,
) {}
async create(createNoticeDto: CreateNoticeDto): Promise<Notice> {
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<Notice[]> {
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<Notice> {
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<Notice> {
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<number> {
return this.noticeRepository.count({
where: [
{ userId, status: NoticeStatus.SENT },
{ userId: null, status: NoticeStatus.SENT }, // 广播通知
],
});
}
private async sendNotice(notice: Notice): Promise<void> {
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<void> {
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<Notice> {
return this.create({
title,
content,
type: NoticeType.SYSTEM,
userId,
});
}
// 发送广播通知的便捷方法
async sendBroadcast(title: string, content: string): Promise<Notice> {
return this.create({
title,
content,
type: NoticeType.BROADCAST,
});
}
}

View File

@@ -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 {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket 测试工具 + API监控 - Pixel Game Server</title>
<title>WebSocket 测试工具 + 通知系统 - Pixel Game Server</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@@ -73,6 +82,7 @@ export class WebSocketTestController {
padding: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
min-height: 200px; /* 确保容器有最小高度 */
}
.status {
padding: 10px;
@@ -234,6 +244,7 @@ export class WebSocketTestController {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
min-height: 400px; /* 确保两列布局有最小高度 */
}
.logs-section {
@@ -262,10 +273,27 @@ export class WebSocketTestController {
max-width: calc(100vw - 60px);
}
}
/* 通知模式特定样式 */
#noticeMode {
width: 100%;
min-height: 500px;
}
#noticeMode .container {
background: white;
border: 1px solid #ddd;
min-height: 300px;
}
#noticeMode h2, #noticeMode h3 {
color: #1976d2;
margin-top: 0;
}
</style>
</head>
<body>
<h1>🎮 Pixel Game Server - WebSocket 测试工具 + API监控</h1>
<h1>🎮 Pixel Game Server - WebSocket 测试工具 + 通知系统</h1>
<div class="info-panel">
<h3>📋 使用说明</h3>
@@ -286,7 +314,8 @@ export class WebSocketTestController {
• 💾 本地存储自动保存Token下次访问无需重新获取<br>
• 📧 真实邮箱支持:检测到真实邮箱时使用真实验证码发送<br>
• 📡 **API调用监控**实时显示所有HTTP请求和响应方便调试<br>
• 🔍 详细日志:支持显示请求体、响应体和调用统计
• 🔍 详细日志:支持显示请求体、响应体和调用统计<br>
• 🔔 **通知系统测试**完整的通知功能测试支持实时推送和API调用
</div>
<div style="margin-top: 15px; padding: 10px; background-color: #fff3e0; border-radius: 4px;">
@@ -319,6 +348,17 @@ export class WebSocketTestController {
</div>
</div>
<!-- 功能切换按钮 -->
<div class="container" style="text-align: center; margin-bottom: 20px;">
<h2>🎛️ 功能选择</h2>
<div style="display: flex; gap: 10px; justify-content: center;">
<button id="chatModeBtn" onclick="switchMode('chat')" style="flex: 1; max-width: 200px; background-color: #1976d2;">💬 聊天测试</button>
<button id="noticeModeBtn" onclick="switchMode('notice')" style="flex: 1; max-width: 200px; background-color: #666;">🔔 通知测试</button>
</div>
</div>
<!-- 聊天模式 -->
<div id="chatMode">
<div class="two-column">
<div class="container">
<h2>🔌 连接控制</h2>
@@ -458,18 +498,110 @@ export class WebSocketTestController {
</div>
</div>
</div>
</div>
<!-- 通知模式 -->
<div id="noticeMode" style="display: none; background-color: #f0f8ff; border: 2px solid red; padding: 20px;">
<h1 style="color: red; font-size: 24px;">🔔 通知测试模式</h1>
<p style="color: blue; font-size: 18px;">如果你能看到这个文字,说明通知模式正常显示了!</p>
<div class="two-column">
<div class="container">
<h2>🔔 通知系统控制</h2>
<div id="noticeConnectionStatus" class="status disconnected">未连接</div>
<div class="form-group">
<label for="noticeWsUrl">通知WebSocket地址:</label>
<input type="text" id="noticeWsUrl" value="ws://localhost:3000/ws/notice" />
</div>
<button id="noticeConnectBtn" onclick="toggleNoticeConnection()">连接通知系统</button>
<h3>🔐 用户认证</h3>
<div class="form-group">
<label for="noticeUserId">用户ID:</label>
<input type="number" id="noticeUserId" value="1" placeholder="输入用户ID" />
</div>
<div class="form-group">
<label for="noticeJwtToken">JWT Token:</label>
<textarea id="noticeJwtToken" rows="3" placeholder="输入JWT Token (可从聊天模式复制)"></textarea>
<button onclick="copyTokenFromChat()" style="margin-top: 5px; background-color: #4caf50;">从聊天模式复制Token</button>
</div>
<button onclick="authenticateNotice()">认证通知连接</button>
</div>
<div class="container">
<h2>📢 发送通知</h2>
<div class="form-group">
<label for="noticeTitle">通知标题:</label>
<input type="text" id="noticeTitle" placeholder="输入通知标题" />
</div>
<div class="form-group">
<label for="noticeContent">通知内容:</label>
<textarea id="noticeContent" rows="3" placeholder="输入通知内容"></textarea>
</div>
<div class="form-group">
<label for="noticeType">通知类型:</label>
<select id="noticeType">
<option value="system">系统通知</option>
<option value="user">用户通知</option>
<option value="broadcast">广播通知</option>
</select>
</div>
<div class="form-group">
<label for="targetUserId">目标用户ID (留空为广播):</label>
<input type="number" id="targetUserId" placeholder="目标用户ID" />
</div>
<div class="form-group">
<label for="scheduledTime">定时发送 (可选):</label>
<input type="datetime-local" id="scheduledTime" />
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<button onclick="sendNoticeViaAPI()" style="background-color: #4caf50;"><3E> 过通过API发送</button>
<button onclick="sendNoticeViaWS()" style="background-color: #ff9800;">🔌 通过WebSocket发送</button>
</div>
<div class="quick-actions" style="margin-top: 15px;">
<div class="quick-action" onclick="sendQuickNotice('系统维护', '系统将在今晚进行维护')">快速: 系统维护</div>
<div class="quick-action" onclick="sendQuickNotice('新功能上线', '我们上线了新的通知功能!')">快速: 新功能</div>
<div class="quick-action" onclick="sendQuickNotice('测试通知', '这是一条测试通知')">快速: 测试通知</div>
<div class="quick-action" onclick="loadNoticeList()"><3E> 刷取新通知列表</div>
<div class="quick-action" onclick="getUnreadCount()"><3E> 获取未读数日</div>
<div class="quick-action" onclick="clearNoticeLog()">🗑️ 清空通知日志</div>
</div>
<h3>📋 通知管理</h3>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<button onclick="loadNoticeList()" style="flex: 1;">获取通知列表</button>
<button onclick="getUnreadCount()" style="flex: 1; background-color: #ff9800;">获取未读数量</button>
</div>
<div id="noticeList" style="max-height: 200px; overflow-y: auto; border: 1px solid #ddd; padding: 10px; background-color: #fafafa;">
<div style="text-align: center; color: #666;">点击"获取通知列表"加载通知</div>
</div>
</div>
</div>
</div>
<!-- 日志区域 - 消息日志和API监控并排显示 -->
<div class="logs-section">
<div class="container">
<h2>📋 消息日志</h2>
<h2 id="logTitle">📋 消息日志</h2>
<div id="messageLog" class="message-log"></div>
</div>
<div class="container">
<h2>📡 API 调用日志</h2>
<h2 id="apiLogTitle">📡 API 调用日志</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<span style="font-size: 14px; color: #666;">实时监控前端API调用</span>
<span style="font-size: 14px; color: #666;" id="apiLogSubtitle">实时监控前端API调用</span>
<div>
<button onclick="clearApiLog()" style="padding: 4px 8px; font-size: 12px; background-color: #ff9800;">清空日志</button>
<button onclick="toggleApiLogDetails()" id="toggleDetailsBtn" style="padding: 4px 8px; font-size: 12px; background-color: #9c27b0; margin-left: 5px;">显示详情</button>
@@ -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 = '<div style="text-align: center; color: #666;">暂无通知</div>';
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 = \`
<div style="font-weight: bold; margin-bottom: 4px;">\${notice.title}</div>
<div style="margin-bottom: 4px;">\${notice.content}</div>
<div style="font-size: 12px; color: #666;">
类型: \${notice.type} |
状态: <span style="color: \${statusColor};">\${statusText}</span> |
时间: \${new Date(notice.createdAt).toLocaleString()}
\${notice.status !== 'read' ?
\`<button onclick="markNoticeAsRead(\${notice.id})" style="margin-left: 10px; padding: 2px 6px; font-size: 11px; background-color: #4caf50; color: white; border: none; border-radius: 2px; cursor: pointer;">标记已读</button>\` :
''
}
</div>
\`;
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', '🗑️ 通知日志已清空');
}
}
</script>
</body>
</html>