feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
98
services/msg/src/shared/redis/idempotency.guard.ts
Normal file
98
services/msg/src/shared/redis/idempotency.guard.ts
Normal 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 INDEX(msg_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 SETNX;Redis 不可用时降级到 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();
|
||||
}
|
||||
64
services/msg/src/shared/redis/redis.client.ts
Normal file
64
services/msg/src/shared/redis/redis.client.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user