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:
SpecialX
2026-07-15 01:28:55 +08:00
parent a75527be80
commit 6af1aa0d82
14 changed files with 402 additions and 484 deletions

View File

@@ -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({
// 站内信数据已落库,仅触发实时推送M7Redis 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)",
);
}

View File

@@ -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 推送。
* 仲裁依据 M4HTTP 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: {

View File

@@ -0,0 +1,78 @@
import { getRedis } from "../redis/redis.client.js";
import { logger } from "../observability/logger.js";
/**
* Redis Pub/Sub 实时推送发布器M7 / ADR-040
*
* 仲裁依据:
* - M7msg 服务消费 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 };
}
}