177 lines
4.4 KiB
TypeScript
177 lines
4.4 KiB
TypeScript
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)");
|
||
return;
|
||
}
|
||
try {
|
||
await esClient.ping();
|
||
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();
|
||
}
|
||
}
|
||
|
||
export interface IndexParams {
|
||
index: string;
|
||
id: string;
|
||
document: Record<string, unknown>;
|
||
}
|
||
|
||
export interface SearchHit {
|
||
_id: string;
|
||
_source: Record<string, unknown>;
|
||
}
|
||
|
||
export interface SearchResult {
|
||
hits: SearchHit[];
|
||
total: number;
|
||
}
|
||
|
||
export async function safeIndex(params: IndexParams): Promise<boolean> {
|
||
if (!esClient) return false;
|
||
try {
|
||
await esClient.index(params);
|
||
return true;
|
||
} catch (err) {
|
||
logger.error(
|
||
{ err, index: params.index, id: params.id },
|
||
"Elasticsearch index failed",
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export async function safeSearch(
|
||
index: string,
|
||
query: Record<string, unknown>,
|
||
from: number = 0,
|
||
size: number = 20,
|
||
): Promise<SearchResult> {
|
||
if (!esClient) return { hits: [], total: 0 };
|
||
try {
|
||
const result = await esClient.search({
|
||
index,
|
||
query,
|
||
from,
|
||
size,
|
||
});
|
||
const hits: SearchHit[] = [];
|
||
for (const raw of result.hits.hits) {
|
||
const source = raw._source;
|
||
if (
|
||
raw._id &&
|
||
source &&
|
||
typeof source === "object" &&
|
||
!Array.isArray(source)
|
||
) {
|
||
hits.push({
|
||
_id: raw._id,
|
||
_source: source as Record<string, unknown>,
|
||
});
|
||
}
|
||
}
|
||
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: [], 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;
|
||
}
|
||
}
|