feat: 初始化知识库系统项目

- 实现基于 Laravel 11 和 Filament 3.X 的文档管理系统
- 添加用户认证和分组管理功能
- 实现文档上传、分类和权限控制
- 集成 Word 文档自动转换为 Markdown
- 集成 Meilisearch 全文搜索引擎
- 实现文档在线预览功能
- 添加安全日志和审计功能
- 完整的简体中文界面
- 包含完整的项目文档和部署指南

技术栈:
- Laravel 11.x
- Filament 3.X
- Meilisearch 1.5+
- Pandoc 文档转换
- Redis 队列系统
- Pest PHP 测试框架
This commit is contained in:
Knowledge Base System
2025-12-05 14:44:44 +08:00
commit acf549c43c
165 changed files with 32838 additions and 0 deletions

View File

@@ -0,0 +1,371 @@
<?php
namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* 文档转换服务
* 负责将 Word 文档转换为 Markdown 格式
*/
class DocumentConversionService
{
/**
* 转换驱动
*
* @var string
*/
protected string $driver;
/**
* Pandoc 可执行文件路径
*
* @var string
*/
protected string $pandocPath;
/**
* 转换超时时间(秒)
*
* @var int
*/
protected int $timeout;
/**
* Markdown 预览长度
*
* @var int
*/
protected int $previewLength;
/**
* 构造函数
*/
public function __construct()
{
$this->driver = config('documents.conversion.driver', 'pandoc');
$this->pandocPath = config('documents.conversion.pandoc_path', 'pandoc');
$this->timeout = config('documents.conversion.timeout', 300);
$this->previewLength = config('documents.markdown.preview_length', 500);
}
/**
* Word 文档转换为 Markdown
*
* @param Document $document
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null, 'tempDir' => string]
* @throws \Exception
*/
public function convertToMarkdown(Document $document): array
{
if ($this->driver === 'pandoc') {
return $this->convertWithPandoc($document);
}
throw new \Exception("不支持的转换驱动: {$this->driver}");
}
/**
* 使用 Pandoc 转换文档
*
* @param Document $document
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null]
* @throws \Exception
*/
protected function convertWithPandoc(Document $document): array
{
// 获取文档的完整路径
$documentPath = Storage::disk('local')->path($document->file_path);
if (!file_exists($documentPath)) {
throw new \Exception("文档文件不存在: {$documentPath}");
}
// 创建临时工作目录
$tempDir = sys_get_temp_dir() . '/pandoc_' . uniqid();
mkdir($tempDir, 0755, true);
$tempOutputPath = $tempDir . '/output.md';
try {
// 在临时目录中执行 Pandoc 转换命令
$result = Process::timeout($this->timeout)
->path($tempDir)
->run([
$this->pandocPath,
$documentPath,
'-f', $this->getInputFormat($document->mime_type),
'-t', 'markdown',
'-o', $tempOutputPath,
'--wrap=none', // 不自动换行
'--extract-media=.', // 提取媒体文件到当前目录
]);
if (!$result->successful()) {
throw new \Exception("Pandoc 转换失败: {$result->errorOutput()}");
}
// 读取转换后的 Markdown 内容
if (!file_exists($tempOutputPath)) {
throw new \Exception("转换后的 Markdown 文件不存在");
}
$markdown = file_get_contents($tempOutputPath);
if ($markdown === false) {
throw new \Exception("无法读取转换后的 Markdown 文件");
}
// 检查是否有提取的媒体文件
$mediaDir = $tempDir . '/media';
$hasMedia = is_dir($mediaDir) && count(glob($mediaDir . '/*')) > 0;
return [
'markdown' => $markdown,
'mediaDir' => $hasMedia ? $mediaDir : null,
'tempDir' => $tempDir,
];
} catch (\Exception $e) {
// 清理临时目录
$this->deleteDirectory($tempDir);
throw $e;
}
}
/**
* 递归删除目录
*
* @param string $dir 目录路径
* @return void
*/
protected function deleteDirectory(string $dir): void
{
if (!file_exists($dir)) {
return;
}
if (!is_dir($dir)) {
unlink($dir);
return;
}
$files = array_diff(scandir($dir), ['.', '..']);
foreach ($files as $file) {
$path = $dir . '/' . $file;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
/**
* 根据 MIME 类型获取 Pandoc 输入格式
*
* @param string $mimeType
* @return string
*/
protected function getInputFormat(string $mimeType): string
{
return match ($mimeType) {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/msword' => 'doc',
default => 'docx',
};
}
/**
* Markdown 内容和媒体文件保存到存储
*
* @param Document $document
* @param string $markdown
* @param string|null $mediaDir 临时媒体目录路径
* @return string 返回 Markdown 文件路径
* @throws \Exception
*/
public function saveMarkdownToFile(Document $document, string $markdown, ?string $mediaDir = null): string
{
// 生成文件路径
$path = $this->generateMarkdownPath($document);
$directory = dirname($path);
// 如果有媒体文件,先保存它们
if ($mediaDir && is_dir($mediaDir)) {
$this->saveMediaFiles($mediaDir, $directory);
}
// 保存 Markdown 文件
$saved = Storage::disk('markdown')->put($path, $markdown);
if (!$saved) {
throw new \Exception("无法保存 Markdown 文件");
}
return $path;
}
/**
* 保存媒体文件到 storage
* 媒体文件保存在文档的 UUID 目录下的 media 子目录中
*
* @param string $sourceDir 源媒体目录
* @param string $targetDir 目标目录(相对于 markdown disk例如2025/12/04/{uuid}
* @return void
*/
protected function saveMediaFiles(string $sourceDir, string $targetDir): void
{
$files = glob($sourceDir . '/*');
foreach ($files as $file) {
if (is_file($file)) {
$filename = basename($file);
// 保存到文档目录下的 media 子目录
$targetPath = $targetDir . '/media/' . $filename;
// 读取文件内容
$content = file_get_contents($file);
// 保存到 storage
Storage::disk('markdown')->put($targetPath, $content);
Log::info('媒体文件已保存', [
'filename' => $filename,
'path' => $targetPath,
]);
}
}
}
/**
* 生成 Markdown 文件路径
* 使用 UUID 作为目录名,确保每个文档有独立的 media 目录
*
* @param Document $document
* @return string
*/
protected function generateMarkdownPath(Document $document): string
{
$organizeByDate = config('documents.storage.organize_by_date', true);
// 生成唯一的 UUID 作为文档目录
$uuid = Str::uuid()->toString();
if ($organizeByDate) {
// 按日期组织: YYYY/MM/DD/{uuid}/{uuid}.md
$date = $document->created_at ?? now();
$directory = $date->format('Y/m/d') . '/' . $uuid;
} else {
// 直接使用 UUID: {uuid}/{uuid}.md
$directory = $uuid;
}
// 文件名也使用相同的 UUID
$filename = $uuid . '.md';
return "{$directory}/{$filename}";
}
/**
* 获取 Markdown 内容的预览(前 N 个字符)
*
* @param string $markdown
* @param int|null $length
* @return string
*/
public function getMarkdownPreview(string $markdown, ?int $length = null): string
{
$length = $length ?? $this->previewLength;
// 移除多余的空白字符
$cleaned = preg_replace('/\s+/', ' ', $markdown);
$cleaned = trim($cleaned);
// 截取指定长度
if (mb_strlen($cleaned) <= $length) {
return $cleaned;
}
return mb_substr($cleaned, 0, $length) . '...';
}
/**
* 更新文档的 Markdown 信息
*
* @param Document $document
* @param string $markdownPath
* @return void
*/
public function updateDocumentMarkdown(Document $document, string $markdownPath): void
{
// 读取 Markdown 内容以生成预览
$markdown = Storage::disk('markdown')->get($markdownPath);
if ($markdown === false) {
Log::warning('无法读取 Markdown 文件以生成预览', [
'document_id' => $document->id,
'markdown_path' => $markdownPath,
]);
$preview = '';
} else {
$preview = $this->getMarkdownPreview($markdown);
}
// 更新文档记录
$document->update([
'markdown_path' => $markdownPath,
'markdown_preview' => $preview,
'conversion_status' => 'completed',
'conversion_error' => null,
]);
}
/**
* 处理转换失败
*
* @param Document $document
* @param \Exception $exception
* @return void
*/
public function handleConversionFailure(Document $document, \Exception $exception): void
{
Log::error('文档转换失败', [
'document_id' => $document->id,
'document_title' => $document->title,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
// 更新文档状态
$document->update([
'conversion_status' => 'failed',
'conversion_error' => $exception->getMessage(),
]);
}
/**
* 将转换任务加入队列
*
* @param Document $document
* @return void
*/
public function queueConversion(Document $document): void
{
// 更新文档状态为处理中
$document->update([
'conversion_status' => 'processing',
'conversion_error' => null,
]);
// 分发队列任务
$queue = config('documents.conversion.queue', 'documents');
\App\Jobs\ConvertDocumentToMarkdown::dispatch($document)->onQueue($queue);
}
}

View File

@@ -0,0 +1,296 @@
<?php
namespace App\Services;
use App\Models\Document;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpWord\IOFactory;
use PhpOffice\PhpWord\Settings;
class DocumentPreviewService
{
/**
* 将文档转换为 HTML 用于预览
* Filament 后台中,直接从 Word 转换以保证图片正确显示
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
*/
public function convertToHtml(Document $document): string
{
try {
// 直接从 Word 转换,以确保图片正确显示
// Markdown 转换的图片路径问题较复杂,暂时不使用
return $this->convertWordToHtml($document);
} catch (\Exception $e) {
throw new \Exception('文档预览失败:' . $e->getMessage());
}
}
/**
* Markdown 转换为 HTML用于专门的 Markdown 预览页面)
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
*/
public function convertMarkdownToHtml(Document $document): string
{
$markdownContent = $document->getMarkdownContent();
if (empty($markdownContent)) {
throw new \Exception('Markdown 内容为空');
}
// 获取 Markdown 文件的目录例如2025/12/04
$markdownDir = dirname($document->markdown_path);
// 修复图片路径:将 ./media/ 替换为 /markdown/{date}/media/
$markdownContent = preg_replace_callback(
'/\(\.\/media\/([^)]+)\)/',
function ($matches) use ($markdownDir) {
$filename = $matches[1];
return '(/markdown/' . $markdownDir . '/media/' . $filename . ')';
},
$markdownContent
);
// 使用 MarkdownRenderService 转换为 HTML
$renderService = app(MarkdownRenderService::class);
$htmlContent = $renderService->render($markdownContent);
return $htmlContent;
}
/**
* 直接从 Word 文档转换为 HTML
*
* @param Document $document
* @return string HTML 内容
* @throws \Exception
*/
protected function convertWordToHtml(Document $document): string
{
// 检查文件是否存在
if (!Storage::disk('local')->exists($document->file_path)) {
throw new \Exception('文档文件不存在');
}
// 获取文件的完整路径
$filePath = Storage::disk('local')->path($document->file_path);
// 设置 PHPWord 的临时目录
Settings::setTempDir(storage_path('app/temp'));
// 加载 Word 文档
$phpWord = IOFactory::load($filePath);
// 提取图片并转换为 base64
$images = $this->extractImagesFromDocument($phpWord);
// 创建 HTML Writer
$htmlWriter = IOFactory::createWriter($phpWord, 'HTML');
// 将内容写入临时文件
$tempHtmlFile = tempnam(sys_get_temp_dir(), 'doc_preview_') . '.html';
$htmlWriter->save($tempHtmlFile);
// 读取 HTML 内容
$htmlContent = file_get_contents($tempHtmlFile);
// 删除临时文件
unlink($tempHtmlFile);
// 将图片嵌入为 base64
$htmlContent = $this->embedImagesInHtml($htmlContent, $images);
// 清理和美化 HTML
$htmlContent = $this->cleanHtml($htmlContent);
return $htmlContent;
}
/**
* Word 文档中提取所有图片
*
* @param \PhpOffice\PhpWord\PhpWord $phpWord
* @return array 图片数组,键为图片索引,值为 base64 编码的图片数据
*/
protected function extractImagesFromDocument($phpWord): array
{
$images = [];
$imageIndex = 0;
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
// 处理图片元素
if (method_exists($element, 'getElements')) {
foreach ($element->getElements() as $childElement) {
if ($childElement instanceof \PhpOffice\PhpWord\Element\Image) {
$imageSource = $childElement->getSource();
if (file_exists($imageSource)) {
$imageData = file_get_contents($imageSource);
$imageType = $childElement->getImageType();
$mimeType = $this->getImageMimeType($imageType);
$base64 = base64_encode($imageData);
$images[$imageIndex] = "data:{$mimeType};base64,{$base64}";
$imageIndex++;
}
}
}
} elseif ($element instanceof \PhpOffice\PhpWord\Element\Image) {
$imageSource = $element->getSource();
if (file_exists($imageSource)) {
$imageData = file_get_contents($imageSource);
$imageType = $element->getImageType();
$mimeType = $this->getImageMimeType($imageType);
$base64 = base64_encode($imageData);
$images[$imageIndex] = "data:{$mimeType};base64,{$base64}";
$imageIndex++;
}
}
}
}
return $images;
}
/**
* 根据图片类型获取 MIME 类型
*
* @param string $imageType
* @return string
*/
protected function getImageMimeType(string $imageType): string
{
$mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'svg' => 'image/svg+xml',
];
return $mimeTypes[strtolower($imageType)] ?? 'image/jpeg';
}
/**
* HTML 中的图片替换为 base64 编码
*
* @param string $html
* @param array $images
* @return string
*/
protected function embedImagesInHtml(string $html, array $images): string
{
// PHPWord 生成的 HTML 中,图片通常以 <img src="..." /> 的形式存在
// 我们需要将这些图片路径替换为 base64 数据
$imageIndex = 0;
$html = preg_replace_callback(
'/<img([^>]*?)src=["\']([^"\']+)["\']([^>]*?)>/i',
function ($matches) use ($images, &$imageIndex) {
$beforeSrc = $matches[1];
$src = $matches[2];
$afterSrc = $matches[3];
// 如果已经是 base64 或 http 链接,不处理
if (strpos($src, 'data:') === 0 || strpos($src, 'http') === 0) {
return $matches[0];
}
// 使用提取的图片数据
if (isset($images[$imageIndex])) {
$src = $images[$imageIndex];
$imageIndex++;
}
return "<img{$beforeSrc}src=\"{$src}\"{$afterSrc}>";
},
$html
);
return $html;
}
/**
* 清理和美化 HTML 内容
*
* @param string $html
* @return string
*/
protected function cleanHtml(string $html): string
{
// 提取 body 内容
if (preg_match('/<body[^>]*>(.*?)<\/body>/is', $html, $matches)) {
$html = $matches[1];
}
// 添加基本样式
$styledHtml = '<div class="document-preview" style="
font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', Arial, sans-serif;
line-height: 1.6;
color: #333;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
">';
$styledHtml .= $html;
$styledHtml .= '</div>';
return $styledHtml;
}
/**
* 检查文档是否可以预览
*
* @param Document $document
* @return bool
*/
public function canPreview(Document $document): bool
{
// 检查文件扩展名
$extension = strtolower(pathinfo($document->file_name, PATHINFO_EXTENSION));
// 目前支持 .doc 和 .docx
return in_array($extension, ['doc', 'docx']);
}
/**
* 获取文档预览的纯文本内容(用于搜索等)
*
* @param Document $document
* @return string
* @throws \Exception
*/
public function extractText(Document $document): string
{
try {
if (!Storage::disk('local')->exists($document->file_path)) {
throw new \Exception('文档文件不存在');
}
$filePath = Storage::disk('local')->path($document->file_path);
$phpWord = IOFactory::load($filePath);
$text = '';
foreach ($phpWord->getSections() as $section) {
foreach ($section->getElements() as $element) {
if (method_exists($element, 'getText')) {
$text .= $element->getText() . "\n";
}
}
}
return trim($text);
} catch (\Exception $e) {
throw new \Exception('文本提取失败:' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,207 @@
<?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(),
]);
// 索引移除失败不影响文档的正常删除,只记录错误
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\Document;
use App\Models\DownloadLog;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DocumentService
{
/**
* 上传文档
*
* @param UploadedFile $file 上传的文件
* @param string $title 文档标题
* @param string $type 文档类型 ('global' 'dedicated')
* @param int|null $groupId 分组 ID (专用文档必填)
* @param int $uploaderId 上传者用户 ID
* @return Document
* @throws \Exception
*/
public function uploadDocument(
UploadedFile $file,
string $title,
string $type,
?int $groupId,
int $uploaderId
): Document {
// 验证文件格式
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, ['doc', 'docx'])) {
throw new \InvalidArgumentException('文件格式不支持,请上传 Word 文档(.doc 或 .docx');
}
// 验证专用文档必须有分组
if ($type === 'dedicated' && empty($groupId)) {
throw new \InvalidArgumentException('专用知识库文档必须指定所属分组');
}
// 使用事务确保一致性
return DB::transaction(function () use ($file, $title, $type, $groupId, $uploaderId) {
// 获取原始文件名
$originalFileName = $file->getClientOriginalName();
// 生成文件存储路径,使用原始文件名
$directory = 'documents/' . date('Y/m/d');
$filePath = $file->storeAs($directory, $originalFileName, 'local');
// 创建数据库记录,设置初始转换状态为 pending
$document = Document::create([
'title' => $title,
'file_path' => $filePath,
'file_name' => $originalFileName,
'file_size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'type' => $type,
'group_id' => $groupId,
'uploaded_by' => $uploaderId,
'description' => '',
'conversion_status' => 'pending',
]);
// 文档保存成功后,触发异步转换
$conversionService = app(DocumentConversionService::class);
$conversionService->queueConversion($document);
return $document;
});
}
/**
* 验证用户是否有权访问指定文档
*
* @param Document $document 要访问的文档
* @param User $user 用户
* @return bool
*/
public function validateDocumentAccess(Document $document, User $user): bool
{
// 如果是全局文档,所有用户都可以访问
if ($document->type === 'global') {
return true;
}
// 如果是专用文档,检查用户是否属于该文档的分组
if ($document->type === 'dedicated') {
// 获取用户所属的所有分组 ID
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
// 检查文档的分组 ID 是否在用户的分组列表中
return in_array($document->group_id, $userGroupIds);
}
return false;
}
/**
* 下载文档
*
* @param Document $document 要下载的文档
* @param User $user 用户
* @return StreamedResponse
* @throws \Exception
*/
public function downloadDocument(Document $document, User $user): StreamedResponse
{
// 验证用户权限
if (!$this->validateDocumentAccess($document, $user)) {
throw new \Exception('您没有权限访问此文档');
}
// 检查文件是否存在
if (!Storage::disk('local')->exists($document->file_path)) {
throw new \Exception('文档不存在或已被删除');
}
// 返回文件流式响应,使用原始文件名
return Storage::disk('local')->download(
$document->file_path,
$document->file_name
);
}
/**
* 记录文档下载日志
*
* @param Document $document 被下载的文档
* @param User $user 下载的用户
* @param string|null $ipAddress IP 地址
* @return DownloadLog
*/
public function logDownload(Document $document, User $user, ?string $ipAddress = null): DownloadLog
{
return DownloadLog::create([
'document_id' => $document->id,
'user_id' => $user->id,
'downloaded_at' => now(),
'ip_address' => $ipAddress ?? request()->ip(),
]);
}
}

View File

@@ -0,0 +1,254 @@
<?php
namespace App\Services;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
use League\CommonMark\MarkdownConverter;
class MarkdownRenderService
{
protected MarkdownConverter $converter;
protected bool $sanitize;
public function __construct()
{
// 从配置文件读取设置
$this->sanitize = config('documents.markdown.sanitize', true);
// 创建环境配置
$config = [
'html_input' => $this->sanitize ? 'strip' : 'allow', // 根据配置决定是否剥离 HTML 标签
'allow_unsafe_links' => false, // 不允许不安全的链接
'max_nesting_level' => 10, // 最大嵌套层级
];
// 创建环境并添加扩展
$environment = new Environment($config);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension()); // 支持表格
$environment->addExtension(new StrikethroughExtension()); // 支持删除线
$environment->addExtension(new TaskListExtension()); // 支持任务列表
// 创建转换器
$this->converter = new MarkdownConverter($environment);
}
/**
* Markdown 内容渲染为 HTML
*
* @param string $markdown Markdown 内容
* @return string 渲染后的 HTML
*/
public function render(string $markdown): string
{
try {
// 转换 Markdown 为 HTML
$html = $this->converter->convert($markdown)->getContent();
// 清理和美化 HTML
$html = $this->sanitize($html);
return $html;
} catch (\Exception $e) {
// 如果渲染失败,返回错误信息
return '<div class="alert alert-danger">Markdown 渲染失败:' . htmlspecialchars($e->getMessage()) . '</div>';
}
}
/**
* 清理 HTML 内容,防止 XSS 攻击
*
* @param string $html HTML 内容
* @return string 清理后的 HTML
*/
public function sanitize(string $html): string
{
// CommonMark 已经配置了 html_input => 'strip',会自动剥离 HTML 标签
// 这里我们添加额外的样式包装
$styledHtml = '<div class="markdown-content" style="
font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', Arial, sans-serif;
line-height: 1.6;
color: #333;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
">';
// 添加基本的 Markdown 样式
$styledHtml .= $this->getMarkdownStyles();
$styledHtml .= $html;
$styledHtml .= '</div>';
return $styledHtml;
}
/**
* 获取 Markdown 内容的 CSS 样式
*
* @return string CSS 样式
*/
protected function getMarkdownStyles(): string
{
return '<style>
.markdown-content h1 { font-size: 2em; margin-top: 0.67em; margin-bottom: 0.67em; font-weight: bold; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
.markdown-content h2 { font-size: 1.5em; margin-top: 0.83em; margin-bottom: 0.83em; font-weight: bold; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
.markdown-content h3 { font-size: 1.17em; margin-top: 1em; margin-bottom: 1em; font-weight: bold; }
.markdown-content h4 { font-size: 1em; margin-top: 1.33em; margin-bottom: 1.33em; font-weight: bold; }
.markdown-content h5 { font-size: 0.83em; margin-top: 1.67em; margin-bottom: 1.67em; font-weight: bold; }
.markdown-content h6 { font-size: 0.67em; margin-top: 2.33em; margin-bottom: 2.33em; font-weight: bold; }
.markdown-content p { margin: 1em 0; }
.markdown-content ul, .markdown-content ol { margin: 1em 0; padding-left: 2em; }
.markdown-content li { margin: 0.5em 0; }
.markdown-content code {
background-color: #f6f8fa;
padding: 2px 6px;
border-radius: 3px;
font-family: \'SFMono-Regular\', \'Consolas\', \'Liberation Mono\', \'Menlo\', \'Courier\', monospace;
font-size: 0.9em;
color: #24292e;
}
.markdown-content pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
border: 1px solid #e1e4e8;
}
.markdown-content pre code {
background-color: transparent;
padding: 0;
border: none;
}
.markdown-content blockquote {
border-left: 4px solid #dfe2e5;
padding-left: 16px;
margin: 1em 0;
color: #6a737d;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
display: block;
overflow-x: auto;
}
.markdown-content table th, .markdown-content table td {
border: 1px solid #dfe2e5;
padding: 8px 12px;
text-align: left;
}
.markdown-content table th {
background-color: #f6f8fa;
font-weight: bold;
}
.markdown-content table tr:nth-child(even) {
background-color: #f6f8fa;
}
.markdown-content a {
color: #0366d6;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content hr {
border: none;
border-top: 2px solid #e1e4e8;
margin: 2em 0;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 6px;
}
.markdown-content del {
text-decoration: line-through;
color: #6a737d;
}
.markdown-content input[type="checkbox"] {
margin-right: 0.5em;
}
</style>';
}
/**
* Markdown 内容中提取摘要
*
* @param string $markdown Markdown 内容
* @param int|null $length 摘要长度(字符数),如果为 null 则使用配置文件中的默认值
* @return string 摘要文本
*/
public function extractPreview(string $markdown, ?int $length = null): string
{
// 如果未指定长度,使用配置文件中的默认值
if ($length === null) {
$length = config('documents.markdown.preview_length', 500);
}
// 移除 Markdown 标记,获取纯文本
$text = $this->stripMarkdown($markdown);
// 移除多余的空白字符
$text = preg_replace('/\s+/', ' ', $text);
$text = trim($text);
// 截取指定长度
if (mb_strlen($text) > $length) {
$text = mb_substr($text, 0, $length) . '...';
}
return $text;
}
/**
* 移除 Markdown 标记,返回纯文本
*
* @param string $markdown Markdown 内容
* @return string 纯文本
*/
protected function stripMarkdown(string $markdown): string
{
// 移除代码块
$text = preg_replace('/```[\s\S]*?```/', '', $markdown);
$text = preg_replace('/`[^`]+`/', '', $text);
// 移除标题标记
$text = preg_replace('/^#{1,6}\s+/m', '', $text);
// 移除链接,保留文本
$text = preg_replace('/\[([^\]]+)\]\([^\)]+\)/', '$1', $text);
// 移除图片
$text = preg_replace('/!\[([^\]]*)\]\([^\)]+\)/', '', $text);
// 移除粗体和斜体标记
$text = preg_replace('/\*\*([^\*]+)\*\*/', '$1', $text);
$text = preg_replace('/\*([^\*]+)\*/', '$1', $text);
$text = preg_replace('/__([^_]+)__/', '$1', $text);
$text = preg_replace('/_([^_]+)_/', '$1', $text);
// 移除删除线
$text = preg_replace('/~~([^~]+)~~/', '$1', $text);
// 移除引用标记
$text = preg_replace('/^>\s+/m', '', $text);
// 移除列表标记
$text = preg_replace('/^[\*\-\+]\s+/m', '', $text);
$text = preg_replace('/^\d+\.\s+/m', '', $text);
// 移除水平线
$text = preg_replace('/^[\-\*_]{3,}$/m', '', $text);
return $text;
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services;
use App\Models\Document;
use App\Models\User;
use Illuminate\Support\Facades\Log;
/**
* 安全日志记录服务
* 用于记录系统中的安全相关事件
*/
class SecurityLogger
{
/**
* 记录未授权的文档访问尝试
* 需求7.3
*
* @param User $user 尝试访问的用户
* @param Document $document 被访问的文档
* @param string $action 尝试的操作 (view, download, update, delete )
* @param string|null $ipAddress IP 地址
* @return void
*/
public function logUnauthorizedAccess(
User $user,
Document $document,
string $action,
?string $ipAddress = null
): void {
$ipAddress = $ipAddress ?? request()->ip();
Log::channel('security')->warning('未授权访问尝试', [
'event' => 'unauthorized_access',
'action' => $action,
'user_id' => $user->id,
'user_name' => $user->name,
'user_email' => $user->email,
'document_id' => $document->id,
'document_title' => $document->title,
'document_type' => $document->type,
'document_group_id' => $document->group_id,
'ip_address' => $ipAddress,
'timestamp' => now()->toIso8601String(),
'user_agent' => request()->userAgent(),
]);
}
/**
* 记录权限验证失败
*
* @param User $user 用户
* @param string $resource 资源类型
* @param int|null $resourceId 资源 ID
* @param string $action 操作
* @param string|null $reason 失败原因
* @return void
*/
public function logAuthorizationFailure(
User $user,
string $resource,
?int $resourceId,
string $action,
?string $reason = null
): void {
Log::channel('security')->warning('权限验证失败', [
'event' => 'authorization_failure',
'user_id' => $user->id,
'user_name' => $user->name,
'user_email' => $user->email,
'resource' => $resource,
'resource_id' => $resourceId,
'action' => $action,
'reason' => $reason,
'ip_address' => request()->ip(),
'timestamp' => now()->toIso8601String(),
'user_agent' => request()->userAgent(),
]);
}
/**
* 记录可疑的访问模式
*
* @param User $user 用户
* @param string $pattern 可疑模式描述
* @param array $context 额外的上下文信息
* @return void
*/
public function logSuspiciousActivity(
User $user,
string $pattern,
array $context = []
): void {
Log::channel('security')->alert('检测到可疑活动', array_merge([
'event' => 'suspicious_activity',
'user_id' => $user->id,
'user_name' => $user->name,
'user_email' => $user->email,
'pattern' => $pattern,
'ip_address' => request()->ip(),
'timestamp' => now()->toIso8601String(),
'user_agent' => request()->userAgent(),
], $context));
}
}