// +---------------------------------------------------------------------- declare (strict_types = 1); namespace addon\docs\service; use addon\docs\model\DocsDoc; use addon\docs\model\DocsSearch; /** * 文档搜索服务 * * 索引表保存正文纯文本副本,避免每次搜索都对 longtext 富文本做 LIKE。 * * @author ywxapp */ class DocSearchService { /** * 摘要截取长度 */ const SNIPPET_LENGTH = 120; /** * 写入 / 更新单篇文档索引 * * @param DocsDoc|array $doc 文档模型或数组 * @return void */ public static function index($doc): void { $data = $doc instanceof DocsDoc ? $doc->toArray() : (array) $doc; $id = (int) ($data['id'] ?? 0); if ($id <= 0) { return; } // 纯目录节点或隐藏文档不进索引 if ((int) ($data['is_dir'] ?? 0) === 1 || (int) ($data['status'] ?? 1) !== 1) { self::remove($id); return; } $plain = self::toPlainText((string) ($data['content'] ?? '')); $payload = [ 'doc_id' => $id, 'project_id' => (int) ($data['project_id'] ?? 0), 'version_id' => (int) ($data['version_id'] ?? 0), 'title' => (string) ($data['title'] ?? ''), 'keywords' => (string) ($data['seo_keywords'] ?? ''), 'plain_text' => $plain, ]; $exists = DocsSearch::where('doc_id', $id)->find(); if ($exists) { $exists->save($payload); } else { DocsSearch::create($payload); } } /** * 删除单篇文档索引 * * @param int $docId 文档ID * @return void */ public static function remove(int $docId): void { if ($docId > 0) { DocsSearch::where('doc_id', $docId)->delete(); } } /** * 重建全部索引 * * @param int $projectId 限定项目,0 表示全部 * @return int 已索引条数 */ public static function rebuild(int $projectId = 0): int { $query = DocsDoc::where('status', 1)->where('is_dir', 0); if ($projectId > 0) { $query->where('project_id', $projectId); } $count = 0; $query->chunk(100, function ($docs) use (&$count) { foreach ($docs as $doc) { self::index($doc); $count++; } }); return $count; } /** * 执行搜索 * * @param string $keyword 关键词 * @param int $projectId 限定项目,0 表示全部 * @param int $limit 返回条数 * @return array */ public static function search(string $keyword, int $projectId = 0, int $limit = 30): array { $keyword = trim($keyword); if ($keyword === '') { return []; } // 转义 LIKE 通配符,防止用户输入 % 造成全表扫描 $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword); $like = '%' . $escaped . '%'; $query = DocsSearch::alias('s') ->join('docs_doc d', 'd.id = s.doc_id') ->join('docs_project p', 'p.id = s.project_id') ->join('docs_version v', 'v.id = s.version_id') ->field('s.doc_id,s.title,s.plain_text,d.name as doc_name,p.name as project_name,p.title as project_title,v.name as version_name') ->where('d.status', 1) ->where('p.status', 1) ->where(function ($q) use ($like) { $q->whereLike('s.title', $like) ->whereOr('s.keywords', 'like', $like) ->whereOr('s.plain_text', 'like', $like); }); if ($projectId > 0) { $query->where('s.project_id', $projectId); } $rows = $query->limit($limit)->select()->toArray(); foreach ($rows as &$row) { $row['snippet'] = self::snippet((string) $row['plain_text'], $keyword); unset($row['plain_text']); } unset($row); return $rows; } /** * HTML 转纯文本 * * @param string $html 富文本 * @return string */ public static function toPlainText(string $html): string { if ($html === '') { return ''; } // 先移除脚本与样式,避免其内容混入正文 $html = preg_replace('#<(script|style)\b[^>]*>.*?#is', ' ', $html) ?? $html; // 块级标签替换为空格,防止相邻段落文字粘连 $html = preg_replace('#<(br|/p|/div|/li|/h[1-6]|/tr)\s*/?>#i', ' ', $html) ?? $html; $text = strip_tags($html); $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $text = preg_replace('/\s+/u', ' ', $text) ?? $text; return trim($text); } /** * 生成命中摘要 * * @param string $text 纯文本 * @param string $keyword 关键词 * @return string */ protected static function snippet(string $text, string $keyword): string { if ($text === '') { return ''; } $pos = mb_stripos($text, $keyword, 0, 'UTF-8'); if ($pos === false) { return mb_substr($text, 0, self::SNIPPET_LENGTH, 'UTF-8'); } $start = max(0, $pos - 40); $snippet = mb_substr($text, $start, self::SNIPPET_LENGTH, 'UTF-8'); return ($start > 0 ? '...' : '') . $snippet . '...'; } }