feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档
This commit is contained in:
144
services/msg/src/announcements/announcements.controller.ts
Normal file
144
services/msg/src/announcements/announcements.controller.ts
Normal 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 header(auth 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 };
|
||||
}
|
||||
}
|
||||
67
services/msg/src/announcements/announcements.dto.ts
Normal file
67
services/msg/src/announcements/announcements.dto.ts
Normal 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
|
||||
>;
|
||||
10
services/msg/src/announcements/announcements.module.ts
Normal file
10
services/msg/src/announcements/announcements.module.ts
Normal 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 {}
|
||||
151
services/msg/src/announcements/announcements.repository.ts
Normal file
151
services/msg/src/announcements/announcements.repository.ts
Normal 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;
|
||||
}
|
||||
72
services/msg/src/announcements/announcements.schema.ts
Normal file
72
services/msg/src/announcements/announcements.schema.ts
Normal 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;
|
||||
123
services/msg/src/announcements/announcements.service.ts
Normal file
123
services/msg/src/announcements/announcements.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 服务根模块。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M1:gRPC 50056 启用(GrpcModule 注册 3 controller 共 17 RPC)
|
||||
* - M1:gRPC 50056 启用(GrpcModule 注册 3 controller 共 13 RPC,ARB-008 裁剪后)
|
||||
* - HTTP REST + gRPC 双协议入口,共享同一套 Service 单例
|
||||
* - KafkaConsumerService 消费 12 类事件触发通知
|
||||
* - AnnouncementsModule 提供公告广播 + 每用户已读跟踪
|
||||
*
|
||||
* 模块依赖图:
|
||||
* AppModule
|
||||
* ├─ NotificationsModule(REST + Service)
|
||||
* ├─ PreferencesModule(REST + Service)
|
||||
* ├─ TemplatesModule(REST + Service)
|
||||
* ├─ GrpcModule(gRPC controllers,imports 上述 3 模块获取 Service)
|
||||
* ├─ AnnouncementsModule(REST + Service,公告广播)
|
||||
* ├─ GrpcModule(gRPC controllers,imports 上述模块获取 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,
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user