feat(push-gateway,msg): redis pubsub backplane for real-time notifications
M7: ADR-040 Redis Pub/Sub as state routing backplane
- push-gateway: remove Kafka consumer, add SSE endpoint
- SSE: subscribe to Redis user:{userId}:notify on connect
- msg: publish notifications to Redis Pub/Sub instead of HTTP push
- docker-compose: remove Kafka env from push-gateway
This commit is contained in:
@@ -8,21 +8,24 @@ import type {
|
||||
* InAppChannel —— 站内信渠道。
|
||||
*
|
||||
* 站内信数据已在 NotificationService 中写入 MySQL + ES,
|
||||
* 此渠道仅负责触发 push-gateway 实时推送给在线用户(软失败)。
|
||||
* 此渠道仅负责触发实时推送给在线用户(软失败)。
|
||||
*
|
||||
* M7 (ADR-040):实时推送通过 Redis Pub/Sub backplane 发布到
|
||||
* `user:{userId}:notify` 通道,realtime-gateway SSE 端点订阅并转发。
|
||||
*
|
||||
* 仲裁依据 02-architecture-design.md §2.4:
|
||||
* - in_app 总是发送,保证站内信可见
|
||||
* - 实时推送通过 push-gateway,不在线时用户下次拉取即可见
|
||||
* - 实时推送通过 Redis Pub/Sub,不在线时用户下次拉取即可见
|
||||
*/
|
||||
import { sendPush } from "../shared/push/push-gateway.client.js";
|
||||
import { publishToRedisPubSub } from "../shared/push/redis-publisher.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({
|
||||
// 站内信数据已落库,仅触发实时推送(M7:Redis Pub/Sub backplane)
|
||||
const pushResult = await publishToRedisPubSub({
|
||||
userId: ctx.userId,
|
||||
event: "notification.new",
|
||||
data: {
|
||||
@@ -37,7 +40,7 @@ class InAppChannel implements NotificationChannelStrategy {
|
||||
if (!pushResult.sent) {
|
||||
logger.debug(
|
||||
{ userId: ctx.userId, notificationId: ctx.notificationId },
|
||||
"In-app push not delivered (user offline or gateway unavailable)",
|
||||
"In-app real-time push not delivered (user offline or Redis unavailable)",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sendPush } from "../shared/push/push-gateway.client.js";
|
||||
import { publishToRedisPubSub } from "../shared/push/redis-publisher.js";
|
||||
import type {
|
||||
ChannelSendContext,
|
||||
ChannelSendResult,
|
||||
@@ -8,16 +8,16 @@ import type {
|
||||
/**
|
||||
* PushChannel —— 移动推送渠道。
|
||||
*
|
||||
* 通过 push-gateway HTTP /internal/push 推送。
|
||||
* 仲裁依据 M4:HTTP POST(豁免 gRPC)。
|
||||
* M7 (ADR-040):通过 Redis Pub/Sub backplane 发布到
|
||||
* `user:{userId}:notify` 通道,realtime-gateway SSE 端点订阅并转发。
|
||||
*
|
||||
* 软失败:push-gateway 不可用或用户离线时返回 sent=false,不阻断。
|
||||
* 软失败:Redis 不可用或用户离线时返回 sent=false,不阻断。
|
||||
*/
|
||||
class PushChannel implements NotificationChannelStrategy {
|
||||
readonly name = "push" as const;
|
||||
|
||||
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
|
||||
const result = await sendPush({
|
||||
const result = await publishToRedisPubSub({
|
||||
userId: ctx.userId,
|
||||
event: "notification.push",
|
||||
data: {
|
||||
|
||||
78
services/msg/src/shared/push/redis-publisher.ts
Normal file
78
services/msg/src/shared/push/redis-publisher.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { getRedis } from "../redis/redis.client.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
|
||||
/**
|
||||
* Redis Pub/Sub 实时推送发布器(M7 / ADR-040)。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M7:msg 服务消费 Kafka(持久化、脱敏)后,通过 Redis Pub/Sub 发布实时推送
|
||||
* - 通道命名:`user:{userId}:notify`
|
||||
* - realtime-gateway 实例仅在用户连接 SSE 时 SUBSCRIBE 该通道
|
||||
* - 软失败:Redis 不可用时返回 { sent: false },不阻断主流程
|
||||
*
|
||||
* 消息格式:JSON 字符串,由 realtime-gateway SSE 端点原样转发为 `data: {json}\n\n`
|
||||
*/
|
||||
|
||||
export interface RedisPushRequest {
|
||||
userId: string;
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RedisPushResult {
|
||||
sent: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 Redis Pub/Sub 通道名。
|
||||
* 通道命名规范(M7):`user:{userId}:notify`
|
||||
*/
|
||||
export function notifyChannel(userId: string): string {
|
||||
return `user:${userId}:notify`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 Redis Pub/Sub 发布实时通知到用户的通道。
|
||||
*
|
||||
* 软失败语义:
|
||||
* - REDIS_URL 未配置 → 返回 { sent: false },不报错
|
||||
* - Redis 发布错误 → 返回 { sent: false, error },logger.warn
|
||||
* - 成功 → 返回 { sent: true }
|
||||
*
|
||||
* 消息体为 JSON 字符串,realtime-gateway SSE 端点原样转发为 SSE data 帧。
|
||||
*/
|
||||
export async function publishToRedisPubSub(
|
||||
req: RedisPushRequest,
|
||||
): Promise<RedisPushResult> {
|
||||
const client = getRedis();
|
||||
if (!client) {
|
||||
return { sent: false };
|
||||
}
|
||||
|
||||
const channel = notifyChannel(req.userId);
|
||||
const payload = JSON.stringify({
|
||||
event: req.event,
|
||||
data: req.data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const receivers = await client.publish(channel, payload);
|
||||
if (receivers === 0) {
|
||||
// 无订阅者:用户未通过 SSE 连接,不算错误(站内信仍可见)
|
||||
logger.debug(
|
||||
{ userId: req.userId, channel },
|
||||
"Redis Pub/Sub: no active SSE subscribers (user offline)",
|
||||
);
|
||||
}
|
||||
return { sent: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
logger.warn(
|
||||
{ err, userId: req.userId, channel },
|
||||
"Redis Pub/Sub publish failed",
|
||||
);
|
||||
return { sent: false, error };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user