feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档
This commit is contained in:
@@ -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