# 站内信通知中心 — 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 为系统全部角色提供站内信通知中心,支持 SSE 实时推送 + 轮询兜底,预留钉钉/企微外发扩展点。 **Architecture:** 新增 `notifications` 表 + `NotificationsModule`,NestJS `@Sse()` 实现 SSE 推送,前端 `EventSource` + `useNotifications` hook,Header 铃铛 Badge + Popover + 全屏通知页。 **Tech Stack:** NestJS 11, TypeORM 0.3, RxJS, React 19, Ant Design 6, EventSource API ## Global Constraints - 表名使用复数形式 `notifications` - Entity 使用 `@Entity('notifications')` + `@Column({ name: 'snake_case' })` 模式 - 所有 entity 在 `apps/server/src/entities/index.ts` 注册导出 - Module 必须 `imports: [TypeOrmModule.forFeature([Notification])]` - Controller 所有方法 `@UseGuards(JwtAuthGuard)` - DTO 使用 class-validator 装饰器 - 前端 axios 实例从 `api/` 导入 - 前端新增路由在 `App.tsx` 注册 - SSE 端点需支持从 query string 提取 JWT token(EventSource 不支持自定义 header) --- ### Task 1: Notification Entity **Files:** - Create: `apps/server/src/entities/notification.entity.ts` - Modify: `apps/server/src/entities/index.ts` **Interfaces:** - Produces: `Notification` entity class — exports for TypeORM `@Entity('notifications')` - [ ] **Step 1: 创建 notification.entity.ts** ```typescript // apps/server/src/entities/notification.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, } from 'typeorm'; import { User } from './user.entity'; export enum NotificationType { BILL_GENERATED = 'bill_generated', BILL_PAID = 'bill_paid', CHECK_IN = 'check_in', CHECK_OUT = 'check_out', DEPOSIT_DUE = 'deposit_due', DEPOSIT_REFUNDED = 'deposit_refunded', CLASS_CHANGE = 'class_change', SCHEDULE_CONFLICT = 'schedule_conflict', ANNOUNCEMENT = 'announcement', } @Entity('notifications') export class Notification { @PrimaryGeneratedColumn() id: number; @Column({ name: 'recipient_id', type: 'integer' }) recipientId: number; @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'recipient_id' }) recipient: User; @Column({ name: 'type', length: 30 }) type: string; @Column({ name: 'title', length: 200 }) title: string; @Column({ name: 'content', type: 'text', nullable: true }) content: string; @Column({ name: 'link', length: 500, nullable: true }) link: string; @Column({ name: 'is_read', type: 'boolean', default: false }) isRead: boolean; @Column({ name: 'read_at', type: 'datetime', nullable: true }) readAt: Date; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; } ``` - [ ] **Step 2: 在 entities/index.ts 中注册导出** 在 `apps/server/src/entities/index.ts` 末尾添加: ```typescript export { Notification, NotificationType } from './notification.entity'; ``` - [ ] **Step 3: 验证 — 启动后端检查 TypeORM 自动建表** ```bash cd apps/server && npm run start:dev ``` Expected: 启动成功,`notifications` 表自动创建。 - [ ] **Step 4: Commit** ```bash git add apps/server/src/entities/notification.entity.ts apps/server/src/entities/index.ts git commit -m "feat: add Notification entity" ``` --- ### Task 2: Notifications Module — DTO + Service **Files:** - Create: `apps/server/src/notifications/dto/notification.dto.ts` - Create: `apps/server/src/notifications/notifications.service.ts` - Create: `apps/server/src/notifications/notifications.module.ts` **Interfaces:** - Consumes: `Notification` entity from Task 1 - Produces: `NotificationsService` with methods: `create`, `findByUser`, `getUnreadCount`, `markRead`, `markAllRead`, `subscribe` - [ ] **Step 1: 创建 DTO** ```typescript // apps/server/src/notifications/dto/notification.dto.ts import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt, IsEnum } from 'class-validator'; import { NotificationType } from '../../entities/notification.entity'; 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; } ``` - [ ] **Step 2: 创建 Service** ```typescript // apps/server/src/notifications/notifications.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import { filter } from 'rxjs/operators'; 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) => this.repo.create({ recipientId, type: dto.type, title: dto.title, content: dto.content ?? '', link: dto.link ?? null, }), ); const saved = await this.repo.save(notifications); // 推送 SSE + emit 事件 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); } } } ``` - [ ] **Step 3: 创建 Module** ```typescript // apps/server/src/notifications/notifications.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Notification } from '../entities/notification.entity'; import { NotificationsService } from './notifications.service'; import { NotificationsController } from './notifications.controller'; @Module({ imports: [TypeOrmModule.forFeature([Notification])], controllers: [NotificationsController], providers: [NotificationsService], exports: [NotificationsService], }) export class NotificationsModule {} ``` - [ ] **Step 4: Commit** ```bash git add apps/server/src/notifications/ git commit -m "feat: add NotificationsService with SSE subject pool" ``` --- ### Task 3: Notifications Controller + SSE Endpoint **Files:** - Create: `apps/server/src/notifications/notifications.controller.ts` - Modify: `apps/server/src/app.module.ts` — 注册 NotificationsModule **Interfaces:** - Consumes: `NotificationsService` from Task 2 - Produces: REST API + SSE stream endpoint - [ ] **Step 1: 创建 Controller** ```typescript // apps/server/src/notifications/notifications.controller.ts import { Controller, Get, Put, Param, Query, Req, Sse, UseGuards, } from '@nestjs/common'; import { Request } from 'express'; import { Observable, map } from 'rxjs'; import { NotificationsService } from './notifications.service'; import { NotificationQueryDto } from './dto/notification.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @UseGuards(JwtAuthGuard) @Controller('notifications') export class NotificationsController { constructor(private readonly service: NotificationsService) {} @Get() async findAll(@Req() req: any, @Query() query: NotificationQueryDto) { const userId = req.user.id; return this.service.findByUser(userId, query.after, query.limit ?? 20); } @Get('unread-count') async unreadCount(@Req() req: any) { const userId = req.user.id; const count = await this.service.getUnreadCount(userId); return { count }; } @Sse('stream') stream(@Req() req: any): Observable { const userId = req.user.id; return this.service.subscribe(userId).pipe( map((notification) => ({ data: JSON.stringify({ id: notification.id, type: notification.type, title: notification.title, content: notification.content, link: notification.link, createdAt: notification.createdAt, }), } as MessageEvent)), ); } @Put(':id/read') async markRead(@Param('id') id: string, @Req() req: any) { await this.service.markRead(+id, req.user.id); return { success: true }; } @Put('read-all') async markAllRead(@Req() req: any) { await this.service.markAllRead(req.user.id); return { success: true }; } } ``` - [ ] **Step 2: 在 AppModule 中注册 NotificationsModule** 在 `apps/server/src/app.module.ts` 中: 1. 在 imports 数组中添加 `NotificationsModule,` 2. 在 entities 数组中添加 `Notification,` ```typescript // 在 TypeOrmModule.forRootAsync 的 allEntities 数组中添加: Notification, // 在 @Module imports 中添加: NotificationsModule, ``` - [ ] **Step 3: 验证 — 启动后端测试 API** ```bash cd apps/server && npm run start:dev ``` Expected: 启动成功。用 curl 测试(先登录获取 token): ```bash # 获取未读数 curl -H "Authorization: Bearer " http://localhost:3000/api/notifications/unread-count # Expected: {"count":0} ``` - [ ] **Step 4: Commit** ```bash git add apps/server/src/notifications/notifications.controller.ts apps/server/src/app.module.ts git commit -m "feat: add NotificationsController with SSE stream endpoint" ``` --- ### Task 4: JWT SSE 认证适配 **Files:** - Modify: `apps/server/src/auth/guards/jwt-auth.guard.ts` **Interfaces:** - Modifies: `JwtAuthGuard` — SSE 请求从 query string 提取 token 作为 fallback - [ ] **Step 1: 修改 JwtAuthGuard 支持 query token** ```typescript // apps/server/src/auth/guards/jwt-auth.guard.ts // 在已有的 JwtAuthGuard 类中,重写 getRequest 或在 canActivate 前增加 extractor // 方案:创建自定义 guard 扩展 ``` 实际上 `passport-jwt` 的 `ExtractJwt.fromAuthHeaderAsBearerToken()` 不支持 query。需要在 strategy 层面处理。 修改 `apps/server/src/auth/strategies/jwt.strategy.ts`: ```typescript // apps/server/src/auth/strategies/jwt.strategy.ts import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; import { Request } from 'express'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor(config: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromExtractors([ // 1. 标准 Bearer header ExtractJwt.fromAuthHeaderAsBearerToken(), // 2. SSE 场景:query string ?token= (req: Request) => { const token = req?.query?.token; if (typeof token === 'string' && token.length > 0) { return token; } return null; }, ]), ``` 保留 `ignoreExpiration` 和 `secretOrKey` 不变。 - [ ] **Step 2: 验证 — SSE 端点认证** ```bash cd apps/server && npm run start:dev # 用浏览器或 curl 测试 SSE: curl -N "http://localhost:3000/api/notifications/stream?token=" # Expected: 连接保持,无错误 ``` - [ ] **Step 3: Commit** ```bash git add apps/server/src/auth/strategies/jwt.strategy.ts git commit -m "feat: support JWT from query string for SSE endpoints" ``` --- ### Task 5: 事件监听 + 钉钉/企微外发(预留) **Files:** - Modify: `apps/server/src/notifications/notifications.module.ts` — 注册 EventEmitter **Interfaces:** - Produces: `notification.created` 事件发射,供钉钉/企微模块异步监听 - [ ] **Step 1: 安装依赖** ```bash cd apps/server && npm install @nestjs/event-emitter ``` - [ ] **Step 2: 注册 EventEmitterModule** 确保 `apps/server/src/app.module.ts` 中已引入 `EventEmitterModule.forRoot()`。检查是否已存在: ```bash grep -r "EventEmitter" apps/server/src/app.module.ts ``` 如果不存在,添加: ```typescript import { EventEmitterModule } from '@nestjs/event-emitter'; // 在 @Module imports 中添加: EventEmitterModule.forRoot(), ``` - [ ] **Step 3: 验证 — 事件发射不报错** Service 中的 `this.eventEmitter.emit('notification.created', n)` 应在 EventEmitter 注册后正常工作。启动后端确认无报错。 - [ ] **Step 4: Commit** ``` --- ### Task 6: 前端 — useNotifications Hook + NotificationBell 组件 **Files:** - Create: `apps/admin/src/hooks/useNotifications.ts` - Create: `apps/admin/src/components/NotificationBell.tsx` **Interfaces:** - Consumes: `/api/notifications/unread-count` and `/api/notifications/stream` - Produces: `useNotifications` hook, `NotificationBell` component - [ ] **Step 1: 创建 useNotifications hook** ```typescript // apps/admin/src/hooks/useNotifications.ts import { useState, useEffect, useCallback } from 'react'; import api from '../api'; interface Notification { id: number; type: string; title: string; content: string; link: string | null; createdAt: string; } export function useNotifications() { const [unreadCount, setUnreadCount] = useState(0); const [latestNotification, setLatestNotification] = useState(null); const fetchUnreadCount = useCallback(async () => { try { const data = await api.get('/notifications/unread-count') as unknown as { count: number }; setUnreadCount(data.count); } catch { // 静默失败 } }, []); useEffect(() => { // 初始加载 fetchUnreadCount(); const token = localStorage.getItem('token'); if (!token) return; // SSE 连接 const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); es.onmessage = (event) => { try { const notification = JSON.parse(event.data) as Notification; setUnreadCount((c) => c + 1); setLatestNotification(notification); } catch { // 解析失败忽略 } }; es.onerror = () => { // SSE 断开 → 切换到轮询兜底 es.close(); const interval = setInterval(() => { fetchUnreadCount(); }, 60_000); return () => clearInterval(interval); }; return () => { es.close(); }; }, [fetchUnreadCount]); const markAsRead = useCallback(async (id: number) => { try { await api.put(`/notifications/${id}/read`); setUnreadCount((c) => Math.max(0, c - 1)); } catch { // 静默失败 } }, []); const markAllAsRead = useCallback(async () => { try { await api.put('/notifications/read-all'); setUnreadCount(0); } catch { // 静默失败 } }, []); return { unreadCount, latestNotification, markAsRead, markAllAsRead }; } ``` - [ ] **Step 2: 创建 NotificationBell 组件** ```tsx // apps/admin/src/components/NotificationBell.tsx import React, { useState, useEffect } from 'react'; import { Badge, Popover, Button, List, Typography, Empty, Space } from 'antd'; import { BellOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import api from '../api'; interface NotificationItem { id: number; type: string; title: string; content: string; link: string | null; isRead: boolean; createdAt: string; } const typeLabels: Record = { bill_generated: '账单', bill_paid: '账单', check_in: '入住', check_out: '退宿', deposit_due: '押金', deposit_refunded: '押金', class_change: '班级', schedule_conflict: '排课', announcement: '公告', }; function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return '刚刚'; if (mins < 60) return `${mins}分钟前`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}小时前`; const days = Math.floor(hours / 24); return `${days}天前`; } const NotificationBell: React.FC = () => { const [unreadCount, setUnreadCount] = useState(0); const [notifications, setNotifications] = useState([]); const [open, setOpen] = useState(false); const navigate = useNavigate(); const fetchNotifications = async () => { try { const data = await api.get('/notifications?limit=20') as unknown as NotificationItem[]; setNotifications(data); } catch { /* ignore */ } }; const fetchUnread = async () => { try { const data = await api.get('/notifications/unread-count') as unknown as { count: number }; setUnreadCount(data.count); } catch { /* ignore */ } }; useEffect(() => { fetchUnread(); // SSE const token = localStorage.getItem('token'); if (!token) return; const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`); es.onmessage = (event) => { try { JSON.parse(event.data); setUnreadCount((c) => c + 1); if (open) fetchNotifications(); } catch { /* ignore */ } }; es.onerror = () => { es.close(); const interval = setInterval(fetchUnread, 60_000); return () => clearInterval(interval); }; return () => es.close(); }, [open]); const handleOpen = (visible: boolean) => { setOpen(visible); if (visible) fetchNotifications(); }; const handleClick = async (item: NotificationItem) => { if (!item.isRead) { try { await api.put(`/notifications/${item.id}/read`); setUnreadCount((c) => Math.max(0, c - 1)); } catch { /* ignore */ } } setOpen(false); if (item.link) navigate(item.link); }; const handleMarkAll = async () => { try { await api.put('/notifications/read-all'); setUnreadCount(0); setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })), ); } catch { /* ignore */ } }; const content = (
通知中心
{notifications.length === 0 ? (
) : ( ( handleClick(item)} style={{ padding: '12px 16px', cursor: 'pointer', backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', }} > ) } title={ [{typeLabels[item.type] || item.type}] {item.title} } description={ {timeAgo(item.createdAt)} } /> )} /> )}
); return ( ); }; export default NotificationBell; ``` - [ ] **Step 3: Commit** ```bash git add apps/admin/src/hooks/useNotifications.ts apps/admin/src/components/NotificationBell.tsx git commit -m "feat: add useNotifications hook and NotificationBell component" ``` --- ### Task 7: 前端 — MainLayout 集成铃铛 **Files:** - Modify: `apps/admin/src/layouts/MainLayout.tsx` - [ ] **Step 1: 在 Header 中添加 NotificationBell** 在 `MainLayout.tsx` 的 Header 右侧区域(用户头像/下拉菜单旁边)添加 `NotificationBell`: ```tsx // apps/admin/src/layouts/MainLayout.tsx // 在 imports 中添加: import NotificationBell from '../components/NotificationBell'; // 在 Header 右侧区域(通常在用户 Dropdown 之前): ``` 具体定位:找到 Header 中 `Dropdown` / `Avatar` 相关的 JSX,在其前面插入 ``。 - [ ] **Step 2: Commit** ```bash git add apps/admin/src/layouts/MainLayout.tsx git commit -m "feat: integrate NotificationBell into MainLayout header" ``` --- ### Task 8: 前端 — 全屏通知页 **Files:** - Create: `apps/admin/src/pages/Notifications/index.tsx` - Modify: `apps/admin/src/App.tsx` — 注册路由 - [ ] **Step 1: 创建 Notifications 页面** ```tsx // apps/admin/src/pages/Notifications/index.tsx import React, { useState, useEffect } from 'react'; import { List, Typography, Menu, Layout, Button, Empty, Spin } from 'antd'; import { BellOutlined, DollarOutlined, HomeOutlined, TeamOutlined, SettingOutlined, } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import api from '../../api'; const { Sider, Content } = Layout; interface NotificationItem { id: number; type: string; title: string; content: string; link: string | null; isRead: boolean; createdAt: string; } const typeMap: Record = { bill_generated: { label: '账单', icon: }, bill_paid: { label: '账单', icon: }, check_in: { label: '入住', icon: }, check_out: { label: '退宿', icon: }, deposit_due: { label: '押金', icon: }, deposit_refunded: { label: '押金', icon: }, class_change: { label: '班级', icon: }, schedule_conflict: { label: '排课', icon: }, announcement: { label: '公告', icon: }, }; function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const mins = Math.floor(diff / 60000); if (mins < 1) return '刚刚'; if (mins < 60) return `${mins}分钟前`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}小时前`; const days = Math.floor(hours / 24); if (days < 7) return `${days}天前`; return new Date(dateStr).toLocaleDateString('zh-CN'); } const NotificationsPage: React.FC = () => { const [notifications, setNotifications] = useState([]); const [filter, setFilter] = useState('all'); const [loading, setLoading] = useState(false); const navigate = useNavigate(); const fetchData = async () => { setLoading(true); try { const data = await api.get('/notifications?limit=50') as unknown as NotificationItem[]; setNotifications(data); } catch { /* ignore */ } setLoading(false); }; useEffect(() => { fetchData(); }, []); const handleClick = async (item: NotificationItem) => { if (!item.isRead) { try { await api.put(`/notifications/${item.id}/read`); setNotifications((prev) => prev.map((n) => (n.id === item.id ? { ...n, isRead: true } : n)), ); } catch { /* ignore */ } } if (item.link) navigate(item.link); }; const handleMarkAll = async () => { try { await api.put('/notifications/read-all'); setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })), ); } catch { /* ignore */ } }; const filtered = filter === 'all' ? notifications : notifications.filter((n) => n.type === filter); return ( setFilter(key)} items={[ { key: 'all', icon: , label: '全部' }, { key: 'bill_generated', icon: , label: '账单' }, { key: 'check_in', icon: , label: '入住' }, { key: 'class_change', icon: , label: '班级' }, { key: 'announcement', icon: , label: '公告' }, ]} />
通知中心
{filtered.length === 0 ? ( ) : ( { const meta = typeMap[item.type] || { label: item.type, icon: }; return ( handleClick(item)} style={{ cursor: 'pointer', padding: '16px 0', backgroundColor: item.isRead ? 'transparent' : '#f0f7ff', }} > {meta.icon} } title={ {item.title} {timeAgo(item.createdAt)} } description={ item.content && ( {item.content} ) } /> ); }} /> )}
); }; export default NotificationsPage; ``` - [ ] **Step 2: 在 App.tsx 注册路由** 在 `apps/admin/src/App.tsx` 中添加 import 和路由: ```tsx import NotificationsPage from './pages/Notifications'; // 在 Routes 内添加: }> } /> ``` - [ ] **Step 3: Commit** ```bash git add apps/admin/src/pages/Notifications/index.tsx apps/admin/src/App.tsx git commit -m "feat: add Notifications full page with sidebar filter" ``` --- ### Task 9: 业务模块集成 — 通知创建点 **Files:** - Modify: `apps/server/src/bills/bills.controller.ts` - Modify: `apps/server/src/occupancies/occupancies.controller.ts` - Modify: `apps/server/src/deposits/deposits.controller.ts` - Modify: `apps/server/src/classes/classes.controller.ts` - Modify: `apps/server/src/schedules/schedules.controller.ts` - 各 module 文件注入 NotificationsModule **Interfaces:** - Consumes: `NotificationsService.create()` from Task 2 - [ ] **Step 1: 各 Module 注入 NotificationsModule** 在每个需要发送通知的 module 的 `imports` 中添加 `NotificationsModule`: ```typescript // bills.module.ts, occupancies.module.ts, deposits.module.ts, classes.module.ts, schedules.module.ts import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ TypeOrmModule.forFeature([...]), NotificationsModule, // 新增 ], }) ``` - [ ] **Step 2: 账单模块 — 生成/状态变更通知** ```typescript // bills.controller.ts import { NotificationsService } from '../notifications/notifications.service'; // constructor 注入: constructor( private notificationsService: NotificationsService, ) {} // POST /bills/generate 方法末尾: const notificationInfo = await this.billsService.getNotificationInfo(result); await this.notificationsService.create({ recipientIds: notificationInfo.studentUserIds, type: 'bill_generated', title: '账单已生成', content: `您的 ${notificationInfo.periodLabel} 账单已生成,总额 ¥${notificationInfo.totalAmount}`, link: `/bills/${result.id}`, }); // PUT /bills/:id/status (确认/已付) 末尾: await this.notificationsService.create({ recipientIds: notificationInfo.studentUserIds, type: 'bill_paid', title: '账单状态更新', content: `您的账单已被标记为${newStatus === 'confirmed' ? '已确认' : '已支付'}`, link: `/bills/${id}`, }); ``` - [ ] **Step 3: 入住模块 — 入住/退宿通知** ```typescript // occupancies.controller.ts // POST check-in: await this.notificationsService.create({ recipientIds: [studentUserId], type: 'check_in', title: '入住登记', content: `您已成功入住 ${roomLabel}`, link: `/occupancies`, }); // PUT check-out: await this.notificationsService.create({ recipientIds: [studentUserId], type: 'check_out', title: '退宿确认', content: `您已从 ${roomLabel} 退宿`, link: `/occupancies`, }); ``` - [ ] **Step 4: 押金模块 — 催缴/退还通知** ```typescript // deposits.controller.ts // 收取押金: await this.notificationsService.create({ recipientIds: [studentUserId], type: 'deposit_due', title: '押金催缴', content: `请缴纳 ${amount} 元押金`, link: `/deposits`, }); // 退还押金: await this.notificationsService.create({ recipientIds: [studentUserId], type: 'deposit_refunded', title: '押金退还', content: `押金 ${amount} 元已退还`, link: `/deposits`, }); ``` - [ ] **Step 5: 班级模块 — 学员/教师变更通知** ```typescript // classes.controller.ts // 添加学员: await this.notificationsService.create({ recipientIds: [headTeacherUserId], type: 'class_change', title: '学员变动', content: `${studentNames.join('、')} 已加入 ${className}`, link: `/classes/${classId}`, }); // 添加教师: await this.notificationsService.create({ recipientIds: [teacherUserId], type: 'class_change', title: '班级分配', content: `您已被分配为 ${className} 的 ${roleLabel}`, link: `/classes/${classId}`, }); ``` - [ ] **Step 6: 排课模块 — 冲突通知** ```typescript // schedules.controller.ts // 创建/编辑排课,冲突检测后: if (conflict) { // 通知相关教务人员 await this.notificationsService.create({ recipientIds: staffUserIds, type: 'schedule_conflict', title: '排课冲突', content: `${classroomName} ${weekDayLabel} ${timeRange} 与已有排课冲突`, link: `/schedules`, }); } ``` - [ ] **Step 7: Commit** ```bash git add apps/server/src/bills/ apps/server/src/occupancies/ apps/server/src/deposits/ apps/server/src/classes/ apps/server/src/schedules/ git commit -m "feat: integrate notification creation into business modules" ``` --- ### Task 10: 验证 + 端到端测试 - [ ] **Step 1: 启动完整环境** ```bash cd apps/server && npm run start:dev & cd apps/admin && npm run dev & ``` - [ ] **Step 2: 浏览器测试流程** 1. 打开 `http://localhost:5173`,登录 2. 确认 Header 铃铛图标可见,未读数显示正确 3. 点击铃铛 → Popover 展开,显示通知列表 4. 执行一个业务操作(如生成账单)→ 对应学生用户的铃铛出现新通知 5. 点击通知 → 标已读 + 跳转 6. "全部已读" → 所有未读标记清除 7. "查看全部" → 进入 `/notifications` 全屏页 8. 左侧筛选 Tab 切换正常 - [ ] **Step 3: SSE 验证** 打开两个浏览器窗口(不同用户),一个执行操作,另一个实时看到通知推送。 - [ ] **Step 4: Commit (如有调整)** ```bash git add -A git commit -m "fix: notification integration tweaks" ```