feat(msg): 完整实现 msg 消息服务
包含 channels/preferences/templates/grpc/kafka/outbox/push/redis 等完整实现
This commit is contained in:
@@ -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 initialization:pool 在首次调用 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();
|
||||
}
|
||||
|
||||
@@ -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.1:notifications 索引 mapping(ik_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
31
services/msg/src/config/grpc.ts
Normal file
31
services/msg/src/config/grpc.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Transport, type MicroserviceOptions } from "@nestjs/microservices";
|
||||
import { resolve } from "node:path";
|
||||
import { env } from "./env.js";
|
||||
|
||||
/**
|
||||
* gRPC 微服务配置。
|
||||
*
|
||||
* 仲裁依据 M1:gRPC 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}`,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user