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

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

View File

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

View File

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