feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:57:41 +08:00
parent 7dd5c44406
commit fb23c5234e
28 changed files with 3644 additions and 7 deletions

View File

@@ -0,0 +1,144 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Query,
Req,
} from "@nestjs/common";
import { AnnouncementsService } from "./announcements.service.js";
import {
createAnnouncementSchema,
updateAnnouncementSchema,
markAnnouncementReadSchema,
} from "./announcements.dto.js";
import type {
CreateAnnouncementDto,
UpdateAnnouncementDto,
} from "./announcements.dto.js";
import {
Permissions,
RequirePermission,
} from "../middleware/permission.guard.js";
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
import { PermissionDeniedError } from "../shared/errors/application-error.js";
/**
* AnnouncementsController —— 公告 REST API。
*
* 端点:
* - POST /announcements创建草稿需 MSG_ANNOUNCEMENT_MANAGE
* - GET /announcements列表需 MSG_ANNOUNCEMENT_READ
* - GET /announcements/:id详情需 MSG_ANNOUNCEMENT_READ
* - PUT /announcements/:id更新需 MSG_ANNOUNCEMENT_MANAGE
* - DELETE /announcements/:id删除需 MSG_ANNOUNCEMENT_MANAGE
* - PUT /announcements/:id/publish发布需 MSG_ANNOUNCEMENT_MANAGE
* - PUT /announcements/:id/archive归档需 MSG_ANNOUNCEMENT_MANAGE
* - PUT /announcements/:id/pin置顶切换需 MSG_ANNOUNCEMENT_MANAGE
* - POST /announcements/:id/read标记已读需 MSG_ANNOUNCEMENT_READ
*/
@Controller("announcements")
export class AnnouncementsController {
constructor(private readonly service: AnnouncementsService) {}
@Post()
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async create(
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dto: CreateAnnouncementDto = createAnnouncementSchema.parse(body);
const result = await this.service.create(dto);
return { success: true, data: result };
}
@Get()
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_READ)
async list(
@Query("status") status: string,
@Query("targetAudience") targetAudience: string,
@Query("page") page: string,
@Query("pageSize") pageSize: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.list({
status: status || undefined,
targetAudience: targetAudience || undefined,
page: Number(page) > 0 ? Number(page) : 1,
pageSize: Number(pageSize) > 0 ? Number(pageSize) : 20,
});
return { success: true, data: result };
}
@Get(":id")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_READ)
async getById(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.getById(id);
return { success: true, data: result };
}
@Put(":id")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async update(
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true; data: unknown }> {
const dto: UpdateAnnouncementDto = updateAnnouncementSchema.parse(body);
const result = await this.service.update(id, dto);
return { success: true, data: result };
}
@Delete(":id")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async delete(@Param("id") id: string): Promise<{ success: true }> {
await this.service.delete(id);
return { success: true };
}
@Put(":id/publish")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async publish(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.publish(id);
return { success: true, data: result };
}
@Put(":id/archive")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async archive(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.archive(id);
return { success: true, data: result };
}
@Put(":id/pin")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_MANAGE)
async togglePin(
@Param("id") id: string,
): Promise<{ success: true; data: unknown }> {
const result = await this.service.togglePin(id);
return { success: true, data: result };
}
@Post(":id/read")
@RequirePermission(Permissions.MSG_ANNOUNCEMENT_READ)
async markAsRead(
@Req() req: AuthenticatedRequest,
@Param("id") id: string,
@Body() body: unknown,
): Promise<{ success: true }> {
// 优先从 body 取 userId降级从 request headerauth middleware 注入)
const dto = markAnnouncementReadSchema.parse(body);
const userId = req.userId ?? dto.userId;
if (!userId) {
throw new PermissionDeniedError("MSG_ANNOUNCEMENT_READ");
}
await this.service.markAsRead(id, userId);
return { success: true };
}
}

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
/**
* msg 服务公告 DTO + Zod 校验 schema。
*
* 对齐 REST API
* - POST /announcements创建草稿
* - PUT /announcements/:id更新
* - PUT /announcements/:id/publish发布
* - PUT /announcements/:id/archive归档
* - PUT /announcements/:id/pin置顶切换
* - POST /announcements/:id/read标记已读
*/
// ============================================================
// Create / Update
// ============================================================
export const createAnnouncementSchema = z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
authorId: z.string().min(1).max(32),
targetAudience: z
.enum(["all", "teachers", "students", "parents", "admin"])
.default("all"),
metadata: z.record(z.string()).optional(),
});
export type CreateAnnouncementDto = z.infer<typeof createAnnouncementSchema>;
export const updateAnnouncementSchema = z.object({
title: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional(),
targetAudience: z
.enum(["all", "teachers", "students", "parents", "admin"])
.optional(),
metadata: z.record(z.string()).optional(),
});
export type UpdateAnnouncementDto = z.infer<typeof updateAnnouncementSchema>;
// ============================================================
// List 查询参数
// ============================================================
export const listAnnouncementsSchema = z.object({
status: z.enum(["draft", "published", "archived"]).optional(),
targetAudience: z
.enum(["all", "teachers", "students", "parents", "admin"])
.optional(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
});
export type ListAnnouncementsDto = z.infer<typeof listAnnouncementsSchema>;
// ============================================================
// Mark as Read
// ============================================================
export const markAnnouncementReadSchema = z.object({
userId: z.string().min(1).max(32),
});
export type MarkAnnouncementReadDto = z.infer<
typeof markAnnouncementReadSchema
>;

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { AnnouncementsController } from "./announcements.controller.js";
import { AnnouncementsService } from "./announcements.service.js";
@Module({
controllers: [AnnouncementsController],
providers: [AnnouncementsService],
exports: [AnnouncementsService],
})
export class AnnouncementsModule {}

View File

@@ -0,0 +1,151 @@
import { and, count, desc, eq, or } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { getDb } from "../config/database.js";
import {
announcements,
announcementReads,
type NewAnnouncement,
type Announcement,
type NewAnnouncementRead,
} from "./announcements.schema.js";
/**
* AnnouncementsRepository —— 公告数据访问层。
*
* 职责:封装 MySQL 读写,与业务逻辑解耦。
* 仲裁依据 G10使用 getDb() 函数式获取 db 实例。
*/
export async function insertAnnouncement(
row: NewAnnouncement,
): Promise<Announcement> {
const db = getDb();
await db.insert(announcements).values(row);
return row as Announcement;
}
export async function findById(id: string): Promise<Announcement | undefined> {
const db = getDb();
const [row] = await db
.select()
.from(announcements)
.where(eq(announcements.id, id))
.limit(1);
return row;
}
export async function list(options: {
status?: string;
targetAudience?: string;
page: number;
pageSize: number;
}): Promise<{ items: Announcement[]; total: number }> {
const db = getDb();
const conditions = [];
if (options.status) {
conditions.push(eq(announcements.status, options.status as never));
}
if (options.targetAudience) {
// target_audience 匹配 "all" 或指定受众
conditions.push(
or(
eq(announcements.targetAudience, "all"),
eq(announcements.targetAudience, options.targetAudience as never),
)!,
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [totalRow] = await db
.select({ value: count() })
.from(announcements)
.where(where);
const items = await db
.select()
.from(announcements)
.where(where)
.orderBy(desc(announcements.isPinned), desc(announcements.createdAt))
.limit(options.pageSize)
.offset((options.page - 1) * options.pageSize);
return { items, total: totalRow?.value ?? 0 };
}
export async function updateAnnouncement(
id: string,
patch: Partial<NewAnnouncement>,
): Promise<Announcement | undefined> {
const db = getDb();
await db.update(announcements).set(patch).where(eq(announcements.id, id));
const [row] = await db
.select()
.from(announcements)
.where(eq(announcements.id, id))
.limit(1);
return row;
}
export async function deleteAnnouncement(id: string): Promise<void> {
const db = getDb();
await db.delete(announcements).where(eq(announcements.id, id));
}
// ============================================================
// 已读记录
// ============================================================
export async function markAsRead(
announcementId: string,
userId: string,
): Promise<void> {
const db = getDb();
// 幂等:先查是否已读
const [existing] = await db
.select()
.from(announcementReads)
.where(
and(
eq(announcementReads.announcementId, announcementId),
eq(announcementReads.userId, userId),
),
)
.limit(1);
if (existing) return;
const row: NewAnnouncementRead = {
id: createId(),
announcementId,
userId,
};
await db.insert(announcementReads).values(row);
}
export async function isReadByUser(
announcementId: string,
userId: string,
): Promise<boolean> {
const db = getDb();
const [row] = await db
.select()
.from(announcementReads)
.where(
and(
eq(announcementReads.announcementId, announcementId),
eq(announcementReads.userId, userId),
),
)
.limit(1);
return !!row;
}
export async function getReadCount(announcementId: string): Promise<number> {
const db = getDb();
const [row] = await db
.select({ value: count() })
.from(announcementReads)
.where(eq(announcementReads.announcementId, announcementId));
return row?.value ?? 0;
}

View File

@@ -0,0 +1,72 @@
import {
boolean,
json,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
/**
* msg 服务公告 Schema。
*
* 表清单:
* - msg_announcements公告主表
* - msg_announcement_reads公告已读记录按用户+公告维度)
*
* 公告与通知msg_notifications的区别
* - 通知是 per-user 的(每条通知有 user_id
* - 公告是 broadcast 的(一条公告面向 target_audience 群体)
* - 公告发布后不会为每个用户生成通知记录,而是通过 msg_announcement_reads 跟踪已读
*/
// ============================================================
// 公告状态 / 目标受众 枚举
// ============================================================
export type AnnouncementStatus = "draft" | "published" | "archived";
export type TargetAudience =
"all" | "teachers" | "students" | "parents" | "admin";
// ============================================================
// msg_announcements公告主表
// ============================================================
export const announcements = mysqlTable("msg_announcements", {
id: varchar("id", { length: 32 }).notNull().primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
content: text("content").notNull(),
status: varchar("status", { length: 32 })
.notNull()
.default("draft")
.$type<AnnouncementStatus>(),
isPinned: boolean("is_pinned").notNull().default(false),
authorId: varchar("author_id", { length: 32 }).notNull(),
targetAudience: varchar("target_audience", { length: 32 })
.notNull()
.default("all")
.$type<TargetAudience>(),
metadata: json("metadata").$type<Record<string, string> | null>(),
publishedAt: timestamp("published_at"),
archivedAt: timestamp("archived_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
});
export type Announcement = typeof announcements.$inferSelect;
export type NewAnnouncement = typeof announcements.$inferInsert;
// ============================================================
// msg_announcement_reads公告已读记录
// ============================================================
export const announcementReads = mysqlTable("msg_announcement_reads", {
id: varchar("id", { length: 32 }).notNull().primaryKey(),
announcementId: varchar("announcement_id", { length: 32 }).notNull(),
userId: varchar("user_id", { length: 32 }).notNull(),
readAt: timestamp("read_at").notNull().defaultNow(),
});
export type AnnouncementRead = typeof announcementReads.$inferSelect;
export type NewAnnouncementRead = typeof announcementReads.$inferInsert;

View File

@@ -0,0 +1,123 @@
import { createId } from "@paralleldrive/cuid2";
import { NotFoundError } from "../shared/errors/application-error.js";
import type {
CreateAnnouncementDto,
UpdateAnnouncementDto,
} from "./announcements.dto.js";
import type { Announcement } from "./announcements.schema.js";
import * as repo from "./announcements.repository.js";
/**
* AnnouncementsService —— 公告业务逻辑层。
*
* 职责:
* - 公告 CRUD草稿→发布→归档 生命周期)
* - 置顶切换
* - 用户已读标记(幂等)
*
* 仲裁依据:
* - 公告与通知分离broadcast vs per-user
* - 发布后不可改 status只能归档
*/
export class AnnouncementsService {
async create(dto: CreateAnnouncementDto): Promise<Announcement> {
const id = createId();
const row = {
id,
title: dto.title,
content: dto.content,
status: "draft" as const,
isPinned: false,
authorId: dto.authorId,
targetAudience: dto.targetAudience,
metadata: dto.metadata ?? null,
};
return await repo.insertAnnouncement(row);
}
async list(options: {
status?: string;
targetAudience?: string;
page: number;
pageSize: number;
}): Promise<{ items: Announcement[]; total: number }> {
return await repo.list(options);
}
async getById(id: string): Promise<Announcement> {
const item = await repo.findById(id);
if (!item) {
throw new NotFoundError("Announcement", id);
}
return item;
}
async update(id: string, dto: UpdateAnnouncementDto): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const patch: Record<string, unknown> = {};
if (dto.title !== undefined) patch.title = dto.title;
if (dto.content !== undefined) patch.content = dto.content;
if (dto.targetAudience !== undefined)
patch.targetAudience = dto.targetAudience;
if (dto.metadata !== undefined) patch.metadata = dto.metadata;
const updated = await repo.updateAnnouncement(id, patch);
return updated ?? existing;
}
async delete(id: string): Promise<void> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
await repo.deleteAnnouncement(id);
}
async publish(id: string): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const updated = await repo.updateAnnouncement(id, {
status: "published",
publishedAt: new Date(),
});
return updated ?? existing;
}
async archive(id: string): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const updated = await repo.updateAnnouncement(id, {
status: "archived",
archivedAt: new Date(),
});
return updated ?? existing;
}
async togglePin(id: string): Promise<Announcement> {
const existing = await repo.findById(id);
if (!existing) {
throw new NotFoundError("Announcement", id);
}
const updated = await repo.updateAnnouncement(id, {
isPinned: !existing.isPinned,
});
return updated ?? existing;
}
async markAsRead(announcementId: string, userId: string): Promise<void> {
// 幂等repo 内部检查
await repo.markAsRead(announcementId, userId);
}
async isReadByUser(announcementId: string, userId: string): Promise<boolean> {
return await repo.isReadByUser(announcementId, userId);
}
}

View File

@@ -3,6 +3,7 @@ import { APP_GUARD } from "@nestjs/core";
import { NotificationsModule } from "./notifications/notifications.module.js";
import { PreferencesModule } from "./preferences/preferences.module.js";
import { TemplatesModule } from "./templates/templates.module.js";
import { AnnouncementsModule } from "./announcements/announcements.module.js";
import { GrpcModule } from "./grpc/grpc.module.js";
import { HealthModule } from "./shared/health/health.module.js";
import { PermissionGuard } from "./middleware/permission.guard.js";
@@ -13,16 +14,18 @@ import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js";
* AppModule —— msg 服务根模块。
*
* 仲裁依据:
* - M1gRPC 50056 启用GrpcModule 注册 3 controller 共 17 RPC
* - M1gRPC 50056 启用GrpcModule 注册 3 controller 共 13 RPCARB-008 裁剪后
* - HTTP REST + gRPC 双协议入口,共享同一套 Service 单例
* - KafkaConsumerService 消费 12 类事件触发通知
* - AnnouncementsModule 提供公告广播 + 每用户已读跟踪
*
* 模块依赖图:
* AppModule
* ├─ NotificationsModuleREST + Service
* ├─ PreferencesModuleREST + Service
* ├─ TemplatesModuleREST + Service
* ├─ GrpcModulegRPC controllersimports 上述 3 模块获取 Service
* ├─ AnnouncementsModuleREST + Service,公告广播
* ├─ GrpcModulegRPC controllersimports 上述模块获取 Service
* ├─ HealthModule/healthz + /readyz
* └─ providers: PermissionGuard(APP_GUARD) + LifecycleService + KafkaConsumerService
*/
@@ -31,6 +34,7 @@ import { KafkaConsumerService } from "./shared/kafka/kafka.consumer.js";
NotificationsModule,
PreferencesModule,
TemplatesModule,
AnnouncementsModule,
GrpcModule,
HealthModule,
],

View File

@@ -12,6 +12,8 @@ export const Permissions = {
MSG_NOTIFICATION_SEND: "MSG_NOTIFICATION_SEND" as const,
MSG_NOTIFICATION_READ: "MSG_NOTIFICATION_READ" as const,
MSG_NOTIFICATION_MANAGE: "MSG_NOTIFICATION_MANAGE" as const,
MSG_ANNOUNCEMENT_MANAGE: "MSG_ANNOUNCEMENT_MANAGE" as const,
MSG_ANNOUNCEMENT_READ: "MSG_ANNOUNCEMENT_READ" as const,
} as const;
export type Permission = (typeof Permissions)[keyof typeof Permissions];
@@ -25,12 +27,23 @@ const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_NOTIFICATION_MANAGE,
Permissions.MSG_ANNOUNCEMENT_MANAGE,
Permissions.MSG_ANNOUNCEMENT_READ,
],
teacher: [
Permissions.MSG_NOTIFICATION_SEND,
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_ANNOUNCEMENT_MANAGE,
Permissions.MSG_ANNOUNCEMENT_READ,
],
student: [
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_ANNOUNCEMENT_READ,
],
parent: [
Permissions.MSG_NOTIFICATION_READ,
Permissions.MSG_ANNOUNCEMENT_READ,
],
student: [Permissions.MSG_NOTIFICATION_READ],
};
@Injectable()

View File

@@ -1,4 +1,4 @@
import { and, count, desc, eq, inArray, lte } from "drizzle-orm";
import { and, count, desc, eq, inArray, lte, sql } from "drizzle-orm";
import { getDb } from "../config/database.js";
import {
notifications,
@@ -51,6 +51,31 @@ export async function findByEventId(
return row;
}
/**
* 批量查询已存在的 eventId用于 sendBatch 幂等过滤)。
* 返回已存在的 eventId 集合。
*/
export async function findExistingEventIds(
eventIds: string[],
): Promise<Set<string>> {
if (eventIds.length === 0) return new Set();
const db = getDb();
const rows = await db
.select({ eventId: notifications.eventId })
.from(notifications)
.where(
and(
inArray(notifications.eventId, eventIds),
sql`${notifications.eventId} IS NOT NULL`,
),
);
return new Set(
rows
.map((r) => r.eventId)
.filter((id): id is string => id !== null && id !== undefined),
);
}
export async function listByUser(
userId: string,
options: {

View File

@@ -174,10 +174,139 @@ export class NotificationsService {
const ids: string[] = [];
const failed: { userId: string; error: string }[] = [];
for (const item of dto.items) {
// 幂等过滤:批量查询已存在的 eventId跳过重复项
const eventIdsToCheck = dto.items
.map((item) => item.eventId)
.filter(
(id): id is string => id !== undefined && id !== null && id !== "",
);
const existingEventIds =
eventIdsToCheck.length > 0
? await repo.findExistingEventIds(eventIdsToCheck)
: new Set<string>();
// 过滤掉已存在的 eventId幂等跳过
const itemsToInsert = dto.items.filter((item) => {
if (!item.eventId) return true;
if (existingEventIds.has(item.eventId)) {
logger.info(
{ eventId: item.eventId, userId: item.userId },
"Batch send: notification already exists (idempotent skip)",
);
return false;
}
return true;
});
// 所有 items 都已存在(幂等跳过)
if (itemsToInsert.length === 0) {
logger.info(
{ groupId, totalCount: dto.items.length },
"Batch send: all items skipped (idempotent)",
);
return { ids: [], failed: [] };
}
// 批量构建通知记录(仅未跳过的)
const rows = itemsToInsert.map((item) => ({
id: createId(),
userId: item.userId,
type: item.type as Notification["type"],
title: item.title,
content: item.content,
channel: (item.channel ?? "in_app") as NotificationChannel,
isRead: false,
status: "pending" as NotificationStatus,
metadata: item.metadata ?? null,
relatedEntityType: item.relatedEntityType ?? null,
relatedEntityId: item.relatedEntityId ?? null,
groupId,
senderId: item.senderId ?? null,
templateId: item.templateId ?? null,
eventId: item.eventId ?? null,
}));
// 批量 INSERT失败时全部标记为 failed
try {
await repo.insertNotifications(rows);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return {
ids: [],
failed: itemsToInsert.map((item) => ({ userId: item.userId, error })),
};
}
// 逐条 ES 索引 + 渠道分发 + Outbox部分失败收集 failed
for (const [i, item] of itemsToInsert.entries()) {
const row = rows[i];
if (!row) continue;
try {
const result = await this.send({ ...item, groupId });
ids.push(result.id);
// ES 索引(降级安全)
await safeIndex({
index: "notifications",
id: row.id,
document: {
id: row.id,
user_id: item.userId,
type: item.type,
title: item.title,
content: item.content,
channel: row.channel,
status: "pending",
group_id: groupId,
related_entity_type: item.relatedEntityType ?? null,
related_entity_id: item.relatedEntityId ?? null,
sender_id: item.senderId ?? null,
is_read: false,
created_at: new Date().toISOString(),
},
});
// 查询用户偏好
const enabledChannels = await this.getUserChannels(
item.userId,
item.type,
);
// 渠道分发
const ctx: ChannelSendContext = {
notificationId: row.id,
userId: item.userId,
title: item.title,
content: item.content,
type: item.type,
metadata: item.metadata ?? null,
relatedEntityType: item.relatedEntityType,
relatedEntityId: item.relatedEntityId,
};
const results = await this.channelDispatcher.dispatch(
ctx,
enabledChannels,
);
// 更新状态
const anySent = results.some((r) => r.sent);
await this.updateStatus(row.id, anySent ? "sent" : "failed");
// Outbox
await outboxPublish(
"notification.sent",
{
notificationId: row.id,
userId: item.userId,
type: item.type,
channel: row.channel,
channels: results.map((r) => r.channel),
},
{
aggregateType: "Notification",
aggregateId: row.id,
metadata: { userId: item.userId },
},
);
ids.push(row.id);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
failed.push({ userId: item.userId, error });