Merge pull request 'feature/notice-system' (#43) from feature/notice-system into main
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
186
src/business/notice/README.md
Normal file
186
src/business/notice/README.md
Normal 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`: 接收者ID(null表示广播)
|
||||
- `senderId`: 发送者ID
|
||||
- `scheduledAt`: 计划发送时间
|
||||
- `sentAt`: 实际发送时间
|
||||
- `readAt`: 阅读时间
|
||||
- `metadata`: 额外数据(JSON格式)
|
||||
- `createdAt`: 创建时间
|
||||
- `updatedAt`: 更新时间
|
||||
|
||||
## 定时任务
|
||||
|
||||
系统每分钟自动检查并发送到期的定时通知。
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 需要在主模块中导入 `NoticeModule`
|
||||
2. 确保数据库中存在 `notices` 表
|
||||
3. WebSocket连接需要用户认证
|
||||
4. 定时通知依赖 `@nestjs/schedule` 包
|
||||
38
src/business/notice/dto/create-notice.dto.ts
Normal file
38
src/business/notice/dto/create-notice.dto.ts
Normal 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>;
|
||||
}
|
||||
43
src/business/notice/dto/notice-response.dto.ts
Normal file
43
src/business/notice/dto/notice-response.dto.ts
Normal 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;
|
||||
}
|
||||
7
src/business/notice/index.ts
Normal file
7
src/business/notice/index.ts
Normal 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';
|
||||
21
src/business/notice/migrations/create-notices-table.sql
Normal file
21
src/business/notice/migrations/create-notices-table.sql
Normal 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 '接收者用户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='通知表';
|
||||
87
src/business/notice/notice.controller.ts
Normal file
87
src/business/notice/notice.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
src/business/notice/notice.entity.ts
Normal file
64
src/business/notice/notice.entity.ts
Normal 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; // 接收者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<string, any>; // 额外数据
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
117
src/business/notice/notice.gateway.ts
Normal file
117
src/business/notice/notice.gateway.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
20
src/business/notice/notice.module.ts
Normal file
20
src/business/notice/notice.module.ts
Normal 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 {}
|
||||
202
src/business/notice/notice.service.spec.ts
Normal file
202
src/business/notice/notice.service.spec.ts
Normal 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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/business/notice/notice.service.ts
Normal file
145
src/business/notice/notice.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user