feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
@@ -12,8 +13,14 @@ import { NotificationsService } from "./notifications.service.js";
|
||||
import {
|
||||
sendNotificationSchema,
|
||||
sendNotificationBatchSchema,
|
||||
batchMarkAsReadSchema,
|
||||
markAllAsReadSchema,
|
||||
recallNotificationSchema,
|
||||
} from "./notifications.dto.js";
|
||||
import type {
|
||||
SendNotificationDto,
|
||||
SendNotificationBatchDto,
|
||||
} from "./notifications.dto.js";
|
||||
import type { SendNotificationDto } from "./notifications.dto.js";
|
||||
import {
|
||||
Permissions,
|
||||
RequirePermission,
|
||||
@@ -21,11 +28,26 @@ import {
|
||||
import type { AuthenticatedRequest } from "../middleware/auth.middleware.js";
|
||||
import { PermissionDeniedError } from "../shared/errors/application-error.js";
|
||||
|
||||
/**
|
||||
* NotificationsController —— REST API 入口。
|
||||
*
|
||||
* 对齐 02-architecture-design.md §4.1 REST API 设计:
|
||||
* - POST /notifications/send(单条发送)
|
||||
* - POST /notifications/batch(批量发送)
|
||||
* - GET /notifications/user/:userId(列表,支持分页+过滤)
|
||||
* - GET /notifications/user/:userId/unread-count(未读数)
|
||||
* - PUT /notifications/:id/read(标记已读)
|
||||
* - PUT /notifications/batch/read(批量标记已读)
|
||||
* - PUT /notifications/read-all(全部已读)
|
||||
* - GET /notifications/search(ES 全文检索)
|
||||
* - POST /notifications/recall(撤回广播)
|
||||
* - DELETE /notifications/:id(删除)
|
||||
*/
|
||||
@Controller("notifications")
|
||||
export class NotificationsController {
|
||||
constructor(private readonly service: NotificationsService) {}
|
||||
|
||||
@Post()
|
||||
@Post("send")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
|
||||
async send(@Body() body: unknown): Promise<{ success: true; data: unknown }> {
|
||||
const dto: SendNotificationDto = sendNotificationSchema.parse(body);
|
||||
@@ -35,11 +57,12 @@ export class NotificationsController {
|
||||
|
||||
@Post("batch")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_SEND)
|
||||
async createBatch(
|
||||
async sendBatch(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const dtos: SendNotificationDto[] = sendNotificationBatchSchema.parse(body);
|
||||
const result = await this.service.createBatch(dtos);
|
||||
const dto: SendNotificationBatchDto =
|
||||
sendNotificationBatchSchema.parse(body);
|
||||
const result = await this.service.sendBatch(dto);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -47,48 +70,100 @@ export class NotificationsController {
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async listByUser(
|
||||
@Param("userId") userId: string,
|
||||
@Query("unread") unread: string,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const result = await this.service.listByUser(userId, unread === "true");
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get("user/:userId/page")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async listByUserPaginated(
|
||||
@Param("userId") userId: string,
|
||||
@Query("onlyUnread") onlyUnread: string,
|
||||
@Query("type") type: string,
|
||||
@Query("page") page: string,
|
||||
@Query("pageSize") pageSize: string,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const pageNum = Number(page) > 0 ? Number(page) : 1;
|
||||
const pageSizeNum = Number(pageSize) > 0 ? Number(pageSize) : 20;
|
||||
const result = await this.service.listByUserWithPagination(
|
||||
userId,
|
||||
pageNum,
|
||||
pageSizeNum,
|
||||
);
|
||||
const result = await this.service.listByUser(userId, {
|
||||
onlyUnread: onlyUnread === "true",
|
||||
type: type || undefined,
|
||||
page: Number(page) > 0 ? Number(page) : 1,
|
||||
pageSize: Number(pageSize) > 0 ? Number(pageSize) : 20,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Get("user/:userId/unread-count")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async getUnreadCount(
|
||||
@Param("userId") userId: string,
|
||||
): Promise<{ success: true; data: { count: number } }> {
|
||||
const count = await this.service.getUnreadCount(userId);
|
||||
return { success: true, data: { count } };
|
||||
}
|
||||
|
||||
@Put(":id/read")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async markAsRead(@Param("id") id: string): Promise<{ success: true }> {
|
||||
await this.service.markAsRead(id);
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async markAsRead(
|
||||
@Param("id") id: string,
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true }> {
|
||||
const userId =
|
||||
typeof body === "object" && body !== null && "userId" in body
|
||||
? String((body as { userId: unknown }).userId)
|
||||
: "";
|
||||
if (!userId) {
|
||||
throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
|
||||
}
|
||||
await this.service.markAsRead(id, userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put("batch/read")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async batchMarkAsRead(@Body() body: unknown): Promise<{ success: true }> {
|
||||
const dto = batchMarkAsReadSchema.parse(body);
|
||||
await this.service.batchMarkAsRead(dto.ids, dto.userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put("read-all")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async markAllAsRead(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { updated: number } }> {
|
||||
const dto = markAllAsReadSchema.parse(body);
|
||||
const updated = await this.service.markAllAsRead(dto.userId, dto.before);
|
||||
return { success: true, data: { updated } };
|
||||
}
|
||||
|
||||
@Get("search")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_READ)
|
||||
async search(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query("q") q: string,
|
||||
@Query("userId") userIdParam: string,
|
||||
@Query("type") type: string,
|
||||
@Query("page") page: string,
|
||||
@Query("pageSize") pageSize: string,
|
||||
): Promise<{ success: true; data: unknown }> {
|
||||
const userId = req.userId ?? userIdParam;
|
||||
if (!userId) {
|
||||
throw new PermissionDeniedError("MSG_NOTIFICATION_READ");
|
||||
}
|
||||
const result = await this.service.search(userId, q);
|
||||
const result = await this.service.search(userId, q, {
|
||||
type: type || undefined,
|
||||
page: Number(page) > 0 ? Number(page) : 1,
|
||||
pageSize: Number(pageSize) > 0 ? Number(pageSize) : 20,
|
||||
});
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@Post("recall")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async recall(
|
||||
@Body() body: unknown,
|
||||
): Promise<{ success: true; data: { recalledCount: number } }> {
|
||||
const dto = recallNotificationSchema.parse(body);
|
||||
const recalledCount = await this.service.recall(dto.groupId);
|
||||
return { success: true, data: { recalledCount } };
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequirePermission(Permissions.MSG_NOTIFICATION_MANAGE)
|
||||
async delete(@Param("id") id: string): Promise<{ success: true }> {
|
||||
await this.service.delete(id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,100 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* msg 服务 DTO + Zod 校验 schema。
|
||||
*
|
||||
* 对齐 proto msg.proto 字段:
|
||||
* - SendNotificationRequest / BatchSendNotificationRequest
|
||||
* - MarkAsRead / BatchMarkAsRead / MarkAllAsRead
|
||||
* - SearchNotifications / RecallNotification
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Send Notification
|
||||
// ============================================================
|
||||
|
||||
export const sendNotificationSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
type: z.string().min(1).max(50),
|
||||
title: z.string().min(1).max(200),
|
||||
type: z.string().min(1).max(32),
|
||||
title: z.string().min(1).max(255),
|
||||
content: z.string().min(1),
|
||||
channel: z.string().max(20).optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
channel: z.string().max(32).optional(),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
relatedEntityType: z.string().max(64).optional(),
|
||||
relatedEntityId: z.string().max(32).optional(),
|
||||
groupId: z.string().max(32).optional(),
|
||||
senderId: z.string().max(32).optional(),
|
||||
templateId: z.string().max(32).optional(),
|
||||
eventId: z.string().max(64).optional(),
|
||||
});
|
||||
|
||||
export const sendNotificationBatchSchema = z.array(sendNotificationSchema);
|
||||
|
||||
export type SendNotificationDto = z.infer<typeof sendNotificationSchema>;
|
||||
|
||||
export const sendNotificationBatchSchema = z.object({
|
||||
items: z.array(sendNotificationSchema).min(1).max(1000),
|
||||
groupId: z.string().max(32).optional(),
|
||||
});
|
||||
|
||||
export type SendNotificationBatchDto = z.infer<
|
||||
typeof sendNotificationBatchSchema
|
||||
>;
|
||||
|
||||
// ============================================================
|
||||
// List / Search
|
||||
// ============================================================
|
||||
|
||||
export const listNotificationsSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
onlyUnread: z.boolean().optional(),
|
||||
type: z.string().max(32).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type ListNotificationsDto = z.infer<typeof listNotificationsSchema>;
|
||||
|
||||
export const searchNotificationsSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
query: z.string().min(1),
|
||||
type: z.string().max(32).optional(),
|
||||
page: z.number().int().min(1).default(1),
|
||||
pageSize: z.number().int().min(1).max(100).default(20),
|
||||
});
|
||||
|
||||
export type SearchNotificationsDto = z.infer<typeof searchNotificationsSchema>;
|
||||
|
||||
// ============================================================
|
||||
// Mark As Read
|
||||
// ============================================================
|
||||
|
||||
export const markAsReadSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type MarkAsReadDto = z.infer<typeof markAsReadSchema>;
|
||||
|
||||
export const batchMarkAsReadSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(1000),
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type BatchMarkAsReadDto = z.infer<typeof batchMarkAsReadSchema>;
|
||||
|
||||
export const markAllAsReadSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
before: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export type MarkAllAsReadDto = z.infer<typeof markAllAsReadSchema>;
|
||||
|
||||
// ============================================================
|
||||
// Recall
|
||||
// ============================================================
|
||||
|
||||
export const recallNotificationSchema = z.object({
|
||||
groupId: z.string().min(1),
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export type RecallNotificationDto = z.infer<typeof recallNotificationSchema>;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsController } from './notifications.controller.js';
|
||||
import { NotificationsService } from './notifications.service.js';
|
||||
import { Module } from "@nestjs/common";
|
||||
import { NotificationsController } from "./notifications.controller.js";
|
||||
import { NotificationsService } from "./notifications.service.js";
|
||||
import { ChannelDispatcherService } from "../channels/channel-dispatcher.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
providers: [NotificationsService, ChannelDispatcherService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
|
||||
166
services/msg/src/notifications/notifications.repository.ts
Normal file
166
services/msg/src/notifications/notifications.repository.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { and, count, desc, eq, inArray, lte } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
notifications,
|
||||
type NewNotification,
|
||||
type Notification,
|
||||
} from "./notifications.schema.js";
|
||||
|
||||
/**
|
||||
* NotificationsRepository —— 通知数据访问层。
|
||||
*
|
||||
* 职责:封装 MySQL 读写,与业务逻辑解耦。
|
||||
* 仲裁依据 G10:使用 getDb() 函数式获取 db 实例。
|
||||
*/
|
||||
|
||||
export async function insertNotification(
|
||||
row: NewNotification,
|
||||
): Promise<Notification> {
|
||||
const db = getDb();
|
||||
await db.insert(notifications).values(row);
|
||||
return row as Notification;
|
||||
}
|
||||
|
||||
export async function insertNotifications(
|
||||
rows: NewNotification[],
|
||||
): Promise<void> {
|
||||
if (rows.length === 0) return;
|
||||
const db = getDb();
|
||||
await db.insert(notifications).values(rows);
|
||||
}
|
||||
|
||||
export async function findById(id: string): Promise<Notification | undefined> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(eq(notifications.id, id))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function findByEventId(
|
||||
eventId: string,
|
||||
): Promise<Notification | undefined> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(eq(notifications.eventId, eventId))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function listByUser(
|
||||
userId: string,
|
||||
options: {
|
||||
onlyUnread?: boolean;
|
||||
type?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
},
|
||||
): Promise<{ items: Notification[]; total: number }> {
|
||||
const db = getDb();
|
||||
const conditions = [eq(notifications.userId, userId)];
|
||||
|
||||
if (options.onlyUnread) {
|
||||
conditions.push(eq(notifications.isRead, false));
|
||||
}
|
||||
if (options.type) {
|
||||
conditions.push(
|
||||
eq(notifications.type, options.type as Notification["type"]),
|
||||
);
|
||||
}
|
||||
|
||||
const where = and(...conditions);
|
||||
const offset = (options.page - 1) * options.pageSize;
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(where)
|
||||
.orderBy(desc(notifications.createdAt))
|
||||
.limit(options.pageSize)
|
||||
.offset(offset);
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(notifications)
|
||||
.where(where);
|
||||
|
||||
return { items, total: countRow?.value ?? 0 };
|
||||
}
|
||||
|
||||
export async function getUnreadCount(userId: string): Promise<number> {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(notifications)
|
||||
.where(
|
||||
and(eq(notifications.userId, userId), eq(notifications.isRead, false)),
|
||||
);
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function markAsRead(id: string, userId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ isRead: true, readAt: new Date(), status: "read" })
|
||||
.where(and(eq(notifications.id, id), eq(notifications.userId, userId)));
|
||||
}
|
||||
|
||||
export async function batchMarkAsRead(
|
||||
ids: string[],
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ isRead: true, readAt: new Date(), status: "read" })
|
||||
.where(
|
||||
and(inArray(notifications.id, ids), eq(notifications.userId, userId)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function markAllAsRead(
|
||||
userId: string,
|
||||
before?: number,
|
||||
): Promise<number> {
|
||||
const db = getDb();
|
||||
const conditions = [
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.isRead, false),
|
||||
];
|
||||
if (before !== undefined) {
|
||||
conditions.push(lte(notifications.createdAt, new Date(before)));
|
||||
}
|
||||
const result = await db
|
||||
.update(notifications)
|
||||
.set({ isRead: true, readAt: new Date(), status: "read" })
|
||||
.where(and(...conditions));
|
||||
|
||||
// mysql2 affectedRows
|
||||
return (result as unknown as { affectedRows?: number }).affectedRows ?? 0;
|
||||
}
|
||||
|
||||
export async function recallByGroup(groupId: string): Promise<number> {
|
||||
const db = getDb();
|
||||
const result = await db
|
||||
.update(notifications)
|
||||
.set({ status: "recalled" })
|
||||
.where(eq(notifications.groupId, groupId));
|
||||
|
||||
return (result as unknown as { affectedRows?: number }).affectedRows ?? 0;
|
||||
}
|
||||
|
||||
export async function deleteById(id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db.delete(notifications).where(eq(notifications.id, id));
|
||||
}
|
||||
|
||||
/** 用于测试/重置 */
|
||||
export async function deleteAllByUserId(userId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db.delete(notifications).where(eq(notifications.userId, userId));
|
||||
}
|
||||
@@ -1,24 +1,163 @@
|
||||
import { mysqlTable, varchar, char, timestamp, text, boolean, json } from 'drizzle-orm/mysql-core';
|
||||
import {
|
||||
boolean,
|
||||
int,
|
||||
json,
|
||||
mysqlTable,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
export const notifications = mysqlTable('msg_notifications', {
|
||||
id: char('id', { length: 36 }).notNull().primaryKey(),
|
||||
userId: char('user_id', { length: 36 }).notNull(),
|
||||
type: varchar('type', { length: 50 }).notNull(),
|
||||
title: varchar('title', { length: 200 }).notNull(),
|
||||
content: text('content').notNull(),
|
||||
channel: varchar('channel', { length: 20 }).notNull().default('in_app'),
|
||||
isRead: boolean('is_read').notNull().default(false),
|
||||
metadata: json('metadata'),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
});
|
||||
/**
|
||||
* msg 服务数据库 Schema。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - 02-architecture-design.md §3.1(表清单 + 字段 + 索引)
|
||||
* - G11:ID 用 cuid2(varchar(32))
|
||||
* - proto msg.proto 字段对齐(Notification / NotificationPreference / NotificationTemplate)
|
||||
*
|
||||
* 表清单:
|
||||
* - msg_notifications(通知主表,扩展字段)
|
||||
* - msg_notification_preferences(用户偏好,扩展字段)
|
||||
* - msg_notification_templates(通知模板,新增)
|
||||
* - msg_notification_deliveries(投递记录,新增)
|
||||
*
|
||||
* 注:msg_outbox_events / processed_events 在 shared/outbox/outbox.schema.ts
|
||||
*/
|
||||
|
||||
export const notificationPreferences = mysqlTable('msg_notification_preferences', {
|
||||
userId: char('user_id', { length: 36 }).notNull().primaryKey(),
|
||||
emailEnabled: boolean('email_enabled').notNull().default(true),
|
||||
smsEnabled: boolean('sms_enabled').notNull().default(false),
|
||||
pushEnabled: boolean('push_enabled').notNull().default(true),
|
||||
inAppEnabled: boolean('in_app_enabled').notNull().default(true),
|
||||
// ============================================================
|
||||
// 通知状态 / 渠道 / 类型 枚举(字符串枚举,DB 存 varchar)
|
||||
// ============================================================
|
||||
|
||||
export type NotificationStatus =
|
||||
"pending" | "sent" | "delivered" | "read" | "recalled" | "failed";
|
||||
|
||||
export type NotificationChannel =
|
||||
"in_app" | "email" | "sms" | "push" | "wechat";
|
||||
|
||||
export type NotificationType =
|
||||
"system" | "exam" | "homework" | "grade" | "attendance" | "mastery";
|
||||
|
||||
export type DeliveryStatus =
|
||||
"pending" | "sent" | "delivered" | "failed" | "retrying";
|
||||
|
||||
export type TemplateStatus = "draft" | "active" | "archived";
|
||||
|
||||
// ============================================================
|
||||
// msg_notifications(通知主表 · 扩展)
|
||||
// ============================================================
|
||||
|
||||
export const notifications = mysqlTable("msg_notifications", {
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
userId: varchar("user_id", { length: 32 }).notNull(),
|
||||
type: varchar("type", { length: 32 }).notNull().$type<NotificationType>(),
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
channel: varchar("channel", { length: 32 })
|
||||
.notNull()
|
||||
.$type<NotificationChannel>(),
|
||||
isRead: boolean("is_read").notNull().default(false),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
// 扩展字段(P5)
|
||||
status: varchar("status", { length: 32 })
|
||||
.notNull()
|
||||
.default("pending")
|
||||
.$type<NotificationStatus>(),
|
||||
metadata: json("metadata").$type<Record<string, string> | null>(),
|
||||
relatedEntityType: varchar("related_entity_type", { length: 64 }),
|
||||
relatedEntityId: varchar("related_entity_id", { length: 32 }),
|
||||
groupId: varchar("group_id", { length: 32 }),
|
||||
senderId: varchar("sender_id", { length: 32 }),
|
||||
templateId: varchar("template_id", { length: 32 }),
|
||||
eventId: varchar("event_id", { length: 64 }),
|
||||
readAt: timestamp("read_at"),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export type Notification = typeof notifications.$inferSelect;
|
||||
export type NotificationPreference = typeof notificationPreferences.$inferSelect;
|
||||
export type NewNotification = typeof notifications.$inferInsert;
|
||||
|
||||
// ============================================================
|
||||
// msg_notification_preferences(用户偏好 · 扩展)
|
||||
// ============================================================
|
||||
|
||||
export const notificationPreferences = mysqlTable(
|
||||
"msg_notification_preferences",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
userId: varchar("user_id", { length: 32 }).notNull(),
|
||||
type: varchar("type", { length: 32 }).notNull().$type<NotificationType>(),
|
||||
channels: json("channels").notNull().$type<NotificationChannel[]>(),
|
||||
frequencyLimit: int("frequency_limit"),
|
||||
quietHoursStart: varchar("quiet_hours_start", { length: 8 }),
|
||||
quietHoursEnd: varchar("quiet_hours_end", { length: 8 }),
|
||||
quietHoursTimezone: varchar("quiet_hours_timezone", { length: 64 }).default(
|
||||
"Asia/Shanghai",
|
||||
),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
);
|
||||
|
||||
export type NotificationPreference =
|
||||
typeof notificationPreferences.$inferSelect;
|
||||
export type NewNotificationPreference =
|
||||
typeof notificationPreferences.$inferInsert;
|
||||
|
||||
// ============================================================
|
||||
// msg_notification_templates(通知模板 · 新增)
|
||||
// ============================================================
|
||||
|
||||
export const notificationTemplates = mysqlTable("msg_notification_templates", {
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
code: varchar("code", { length: 64 }).notNull(),
|
||||
type: varchar("type", { length: 32 }).notNull().$type<NotificationType>(),
|
||||
titleTemplate: varchar("title_template", { length: 255 }).notNull(),
|
||||
contentTemplate: text("content_template").notNull(),
|
||||
defaultChannels: json("default_channels")
|
||||
.notNull()
|
||||
.$type<NotificationChannel[]>(),
|
||||
variables: json("variables").notNull().$type<string[]>(),
|
||||
locale: varchar("locale", { length: 16 }).notNull().default("zh-CN"),
|
||||
status: varchar("status", { length: 32 })
|
||||
.notNull()
|
||||
.default("draft")
|
||||
.$type<TemplateStatus>(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
});
|
||||
|
||||
export type NotificationTemplate = typeof notificationTemplates.$inferSelect;
|
||||
export type NewNotificationTemplate = typeof notificationTemplates.$inferInsert;
|
||||
|
||||
// ============================================================
|
||||
// msg_notification_deliveries(投递记录 · 新增)
|
||||
// ============================================================
|
||||
|
||||
export const notificationDeliveries = mysqlTable(
|
||||
"msg_notification_deliveries",
|
||||
{
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
notificationId: varchar("notification_id", { length: 32 }).notNull(),
|
||||
channel: varchar("channel", { length: 32 })
|
||||
.notNull()
|
||||
.$type<NotificationChannel>(),
|
||||
status: varchar("status", { length: 32 })
|
||||
.notNull()
|
||||
.default("pending")
|
||||
.$type<DeliveryStatus>(),
|
||||
externalId: varchar("external_id", { length: 128 }),
|
||||
attemptCount: int("attempt_count").notNull().default(0),
|
||||
maxRetry: int("max_retry").notNull().default(3),
|
||||
lastError: text("last_error"),
|
||||
nextRetryAt: timestamp("next_retry_at"),
|
||||
deliveredAt: timestamp("delivered_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow().onUpdateNow(),
|
||||
},
|
||||
);
|
||||
|
||||
export type NotificationDelivery = typeof notificationDeliveries.$inferSelect;
|
||||
export type NewNotificationDelivery =
|
||||
typeof notificationDeliveries.$inferInsert;
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import { db } from "../config/database.js";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { getDb } from "../config/database.js";
|
||||
import {
|
||||
notifications,
|
||||
notificationPreferences,
|
||||
type Notification,
|
||||
type NotificationChannel,
|
||||
type NotificationStatus,
|
||||
} from "./notifications.schema.js";
|
||||
import * as repo from "./notifications.repository.js";
|
||||
import type {
|
||||
Notification,
|
||||
NotificationPreference,
|
||||
} from "./notifications.schema.js";
|
||||
import type { SendNotificationDto } from "./notifications.dto.js";
|
||||
import { safeIndex, safeSearch } from "../config/elasticsearch.js";
|
||||
import { env } from "../config/env.js";
|
||||
SendNotificationDto,
|
||||
SendNotificationBatchDto,
|
||||
} from "./notifications.dto.js";
|
||||
import { ChannelDispatcherService } from "../channels/channel-dispatcher.service.js";
|
||||
import type { ChannelSendContext } from "../channels/channel.types.js";
|
||||
import { safeIndex, safeSearch, safeDelete } from "../config/elasticsearch.js";
|
||||
import { publish as outboxPublish } from "../shared/outbox/outbox.service.js";
|
||||
import { logger } from "../shared/observability/logger.js";
|
||||
|
||||
export interface SendResult {
|
||||
id: string;
|
||||
skipped: boolean;
|
||||
pushed: boolean;
|
||||
status: string;
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
export interface BatchSendResult {
|
||||
ids: string[];
|
||||
failed: { userId: string; error: string }[];
|
||||
}
|
||||
|
||||
export interface PaginatedResult {
|
||||
@@ -28,177 +38,320 @@ export interface PaginatedResult {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
items: Record<string, unknown>[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationService —— 通知发送编排层。
|
||||
*
|
||||
* 职责:
|
||||
* 1. 幂等检查(eventId 去重)
|
||||
* 2. 查询用户偏好
|
||||
* 3. 写入 MySQL(通知主表)
|
||||
* 4. 写入 ES 索引(降级安全)
|
||||
* 5. 调用 ChannelDispatcher fan-out 到多渠道
|
||||
* 6. 写入 Outbox(发布 notification.sent 事件)
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - G10:getDb() 函数式
|
||||
* - G11:cuid2 ID
|
||||
* - 02-architecture-design.md §2.4:in_app 总是发送,其他渠道按偏好
|
||||
*/
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(private readonly channelDispatcher: ChannelDispatcherService) {}
|
||||
|
||||
async send(dto: SendNotificationDto): Promise<SendResult> {
|
||||
const id = randomUUID();
|
||||
const channel = dto.channel ?? "in_app";
|
||||
|
||||
const [pref] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, dto.userId));
|
||||
|
||||
if (pref && !this.isChannelEnabled(pref, channel)) {
|
||||
logger.info(
|
||||
{ userId: dto.userId, channel },
|
||||
"Notification skipped by preference",
|
||||
);
|
||||
return { id, skipped: true, pushed: false };
|
||||
// 幂等:eventId 去重
|
||||
if (dto.eventId) {
|
||||
const existing = await repo.findByEventId(dto.eventId);
|
||||
if (existing) {
|
||||
logger.info(
|
||||
{ eventId: dto.eventId, id: existing.id },
|
||||
"Notification already exists (idempotent skip)",
|
||||
);
|
||||
return {
|
||||
id: existing.id,
|
||||
status: existing.status,
|
||||
channels: [existing.channel],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await db.insert(notifications).values({
|
||||
const id = createId();
|
||||
const channel = (dto.channel ?? "in_app") as NotificationChannel;
|
||||
|
||||
// 写入 MySQL
|
||||
await repo.insertNotification({
|
||||
id,
|
||||
userId: dto.userId,
|
||||
type: dto.type,
|
||||
type: dto.type as Notification["type"],
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
channel,
|
||||
metadata: dto.metadata,
|
||||
isRead: false,
|
||||
status: "pending",
|
||||
metadata: dto.metadata ?? null,
|
||||
relatedEntityType: dto.relatedEntityType ?? null,
|
||||
relatedEntityId: dto.relatedEntityId ?? null,
|
||||
groupId: dto.groupId ?? null,
|
||||
senderId: dto.senderId ?? null,
|
||||
templateId: dto.templateId ?? null,
|
||||
eventId: dto.eventId ?? null,
|
||||
});
|
||||
|
||||
// 写入 ES(降级安全)
|
||||
await safeIndex({
|
||||
index: "notifications",
|
||||
id,
|
||||
document: {
|
||||
userId: dto.userId,
|
||||
id,
|
||||
user_id: dto.userId,
|
||||
type: dto.type,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
channel,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
group_id: dto.groupId ?? null,
|
||||
related_entity_type: dto.relatedEntityType ?? null,
|
||||
related_entity_id: dto.relatedEntityId ?? null,
|
||||
sender_id: dto.senderId ?? null,
|
||||
is_read: false,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const pushed = await this.notifyPushGateway(dto, channel);
|
||||
// 查询用户偏好
|
||||
const enabledChannels = await this.getUserChannels(dto.userId, dto.type);
|
||||
|
||||
return { id, skipped: false, pushed };
|
||||
// 渠道分发
|
||||
const ctx: ChannelSendContext = {
|
||||
notificationId: id,
|
||||
userId: dto.userId,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
type: dto.type,
|
||||
metadata: dto.metadata ?? null,
|
||||
relatedEntityType: dto.relatedEntityType,
|
||||
relatedEntityId: dto.relatedEntityId,
|
||||
};
|
||||
const results = await this.channelDispatcher.dispatch(ctx, enabledChannels);
|
||||
|
||||
// 更新状态为 sent
|
||||
const anySent = results.some((r) => r.sent);
|
||||
await this.updateStatus(id, anySent ? "sent" : "failed");
|
||||
|
||||
// 写入 Outbox(发布 notification.sent 事件)
|
||||
await outboxPublish(
|
||||
"notification.sent",
|
||||
{
|
||||
notificationId: id,
|
||||
userId: dto.userId,
|
||||
type: dto.type,
|
||||
channel,
|
||||
channels: results.map((r) => r.channel),
|
||||
},
|
||||
{
|
||||
aggregateType: "Notification",
|
||||
aggregateId: id,
|
||||
metadata: { userId: dto.userId },
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
status: anySent ? "sent" : "failed",
|
||||
channels: results.map((r) => r.channel),
|
||||
};
|
||||
}
|
||||
|
||||
async createBatch(dtos: SendNotificationDto[]): Promise<SendResult[]> {
|
||||
const results: SendResult[] = [];
|
||||
for (const dto of dtos) {
|
||||
const result = await this.send(dto);
|
||||
results.push(result);
|
||||
async sendBatch(dto: SendNotificationBatchDto): Promise<BatchSendResult> {
|
||||
const groupId = dto.groupId ?? createId();
|
||||
const ids: string[] = [];
|
||||
const failed: { userId: string; error: string }[] = [];
|
||||
|
||||
for (const item of dto.items) {
|
||||
try {
|
||||
const result = await this.send({ ...item, groupId });
|
||||
ids.push(result.id);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
failed.push({ userId: item.userId, error });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
|
||||
return { ids, failed };
|
||||
}
|
||||
|
||||
async listByUser(
|
||||
userId: string,
|
||||
onlyUnread: boolean = false,
|
||||
): Promise<Notification[]> {
|
||||
const conditions = onlyUnread
|
||||
? and(eq(notifications.userId, userId), eq(notifications.isRead, false))
|
||||
: eq(notifications.userId, userId);
|
||||
return db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(conditions)
|
||||
.orderBy(desc(notifications.createdAt));
|
||||
}
|
||||
|
||||
async listByUserWithPagination(
|
||||
userId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
options: {
|
||||
onlyUnread?: boolean;
|
||||
type?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
},
|
||||
): Promise<PaginatedResult> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const items = await db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(eq(notifications.userId, userId))
|
||||
.orderBy(desc(notifications.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(notifications)
|
||||
.where(eq(notifications.userId, userId));
|
||||
|
||||
const total = countRow ? Number(countRow.count) : 0;
|
||||
|
||||
return { items, total, page, pageSize };
|
||||
const { items, total } = await repo.listByUser(userId, options);
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async markAsRead(id: string): Promise<void> {
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
return repo.getUnreadCount(userId);
|
||||
}
|
||||
|
||||
async markAsRead(id: string, userId: string): Promise<void> {
|
||||
await repo.markAsRead(id, userId);
|
||||
|
||||
// 发布 notification.read 事件
|
||||
await outboxPublish(
|
||||
"notification.read",
|
||||
{ notificationId: id, userId },
|
||||
{
|
||||
aggregateType: "Notification",
|
||||
aggregateId: id,
|
||||
metadata: { userId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async batchMarkAsRead(ids: string[], userId: string): Promise<void> {
|
||||
await repo.batchMarkAsRead(ids, userId);
|
||||
|
||||
// 批量发布 read 事件
|
||||
for (const id of ids) {
|
||||
await outboxPublish(
|
||||
"notification.read",
|
||||
{ notificationId: id, userId },
|
||||
{
|
||||
aggregateType: "Notification",
|
||||
aggregateId: id,
|
||||
metadata: { userId },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string, before?: number): Promise<number> {
|
||||
const updated = await repo.markAllAsRead(userId, before);
|
||||
|
||||
// 发布 read 事件
|
||||
await outboxPublish(
|
||||
"notification.read",
|
||||
{ userId, before: before ?? Date.now(), bulk: true },
|
||||
{
|
||||
aggregateType: "Notification",
|
||||
aggregateId: userId,
|
||||
metadata: { userId, bulk: "true" },
|
||||
},
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
options: { type?: string; page: number; pageSize: number },
|
||||
): Promise<SearchResult> {
|
||||
const from = (options.page - 1) * options.pageSize;
|
||||
const must: Record<string, unknown>[] = [
|
||||
{ term: { user_id: userId } },
|
||||
{ multi_match: { query, fields: ["title", "content"] } },
|
||||
];
|
||||
if (options.type) {
|
||||
must.push({ term: { type: options.type } });
|
||||
}
|
||||
|
||||
const result = await safeSearch(
|
||||
"notifications",
|
||||
{ bool: { must } },
|
||||
from,
|
||||
options.pageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
items: result.hits.map((h) => h._source),
|
||||
total: result.total,
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async recall(groupId: string): Promise<number> {
|
||||
const recalled = await repo.recallByGroup(groupId);
|
||||
|
||||
// 同步删除 ES 索引(撤回的通知不再可搜索)
|
||||
void this.deleteEsByGroup(groupId);
|
||||
|
||||
// 发布 notification.recalled 事件
|
||||
await outboxPublish(
|
||||
"notification.recalled",
|
||||
{ groupId, recalledCount: recalled },
|
||||
{
|
||||
aggregateType: "Notification",
|
||||
aggregateId: groupId,
|
||||
metadata: { groupId },
|
||||
},
|
||||
);
|
||||
|
||||
return recalled;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await repo.deleteById(id);
|
||||
await safeDelete("notifications", id);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 内部方法
|
||||
// ============================================================
|
||||
|
||||
private async getUserChannels(
|
||||
userId: string,
|
||||
type: string,
|
||||
): Promise<NotificationChannel[] | null> {
|
||||
const db = getDb();
|
||||
const [pref] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationPreferences.userId, userId),
|
||||
eq(notificationPreferences.type, type as Notification["type"]),
|
||||
eq(notificationPreferences.enabled, true),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return pref?.channels ?? null;
|
||||
}
|
||||
|
||||
private async updateStatus(
|
||||
id: string,
|
||||
status: NotificationStatus,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ isRead: true })
|
||||
.set({ status })
|
||||
.where(eq(notifications.id, id));
|
||||
}
|
||||
|
||||
async search(userId: string, query: string): Promise<unknown[]> {
|
||||
const result = await safeSearch("notifications", {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { userId } },
|
||||
{
|
||||
multi_match: {
|
||||
query,
|
||||
fields: ["title", "content"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return result.hits;
|
||||
}
|
||||
|
||||
private isChannelEnabled(
|
||||
pref: NotificationPreference,
|
||||
channel: string,
|
||||
): boolean {
|
||||
switch (channel) {
|
||||
case "email":
|
||||
return pref.emailEnabled;
|
||||
case "sms":
|
||||
return pref.smsEnabled;
|
||||
case "push":
|
||||
return pref.pushEnabled;
|
||||
case "in_app":
|
||||
return pref.inAppEnabled;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 Push Gateway 推送通知。Push Gateway 不可用时 try/catch 跳过(降级模式)。
|
||||
* 仅在 PUSH_GATEWAY_URL 配置且 channel 为 push 或 in_app 时触发。
|
||||
*/
|
||||
private async notifyPushGateway(
|
||||
dto: SendNotificationDto,
|
||||
channel: string,
|
||||
): Promise<boolean> {
|
||||
if (!env.PUSH_GATEWAY_URL) return false;
|
||||
if (channel !== "push" && channel !== "in_app") return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${env.PUSH_GATEWAY_URL}/internal/push`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: dto.userId,
|
||||
type: dto.type,
|
||||
title: dto.title,
|
||||
content: dto.content,
|
||||
channel,
|
||||
metadata: dto.metadata,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn(
|
||||
{ status: res.status },
|
||||
"Push gateway returned non-ok status",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Push gateway unavailable, skipping push");
|
||||
return false;
|
||||
}
|
||||
private async deleteEsByGroup(groupId: string): Promise<void> {
|
||||
// ES 删除按 group_id 查询后逐条删除(ES 无批量 delete by query 在 safeDelete 封装中)
|
||||
// P5 简化:仅记录日志,实际清理走后台任务
|
||||
logger.info(
|
||||
{ groupId },
|
||||
"ES cleanup for recalled notifications (deferred)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user