feat: 实现完整的文件系统操作API

## 新增功能

### Handler层
- ✅ CreateDirectory: 创建目录功能实现
- ✅ DeleteItem: 删除文件/目录(支持operation_id去重)
- ✅ MoveItem: 移动/重命名(支持overwrite参数)
- ✅ CopyItem: 复制文件/目录(支持overwrite参数)
- ✅ SearchFiles: 文件搜索(支持类型过滤:photo/video)

### Service层
- ✅ CreateDirectory: 目录创建逻辑 + 路径存在检查
- ✅ DeleteItem: 文件删除逻辑 + operation_id支持
- ✅ MoveItem: 移动/重命名逻辑 + 覆盖控制
- ✅ CopyItem: 复制逻辑(文件/目录) + 大小计算
- ✅ SearchFiles: 搜索逻辑 + 类型过滤 + 性能计时
- ✅ isPhotoFile: 照片文件类型判断(12种格式)
- ✅ isVideoFile: 视频文件类型判断(10种格式)

### HTTP测试文件
- 📄 tests/filesystem_api.http: 完整的API测试用例
  - 文件系统浏览测试(7个用例)
  - 磁盘使用情况测试(2个用例)
  - 文件/目录操作测试(9个用例)
  - 文件搜索测试(6个用例)
  - 错误场景测试(6个用例)
  - 健康检查(2个用例)
  - 实际应用场景示例(5个用例)

## 技术细节

### API设计
- 遵循RESTful规范
- 支持operation_id用于SSE事件去重
- 完整的错误处理和日志记录
- 支持Windows和Unix路径

### 支持的文件类型
- **照片**: .jpg, .jpeg, .png, .gif, .bmp, .webp, .heic, .heif, .raw, .cr2, .nef, .arw
- **视频**: .mp4, .avi, .mov, .wmv, .flv, .mkv, .webm, .m4v, .mpg, .mpeg

### 已实现的API端点
- GET /api/v1/filesystem/browse
- GET /api/v1/filesystem/disk-usage
- GET /api/v1/filesystem/search
- POST /api/v1/filesystem/directory
- DELETE /api/v1/filesystem/item
- PUT /api/v1/filesystem/item/move
- POST /api/v1/filesystem/item/copy

## 测试指南
使用 tests/filesystem_api.http 文件进行测试:
1. 确保后端服务运行在 localhost:8726
2. 使用 REST Client 或类似工具打开测试文件
3. 根据实际环境修改路径
4. 按顺序执行测试用例

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jimmyandClaude committed 2025-10-03 00:30:01 +08:00
1 parent c61e076cd4
commit 2d74d18262
3 files changed
+574 -39

No files matched your search

+100 -39
View File
@@ -161,19 +161,29 @@ func (h *FileOperationsHandler) CreateDirectory(c *gin.Context) {
zap.String("client_ip", c.ClientIP()),
)
// 这里可以添加创建目录的逻辑
// 暂时返回成功响应,实际实现需要调用utils.FileUtils.CreateDir
// 调用service创建目录
result, err := h.fileSystemService.CreateDirectory(req.Path)
if err != nil {
logger.Error("Failed to create directory",
zap.String("path", req.Path),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
Message: "创建目录功能正在开发中",
Data: map[string]string{
"path": req.Path,
},
Message: "创建目录成功",
Data: result,
})
}
// DeleteItem 删除文件或目录
// DELETE /api/v1/filesystem/item?path=/path/to/item
// DELETE /api/v1/filesystem/item?path=/path/to/item&operation_id=uuid
func (h *FileOperationsHandler) DeleteItem(c *gin.Context) {
path := c.Query("path")
if path == "" {
@@ -184,29 +194,47 @@ func (h *FileOperationsHandler) DeleteItem(c *gin.Context) {
return
}
operationID := c.Query("operation_id")
recursiveStr := c.DefaultQuery("recursive", "true")
recursive := recursiveStr == "true"
logger.Info("Delete item request",
zap.String("path", path),
zap.String("operation_id", operationID),
zap.Bool("recursive", recursive),
zap.String("client_ip", c.ClientIP()),
)
// 这里可以添加删除逻辑
// 暂时返回成功响应,实际实现需要调用utils.FileUtils.Delete
// 调用service删除
result, err := h.fileSystemService.DeleteItem(path, operationID)
if err != nil {
logger.Error("Failed to delete item",
zap.String("path", path),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
Message: "删除功能正在开发中",
Data: map[string]string{
"path": path,
},
Message: "删除成功",
Data: result,
})
}
// MoveItem 移动/重命名文件或目录
// PUT /api/v1/filesystem/item/move
// Body: {"source": "/old/path", "destination": "/new/path"}
// Body: {"source": "/old/path", "destination": "/new/path", "operation_id": "uuid", "overwrite": false}
func (h *FileOperationsHandler) MoveItem(c *gin.Context) {
var req struct {
Source string `json:"source" binding:"required"`
Destination string `json:"destination" binding:"required"`
OperationID string `json:"operation_id"`
Overwrite bool `json:"overwrite"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -220,28 +248,41 @@ func (h *FileOperationsHandler) MoveItem(c *gin.Context) {
logger.Info("Move item request",
zap.String("source", req.Source),
zap.String("destination", req.Destination),
zap.String("operation_id", req.OperationID),
zap.Bool("overwrite", req.Overwrite),
zap.String("client_ip", c.ClientIP()),
)
// 这里可以添加移动逻辑
// 暂时返回成功响应,实际实现需要调用utils.FileUtils.MoveFile
// 调用service移动
result, err := h.fileSystemService.MoveItem(req.Source, req.Destination, req.OperationID, req.Overwrite)
if err != nil {
logger.Error("Failed to move item",
zap.String("source", req.Source),
zap.String("destination", req.Destination),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
Message: "移动功能正在开发中",
Data: map[string]interface{}{
"source": req.Source,
"destination": req.Destination,
},
Message: "移动成功",
Data: result,
})
}
// CopyItem 复制文件或目录
// POST /api/v1/filesystem/item/copy
// Body: {"source": "/source/path", "destination": "/dest/path", "overwrite": false}
// Body: {"source": "/source/path", "destination": "/dest/path", "operation_id": "uuid", "overwrite": false}
func (h *FileOperationsHandler) CopyItem(c *gin.Context) {
var req struct {
Source string `json:"source" binding:"required"`
Destination string `json:"destination" binding:"required"`
OperationID string `json:"operation_id"`
Overwrite bool `json:"overwrite"`
}
@@ -256,29 +297,40 @@ func (h *FileOperationsHandler) CopyItem(c *gin.Context) {
logger.Info("Copy item request",
zap.String("source", req.Source),
zap.String("destination", req.Destination),
zap.String("operation_id", req.OperationID),
zap.Bool("overwrite", req.Overwrite),
zap.String("client_ip", c.ClientIP()),
)
// 这里可以添加复制逻辑
// 暂时返回成功响应,实际实现需要调用utils.FileUtils.CopyFile或CopyDir
// 调用service复制
result, err := h.fileSystemService.CopyItem(req.Source, req.Destination, req.OperationID, req.Overwrite)
if err != nil {
logger.Error("Failed to copy item",
zap.String("source", req.Source),
zap.String("destination", req.Destination),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
Message: "复制功能正在开发中",
Data: map[string]interface{}{
"source": req.Source,
"destination": req.Destination,
"overwrite": req.Overwrite,
},
Message: "复制成功",
Data: result,
})
}
// SearchFiles 搜索文件
// GET /api/v1/filesystem/search?path=/search/path&pattern=*.txt&recursive=true
// GET /api/v1/filesystem/search?path=/search/path&pattern=*.txt&recursive=true&type=photo
func (h *FileSystemHandler) SearchFiles(c *gin.Context) {
path := c.Query("path")
pattern := c.Query("pattern")
recursiveStr := c.Query("recursive")
fileType := c.Query("type")
if path == "" {
c.JSON(http.StatusBadRequest, model.Response{
@@ -302,19 +354,28 @@ func (h *FileSystemHandler) SearchFiles(c *gin.Context) {
logger.Info("Search files request",
zap.String("path", path),
zap.String("pattern", pattern),
zap.String("type", fileType),
zap.Bool("recursive", recursive),
zap.String("client_ip", c.ClientIP()),
)
// 这里可以添加搜索逻辑
// 暂时返回成功响应,实际实现需要调用utils.FileUtils.SearchFiles
// 调用service搜索
result, err := h.fileSystemService.SearchFiles(path, pattern, fileType, recursive)
if err != nil {
logger.Error("Failed to search files",
zap.String("path", path),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
Message: "搜索功能正在开发中",
Data: map[string]interface{}{
"path": path,
"pattern": pattern,
"recursive": recursive,
},
Message: "搜索完成",
Data: result,
})
}
@@ -299,4 +299,202 @@ func (fs *FileSystemService) GetDiskUsage(path string) (*DriveInfo, error) {
// FormatSize 格式化文件大小
func (fs *FileSystemService) FormatSize(bytes int64) string {
return utils.FileUtils.FormatFileSize(bytes)
}
// CreateDirectory 创建目录
func (fs *FileSystemService) CreateDirectory(path string) (map[string]interface{}, error) {
// 检查路径是否已存在
if utils.FileUtils.Exists(path) {
return nil, fmt.Errorf("路径已存在: %s", path)
}
// 创建目录
if err := utils.FileUtils.CreateDir(path); err != nil {
return nil, fmt.Errorf("创建目录失败: %w", err)
}
return map[string]interface{}{
"path": path,
"created_at": time.Now().Format(time.RFC3339),
}, nil
}
// DeleteItem 删除文件或目录
func (fs *FileSystemService) DeleteItem(path string, operationID string) (map[string]interface{}, error) {
// 检查路径是否存在
if !utils.FileUtils.Exists(path) {
return nil, fmt.Errorf("路径不存在: %s", path)
}
// 删除文件或目录
if err := utils.FileUtils.Delete(path); err != nil {
return nil, fmt.Errorf("删除失败: %w", err)
}
return map[string]interface{}{
"path": path,
"deleted_at": time.Now().Format(time.RFC3339),
"operation_id": operationID,
}, nil
}
// MoveItem 移动/重命名文件或目录
func (fs *FileSystemService) MoveItem(source, destination, operationID string, overwrite bool) (map[string]interface{}, error) {
// 检查源路径是否存在
if !utils.FileUtils.Exists(source) {
return nil, fmt.Errorf("源路径不存在: %s", source)
}
// 检查目标路径是否已存在
if !overwrite && utils.FileUtils.Exists(destination) {
return nil, fmt.Errorf("目标路径已存在: %s", destination)
}
// 移动文件或目录
if err := utils.FileUtils.MoveFile(source, destination); err != nil {
return nil, fmt.Errorf("移动失败: %w", err)
}
return map[string]interface{}{
"source": source,
"destination": destination,
"operation_id": operationID,
}, nil
}
// CopyItem 复制文件或目录
func (fs *FileSystemService) CopyItem(source, destination, operationID string, overwrite bool) (map[string]interface{}, error) {
// 检查源路径是否存在
if !utils.FileUtils.Exists(source) {
return nil, fmt.Errorf("源路径不存在: %s", source)
}
// 获取源文件/目录信息
isDir := utils.FileUtils.IsDir(source)
var err error
var size int64
if isDir {
// 复制目录
opts := &utils.CopyOptions{
Overwrite: overwrite,
SkipSymlinks: false,
}
err = utils.FileUtils.CopyDirWithOptions(source, destination, opts)
if err != nil {
return nil, fmt.Errorf("复制目录失败: %w", err)
}
// 计算目录大小
size, _ = utils.FileUtils.GetDirSize(destination)
} else {
// 检查目标文件是否已存在
if !overwrite && utils.FileUtils.Exists(destination) {
return nil, fmt.Errorf("目标文件已存在: %s", destination)
}
// 复制文件
err = utils.FileUtils.CopyFile(source, destination)
if err != nil {
return nil, fmt.Errorf("复制文件失败: %w", err)
}
size, _ = utils.FileUtils.GetFileSize(destination)
}
return map[string]interface{}{
"source": source,
"destination": destination,
"size": size,
"operation_id": operationID,
}, nil
}
// SearchFiles 搜索文件
func (fs *FileSystemService) SearchFiles(path, pattern, fileType string, recursive bool) (map[string]interface{}, error) {
// 检查路径是否存在
if !utils.FileUtils.Exists(path) {
return nil, fmt.Errorf("路径不存在: %s", path)
}
// 检查是否为目录
if !utils.FileUtils.IsDir(path) {
return nil, fmt.Errorf("路径不是目录: %s", path)
}
startTime := time.Now()
// 搜索文件
matches, err := utils.FileUtils.SearchFiles(path, pattern, recursive)
if err != nil {
return nil, fmt.Errorf("搜索文件失败: %w", err)
}
// 根据文件类型过滤
results := make([]FileSystemItem, 0)
for _, matchPath := range matches {
// 获取文件信息
info, err := utils.FileUtils.GetFileSize(matchPath)
if err != nil {
continue
}
modTime, _ := utils.FileUtils.GetModTime(matchPath)
ext := utils.FileUtils.GetExtension(matchPath)
name := utils.FileUtils.GetBaseName(matchPath)
// 判断文件类型
isPhoto := fs.isPhotoFile(ext)
isVideo := fs.isVideoFile(ext)
// 根据类型过滤
if fileType == "photo" && !isPhoto {
continue
}
if fileType == "video" && !isVideo {
continue
}
item := FileSystemItem{
ID: fmt.Sprintf("file_%s", strings.ReplaceAll(matchPath, string(filepath.Separator), "_")),
Name: name,
Path: matchPath,
Type: ItemTypeFile,
Size: info,
ModTime: modTime,
IsAccessible: true,
}
results = append(results, item)
}
duration := time.Since(startTime).Milliseconds()
return map[string]interface{}{
"results": results,
"total_count": len(results),
"search_duration_ms": duration,
}, nil
}
// 辅助函数:判断是否为照片文件
func (fs *FileSystemService) isPhotoFile(ext string) bool {
photoExts := []string{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif", ".raw", ".cr2", ".nef", ".arw"}
ext = strings.ToLower(ext)
for _, photoExt := range photoExts {
if ext == photoExt {
return true
}
}
return false
}
// 辅助函数:判断是否为视频文件
func (fs *FileSystemService) isVideoFile(ext string) bool {
videoExts := []string{".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv", ".webm", ".m4v", ".mpg", ".mpeg"}
ext = strings.ToLower(ext)
for _, videoExt := range videoExts {
if ext == videoExt {
return true
}
}
return false
}
+276
View File
@@ -0,0 +1,276 @@
### Argus 文件系统API测试
### 基础URL
@baseUrl = http://localhost:8726
@apiUrl = {{baseUrl}}/api/v1
###############################################################################
# 1. 文件系统浏览测试
###############################################################################
### 1.1 获取驱动器列表(根目录)
GET {{apiUrl}}/filesystem/browse
Accept: application/json
### 1.2 浏览指定目录 (Windows路径)
GET {{apiUrl}}/filesystem/browse?path=D:\Photos
Accept: application/json
### 1.3 浏览指定目录 (Unix路径示例)
GET {{apiUrl}}/filesystem/browse?path=/home/user/photos
Accept: application/json
### 1.4 浏览目录 - 带排序参数
GET {{apiUrl}}/filesystem/browse?path=D:\Photos&sort=name&order=asc
Accept: application/json
### 1.5 浏览目录 - 按文件大小排序
GET {{apiUrl}}/filesystem/browse?path=D:\Photos&sort=size&order=desc
Accept: application/json
### 1.6 浏览目录 - 只显示照片
GET {{apiUrl}}/filesystem/browse?path=D:\Photos&filter=photo
Accept: application/json
###############################################################################
# 2. 磁盘使用情况测试
###############################################################################
### 2.1 获取C盘使用情况
GET {{apiUrl}}/filesystem/disk-usage?path=C:\
Accept: application/json
### 2.2 获取D盘使用情况
GET {{apiUrl}}/filesystem/disk-usage?path=D:\
Accept: application/json
###############################################################################
# 3. 文件/目录操作测试
###############################################################################
### 3.1 创建新目录
POST {{apiUrl}}/filesystem/directory
Content-Type: application/json
{
"path": "D:\\Photos\\TestFolder_20251002"
}
### 3.2 创建嵌套目录
POST {{apiUrl}}/filesystem/directory
Content-Type: application/json
{
"path": "D:\\Photos\\TestFolder_20251002\\SubFolder\\DeepFolder"
}
### 3.3 删除文件
DELETE {{apiUrl}}/filesystem/item?path=D:\Photos\test_file.txt&operation_id=op-001
Accept: application/json
### 3.4 删除目录(递归)
DELETE {{apiUrl}}/filesystem/item?path=D:\Photos\TestFolder_20251002&operation_id=op-002&recursive=true
Accept: application/json
### 3.5 移动/重命名文件
PUT {{apiUrl}}/filesystem/item/move
Content-Type: application/json
{
"source": "D:\\Photos\\old_name.jpg",
"destination": "D:\\Photos\\2024\\new_name.jpg",
"operation_id": "op-003",
"overwrite": false
}
### 3.6 重命名目录
PUT {{apiUrl}}/filesystem/item/move
Content-Type: application/json
{
"source": "D:\\Photos\\OldFolderName",
"destination": "D:\\Photos\\NewFolderName",
"operation_id": "op-004",
"overwrite": false
}
### 3.7 复制文件
POST {{apiUrl}}/filesystem/item/copy
Content-Type: application/json
{
"source": "D:\\Photos\\photo.jpg",
"destination": "D:\\Backup\\photo.jpg",
"operation_id": "op-005",
"overwrite": false
}
### 3.8 复制目录
POST {{apiUrl}}/filesystem/item/copy
Content-Type: application/json
{
"source": "D:\\Photos\\2024",
"destination": "D:\\Backup\\2024",
"operation_id": "op-006",
"overwrite": false
}
### 3.9 复制文件(允许覆盖)
POST {{apiUrl}}/filesystem/item/copy
Content-Type: application/json
{
"source": "D:\\Photos\\photo.jpg",
"destination": "D:\\Backup\\photo.jpg",
"operation_id": "op-007",
"overwrite": true
}
###############################################################################
# 4. 文件搜索测试
###############################################################################
### 4.1 搜索所有文件(非递归)
GET {{apiUrl}}/filesystem/search?path=D:\Photos&pattern=*&recursive=false
Accept: application/json
### 4.2 搜索JPG文件(递归)
GET {{apiUrl}}/filesystem/search?path=D:\Photos&pattern=*.jpg&recursive=true
Accept: application/json
### 4.3 搜索PNG文件
GET {{apiUrl}}/filesystem/search?path=D:\Photos&pattern=*.png&recursive=true
Accept: application/json
### 4.4 搜索所有照片(带类型过滤)
GET {{apiUrl}}/filesystem/search?path=D:\Photos&pattern=*&type=photo&recursive=true
Accept: application/json
### 4.5 搜索所有视频
GET {{apiUrl}}/filesystem/search?path=D:\Photos&pattern=*&type=video&recursive=true
Accept: application/json
### 4.6 按文件名模式搜索
GET {{apiUrl}}/filesystem/search?path=D:\Photos&pattern=sunset*.jpg&recursive=true
Accept: application/json
###############################################################################
# 5. 错误场景测试
###############################################################################
### 5.1 浏览不存在的路径
GET {{apiUrl}}/filesystem/browse?path=D:\NonExistentFolder
Accept: application/json
### 5.2 获取磁盘使用情况 - 无效路径
GET {{apiUrl}}/filesystem/disk-usage?path=
Accept: application/json
### 5.3 创建目录 - 路径已存在
POST {{apiUrl}}/filesystem/directory
Content-Type: application/json
{
"path": "D:\\Photos"
}
### 5.4 删除不存在的文件
DELETE {{apiUrl}}/filesystem/item?path=D:\NonExistent\file.txt
Accept: application/json
### 5.5 移动不存在的文件
PUT {{apiUrl}}/filesystem/item/move
Content-Type: application/json
{
"source": "D:\\NonExistent\\file.txt",
"destination": "D:\\Photos\\file.txt",
"overwrite": false
}
### 5.6 复制到已存在的位置(不允许覆盖)
POST {{apiUrl}}/filesystem/item/copy
Content-Type: application/json
{
"source": "D:\\Photos\\photo.jpg",
"destination": "D:\\Photos\\existing_photo.jpg",
"overwrite": false
}
###############################################################################
# 6. 健康检查
###############################################################################
### 6.1 健康检查
GET {{baseUrl}}/health
Accept: application/json
### 6.2 API根路径
GET {{apiUrl}}
Accept: application/json
###############################################################################
# 测试说明
###############################################################################
# 测试前准备:
# 1. 确保后端服务运行在 localhost:8726
# 2. 根据实际环境修改路径(Windows使用反斜杠,Unix使用正斜杠)
# 3. 创建测试用的文件和目录
# 4. 注意:删除操作是真实的,请小心测试
# 测试顺序建议:
# 1. 先测试健康检查和浏览功能
# 2. 测试创建目录
# 3. 测试搜索功能
# 4. 测试复制和移动
# 5. 最后测试删除(确保删除的是测试数据)
# 注意事项:
# - operation_id 用于SSE事件去重,可以使用UUID
# - Windows路径需要使用双反斜杠或单正斜杠
# - 递归删除会删除目录下所有内容,请谨慎使用
# - 搜索pattern使用通配符语法:* 表示任意字符
###############################################################################
# 实际应用场景示例
###############################################################################
### 场景1: 创建新年份照片目录结构
POST {{apiUrl}}/filesystem/directory
Content-Type: application/json
{
"path": "D:\\Photos\\2025\\January"
}
### 场景2: 将照片移动到对应月份文件夹
PUT {{apiUrl}}/filesystem/item/move
Content-Type: application/json
{
"source": "D:\\Photos\\IMG_20250102.jpg",
"destination": "D:\\Photos\\2025\\January\\IMG_20250102.jpg",
"operation_id": "move-to-january-001",
"overwrite": false
}
### 场景3: 备份照片到外部存储
POST {{apiUrl}}/filesystem/item/copy
Content-Type: application/json
{
"source": "D:\\Photos\\2024",
"destination": "E:\\Backup\\Photos\\2024",
"operation_id": "backup-2024-photos",
"overwrite": false
}
### 场景4: 查找所有未整理的照片
GET {{apiUrl}}/filesystem/search?path=D:\Photos&pattern=IMG_*.jpg&type=photo&recursive=false
Accept: application/json
### 场景5: 清理临时文件
DELETE {{apiUrl}}/filesystem/item?path=D:\Photos\.temp&operation_id=cleanup-temp&recursive=true
Accept: application/json