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,98 @@
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
import { getDb } from "../../config/database.js";
import { getRedis } from "./redis.client.js";
import { logger } from "../observability/logger.js";
import { processedEvents } from "../outbox/outbox.schema.js";
/**
* 幂等去重守卫。
*
* 三层防线(仲裁依据 02-architecture-design.md §3.3.1 + §5
* 1. Redis SETNX首选高性能key=`msg:processed:{eventId}` TTL 7 天
* 2. DB 唯一索引Redis 不可用时降级msg_idempotency 表 event_id UNIQUE
* 3. 业务 event_id UNIQUE INDEXmsg_notifications.event_id最终防线
*
* 调用方在处理 Kafka 消息或 HTTP send 时,先调 checkAndMark(eventId)
* - 返回 true → 首次处理,继续业务逻辑
* - 返回 false → 已处理过,跳过(幂等)
*/
const REDIS_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 天
export interface IdempotencyResult {
/** true=首次处理可继续false=已处理过应跳过 */
isFirst: boolean;
/** 去重使用的 key */
key: string;
}
/**
* 检查并标记事件为已处理。
*
* 优先走 Redis SETNXRedis 不可用时降级到 DB 唯一索引插入。
* 两者均不可用(极端情况)返回 true 放行,由业务层 event_id UNIQUE 兜底。
*/
export async function checkAndMark(
eventId: string,
topic?: string,
): Promise<IdempotencyResult> {
const key = `msg:processed:${eventId}`;
// 1. Redis 优先
const redis = getRedis();
if (redis) {
try {
const result = await redis.set(key, "1", "EX", REDIS_TTL_SECONDS, "NX");
if (result === "OK") {
return { isFirst: true, key };
}
return { isFirst: false, key };
} catch (err) {
logger.warn({ err, eventId }, "Redis SETNX failed, falling back to DB");
}
}
// 2. DB 降级
try {
const db = getDb();
await db.insert(processedEvents).values({
eventId,
topic: topic ?? "unknown",
});
return { isFirst: true, key };
} catch (err) {
// 唯一索引冲突 = 已处理
logger.debug({ eventId, err }, "Idempotency DB hit (already processed)");
return { isFirst: false, key };
}
}
/**
* 仅检查不标记(用于查询是否已处理,不产生副作用)。
*/
export async function isProcessed(eventId: string): Promise<boolean> {
const redis = getRedis();
if (redis) {
try {
const exists = await redis.exists(`msg:processed:${eventId}`);
return exists === 1;
} catch {
// fall through to DB
}
}
const db = getDb();
const [row] = await db
.select({ eventId: processedEvents.eventId })
.from(processedEvents)
.where(eq(processedEvents.eventId, eventId))
.limit(1);
return row !== undefined;
}
/**
* 生成幂等键HTTP send 无外部 eventId 时使用)。
* 格式cuid2保证全局唯一。
*/
export function generateIdempotencyKey(): string {
return createId();
}

View File

@@ -0,0 +1,64 @@
import { Redis } from "ioredis";
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
/**
* msg 服务 Redis 客户端。
*
* 仲裁依据:
* - REDIS_URL 可选,未配置时 redisClient=null降级到 DB 唯一索引去重
* - 用途幂等去重SETNX、已读状态位图、未读计数缓存、频率限流
*
* 使用 lazy initialization连接在首次调用 getRedis() 时建立。
*/
let redisInstance: Redis | null = null;
let connectAttempted = false;
export function getRedis(): Redis | null {
if (!env.REDIS_URL) return null;
if (!redisInstance && !connectAttempted) {
connectAttempted = true;
try {
redisInstance = new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
redisInstance.on("error", (err) => {
logger.warn({ err }, "Redis client error");
});
redisInstance.on("connect", () => {
logger.info("Redis connected");
});
} catch (err) {
logger.warn({ err }, "Redis init failed, falling back to DB idempotency");
redisInstance = null;
}
}
return redisInstance;
}
export async function checkRedisConnection(): Promise<void> {
const client = getRedis();
if (!client) {
logger.info("Redis disabled (REDIS_URL not set)");
return;
}
try {
const pong = await client.ping();
if (pong === "PONG") {
logger.info("Redis connection healthy");
}
} catch (err) {
logger.warn({ err }, "Redis connection check failed");
}
}
export async function closeRedis(): Promise<void> {
if (redisInstance) {
await redisInstance.quit();
redisInstance = null;
connectAttempted = false;
logger.info("Redis disconnected");
}
}