diff --git a/prisma/migrations/20261011142232_notice/migration.sql b/prisma/migrations/20261011142232_notice/migration.sql new file mode 100644 index 0000000..91c2c58 --- /dev/null +++ b/prisma/migrations/20261011142232_notice/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 57b19cd..35b0d42 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -231,6 +231,35 @@ model RefreshToken { @@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 { id String @id @default(uuid(7)) @db.VarChar(36) diff --git a/src/app.module.ts b/src/app.module.ts index 34661fa..decb691 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { ConfigsModule } from './configs/configs.module.js'; import { AuditLogsModule } from './audit-logs/audit-logs.module.js'; import { LoginLogsModule } from './login-logs/login-logs.module.js'; import { SessionsModule } from './sessions/sessions.module.js'; +import { NoticesModule } from './notices/notices.module.js'; import { AppController } from './app.controller.js'; import { AppService } from './app.service.js'; @@ -45,6 +46,7 @@ import { AppService } from './app.service.js'; AuditLogsModule, LoginLogsModule, SessionsModule, + NoticesModule, ], controllers: [AppController], providers: [ diff --git a/src/common/permissions.ts b/src/common/permissions.ts index b0285c9..6c23d76 100644 --- a/src/common/permissions.ts +++ b/src/common/permissions.ts @@ -52,6 +52,12 @@ export const Permissions = { delete: 'config:delete', }, session: { list: 'session:list', kick: 'session:kick' }, + notice: { + list: 'notice:list', + create: 'notice:create', + update: 'notice:update', + delete: 'notice:delete', + }, audit: { list: 'audit:list' }, loginLog: { list: 'login-log:list' }, } as const; diff --git a/src/common/tenant-template.ts b/src/common/tenant-template.ts index 0dc8abc..bc0c93b 100644 --- a/src/common/tenant-template.ts +++ b/src/common/tenant-template.ts @@ -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: '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: '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: '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 }, @@ -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.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.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' }, ]; /** 按钮资源(纯前端按钮,无独立受控接口)。 */ diff --git a/src/notices/dto.ts b/src/notices/dto.ts new file mode 100644 index 0000000..07ab5d1 --- /dev/null +++ b/src/notices/dto.ts @@ -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; +} diff --git a/src/notices/notices.controller.ts b/src/notices/notices.controller.ts new file mode 100644 index 0000000..3d3ca94 --- /dev/null +++ b/src/notices/notices.controller.ts @@ -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(); + } +} diff --git a/src/notices/notices.module.ts b/src/notices/notices.module.ts new file mode 100644 index 0000000..1796529 --- /dev/null +++ b/src/notices/notices.module.ts @@ -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 {} diff --git a/src/notices/notices.service.ts b/src/notices/notices.service.ts new file mode 100644 index 0000000..938efd2 --- /dev/null +++ b/src/notices/notices.service.ts @@ -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 = {}; + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 97098f3..3d4a765 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -5,7 +5,7 @@ import { getRequestContext } from '../common/context/request-context.js'; /** 不带 tenantCode 的模型(租户表自身)。 */ const TENANT_FREE = new Set(['Tenant']); /** 软删模型:读操作统一注入 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']); /** 没有 where 子句的操作(create 的租户由 service 在 data 里显式写入)。 */ const NO_WHERE_OPS = new Set(['create', 'createMany']);