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

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

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

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4

89
.env.example Normal file
View File

@@ -0,0 +1,89 @@
APP_NAME="知识库系统"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_LOCALE=zh_CN
APP_FALLBACK_LOCALE=zh_CN
APP_FAKER_LOCALE=zh_CN
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=knowledge_base
DB_USERNAME=root
DB_PASSWORD=
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
CACHE_PREFIX=kb_cache
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Meilisearch Configuration
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=masterKey
# Document Conversion Configuration
DOCUMENT_CONVERSION_DRIVER=pandoc
PANDOC_PATH=/opt/homebrew/bin/pandoc
CONVERSION_TIMEOUT=300
CONVERSION_QUEUE=documents
CONVERSION_RETRY_TIMES=3
CONVERSION_RETRY_DELAY=60
# Markdown Configuration
MARKDOWN_RENDERER=commonmark
MARKDOWN_SANITIZE=true
MARKDOWN_PREVIEW_LENGTH=500
MARKDOWN_MAX_FILE_SIZE=10485760
# Storage Configuration
DOCUMENTS_DISK=documents
MARKDOWN_DISK=markdown
STORAGE_ORGANIZE_BY_DATE=true

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/meilisearch
/vendor
Homestead.json
Homestead.yaml
Thumbs.db

View File

@@ -0,0 +1,138 @@
# 代码仓库整理需求文档
## 简介
本文档定义了对现有知识库系统代码仓库进行整理和优化的需求。系统是一个基于 Laravel 11 和 Filament 3.X 构建的企业级文档管理平台,当前已实现核心功能,需要对代码结构、文档和配置进行系统性整理,以提高可维护性和可扩展性。
## 术语表
- **知识库系统Knowledge Base System**:本项目的主系统,用于管理和检索文档
- **代码仓库Code Repository**:存储项目源代码、配置和文档的 Git 仓库
- **目录结构Directory Structure**:项目文件和文件夹的组织方式
- **Laravel**PHP Web 应用框架
- **Filament**:基于 Laravel 的管理面板框架
- **Meilisearch**:全文搜索引擎
- **Composer**PHP 依赖管理工具
- **PSR-4**PHP 自动加载标准
## 需求
### 需求 1目录结构规范化
**用户故事**:作为开发者,我希望项目目录结构清晰规范,以便快速定位和理解代码组织方式。
#### 验收标准
1. WHEN 开发者查看项目根目录 THEN 系统 SHALL 提供清晰的目录结构文档说明每个主要目录的用途
2. WHEN 新文件需要添加到项目 THEN 系统 SHALL 提供明确的目录分类规则指导文件放置位置
3. WHEN 检查 app 目录 THEN 系统 SHALL 按照 Laravel 最佳实践组织所有应用代码
4. WHEN 检查服务类 THEN 系统 SHALL 将所有业务逻辑服务统一放置在 app/Services 目录
5. WHEN 检查测试文件 THEN 系统 SHALL 确保测试目录结构与源代码目录结构对应
### 需求 2文档完整性和一致性
**用户故事**:作为新加入的开发者,我希望有完整准确的文档,以便快速了解项目并开始贡献代码。
#### 验收标准
1. WHEN 开发者阅读 README.md THEN 系统 SHALL 提供项目概述、快速开始指南和核心功能说明
2. WHEN 开发者需要部署系统 THEN 系统 SHALL 提供详细的部署文档包含所有环境配置步骤
3. WHEN 开发者需要了解 API THEN 系统 SHALL 提供完整的 API 参考文档说明所有公共服务方法
4. WHEN 文档中引用配置项 THEN 系统 SHALL 确保配置项名称与实际代码一致
5. WHEN 文档描述功能特性 THEN 系统 SHALL 确保描述与当前实现的功能匹配
### 需求 3配置文件管理
**用户故事**:作为系统管理员,我希望配置文件清晰易懂,以便正确配置和部署系统。
#### 验收标准
1. WHEN 管理员查看 .env.example THEN 系统 SHALL 包含所有必需的配置项并提供清晰的注释说明
2. WHEN 配置文件包含敏感信息 THEN 系统 SHALL 确保这些文件在 .gitignore 中被正确排除
3. WHEN 系统使用自定义配置 THEN 系统 SHALL 在 config 目录中创建独立的配置文件
4. WHEN 配置项有默认值 THEN 系统 SHALL 在配置文件中明确标注默认值
5. WHEN 配置项之间有依赖关系 THEN 系统 SHALL 在注释中说明依赖关系
### 需求 4依赖管理规范
**用户故事**:作为开发者,我希望项目依赖清晰明确,以便正确安装和更新依赖包。
#### 验收标准
1. WHEN 检查 composer.json THEN 系统 SHALL 明确区分生产依赖和开发依赖
2. WHEN 检查 package.json THEN 系统 SHALL 列出所有前端依赖及其用途
3. WHEN 添加新依赖 THEN 系统 SHALL 在文档中说明该依赖的作用和必要性
4. WHEN 依赖包有版本要求 THEN 系统 SHALL 使用语义化版本约束
5. WHEN 系统依赖外部服务 THEN 系统 SHALL 在文档中明确说明外部服务的版本要求
### 需求 5代码组织和命名规范
**用户故事**:作为开发者,我希望代码遵循一致的命名和组织规范,以便提高代码可读性和可维护性。
#### 验收标准
1. WHEN 创建新的服务类 THEN 系统 SHALL 使用 Service 后缀并放置在 app/Services 目录
2. WHEN 创建新的策略类 THEN 系统 SHALL 使用 Policy 后缀并放置在 app/Policies 目录
3. WHEN 创建新的观察者类 THEN 系统 SHALL 使用 Observer 后缀并放置在 app/Observers 目录
4. WHEN 创建新的任务类 THEN 系统 SHALL 放置在 app/Jobs 目录并实现 ShouldQueue 接口
5. WHEN 命名类和方法 THEN 系统 SHALL 遵循 PSR-12 编码规范
### 需求 6测试文件组织
**用户故事**:作为开发者,我希望测试文件组织清晰,以便快速找到和运行相关测试。
#### 验收标准
1. WHEN 检查测试目录 THEN 系统 SHALL 将单元测试放置在 tests/Unit 目录
2. WHEN 检查测试目录 THEN 系统 SHALL 将功能测试放置在 tests/Feature 目录
3. WHEN 测试需要测试数据 THEN 系统 SHALL 将测试固件放置在 tests/fixtures 目录
4. WHEN 测试文件命名 THEN 系统 SHALL 使用 Test 后缀并与被测试的类名对应
5. WHEN 运行测试 THEN 系统 SHALL 提供清晰的测试输出显示通过和失败的测试
### 需求 7版本控制和忽略规则
**用户故事**:作为开发者,我希望版本控制配置合理,以便只跟踪必要的文件。
#### 验收标准
1. WHEN 检查 .gitignore THEN 系统 SHALL 排除所有生成的文件和目录
2. WHEN 检查 .gitignore THEN 系统 SHALL 排除所有包含敏感信息的文件
3. WHEN 检查 .gitignore THEN 系统 SHALL 排除所有依赖包目录
4. WHEN 检查 .gitignore THEN 系统 SHALL 排除所有 IDE 特定的配置文件
5. WHEN 检查 .gitignore THEN 系统 SHALL 保留必要的示例配置文件
### 需求 8脚本和自动化工具
**用户故事**:作为开发者,我希望有便捷的脚本工具,以便快速执行常见任务。
#### 验收标准
1. WHEN 需要启动开发环境 THEN 系统 SHALL 提供一键启动脚本
2. WHEN 需要运行测试 THEN 系统 SHALL 在 composer.json 中定义测试脚本
3. WHEN 需要代码格式化 THEN 系统 SHALL 提供格式化脚本使用 Laravel Pint
4. WHEN 需要验证安装 THEN 系统 SHALL 提供验证脚本检查所有依赖和配置
5. WHEN 脚本执行失败 THEN 系统 SHALL 提供清晰的错误信息和解决建议
### 需求 9存储目录结构
**用户故事**:作为系统管理员,我希望存储目录结构清晰,以便管理上传的文件和生成的内容。
#### 验收标准
1. WHEN 文档上传 THEN 系统 SHALL 将原始文档存储在 storage/app/private/documents 目录按日期分层
2. WHEN 文档转换完成 THEN 系统 SHALL 将 Markdown 文件存储在 storage/app/private/markdown 目录按日期分层
3. WHEN 检查存储目录 THEN 系统 SHALL 在每个存储子目录中包含 .gitignore 文件
4. WHEN 系统需要临时文件 THEN 系统 SHALL 使用 storage/app/temp 目录并定期清理
5. WHEN 检查存储权限 THEN 系统 SHALL 确保 storage 目录及其子目录具有正确的写入权限
### 需求 10规格文档管理
**用户故事**:作为项目经理,我希望功能规格文档组织良好,以便跟踪功能开发进度。
#### 验收标准
1. WHEN 检查规格目录 THEN 系统 SHALL 将所有功能规格放置在 .kiro/specs 目录
2. WHEN 创建新功能规格 THEN 系统 SHALL 为每个功能创建独立的子目录
3. WHEN 功能规格包含多个文档 THEN 系统 SHALL 包含 requirements.md、design.md 和 tasks.md
4. WHEN 规格文档更新 THEN 系统 SHALL 确保文档之间的引用保持一致
5. WHEN 功能开发完成 THEN 系统 SHALL 在规格文档中标记完成状态

View File

@@ -0,0 +1,993 @@
# 设计文档
## 概述
知识库系统是一个基于 Laravel Filament 3.X 构建的文档管理平台。系统采用 Laravel 的 MVC 架构模式,利用 Filament 的管理面板功能提供直观的中文用户界面。核心功能包括文档上传、分类管理、基于分组的权限控制、文档格式转换和全文搜索。
系统使用 Laravel 的 Eloquent ORM 进行数据库操作,利用 Filament 的资源Resources和表单构建器创建管理界面通过策略Policies和作用域Scopes实现细粒度的权限控制。文档上传后自动转换为 Markdown 格式,并通过 Meilisearch 搜索引擎实现快速的全文搜索功能。用户可以在线预览 Markdown 渲染内容,也可以下载原始 Word 文档。
## 架构
### 技术栈
- **后端框架**: Laravel 10.x/11.x
- **管理面板**: Filament 3.X
- **数据库**: MySQL 8.0+ / PostgreSQL 13+
- **文件存储**: Laravel Storage (支持本地和云存储)
- **认证**: Laravel Breeze/Jetstream + Filament Auth
- **搜索引擎**: Meilisearch 1.5+
- **文档转换**: Pandoc 或 PHPWord
- **Markdown 渲染**: CommonMark PHP 或 Laravel Markdown
- **队列系统**: Redis Queue (用于异步文档转换)
### 架构模式
系统采用分层架构:
```
┌─────────────────────────────────────┐
│ Filament UI Layer (视图层) │
│ - Resources │
│ - Forms & Tables │
│ - Actions & Filters │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Application Layer (应用层) │
│ - Controllers │
│ - Policies │
│ - Form Requests │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Domain Layer (领域层) │
│ - Models │
│ - Services │
│ - Repositories (可选) │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Infrastructure Layer (基础设施层) │
│ - Database │
│ - File Storage │
│ - Cache │
└─────────────────────────────────────┘
```
## 组件和接口
### 核心模型
#### User (用户模型)
```php
class User extends Authenticatable
{
// 关联关系
public function groups(): BelongsToMany;
public function uploadedDocuments(): HasMany;
public function downloadLogs(): HasMany;
}
```
#### Group (分组模型)
```php
class Group extends Model
{
// 关联关系
public function users(): BelongsToMany;
public function documents(): HasMany;
}
```
#### Document (文档模型)
```php
class Document extends Model
{
use Searchable; // Laravel Scout trait for Meilisearch
// 属性
protected $fillable = [
'title',
'file_path',
'file_name',
'file_size',
'mime_type',
'type', // 'global' 或 'dedicated'
'group_id',
'uploaded_by',
'description',
'markdown_path', // Markdown 文件路径
'markdown_preview', // Markdown 内容摘要
'conversion_status' // 'pending', 'processing', 'completed', 'failed'
];
// 关联关系
public function group(): BelongsTo;
public function uploader(): BelongsTo;
public function downloadLogs(): HasMany;
// 作用域
public function scopeAccessibleBy(Builder $query, User $user): Builder;
public function scopeGlobal(Builder $query): Builder;
public function scopeDedicated(Builder $query): Builder;
// Meilisearch 配置
public function toSearchableArray(): array;
public function shouldBeSearchable(): bool;
// 辅助方法
public function getMarkdownContent(): ?string; // 从文件读取完整 Markdown 内容
public function hasMarkdown(): bool; // 检查是否已转换
}
```
#### DownloadLog (下载日志模型)
```php
class DownloadLog extends Model
{
protected $fillable = [
'document_id',
'user_id',
'downloaded_at',
'ip_address'
];
public function document(): BelongsTo;
public function user(): BelongsTo;
}
```
### Filament 资源
#### DocumentResource
负责文档的 CRUD 操作界面:
- 列表页:显示文档列表,支持搜索和筛选
- 创建页:上传文档并设置分类
- 编辑页:修改文档信息和分类
- 查看页:查看文档详情和下载
#### GroupResource
负责分组管理界面:
- 列表页:显示所有分组
- 创建页:创建新分组
- 编辑页:编辑分组信息和成员
- 关系管理器:管理分组成员和文档
#### UserResource
负责用户管理界面:
- 列表页:显示所有用户
- 编辑页:编辑用户信息和分组归属
- 关系管理器:管理用户的分组关系
### 服务类
#### DocumentService
```php
class DocumentService
{
public function uploadDocument(
UploadedFile $file,
string $title,
string $type,
?int $groupId,
int $uploaderId
): Document;
public function validateDocumentAccess(Document $document, User $user): bool;
public function downloadDocument(Document $document, User $user): StreamedResponse;
public function logDownload(Document $document, User $user): void;
}
```
#### DocumentConversionService
```php
class DocumentConversionService
{
public function convertToMarkdown(Document $document): string;
public function queueConversion(Document $document): void;
public function saveMarkdownToFile(Document $document, string $markdown): string; // 返回文件路径
public function updateDocumentMarkdown(Document $document, string $markdownPath): void;
public function handleConversionFailure(Document $document, Exception $e): void;
public function getMarkdownPreview(string $markdown, int $length = 500): string;
}
```
#### DocumentSearchService
```php
class DocumentSearchService
{
public function search(string $query, User $user, array $filters = []): Collection;
public function indexDocument(Document $document): void; // 读取 Markdown 文件并索引
public function updateDocumentIndex(Document $document): void;
public function removeDocumentFromIndex(Document $document): void;
public function filterByUserPermissions(Collection $results, User $user): Collection;
public function prepareSearchableData(Document $document): array; // 准备索引数据,包含完整 Markdown 内容
}
```
#### MarkdownRenderService
```php
class MarkdownRenderService
{
public function render(string $markdown): string;
public function sanitize(string $html): string;
public function extractPreview(string $markdown, int $length = 200): string;
}
```
### 策略类
#### DocumentPolicy
```php
class DocumentPolicy
{
public function viewAny(User $user): bool;
public function view(User $user, Document $document): bool;
public function create(User $user): bool;
public function update(User $user, Document $document): bool;
public function delete(User $user, Document $document): bool;
public function download(User $user, Document $document): bool;
}
```
## 数据模型
### 数据库表结构
#### users 表
```sql
- id: bigint (主键)
- name: varchar(255)
- email: varchar(255) (唯一)
- password: varchar(255)
- created_at: timestamp
- updated_at: timestamp
```
#### groups 表
```sql
- id: bigint (主键)
- name: varchar(255)
- description: text (可空)
- created_at: timestamp
- updated_at: timestamp
```
#### group_user 表 (多对多中间表)
```sql
- id: bigint (主键)
- group_id: bigint (外键)
- user_id: bigint (外键)
- created_at: timestamp
- updated_at: timestamp
- 唯一索引: (group_id, user_id)
```
#### documents 表
```sql
- id: bigint (主键)
- title: varchar(255)
- description: text (可空)
- file_path: varchar(500) -- 原始 Word 文档路径
- file_name: varchar(255)
- file_size: bigint
- mime_type: varchar(100)
- type: enum('global', 'dedicated')
- group_id: bigint (外键, 可空)
- uploaded_by: bigint (外键)
- markdown_path: varchar(500) (可空) -- Markdown 文件存储路径
- markdown_preview: text (可空) -- Markdown 内容摘要(前 500 字符),用于快速预览
- conversion_status: enum('pending', 'processing', 'completed', 'failed') -- 转换状态
- conversion_error: text (可空) -- 转换失败时的错误信息
- created_at: timestamp
- updated_at: timestamp
- 索引: type, group_id, uploaded_by, conversion_status
```
**存储策略说明**
- 原始 Word 文档存储在 `storage/app/private/documents/` 目录
- 转换后的 Markdown 文件存储在 `storage/app/private/markdown/` 目录
- 数据库中只保存文件路径和内容摘要
- Meilisearch 索引包含完整的 Markdown 内容用于搜索
- 这种设计减少数据库体积,同时保持搜索性能
#### download_logs 表
```sql
- id: bigint (主键)
- document_id: bigint (外键)
- user_id: bigint (外键)
- ip_address: varchar(45)
- downloaded_at: timestamp
- 索引: document_id, user_id, downloaded_at
```
### 实体关系图
```mermaid
erDiagram
User ||--o{ Document : uploads
User }o--o{ Group : belongs_to
Group ||--o{ Document : owns
Document ||--o{ DownloadLog : has
User ||--o{ DownloadLog : creates
User {
bigint id PK
string name
string email UK
string password
timestamp created_at
timestamp updated_at
}
Group {
bigint id PK
string name
text description
timestamp created_at
timestamp updated_at
}
Document {
bigint id PK
string title
text description
string file_path
string file_name
bigint file_size
string mime_type
enum type
bigint group_id FK
bigint uploaded_by FK
timestamp created_at
timestamp updated_at
}
DownloadLog {
bigint id PK
bigint document_id FK
bigint user_id FK
string ip_address
timestamp downloaded_at
}
```
## 正确性属性
*属性是指在系统的所有有效执行中都应该成立的特征或行为——本质上是关于系统应该做什么的正式声明。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。*
基于需求文档中的验收标准,我们识别出以下可测试的正确性属性:
### 属性 1文件格式验证
*对于任何*上传的文件,系统应该正确识别其是否为有效的 Word 文档格式(.doc 或 .docx并且只接受有效格式的文件
**验证需求1.1, 1.4**
### 属性 2文档存储完整性
*对于任何*成功上传的文档系统应该将文件存储到指定位置并在数据库中记录完整的元数据标题、文件路径、文件大小、MIME 类型、上传者等)
**验证需求1.2**
### 属性 3上传事务一致性
*对于任何*文档上传操作,如果过程中发生错误,系统应该回滚所有更改,确保数据库记录和文件系统保持一致状态
**验证需求1.5**
### 属性 4专用文档必须关联分组
*对于任何*类型为"专用知识库"的文档,系统应该要求并验证其关联了有效的分组 ID
**验证需求2.2**
### 属性 5全局文档对所有用户可见
*对于任何*全局知识库文档和任何已认证用户,该用户应该能够查看和访问该文档
**验证需求2.3**
### 属性 6文档分类持久化
*对于任何*文档,当其分类信息(类型和分组)被保存后,从数据库查询应该返回相同的分类信息
**验证需求2.4**
### 属性 7用户文档列表权限过滤
*对于任何*用户,当查询其可访问的文档列表时,返回的结果应该只包含:(1) 所有全局知识库文档,以及 (2) 该用户所属分组的专用知识库文档
**验证需求3.1, 3.2, 3.3**
### 属性 8其他分组文档隔离
*对于任何*用户和任何不属于该用户分组的专用知识库文档,该文档不应该出现在用户的可访问文档列表中
**验证需求3.4**
### 属性 9有权限文档可下载
*对于任何*用户和该用户有权限访问的文档,系统应该允许用户成功下载该文档
**验证需求4.1**
### 属性 10无权限文档访问拒绝
*对于任何*用户和该用户无权限访问的文档(其他分组的专用文档),系统应该拒绝访问请求
**验证需求4.2**
### 属性 11下载日志记录
*对于任何*文档下载操作,系统应该在 download_logs 表中创建一条记录,包含文档 ID、用户 ID、下载时间和 IP 地址
**验证需求4.3**
### 属性 12文档下载往返一致性
*对于任何*上传的文档,下载后的文件内容应该与原始上传的文件内容完全一致
**验证需求4.4**
### 属性 13分组数据持久化
*对于任何*新创建的分组,系统应该在数据库中保存分组名称和描述信息,并且查询应该返回相同的数据
**验证需求5.1**
### 属性 14用户分组关联
*对于任何*用户和分组,当用户被分配到该分组时,系统应该在 group_user 表中创建关联记录
**验证需求5.2**
### 属性 15分组分配授予权限
*对于任何*用户,当该用户被分配到某个分组后,该用户应该能够访问该分组的所有专用知识库文档
**验证需求5.3**
### 属性 16分组移除撤销权限
*对于任何*用户,当该用户从某个分组中移除后,该用户应该无法再访问该分组的专用知识库文档
**验证需求5.4**
### 属性 17分组删除级联处理
*对于任何*分组,当该分组被删除时,系统应该正确处理该分组的专用文档(设置为孤立状态或重新分配)
**验证需求5.5**
### 属性 18关键词搜索匹配
*对于任何*搜索关键词,系统返回的文档列表应该只包含标题或描述中包含该关键词的文档
**验证需求6.1**
### 属性 19分类筛选准确性
*对于任何*选定的文档分类(全局或专用),系统返回的文档列表应该只包含该分类的文档
**验证需求6.2**
### 属性 20分组筛选准确性
*对于任何*选定的分组,系统返回的文档列表应该只包含属于该分组的专用文档
**验证需求6.3**
### 属性 21组合筛选交集
*对于任何*多个筛选条件的组合(如分类 + 分组 + 关键词),系统返回的文档应该同时满足所有筛选条件
**验证需求6.5**
### 属性 22权限检查验证用户和分组
*对于任何*文档访问请求,系统应该验证请求用户的身份和该用户的分组归属
**验证需求7.1**
### 属性 23数据返回前权限验证
*对于任何*文档查询操作,系统应该在返回数据前执行权限检查,确保用户有权访问
**验证需求7.2**
### 属性 24未授权访问日志记录
*对于任何*权限验证失败的访问尝试,系统应该在安全日志中记录该事件
**验证需求7.3**
### 属性 25查询结果数据隔离
*对于任何*用户的文档查询,返回的结果集应该只包含该用户有权访问的文档,不包含其他用户的私有数据
**验证需求7.4**
### 属性 26错误消息中文化
*对于任何*系统操作产生的错误或成功消息,消息文本应该使用简体中文
**验证需求8.3**
### 属性 27日期格式中文化
*对于任何*需要显示的日期时间值,系统应该使用中文日期格式进行格式化
**验证需求8.5**
### 属性 28文档上传触发转换
*对于任何*成功上传的 Word 文档,系统应该自动触发 Markdown 转换流程
**验证需求9.1**
### 属性 29Markdown 内容持久化
*对于任何*转换完成的文档,系统应该将 Markdown 内容正确存储到文件系统,并在数据库中记录文件路径和内容摘要,读取文件应该返回相同的内容
**验证需求9.2**
### 属性 30转换失败不影响文档可用性
*对于任何*转换失败的文档,系统应该记录错误信息,但文档记录应该保持有效状态,用户仍然可以下载原始文件
**验证需求9.3**
### 属性 31原始文档保留
*对于任何*转换成功的文档,原始 Word 文件应该仍然存在于文件系统中,并且可以被下载
**验证需求9.4**
### 属性 32文档更新重新转换
*对于任何*已存在的文档,当其被更新或重新上传时,系统应该重新执行转换流程,并且新的 Markdown 内容应该替换旧内容
**验证需求9.5**
### 属性 33搜索匹配 Markdown 内容
*对于任何*包含特定关键词的文档 Markdown 内容,使用该关键词搜索应该返回该文档(假设用户有权限)
**验证需求10.1**
### 属性 34搜索覆盖多个字段
*对于任何*关键词,如果该关键词出现在文档的标题、描述或 Markdown 内容中的任何一个字段,搜索应该返回该文档
**验证需求10.2**
### 属性 35搜索结果权限过滤
*对于任何*搜索查询和用户,返回的搜索结果应该只包含该用户有权限访问的文档(全局文档和用户分组的专用文档)
**验证需求10.3**
### 属性 36搜索结果包含必需信息
*对于任何*搜索结果项,应该包含文档标题、内容片段、文档类型和上传时间等信息
**验证需求10.4**
### 属性 37有权限文档可预览
*对于任何*用户有权限访问的文档,该用户应该能够获取该文档的 Markdown 渲染后的 HTML 内容
**验证需求11.1**
### 属性 38Markdown 渲染正确性
*对于任何*包含标准 Markdown 元素(标题、列表、表格等)的 Markdown 内容,渲染后的 HTML 应该正确表示这些元素
**验证需求11.2**
### 属性 39无权限文档预览拒绝
*对于任何*用户无权限访问的文档,该用户尝试预览时应该被拒绝访问
**验证需求11.3**
### 属性 40转换完成触发索引
*对于任何*Markdown 转换完成的文档,系统应该自动将该文档索引到 Meilisearch
**验证需求12.1**
### 属性 41索引数据完整性
*对于任何*索引到 Meilisearch 的文档,索引数据应该包含文档 ID、标题、描述、Markdown 内容、类型和分组 ID
**验证需求12.2**
### 属性 42文档更新同步索引
*对于任何*已索引的文档当文档被更新时Meilisearch 中的索引数据应该同步更新
**验证需求12.3**
### 属性 43文档删除移除索引
*对于任何*已索引的文档,当文档被删除时,该文档应该从 Meilisearch 索引中移除
**验证需求12.4**
### 属性 44索引失败不影响文档保存
*对于任何*文档,即使 Meilisearch 索引操作失败,文档仍应该成功保存到数据库,并且可以正常使用
**验证需求12.5**
## 错误处理
### 文件上传错误
- **无效文件格式**: 返回 422 状态码,提示"文件格式不支持,请上传 Word 文档(.doc 或 .docx"
- **文件过大**: 返回 413 状态码,提示"文件大小超过限制(最大 XX MB"
- **存储失败**: 返回 500 状态码,提示"文件上传失败,请稍后重试",并回滚数据库操作
### 权限错误
- **未认证访问**: 返回 401 状态码,重定向到登录页面
- **无权限访问**: 返回 403 状态码,提示"您没有权限访问此文档"
- **文档不存在**: 返回 404 状态码,提示"文档不存在或已被删除"
### 数据验证错误
- **必填字段缺失**: 返回 422 状态码,提示具体缺失的字段
- **专用文档未指定分组**: 返回 422 状态码,提示"专用知识库文档必须指定所属分组"
- **无效的分组 ID**: 返回 422 状态码,提示"指定的分组不存在"
### 数据库错误
- **外键约束失败**: 返回 500 状态码,记录错误日志,提示"操作失败,请联系管理员"
- **唯一约束冲突**: 返回 422 状态码,提示"该记录已存在"
- **连接超时**: 返回 503 状态码,提示"服务暂时不可用,请稍后重试"
### 文档转换错误
- **转换工具不可用**: 返回 500 状态码,记录错误日志,文档状态设置为 'failed'
- **Word 文档损坏**: 返回 422 状态码,提示"文档文件损坏,无法转换"
- **转换超时**: 记录错误日志,文档状态设置为 'failed',提示"文档转换超时,请稍后重试"
- **Markdown 内容过大**: 记录警告日志,截断内容或使用外部存储
### 搜索相关错误
- **Meilisearch 服务不可用**: 返回 503 状态码,提示"搜索服务暂时不可用,请稍后重试"
- **搜索查询语法错误**: 返回 400 状态码,提示"搜索关键词格式不正确"
- **索引操作失败**: 记录错误日志,不影响文档的正常保存和使用
- **搜索超时**: 返回 504 状态码,提示"搜索请求超时,请简化搜索条件"
### 预览相关错误
- **Markdown 内容为空**: 返回 200 状态码,显示提示信息和下载按钮
- **Markdown 渲染失败**: 返回 500 状态码,提示"内容渲染失败,请下载原始文档"
- **预览权限不足**: 返回 403 状态码,提示"您没有权限预览此文档"
### 错误处理策略
1. **用户友好的错误消息**: 所有错误消息使用简体中文,避免技术术语
2. **详细的日志记录**: 在服务器端记录完整的错误堆栈和上下文信息
3. **事务回滚**: 对于涉及多个操作的流程,确保失败时完全回滚
4. **安全考虑**: 不在错误消息中暴露敏感信息(如数据库结构、文件路径等)
5. **优雅降级**: 转换或搜索功能失败时,不影响文档的基本上传和下载功能
6. **异步重试**: 对于转换和索引失败的文档,支持后台重试机制
## 测试策略
### 单元测试
使用 PHPUnit 进行单元测试,覆盖以下组件:
1. **模型测试**
- 测试模型关联关系是否正确定义
- 测试作用域Scopes逻辑
- 测试模型验证规则
2. **服务类测试**
- 测试 DocumentService 的文档上传逻辑
- 测试权限验证逻辑
- 测试文件存储和检索
3. **策略测试**
- 测试 DocumentPolicy 的各种权限判断
- 测试边缘情况(如文档无分组、用户无分组等)
4. **表单请求测试**
- 测试验证规则是否正确
- 测试自定义验证逻辑
### 属性基础测试Property-Based Testing
使用 **Pest PHP** 的属性测试功能进行属性基础测试。每个属性测试应该:
- 配置为运行至少 **100 次迭代**
- 使用注释明确标注对应的设计文档中的正确性属性
- 标注格式:`// Feature: knowledge-base-system, Property X: [属性描述]`
- 每个正确性属性对应一个独立的属性测试
属性测试覆盖范围:
1. **文件格式验证属性**(属性 1
2. **文档存储完整性属性**(属性 2
3. **事务一致性属性**(属性 3
4. **权限过滤属性**(属性 7, 8
5. **往返一致性属性**(属性 12
6. **权限授予和撤销属性**(属性 15, 16
7. **搜索和筛选属性**(属性 18-21
8. **数据隔离属性**(属性 25
9. **文档转换属性**(属性 28-32
10. **全文搜索属性**(属性 33-36
11. **Markdown 预览属性**(属性 37-39
12. **Meilisearch 索引属性**(属性 40-44
### 功能测试
使用 Laravel 的功能测试框架测试完整的用户流程:
1. **文档上传流程**
- 管理员上传全局文档
- 管理员上传专用文档并指定分组
- 上传无效格式文件被拒绝
2. **权限控制流程**
- 用户查看自己分组的专用文档
- 用户无法查看其他分组的专用文档
- 用户可以查看所有全局文档
3. **分组管理流程**
- 创建分组并分配用户
- 用户从分组移除后失去权限
- 删除分组后文档状态正确处理
4. **搜索和筛选流程**
- 按关键词搜索文档
- 按分类筛选文档
- 组合多个筛选条件
5. **文档转换流程**
- 上传 Word 文档后自动触发转换
- 转换完成后 Markdown 内容正确存储
- 转换失败时文档仍然可用
- 更新文档时重新转换
6. **全文搜索流程**
- 搜索 Markdown 内容中的关键词
- 搜索结果遵循权限控制
- 搜索结果包含内容片段
- 空搜索关键词的处理
7. **文档预览流程**
- 有权限用户可以预览 Markdown 内容
- 无权限用户无法预览
- Markdown 正确渲染为 HTML
- 预览页面提供下载按钮
8. **Meilisearch 索引流程**
- 文档转换后自动索引
- 文档更新时同步索引
- 文档删除时移除索引
- 索引失败不影响文档保存
### 测试数据生成
使用 Laravel Factories 生成测试数据:
```php
// UserFactory - 生成随机用户
// GroupFactory - 生成随机分组
// DocumentFactory - 生成随机文档(包括全局和专用)
// 使用 Faker 生成随机的中文文本
```
### 测试覆盖率目标
- 代码覆盖率:≥ 80%
- 关键业务逻辑覆盖率:≥ 95%
- 所有正确性属性都有对应的属性测试
### 持续集成
- 在 CI/CD 管道中自动运行所有测试
- 测试失败时阻止代码合并
- 生成测试覆盖率报告
## 性能考虑
### 数据库优化
1. **索引策略**
- documents 表:在 type, group_id, uploaded_by 字段上创建索引
- download_logs 表:在 document_id, user_id, downloaded_at 上创建索引
- 在 group_user 表的 (group_id, user_id) 上创建唯一复合索引
2. **查询优化**
- 使用 Eager Loading 避免 N+1 查询问题
- 对文档列表查询使用分页
- 使用数据库视图或查询作用域简化复杂权限查询
3. **缓存策略**
- 缓存用户的分组信息TTL: 1小时
- 缓存文档元数据TTL: 30分钟
- 使用 Redis 存储会话和缓存数据
### 文件存储优化
1. **存储策略**
- 使用 Laravel Storage 抽象层,支持本地和云存储切换
- 按日期组织文件目录结构:`documents/YYYY/MM/DD/``markdown/YYYY/MM/DD/`
- 使用 UUID 作为文件名避免冲突
- Markdown 文件单独存储,便于管理和备份
- 数据库中保存 Markdown 摘要500 字符)用于快速预览
2. **大文件处理**
- 配置合理的上传大小限制(建议 50MB
- 使用流式下载避免内存溢出
- 使用队列异步处理文档转换
- 大 Markdown 文件(> 1MB分块读取和索引
3. **缓存策略**
- 缓存已读取的 Markdown 内容TTL: 1小时
- 缓存渲染后的 HTMLTTL: 30分钟
- 使用 Redis 存储热门文档的 Markdown 内容
### 并发控制
- 使用数据库事务确保数据一致性
- 对关键操作使用乐观锁或悲观锁
- 使用队列处理耗时操作(如文件处理、日志记录)
## 安全考虑
### 认证和授权
1. **认证机制**
- 使用 Laravel Sanctum 或 Jetstream 提供认证
- 实施会话超时机制
- 支持记住我功能
2. **授权机制**
- 使用 Laravel Policy 实现细粒度权限控制
- 在控制器和 Filament 资源中强制执行策略
- 使用 Gate 定义全局权限规则
### 数据安全
1. **文件安全**
- 文件存储在非公开目录
- 通过控制器验证权限后提供文件下载
- 验证文件 MIME 类型,防止恶意文件上传
2. **SQL 注入防护**
- 使用 Eloquent ORM 和参数化查询
- 避免使用原始 SQL 查询
- 对用户输入进行验证和清理
3. **XSS 防护**
- 使用 Blade 模板引擎自动转义输出
- 对富文本内容使用 HTML Purifier
- 设置适当的 Content Security Policy
### 审计日志
1. **操作日志**
- 记录所有文档的创建、修改、删除操作
- 记录用户的登录、登出活动
- 记录权限变更操作
2. **安全日志**
- 记录所有权限验证失败的尝试
- 记录异常的访问模式
- 定期审查安全日志
## 部署和配置
### 环境要求
- PHP 8.1+
- MySQL 8.0+ 或 PostgreSQL 13+
- Redis 6.0+(用于缓存和队列)
- Composer 2.x
- Node.js 18+ 和 npm用于前端资源编译
- Meilisearch 1.5+(搜索引擎服务)
- Pandoc 2.x+(用于文档转换,可选)或 PHPWord 库
### Filament 配置
1. **中文化配置**
```php
// config/app.php
'locale' => 'zh_CN',
'fallback_locale' => 'zh_CN',
// 安装 Filament 中文语言包
composer require filament/filament-zh-cn
```
2. **面板配置**
```php
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Blue,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([
Authenticate::class,
]);
}
```
### 文件存储配置
```php
// config/filesystems.php
'disks' => [
'documents' => [
'driver' => 'local',
'root' => storage_path('app/private/documents'),
'visibility' => 'private',
],
'markdown' => [
'driver' => 'local',
'root' => storage_path('app/private/markdown'),
'visibility' => 'private',
],
],
```
**文件组织结构**
```
storage/app/private/
├── documents/ # 原始 Word 文档
│ └── YYYY/MM/DD/
│ └── {uuid}.docx
└── markdown/ # 转换后的 Markdown 文件
└── YYYY/MM/DD/
└── {uuid}.md
```
### 队列配置
```php
// .env
QUEUE_CONNECTION=redis
// 启动队列工作进程
php artisan queue:work --queue=default,documents
```
### Meilisearch 配置
```php
// .env
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-master-key
SCOUT_DRIVER=meilisearch
// config/scout.php
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
'documents' => [
'filterableAttributes' => ['type', 'group_id', 'uploaded_by'],
'sortableAttributes' => ['created_at', 'title'],
'searchableAttributes' => ['title', 'description', 'markdown_content'],
],
],
],
// 启动 Meilisearch 服务
meilisearch --master-key="your-master-key"
```
### 文档转换配置
```php
// .env
DOCUMENT_CONVERSION_DRIVER=pandoc // 或 phpword
PANDOC_PATH=/usr/local/bin/pandoc
CONVERSION_TIMEOUT=300 // 秒
// config/documents.php
return [
'conversion' => [
'driver' => env('DOCUMENT_CONVERSION_DRIVER', 'pandoc'),
'pandoc_path' => env('PANDOC_PATH', '/usr/bin/pandoc'),
'timeout' => env('CONVERSION_TIMEOUT', 300),
'queue' => 'documents',
],
'markdown' => [
'renderer' => 'commonmark', // 或 'parsedown'
'sanitize' => true,
],
];
```
## 扩展性考虑
### 未来功能扩展
1. **文档版本控制**
- 支持文档多版本管理
- 版本比较和回滚功能
- 保留每个版本的 Markdown 内容
2. **增强的文档转换**
- 支持更多文档格式PDF、Excel、PPT
- 保留文档中的图片和格式
- 支持 OCR 识别扫描文档
3. **高级搜索功能**
- 支持搜索语法AND、OR、NOT
- 按日期范围筛选
- 搜索结果高亮显示
- 搜索建议和自动补全
4. **文档标签系统**
- 为文档添加多个标签
- 按标签筛选和搜索
- 标签云展示
5. **文档评论和协作**
- 用户可以对文档添加评论
- 支持文档收藏和分享
- 文档阅读统计
6. **AI 增强功能**
- 文档内容摘要生成
- 智能问答(基于文档内容)
- 相关文档推荐
### 架构扩展性
1. **微服务化**
- 文档存储服务可以独立部署
- 使用消息队列解耦服务
2. **多租户支持**
- 支持多个组织独立使用系统
- 数据完全隔离
3. **API 接口**
- 提供 RESTful API
- 支持第三方系统集成
## 总结
本设计文档详细描述了基于 Laravel Filament 3.X 的知识库系统的架构、组件、数据模型和正确性属性。系统采用分层架构,通过 Eloquent ORM 和 Filament 资源实现数据管理,使用策略和作用域实现细粒度的权限控制。系统集成了文档格式转换和 Meilisearch 全文搜索功能,提供强大的文档管理和检索能力。
关键设计决策包括:
1. 使用枚举类型区分全局和专用知识库
2. 通过多对多关系管理用户和分组
3. 使用查询作用域实现权限过滤
4. 采用属性基础测试验证核心业务逻辑
5. 全面的中文化支持
6. 异步队列处理文档转换,避免阻塞用户操作
7. 使用 Meilisearch 实现快速全文搜索,搜索结果遵循权限控制
8. Markdown 格式存储支持在线预览和内容检索
9. 优雅降级策略,转换或搜索失败不影响核心功能
系统设计充分考虑了安全性、性能和可扩展性,为后续的实施提供了清晰的指导。文档转换和搜索功能的引入大大提升了知识库的可用性和检索效率。

View File

@@ -0,0 +1,168 @@
# 需求文档
## 简介
知识库系统是一个基于 Laravel Filament 3.X 的文档管理平台,允许用户上传和管理 Word 文档。系统支持文档分类为全局知识库和专用知识库,并根据用户所属分组控制访问权限。用户只能查看全局知识库和自己分组的专用知识库,无法访问其他分组的专用知识库。
系统具备文档格式转换功能,自动将上传的 Word 文档(.doc/.docx转换为 Markdown 格式存储,并通过 Meilisearch 搜索引擎提供强大的全文搜索能力。用户可以通过关键词快速检索文档内容,也可以在线预览文档的 Markdown 渲染内容,无需下载即可查看文档信息。
## 术语表
- **系统System**: 指知识库管理系统
- **用户User**: 使用系统的人员
- **分组Group**: 用户所属的组织单位
- **文档Document**: 用户上传的 Word 文件
- **全局知识库Global Knowledge Base**: 所有用户都可以查看的知识库
- **专用知识库Dedicated Knowledge Base**: 仅特定分组用户可以查看的知识库
- **文档分类Document Category**: 文档的类型标签,用于组织和检索
- **Markdown 内容Markdown Content**: 从 Word 文档转换后的 Markdown 格式文本
- **文档转换Document Conversion**: 将 Word 文档转换为 Markdown 格式的过程
- **全文搜索Full-Text Search**: 在文档内容中搜索关键词的功能
- **Meilisearch**: 用于实现全文搜索的搜索引擎
- **文档索引Document Index**: 在 Meilisearch 中存储的文档搜索数据
- **文档预览Document Preview**: 在线查看文档 Markdown 内容的功能
## 需求
### 需求 1
**用户故事:** 作为系统管理员,我想要上传 Word 文档到知识库,以便为用户提供知识资源
#### 验收标准
1. WHEN 管理员选择 Word 文档并点击上传按钮 THEN 系统 SHALL 验证文件格式为 .doc 或 .docx
2. WHEN 文档格式验证通过 THEN 系统 SHALL 将文档存储到服务器并记录文档元数据
3. WHEN 文档上传成功 THEN 系统 SHALL 显示成功消息并返回文档列表页面
4. IF 上传的文件不是 Word 格式 THEN 系统 SHALL 拒绝上传并显示错误提示信息
5. WHEN 文档上传过程中发生错误 THEN 系统 SHALL 回滚操作并保持数据一致性
### 需求 2
**用户故事:** 作为系统管理员,我想要为文档设置分类,以便组织和管理知识库内容
#### 验收标准
1. WHEN 管理员创建或编辑文档 THEN 系统 SHALL 提供选项将文档分类为全局知识库或专用知识库
2. WHERE 文档被分类为专用知识库 THEN 系统 SHALL 要求管理员指定关联的分组
3. WHEN 文档被分类为全局知识库 THEN 系统 SHALL 允许所有用户查看该文档
4. WHEN 文档分类信息被保存 THEN 系统 SHALL 更新数据库中的文档分类字段
5. WHEN 文档已关联分组 THEN 系统 SHALL 在文档列表中显示关联的分组名称
### 需求 3
**用户故事:** 作为普通用户,我想要查看知识库文档列表,以便找到我需要的知识资源
#### 验收标准
1. WHEN 用户访问知识库页面 THEN 系统 SHALL 显示该用户有权限查看的所有文档列表
2. WHEN 系统过滤文档列表 THEN 系统 SHALL 包含所有全局知识库文档
3. WHEN 系统过滤文档列表 THEN 系统 SHALL 包含用户所属分组的专用知识库文档
4. WHEN 系统过滤文档列表 THEN 系统 SHALL 排除其他分组的专用知识库文档
5. WHEN 文档列表显示 THEN 系统 SHALL 展示文档标题、分类类型、上传时间和所属分组信息
### 需求 4
**用户故事:** 作为普通用户,我想要下载或查看文档内容,以便获取知识信息
#### 验收标准
1. WHEN 用户点击有权限的文档 THEN 系统 SHALL 允许用户下载该文档
2. WHEN 用户尝试访问无权限的文档 THEN 系统 SHALL 拒绝访问并显示权限不足提示
3. WHEN 用户下载文档 THEN 系统 SHALL 记录下载日志包含用户信息和时间戳
4. WHEN 文档被下载 THEN 系统 SHALL 保持原始文件格式和内容完整性
### 需求 5
**用户故事:** 作为系统管理员,我想要管理用户分组,以便控制专用知识库的访问权限
#### 验收标准
1. WHEN 管理员创建新分组 THEN 系统 SHALL 保存分组名称和描述信息
2. WHEN 管理员将用户分配到分组 THEN 系统 SHALL 更新用户的分组关联关系
3. WHEN 用户被分配到分组 THEN 系统 SHALL 自动授予该用户访问该分组专用知识库的权限
4. WHEN 用户从分组中移除 THEN 系统 SHALL 撤销该用户访问该分组专用知识库的权限
5. WHEN 分组被删除 THEN 系统 SHALL 将该分组的专用知识库文档转换为孤立状态或重新分配
### 需求 6
**用户故事:** 作为系统管理员,我想要搜索和筛选文档,以便快速找到特定的知识资源
#### 验收标准
1. WHEN 管理员在搜索框输入关键词 THEN 系统 SHALL 在文档标题和内容中搜索匹配项
2. WHEN 管理员选择分类筛选器 THEN 系统 SHALL 仅显示选定分类的文档
3. WHEN 管理员选择分组筛选器 THEN 系统 SHALL 仅显示选定分组的文档
4. WHEN 搜索结果为空 THEN 系统 SHALL 显示无结果提示信息
5. WHEN 应用多个筛选条件 THEN 系统 SHALL 返回同时满足所有条件的文档
### 需求 7
**用户故事:** 作为系统,我想要确保数据安全和权限隔离,以便保护敏感知识资源
#### 验收标准
1. WHEN 系统执行权限检查 THEN 系统 SHALL 验证用户身份和分组归属
2. WHEN 用户尝试访问文档 THEN 系统 SHALL 在返回数据前验证访问权限
3. WHEN 权限验证失败 THEN 系统 SHALL 记录未授权访问尝试到安全日志
4. WHEN 文档数据被查询 THEN 系统 SHALL 应用行级安全策略确保数据隔离
5. WHEN 用户会话过期 THEN 系统 SHALL 要求重新认证后才能访问知识库
### 需求 8
**用户故事:** 作为系统,我想要提供中文界面,以便中文用户能够流畅使用系统
#### 验收标准
1. WHEN 系统加载任何页面 THEN 系统 SHALL 以简体中文显示所有界面元素
2. WHEN 系统显示表单标签 THEN 系统 SHALL 使用中文标签和占位符文本
3. WHEN 系统显示错误或成功消息 THEN 系统 SHALL 使用中文提示信息
4. WHEN 系统显示数据表格 THEN 系统 SHALL 使用中文列标题和操作按钮
5. WHEN 系统显示日期时间 THEN 系统 SHALL 使用中文日期格式
### 需求 9
**用户故事:** 作为系统管理员,我想要系统自动将上传的 Word 文档转换为 Markdown 格式,以便实现文档内容的全文搜索和在线预览
#### 验收标准
1. WHEN 用户上传 Word 文档并保存成功 THEN 系统 SHALL 自动将文档内容转换为 Markdown 格式
2. WHEN 文档转换完成 THEN 系统 SHALL 将 Markdown 内容存储到数据库的 markdown_content 字段
3. WHEN 文档转换失败 THEN 系统 SHALL 记录错误日志并保留原始文档,不影响文档的正常使用
4. WHEN 文档转换成功 THEN 系统 SHALL 保留原始 Word 文档供用户下载
5. WHEN 文档被更新或重新上传 THEN 系统 SHALL 重新执行转换流程并更新 Markdown 内容
### 需求 10
**用户故事:** 作为普通用户,我想要通过关键词搜索文档内容,以便快速找到包含特定信息的文档
#### 验收标准
1. WHEN 用户在搜索页面输入关键词 THEN 系统 SHALL 使用 Meilisearch 在文档的 Markdown 内容中搜索匹配项
2. WHEN 系统执行搜索 THEN 系统 SHALL 同时搜索文档标题、描述和 Markdown 内容
3. WHEN 搜索返回结果 THEN 系统 SHALL 只显示用户有权限访问的文档(全局文档和用户分组的专用文档)
4. WHEN 搜索结果显示 THEN 系统 SHALL 展示文档标题、匹配的内容片段、文档类型和上传时间
5. WHEN 搜索关键词为空 THEN 系统 SHALL 显示提示信息要求输入搜索关键词
### 需求 11
**用户故事:** 作为普通用户,我想要在线预览文档的 Markdown 内容,以便无需下载即可查看文档内容
#### 验收标准
1. WHEN 用户点击有权限的文档查看按钮 THEN 系统 SHALL 显示文档的 Markdown 内容渲染后的 HTML 页面
2. WHEN 系统渲染 Markdown 内容 THEN 系统 SHALL 正确处理标题、列表、表格、图片等 Markdown 元素
3. WHEN 用户尝试预览无权限的文档 THEN 系统 SHALL 拒绝访问并显示权限不足提示
4. WHEN 文档的 Markdown 内容为空 THEN 系统 SHALL 显示提示信息并提供下载原始文档的选项
5. WHEN 预览页面显示 THEN 系统 SHALL 提供下载原始 Word 文档的按钮
### 需求 12
**用户故事:** 作为系统,我想要将文档内容索引到 Meilisearch以便提供快速准确的全文搜索功能
#### 验收标准
1. WHEN 文档的 Markdown 转换完成 THEN 系统 SHALL 将文档信息索引到 Meilisearch
2. WHEN 索引文档到 Meilisearch THEN 系统 SHALL 包含文档 ID、标题、描述、Markdown 内容、类型和分组 ID
3. WHEN 文档被更新 THEN 系统 SHALL 更新 Meilisearch 中对应的索引
4. WHEN 文档被删除 THEN 系统 SHALL 从 Meilisearch 中删除对应的索引
5. WHEN Meilisearch 索引操作失败 THEN 系统 SHALL 记录错误日志但不影响文档的正常保存和使用

View File

@@ -0,0 +1,670 @@
# 实施计划
- [x] 1. 初始化 Laravel 项目和安装 Filament
- 创建新的 Laravel 项目或在现有项目中安装 Filament 3.X
- 配置数据库连接
- 安装 Filament 中文语言包
- 配置应用语言为简体中文
- _需求8.1, 8.2, 8.3, 8.4, 8.5_
- [x] 2. 创建数据库迁移和模型
- [x] 2.1 创建 groups 表迁移和 Group 模型
- 编写迁移文件创建 groups 表id, name, description, timestamps
- 创建 Group 模型并定义关联关系
- _需求5.1_
- [x] 2.2 创建 group_user 中间表迁移
- 编写迁移文件创建 group_user 表id, group_id, user_id, timestamps
- 添加外键约束和唯一索引
- _需求5.2_
- [x] 2.3 更新 User 模型添加分组关联
- 在 User 模型中添加 groups() 关联方法
- 添加 uploadedDocuments() 和 downloadLogs() 关联方法
- _需求5.2_
- [x] 2.4 创建 documents 表迁移和 Document 模型
- 编写迁移文件创建 documents 表(包含所有必需字段和索引)
- 创建 Document 模型并定义 fillable 属性
- 定义与 Group、User 的关联关系
- _需求1.2, 2.1, 2.2, 2.4_
- [x] 2.5 创建 download_logs 表迁移和 DownloadLog 模型
- 编写迁移文件创建 download_logs 表
- 创建 DownloadLog 模型并定义关联关系
- _需求4.3_
- [x] 2.6 运行迁移创建数据库表
- 执行 php artisan migrate 命令
- 验证所有表和索引创建成功
- _需求所有数据模型相关需求_
- [ ] 3. 实现文档权限查询作用域
- [x] 3.1 在 Document 模型中实现 accessibleBy 作用域
- 编写 scopeAccessibleBy 方法实现权限过滤逻辑
- 包含全局文档和用户分组的专用文档
- 排除其他分组的专用文档
- _需求3.1, 3.2, 3.3, 3.4_
- [x] 3.2 编写属性测试验证权限过滤逻辑
- **属性 7用户文档列表权限过滤**
- **属性 8其他分组文档隔离**
- **验证需求3.1, 3.2, 3.3, 3.4**
- [x] 3.3 实现 global 和 dedicated 查询作用域
- 编写 scopeGlobal 方法过滤全局文档
- 编写 scopeDedicated 方法过滤专用文档
- _需求2.3, 6.2_
- [x] 4. 创建 DocumentService 服务类
- [x] 4.1 实现文档上传方法
- 编写 uploadDocument 方法处理文件上传
- 验证文件格式(.doc 或 .docx
- 存储文件到指定位置
- 创建数据库记录
- 实现事务处理确保一致性
- _需求1.1, 1.2, 1.4, 1.5_
- [ ]* 4.2 编写属性测试验证文件上传逻辑
- **属性 1文件格式验证**
- **属性 2文档存储完整性**
- **属性 3上传事务一致性**
- **验证需求1.1, 1.2, 1.4, 1.5**
- [x] 4.3 实现文档访问权限验证方法
- 编写 validateDocumentAccess 方法
- 检查用户是否有权访问指定文档
- _需求4.1, 4.2, 7.1, 7.2_
- [x] 4.4 实现文档下载方法
- 编写 downloadDocument 方法
- 验证用户权限
- 返回文件流式响应
- _需求4.1, 4.4_
- [ ]* 4.5 编写属性测试验证文档下载
- **属性 9有权限文档可下载**
- **属性 10无权限文档访问拒绝**
- **属性 12文档下载往返一致性**
- **验证需求4.1, 4.2, 4.4**
- [x] 4.6 实现下载日志记录方法
- 编写 logDownload 方法
- 记录用户、文档、时间和 IP 地址
- _需求4.3_
- [ ]* 4.7 编写属性测试验证下载日志
- **属性 11下载日志记录**
- **验证需求4.3**
- [ ] 5. 创建 DocumentPolicy 策略类
- [x] 5.1 实现文档策略的各种权限方法
- 编写 viewAny、view、create、update、delete 方法
- 编写 download 方法验证下载权限
- 实现基于分组的权限判断逻辑
- _需求3.1, 3.4, 4.1, 4.2, 7.1, 7.2_
- [ ]* 5.2 编写单元测试验证策略逻辑
- 测试各种权限场景(有权限、无权限、边缘情况)
- 测试全局文档和专用文档的权限差异
- _需求3.1, 3.4, 4.1, 4.2_
- [x] 6. 创建 Filament 资源 - GroupResource
- [x] 6.1 创建 GroupResource 基础结构
- 使用 Filament 命令生成 GroupResource
- 配置中文导航标签和标题
- _需求5.1, 8.1, 8.2_
- [x] 6.2 定义 Group 表单字段
- 添加名称字段(必填,中文标签)
- 添加描述字段(可选,中文标签)
- 配置验证规则
- _需求5.1, 8.2_
- [x] 6.3 定义 Group 表格列
- 添加 ID、名称、描述、创建时间列
- 配置中文列标题
- 添加搜索和排序功能
- _需求5.1, 8.4_
- [x] 6.4 添加用户关系管理器
- 创建 RelationManager 管理分组成员
- 配置用户添加和移除功能
- 使用中文标签
- _需求5.2, 5.3, 5.4_
- [ ]* 6.5 编写属性测试验证分组管理
- **属性 13分组数据持久化**
- **属性 14用户分组关联**
- **属性 15分组分配授予权限**
- **属性 16分组移除撤销权限**
- **验证需求5.1, 5.2, 5.3, 5.4**
- [x] 7. 创建 Filament 资源 - DocumentResource
- [x] 7.1 创建 DocumentResource 基础结构
- 使用 Filament 命令生成 DocumentResource
- 配置中文导航标签和标题
- _需求1.1, 8.1, 8.2_
- [x] 7.2 定义 Document 表单字段
- 添加标题字段(必填,中文标签)
- 添加描述字段(可选,中文标签)
- 添加文件上传字段(限制 .doc 和 .docx 格式)
- 添加类型选择字段(全局/专用,中文选项)
- 添加分组选择字段(当类型为专用时必填)
- 配置验证规则
- _需求1.1, 1.4, 2.1, 2.2, 8.2_
- [x] 7.3 实现文件上传处理逻辑
- 在表单中集成 DocumentService 的上传方法
- 处理文件验证和存储
- 显示中文成功/错误消息
- _需求1.1, 1.2, 1.3, 1.4, 8.3_
- [ ]* 7.4 编写属性测试验证专用文档分组要求
- **属性 4专用文档必须关联分组**
- **验证需求2.2**
- [x] 7.5 定义 Document 表格列
- 添加标题、类型、分组、上传者、上传时间列
- 配置中文列标题
- 使用中文日期格式
- 添加搜索功能
- _需求3.5, 8.4, 8.5_
- [x] 7.6 实现表格查询权限过滤
- 在 getEloquentQuery 方法中应用 accessibleBy 作用域
- 确保用户只能看到有权限的文档
- _需求3.1, 3.2, 3.3, 3.4_
- [x] 7.7 添加文档下载操作
- 创建自定义 Action 用于下载文档
- 集成 DocumentService 的下载方法
- 验证权限并记录日志
- 使用中文按钮标签
- _需求4.1, 4.2, 4.3, 8.2_
- [x] 7.8 添加筛选器
- 添加类型筛选器(全局/专用,中文标签)
- 添加分组筛选器(中文标签)
- 添加上传者筛选器
- _需求6.2, 6.3, 8.2_
- [ ]* 7.9 编写属性测试验证搜索和筛选
- **属性 18关键词搜索匹配**
- **属性 19分类筛选准确性**
- **属性 20分组筛选准确性**
- **属性 21组合筛选交集**
- **验证需求6.1, 6.2, 6.3, 6.5**
- [ ] 8. 更新 UserResource 添加分组管理
- [x] 8.1 在 UserResource 中添加分组关系管理器
- 创建 RelationManager 管理用户所属分组
- 配置分组添加和移除功能
- 使用中文标签
- _需求5.2, 5.3, 5.4_
- [x] 8.2 在用户表单中添加分组选择字段
- 添加多选分组字段
- 配置中文标签
- _需求5.2_
- [x] 9. 配置文件存储
- [x] 9.1 配置 documents 存储磁盘
- 在 config/filesystems.php 中添加 documents 磁盘配置
- 设置为 private 可见性
- _需求1.2, 4.4_
- [x] 9.2 创建文档存储目录
- 创建 storage/app/documents 目录
- 配置适当的权限
- _需求1.2_
- [x] 10. 实现安全日志记录
- [x] 10.1 创建安全日志记录功能
- 创建 SecurityLog 模型和迁移(可选)
- 或使用 Laravel 日志系统记录安全事件
- 记录未授权访问尝试
- _需求7.3_
- [ ]* 10.2 编写属性测试验证安全日志
- **属性 24未授权访问日志记录**
- **验证需求7.3**
- [x] 10.3 在 DocumentPolicy 中集成安全日志
- 在权限验证失败时记录日志
- 包含用户信息、文档信息和时间戳
- _需求7.3_
- [x] 11. 实现分组删除级联处理
- [x] 11.1 在 Group 模型中添加删除事件监听器
- 使用 Model Events 监听 deleting 事件
- 处理关联的专用文档(设置 group_id 为 null 或删除)
- _需求5.5_
- [ ]* 11.2 编写属性测试验证级联处理
- **属性 17分组删除级联处理**
- **验证需求5.5**
- [x] 12. 配置 Filament 面板中文化
- [x] 12.1 配置应用语言设置
- 在 config/app.php 中设置 locale 为 zh_CN
- 配置 fallback_locale
- _需求8.1, 8.2, 8.3, 8.4, 8.5_
- [x] 12.2 发布和配置 Filament 语言文件
- 发布 Filament 语言文件
- 自定义翻译文本以符合业务需求
- _需求8.1, 8.2, 8.3, 8.4_
- [x] 12.3 配置日期时间格式
- 在 Filament 配置中设置中文日期格式
- 配置 Carbon 使用中文语言
- _需求8.5_
- [ ]* 12.4 编写属性测试验证中文化
- **属性 26错误消息中文化**
- **属性 27日期格式中文化**
- **验证需求8.3, 8.5**
- [x] 13. 创建测试工厂Factories
- [x] 13.1 创建 GroupFactory
- 生成随机分组名称和描述
- 使用 Faker 生成中文文本
- _需求测试数据生成_
- [x] 13.2 创建 DocumentFactory
- 生成随机文档数据
- 支持生成全局和专用文档
- 关联随机分组和上传者
- _需求测试数据生成_
- [x] 13.3 创建 DownloadLogFactory
- 生成随机下载日志数据
- 关联随机文档和用户
- _需求测试数据生成_
- [ ] 14. 编写剩余的属性测试
- [ ]* 14.1 编写全局文档访问属性测试
- **属性 5全局文档对所有用户可见**
- **验证需求2.3**
- [ ]* 14.2 编写文档分类持久化属性测试
- **属性 6文档分类持久化**
- **验证需求2.4**
- [ ]* 14.3 编写权限检查属性测试
- **属性 22权限检查验证用户和分组**
- **属性 23数据返回前权限验证**
- **验证需求7.1, 7.2**
- [ ]* 14.4 编写数据隔离属性测试
- **属性 25查询结果数据隔离**
- **验证需求7.4**
- [ ] 15. 编写功能测试
- [ ]* 15.1 编写文档上传流程测试
- 测试管理员上传全局文档
- 测试管理员上传专用文档
- 测试上传无效格式文件被拒绝
- _需求1.1, 1.2, 1.4, 2.1, 2.2_
- [ ]* 15.2 编写权限控制流程测试
- 测试用户查看自己分组的专用文档
- 测试用户无法查看其他分组的专用文档
- 测试用户可以查看所有全局文档
- _需求3.1, 3.2, 3.3, 3.4_
- [ ]* 15.3 编写分组管理流程测试
- 测试创建分组并分配用户
- 测试用户从分组移除后失去权限
- 测试删除分组后文档状态
- _需求5.1, 5.2, 5.3, 5.4, 5.5_
- [ ]* 15.4 编写搜索和筛选流程测试
- 测试按关键词搜索文档
- 测试按分类筛选文档
- 测试组合多个筛选条件
- _需求6.1, 6.2, 6.3, 6.5_
- [ ] 16. 配置错误处理和验证消息
- [ ] 16.1 自定义验证错误消息
- 在语言文件中添加自定义验证消息
- 确保所有消息使用简体中文
- _需求8.3_
- [ ] 16.2 实现全局异常处理
- 在 Handler.php 中自定义异常响应
- 为不同类型的错误返回友好的中文消息
- _需求错误处理策略_
- [ ] 16.3 配置文件上传限制
- 在 php.ini 或 .htaccess 中配置上传大小限制
- 在表单验证中添加文件大小验证
- 显示中文错误提示
- _需求1.1, 错误处理_
- [ ] 17. 性能优化
- [ ] 17.1 添加数据库索引
- 验证所有必要的索引已创建
- 使用 EXPLAIN 分析查询性能
- _需求性能考虑_
- [ ] 17.2 实现查询优化
- 在 Filament 资源中使用 Eager Loading
- 避免 N+1 查询问题
- _需求性能考虑_
- [ ] 17.3 配置缓存
- 配置 Redis 缓存驱动
- 缓存用户分组信息
- 缓存文档元数据
- _需求性能考虑_
- [x] 18. 创建种子数据Seeders
- [x] 18.1 创建演示数据 Seeder
- 创建示例用户
- 创建示例分组
- 创建示例文档
- 建立关联关系
- _需求开发和演示_
- [x] 18.2 运行 Seeder 生成测试数据
- 执行 php artisan db:seed
- 验证数据正确生成
- _需求开发和演示_
- [x] 19. 安装和配置 Meilisearch
- [x] 19.1 安装 Meilisearch 服务
- 安装 Meilisearch 服务器Docker 或本地安装)
- 配置 Meilisearch 主密钥
- 启动 Meilisearch 服务
- _需求10.1, 12.1_
- [x] 19.2 安装 Laravel Scout 和 Meilisearch 驱动
- 安装 laravel/scout 包
- 安装 meilisearch/meilisearch-php 包
- 发布 Scout 配置文件
- _需求10.1, 12.1_
- [x] 19.3 配置 Scout 和 Meilisearch
- 在 .env 中配置 Meilisearch 连接信息
- 在 config/scout.php 中配置索引设置
- 配置可过滤、可排序和可搜索属性
- _需求10.1, 12.1_
- [ ] 20. 添加文档转换功能的数据库字段
- [x] 20.1 创建数据库迁移添加新字段
- 添加 markdown_path 字段varchar 500
- 添加 markdown_preview 字段text
- 添加 conversion_status 字段enum
- 添加 conversion_error 字段text
- 添加相应的索引
- _需求9.1, 9.2_
- [x] 20.2 运行迁移更新数据库
- 执行 php artisan migrate
- 验证新字段创建成功
- _需求9.1, 9.2_
- [x] 20.3 更新 Document 模型
- 添加新字段到 fillable 数组
- 添加 Searchable trait
- 实现 toSearchableArray 方法
- 实现 shouldBeSearchable 方法
- 添加 getMarkdownContent 辅助方法
- _需求9.2, 12.2_
- [x] 21. 安装和配置文档转换工具
- [x] 21.1 选择并安装转换工具
- 安装 Pandoc推荐或 phpoffice/phpword 包
- 验证 Pandoc 可执行文件路径
- 测试转换功能是否正常
- _需求9.1_
- [x] 21.2 创建文档转换配置文件
- 创建 config/documents.php 配置文件
- 配置转换驱动pandoc 或 phpword
- 配置转换超时时间
- 配置队列名称
- _需求9.1_
- [x] 21.3 配置 markdown 存储磁盘
- 在 config/filesystems.php 中添加 markdown 磁盘
- 设置为 private 可见性
- 创建 storage/app/private/markdown 目录
- _需求9.2_
- [x] 22. 实现 DocumentConversionService
- [x] 22.1 创建 DocumentConversionService 类
- 创建服务类文件
- 实现 convertToMarkdown 方法(调用 Pandoc 或 PHPWord
- 实现 saveMarkdownToFile 方法
- 实现 getMarkdownPreview 方法(提取前 500 字符)
- _需求9.1, 9.2_
- [x] 22.2 实现转换队列任务
- 创建 ConvertDocumentToMarkdown Job
- 在 Job 中调用 DocumentConversionService
- 实现转换成功后的处理逻辑
- 实现转换失败后的错误处理
- _需求9.1, 9.3_
- [x] 22.3 实现 queueConversion 方法
- 创建队列任务分发方法
- 更新文档状态为 'processing'
- 分发到 documents 队列
- _需求9.1_
- [ ]* 22.4 编写属性测试验证文档转换
- **属性 28文档上传触发转换**
- **属性 29Markdown 内容持久化**
- **属性 30转换失败不影响文档可用性**
- **属性 31原始文档保留**
- **属性 32文档更新重新转换**
- **验证需求9.1, 9.2, 9.3, 9.4, 9.5**
- [x] 23. 集成文档转换到上传流程
- [x] 23.1 更新 DocumentService 的 uploadDocument 方法
- 在文档保存成功后调用 queueConversion
- 设置初始 conversion_status 为 'pending'
- 确保事务正确处理
- _需求9.1_
- [x] 23.2 更新 DocumentResource 表单
- 在表格中显示转换状态
- 添加转换状态的中文标签
- 添加转换状态筛选器
- _需求9.1_
- [x] 23.3 处理文档更新时的重新转换
- 在文档更新时检测文件是否变更
- 如果文件变更,触发重新转换
- 删除旧的 Markdown 文件
- _需求9.5_
- [x] 24. 实现 DocumentSearchService
- [x] 24.1 创建 DocumentSearchService 类
- 创建服务类文件
- 实现 search 方法(使用 Scout 搜索)
- 实现 filterByUserPermissions 方法
- 实现 prepareSearchableData 方法
- _需求10.1, 10.2, 10.3_
- [x] 24.2 实现索引管理方法
- 实现 indexDocument 方法(读取 Markdown 文件并索引)
- 实现 updateDocumentIndex 方法
- 实现 removeDocumentFromIndex 方法
- 处理 Meilisearch 操作失败的情况
- _需求12.1, 12.3, 12.4, 12.5_
- [x] 24.3 集成索引到文档生命周期
- 在转换完成后自动索引文档
- 在文档更新时更新索引
- 在文档删除时移除索引
- 使用模型事件或观察者模式
- _需求12.1, 12.3, 12.4_
- [ ]* 24.4 编写属性测试验证搜索功能
- **属性 33搜索匹配 Markdown 内容**
- **属性 34搜索覆盖多个字段**
- **属性 35搜索结果权限过滤**
- **属性 36搜索结果包含必需信息**
- **验证需求10.1, 10.2, 10.3, 10.4**
- [ ]* 24.5 编写属性测试验证索引管理
- **属性 40转换完成触发索引**
- **属性 41索引数据完整性**
- **属性 42文档更新同步索引**
- **属性 43文档删除移除索引**
- **属性 44索引失败不影响文档保存**
- **验证需求12.1, 12.2, 12.3, 12.4, 12.5**
- [x] 25. 创建搜索页面
- [x] 25.1 创建 Filament 自定义页面 - SearchPage
- 使用 Filament 命令生成自定义页面
- 配置中文导航标签
- 添加到导航菜单
- _需求10.1_
- [x] 25.2 实现搜索表单
- 添加搜索关键词输入框
- 添加文档类型筛选器
- 添加分组筛选器
- 配置中文标签和占位符
- _需求10.1, 10.5_
- [x] 25.3 实现搜索结果展示
- 创建搜索结果表格或列表
- 显示文档标题、内容片段、类型、上传时间
- 添加下载原始文档操作(不提供 Markdown 预览)
- 实现分页
- 使用中文标签
- _需求10.4_
- [x] 25.4 集成 DocumentSearchService
- 在页面中调用搜索服务
- 应用用户权限过滤
- 处理空搜索关键词
- 显示中文提示信息
- _需求10.1, 10.3, 10.5_
- [x] 26. 实现 MarkdownRenderService
- [x] 26.1 安装 Markdown 渲染库
- 安装 league/commonmark 包
- 或安装其他 Markdown 渲染库
- _需求11.1, 11.2_
- [x] 26.2 创建 MarkdownRenderService 类
- 创建服务类文件
- 实现 render 方法Markdown 转 HTML
- 实现 sanitize 方法(清理 HTML防止 XSS
- 实现 extractPreview 方法(提取摘要)
- _需求11.1, 11.2_
- [x] 26.3 配置 Markdown 渲染选项
- 配置支持的 Markdown 扩展(表格、代码高亮等)
- 配置 HTML 清理规则
- 配置代码高亮样式
- _需求11.2_
- [ ]* 26.4 编写属性测试验证 Markdown 渲染
- **属性 38Markdown 渲染正确性**
- **验证需求11.2**
- [x] 27. 实现文档 Markdown 预览功能(仅在文档管理中)
- [x] 27.1 在 DocumentResource 中添加 Markdown 预览操作
- 在文档列表和详情页添加"预览 Markdown"按钮
- 创建自定义 Action 用于预览 Markdown 内容
- 验证用户权限
- 使用中文按钮标签(如"预览 Markdown"
- 注意:其他地方(如搜索结果)只提供下载原始文档功能
- _需求11.1, 11.3_
- [x] 27.2 创建 Markdown 预览页面
- 创建独立的预览页面或使用 Filament 模态框
- 显示渲染后的 Markdown HTML
- 添加下载原始 Word 文档按钮
- 使用响应式布局,支持移动端查看
- _需求11.1, 11.5_
- [x] 27.3 实现预览控制器方法
- 创建控制器方法处理预览请求
- 验证用户权限(使用 DocumentPolicy
- 读取 Markdown 文件并使用 MarkdownRenderService 渲染
- 处理 Markdown 内容为空的情况(显示提示并提供下载按钮)
- 返回渲染后的 HTML 视图
- _需求11.1, 11.3, 11.4_
- [ ]* 27.4 编写属性测试验证预览功能
- **属性 37有权限文档可预览**
- **属性 39无权限文档预览拒绝**
- **验证需求11.1, 11.3**
- [ ] 28. 编写功能测试 - 文档转换和搜索
- [ ]* 28.1 编写文档转换流程测试
- 测试上传文档后自动触发转换
- 测试转换完成后 Markdown 文件存在
- 测试转换失败时文档仍可用
- 测试更新文档时重新转换
- _需求9.1, 9.2, 9.3, 9.5_
- [ ]* 28.2 编写全文搜索流程测试
- 测试搜索 Markdown 内容中的关键词
- 测试搜索结果遵循权限控制
- 测试搜索多个字段(标题、描述、内容)
- 测试空搜索关键词的处理
- _需求10.1, 10.2, 10.3, 10.5_
- [ ]* 28.3 编写文档预览流程测试
- 测试有权限用户可以预览
- 测试无权限用户无法预览
- 测试 Markdown 正确渲染为 HTML
- 测试预览页面包含下载按钮
- _需求11.1, 11.2, 11.3, 11.5_
- [ ]* 28.4 编写 Meilisearch 索引流程测试
- 测试文档转换后自动索引
- 测试文档更新时同步索引
- 测试文档删除时移除索引
- 测试索引失败不影响文档保存
- _需求12.1, 12.3, 12.4, 12.5_
- [ ] 29. 检查点 - 确保转换和搜索功能正常
- 运行所有新增的测试
- 手动测试文档上传和转换流程
- 手动测试搜索功能
- 手动测试预览功能
- 验证 Meilisearch 索引正常工作
- 如有问题请咨询用户
- _需求9.1-9.5, 10.1-10.5, 11.1-11.5, 12.1-12.5_
- [ ] 30. 最终检查点 - 确保所有测试通过
- 运行所有单元测试、属性测试和功能测试
- 验证测试覆盖率达到目标
- 修复任何失败的测试
- 如有问题请咨询用户
- _需求所有需求_
- [ ] 31. 文档和部署准备
- [ ] 31.1 更新 README 文档
- 添加文档转换和搜索功能说明
- 添加 Meilisearch 安装和配置步骤
- 添加 Pandoc 安装说明
- 更新使用说明
- 使用简体中文编写
- _需求部署和配置_
- [ ] 31.2 更新环境配置示例
- 在 .env.example 中添加 Meilisearch 配置项
- 添加文档转换配置项
- 添加必要的配置项注释
- _需求部署和配置_
- [ ] 31.3 更新部署脚本
- 添加 Meilisearch 服务启动步骤
- 添加队列工作进程启动步骤
- 添加 Markdown 存储目录创建步骤
- 包含数据库迁移步骤
- 包含文件权限配置
- _需求部署和配置_

View File

@@ -0,0 +1,484 @@
# 设计文档
## 概述
本设计文档描述了知识库系统UI界面美化的技术实现方案。通过集成Alpine.js和Tailwind CSS我们将为现有的Filament界面添加现代化的视觉效果和流畅的交互动画提升用户体验。
设计遵循以下原则:
- **渐进增强**:在不破坏现有功能的基础上添加视觉增强
- **性能优先**使用CSS动画和轻量级JavaScript避免性能问题
- **响应式设计**:确保在所有设备上都有良好的显示效果
- **无障碍访问**遵循WCAG 2.1标准,支持键盘导航和屏幕阅读器
- **主题一致性**与Filament的设计语言保持一致
## 架构
### 技术栈
- **Alpine.js 3.x**:用于添加交互行为和状态管理
- **Tailwind CSS 3.x**:用于样式设计和响应式布局
- **Filament 3.x**:现有的管理面板框架
- **Laravel Blade**:模板引擎
- **CSS Transitions/Animations**:用于动画效果
### 组件层次
```
┌─────────────────────────────────────┐
│ Blade Templates │
│ (搜索页面、预览模态框、文档列表) │
└──────────────┬──────────────────────┘
┌──────────────┴──────────────────────┐
│ Alpine.js Components │
│ (交互逻辑、状态管理、事件处理) │
└──────────────┬──────────────────────┘
┌──────────────┴──────────────────────┐
│ Tailwind CSS Classes │
│ (样式、动画、响应式布局) │
└─────────────────────────────────────┘
```
### 文件结构
```
resources/
├── views/
│ ├── filament/
│ │ ├── pages/
│ │ │ ├── search-page.blade.php (增强版搜索页面)
│ │ │ └── document-preview-modal.blade.php (增强版预览模态框)
│ │ └── resources/
│ │ └── document/
│ │ └── card.blade.php (新增:文档卡片组件)
│ └── components/
│ ├── ui/
│ │ ├── button.blade.php (新增:增强按钮组件)
│ │ ├── input.blade.php (新增:增强输入框组件)
│ │ ├── card.blade.php (新增:卡片组件)
│ │ └── badge.blade.php (新增:徽章组件)
│ └── animations/
│ ├── fade-in.blade.php (新增:淡入动画)
│ └── slide-in.blade.php (新增:滑入动画)
├── css/
│ └── custom/
│ ├── animations.css (新增:自定义动画)
│ ├── components.css (新增:组件样式)
│ └── utilities.css (新增:工具类)
└── js/
└── alpine/
├── search.js (新增:搜索页面逻辑)
├── preview.js (新增:预览模态框逻辑)
└── filters.js (新增:筛选器逻辑)
```
## 组件和接口
### 1. 搜索页面组件
**职责**:提供美化的搜索界面和结果展示
**Alpine.js数据结构**
```javascript
{
// 搜索状态
searchQuery: '',
isSearching: false,
hasSearched: false,
// 筛选器状态
filters: {
type: null,
groupId: null
},
showFilters: false,
// 结果状态
results: [],
resultCount: 0,
// UI状态
viewMode: 'grid', // 'grid' 或 'list'
sortBy: 'created_at',
sortOrder: 'desc'
}
```
**方法**
- `search()`:执行搜索
- `clearSearch()`:清空搜索
- `toggleFilters()`:切换筛选器显示
- `applyFilter(key, value)`:应用筛选条件
- `removeFilter(key)`:移除筛选条件
- `toggleViewMode()`:切换视图模式
- `sortResults(field)`:排序结果
### 2. 文档卡片组件
**职责**:以卡片形式展示文档信息
**Props**
- `document`:文档对象
- `showActions`:是否显示操作按钮
- `compact`:是否使用紧凑模式
**样式类**
- `document-card`:基础卡片样式
- `document-card-hover`:悬停效果
- `document-card-compact`:紧凑模式
### 3. 预览模态框组件
**职责**:提供优雅的文档预览体验
**Alpine.js数据结构**
```javascript
{
// 模态框状态
isOpen: false,
isLoading: true,
// 内容状态
content: null,
error: null,
// UI状态
scrollProgress: 0,
showScrollTop: false
}
```
**方法**
- `open()`:打开模态框
- `close()`:关闭模态框
- `loadContent()`:加载内容
- `scrollToTop()`:滚动到顶部
- `updateScrollProgress()`:更新滚动进度
### 4. 增强按钮组件
**职责**:提供统一的按钮样式和交互效果
**Props**
- `variant`按钮变体primary, secondary, danger等
- `size`按钮大小sm, md, lg
- `loading`:加载状态
- `disabled`:禁用状态
- `icon`:图标名称
**样式类**
- `btn-enhanced`:基础增强样式
- `btn-loading`:加载状态
- `btn-pulse`:脉冲效果
### 5. 增强输入框组件
**职责**:提供友好的输入交互效果
**Props**
- `label`:标签文本
- `placeholder`:占位符
- `maxLength`:最大长度
- `showCounter`:显示字符计数
- `validation`:验证规则
**Alpine.js数据结构**
```javascript
{
value: '',
isFocused: false,
hasError: false,
errorMessage: '',
charCount: 0
}
```
## 数据模型
本功能主要涉及UI增强不需要修改现有数据模型。所有数据仍使用现有的Document、Group等模型。
## 正确性属性
*属性是一个特征或行为,应该在系统的所有有效执行中保持为真。属性作为人类可读规范和机器可验证正确性保证之间的桥梁。*
### 属性 1文档类型徽章颜色一致性
*对于任何*文档,当显示为卡片时,全局文档应该使用绿色徽章,专用文档应该使用蓝色徽章
**验证需求2.3**
### 属性 2ARIA标签完整性
*对于任何*可交互元素该元素应该包含适当的ARIA标签或role属性
**验证需求10.2**
### 属性 3颜色对比度合规性
*对于任何*文本元素其前景色和背景色的对比度应该至少为4.5:1普通文本或3:1大文本
**验证需求10.5**
## 错误处理
### 1. 动画性能问题
**场景**:在低性能设备上动画可能导致卡顿
**处理策略**
- 检测设备性能,在低性能设备上禁用复杂动画
- 使用CSS `will-change`属性优化动画性能
- 遵循用户的`prefers-reduced-motion`设置
### 2. Alpine.js加载失败
**场景**CDN不可用或网络问题导致Alpine.js加载失败
**处理策略**
- 使用本地备份的Alpine.js文件
- 确保核心功能在没有JavaScript的情况下仍可用
- 显示友好的降级界面
### 3. 深色模式切换问题
**场景**:主题切换时可能出现闪烁
**处理策略**
- 在页面加载前检测主题偏好
- 使用CSS变量实现平滑过渡
- 将主题偏好保存到localStorage
### 4. 响应式布局问题
**场景**:某些设备上布局可能错乱
**处理策略**
- 使用Tailwind的响应式断点
- 在多种设备上测试
- 提供最小宽度限制
## 测试策略
### 单元测试
使用PHPUnit和Pest进行后端测试
1. **组件渲染测试**
- 测试Blade组件是否正确渲染
- 测试props是否正确传递
- 测试条件渲染逻辑
2. **样式类测试**
- 测试CSS类是否正确应用
- 测试响应式类是否存在
### 前端测试
使用Jest和Testing Library进行前端测试
1. **Alpine.js组件测试**
- 测试数据绑定
- 测试事件处理
- 测试状态变化
2. **交互测试**
- 测试按钮点击
- 测试表单输入
- 测试键盘导航
3. **视觉回归测试**
- 使用Percy或Chromatic进行截图对比
- 测试不同主题下的显示效果
- 测试不同屏幕尺寸下的布局
### 无障碍测试
1. **自动化测试**
- 使用axe-core进行无障碍扫描
- 测试ARIA标签
- 测试键盘导航
2. **手动测试**
- 使用屏幕阅读器测试
- 测试键盘完整导航
- 测试颜色对比度
### 性能测试
1. **动画性能**
- 使用Chrome DevTools测试FPS
- 测试动画是否触发重排
- 测试低性能设备表现
2. **加载性能**
- 测试CSS和JS文件大小
- 测试首次内容绘制时间
- 测试交互就绪时间
### 浏览器兼容性测试
测试以下浏览器:
- Chrome最新版本和前一版本
- Firefox最新版本和前一版本
- Safari最新版本
- Edge最新版本
- 移动浏览器iOS Safari、Chrome Mobile
## 实现细节
### 1. Tailwind CSS配置
扩展Tailwind配置以支持自定义动画和颜色
```javascript
// tailwind.config.js
module.exports = {
theme: {
extend: {
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-in': 'slideIn 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'shake': 'shake 0.5s ease-in-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
scaleIn: {
'0%': { transform: 'scale(0.95)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
shake: {
'0%, 100%': { transform: 'translateX(0)' },
'25%': { transform: 'translateX(-10px)' },
'75%': { transform: 'translateX(10px)' },
},
},
},
},
}
```
### 2. Alpine.js集成
在Blade模板中集成Alpine.js
```html
<div x-data="searchComponent()" x-init="init()">
<!-- 组件内容 -->
</div>
<script>
function searchComponent() {
return {
// 数据和方法
}
}
</script>
```
### 3. 自定义CSS动画
创建可复用的动画类:
```css
/* animations.css */
.animate-stagger > * {
animation: fadeIn 0.3s ease-in-out;
animation-fill-mode: both;
}
.animate-stagger > *:nth-child(1) { animation-delay: 0.05s; }
.animate-stagger > *:nth-child(2) { animation-delay: 0.1s; }
.animate-stagger > *:nth-child(3) { animation-delay: 0.15s; }
/* ... */
.hover-lift {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hover-lift:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
```
### 4. 深色模式支持
使用Tailwind的深色模式类
```html
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100">
<!-- 内容 -->
</div>
```
### 5. 响应式设计
使用Tailwind的响应式前缀
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- 卡片 -->
</div>
```
### 6. 无障碍支持
添加适当的ARIA属性
```html
<button
aria-label="搜索文档"
aria-pressed="false"
role="button"
tabindex="0"
>
搜索
</button>
```
### 7. 性能优化
- 使用CSS `contain`属性隔离动画
- 使用`will-change`提示浏览器优化
- 延迟加载非关键动画
- 使用`requestAnimationFrame`优化JavaScript动画
```css
.animated-card {
contain: layout style paint;
will-change: transform;
}
```
## 部署考虑
### 1. 资源打包
- 使用Laravel Mix或Vite打包CSS和JS
- 启用CSS和JS压缩
- 使用版本控制避免缓存问题
### 2. CDN配置
- 考虑使用CDN加速Alpine.js加载
- 提供本地备份文件
### 3. 浏览器支持
- 添加必要的polyfills
- 提供降级方案
### 4. 监控
- 监控动画性能
- 收集用户反馈
- 跟踪错误日志

View File

@@ -0,0 +1,138 @@
# 需求文档
## 简介
本文档定义了知识库系统UI界面美化的需求。系统当前使用Filament框架构建需要通过Alpine.js和Tailwind CSS增强用户界面的视觉效果和交互体验使界面更加现代化、美观和易用。
## 术语表
- **知识库系统Knowledge Base System**基于Laravel和Filament构建的文档管理系统
- **Alpine.js**轻量级JavaScript框架用于添加交互行为
- **Tailwind CSS**实用优先的CSS框架用于样式设计
- **Filament**Laravel的管理面板框架
- **搜索页面Search Page**:用户搜索文档的界面
- **文档预览Document Preview**:在模态框中显示文档内容的功能
- **文档列表Document List**:显示文档资源的表格界面
- **响应式设计Responsive Design**:适配不同屏幕尺寸的界面设计
## 需求
### 需求 1
**用户故事:** 作为用户,我希望搜索页面具有现代化的视觉设计,以便获得更好的使用体验。
#### 验收标准
1. WHEN 用户访问搜索页面 THEN 系统应当显示具有渐变背景和阴影效果的搜索表单卡片
2. WHEN 用户将鼠标悬停在搜索按钮上 THEN 系统应当显示平滑的过渡动画效果
3. WHEN 用户输入搜索关键词 THEN 系统应当在输入框获得焦点时显示高亮边框效果
4. WHEN 搜索表单加载完成 THEN 系统应当显示淡入动画效果
5. WHEN 用户在移动设备上访问 THEN 系统应当显示适配移动端的响应式布局
### 需求 2
**用户故事:** 作为用户,我希望搜索结果以卡片形式展示,以便更直观地浏览文档信息。
#### 验收标准
1. WHEN 搜索返回结果 THEN 系统应当以卡片网格布局显示文档列表
2. WHEN 用户将鼠标悬停在文档卡片上 THEN 系统应当显示卡片上浮和阴影增强效果
3. WHEN 文档卡片包含类型标签 THEN 系统应当使用不同颜色的徽章区分全局和专用文档
4. WHEN 搜索结果加载完成 THEN 系统应当显示交错淡入动画效果
5. WHEN 文档卡片显示内容片段 THEN 系统应当使用渐变遮罩处理文本溢出
### 需求 3
**用户故事:** 作为用户,我希望文档预览模态框具有优雅的设计,以便舒适地阅读文档内容。
#### 验收标准
1. WHEN 用户点击预览按钮 THEN 系统应当显示带有缩放淡入动画的模态框
2. WHEN 模态框打开时 THEN 系统应当显示半透明背景遮罩和模糊效果
3. WHEN 文档内容较长 THEN 系统应当提供带有自定义滚动条样式的滚动区域
4. WHEN 文档包含代码块 THEN 系统应当使用语法高亮和圆角边框样式
5. WHEN 文档包含表格 THEN 系统应当使用斑马纹和悬停高亮效果
### 需求 4
**用户故事:** 作为用户,我希望操作按钮具有清晰的视觉反馈,以便明确操作状态。
#### 验收标准
1. WHEN 用户将鼠标悬停在按钮上 THEN 系统应当显示颜色加深和轻微缩放效果
2. WHEN 用户点击按钮 THEN 系统应当显示按下动画效果
3. WHEN 按钮处于加载状态 THEN 系统应当显示旋转的加载图标
4. WHEN 按钮处于禁用状态 THEN 系统应当显示降低透明度和禁用鼠标指针
5. WHEN 操作成功完成 THEN 系统应当显示带有图标的成功通知动画
### 需求 5
**用户故事:** 作为用户,我希望表单输入具有友好的交互效果,以便更好地完成输入操作。
#### 验收标准
1. WHEN 输入框获得焦点 THEN 系统应当显示边框颜色变化和标签上移动画
2. WHEN 用户输入内容 THEN 系统应当实时显示字符计数或验证状态
3. WHEN 输入验证失败 THEN 系统应当显示红色边框和抖动动画效果
4. WHEN 下拉选择框展开 THEN 系统应当显示下滑淡入动画
5. WHEN 用户清空输入 THEN 系统应当显示清除按钮的淡入淡出效果
### 需求 6
**用户故事:** 作为用户,我希望页面加载和状态变化具有流畅的过渡效果,以便获得连贯的使用体验。
#### 验收标准
1. WHEN 页面首次加载 THEN 系统应当显示骨架屏加载动画
2. WHEN 搜索正在执行 THEN 系统应当显示加载指示器和脉冲动画
3. WHEN 内容状态改变 THEN 系统应当使用淡入淡出过渡效果
4. WHEN 列表项添加或删除 THEN 系统应当显示滑入滑出动画
5. WHEN 错误发生 THEN 系统应当显示带有图标的错误提示和抖动效果
### 需求 7
**用户故事:** 作为用户,我希望界面支持深色模式,以便在不同光线环境下舒适使用。
#### 验收标准
1. WHEN 系统检测到深色模式偏好 THEN 系统应当自动切换到深色主题
2. WHEN 深色模式激活 THEN 系统应当使用深色背景和浅色文字
3. WHEN 深色模式下显示卡片 THEN 系统应当使用深色卡片背景和适当的边框
4. WHEN 深色模式下显示按钮 THEN 系统应当调整按钮颜色以保持对比度
5. WHEN 主题切换时 THEN 系统应当显示平滑的颜色过渡动画
### 需求 8
**用户故事:** 作为用户,我希望界面元素具有微交互效果,以便获得更生动的使用体验。
#### 验收标准
1. WHEN 用户将鼠标悬停在图标上 THEN 系统应当显示图标旋转或缩放动画
2. WHEN 用户点击收藏按钮 THEN 系统应当显示心形填充动画
3. WHEN 通知消息出现 THEN 系统应当从右侧滑入并自动淡出
4. WHEN 用户滚动页面 THEN 系统应当显示返回顶部按钮的淡入效果
5. WHEN 用户拖拽元素 THEN 系统应当显示拖拽阴影和位置指示器
### 需求 9
**用户故事:** 作为用户,我希望文档列表具有高级筛选和排序界面,以便快速找到目标文档。
#### 验收标准
1. WHEN 用户点击筛选按钮 THEN 系统应当显示侧边栏滑入动画
2. WHEN 用户选择筛选条件 THEN 系统应当实时更新结果数量徽章
3. WHEN 用户应用筛选 THEN 系统应当显示已选筛选条件的标签
4. WHEN 用户点击排序选项 THEN 系统应当显示排序图标的旋转动画
5. WHEN 筛选结果为空 THEN 系统应当显示友好的空状态插图和提示
### 需求 10
**用户故事:** 作为用户,我希望界面具有无障碍访问支持,以便所有用户都能使用系统。
#### 验收标准
1. WHEN 用户使用键盘导航 THEN 系统应当显示清晰的焦点指示器
2. WHEN 屏幕阅读器访问页面 THEN 系统应当提供适当的ARIA标签
3. WHEN 用户使用Tab键切换 THEN 系统应当按逻辑顺序聚焦可交互元素
4. WHEN 动画效果播放 THEN 系统应当遵循用户的减少动画偏好设置
5. WHEN 界面显示颜色信息 THEN 系统应当确保足够的颜色对比度

View File

@@ -0,0 +1,334 @@
# 实施计划
- [ ] 1. 配置开发环境和依赖
- [ ] 1.1 确认Alpine.js和Tailwind CSS已安装
- 检查package.json中的依赖
- 如需要则安装Alpine.js 3.x
- 确认Tailwind CSS 3.x已配置
- _需求所有需求的基础_
- [ ] 1.2 扩展Tailwind配置文件
- 在tailwind.config.js中添加自定义动画
- 添加自定义关键帧fadeIn, slideIn, scaleIn, shake
- 配置动画时长和缓动函数
- _需求1.2, 1.4, 2.2, 2.4, 3.1, 4.1, 5.1, 5.3, 6.1-6.5, 8.1-8.5_
- [ ] 1.3 创建自定义CSS文件结构
- 创建resources/css/custom/animations.css
- 创建resources/css/custom/components.css
- 创建resources/css/custom/utilities.css
- 在app.css中导入自定义CSS文件
- _需求所有需求_
- [ ] 2. 创建可复用的UI组件
- [ ] 2.1 创建增强按钮组件
- 创建resources/views/components/ui/button.blade.php
- 实现按钮变体primary, secondary, danger等
- 添加悬停效果(颜色加深、轻微缩放)
- 添加点击动画效果
- 添加加载状态(旋转图标)
- 添加禁用状态样式
- _需求4.1, 4.2, 4.3, 4.4_
- [ ]* 2.2 编写属性测试验证按钮组件
- **属性 1文档类型徽章颜色一致性**
- **验证需求2.3**
- [ ] 2.3 创建增强输入框组件
- 创建resources/views/components/ui/input.blade.php
- 实现焦点状态(边框颜色变化、标签上移)
- 添加字符计数功能
- 添加验证错误状态(红色边框、抖动动画)
- 添加清除按钮(淡入淡出效果)
- _需求5.1, 5.2, 5.3, 5.5_
- [ ] 2.4 创建卡片组件
- 创建resources/views/components/ui/card.blade.php
- 实现基础卡片样式(渐变背景、阴影)
- 添加悬停效果(上浮、阴影增强)
- 支持深色模式样式
- _需求1.1, 2.1, 2.2, 7.3_
- [ ] 2.5 创建徽章组件
- 创建resources/views/components/ui/badge.blade.php
- 实现不同颜色变体success, info, warning, danger
- 为全局文档使用绿色徽章
- 为专用文档使用蓝色徽章
- 支持深色模式
- _需求2.3, 7.4_
- [ ] 3. 增强搜索页面UI
- [ ] 3.1 更新搜索表单样式
- 修改resources/views/filament/pages/search-page.blade.php
- 应用渐变背景和阴影效果到搜索卡片
- 添加表单加载时的淡入动画
- 使用增强输入框组件替换原有输入框
- 使用增强按钮组件替换原有按钮
- _需求1.1, 1.2, 1.3, 1.4_
- [ ] 3.2 实现响应式搜索表单布局
- 添加移动端适配样式
- 使用Tailwind响应式类sm:, md:, lg:
- 测试不同屏幕尺寸下的显示效果
- _需求1.5_
- [ ] 3.3 创建文档卡片视图组件
- 创建resources/views/filament/resources/document/card.blade.php
- 实现卡片网格布局
- 添加文档标题、类型徽章、内容片段
- 添加悬停效果(卡片上浮、阴影增强)
- 使用渐变遮罩处理文本溢出
- _需求2.1, 2.2, 2.3, 2.5_
- [ ] 3.4 添加搜索结果动画
- 实现交错淡入动画stagger animation
- 为每个卡片添加延迟动画
- 添加加载骨架屏
- _需求2.4, 6.1_
- [ ] 3.5 集成Alpine.js到搜索页面
- 创建resources/js/alpine/search.js
- 实现搜索状态管理isSearching, hasSearched
- 实现筛选器切换逻辑
- 实现视图模式切换(网格/列表)
- 添加搜索加载指示器
- _需求6.2, 9.1, 9.2_
- [ ] 3.6 实现高级筛选器界面
- 添加筛选按钮和侧边栏
- 实现侧边栏滑入动画
- 显示已选筛选条件的标签
- 实时更新结果数量徽章
- 添加清空筛选按钮
- _需求9.1, 9.2, 9.3_
- [ ] 3.7 实现空状态UI
- 创建友好的空状态插图
- 添加提示文本
- 提供建议操作
- _需求9.5_
- [ ] 4. 增强文档预览模态框
- [ ] 4.1 更新预览模态框样式
- 修改resources/views/filament/pages/document-preview-modal.blade.php
- 添加模态框打开动画(缩放淡入)
- 添加半透明背景遮罩和模糊效果
- 优化内容区域样式
- _需求3.1, 3.2_
- [ ] 4.2 自定义滚动条样式
- 为预览内容区域添加自定义滚动条
- 使用Tailwind的scrollbar插件或自定义CSS
- 支持深色模式滚动条
- _需求3.3_
- [ ] 4.3 增强Markdown内容样式
- 优化代码块样式(语法高亮、圆角边框)
- 优化表格样式(斑马纹、悬停高亮)
- 优化标题、列表、引用样式
- 优化图片显示(响应式、圆角)
- _需求3.4, 3.5_
- [ ] 4.4 集成Alpine.js到预览模态框
- 创建resources/js/alpine/preview.js
- 实现模态框状态管理isOpen, isLoading
- 实现滚动进度跟踪
- 添加返回顶部按钮(滚动时淡入)
- _需求8.4_
- [ ] 5. 实现深色模式支持
- [ ] 5.1 配置深色模式检测
- 在主布局中添加深色模式检测脚本
- 检测系统偏好prefers-color-scheme
- 从localStorage读取用户偏好
- 应用深色模式类到html元素
- _需求7.1_
- [ ] 5.2 更新所有组件的深色模式样式
- 为搜索页面添加深色模式样式
- 为文档卡片添加深色模式样式
- 为预览模态框添加深色模式样式
- 为按钮和输入框添加深色模式样式
- 确保颜色对比度符合标准
- _需求7.2, 7.3, 7.4_
- [ ] 5.3 实现主题切换动画
- 添加主题切换时的颜色过渡效果
- 使用CSS变量实现平滑过渡
- 避免切换时的闪烁
- _需求7.5_
- [ ] 6. 添加微交互效果
- [ ] 6.1 实现图标动画
- 为搜索图标添加悬停旋转效果
- 为下载图标添加悬停缩放效果
- 为筛选图标添加点击动画
- _需求8.1_
- [ ] 6.2 实现通知动画
- 优化Filament通知的显示动画
- 实现从右侧滑入效果
- 实现自动淡出效果
- 添加成功/错误图标动画
- _需求4.5, 8.3_
- [ ] 6.3 实现排序动画
- 为排序图标添加旋转动画
- 添加排序方向指示器
- 实现列表重排动画
- _需求9.4_
- [ ] 7. 实现页面过渡和加载状态
- [ ] 7.1 创建骨架屏组件
- 创建搜索结果骨架屏
- 创建文档卡片骨架屏
- 添加脉冲动画效果
- _需求6.1_
- [ ] 7.2 实现加载指示器
- 为搜索按钮添加加载状态
- 为预览模态框添加加载指示器
- 使用旋转动画和脉冲效果
- _需求6.2_
- [ ] 7.3 实现内容过渡动画
- 为内容状态变化添加淡入淡出效果
- 为列表项添加滑入滑出动画
- 优化动画时序
- _需求6.3, 6.4_
- [ ] 7.4 实现错误状态UI
- 创建错误提示组件
- 添加抖动动画效果
- 显示错误图标
- _需求6.5_
- [ ] 8. 实现无障碍访问支持
- [ ] 8.1 添加键盘导航支持
- 为所有交互元素添加tabindex
- 实现清晰的焦点指示器样式
- 测试Tab键导航顺序
- 添加键盘快捷键如Esc关闭模态框
- _需求10.1, 10.3_
- [ ] 8.2 添加ARIA标签
- 为按钮添加aria-label
- 为模态框添加role和aria-modal
- 为加载状态添加aria-busy
- 为展开/折叠元素添加aria-expanded
- _需求10.2_
- [ ]* 8.3 编写属性测试验证ARIA标签
- **属性 2ARIA标签完整性**
- **验证需求10.2**
- [ ] 8.4 实现动画偏好支持
- 检测prefers-reduced-motion设置
- 在用户偏好减少动画时禁用动画
- 提供静态替代方案
- _需求10.4_
- [ ] 8.5 验证颜色对比度
- 使用工具检查所有文本的对比度
- 确保至少4.5:1普通文本或3:1大文本
- 调整不符合标准的颜色
- _需求10.5_
- [ ]* 8.6 编写属性测试验证颜色对比度
- **属性 3颜色对比度合规性**
- **验证需求10.5**
- [ ] 9. 优化性能
- [ ] 9.1 优化CSS
- 移除未使用的Tailwind类
- 压缩CSS文件
- 使用PurgeCSS减小文件大小
- _需求性能优化_
- [ ] 9.2 优化JavaScript
- 延迟加载非关键JavaScript
- 使用代码分割
- 压缩JavaScript文件
- _需求性能优化_
- [ ] 9.3 优化动画性能
- 使用CSS transform和opacity避免重排
- 添加will-change提示
- 使用contain属性隔离动画
- 在低性能设备上禁用复杂动画
- _需求性能优化_
- [ ] 10. 测试和验证
- [ ]* 10.1 编写组件单元测试
- 测试按钮组件的各种状态
- 测试输入框组件的交互
- 测试卡片组件的渲染
- 测试徽章组件的颜色逻辑
- _需求所有组件相关需求_
- [ ]* 10.2 编写Alpine.js组件测试
- 测试搜索组件的状态管理
- 测试筛选器逻辑
- 测试预览模态框逻辑
- _需求所有交互相关需求_
- [ ]* 10.3 进行无障碍测试
- 使用axe-core进行自动化扫描
- 使用屏幕阅读器测试
- 测试键盘完整导航
- _需求10.1-10.5_
- [ ]* 10.4 进行视觉回归测试
- 截图对比测试使用Percy或Chromatic
- 测试深色模式显示
- 测试响应式布局
- _需求所有视觉相关需求_
- [ ]* 10.5 进行性能测试
- 使用Chrome DevTools测试动画FPS
- 测试首次内容绘制时间
- 测试交互就绪时间
- 在低性能设备上测试
- _需求性能相关需求_
- [ ]* 10.6 进行浏览器兼容性测试
- 在Chrome、Firefox、Safari、Edge上测试
- 在移动浏览器上测试
- 修复兼容性问题
- _需求所有需求_
- [ ] 11. 文档和部署
- [ ] 11.1 更新开发文档
- 记录新增的UI组件使用方法
- 记录Alpine.js组件的API
- 记录自定义CSS类的用法
- 添加样式指南
- _需求文档需求_
- [ ] 11.2 创建组件演示页面
- 创建Storybook或类似的组件展示页面
- 展示所有UI组件的各种状态
- 提供代码示例
- _需求文档需求_
- [ ] 11.3 优化生产构建
- 配置Laravel Mix或Vite进行生产构建
- 启用CSS和JS压缩
- 配置资源版本控制
- 测试生产环境构建
- _需求部署需求_
- [ ] 11.4 准备部署清单
- 列出需要部署的文件
- 列出需要运行的命令
- 列出需要检查的配置
- 创建回滚计划
- _需求部署需求_
- [ ] 12. 最终检查点
- 确保所有UI增强功能正常工作
- 验证在不同设备和浏览器上的显示效果
- 确认无障碍访问功能正常
- 验证性能指标达标
- 如有问题请咨询用户
- _需求所有需求_

103
CHANGELOG.md Normal file
View File

@@ -0,0 +1,103 @@
# 更新日志
本文档记录项目的所有重要更改。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
## [未发布]
### 计划中
- 属性基础测试Property-Based Testing
- 完整的功能测试套件
- 性能优化(缓存、索引优化)
- UI 增强Alpine.js 动画和交互)
- 文档版本控制
- 文档标签系统
- 文档评论功能
## [1.0.0] - 2025-12-05
### 新增
- 🎉 初始版本发布
- ✨ 用户认证和授权系统
- ✨ 用户分组管理功能
- ✨ Word 文档上传和存储
- ✨ 文档分类(全局知识库和专用知识库)
- ✨ 基于分组的细粒度权限控制
- ✨ 文档下载功能和下载日志记录
- ✨ Word 文档自动转换为 Markdown
- ✨ 异步队列处理文档转换任务
- ✨ Meilisearch 全文搜索集成
- ✨ 文档 Markdown 在线预览
- ✨ 搜索结果权限过滤
- ✨ 安全日志记录和审计
- ✨ Filament 3.X 管理面板
- ✨ 完整的简体中文界面
- ✨ 响应式设计,支持移动设备
### 技术实现
- 🔧 Laravel 11.x 框架
- 🔧 Filament 3.X 管理面板
- 🔧 Meilisearch 搜索引擎
- 🔧 Pandoc 文档转换工具
- 🔧 Redis 队列系统
- 🔧 Laravel Scout 搜索集成
- 🔧 CommonMark Markdown 渲染
### 文档
- 📚 项目概览文档
- 📚 部署指南
- 📚 API 参考文档
- 📚 Meilisearch 配置指南
- 📚 文档转换指南
- 📚 安全日志说明
### 测试
- ✅ 单元测试框架Pest PHP
- ✅ 测试工厂Factories
- ✅ 测试数据填充Seeders
### 安全
- 🔒 基于策略的权限控制
- 🔒 文件访问权限验证
- 🔒 未授权访问日志记录
- 🔒 XSS 防护HTML 清理)
- 🔒 文件类型验证
### 性能
- ⚡ 异步文档转换
- ⚡ 队列任务处理
- ⚡ Meilisearch 快速搜索
- ⚡ 文件流式下载
## 版本说明
### 版本号规则
- **主版本号Major**:不兼容的 API 修改
- **次版本号Minor**:向下兼容的功能性新增
- **修订号Patch**:向下兼容的问题修正
### 更新类型
- `新增`:新功能
- `变更`:对现有功能的变更
- `弃用`:即将移除的功能
- `移除`:已移除的功能
- `修复`Bug 修复
- `安全`:安全相关的修复
## 升级指南
### 从开发版升级到 1.0.0
这是首个正式版本,无需升级操作。
## 贡献者
感谢所有为本项目做出贡献的开发者!
---
**注意**:本更新日志遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/) 格式。

316
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,316 @@
# 贡献指南
感谢你考虑为知识库系统做出贡献!
## 行为准则
本项目遵循贡献者公约行为准则。参与本项目即表示你同意遵守其条款。
## 如何贡献
### 报告 Bug
如果你发现了 bug请创建一个 issue 并包含以下信息:
- **清晰的标题和描述**
- **重现步骤**
- **预期行为**
- **实际行为**
- **环境信息**PHP 版本、Laravel 版本、操作系统等)
- **相关日志或截图**
### 提出新功能
如果你有新功能的想法:
1. 先在 Discussions 中讨论
2. 确保功能符合项目目标
3. 创建详细的功能请求 issue
### 提交代码
#### 开发流程
1. **Fork 项目**
```bash
# 在 GitHub 上 fork 项目
git clone https://github.com/your-username/knowledge-base-system.git
cd knowledge-base-system
```
2. **创建分支**
```bash
git checkout -b feature/your-feature-name
# 或
git checkout -b fix/your-bug-fix
```
3. **安装依赖**
```bash
composer install
npm install
cp .env.example .env
php artisan key:generate
```
4. **进行更改**
- 编写代码
- 添加测试
- 更新文档
5. **运行测试**
```bash
php artisan test
```
6. **提交更改**
```bash
git add .
git commit -m "feat: 添加新功能描述"
```
7. **推送到 GitHub**
```bash
git push origin feature/your-feature-name
```
8. **创建 Pull Request**
- 在 GitHub 上创建 PR
- 填写 PR 模板
- 等待代码审查
#### 提交信息规范
使用语义化提交信息:
- `feat:` 新功能
- `fix:` Bug 修复
- `docs:` 文档更新
- `style:` 代码格式(不影响代码运行)
- `refactor:` 重构
- `test:` 测试相关
- `chore:` 构建过程或辅助工具的变动
示例:
```
feat: 添加文档批量上传功能
- 支持同时上传多个文档
- 添加进度条显示
- 更新相关文档
Closes #123
```
#### 代码规范
1. **PHP 代码**
- 遵循 PSR-12 编码标准
- 使用 Laravel Pint 格式化代码
```bash
./vendor/bin/pint
```
2. **JavaScript 代码**
- 使用 2 空格缩进
- 使用单引号
- 添加适当的注释
3. **命名规范**
- 类名PascalCase
- 方法名camelCase
- 变量名camelCase
- 常量UPPER_SNAKE_CASE
4. **注释**
- 为复杂逻辑添加注释
- 使用 PHPDoc 注释公共方法
- 注释使用中文
#### 测试要求
- 所有新功能必须包含测试
- Bug 修复应包含回归测试
- 确保所有测试通过
- 保持测试覆盖率 ≥ 80%
测试类型:
- **单元测试**:测试独立的类和方法
- **功能测试**:测试完整的用户流程
- **属性测试**:使用 Property-Based Testing
#### 文档要求
如果你的更改影响到:
- **API**:更新 `docs/API_REFERENCE.md`
- **配置**:更新 `docs/DEPLOYMENT.md`
- **功能**:更新 `README.md``docs/PROJECT_OVERVIEW.md`
- **使用方法**:添加或更新相关指南
### Pull Request 检查清单
在提交 PR 之前,请确保:
- [ ] 代码遵循项目的编码规范
- [ ] 所有测试通过
- [ ] 添加了必要的测试
- [ ] 更新了相关文档
- [ ] 提交信息清晰且符合规范
- [ ] PR 描述清楚说明了更改内容
- [ ] 没有合并冲突
### 代码审查
所有提交都需要经过代码审查:
- 至少一位维护者批准
- 所有讨论都已解决
- CI 检查通过
审查者会关注:
- 代码质量
- 测试覆盖率
- 文档完整性
- 性能影响
- 安全问题
## 开发环境设置
### 必需软件
- PHP 8.1+
- Composer 2.x
- Node.js 18+
- MySQL 8.0+ 或 PostgreSQL 13+
- Redis 6.0+
- Meilisearch 1.5+
### 本地开发
1. **配置数据库**
```env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=knowledge_base_dev
DB_USERNAME=root
DB_PASSWORD=
```
2. **运行迁移**
```bash
php artisan migrate
php artisan db:seed
```
3. **启动服务**
```bash
# 终端 1Laravel 服务器
php artisan serve
# 终端 2队列工作进程
php artisan queue:work
# 终端 3Meilisearch
meilisearch --master-key="dev-key"
# 终端 4前端构建开发模式
npm run dev
```
4. **创建测试用户**
```bash
php artisan make:filament-user
```
### 调试技巧
1. **使用 Laravel Telescope**(如果安装)
```bash
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
```
2. **查看日志**
```bash
tail -f storage/logs/laravel.log
```
3. **使用 Tinker**
```bash
php artisan tinker
```
4. **调试队列任务**
```bash
php artisan queue:work --verbose
```
## 项目结构
```
knowledge-base-system/
├── app/
│ ├── Filament/ # Filament 资源和页面
│ ├── Http/ # 控制器
│ ├── Jobs/ # 队列任务
│ ├── Models/ # Eloquent 模型
│ ├── Observers/ # 模型观察者
│ ├── Policies/ # 授权策略
│ └── Services/ # 业务逻辑服务
├── config/ # 配置文件
├── database/ # 迁移和种子
├── docs/ # 项目文档
├── resources/ # 视图和前端资源
├── tests/ # 测试文件
└── .kiro/specs/ # 功能规格文档
```
## 常见问题
### 如何添加新的服务类?
1. 在 `app/Services/` 创建服务类
2. 添加必要的方法和注释
3. 在 `docs/API_REFERENCE.md` 中添加文档
4. 编写单元测试
### 如何添加新的 Filament 资源?
1. 使用 Artisan 命令生成资源
```bash
php artisan make:filament-resource ModelName
```
2. 配置表单和表格
3. 添加必要的策略
4. 更新中文翻译
### 如何添加新的队列任务?
1. 创建 Job 类
```bash
php artisan make:job JobName
```
2. 实现 `handle()` 方法
3. 添加错误处理
4. 编写测试
## 获取帮助
如果你需要帮助:
- 查看 [文档](docs/)
- 在 [Discussions](https://github.com/your-repo/discussions) 中提问
- 加入我们的社区频道
## 许可证
通过贡献代码,你同意你的贡献将在 MIT 许可证下发布。
---
再次感谢你的贡献!🎉

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Knowledge Base System Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

315
README.md Normal file
View File

@@ -0,0 +1,315 @@
# 知识库系统
基于 Laravel 11 和 Filament 3.X 构建的企业级文档管理平台,支持 Word 文档上传、自动转换为 Markdown、全文搜索和基于分组的权限控制。
[![Laravel](https://img.shields.io/badge/Laravel-11.x-red.svg)](https://laravel.com)
[![Filament](https://img.shields.io/badge/Filament-3.x-orange.svg)](https://filamentphp.com)
[![PHP](https://img.shields.io/badge/PHP-8.1+-blue.svg)](https://php.net)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
## ✨ 功能特性
### 📄 文档管理
- **多格式支持**:支持 .doc 和 .docx 格式的 Word 文档上传
- **智能分类**
- 全局知识库:所有用户可访问
- 专用知识库:仅特定分组用户可访问
- **安全下载**:支持原始文档下载,自动记录下载日志
- **在线预览**Markdown 格式在线预览,无需下载
### 🔄 自动转换
- **异步处理**:使用 Laravel Queue 异步转换文档
- **多引擎支持**:支持 Pandoc 或 PHPWord 作为转换引擎
- **状态跟踪**:实时显示转换状态(待处理、处理中、已完成、失败)
- **容错机制**:转换失败不影响文档正常使用
### 🔍 全文搜索
- **快速搜索**:集成 Meilisearch 提供毫秒级搜索响应
- **多字段搜索**:同时搜索标题、描述和文档内容
- **智能过滤**:搜索结果自动应用权限过滤
- **高级筛选**:支持按类型、分组、上传者等条件筛选
### 🔐 权限控制
- **灵活分组**:用户可以属于多个分组
- **细粒度控制**
- 全局文档:所有用户可访问
- 专用文档:只有所属分组用户可访问
- **多层验证**:在查询、下载、预览等操作中强制执行权限
- **安全审计**:记录所有未授权访问尝试
### 🎨 用户界面
- **现代化设计**:基于 Filament 3.X 的美观管理界面
- **完整中文化**:所有界面元素使用简体中文
- **响应式布局**:完美支持桌面和移动设备
- **直观操作**:简洁的操作流程,降低学习成本
## 🚀 快速开始
### 环境要求
- PHP 8.1 或更高版本
- Composer 2.x
- Node.js 18+ 和 npm
- MySQL 8.0+ 或 PostgreSQL 13+
- Redis 6.0+
- Meilisearch 1.5+
- Pandoc 2.x+(可选,用于文档转换)
### 安装步骤
1. **克隆项目**
```bash
git clone <repository-url>
cd knowledge-base-system
```
2. **安装依赖**
```bash
composer install
npm install
```
3. **配置环境**
```bash
cp .env.example .env
php artisan key:generate
```
编辑 `.env` 文件,配置数据库连接:
```env
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=knowledge_base
DB_USERNAME=your_username
DB_PASSWORD=your_password
```
4. **运行迁移**
```bash
php artisan migrate
```
5. **生成测试数据**(可选)
```bash
php artisan db:seed
```
6. **创建管理员账户**
```bash
php artisan make:filament-user
```
7. **编译前端资源**
```bash
npm run build
```
8. **启动服务**
在不同的终端窗口中运行:
```bash
# Laravel 开发服务器
php artisan serve
# 队列工作进程
php artisan queue:work
# Meilisearch如果本地安装
meilisearch --master-key="your-master-key"
```
9. **访问系统**
打开浏览器访问:`http://localhost:8000/admin`
## 📚 文档
- [项目概览](docs/PROJECT_OVERVIEW.md) - 系统架构和功能详解
- [部署指南](docs/DEPLOYMENT.md) - 生产环境部署步骤
- [API 参考](docs/API_REFERENCE.md) - 服务类和方法文档
- [Meilisearch 配置](docs/MEILISEARCH_SETUP.md) - 搜索引擎配置说明
- [文档转换指南](docs/DOCUMENT_CONVERSION_GUIDE.md) - 转换功能配置
- [安全日志](docs/security-logging.md) - 安全审计功能说明
## 🔧 配置
### Meilisearch 配置
`.env` 文件中配置:
```env
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-master-key
SCOUT_DRIVER=meilisearch
```
### 文档转换配置
```env
DOCUMENT_CONVERSION_DRIVER=pandoc
PANDOC_PATH=/usr/local/bin/pandoc
CONVERSION_TIMEOUT=300
```
### 队列配置
```env
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
```
详细配置说明请参考 [部署指南](docs/DEPLOYMENT.md)。
## 🧪 测试
### 运行测试
```bash
# 运行所有测试
php artisan test
# 运行特定测试
php artisan test --filter=DocumentAccessScopePropertyTest
# 生成测试覆盖率报告
php artisan test --coverage
```
### 测试类型
- **单元测试**:测试独立的类和方法
- **功能测试**:测试完整的用户流程
- **属性测试**:使用 Property-Based Testing 验证核心逻辑
## 📦 技术栈
### 后端
- **Laravel 11.x** - PHP Web 应用框架
- **Filament 3.X** - 管理面板框架
- **Laravel Scout** - 全文搜索集成
- **Meilisearch** - 快速搜索引擎
- **Pandoc** - 文档格式转换工具
### 前端
- **Blade** - Laravel 模板引擎
- **Tailwind CSS** - CSS 框架
- **Alpine.js** - JavaScript 框架Filament 内置)
- **Livewire** - 全栈框架Filament 内置)
### 数据库
- **MySQL 8.0+** 或 **PostgreSQL 13+**
- **Redis** - 缓存和队列
### 开发工具
- **Pest PHP** - 测试框架
- **PHPStan** - 静态分析工具
- **Laravel Pint** - 代码格式化工具
## 🗂️ 项目结构
```
knowledge-base-system/
├── app/
│ ├── Filament/ # Filament 资源和页面
│ │ ├── Pages/ # 自定义页面(搜索)
│ │ └── Resources/ # 资源管理(文档、分组、用户)
│ ├── Http/
│ │ └── Controllers/ # 控制器(文档预览)
│ ├── Jobs/ # 队列任务(文档转换)
│ ├── Models/ # Eloquent 模型
│ ├── Observers/ # 模型观察者(文档索引)
│ ├── Policies/ # 授权策略
│ └── Services/ # 业务逻辑服务
├── config/
│ ├── documents.php # 文档转换配置
│ ├── filesystems.php # 文件存储配置
│ └── scout.php # Meilisearch 配置
├── database/
│ ├── factories/ # 测试数据工厂
│ ├── migrations/ # 数据库迁移
│ └── seeders/ # 数据填充
├── docs/ # 项目文档
├── resources/
│ └── views/ # Blade 视图模板
├── storage/
│ └── app/
│ └── private/
│ ├── documents/ # 原始文档存储
│ └── markdown/ # Markdown 文件存储
├── tests/ # 测试文件
└── .kiro/
└── specs/ # 功能规格文档
```
## 🤝 贡献
欢迎贡献代码!请遵循以下步骤:
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 开启 Pull Request
## 📝 更新日志
### v1.0.0 (2025-12-05)
#### 已实现功能
- ✅ 用户认证和授权
- ✅ 用户分组管理
- ✅ 文档上传和存储
- ✅ 文档分类(全局/专用)
- ✅ 基于分组的权限控制
- ✅ 文档下载和日志记录
- ✅ Word 文档自动转换为 Markdown
- ✅ 异步队列处理转换任务
- ✅ Meilisearch 全文搜索集成
- ✅ 文档 Markdown 在线预览
- ✅ 搜索结果权限过滤
- ✅ 安全日志记录
- ✅ Filament 管理面板
- ✅ 完整中文界面
#### 待完成功能
- ⏳ 属性基础测试Property-Based Testing
- ⏳ 完整的功能测试套件
- ⏳ 性能优化(缓存、索引优化)
- ⏳ UI 增强Alpine.js 动画和交互)
## 🔒 安全
如果发现安全漏洞,请发送邮件至 security@example.com我们会及时处理。
请勿在公开的 issue 中报告安全问题。
## 📄 许可证
本项目采用 MIT 许可证。详情请参阅 [LICENSE](LICENSE) 文件。
## 🙏 致谢
感谢以下开源项目:
- [Laravel](https://laravel.com) - 优雅的 PHP 框架
- [Filament](https://filamentphp.com) - 强大的管理面板
- [Meilisearch](https://www.meilisearch.com) - 快速搜索引擎
- [Pandoc](https://pandoc.org) - 通用文档转换器
- [Tailwind CSS](https://tailwindcss.com) - 实用优先的 CSS 框架
## 📞 联系方式
- **问题反馈**: 请在 GitHub Issues 中提交
- **功能建议**: 欢迎在 GitHub Discussions 中讨论
---
**开发状态**: 🚧 活跃开发中
**最后更新**: 2025-12-05
**版本**: 1.0.0

View 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 [];
}
}

View 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'),
];
}
}

View File

@@ -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');
}
}

View File

@@ -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('文档信息已成功更新。');
}
}

View File

@@ -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('上传文档'),
];
}
}

View 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),
]);
}
}

View 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'),
];
}
}

View 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 '分组创建成功';
}
}

View 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 '分组更新成功';
}
}

View 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('创建分组'),
];
}
}

View File

@@ -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('取消'),
]),
]);
}
}

View 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'),
];
}
}

View 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 '用户创建成功';
}
}

View 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 '用户更新成功';
}
}

View 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('创建用户'),
];
}
}

View File

@@ -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('取消'),
]),
]);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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());
}
}
}

View 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
View 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';
}
}

View 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
View 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
View 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);
}
}

View 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(),
]);
// 不抛出异常,避免影响删除操作
}
}
}

View 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;
}
}

View 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);
}
}

View 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,
]);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,207 @@
<?php
namespace App\Services;
use App\Models\Document;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
/**
* 文档搜索服务
* 负责处理文档的全文搜索和 Meilisearch 索引管理
*/
class DocumentSearchService
{
/**
* 搜索文档
* 使用 Laravel Scout Meilisearch 进行全文搜索
*
* @param string $query 搜索关键词
* @param User $user 当前用户
* @param array $filters 额外的筛选条件
* @return Collection
*/
public function search(string $query, User $user, array $filters = []): Collection
{
try {
// 使用 Scout 进行搜索
$searchBuilder = Document::search($query);
// 应用额外的筛选条件
if (!empty($filters['type'])) {
$searchBuilder->where('type', $filters['type']);
}
if (!empty($filters['group_id'])) {
$searchBuilder->where('group_id', $filters['group_id']);
}
if (!empty($filters['uploaded_by'])) {
$searchBuilder->where('uploaded_by', $filters['uploaded_by']);
}
// 执行搜索并获取结果
$results = $searchBuilder->get();
// 应用用户权限过滤
return $this->filterByUserPermissions($results, $user);
} catch (\Exception $e) {
Log::error('文档搜索失败', [
'query' => $query,
'user_id' => $user->id,
'filters' => $filters,
'error' => $e->getMessage(),
]);
// 搜索失败时返回空集合
return new Collection();
}
}
/**
* 根据用户权限过滤搜索结果
* 确保用户只能看到有权限访问的文档
*
* @param Collection $results 搜索结果
* @param User $user 当前用户
* @return Collection
*/
public function filterByUserPermissions(Collection $results, User $user): Collection
{
// 获取用户所属的所有分组 ID
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
return $results->filter(function (Document $document) use ($userGroupIds) {
// 全局文档对所有用户可见
if ($document->type === 'global') {
return true;
}
// 专用文档只对所属分组的用户可见
if ($document->type === 'dedicated') {
return in_array($document->group_id, $userGroupIds);
}
return false;
});
}
/**
* 准备文档的可搜索数据
* 包含完整的 Markdown 内容用于索引
*
* @param Document $document 文档模型
* @return array
*/
public function prepareSearchableData(Document $document): array
{
return [
'id' => $document->id,
'title' => $document->title,
'description' => $document->description,
'markdown_content' => $document->getMarkdownContent(),
'type' => $document->type,
'group_id' => $document->group_id,
'uploaded_by' => $document->uploaded_by,
'created_at' => $document->created_at?->timestamp,
'updated_at' => $document->updated_at?->timestamp,
];
}
/**
* 索引文档到 Meilisearch
* 读取 Markdown 文件并创建搜索索引
*
* @param Document $document 文档模型
* @return void
*/
public function indexDocument(Document $document): void
{
try {
// 只索引已完成转换的文档
if (!$document->shouldBeSearchable()) {
Log::info('文档未完成转换,跳过索引', [
'document_id' => $document->id,
'conversion_status' => $document->conversion_status,
]);
return;
}
// 使用 Scout 的 searchable 方法进行索引
$document->searchable();
Log::info('文档索引成功', [
'document_id' => $document->id,
'title' => $document->title,
]);
} catch (\Exception $e) {
Log::error('文档索引失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 索引失败不影响文档的正常使用,只记录错误
}
}
/**
* 更新文档在 Meilisearch 中的索引
*
* @param Document $document 文档模型
* @return void
*/
public function updateDocumentIndex(Document $document): void
{
try {
// 如果文档应该被索引,则更新索引
if ($document->shouldBeSearchable()) {
$document->searchable();
Log::info('文档索引更新成功', [
'document_id' => $document->id,
'title' => $document->title,
]);
} else {
// 如果文档不应该被索引(例如转换失败),则从索引中移除
$this->removeDocumentFromIndex($document);
}
} catch (\Exception $e) {
Log::error('文档索引更新失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 索引更新失败不影响文档的正常使用,只记录错误
}
}
/**
* Meilisearch 中移除文档索引
*
* @param Document $document 文档模型
* @return void
*/
public function removeDocumentFromIndex(Document $document): void
{
try {
// 使用 Scout 的 unsearchable 方法移除索引
$document->unsearchable();
Log::info('文档索引移除成功', [
'document_id' => $document->id,
'title' => $document->title,
]);
} catch (\Exception $e) {
Log::error('文档索引移除失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
// 索引移除失败不影响文档的正常删除,只记录错误
}
}
}

View File

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

View File

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

View File

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

18
artisan Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

18
bootstrap/app.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

6
bootstrap/providers.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
];

95
composer.json Normal file
View File

@@ -0,0 +1,95 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.2",
"filament/filament": "^3.0",
"http-interop/http-factory-guzzle": "^1.2",
"laravel/framework": "^12.0",
"laravel/scout": "^10.22",
"laravel/tinker": "^2.10.1",
"league/commonmark": "^2.8",
"meilisearch/meilisearch-php": "^1.16",
"phpoffice/phpword": "^1.4"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel-lang/common": "^6.7",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.8",
"phpunit/phpunit": "^11.5.3"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"setup": [
"composer install",
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate",
"@php artisan migrate --force",
"npm install",
"npm run build"
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=documents,default --tries=3 --timeout=300\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

13257
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'zh_CN'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'zh_CN'),
'faker_locale' => env('APP_FAKER_LOCALE', 'zh_CN'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

115
config/auth.php Normal file
View File

@@ -0,0 +1,115 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

117
config/cache.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane",
| "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];

183
config/database.php Normal file
View File

@@ -0,0 +1,183 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

113
config/documents.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| 文档转换配置
|--------------------------------------------------------------------------
|
| 这里配置文档转换相关的设置,包括转换驱动、超时时间和队列配置。
|
*/
'conversion' => [
/*
| 转换驱动
| 支持的驱动: 'pandoc', 'phpword'
| 推荐使用 pandoc 以获得更好的转换质量
*/
'driver' => env('DOCUMENT_CONVERSION_DRIVER', 'pandoc'),
/*
| Pandoc 可执行文件路径
| 如果 pandoc 在系统 PATH 中,可以直接使用 'pandoc'
| 否则需要指定完整路径,如 '/usr/local/bin/pandoc'
*/
'pandoc_path' => env('PANDOC_PATH', '/opt/homebrew/bin/pandoc'),
/*
| 转换超时时间(秒)
| 大文档可能需要更长的转换时间
*/
'timeout' => env('CONVERSION_TIMEOUT', 300),
/*
| 队列名称
| 文档转换任务将被分发到此队列
*/
'queue' => env('CONVERSION_QUEUE', 'documents'),
/*
| 转换失败后的重试次数
*/
'retry_times' => env('CONVERSION_RETRY_TIMES', 3),
/*
| 转换失败后的重试延迟(秒)
*/
'retry_delay' => env('CONVERSION_RETRY_DELAY', 60),
],
/*
|--------------------------------------------------------------------------
| Markdown 配置
|--------------------------------------------------------------------------
|
| Markdown 渲染和处理相关的配置。
|
*/
'markdown' => [
/*
| Markdown 渲染器
| 支持的渲染器: 'commonmark', 'parsedown'
*/
'renderer' => env('MARKDOWN_RENDERER', 'commonmark'),
/*
| 是否清理 HTML 以防止 XSS 攻击
*/
'sanitize' => env('MARKDOWN_SANITIZE', true),
/*
| Markdown 预览长度(字符数)
| 用于在数据库中存储的内容摘要
*/
'preview_length' => env('MARKDOWN_PREVIEW_LENGTH', 500),
/*
| Markdown 文件最大大小(字节)
| 超过此大小的文件将分块处理
*/
'max_file_size' => env('MARKDOWN_MAX_FILE_SIZE', 10485760), // 10MB
],
/*
|--------------------------------------------------------------------------
| 存储配置
|--------------------------------------------------------------------------
|
| 文档和 Markdown 文件的存储配置。
|
*/
'storage' => [
/*
| 文档存储磁盘
*/
'documents_disk' => env('DOCUMENTS_DISK', 'documents'),
/*
| Markdown 存储磁盘
*/
'markdown_disk' => env('MARKDOWN_DISK', 'markdown'),
/*
| 是否按日期组织文件目录
| 格式: YYYY/MM/DD/
*/
'organize_by_date' => env('STORAGE_ORGANIZE_BY_DATE', true),
],
];

96
config/filesystems.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
'documents' => [
'driver' => 'local',
'root' => storage_path('app/private/documents'),
'visibility' => 'private',
'throw' => false,
'report' => false,
],
'markdown' => [
'driver' => 'local',
'root' => storage_path('app/private/markdown'),
'visibility' => 'private',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

140
config/logging.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => env('LOG_LEVEL', 'info'),
'days' => env('LOG_SECURITY_DAYS', 90),
'replace_placeholders' => true,
],
],
];

118
config/mail.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', 'Example'),
],
];

129
config/queue.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

212
config/scout.php Normal file
View File

@@ -0,0 +1,212 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Search Engine
|--------------------------------------------------------------------------
|
| This option controls the default search connection that gets used while
| using Laravel Scout. This connection is used when syncing all models
| to the search service. You should adjust this based on your needs.
|
| Supported: "algolia", "meilisearch", "typesense",
| "database", "collection", "null"
|
*/
'driver' => env('SCOUT_DRIVER', 'collection'),
/*
|--------------------------------------------------------------------------
| Index Prefix
|--------------------------------------------------------------------------
|
| Here you may specify a prefix that will be applied to all search index
| names used by Scout. This prefix may be useful if you have multiple
| "tenants" or applications sharing the same search infrastructure.
|
*/
'prefix' => env('SCOUT_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Queue Data Syncing
|--------------------------------------------------------------------------
|
| This option allows you to control if the operations that sync your data
| with your search engines are queued. When this is set to "true" then
| all automatic data syncing will get queued for better performance.
|
*/
'queue' => env('SCOUT_QUEUE', false),
/*
|--------------------------------------------------------------------------
| Database Transactions
|--------------------------------------------------------------------------
|
| This configuration option determines if your data will only be synced
| with your search indexes after every open database transaction has
| been committed, thus preventing any discarded data from syncing.
|
*/
'after_commit' => false,
/*
|--------------------------------------------------------------------------
| Chunk Sizes
|--------------------------------------------------------------------------
|
| These options allow you to control the maximum chunk size when you are
| mass importing data into the search engine. This allows you to fine
| tune each of these chunk sizes based on the power of the servers.
|
*/
'chunk' => [
'searchable' => 500,
'unsearchable' => 500,
],
/*
|--------------------------------------------------------------------------
| Soft Deletes
|--------------------------------------------------------------------------
|
| This option allows to control whether to keep soft deleted records in
| the search indexes. Maintaining soft deleted records can be useful
| if your application still needs to search for the records later.
|
*/
'soft_delete' => false,
/*
|--------------------------------------------------------------------------
| Identify User
|--------------------------------------------------------------------------
|
| This option allows you to control whether to notify the search engine
| of the user performing the search. This is sometimes useful if the
| engine supports any analytics based on this application's users.
|
| Supported engines: "algolia"
|
*/
'identify' => env('SCOUT_IDENTIFY', false),
/*
|--------------------------------------------------------------------------
| Algolia Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Algolia settings. Algolia is a cloud hosted
| search engine which works great with Scout out of the box. Just plug
| in your application ID and admin API key to get started searching.
|
*/
'algolia' => [
'id' => env('ALGOLIA_APP_ID', ''),
'secret' => env('ALGOLIA_SECRET', ''),
'index-settings' => [
// 'users' => [
// 'searchableAttributes' => ['id', 'name', 'email'],
// 'attributesForFaceting'=> ['filterOnly(email)'],
// ],
],
],
/*
|--------------------------------------------------------------------------
| Meilisearch Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Meilisearch settings. Meilisearch is an open
| source search engine with minimal configuration. Below, you can state
| the host and key information for your own Meilisearch installation.
|
| See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options
|
*/
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
'documents' => [
'filterableAttributes' => ['type', 'group_id', 'uploaded_by', 'conversion_status'],
'sortableAttributes' => ['created_at', 'title', 'updated_at'],
'searchableAttributes' => ['title', 'description', 'markdown_content'],
'displayedAttributes' => ['id', 'title', 'description', 'type', 'group_id', 'uploaded_by', 'created_at', 'updated_at'],
],
],
],
/*
|--------------------------------------------------------------------------
| Typesense Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your Typesense settings. Typesense is an open
| source search engine using minimal configuration. Below, you will
| state the host, key, and schema configuration for the instance.
|
*/
'typesense' => [
'client-settings' => [
'api_key' => env('TYPESENSE_API_KEY', 'xyz'),
'nodes' => [
[
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
],
'nearest_node' => [
'host' => env('TYPESENSE_HOST', 'localhost'),
'port' => env('TYPESENSE_PORT', '8108'),
'path' => env('TYPESENSE_PATH', ''),
'protocol' => env('TYPESENSE_PROTOCOL', 'http'),
],
'connection_timeout_seconds' => env('TYPESENSE_CONNECTION_TIMEOUT_SECONDS', 2),
'healthcheck_interval_seconds' => env('TYPESENSE_HEALTHCHECK_INTERVAL_SECONDS', 30),
'num_retries' => env('TYPESENSE_NUM_RETRIES', 3),
'retry_interval_seconds' => env('TYPESENSE_RETRY_INTERVAL_SECONDS', 1),
],
// 'max_total_results' => env('TYPESENSE_MAX_TOTAL_RESULTS', 1000),
'model-settings' => [
// User::class => [
// 'collection-schema' => [
// 'fields' => [
// [
// 'name' => 'id',
// 'type' => 'string',
// ],
// [
// 'name' => 'name',
// 'type' => 'string',
// ],
// [
// 'name' => 'created_at',
// 'type' => 'int64',
// ],
// ],
// 'default_sorting_field' => 'created_at',
// ],
// 'search-parameters' => [
// 'query_by' => 'name'
// ],
// ],
],
],
];

38
config/services.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

217
config/session.php Normal file
View File

@@ -0,0 +1,217 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain and all subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

1
database/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sqlite*

View File

@@ -0,0 +1,129 @@
<?php
namespace Database\Factories;
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Document>
*/
class DocumentFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Document::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
// 使用中文 Faker 生成器
$faker = \Faker\Factory::create('zh_CN');
// 生成中文文档标题
$titles = [
'项目管理规范',
'技术文档模板',
'员工手册',
'产品需求文档',
'系统设计方案',
'测试报告',
'会议纪要',
'培训资料',
'操作指南',
'年度总结报告',
];
$title = $faker->randomElement($titles) . ' - ' . $faker->word();
$fileName = $faker->word() . '_' . date('Ymd') . '.docx';
return [
'title' => $title,
'description' => $faker->paragraph(3),
'file_path' => 'documents/' . date('Y/m/d') . '/' . fake()->uuid() . '.docx',
'file_name' => $fileName,
'file_size' => fake()->numberBetween(10000, 5000000),
'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'type' => fake()->randomElement(['global', 'dedicated']),
'group_id' => null,
'uploaded_by' => User::factory(),
'markdown_path' => null,
'markdown_preview' => null,
'conversion_status' => 'pending',
'conversion_error' => null,
];
}
/**
* 指定文档为全局类型
*/
public function global(): static
{
return $this->state(fn (array $attributes) => [
'type' => 'global',
'group_id' => null,
]);
}
/**
* 指定文档为专用类型
*/
public function dedicated(?int $groupId = null): static
{
return $this->state(fn (array $attributes) => [
'type' => 'dedicated',
'group_id' => $groupId ?? Group::factory(),
]);
}
/**
* 指定文档已完成转换
*/
public function converted(): static
{
$faker = \Faker\Factory::create('zh_CN');
$uuid = fake()->uuid();
return $this->state(fn (array $attributes) => [
'markdown_path' => 'markdown/' . date('Y/m/d') . '/' . $uuid . '.md',
'markdown_preview' => $faker->text(500),
'conversion_status' => 'completed',
'conversion_error' => null,
]);
}
/**
* 指定文档转换失败
*/
public function conversionFailed(): static
{
return $this->state(fn (array $attributes) => [
'markdown_path' => null,
'markdown_preview' => null,
'conversion_status' => 'failed',
'conversion_error' => 'Failed to convert document: Invalid file format',
]);
}
/**
* 指定文档正在转换中
*/
public function converting(): static
{
return $this->state(fn (array $attributes) => [
'markdown_path' => null,
'markdown_preview' => null,
'conversion_status' => 'processing',
'conversion_error' => null,
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Database\Factories;
use App\Models\Document;
use App\Models\DownloadLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DownloadLog>
*/
class DownloadLogFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = DownloadLog::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'document_id' => Document::factory(),
'user_id' => User::factory(),
'downloaded_at' => fake()->dateTimeBetween('-1 year', 'now'),
'ip_address' => fake()->ipv4(),
];
}
/**
* 指定下载日志使用特定的文档
*/
public function forDocument(Document|int $document): static
{
$documentId = $document instanceof Document ? $document->id : $document;
return $this->state(fn (array $attributes) => [
'document_id' => $documentId,
]);
}
/**
* 指定下载日志使用特定的用户
*/
public function forUser(User|int $user): static
{
$userId = $user instanceof User ? $user->id : $user;
return $this->state(fn (array $attributes) => [
'user_id' => $userId,
]);
}
/**
* 指定下载日志使用最近的时间
*/
public function recent(): static
{
return $this->state(fn (array $attributes) => [
'downloaded_at' => fake()->dateTimeBetween('-7 days', 'now'),
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Database\Factories;
use App\Models\Group;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Group>
*/
class GroupFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Group::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
// 使用中文 Faker 生成器
$faker = \Faker\Factory::create('zh_CN');
// 生成中文分组名称(使用公司名或部门名)
$groupNames = [
'技术部',
'市场部',
'人力资源部',
'财务部',
'运营部',
'产品部',
'设计部',
'客服部',
'研发中心',
'销售部',
];
return [
'name' => $faker->randomElement($groupNames) . ' - ' . $faker->company(),
'description' => $faker->sentence(10),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('groups', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('groups');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('group_user', function (Blueprint $table) {
$table->id();
$table->foreignId('group_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamps();
// 添加唯一索引,确保同一用户不会重复加入同一分组
$table->unique(['group_id', 'user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('group_user');
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->string('file_path', 500);
$table->string('file_name');
$table->bigInteger('file_size');
$table->string('mime_type', 100);
$table->enum('type', ['global', 'dedicated']);
$table->foreignId('group_id')->nullable()->constrained()->onDelete('set null');
$table->foreignId('uploaded_by')->constrained('users')->onDelete('cascade');
$table->timestamps();
// 添加索引以优化查询性能
$table->index('type');
$table->index('group_id');
$table->index('uploaded_by');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('documents');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('download_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('ip_address', 45);
$table->timestamp('downloaded_at');
// 添加索引以优化查询性能
$table->index('document_id');
$table->index('user_id');
$table->index('downloaded_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('download_logs');
}
};

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('documents', function (Blueprint $table) {
// 添加 Markdown 文件路径字段
$table->string('markdown_path', 500)->nullable()->after('description');
// 添加 Markdown 内容摘要字段(用于快速预览)
$table->text('markdown_preview')->nullable()->after('markdown_path');
// 添加转换状态字段
$table->enum('conversion_status', ['pending', 'processing', 'completed', 'failed'])
->default('pending')
->after('markdown_preview');
// 添加转换错误信息字段
$table->text('conversion_error')->nullable()->after('conversion_status');
// 添加索引以优化查询性能
$table->index('conversion_status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('documents', function (Blueprint $table) {
// 删除索引
$table->dropIndex(['conversion_status']);
// 删除字段
$table->dropColumn([
'markdown_path',
'markdown_preview',
'conversion_status',
'conversion_error'
]);
});
}
};

View File

@@ -0,0 +1,218 @@
<?php
namespace Database\Seeders;
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
$this->command->info('开始生成演示数据...');
// 1. 创建管理员用户
$this->command->info('创建管理员用户...');
$admin = User::factory()->create([
'name' => '系统管理员',
'email' => 'admin@example.com',
'password' => Hash::make('password'),
]);
// 2. 创建普通用户
$this->command->info('创建普通用户...');
$user1 = User::factory()->create([
'name' => '张三',
'email' => 'zhangsan@example.com',
'password' => Hash::make('password'),
]);
$user2 = User::factory()->create([
'name' => '李四',
'email' => 'lisi@example.com',
'password' => Hash::make('password'),
]);
$user3 = User::factory()->create([
'name' => '王五',
'email' => 'wangwu@example.com',
'password' => Hash::make('password'),
]);
$user4 = User::factory()->create([
'name' => '赵六',
'email' => 'zhaoliu@example.com',
'password' => Hash::make('password'),
]);
// 3. 创建分组
$this->command->info('创建分组...');
$techGroup = Group::factory()->create([
'name' => '技术部',
'description' => '负责公司技术研发和系统维护工作',
]);
$marketGroup = Group::factory()->create([
'name' => '市场部',
'description' => '负责市场推广和品牌建设工作',
]);
$hrGroup = Group::factory()->create([
'name' => '人力资源部',
'description' => '负责人力资源管理和员工关系维护',
]);
// 4. 建立用户和分组的关联关系
$this->command->info('建立用户和分组的关联关系...');
// 管理员属于所有分组
$admin->groups()->attach([$techGroup->id, $marketGroup->id, $hrGroup->id]);
// 张三和李四属于技术部
$user1->groups()->attach($techGroup->id);
$user2->groups()->attach($techGroup->id);
// 王五属于市场部
$user3->groups()->attach($marketGroup->id);
// 赵六属于人力资源部
$user4->groups()->attach($hrGroup->id);
// 5. 创建全局文档
$this->command->info('创建全局文档...');
Document::factory()->global()->create([
'title' => '公司员工手册',
'description' => '包含公司规章制度、员工福利、考勤制度等重要信息',
'file_name' => '员工手册_2024.docx',
'uploaded_by' => $admin->id,
]);
Document::factory()->global()->create([
'title' => '办公室使用规范',
'description' => '办公室设施使用规范和注意事项',
'file_name' => '办公室规范.docx',
'uploaded_by' => $admin->id,
]);
Document::factory()->global()->create([
'title' => '公司年度总结报告',
'description' => '2024年度公司发展总结和2025年规划',
'file_name' => '年度总结_2024.docx',
'uploaded_by' => $admin->id,
]);
Document::factory()->global()->create([
'title' => '安全管理制度',
'description' => '公司信息安全和物理安全管理制度',
'file_name' => '安全管理制度.docx',
'uploaded_by' => $admin->id,
]);
// 6. 创建技术部专用文档
$this->command->info('创建技术部专用文档...');
Document::factory()->dedicated($techGroup->id)->create([
'title' => '系统架构设计文档',
'description' => '公司核心系统的架构设计和技术选型说明',
'file_name' => '系统架构设计.docx',
'uploaded_by' => $user1->id,
]);
Document::factory()->dedicated($techGroup->id)->create([
'title' => '代码规范指南',
'description' => '团队代码编写规范和最佳实践',
'file_name' => '代码规范.docx',
'uploaded_by' => $admin->id,
]);
Document::factory()->dedicated($techGroup->id)->create([
'title' => '数据库设计文档',
'description' => '数据库表结构设计和关系说明',
'file_name' => '数据库设计.docx',
'uploaded_by' => $user2->id,
]);
Document::factory()->dedicated($techGroup->id)->create([
'title' => 'API 接口文档',
'description' => '系统对外提供的 API 接口说明和使用示例',
'file_name' => 'API接口文档.docx',
'uploaded_by' => $user1->id,
]);
// 7. 创建市场部专用文档
$this->command->info('创建市场部专用文档...');
Document::factory()->dedicated($marketGroup->id)->create([
'title' => '市场推广方案',
'description' => '2025年第一季度市场推广计划和预算',
'file_name' => '市场推广方案_Q1.docx',
'uploaded_by' => $user3->id,
]);
Document::factory()->dedicated($marketGroup->id)->create([
'title' => '品牌宣传策略',
'description' => '品牌定位和宣传渠道策略',
'file_name' => '品牌宣传策略.docx',
'uploaded_by' => $admin->id,
]);
Document::factory()->dedicated($marketGroup->id)->create([
'title' => '客户调研报告',
'description' => '目标客户群体调研分析报告',
'file_name' => '客户调研报告.docx',
'uploaded_by' => $user3->id,
]);
// 8. 创建人力资源部专用文档
$this->command->info('创建人力资源部专用文档...');
Document::factory()->dedicated($hrGroup->id)->create([
'title' => '招聘流程指南',
'description' => '公司招聘流程和面试评估标准',
'file_name' => '招聘流程指南.docx',
'uploaded_by' => $user4->id,
]);
Document::factory()->dedicated($hrGroup->id)->create([
'title' => '员工培训计划',
'description' => '2025年员工培训计划和课程安排',
'file_name' => '员工培训计划_2025.docx',
'uploaded_by' => $admin->id,
]);
Document::factory()->dedicated($hrGroup->id)->create([
'title' => '薪酬福利制度',
'description' => '公司薪酬结构和福利政策说明',
'file_name' => '薪酬福利制度.docx',
'uploaded_by' => $user4->id,
]);
Document::factory()->dedicated($hrGroup->id)->create([
'title' => '绩效考核标准',
'description' => '员工绩效考核指标和评估流程',
'file_name' => '绩效考核标准.docx',
'uploaded_by' => $admin->id,
]);
$this->command->info('演示数据生成完成!');
$this->command->newLine();
$this->command->info('=== 生成的数据摘要 ===');
$this->command->info('用户数量: ' . User::count());
$this->command->info('分组数量: ' . Group::count());
$this->command->info('文档数量: ' . Document::count());
$this->command->info(' - 全局文档: ' . Document::global()->count());
$this->command->info(' - 专用文档: ' . Document::dedicated()->count());
$this->command->newLine();
$this->command->info('=== 测试账号信息 ===');
$this->command->info('管理员: admin@example.com / password');
$this->command->info('张三(技术部): zhangsan@example.com / password');
$this->command->info('李四(技术部): lisi@example.com / password');
$this->command->info('王五(市场部): wangwu@example.com / password');
$this->command->info('赵六(人力资源部): zhaoliu@example.com / password');
}
}

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
meilisearch:
image: getmeili/meilisearch:v1.5
container_name: knowledge_base_meilisearch
ports:
- "7700:7700"
environment:
- MEILI_MASTER_KEY=your-master-key-change-this-in-production
- MEILI_ENV=development
volumes:
- ./storage/meilisearch:/meili_data
restart: unless-stopped
networks:
- knowledge_base_network
networks:
knowledge_base_network:
driver: bridge

852
docs/API_REFERENCE.md Normal file
View File

@@ -0,0 +1,852 @@
# API 参考文档
本文档描述知识库系统的核心服务类和方法。
## 目录
1. [DocumentService](#documentservice)
2. [DocumentConversionService](#documentconversionservice)
3. [DocumentSearchService](#documentsearchservice)
4. [MarkdownRenderService](#markdownrenderservice)
5. [SecurityLogger](#securitylogger)
---
## DocumentService
文档管理服务,处理文档上传、下载和权限验证。
**位置**: `app/Services/DocumentService.php`
### 方法
#### uploadDocument()
上传并保存文档。
```php
public function uploadDocument(
UploadedFile $file,
string $title,
string $type,
?int $groupId,
int $uploaderId,
?string $description = null
): Document
```
**参数**:
- `$file` - 上传的文件对象
- `$title` - 文档标题
- `$type` - 文档类型('global' 或 'dedicated'
- `$groupId` - 分组 ID专用文档必填
- `$uploaderId` - 上传者用户 ID
- `$description` - 文档描述(可选)
**返回**: `Document` - 创建的文档模型实例
**异常**:
- `ValidationException` - 文件格式无效或专用文档未指定分组
- `Exception` - 文件存储失败
**示例**:
```php
use App\Services\DocumentService;
use Illuminate\Http\UploadedFile;
$service = app(DocumentService::class);
$document = $service->uploadDocument(
file: $request->file('document'),
title: '技术文档',
type: 'global',
groupId: null,
uploaderId: auth()->id(),
description: '系统架构说明'
);
```
#### validateDocumentAccess()
验证用户是否有权访问文档。
```php
public function validateDocumentAccess(Document $document, User $user): bool
```
**参数**:
- `$document` - 文档实例
- `$user` - 用户实例
**返回**: `bool` - 是否有权访问
**示例**:
```php
$hasAccess = $service->validateDocumentAccess($document, auth()->user());
if (!$hasAccess) {
abort(403, '您没有权限访问此文档');
}
```
#### downloadDocument()
下载文档并记录日志。
```php
public function downloadDocument(Document $document, User $user): StreamedResponse
```
**参数**:
- `$document` - 文档实例
- `$user` - 用户实例
**返回**: `StreamedResponse` - 文件流式响应
**异常**:
- `AuthorizationException` - 用户无权下载
- `FileNotFoundException` - 文件不存在
**示例**:
```php
return $service->downloadDocument($document, auth()->user());
```
#### logDownload()
记录文档下载日志。
```php
public function logDownload(Document $document, User $user): void
```
**参数**:
- `$document` - 文档实例
- `$user` - 用户实例
**示例**:
```php
$service->logDownload($document, auth()->user());
```
---
## DocumentConversionService
文档格式转换服务,将 Word 文档转换为 Markdown。
**位置**: `app/Services/DocumentConversionService.php`
### 方法
#### convertToMarkdown()
将 Word 文档转换为 Markdown 格式。
```php
public function convertToMarkdown(Document $document): string
```
**参数**:
- `$document` - 文档实例
**返回**: `string` - Markdown 内容
**异常**:
- `ConversionException` - 转换失败
**示例**:
```php
use App/Services/DocumentConversionService;
$service = app(DocumentConversionService::class);
$markdown = $service->convertToMarkdown($document);
```
#### queueConversion()
将文档转换任务加入队列。
```php
public function queueConversion(Document $document): void
```
**参数**:
- `$document` - 文档实例
**示例**:
```php
$service->queueConversion($document);
```
#### saveMarkdownToFile()
保存 Markdown 内容到文件。
```php
public function saveMarkdownToFile(Document $document, string $markdown): string
```
**参数**:
- `$document` - 文档实例
- `$markdown` - Markdown 内容
**返回**: `string` - 文件存储路径
**示例**:
```php
$path = $service->saveMarkdownToFile($document, $markdown);
```
#### updateDocumentMarkdown()
更新文档的 Markdown 相关字段。
```php
public function updateDocumentMarkdown(Document $document, string $markdownPath): void
```
**参数**:
- `$document` - 文档实例
- `$markdownPath` - Markdown 文件路径
**示例**:
```php
$service->updateDocumentMarkdown($document, $markdownPath);
```
#### handleConversionFailure()
处理转换失败的情况。
```php
public function handleConversionFailure(Document $document, Exception $e): void
```
**参数**:
- `$document` - 文档实例
- `$e` - 异常对象
**示例**:
```php
try {
$markdown = $service->convertToMarkdown($document);
} catch (Exception $e) {
$service->handleConversionFailure($document, $e);
}
```
#### getMarkdownPreview()
获取 Markdown 内容摘要。
```php
public function getMarkdownPreview(string $markdown, int $length = 500): string
```
**参数**:
- `$markdown` - Markdown 内容
- `$length` - 摘要长度(默认 500 字符)
**返回**: `string` - Markdown 摘要
**示例**:
```php
$preview = $service->getMarkdownPreview($markdown, 200);
```
---
## DocumentSearchService
文档搜索服务,提供全文搜索和权限过滤。
**位置**: `app/Services/DocumentSearchService.php`
### 方法
#### search()
搜索文档。
```php
public function search(string $query, User $user, array $filters = []): Collection
```
**参数**:
- `$query` - 搜索关键词
- `$user` - 用户实例
- `$filters` - 筛选条件数组(可选)
- `type`: 文档类型('global' 或 'dedicated'
- `group_id`: 分组 ID
**返回**: `Collection` - 搜索结果集合
**示例**:
```php
use App\Services\DocumentSearchService;
$service = app(DocumentSearchService::class);
$results = $service->search(
query: '技术文档',
user: auth()->user(),
filters: [
'type' => 'global',
]
);
```
#### indexDocument()
将文档索引到 Meilisearch。
```php
public function indexDocument(Document $document): void
```
**参数**:
- `$document` - 文档实例
**示例**:
```php
$service->indexDocument($document);
```
#### updateDocumentIndex()
更新文档索引。
```php
public function updateDocumentIndex(Document $document): void
```
**参数**:
- `$document` - 文档实例
**示例**:
```php
$service->updateDocumentIndex($document);
```
#### removeDocumentFromIndex()
从索引中删除文档。
```php
public function removeDocumentFromIndex(Document $document): void
```
**参数**:
- `$document` - 文档实例
**示例**:
```php
$service->removeDocumentFromIndex($document);
```
#### filterByUserPermissions()
根据用户权限过滤搜索结果。
```php
public function filterByUserPermissions(Collection $results, User $user): Collection
```
**参数**:
- `$results` - 搜索结果集合
- `$user` - 用户实例
**返回**: `Collection` - 过滤后的结果集合
**示例**:
```php
$filteredResults = $service->filterByUserPermissions($results, auth()->user());
```
#### prepareSearchableData()
准备文档的可搜索数据。
```php
public function prepareSearchableData(Document $document): array
```
**参数**:
- `$document` - 文档实例
**返回**: `array` - 可搜索数据数组
**示例**:
```php
$searchableData = $service->prepareSearchableData($document);
```
---
## MarkdownRenderService
Markdown 渲染服务,将 Markdown 转换为 HTML。
**位置**: `app/Services/MarkdownRenderService.php`
### 方法
#### render()
将 Markdown 渲染为 HTML。
```php
public function render(string $markdown): string
```
**参数**:
- `$markdown` - Markdown 内容
**返回**: `string` - 渲染后的 HTML
**示例**:
```php
use App\Services\MarkdownRenderService;
$service = app(MarkdownRenderService::class);
$html = $service->render($markdown);
```
#### sanitize()
清理 HTML 内容,防止 XSS 攻击。
```php
public function sanitize(string $html): string
```
**参数**:
- `$html` - HTML 内容
**返回**: `string` - 清理后的 HTML
**示例**:
```php
$safeHtml = $service->sanitize($html);
```
#### extractPreview()
从 Markdown 中提取摘要。
```php
public function extractPreview(string $markdown, int $length = 200): string
```
**参数**:
- `$markdown` - Markdown 内容
- `$length` - 摘要长度(默认 200 字符)
**返回**: `string` - 摘要文本
**示例**:
```php
$preview = $service->extractPreview($markdown, 150);
```
---
## SecurityLogger
安全日志记录服务。
**位置**: `app/Services/SecurityLogger.php`
### 方法
#### logUnauthorizedAccess()
记录未授权访问尝试。
```php
public function logUnauthorizedAccess(
User $user,
Document $document,
string $action
): void
```
**参数**:
- `$user` - 用户实例
- `$document` - 文档实例
- `$action` - 尝试的操作(如 'view', 'download'
**示例**:
```php
use App\Services\SecurityLogger;
$logger = app(SecurityLogger::class);
$logger->logUnauthorizedAccess(
user: auth()->user(),
document: $document,
action: 'download'
);
```
#### logDocumentAccess()
记录文档访问。
```php
public function logDocumentAccess(
User $user,
Document $document,
string $action
): void
```
**参数**:
- `$user` - 用户实例
- `$document` - 文档实例
- `$action` - 操作类型
**示例**:
```php
$logger->logDocumentAccess(
user: auth()->user(),
document: $document,
action: 'view'
);
```
---
## 模型查询作用域
### Document 模型
#### accessibleBy()
获取用户可访问的文档。
```php
Document::accessibleBy($user)->get();
```
**参数**:
- `$user` - 用户实例
**返回**: 查询构建器
**示例**:
```php
// 获取用户可访问的所有文档
$documents = Document::accessibleBy(auth()->user())->get();
// 结合其他查询条件
$recentDocuments = Document::accessibleBy(auth()->user())
->where('created_at', '>=', now()->subDays(7))
->orderBy('created_at', 'desc')
->get();
```
#### global()
获取全局文档。
```php
Document::global()->get();
```
**返回**: 查询构建器
**示例**:
```php
$globalDocuments = Document::global()->get();
```
#### dedicated()
获取专用文档。
```php
Document::dedicated()->get();
```
**返回**: 查询构建器
**示例**:
```php
$dedicatedDocuments = Document::dedicated()
->where('group_id', $groupId)
->get();
```
---
## 策略方法
### DocumentPolicy
#### view()
检查用户是否可以查看文档。
```php
Gate::allows('view', $document);
```
**参数**:
- `$document` - 文档实例
**返回**: `bool`
**示例**:
```php
if (Gate::allows('view', $document)) {
// 用户可以查看文档
}
// 或使用 authorize 方法
$this->authorize('view', $document);
```
#### download()
检查用户是否可以下载文档。
```php
Gate::allows('download', $document);
```
**参数**:
- `$document` - 文档实例
**返回**: `bool`
**示例**:
```php
if (Gate::denies('download', $document)) {
abort(403, '您没有权限下载此文档');
}
```
---
## 队列任务
### ConvertDocumentToMarkdown
文档转换队列任务。
**位置**: `app/Jobs/ConvertDocumentToMarkdown.php`
**分发任务**:
```php
use App\Jobs\ConvertDocumentToMarkdown;
ConvertDocumentToMarkdown::dispatch($document);
```
**延迟分发**:
```php
ConvertDocumentToMarkdown::dispatch($document)
->delay(now()->addMinutes(5));
```
**指定队列**:
```php
ConvertDocumentToMarkdown::dispatch($document)
->onQueue('documents');
```
---
## 事件和监听器
### 文档事件
系统会在文档生命周期的关键点触发事件:
#### 文档创建后
```php
// 自动触发转换和索引
Document::created(function ($document) {
// 队列转换任务
// 索引到 Meilisearch
});
```
#### 文档更新后
```php
Document::updated(function ($document) {
// 如果文件变更,重新转换
// 更新 Meilisearch 索引
});
```
#### 文档删除后
```php
Document::deleted(function ($document) {
// 删除文件
// 从 Meilisearch 移除索引
});
```
---
## 配置选项
### 文档转换配置
**文件**: `config/documents.php`
```php
return [
'conversion' => [
'driver' => env('DOCUMENT_CONVERSION_DRIVER', 'pandoc'),
'pandoc_path' => env('PANDOC_PATH', '/usr/bin/pandoc'),
'timeout' => env('CONVERSION_TIMEOUT', 300),
'queue' => 'documents',
],
'markdown' => [
'renderer' => 'commonmark',
'sanitize' => true,
],
];
```
### Scout 配置
**文件**: `config/scout.php`
```php
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://127.0.0.1:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
'documents' => [
'filterableAttributes' => ['type', 'group_id', 'uploaded_by'],
'sortableAttributes' => ['created_at', 'title'],
'searchableAttributes' => ['title', 'description', 'markdown_content'],
],
],
],
```
---
## 错误处理
### 常见异常
#### ValidationException
文件格式验证失败或必填字段缺失。
```php
try {
$document = $service->uploadDocument(...);
} catch (ValidationException $e) {
return back()->withErrors($e->errors());
}
```
#### AuthorizationException
用户无权执行操作。
```php
try {
$this->authorize('download', $document);
} catch (AuthorizationException $e) {
abort(403, '您没有权限下载此文档');
}
```
#### FileNotFoundException
文件不存在。
```php
try {
return $service->downloadDocument($document, $user);
} catch (FileNotFoundException $e) {
abort(404, '文件不存在');
}
```
#### ConversionException
文档转换失败。
```php
try {
$markdown = $service->convertToMarkdown($document);
} catch (ConversionException $e) {
Log::error('文档转换失败', [
'document_id' => $document->id,
'error' => $e->getMessage(),
]);
}
```
---
## 最佳实践
### 1. 使用依赖注入
```php
class DocumentController extends Controller
{
public function __construct(
private DocumentService $documentService,
private MarkdownRenderService $markdownService
) {}
public function preview(Document $document)
{
$this->authorize('view', $document);
$markdown = $document->getMarkdownContent();
$html = $this->markdownService->render($markdown);
return view('documents.preview', compact('html', 'document'));
}
}
```
### 2. 使用事务处理
```php
DB::transaction(function () use ($file, $data) {
$document = $this->documentService->uploadDocument(
file: $file,
title: $data['title'],
type: $data['type'],
groupId: $data['group_id'] ?? null,
uploaderId: auth()->id()
);
// 其他相关操作
});
```
### 3. 使用查询作用域
```php
// 好的做法
$documents = Document::accessibleBy(auth()->user())
->where('type', 'global')
->latest()
->paginate(20);
// 避免手动实现权限过滤
```
### 4. 异步处理耗时操作
```php
// 文档转换应该异步处理
ConvertDocumentToMarkdown::dispatch($document);
// 而不是同步执行
// $service->convertToMarkdown($document); // 避免
```
---
**最后更新**2025-12-05

816
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,816 @@
# 知识库系统部署指南
本文档提供知识库系统的详细部署步骤和配置说明。
## 目录
1. [服务器要求](#服务器要求)
2. [安装依赖](#安装依赖)
3. [项目部署](#项目部署)
4. [配置服务](#配置服务)
5. [性能优化](#性能优化)
6. [监控和维护](#监控和维护)
7. [故障排除](#故障排除)
## 服务器要求
### 最低配置
- **CPU**: 2 核心
- **内存**: 4GB RAM
- **存储**: 50GB SSD
- **操作系统**: Ubuntu 20.04+ / CentOS 8+ / Debian 11+
### 推荐配置
- **CPU**: 4 核心
- **内存**: 8GB RAM
- **存储**: 100GB SSD
- **操作系统**: Ubuntu 22.04 LTS
### 软件要求
- PHP 8.1 或更高版本
- MySQL 8.0+ 或 PostgreSQL 13+
- Redis 6.0+
- Nginx 1.18+ 或 Apache 2.4+
- Composer 2.x
- Node.js 18+ 和 npm
- Meilisearch 1.5+
- Pandoc 2.x+(用于文档转换)
- Supervisor用于管理队列进程
## 安装依赖
### 1. 安装 PHP 和扩展
#### Ubuntu/Debian
```bash
sudo apt update
sudo apt install -y php8.1 php8.1-fpm php8.1-cli php8.1-common \
php8.1-mysql php8.1-pgsql php8.1-redis php8.1-xml php8.1-mbstring \
php8.1-curl php8.1-zip php8.1-gd php8.1-intl php8.1-bcmath
```
#### CentOS/RHEL
```bash
sudo dnf install -y php php-fpm php-cli php-common php-mysqlnd \
php-pgsql php-redis php-xml php-mbstring php-curl php-zip \
php-gd php-intl php-bcmath
```
### 2. 安装 Composer
```bash
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
```
### 3. 安装 Node.js 和 npm
```bash
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs
```
### 4. 安装 MySQL
```bash
sudo apt install -y mysql-server
sudo mysql_secure_installation
```
创建数据库和用户:
```sql
CREATE DATABASE knowledge_base CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'kb_user'@'localhost' IDENTIFIED BY 'your_secure_password';
GRANT ALL PRIVILEGES ON knowledge_base.* TO 'kb_user'@'localhost';
FLUSH PRIVILEGES;
```
### 5. 安装 Redis
```bash
sudo apt install -y redis-server
sudo systemctl enable redis-server
sudo systemctl start redis-server
```
### 6. 安装 Meilisearch
#### 使用 Docker推荐
```bash
docker run -d \
--name meilisearch \
-p 7700:7700 \
-v $(pwd)/storage/meilisearch:/meili_data \
-e MEILI_MASTER_KEY='your_master_key_here' \
getmeili/meilisearch:v1.5
```
#### 直接安装
```bash
curl -L https://install.meilisearch.com | sh
sudo mv ./meilisearch /usr/local/bin/
```
创建 systemd 服务:
```bash
sudo nano /etc/systemd/system/meilisearch.service
```
内容:
```ini
[Unit]
Description=Meilisearch
After=network.target
[Service]
Type=simple
User=www-data
ExecStart=/usr/local/bin/meilisearch --master-key="your_master_key_here" --db-path=/var/lib/meilisearch/data
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
启动服务:
```bash
sudo systemctl daemon-reload
sudo systemctl enable meilisearch
sudo systemctl start meilisearch
```
### 7. 安装 Pandoc
```bash
sudo apt install -y pandoc
```
或下载最新版本:
```bash
wget https://github.com/jgm/pandoc/releases/download/3.1.11/pandoc-3.1.11-1-amd64.deb
sudo dpkg -i pandoc-3.1.11-1-amd64.deb
```
### 8. 安装 Supervisor
```bash
sudo apt install -y supervisor
sudo systemctl enable supervisor
sudo systemctl start supervisor
```
## 项目部署
### 1. 克隆项目
```bash
cd /var/www
sudo git clone <repository-url> knowledge-base
cd knowledge-base
```
### 2. 设置权限
```bash
sudo chown -R www-data:www-data /var/www/knowledge-base
sudo chmod -R 755 /var/www/knowledge-base
sudo chmod -R 775 /var/www/knowledge-base/storage
sudo chmod -R 775 /var/www/knowledge-base/bootstrap/cache
```
### 3. 安装依赖
```bash
# PHP 依赖
composer install --no-dev --optimize-autoloader
# 前端依赖
npm install
npm run build
```
### 4. 配置环境变量
```bash
cp .env.example .env
nano .env
```
配置以下关键参数:
```env
APP_NAME="知识库系统"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://your-domain.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=knowledge_base
DB_USERNAME=kb_user
DB_PASSWORD=your_secure_password
CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your_master_key_here
SCOUT_DRIVER=meilisearch
DOCUMENT_CONVERSION_DRIVER=pandoc
PANDOC_PATH=/usr/bin/pandoc
CONVERSION_TIMEOUT=300
FILESYSTEM_DISK=local
```
### 5. 生成应用密钥
```bash
php artisan key:generate
```
### 6. 运行数据库迁移
```bash
php artisan migrate --force
```
### 7. 创建存储目录
```bash
mkdir -p storage/app/private/documents
mkdir -p storage/app/private/markdown
sudo chown -R www-data:www-data storage/app/private
sudo chmod -R 775 storage/app/private
```
### 8. 优化应用
```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan filament:optimize
```
### 9. 创建管理员用户
```bash
php artisan make:filament-user
```
## 配置服务
### 1. 配置 Nginx
创建站点配置:
```bash
sudo nano /etc/nginx/sites-available/knowledge-base
```
内容:
```nginx
server {
listen 80;
listen [::]:80;
server_name your-domain.com;
root /var/www/knowledge-base/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
# 文件上传大小限制
client_max_body_size 50M;
}
```
启用站点:
```bash
sudo ln -s /etc/nginx/sites-available/knowledge-base /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 2. 配置 SSL使用 Let's Encrypt
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com
```
### 3. 配置队列工作进程
创建 Supervisor 配置:
```bash
sudo nano /etc/supervisor/conf.d/knowledge-base-worker.conf
```
内容:
```ini
[program:knowledge-base-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/knowledge-base/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/knowledge-base/storage/logs/worker.log
stopwaitsecs=3600
```
重新加载 Supervisor
```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start knowledge-base-worker:*
```
### 4. 配置定时任务
编辑 crontab
```bash
sudo crontab -e -u www-data
```
添加:
```cron
* * * * * cd /var/www/knowledge-base && php artisan schedule:run >> /dev/null 2>&1
```
## 性能优化
### 1. PHP-FPM 优化
编辑 PHP-FPM 配置:
```bash
sudo nano /etc/php/8.1/fpm/pool.d/www.conf
```
调整参数:
```ini
pm = dynamic
pm.max_children = 50
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500
```
重启 PHP-FPM
```bash
sudo systemctl restart php8.1-fpm
```
### 2. Redis 优化
编辑 Redis 配置:
```bash
sudo nano /etc/redis/redis.conf
```
调整参数:
```conf
maxmemory 2gb
maxmemory-policy allkeys-lru
```
重启 Redis
```bash
sudo systemctl restart redis-server
```
### 3. MySQL 优化
编辑 MySQL 配置:
```bash
sudo nano /etc/mysql/mysql.conf.d/mysqld.cnf
```
添加优化参数:
```ini
[mysqld]
innodb_buffer_pool_size = 2G
innodb_log_file_size = 256M
innodb_flush_log_at_trx_commit = 2
innodb_flush_method = O_DIRECT
query_cache_size = 0
query_cache_type = 0
```
重启 MySQL
```bash
sudo systemctl restart mysql
```
### 4. Opcache 配置
编辑 PHP 配置:
```bash
sudo nano /etc/php/8.1/fpm/conf.d/10-opcache.ini
```
内容:
```ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.revalidate_freq=60
opcache.fast_shutdown=1
```
## 监控和维护
### 1. 日志监控
查看应用日志:
```bash
tail -f /var/www/knowledge-base/storage/logs/laravel.log
```
查看队列工作进程日志:
```bash
tail -f /var/www/knowledge-base/storage/logs/worker.log
```
查看 Nginx 日志:
```bash
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log
```
### 2. 定期备份
#### 数据库备份
创建备份脚本:
```bash
sudo nano /usr/local/bin/backup-kb-db.sh
```
内容:
```bash
#!/bin/bash
BACKUP_DIR="/var/backups/knowledge-base"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
mysqldump -u kb_user -p'your_secure_password' knowledge_base | gzip > $BACKUP_DIR/db_$DATE.sql.gz
# 保留最近 7 天的备份
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete
```
设置权限并添加到 crontab
```bash
sudo chmod +x /usr/local/bin/backup-kb-db.sh
sudo crontab -e
```
添加每日备份任务:
```cron
0 2 * * * /usr/local/bin/backup-kb-db.sh
```
#### 文件备份
```bash
sudo nano /usr/local/bin/backup-kb-files.sh
```
内容:
```bash
#!/bin/bash
BACKUP_DIR="/var/backups/knowledge-base"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR
tar -czf $BACKUP_DIR/files_$DATE.tar.gz \
/var/www/knowledge-base/storage/app/private/documents \
/var/www/knowledge-base/storage/app/private/markdown
# 保留最近 7 天的备份
find $BACKUP_DIR -name "files_*.tar.gz" -mtime +7 -delete
```
### 3. 系统监控
安装监控工具:
```bash
sudo apt install -y htop iotop nethogs
```
监控系统资源:
```bash
# CPU 和内存
htop
# 磁盘 I/O
iotop
# 网络流量
nethogs
```
### 4. 应用健康检查
创建健康检查脚本:
```bash
sudo nano /usr/local/bin/check-kb-health.sh
```
内容:
```bash
#!/bin/bash
# 检查 Web 服务
if ! curl -f http://localhost > /dev/null 2>&1; then
echo "Web service is down!"
sudo systemctl restart nginx
fi
# 检查队列工作进程
if ! sudo supervisorctl status knowledge-base-worker:* | grep RUNNING > /dev/null; then
echo "Queue worker is down!"
sudo supervisorctl restart knowledge-base-worker:*
fi
# 检查 Meilisearch
if ! curl -f http://localhost:7700/health > /dev/null 2>&1; then
echo "Meilisearch is down!"
sudo systemctl restart meilisearch
fi
```
添加到 crontab每 5 分钟检查一次):
```cron
*/5 * * * * /usr/local/bin/check-kb-health.sh
```
## 故障排除
### 1. 文件上传失败
检查 PHP 配置:
```bash
php -i | grep upload_max_filesize
php -i | grep post_max_size
```
如需调整,编辑 PHP 配置:
```bash
sudo nano /etc/php/8.1/fpm/php.ini
```
修改:
```ini
upload_max_filesize = 50M
post_max_size = 50M
```
重启 PHP-FPM
```bash
sudo systemctl restart php8.1-fpm
```
### 2. 队列任务不执行
检查队列工作进程状态:
```bash
sudo supervisorctl status knowledge-base-worker:*
```
重启工作进程:
```bash
sudo supervisorctl restart knowledge-base-worker:*
```
查看队列日志:
```bash
tail -f /var/www/knowledge-base/storage/logs/worker.log
```
### 3. Meilisearch 连接失败
检查 Meilisearch 状态:
```bash
curl http://localhost:7700/health
```
检查 Meilisearch 日志:
```bash
sudo journalctl -u meilisearch -f
```
重启 Meilisearch
```bash
sudo systemctl restart meilisearch
```
### 4. 文档转换失败
检查 Pandoc 是否安装:
```bash
which pandoc
pandoc --version
```
检查转换日志:
```bash
grep "conversion" /var/www/knowledge-base/storage/logs/laravel.log
```
手动测试转换:
```bash
pandoc test.docx -o test.md
```
### 5. 权限问题
重置存储目录权限:
```bash
cd /var/www/knowledge-base
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache
```
### 6. 缓存问题
清除所有缓存:
```bash
cd /var/www/knowledge-base
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear
```
重新生成缓存:
```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
```
## 更新部署
### 1. 拉取最新代码
```bash
cd /var/www/knowledge-base
sudo -u www-data git pull origin main
```
### 2. 更新依赖
```bash
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data npm install
sudo -u www-data npm run build
```
### 3. 运行迁移
```bash
php artisan migrate --force
```
### 4. 清除和重建缓存
```bash
php artisan cache:clear
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan filament:optimize
```
### 5. 重启服务
```bash
sudo systemctl reload php8.1-fpm
sudo systemctl reload nginx
sudo supervisorctl restart knowledge-base-worker:*
```
## 安全建议
1. **定期更新系统和软件包**
```bash
sudo apt update && sudo apt upgrade -y
```
2. **配置防火墙**
```bash
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
3. **禁用不必要的 PHP 函数**
编辑 `php.ini`,添加:
```ini
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
```
4. **设置强密码策略**
5. **定期审查安全日志**
6. **启用 HTTPS**
7. **限制文件上传类型**
8. **定期备份数据**
## 联系支持
如遇到部署问题,请联系技术支持团队。
---
**最后更新**2025-12-05

View File

@@ -0,0 +1,171 @@
# 文档转换服务使用指南
## 概述
DocumentConversionService 负责将上传的 Word 文档(.doc/.docx转换为 Markdown 格式。转换过程是异步的,通过队列系统处理。
## 功能特性
1. **自动转换**: 文档上传后自动触发转换
2. **异步处理**: 使用队列系统,不阻塞用户操作
3. **错误处理**: 转换失败时记录错误,但不影响文档的基本功能
4. **重试机制**: 转换失败后自动重试(默认 3 次)
5. **预览生成**: 自动生成 Markdown 内容的预览(前 500 字符)
## 使用方法
### 1. 队列转换(推荐)
```php
use App\Services\DocumentConversionService;
use App\Models\Document;
$document = Document::find($id);
$conversionService = app(DocumentConversionService::class);
// 将转换任务加入队列
$conversionService->queueConversion($document);
```
### 2. 同步转换(不推荐用于生产环境)
```php
use App\Services\DocumentConversionService;
use App\Models\Document;
$document = Document::find($id);
$conversionService = app(DocumentConversionService::class);
try {
// 转换文档
$markdown = $conversionService->convertToMarkdown($document);
// 保存 Markdown 文件
$markdownPath = $conversionService->saveMarkdownToFile($document, $markdown);
// 更新文档信息
$conversionService->updateDocumentMarkdown($document, $markdownPath);
} catch (\Exception $e) {
// 处理转换失败
$conversionService->handleConversionFailure($document, $e);
}
```
## 转换状态
文档的 `conversion_status` 字段有以下几种状态:
- `pending`: 等待转换
- `processing`: 正在转换
- `completed`: 转换完成
- `failed`: 转换失败
## 配置
转换相关的配置在 `config/documents.php` 文件中:
```php
'conversion' => [
'driver' => 'pandoc', // 转换驱动
'pandoc_path' => '/usr/local/bin/pandoc', // Pandoc 路径
'timeout' => 300, // 超时时间(秒)
'queue' => 'documents', // 队列名称
'retry_times' => 3, // 重试次数
'retry_delay' => 60, // 重试延迟(秒)
],
```
## 队列工作进程
确保队列工作进程正在运行:
```bash
# 启动队列工作进程
php artisan queue:work --queue=documents
# 或者使用 Supervisor 管理队列进程
```
## 依赖要求
### Pandoc
系统需要安装 Pandoc 才能进行文档转换:
**macOS:**
```bash
brew install pandoc
```
**Ubuntu/Debian:**
```bash
sudo apt-get install pandoc
```
**验证安装:**
```bash
pandoc --version
```
## 错误处理
转换失败时,系统会:
1. 记录详细的错误日志
2. 更新文档的 `conversion_status``failed`
3. 在 `conversion_error` 字段中保存错误信息
4. 保留原始 Word 文档,用户仍可下载
## 监控和调试
### 查看转换日志
```bash
tail -f storage/logs/laravel.log | grep "文档转换"
```
### 查看队列任务
```bash
php artisan queue:failed
```
### 重试失败的任务
```bash
# 重试所有失败的任务
php artisan queue:retry all
# 重试特定任务
php artisan queue:retry {job-id}
```
## 性能优化
1. **使用 Redis 队列**: 比数据库队列更快
2. **增加队列工作进程**: 并行处理多个转换任务
3. **调整超时时间**: 根据文档大小调整 `timeout` 配置
4. **监控队列长度**: 避免队列积压
## 故障排查
### 转换一直处于 processing 状态
- 检查队列工作进程是否运行
- 检查 Pandoc 是否正确安装
- 查看错误日志
### 转换失败
- 检查 Pandoc 路径配置是否正确
- 检查文档文件是否存在
- 检查文档文件是否损坏
- 查看 `conversion_error` 字段的错误信息
### 队列任务不执行
- 确认队列连接配置正确Redis/Database
- 确认队列工作进程正在运行
- 检查队列名称是否匹配

View File

@@ -0,0 +1,146 @@
# 文档预览功能使用指南
## 功能概述
知识库系统现在支持在线预览 Word 文档(.doc 和 .docx 格式)。用户可以在文档查看页面直接查看文档内容,无需下载。
## 技术实现
### 核心组件
1. **DocumentPreviewService** (`app/Services/DocumentPreviewService.php`)
- 负责将 Word 文档转换为 HTML 格式
- 使用 PHPWord 库进行文档解析
- 提供文档预览能力检查
2. **ViewDocument 页面** (`app/Filament/Resources/DocumentResource/Pages/ViewDocument.php`)
- 使用 Filament Infolist 显示文档信息
- 集成文档预览组件
- 提供下载按钮
3. **预览视图** (`resources/views/filament/resources/document/preview.blade.php`)
- 渲染文档预览 HTML
- 处理错误和不支持的格式
- 提供友好的用户界面
### 依赖库
- **phpoffice/phpword**: 用于读取和转换 Word 文档
## 功能特性
### 1. 文档信息展示
在查看页面顶部显示:
- 文档标题
- 文档描述
- 文档类型(全局/专用知识库)
- 所属分组(专用文档)
- 上传者
- 文件名
- 文件大小
- 上传时间
- 更新时间
### 2. 文档预览
- **支持格式**: .doc, .docx
- **预览内容**:
- 文本内容
- 基本格式(标题、段落、列表等)
- 表格
- 图片(如果有)
- **最大高度**: 600px超出部分可滚动查看
### 3. 错误处理
系统会优雅地处理以下情况:
- 文档文件不存在
- 文档格式不支持预览
- 文档转换失败
### 4. 下载功能
- 在页面顶部提供"下载文档"按钮
- 自动记录下载日志
- 验证用户权限
## 使用方法
### 查看文档
1. 登录系统
2. 进入"文档管理"
3. 点击任意文档的"查看"按钮
4. 系统会自动显示文档信息和预览
### 下载文档
1. 在文档查看页面
2. 点击顶部的"下载文档"按钮
3. 文档会自动下载到本地
## API 方法
### DocumentPreviewService
```php
// 检查文档是否可以预览
$canPreview = $previewService->canPreview($document);
// 将文档转换为 HTML
$htmlContent = $previewService->convertToHtml($document);
// 提取文档纯文本(用于搜索等)
$text = $previewService->extractText($document);
```
## 性能考虑
1. **首次加载**: 文档转换可能需要几秒钟,取决于文档大小和复杂度
2. **缓存**: 目前未实现缓存,每次查看都会重新转换
3. **大文件**: 建议对大文件(>10MB提示用户下载查看
## 未来改进
1. **缓存机制**: 缓存转换后的 HTML提高加载速度
2. **更多格式**: 支持 PDF、Excel 等格式
3. **全文搜索**: 利用提取的文本内容实现全文搜索
4. **在线编辑**: 支持在线编辑文档内容
5. **版本控制**: 支持文档版本管理
## 故障排除
### 预览失败
如果预览失败,可能的原因:
1. 文档文件损坏
2. 文档格式不标准
3. PHPWord 库不支持某些特殊格式
解决方法:
- 检查文档是否可以在本地打开
- 尝试重新上传文档
- 使用下载功能获取原始文档
### 格式显示异常
预览版本可能与原始格式略有差异,这是正常现象。如需查看完整格式,请下载文档。
## 安全考虑
1. **权限验证**: 只有有权限的用户才能查看文档
2. **文件隔离**: 文档存储在私有目录,不能直接访问
3. **XSS 防护**: HTML 内容经过清理,防止 XSS 攻击
## 测试
运行测试:
```bash
php artisan test --filter=DocumentPreviewServiceTest
```
测试覆盖:
- 文档格式检查
- 文档不存在时的错误处理
- HTML 转换功能

View File

@@ -0,0 +1,192 @@
# 文档搜索功能指南
## 概述
文档搜索功能使用 Laravel Scout 和 Meilisearch 提供强大的全文搜索能力。系统会自动将转换完成的文档索引到 Meilisearch用户可以通过关键词快速检索文档内容。
## 核心组件
### DocumentSearchService
文档搜索服务类,提供以下功能:
1. **search()** - 执行全文搜索
- 搜索文档标题、描述和 Markdown 内容
- 支持额外的筛选条件(类型、分组、上传者)
- 自动应用用户权限过滤
2. **filterByUserPermissions()** - 权限过滤
- 确保用户只能看到有权限访问的文档
- 全局文档对所有用户可见
- 专用文档只对所属分组的用户可见
3. **prepareSearchableData()** - 准备索引数据
- 包含完整的 Markdown 内容用于搜索
- 包含文档元数据(标题、描述、类型、分组等)
4. **indexDocument()** - 索引文档
- 将文档添加到 Meilisearch 索引
- 只索引转换完成的文档
5. **updateDocumentIndex()** - 更新索引
- 更新已索引文档的信息
- 如果文档不应被索引,则移除索引
6. **removeDocumentFromIndex()** - 移除索引
- 从 Meilisearch 中删除文档索引
### DocumentObserver
文档观察者,自动管理文档的搜索索引:
- **created** - 文档创建时不立即索引(等待转换完成)
- **updated** - 文档更新时检查转换状态并更新索引
- 当 conversion_status 变为 'completed' 时自动索引
- 当其他重要字段更新时也更新索引
- **deleted** - 文档删除时移除索引
- **restored** - 文档恢复时重新索引
- **forceDeleted** - 强制删除时移除索引
## 工作流程
### 文档索引流程
1. 用户上传 Word 文档
2. 文档保存到数据库conversion_status 设置为 'pending'
3. 转换任务加入队列
4. 转换完成后updateDocumentMarkdown() 将 conversion_status 更新为 'completed'
5. DocumentObserver 监听到 updated 事件
6. 自动调用 DocumentSearchService::indexDocument() 创建索引
7. 文档可以被搜索
### 搜索流程
1. 用户输入搜索关键词
2. 调用 DocumentSearchService::search()
3. 使用 Scout 在 Meilisearch 中搜索
4. 应用额外的筛选条件
5. 使用 filterByUserPermissions() 过滤结果
6. 返回用户有权限访问的文档列表
## 配置
### Meilisearch 配置
`config/scout.php` 中配置:
```php
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
'documents' => [
'filterableAttributes' => ['type', 'group_id', 'uploaded_by', 'conversion_status'],
'sortableAttributes' => ['created_at', 'title', 'updated_at'],
'searchableAttributes' => ['title', 'description', 'markdown_content'],
],
],
],
```
### 环境变量
`.env` 文件中配置:
```env
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-master-key
```
## 使用示例
### 基本搜索
```php
use App\Services\DocumentSearchService;
$searchService = app(DocumentSearchService::class);
$user = auth()->user();
// 搜索关键词
$results = $searchService->search('Laravel', $user);
// 带筛选条件的搜索
$results = $searchService->search('Laravel', $user, [
'type' => 'global',
'group_id' => 1,
]);
```
### 手动索引管理
```php
use App\Services\DocumentSearchService;
use App\Models\Document;
$searchService = app(DocumentSearchService::class);
$document = Document::find(1);
// 索引文档
$searchService->indexDocument($document);
// 更新索引
$searchService->updateDocumentIndex($document);
// 移除索引
$searchService->removeDocumentFromIndex($document);
```
### 批量重建索引
```php
// 重建所有文档的索引
php artisan scout:import "App\Models\Document"
// 清空索引
php artisan scout:flush "App\Models\Document"
```
## 错误处理
所有索引操作都包含错误处理:
- 索引失败不会影响文档的正常保存和使用
- 所有错误都会记录到日志中
- 搜索失败时返回空集合
## 性能考虑
1. **异步索引** - 文档转换和索引都在队列中异步处理
2. **权限过滤** - 在应用层过滤搜索结果,确保数据安全
3. **缓存策略** - 可以缓存热门搜索结果(待实现)
4. **分页** - 搜索结果应该分页显示(在前端实现)
## 故障排查
### 文档无法被搜索
1. 检查 conversion_status 是否为 'completed'
2. 检查 Meilisearch 服务是否运行
3. 检查文档是否已索引:`php artisan scout:import "App\Models\Document"`
4. 查看日志文件中的错误信息
### Meilisearch 连接失败
1. 确认 Meilisearch 服务正在运行
2. 检查 .env 中的 MEILISEARCH_HOST 和 MEILISEARCH_KEY
3. 测试连接:`curl http://localhost:7700/health`
### 搜索结果为空
1. 确认文档已完成转换
2. 确认用户有权限访问文档
3. 检查搜索关键词是否正确
4. 查看 Meilisearch 日志
## 下一步
- 实现搜索页面 UI任务 25
- 添加搜索结果高亮显示
- 实现搜索建议和自动补全
- 添加高级搜索语法支持

View File

@@ -0,0 +1,261 @@
# 文件名保留功能说明
## 功能概述
知识库系统现在会完整保留用户上传文档时的原始文件名,包括中文、特殊字符等。下载文档时,文件名将与上传时保持一致。
## 实现细节
### 1. Filament 表单配置
`DocumentResource` 的文件上传字段中添加了 `preserveFilenames()` 方法:
```php
Forms\Components\FileUpload::make('file')
->label('文档文件')
->required()
->acceptedFileTypes([...])
->preserveFilenames() // 保留原始文件名
->disk('local')
->directory('documents/' . date('Y/m/d'))
// ...
```
### 2. 文件上传处理
#### CreateDocument 页面
```php
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['file'])) {
$filePath = $data['file'];
$originalFileName = basename($filePath); // 获取原始文件名
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
// ...
}
return $data;
}
```
#### EditDocument 页面
```php
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['file']) && $data['file'] !== $this->record->file_path) {
$filePath = $data['file'];
$originalFileName = basename($filePath); // 获取原始文件名
$data['file_path'] = $filePath;
$data['file_name'] = $originalFileName; // 保存原始文件名
// ...
}
return $data;
}
```
### 3. DocumentService 更新
#### 上传方法
```php
public function uploadDocument(...): Document
{
return DB::transaction(function () use (...) {
// 获取原始文件名
$originalFileName = $file->getClientOriginalName();
// 使用 storeAs 保存文件,保留原始文件名
$directory = 'documents/' . date('Y/m/d');
$filePath = $file->storeAs($directory, $originalFileName, 'local');
// 保存到数据库
$document = Document::create([
'file_path' => $filePath,
'file_name' => $originalFileName, // 原始文件名
// ...
]);
return $document;
});
}
```
#### 下载方法
```php
public function downloadDocument(Document $document, User $user): StreamedResponse
{
// 使用原始文件名作为下载文件名
return Storage::disk('local')->download(
$document->file_path,
$document->file_name // 原始文件名
);
}
```
## 支持的文件名格式
### ✅ 完全支持
1. **中文文件名**
- 示例:`知识库管理系统需求文档.docx`
- 上传后保留:✅
- 下载时保留:✅
2. **英文文件名**
- 示例:`Requirements Document.docx`
- 上传后保留:✅
- 下载时保留:✅
3. **数字文件名**
- 示例:`2024-Report.docx`
- 上传后保留:✅
- 下载时保留:✅
4. **特殊字符文件名**
- 示例:`文档(2024-01-01)_v1.0.docx`
- 支持的特殊字符:`()[]_-`
- 上传后保留:✅
- 下载时保留:✅
5. **混合格式文件名**
- 示例:`Project_项目文档_2024.docx`
- 上传后保留:✅
- 下载时保留:✅
## 文件存储结构
文件按日期组织存储:
```
storage/app/
└── documents/
└── 2024/
└── 12/
└── 04/
├── 知识库管理系统需求文档.docx
├── Requirements Document.docx
└── 文档(2024-01-01)_v1.0.docx
```
## 数据库字段
### documents 表
- `file_path`: 存储相对路径(如:`documents/2024/12/04/知识库管理系统需求文档.docx`
- `file_name`: 存储原始文件名(如:`知识库管理系统需求文档.docx`
- `file_size`: 文件大小(字节)
- `mime_type`: MIME 类型
## 浏览器兼容性
### 下载文件名编码
对于包含非 ASCII 字符(如中文)的文件名,系统会自动处理编码:
```
Content-Disposition: attachment; filename=document.docx; filename*=utf-8''%E7%9F%A5%E8%AF%86%E5%BA%93.docx
```
- `filename`: ASCII 兼容的后备文件名
- `filename*`: UTF-8 编码的完整文件名RFC 5987
### 支持的浏览器
- ✅ Chrome 80+
- ✅ Firefox 75+
- ✅ Safari 13+
- ✅ Edge 80+
- ✅ 移动浏览器iOS Safari, Chrome Mobile
## 测试覆盖
### 测试文件:`tests/Feature/DocumentFileNameTest.php`
1. **test_上传文档时保留原始文件名**
- 验证上传后 `file_name` 字段正确保存
2. **test_下载文档时使用原始文件名**
- 验证下载响应头包含原始文件名
3. **test_中文文件名正确处理**
- 验证中文文件名的完整支持
4. **test_特殊字符文件名正确处理**
- 验证特殊字符的正确处理
### 运行测试
```bash
php artisan test --filter=DocumentFileNameTest
```
## 注意事项
### 1. 文件名冲突
如果同一天上传了同名文件,后上传的文件会覆盖先上传的文件。建议:
- 用户在上传前检查文件名
- 或者在文件名中添加时间戳或版本号
### 2. 文件名长度限制
- 数据库字段 `file_name` 限制255 字符
- 文件系统限制:通常为 255 字节
- 建议文件名不超过 100 个字符
### 3. 特殊字符限制
某些特殊字符可能在不同操作系统上有限制:
- Windows: 不支持 `< > : " / \ | ? *`
- Linux/Mac: 不支持 `/`
系统会自动处理这些字符,但建议避免使用。
## 安全考虑
### 1. 路径遍历攻击防护
- 使用 `basename()` 提取文件名,防止路径遍历
- 文件存储在受控目录中
### 2. 文件名注入防护
- Laravel Storage 自动处理文件名转义
- 数据库使用参数化查询
### 3. XSS 防护
- 文件名在显示时会被 Blade 模板自动转义
- 下载响应头使用标准编码
## 未来改进
1. **文件名冲突处理**
- 自动添加序号:`文档.docx``文档(1).docx`
- 或添加时间戳:`文档.docx``文档_20240104_143022.docx`
2. **文件名验证**
- 添加文件名格式验证
- 限制特殊字符使用
- 提供文件名建议
3. **批量上传**
- 支持批量上传多个文件
- 自动处理文件名冲突
4. **文件名搜索**
- 支持按文件名搜索文档
- 支持模糊匹配
## 总结
文件名保留功能确保了用户上传和下载文档时的一致性体验,特别是对中文文件名的完整支持,使得知识库系统更加符合中文用户的使用习惯。
所有功能都经过完整测试,确保在各种场景下都能正常工作。

258
docs/MEILISEARCH_SETUP.md Normal file
View File

@@ -0,0 +1,258 @@
# Meilisearch 安装和配置指南
## 概述
本项目使用 Meilisearch 作为全文搜索引擎,为文档内容提供快速准确的搜索功能。
## 安装方式
### 方式 1使用 Docker推荐
项目已经配置了 `docker-compose.yml` 文件,可以快速启动 Meilisearch 服务。
#### 启动服务
```bash
# 启动 Meilisearch 服务
docker-compose up -d meilisearch
# 查看服务状态
docker-compose ps
# 查看服务日志
docker-compose logs -f meilisearch
```
#### 停止服务
```bash
# 停止服务
docker-compose down
# 停止服务并删除数据卷
docker-compose down -v
```
### 方式 2本地安装macOS
使用 Homebrew 安装:
```bash
# 安装 Meilisearch
brew install meilisearch
# 启动服务
meilisearch --master-key="your-master-key-change-this-in-production"
```
### 方式 3本地安装Linux
```bash
# 下载 Meilisearch
curl -L https://install.meilisearch.com | sh
# 启动服务
./meilisearch --master-key="your-master-key-change-this-in-production"
```
### 方式 4本地安装Windows
从 [Meilisearch 官方网站](https://www.meilisearch.com/docs/learn/getting_started/installation) 下载 Windows 版本,然后运行:
```powershell
.\meilisearch.exe --master-key="your-master-key-change-this-in-production"
```
## Laravel Scout 安装
本项目使用 Laravel Scout 作为搜索抽象层。
### 安装依赖包
```bash
# 安装 Laravel Scout
composer require laravel/scout
# 安装 Meilisearch PHP SDK
composer require meilisearch/meilisearch-php http-interop/http-factory-guzzle
# 发布 Scout 配置文件
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
```
安装完成后,会在 `config/scout.php` 中生成配置文件。
## 配置
### 主密钥Master Key
**重要**:在生产环境中,必须更改默认的主密钥!
1. 在 `docker-compose.yml` 中修改 `MEILI_MASTER_KEY` 环境变量
2. 在 `.env` 文件中更新 `MEILISEARCH_KEY` 配置
### 环境变量
`.env` 文件中配置以下变量:
```env
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-master-key-change-this-in-production
```
### Scout 索引配置
`config/scout.php` 中已配置 documents 索引的设置:
```php
'meilisearch' => [
'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'),
'key' => env('MEILISEARCH_KEY'),
'index-settings' => [
'documents' => [
'filterableAttributes' => ['type', 'group_id', 'uploaded_by', 'conversion_status'],
'sortableAttributes' => ['created_at', 'title', 'updated_at'],
'searchableAttributes' => ['title', 'description', 'markdown_content'],
'displayedAttributes' => ['id', 'title', 'description', 'type', 'group_id', 'uploaded_by', 'created_at', 'updated_at'],
],
],
],
```
**配置说明**
- `filterableAttributes`: 可用于筛选的字段(类型、分组、上传者、转换状态)
- `sortableAttributes`: 可用于排序的字段(创建时间、标题、更新时间)
- `searchableAttributes`: 可搜索的字段标题、描述、Markdown 内容)
- `displayedAttributes`: 搜索结果中返回的字段
## 验证安装
访问 Meilisearch 管理界面:
```
http://localhost:7700
```
或使用 curl 测试:
```bash
curl -X GET 'http://localhost:7700/health'
```
应该返回:
```json
{"status":"available"}
```
## 数据持久化
使用 Docker 方式时Meilisearch 数据存储在 `storage/meilisearch` 目录中。
**注意**:请确保将此目录添加到 `.gitignore` 文件中,避免将索引数据提交到版本控制系统。
## 索引管理
### 查看所有索引
```bash
curl -X GET 'http://localhost:7700/indexes' \
-H 'Authorization: Bearer your-master-key-change-this-in-production'
```
### 删除索引
```bash
curl -X DELETE 'http://localhost:7700/indexes/documents' \
-H 'Authorization: Bearer your-master-key-change-this-in-production'
```
### 重建索引
在 Laravel 项目中运行:
```bash
# 清空所有索引
php artisan scout:flush "App\Models\Document"
# 重新导入所有文档
php artisan scout:import "App\Models\Document"
```
## 故障排除
### 服务无法启动
1. 检查端口 7700 是否被占用:
```bash
lsof -i :7700
```
2. 查看 Docker 日志:
```bash
docker-compose logs meilisearch
```
### 搜索不返回结果
1. 检查文档是否已索引:
```bash
php artisan scout:import "App\Models\Document"
```
2. 验证索引配置:
```bash
curl -X GET 'http://localhost:7700/indexes/documents/settings' \
-H 'Authorization: Bearer your-master-key-change-this-in-production'
```
### 权限错误
确保 `storage/meilisearch` 目录有正确的写入权限:
```bash
chmod -R 775 storage/meilisearch
```
## 性能优化
### 生产环境配置
在生产环境中,建议:
1. 使用强密钥作为 `MEILI_MASTER_KEY`
2. 设置 `MEILI_ENV=production`
3. 配置适当的资源限制CPU、内存
4. 定期备份 `storage/meilisearch` 目录
### 索引优化
根据实际使用情况调整索引设置:
```php
// config/scout.php
'meilisearch' => [
'index-settings' => [
'documents' => [
'filterableAttributes' => ['type', 'group_id', 'uploaded_by'],
'sortableAttributes' => ['created_at', 'title'],
'searchableAttributes' => ['title', 'description', 'markdown_content'],
'rankingRules' => [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness',
],
],
],
],
```
## 更多信息
- [Meilisearch 官方文档](https://www.meilisearch.com/docs)
- [Laravel Scout 文档](https://laravel.com/docs/scout)
- [Meilisearch PHP SDK](https://github.com/meilisearch/meilisearch-php)

304
docs/PROJECT_OVERVIEW.md Normal file
View File

@@ -0,0 +1,304 @@
# 知识库系统项目概览
## 项目简介
知识库系统是一个基于 Laravel 11 和 Filament 3.X 构建的企业级文档管理平台。系统支持 Word 文档上传、自动转换为 Markdown 格式、全文搜索、在线预览以及基于用户分组的细粒度权限控制。
## 核心功能
### 1. 文档管理
- **文档上传**:支持 .doc 和 .docx 格式的 Word 文档上传
- **文档分类**
- 全局知识库:所有用户可访问
- 专用知识库:仅特定分组用户可访问
- **文档下载**:支持原始 Word 文档下载,并记录下载日志
- **文档预览**:在线预览 Markdown 渲染后的文档内容
### 2. 自动文档转换
- **异步转换**:使用队列系统异步将 Word 文档转换为 Markdown 格式
- **转换工具**:支持 Pandoc 或 PHPWord 作为转换引擎
- **状态跟踪**实时跟踪转换状态pending、processing、completed、failed
- **错误处理**:转换失败不影响文档的正常使用
### 3. 全文搜索
- **搜索引擎**:集成 Meilisearch 提供快速的全文搜索
- **搜索范围**:支持搜索文档标题、描述和 Markdown 内容
- **权限过滤**:搜索结果自动过滤,只显示用户有权访问的文档
- **高级筛选**:支持按文档类型、分组等条件筛选
### 4. 权限控制
- **用户分组**:用户可以属于多个分组
- **访问控制**
- 全局文档:所有用户可访问
- 专用文档:只有所属分组的用户可访问
- **权限验证**:在数据查询、下载、预览等操作中强制执行权限检查
- **安全日志**:记录所有未授权访问尝试
### 5. 用户界面
- **中文界面**:完整的简体中文用户界面
- **管理面板**:基于 Filament 3.X 的现代化管理界面
- **响应式设计**:支持桌面和移动设备访问
## 技术栈
### 后端
- **框架**Laravel 11.x
- **管理面板**Filament 3.X
- **数据库**MySQL 8.0+ / PostgreSQL 13+
- **搜索引擎**Meilisearch 1.5+
- **队列系统**Redis Queue
- **文档转换**Pandoc 2.x+ 或 PHPWord
### 前端
- **模板引擎**Laravel Blade
- **CSS 框架**Tailwind CSS 3.x
- **JavaScript**Alpine.js 3.xFilament 内置)
### 开发工具
- **包管理**Composer 2.x, npm
- **测试框架**Pest PHP
- **代码质量**PHPStan, Laravel Pint
## 项目结构
```
knowledge-base-system/
├── app/
│ ├── Filament/ # Filament 资源和页面
│ │ ├── Pages/ # 自定义页面(搜索页面)
│ │ └── Resources/ # 资源管理(文档、分组、用户)
│ ├── Http/
│ │ └── Controllers/ # 控制器(文档预览)
│ ├── Jobs/ # 队列任务(文档转换)
│ ├── Models/ # Eloquent 模型
│ ├── Observers/ # 模型观察者(文档索引)
│ ├── Policies/ # 授权策略
│ └── Services/ # 业务逻辑服务
├── config/
│ ├── documents.php # 文档转换配置
│ ├── filesystems.php # 文件存储配置
│ └── scout.php # Meilisearch 配置
├── database/
│ ├── factories/ # 测试数据工厂
│ ├── migrations/ # 数据库迁移
│ └── seeders/ # 数据填充
├── docs/ # 项目文档
├── resources/
│ └── views/ # Blade 视图模板
├── storage/
│ └── app/
│ └── private/
│ ├── documents/ # 原始 Word 文档存储
│ └── markdown/ # Markdown 文件存储
├── tests/ # 测试文件
└── .kiro/
└── specs/ # 功能规格文档
```
## 数据模型
### 核心实体
1. **User用户**
- 系统用户
- 可属于多个分组
- 可上传文档
2. **Group分组**
- 用户组织单位
- 拥有专用知识库文档
- 管理成员访问权限
3. **Document文档**
- 文档记录
- 包含原始文件和 Markdown 内容
- 关联分组和上传者
4. **DownloadLog下载日志**
- 记录文档下载历史
- 用于审计和统计
### 关系图
```
User ──┬── uploads ──> Document
└── belongs_to ──> Group ──> owns ──> Document
└── has ──> DownloadLog
```
## 已实现功能清单
### ✅ 核心功能
- [x] 用户认证和授权
- [x] 用户分组管理
- [x] 文档上传和存储
- [x] 文档分类(全局/专用)
- [x] 基于分组的权限控制
- [x] 文档下载和日志记录
- [x] 文档搜索和筛选
### ✅ 高级功能
- [x] Word 文档自动转换为 Markdown
- [x] 异步队列处理转换任务
- [x] Meilisearch 全文搜索集成
- [x] 文档 Markdown 在线预览
- [x] 搜索结果权限过滤
- [x] 安全日志记录
### ✅ 用户界面
- [x] Filament 管理面板
- [x] 完整中文界面
- [x] 文档管理界面
- [x] 分组管理界面
- [x] 用户管理界面
- [x] 搜索页面
- [x] 预览页面
### ⏳ 待完成功能
- [ ] 属性基础测试Property-Based Testing
- [ ] 完整的功能测试套件
- [ ] 性能优化(缓存、索引优化)
- [ ] UI 增强Alpine.js 动画和交互)
- [ ] 部署文档和脚本
## 快速开始
### 环境要求
- PHP 8.1+
- Composer 2.x
- Node.js 18+
- MySQL 8.0+ 或 PostgreSQL 13+
- Redis 6.0+
- Meilisearch 1.5+
- Pandoc 2.x+(可选)
### 安装步骤
1. **克隆项目**
```bash
git clone <repository-url>
cd knowledge-base-system
```
2. **安装依赖**
```bash
composer install
npm install
```
3. **配置环境**
```bash
cp .env.example .env
php artisan key:generate
```
4. **配置数据库**
编辑 `.env` 文件,设置数据库连接信息
5. **运行迁移**
```bash
php artisan migrate
```
6. **生成测试数据**(可选)
```bash
php artisan db:seed
```
7. **启动服务**
```bash
# 启动 Laravel 开发服务器
php artisan serve
# 启动队列工作进程
php artisan queue:work
# 启动 Meilisearch
meilisearch --master-key="your-master-key"
```
8. **访问系统**
打开浏览器访问 `http://localhost:8000/admin`
## 配置说明
### 文档转换配置
`.env` 文件中配置:
```env
DOCUMENT_CONVERSION_DRIVER=pandoc
PANDOC_PATH=/usr/local/bin/pandoc
CONVERSION_TIMEOUT=300
```
### Meilisearch 配置
```env
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=your-master-key
SCOUT_DRIVER=meilisearch
```
### 队列配置
```env
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
```
## 测试
### 运行测试
```bash
# 运行所有测试
php artisan test
# 运行特定测试
php artisan test --filter=DocumentAccessScopePropertyTest
# 生成测试覆盖率报告
php artisan test --coverage
```
### 测试数据
使用 Factory 生成测试数据:
```php
// 创建用户
$user = User::factory()->create();
// 创建分组
$group = Group::factory()->create();
// 创建文档
$document = Document::factory()->create([
'type' => 'global',
]);
```
## 部署
详细的部署指南请参考 [DEPLOYMENT.md](./DEPLOYMENT.md)
## 贡献指南
请参考 [CONTRIBUTING.md](./CONTRIBUTING.md)
## 许可证
本项目采用 MIT 许可证
## 联系方式
如有问题或建议,请联系项目维护者。
---
**最后更新**2025-12-05
**版本**1.0.0

130
docs/SETUP.md Normal file
View File

@@ -0,0 +1,130 @@
# 知识库系统 - 安装配置文档
## 项目初始化完成
本项目已成功完成初始化,包括以下配置:
### 1. Laravel 框架
- **版本**: Laravel 12.x
- **PHP 版本要求**: PHP 8.1+
- **数据库**: SQLite (database/database.sqlite)
### 2. Filament 管理面板
- **版本**: Filament 3.3.45
- **面板 ID**: admin
- **访问路径**: `/admin`
- **登录路径**: `/admin/login`
### 3. 中文化配置
- **应用语言**: zh_CN (简体中文)
- **Laravel 语言包**: laravel-lang/common ^6.7
- **Filament 中文翻译**: 已内置并发布
### 4. 管理员账户
- **用户名**: admin
- **邮箱**: admin@example.com
- **密码**: (创建时设置)
## 环境配置
### 应用配置 (.env)
```env
APP_LOCALE=zh_CN
APP_FALLBACK_LOCALE=zh_CN
APP_FAKER_LOCALE=zh_CN
```
### 数据库配置
```env
DB_CONNECTION=sqlite
```
## 已安装的主要依赖
### 生产依赖
- filament/filament: ^3.0
- livewire/livewire: ^3.7
- blade-ui-kit/blade-heroicons: ^2.6
- doctrine/dbal: ^4.4
### 开发依赖
- laravel-lang/common: ^6.7
- phpunit/phpunit: ^11.5
- laravel/pint: ^1.26
## 目录结构
```
.
├── app/
│ ├── Filament/
│ │ ├── Resources/ # Filament 资源文件
│ │ ├── Pages/ # Filament 页面
│ │ └── Widgets/ # Filament 小部件
│ ├── Models/ # Eloquent 模型
│ └── Providers/
│ └── Filament/
│ └── AdminPanelProvider.php # Filament 面板配置
├── database/
│ ├── database.sqlite # SQLite 数据库文件
│ ├── migrations/ # 数据库迁移
│ └── factories/ # 模型工厂
├── lang/
│ ├── zh_CN/ # Laravel 中文语言包
│ └── vendor/
│ └── filament/
│ └── zh_CN/ # Filament 中文翻译
├── tests/
│ └── Feature/
│ └── SetupTest.php # 配置验证测试
└── .env # 环境配置文件
```
## 验证安装
运行以下命令验证安装是否成功:
```bash
# 运行配置测试
php artisan test --filter=SetupTest
# 清除缓存
php artisan config:clear
php artisan route:clear
php artisan view:clear
# 查看路由
php artisan route:list --path=admin
```
## 启动开发服务器
```bash
php artisan serve
```
然后访问 http://localhost:8000/admin/login 登录管理面板。
## 下一步
根据 `.kiro/specs/knowledge-base-system/tasks.md` 中的任务列表,接下来需要:
1. 创建数据库迁移和模型
2. 实现文档权限查询作用域
3. 创建服务类和策略类
4. 创建 Filament 资源
## 测试结果
所有初始化测试均已通过:
- ✓ 应用语言配置为简体中文
- ✓ 数据库连接正常
- ✓ Filament 管理面板路由可访问
- ✓ 中文翻译文件存在
## 技术支持
如有问题,请参考:
- Laravel 文档: https://laravel.com/docs
- Filament 文档: https://filamentphp.com/docs
- Laravel Lang 文档: https://laravel-lang.com/

189
docs/security-logging.md Normal file
View File

@@ -0,0 +1,189 @@
# 安全日志记录
## 概述
知识库系统实现了全面的安全日志记录功能,用于记录所有未授权访问尝试和安全相关事件。
## 功能特性
### 1. 自动记录未授权访问
系统会自动记录以下未授权访问尝试:
- **查看文档** (view):用户尝试查看无权访问的专用文档
- **下载文档** (download):用户尝试下载无权访问的文档
- **更新文档** (update):用户尝试更新不属于自己的文档
- **删除文档** (delete):用户尝试删除不属于自己的文档
- **恢复文档** (restore):用户尝试恢复不属于自己的文档
- **永久删除** (forceDelete):用户尝试永久删除不属于自己的文档
### 2. 记录的信息
每条安全日志包含以下信息:
- **事件类型** (event)unauthorized_access
- **操作类型** (action)view, download, update, delete 等
- **用户信息**
- 用户 ID (user_id)
- 用户名 (user_name)
- 用户邮箱 (user_email)
- **文档信息**
- 文档 ID (document_id)
- 文档标题 (document_title)
- 文档类型 (document_type)
- 文档分组 ID (document_group_id)
- **请求信息**
- IP 地址 (ip_address)
- 时间戳 (timestamp)
- 用户代理 (user_agent)
## 日志配置
### 日志通道
安全日志使用独立的 `security` 通道,配置在 `config/logging.php` 中:
```php
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => env('LOG_LEVEL', 'info'),
'days' => env('LOG_SECURITY_DAYS', 90),
'replace_placeholders' => true,
],
```
### 日志保留期
默认情况下,安全日志保留 90 天。可以通过环境变量 `LOG_SECURITY_DAYS` 调整:
```env
LOG_SECURITY_DAYS=90
```
## 查看日志
### 日志文件位置
安全日志存储在:`storage/logs/security.log`
每天会自动创建新的日志文件,格式为:`security-YYYY-MM-DD.log`
### 日志格式示例
```
[2024-12-04 10:30:45] local.WARNING: 未授权访问尝试 {"event":"unauthorized_access","action":"view","user_id":2,"user_name":"张三","user_email":"zhangsan@example.com","document_id":5,"document_title":"机密文档","document_type":"dedicated","document_group_id":3,"ip_address":"192.168.1.100","timestamp":"2024-12-04T10:30:45+08:00","user_agent":"Mozilla/5.0..."}
```
### 使用命令行查看日志
查看最新的安全日志:
```bash
tail -f storage/logs/security.log
```
查看今天的安全日志:
```bash
cat storage/logs/security-$(date +%Y-%m-%d).log
```
搜索特定用户的未授权访问:
```bash
grep "user_id\":2" storage/logs/security.log
```
搜索特定文档的访问尝试:
```bash
grep "document_id\":5" storage/logs/security.log
```
## 安全监控建议
### 1. 定期审查
建议定期审查安全日志,特别关注:
- 频繁的未授权访问尝试
- 来自异常 IP 地址的访问
- 针对敏感文档的访问尝试
- 同一用户的大量失败尝试
### 2. 告警设置
可以配置日志监控工具(如 ELK Stack、Graylog 等)来:
- 实时监控安全日志
- 设置告警规则
- 生成安全报告
- 可视化安全事件
### 3. 日志分析
使用日志分析工具可以:
- 识别攻击模式
- 发现潜在的安全威胁
- 追踪用户行为
- 生成合规报告
## 扩展功能
### SecurityLogger 服务
系统提供了 `SecurityLogger` 服务类,可以用于记录其他安全事件:
```php
use App\Services\SecurityLogger;
// 记录未授权访问
$securityLogger->logUnauthorizedAccess($user, $document, 'view');
// 记录权限验证失败
$securityLogger->logAuthorizationFailure($user, 'Document', $documentId, 'view', '用户不在分组中');
// 记录可疑活动
$securityLogger->logSuspiciousActivity($user, '短时间内大量下载尝试', [
'attempts' => 50,
'timeframe' => '5分钟'
]);
```
## 合规性
安全日志记录有助于满足以下合规要求:
- **数据保护法规**:记录数据访问和使用情况
- **审计要求**:提供完整的访问审计轨迹
- **安全标准**:符合 ISO 27001 等安全标准
- **内部政策**:支持组织的安全政策执行
## 注意事项
1. **隐私保护**:日志中包含用户信息,需要妥善保管
2. **存储空间**:定期清理旧日志以节省存储空间
3. **性能影响**:日志记录对性能影响很小,但在高并发场景下需要监控
4. **日志轮转**:使用 daily 驱动自动进行日志轮转
## 故障排查
### 日志未生成
1. 检查 `storage/logs` 目录权限
2. 确认日志配置正确
3. 检查 `LOG_CHANNEL` 环境变量
### 日志文件过大
1. 调整 `LOG_SECURITY_DAYS` 减少保留天数
2. 配置日志轮转策略
3. 使用外部日志管理系统
### 无法写入日志
1. 检查文件系统权限
2. 确认磁盘空间充足
3. 检查 SELinux 或 AppArmor 配置

View File

@@ -0,0 +1,9 @@
<?php
return [
'messages' => [
'uploading_file' => '文件上传中...',
],
];

View File

@@ -0,0 +1,9 @@
<?php
return [
'messages' => [
'copied' => '已复制',
],
];

View File

@@ -0,0 +1,13 @@
<?php
return [
'actions' => [
'close' => [
'label' => '关闭',
],
],
];

View File

@@ -0,0 +1,39 @@
<?php
return [
'label' => '分页',
'overview' => '{1} 只有 1 条记录|[2,*] 当前显示第 :first 条到第 :last 条,共 :total 条',
'fields' => [
'records_per_page' => [
'label' => '每页',
'options' => [
'all' => '所有',
],
],
],
'actions' => [
'go_to_page' => [
'label' => '跳转到 :page',
],
'next' => [
'label' => '下一页',
],
'previous' => [
'label' => '上一页',
],
],
];

228
lang/zh_CN.json Normal file
View File

@@ -0,0 +1,228 @@
{
"(and :count more error)": "(还有 :count 个错误)",
"(and :count more errors)": "(还有 :count 个错误)",
"A decryption key is required.": "需要解密密钥。",
"A Timeout Occurred": "发生超时",
"Accept": "接受",
"Accepted": "已接受",
"Action": "操作",
"Actions": "操作",
"Add": "添加",
"Add :name": "添加 :name",
"Admin": "管理员",
"Agree": "同意",
"All rights reserved.": "版权所有。",
"Already Reported": "已上报",
"Archive": "档案",
"Assign": "分配",
"Associate": "联系",
"Attach": "附加",
"Bad Gateway": "网关错误",
"Bad Request": "请求错误",
"Bandwidth Limit Exceeded": "超出带宽限制",
"Browse": "浏览",
"Cancel": "取消",
"Choose": "选择",
"Choose :name": "选择:name",
"Choose File": "选择文件",
"Choose Image": "选择图片",
"Click to copy": "点击复制",
"Client Closed Request": "客户端关闭请求",
"Close": "关闭",
"Collapse": "收缩",
"Collapse All": "全部收缩",
"Comment": "评论",
"Confirm": "确认",
"Conflict": "冲突",
"Connect": "连接",
"Connection Closed Without Response": "连接关闭无响应",
"Connection Timed Out": "连接超时",
"Continue": "继续请求",
"Create": "创建",
"Create :name": "创建 :name 个",
"Created": "已创建",
"Delete": "删除",
"Delete :name": "删除 :name 个",
"Detach": "分离",
"Details": "详情",
"Disable": "禁用",
"Discard": "丢弃",
"Done": "完成",
"Down": "向下",
"Duplicate": "复制",
"Duplicate :name": "重复:名称",
"Edit": "编辑",
"Edit :name": "编辑:name",
"Enable": "启用",
"Encrypted environment file already exists.": "加密环境配置文件已存在。",
"Encrypted environment file not found.": "未找到加密环境配置文件。",
"Environment file already exists.": "环境配置文件已存在。",
"Environment file not found.": "未找到环境配置文件。",
"errors": "错误",
"Expand": "展开",
"Expand All": "展开全部",
"Expectation Failed": "期望不满足",
"Explanation": "解释",
"Export": "导出",
"Export :name": "导出 :name",
"Failed Dependency": "失败的依赖",
"File": "文件",
"Files": "文件",
"Forbidden": "访问被拒绝",
"Found": "临时移动",
"Gateway Timeout": "网关超时",
"Go Home": "回首页",
"Go to page :page": "前往第 :page 页",
"Gone": "不可用",
"Hello!": "您好!",
"Hide": "隐藏",
"Hide :name": "隐藏 :name",
"Home": "家",
"HTTP Version Not Supported": "HTTP版本不支持",
"I'm a teapot": "我是一个茶壶",
"If you did not create an account, no further action is required.": "如果您未注册帐号,请忽略此邮件。",
"If you did not request a password reset, no further action is required.": "如果您未申请重设密码,请忽略此邮件。",
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "如果您单击「:actionText」按钮时遇到问题请复制下方链接到浏览器中访问",
"IM Used": "IM已使用",
"Image": "图像",
"Impersonate": "模拟登录",
"Impersonation": "冒充",
"Import": "导入",
"Import :name": "导入 :name",
"Insufficient Storage": "存储空间不足",
"Internal Server Error": "内部服务器错误",
"Introduction": "介绍",
"Invalid filename.": "无效的文件名。",
"Invalid JSON was returned from the route.": "从路由返回无效的 JSON。",
"Invalid SSL Certificate": "无效的SSL证书",
"Length Required": "长度要求",
"Like": "喜欢",
"Load": "加载",
"Localize": "本地化",
"Location": "地点",
"Locked": "锁定",
"Log In": "登录",
"Log Out": "登出",
"Login": "登录",
"Logout": "登出",
"Loop Detected": "检测到环路",
"Maintenance Mode": "服务不可用",
"Method Not Allowed": "方法不允许",
"Misdirected Request": "错误的请求",
"Moved Permanently": "已永久移动",
"Multi-Status": "多状态",
"Multiple Choices": "多种选择",
"Network Authentication Required": "需要网络验证",
"Network Connect Timeout Error": "网络连接超时",
"Network Read Timeout Error": "网络读取超时",
"New": "新建",
"New :name": "新 :name",
"No": "不",
"No Content": "无内容",
"Non-Authoritative Information": "非权威信息",
"Not Acceptable": "无法接受",
"Not Extended": "未延期",
"Not Found": "页面不存在",
"Not Implemented": "未实现",
"Not Modified": "未修改",
"of": "于",
"OK": "OK",
"Open": "打开",
"Open in a current window": "在当前窗口中打开",
"Open in a new window": "在新窗口中打开",
"Open in a parent frame": "在父框架中打开",
"Open in the topmost frame": "在最上面的框架中打开",
"Open on the website": "在网站上打开",
"Origin Is Unreachable": "原点无法到达",
"Page Expired": "页面会话已超时",
"Pagination Navigation": "分页导航",
"Partial Content": "部分内容",
"Payload Too Large": "请求实体过大",
"Payment Required": "需要付款",
"Permanent Redirect": "永久重定向",
"Please click the button below to verify your email address.": "请点击下面按钮验证您的 E-mail",
"Precondition Failed": "前提条件未满足",
"Precondition Required": "前提要求",
"Preview": "预览",
"Price": "价格",
"Processing": "处理中",
"Proxy Authentication Required": "需要代理验证",
"Railgun Error": "轨道炮错误",
"Range Not Satisfiable": "请求范围不符合",
"Record": "记录",
"Regards,": "致敬,",
"Register": "注册",
"Request Header Fields Too Large": "请求标头字段太大",
"Request Timeout": "请求超时",
"Reset Content": "重置内容",
"Reset Password": "重置密码",
"Reset Password Notification": "重置密码通知",
"Restore": "恢复",
"Restore :name": "恢复:name",
"results": "结果",
"Retry With": "重试",
"Save": "保存",
"Save & Close": "保存并关闭",
"Save & Return": "保存并返回",
"Save :name": "保存 :name",
"Search": "搜索",
"Search :name": "搜索 :name",
"See Other": "见其他",
"Select": "选择",
"Select All": "全选",
"Send": "发送",
"Server Error": "服务器错误",
"Service Unavailable": "服务不可用",
"Session Has Expired": "会话已过期",
"Settings": "设置",
"Show": "显示",
"Show :name": "显示 :name",
"Show All": "显示所有",
"Showing": "显示中",
"Sign In": "登入",
"Solve": "解决",
"SSL Handshake Failed": "SSL握手失败",
"Start": "开始",
"Stop": "停止",
"Submit": "提交",
"Subscribe": "订阅",
"Switch": "切换",
"Switch To Role": "切换角色",
"Switching Protocols": "切换协议",
"Tag": "标签",
"Tags": "标签",
"Temporary Redirect": "临时重定向",
"The given data was invalid.": "给定的数据无效。",
"The response is not a streamed response.": "该响应不是流式响应。",
"The response is not a view.": "响应不是视图。",
"This action is unauthorized.": "此操作未经授权。",
"This password reset link will expire in :count minutes.": "这个重设密码链接将会在 :count 分钟后失效。",
"to": "至",
"Toggle navigation": "切换导航",
"Too Early": "太早了",
"Too Many Requests": "请求次数过多。",
"Translate": "翻译",
"Translate It": "翻译它",
"Unauthorized": "未授权",
"Unavailable For Legal Reasons": "法律原因不可用",
"Unknown Error": "未知错误",
"Unpack": "打开",
"Unprocessable Entity": "不可处理的实体",
"Unsubscribe": "退订",
"Unsupported Media Type": "不支持的媒体类型",
"Up": "向上",
"Update": "更新",
"Update :name": "更新:name",
"Upgrade Required": "需要升级",
"URI Too Long": "URI太长了",
"Use Proxy": "使用代理",
"User": "用户",
"Variant Also Negotiates": "变体协商",
"Verify Email Address": "验证 E-mail",
"View": "查看",
"View :name": "查看 :name",
"Web Server is Down": "Web服务器已关闭",
"Whoops!": "哎呀!",
"Yes": "是的",
"You are receiving this email because we received a password reset request for your account.": "您收到此电子邮件是因为我们收到了您帐户的密码重设请求。"
}

119
lang/zh_CN/actions.php Normal file
View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
return [
'accept' => '接受',
'action' => '行动',
'actions' => '行动',
'add' => '添加',
'admin' => '管理员',
'agree' => '同意',
'archive' => '档案',
'assign' => '分配',
'associate' => '联系',
'attach' => '附',
'browse' => '浏览',
'cancel' => '取消',
'choose' => '选择',
'choose_file' => '选择文件',
'choose_image' => '选择图片',
'click_to_copy' => '点击复制',
'close' => '关闭',
'collapse' => '收缩',
'collapse_all' => '全部收缩',
'comment' => '评论',
'confirm' => '确认',
'connect' => '连接',
'create' => '创建',
'delete' => '删除',
'detach' => '分离',
'details' => '细节',
'disable' => '禁用',
'discard' => '丢弃',
'done' => '完成',
'down' => '向下',
'duplicate' => '复制',
'edit' => '编辑',
'enable' => '启用',
'expand' => '展开',
'expand_all' => '展开全部',
'explanation' => '解释',
'export' => '导出',
'file' => '文件',
'files' => '文件',
'go_home' => '回家',
'hide' => '隐藏',
'home' => '家',
'image' => '图像',
'impersonate' => '模仿',
'impersonation' => '冒充',
'import' => '导入',
'introduction' => '介绍',
'like' => '喜欢',
'load' => '加载',
'localize' => '本地化',
'log_in' => '登录',
'log_out' => '登出',
'named' => [
'add' => '添加 :name',
'choose' => '选择:name',
'create' => '创建 :name 个',
'delete' => '删除 :name 个',
'duplicate' => '重复:名称',
'edit' => '编辑:name',
'export' => '导出 :name',
'hide' => '隐藏 :name',
'import' => '导入 :name',
'new' => '新 :name',
'restore' => '恢复:name',
'save' => '保存 :name',
'search' => '搜索 :name',
'show' => '显示 :name',
'update' => '更新:name',
'view' => '查看 :name',
],
'new' => '新的',
'no' => '不',
'open' => '打开',
'open_website' => '在网站上打开',
'preview' => '预览',
'price' => '价格',
'record' => '记录',
'restore' => '恢复',
'save' => '保存',
'save_and_close' => '保存并关闭',
'save_and_return' => '保存并返回',
'search' => '搜索',
'select' => '选择',
'select_all' => '全选',
'send' => '发送',
'settings' => '设置',
'show' => '显示',
'show_all' => '显示所有',
'sign_in' => '登入',
'solve' => '解决',
'start' => '开始',
'stop' => '停止',
'submit' => '提交',
'subscribe' => '订阅',
'switch' => '切换',
'switch_to_role' => '切换角色',
'tag' => '标签',
'tags' => '标签',
'target_link' => [
'blank' => '在新窗口中打开',
'parent' => '在父框架中打开',
'self' => '在当前窗口中打开',
'top' => '在最上面的框架中打开',
],
'translate' => '翻译',
'translate_it' => '翻译它',
'unpack' => '打开',
'unsubscribe' => '退订',
'up' => '向上',
'update' => '更新',
'user' => '用户',
'view' => '查看',
'yes' => '是的',
];

9
lang/zh_CN/auth.php Normal file
View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
return [
'failed' => '用户名或密码错误。',
'password' => '密码错误',
'throttle' => '您尝试的登录次数过多,请 :seconds 秒后再试。',
];

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
return [
'0' => '未知错误',
'100' => '继续请求',
'101' => '切换协议',
'102' => '处理中',
'200' => '请求成功',
'201' => '已创建',
'202' => '已接受',
'203' => '非权威信息',
'204' => '无内容',
'205' => '重置内容',
'206' => '部分内容',
'207' => '多状态',
'208' => '已上报',
'226' => 'IM已使用',
'300' => '多种选择',
'301' => '已永久移动',
'302' => '临时移动',
'303' => '见其他',
'304' => '未修改',
'305' => '使用代理',
'307' => '临时重定向',
'308' => '永久重定向',
'400' => '请求错误',
'401' => '未授权',
'402' => '需要付款',
'403' => '禁止',
'404' => '未找到',
'405' => '方法不允许',
'406' => '无法接受',
'407' => '需要代理验证',
'408' => '请求超时',
'409' => '冲突',
'410' => '不可用',
'411' => '长度要求',
'412' => '前提条件未满足',
'413' => '请求实体过大',
'414' => 'URI太长了',
'415' => '不支持的媒体类型',
'416' => '请求范围不符合',
'417' => '期望不满足',
'418' => '我是一个茶壶',
'419' => '会话已过期',
'421' => '错误的请求',
'422' => '不可处理的实体',
'423' => '锁定',
'424' => '失败的依赖',
'425' => '太早了',
'426' => '需要升级',
'428' => '前提要求',
'429' => '请求太多',
'431' => '请求标头字段太大',
'444' => '连接关闭无响应',
'449' => '重试',
'451' => '法律原因不可用',
'499' => '客户端关闭请求',
'500' => '内部服务器错误',
'501' => '未实现',
'502' => '网关错误',
'503' => '服务不可用',
'504' => '网关超时',
'505' => 'HTTP版本不支持',
'506' => '变体协商',
'507' => '存储空间不足',
'508' => '检测到环路',
'509' => '超出带宽限制',
'510' => '未延期',
'511' => '需要网络验证',
'520' => '未知错误',
'521' => 'Web服务器已关闭',
'522' => '连接超时',
'523' => '原点无法到达',
'524' => '发生超时',
'525' => 'SSL握手失败',
'526' => '无效的SSL证书',
'527' => '轨道炮错误',
'598' => '网络读取超时',
'599' => '网络连接超时',
'unknownError' => '未知错误',
];

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
return [
'next' => '下一页 &raquo;',
'previous' => '&laquo; 上一页',
];

Some files were not shown because too many files have changed in this diff Show More