feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
154
services/msg/src/channels/channel-dispatcher.service.ts
Normal file
154
services/msg/src/channels/channel-dispatcher.service.ts
Normal 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
services/msg/src/channels/channel.types.ts
Normal file
53
services/msg/src/channels/channel.types.ts
Normal 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>;
|
||||
}
|
||||
33
services/msg/src/channels/email.channel.ts
Normal file
33
services/msg/src/channels/email.channel.ts
Normal 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();
|
||||
52
services/msg/src/channels/in-app.channel.ts
Normal file
52
services/msg/src/channels/in-app.channel.ts
Normal 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();
|
||||
40
services/msg/src/channels/push.channel.ts
Normal file
40
services/msg/src/channels/push.channel.ts
Normal 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 推送。
|
||||
* 仲裁依据 M4:HTTP 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();
|
||||
33
services/msg/src/channels/sms.channel.ts
Normal file
33
services/msg/src/channels/sms.channel.ts
Normal 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();
|
||||
Reference in New Issue
Block a user