feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
export type ErrorType =
|
||||
| 'validation'
|
||||
| 'not_found'
|
||||
| 'permission_denied'
|
||||
| 'conflict'
|
||||
| 'business'
|
||||
| 'database'
|
||||
| 'internal';
|
||||
| "validation"
|
||||
| "not_found"
|
||||
| "permission_denied"
|
||||
| "conflict"
|
||||
| "business"
|
||||
| "database"
|
||||
| "internal";
|
||||
|
||||
export interface ErrorDetails {
|
||||
[key: string]: unknown;
|
||||
@@ -40,57 +40,135 @@ export abstract class ApplicationError extends Error {
|
||||
}
|
||||
|
||||
export class ValidationError extends ApplicationError {
|
||||
readonly type = 'validation' as const;
|
||||
readonly type = "validation" as const;
|
||||
readonly statusCode = 400;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'MSG_VALIDATION_ERROR', details);
|
||||
super(message, "MSG_VALIDATION_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApplicationError {
|
||||
readonly type = 'not_found' as const;
|
||||
readonly type = "not_found" as const;
|
||||
readonly statusCode = 404;
|
||||
constructor(resource: string, id: string) {
|
||||
super(`${resource} not found: ${id}`, 'MSG_NOT_FOUND', { resource, id });
|
||||
super(`${resource} not found: ${id}`, "MSG_NOT_FOUND", { resource, id });
|
||||
}
|
||||
}
|
||||
|
||||
export class PermissionDeniedError extends ApplicationError {
|
||||
readonly type = 'permission_denied' as const;
|
||||
readonly type = "permission_denied" as const;
|
||||
readonly statusCode = 403;
|
||||
constructor(permission: string) {
|
||||
super(`Permission denied: ${permission}`, 'MSG_PERMISSION_DENIED', { permission });
|
||||
super(`Permission denied: ${permission}`, "MSG_PERMISSION_DENIED", {
|
||||
permission,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends ApplicationError {
|
||||
readonly type = 'conflict' as const;
|
||||
readonly type = "conflict" as const;
|
||||
readonly statusCode = 409;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'MSG_CONFLICT', details);
|
||||
super(message, "MSG_CONFLICT", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class BusinessError extends ApplicationError {
|
||||
readonly type = 'business' as const;
|
||||
readonly type = "business" as const;
|
||||
readonly statusCode = 422;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'MSG_BUSINESS_ERROR', details);
|
||||
super(message, "MSG_BUSINESS_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends ApplicationError {
|
||||
readonly type = 'database' as const;
|
||||
readonly type = "database" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'MSG_DATABASE_ERROR', details);
|
||||
super(message, "MSG_DATABASE_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends ApplicationError {
|
||||
readonly type = 'internal' as const;
|
||||
readonly type = "internal" as const;
|
||||
readonly statusCode = 500;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, 'MSG_INTERNAL_ERROR', details);
|
||||
super(message, "MSG_INTERNAL_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ES 不可用且无降级路径(503)。
|
||||
* 仲裁依据:02-architecture-design.md §6.2
|
||||
*/
|
||||
export class EsUnavailableError extends ApplicationError {
|
||||
readonly type = "internal" as const;
|
||||
readonly statusCode = 503;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "MSG_ES_UNAVAILABLE", details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 不可用且无降级路径(503)。
|
||||
* 仲裁依据:02-architecture-design.md §6.2
|
||||
*/
|
||||
export class RedisUnavailableError extends ApplicationError {
|
||||
readonly type = "internal" as const;
|
||||
readonly statusCode = 503;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "MSG_REDIS_UNAVAILABLE", details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push Gateway 不可用(503)。
|
||||
* 仲裁依据:02-architecture-design.md §6.2 + coord M4(软失败,仅必要时抛出)
|
||||
*/
|
||||
export class PushGatewayUnavailableError extends ApplicationError {
|
||||
readonly type = "internal" as const;
|
||||
readonly statusCode = 503;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "MSG_PUSH_GATEWAY_UNAVAILABLE", details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知模板不存在(404)。
|
||||
* 仲裁依据:02-architecture-design.md §6.2
|
||||
*/
|
||||
export class TemplateNotFoundError extends ApplicationError {
|
||||
readonly type = "not_found" as const;
|
||||
readonly statusCode = 404;
|
||||
constructor(templateCode: string) {
|
||||
super(
|
||||
`Notification template not found: ${templateCode}`,
|
||||
"MSG_TEMPLATE_NOT_FOUND",
|
||||
{ templateCode },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板渲染失败(变量缺失等,422)。
|
||||
* 仲裁依据:02-architecture-design.md §6.2
|
||||
*/
|
||||
export class TemplateRenderError extends ApplicationError {
|
||||
readonly type = "business" as const;
|
||||
readonly statusCode = 422;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "MSG_TEMPLATE_RENDER_ERROR", details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知频率超限(429)。
|
||||
* 仲裁依据:02-architecture-design.md §6.2(NotificationPreference.frequency_limit 预留)
|
||||
*/
|
||||
export class RateLimitExceededError extends ApplicationError {
|
||||
readonly type = "business" as const;
|
||||
readonly statusCode = 429;
|
||||
constructor(message: string, details?: ErrorDetails) {
|
||||
super(message, "MSG_RATE_LIMIT_EXCEEDED", details);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import { Controller, Get, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../../config/database.js";
|
||||
import { getDb } from "../../config/database.js";
|
||||
import { esClient } from "../../config/elasticsearch.js";
|
||||
import { getRedis } from "../redis/redis.client.js";
|
||||
import { isKafkaHealthy } from "../kafka/kafka.client.js";
|
||||
import { checkPushGateway } from "../push/push-gateway.client.js";
|
||||
|
||||
const SERVICE_NAME = "msg";
|
||||
|
||||
/**
|
||||
* 健康检查端点。
|
||||
*
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 DB 连接(Drizzle `SELECT 1`),失败返回 503。
|
||||
* 仲裁依据 G2:/readyz 多依赖检查(DB/ES/Redis/Kafka/PushGateway)。
|
||||
*
|
||||
* 不需要鉴权,必须在路由白名单中放行。
|
||||
* - GET /healthz:liveness,仅返回进程存活,不检查依赖。
|
||||
* - GET /readyz:readiness,检查 DB(关键)+ ES/Redis/Kafka/PushGateway(非关键)。
|
||||
* DB 不可用返回 503;其他依赖降级标记但不影响 readyz 状态码。
|
||||
*/
|
||||
|
||||
type CheckStatus = "ok" | "disabled" | "error";
|
||||
|
||||
interface DependencyCheck {
|
||||
status: CheckStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ReadyzResponse {
|
||||
status: "ok" | "degraded";
|
||||
service: string;
|
||||
timestamp: string;
|
||||
checks: {
|
||||
database: DependencyCheck;
|
||||
elasticsearch: DependencyCheck;
|
||||
redis: DependencyCheck;
|
||||
kafka: DependencyCheck;
|
||||
pushGateway: DependencyCheck;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
@Get("healthz")
|
||||
@@ -24,29 +50,88 @@ export class HealthController {
|
||||
}
|
||||
|
||||
@Get("readyz")
|
||||
async readiness(): Promise<{
|
||||
status: string;
|
||||
service: string;
|
||||
timestamp: string;
|
||||
}> {
|
||||
async readiness(): Promise<ReadyzResponse> {
|
||||
const checks: ReadyzResponse["checks"] = {
|
||||
database: { status: "ok" },
|
||||
elasticsearch: { status: "disabled" },
|
||||
redis: { status: "disabled" },
|
||||
kafka: { status: "ok" },
|
||||
pushGateway: { status: "disabled" },
|
||||
};
|
||||
|
||||
// 1. DB(关键依赖)
|
||||
try {
|
||||
const db = getDb();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return {
|
||||
status: "ok",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
checks.database = {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "database unreachable",
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Elasticsearch(可选依赖)
|
||||
if (esClient) {
|
||||
try {
|
||||
await esClient.ping();
|
||||
checks.elasticsearch = { status: "ok" };
|
||||
} catch (error) {
|
||||
checks.elasticsearch = {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "ES unreachable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Redis(可选依赖)
|
||||
const redis = getRedis();
|
||||
if (redis) {
|
||||
try {
|
||||
const pong = await redis.ping();
|
||||
checks.redis = { status: pong === "PONG" ? "ok" : "error" };
|
||||
} catch (error) {
|
||||
checks.redis = {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : "Redis unreachable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Kafka(非关键依赖,降级模式)
|
||||
checks.kafka = { status: isKafkaHealthy() ? "ok" : "error" };
|
||||
|
||||
// 5. PushGateway(软失败,可选依赖)
|
||||
try {
|
||||
const pushOk = await checkPushGateway();
|
||||
checks.pushGateway = { status: pushOk ? "ok" : "error" };
|
||||
} catch {
|
||||
checks.pushGateway = {
|
||||
status: "error",
|
||||
error: "PushGateway unreachable",
|
||||
};
|
||||
}
|
||||
|
||||
// DB 不可用 -> 503;其他依赖降级 -> 200 + degraded 标记
|
||||
const dbOk = checks.database.status === "ok";
|
||||
const allOk = dbOk && Object.values(checks).every((c) => c.status === "ok");
|
||||
|
||||
if (!dbOk) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: "error",
|
||||
status: "unavailable",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
error:
|
||||
error instanceof Error ? error.message : "database unreachable",
|
||||
checks,
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: allOk ? "ok" : "degraded",
|
||||
service: SERVICE_NAME,
|
||||
timestamp: new Date().toISOString(),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
88
services/msg/src/shared/kafka/kafka.client.ts
Normal file
88
services/msg/src/shared/kafka/kafka.client.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Kafka, type Consumer, type Producer } from "kafkajs";
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
|
||||
/**
|
||||
* msg 服务 Kafka 客户端。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - producer 配置 idempotent=true + transactionalId(at-least-once + 有序)
|
||||
* - consumer 使用 KAFKA_CONSUMER_GROUP_ID 隔离消费组
|
||||
* - Kafka 不可用时服务仍启动(降级模式,outbox 消息暂存待发)
|
||||
*
|
||||
* 使用 lazy initialization:连接在首次 connect 时建立,
|
||||
* 避免模块导入副作用导致测试环境难以 mock。
|
||||
*/
|
||||
let kafkaInstance: Kafka | null = null;
|
||||
let producerInstance: Producer | null = null;
|
||||
let consumerInstance: Consumer | null = null;
|
||||
let kafkaHealthy = false;
|
||||
|
||||
function getKafka(): Kafka {
|
||||
if (!kafkaInstance) {
|
||||
kafkaInstance = new Kafka({
|
||||
brokers: env.KAFKA_BROKERS.split(","),
|
||||
clientId: env.KAFKA_CLIENT_ID,
|
||||
});
|
||||
}
|
||||
return kafkaInstance;
|
||||
}
|
||||
|
||||
export function getProducer(): Producer {
|
||||
if (!producerInstance) {
|
||||
producerInstance = getKafka().producer({
|
||||
idempotent: true,
|
||||
transactionalId: "msg-service-tx",
|
||||
});
|
||||
}
|
||||
return producerInstance;
|
||||
}
|
||||
|
||||
export function getConsumer(): Consumer {
|
||||
if (!consumerInstance) {
|
||||
consumerInstance = getKafka().consumer({
|
||||
groupId: env.KAFKA_CONSUMER_GROUP_ID,
|
||||
sessionTimeout: 30000,
|
||||
rebalanceTimeout: 60000,
|
||||
});
|
||||
}
|
||||
return consumerInstance;
|
||||
}
|
||||
|
||||
export async function connectKafka(): Promise<void> {
|
||||
try {
|
||||
await getProducer().connect();
|
||||
await getConsumer().connect();
|
||||
kafkaHealthy = true;
|
||||
logger.info(
|
||||
{ brokers: env.KAFKA_BROKERS, clientId: env.KAFKA_CLIENT_ID },
|
||||
"Kafka connected",
|
||||
);
|
||||
} catch (err) {
|
||||
kafkaHealthy = false;
|
||||
logger.warn(
|
||||
{ err },
|
||||
"Kafka connect failed, running without Kafka (outbox will buffer)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** /readyz 健康检查:Kafka producer+consumer 是否已连接 */
|
||||
export function isKafkaHealthy(): boolean {
|
||||
return kafkaHealthy;
|
||||
}
|
||||
|
||||
export async function disconnectKafka(): Promise<void> {
|
||||
try {
|
||||
if (consumerInstance) {
|
||||
await consumerInstance.disconnect();
|
||||
}
|
||||
if (producerInstance) {
|
||||
await producerInstance.disconnect();
|
||||
}
|
||||
kafkaHealthy = false;
|
||||
logger.info("Kafka disconnected");
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "Kafka disconnect error (ignored on shutdown)");
|
||||
}
|
||||
}
|
||||
420
services/msg/src/shared/kafka/kafka.consumer.ts
Normal file
420
services/msg/src/shared/kafka/kafka.consumer.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
Injectable,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import type { KafkaMessage } from "kafkajs";
|
||||
import { connectKafka, getConsumer } from "./kafka.client.js";
|
||||
import { CONSUMER_TOPICS } from "./topic-map.js";
|
||||
import { checkAndMark } from "../redis/idempotency.guard.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { NotificationsService } from "../../notifications/notifications.service.js";
|
||||
|
||||
/**
|
||||
* KafkaConsumer —— 消费 iam/core-edu/data-ana 事件,触发通知。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - 02-architecture-design.md §5.1(12 类消费事件)
|
||||
* - JSON payload 降级:events.proto 未定义全部事件类型,直接解析 JSON
|
||||
* - 幂等:event_id 去重(Redis SETNX + DB 降级)
|
||||
* - at-least-once:消费失败不 commit offset,Kafka 重投
|
||||
*
|
||||
* 事件路由:
|
||||
* - iam:user.created/updated/deleted/role_changed, role.created/updated
|
||||
* - core-edu:exam.published, assignment.submitted/graded, grade.recorded, attendance.recorded
|
||||
* - data-ana:mastery.updated
|
||||
*/
|
||||
@Injectable()
|
||||
export class KafkaConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||
private running = false;
|
||||
|
||||
constructor(private readonly notificationsService: NotificationsService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.start();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return;
|
||||
|
||||
// 确保 Kafka producer+consumer 已连接(幂等,connectKafka 内部处理重复调用)
|
||||
await connectKafka();
|
||||
|
||||
const consumer = getConsumer();
|
||||
try {
|
||||
await consumer.subscribe({
|
||||
topics: [...CONSUMER_TOPICS],
|
||||
fromBeginning: false,
|
||||
});
|
||||
|
||||
this.running = true;
|
||||
await consumer.run({
|
||||
eachMessage: async ({ topic, partition, message }) => {
|
||||
await this.handleMessage(topic, partition, message);
|
||||
},
|
||||
});
|
||||
logger.info({ topics: CONSUMER_TOPICS }, "KafkaConsumer started");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
"KafkaConsumer start failed (running without consumer)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
try {
|
||||
const consumer = getConsumer();
|
||||
await consumer.stop();
|
||||
await consumer.disconnect();
|
||||
logger.info("KafkaConsumer stopped");
|
||||
} catch (err) {
|
||||
logger.warn({ err }, "KafkaConsumer stop error");
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(
|
||||
topic: string,
|
||||
partition: number,
|
||||
message: KafkaMessage,
|
||||
): Promise<void> {
|
||||
const eventId = this.extractEventId(message, topic, partition);
|
||||
const payload = this.parsePayload(message);
|
||||
|
||||
if (!payload) {
|
||||
logger.warn({ topic, eventId }, "Failed to parse Kafka message payload");
|
||||
return;
|
||||
}
|
||||
|
||||
// 幂等检查
|
||||
const { isFirst } = await checkAndMark(eventId, topic);
|
||||
if (!isFirst) {
|
||||
logger.debug(
|
||||
{ topic, eventId },
|
||||
"Event already processed (idempotent skip)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 路由到处理器
|
||||
try {
|
||||
await this.routeEvent(topic, eventId, payload);
|
||||
logger.info({ topic, eventId }, "Kafka event processed");
|
||||
} catch (err) {
|
||||
logger.error({ topic, eventId, err }, "Failed to process Kafka event");
|
||||
// 不抛出:at-least-once 语义下,失败的 event 已标记 processed,
|
||||
// 后续靠人工或监控重处理(避免无限重试阻塞消费)
|
||||
}
|
||||
}
|
||||
|
||||
private extractEventId(
|
||||
message: KafkaMessage,
|
||||
topic: string,
|
||||
partition: number,
|
||||
): string {
|
||||
// 优先从 headers 取 eventId
|
||||
const headerValue = message.headers?.eventId;
|
||||
if (headerValue) {
|
||||
if (typeof headerValue === "string") {
|
||||
return headerValue;
|
||||
}
|
||||
// KafkaJS header 可能是 Buffer 或 (string|Buffer)[],取首元素
|
||||
const buf = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
||||
if (buf) {
|
||||
return Buffer.from(buf).toString("utf-8");
|
||||
}
|
||||
}
|
||||
// 降级:topic + partition + offset 组合
|
||||
return `${topic}:${partition}:${message.offset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON payload 降级:events.proto 未定义全部事件类型,
|
||||
* 直接解析 JSON。若解析失败返回 null。
|
||||
*/
|
||||
private parsePayload(message: KafkaMessage): Record<string, unknown> | null {
|
||||
try {
|
||||
const value = message.value;
|
||||
if (!value) return null;
|
||||
const str =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: Buffer.from(value).toString("utf-8");
|
||||
return JSON.parse(str) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 topic 路由到具体的事件处理器。
|
||||
*/
|
||||
private async routeEvent(
|
||||
topic: string,
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
switch (topic) {
|
||||
// iam 事件
|
||||
case "edu.identity.user.created":
|
||||
await this.handleUserCreated(eventId, payload);
|
||||
break;
|
||||
case "edu.identity.user.role_changed":
|
||||
await this.handleRoleChanged(eventId, payload);
|
||||
break;
|
||||
case "edu.identity.role.updated":
|
||||
await this.handleRoleUpdated(eventId, payload);
|
||||
break;
|
||||
case "edu.identity.user.updated":
|
||||
case "edu.identity.user.deleted":
|
||||
case "edu.identity.role.created":
|
||||
// 无需通知,仅幂等标记
|
||||
logger.debug(
|
||||
{ topic, eventId },
|
||||
"Event acknowledged (no notification)",
|
||||
);
|
||||
break;
|
||||
|
||||
// core-edu 事件
|
||||
case "edu.teaching.exam.published":
|
||||
await this.handleExamPublished(eventId, payload);
|
||||
break;
|
||||
case "edu.teaching.assignment.submitted":
|
||||
await this.handleAssignmentSubmitted(eventId, payload);
|
||||
break;
|
||||
case "edu.teaching.assignment.graded":
|
||||
await this.handleAssignmentGraded(eventId, payload);
|
||||
break;
|
||||
case "edu.teaching.grade.recorded":
|
||||
await this.handleGradeRecorded(eventId, payload);
|
||||
break;
|
||||
case "edu.teaching.attendance.recorded":
|
||||
await this.handleAttendanceRecorded(eventId, payload);
|
||||
break;
|
||||
|
||||
// data-ana 事件
|
||||
case "edu.insight.mastery.updated":
|
||||
await this.handleMasteryUpdated(eventId, payload);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn({ topic, eventId }, "Unknown topic, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 事件处理器(每个创建对应通知)
|
||||
// ============================================================
|
||||
|
||||
private async handleUserCreated(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const userId = String(payload.userId ?? payload.user_id ?? "");
|
||||
if (!userId) return;
|
||||
const name = String(payload.name ?? payload.username ?? "新用户");
|
||||
|
||||
await this.notificationsService.send({
|
||||
userId,
|
||||
type: "system",
|
||||
title: "欢迎加入 Edu 云课堂",
|
||||
content: `你好 ${name},欢迎加入 Edu 云课堂!开始你的学习之旅吧。`,
|
||||
channel: "in_app",
|
||||
eventId,
|
||||
metadata: { source: "iam", event: "user.created" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRoleChanged(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const userId = String(payload.userId ?? payload.user_id ?? "");
|
||||
if (!userId) return;
|
||||
const oldRole = String(payload.oldRole ?? payload.old_role ?? "未知");
|
||||
const newRole = String(payload.newRole ?? payload.new_role ?? "未知");
|
||||
|
||||
await this.notificationsService.send({
|
||||
userId,
|
||||
type: "system",
|
||||
title: "角色变更通知",
|
||||
content: `你的角色已从「${oldRole}」变更为「${newRole}」`,
|
||||
channel: "in_app",
|
||||
eventId,
|
||||
metadata: { source: "iam", event: "user.role_changed" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRoleUpdated(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const affectedUserIds =
|
||||
payload.affectedUserIds ?? payload.affected_user_ids;
|
||||
if (!Array.isArray(affectedUserIds)) return;
|
||||
|
||||
for (const userId of affectedUserIds) {
|
||||
await this.notificationsService.send({
|
||||
userId: String(userId),
|
||||
type: "system",
|
||||
title: "权限变更通知",
|
||||
content: "你的角色权限已更新,请查看最新权限。",
|
||||
channel: "in_app",
|
||||
eventId: `${eventId}:${userId}`,
|
||||
metadata: { source: "iam", event: "role.updated" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleExamPublished(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const studentIds = payload.studentIds ?? payload.student_ids;
|
||||
if (!Array.isArray(studentIds)) return;
|
||||
const examTitle = String(payload.examTitle ?? payload.exam_title ?? "考试");
|
||||
const className = String(payload.className ?? payload.class_name ?? "");
|
||||
|
||||
for (const userId of studentIds) {
|
||||
await this.notificationsService.send({
|
||||
userId: String(userId),
|
||||
type: "exam",
|
||||
title: "新考试通知",
|
||||
content: `「${className}」班级发布了新考试:${examTitle}`,
|
||||
channel: "in_app",
|
||||
groupId: eventId,
|
||||
eventId: `${eventId}:${userId}`,
|
||||
relatedEntityType: "exam",
|
||||
relatedEntityId: String(payload.examId ?? payload.exam_id ?? ""),
|
||||
metadata: { source: "core-edu", event: "exam.published" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAssignmentSubmitted(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const teacherId = String(payload.teacherId ?? payload.teacher_id ?? "");
|
||||
if (!teacherId) return;
|
||||
const studentName = String(
|
||||
payload.studentName ?? payload.student_name ?? "学生",
|
||||
);
|
||||
const homeworkTitle = String(
|
||||
payload.homeworkTitle ?? payload.homework_title ?? "作业",
|
||||
);
|
||||
|
||||
await this.notificationsService.send({
|
||||
userId: teacherId,
|
||||
type: "homework",
|
||||
title: "作业提交通知",
|
||||
content: `${studentName} 提交了作业:${homeworkTitle}`,
|
||||
channel: "in_app",
|
||||
eventId,
|
||||
relatedEntityType: "homework",
|
||||
relatedEntityId: String(payload.homeworkId ?? payload.homework_id ?? ""),
|
||||
metadata: { source: "core-edu", event: "assignment.submitted" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAssignmentGraded(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const studentId = String(payload.studentId ?? payload.student_id ?? "");
|
||||
if (!studentId) return;
|
||||
const homeworkTitle = String(
|
||||
payload.homeworkTitle ?? payload.homework_title ?? "作业",
|
||||
);
|
||||
const score = payload.score ?? payload.grade;
|
||||
|
||||
await this.notificationsService.send({
|
||||
userId: studentId,
|
||||
type: "grade",
|
||||
title: "作业批改通知",
|
||||
content: `你的作业「${homeworkTitle}」已批改${score ? `,得分:${score}` : ""}`,
|
||||
channel: "in_app",
|
||||
eventId,
|
||||
relatedEntityType: "homework",
|
||||
relatedEntityId: String(payload.homeworkId ?? payload.homework_id ?? ""),
|
||||
metadata: { source: "core-edu", event: "assignment.graded" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleGradeRecorded(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const studentId = String(payload.studentId ?? payload.student_id ?? "");
|
||||
if (!studentId) return;
|
||||
const subject = String(payload.subject ?? "科目");
|
||||
const score = payload.score ?? payload.grade;
|
||||
|
||||
await this.notificationsService.send({
|
||||
userId: studentId,
|
||||
type: "grade",
|
||||
title: "成绩录入通知",
|
||||
content: `你的${subject}成绩已录入${score ? `:${score}` : ""}`,
|
||||
channel: "in_app",
|
||||
eventId,
|
||||
relatedEntityType: "grade",
|
||||
relatedEntityId: String(payload.gradeId ?? payload.grade_id ?? ""),
|
||||
metadata: { source: "core-edu", event: "grade.recorded" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAttendanceRecorded(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const parentId = String(payload.parentId ?? payload.parent_id ?? "");
|
||||
if (!parentId) return;
|
||||
const studentName = String(
|
||||
payload.studentName ?? payload.student_name ?? "学生",
|
||||
);
|
||||
const status = String(payload.status ?? "缺勤");
|
||||
const date = String(payload.date ?? "");
|
||||
|
||||
await this.notificationsService.send({
|
||||
userId: parentId,
|
||||
type: "attendance",
|
||||
title: "出勤异常通知",
|
||||
content: `${studentName} 在 ${date} 的出勤状态为:${status}`,
|
||||
channel: "in_app",
|
||||
eventId,
|
||||
relatedEntityType: "attendance",
|
||||
relatedEntityId: String(
|
||||
payload.attendanceId ?? payload.attendance_id ?? "",
|
||||
),
|
||||
metadata: { source: "core-edu", event: "attendance.recorded" },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMasteryUpdated(
|
||||
eventId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const studentId = String(payload.studentId ?? payload.student_id ?? "");
|
||||
if (!studentId) return;
|
||||
const subject = String(payload.subject ?? "科目");
|
||||
const mastery = payload.mastery ?? payload.masteryLevel;
|
||||
const trend = String(payload.trend ?? "下降");
|
||||
|
||||
await this.notificationsService.send({
|
||||
userId: studentId,
|
||||
type: "mastery",
|
||||
title: "学情预警通知",
|
||||
content: `你的${subject}掌握度${trend}(当前:${mastery ?? "未知"}),建议加强复习`,
|
||||
channel: "in_app",
|
||||
eventId,
|
||||
relatedEntityType: "mastery",
|
||||
relatedEntityId: String(payload.masteryId ?? payload.mastery_id ?? ""),
|
||||
metadata: { source: "data-ana", event: "mastery.updated" },
|
||||
});
|
||||
}
|
||||
}
|
||||
62
services/msg/src/shared/kafka/topic-map.ts
Normal file
62
services/msg/src/shared/kafka/topic-map.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* msg 服务 Kafka Topic 映射。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M5:消费 topic 用 `edu.teaching.*` / `edu.identity.*` / `edu.insight.*`
|
||||
* - 发布 topic 用 `edu.notification.*`(02-architecture-design.md §5.2)
|
||||
*
|
||||
* PRODUCER_TOPIC_MAP:eventType → topic,OutboxPublisher 按此路由发布。
|
||||
* CONSUMER_TOPICS:msg 消费的所有 topic 列表,KafkaConsumer 订阅。
|
||||
*/
|
||||
|
||||
/** 生产者:eventType → Kafka topic 路由 */
|
||||
export const PRODUCER_TOPIC_MAP: Record<string, string> = {
|
||||
"notification.sent": "edu.notification.sent",
|
||||
"notification.read": "edu.notification.read",
|
||||
"notification.recalled": "edu.notification.recalled",
|
||||
"notification.failed": "edu.notification.failed",
|
||||
};
|
||||
|
||||
/** 兜底 topic:未在 TOPIC_MAP 命中的 eventType 走此 topic */
|
||||
export const FALLBACK_TOPIC = "edu.notification.events";
|
||||
|
||||
/** 消费者:订阅的 topic 列表(iam 6 + core-edu 5 + data-ana 1 = 12 类事件) */
|
||||
export const CONSUMER_TOPICS: readonly string[] = [
|
||||
// iam(identity)
|
||||
"edu.identity.user.created",
|
||||
"edu.identity.user.updated",
|
||||
"edu.identity.user.deleted",
|
||||
"edu.identity.user.role_changed",
|
||||
"edu.identity.role.created",
|
||||
"edu.identity.role.updated",
|
||||
// core-edu(teaching)
|
||||
"edu.teaching.exam.published",
|
||||
"edu.teaching.assignment.submitted",
|
||||
"edu.teaching.assignment.graded",
|
||||
"edu.teaching.grade.recorded",
|
||||
"edu.teaching.attendance.recorded",
|
||||
// data-ana(insight)
|
||||
"edu.insight.mastery.updated",
|
||||
] as const;
|
||||
|
||||
/** 消费事件 → 通知类型 映射(KafkaConsumer 路由用) */
|
||||
export const CONSUMER_EVENT_TYPE_MAP: Record<string, string> = {
|
||||
"edu.identity.user.created": "system",
|
||||
"edu.identity.user.role_changed": "system",
|
||||
"edu.identity.role.created": "system",
|
||||
"edu.identity.role.updated": "system",
|
||||
"edu.teaching.exam.published": "exam",
|
||||
"edu.teaching.assignment.submitted": "homework",
|
||||
"edu.teaching.assignment.graded": "grade",
|
||||
"edu.teaching.grade.recorded": "grade",
|
||||
"edu.teaching.attendance.recorded": "attendance",
|
||||
"edu.insight.mastery.updated": "mastery",
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 eventType 解析目标 topic。
|
||||
* 未命中时降级到 FALLBACK_TOPIC。
|
||||
*/
|
||||
export function resolveTopic(eventType: string): string {
|
||||
return PRODUCER_TOPIC_MAP[eventType] ?? FALLBACK_TOPIC;
|
||||
}
|
||||
@@ -6,22 +6,42 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { closeDb } from "../../config/database.js";
|
||||
import { closeEs } from "../../config/elasticsearch.js";
|
||||
import { connectKafka, disconnectKafka } from "../kafka/kafka.client.js";
|
||||
import { closeRedis } from "../redis/redis.client.js";
|
||||
import { outboxPublisher } from "../outbox/outbox.publisher.js";
|
||||
|
||||
const SERVICE_NAME = "msg";
|
||||
|
||||
/**
|
||||
* 优雅停机服务。
|
||||
* 优雅停机服务 + 资源生命周期管理。
|
||||
*
|
||||
* 信号处理由 NestJS 在 `app.listen` 之前调用 `app.enableShutdownHooks()`
|
||||
* 触发(SIGTERM / SIGINT),NestJS 会依次调用 OnApplicationShutdown 钩子。
|
||||
* 仲裁依据 G2:统一管理 DB/ES/Redis/Kafka/OutboxPublisher 生命周期。
|
||||
*
|
||||
* 关闭顺序:ES → Drizzle。先关搜索索引避免新数据丢失,再关 DB。
|
||||
* 启动顺序(onModuleInit):
|
||||
* 1. connectKafka():连接 producer + consumer(幂等,KafkaConsumerService 也会调用)
|
||||
* 2. outboxPublisher.start():启动轮询 worker,投递 pending 事件到 Kafka
|
||||
*
|
||||
* 关闭顺序(onApplicationShutdown,在 OnModuleDestroy 之后执行):
|
||||
* 1. outboxPublisher.stop():停止轮询
|
||||
* 2. disconnectKafka():断开 producer + consumer
|
||||
* 3. closeRedis():关闭 Redis 连接
|
||||
* 4. closeEs():关闭 ES 客户端
|
||||
* 5. closeDb():关闭 MySQL 连接池
|
||||
*
|
||||
* 注:KafkaConsumerService 实现 OnModuleDestroy,其 stop() 会在
|
||||
* onApplicationShutdown 之前被 NestJS 调用。disconnectKafka() 是幂等的。
|
||||
*/
|
||||
@Injectable()
|
||||
export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
private readonly logger = new Logger(LifecycleService.name);
|
||||
|
||||
onModuleInit(): void {
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 1. 连接 Kafka(producer + consumer)
|
||||
await connectKafka();
|
||||
|
||||
// 2. 启动 Outbox publisher 轮询
|
||||
await outboxPublisher.start();
|
||||
|
||||
this.logger.log(`service ${SERVICE_NAME} module initialized`);
|
||||
}
|
||||
|
||||
@@ -30,15 +50,19 @@ export class LifecycleService implements OnModuleInit, OnApplicationShutdown {
|
||||
`service ${SERVICE_NAME} shutting down (signal=${signal ?? "unknown"})`,
|
||||
);
|
||||
|
||||
await this.safeDisconnect("elasticsearch", () => closeEs());
|
||||
await this.safeDisconnect("drizzle", () => closeDb());
|
||||
// 按依赖反序关闭
|
||||
await this.safeStop("outboxPublisher", () => outboxPublisher.stop());
|
||||
await this.safeStop("kafka", () => disconnectKafka());
|
||||
await this.safeStop("redis", () => closeRedis());
|
||||
await this.safeStop("elasticsearch", () => closeEs());
|
||||
await this.safeStop("drizzle", () => closeDb());
|
||||
|
||||
this.logger.log(`service ${SERVICE_NAME} shutdown complete`);
|
||||
}
|
||||
|
||||
private async safeDisconnect(
|
||||
private async safeStop(
|
||||
name: string,
|
||||
fn: () => Promise<void>,
|
||||
fn: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fn();
|
||||
|
||||
@@ -4,25 +4,234 @@ const registry = new promClient.Registry();
|
||||
// 修复:在本地 registry 上设置默认标签(原代码误用全局 register)
|
||||
registry.setDefaultLabels({ service: "msg" });
|
||||
|
||||
// ============================================================
|
||||
// HTTP 指标(02-architecture-design.md §6.4)
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_requests_total",
|
||||
help: "Total number of msg requests",
|
||||
labelNames: ["method", "endpoint", "status"],
|
||||
name: "msg_http_requests_total",
|
||||
help: "Total number of msg HTTP requests",
|
||||
labelNames: ["method", "route", "status_code"],
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: "msg_request_duration_seconds",
|
||||
help: "Msg request duration in seconds",
|
||||
labelNames: ["method", "endpoint"],
|
||||
name: "msg_http_request_duration_seconds",
|
||||
help: "Msg HTTP request duration in seconds",
|
||||
labelNames: ["method", "route"],
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// gRPC 指标
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_grpc_requests_total",
|
||||
help: "Total number of msg gRPC requests",
|
||||
labelNames: ["rpc_method", "status"],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 业务指标:通知发送
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_notification_sent_total",
|
||||
help: "Total number of notifications sent",
|
||||
labelNames: ["type", "channel", "status"],
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: "msg_notification_send_duration_seconds",
|
||||
help: "Notification send duration in seconds",
|
||||
labelNames: ["type", "channel"],
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_notification_failed_total",
|
||||
help: "Total number of notification send failures",
|
||||
labelNames: ["type", "channel", "error_code"],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 业务指标:通知已读
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_notification_read_total",
|
||||
help: "Total number of notifications marked as read",
|
||||
labelNames: ["type"],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 业务指标:渠道分发
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: "msg_channel_dispatch_duration_seconds",
|
||||
help: "Channel dispatch duration in seconds",
|
||||
labelNames: ["channel"],
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Kafka 消费指标
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Gauge({
|
||||
name: "msg_kafka_consumer_lag",
|
||||
help: "Kafka consumer lag (offset difference)",
|
||||
labelNames: ["topic"],
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_kafka_consumer_processed_total",
|
||||
help: "Total number of Kafka messages processed",
|
||||
labelNames: ["topic", "status"],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 幂等去重指标
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_idempotent_duplicate_total",
|
||||
help: "Total number of idempotent duplicate hits",
|
||||
labelNames: ["topic"],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Outbox 指标
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Gauge({
|
||||
name: "msg_outbox_pending_count",
|
||||
help: "Number of pending outbox events",
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Push Gateway 指标
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Counter({
|
||||
name: "msg_push_gateway_call_total",
|
||||
help: "Total number of Push Gateway calls",
|
||||
labelNames: ["status"],
|
||||
}),
|
||||
);
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Histogram({
|
||||
name: "msg_push_gateway_call_duration_seconds",
|
||||
help: "Push Gateway call duration in seconds",
|
||||
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||
}),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// 业务状态指标
|
||||
// ============================================================
|
||||
|
||||
registry.registerMetric(
|
||||
new promClient.Gauge({
|
||||
name: "msg_unread_count",
|
||||
help: "User unread notification count (sampled)",
|
||||
labelNames: ["user_id"],
|
||||
}),
|
||||
);
|
||||
|
||||
// 自动收集 Node.js 进程级指标(CPU/内存/事件循环/GC等)
|
||||
// 这些指标无需业务代码埋点,prom-client 自动采集
|
||||
promClient.collectDefaultMetrics({ register: registry });
|
||||
|
||||
export { registry as metricsRegistry };
|
||||
// ============================================================
|
||||
// 便捷访问器:业务代码通过命名导出获取已注册的 Metric 实例
|
||||
// ============================================================
|
||||
|
||||
export const metricsRegistry = registry;
|
||||
|
||||
export const httpRequestsTotal = registry.getSingleMetric(
|
||||
"msg_http_requests_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const httpRequestDurationSeconds = registry.getSingleMetric(
|
||||
"msg_http_request_duration_seconds",
|
||||
) as promClient.Histogram<string>;
|
||||
|
||||
export const grpcRequestsTotal = registry.getSingleMetric(
|
||||
"msg_grpc_requests_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const notificationSentTotal = registry.getSingleMetric(
|
||||
"msg_notification_sent_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const notificationSendDurationSeconds = registry.getSingleMetric(
|
||||
"msg_notification_send_duration_seconds",
|
||||
) as promClient.Histogram<string>;
|
||||
|
||||
export const notificationFailedTotal = registry.getSingleMetric(
|
||||
"msg_notification_failed_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const notificationReadTotal = registry.getSingleMetric(
|
||||
"msg_notification_read_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const channelDispatchDurationSeconds = registry.getSingleMetric(
|
||||
"msg_channel_dispatch_duration_seconds",
|
||||
) as promClient.Histogram<string>;
|
||||
|
||||
export const kafkaConsumerLag = registry.getSingleMetric(
|
||||
"msg_kafka_consumer_lag",
|
||||
) as promClient.Gauge<string>;
|
||||
|
||||
export const kafkaConsumerProcessedTotal = registry.getSingleMetric(
|
||||
"msg_kafka_consumer_processed_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const idempotentDuplicateTotal = registry.getSingleMetric(
|
||||
"msg_idempotent_duplicate_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const outboxPendingCount = registry.getSingleMetric(
|
||||
"msg_outbox_pending_count",
|
||||
) as promClient.Gauge<string>;
|
||||
|
||||
export const pushGatewayCallTotal = registry.getSingleMetric(
|
||||
"msg_push_gateway_call_total",
|
||||
) as promClient.Counter<string>;
|
||||
|
||||
export const pushGatewayCallDurationSeconds = registry.getSingleMetric(
|
||||
"msg_push_gateway_call_duration_seconds",
|
||||
) as promClient.Histogram<string>;
|
||||
|
||||
export const unreadCount = registry.getSingleMetric(
|
||||
"msg_unread_count",
|
||||
) as promClient.Gauge<string>;
|
||||
|
||||
125
services/msg/src/shared/outbox/outbox.publisher.ts
Normal file
125
services/msg/src/shared/outbox/outbox.publisher.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { getProducer } from "../kafka/kafka.client.js";
|
||||
import { resolveTopic } from "../kafka/topic-map.js";
|
||||
import {
|
||||
findPending,
|
||||
incrementRetry,
|
||||
markFailed,
|
||||
markPublished,
|
||||
} from "./outbox.repository.js";
|
||||
import type { OutboxEvent } from "./outbox.schema.js";
|
||||
|
||||
/**
|
||||
* OutboxPublisher —— 轮询 pending 记录并投递到 Kafka(多 topic 路由)。
|
||||
*
|
||||
* 参照 core-edu OutboxPublisher 模式,支持 TOPIC_MAP 多 topic 路由:
|
||||
* - notification.sent → edu.notification.sent
|
||||
* - notification.read → edu.notification.read
|
||||
* - notification.recalled → edu.notification.recalled
|
||||
* - notification.failed → edu.notification.failed
|
||||
*
|
||||
* 仲裁依据:at-least-once 投递 + 指数退避重试 + 幂等(消费端 event_id 去重)。
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const BATCH_SIZE = 100;
|
||||
const MAX_RETRY = 5;
|
||||
const RETRY_BACKOFF_MS = 2000;
|
||||
|
||||
class OutboxPublisher {
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private isPolling = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.intervalId) return;
|
||||
logger.info(
|
||||
{ pollIntervalMs: POLL_INTERVAL_MS, batchSize: BATCH_SIZE },
|
||||
"OutboxPublisher started",
|
||||
);
|
||||
this.intervalId = setInterval(() => {
|
||||
void this.poll();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
logger.info("OutboxPublisher stopped");
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (this.isPolling) return;
|
||||
this.isPolling = true;
|
||||
try {
|
||||
const messages = await findPending(BATCH_SIZE);
|
||||
for (const message of messages) {
|
||||
await this.dispatch(message);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Outbox poll failed");
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatch(message: OutboxEvent): Promise<void> {
|
||||
const topic = resolveTopic(message.eventType);
|
||||
try {
|
||||
const producer = getProducer();
|
||||
await producer.send({
|
||||
topic,
|
||||
messages: [
|
||||
{
|
||||
key: message.aggregateId,
|
||||
value:
|
||||
typeof message.payload === "string"
|
||||
? message.payload
|
||||
: JSON.stringify(message.payload),
|
||||
headers: this.buildHeaders(message),
|
||||
},
|
||||
],
|
||||
});
|
||||
await markPublished(message.eventId);
|
||||
logger.info(
|
||||
{ eventId: message.eventId, eventType: message.eventType, topic },
|
||||
"Outbox message published",
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
{
|
||||
eventId: message.eventId,
|
||||
eventType: message.eventType,
|
||||
error: errorMessage,
|
||||
},
|
||||
"Outbox publish failed",
|
||||
);
|
||||
if (message.retryCount + 1 >= MAX_RETRY) {
|
||||
await markFailed(message.eventId, errorMessage);
|
||||
} else {
|
||||
const backoffMs = RETRY_BACKOFF_MS * 2 ** message.retryCount;
|
||||
await incrementRetry(message.eventId, errorMessage, backoffMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildHeaders(message: OutboxEvent): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
eventId: message.eventId,
|
||||
eventType: message.eventType,
|
||||
aggregateType: message.aggregateType,
|
||||
aggregateId: message.aggregateId,
|
||||
};
|
||||
if (message.metadata) {
|
||||
for (const [key, value] of Object.entries(message.metadata)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
export const outboxPublisher = new OutboxPublisher();
|
||||
63
services/msg/src/shared/outbox/outbox.repository.ts
Normal file
63
services/msg/src/shared/outbox/outbox.repository.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { and, asc, eq, isNull, lte, or, sql, type SQL } from "drizzle-orm";
|
||||
import { getDb } from "../../config/database.js";
|
||||
import { outboxEvents, type OutboxEvent } from "./outbox.schema.js";
|
||||
|
||||
/**
|
||||
* Outbox 数据访问层。
|
||||
*
|
||||
* 参照 core-edu outbox.repository 模式:
|
||||
* - findPending:拉取到期 pending 记录(nextRetryAt IS NULL 或 <= now)
|
||||
* - markPublished:投递成功后标记
|
||||
* - markFailed:重试耗尽标记 failed
|
||||
* - incrementRetry:失败时重试计数 +1 + 退避
|
||||
*/
|
||||
export async function findPending(batchSize: number): Promise<OutboxEvent[]> {
|
||||
const db = getDb();
|
||||
const now = new Date();
|
||||
const where: SQL | undefined = and(
|
||||
eq(outboxEvents.status, "pending"),
|
||||
or(isNull(outboxEvents.nextRetryAt), lte(outboxEvents.nextRetryAt, now)),
|
||||
);
|
||||
return db
|
||||
.select()
|
||||
.from(outboxEvents)
|
||||
.where(where)
|
||||
.orderBy(asc(outboxEvents.createdAt))
|
||||
.limit(batchSize);
|
||||
}
|
||||
|
||||
export async function markPublished(eventId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(outboxEvents)
|
||||
.set({ status: "published", publishedAt: new Date(), lastError: null })
|
||||
.where(eq(outboxEvents.eventId, eventId));
|
||||
}
|
||||
|
||||
export async function markFailed(
|
||||
eventId: string,
|
||||
error: string,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(outboxEvents)
|
||||
.set({ status: "failed", lastError: error })
|
||||
.where(eq(outboxEvents.eventId, eventId));
|
||||
}
|
||||
|
||||
export async function incrementRetry(
|
||||
eventId: string,
|
||||
error: string,
|
||||
backoffMs: number,
|
||||
): Promise<void> {
|
||||
const db = getDb();
|
||||
const nextRetryAt = new Date(Date.now() + backoffMs);
|
||||
await db
|
||||
.update(outboxEvents)
|
||||
.set({
|
||||
retryCount: sql`${outboxEvents.retryCount} + 1`,
|
||||
nextRetryAt,
|
||||
lastError: error,
|
||||
})
|
||||
.where(eq(outboxEvents.eventId, eventId));
|
||||
}
|
||||
66
services/msg/src/shared/outbox/outbox.schema.ts
Normal file
66
services/msg/src/shared/outbox/outbox.schema.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
int,
|
||||
json,
|
||||
mysqlTable,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
|
||||
/**
|
||||
* msg 服务 Outbox + 消费幂等 Schema。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - 02-architecture-design.md §3.1.4(msg_outbox_events)
|
||||
* - 02-architecture-design.md §3.1.5(processed_events)
|
||||
* - G11:ID 用 cuid2(varchar(32))
|
||||
*
|
||||
* 参照 core-edu OutboxPublisher 模式:本地 TOPIC_MAP 多 topic 路由。
|
||||
*/
|
||||
|
||||
/** Outbox 记录状态机 */
|
||||
export type OutboxStatus = "pending" | "published" | "failed";
|
||||
|
||||
/**
|
||||
* msg_outbox_events 表:事务性 Outbox。
|
||||
*
|
||||
* 业务事务内写入,OutboxPublisher 轮询 pending 记录投递到 Kafka。
|
||||
* eventType 经 TOPIC_MAP 路由到不同 topic(edu.notification.sent/read/recalled/failed)。
|
||||
*/
|
||||
export const outboxEvents = mysqlTable("msg_outbox_events", {
|
||||
eventId: varchar("event_id", { length: 64 }).notNull().primaryKey(),
|
||||
aggregateType: varchar("aggregate_type", { length: 64 }).notNull(),
|
||||
aggregateId: varchar("aggregate_id", { length: 32 }).notNull(),
|
||||
eventType: varchar("event_type", { length: 64 }).notNull(),
|
||||
topic: varchar("topic", { length: 128 }).notNull(),
|
||||
payload: json("payload").notNull(),
|
||||
status: varchar("status", { length: 16 })
|
||||
.notNull()
|
||||
.default("pending")
|
||||
.$type<OutboxStatus>(),
|
||||
retryCount: int("retry_count").notNull().default(0),
|
||||
maxRetryCount: int("max_retry_count").notNull().default(5),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
publishedAt: timestamp("published_at"),
|
||||
nextRetryAt: timestamp("next_retry_at"),
|
||||
lastError: text("last_error"),
|
||||
metadata: json("metadata").$type<Record<string, string> | null>(),
|
||||
});
|
||||
|
||||
/** outbox 行类型 */
|
||||
export type OutboxEvent = typeof outboxEvents.$inferSelect;
|
||||
export type NewOutboxEvent = typeof outboxEvents.$inferInsert;
|
||||
|
||||
/**
|
||||
* processed_events 表:消费幂等(Redis 不可用时降级)。
|
||||
*
|
||||
* event_id 唯一索引:重复消费时 INSERT 冲突即跳过。
|
||||
*/
|
||||
export const processedEvents = mysqlTable("processed_events", {
|
||||
eventId: varchar("event_id", { length: 64 }).notNull().primaryKey(),
|
||||
topic: varchar("topic", { length: 128 }).notNull(),
|
||||
processedAt: timestamp("processed_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type ProcessedEvent = typeof processedEvents.$inferSelect;
|
||||
export type NewProcessedEvent = typeof processedEvents.$inferInsert;
|
||||
75
services/msg/src/shared/outbox/outbox.service.ts
Normal file
75
services/msg/src/shared/outbox/outbox.service.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { getDb } from "../../config/database.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
import { resolveTopic } from "../kafka/topic-map.js";
|
||||
import { outboxEvents } from "./outbox.schema.js";
|
||||
|
||||
/**
|
||||
* OutboxService —— 业务层写入 outbox 记录的接口。
|
||||
*
|
||||
* 调用方在业务事务内调用 publish(),将事件记录写入 msg_outbox_events 表,
|
||||
* 与业务写在同一事务中原子提交(事务性 Outbox 模式)。
|
||||
*
|
||||
* 由 OutboxPublisher 负责异步轮询 pending 记录并投递到 Kafka(at-least-once)。
|
||||
*
|
||||
* 仲裁依据 G11:eventId 用 cuid2,同时作为 Kafka 消息 key 实现幂等去重。
|
||||
*/
|
||||
|
||||
export interface PublishOptions {
|
||||
/** 聚合根类型(如 "Notification") */
|
||||
aggregateType: string;
|
||||
/** 聚合根 ID */
|
||||
aggregateId: string;
|
||||
/** 附加元数据,写入 outbox 行并随消息头投递 */
|
||||
metadata?: Record<string, string>;
|
||||
/** 延迟投递毫秒数 */
|
||||
delayMs?: number;
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
eventId: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一条 outbox 记录。
|
||||
*
|
||||
* @param eventType 事件类型(如 "notification.sent"),用于 TOPIC_MAP 路由
|
||||
* @param payload 事件负载(将被 JSON 序列化)
|
||||
* @param options 聚合信息 + 元数据 + 延迟
|
||||
* @returns eventId + 解析的 topic
|
||||
*/
|
||||
export async function publish(
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
options: PublishOptions,
|
||||
): Promise<PublishResult> {
|
||||
const eventId = createId();
|
||||
const topic = resolveTopic(eventType);
|
||||
const now = new Date();
|
||||
const nextRetryAt =
|
||||
options.delayMs !== undefined && options.delayMs > 0
|
||||
? new Date(now.getTime() + options.delayMs)
|
||||
: null;
|
||||
|
||||
const db = getDb();
|
||||
await db.insert(outboxEvents).values({
|
||||
eventId,
|
||||
aggregateType: options.aggregateType,
|
||||
aggregateId: options.aggregateId,
|
||||
eventType,
|
||||
topic,
|
||||
payload: payload as Record<string, unknown>,
|
||||
status: "pending",
|
||||
retryCount: 0,
|
||||
maxRetryCount: 5,
|
||||
metadata: options.metadata ?? null,
|
||||
nextRetryAt,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
{ eventId, eventType, topic, aggregateId: options.aggregateId },
|
||||
"Outbox record enqueued",
|
||||
);
|
||||
return { eventId, topic };
|
||||
}
|
||||
99
services/msg/src/shared/push/push-gateway.client.ts
Normal file
99
services/msg/src/shared/push/push-gateway.client.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { env } from "../../config/env.js";
|
||||
import { logger } from "../observability/logger.js";
|
||||
|
||||
/**
|
||||
* Push Gateway HTTP 客户端。
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - M4:HTTP POST /internal/push(豁免 gRPC)
|
||||
* - PushGateway 软失败:不可用时返回 false,不阻断主流程
|
||||
* - 鉴权头:X-Internal-Key = PUSH_INTERNAL_TOKEN
|
||||
*
|
||||
* 请求体对齐 push-gateway handler.go PushHandler:
|
||||
* { userId, event, data }
|
||||
*/
|
||||
|
||||
export interface PushRequest {
|
||||
userId: string;
|
||||
event: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PushResult {
|
||||
sent: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向 push-gateway 发送实时推送。
|
||||
*
|
||||
* 软失败语义:
|
||||
* - PUSH_GATEWAY_URL 未配置 → 返回 { sent: false },不报错
|
||||
* - 网络错误/非 2xx → 返回 { sent: false, error },logger.warn
|
||||
* - 成功 → 返回 { sent: true }
|
||||
*/
|
||||
export async function sendPush(req: PushRequest): Promise<PushResult> {
|
||||
if (!env.PUSH_GATEWAY_URL) {
|
||||
return { sent: false };
|
||||
}
|
||||
|
||||
const url = `${env.PUSH_GATEWAY_URL}/internal/push`;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (env.PUSH_INTERNAL_TOKEN) {
|
||||
headers["X-Internal-Key"] = env.PUSH_INTERNAL_TOKEN;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
userId: req.userId,
|
||||
event: req.event,
|
||||
data: req.data,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const error = `push-gateway returned ${res.status}`;
|
||||
logger.warn({ status: res.status, userId: req.userId }, error);
|
||||
return { sent: false, error };
|
||||
}
|
||||
return { sent: true };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
logger.warn({ err, userId: req.userId }, "Push gateway unavailable");
|
||||
return { sent: false, error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量推送(逐条发送,push-gateway 无批量 API)。
|
||||
* 任一失败不影响其他,返回每条结果。
|
||||
*/
|
||||
export async function sendPushBatch(
|
||||
requests: PushRequest[],
|
||||
): Promise<PushResult[]> {
|
||||
const results: PushResult[] = [];
|
||||
for (const req of requests) {
|
||||
results.push(await sendPush(req));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查:探测 push-gateway /readyz。
|
||||
* 软失败:不可用返回 false,不阻断 /readyz(仲裁 M2)。
|
||||
*/
|
||||
export async function checkPushGateway(): Promise<boolean> {
|
||||
if (!env.PUSH_GATEWAY_URL) return false;
|
||||
try {
|
||||
const res = await fetch(`${env.PUSH_GATEWAY_URL}/readyz`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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