chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
/**
|
||||
* EsSearch —— 轻量 ElasticSearch REST 封装(零新增依赖,复用项目已引入的 GuzzleHttp)
|
||||
*
|
||||
* 设计原则(对应路线 C3「ES 全文检索替代 LIKE」):
|
||||
* - 薄封装:ES 仅负责「按关键词返回匹配的 id 列表」,命中后由调用方 `whereIn('id', $ids)` 回表取完整行 + 关联,
|
||||
* 最大限度复用现有视图与模型关联,降低改造面。
|
||||
* - 可降级:本类方法在 ES 不可用时抛出异常,由上层 SearchService 捕获并回退到原生 LIKE。
|
||||
* - 中文分词:依赖 ES 服务端 IK 分词插件(index 创建时指定 analyzer);若未装,ES 默认 standard 仍可按词/字匹配。
|
||||
*
|
||||
* 依赖:项目根 composer.json 已间接引入 guzzlehttp/guzzle(经 yansongda/pay)。
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ywxapp\library\Search;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
|
||||
class EsSearch
|
||||
{
|
||||
/** @var Client|null 懒加载的 HTTP 客户端 */
|
||||
protected static ?Client $client = null;
|
||||
|
||||
/**
|
||||
* 获取 HTTP 客户端
|
||||
*/
|
||||
protected static function client(): Client
|
||||
{
|
||||
if (self::$client === null) {
|
||||
self::$client = new Client(['timeout' => 3.0, 'connect_timeout' => 2.0]);
|
||||
}
|
||||
return self::$client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接 ES 端点 URL
|
||||
*/
|
||||
protected static function url(string $host, string $path): string
|
||||
{
|
||||
return rtrim($host, '/') . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保索引存在(带中文 IK 分词器映射,缺失则创建)
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public static function ensureIndex(string $host, string $index, array $fields): void
|
||||
{
|
||||
$url = self::url($host, '/' . $index);
|
||||
try {
|
||||
$resp = self::client()->request('HEAD', $url);
|
||||
if ($resp->getStatusCode() === 200) {
|
||||
return; // 已存在
|
||||
}
|
||||
} catch (TransferException $e) {
|
||||
throw new \RuntimeException('ES 连接失败:' . $e->getMessage());
|
||||
}
|
||||
|
||||
// 创建索引,默认用 ik_max_word(若有 IK 插件),否则 standard
|
||||
$properties = [];
|
||||
foreach ($fields as $f) {
|
||||
$properties[$f] = ['type' => 'text', 'analyzer' => 'ik_max_word'];
|
||||
}
|
||||
$body = json_encode([
|
||||
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
|
||||
'mappings' => ['properties' => $properties],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
try {
|
||||
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
|
||||
} catch (TransferException $e) {
|
||||
// IK 分词器不存在时降级为 standard
|
||||
$properties = [];
|
||||
foreach ($fields as $f) {
|
||||
$properties[$f] = ['type' => 'text'];
|
||||
}
|
||||
$body = json_encode([
|
||||
'settings' => ['number_of_shards' => 1, 'number_of_replicas' => 0],
|
||||
'mappings' => ['properties' => $properties],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
self::client()->request('PUT', $url, ['body' => $body, 'headers' => ['Content-Type' => 'application/json']]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 索引单条文档
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public static function indexDoc(string $host, string $index, int $id, array $body): void
|
||||
{
|
||||
try {
|
||||
self::client()->request(
|
||||
'PUT',
|
||||
self::url($host, '/' . $index . '/_doc/' . $id),
|
||||
['body' => json_encode($body, JSON_UNESCAPED_UNICODE), 'headers' => ['Content-Type' => 'application/json']]
|
||||
);
|
||||
} catch (TransferException $e) {
|
||||
throw new \RuntimeException('ES 写入失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
*/
|
||||
public static function deleteDoc(string $host, string $index, int $id): void
|
||||
{
|
||||
try {
|
||||
self::client()->request('DELETE', self::url($host, '/' . $index . '/_doc/' . $id));
|
||||
} catch (TransferException $e) {
|
||||
// 索引不存在或文档已删,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全文检索,返回匹配的 id 列表(按相关度排序)
|
||||
* @param array $fields 检索字段,如 ['title','content']
|
||||
* @return int[] 命中文档 id(相关度降序)
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public static function search(string $host, string $index, string $keyword, array $fields, int $limit = 50, int $from = 0): array
|
||||
{
|
||||
if (trim($keyword) === '') {
|
||||
return [];
|
||||
}
|
||||
$should = [];
|
||||
foreach ($fields as $f) {
|
||||
$should[] = ['match' => [$f => ['query' => $keyword, 'boost' => 1.0]]];
|
||||
}
|
||||
$body = json_encode([
|
||||
'from' => $from,
|
||||
'size' => $limit,
|
||||
'query' => ['bool' => ['should' => $should, 'minimum_should_match' => 1]],
|
||||
'_source' => false,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
try {
|
||||
$resp = self::client()->request(
|
||||
'POST',
|
||||
self::url($host, '/' . $index . '/_search'),
|
||||
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
|
||||
);
|
||||
} catch (TransferException $e) {
|
||||
throw new \RuntimeException('ES 检索失败:' . $e->getMessage());
|
||||
}
|
||||
$data = json_decode((string) $resp->getBody(), true);
|
||||
$ids = [];
|
||||
foreach (($data['hits']['hits'] ?? []) as $hit) {
|
||||
$ids[] = (int) ($hit['_id'] ?? 0);
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 相关推荐(基于 ES more_like_this,按内容相似度返回相似文档 id)
|
||||
* @param array $fields 参与相似的字段,如 ['title','content']
|
||||
* @param string $likeText 源文档文本(title + content 拼接)
|
||||
* @return int[] 相似文档 id(相关度降序,不含自身)
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public static function moreLikeThis(string $host, string $index, string $likeText, array $fields, int $excludeId, int $limit = 6): array
|
||||
{
|
||||
$likeText = trim($likeText);
|
||||
if ($likeText === '') {
|
||||
return [];
|
||||
}
|
||||
$body = json_encode([
|
||||
'size' => $limit + 1,
|
||||
'query' => [
|
||||
'more_like_this' => [
|
||||
'fields' => $fields,
|
||||
'like' => $likeText,
|
||||
'min_term_freq' => 1,
|
||||
'min_doc_freq' => 1,
|
||||
'minimum_should_match' => '20%',
|
||||
],
|
||||
],
|
||||
'_source' => false,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
try {
|
||||
$resp = self::client()->request(
|
||||
'POST',
|
||||
self::url($host, '/' . $index . '/_search'),
|
||||
['body' => $body, 'headers' => ['Content-Type' => 'application/json']]
|
||||
);
|
||||
} catch (TransferException $e) {
|
||||
throw new \RuntimeException('ES 相关推荐失败:' . $e->getMessage());
|
||||
}
|
||||
$data = json_decode((string) $resp->getBody(), true);
|
||||
$ids = [];
|
||||
foreach (($data['hits']['hits'] ?? []) as $hit) {
|
||||
$id = (int) ($hit['_id'] ?? 0);
|
||||
if ($id !== $excludeId) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return array_slice($ids, 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量重建索引(清空后批量写入)
|
||||
* @param callable $each 迭代器回调:function(int $page, int $size): array 返回 [id=>body] 映射
|
||||
*/
|
||||
public static function rebuild(string $host, string $index, array $fields, callable $each, int $size = 200): void
|
||||
{
|
||||
// 删除旧索引重建
|
||||
try {
|
||||
self::client()->request('DELETE', self::url($host, '/' . $index));
|
||||
} catch (TransferException $e) {
|
||||
}
|
||||
self::ensureIndex($host, $index, $fields);
|
||||
|
||||
$page = 1;
|
||||
while (true) {
|
||||
$batch = $each($page, $size);
|
||||
if (empty($batch)) {
|
||||
break;
|
||||
}
|
||||
foreach ($batch as $id => $doc) {
|
||||
self::indexDoc($host, $index, (int) $id, $doc);
|
||||
}
|
||||
if (count($batch) < $size) {
|
||||
break;
|
||||
}
|
||||
$page++;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user