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:
281
app/Filament/Pages/SearchPage.php
Normal file
281
app/Filament/Pages/SearchPage.php
Normal file
@@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\Group;
|
||||
use App\Services\DocumentSearchService;
|
||||
use App\Services\DocumentService;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Concerns\InteractsWithTable;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class SearchPage extends Page implements HasForms, HasTable
|
||||
{
|
||||
use InteractsWithForms;
|
||||
use InteractsWithTable;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
|
||||
|
||||
protected static string $view = 'filament.pages.search-page';
|
||||
|
||||
protected static ?string $navigationLabel = '搜索文档';
|
||||
|
||||
protected static ?string $title = '搜索文档';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
// 表单数据
|
||||
public ?string $searchQuery = null;
|
||||
public ?string $documentType = null;
|
||||
public ?int $groupId = null;
|
||||
|
||||
// 搜索结果
|
||||
public $searchResults = null;
|
||||
public bool $hasSearched = false;
|
||||
|
||||
/**
|
||||
* 挂载页面时的初始化
|
||||
*/
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'searchQuery' => '',
|
||||
'documentType' => null,
|
||||
'groupId' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义搜索表单
|
||||
*/
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
TextInput::make('searchQuery')
|
||||
->label('搜索关键词')
|
||||
->placeholder('请输入搜索关键词...')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
|
||||
Select::make('documentType')
|
||||
->label('文档类型')
|
||||
->placeholder('全部类型')
|
||||
->options([
|
||||
'global' => '全局知识库',
|
||||
'dedicated' => '专用知识库',
|
||||
])
|
||||
->native(false),
|
||||
|
||||
Select::make('groupId')
|
||||
->label('所属分组')
|
||||
->placeholder('全部分组')
|
||||
->options(Group::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->native(false),
|
||||
])
|
||||
->columns(3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义搜索结果表格
|
||||
*/
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query($this->getTableQuery())
|
||||
->columns([
|
||||
TextColumn::make('title')
|
||||
->label('文档标题')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(50),
|
||||
|
||||
TextColumn::make('markdown_preview')
|
||||
->label('内容片段')
|
||||
->limit(100)
|
||||
->wrap()
|
||||
->default('暂无内容预览'),
|
||||
|
||||
TextColumn::make('type')
|
||||
->label('文档类型')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'global' => '全局知识库',
|
||||
'dedicated' => '专用知识库',
|
||||
default => $state,
|
||||
})
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'global' => 'success',
|
||||
'dedicated' => 'info',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
TextColumn::make('group.name')
|
||||
->label('所属分组')
|
||||
->default('无')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label('上传时间')
|
||||
->dateTime('Y-m-d H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Action::make('preview')
|
||||
->label('预览')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('info')
|
||||
->modalHeading(fn (Document $record) => $record->title)
|
||||
->modalContent(fn (Document $record) => view('filament.pages.document-preview-modal', [
|
||||
'document' => $record,
|
||||
]))
|
||||
->modalWidth('7xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('关闭')
|
||||
->visible(fn (Document $record) => $record->conversion_status === 'completed'),
|
||||
|
||||
Action::make('download')
|
||||
->label('下载')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(function (Document $record) {
|
||||
try {
|
||||
$documentService = app(DocumentService::class);
|
||||
$user = Auth::user();
|
||||
|
||||
// 记录下载日志
|
||||
$documentService->logDownload($record, $user);
|
||||
|
||||
// 返回文件下载响应
|
||||
return $documentService->downloadDocument($record, $user);
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->title('下载失败')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->paginated([10, 25, 50, 100])
|
||||
->defaultPaginationPageOption(25)
|
||||
->emptyStateHeading('暂无搜索结果')
|
||||
->emptyStateDescription('请输入搜索关键词并点击搜索按钮')
|
||||
->emptyStateIcon('heroicon-o-magnifying-glass');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取表格查询构建器
|
||||
*/
|
||||
protected function getTableQuery(): Builder
|
||||
{
|
||||
if (!$this->hasSearched || empty($this->searchQuery)) {
|
||||
// 如果还没有搜索或搜索关键词为空,返回空查询
|
||||
return Document::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
// 使用 DocumentSearchService 进行搜索
|
||||
$searchService = app(DocumentSearchService::class);
|
||||
$user = Auth::user();
|
||||
|
||||
$filters = [];
|
||||
if ($this->documentType) {
|
||||
$filters['type'] = $this->documentType;
|
||||
}
|
||||
if ($this->groupId) {
|
||||
$filters['group_id'] = $this->groupId;
|
||||
}
|
||||
|
||||
// 执行搜索
|
||||
$results = $searchService->search($this->searchQuery, $user, $filters);
|
||||
|
||||
// 获取搜索结果的 ID 列表
|
||||
$documentIds = $results->pluck('id')->toArray();
|
||||
|
||||
// 返回包含这些 ID 的查询构建器
|
||||
if (empty($documentIds)) {
|
||||
return Document::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return Document::query()
|
||||
->whereIn('id', $documentIds)
|
||||
->with(['group', 'uploader']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行搜索
|
||||
*/
|
||||
public function search(): void
|
||||
{
|
||||
// 验证表单
|
||||
$data = $this->form->getState();
|
||||
|
||||
// 检查搜索关键词是否为空
|
||||
if (empty($data['searchQuery'])) {
|
||||
Notification::make()
|
||||
->title('请输入搜索关键词')
|
||||
->warning()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新搜索参数
|
||||
$this->searchQuery = $data['searchQuery'];
|
||||
$this->documentType = $data['documentType'];
|
||||
$this->groupId = $data['groupId'];
|
||||
$this->hasSearched = true;
|
||||
|
||||
// 重置表格分页
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title('搜索完成')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空搜索
|
||||
*/
|
||||
public function clearSearch(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
'searchQuery' => '',
|
||||
'documentType' => null,
|
||||
'groupId' => null,
|
||||
]);
|
||||
|
||||
$this->searchQuery = null;
|
||||
$this->documentType = null;
|
||||
$this->groupId = null;
|
||||
$this->hasSearched = false;
|
||||
|
||||
$this->resetTable();
|
||||
|
||||
Notification::make()
|
||||
->title('已清空搜索')
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面头部操作
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
301
app/Filament/Resources/DocumentResource.php
Normal file
301
app/Filament/Resources/DocumentResource.php
Normal file
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\DocumentResource\Pages;
|
||||
use App\Filament\Resources\DocumentResource\RelationManagers;
|
||||
use App\Models\Document;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class DocumentResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Document::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-document-text';
|
||||
|
||||
protected static ?string $navigationLabel = '文档管理';
|
||||
|
||||
protected static ?string $modelLabel = '文档';
|
||||
|
||||
protected static ?string $pluralModelLabel = '文档';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
// 应用 accessibleBy 作用域,确保用户只能看到有权限的文档
|
||||
$user = auth()->user();
|
||||
if ($user) {
|
||||
$query->accessibleBy($user);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('title')
|
||||
->label('文档标题')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->placeholder('请输入文档标题')
|
||||
->columnSpanFull(),
|
||||
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('文档描述')
|
||||
->rows(3)
|
||||
->maxLength(65535)
|
||||
->placeholder('请输入文档描述(可选)')
|
||||
->columnSpanFull(),
|
||||
|
||||
Forms\Components\FileUpload::make('file')
|
||||
->label('文档文件')
|
||||
->required()
|
||||
->acceptedFileTypes(['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])
|
||||
->maxSize(51200) // 50MB
|
||||
->disk('local')
|
||||
->directory('documents/' . date('Y/m/d'))
|
||||
->visibility('private')
|
||||
->downloadable()
|
||||
->preserveFilenames() // 保留原始文件名
|
||||
->helperText('仅支持 .doc 和 .docx 格式,最大 50MB')
|
||||
->columnSpanFull(),
|
||||
|
||||
Forms\Components\Select::make('type')
|
||||
->label('文档类型')
|
||||
->required()
|
||||
->options([
|
||||
'global' => '全局知识库',
|
||||
'dedicated' => '专用知识库',
|
||||
])
|
||||
->default('global')
|
||||
->reactive()
|
||||
->afterStateUpdated(fn ($state, callable $set) =>
|
||||
$state === 'global' ? $set('group_id', null) : null
|
||||
)
|
||||
->helperText('全局知识库所有用户可见,专用知识库仅指定分组可见'),
|
||||
|
||||
Forms\Components\Select::make('group_id')
|
||||
->label('所属分组')
|
||||
->relationship('group', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
|
||||
->visible(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
|
||||
->helperText('专用知识库必须选择所属分组'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('title')
|
||||
->label('文档标题')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(50)
|
||||
->tooltip(function (Tables\Columns\TextColumn $column): ?string {
|
||||
$state = $column->getState();
|
||||
if (strlen($state) > 50) {
|
||||
return $state;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('文档类型')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'global' => 'success',
|
||||
'dedicated' => 'warning',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'global' => '全局知识库',
|
||||
'dedicated' => '专用知识库',
|
||||
default => $state,
|
||||
})
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('group.name')
|
||||
->label('所属分组')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('uploader.name')
|
||||
->label('上传者')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('file_size')
|
||||
->label('文件大小')
|
||||
->formatStateUsing(fn ($state): string => self::formatFileSize($state))
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('conversion_status')
|
||||
->label('转换状态')
|
||||
->badge()
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
'completed' => 'success',
|
||||
'processing' => 'info',
|
||||
'pending' => 'warning',
|
||||
'failed' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'completed' => '已完成',
|
||||
'processing' => '转换中',
|
||||
'pending' => '等待转换',
|
||||
'failed' => '转换失败',
|
||||
default => '未知',
|
||||
})
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('上传时间')
|
||||
->dateTime('Y年m月d日 H:i')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('更新时间')
|
||||
->dateTime('Y年m月d日 H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->label('文档类型')
|
||||
->options([
|
||||
'global' => '全局知识库',
|
||||
'dedicated' => '专用知识库',
|
||||
])
|
||||
->placeholder('全部类型'),
|
||||
|
||||
Tables\Filters\SelectFilter::make('group_id')
|
||||
->label('所属分组')
|
||||
->relationship('group', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->placeholder('全部分组'),
|
||||
|
||||
Tables\Filters\SelectFilter::make('uploaded_by')
|
||||
->label('上传者')
|
||||
->relationship('uploader', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->placeholder('全部上传者'),
|
||||
|
||||
Tables\Filters\SelectFilter::make('conversion_status')
|
||||
->label('转换状态')
|
||||
->options([
|
||||
'pending' => '等待转换',
|
||||
'processing' => '转换中',
|
||||
'completed' => '已完成',
|
||||
'failed' => '转换失败',
|
||||
])
|
||||
->placeholder('全部状态'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\Action::make('preview')
|
||||
->label('预览 Markdown')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('info')
|
||||
->visible(fn (Document $record): bool => $record->conversion_status === 'completed')
|
||||
->url(fn (Document $record): string => route('documents.preview', $record))
|
||||
->openUrlInNewTab()
|
||||
->tooltip(fn (Document $record): ?string =>
|
||||
$record->conversion_status !== 'completed'
|
||||
? '文档尚未完成转换'
|
||||
: null
|
||||
),
|
||||
Tables\Actions\Action::make('download')
|
||||
->label('下载')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('success')
|
||||
->action(function (Document $record) {
|
||||
$documentService = app(\App\Services\DocumentService::class);
|
||||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
// 记录下载日志
|
||||
$documentService->logDownload($record, $user);
|
||||
|
||||
// 返回文件下载响应
|
||||
return $documentService->downloadDocument($record, $user);
|
||||
} catch (\Exception $e) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
->danger()
|
||||
->title('下载失败')
|
||||
->body($e->getMessage())
|
||||
->send();
|
||||
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
Tables\Actions\ViewAction::make()
|
||||
->label('查看'),
|
||||
Tables\Actions\EditAction::make()
|
||||
->label('编辑'),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->label('删除'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
->label('批量删除'),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
public static function formatFileSize(?int $bytes): string
|
||||
{
|
||||
if ($bytes === null) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListDocuments::route('/'),
|
||||
'create' => Pages\CreateDocument::route('/create'),
|
||||
'view' => Pages\ViewDocument::route('/{record}'),
|
||||
'edit' => Pages\EditDocument::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DocumentResource;
|
||||
use App\Services\DocumentService;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CreateDocument extends CreateRecord
|
||||
{
|
||||
protected static string $resource = DocumentResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
// 设置上传者为当前用户
|
||||
$data['uploaded_by'] = Auth::id();
|
||||
|
||||
// 如果是全局文档,确保 group_id 为 null
|
||||
if ($data['type'] === 'global') {
|
||||
$data['group_id'] = null;
|
||||
}
|
||||
|
||||
// 处理文件上传
|
||||
if (isset($data['file'])) {
|
||||
$filePath = $data['file'];
|
||||
|
||||
// 获取原始文件名(由于使用了 preserveFilenames(),basename 就是原始文件名)
|
||||
$originalFileName = basename($filePath);
|
||||
|
||||
// 保存文件信息
|
||||
$data['file_path'] = $filePath;
|
||||
$data['file_name'] = $originalFileName; // 保存原始文件名
|
||||
$data['file_size'] = Storage::disk('local')->size($filePath);
|
||||
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
||||
|
||||
// 移除临时的 file 字段
|
||||
unset($data['file']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
// 文档创建后,触发转换任务
|
||||
$conversionService = app(\App\Services\DocumentConversionService::class);
|
||||
$conversionService->queueConversion($this->record);
|
||||
}
|
||||
|
||||
protected function getCreatedNotification(): ?Notification
|
||||
{
|
||||
return Notification::make()
|
||||
->success()
|
||||
->title('文档上传成功')
|
||||
->body('文档已成功上传到知识库。');
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DocumentResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class EditDocument extends EditRecord
|
||||
{
|
||||
protected static string $resource = DocumentResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make()
|
||||
->label('查看'),
|
||||
Actions\DeleteAction::make()
|
||||
->label('删除'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
// 将文件路径设置到 file 字段以便显示
|
||||
if (isset($data['file_path'])) {
|
||||
$data['file'] = $data['file_path'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
// 如果是全局文档,确保 group_id 为 null
|
||||
if ($data['type'] === 'global') {
|
||||
$data['group_id'] = null;
|
||||
}
|
||||
|
||||
// 处理文件更新
|
||||
if (isset($data['file']) && $data['file'] !== $this->record->file_path) {
|
||||
$filePath = $data['file'];
|
||||
|
||||
// 删除旧的 Word 文件
|
||||
if ($this->record->file_path && Storage::disk('local')->exists($this->record->file_path)) {
|
||||
Storage::disk('local')->delete($this->record->file_path);
|
||||
}
|
||||
|
||||
// 删除旧的 Markdown 文件
|
||||
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
|
||||
Storage::disk('markdown')->delete($this->record->markdown_path);
|
||||
}
|
||||
|
||||
// 获取原始文件名(由于使用了 preserveFilenames(),basename 就是原始文件名)
|
||||
$originalFileName = basename($filePath);
|
||||
|
||||
// 更新文件信息
|
||||
$data['file_path'] = $filePath;
|
||||
$data['file_name'] = $originalFileName; // 保存原始文件名
|
||||
$data['file_size'] = Storage::disk('local')->size($filePath);
|
||||
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
||||
|
||||
// 重置转换状态,准备重新转换
|
||||
$data['conversion_status'] = 'pending';
|
||||
$data['markdown_path'] = null;
|
||||
$data['markdown_preview'] = null;
|
||||
$data['conversion_error'] = null;
|
||||
}
|
||||
|
||||
// 移除临时的 file 字段
|
||||
unset($data['file']);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// 如果文档的转换状态是 pending,说明文件已更新,需要触发重新转换
|
||||
if ($this->record->conversion_status === 'pending') {
|
||||
$conversionService = app(\App\Services\DocumentConversionService::class);
|
||||
$conversionService->queueConversion($this->record);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getSavedNotification(): ?Notification
|
||||
{
|
||||
return Notification::make()
|
||||
->success()
|
||||
->title('文档更新成功')
|
||||
->body('文档信息已成功更新。');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DocumentResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListDocuments extends ListRecords
|
||||
{
|
||||
protected static string $resource = DocumentResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('上传文档'),
|
||||
];
|
||||
}
|
||||
}
|
||||
133
app/Filament/Resources/DocumentResource/Pages/ViewDocument.php
Normal file
133
app/Filament/Resources/DocumentResource/Pages/ViewDocument.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||
|
||||
use App\Filament\Resources\DocumentResource;
|
||||
use App\Services\DocumentPreviewService;
|
||||
use App\Services\DocumentService;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\Section;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Infolists\Infolist;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewDocument extends ViewRecord
|
||||
{
|
||||
protected static string $resource = DocumentResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('preview')
|
||||
->label('预览 Markdown')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('info')
|
||||
->visible(fn (): bool => $this->record->conversion_status === 'completed')
|
||||
->url(fn (): string => route('documents.preview', $this->record))
|
||||
->openUrlInNewTab()
|
||||
->tooltip(fn (): ?string =>
|
||||
$this->record->conversion_status !== 'completed'
|
||||
? '文档尚未完成转换'
|
||||
: null
|
||||
),
|
||||
Actions\Action::make('download')
|
||||
->label('下载文档')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->color('success')
|
||||
->action(function () {
|
||||
$documentService = app(DocumentService::class);
|
||||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
// 记录下载日志
|
||||
$documentService->logDownload($this->record, $user);
|
||||
|
||||
// 返回文件下载响应
|
||||
return $documentService->downloadDocument($this->record, $user);
|
||||
} catch (\Exception $e) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('下载失败')
|
||||
->body($e->getMessage())
|
||||
->send();
|
||||
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
Actions\EditAction::make()
|
||||
->label('编辑'),
|
||||
Actions\DeleteAction::make()
|
||||
->label('删除'),
|
||||
];
|
||||
}
|
||||
|
||||
public function infolist(Infolist $infolist): Infolist
|
||||
{
|
||||
return $infolist
|
||||
->schema([
|
||||
Section::make('文档信息')
|
||||
->schema([
|
||||
TextEntry::make('title')
|
||||
->label('文档标题')
|
||||
->size(TextEntry\TextEntrySize::Large)
|
||||
->weight('bold'),
|
||||
|
||||
TextEntry::make('description')
|
||||
->label('文档描述')
|
||||
->placeholder('无描述')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextEntry::make('type')
|
||||
->label('文档类型')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'global' => 'success',
|
||||
'dedicated' => 'warning',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'global' => '全局知识库',
|
||||
'dedicated' => '专用知识库',
|
||||
default => $state,
|
||||
}),
|
||||
|
||||
TextEntry::make('group.name')
|
||||
->label('所属分组')
|
||||
->placeholder('—')
|
||||
->visible(fn ($record) => $record->type === 'dedicated'),
|
||||
|
||||
TextEntry::make('uploader.name')
|
||||
->label('上传者'),
|
||||
|
||||
TextEntry::make('file_name')
|
||||
->label('文件名'),
|
||||
|
||||
TextEntry::make('file_size')
|
||||
->label('文件大小')
|
||||
->formatStateUsing(fn ($state): string => DocumentResource::formatFileSize($state)),
|
||||
|
||||
TextEntry::make('created_at')
|
||||
->label('上传时间')
|
||||
->dateTime('Y年m月d日 H:i:s'),
|
||||
|
||||
TextEntry::make('updated_at')
|
||||
->label('更新时间')
|
||||
->dateTime('Y年m月d日 H:i:s'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('文档预览')
|
||||
->schema([
|
||||
ViewEntry::make('preview')
|
||||
->label('')
|
||||
->view('filament.resources.document.preview')
|
||||
->viewData([
|
||||
'document' => $this->record,
|
||||
]),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
app/Filament/Resources/GroupResource.php
Normal file
114
app/Filament/Resources/GroupResource.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\GroupResource\Pages;
|
||||
use App\Filament\Resources\GroupResource\RelationManagers;
|
||||
use App\Models\Group;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class GroupResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Group::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static ?string $navigationLabel = '分组管理';
|
||||
|
||||
protected static ?string $modelLabel = '分组';
|
||||
|
||||
protected static ?string $pluralModelLabel = '分组';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('分组名称')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->placeholder('请输入分组名称'),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('分组描述')
|
||||
->rows(3)
|
||||
->maxLength(65535)
|
||||
->placeholder('请输入分组描述(可选)')
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('分组名称')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('description')
|
||||
->label('分组描述')
|
||||
->limit(50)
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('users_count')
|
||||
->label('成员数量')
|
||||
->counts('users')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('创建时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('更新时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make()
|
||||
->label('查看'),
|
||||
Tables\Actions\EditAction::make()
|
||||
->label('编辑'),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->label('删除'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
->label('批量删除'),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\UsersRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListGroups::route('/'),
|
||||
'create' => Pages\CreateGroup::route('/create'),
|
||||
'edit' => Pages\EditGroup::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Filament/Resources/GroupResource/Pages/CreateGroup.php
Normal file
24
app/Filament/Resources/GroupResource/Pages/CreateGroup.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GroupResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateGroup extends CreateRecord
|
||||
{
|
||||
protected static string $resource = GroupResource::class;
|
||||
|
||||
protected static ?string $title = '创建分组';
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return '分组创建成功';
|
||||
}
|
||||
}
|
||||
31
app/Filament/Resources/GroupResource/Pages/EditGroup.php
Normal file
31
app/Filament/Resources/GroupResource/Pages/EditGroup.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GroupResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditGroup extends EditRecord
|
||||
{
|
||||
protected static string $resource = GroupResource::class;
|
||||
|
||||
protected static ?string $title = '编辑分组';
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->label('删除')
|
||||
->modalHeading('删除分组')
|
||||
->modalDescription('确定要删除此分组吗?此操作无法撤销。')
|
||||
->modalSubmitActionLabel('确认删除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSavedNotificationTitle(): ?string
|
||||
{
|
||||
return '分组更新成功';
|
||||
}
|
||||
}
|
||||
22
app/Filament/Resources/GroupResource/Pages/ListGroups.php
Normal file
22
app/Filament/Resources/GroupResource/Pages/ListGroups.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\GroupResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListGroups extends ListRecords
|
||||
{
|
||||
protected static string $resource = GroupResource::class;
|
||||
|
||||
protected static ?string $title = '分组列表';
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('创建分组'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\GroupResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class UsersRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'users';
|
||||
|
||||
protected static ?string $title = '分组成员';
|
||||
|
||||
protected static ?string $modelLabel = '用户';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('用户名称')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('email')
|
||||
->label('邮箱')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('用户名称')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('email')
|
||||
->label('邮箱')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('加入时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()
|
||||
->label('添加成员')
|
||||
->preloadRecordSelect()
|
||||
->modalHeading('添加分组成员')
|
||||
->modalSubmitActionLabel('添加')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\DetachAction::make()
|
||||
->label('移除')
|
||||
->modalHeading('移除分组成员')
|
||||
->modalDescription('确定要将此用户从分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DetachBulkAction::make()
|
||||
->label('批量移除')
|
||||
->modalHeading('批量移除分组成员')
|
||||
->modalDescription('确定要将选中的用户从分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
app/Filament/Resources/UserResource.php
Normal file
129
app/Filament/Resources/UserResource.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\UserResource\Pages;
|
||||
use App\Filament\Resources\UserResource\RelationManagers;
|
||||
use App\Models\User;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-users';
|
||||
|
||||
protected static ?string $navigationLabel = '用户管理';
|
||||
|
||||
protected static ?string $modelLabel = '用户';
|
||||
|
||||
protected static ?string $pluralModelLabel = '用户';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('用户名称')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->placeholder('请输入用户名称'),
|
||||
Forms\Components\TextInput::make('email')
|
||||
->label('邮箱')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->placeholder('请输入邮箱地址'),
|
||||
Forms\Components\TextInput::make('password')
|
||||
->label('密码')
|
||||
->password()
|
||||
->required(fn (string $context): bool => $context === 'create')
|
||||
->dehydrated(fn ($state) => filled($state))
|
||||
->minLength(8)
|
||||
->placeholder('请输入密码(至少8位)')
|
||||
->helperText('编辑时留空表示不修改密码'),
|
||||
Forms\Components\Select::make('groups')
|
||||
->label('所属分组')
|
||||
->multiple()
|
||||
->relationship('groups', 'name')
|
||||
->preload()
|
||||
->placeholder('请选择用户所属的分组')
|
||||
->helperText('用户可以属于多个分组'),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('用户名称')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('email')
|
||||
->label('邮箱')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('groups.name')
|
||||
->label('所属分组')
|
||||
->badge()
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('创建时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('更新时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make()
|
||||
->label('查看'),
|
||||
Tables\Actions\EditAction::make()
|
||||
->label('编辑'),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->label('删除'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make()
|
||||
->label('批量删除'),
|
||||
]),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\GroupsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListUsers::route('/'),
|
||||
'create' => Pages\CreateUser::route('/create'),
|
||||
'edit' => Pages\EditUser::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
22
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
|
||||
protected function getCreatedNotificationTitle(): ?string
|
||||
{
|
||||
return '用户创建成功';
|
||||
}
|
||||
}
|
||||
29
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
29
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditUser extends EditRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->label('删除')
|
||||
->modalHeading('删除用户')
|
||||
->modalDescription('确定要删除此用户吗?此操作无法撤销。')
|
||||
->modalSubmitActionLabel('确认删除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSavedNotificationTitle(): ?string
|
||||
{
|
||||
return '用户更新成功';
|
||||
}
|
||||
}
|
||||
20
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
20
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListUsers extends ListRecords
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make()
|
||||
->label('创建用户'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class GroupsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'groups';
|
||||
|
||||
protected static ?string $title = '用户分组';
|
||||
|
||||
protected static ?string $modelLabel = '分组';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('分组名称')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('分组描述')
|
||||
->rows(3)
|
||||
->maxLength(65535),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('分组名称')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('description')
|
||||
->label('分组描述')
|
||||
->limit(50)
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('加入时间')
|
||||
->dateTime('Y-m-d H:i:s')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()
|
||||
->label('添加分组')
|
||||
->preloadRecordSelect()
|
||||
->modalHeading('添加用户到分组')
|
||||
->modalSubmitActionLabel('添加')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\DetachAction::make()
|
||||
->label('移除')
|
||||
->modalHeading('移除用户分组')
|
||||
->modalDescription('确定要将此用户从该分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DetachBulkAction::make()
|
||||
->label('批量移除')
|
||||
->modalHeading('批量移除用户分组')
|
||||
->modalDescription('确定要将此用户从选中的分组中移除吗?')
|
||||
->modalSubmitActionLabel('确认移除')
|
||||
->modalCancelActionLabel('取消'),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
98
app/Http/Controllers/DocumentController.php
Normal file
98
app/Http/Controllers/DocumentController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentService;
|
||||
use App\Services\MarkdownRenderService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class DocumentController extends Controller
|
||||
{
|
||||
protected DocumentService $documentService;
|
||||
protected MarkdownRenderService $markdownRenderService;
|
||||
|
||||
public function __construct(
|
||||
DocumentService $documentService,
|
||||
MarkdownRenderService $markdownRenderService
|
||||
) {
|
||||
$this->documentService = $documentService;
|
||||
$this->markdownRenderService = $markdownRenderService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览文档的 Markdown 内容(支持图片显示)
|
||||
* 需求:11.1, 11.3, 11.4
|
||||
*
|
||||
* @param Document $document
|
||||
* @return \Illuminate\View\View|\Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function preview(Document $document)
|
||||
{
|
||||
// 验证用户权限(使用 DocumentPolicy)
|
||||
// 需求:11.3
|
||||
if (!Gate::allows('view', $document)) {
|
||||
abort(403, '您没有权限预览此文档');
|
||||
}
|
||||
|
||||
// 检查文档是否已完成转换
|
||||
if ($document->conversion_status !== 'completed') {
|
||||
return view('documents.preview', [
|
||||
'document' => $document,
|
||||
'markdownHtml' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$markdownHtml = null;
|
||||
|
||||
try {
|
||||
// 使用 DocumentPreviewService 的 Markdown 预览方法
|
||||
// 这会修复图片路径并渲染 Markdown
|
||||
// 需求:11.1
|
||||
$previewService = app(\App\Services\DocumentPreviewService::class);
|
||||
$markdownHtml = $previewService->convertMarkdownToHtml($document);
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但不中断流程
|
||||
\Log::error('Markdown 预览失败', [
|
||||
'document_id' => $document->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 处理内容为空的情况
|
||||
// 需求:11.4
|
||||
// 返回渲染后的 HTML 视图
|
||||
return view('documents.preview', [
|
||||
'document' => $document,
|
||||
'markdownHtml' => $markdownHtml,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文档
|
||||
*
|
||||
* @param Document $document
|
||||
* @return \Symfony\Component\HttpFoundation\StreamedResponse
|
||||
*/
|
||||
public function download(Document $document)
|
||||
{
|
||||
// 验证用户权限
|
||||
if (!Gate::allows('download', $document)) {
|
||||
abort(403, '您没有权限下载此文档');
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
try {
|
||||
// 记录下载日志
|
||||
$this->documentService->logDownload($document, $user);
|
||||
|
||||
// 返回文件下载响应
|
||||
return $this->documentService->downloadDocument($document, $user);
|
||||
} catch (\Exception $e) {
|
||||
abort(500, '下载失败:' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
177
app/Jobs/ConvertDocumentToMarkdown.php
Normal file
177
app/Jobs/ConvertDocumentToMarkdown.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentConversionService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 文档转换为 Markdown 的队列任务
|
||||
*/
|
||||
class ConvertDocumentToMarkdown implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* 任务最大尝试次数
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries;
|
||||
|
||||
/**
|
||||
* 任务超时时间(秒)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $timeout;
|
||||
|
||||
/**
|
||||
* 重试延迟(秒)
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $backoff;
|
||||
|
||||
/**
|
||||
* 文档实例
|
||||
*
|
||||
* @var Document
|
||||
*/
|
||||
protected Document $document;
|
||||
|
||||
/**
|
||||
* 创建新的任务实例
|
||||
*
|
||||
* @param Document $document
|
||||
*/
|
||||
public function __construct(Document $document)
|
||||
{
|
||||
$this->document = $document;
|
||||
$this->tries = config('documents.conversion.retry_times', 3);
|
||||
$this->timeout = config('documents.conversion.timeout', 300);
|
||||
$this->backoff = config('documents.conversion.retry_delay', 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行任务
|
||||
*
|
||||
* @param DocumentConversionService $conversionService
|
||||
* @return void
|
||||
*/
|
||||
public function handle(DocumentConversionService $conversionService): void
|
||||
{
|
||||
try {
|
||||
Log::info('开始转换文档', [
|
||||
'document_id' => $this->document->id,
|
||||
'document_title' => $this->document->title,
|
||||
'attempt' => $this->attempts(),
|
||||
]);
|
||||
|
||||
// 转换文档为 Markdown
|
||||
$result = $conversionService->convertToMarkdown($this->document);
|
||||
$markdown = $result['markdown'];
|
||||
$mediaDir = $result['mediaDir'] ?? null;
|
||||
$tempDir = $result['tempDir'];
|
||||
|
||||
try {
|
||||
// 保存 Markdown 文件和媒体文件
|
||||
$markdownPath = $conversionService->saveMarkdownToFile($this->document, $markdown, $mediaDir);
|
||||
|
||||
// 更新文档的 Markdown 信息
|
||||
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
|
||||
} finally {
|
||||
// 清理临时目录
|
||||
if (isset($tempDir) && file_exists($tempDir)) {
|
||||
$this->deleteDirectory($tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('文档转换成功', [
|
||||
'document_id' => $this->document->id,
|
||||
'document_title' => $this->document->title,
|
||||
'markdown_path' => $markdownPath,
|
||||
]);
|
||||
|
||||
// 转换成功后,触发索引(如果需要)
|
||||
// 这将在后续任务中实现
|
||||
// $this->document->searchable();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('文档转换失败', [
|
||||
'document_id' => $this->document->id,
|
||||
'document_title' => $this->document->title,
|
||||
'attempt' => $this->attempts(),
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
// 如果已达到最大重试次数,标记为失败
|
||||
if ($this->attempts() >= $this->tries) {
|
||||
$conversionService->handleConversionFailure($this->document, $e);
|
||||
}
|
||||
|
||||
// 重新抛出异常以触发重试
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务失败时的处理
|
||||
*
|
||||
* @param \Throwable $exception
|
||||
* @return void
|
||||
*/
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('文档转换任务最终失败', [
|
||||
'document_id' => $this->document->id,
|
||||
'document_title' => $this->document->title,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
// 确保文档状态被标记为失败
|
||||
$conversionService = app(DocumentConversionService::class);
|
||||
$conversionService->handleConversionFailure(
|
||||
$this->document,
|
||||
$exception instanceof \Exception ? $exception : new \Exception($exception->getMessage())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归删除目录
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
176
app/Models/Document.php
Normal file
176
app/Models/Document.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
class Document extends Model
|
||||
{
|
||||
use HasFactory, Searchable;
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'file_path',
|
||||
'file_name',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
'type',
|
||||
'group_id',
|
||||
'uploaded_by',
|
||||
'description',
|
||||
'markdown_path',
|
||||
'markdown_preview',
|
||||
'conversion_status',
|
||||
'conversion_error',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取文档所属的分组
|
||||
*/
|
||||
public function group(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Group::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档的上传者
|
||||
*/
|
||||
public function uploader(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档的所有下载日志
|
||||
*/
|
||||
public function downloadLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(DownloadLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询作用域:获取用户可访问的文档
|
||||
* 包含全局文档和用户分组的专用文档,排除其他分组的专用文档
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param User $user
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeAccessibleBy(Builder $query, User $user): Builder
|
||||
{
|
||||
// 获取用户所属的所有分组 ID
|
||||
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
|
||||
|
||||
return $query->where(function (Builder $query) use ($userGroupIds) {
|
||||
// 包含所有全局文档
|
||||
$query->where('type', 'global')
|
||||
// 或者包含用户所属分组的专用文档
|
||||
->orWhere(function (Builder $query) use ($userGroupIds) {
|
||||
$query->where('type', 'dedicated')
|
||||
->whereIn('group_id', $userGroupIds);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询作用域:仅获取全局文档
|
||||
*
|
||||
* @param Builder $query
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeGlobal(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', 'global');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询作用域:仅获取专用文档
|
||||
*
|
||||
* @param Builder $query
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeDedicated(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', 'dedicated');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可搜索的数组数据
|
||||
* 用于 Meilisearch 索引
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'file_name' => $this->file_name,
|
||||
'description' => $this->description,
|
||||
'markdown_content' => $this->getMarkdownContent(),
|
||||
'type' => $this->type,
|
||||
'group_id' => $this->group_id,
|
||||
'uploaded_by' => $this->uploaded_by,
|
||||
'created_at' => $this->created_at?->timestamp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断文档是否应该被索引
|
||||
* 只有转换完成的文档才会被索引
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldBeSearchable(): bool
|
||||
{
|
||||
return $this->conversion_status === 'completed';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整的 Markdown 内容
|
||||
* 从文件系统读取 Markdown 文件
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getMarkdownContent(): ?string
|
||||
{
|
||||
if (!$this->markdown_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (Storage::disk('markdown')->exists($this->markdown_path)) {
|
||||
return Storage::disk('markdown')->get($this->markdown_path);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 记录错误但不抛出异常
|
||||
\Log::warning('Failed to read markdown content', [
|
||||
'document_id' => $this->id,
|
||||
'markdown_path' => $this->markdown_path,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文档是否已转换为 Markdown
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasMarkdown(): bool
|
||||
{
|
||||
return !empty($this->markdown_path) && $this->conversion_status === 'completed';
|
||||
}
|
||||
}
|
||||
56
app/Models/DownloadLog.php
Normal file
56
app/Models/DownloadLog.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DownloadLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
/**
|
||||
* 表示模型不使用 created_at 和 updated_at 时间戳
|
||||
* 因为我们使用自定义的 downloaded_at 字段
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'document_id',
|
||||
'user_id',
|
||||
'downloaded_at',
|
||||
'ip_address',
|
||||
];
|
||||
|
||||
/**
|
||||
* 应该被转换为日期的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'downloaded_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取下载日志关联的文档
|
||||
*/
|
||||
public function document(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Document::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载日志关联的用户
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
53
app/Models/Group.php
Normal file
53
app/Models/Group.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Group extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
/**
|
||||
* 可批量赋值的属性
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
];
|
||||
|
||||
/**
|
||||
* 模型的启动方法
|
||||
* 注册模型事件监听器
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// 监听分组删除事件
|
||||
static::deleting(function (Group $group) {
|
||||
// 将该分组的所有专用文档的 group_id 设置为 null(孤立状态)
|
||||
$group->documents()->update(['group_id' => null]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组的所有用户
|
||||
*/
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分组的所有文档
|
||||
*/
|
||||
public function documents(): HasMany
|
||||
{
|
||||
return $this->hasMany(Document::class);
|
||||
}
|
||||
}
|
||||
74
app/Models/User.php
Normal file
74
app/Models/User.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所属的所有分组
|
||||
*/
|
||||
public function groups(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Group::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户上传的所有文档
|
||||
*/
|
||||
public function uploadedDocuments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Document::class, 'uploaded_by');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有下载日志
|
||||
*/
|
||||
public function downloadLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(DownloadLog::class);
|
||||
}
|
||||
}
|
||||
135
app/Observers/DocumentObserver.php
Normal file
135
app/Observers/DocumentObserver.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Services\DocumentSearchService;
|
||||
|
||||
/**
|
||||
* 文档观察者
|
||||
* 监听文档模型事件,自动管理 Meilisearch 索引
|
||||
*/
|
||||
class DocumentObserver
|
||||
{
|
||||
protected DocumentSearchService $searchService;
|
||||
|
||||
public function __construct(DocumentSearchService $searchService)
|
||||
{
|
||||
$this->searchService = $searchService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文档 "created" 事件
|
||||
* 注意:文档创建时不立即索引,等待转换完成后再索引
|
||||
*/
|
||||
public function created(Document $document): void
|
||||
{
|
||||
// 文档创建时不立即索引,因为 Markdown 内容还未生成
|
||||
// 索引将在转换完成后通过 updated 事件触发
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文档 "updated" 事件
|
||||
* 当文档更新时,检查转换状态并更新索引
|
||||
*/
|
||||
public function updated(Document $document): void
|
||||
{
|
||||
// 检查转换状态是否变为 completed
|
||||
if ($document->wasChanged('conversion_status') && $document->conversion_status === 'completed') {
|
||||
// 转换完成,创建或更新索引
|
||||
$this->searchService->indexDocument($document);
|
||||
} elseif ($document->wasChanged(['title', 'description', 'markdown_path', 'type', 'group_id'])) {
|
||||
// 其他重要字段更新时,也更新索引
|
||||
$this->searchService->updateDocumentIndex($document);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文档 "deleting" 事件
|
||||
* 在删除前清理相关文件
|
||||
*/
|
||||
public function deleting(Document $document): void
|
||||
{
|
||||
$this->cleanupDocumentFiles($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文档 "deleted" 事件
|
||||
* 从 Meilisearch 中移除文档索引
|
||||
*/
|
||||
public function deleted(Document $document): void
|
||||
{
|
||||
$this->searchService->removeDocumentFromIndex($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文档 "restored" 事件
|
||||
* 恢复文档时重新索引
|
||||
*/
|
||||
public function restored(Document $document): void
|
||||
{
|
||||
if ($document->shouldBeSearchable()) {
|
||||
$this->searchService->indexDocument($document);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文档 "force deleting" 事件
|
||||
* 在强制删除前清理相关文件
|
||||
*/
|
||||
public function forceDeleting(Document $document): void
|
||||
{
|
||||
$this->cleanupDocumentFiles($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文档 "force deleted" 事件
|
||||
* 强制删除时也要移除索引
|
||||
*/
|
||||
public function forceDeleted(Document $document): void
|
||||
{
|
||||
$this->searchService->removeDocumentFromIndex($document);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文档相关的所有文件
|
||||
* 包括原始文档文件、Markdown 文件和媒体文件
|
||||
*
|
||||
* @param Document $document
|
||||
* @return void
|
||||
*/
|
||||
protected function cleanupDocumentFiles(Document $document): void
|
||||
{
|
||||
try {
|
||||
// 删除原始文档文件
|
||||
if ($document->file_path && \Storage::disk('local')->exists($document->file_path)) {
|
||||
\Storage::disk('local')->delete($document->file_path);
|
||||
\Log::info('已删除原始文档文件', [
|
||||
'document_id' => $document->id,
|
||||
'file_path' => $document->file_path,
|
||||
]);
|
||||
}
|
||||
|
||||
// 删除 Markdown 文件和整个文档目录(包括 media)
|
||||
if ($document->markdown_path) {
|
||||
// 获取文档目录(例如:2025/12/04/{uuid})
|
||||
$documentDir = dirname($document->markdown_path);
|
||||
|
||||
// 删除整个文档目录(包括 Markdown 文件和 media 目录)
|
||||
if (\Storage::disk('markdown')->exists($documentDir)) {
|
||||
\Storage::disk('markdown')->deleteDirectory($documentDir);
|
||||
\Log::info('已删除文档目录', [
|
||||
'document_id' => $document->id,
|
||||
'directory' => $documentDir,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('清理文档文件失败', [
|
||||
'document_id' => $document->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
// 不抛出异常,避免影响删除操作
|
||||
}
|
||||
}
|
||||
}
|
||||
201
app/Policies/DocumentPolicy.php
Normal file
201
app/Policies/DocumentPolicy.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\User;
|
||||
use App\Services\SecurityLogger;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
|
||||
class DocumentPolicy
|
||||
{
|
||||
/**
|
||||
* 安全日志记录器
|
||||
*/
|
||||
protected SecurityLogger $securityLogger;
|
||||
|
||||
/**
|
||||
* 构造函数
|
||||
*/
|
||||
public function __construct(SecurityLogger $securityLogger)
|
||||
{
|
||||
$this->securityLogger = $securityLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以查看文档列表
|
||||
* 所有已认证用户都可以查看文档列表(但列表会根据权限过滤)
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
// 所有已认证用户都可以查看文档列表
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以查看特定文档
|
||||
* 需求:3.1, 3.4, 7.1, 7.2, 7.3
|
||||
* - 全局文档:所有用户都可以查看
|
||||
* - 专用文档:只有所属分组的用户可以查看
|
||||
* - 记录未授权访问尝试
|
||||
*
|
||||
* @param User $user
|
||||
* @param Document $document
|
||||
* @return bool
|
||||
*/
|
||||
public function view(User $user, Document $document): bool
|
||||
{
|
||||
// 如果是全局文档,所有用户都可以查看
|
||||
if ($document->type === 'global') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果是专用文档,检查用户是否属于该文档的分组
|
||||
if ($document->type === 'dedicated') {
|
||||
// 如果文档没有关联分组,拒绝访问
|
||||
if (!$document->group_id) {
|
||||
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查用户是否属于该文档的分组
|
||||
$hasAccess = $user->groups()->where('groups.id', $document->group_id)->exists();
|
||||
|
||||
// 如果没有权限,记录未授权访问尝试
|
||||
if (!$hasAccess) {
|
||||
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||
}
|
||||
|
||||
return $hasAccess;
|
||||
}
|
||||
|
||||
// 其他情况拒绝访问
|
||||
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以创建文档
|
||||
* 假设所有已认证用户都可以创建文档(可根据实际需求调整)
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
// 所有已认证用户都可以创建文档
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以更新文档
|
||||
* 只有文档的上传者可以更新文档(可根据实际需求调整为管理员也可以)
|
||||
* 需求:7.3
|
||||
*
|
||||
* @param User $user
|
||||
* @param Document $document
|
||||
* @return bool
|
||||
*/
|
||||
public function update(User $user, Document $document): bool
|
||||
{
|
||||
// 只有文档的上传者可以更新
|
||||
$canUpdate = $document->uploaded_by === $user->id;
|
||||
|
||||
// 如果没有权限,记录未授权访问尝试
|
||||
if (!$canUpdate) {
|
||||
$this->securityLogger->logUnauthorizedAccess($user, $document, 'update');
|
||||
}
|
||||
|
||||
return $canUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以删除文档
|
||||
* 只有文档的上传者可以删除文档(可根据实际需求调整为管理员也可以)
|
||||
* 需求:7.3
|
||||
*
|
||||
* @param User $user
|
||||
* @param Document $document
|
||||
* @return bool
|
||||
*/
|
||||
public function delete(User $user, Document $document): bool
|
||||
{
|
||||
// 只有文档的上传者可以删除
|
||||
$canDelete = $document->uploaded_by === $user->id;
|
||||
|
||||
// 如果没有权限,记录未授权访问尝试
|
||||
if (!$canDelete) {
|
||||
$this->securityLogger->logUnauthorizedAccess($user, $document, 'delete');
|
||||
}
|
||||
|
||||
return $canDelete;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以下载文档
|
||||
* 需求:4.1, 4.2, 7.1, 7.2, 7.3
|
||||
* 下载权限与查看权限相同:
|
||||
* - 全局文档:所有用户都可以下载
|
||||
* - 专用文档:只有所属分组的用户可以下载
|
||||
* - 记录未授权下载尝试
|
||||
*
|
||||
* @param User $user
|
||||
* @param Document $document
|
||||
* @return bool
|
||||
*/
|
||||
public function download(User $user, Document $document): bool
|
||||
{
|
||||
// 下载权限与查看权限相同
|
||||
$canDownload = $this->view($user, $document);
|
||||
|
||||
// 注意:view 方法已经记录了未授权访问,这里不需要重复记录
|
||||
// 但如果需要区分 view 和 download 操作,可以在这里单独记录
|
||||
|
||||
return $canDownload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以恢复已删除的文档
|
||||
* 需求:7.3
|
||||
*
|
||||
* @param User $user
|
||||
* @param Document $document
|
||||
* @return bool
|
||||
*/
|
||||
public function restore(User $user, Document $document): bool
|
||||
{
|
||||
// 只有文档的上传者可以恢复
|
||||
$canRestore = $document->uploaded_by === $user->id;
|
||||
|
||||
// 如果没有权限,记录未授权访问尝试
|
||||
if (!$canRestore) {
|
||||
$this->securityLogger->logUnauthorizedAccess($user, $document, 'restore');
|
||||
}
|
||||
|
||||
return $canRestore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否可以永久删除文档
|
||||
* 需求:7.3
|
||||
*
|
||||
* @param User $user
|
||||
* @param Document $document
|
||||
* @return bool
|
||||
*/
|
||||
public function forceDelete(User $user, Document $document): bool
|
||||
{
|
||||
// 只有文档的上传者可以永久删除
|
||||
$canForceDelete = $document->uploaded_by === $user->id;
|
||||
|
||||
// 如果没有权限,记录未授权访问尝试
|
||||
if (!$canForceDelete) {
|
||||
$this->securityLogger->logUnauthorizedAccess($user, $document, 'forceDelete');
|
||||
}
|
||||
|
||||
return $canForceDelete;
|
||||
}
|
||||
}
|
||||
31
app/Providers/AppServiceProvider.php
Normal file
31
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Observers\DocumentObserver;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// 配置 Carbon 使用中文
|
||||
Carbon::setLocale('zh_CN');
|
||||
|
||||
// 注册文档观察者,用于自动管理 Meilisearch 索引
|
||||
Document::observe(DocumentObserver::class);
|
||||
}
|
||||
}
|
||||
58
app/Providers/Filament/AdminPanelProvider.php
Normal file
58
app/Providers/Filament/AdminPanelProvider.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Pages;
|
||||
use Filament\Panel;
|
||||
use Filament\PanelProvider;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Widgets;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
public function panel(Panel $panel): Panel
|
||||
{
|
||||
return $panel
|
||||
->default()
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||
->pages([
|
||||
Pages\Dashboard::class,
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->widgets([
|
||||
Widgets\AccountWidget::class,
|
||||
Widgets\FilamentInfoWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
StartSession::class,
|
||||
AuthenticateSession::class,
|
||||
ShareErrorsFromSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
DisableBladeIconComponents::class,
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
Authenticate::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
371
app/Services/DocumentConversionService.php
Normal file
371
app/Services/DocumentConversionService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
296
app/Services/DocumentPreviewService.php
Normal file
296
app/Services/DocumentPreviewService.php
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/Services/DocumentSearchService.php
Normal file
207
app/Services/DocumentSearchService.php
Normal 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(),
|
||||
]);
|
||||
|
||||
// 索引移除失败不影响文档的正常删除,只记录错误
|
||||
}
|
||||
}
|
||||
}
|
||||
145
app/Services/DocumentService.php
Normal file
145
app/Services/DocumentService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
254
app/Services/MarkdownRenderService.php
Normal file
254
app/Services/MarkdownRenderService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
105
app/Services/SecurityLogger.php
Normal file
105
app/Services/SecurityLogger.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user