refactor: kb & station & terminal

This commit is contained in:
2026-03-23 20:17:17 +08:00
parent 63ea2686e1
commit b74ba1a3f8
81 changed files with 1016 additions and 2492 deletions

View File

@@ -3,100 +3,103 @@
namespace App\Services;
use App\Models\Document;
use App\Models\User;
use App\Models\KnowledgeBase;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
/**
* 文档搜索服务
* 负责处理文档的全文搜索和 Meilisearch 索引管理
*/
class DocumentSearchService
{
/**
* 搜索文档
* 使用 Laravel Scout Meilisearch 进行全文搜索
*
* @param string $query 搜索关键词
* @param User $user 当前用户
* @param array $filters 额外的筛选条件
* @param array $accessibleStationIds 权限边界的线站 IDs=不限制)
* @param array $filters 用户主动筛选:
* - station_ids: int[] 线站 ID
* - knowledge_base_ids: int[] 知识库 ID
* - uploaded_by: int 上传者 ID
* @return Collection
*/
public function search(string $query, User $user, array $filters = []): Collection
public function search(string $query, array $accessibleStationIds = [], array $filters = []): Collection
{
try {
// 使用 Scout 进行搜索
$kbIds = $this->resolveKnowledgeBaseIds($accessibleStationIds, $filters);
$searchBuilder = Document::search($query);
// 应用额外的筛选条件
if (!empty($filters['type'])) {
$searchBuilder->where('type', $filters['type']);
}
if (!empty($filters['group_id'])) {
$searchBuilder->where('group_id', $filters['group_id']);
}
if (!empty($filters['uploaded_by'])) {
$searchBuilder->where('uploaded_by', $filters['uploaded_by']);
}
if (!empty($filters['knowledge_base_id'])) {
$searchBuilder->where('knowledge_base_id', $filters['knowledge_base_id']);
if (!empty($kbIds)) {
$searchBuilder->query(fn ($q) => $q->whereIn('knowledge_base_id', $kbIds));
}
// 执行搜索并获取结果
$results = $searchBuilder->get();
// 应用用户权限过滤
return $this->filterByUserPermissions($results, $user);
return $searchBuilder->get();
} catch (\Exception $e) {
Log::error('文档搜索失败', [
'query' => $query,
'user_id' => $user->id,
'filters' => $filters,
'error' => $e->getMessage(),
]);
// 搜索失败时返回空集合
return new Collection();
}
}
/**
* 根据用户权限过滤搜索结果
* 确保用户只能看到有权限访问的文档
* 解析最终可搜索的知识库 ID 列表
*
* @param Collection $results 搜索结果
* @param User $user 当前用户
* @return Collection
* 1. accessibleStationIds 得到可访问 KB全局 KB + 关联 station KB
* 2. filters 得到用户指定的 KBstation_ids + knowledge_base_ids 并集)
* 3. 取交集(若有 filter 指定),否则用可访问范围
*/
public function filterByUserPermissions(Collection $results, User $user): Collection
private function resolveKnowledgeBaseIds(array $accessibleStationIds, array $filters): array
{
// 获取用户所属的所有分组 ID
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
// 权限边界
$accessibleKbIds = null;
if (!empty($accessibleStationIds)) {
$accessibleKbIds = KnowledgeBase::where(function ($q) use ($accessibleStationIds) {
$q->whereDoesntHave('stations')
->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $accessibleStationIds));
})->pluck('id')->toArray();
}
return $results->filter(function (Document $document) use ($userGroupIds) {
// 全局文档对所有用户可见
if ($document->type === 'global') {
return true;
}
// 用户主动筛选
$kbIdsFromStations = [];
if (!empty($filters['station_ids'])) {
$kbIdsFromStations = KnowledgeBase::where(function ($q) use ($filters) {
$q->whereDoesntHave('stations')
->orWhereHas('stations', fn ($sq) => $sq->whereIn('stations.id', $filters['station_ids']));
})->pluck('id')->toArray();
}
// 专用文档只对所属分组的用户可见
if ($document->type === 'dedicated') {
return in_array($document->group_id, $userGroupIds);
}
$kbIdsFromFilter = $filters['knowledge_base_ids'] ?? [];
return false;
});
if (!empty($kbIdsFromStations) && !empty($kbIdsFromFilter)) {
$filterKbIds = array_values(array_intersect($kbIdsFromStations, $kbIdsFromFilter));
} elseif (!empty($kbIdsFromStations)) {
$filterKbIds = $kbIdsFromStations;
} elseif (!empty($kbIdsFromFilter)) {
$filterKbIds = $kbIdsFromFilter;
} else {
$filterKbIds = [];
}
// 合并
if ($accessibleKbIds === null) {
return $filterKbIds; // 无权限限制
}
if (empty($filterKbIds)) {
return $accessibleKbIds; // 无主动筛选,用权限范围
}
return array_values(array_intersect($filterKbIds, $accessibleKbIds)); // 取交集
}
/**
* 准备文档的可搜索数据
* 包含完整的 Markdown 内容用于索引
*
* @param Document $document 文档模型
* @return array
*/
public function prepareSearchableData(Document $document): array
{
@@ -105,8 +108,6 @@ class DocumentSearchService
'title' => $document->title,
'description' => $document->description,
'markdown_content' => $document->getMarkdownContent(),
'type' => $document->type,
'group_id' => $document->group_id,
'knowledge_base_id' => $document->knowledge_base_id,
'uploaded_by' => $document->uploaded_by,
'created_at' => $document->created_at?->timestamp,
@@ -114,99 +115,37 @@ class DocumentSearchService
];
}
/**
* 索引文档到 Meilisearch
* 读取 Markdown 文件并创建搜索索引
*
* @param Document $document 文档模型
* @return void
*/
public function indexDocument(Document $document): void
{
try {
// 只索引已完成转换的文档
if (!$document->shouldBeSearchable()) {
Log::info('文档未完成转换,跳过索引', [
'document_id' => $document->id,
'conversion_status' => $document->conversion_status,
]);
return;
}
// 使用 Scout 的 searchable 方法进行索引
$document->searchable();
Log::info('文档索引成功', [
'document_id' => $document->id,
'title' => $document->title,
]);
} catch (\Exception $e) {
Log::error('文档索引失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 索引失败不影响文档的正常使用,只记录错误
Log::error('文档索引失败', ['document_id' => $document->id, 'error' => $e->getMessage()]);
}
}
/**
* 更新文档在 Meilisearch 中的索引
*
* @param Document $document 文档模型
* @return void
*/
public function updateDocumentIndex(Document $document): void
{
try {
// 如果文档应该被索引,则更新索引
if ($document->shouldBeSearchable()) {
$document->searchable();
Log::info('文档索引更新成功', [
'document_id' => $document->id,
'title' => $document->title,
]);
} else {
// 如果文档不应该被索引(例如转换失败),则从索引中移除
$this->removeDocumentFromIndex($document);
}
} catch (\Exception $e) {
Log::error('文档索引更新失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 索引更新失败不影响文档的正常使用,只记录错误
Log::error('文档索引更新失败', ['document_id' => $document->id, 'error' => $e->getMessage()]);
}
}
/**
* Meilisearch 中移除文档索引
*
* @param Document $document 文档模型
* @return void
*/
public function removeDocumentFromIndex(Document $document): void
{
try {
// 使用 Scout 的 unsearchable 方法移除索引
$document->unsearchable();
Log::info('文档索引移除成功', [
'document_id' => $document->id,
'title' => $document->title,
]);
} catch (\Exception $e) {
Log::error('文档索引移除失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 索引移除失败不影响文档的正常删除,只记录错误
Log::error('文档索引移除失败', ['document_id' => $document->id, 'error' => $e->getMessage()]);
}
}
}