如何使用Cursor+NestJS 优雅地实现一个站内信系统

346 阅读4分钟

前言

在企业级后台系统中,“站内信”是一种非常常见的消息通知机制,用于提示用户关于系统、业务或提醒类的重要信息。

本文将带你一步一步梳理如何在 NestJS 中构建一个灵活、可扩展的站内信模块,包含私信、系统通知、广播消息等类型的支持,并考虑消息已读未读、分页查询等功能需求。

一、明确业务需求

在动手之前,首先需要明确站内信的功能边界:

  • ✅ 支持发送私信(指定用户)
  • ✅ 支持广播消息(发送给所有用户)
  • ✅ 支持系统消息(例如公告、提醒)
  • ✅ 支持已读/未读标记
  • ✅ 支持消息分页和类型筛选
  • ✅ 用户可以点击消息跳转到指定业务模块

二、数据库设计

询问Cursor,他将消息系统抽象成两张核心表:

  1. Message 表:用于存储消息主体,包含内容、跳转链接、类型、是否是广播等字段。
  2. MessageRead 表:用于记录每个用户对消息的阅读状态。

image.png

注意:所有用户都可读取广播消息,但只有读取过的才会在 MessageRead 表中记录。

具体的实体类

// message-read.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';

@Entity('message_reads')
@Index(['userId', 'messageId'], { unique: true })
export class MessageRead {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    userId: number;

    @Column()
    messageId: number;

    @CreateDateColumn()
    readAt: Date;
}

// message.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm';

@Entity('messages')
export class Message {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    senderId: number;

    @Column({ nullable: true })
    receiverId: number | null;

    @Column('text')
    content: string;

    @Column({ default: false })
    isBroadcast: boolean; // ✨ 是否为广播消息

    @Column({
        type: 'enum',
        enum: ['system', 'notification', 'private'],
        default: 'notification',
    })
    type: 'system' | 'notification' | 'private';

    @Column({ nullable: true })
    redirectUrl: string; // ✨ 可选:点击跳转链接(前端根据此跳转)

    @Column({ default: false })
    isRead: boolean;

    @CreateDateColumn()
    createdAt: Date;
}

三、模块拆分

NestJS 提倡模块化开发,cursor建议按照模块进行开发,因此站内信系统应包含以下模块:

  • MessageModule: 核心消息模块
  • MessageService: 消息的业务处理逻辑
  • MessageController: 提供 API 接口
  • MessageRepository: TypeORM 仓库用于操作数据库

message.controller.ts

import { Controller, Post, Body, Get, Param, Patch, Request, UseGuards } from '@nestjs/common';
import { MessageService } from './message.service';
import { SendMessageDto } from './dto/send-message.dto';
import { ApiBody, ApiOperation } from '@nestjs/swagger';
import { Request as ExpressRequest } from 'express';
import { AuthGuard } from 'src/auth/auth.guard';

@UseGuards(AuthGuard)
@Controller('messages')
export class MessageController {
    constructor(private readonly messageService: MessageService) { }

    @Post()
    @ApiOperation({ summary: '发送消息' })
    @ApiBody({ type: SendMessageDto })
    sendMessage(@Request() req: ExpressRequest, @Body() dto: SendMessageDto) {
        const userId = req.user.userId;
        return this.messageService.sendMessage(dto, userId);
    }

    @Get(':type')
    @ApiOperation({ summary: '获取消息列表' })
    getMessages(@Request() req: ExpressRequest, @Param('type') type: string) {
        const userId = req.user.userId;
        return this.messageService.getMessagesForUser(+userId, type);
    }

    @Patch(':messageId/read/:userId')
    @ApiOperation({ summary: '标记为已读' })
    markBroadcastAsRead(
        @Request() req: ExpressRequest,
        @Param('messageId') messageId: number,
    ) {
        const userId = req.user.userId;
        return this.messageService.markBroadcastAsRead(+userId, +messageId);
    }
}

message.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Message } from './entities/message.entity';
import { MessageRead } from './entities/message-read.entity';
import { Repository } from 'typeorm';
import { SendMessageDto } from './dto/send-message.dto';

@Injectable()
export class MessageService {
    constructor(
        @InjectRepository(Message)
        private readonly messageRepo: Repository<Message>,
        @InjectRepository(MessageRead)
        private readonly messageReadRepo: Repository<MessageRead>,
    ) { }

    async sendMessage(dto: SendMessageDto, userId: number) {
        const message = this.messageRepo.create({
            ...dto,
            senderId: userId,
            receiverId: dto.receiverId ?? null,
            isBroadcast: !dto?.receiverId, // 没有 receiverId 默认广播
        });
        return this.messageRepo.save(message);
    }

    async getMessagesForUser(userId: number, type: string) {
        const qb = this.messageRepo
            .createQueryBuilder('m')
            .leftJoin(
                MessageRead,
                'mr',
                'mr.messageId = m.id AND mr.userId = :userId',
                { userId },
            );

        // 根据类型筛选
        if (type && ['system', 'notification', 'private'].includes(type)) {
            qb.andWhere('m.type = :type', { type });
        }

        // 查询条件:用户的私信或广播消息(包括已读和未读)
        qb.andWhere(
            '(m.receiverId = :userId OR (m.isBroadcast = true))',
            { userId }
        )
            .orderBy('m.createdAt', 'DESC');

        const messages = await qb.getMany();

        // 补上是否已读字段
        return Promise.all(
            messages.map(async (msg) => {
                if (!msg.isBroadcast) {
                    return { ...msg, isRead: msg.isRead }; // 使用数据库中存储的已读状态
                }

                // 对于广播消息,查询是否有阅读记录
                const read = await this.messageReadRepo.findOne({
                    where: { userId, messageId: msg.id },
                });

                return {
                    ...msg,
                    isRead: !!read,
                };
            }),
        );
    }

    async markBroadcastAsRead(userId: number, messageId: number) {
        const exists = await this.messageReadRepo.findOne({
            where: { userId, messageId },
        });

        if (!exists) {
            const read = this.messageReadRepo.create({ userId, messageId });
            return this.messageReadRepo.save(read);
        }

        return exists;
    }
}

四、发送逻辑设计

根据不同类型的消息,发送时应有不同处理逻辑:

  • 私信:需要指定接收用户的 ID。
  • 广播:receiverId 可为空,isBroadcast 设为 true
  • 系统消息:可视为特殊类型的广播,但通常不允许删除,或仅管理员可发送。

在服务端接收到发送请求时,应该自动判断是否为广播并设置默认值(如 receiverId = nullisBroadcast = true 等)。

五、消息读取逻辑

前端访问消息列表时,后端应根据以下逻辑返回消息:

  • 私信:receiverId 等于当前用户 ID
  • 广播消息:所有 isBroadcast = true 的消息,但排除当前用户已经读取过的(通过 MessageRead 判断)

用户点击查看后,系统应记录一条 MessageRead 数据,表示该消息已读。

六、用户认证与权限控制

整个消息系统的核心依赖是“当前用户 ID”,因此需要搭配 JWT 认证来自动获取 userId

  • 登录成功时,JWT payload 中应包含 userId
  • 使用 @UseGuards(AuthGuard('jwt')) 保护消息接口
  • 通过 @Request() 或自定义 @User() 装饰器快速获取当前用户信息

image.png

七、API 接口设计

建议暴露以下接口:

  • GET /messages/:type: 获取指定类型的消息列表(如:private、system、broadcast)
  • PATCH /messages/:id/read: 标记指定消息为已读
  • POST /messages: 发送一条消息(私信/广播)
  • DELETE /messages/:id: 删除用户的私信(业务上可限制不能删除系统消息)

image.png

结语

站内信作为一个轻量却高频的功能模块,在后端的设计上既要保证通用性,又要具备良好的可维护性。借助 NestJS 的模块化、装饰器、TypeORM 等特性,我们可以优雅地构建一套健壮的站内信系统,并且具备良好的拓展能力。

image.png

希望这篇文章的梳理能帮你理清思路,写出更清晰、更专业的业务代码。