feat: add NotificationsService with SSE subject pool
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.1.0",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +29,9 @@ import {
|
|||||||
DingAttendanceRaw,
|
DingAttendanceRaw,
|
||||||
SyncLog,
|
SyncLog,
|
||||||
SyncState,
|
SyncState,
|
||||||
} from './entities';
|
ExpenseType,
|
||||||
|
Notification,
|
||||||
|
} from './entities';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { RbacModule } from './rbac/rbac.module';
|
import { RbacModule } from './rbac/rbac.module';
|
||||||
import { StudentsModule } from './students/students.module';
|
import { StudentsModule } from './students/students.module';
|
||||||
@@ -48,6 +51,8 @@ import { AttendanceModule } from './attendance/attendance.module';
|
|||||||
import { SchedulesModule } from './schedules/schedules.module';
|
import { SchedulesModule } from './schedules/schedules.module';
|
||||||
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
|
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
|
||||||
import { SyncModule } from './sync/sync.module';
|
import { SyncModule } from './sync/sync.module';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
|
import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -58,6 +63,7 @@ import { SyncModule } from './sync/sync.module';
|
|||||||
limit: 100, // 普通接口每分钟100次
|
limit: 100, // 普通接口每分钟100次
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
@@ -88,7 +94,9 @@ import { SyncModule } from './sync/sync.module';
|
|||||||
DingAttendanceRaw,
|
DingAttendanceRaw,
|
||||||
SyncLog,
|
SyncLog,
|
||||||
SyncState,
|
SyncState,
|
||||||
];
|
ExpenseType,
|
||||||
|
Notification,
|
||||||
|
];
|
||||||
if (dbType === 'mysql') {
|
if (dbType === 'mysql') {
|
||||||
return {
|
return {
|
||||||
type: 'mysql' as const,
|
type: 'mysql' as const,
|
||||||
@@ -127,6 +135,8 @@ import { SyncModule } from './sync/sync.module';
|
|||||||
SchedulesModule,
|
SchedulesModule,
|
||||||
ClassroomRentalsModule,
|
ClassroomRentalsModule,
|
||||||
SyncModule,
|
SyncModule,
|
||||||
|
ExpenseTypesModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
|||||||
33
apps/server/src/notifications/dto/notification.dto.ts
Normal file
33
apps/server/src/notifications/dto/notification.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateNotificationDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
recipientIds: number[];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
type: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
content?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
after?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
11
apps/server/src/notifications/notifications.module.ts
Normal file
11
apps/server/src/notifications/notifications.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Notification } from '../entities/notification.entity';
|
||||||
|
import { NotificationsService } from './notifications.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Notification])],
|
||||||
|
providers: [NotificationsService],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
90
apps/server/src/notifications/notifications.service.ts
Normal file
90
apps/server/src/notifications/notifications.service.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Subject, Observable } from 'rxjs';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
|
import { Notification } from '../entities/notification.entity';
|
||||||
|
import { CreateNotificationDto } from './dto/notification.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationsService {
|
||||||
|
private subjects = new Map<number, Subject<Notification>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Notification)
|
||||||
|
private repo: Repository<Notification>,
|
||||||
|
private eventEmitter: EventEmitter2,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: CreateNotificationDto): Promise<Notification[]> {
|
||||||
|
const notifications = dto.recipientIds.map((recipientId) => ({
|
||||||
|
recipientId,
|
||||||
|
type: dto.type,
|
||||||
|
title: dto.title,
|
||||||
|
content: dto.content ?? '',
|
||||||
|
link: dto.link,
|
||||||
|
}));
|
||||||
|
const saved = await this.repo.save(notifications);
|
||||||
|
|
||||||
|
// Push SSE + emit event
|
||||||
|
for (const n of saved) {
|
||||||
|
this.subjects.get(n.recipientId)?.next(n);
|
||||||
|
this.eventEmitter.emit('notification.created', n);
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUser(
|
||||||
|
userId: number,
|
||||||
|
after?: number,
|
||||||
|
limit: number = 20,
|
||||||
|
): Promise<Notification[]> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('n')
|
||||||
|
.where('n.recipientId = :userId', { userId })
|
||||||
|
.orderBy('n.createdAt', 'DESC')
|
||||||
|
.take(limit);
|
||||||
|
|
||||||
|
if (after) {
|
||||||
|
qb.andWhere('n.id < :after', { after });
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUnreadCount(userId: number): Promise<number> {
|
||||||
|
return this.repo.count({
|
||||||
|
where: { recipientId: userId, isRead: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRead(id: number, userId: number): Promise<void> {
|
||||||
|
await this.repo.update(
|
||||||
|
{ id, recipientId: userId },
|
||||||
|
{ isRead: true, readAt: new Date() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markAllRead(userId: number): Promise<void> {
|
||||||
|
await this.repo.update(
|
||||||
|
{ recipientId: userId, isRead: false },
|
||||||
|
{ isRead: true, readAt: new Date() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(userId: number): Observable<Notification> {
|
||||||
|
if (!this.subjects.has(userId)) {
|
||||||
|
this.subjects.set(userId, new Subject<Notification>());
|
||||||
|
}
|
||||||
|
return this.subjects.get(userId)!.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe(userId: number): void {
|
||||||
|
const subj = this.subjects.get(userId);
|
||||||
|
if (subj) {
|
||||||
|
subj.complete();
|
||||||
|
this.subjects.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
package-lock.json
generated
20
package-lock.json
generated
@@ -51,6 +51,7 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.4",
|
"@nestjs/config": "^4.0.4",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.1.0",
|
||||||
"@nestjs/jwt": "^11.0.2",
|
"@nestjs/jwt": "^11.0.2",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.1.19",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
@@ -2981,6 +2982,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/event-emitter": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@nestjs/event-emitter/-/event-emitter-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-DOY/4XBGyIjYyOJKkO6jl1kzFE0ZfX0wV+M2HR5NWymPT9Z0zdCEcZGxTXXkoMRwPtglnvCGJALSjOpXPIcM3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter2": "6.4.9"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^10.0.0 || ^11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/jwt": {
|
"node_modules/@nestjs/jwt": {
|
||||||
"version": "11.0.2",
|
"version": "11.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@nestjs/jwt/-/jwt-11.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@nestjs/jwt/-/jwt-11.0.2.tgz",
|
||||||
@@ -8861,6 +8875,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter2": {
|
||||||
|
"version": "6.4.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/eventemitter2/-/eventemitter2-6.4.9.tgz",
|
||||||
|
"integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user