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:
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
89
.env.example
Normal 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
11
.gitattributes
vendored
Normal 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
25
.gitignore
vendored
Normal 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
|
||||||
138
.kiro/specs/code-repository-organization/requirements.md
Normal file
138
.kiro/specs/code-repository-organization/requirements.md
Normal 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 在规格文档中标记完成状态
|
||||||
993
.kiro/specs/knowledge-base-system/design.md
Normal file
993
.kiro/specs/knowledge-base-system/design.md
Normal 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**
|
||||||
|
|
||||||
|
### 属性 29:Markdown 内容持久化
|
||||||
|
*对于任何*转换完成的文档,系统应该将 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**
|
||||||
|
|
||||||
|
### 属性 38:Markdown 渲染正确性
|
||||||
|
*对于任何*包含标准 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小时)
|
||||||
|
- 缓存渲染后的 HTML(TTL: 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. 优雅降级策略,转换或搜索失败不影响核心功能
|
||||||
|
|
||||||
|
系统设计充分考虑了安全性、性能和可扩展性,为后续的实施提供了清晰的指导。文档转换和搜索功能的引入大大提升了知识库的可用性和检索效率。
|
||||||
168
.kiro/specs/knowledge-base-system/requirements.md
Normal file
168
.kiro/specs/knowledge-base-system/requirements.md
Normal 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 记录错误日志但不影响文档的正常保存和使用
|
||||||
670
.kiro/specs/knowledge-base-system/tasks.md
Normal file
670
.kiro/specs/knowledge-base-system/tasks.md
Normal 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:文档上传触发转换**
|
||||||
|
- **属性 29:Markdown 内容持久化**
|
||||||
|
- **属性 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 渲染
|
||||||
|
- **属性 38:Markdown 渲染正确性**
|
||||||
|
- **验证需求: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 存储目录创建步骤
|
||||||
|
- 包含数据库迁移步骤
|
||||||
|
- 包含文件权限配置
|
||||||
|
- _需求:部署和配置_
|
||||||
484
.kiro/specs/ui-enhancement/design.md
Normal file
484
.kiro/specs/ui-enhancement/design.md
Normal 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**
|
||||||
|
|
||||||
|
### 属性 2:ARIA标签完整性
|
||||||
|
|
||||||
|
*对于任何*可交互元素,该元素应该包含适当的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. 监控
|
||||||
|
|
||||||
|
- 监控动画性能
|
||||||
|
- 收集用户反馈
|
||||||
|
- 跟踪错误日志
|
||||||
138
.kiro/specs/ui-enhancement/requirements.md
Normal file
138
.kiro/specs/ui-enhancement/requirements.md
Normal 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 系统应当确保足够的颜色对比度
|
||||||
334
.kiro/specs/ui-enhancement/tasks.md
Normal file
334
.kiro/specs/ui-enhancement/tasks.md
Normal 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标签
|
||||||
|
- **属性 2:ARIA标签完整性**
|
||||||
|
- **验证需求: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
103
CHANGELOG.md
Normal 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
316
CONTRIBUTING.md
Normal 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
|
||||||
|
# 终端 1:Laravel 服务器
|
||||||
|
php artisan serve
|
||||||
|
|
||||||
|
# 终端 2:队列工作进程
|
||||||
|
php artisan queue:work
|
||||||
|
|
||||||
|
# 终端 3:Meilisearch
|
||||||
|
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
21
LICENSE
Normal 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
315
README.md
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
# 知识库系统
|
||||||
|
|
||||||
|
基于 Laravel 11 和 Filament 3.X 构建的企业级文档管理平台,支持 Word 文档上传、自动转换为 Markdown、全文搜索和基于分组的权限控制。
|
||||||
|
|
||||||
|
[](https://laravel.com)
|
||||||
|
[](https://filamentphp.com)
|
||||||
|
[](https://php.net)
|
||||||
|
[](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
|
||||||
281
app/Filament/Pages/SearchPage.php
Normal file
281
app/Filament/Pages/SearchPage.php
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\Group;
|
||||||
|
use App\Services\DocumentSearchService;
|
||||||
|
use App\Services\DocumentService;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Tables\Actions\Action;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class SearchPage extends Page implements HasForms, HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-magnifying-glass';
|
||||||
|
|
||||||
|
protected static string $view = 'filament.pages.search-page';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = '搜索文档';
|
||||||
|
|
||||||
|
protected static ?string $title = '搜索文档';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
public ?string $searchQuery = null;
|
||||||
|
public ?string $documentType = null;
|
||||||
|
public ?int $groupId = null;
|
||||||
|
|
||||||
|
// 搜索结果
|
||||||
|
public $searchResults = null;
|
||||||
|
public bool $hasSearched = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂载页面时的初始化
|
||||||
|
*/
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->form->fill([
|
||||||
|
'searchQuery' => '',
|
||||||
|
'documentType' => null,
|
||||||
|
'groupId' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义搜索表单
|
||||||
|
*/
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
TextInput::make('searchQuery')
|
||||||
|
->label('搜索关键词')
|
||||||
|
->placeholder('请输入搜索关键词...')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
|
||||||
|
Select::make('documentType')
|
||||||
|
->label('文档类型')
|
||||||
|
->placeholder('全部类型')
|
||||||
|
->options([
|
||||||
|
'global' => '全局知识库',
|
||||||
|
'dedicated' => '专用知识库',
|
||||||
|
])
|
||||||
|
->native(false),
|
||||||
|
|
||||||
|
Select::make('groupId')
|
||||||
|
->label('所属分组')
|
||||||
|
->placeholder('全部分组')
|
||||||
|
->options(Group::pluck('name', 'id'))
|
||||||
|
->searchable()
|
||||||
|
->native(false),
|
||||||
|
])
|
||||||
|
->columns(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义搜索结果表格
|
||||||
|
*/
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query($this->getTableQuery())
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('文档标题')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->limit(50),
|
||||||
|
|
||||||
|
TextColumn::make('markdown_preview')
|
||||||
|
->label('内容片段')
|
||||||
|
->limit(100)
|
||||||
|
->wrap()
|
||||||
|
->default('暂无内容预览'),
|
||||||
|
|
||||||
|
TextColumn::make('type')
|
||||||
|
->label('文档类型')
|
||||||
|
->badge()
|
||||||
|
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||||
|
'global' => '全局知识库',
|
||||||
|
'dedicated' => '专用知识库',
|
||||||
|
default => $state,
|
||||||
|
})
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'global' => 'success',
|
||||||
|
'dedicated' => 'info',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextColumn::make('group.name')
|
||||||
|
->label('所属分组')
|
||||||
|
->default('无')
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label('上传时间')
|
||||||
|
->dateTime('Y-m-d H:i')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Action::make('preview')
|
||||||
|
->label('预览')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('info')
|
||||||
|
->modalHeading(fn (Document $record) => $record->title)
|
||||||
|
->modalContent(fn (Document $record) => view('filament.pages.document-preview-modal', [
|
||||||
|
'document' => $record,
|
||||||
|
]))
|
||||||
|
->modalWidth('7xl')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('关闭')
|
||||||
|
->visible(fn (Document $record) => $record->conversion_status === 'completed'),
|
||||||
|
|
||||||
|
Action::make('download')
|
||||||
|
->label('下载')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->action(function (Document $record) {
|
||||||
|
try {
|
||||||
|
$documentService = app(DocumentService::class);
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
// 记录下载日志
|
||||||
|
$documentService->logDownload($record, $user);
|
||||||
|
|
||||||
|
// 返回文件下载响应
|
||||||
|
return $documentService->downloadDocument($record, $user);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('下载失败')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->paginated([10, 25, 50, 100])
|
||||||
|
->defaultPaginationPageOption(25)
|
||||||
|
->emptyStateHeading('暂无搜索结果')
|
||||||
|
->emptyStateDescription('请输入搜索关键词并点击搜索按钮')
|
||||||
|
->emptyStateIcon('heroicon-o-magnifying-glass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取表格查询构建器
|
||||||
|
*/
|
||||||
|
protected function getTableQuery(): Builder
|
||||||
|
{
|
||||||
|
if (!$this->hasSearched || empty($this->searchQuery)) {
|
||||||
|
// 如果还没有搜索或搜索关键词为空,返回空查询
|
||||||
|
return Document::query()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 DocumentSearchService 进行搜索
|
||||||
|
$searchService = app(DocumentSearchService::class);
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$filters = [];
|
||||||
|
if ($this->documentType) {
|
||||||
|
$filters['type'] = $this->documentType;
|
||||||
|
}
|
||||||
|
if ($this->groupId) {
|
||||||
|
$filters['group_id'] = $this->groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行搜索
|
||||||
|
$results = $searchService->search($this->searchQuery, $user, $filters);
|
||||||
|
|
||||||
|
// 获取搜索结果的 ID 列表
|
||||||
|
$documentIds = $results->pluck('id')->toArray();
|
||||||
|
|
||||||
|
// 返回包含这些 ID 的查询构建器
|
||||||
|
if (empty($documentIds)) {
|
||||||
|
return Document::query()->whereRaw('1 = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Document::query()
|
||||||
|
->whereIn('id', $documentIds)
|
||||||
|
->with(['group', 'uploader']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行搜索
|
||||||
|
*/
|
||||||
|
public function search(): void
|
||||||
|
{
|
||||||
|
// 验证表单
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
// 检查搜索关键词是否为空
|
||||||
|
if (empty($data['searchQuery'])) {
|
||||||
|
Notification::make()
|
||||||
|
->title('请输入搜索关键词')
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新搜索参数
|
||||||
|
$this->searchQuery = $data['searchQuery'];
|
||||||
|
$this->documentType = $data['documentType'];
|
||||||
|
$this->groupId = $data['groupId'];
|
||||||
|
$this->hasSearched = true;
|
||||||
|
|
||||||
|
// 重置表格分页
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('搜索完成')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空搜索
|
||||||
|
*/
|
||||||
|
public function clearSearch(): void
|
||||||
|
{
|
||||||
|
$this->form->fill([
|
||||||
|
'searchQuery' => '',
|
||||||
|
'documentType' => null,
|
||||||
|
'groupId' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->searchQuery = null;
|
||||||
|
$this->documentType = null;
|
||||||
|
$this->groupId = null;
|
||||||
|
$this->hasSearched = false;
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('已清空搜索')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取页面头部操作
|
||||||
|
*/
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
301
app/Filament/Resources/DocumentResource.php
Normal file
301
app/Filament/Resources/DocumentResource.php
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
use App\Filament\Resources\DocumentResource\RelationManagers;
|
||||||
|
use App\Models\Document;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
|
||||||
|
class DocumentResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Document::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-document-text';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = '文档管理';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = '文档';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = '文档';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$query = parent::getEloquentQuery();
|
||||||
|
|
||||||
|
// 应用 accessibleBy 作用域,确保用户只能看到有权限的文档
|
||||||
|
$user = auth()->user();
|
||||||
|
if ($user) {
|
||||||
|
$query->accessibleBy($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('title')
|
||||||
|
->label('文档标题')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->placeholder('请输入文档标题')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Forms\Components\Textarea::make('description')
|
||||||
|
->label('文档描述')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(65535)
|
||||||
|
->placeholder('请输入文档描述(可选)')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Forms\Components\FileUpload::make('file')
|
||||||
|
->label('文档文件')
|
||||||
|
->required()
|
||||||
|
->acceptedFileTypes(['application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'])
|
||||||
|
->maxSize(51200) // 50MB
|
||||||
|
->disk('local')
|
||||||
|
->directory('documents/' . date('Y/m/d'))
|
||||||
|
->visibility('private')
|
||||||
|
->downloadable()
|
||||||
|
->preserveFilenames() // 保留原始文件名
|
||||||
|
->helperText('仅支持 .doc 和 .docx 格式,最大 50MB')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Forms\Components\Select::make('type')
|
||||||
|
->label('文档类型')
|
||||||
|
->required()
|
||||||
|
->options([
|
||||||
|
'global' => '全局知识库',
|
||||||
|
'dedicated' => '专用知识库',
|
||||||
|
])
|
||||||
|
->default('global')
|
||||||
|
->reactive()
|
||||||
|
->afterStateUpdated(fn ($state, callable $set) =>
|
||||||
|
$state === 'global' ? $set('group_id', null) : null
|
||||||
|
)
|
||||||
|
->helperText('全局知识库所有用户可见,专用知识库仅指定分组可见'),
|
||||||
|
|
||||||
|
Forms\Components\Select::make('group_id')
|
||||||
|
->label('所属分组')
|
||||||
|
->relationship('group', 'name')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->required(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
|
||||||
|
->visible(fn (Forms\Get $get): bool => $get('type') === 'dedicated')
|
||||||
|
->helperText('专用知识库必须选择所属分组'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('title')
|
||||||
|
->label('文档标题')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->limit(50)
|
||||||
|
->tooltip(function (Tables\Columns\TextColumn $column): ?string {
|
||||||
|
$state = $column->getState();
|
||||||
|
if (strlen($state) > 50) {
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('type')
|
||||||
|
->label('文档类型')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'global' => 'success',
|
||||||
|
'dedicated' => 'warning',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||||
|
'global' => '全局知识库',
|
||||||
|
'dedicated' => '专用知识库',
|
||||||
|
default => $state,
|
||||||
|
})
|
||||||
|
->sortable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('group.name')
|
||||||
|
->label('所属分组')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('uploader.name')
|
||||||
|
->label('上传者')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('file_size')
|
||||||
|
->label('文件大小')
|
||||||
|
->formatStateUsing(fn ($state): string => self::formatFileSize($state))
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('conversion_status')
|
||||||
|
->label('转换状态')
|
||||||
|
->badge()
|
||||||
|
->color(fn (?string $state): string => match ($state) {
|
||||||
|
'completed' => 'success',
|
||||||
|
'processing' => 'info',
|
||||||
|
'pending' => 'warning',
|
||||||
|
'failed' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||||
|
'completed' => '已完成',
|
||||||
|
'processing' => '转换中',
|
||||||
|
'pending' => '等待转换',
|
||||||
|
'failed' => '转换失败',
|
||||||
|
default => '未知',
|
||||||
|
})
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('上传时间')
|
||||||
|
->dateTime('Y年m月d日 H:i')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('更新时间')
|
||||||
|
->dateTime('Y年m月d日 H:i')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('type')
|
||||||
|
->label('文档类型')
|
||||||
|
->options([
|
||||||
|
'global' => '全局知识库',
|
||||||
|
'dedicated' => '专用知识库',
|
||||||
|
])
|
||||||
|
->placeholder('全部类型'),
|
||||||
|
|
||||||
|
Tables\Filters\SelectFilter::make('group_id')
|
||||||
|
->label('所属分组')
|
||||||
|
->relationship('group', 'name')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->placeholder('全部分组'),
|
||||||
|
|
||||||
|
Tables\Filters\SelectFilter::make('uploaded_by')
|
||||||
|
->label('上传者')
|
||||||
|
->relationship('uploader', 'name')
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->placeholder('全部上传者'),
|
||||||
|
|
||||||
|
Tables\Filters\SelectFilter::make('conversion_status')
|
||||||
|
->label('转换状态')
|
||||||
|
->options([
|
||||||
|
'pending' => '等待转换',
|
||||||
|
'processing' => '转换中',
|
||||||
|
'completed' => '已完成',
|
||||||
|
'failed' => '转换失败',
|
||||||
|
])
|
||||||
|
->placeholder('全部状态'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\Action::make('preview')
|
||||||
|
->label('预览 Markdown')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('info')
|
||||||
|
->visible(fn (Document $record): bool => $record->conversion_status === 'completed')
|
||||||
|
->url(fn (Document $record): string => route('documents.preview', $record))
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->tooltip(fn (Document $record): ?string =>
|
||||||
|
$record->conversion_status !== 'completed'
|
||||||
|
? '文档尚未完成转换'
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
Tables\Actions\Action::make('download')
|
||||||
|
->label('下载')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('success')
|
||||||
|
->action(function (Document $record) {
|
||||||
|
$documentService = app(\App\Services\DocumentService::class);
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 记录下载日志
|
||||||
|
$documentService->logDownload($record, $user);
|
||||||
|
|
||||||
|
// 返回文件下载响应
|
||||||
|
return $documentService->downloadDocument($record, $user);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Filament\Notifications\Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title('下载失败')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Tables\Actions\ViewAction::make()
|
||||||
|
->label('查看'),
|
||||||
|
Tables\Actions\EditAction::make()
|
||||||
|
->label('编辑'),
|
||||||
|
Tables\Actions\DeleteAction::make()
|
||||||
|
->label('删除'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DeleteBulkAction::make()
|
||||||
|
->label('批量删除'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
*/
|
||||||
|
public static function formatFileSize(?int $bytes): string
|
||||||
|
{
|
||||||
|
if ($bytes === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
$bytes = max($bytes, 0);
|
||||||
|
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||||
|
$pow = min($pow, count($units) - 1);
|
||||||
|
$bytes /= (1 << (10 * $pow));
|
||||||
|
|
||||||
|
return round($bytes, 2) . ' ' . $units[$pow];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListDocuments::route('/'),
|
||||||
|
'create' => Pages\CreateDocument::route('/create'),
|
||||||
|
'view' => Pages\ViewDocument::route('/{record}'),
|
||||||
|
'edit' => Pages\EditDocument::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DocumentResource;
|
||||||
|
use App\Services\DocumentService;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class CreateDocument extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = DocumentResource::class;
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
// 设置上传者为当前用户
|
||||||
|
$data['uploaded_by'] = Auth::id();
|
||||||
|
|
||||||
|
// 如果是全局文档,确保 group_id 为 null
|
||||||
|
if ($data['type'] === 'global') {
|
||||||
|
$data['group_id'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件上传
|
||||||
|
if (isset($data['file'])) {
|
||||||
|
$filePath = $data['file'];
|
||||||
|
|
||||||
|
// 获取原始文件名(由于使用了 preserveFilenames(),basename 就是原始文件名)
|
||||||
|
$originalFileName = basename($filePath);
|
||||||
|
|
||||||
|
// 保存文件信息
|
||||||
|
$data['file_path'] = $filePath;
|
||||||
|
$data['file_name'] = $originalFileName; // 保存原始文件名
|
||||||
|
$data['file_size'] = Storage::disk('local')->size($filePath);
|
||||||
|
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
||||||
|
|
||||||
|
// 移除临时的 file 字段
|
||||||
|
unset($data['file']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
// 文档创建后,触发转换任务
|
||||||
|
$conversionService = app(\App\Services\DocumentConversionService::class);
|
||||||
|
$conversionService->queueConversion($this->record);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCreatedNotification(): ?Notification
|
||||||
|
{
|
||||||
|
return Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('文档上传成功')
|
||||||
|
->body('文档已成功上传到知识库。');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DocumentResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class EditDocument extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = DocumentResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\ViewAction::make()
|
||||||
|
->label('查看'),
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->label('删除'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeFill(array $data): array
|
||||||
|
{
|
||||||
|
// 将文件路径设置到 file 字段以便显示
|
||||||
|
if (isset($data['file_path'])) {
|
||||||
|
$data['file'] = $data['file_path'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
// 如果是全局文档,确保 group_id 为 null
|
||||||
|
if ($data['type'] === 'global') {
|
||||||
|
$data['group_id'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件更新
|
||||||
|
if (isset($data['file']) && $data['file'] !== $this->record->file_path) {
|
||||||
|
$filePath = $data['file'];
|
||||||
|
|
||||||
|
// 删除旧的 Word 文件
|
||||||
|
if ($this->record->file_path && Storage::disk('local')->exists($this->record->file_path)) {
|
||||||
|
Storage::disk('local')->delete($this->record->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧的 Markdown 文件
|
||||||
|
if ($this->record->markdown_path && Storage::disk('markdown')->exists($this->record->markdown_path)) {
|
||||||
|
Storage::disk('markdown')->delete($this->record->markdown_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取原始文件名(由于使用了 preserveFilenames(),basename 就是原始文件名)
|
||||||
|
$originalFileName = basename($filePath);
|
||||||
|
|
||||||
|
// 更新文件信息
|
||||||
|
$data['file_path'] = $filePath;
|
||||||
|
$data['file_name'] = $originalFileName; // 保存原始文件名
|
||||||
|
$data['file_size'] = Storage::disk('local')->size($filePath);
|
||||||
|
$data['mime_type'] = Storage::disk('local')->mimeType($filePath);
|
||||||
|
|
||||||
|
// 重置转换状态,准备重新转换
|
||||||
|
$data['conversion_status'] = 'pending';
|
||||||
|
$data['markdown_path'] = null;
|
||||||
|
$data['markdown_preview'] = null;
|
||||||
|
$data['conversion_error'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除临时的 file 字段
|
||||||
|
unset($data['file']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
// 如果文档的转换状态是 pending,说明文件已更新,需要触发重新转换
|
||||||
|
if ($this->record->conversion_status === 'pending') {
|
||||||
|
$conversionService = app(\App\Services\DocumentConversionService::class);
|
||||||
|
$conversionService->queueConversion($this->record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSavedNotification(): ?Notification
|
||||||
|
{
|
||||||
|
return Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('文档更新成功')
|
||||||
|
->body('文档信息已成功更新。');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DocumentResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListDocuments extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = DocumentResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make()
|
||||||
|
->label('上传文档'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
133
app/Filament/Resources/DocumentResource/Pages/ViewDocument.php
Normal file
133
app/Filament/Resources/DocumentResource/Pages/ViewDocument.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\DocumentResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\DocumentResource;
|
||||||
|
use App\Services\DocumentPreviewService;
|
||||||
|
use App\Services\DocumentService;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Infolists\Components\Section;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Infolists\Infolist;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewDocument extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = DocumentResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\Action::make('preview')
|
||||||
|
->label('预览 Markdown')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->color('info')
|
||||||
|
->visible(fn (): bool => $this->record->conversion_status === 'completed')
|
||||||
|
->url(fn (): string => route('documents.preview', $this->record))
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->tooltip(fn (): ?string =>
|
||||||
|
$this->record->conversion_status !== 'completed'
|
||||||
|
? '文档尚未完成转换'
|
||||||
|
: null
|
||||||
|
),
|
||||||
|
Actions\Action::make('download')
|
||||||
|
->label('下载文档')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->color('success')
|
||||||
|
->action(function () {
|
||||||
|
$documentService = app(DocumentService::class);
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 记录下载日志
|
||||||
|
$documentService->logDownload($this->record, $user);
|
||||||
|
|
||||||
|
// 返回文件下载响应
|
||||||
|
return $documentService->downloadDocument($this->record, $user);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Notification::make()
|
||||||
|
->danger()
|
||||||
|
->title('下载失败')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Actions\EditAction::make()
|
||||||
|
->label('编辑'),
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->label('删除'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function infolist(Infolist $infolist): Infolist
|
||||||
|
{
|
||||||
|
return $infolist
|
||||||
|
->schema([
|
||||||
|
Section::make('文档信息')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('title')
|
||||||
|
->label('文档标题')
|
||||||
|
->size(TextEntry\TextEntrySize::Large)
|
||||||
|
->weight('bold'),
|
||||||
|
|
||||||
|
TextEntry::make('description')
|
||||||
|
->label('文档描述')
|
||||||
|
->placeholder('无描述')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
TextEntry::make('type')
|
||||||
|
->label('文档类型')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'global' => 'success',
|
||||||
|
'dedicated' => 'warning',
|
||||||
|
})
|
||||||
|
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||||
|
'global' => '全局知识库',
|
||||||
|
'dedicated' => '专用知识库',
|
||||||
|
default => $state,
|
||||||
|
}),
|
||||||
|
|
||||||
|
TextEntry::make('group.name')
|
||||||
|
->label('所属分组')
|
||||||
|
->placeholder('—')
|
||||||
|
->visible(fn ($record) => $record->type === 'dedicated'),
|
||||||
|
|
||||||
|
TextEntry::make('uploader.name')
|
||||||
|
->label('上传者'),
|
||||||
|
|
||||||
|
TextEntry::make('file_name')
|
||||||
|
->label('文件名'),
|
||||||
|
|
||||||
|
TextEntry::make('file_size')
|
||||||
|
->label('文件大小')
|
||||||
|
->formatStateUsing(fn ($state): string => DocumentResource::formatFileSize($state)),
|
||||||
|
|
||||||
|
TextEntry::make('created_at')
|
||||||
|
->label('上传时间')
|
||||||
|
->dateTime('Y年m月d日 H:i:s'),
|
||||||
|
|
||||||
|
TextEntry::make('updated_at')
|
||||||
|
->label('更新时间')
|
||||||
|
->dateTime('Y年m月d日 H:i:s'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
|
||||||
|
Section::make('文档预览')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('preview')
|
||||||
|
->label('')
|
||||||
|
->view('filament.resources.document.preview')
|
||||||
|
->viewData([
|
||||||
|
'document' => $this->record,
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->collapsible()
|
||||||
|
->collapsed(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/Filament/Resources/GroupResource.php
Normal file
114
app/Filament/Resources/GroupResource.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\GroupResource\Pages;
|
||||||
|
use App\Filament\Resources\GroupResource\RelationManagers;
|
||||||
|
use App\Models\Group;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
|
||||||
|
class GroupResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Group::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-user-group';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = '分组管理';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = '分组';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = '分组';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
public static function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label('分组名称')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->placeholder('请输入分组名称'),
|
||||||
|
Forms\Components\Textarea::make('description')
|
||||||
|
->label('分组描述')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(65535)
|
||||||
|
->placeholder('请输入分组描述(可选)')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('id')
|
||||||
|
->label('ID')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('分组名称')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('description')
|
||||||
|
->label('分组描述')
|
||||||
|
->limit(50)
|
||||||
|
->searchable()
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('users_count')
|
||||||
|
->label('成员数量')
|
||||||
|
->counts('users')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('创建时间')
|
||||||
|
->dateTime('Y-m-d H:i:s')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('更新时间')
|
||||||
|
->dateTime('Y-m-d H:i:s')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\ViewAction::make()
|
||||||
|
->label('查看'),
|
||||||
|
Tables\Actions\EditAction::make()
|
||||||
|
->label('编辑'),
|
||||||
|
Tables\Actions\DeleteAction::make()
|
||||||
|
->label('删除'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DeleteBulkAction::make()
|
||||||
|
->label('批量删除'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\UsersRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListGroups::route('/'),
|
||||||
|
'create' => Pages\CreateGroup::route('/create'),
|
||||||
|
'edit' => Pages\EditGroup::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Filament/Resources/GroupResource/Pages/CreateGroup.php
Normal file
24
app/Filament/Resources/GroupResource/Pages/CreateGroup.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\GroupResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\GroupResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateGroup extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = GroupResource::class;
|
||||||
|
|
||||||
|
protected static ?string $title = '创建分组';
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCreatedNotificationTitle(): ?string
|
||||||
|
{
|
||||||
|
return '分组创建成功';
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Filament/Resources/GroupResource/Pages/EditGroup.php
Normal file
31
app/Filament/Resources/GroupResource/Pages/EditGroup.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\GroupResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\GroupResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditGroup extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = GroupResource::class;
|
||||||
|
|
||||||
|
protected static ?string $title = '编辑分组';
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->label('删除')
|
||||||
|
->modalHeading('删除分组')
|
||||||
|
->modalDescription('确定要删除此分组吗?此操作无法撤销。')
|
||||||
|
->modalSubmitActionLabel('确认删除')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSavedNotificationTitle(): ?string
|
||||||
|
{
|
||||||
|
return '分组更新成功';
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Filament/Resources/GroupResource/Pages/ListGroups.php
Normal file
22
app/Filament/Resources/GroupResource/Pages/ListGroups.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\GroupResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\GroupResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListGroups extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = GroupResource::class;
|
||||||
|
|
||||||
|
protected static ?string $title = '分组列表';
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make()
|
||||||
|
->label('创建分组'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\GroupResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
|
||||||
|
class UsersRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'users';
|
||||||
|
|
||||||
|
protected static ?string $title = '分组成员';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = '用户';
|
||||||
|
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label('用户名称')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\TextInput::make('email')
|
||||||
|
->label('邮箱')
|
||||||
|
->email()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('name')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('id')
|
||||||
|
->label('ID')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('用户名称')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('email')
|
||||||
|
->label('邮箱')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('加入时间')
|
||||||
|
->dateTime('Y-m-d H:i:s')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
Tables\Actions\AttachAction::make()
|
||||||
|
->label('添加成员')
|
||||||
|
->preloadRecordSelect()
|
||||||
|
->modalHeading('添加分组成员')
|
||||||
|
->modalSubmitActionLabel('添加')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\DetachAction::make()
|
||||||
|
->label('移除')
|
||||||
|
->modalHeading('移除分组成员')
|
||||||
|
->modalDescription('确定要将此用户从分组中移除吗?')
|
||||||
|
->modalSubmitActionLabel('确认移除')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DetachBulkAction::make()
|
||||||
|
->label('批量移除')
|
||||||
|
->modalHeading('批量移除分组成员')
|
||||||
|
->modalDescription('确定要将选中的用户从分组中移除吗?')
|
||||||
|
->modalSubmitActionLabel('确认移除')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Filament/Resources/UserResource.php
Normal file
129
app/Filament/Resources/UserResource.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\UserResource\Pages;
|
||||||
|
use App\Filament\Resources\UserResource\RelationManagers;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
|
||||||
|
class UserResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = User::class;
|
||||||
|
|
||||||
|
protected static ?string $navigationIcon = 'heroicon-o-users';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = '用户管理';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = '用户';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = '用户';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label('用户名称')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->placeholder('请输入用户名称'),
|
||||||
|
Forms\Components\TextInput::make('email')
|
||||||
|
->label('邮箱')
|
||||||
|
->email()
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->placeholder('请输入邮箱地址'),
|
||||||
|
Forms\Components\TextInput::make('password')
|
||||||
|
->label('密码')
|
||||||
|
->password()
|
||||||
|
->required(fn (string $context): bool => $context === 'create')
|
||||||
|
->dehydrated(fn ($state) => filled($state))
|
||||||
|
->minLength(8)
|
||||||
|
->placeholder('请输入密码(至少8位)')
|
||||||
|
->helperText('编辑时留空表示不修改密码'),
|
||||||
|
Forms\Components\Select::make('groups')
|
||||||
|
->label('所属分组')
|
||||||
|
->multiple()
|
||||||
|
->relationship('groups', 'name')
|
||||||
|
->preload()
|
||||||
|
->placeholder('请选择用户所属的分组')
|
||||||
|
->helperText('用户可以属于多个分组'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('id')
|
||||||
|
->label('ID')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('用户名称')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('email')
|
||||||
|
->label('邮箱')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('groups.name')
|
||||||
|
->label('所属分组')
|
||||||
|
->badge()
|
||||||
|
->searchable()
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('创建时间')
|
||||||
|
->dateTime('Y-m-d H:i:s')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(),
|
||||||
|
Tables\Columns\TextColumn::make('updated_at')
|
||||||
|
->label('更新时间')
|
||||||
|
->dateTime('Y-m-d H:i:s')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\ViewAction::make()
|
||||||
|
->label('查看'),
|
||||||
|
Tables\Actions\EditAction::make()
|
||||||
|
->label('编辑'),
|
||||||
|
Tables\Actions\DeleteAction::make()
|
||||||
|
->label('删除'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DeleteBulkAction::make()
|
||||||
|
->label('批量删除'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->defaultSort('created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
RelationManagers\GroupsRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => Pages\ListUsers::route('/'),
|
||||||
|
'create' => Pages\CreateUser::route('/create'),
|
||||||
|
'edit' => Pages\EditUser::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
22
app/Filament/Resources/UserResource/Pages/CreateUser.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\UserResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateUser extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getCreatedNotificationTitle(): ?string
|
||||||
|
{
|
||||||
|
return '用户创建成功';
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
29
app/Filament/Resources/UserResource/Pages/EditUser.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\UserResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditUser extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->label('删除')
|
||||||
|
->modalHeading('删除用户')
|
||||||
|
->modalDescription('确定要删除此用户吗?此操作无法撤销。')
|
||||||
|
->modalSubmitActionLabel('确认删除')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getSavedNotificationTitle(): ?string
|
||||||
|
{
|
||||||
|
return '用户更新成功';
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
20
app/Filament/Resources/UserResource/Pages/ListUsers.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\UserResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListUsers extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make()
|
||||||
|
->label('创建用户'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\UserResource\RelationManagers;
|
||||||
|
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Forms\Form;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
|
||||||
|
class GroupsRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'groups';
|
||||||
|
|
||||||
|
protected static ?string $title = '用户分组';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = '分组';
|
||||||
|
|
||||||
|
public function form(Form $form): Form
|
||||||
|
{
|
||||||
|
return $form
|
||||||
|
->schema([
|
||||||
|
Forms\Components\TextInput::make('name')
|
||||||
|
->label('分组名称')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Forms\Components\Textarea::make('description')
|
||||||
|
->label('分组描述')
|
||||||
|
->rows(3)
|
||||||
|
->maxLength(65535),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('name')
|
||||||
|
->columns([
|
||||||
|
Tables\Columns\TextColumn::make('id')
|
||||||
|
->label('ID')
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('name')
|
||||||
|
->label('分组名称')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
Tables\Columns\TextColumn::make('description')
|
||||||
|
->label('分组描述')
|
||||||
|
->limit(50)
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('created_at')
|
||||||
|
->label('加入时间')
|
||||||
|
->dateTime('Y-m-d H:i:s')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
Tables\Actions\AttachAction::make()
|
||||||
|
->label('添加分组')
|
||||||
|
->preloadRecordSelect()
|
||||||
|
->modalHeading('添加用户到分组')
|
||||||
|
->modalSubmitActionLabel('添加')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Tables\Actions\DetachAction::make()
|
||||||
|
->label('移除')
|
||||||
|
->modalHeading('移除用户分组')
|
||||||
|
->modalDescription('确定要将此用户从该分组中移除吗?')
|
||||||
|
->modalSubmitActionLabel('确认移除')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
Tables\Actions\DetachBulkAction::make()
|
||||||
|
->label('批量移除')
|
||||||
|
->modalHeading('批量移除用户分组')
|
||||||
|
->modalDescription('确定要将此用户从选中的分组中移除吗?')
|
||||||
|
->modalSubmitActionLabel('确认移除')
|
||||||
|
->modalCancelActionLabel('取消'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
98
app/Http/Controllers/DocumentController.php
Normal file
98
app/Http/Controllers/DocumentController.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Services\DocumentService;
|
||||||
|
use App\Services\MarkdownRenderService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class DocumentController extends Controller
|
||||||
|
{
|
||||||
|
protected DocumentService $documentService;
|
||||||
|
protected MarkdownRenderService $markdownRenderService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
DocumentService $documentService,
|
||||||
|
MarkdownRenderService $markdownRenderService
|
||||||
|
) {
|
||||||
|
$this->documentService = $documentService;
|
||||||
|
$this->markdownRenderService = $markdownRenderService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览文档的 Markdown 内容(支持图片显示)
|
||||||
|
* 需求:11.1, 11.3, 11.4
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return \Illuminate\View\View|\Illuminate\Http\RedirectResponse
|
||||||
|
*/
|
||||||
|
public function preview(Document $document)
|
||||||
|
{
|
||||||
|
// 验证用户权限(使用 DocumentPolicy)
|
||||||
|
// 需求:11.3
|
||||||
|
if (!Gate::allows('view', $document)) {
|
||||||
|
abort(403, '您没有权限预览此文档');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文档是否已完成转换
|
||||||
|
if ($document->conversion_status !== 'completed') {
|
||||||
|
return view('documents.preview', [
|
||||||
|
'document' => $document,
|
||||||
|
'markdownHtml' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdownHtml = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 DocumentPreviewService 的 Markdown 预览方法
|
||||||
|
// 这会修复图片路径并渲染 Markdown
|
||||||
|
// 需求:11.1
|
||||||
|
$previewService = app(\App\Services\DocumentPreviewService::class);
|
||||||
|
$markdownHtml = $previewService->convertMarkdownToHtml($document);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 记录错误但不中断流程
|
||||||
|
\Log::error('Markdown 预览失败', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理内容为空的情况
|
||||||
|
// 需求:11.4
|
||||||
|
// 返回渲染后的 HTML 视图
|
||||||
|
return view('documents.preview', [
|
||||||
|
'document' => $document,
|
||||||
|
'markdownHtml' => $markdownHtml,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文档
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return \Symfony\Component\HttpFoundation\StreamedResponse
|
||||||
|
*/
|
||||||
|
public function download(Document $document)
|
||||||
|
{
|
||||||
|
// 验证用户权限
|
||||||
|
if (!Gate::allows('download', $document)) {
|
||||||
|
abort(403, '您没有权限下载此文档');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 记录下载日志
|
||||||
|
$this->documentService->logDownload($document, $user);
|
||||||
|
|
||||||
|
// 返回文件下载响应
|
||||||
|
return $this->documentService->downloadDocument($document, $user);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
abort(500, '下载失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/Jobs/ConvertDocumentToMarkdown.php
Normal file
177
app/Jobs/ConvertDocumentToMarkdown.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Services\DocumentConversionService;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档转换为 Markdown 的队列任务
|
||||||
|
*/
|
||||||
|
class ConvertDocumentToMarkdown implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务最大尝试次数
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $tries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务超时时间(秒)
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $timeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试延迟(秒)
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $backoff;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档实例
|
||||||
|
*
|
||||||
|
* @var Document
|
||||||
|
*/
|
||||||
|
protected Document $document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新的任务实例
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
*/
|
||||||
|
public function __construct(Document $document)
|
||||||
|
{
|
||||||
|
$this->document = $document;
|
||||||
|
$this->tries = config('documents.conversion.retry_times', 3);
|
||||||
|
$this->timeout = config('documents.conversion.timeout', 300);
|
||||||
|
$this->backoff = config('documents.conversion.retry_delay', 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行任务
|
||||||
|
*
|
||||||
|
* @param DocumentConversionService $conversionService
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handle(DocumentConversionService $conversionService): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Log::info('开始转换文档', [
|
||||||
|
'document_id' => $this->document->id,
|
||||||
|
'document_title' => $this->document->title,
|
||||||
|
'attempt' => $this->attempts(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 转换文档为 Markdown
|
||||||
|
$result = $conversionService->convertToMarkdown($this->document);
|
||||||
|
$markdown = $result['markdown'];
|
||||||
|
$mediaDir = $result['mediaDir'] ?? null;
|
||||||
|
$tempDir = $result['tempDir'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 保存 Markdown 文件和媒体文件
|
||||||
|
$markdownPath = $conversionService->saveMarkdownToFile($this->document, $markdown, $mediaDir);
|
||||||
|
|
||||||
|
// 更新文档的 Markdown 信息
|
||||||
|
$conversionService->updateDocumentMarkdown($this->document, $markdownPath);
|
||||||
|
} finally {
|
||||||
|
// 清理临时目录
|
||||||
|
if (isset($tempDir) && file_exists($tempDir)) {
|
||||||
|
$this->deleteDirectory($tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('文档转换成功', [
|
||||||
|
'document_id' => $this->document->id,
|
||||||
|
'document_title' => $this->document->title,
|
||||||
|
'markdown_path' => $markdownPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 转换成功后,触发索引(如果需要)
|
||||||
|
// 这将在后续任务中实现
|
||||||
|
// $this->document->searchable();
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('文档转换失败', [
|
||||||
|
'document_id' => $this->document->id,
|
||||||
|
'document_title' => $this->document->title,
|
||||||
|
'attempt' => $this->attempts(),
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 如果已达到最大重试次数,标记为失败
|
||||||
|
if ($this->attempts() >= $this->tries) {
|
||||||
|
$conversionService->handleConversionFailure($this->document, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新抛出异常以触发重试
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务失败时的处理
|
||||||
|
*
|
||||||
|
* @param \Throwable $exception
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('文档转换任务最终失败', [
|
||||||
|
'document_id' => $this->document->id,
|
||||||
|
'document_title' => $this->document->title,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 确保文档状态被标记为失败
|
||||||
|
$conversionService = app(DocumentConversionService::class);
|
||||||
|
$conversionService->handleConversionFailure(
|
||||||
|
$this->document,
|
||||||
|
$exception instanceof \Exception ? $exception : new \Exception($exception->getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归删除目录
|
||||||
|
*
|
||||||
|
* @param string $dir 目录路径
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function deleteDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!file_exists($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
unlink($dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$path = $dir . '/' . $file;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$this->deleteDirectory($path);
|
||||||
|
} else {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
176
app/Models/Document.php
Normal file
176
app/Models/Document.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Laravel\Scout\Searchable;
|
||||||
|
|
||||||
|
class Document extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, Searchable;
|
||||||
|
/**
|
||||||
|
* 可批量赋值的属性
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'file_path',
|
||||||
|
'file_name',
|
||||||
|
'file_size',
|
||||||
|
'mime_type',
|
||||||
|
'type',
|
||||||
|
'group_id',
|
||||||
|
'uploaded_by',
|
||||||
|
'description',
|
||||||
|
'markdown_path',
|
||||||
|
'markdown_preview',
|
||||||
|
'conversion_status',
|
||||||
|
'conversion_error',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档所属的分组
|
||||||
|
*/
|
||||||
|
public function group(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Group::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档的上传者
|
||||||
|
*/
|
||||||
|
public function uploader(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'uploaded_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档的所有下载日志
|
||||||
|
*/
|
||||||
|
public function downloadLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DownloadLog::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询作用域:获取用户可访问的文档
|
||||||
|
* 包含全局文档和用户分组的专用文档,排除其他分组的专用文档
|
||||||
|
*
|
||||||
|
* @param Builder $query
|
||||||
|
* @param User $user
|
||||||
|
* @return Builder
|
||||||
|
*/
|
||||||
|
public function scopeAccessibleBy(Builder $query, User $user): Builder
|
||||||
|
{
|
||||||
|
// 获取用户所属的所有分组 ID
|
||||||
|
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
|
||||||
|
|
||||||
|
return $query->where(function (Builder $query) use ($userGroupIds) {
|
||||||
|
// 包含所有全局文档
|
||||||
|
$query->where('type', 'global')
|
||||||
|
// 或者包含用户所属分组的专用文档
|
||||||
|
->orWhere(function (Builder $query) use ($userGroupIds) {
|
||||||
|
$query->where('type', 'dedicated')
|
||||||
|
->whereIn('group_id', $userGroupIds);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询作用域:仅获取全局文档
|
||||||
|
*
|
||||||
|
* @param Builder $query
|
||||||
|
* @return Builder
|
||||||
|
*/
|
||||||
|
public function scopeGlobal(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('type', 'global');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询作用域:仅获取专用文档
|
||||||
|
*
|
||||||
|
* @param Builder $query
|
||||||
|
* @return Builder
|
||||||
|
*/
|
||||||
|
public function scopeDedicated(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('type', 'dedicated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可搜索的数组数据
|
||||||
|
* 用于 Meilisearch 索引
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function toSearchableArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->title,
|
||||||
|
'file_name' => $this->file_name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'markdown_content' => $this->getMarkdownContent(),
|
||||||
|
'type' => $this->type,
|
||||||
|
'group_id' => $this->group_id,
|
||||||
|
'uploaded_by' => $this->uploaded_by,
|
||||||
|
'created_at' => $this->created_at?->timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断文档是否应该被索引
|
||||||
|
* 只有转换完成的文档才会被索引
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function shouldBeSearchable(): bool
|
||||||
|
{
|
||||||
|
return $this->conversion_status === 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取完整的 Markdown 内容
|
||||||
|
* 从文件系统读取 Markdown 文件
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getMarkdownContent(): ?string
|
||||||
|
{
|
||||||
|
if (!$this->markdown_path) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Storage::disk('markdown')->exists($this->markdown_path)) {
|
||||||
|
return Storage::disk('markdown')->get($this->markdown_path);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 记录错误但不抛出异常
|
||||||
|
\Log::warning('Failed to read markdown content', [
|
||||||
|
'document_id' => $this->id,
|
||||||
|
'markdown_path' => $this->markdown_path,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文档是否已转换为 Markdown
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasMarkdown(): bool
|
||||||
|
{
|
||||||
|
return !empty($this->markdown_path) && $this->conversion_status === 'completed';
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Models/DownloadLog.php
Normal file
56
app/Models/DownloadLog.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DownloadLog extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
/**
|
||||||
|
* 表示模型不使用 created_at 和 updated_at 时间戳
|
||||||
|
* 因为我们使用自定义的 downloaded_at 字段
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可批量赋值的属性
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'document_id',
|
||||||
|
'user_id',
|
||||||
|
'downloaded_at',
|
||||||
|
'ip_address',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应该被转换为日期的属性
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'downloaded_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下载日志关联的文档
|
||||||
|
*/
|
||||||
|
public function document(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Document::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取下载日志关联的用户
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Models/Group.php
Normal file
53
app/Models/Group.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Group extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
/**
|
||||||
|
* 可批量赋值的属性
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型的启动方法
|
||||||
|
* 注册模型事件监听器
|
||||||
|
*/
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
// 监听分组删除事件
|
||||||
|
static::deleting(function (Group $group) {
|
||||||
|
// 将该分组的所有专用文档的 group_id 设置为 null(孤立状态)
|
||||||
|
$group->documents()->update(['group_id' => null]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分组的所有用户
|
||||||
|
*/
|
||||||
|
public function users(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分组的所有文档
|
||||||
|
*/
|
||||||
|
public function documents(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Document::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Models/User.php
Normal file
74
app/Models/User.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
|
class User extends Authenticatable
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'password',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'remember_token',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户所属的所有分组
|
||||||
|
*/
|
||||||
|
public function groups(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Group::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户上传的所有文档
|
||||||
|
*/
|
||||||
|
public function uploadedDocuments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Document::class, 'uploaded_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的所有下载日志
|
||||||
|
*/
|
||||||
|
public function downloadLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DownloadLog::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
app/Observers/DocumentObserver.php
Normal file
135
app/Observers/DocumentObserver.php
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Services\DocumentSearchService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档观察者
|
||||||
|
* 监听文档模型事件,自动管理 Meilisearch 索引
|
||||||
|
*/
|
||||||
|
class DocumentObserver
|
||||||
|
{
|
||||||
|
protected DocumentSearchService $searchService;
|
||||||
|
|
||||||
|
public function __construct(DocumentSearchService $searchService)
|
||||||
|
{
|
||||||
|
$this->searchService = $searchService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档 "created" 事件
|
||||||
|
* 注意:文档创建时不立即索引,等待转换完成后再索引
|
||||||
|
*/
|
||||||
|
public function created(Document $document): void
|
||||||
|
{
|
||||||
|
// 文档创建时不立即索引,因为 Markdown 内容还未生成
|
||||||
|
// 索引将在转换完成后通过 updated 事件触发
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档 "updated" 事件
|
||||||
|
* 当文档更新时,检查转换状态并更新索引
|
||||||
|
*/
|
||||||
|
public function updated(Document $document): void
|
||||||
|
{
|
||||||
|
// 检查转换状态是否变为 completed
|
||||||
|
if ($document->wasChanged('conversion_status') && $document->conversion_status === 'completed') {
|
||||||
|
// 转换完成,创建或更新索引
|
||||||
|
$this->searchService->indexDocument($document);
|
||||||
|
} elseif ($document->wasChanged(['title', 'description', 'markdown_path', 'type', 'group_id'])) {
|
||||||
|
// 其他重要字段更新时,也更新索引
|
||||||
|
$this->searchService->updateDocumentIndex($document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档 "deleting" 事件
|
||||||
|
* 在删除前清理相关文件
|
||||||
|
*/
|
||||||
|
public function deleting(Document $document): void
|
||||||
|
{
|
||||||
|
$this->cleanupDocumentFiles($document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档 "deleted" 事件
|
||||||
|
* 从 Meilisearch 中移除文档索引
|
||||||
|
*/
|
||||||
|
public function deleted(Document $document): void
|
||||||
|
{
|
||||||
|
$this->searchService->removeDocumentFromIndex($document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档 "restored" 事件
|
||||||
|
* 恢复文档时重新索引
|
||||||
|
*/
|
||||||
|
public function restored(Document $document): void
|
||||||
|
{
|
||||||
|
if ($document->shouldBeSearchable()) {
|
||||||
|
$this->searchService->indexDocument($document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档 "force deleting" 事件
|
||||||
|
* 在强制删除前清理相关文件
|
||||||
|
*/
|
||||||
|
public function forceDeleting(Document $document): void
|
||||||
|
{
|
||||||
|
$this->cleanupDocumentFiles($document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文档 "force deleted" 事件
|
||||||
|
* 强制删除时也要移除索引
|
||||||
|
*/
|
||||||
|
public function forceDeleted(Document $document): void
|
||||||
|
{
|
||||||
|
$this->searchService->removeDocumentFromIndex($document);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理文档相关的所有文件
|
||||||
|
* 包括原始文档文件、Markdown 文件和媒体文件
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function cleanupDocumentFiles(Document $document): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 删除原始文档文件
|
||||||
|
if ($document->file_path && \Storage::disk('local')->exists($document->file_path)) {
|
||||||
|
\Storage::disk('local')->delete($document->file_path);
|
||||||
|
\Log::info('已删除原始文档文件', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'file_path' => $document->file_path,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 Markdown 文件和整个文档目录(包括 media)
|
||||||
|
if ($document->markdown_path) {
|
||||||
|
// 获取文档目录(例如:2025/12/04/{uuid})
|
||||||
|
$documentDir = dirname($document->markdown_path);
|
||||||
|
|
||||||
|
// 删除整个文档目录(包括 Markdown 文件和 media 目录)
|
||||||
|
if (\Storage::disk('markdown')->exists($documentDir)) {
|
||||||
|
\Storage::disk('markdown')->deleteDirectory($documentDir);
|
||||||
|
\Log::info('已删除文档目录', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'directory' => $documentDir,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::error('清理文档文件失败', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
// 不抛出异常,避免影响删除操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Policies/DocumentPolicy.php
Normal file
201
app/Policies/DocumentPolicy.php
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\SecurityLogger;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class DocumentPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 安全日志记录器
|
||||||
|
*/
|
||||||
|
protected SecurityLogger $securityLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
*/
|
||||||
|
public function __construct(SecurityLogger $securityLogger)
|
||||||
|
{
|
||||||
|
$this->securityLogger = $securityLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以查看文档列表
|
||||||
|
* 所有已认证用户都可以查看文档列表(但列表会根据权限过滤)
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
// 所有已认证用户都可以查看文档列表
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以查看特定文档
|
||||||
|
* 需求:3.1, 3.4, 7.1, 7.2, 7.3
|
||||||
|
* - 全局文档:所有用户都可以查看
|
||||||
|
* - 专用文档:只有所属分组的用户可以查看
|
||||||
|
* - 记录未授权访问尝试
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param Document $document
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function view(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
// 如果是全局文档,所有用户都可以查看
|
||||||
|
if ($document->type === 'global') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是专用文档,检查用户是否属于该文档的分组
|
||||||
|
if ($document->type === 'dedicated') {
|
||||||
|
// 如果文档没有关联分组,拒绝访问
|
||||||
|
if (!$document->group_id) {
|
||||||
|
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否属于该文档的分组
|
||||||
|
$hasAccess = $user->groups()->where('groups.id', $document->group_id)->exists();
|
||||||
|
|
||||||
|
// 如果没有权限,记录未授权访问尝试
|
||||||
|
if (!$hasAccess) {
|
||||||
|
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hasAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他情况拒绝访问
|
||||||
|
$this->securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以创建文档
|
||||||
|
* 假设所有已认证用户都可以创建文档(可根据实际需求调整)
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
// 所有已认证用户都可以创建文档
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以更新文档
|
||||||
|
* 只有文档的上传者可以更新文档(可根据实际需求调整为管理员也可以)
|
||||||
|
* 需求:7.3
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param Document $document
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function update(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
// 只有文档的上传者可以更新
|
||||||
|
$canUpdate = $document->uploaded_by === $user->id;
|
||||||
|
|
||||||
|
// 如果没有权限,记录未授权访问尝试
|
||||||
|
if (!$canUpdate) {
|
||||||
|
$this->securityLogger->logUnauthorizedAccess($user, $document, 'update');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $canUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以删除文档
|
||||||
|
* 只有文档的上传者可以删除文档(可根据实际需求调整为管理员也可以)
|
||||||
|
* 需求:7.3
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param Document $document
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function delete(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
// 只有文档的上传者可以删除
|
||||||
|
$canDelete = $document->uploaded_by === $user->id;
|
||||||
|
|
||||||
|
// 如果没有权限,记录未授权访问尝试
|
||||||
|
if (!$canDelete) {
|
||||||
|
$this->securityLogger->logUnauthorizedAccess($user, $document, 'delete');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $canDelete;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以下载文档
|
||||||
|
* 需求:4.1, 4.2, 7.1, 7.2, 7.3
|
||||||
|
* 下载权限与查看权限相同:
|
||||||
|
* - 全局文档:所有用户都可以下载
|
||||||
|
* - 专用文档:只有所属分组的用户可以下载
|
||||||
|
* - 记录未授权下载尝试
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param Document $document
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function download(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
// 下载权限与查看权限相同
|
||||||
|
$canDownload = $this->view($user, $document);
|
||||||
|
|
||||||
|
// 注意:view 方法已经记录了未授权访问,这里不需要重复记录
|
||||||
|
// 但如果需要区分 view 和 download 操作,可以在这里单独记录
|
||||||
|
|
||||||
|
return $canDownload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以恢复已删除的文档
|
||||||
|
* 需求:7.3
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param Document $document
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function restore(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
// 只有文档的上传者可以恢复
|
||||||
|
$canRestore = $document->uploaded_by === $user->id;
|
||||||
|
|
||||||
|
// 如果没有权限,记录未授权访问尝试
|
||||||
|
if (!$canRestore) {
|
||||||
|
$this->securityLogger->logUnauthorizedAccess($user, $document, 'restore');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $canRestore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户是否可以永久删除文档
|
||||||
|
* 需求:7.3
|
||||||
|
*
|
||||||
|
* @param User $user
|
||||||
|
* @param Document $document
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function forceDelete(User $user, Document $document): bool
|
||||||
|
{
|
||||||
|
// 只有文档的上传者可以永久删除
|
||||||
|
$canForceDelete = $document->uploaded_by === $user->id;
|
||||||
|
|
||||||
|
// 如果没有权限,记录未授权访问尝试
|
||||||
|
if (!$canForceDelete) {
|
||||||
|
$this->securityLogger->logUnauthorizedAccess($user, $document, 'forceDelete');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $canForceDelete;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Providers/AppServiceProvider.php
Normal file
31
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Observers\DocumentObserver;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// 配置 Carbon 使用中文
|
||||||
|
Carbon::setLocale('zh_CN');
|
||||||
|
|
||||||
|
// 注册文档观察者,用于自动管理 Meilisearch 索引
|
||||||
|
Document::observe(DocumentObserver::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Providers/Filament/AdminPanelProvider.php
Normal file
58
app/Providers/Filament/AdminPanelProvider.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
|
use Filament\Http\Middleware\Authenticate;
|
||||||
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
|
use Filament\Pages;
|
||||||
|
use Filament\Panel;
|
||||||
|
use Filament\PanelProvider;
|
||||||
|
use Filament\Support\Colors\Color;
|
||||||
|
use Filament\Widgets;
|
||||||
|
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
|
||||||
|
class AdminPanelProvider extends PanelProvider
|
||||||
|
{
|
||||||
|
public function panel(Panel $panel): Panel
|
||||||
|
{
|
||||||
|
return $panel
|
||||||
|
->default()
|
||||||
|
->id('admin')
|
||||||
|
->path('admin')
|
||||||
|
->login()
|
||||||
|
->colors([
|
||||||
|
'primary' => Color::Amber,
|
||||||
|
])
|
||||||
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||||
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||||
|
->pages([
|
||||||
|
Pages\Dashboard::class,
|
||||||
|
])
|
||||||
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||||
|
->widgets([
|
||||||
|
Widgets\AccountWidget::class,
|
||||||
|
Widgets\FilamentInfoWidget::class,
|
||||||
|
])
|
||||||
|
->middleware([
|
||||||
|
EncryptCookies::class,
|
||||||
|
AddQueuedCookiesToResponse::class,
|
||||||
|
StartSession::class,
|
||||||
|
AuthenticateSession::class,
|
||||||
|
ShareErrorsFromSession::class,
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
SubstituteBindings::class,
|
||||||
|
DisableBladeIconComponents::class,
|
||||||
|
DispatchServingFilamentEvent::class,
|
||||||
|
])
|
||||||
|
->authMiddleware([
|
||||||
|
Authenticate::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
371
app/Services/DocumentConversionService.php
Normal file
371
app/Services/DocumentConversionService.php
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Process;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档转换服务
|
||||||
|
* 负责将 Word 文档转换为 Markdown 格式
|
||||||
|
*/
|
||||||
|
class DocumentConversionService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 转换驱动
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected string $driver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pandoc 可执行文件路径
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected string $pandocPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换超时时间(秒)
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
protected int $timeout;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 预览长度
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
protected int $previewLength;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->driver = config('documents.conversion.driver', 'pandoc');
|
||||||
|
$this->pandocPath = config('documents.conversion.pandoc_path', 'pandoc');
|
||||||
|
$this->timeout = config('documents.conversion.timeout', 300);
|
||||||
|
$this->previewLength = config('documents.markdown.preview_length', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Word 文档转换为 Markdown
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null, 'tempDir' => string]
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function convertToMarkdown(Document $document): array
|
||||||
|
{
|
||||||
|
if ($this->driver === 'pandoc') {
|
||||||
|
return $this->convertWithPandoc($document);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \Exception("不支持的转换驱动: {$this->driver}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Pandoc 转换文档
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return array 返回 ['markdown' => string, 'mediaDir' => string|null]
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
protected function convertWithPandoc(Document $document): array
|
||||||
|
{
|
||||||
|
// 获取文档的完整路径
|
||||||
|
$documentPath = Storage::disk('local')->path($document->file_path);
|
||||||
|
|
||||||
|
if (!file_exists($documentPath)) {
|
||||||
|
throw new \Exception("文档文件不存在: {$documentPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建临时工作目录
|
||||||
|
$tempDir = sys_get_temp_dir() . '/pandoc_' . uniqid();
|
||||||
|
mkdir($tempDir, 0755, true);
|
||||||
|
|
||||||
|
$tempOutputPath = $tempDir . '/output.md';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 在临时目录中执行 Pandoc 转换命令
|
||||||
|
$result = Process::timeout($this->timeout)
|
||||||
|
->path($tempDir)
|
||||||
|
->run([
|
||||||
|
$this->pandocPath,
|
||||||
|
$documentPath,
|
||||||
|
'-f', $this->getInputFormat($document->mime_type),
|
||||||
|
'-t', 'markdown',
|
||||||
|
'-o', $tempOutputPath,
|
||||||
|
'--wrap=none', // 不自动换行
|
||||||
|
'--extract-media=.', // 提取媒体文件到当前目录
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$result->successful()) {
|
||||||
|
throw new \Exception("Pandoc 转换失败: {$result->errorOutput()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取转换后的 Markdown 内容
|
||||||
|
if (!file_exists($tempOutputPath)) {
|
||||||
|
throw new \Exception("转换后的 Markdown 文件不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
$markdown = file_get_contents($tempOutputPath);
|
||||||
|
|
||||||
|
if ($markdown === false) {
|
||||||
|
throw new \Exception("无法读取转换后的 Markdown 文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有提取的媒体文件
|
||||||
|
$mediaDir = $tempDir . '/media';
|
||||||
|
$hasMedia = is_dir($mediaDir) && count(glob($mediaDir . '/*')) > 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'markdown' => $markdown,
|
||||||
|
'mediaDir' => $hasMedia ? $mediaDir : null,
|
||||||
|
'tempDir' => $tempDir,
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 清理临时目录
|
||||||
|
$this->deleteDirectory($tempDir);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归删除目录
|
||||||
|
*
|
||||||
|
* @param string $dir 目录路径
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function deleteDirectory(string $dir): void
|
||||||
|
{
|
||||||
|
if (!file_exists($dir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
unlink($dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_diff(scandir($dir), ['.', '..']);
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$path = $dir . '/' . $file;
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$this->deleteDirectory($path);
|
||||||
|
} else {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 MIME 类型获取 Pandoc 输入格式
|
||||||
|
*
|
||||||
|
* @param string $mimeType
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function getInputFormat(string $mimeType): string
|
||||||
|
{
|
||||||
|
return match ($mimeType) {
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
|
||||||
|
'application/msword' => 'doc',
|
||||||
|
default => 'docx',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 内容和媒体文件保存到存储
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @param string $markdown
|
||||||
|
* @param string|null $mediaDir 临时媒体目录路径
|
||||||
|
* @return string 返回 Markdown 文件路径
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function saveMarkdownToFile(Document $document, string $markdown, ?string $mediaDir = null): string
|
||||||
|
{
|
||||||
|
// 生成文件路径
|
||||||
|
$path = $this->generateMarkdownPath($document);
|
||||||
|
$directory = dirname($path);
|
||||||
|
|
||||||
|
// 如果有媒体文件,先保存它们
|
||||||
|
if ($mediaDir && is_dir($mediaDir)) {
|
||||||
|
$this->saveMediaFiles($mediaDir, $directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 Markdown 文件
|
||||||
|
$saved = Storage::disk('markdown')->put($path, $markdown);
|
||||||
|
|
||||||
|
if (!$saved) {
|
||||||
|
throw new \Exception("无法保存 Markdown 文件");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存媒体文件到 storage
|
||||||
|
* 媒体文件保存在文档的 UUID 目录下的 media 子目录中
|
||||||
|
*
|
||||||
|
* @param string $sourceDir 源媒体目录
|
||||||
|
* @param string $targetDir 目标目录(相对于 markdown disk,例如:2025/12/04/{uuid})
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function saveMediaFiles(string $sourceDir, string $targetDir): void
|
||||||
|
{
|
||||||
|
$files = glob($sourceDir . '/*');
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (is_file($file)) {
|
||||||
|
$filename = basename($file);
|
||||||
|
// 保存到文档目录下的 media 子目录
|
||||||
|
$targetPath = $targetDir . '/media/' . $filename;
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
|
||||||
|
// 保存到 storage
|
||||||
|
Storage::disk('markdown')->put($targetPath, $content);
|
||||||
|
|
||||||
|
Log::info('媒体文件已保存', [
|
||||||
|
'filename' => $filename,
|
||||||
|
'path' => $targetPath,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Markdown 文件路径
|
||||||
|
* 使用 UUID 作为目录名,确保每个文档有独立的 media 目录
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function generateMarkdownPath(Document $document): string
|
||||||
|
{
|
||||||
|
$organizeByDate = config('documents.storage.organize_by_date', true);
|
||||||
|
|
||||||
|
// 生成唯一的 UUID 作为文档目录
|
||||||
|
$uuid = Str::uuid()->toString();
|
||||||
|
|
||||||
|
if ($organizeByDate) {
|
||||||
|
// 按日期组织: YYYY/MM/DD/{uuid}/{uuid}.md
|
||||||
|
$date = $document->created_at ?? now();
|
||||||
|
$directory = $date->format('Y/m/d') . '/' . $uuid;
|
||||||
|
} else {
|
||||||
|
// 直接使用 UUID: {uuid}/{uuid}.md
|
||||||
|
$directory = $uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件名也使用相同的 UUID
|
||||||
|
$filename = $uuid . '.md';
|
||||||
|
|
||||||
|
return "{$directory}/{$filename}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Markdown 内容的预览(前 N 个字符)
|
||||||
|
*
|
||||||
|
* @param string $markdown
|
||||||
|
* @param int|null $length
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getMarkdownPreview(string $markdown, ?int $length = null): string
|
||||||
|
{
|
||||||
|
$length = $length ?? $this->previewLength;
|
||||||
|
|
||||||
|
// 移除多余的空白字符
|
||||||
|
$cleaned = preg_replace('/\s+/', ' ', $markdown);
|
||||||
|
$cleaned = trim($cleaned);
|
||||||
|
|
||||||
|
// 截取指定长度
|
||||||
|
if (mb_strlen($cleaned) <= $length) {
|
||||||
|
return $cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mb_substr($cleaned, 0, $length) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文档的 Markdown 信息
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @param string $markdownPath
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updateDocumentMarkdown(Document $document, string $markdownPath): void
|
||||||
|
{
|
||||||
|
// 读取 Markdown 内容以生成预览
|
||||||
|
$markdown = Storage::disk('markdown')->get($markdownPath);
|
||||||
|
|
||||||
|
if ($markdown === false) {
|
||||||
|
Log::warning('无法读取 Markdown 文件以生成预览', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'markdown_path' => $markdownPath,
|
||||||
|
]);
|
||||||
|
$preview = '';
|
||||||
|
} else {
|
||||||
|
$preview = $this->getMarkdownPreview($markdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新文档记录
|
||||||
|
$document->update([
|
||||||
|
'markdown_path' => $markdownPath,
|
||||||
|
'markdown_preview' => $preview,
|
||||||
|
'conversion_status' => 'completed',
|
||||||
|
'conversion_error' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理转换失败
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @param \Exception $exception
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function handleConversionFailure(Document $document, \Exception $exception): void
|
||||||
|
{
|
||||||
|
Log::error('文档转换失败', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'document_title' => $document->title,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
'trace' => $exception->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 更新文档状态
|
||||||
|
$document->update([
|
||||||
|
'conversion_status' => 'failed',
|
||||||
|
'conversion_error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将转换任务加入队列
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function queueConversion(Document $document): void
|
||||||
|
{
|
||||||
|
// 更新文档状态为处理中
|
||||||
|
$document->update([
|
||||||
|
'conversion_status' => 'processing',
|
||||||
|
'conversion_error' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 分发队列任务
|
||||||
|
$queue = config('documents.conversion.queue', 'documents');
|
||||||
|
\App\Jobs\ConvertDocumentToMarkdown::dispatch($document)->onQueue($queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
296
app/Services/DocumentPreviewService.php
Normal file
296
app/Services/DocumentPreviewService.php
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use PhpOffice\PhpWord\IOFactory;
|
||||||
|
use PhpOffice\PhpWord\Settings;
|
||||||
|
|
||||||
|
class DocumentPreviewService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 将文档转换为 HTML 用于预览
|
||||||
|
* 在 Filament 后台中,直接从 Word 转换以保证图片正确显示
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return string HTML 内容
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function convertToHtml(Document $document): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 直接从 Word 转换,以确保图片正确显示
|
||||||
|
// Markdown 转换的图片路径问题较复杂,暂时不使用
|
||||||
|
return $this->convertWordToHtml($document);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \Exception('文档预览失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 转换为 HTML(用于专门的 Markdown 预览页面)
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return string HTML 内容
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function convertMarkdownToHtml(Document $document): string
|
||||||
|
{
|
||||||
|
$markdownContent = $document->getMarkdownContent();
|
||||||
|
|
||||||
|
if (empty($markdownContent)) {
|
||||||
|
throw new \Exception('Markdown 内容为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Markdown 文件的目录(例如:2025/12/04)
|
||||||
|
$markdownDir = dirname($document->markdown_path);
|
||||||
|
|
||||||
|
// 修复图片路径:将 ./media/ 替换为 /markdown/{date}/media/
|
||||||
|
$markdownContent = preg_replace_callback(
|
||||||
|
'/\(\.\/media\/([^)]+)\)/',
|
||||||
|
function ($matches) use ($markdownDir) {
|
||||||
|
$filename = $matches[1];
|
||||||
|
return '(/markdown/' . $markdownDir . '/media/' . $filename . ')';
|
||||||
|
},
|
||||||
|
$markdownContent
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用 MarkdownRenderService 转换为 HTML
|
||||||
|
$renderService = app(MarkdownRenderService::class);
|
||||||
|
$htmlContent = $renderService->render($markdownContent);
|
||||||
|
|
||||||
|
return $htmlContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直接从 Word 文档转换为 HTML
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return string HTML 内容
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
protected function convertWordToHtml(Document $document): string
|
||||||
|
{
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!Storage::disk('local')->exists($document->file_path)) {
|
||||||
|
throw new \Exception('文档文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件的完整路径
|
||||||
|
$filePath = Storage::disk('local')->path($document->file_path);
|
||||||
|
|
||||||
|
// 设置 PHPWord 的临时目录
|
||||||
|
Settings::setTempDir(storage_path('app/temp'));
|
||||||
|
|
||||||
|
// 加载 Word 文档
|
||||||
|
$phpWord = IOFactory::load($filePath);
|
||||||
|
|
||||||
|
// 提取图片并转换为 base64
|
||||||
|
$images = $this->extractImagesFromDocument($phpWord);
|
||||||
|
|
||||||
|
// 创建 HTML Writer
|
||||||
|
$htmlWriter = IOFactory::createWriter($phpWord, 'HTML');
|
||||||
|
|
||||||
|
// 将内容写入临时文件
|
||||||
|
$tempHtmlFile = tempnam(sys_get_temp_dir(), 'doc_preview_') . '.html';
|
||||||
|
$htmlWriter->save($tempHtmlFile);
|
||||||
|
|
||||||
|
// 读取 HTML 内容
|
||||||
|
$htmlContent = file_get_contents($tempHtmlFile);
|
||||||
|
|
||||||
|
// 删除临时文件
|
||||||
|
unlink($tempHtmlFile);
|
||||||
|
|
||||||
|
// 将图片嵌入为 base64
|
||||||
|
$htmlContent = $this->embedImagesInHtml($htmlContent, $images);
|
||||||
|
|
||||||
|
// 清理和美化 HTML
|
||||||
|
$htmlContent = $this->cleanHtml($htmlContent);
|
||||||
|
|
||||||
|
return $htmlContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Word 文档中提取所有图片
|
||||||
|
*
|
||||||
|
* @param \PhpOffice\PhpWord\PhpWord $phpWord
|
||||||
|
* @return array 图片数组,键为图片索引,值为 base64 编码的图片数据
|
||||||
|
*/
|
||||||
|
protected function extractImagesFromDocument($phpWord): array
|
||||||
|
{
|
||||||
|
$images = [];
|
||||||
|
$imageIndex = 0;
|
||||||
|
|
||||||
|
foreach ($phpWord->getSections() as $section) {
|
||||||
|
foreach ($section->getElements() as $element) {
|
||||||
|
// 处理图片元素
|
||||||
|
if (method_exists($element, 'getElements')) {
|
||||||
|
foreach ($element->getElements() as $childElement) {
|
||||||
|
if ($childElement instanceof \PhpOffice\PhpWord\Element\Image) {
|
||||||
|
$imageSource = $childElement->getSource();
|
||||||
|
if (file_exists($imageSource)) {
|
||||||
|
$imageData = file_get_contents($imageSource);
|
||||||
|
$imageType = $childElement->getImageType();
|
||||||
|
$mimeType = $this->getImageMimeType($imageType);
|
||||||
|
$base64 = base64_encode($imageData);
|
||||||
|
$images[$imageIndex] = "data:{$mimeType};base64,{$base64}";
|
||||||
|
$imageIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} elseif ($element instanceof \PhpOffice\PhpWord\Element\Image) {
|
||||||
|
$imageSource = $element->getSource();
|
||||||
|
if (file_exists($imageSource)) {
|
||||||
|
$imageData = file_get_contents($imageSource);
|
||||||
|
$imageType = $element->getImageType();
|
||||||
|
$mimeType = $this->getImageMimeType($imageType);
|
||||||
|
$base64 = base64_encode($imageData);
|
||||||
|
$images[$imageIndex] = "data:{$mimeType};base64,{$base64}";
|
||||||
|
$imageIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $images;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据图片类型获取 MIME 类型
|
||||||
|
*
|
||||||
|
* @param string $imageType
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function getImageMimeType(string $imageType): string
|
||||||
|
{
|
||||||
|
$mimeTypes = [
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'bmp' => 'image/bmp',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $mimeTypes[strtolower($imageType)] ?? 'image/jpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 HTML 中的图片替换为 base64 编码
|
||||||
|
*
|
||||||
|
* @param string $html
|
||||||
|
* @param array $images
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function embedImagesInHtml(string $html, array $images): string
|
||||||
|
{
|
||||||
|
// PHPWord 生成的 HTML 中,图片通常以 <img src="..." /> 的形式存在
|
||||||
|
// 我们需要将这些图片路径替换为 base64 数据
|
||||||
|
|
||||||
|
$imageIndex = 0;
|
||||||
|
$html = preg_replace_callback(
|
||||||
|
'/<img([^>]*?)src=["\']([^"\']+)["\']([^>]*?)>/i',
|
||||||
|
function ($matches) use ($images, &$imageIndex) {
|
||||||
|
$beforeSrc = $matches[1];
|
||||||
|
$src = $matches[2];
|
||||||
|
$afterSrc = $matches[3];
|
||||||
|
|
||||||
|
// 如果已经是 base64 或 http 链接,不处理
|
||||||
|
if (strpos($src, 'data:') === 0 || strpos($src, 'http') === 0) {
|
||||||
|
return $matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用提取的图片数据
|
||||||
|
if (isset($images[$imageIndex])) {
|
||||||
|
$src = $images[$imageIndex];
|
||||||
|
$imageIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<img{$beforeSrc}src=\"{$src}\"{$afterSrc}>";
|
||||||
|
},
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理和美化 HTML 内容
|
||||||
|
*
|
||||||
|
* @param string $html
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function cleanHtml(string $html): string
|
||||||
|
{
|
||||||
|
// 提取 body 内容
|
||||||
|
if (preg_match('/<body[^>]*>(.*?)<\/body>/is', $html, $matches)) {
|
||||||
|
$html = $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加基本样式
|
||||||
|
$styledHtml = '<div class="document-preview" style="
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
">';
|
||||||
|
|
||||||
|
$styledHtml .= $html;
|
||||||
|
$styledHtml .= '</div>';
|
||||||
|
|
||||||
|
return $styledHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文档是否可以预览
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function canPreview(Document $document): bool
|
||||||
|
{
|
||||||
|
// 检查文件扩展名
|
||||||
|
$extension = strtolower(pathinfo($document->file_name, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
// 目前支持 .doc 和 .docx
|
||||||
|
return in_array($extension, ['doc', 'docx']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文档预览的纯文本内容(用于搜索等)
|
||||||
|
*
|
||||||
|
* @param Document $document
|
||||||
|
* @return string
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function extractText(Document $document): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!Storage::disk('local')->exists($document->file_path)) {
|
||||||
|
throw new \Exception('文档文件不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
$filePath = Storage::disk('local')->path($document->file_path);
|
||||||
|
$phpWord = IOFactory::load($filePath);
|
||||||
|
|
||||||
|
$text = '';
|
||||||
|
foreach ($phpWord->getSections() as $section) {
|
||||||
|
foreach ($section->getElements() as $element) {
|
||||||
|
if (method_exists($element, 'getText')) {
|
||||||
|
$text .= $element->getText() . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return trim($text);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \Exception('文本提取失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
app/Services/DocumentSearchService.php
Normal file
207
app/Services/DocumentSearchService.php
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文档搜索服务
|
||||||
|
* 负责处理文档的全文搜索和 Meilisearch 索引管理
|
||||||
|
*/
|
||||||
|
class DocumentSearchService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 搜索文档
|
||||||
|
* 使用 Laravel Scout 和 Meilisearch 进行全文搜索
|
||||||
|
*
|
||||||
|
* @param string $query 搜索关键词
|
||||||
|
* @param User $user 当前用户
|
||||||
|
* @param array $filters 额外的筛选条件
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function search(string $query, User $user, array $filters = []): Collection
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 使用 Scout 进行搜索
|
||||||
|
$searchBuilder = Document::search($query);
|
||||||
|
|
||||||
|
// 应用额外的筛选条件
|
||||||
|
if (!empty($filters['type'])) {
|
||||||
|
$searchBuilder->where('type', $filters['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['group_id'])) {
|
||||||
|
$searchBuilder->where('group_id', $filters['group_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['uploaded_by'])) {
|
||||||
|
$searchBuilder->where('uploaded_by', $filters['uploaded_by']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行搜索并获取结果
|
||||||
|
$results = $searchBuilder->get();
|
||||||
|
|
||||||
|
// 应用用户权限过滤
|
||||||
|
return $this->filterByUserPermissions($results, $user);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('文档搜索失败', [
|
||||||
|
'query' => $query,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'filters' => $filters,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 搜索失败时返回空集合
|
||||||
|
return new Collection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户权限过滤搜索结果
|
||||||
|
* 确保用户只能看到有权限访问的文档
|
||||||
|
*
|
||||||
|
* @param Collection $results 搜索结果
|
||||||
|
* @param User $user 当前用户
|
||||||
|
* @return Collection
|
||||||
|
*/
|
||||||
|
public function filterByUserPermissions(Collection $results, User $user): Collection
|
||||||
|
{
|
||||||
|
// 获取用户所属的所有分组 ID
|
||||||
|
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
|
||||||
|
|
||||||
|
return $results->filter(function (Document $document) use ($userGroupIds) {
|
||||||
|
// 全局文档对所有用户可见
|
||||||
|
if ($document->type === 'global') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 专用文档只对所属分组的用户可见
|
||||||
|
if ($document->type === 'dedicated') {
|
||||||
|
return in_array($document->group_id, $userGroupIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 准备文档的可搜索数据
|
||||||
|
* 包含完整的 Markdown 内容用于索引
|
||||||
|
*
|
||||||
|
* @param Document $document 文档模型
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function prepareSearchableData(Document $document): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $document->id,
|
||||||
|
'title' => $document->title,
|
||||||
|
'description' => $document->description,
|
||||||
|
'markdown_content' => $document->getMarkdownContent(),
|
||||||
|
'type' => $document->type,
|
||||||
|
'group_id' => $document->group_id,
|
||||||
|
'uploaded_by' => $document->uploaded_by,
|
||||||
|
'created_at' => $document->created_at?->timestamp,
|
||||||
|
'updated_at' => $document->updated_at?->timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 索引文档到 Meilisearch
|
||||||
|
* 读取 Markdown 文件并创建搜索索引
|
||||||
|
*
|
||||||
|
* @param Document $document 文档模型
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function indexDocument(Document $document): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 只索引已完成转换的文档
|
||||||
|
if (!$document->shouldBeSearchable()) {
|
||||||
|
Log::info('文档未完成转换,跳过索引', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'conversion_status' => $document->conversion_status,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 Scout 的 searchable 方法进行索引
|
||||||
|
$document->searchable();
|
||||||
|
|
||||||
|
Log::info('文档索引成功', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'title' => $document->title,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('文档索引失败', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 索引失败不影响文档的正常使用,只记录错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新文档在 Meilisearch 中的索引
|
||||||
|
*
|
||||||
|
* @param Document $document 文档模型
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updateDocumentIndex(Document $document): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 如果文档应该被索引,则更新索引
|
||||||
|
if ($document->shouldBeSearchable()) {
|
||||||
|
$document->searchable();
|
||||||
|
|
||||||
|
Log::info('文档索引更新成功', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'title' => $document->title,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// 如果文档不应该被索引(例如转换失败),则从索引中移除
|
||||||
|
$this->removeDocumentFromIndex($document);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('文档索引更新失败', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 索引更新失败不影响文档的正常使用,只记录错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Meilisearch 中移除文档索引
|
||||||
|
*
|
||||||
|
* @param Document $document 文档模型
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function removeDocumentFromIndex(Document $document): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 使用 Scout 的 unsearchable 方法移除索引
|
||||||
|
$document->unsearchable();
|
||||||
|
|
||||||
|
Log::info('文档索引移除成功', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'title' => $document->title,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('文档索引移除失败', [
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 索引移除失败不影响文档的正常删除,只记录错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
app/Services/DocumentService.php
Normal file
145
app/Services/DocumentService.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\DownloadLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class DocumentService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 上传文档
|
||||||
|
*
|
||||||
|
* @param UploadedFile $file 上传的文件
|
||||||
|
* @param string $title 文档标题
|
||||||
|
* @param string $type 文档类型 ('global' 或 'dedicated')
|
||||||
|
* @param int|null $groupId 分组 ID (专用文档必填)
|
||||||
|
* @param int $uploaderId 上传者用户 ID
|
||||||
|
* @return Document
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function uploadDocument(
|
||||||
|
UploadedFile $file,
|
||||||
|
string $title,
|
||||||
|
string $type,
|
||||||
|
?int $groupId,
|
||||||
|
int $uploaderId
|
||||||
|
): Document {
|
||||||
|
// 验证文件格式
|
||||||
|
$extension = strtolower($file->getClientOriginalExtension());
|
||||||
|
if (!in_array($extension, ['doc', 'docx'])) {
|
||||||
|
throw new \InvalidArgumentException('文件格式不支持,请上传 Word 文档(.doc 或 .docx)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证专用文档必须有分组
|
||||||
|
if ($type === 'dedicated' && empty($groupId)) {
|
||||||
|
throw new \InvalidArgumentException('专用知识库文档必须指定所属分组');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用事务确保一致性
|
||||||
|
return DB::transaction(function () use ($file, $title, $type, $groupId, $uploaderId) {
|
||||||
|
// 获取原始文件名
|
||||||
|
$originalFileName = $file->getClientOriginalName();
|
||||||
|
|
||||||
|
// 生成文件存储路径,使用原始文件名
|
||||||
|
$directory = 'documents/' . date('Y/m/d');
|
||||||
|
$filePath = $file->storeAs($directory, $originalFileName, 'local');
|
||||||
|
|
||||||
|
// 创建数据库记录,设置初始转换状态为 pending
|
||||||
|
$document = Document::create([
|
||||||
|
'title' => $title,
|
||||||
|
'file_path' => $filePath,
|
||||||
|
'file_name' => $originalFileName,
|
||||||
|
'file_size' => $file->getSize(),
|
||||||
|
'mime_type' => $file->getMimeType(),
|
||||||
|
'type' => $type,
|
||||||
|
'group_id' => $groupId,
|
||||||
|
'uploaded_by' => $uploaderId,
|
||||||
|
'description' => '',
|
||||||
|
'conversion_status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 文档保存成功后,触发异步转换
|
||||||
|
$conversionService = app(DocumentConversionService::class);
|
||||||
|
$conversionService->queueConversion($document);
|
||||||
|
|
||||||
|
return $document;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户是否有权访问指定文档
|
||||||
|
*
|
||||||
|
* @param Document $document 要访问的文档
|
||||||
|
* @param User $user 用户
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function validateDocumentAccess(Document $document, User $user): bool
|
||||||
|
{
|
||||||
|
// 如果是全局文档,所有用户都可以访问
|
||||||
|
if ($document->type === 'global') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是专用文档,检查用户是否属于该文档的分组
|
||||||
|
if ($document->type === 'dedicated') {
|
||||||
|
// 获取用户所属的所有分组 ID
|
||||||
|
$userGroupIds = $user->groups()->pluck('groups.id')->toArray();
|
||||||
|
|
||||||
|
// 检查文档的分组 ID 是否在用户的分组列表中
|
||||||
|
return in_array($document->group_id, $userGroupIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载文档
|
||||||
|
*
|
||||||
|
* @param Document $document 要下载的文档
|
||||||
|
* @param User $user 用户
|
||||||
|
* @return StreamedResponse
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function downloadDocument(Document $document, User $user): StreamedResponse
|
||||||
|
{
|
||||||
|
// 验证用户权限
|
||||||
|
if (!$this->validateDocumentAccess($document, $user)) {
|
||||||
|
throw new \Exception('您没有权限访问此文档');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!Storage::disk('local')->exists($document->file_path)) {
|
||||||
|
throw new \Exception('文档不存在或已被删除');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回文件流式响应,使用原始文件名
|
||||||
|
return Storage::disk('local')->download(
|
||||||
|
$document->file_path,
|
||||||
|
$document->file_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录文档下载日志
|
||||||
|
*
|
||||||
|
* @param Document $document 被下载的文档
|
||||||
|
* @param User $user 下载的用户
|
||||||
|
* @param string|null $ipAddress IP 地址
|
||||||
|
* @return DownloadLog
|
||||||
|
*/
|
||||||
|
public function logDownload(Document $document, User $user, ?string $ipAddress = null): DownloadLog
|
||||||
|
{
|
||||||
|
return DownloadLog::create([
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'downloaded_at' => now(),
|
||||||
|
'ip_address' => $ipAddress ?? request()->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
254
app/Services/MarkdownRenderService.php
Normal file
254
app/Services/MarkdownRenderService.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use League\CommonMark\CommonMarkConverter;
|
||||||
|
use League\CommonMark\Environment\Environment;
|
||||||
|
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||||
|
use League\CommonMark\Extension\Table\TableExtension;
|
||||||
|
use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
|
||||||
|
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||||
|
use League\CommonMark\MarkdownConverter;
|
||||||
|
|
||||||
|
class MarkdownRenderService
|
||||||
|
{
|
||||||
|
protected MarkdownConverter $converter;
|
||||||
|
protected bool $sanitize;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// 从配置文件读取设置
|
||||||
|
$this->sanitize = config('documents.markdown.sanitize', true);
|
||||||
|
|
||||||
|
// 创建环境配置
|
||||||
|
$config = [
|
||||||
|
'html_input' => $this->sanitize ? 'strip' : 'allow', // 根据配置决定是否剥离 HTML 标签
|
||||||
|
'allow_unsafe_links' => false, // 不允许不安全的链接
|
||||||
|
'max_nesting_level' => 10, // 最大嵌套层级
|
||||||
|
];
|
||||||
|
|
||||||
|
// 创建环境并添加扩展
|
||||||
|
$environment = new Environment($config);
|
||||||
|
$environment->addExtension(new CommonMarkCoreExtension());
|
||||||
|
$environment->addExtension(new TableExtension()); // 支持表格
|
||||||
|
$environment->addExtension(new StrikethroughExtension()); // 支持删除线
|
||||||
|
$environment->addExtension(new TaskListExtension()); // 支持任务列表
|
||||||
|
|
||||||
|
// 创建转换器
|
||||||
|
$this->converter = new MarkdownConverter($environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Markdown 内容渲染为 HTML
|
||||||
|
*
|
||||||
|
* @param string $markdown Markdown 内容
|
||||||
|
* @return string 渲染后的 HTML
|
||||||
|
*/
|
||||||
|
public function render(string $markdown): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 转换 Markdown 为 HTML
|
||||||
|
$html = $this->converter->convert($markdown)->getContent();
|
||||||
|
|
||||||
|
// 清理和美化 HTML
|
||||||
|
$html = $this->sanitize($html);
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 如果渲染失败,返回错误信息
|
||||||
|
return '<div class="alert alert-danger">Markdown 渲染失败:' . htmlspecialchars($e->getMessage()) . '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理 HTML 内容,防止 XSS 攻击
|
||||||
|
*
|
||||||
|
* @param string $html HTML 内容
|
||||||
|
* @return string 清理后的 HTML
|
||||||
|
*/
|
||||||
|
public function sanitize(string $html): string
|
||||||
|
{
|
||||||
|
// CommonMark 已经配置了 html_input => 'strip',会自动剥离 HTML 标签
|
||||||
|
// 这里我们添加额外的样式包装
|
||||||
|
|
||||||
|
$styledHtml = '<div class="markdown-content" style="
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
">';
|
||||||
|
|
||||||
|
// 添加基本的 Markdown 样式
|
||||||
|
$styledHtml .= $this->getMarkdownStyles();
|
||||||
|
|
||||||
|
$styledHtml .= $html;
|
||||||
|
$styledHtml .= '</div>';
|
||||||
|
|
||||||
|
return $styledHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Markdown 内容的 CSS 样式
|
||||||
|
*
|
||||||
|
* @return string CSS 样式
|
||||||
|
*/
|
||||||
|
protected function getMarkdownStyles(): string
|
||||||
|
{
|
||||||
|
return '<style>
|
||||||
|
.markdown-content h1 { font-size: 2em; margin-top: 0.67em; margin-bottom: 0.67em; font-weight: bold; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
.markdown-content h2 { font-size: 1.5em; margin-top: 0.83em; margin-bottom: 0.83em; font-weight: bold; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
|
||||||
|
.markdown-content h3 { font-size: 1.17em; margin-top: 1em; margin-bottom: 1em; font-weight: bold; }
|
||||||
|
.markdown-content h4 { font-size: 1em; margin-top: 1.33em; margin-bottom: 1.33em; font-weight: bold; }
|
||||||
|
.markdown-content h5 { font-size: 0.83em; margin-top: 1.67em; margin-bottom: 1.67em; font-weight: bold; }
|
||||||
|
.markdown-content h6 { font-size: 0.67em; margin-top: 2.33em; margin-bottom: 2.33em; font-weight: bold; }
|
||||||
|
.markdown-content p { margin: 1em 0; }
|
||||||
|
.markdown-content ul, .markdown-content ol { margin: 1em 0; padding-left: 2em; }
|
||||||
|
.markdown-content li { margin: 0.5em 0; }
|
||||||
|
.markdown-content code {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: \'SFMono-Regular\', \'Consolas\', \'Liberation Mono\', \'Menlo\', \'Courier\', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #24292e;
|
||||||
|
}
|
||||||
|
.markdown-content pre {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
border: 1px solid #e1e4e8;
|
||||||
|
}
|
||||||
|
.markdown-content pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 4px solid #dfe2e5;
|
||||||
|
padding-left: 16px;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: #6a737d;
|
||||||
|
}
|
||||||
|
.markdown-content table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.markdown-content table th, .markdown-content table td {
|
||||||
|
border: 1px solid #dfe2e5;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.markdown-content table th {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.markdown-content table tr:nth-child(even) {
|
||||||
|
background-color: #f6f8fa;
|
||||||
|
}
|
||||||
|
.markdown-content a {
|
||||||
|
color: #0366d6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.markdown-content a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.markdown-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid #e1e4e8;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
.markdown-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.markdown-content del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #6a737d;
|
||||||
|
}
|
||||||
|
.markdown-content input[type="checkbox"] {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
</style>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Markdown 内容中提取摘要
|
||||||
|
*
|
||||||
|
* @param string $markdown Markdown 内容
|
||||||
|
* @param int|null $length 摘要长度(字符数),如果为 null 则使用配置文件中的默认值
|
||||||
|
* @return string 摘要文本
|
||||||
|
*/
|
||||||
|
public function extractPreview(string $markdown, ?int $length = null): string
|
||||||
|
{
|
||||||
|
// 如果未指定长度,使用配置文件中的默认值
|
||||||
|
if ($length === null) {
|
||||||
|
$length = config('documents.markdown.preview_length', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除 Markdown 标记,获取纯文本
|
||||||
|
$text = $this->stripMarkdown($markdown);
|
||||||
|
|
||||||
|
// 移除多余的空白字符
|
||||||
|
$text = preg_replace('/\s+/', ' ', $text);
|
||||||
|
$text = trim($text);
|
||||||
|
|
||||||
|
// 截取指定长度
|
||||||
|
if (mb_strlen($text) > $length) {
|
||||||
|
$text = mb_substr($text, 0, $length) . '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 Markdown 标记,返回纯文本
|
||||||
|
*
|
||||||
|
* @param string $markdown Markdown 内容
|
||||||
|
* @return string 纯文本
|
||||||
|
*/
|
||||||
|
protected function stripMarkdown(string $markdown): string
|
||||||
|
{
|
||||||
|
// 移除代码块
|
||||||
|
$text = preg_replace('/```[\s\S]*?```/', '', $markdown);
|
||||||
|
$text = preg_replace('/`[^`]+`/', '', $text);
|
||||||
|
|
||||||
|
// 移除标题标记
|
||||||
|
$text = preg_replace('/^#{1,6}\s+/m', '', $text);
|
||||||
|
|
||||||
|
// 移除链接,保留文本
|
||||||
|
$text = preg_replace('/\[([^\]]+)\]\([^\)]+\)/', '$1', $text);
|
||||||
|
|
||||||
|
// 移除图片
|
||||||
|
$text = preg_replace('/!\[([^\]]*)\]\([^\)]+\)/', '', $text);
|
||||||
|
|
||||||
|
// 移除粗体和斜体标记
|
||||||
|
$text = preg_replace('/\*\*([^\*]+)\*\*/', '$1', $text);
|
||||||
|
$text = preg_replace('/\*([^\*]+)\*/', '$1', $text);
|
||||||
|
$text = preg_replace('/__([^_]+)__/', '$1', $text);
|
||||||
|
$text = preg_replace('/_([^_]+)_/', '$1', $text);
|
||||||
|
|
||||||
|
// 移除删除线
|
||||||
|
$text = preg_replace('/~~([^~]+)~~/', '$1', $text);
|
||||||
|
|
||||||
|
// 移除引用标记
|
||||||
|
$text = preg_replace('/^>\s+/m', '', $text);
|
||||||
|
|
||||||
|
// 移除列表标记
|
||||||
|
$text = preg_replace('/^[\*\-\+]\s+/m', '', $text);
|
||||||
|
$text = preg_replace('/^\d+\.\s+/m', '', $text);
|
||||||
|
|
||||||
|
// 移除水平线
|
||||||
|
$text = preg_replace('/^[\-\*_]{3,}$/m', '', $text);
|
||||||
|
|
||||||
|
return $text;
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Services/SecurityLogger.php
Normal file
105
app/Services/SecurityLogger.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Document;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全日志记录服务
|
||||||
|
* 用于记录系统中的安全相关事件
|
||||||
|
*/
|
||||||
|
class SecurityLogger
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 记录未授权的文档访问尝试
|
||||||
|
* 需求:7.3
|
||||||
|
*
|
||||||
|
* @param User $user 尝试访问的用户
|
||||||
|
* @param Document $document 被访问的文档
|
||||||
|
* @param string $action 尝试的操作 (view, download, update, delete 等)
|
||||||
|
* @param string|null $ipAddress IP 地址
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function logUnauthorizedAccess(
|
||||||
|
User $user,
|
||||||
|
Document $document,
|
||||||
|
string $action,
|
||||||
|
?string $ipAddress = null
|
||||||
|
): void {
|
||||||
|
$ipAddress = $ipAddress ?? request()->ip();
|
||||||
|
|
||||||
|
Log::channel('security')->warning('未授权访问尝试', [
|
||||||
|
'event' => 'unauthorized_access',
|
||||||
|
'action' => $action,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_name' => $user->name,
|
||||||
|
'user_email' => $user->email,
|
||||||
|
'document_id' => $document->id,
|
||||||
|
'document_title' => $document->title,
|
||||||
|
'document_type' => $document->type,
|
||||||
|
'document_group_id' => $document->group_id,
|
||||||
|
'ip_address' => $ipAddress,
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录权限验证失败
|
||||||
|
*
|
||||||
|
* @param User $user 用户
|
||||||
|
* @param string $resource 资源类型
|
||||||
|
* @param int|null $resourceId 资源 ID
|
||||||
|
* @param string $action 操作
|
||||||
|
* @param string|null $reason 失败原因
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function logAuthorizationFailure(
|
||||||
|
User $user,
|
||||||
|
string $resource,
|
||||||
|
?int $resourceId,
|
||||||
|
string $action,
|
||||||
|
?string $reason = null
|
||||||
|
): void {
|
||||||
|
Log::channel('security')->warning('权限验证失败', [
|
||||||
|
'event' => 'authorization_failure',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_name' => $user->name,
|
||||||
|
'user_email' => $user->email,
|
||||||
|
'resource' => $resource,
|
||||||
|
'resource_id' => $resourceId,
|
||||||
|
'action' => $action,
|
||||||
|
'reason' => $reason,
|
||||||
|
'ip_address' => request()->ip(),
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录可疑的访问模式
|
||||||
|
*
|
||||||
|
* @param User $user 用户
|
||||||
|
* @param string $pattern 可疑模式描述
|
||||||
|
* @param array $context 额外的上下文信息
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function logSuspiciousActivity(
|
||||||
|
User $user,
|
||||||
|
string $pattern,
|
||||||
|
array $context = []
|
||||||
|
): void {
|
||||||
|
Log::channel('security')->alert('检测到可疑活动', array_merge([
|
||||||
|
'event' => 'suspicious_activity',
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'user_name' => $user->name,
|
||||||
|
'user_email' => $user->email,
|
||||||
|
'pattern' => $pattern,
|
||||||
|
'ip_address' => request()->ip(),
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'user_agent' => request()->userAgent(),
|
||||||
|
], $context));
|
||||||
|
}
|
||||||
|
}
|
||||||
18
artisan
Executable file
18
artisan
Executable 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
18
bootstrap/app.php
Normal 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
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
6
bootstrap/providers.php
Normal file
6
bootstrap/providers.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
App\Providers\Filament\AdminPanelProvider::class,
|
||||||
|
];
|
||||||
95
composer.json
Normal file
95
composer.json
Normal 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
13257
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal 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
115
config/auth.php
Normal 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
117
config/cache.php
Normal 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
183
config/database.php
Normal 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
113
config/documents.php
Normal 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
96
config/filesystems.php
Normal 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
140
config/logging.php
Normal 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
118
config/mail.php
Normal 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
129
config/queue.php
Normal 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
212
config/scout.php
Normal 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
38
config/services.php
Normal 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
217
config/session.php
Normal 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
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sqlite*
|
||||||
129
database/factories/DocumentFactory.php
Normal file
129
database/factories/DocumentFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
database/factories/DownloadLogFactory.php
Normal file
70
database/factories/DownloadLogFactory.php
Normal 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/factories/GroupFactory.php
Normal file
49
database/factories/GroupFactory.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
218
database/seeders/DatabaseSeeder.php
Normal file
218
database/seeders/DatabaseSeeder.php
Normal 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
18
docker-compose.yml
Normal 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
852
docs/API_REFERENCE.md
Normal 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
816
docs/DEPLOYMENT.md
Normal 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
|
||||||
171
docs/DOCUMENT_CONVERSION_GUIDE.md
Normal file
171
docs/DOCUMENT_CONVERSION_GUIDE.md
Normal 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)
|
||||||
|
- 确认队列工作进程正在运行
|
||||||
|
- 检查队列名称是否匹配
|
||||||
|
|
||||||
146
docs/DOCUMENT_PREVIEW_GUIDE.md
Normal file
146
docs/DOCUMENT_PREVIEW_GUIDE.md
Normal 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 转换功能
|
||||||
192
docs/DOCUMENT_SEARCH_GUIDE.md
Normal file
192
docs/DOCUMENT_SEARCH_GUIDE.md
Normal 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)
|
||||||
|
- 添加搜索结果高亮显示
|
||||||
|
- 实现搜索建议和自动补全
|
||||||
|
- 添加高级搜索语法支持
|
||||||
261
docs/FILENAME_PRESERVATION.md
Normal file
261
docs/FILENAME_PRESERVATION.md
Normal 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
258
docs/MEILISEARCH_SETUP.md
Normal 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
304
docs/PROJECT_OVERVIEW.md
Normal 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.x(Filament 内置)
|
||||||
|
|
||||||
|
### 开发工具
|
||||||
|
- **包管理**: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
130
docs/SETUP.md
Normal 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
189
docs/security-logging.md
Normal 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 配置
|
||||||
9
lang/vendor/filament/zh_CN/components/button.php
vendored
Normal file
9
lang/vendor/filament/zh_CN/components/button.php
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'messages' => [
|
||||||
|
'uploading_file' => '文件上传中...',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
9
lang/vendor/filament/zh_CN/components/copyable.php
vendored
Normal file
9
lang/vendor/filament/zh_CN/components/copyable.php
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'messages' => [
|
||||||
|
'copied' => '已复制',
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
13
lang/vendor/filament/zh_CN/components/modal.php
vendored
Normal file
13
lang/vendor/filament/zh_CN/components/modal.php
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'actions' => [
|
||||||
|
|
||||||
|
'close' => [
|
||||||
|
'label' => '关闭',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
39
lang/vendor/filament/zh_CN/components/pagination.php
vendored
Normal file
39
lang/vendor/filament/zh_CN/components/pagination.php
vendored
Normal 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
228
lang/zh_CN.json
Normal 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
119
lang/zh_CN/actions.php
Normal 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
9
lang/zh_CN/auth.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'failed' => '用户名或密码错误。',
|
||||||
|
'password' => '密码错误',
|
||||||
|
'throttle' => '您尝试的登录次数过多,请 :seconds 秒后再试。',
|
||||||
|
];
|
||||||
84
lang/zh_CN/http-statuses.php
Normal file
84
lang/zh_CN/http-statuses.php
Normal 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' => '未知错误',
|
||||||
|
];
|
||||||
8
lang/zh_CN/pagination.php
Normal file
8
lang/zh_CN/pagination.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'next' => '下一页 »',
|
||||||
|
'previous' => '« 上一页',
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user