前言
在企业级后台系统中,“站内信”是一种非常常见的消息通知机制,用于提示用户关于系统、业务或提醒类的重要信息。
本文将带你一步一步梳理如何在 NestJS 中构建一个灵活、可扩展的站内信模块,包含私信、系统通知、广播消息等类型的支持,并考虑消息已读未读、分页查询等功能需求。
一、明确业务需求
在动手之前,首先需要明确站内信的功能边界:
- ✅ 支持发送私信(指定用户)
- ✅ 支持广播消息(发送给所有用户)
- ✅ 支持系统消息(例如公告、提醒)
- ✅ 支持已读/未读标记
- ✅ 支持消息分页和类型筛选
- ✅ 用户可以点击消息跳转到指定业务模块
二、数据库设计
询问Cursor,他将消息系统抽象成两张核心表:
- Message 表:用于存储消息主体,包含内容、跳转链接、类型、是否是广播等字段。
- MessageRead 表:用于记录每个用户对消息的阅读状态。
注意:所有用户都可读取广播消息,但只有读取过的才会在 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 = null,isBroadcast = true 等)。
五、消息读取逻辑
前端访问消息列表时,后端应根据以下逻辑返回消息:
- 私信:receiverId 等于当前用户 ID
- 广播消息:所有
isBroadcast = true的消息,但排除当前用户已经读取过的(通过MessageRead判断)
用户点击查看后,系统应记录一条 MessageRead 数据,表示该消息已读。
六、用户认证与权限控制
整个消息系统的核心依赖是“当前用户 ID”,因此需要搭配 JWT 认证来自动获取 userId:
- 登录成功时,JWT payload 中应包含
userId - 使用
@UseGuards(AuthGuard('jwt'))保护消息接口 - 通过
@Request()或自定义@User()装饰器快速获取当前用户信息
七、API 接口设计
建议暴露以下接口:
GET /messages/:type: 获取指定类型的消息列表(如:private、system、broadcast)PATCH /messages/:id/read: 标记指定消息为已读POST /messages: 发送一条消息(私信/广播)DELETE /messages/:id: 删除用户的私信(业务上可限制不能删除系统消息)
结语
站内信作为一个轻量却高频的功能模块,在后端的设计上既要保证通用性,又要具备良好的可维护性。借助 NestJS 的模块化、装饰器、TypeORM 等特性,我们可以优雅地构建一套健壮的站内信系统,并且具备良好的拓展能力。
希望这篇文章的梳理能帮你理清思路,写出更清晰、更专业的业务代码。