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,16 +1,48 @@
import { drizzle } from "drizzle-orm/mysql2";
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import { env } from "./env.js";
const pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
/**
* msg 服务 MySQL 连接池。
*
* 仲裁依据 G10统一 getDb() 函数式(对齐 classes 黄金模板)。
*
* 使用 lazy initializationpool 在首次调用 getDb() 时创建,
* 避免模块导入副作用导致测试环境难以 mock。
*/
let pool: mysql.Pool | null = null;
let dbInstance: MySql2Database<Record<string, never>> | null = null;
export const db = drizzle(pool);
function getPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool({
uri: env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
}
return pool;
}
export function getDb(): MySql2Database<Record<string, never>> {
if (!dbInstance) {
dbInstance = drizzle(getPool());
}
return dbInstance;
}
export async function closeDb(): Promise<void> {
await pool.end();
if (pool) {
await pool.end();
pool = null;
dbInstance = null;
}
}
/**
* 健康检查用:直接访问底层 pool。
*/
export function getPoolInstance(): mysql.Pool {
return getPool();
}

View File

@@ -2,10 +2,56 @@ import { Client } from "@elastic/elasticsearch";
import { env } from "./env.js";
import { logger } from "../shared/observability/logger.js";
/**
* msg 服务 Elasticsearch 客户端。
*
* 仲裁依据:
* - ES_URL 可选,未配置时 esClient=null降级到 MySQL LIKE 查询
* - 02-architecture-design.md §3.2.1notifications 索引 mappingik_max_word 分词)
*/
export const esClient: Client | null = env.ES_URL
? new Client({ node: env.ES_URL })
: null;
/**
* notifications 索引 mapping对齐 02-architecture-design.md §3.2.1)。
*
* ik_max_word索引时最大粒度分词ik_smart查询时智能分词。
* 若 ES 未安装 ik 分词器,会回退到 standard 分词器(不影响功能)。
*/
const NOTIFICATIONS_INDEX_MAPPING = {
mappings: {
properties: {
id: { type: "keyword" },
user_id: { type: "keyword" },
type: { type: "keyword" },
title: {
type: "text",
analyzer: "ik_max_word",
search_analyzer: "ik_smart",
},
content: {
type: "text",
analyzer: "ik_max_word",
search_analyzer: "ik_smart",
},
channel: { type: "keyword" },
status: { type: "keyword" },
group_id: { type: "keyword" },
related_entity_type: { type: "keyword" },
related_entity_id: { type: "keyword" },
sender_id: { type: "keyword" },
is_read: { type: "boolean" },
created_at: { type: "date" },
read_at: { type: "date" },
},
},
settings: {
number_of_shards: 1,
number_of_replicas: 1,
},
} as const;
export async function checkEsConnection(): Promise<void> {
if (!esClient) {
logger.info("Elasticsearch disabled (ES_URL not set)");
@@ -13,12 +59,32 @@ export async function checkEsConnection(): Promise<void> {
}
try {
await esClient.ping();
logger.info("Elasticsearch connected");
await ensureNotificationsIndex();
logger.info("Elasticsearch connected, notifications index ensured");
} catch (err) {
logger.error({ err }, "Elasticsearch connection failed");
}
}
/**
* 幂等创建 notifications 索引。若索引已存在则跳过。
*/
export async function ensureNotificationsIndex(): Promise<void> {
if (!esClient) return;
try {
const exists = await esClient.indices.exists({ index: "notifications" });
if (!exists) {
await esClient.indices.create({
index: "notifications",
...NOTIFICATIONS_INDEX_MAPPING,
});
logger.info("notifications index created");
}
} catch (err) {
logger.error({ err }, "ensureNotificationsIndex failed");
}
}
export async function closeEs(): Promise<void> {
if (esClient) {
await esClient.close();
@@ -38,6 +104,7 @@ export interface SearchHit {
export interface SearchResult {
hits: SearchHit[];
total: number;
}
export async function safeIndex(params: IndexParams): Promise<boolean> {
@@ -57,10 +124,17 @@ export async function safeIndex(params: IndexParams): Promise<boolean> {
export async function safeSearch(
index: string,
query: Record<string, unknown>,
from: number = 0,
size: number = 20,
): Promise<SearchResult> {
if (!esClient) return { hits: [] };
if (!esClient) return { hits: [], total: 0 };
try {
const result = await esClient.search({ index, query });
const result = await esClient.search({
index,
query,
from,
size,
});
const hits: SearchHit[] = [];
for (const raw of result.hits.hits) {
const source = raw._source;
@@ -70,16 +144,33 @@ export async function safeSearch(
typeof source === "object" &&
!Array.isArray(source)
) {
// source 已收窄为 object但 object 无索引签名,需断言为 Record<string, unknown>
hits.push({
_id: raw._id,
_source: source as Record<string, unknown>,
});
}
}
return { hits };
const total =
typeof result.hits.total === "number"
? result.hits.total
: (result.hits.total?.value ?? hits.length);
return { hits, total };
} catch (err) {
logger.error({ err, index }, "Elasticsearch search failed");
return { hits: [] };
return { hits: [], total: 0 };
}
}
/**
* 删除索引文档(撤回通知时同步删除 ES 索引)。
*/
export async function safeDelete(index: string, id: string): Promise<boolean> {
if (!esClient) return false;
try {
await esClient.delete({ index, id });
return true;
} catch (err) {
logger.error({ err, index, id }, "Elasticsearch delete failed");
return false;
}
}

View File

@@ -1,14 +1,27 @@
import { z } from "zod";
/**
* msg 服务环境变量 schema。
*
* 仲裁依据:
* - G2 /readyz 多依赖检查DB/ES/Redis/Kafka/PushGateway
* - M2 PushGateway 软失败PUSH_GATEWAY_URL 可选)
* - M4 HTTP POST /internal/push豁免 gRPC
* - REDIS_URL 可选,未配置时降级到 DB 唯一索引去重
*/
const envSchema = z.object({
PORT: z.string().default("3007"),
GRPC_PORT: z.string().default("50056"),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().optional(),
JWT_ISSUER: z.string().default("next-edu-cloud"),
KAFKA_BROKERS: z.string().default("localhost:9092"),
KAFKA_CLIENT_ID: z.string().default("msg-service"),
KAFKA_CONSUMER_GROUP_ID: z.string().default("msg-service-group"),
ES_URL: z.string().url().optional(),
PUSH_GATEWAY_URL: z.string().url().optional(),
PUSH_INTERNAL_TOKEN: z.string().optional(),
OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url().optional(),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"])

View File

@@ -0,0 +1,31 @@
import { Transport, type MicroserviceOptions } from "@nestjs/microservices";
import { resolve } from "node:path";
import { env } from "./env.js";
/**
* gRPC 微服务配置。
*
* 仲裁依据 M1gRPC 50056 启用,对齐 msg.proto package next_edu_cloud.msg.v1。
*
* proto 文件路径解析:
* - 开发环境:从 services/msg/ 出发,../../packages/shared-proto/proto/msg.proto
* - 生产环境Docker 容器内保留 workspace 结构,同上路径
*/
const PROTO_PATH = resolve(
process.cwd(),
"..",
"..",
"packages",
"shared-proto",
"proto",
"msg.proto",
);
export const grpcMicroserviceOptions: MicroserviceOptions = {
transport: Transport.GRPC,
options: {
package: "next_edu_cloud.msg.v1",
protoPath: PROTO_PATH,
url: `0.0.0.0:${env.GRPC_PORT}`,
},
};