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:
parent
9869974d4c
commit
bad8639175
28
prisma/migrations/20261011142232_notice/migration.sql
Normal file
28
prisma/migrations/20261011142232_notice/migration.sql
Normal 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;
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
49
src/notices/dto.ts
Normal 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;
|
||||||
|
}
|
||||||
59
src/notices/notices.controller.ts
Normal file
59
src/notices/notices.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/notices/notices.module.ts
Normal file
10
src/notices/notices.module.ts
Normal 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 {}
|
||||||
84
src/notices/notices.service.ts
Normal file
84
src/notices/notices.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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']);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user