- 实现基于 Laravel 11 和 Filament 3.X 的文档管理系统 - 添加用户认证和分组管理功能 - 实现文档上传、分类和权限控制 - 集成 Word 文档自动转换为 Markdown - 集成 Meilisearch 全文搜索引擎 - 实现文档在线预览功能 - 添加安全日志和审计功能 - 完整的简体中文界面 - 包含完整的项目文档和部署指南 技术栈: - Laravel 11.x - Filament 3.X - Meilisearch 1.5+ - Pandoc 文档转换 - Redis 队列系统 - Pest PHP 测试框架
208 lines
6.4 KiB
PHP
208 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Document;
|
|
use App\Models\User;
|
|
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 额外的筛选条件
|
|
* @return Collection
|
|
*/
|
|
public function search(string $query, User $user, array $filters = []): Collection
|
|
{
|
|
try {
|
|
// 使用 Scout 进行搜索
|
|
$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']);
|
|
}
|
|
|
|
// 执行搜索并获取结果
|
|
$results = $searchBuilder->get();
|
|
|
|
// 应用用户权限过滤
|
|
return $this->filterByUserPermissions($results, $user);
|
|
} catch (\Exception $e) {
|
|
Log::error('文档搜索失败', [
|
|
'query' => $query,
|
|
'user_id' => $user->id,
|
|
'filters' => $filters,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
// 搜索失败时返回空集合
|
|
return new Collection();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 根据用户权限过滤搜索结果
|
|
* 确保用户只能看到有权限访问的文档
|
|
*
|
|
* @param Collection $results 搜索结果
|
|
* @param User $user 当前用户
|
|
* @return Collection
|
|
*/
|
|
public function filterByUserPermissions(Collection $results, User $user): Collection
|
|
{
|
|
// 获取用户所属的所有分组 ID
|
|
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
|
|
|
|
return $results->filter(function (Document $document) use ($userGroupIds) {
|
|
// 全局文档对所有用户可见
|
|
if ($document->type === 'global') {
|
|
return true;
|
|
}
|
|
|
|
// 专用文档只对所属分组的用户可见
|
|
if ($document->type === 'dedicated') {
|
|
return in_array($document->group_id, $userGroupIds);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 准备文档的可搜索数据
|
|
* 包含完整的 Markdown 内容用于索引
|
|
*
|
|
* @param Document $document 文档模型
|
|
* @return array
|
|
*/
|
|
public function prepareSearchableData(Document $document): array
|
|
{
|
|
return [
|
|
'id' => $document->id,
|
|
'title' => $document->title,
|
|
'description' => $document->description,
|
|
'markdown_content' => $document->getMarkdownContent(),
|
|
'type' => $document->type,
|
|
'group_id' => $document->group_id,
|
|
'uploaded_by' => $document->uploaded_by,
|
|
'created_at' => $document->created_at?->timestamp,
|
|
'updated_at' => $document->updated_at?->timestamp,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 索引文档到 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(),
|
|
]);
|
|
|
|
// 索引失败不影响文档的正常使用,只记录错误
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 更新文档在 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(),
|
|
]);
|
|
|
|
// 索引更新失败不影响文档的正常使用,只记录错误
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 从 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(),
|
|
]);
|
|
|
|
// 索引移除失败不影响文档的正常删除,只记录错误
|
|
}
|
|
}
|
|
}
|