feat(msg): 完整实现 msg 消息服务

包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:09:52 +08:00
parent 21530dc7f6
commit 7b7abbb309
51 changed files with 5204 additions and 371 deletions

View File

@@ -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/searchES 全文检索)
* - 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 };
}
}

View File

@@ -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>;

View File

@@ -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 {}

View 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));
}

View File

@@ -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(表清单 + 字段 + 索引)
* - G11ID 用 cuid2varchar(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;

View File

@@ -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 事件)
*
* 仲裁依据:
* - G10getDb() 函数式
* - G11cuid2 ID
* - 02-architecture-design.md §2.4in_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)",
);
}
}