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

@@ -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.2NotificationPreference.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);
}
}

View File

@@ -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 /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 DB 连接Drizzle `SELECT 1`),失败返回 503。
* 仲裁依据 G2/readyz 多依赖检查DB/ES/Redis/Kafka/PushGateway
*
* 不需要鉴权,必须在路由白名单中放行
* - GET /healthzliveness仅返回进程存活不检查依赖
* - GET /readyzreadiness检查 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,
};
}
}

View 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 + transactionalIdat-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)");
}
}

View 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.112 类消费事件)
* - JSON payload 降级events.proto 未定义全部事件类型,直接解析 JSON
* - 幂等event_id 去重Redis SETNX + DB 降级)
* - at-least-once消费失败不 commit offsetKafka 重投
*
* 事件路由:
* - iamuser.created/updated/deleted/role_changed, role.created/updated
* - core-eduexam.published, assignment.submitted/graded, grade.recorded, attendance.recorded
* - data-anamastery.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" },
});
}
}

View 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_MAPeventType → topicOutboxPublisher 按此路由发布。
* CONSUMER_TOPICSmsg 消费的所有 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[] = [
// iamidentity
"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-eduteaching
"edu.teaching.exam.published",
"edu.teaching.assignment.submitted",
"edu.teaching.assignment.graded",
"edu.teaching.grade.recorded",
"edu.teaching.attendance.recorded",
// data-anainsight
"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;
}

View File

@@ -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 / SIGINTNestJS 会依次调用 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. 连接 Kafkaproducer + 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();

View File

@@ -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>;

View 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();

View 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));
}

View 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.4msg_outbox_events
* - 02-architecture-design.md §3.1.5processed_events
* - G11ID 用 cuid2varchar(32)
*
* 参照 core-edu OutboxPublisher 模式:本地 TOPIC_MAP 多 topic 路由。
*/
/** Outbox 记录状态机 */
export type OutboxStatus = "pending" | "published" | "failed";
/**
* msg_outbox_events 表:事务性 Outbox。
*
* 业务事务内写入OutboxPublisher 轮询 pending 记录投递到 Kafka。
* eventType 经 TOPIC_MAP 路由到不同 topicedu.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;

View 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 记录并投递到 Kafkaat-least-once
*
* 仲裁依据 G11eventId 用 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 };
}

View File

@@ -0,0 +1,99 @@
import { env } from "../../config/env.js";
import { logger } from "../observability/logger.js";
/**
* Push Gateway HTTP 客户端。
*
* 仲裁依据:
* - M4HTTP 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;
}
}

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");
}
}