forked from datawhale/whale-town-end
feat:实现通知系统核心功能
- 添加通知实体和数据传输对象 - 实现通知服务层逻辑,支持创建、查询、标记已读 - 添加通知REST API控制器 - 实现WebSocket网关,支持实时通知推送 - 支持系统通知、用户通知、广播通知三种类型 - 支持定时通知功能,每分钟自动检查待发送通知 - 添加通知模块导出
This commit is contained in:
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';
|
||||
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 {}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user