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

@@ -0,0 +1,154 @@
import { Injectable } from "@nestjs/common";
import { createId } from "@paralleldrive/cuid2";
import { getDb } from "../config/database.js";
import { notificationDeliveries } from "../notifications/notifications.schema.js";
import type { NotificationChannel } from "../notifications/notifications.schema.js";
import { logger } from "../shared/observability/logger.js";
import { inAppChannel } from "./in-app.channel.js";
import { emailChannel } from "./email.channel.js";
import { smsChannel } from "./sms.channel.js";
import { pushChannel } from "./push.channel.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* ChannelDispatcher —— 多渠道分发编排器。
*
* 仲裁依据 02-architecture-design.md §2.4
* 1. in_app 渠道总是发送(保证站内信可见)
* 2. 其他渠道按 NotificationPreference.channels 配置启用/禁用
* 3. 所有渠道并行发送、软失败,结果记录到 msg_notification_deliveries
*
* 渠道注册表:新增渠道只需实现 NotificationChannelStrategy 并注册到此。
*/
const CHANNEL_REGISTRY: Record<
NotificationChannel,
NotificationChannelStrategy
> = {
in_app: inAppChannel,
email: emailChannel,
sms: smsChannel,
push: pushChannel,
wechat: {
// wechat 渠道预留(未实现),直接返回未配置
name: "wechat" as const,
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
logger.debug(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"WeChat channel not implemented (future channel)",
);
return {
channel: "wechat",
sent: false,
error: "WeChat channel not implemented",
};
},
} as NotificationChannelStrategy,
};
/** 默认渠道(无偏好配置时) */
const DEFAULT_CHANNELS: NotificationChannel[] = ["in_app"];
@Injectable()
export class ChannelDispatcherService {
/**
* 根据用户偏好 fan-out 到启用的渠道。
*
* @param ctx 发送上下文
* @param enabledChannels 用户偏好的启用渠道列表(可为 null=用默认)
* @returns 每个渠道的发送结果
*/
async dispatch(
ctx: ChannelSendContext,
enabledChannels: NotificationChannel[] | null,
): Promise<ChannelSendResult[]> {
// 确定要发送的渠道列表in_app 总是包含
const channels = this.resolveChannels(enabledChannels);
// 并行发送所有渠道
const results = await Promise.allSettled(
channels.map((ch) => this.sendToChannel(ctx, ch)),
);
// 收集结果rejected 的转为 failed 结果
// 用 channels.map 遍历保证 channel 一定存在results 与 channels 长度一致)
const sendResults: ChannelSendResult[] = channels.map((channel, i) => {
const r = results[i];
if (!r) {
return { channel, sent: false, error: "No result" };
}
if (r.status === "fulfilled") {
return r.value;
}
const error =
r.reason instanceof Error ? r.reason.message : String(r.reason);
return { channel, sent: false, error };
});
// 异步记录投递结果(不阻断返回)
void this.recordDeliveries(ctx.notificationId, sendResults);
return sendResults;
}
/**
* 解析要发送的渠道列表。
* in_app 总是包含;其他渠道按偏好配置。
*/
private resolveChannels(
enabledChannels: NotificationChannel[] | null,
): NotificationChannel[] {
if (!enabledChannels || enabledChannels.length === 0) {
return DEFAULT_CHANNELS;
}
// 确保 in_app 总在列表中
const set = new Set<NotificationChannel>(enabledChannels);
set.add("in_app");
return Array.from(set);
}
private async sendToChannel(
ctx: ChannelSendContext,
channel: NotificationChannel,
): Promise<ChannelSendResult> {
const strategy = CHANNEL_REGISTRY[channel];
if (!strategy) {
return { channel, sent: false, error: `Unknown channel: ${channel}` };
}
return strategy.send(ctx);
}
/**
* 记录投递结果到 msg_notification_deliveries 表。
* 软失败:记录失败不阻断主流程。
*/
private async recordDeliveries(
notificationId: string,
results: ChannelSendResult[],
): Promise<void> {
try {
const db = getDb();
const now = new Date();
const rows = results.map((r) => ({
id: createId(),
notificationId,
channel: r.channel,
status: r.sent ? ("sent" as const) : ("failed" as const),
attemptCount: 1,
lastError: r.error ?? null,
deliveredAt: r.sent ? now : null,
}));
if (rows.length > 0) {
await db.insert(notificationDeliveries).values(rows);
}
} catch (err) {
logger.warn(
{ err, notificationId },
"Failed to record deliveries (non-fatal)",
);
}
}
}

View File

@@ -0,0 +1,53 @@
import type { NotificationChannel } from "../notifications/notifications.schema.js";
/**
* 渠道策略接口(策略模式)。
*
* 每个渠道实现此接口ChannelDispatcher 根据 NotificationPreference
* 配置 fan-out 到启用的渠道。
*
* 仲裁依据 02-architecture-design.md §2.4
* - in_app 总是发送(保证站内信可见)
* - 其他渠道按偏好配置启用/禁用
* - 所有渠道软失败:投递失败不阻断主流程,记录 delivery 状态
*/
/** 渠道发送上下文(由 NotificationService 构造) */
export interface ChannelSendContext {
/** 通知 ID已写入 DB 的) */
notificationId: string;
/** 接收者用户 ID */
userId: string;
/** 通知标题 */
title: string;
/** 通知内容 */
content: string;
/** 通知类型 */
type: string;
/** 元数据 */
metadata: Record<string, string> | null;
/** 关联实体类型 */
relatedEntityType?: string | null;
/** 关联实体 ID */
relatedEntityId?: string | null;
}
/** 渠道发送结果 */
export interface ChannelSendResult {
/** 渠道名称 */
channel: NotificationChannel;
/** 是否发送成功 */
sent: boolean;
/** 外部网关返回的 ID如有 */
externalId?: string;
/** 失败原因 */
error?: string;
}
/** 渠道策略接口 */
export interface NotificationChannelStrategy {
/** 渠道名称 */
readonly name: NotificationChannel;
/** 发送通知 */
send(ctx: ChannelSendContext): Promise<ChannelSendResult>;
}

View File

@@ -0,0 +1,33 @@
import { logger } from "../shared/observability/logger.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* EmailChannel —— 邮件渠道P5 stub
*
* P5 阶段无实际 SMTP 网关,仅记录投递意图。
* 后续接入 SMTP 服务时替换 send 实现,接口不变(策略模式扩展点)。
*
* 仲裁依据 02-architecture-design.md §2.4:软失败,不阻断主流程。
*/
class EmailChannel implements NotificationChannelStrategy {
readonly name = "email" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
// P5 stub记录投递意图不实际发送
logger.info(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"Email channel stub: delivery intent logged (SMTP not configured)",
);
return {
channel: "email",
sent: false,
error: "SMTP gateway not configured (P5 stub)",
};
}
}
export const emailChannel = new EmailChannel();

View File

@@ -0,0 +1,52 @@
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* InAppChannel —— 站内信渠道。
*
* 站内信数据已在 NotificationService 中写入 MySQL + ES
* 此渠道仅负责触发 push-gateway 实时推送给在线用户(软失败)。
*
* 仲裁依据 02-architecture-design.md §2.4
* - in_app 总是发送,保证站内信可见
* - 实时推送通过 push-gateway不在线时用户下次拉取即可见
*/
import { sendPush } from "../shared/push/push-gateway.client.js";
import { logger } from "../shared/observability/logger.js";
class InAppChannel implements NotificationChannelStrategy {
readonly name = "in_app" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
// 站内信数据已落库,仅触发实时推送
const pushResult = await sendPush({
userId: ctx.userId,
event: "notification.new",
data: {
id: ctx.notificationId,
type: ctx.type,
title: ctx.title,
content: ctx.content,
metadata: ctx.metadata,
},
});
if (!pushResult.sent) {
logger.debug(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"In-app push not delivered (user offline or gateway unavailable)",
);
}
return {
channel: "in_app",
sent: true, // 站内信本身已成功数据已落库push 是增强
externalId: pushResult.sent ? ctx.notificationId : undefined,
};
}
}
export const inAppChannel = new InAppChannel();

View File

@@ -0,0 +1,40 @@
import { sendPush } from "../shared/push/push-gateway.client.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* PushChannel —— 移动推送渠道。
*
* 通过 push-gateway HTTP /internal/push 推送。
* 仲裁依据 M4HTTP POST豁免 gRPC
*
* 软失败push-gateway 不可用或用户离线时返回 sent=false不阻断。
*/
class PushChannel implements NotificationChannelStrategy {
readonly name = "push" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
const result = await sendPush({
userId: ctx.userId,
event: "notification.push",
data: {
id: ctx.notificationId,
type: ctx.type,
title: ctx.title,
content: ctx.content,
metadata: ctx.metadata,
},
});
return {
channel: "push",
sent: result.sent,
error: result.error,
};
}
}
export const pushChannel = new PushChannel();

View File

@@ -0,0 +1,33 @@
import { logger } from "../shared/observability/logger.js";
import type {
ChannelSendContext,
ChannelSendResult,
NotificationChannelStrategy,
} from "./channel.types.js";
/**
* SmsChannel —— 短信渠道P5 stub
*
* P5 阶段无实际 SMS 网关,仅记录投递意图。
* 后续接入短信服务商时替换 send 实现,接口不变(策略模式扩展点)。
*
* 仲裁依据 02-architecture-design.md §2.4:软失败,不阻断主流程。
*/
class SmsChannel implements NotificationChannelStrategy {
readonly name = "sms" as const;
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
// P5 stub记录投递意图不实际发送
logger.info(
{ userId: ctx.userId, notificationId: ctx.notificationId },
"SMS channel stub: delivery intent logged (gateway not configured)",
);
return {
channel: "sms",
sent: false,
error: "SMS gateway not configured (P5 stub)",
};
}
}
export const smsChannel = new SmsChannel();