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

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