feat(notice): 站内通知(公告 + 已读追踪)

- Notice/NoticeRead 实体;管理侧 CRUD + 用户侧 mine/unread-count/read/read-all
- 资源注册 notice 菜单 + 接口

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 22:25:19 +08:00
parent 9869974d4c
commit bad8639175
10 changed files with 273 additions and 1 deletions

View File

@ -0,0 +1,28 @@
-- CreateTable
CREATE TABLE `sys_notice` (
`id` VARCHAR(36) NOT NULL,
`tenant_code` VARCHAR(64) NOT NULL,
`title` VARCHAR(128) NOT NULL,
`content` TEXT NOT NULL,
`status` VARCHAR(16) NOT NULL DEFAULT 'enabled',
`created_by` VARCHAR(36) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
`deleted_at` DATETIME(3) NULL,
INDEX `sys_notice_tenant_code_idx`(`tenant_code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sys_notice_read` (
`id` VARCHAR(36) NOT NULL,
`tenant_code` VARCHAR(64) NOT NULL,
`notice_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(36) NOT NULL,
`read_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `sys_notice_read_tenant_code_user_id_idx`(`tenant_code`, `user_id`),
UNIQUE INDEX `sys_notice_read_tenant_code_notice_id_user_id_key`(`tenant_code`, `notice_id`, `user_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -231,6 +231,35 @@ model RefreshToken {
@@map("sys_refresh_token") @@map("sys_refresh_token")
} }
/// 通知/公告
model Notice {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
title String @db.VarChar(128)
content String @db.Text
status String @default("enabled") @db.VarChar(16)
createdBy String? @map("created_by") @db.VarChar(36)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@index([tenantCode])
@@map("sys_notice")
}
/// 通知已读追踪
model NoticeRead {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
noticeId String @map("notice_id") @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
readAt DateTime @default(now()) @map("read_at")
@@unique([tenantCode, noticeId, userId])
@@index([tenantCode, userId])
@@map("sys_notice_read")
}
/// 用户-角色关联 /// 用户-角色关联
model UserRole { model UserRole {
id String @id @default(uuid(7)) @db.VarChar(36) id String @id @default(uuid(7)) @db.VarChar(36)

View File

@ -24,6 +24,7 @@ import { ConfigsModule } from './configs/configs.module.js';
import { AuditLogsModule } from './audit-logs/audit-logs.module.js'; import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
import { LoginLogsModule } from './login-logs/login-logs.module.js'; import { LoginLogsModule } from './login-logs/login-logs.module.js';
import { SessionsModule } from './sessions/sessions.module.js'; import { SessionsModule } from './sessions/sessions.module.js';
import { NoticesModule } from './notices/notices.module.js';
import { AppController } from './app.controller.js'; import { AppController } from './app.controller.js';
import { AppService } from './app.service.js'; import { AppService } from './app.service.js';
@ -45,6 +46,7 @@ import { AppService } from './app.service.js';
AuditLogsModule, AuditLogsModule,
LoginLogsModule, LoginLogsModule,
SessionsModule, SessionsModule,
NoticesModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@ -52,6 +52,12 @@ export const Permissions = {
delete: 'config:delete', delete: 'config:delete',
}, },
session: { list: 'session:list', kick: 'session:kick' }, session: { list: 'session:list', kick: 'session:kick' },
notice: {
list: 'notice:list',
create: 'notice:create',
update: 'notice:update',
delete: 'notice:delete',
},
audit: { list: 'audit:list' }, audit: { list: 'audit:list' },
loginLog: { list: 'login-log:list' }, loginLog: { list: 'login-log:list' },
} as const; } as const;

View File

@ -14,6 +14,7 @@ export const MENUS = [
{ code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', icon: 'mdi:domain', sort: 1 }, { code: 'tenant', name: '租户管理', parent: 'system', path: '/system/tenant', component: 'system/tenant/index', icon: 'mdi:domain', sort: 1 },
{ code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', icon: 'mdi:book-open-variant', sort: 2 }, { code: 'dict', name: '字典管理', parent: 'system', path: '/system/dict', component: 'system/dict/index', icon: 'mdi:book-open-variant', sort: 2 },
{ code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', icon: 'mdi:tune', sort: 3 }, { code: 'config', name: '参数配置', parent: 'system', path: '/system/config', component: 'system/config/index', icon: 'mdi:tune', sort: 3 },
{ code: 'notice', name: '通知管理', parent: 'system', path: '/system/notice', component: 'system/notice/index', icon: 'mdi:bell-ring', sort: 4 },
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 }, { code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 }, { code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 }, { code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
@ -60,6 +61,10 @@ export const APIS: { code: string; name: string; method: string; path: string; p
{ code: Permissions.user.kickSessions, name: '踢全部会话', method: 'POST', path: '/api/v1/users/:id/kick-sessions', parent: 'user' }, { code: Permissions.user.kickSessions, name: '踢全部会话', method: 'POST', path: '/api/v1/users/:id/kick-sessions', parent: 'user' },
{ code: Permissions.user.lock, name: '锁定用户', method: 'POST', path: '/api/v1/users/:id/lock', parent: 'user' }, { code: Permissions.user.lock, name: '锁定用户', method: 'POST', path: '/api/v1/users/:id/lock', parent: 'user' },
{ code: Permissions.user.unlock, name: '解锁用户', method: 'POST', path: '/api/v1/users/:id/unlock', parent: 'user' }, { code: Permissions.user.unlock, name: '解锁用户', method: 'POST', path: '/api/v1/users/:id/unlock', parent: 'user' },
{ code: Permissions.notice.list, name: '通知列表', method: 'GET', path: '/api/v1/notices', parent: 'notice' },
{ code: Permissions.notice.create, name: '创建通知', method: 'POST', path: '/api/v1/notices', parent: 'notice' },
{ code: Permissions.notice.update, name: '更新通知', method: 'PATCH', path: '/api/v1/notices/:id', parent: 'notice' },
{ code: Permissions.notice.delete, name: '删除通知', method: 'DELETE', path: '/api/v1/notices/:id', parent: 'notice' },
]; ];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */ /** 按钮资源(纯前端按钮,无独立受控接口)。 */

49
src/notices/dto.ts Normal file
View File

@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class CreateNoticeDto {
@ApiProperty({ description: '标题' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiProperty({ description: '内容' })
@IsString()
@IsNotEmpty()
content!: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled', default: 'enabled' })
@IsOptional()
@IsIn(['enabled', 'disabled'])
status?: string;
}
export class UpdateNoticeDto {
@ApiPropertyOptional({ description: '标题' })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ description: '内容' })
@IsOptional()
@IsString()
content?: string;
@ApiPropertyOptional({ description: '状态:enabled/disabled' })
@IsOptional()
@IsIn(['enabled', 'disabled'])
status?: string;
}
export class ListNoticeQuery extends PageQuery {
@ApiPropertyOptional({ description: '标题(模糊)' })
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional({ description: '状态' })
@IsOptional()
@IsString()
status?: string;
}

View File

@ -0,0 +1,59 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { CreateNoticeDto, ListNoticeQuery, UpdateNoticeDto } from './dto.js';
import { NoticesService } from './notices.service.js';
@Controller('notices')
export class NoticesController {
constructor(private readonly service: NoticesService) {}
@Get()
@RequirePermissions(Permissions.notice.list)
list(@Query() q: ListNoticeQuery) {
return this.service.listNotices(q);
}
@Post()
@RequirePermissions(Permissions.notice.create)
@AuditLog('notice', 'create')
create(@Body() dto: CreateNoticeDto) {
return this.service.createNotice(dto);
}
@Patch(':id')
@RequirePermissions(Permissions.notice.update)
@AuditLog('notice', 'update')
update(@Param('id') id: string, @Body() dto: UpdateNoticeDto) {
return this.service.updateNotice(id, dto);
}
@Delete(':id')
@RequirePermissions(Permissions.notice.delete)
@AuditLog('notice', 'delete')
remove(@Param('id') id: string) {
return this.service.softRemove(id);
}
// ---- 用户侧(仅登录态,无权限码) ----
@Get('mine')
mine(@Query() q: ListNoticeQuery) {
return this.service.mine(q);
}
@Get('unread-count')
unreadCount() {
return this.service.unreadCount();
}
@Post(':id/read')
read(@Param('id') id: string) {
return this.service.read(id);
}
@Post('read-all')
readAll() {
return this.service.readAll();
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NoticesController } from './notices.controller.js';
import { NoticesService } from './notices.service.js';
@Module({
controllers: [NoticesController],
providers: [NoticesService],
exports: [NoticesService],
})
export class NoticesModule {}

View File

@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { CreateNoticeDto, ListNoticeQuery, UpdateNoticeDto } from './dto.js';
@Injectable()
export class NoticesService extends BaseCrudService {
constructor(prisma: PrismaService) {
super(prisma, 'notice');
}
listNotices(q: ListNoticeQuery) {
const where: Record<string, unknown> = {};
if (q.title) where.title = { contains: q.title };
if (q.status) where.status = q.status;
return this.paginate(q.page, q.pageSize, where);
}
createNotice(dto: CreateNoticeDto) {
return this.create({ ...dto });
}
updateNotice(id: string, dto: UpdateNoticeDto) {
return this.update(id, { ...dto });
}
/** 当前用户可见通知(enabled)+ 已读状态。 */
async mine(q: ListNoticeQuery) {
const ctx = getRequestContext()!;
const [rows, total] = await Promise.all([
this.prisma.client.notice.findMany({
where: { status: 'enabled' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true, content: true, status: true, createdAt: true },
}),
this.prisma.client.notice.count({ where: { status: 'enabled' } }),
]);
const ids = rows.map((r) => r.id);
const readRows = ids.length
? await this.prisma.client.noticeRead.findMany({
where: { userId: ctx.userId, noticeId: { in: ids } },
select: { noticeId: true, readAt: true },
})
: [];
const readMap = new Map(readRows.map((r) => [r.noticeId, r.readAt]));
const list = rows.map((r) => ({ ...r, readAt: readMap.get(r.id) ?? null }));
return { list, total, page: q.page, pageSize: q.pageSize };
}
/** 未读数 = enabled 通知数 − 当前用户已读数。 */
async unreadCount(): Promise<number> {
const ctx = getRequestContext()!;
const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } });
const enabledIds = enabled.map((n) => n.id);
const readCount = enabledIds.length
? await this.prisma.client.noticeRead.count({ where: { userId: ctx.userId, noticeId: { in: enabledIds } } })
: 0;
return Math.max(0, enabled.length - readCount);
}
async read(id: string): Promise<null> {
const ctx = getRequestContext()!;
await this.prisma.client.noticeRead.upsert({
where: { tenantCode_noticeId_userId: { tenantCode: ctx.tenantCode, noticeId: id, userId: ctx.userId } },
update: {},
create: { tenantCode: ctx.tenantCode, noticeId: id, userId: ctx.userId },
});
return null;
}
async readAll(): Promise<null> {
const ctx = getRequestContext()!;
const enabled = await this.prisma.client.notice.findMany({ where: { status: 'enabled' }, select: { id: true } });
if (enabled.length === 0) return null;
await this.prisma.client.noticeRead.createMany({
data: enabled.map((n) => ({ tenantCode: ctx.tenantCode, noticeId: n.id, userId: ctx.userId })),
skipDuplicates: true,
});
return null;
}
}

View File

@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js';
/** 不带 tenantCode 的模型(租户表自身)。 */ /** 不带 tenantCode 的模型(租户表自身)。 */
const TENANT_FREE = new Set(['Tenant']); const TENANT_FREE = new Set(['Tenant']);
/** 软删模型:读操作统一注入 deletedAt IS NULL。 */ /** 软删模型:读操作统一注入 deletedAt IS NULL。 */
const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config']); const SOFT_DELETE = new Set(['User', 'Role', 'Resource', 'Org', 'DictType', 'DictItem', 'Config', 'Notice']);
const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']); const READ_OPS = new Set(['findMany', 'findFirst', 'findUnique', 'count', 'aggregate', 'groupBy']);
/** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */ /** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */
const NO_WHERE_OPS = new Set(['create', 'createMany']); const NO_WHERE_OPS = new Set(['create', 'createMany']);