feat(msg): 完整实现 msg 消息服务

包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
SpecialX
2026-07-10 19:09:52 +08:00
parent 21530dc7f6
commit 7b7abbb309
51 changed files with 5204 additions and 371 deletions

View File

@@ -0,0 +1,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;
}