diff --git a/apps/server/package.json b/apps/server/package.json index a204458..8099c4b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -25,6 +25,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.19", diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 4ddefe4..9d0043a 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { @@ -28,7 +29,9 @@ import { DingAttendanceRaw, SyncLog, SyncState, -} from './entities'; + ExpenseType, + Notification, + } from './entities'; import { AuthModule } from './auth/auth.module'; import { RbacModule } from './rbac/rbac.module'; import { StudentsModule } from './students/students.module'; @@ -48,6 +51,8 @@ import { AttendanceModule } from './attendance/attendance.module'; import { SchedulesModule } from './schedules/schedules.module'; import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module'; import { SyncModule } from './sync/sync.module'; +import { NotificationsModule } from './notifications/notifications.module'; +import { ExpenseTypesModule } from './expense-types/expense-types.module'; @Module({ imports: [ @@ -58,6 +63,7 @@ import { SyncModule } from './sync/sync.module'; limit: 100, // 普通接口每分钟100次 }, ]), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -88,7 +94,9 @@ import { SyncModule } from './sync/sync.module'; DingAttendanceRaw, SyncLog, SyncState, - ]; + ExpenseType, + Notification, + ]; if (dbType === 'mysql') { return { type: 'mysql' as const, @@ -127,6 +135,8 @@ import { SyncModule } from './sync/sync.module'; SchedulesModule, ClassroomRentalsModule, SyncModule, + ExpenseTypesModule, + NotificationsModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard }, diff --git a/apps/server/src/notifications/dto/notification.dto.ts b/apps/server/src/notifications/dto/notification.dto.ts new file mode 100644 index 0000000..66dcaaf --- /dev/null +++ b/apps/server/src/notifications/dto/notification.dto.ts @@ -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; +} diff --git a/apps/server/src/notifications/notifications.module.ts b/apps/server/src/notifications/notifications.module.ts new file mode 100644 index 0000000..4e3ccb8 --- /dev/null +++ b/apps/server/src/notifications/notifications.module.ts @@ -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 {} diff --git a/apps/server/src/notifications/notifications.service.ts b/apps/server/src/notifications/notifications.service.ts new file mode 100644 index 0000000..4191459 --- /dev/null +++ b/apps/server/src/notifications/notifications.service.ts @@ -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>(); + + constructor( + @InjectRepository(Notification) + private repo: Repository, + private eventEmitter: EventEmitter2, + ) {} + + async create(dto: CreateNotificationDto): Promise { + 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 { + 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 { + return this.repo.count({ + where: { recipientId: userId, isRead: false }, + }); + } + + async markRead(id: number, userId: number): Promise { + await this.repo.update( + { id, recipientId: userId }, + { isRead: true, readAt: new Date() }, + ); + } + + async markAllRead(userId: number): Promise { + await this.repo.update( + { recipientId: userId, isRead: false }, + { isRead: true, readAt: new Date() }, + ); + } + + subscribe(userId: number): Observable { + if (!this.subjects.has(userId)) { + this.subjects.set(userId, new Subject()); + } + return this.subjects.get(userId)!.asObservable(); + } + + unsubscribe(userId: number): void { + const subj = this.subjects.get(userId); + if (subj) { + subj.complete(); + this.subjects.delete(userId); + } + } +} diff --git a/package-lock.json b/package-lock.json index f0d8509..d3efd66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.4", "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@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": { "version": "11.0.2", "resolved": "https://registry.npmmirror.com/@nestjs/jwt/-/jwt-11.0.2.tgz", @@ -8861,6 +8875,12 @@ "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": { "version": "3.3.0", "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz",