A:完成照片接口对接

This commit is contained in:
jimmy committed 2025-08-21 21:46:43 +08:00
1 parent 5bd1481766
commit 83cdefe5d1
21 files changed
+1393 -403

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
# PhotosView API 接口需求文档
## 概述
PhotosView 是照片时间线页面,展示按月份分组的照片,支持虚拟滚动和时间线导航。以下是需要后端提供的API接口和数据结构。
## 核心数据结构
### Photo (照片对象)
```typescript
interface Photo {
id: string // 照片唯一标识符
url: string // 照片URL路径 (如: /api/photos/thumb/123.jpg)
fullUrl?: string // 原图URL路径 (如: /api/photos/full/123.jpg)
width: number // 照片宽度 (像素)
height: number // 照片高度 (像素)
takenAt: string // 拍摄时间 (ISO 8601格式)
fileName: string // 原始文件名
size?: number // 文件大小 (字节)
metadata?: { // 可选元数据
camera?: string // 相机型号
iso?: number // ISO值
aperture?: string // 光圈值
shutterSpeed?: string // 快门速度
}
}
```
### PhotoPeriod (时期分组)
```typescript
interface PhotoPeriod {
id: string // 时期标识符 (如 "2024-08")
period: string // 时期字符串 "YYYY-MM"
displayName: string // 显示名称 "2024年8月"
photos: Photo[] // 该时期的照片列表
photoCount: number // 照片总数
}
```
## 需要的API接口
### 1. 获取照片时间线数据
**接口路径**: `GET /api/photos/timeline`
**请求参数**: 无
**响应数据**:
```json
{
"success": true,
"data": {
"periods": [
{
"id": "2024-08",
"period": "2024-08",
"displayName": "2024年8月",
"photoCount": 342,
"photos": [
{
"id": "photo_001",
"url": "/api/photos/thumb/photo_001.jpg",
"fullUrl": "/api/photos/full/photo_001.jpg",
"width": 4032,
"height": 3024,
"takenAt": "2024-08-15T14:30:25Z",
"fileName": "IMG_001.jpg",
"size": 2458624
}
]
}
],
"totalPhotos": 10847
}
}
```
### 2. 获取照片缩略图
**接口路径**: `GET /api/photos/thumb/{photoId}.jpg`
### 3. 获取原图
**接口路径**: `GET /api/photos/full/{photoId}.jpg`
## 数据要求
### 照片处理
- **缩略图**: 建议300x300像素,JPEG格式,质量80%
- **原图**: 保持原始尺寸和格式
- **路径**: 统一使用相对路径,前端会自动添加base URL
### 时间分组
- **分组逻辑**: 按拍摄时间的年月分组
- **排序**: 最新月份在前,同月内按拍摄时间降序
- **格式**: 时期ID使用"YYYY-MM"格式
### 前端适配说明
当前前端会根据照片的width和height计算宽高比进行响应式布局:
- 桌面端: 每行5-7张照片
- 移动端: 每行2-3张照片
## 现有前端代码修改点
需要将PhotosView.vue中的模拟数据替换为真实API调用:
```typescript
// 替换 generatePhotoPeriods() 函数为:
const loadPhotoPeriods = async (): Promise<PhotoPeriod[]> => {
try {
const response = await fetch('/api/photos/timeline')
const result = await response.json()
if (result.success) {
return result.data.periods
}
} catch (error) {
console.error('加载照片数据失败:', error)
}
return []
}
```
## 优先级
1. **高优先级**: 照片时间线接口 + 缩略图接口
2. **中优先级**: 原图接口 + 基础元数据
3. **低优先级**: 高级元数据(相机信息等)
## 已完成的集成
### ✅ API对接状态
- **时间线接口**: 已完成 `GET /api/v1/photos/timeline`
- **照片数据接口**: 已完成 `GET /api/v1/photos`
- **缩略图接口**: 已完成 `GET /api/v1/photo/{hash}?format=thumbnail&size=400x400`
- **HTTP配置**: 已更新baseURL为 `http://localhost:9482`
### ✅ 前端实现特性
1. **时间线加载**: 按月分组显示照片统计
2. **懒加载**: 滚动时自动加载对应月份的照片数据
3. **缩略图懒加载**: 可视区域照片自动加载缩略图
4. **虚拟滚动**: 支持大量照片的高性能展示
5. **响应式布局**: 根据屏幕尺寸自适应照片排列
### 📝 数据流程
1. 页面初始化 → 加载时间线统计数据
2. 滚动到某月份 → 懒加载该月份的照片基础数据(hash、宽高比等)
3. 进入可视区域 → 加载实际缩略图显示
### 🔧 配置说明
- 后端端口: 9482
- 缩略图尺寸: 400x400
- 每批加载: 100张照片
- 照片高度: 统一200px,宽度按比例计算
---
*✅ 前端API对接已完成,可以与后端进行实时数据交互。*
+3 -1
View File
@@ -12,7 +12,9 @@
"Bash(mv:*)", "Bash(mv:*)",
"Bash(rm:*)", "Bash(rm:*)",
"Bash(go test:*)", "Bash(go test:*)",
"Bash(git add:*)" "Bash(git add:*)",
"Bash(del \"D:\\go-argus\\src\\rear\\api_tests.http\")",
"Bash(curl:*)"
], ],
"deny": [] "deny": []
} }
-8
View File
@@ -1,8 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
-218
View File
@@ -1,218 +0,0 @@
### Argus 照片管理系统 API 测试文件
### 服务器地址
@baseUrl = http://localhost:8080
@apiBase = {{baseUrl}}/api/v1
### ===== 基础健康检查 =====
### 健康检查
GET {{baseUrl}}/health
### API 基础路由
GET {{apiBase}}
### ===== 图像库管理 =====
### 查看当前图像库
GET {{apiBase}}/library
### 添加图像库路径 (需要修改为实际存在的路径)
POST {{apiBase}}/library
Content-Type: application/json
{
"path": "D:/Pictures"
}
### 更新图像库状态
PUT {{apiBase}}/library
Content-Type: application/json
{
"path": "D:/Pictures",
"is_enable": true
}
### 开始图像索引 (需要先添加图像库)
POST {{apiBase}}/library/indexed
### 删除图像库
DELETE {{apiBase}}/library?path=D:/Pictures
### ===== 照片接口测试 =====
### 注意:需要先运行索引获得实际的hash值
### 获取原图 (替换为实际hash)
GET {{apiBase}}/photo/sample_hash_12345?format=original
### 获取默认尺寸缩略图
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail
### 获取指定尺寸缩略图 (400x400)
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail&size=400x400
### 获取指定尺寸缩略图 (800x600)
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail&size=800x600
### 获取正方形缩略图 (500x500)
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail&size=500x500
### 获取小尺寸缩略图 (200x200)
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail&size=200x200
### ===== 图像详情接口测试 =====
### 获取图像详细信息 (替换为实际hash)
GET {{apiBase}}/assets/sample_hash_12345
### ===== 错误测试 =====
### 测试不存在的hash - 图像文件
GET {{apiBase}}/photo/nonexistent_hash
### 测试不存在的hash - 图像详情
GET {{apiBase}}/assets/nonexistent_hash
### 测试无效的格式参数
GET {{apiBase}}/photo/sample_hash_12345?format=invalid_format
### 测试无效的尺寸参数
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail&size=invalid_size
### 测试负数尺寸
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail&size=-100x-100
### 测试错误的尺寸格式
GET {{apiBase}}/photo/sample_hash_12345?format=thumbnail&size=400x
### ===== EXIF信息接口测试 =====
### 获取所有EXIF记录
GET {{apiBase}}/exif
### 根据hash获取EXIF信息
GET {{apiBase}}/exif/sample_hash_12345
### 获取EXIF统计信息
GET {{apiBase}}/exif/statistics
### 获取相机列表
GET {{apiBase}}/exif/cameras
### 获取相机统计
GET {{apiBase}}/exif/cameras/stats
### 搜索EXIF信息
GET {{apiBase}}/exif/search?q=Canon
### 获取有GPS信息的照片
GET {{apiBase}}/exif/gps
### 根据ISO筛选
GET {{apiBase}}/exif/iso?min=100&max=800
### 根据光圈筛选
GET {{apiBase}}/exif/aperture?min=1.4&max=5.6
### 根据相机筛选
GET {{apiBase}}/exif/camera?make=Canon&model=EOS
### ===== 用户管理接口测试 =====
### 获取所有用户
GET {{apiBase}}/users
### 获取指定用户
GET {{apiBase}}/users/1
### 创建新用户
POST {{apiBase}}/users
Content-Type: application/json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
"age": 25
}
### 更新用户信息
PUT {{apiBase}}/users/1
Content-Type: application/json
{
"username": "updateduser",
"email": "updated@example.com",
"age": 26
}
### 删除用户
DELETE {{apiBase}}/users/1
### ===== 开发接口测试 =====
### 开发工具 - 获取EXIF信息
GET {{baseUrl}}/dev/tool/exiftool/get_exif?imagePath=D:/Pictures/sample.jpg
### ===== 批量测试脚本 =====
### 可以通过IDE的"运行所有请求"功能批量执行
### 测试序列1: 基础功能
# 1. 健康检查
# 2. 查看图像库
# 3. 添加图像库
# 4. 开始索引
### 测试序列2: 图像接口 (需要实际hash)
# 1. 获取图像详情
# 2. 获取原图
# 3. 获取各种尺寸缩略图
### 测试序列3: 错误处理
# 1. 测试不存在的资源
# 2. 测试无效参数
# 3. 测试边界条件
### ===== 环境变量示例 =====
### 在http-client.env.json中配置:
### {
### "dev": {
### "baseUrl": "http://localhost:8080",
### "sampleHash": "your_actual_hash_here"
### },
### "prod": {
### "baseUrl": "https://your-production-domain.com",
### "sampleHash": "production_hash_here"
### }
### }
### ===== 性能测试示例 =====
### 并发获取缩略图测试 (可以在终端中使用curl并发执行)
# curl "{{apiBase}}/photo/hash1?format=thumbnail&size=400x400" &
# curl "{{apiBase}}/photo/hash2?format=thumbnail&size=400x400" &
# curl "{{apiBase}}/photo/hash3?format=thumbnail&size=400x400" &
### ===== 使用说明 =====
###
### 1. 启动服务器:
### go run .
###
### 2. 修改变量:
### - 将 sample_hash_12345 替换为实际的图像hash
### - 将 D:/Pictures 替换为实际的图像目录路径
###
### 3. 执行测试:
### - 先执行基础功能测试(健康检查、添加图像库、索引)
### - 从索引结果中获取实际的hash值
### - 使用实际hash测试图像接口
###
### 4. 查看响应:
### - 图像文件接口会返回二进制图像数据
### - 详情接口会返回JSON格式的完整信息
### - 错误情况会返回包含错误信息的JSON
###
### 5. 注意事项:
### - 确保图像文件存在且可访问
### - 缩略图生成可能需要一些时间
### - 大图像的缩略图生成会比较慢
+1 -1
View File
@@ -2,7 +2,7 @@
# 只包含需要自定义的配置项,其他将使用默认值 # 只包含需要自定义的配置项,其他将使用默认值
app: app:
port: "8080" port: "9483"
mode: "release" mode: "release"
# 开发阶段标识 - 设置为 false 或删除此行启用生产模式 # 开发阶段标识 - 设置为 false 或删除此行启用生产模式
development: true development: true
+3 -3
View File
@@ -319,7 +319,7 @@ func (api *ImageAPI) convertOtherToTarget(ctx context.Context, outputPath string
// generateThumbnail 生成缩略图 // generateThumbnail 生成缩略图
func (api *ImageAPI) generateThumbnail(ctx context.Context, sourcePath string, longSide int) (string, error) { func (api *ImageAPI) generateThumbnail(ctx context.Context, sourcePath string, longSide int) (string, error) {
// 获取缩略图路径 // 获取缩略图路径
thumbnailPath := api.getThumbnailPath(longSide) thumbnailPath := api.GetThumbnailPath(longSide)
// 检查缩略图是否已存在 // 检查缩略图是否已存在
if utils.FileUtils.Exists(thumbnailPath) { if utils.FileUtils.Exists(thumbnailPath) {
@@ -349,8 +349,8 @@ func (api *ImageAPI) generateThumbnail(ctx context.Context, sourcePath string, l
return api.generateStandardThumbnail(ctx, sourcePath, thumbnailPath, targetWidth, targetHeight) return api.generateStandardThumbnail(ctx, sourcePath, thumbnailPath, targetWidth, targetHeight)
} }
// getThumbnailPath 获取缩略图路径 // GetThumbnailPath 获取缩略图路径(公开方法)
func (api *ImageAPI) getThumbnailPath(longSide int) string { func (api *ImageAPI) GetThumbnailPath(longSide int) string {
// 使用与 main.go createCachePath 相同的逻辑构建完整缩略图路径 // 使用与 main.go createCachePath 相同的逻辑构建完整缩略图路径
thumbnailPath := filepath.Join(config.CONFIG.AppDir, config.CONFIG.PathConfig.CachePath, config.CONFIG.PathConfig.ThumbnailPath) thumbnailPath := filepath.Join(config.CONFIG.AppDir, config.CONFIG.PathConfig.CachePath, config.CONFIG.PathConfig.ThumbnailPath)
+34 -7
View File
@@ -204,16 +204,43 @@ func (h *LibraryHandler) LibraryIndex(c *gin.Context) {
fileList = append(fileList, info.Path) fileList = append(fileList, info.Path)
} }
} }
logger.Info(fmt.Sprintf("得到的照片: %v", fileList)) logger.Info(fmt.Sprintf("得到的照片总数: %d", len(fileList)))
// 启动后台任务,开始处理 // 检查哪些文件已经在数据库中存在,过滤出需要处理的文件
for i := range fileList { var newFiles []string
file := fileList[i] skippedCount := 0
h.imgContain.ImgTaskManager.AddTask(file)
for _, file := range fileList {
// 检查数据库中是否已经存在该文件记录
_, err := h.container.PhotoRepo.GetByPath(file)
if err != nil {
// 数据库中不存在该记录,需要处理
newFiles = append(newFiles, file)
} else {
// 数据库中已存在该记录,跳过
skippedCount++
}
} }
logger.Info(fmt.Sprintf("需要处理的新文件: %d, 跳过已存在的文件: %d", len(newFiles), skippedCount))
// 异步启动后台任务,开始处理新文件
go func() {
for _, file := range newFiles {
h.imgContain.ImgTaskManager.AddTask(file)
}
logger.Info(fmt.Sprintf("已添加 %d 个任务到处理队列", len(newFiles)))
}()
// 立即返回响应
c.JSON(http.StatusOK, model.Response{ c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK, Code: http.StatusOK,
Message: "索引任务已启动", Message: fmt.Sprintf("索引任务已启动,共发现 %d 个文件,其中 %d 个新文件需要处理,%d 个文件已存在跳过",
len(fileList), len(newFiles), skippedCount),
Data: map[string]interface{}{
"total_files": len(fileList),
"new_files": len(newFiles),
"skipped_files": skippedCount,
},
}) })
} }
+199 -119
View File
@@ -2,16 +2,14 @@ package handler
import ( import (
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "rear/internal/api"
"path/filepath"
"rear/internal/config" "rear/internal/config"
"rear/internal/container" "rear/internal/container"
"rear/internal/db"
"rear/internal/model" "rear/internal/model"
"rear/internal/model/tables" "rear/internal/repositories"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -34,23 +32,23 @@ func NewPhotoHandler(container *container.DbContainer, imgContain *container.Tas
// PhotoDetailResponse 图像详细信息响应 // PhotoDetailResponse 图像详细信息响应
type PhotoDetailResponse struct { type PhotoDetailResponse struct {
// Photo基础信息 // Photo基础信息
Hash string `json:"hash"` Hash string `json:"hash"`
ImgPath string `json:"imgPath"` ImgPath string `json:"imgPath"`
ImgName string `json:"imgName"` ImgName string `json:"imgName"`
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
AspectRatio float32 `json:"aspectRatio"` AspectRatio float32 `json:"aspectRatio"`
FileSize int64 `json:"fileSize"` FileSize int64 `json:"fileSize"`
Format string `json:"format"` Format string `json:"format"`
Notes *string `json:"notes,omitempty"` Notes *string `json:"notes,omitempty"`
FileCreatedAt *time.Time `json:"fileCreatedAt,omitempty"` FileCreatedAt *time.Time `json:"fileCreatedAt,omitempty"`
TakenAt *time.Time `json:"takenAt,omitempty"` TakenAt *time.Time `json:"takenAt,omitempty"`
LastModified *time.Time `json:"lastModified,omitempty"` LastModified *time.Time `json:"lastModified,omitempty"`
Rating int `json:"rating"` Rating int `json:"rating"`
LastViewedAt *time.Time `json:"lastViewedAt,omitempty"` LastViewedAt *time.Time `json:"lastViewedAt,omitempty"`
ViewCount int `json:"viewCount"` ViewCount int `json:"viewCount"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
// EXIF信息 // EXIF信息
ExifInfo *PhotoExifInfo `json:"exifInfo,omitempty"` ExifInfo *PhotoExifInfo `json:"exifInfo,omitempty"`
@@ -88,7 +86,7 @@ type PhotoExifInfo struct {
} }
// GetPhoto 获取图像文件 // GetPhoto 获取图像文件
// GET api/v1/photo/{hash}?format=thumbnail&size=400x400 // GET api/v1/photo/{hash}?format=thumbnail&size=400
func (h *PhotoHandler) GetPhoto(c *gin.Context) { func (h *PhotoHandler) GetPhoto(c *gin.Context) {
hash := c.Param("hash") hash := c.Param("hash")
if hash == "" { if hash == "" {
@@ -103,16 +101,6 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) {
format := c.DefaultQuery("format", "original") // original, thumbnail format := c.DefaultQuery("format", "original") // original, thumbnail
sizeParam := c.Query("size") sizeParam := c.Query("size")
// 解析尺寸参数
maxWidth, maxHeight, err := h.parseSize(sizeParam)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("Invalid size parameter: %v", err),
})
return
}
// 查询照片信息 // 查询照片信息
photo, err := h.container.PhotoRepo.GetByHash(hash) photo, err := h.container.PhotoRepo.GetByHash(hash)
if err != nil { if err != nil {
@@ -124,17 +112,47 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) {
return return
} }
// 更新访问计数和时间 // 异步更新访问计数和时间
if err := h.container.PhotoRepo.UpdateViewCount(hash); err != nil { task := &db.UpdateViewCountTask{Hash: hash}
logger.Warn("更新访问计数失败", zap.String("hash", hash), zap.Error(err)) callback := db.TaskCallback{
OnError: func(taskID string, err error) {
logger.Warn("异步更新访问计数失败",
zap.String("hash", hash),
zap.String("taskID", taskID),
zap.Error(err))
},
}
if err := db.GetManger().SubmitWriteTask(task, callback); err != nil {
logger.Warn("提交更新访问计数任务失败", zap.String("hash", hash), zap.Error(err))
}
// 使用ImageAPI处理图像
imageAPI, err := api.NewImageAPI(photo.ImgPath)
if err != nil {
logger.Error("创建ImageAPI失败", zap.String("path", photo.ImgPath), zap.Error(err))
c.JSON(http.StatusInternalServerError, model.Response{
Code: http.StatusInternalServerError,
Message: "Failed to process image",
})
return
} }
var imagePath string var imagePath string
switch format { switch format {
case "thumbnail": case "thumbnail":
// 生成或获取缩略图路径 // 解析尺寸参数
imagePath, err = h.getThumbnailPath(photo, maxWidth, maxHeight) size, err := h.parseSize(sizeParam)
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: fmt.Sprintf("Invalid size parameter: %v", err),
})
return
}
// 使用ImageAPI获取缩略图
imagePath, err = imageAPI.GetImagePath(size)
if err != nil { if err != nil {
logger.Error("获取缩略图失败", zap.String("hash", hash), zap.Error(err)) logger.Error("获取缩略图失败", zap.String("hash", hash), zap.Error(err))
c.JSON(http.StatusInternalServerError, model.Response{ c.JSON(http.StatusInternalServerError, model.Response{
@@ -144,8 +162,16 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) {
return return
} }
case "original": case "original":
// 返回原图 // 使用ImageAPI获取支持的原图格式
imagePath = photo.ImgPath imagePath, err = imageAPI.GetSupportOriginalImagePath(c.Request.Context())
if err != nil {
logger.Error("获取原图失败", zap.String("hash", hash), zap.Error(err))
c.JSON(http.StatusInternalServerError, model.Response{
Code: http.StatusInternalServerError,
Message: "Failed to get original image",
})
return
}
default: default:
c.JSON(http.StatusBadRequest, model.Response{ c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest, Code: http.StatusBadRequest,
@@ -154,18 +180,8 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) {
return return
} }
// 检查文件是否存在
if _, err := os.Stat(imagePath); os.IsNotExist(err) {
logger.Error("图像文件不存在", zap.String("path", imagePath))
c.JSON(http.StatusNotFound, model.Response{
Code: http.StatusNotFound,
Message: "Image file not found",
})
return
}
// 设置响应头 // 设置响应头
h.setImageHeaders(c, imagePath, photo.Format) h.setImageHeaders(c, imagePath, imageAPI.GetFormat())
// 返回文件 // 返回文件
c.File(imagePath) c.File(imagePath)
@@ -262,98 +278,163 @@ func (h *PhotoHandler) GetPhotoAssets(c *gin.Context) {
}) })
} }
// parseSize 解析尺寸参数 // parseSize 解析尺寸参数,返回长边尺寸
func (h *PhotoHandler) parseSize(sizeParam string) (int, int, error) { func (h *PhotoHandler) parseSize(sizeParam string) (int, error) {
if sizeParam == "" { if sizeParam == "" {
// 使用默认缩略图尺寸 // 使用默认缩略图尺寸
defaultSize := config.GetDefaultThumbnailSize() defaultSize := config.GetDefaultThumbnailSize()
return defaultSize, defaultSize, nil return defaultSize, nil
} }
// 解析 "400x400" 格式 size, err := strconv.Atoi(sizeParam)
parts := strings.Split(sizeParam, "x")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid size format, expected WIDTHxHEIGHT")
}
width, err := strconv.Atoi(parts[0])
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("invalid width: %v", err) return 0, fmt.Errorf("invalid size parameter: %v", err)
} }
height, err := strconv.Atoi(parts[1]) if size <= 0 {
if err != nil { return 0, fmt.Errorf("size must be positive")
return 0, 0, fmt.Errorf("invalid height: %v", err)
} }
if width <= 0 || height <= 0 { return size, nil
return 0, 0, fmt.Errorf("width and height must be positive")
}
return width, height, nil
} }
// getThumbnailPath 获取或生成缩略图路径 // GetTimeline 获取照片时间线统计
func (h *PhotoHandler) getThumbnailPath(photo *tables.Photo, maxWidth, maxHeight int) (string, error) { // GET /api/v1/photos/timeline?start_date=2023-01-01&end_date=2023-12-31
// 构建缩略图文件名 func (h *PhotoHandler) GetTimeline(c *gin.Context) {
// 格式: {hash}_{maxWidth}x{maxHeight}.jpg startDateStr := c.Query("start_date")
thumbnailName := fmt.Sprintf("%s_%dx%d.jpg", photo.Hash, maxWidth, maxHeight) endDateStr := c.Query("end_date")
// 构建缩略图完整路径
appDir := config.CONFIG.AppDir
thumbnailDir := filepath.Join(appDir, config.CONFIG.PathConfig.CachePath, config.CONFIG.PathConfig.ThumbnailPath)
thumbnailPath := filepath.Join(thumbnailDir, thumbnailName)
// 检查缩略图是否已存在 var timeline []repositories.PhotoTimelineItem
if _, err := os.Stat(thumbnailPath); err == nil { var err error
return thumbnailPath, nil
// 如果提供了时间范围参数
if startDateStr != "" && endDateStr != "" {
startDate, err1 := time.Parse("2006-01-02", startDateStr)
endDate, err2 := time.Parse("2006-01-02", endDateStr)
if err1 != nil || err2 != nil {
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: "Invalid date format. Use YYYY-MM-DD format",
})
return
}
timeline, err = h.container.PhotoRepo.GetPhotoTimelineByDateRange(startDate, endDate)
} else {
// 获取全部时间线数据
timeline, err = h.container.PhotoRepo.GetPhotoTimeline()
} }
// 缩略图不存在,需要生成
err := h.generateThumbnail(photo.ImgPath, thumbnailPath, maxWidth, maxHeight)
if err != nil { if err != nil {
return "", fmt.Errorf("生成缩略图失败: %w", err) logger.Error("获取时间线数据失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, model.Response{
Code: http.StatusInternalServerError,
Message: "Failed to get timeline data",
})
return
} }
return thumbnailPath, nil c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
Message: "Success",
Data: timeline,
})
} }
// generateThumbnail 生成缩略图 // PhotoColumnResponse 照片列式存储响应格式
func (h *PhotoHandler) generateThumbnail(originalPath, thumbnailPath string, maxWidth, maxHeight int) error { type PhotoColumnResponse struct {
// 确保缩略图目录存在 Hash []string `json:"hash"`
thumbnailDir := filepath.Dir(thumbnailPath) IsImage []bool `json:"isImage"`
if err := os.MkdirAll(thumbnailDir, 0755); err != nil { TakenAt []string `json:"takenAt"`
return fmt.Errorf("创建缩略图目录失败: %w", err) Ratio []float32 `json:"ratio"`
}
// TODO: 这里应该调用图像处理库生成缩略图
// 目前先简单复制原图作为占位符
return h.copyFile(originalPath, thumbnailPath)
} }
// copyFile 复制文件(临时方案) // GetPhotos 获取照片列表(列式存储格式)
func (h *PhotoHandler) copyFile(src, dst string) error { // GET /api/v1/photos?limit=100&offset=0&start_date=2023-01-01&end_date=2023-12-31
srcFile, err := os.Open(src) func (h *PhotoHandler) GetPhotos(c *gin.Context) {
if err != nil { limitStr := c.DefaultQuery("limit", "50")
return err offsetStr := c.DefaultQuery("offset", "0")
} startDateStr := c.Query("start_date")
defer srcFile.Close() endDateStr := c.Query("end_date")
dstFile, err := os.Create(dst) limit, err := strconv.Atoi(limitStr)
if err != nil { if err != nil || limit <= 0 {
return err limit = 50
}
if limit > 1000 { // 限制最大数量
limit = 1000
} }
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile) offset, err := strconv.Atoi(offsetStr)
return err if err != nil || offset < 0 {
offset = 0
}
var photos []repositories.PhotoListItem
// 如果提供了时间范围参数
if startDateStr != "" && endDateStr != "" {
startDate, err1 := time.Parse("2006-01-02", startDateStr)
endDate, err2 := time.Parse("2006-01-02", endDateStr)
if err1 != nil || err2 != nil {
c.JSON(http.StatusBadRequest, model.Response{
Code: http.StatusBadRequest,
Message: "Invalid date format. Use YYYY-MM-DD format",
})
return
}
photos, err = h.container.PhotoRepo.GetPhotoListItemsByDateRange(startDate, endDate, limit, offset)
} else {
// 获取全部照片列表
photos, err = h.container.PhotoRepo.GetPhotoListItems(limit, offset)
}
if err != nil {
logger.Error("获取照片列表失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, model.Response{
Code: http.StatusInternalServerError,
Message: "Failed to get photos list",
})
return
}
// 转换为列式存储格式
columnData := PhotoColumnResponse{
Hash: make([]string, len(photos)),
IsImage: make([]bool, len(photos)),
TakenAt: make([]string, len(photos)),
Ratio: make([]float32, len(photos)),
}
for i, photo := range photos {
columnData.Hash[i] = photo.Hash
columnData.IsImage[i] = true // 所有照片都是图像
// 处理拍摄时间
if photo.TakenAt != nil {
columnData.TakenAt[i] = photo.TakenAt.Format("2006-01-02T15:04:05")
} else {
columnData.TakenAt[i] = ""
}
columnData.Ratio[i] = photo.AspectRatio
}
c.JSON(http.StatusOK, model.Response{
Code: http.StatusOK,
Message: "Success",
Data: columnData,
})
} }
// setImageHeaders 设置图像响应头 // setImageHeaders 设置图像响应头
func (h *PhotoHandler) setImageHeaders(c *gin.Context, imagePath, format string) { func (h *PhotoHandler) setImageHeaders(c *gin.Context, imagePath, format string) {
// 设置Content-Type // 设置Content-Type
var contentType string var contentType string
switch strings.ToLower(format) { switch format {
case "jpg", "jpeg": case "jpg", "jpeg":
contentType = "image/jpeg" contentType = "image/jpeg"
case "png": case "png":
@@ -365,14 +446,13 @@ func (h *PhotoHandler) setImageHeaders(c *gin.Context, imagePath, format string)
default: default:
contentType = "application/octet-stream" contentType = "application/octet-stream"
} }
c.Header("Content-Type", contentType) c.Header("Content-Type", contentType)
// 设置缓存头 // 设置缓存头
c.Header("Cache-Control", "public, max-age=86400") // 缓存1天 c.Header("Cache-Control", "public, max-age=86400") // 缓存1天
c.Header("ETag", fmt.Sprintf(`"%s"`, filepath.Base(imagePath)))
// 设置文件名 // 设置文件名
fileName := filepath.Base(imagePath) fileName := fmt.Sprintf("image.%s", format)
c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, fileName)) c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, fileName))
} }
@@ -1,6 +1,7 @@
package repositories package repositories
import ( import (
"context"
"gorm.io/gorm" "gorm.io/gorm"
"rear/internal/db" "rear/internal/db"
"rear/internal/model/tables" "rear/internal/model/tables"
@@ -18,11 +19,24 @@ func NewExifRepository() *ExifRepository {
} }
} }
// Create 创建EXIF记录 // Create 创建EXIF记录(同步)
func (r *ExifRepository) Create(exif *tables.PhotoExif) error { func (r *ExifRepository) Create(exif *tables.PhotoExif) error {
return r.db.Create(exif).Error return r.db.Create(exif).Error
} }
// CreateAsync 异步创建EXIF记录
func (r *ExifRepository) CreateAsync(ctx context.Context, exif *tables.PhotoExif) error {
task := &db.ExifCreateTask{Exif: exif}
callback := db.TaskCallback{}
return db.GetManger().SubmitWriteTask(task, callback)
}
// CreateAsyncSync 同步等待异步创建EXIF记录
func (r *ExifRepository) CreateAsyncSync(ctx context.Context, exif *tables.PhotoExif) error {
task := &db.ExifCreateTask{Exif: exif}
return db.GetManger().SubmitWriteTaskSync(ctx, task)
}
// GetByHash 根据Hash获取EXIF信息 // GetByHash 根据Hash获取EXIF信息
func (r *ExifRepository) GetByHash(hash string) (*tables.PhotoExif, error) { func (r *ExifRepository) GetByHash(hash string) (*tables.PhotoExif, error) {
var exif tables.PhotoExif var exif tables.PhotoExif
@@ -33,16 +47,42 @@ func (r *ExifRepository) GetByHash(hash string) (*tables.PhotoExif, error) {
return &exif, nil return &exif, nil
} }
// Update 更新EXIF记录 // Update 更新EXIF记录(同步)
func (r *ExifRepository) Update(exif *tables.PhotoExif) error { func (r *ExifRepository) Update(exif *tables.PhotoExif) error {
return r.db.Save(exif).Error return r.db.Save(exif).Error
} }
// Delete 删除EXIF记录 // UpdateAsync 异步更新EXIF记录
func (r *ExifRepository) UpdateAsync(ctx context.Context, exif *tables.PhotoExif) error {
task := &db.ExifUpdateTask{Exif: exif}
callback := db.TaskCallback{}
return db.GetManger().SubmitWriteTask(task, callback)
}
// UpdateAsyncSync 同步等待异步更新EXIF记录
func (r *ExifRepository) UpdateAsyncSync(ctx context.Context, exif *tables.PhotoExif) error {
task := &db.ExifUpdateTask{Exif: exif}
return db.GetManger().SubmitWriteTaskSync(ctx, task)
}
// Delete 删除EXIF记录(同步)
func (r *ExifRepository) Delete(hash string) error { func (r *ExifRepository) Delete(hash string) error {
return r.db.Where("hash = ?", hash).Delete(&tables.PhotoExif{}).Error return r.db.Where("hash = ?", hash).Delete(&tables.PhotoExif{}).Error
} }
// DeleteAsync 异步删除EXIF记录
func (r *ExifRepository) DeleteAsync(ctx context.Context, hash string) error {
task := &db.ExifDeleteTask{Hash: hash}
callback := db.TaskCallback{}
return db.GetManger().SubmitWriteTask(task, callback)
}
// DeleteAsyncSync 同步等待异步删除EXIF记录
func (r *ExifRepository) DeleteAsyncSync(ctx context.Context, hash string) error {
task := &db.ExifDeleteTask{Hash: hash}
return db.GetManger().SubmitWriteTaskSync(ctx, task)
}
// Exists 检查EXIF记录是否存在 // Exists 检查EXIF记录是否存在
func (r *ExifRepository) Exists(hash string) (bool, error) { func (r *ExifRepository) Exists(hash string) (bool, error) {
var count int64 var count int64
@@ -1,6 +1,7 @@
package repositories package repositories
import ( import (
"context"
"errors" "errors"
"math" "math"
"os" "os"
@@ -26,11 +27,24 @@ func NewPhotoRepository() *PhotoRepository {
} }
} }
// Create 创建照片记录 // Create 创建照片记录(同步)
func (r *PhotoRepository) Create(photo *tables.Photo) error { func (r *PhotoRepository) Create(photo *tables.Photo) error {
return r.db.Create(photo).Error return r.db.Create(photo).Error
} }
// CreateAsync 异步创建照片记录
func (r *PhotoRepository) CreateAsync(ctx context.Context, photo *tables.Photo) error {
task := &db.PhotoCreateTask{Photo: photo}
callback := db.TaskCallback{}
return db.GetManger().SubmitWriteTask(task, callback)
}
// CreateAsyncSync 同步等待异步创建照片记录
func (r *PhotoRepository) CreateAsyncSync(ctx context.Context, photo *tables.Photo) error {
task := &db.PhotoCreateTask{Photo: photo}
return db.GetManger().SubmitWriteTaskSync(ctx, task)
}
// GetByHash 根据Hash获取照片信息 // GetByHash 根据Hash获取照片信息
func (r *PhotoRepository) GetByHash(hash string) (*tables.Photo, error) { func (r *PhotoRepository) GetByHash(hash string) (*tables.Photo, error) {
var photo tables.Photo var photo tables.Photo
@@ -41,16 +55,42 @@ func (r *PhotoRepository) GetByHash(hash string) (*tables.Photo, error) {
return &photo, nil return &photo, nil
} }
// Update 更新照片记录 // Update 更新照片记录(同步)
func (r *PhotoRepository) Update(photo *tables.Photo) error { func (r *PhotoRepository) Update(photo *tables.Photo) error {
return r.db.Save(photo).Error return r.db.Save(photo).Error
} }
// Delete 删除照片记录 // UpdateAsync 异步更新照片记录
func (r *PhotoRepository) UpdateAsync(ctx context.Context, photo *tables.Photo) error {
task := &db.PhotoUpdateTask{Photo: photo}
callback := db.TaskCallback{}
return db.GetManger().SubmitWriteTask(task, callback)
}
// UpdateAsyncSync 同步等待异步更新照片记录
func (r *PhotoRepository) UpdateAsyncSync(ctx context.Context, photo *tables.Photo) error {
task := &db.PhotoUpdateTask{Photo: photo}
return db.GetManger().SubmitWriteTaskSync(ctx, task)
}
// Delete 删除照片记录(同步)
func (r *PhotoRepository) Delete(hash string) error { func (r *PhotoRepository) Delete(hash string) error {
return r.db.Where("hash = ?", hash).Delete(&tables.Photo{}).Error return r.db.Where("hash = ?", hash).Delete(&tables.Photo{}).Error
} }
// DeleteAsync 异步删除照片记录
func (r *PhotoRepository) DeleteAsync(ctx context.Context, hash string) error {
task := &db.PhotoDeleteTask{Hash: hash}
callback := db.TaskCallback{}
return db.GetManger().SubmitWriteTask(task, callback)
}
// DeleteAsyncSync 同步等待异步删除照片记录
func (r *PhotoRepository) DeleteAsyncSync(ctx context.Context, hash string) error {
task := &db.PhotoDeleteTask{Hash: hash}
return db.GetManger().SubmitWriteTaskSync(ctx, task)
}
// Exists 检查照片记录是否存在 // Exists 检查照片记录是否存在
func (r *PhotoRepository) Exists(hash string) (bool, error) { func (r *PhotoRepository) Exists(hash string) (bool, error) {
var count int64 var count int64
@@ -58,14 +98,14 @@ func (r *PhotoRepository) Exists(hash string) (bool, error) {
return count > 0, err return count > 0, err
} }
// CreateOrUpdate 创建或更新照片记录 // CreateOrUpdate 创建或更新照片记录(同步)
func (r *PhotoRepository) CreateOrUpdate(photo *tables.Photo) error { func (r *PhotoRepository) CreateOrUpdate(photo *tables.Photo) error {
// 先检查是否存在 // 先检查是否存在
exists, err := r.Exists(photo.Hash) exists, err := r.Exists(photo.Hash)
if err != nil { if err != nil {
return err return err
} }
if exists { if exists {
return r.Update(photo) return r.Update(photo)
} else { } else {
@@ -73,6 +113,21 @@ func (r *PhotoRepository) CreateOrUpdate(photo *tables.Photo) error {
} }
} }
// CreateOrUpdateAsync 异步创建或更新照片记录
func (r *PhotoRepository) CreateOrUpdateAsync(ctx context.Context, photo *tables.Photo) error {
// 先检查是否存在
exists, err := r.Exists(photo.Hash)
if err != nil {
return err
}
if exists {
return r.UpdateAsync(ctx, photo)
} else {
return r.CreateAsync(ctx, photo)
}
}
// GetAll 获取所有照片记录 // GetAll 获取所有照片记录
func (r *PhotoRepository) GetAll(limit int, offset int) ([]*tables.Photo, error) { func (r *PhotoRepository) GetAll(limit int, offset int) ([]*tables.Photo, error) {
var photos []*tables.Photo var photos []*tables.Photo
@@ -128,7 +183,7 @@ func (r *PhotoRepository) GetPhotosBySize(minSize, maxSize int64, limit int, off
// GetPhotosByDimensions 根据尺寸范围获取照片 // GetPhotosByDimensions 根据尺寸范围获取照片
func (r *PhotoRepository) GetPhotosByDimensions(minWidth, maxWidth, minHeight, maxHeight int, limit int, offset int) ([]*tables.Photo, error) { func (r *PhotoRepository) GetPhotosByDimensions(minWidth, maxWidth, minHeight, maxHeight int, limit int, offset int) ([]*tables.Photo, error) {
var photos []*tables.Photo var photos []*tables.Photo
err := r.db.Where("width BETWEEN ? AND ? AND height BETWEEN ? AND ?", err := r.db.Where("width BETWEEN ? AND ? AND height BETWEEN ? AND ?",
minWidth, maxWidth, minHeight, maxHeight). minWidth, maxWidth, minHeight, maxHeight).
Limit(limit). Limit(limit).
Offset(offset). Offset(offset).
@@ -171,7 +226,7 @@ func (r *PhotoRepository) UpdateViewCount(hash string) error {
func (r *PhotoRepository) CreatePhotoFromImageAPI(hash, imgPath string, exifData interface{}) *tables.Photo { func (r *PhotoRepository) CreatePhotoFromImageAPI(hash, imgPath string, exifData interface{}) *tables.Photo {
// 提取文件名 // 提取文件名
imgName := filepath.Base(imgPath) imgName := filepath.Base(imgPath)
// 提取文件格式 // 提取文件格式
format := strings.ToLower(filepath.Ext(imgPath)) format := strings.ToLower(filepath.Ext(imgPath))
if format != "" && format[0] == '.' { if format != "" && format[0] == '.' {
@@ -179,28 +234,33 @@ func (r *PhotoRepository) CreatePhotoFromImageAPI(hash, imgPath string, exifData
} }
photo := &tables.Photo{ photo := &tables.Photo{
ImgPath: imgPath, ImgPath: imgPath,
ImgName: imgName, ImgName: imgName,
Hash: hash, Hash: hash,
Format: format, Format: format,
Rating: 0, Rating: 0,
ViewCount: 0, ViewCount: 0,
} }
// 获取文件信息以获取修改时间 // 获取文件信息以获取修改时间和文件大小
if fileInfo, err := os.Stat(imgPath); err == nil { if fileInfo, err := os.Stat(imgPath); err == nil {
fileModTime := fileInfo.ModTime() fileModTime := fileInfo.ModTime()
photo.LastModified = &fileModTime photo.LastModified = &fileModTime
// 如果没有EXIF数据,至少设置文件大小
if photo.FileSize == 0 {
photo.FileSize = fileInfo.Size()
}
} }
// 如果有EXIF数据,提取基础信息和时间信息 // 如果有EXIF数据,提取基础信息和时间信息
if exifData != nil { if exifData != nil {
if parsedExif, ok := exifData.(*model.ParsedExif); ok { if parsedExif, ok := exifData.(*model.ParsedExif); ok && parsedExif != nil {
// 设置基础图片信息 // 设置基础图片信息
photo.Width = parsedExif.BaseInfo.ImageWidth photo.Width = parsedExif.BaseInfo.ImageWidth
photo.Height = parsedExif.BaseInfo.ImageHeight photo.Height = parsedExif.BaseInfo.ImageHeight
photo.FileSize = parsedExif.BaseInfo.FileSize photo.FileSize = parsedExif.BaseInfo.FileSize
// 计算宽高比(保留2位小数) // 计算宽高比(保留2位小数)
if photo.Height > 0 { if photo.Height > 0 {
ratio := float64(photo.Width) / float64(photo.Height) ratio := float64(photo.Width) / float64(photo.Height)
@@ -209,19 +269,19 @@ func (r *PhotoRepository) CreatePhotoFromImageAPI(hash, imgPath string, exifData
// 处理拍摄时间 - 优先使用EXIF中的拍摄时间,如果没有则使用文件修改时间 // 处理拍摄时间 - 优先使用EXIF中的拍摄时间,如果没有则使用文件修改时间
var takenAt *time.Time var takenAt *time.Time
// 尝试解析EXIF中的拍摄时间 // 尝试解析EXIF中的拍摄时间
if parsedExif.Exif.DateTimeOrig != "" { if parsedExif.Exif.DateTimeOrig != "" {
if parsedTime, err := parseExifDateTime(parsedExif.Exif.DateTimeOrig); err == nil { if parsedTime, err := parseExifDateTime(parsedExif.Exif.DateTimeOrig); err == nil {
takenAt = &parsedTime takenAt = &parsedTime
} }
} }
// 如果EXIF中没有拍摄时间,使用文件修改时间 // 如果EXIF中没有拍摄时间,使用文件修改时间
if takenAt == nil && photo.LastModified != nil { if takenAt == nil && photo.LastModified != nil {
takenAt = photo.LastModified takenAt = photo.LastModified
} }
photo.TakenAt = takenAt photo.TakenAt = takenAt
// 处理文件创建时间 // 处理文件创建时间
@@ -241,17 +301,17 @@ func (r *PhotoRepository) CreatePhotoFromImageAPI(hash, imgPath string, exifData
func parseExifDateTime(dateTimeStr string) (time.Time, error) { func parseExifDateTime(dateTimeStr string) (time.Time, error) {
// 常见的EXIF时间格式 // 常见的EXIF时间格式
formats := []string{ formats := []string{
"2006:01:02 15:04:05", // 标准EXIF格式 "2006:01:02 15:04:05", // 标准EXIF格式
"2006:01:02 15:04:05-07:00", // 带时区 "2006:01:02 15:04:05-07:00", // 带时区
"2006:01:02 15:04:05+07:00", // 带时区 "2006:01:02 15:04:05+07:00", // 带时区
"2006-01-02 15:04:05", // ISO格式 "2006-01-02 15:04:05", // ISO格式
"2006-01-02T15:04:05Z", // ISO格式带Z "2006-01-02T15:04:05Z", // ISO格式带Z
"2006-01-02T15:04:05-07:00", // ISO格式带时区 "2006-01-02T15:04:05-07:00", // ISO格式带时区
} }
// 替换EXIF格式中的冒号为横线(年月日部分) // 替换EXIF格式中的冒号为横线(年月日部分)
normalizedStr := strings.Replace(dateTimeStr, ":", "-", 2) normalizedStr := strings.Replace(dateTimeStr, ":", "-", 2)
// 尝试不同的格式 // 尝试不同的格式
for _, format := range formats { for _, format := range formats {
if t, err := time.Parse(format, dateTimeStr); err == nil { if t, err := time.Parse(format, dateTimeStr); err == nil {
@@ -261,7 +321,7 @@ func parseExifDateTime(dateTimeStr string) (time.Time, error) {
return t, nil return t, nil
} }
} }
// 如果都解析失败,返回错误 // 如果都解析失败,返回错误
return time.Time{}, errors.New("unable to parse datetime string: " + dateTimeStr) return time.Time{}, errors.New("unable to parse datetime string: " + dateTimeStr)
} }
@@ -316,4 +376,79 @@ func (r *PhotoRepository) GetPhotosWithoutTakenDate(limit int, offset int) ([]*t
Offset(offset). Offset(offset).
Find(&photos).Error Find(&photos).Error
return photos, err return photos, err
} }
// PhotoTimelineItem 时间线统计项
type PhotoTimelineItem struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
// GetPhotoTimeline 获取照片时间线统计(按拍摄日期)
func (r *PhotoRepository) GetPhotoTimeline() ([]PhotoTimelineItem, error) {
var results []PhotoTimelineItem
// 使用DATE函数按日期分组统计
err := r.db.Model(&tables.Photo{}).
Select("DATE(taken_at) as date, COUNT(*) as count").
Where("taken_at IS NOT NULL").
Group("DATE(taken_at)").
Order("date ASC").
Scan(&results).Error
return results, err
}
// GetPhotoTimelineByDateRange 获取指定时间范围的照片时间线统计
func (r *PhotoRepository) GetPhotoTimelineByDateRange(startDate, endDate time.Time) ([]PhotoTimelineItem, error) {
var results []PhotoTimelineItem
err := r.db.Model(&tables.Photo{}).
Select("DATE(taken_at) as date, COUNT(*) as count").
Where("taken_at IS NOT NULL AND DATE(taken_at) BETWEEN DATE(?) AND DATE(?)", startDate, endDate).
Group("DATE(taken_at)").
Order("date ASC").
Scan(&results).Error
return results, err
}
// PhotoListItem 照片列表项(用于前端渲染框架)
type PhotoListItem struct {
Hash string `json:"hash"`
Width int `json:"width"`
Height int `json:"height"`
AspectRatio float32 `json:"aspectRatio"`
Format string `json:"format"`
TakenAt *time.Time `json:"takenAt,omitempty"`
FileSize int64 `json:"fileSize"`
}
// GetPhotoListItems 获取照片列表项(支持范围查询)
func (r *PhotoRepository) GetPhotoListItems(limit int, offset int) ([]PhotoListItem, error) {
var items []PhotoListItem
err := r.db.Model(&tables.Photo{}).
Select("hash, width, height, aspect_ratio, format, taken_at, file_size").
Order("taken_at DESC, created_at DESC").
Limit(limit).
Offset(offset).
Scan(&items).Error
return items, err
}
// GetPhotoListItemsByDateRange 根据拍摄时间范围获取照片列表项
func (r *PhotoRepository) GetPhotoListItemsByDateRange(startDate, endDate time.Time, limit int, offset int) ([]PhotoListItem, error) {
var items []PhotoListItem
err := r.db.Model(&tables.Photo{}).
Select("hash, width, height, aspect_ratio, format, taken_at, file_size").
Where("taken_at IS NOT NULL AND DATE(taken_at) BETWEEN DATE(?) AND DATE(?)", startDate, endDate).
Order("taken_at DESC").
Limit(limit).
Offset(offset).
Scan(&items).Error
return items, err
}
+22 -15
View File
@@ -20,7 +20,7 @@ func SetupRoutes(r *gin.Engine, contain *container.DbContainer, imgContain *cont
devImageHandler := handler.NewDevImageHandler(contain) devImageHandler := handler.NewDevImageHandler(contain)
exifHandler := handler.NewExifHandler(contain) exifHandler := handler.NewExifHandler(contain)
photoHandler := handler.NewPhotoHandler(contain, imgContain) photoHandler := handler.NewPhotoHandler(contain, imgContain)
// API版本组 // API版本组
v1 := r.Group("/api/v1") v1 := r.Group("/api/v1")
{ {
@@ -47,28 +47,35 @@ func SetupRoutes(r *gin.Engine, contain *container.DbContainer, imgContain *cont
// EXIF信息相关路由 // EXIF信息相关路由
exif := v1.Group("/exif") exif := v1.Group("/exif")
{ {
exif.GET("", exifHandler.GetAllExifs) // 获取所有EXIF记录 exif.GET("", exifHandler.GetAllExifs) // 获取所有EXIF记录
exif.GET("/:hash", exifHandler.GetExifByHash) // 根据照片哈希获取EXIF exif.GET("/:hash", exifHandler.GetExifByHash) // 根据照片哈希获取EXIF
exif.GET("/statistics", exifHandler.GetExifStatistics) // 获取统计信息 exif.GET("/statistics", exifHandler.GetExifStatistics) // 获取统计信息
exif.GET("/cameras", exifHandler.GetUniqueCameras) // 获取相机列表 exif.GET("/cameras", exifHandler.GetUniqueCameras) // 获取相机列表
exif.GET("/cameras/stats", exifHandler.GetCameraStatistics) // 获取相机统计 exif.GET("/cameras/stats", exifHandler.GetCameraStatistics) // 获取相机统计
exif.GET("/search", exifHandler.SearchExifs) // 搜索EXIF exif.GET("/search", exifHandler.SearchExifs) // 搜索EXIF
exif.GET("/gps", exifHandler.GetExifsWithGPS) // 获取GPS信息 exif.GET("/gps", exifHandler.GetExifsWithGPS) // 获取GPS信息
exif.GET("/iso", exifHandler.GetExifsByISO) // 根据ISO筛选 exif.GET("/iso", exifHandler.GetExifsByISO) // 根据ISO筛选
exif.GET("/aperture", exifHandler.GetExifsByAperture) // 根据光圈筛选 exif.GET("/aperture", exifHandler.GetExifsByAperture) // 根据光圈筛选
exif.GET("/camera", exifHandler.GetExifsByCamera) // 根据相机筛选 exif.GET("/camera", exifHandler.GetExifsByCamera) // 根据相机筛选
} }
// 照片相关路由 // 照片相关路由
photo := v1.Group("/photo") photo := v1.Group("/photo")
{ {
photo.GET("/:hash", photoHandler.GetPhoto) // 获取图像文件 photo.GET("/:hash", photoHandler.GetPhoto) // 获取图像文件
} }
// 照片列表相关路由
photos := v1.Group("/photos")
{
photos.GET("/timeline", photoHandler.GetTimeline) // 获取照片时间线统计
photos.GET("", photoHandler.GetPhotos) // 获取照片列表(支持范围查询)
}
// 资产信息相关路由 // 资产信息相关路由
assets := v1.Group("/assets") assets := v1.Group("/assets")
{ {
assets.GET("/:hash", photoHandler.GetPhotoAssets) // 获取图像详细信息 assets.GET("/:hash", photoHandler.GetPhotoAssets) // 获取图像详细信息
} }
} }
// 开发组 // 开发组
+33
View File
@@ -0,0 +1,33 @@
2025-08-16 17:50:46.628 DEBUG api/img_api.go:505 生成缩略图完成 {"original": "E:\\整合\\kkimkkimmy\\Pic\\116055250_The light is nice. ️_01_8cc4a8a3-0f83-4c22-bf8c-c5b9d1de3419.jpg", "thumbnail": "C:\\Users\\charlatans\\AppData\\Local\\JetBrains\\GoLand2025.1\\tmp\\GoLand\\cache\\thumbnail\\fa\\35\\f2\\fa35f27766e9cbeb84a557e0b74e9680f252c5000f33b248d38f6c799dbf79c7\\720X720.jpg", "size": 720}
2025-08-16 17:50:46.628 DEBUG workflow/img_task.go:233 任务步骤更新 {"task_id": "b2a5347d-fabb-49de-ad2c-e0b625a2486e", "step_name": "生成缩略图", "overall_progress": 0.85}
2025-08-16 17:50:46.628 DEBUG workflow/img_task.go:233 任务步骤更新 {"task_id": "b2a5347d-fabb-49de-ad2c-e0b625a2486e", "step_name": "保存数据", "overall_progress": 0.9}
2025-08-16 17:50:46.628 INFO workflow/img_task.go:367 保存图像信息到数据库 {"hash": "fa35f27766e9cbeb84a557e0b74e9680f252c5000f33b248d38f6c799dbf79c7", "path": "E:\\整合\\kkimkkimmy\\Pic\\116055250_The light is nice. ️_01_8cc4a8a3-0f83-4c22-bf8c-c5b9d1de3419.jpg"}
2025-08-16 17:50:46.628 WARN workflow/img_task.go:393 没有EXIF数据可保存 {"hash": "fa35f27766e9cbeb84a557e0b74e9680f252c5000f33b248d38f6c799dbf79c7"}
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x18 pc=0x7ff6ab5ed44a]
goroutine 269 [running]:
rear/internal/repositories.(*PhotoRepository).CreatePhotoFromImageAPI(0x7ff6aba65a4a?, {0xc0001297c0, 0x40}, {0xc0003ff2d0, 0x65}, {0x7ff6ab9591a0, 0x0})
D:/go-argus/src/rear/internal/repositories/photo_repository.go:200 +0x26a
rear/internal/workflow.(*PictureTask).saveToDatabase(0xc0004a4600, 0x7ff6aba5eddf?)
D:/go-argus/src/rear/internal/workflow/img_task.go:398 +0xa85
rear/internal/workflow.(*PictureTask).Run(0xc0004a4600)
D:/go-argus/src/rear/internal/workflow/img_task.go:318 +0x26e
rear/internal/workflow.(*ImgTaskManager).run.func1(0xc0004a4600)
D:/go-argus/src/rear/internal/workflow/img_task.go:619 +0x51
created by rear/internal/workflow.(*ImgTaskManager).run in goroutine 14
D:/go-argus/src/rear/internal/workflow/img_task.go:617 +0x38
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x0 addr=0x18 pc=0x7ff6ab5ed44a]
goroutine 265 [running]:
rear/internal/repositories.(*PhotoRepository).CreatePhotoFromImageAPI(0x7ff6aba65a4a?, {0xc003d95340, 0x40}, {0xc0003ff110, 0x65}, {0x7ff6ab9591a0, 0x0})
D:/go-argus/src/rear/internal/repositories/photo_repository.go:200 +0x26a
rear/internal/workflow.(*PictureTask).saveToDatabase(0xc0004a4480, 0x7ff6aba5eddf?)
D:/go-argus/src/rear/internal/workflow/img_task.go:398 +0xa85
rear/internal/workflow.(*PictureTask).Run(0xc0004a4480)
D:/go-argus/src/rear/internal/workflow/img_task.go:318 +0x26e
rear/internal/workflow.(*ImgTaskManager).run.func1(0xc0004a4480)
D:/go-argus/src/rear/internal/workflow/img_task.go:619 +0x51
created by rear/internal/workflow.(*ImgTaskManager).run in goroutine 14
D:/go-argus/src/rear/internal/workflow/img_task.go:617 +0x38
+13
View File
@@ -51,3 +51,16 @@
{"level":"info","time":"2025-08-16 17:14:28.812","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"} {"level":"info","time":"2025-08-16 17:14:28.812","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-16 17:14:57.883","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"} {"level":"info","time":"2025-08-16 17:14:57.883","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-16 17:34:36.360","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"} {"level":"info","time":"2025-08-16 17:34:36.360","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-16 17:45:43.142","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-16 17:50:33.898","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-16 17:57:49.324","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-16 18:07:19.361","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 15:32:10.131","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 15:34:34.457","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 16:13:22.229","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 18:49:21.505","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 19:12:06.951","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 19:12:39.705","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 19:14:38.561","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
{"level":"info","time":"2025-08-21 21:12:53.018","caller":"config/config.go:90","msg":"加载配置文件成功","path":"D:\\go-argus\\src\\rear\\config.yaml"}
{"level":"info","time":"2025-08-21 21:20:29.322","caller":"config/config.go:90","msg":"加载配置文件成功","path":"config.yaml"}
BIN
View File
Binary file not shown.
+185
View File
@@ -0,0 +1,185 @@
# Argus HTTP API 测试文件
本目录包含了 Argus 照片管理系统的全套 HTTP API 测试文件,按功能模块进行分类。
## 📁 文件结构
```
tests/
├── README.md # 本文档
├── base.http # 基础功能测试(健康检查、环境配置)
├── library.http # 图像库管理测试
├── photo.http # 照片文件和详情接口测试
├── exif.http # EXIF信息查询测试
├── user.http # 用户管理接口测试
└── dev.http # 开发调试接口测试
```
## 🚀 快速开始
### 1. 环境准备
在项目根目录创建 `http-client.env.json` 文件:
```json
{
"dev": {
"baseUrl": "http://localhost:8080",
"sampleHash": "your_actual_hash_here"
},
"prod": {
"baseUrl": "https://your-production-domain.com",
"sampleHash": "production_hash_here"
}
}
```
### 2. 启动服务器
```bash
go run .
```
### 3. 测试顺序
建议按以下顺序执行测试:
1. **基础功能** (`base.http`)
- 健康检查
- API基础路由
2. **图像库管理** (`library.http`)
- 添加图像库路径
- 启用图像库
- 开始索引
- 获取图像hash值
3. **照片接口** (`photo.http`)
- 使用实际hash测试图像访问
- 测试各种缩略图尺寸
- 获取图像详情
4. **EXIF查询** (`exif.http`)
- 查询EXIF信息
- 按相机、参数筛选
- GPS信息查询
5. **用户管理** (`user.http`)
- 用户CRUD操作
- 用户查询和筛选
6. **开发调试** (`dev.http`)
- 系统状态监控
- 性能测试
- 开发工具
## 📝 测试说明
### 变量替换
测试文件中使用了以下变量,需要替换为实际值:
- `{{baseUrl}}`: 服务器地址 (如: http://localhost:8080)
- `{{apiBase}}`: API基础路径 ({{baseUrl}}/api/v1)
- `{{sampleHash}}`: 实际的图像hash值
### 获取实际Hash值
执行 `library.http` 中的图像库索引后,从响应中获取实际的图像hash值,然后替换测试文件中的 `sample_hash_12345`。
### 文件路径注意事项
- Windows: 使用反斜杠 `D:\\Pictures\\sample.jpg` 或正斜杠 `D:/Pictures/sample.jpg`
- Linux/Mac: 使用正斜杠 `/home/user/Pictures/sample.jpg`
- 确保路径中的图像文件真实存在
## 🔧 IDE 配置
### VS Code
安装 `REST Client` 扩展,可以直接在编辑器中执行HTTP请求。
### IntelliJ IDEA / WebStorm
内置HTTP客户端支持,可以直接运行 `.http` 文件。
### 命令行工具
也可以使用 curl 执行测试:
```bash
# 健康检查
curl http://localhost:8080/health
# 获取图像
curl "http://localhost:8080/api/v1/photo/your_hash?format=thumbnail&size=400x400"
# 获取图像详情
curl "http://localhost:8080/api/v1/assets/your_hash"
```
## 📊 响应格式
### 成功响应
```json
{
"code": 200,
"message": "Success",
"data": { ... }
}
```
### 错误响应
```json
{
"code": 404,
"message": "Photo not found",
"error": "详细错误信息"
}
```
### 图像文件响应
图像接口直接返回二进制数据,HTTP头包含正确的 `Content-Type`。
## ⚠️ 注意事项
1. **数据安全**: 测试前备份重要数据
2. **路径检查**: 确保文件路径存在且可访问
3. **性能影响**: 大量并发测试可能影响系统性能
4. **开发接口**: `dev.http` 中的接口仅供开发环境使用
5. **权限要求**: 某些操作可能需要特定权限
## 🐛 常见问题
### 连接失败
- 检查服务器是否启动
- 确认端口号正确
- 检查防火墙设置
### 404 错误
- 确认API路径正确
- 检查图像hash是否有效
- 验证图像文件是否存在
### 缩略图生成失败
- 检查ImageMagick/LibVips安装
- 确认原图文件可读
- 查看系统日志获取详细错误
### EXIF解析失败
- 检查ExifTool安装
- 确认图像文件包含EXIF数据
- 验证文件格式支持
## 📚 相关文档
- [API使用指南](../API_USAGE.md)
- [项目说明](../CLAUDE.md)
- [配置文档](../config.yaml)
+25
View File
@@ -0,0 +1,25 @@
### Argus 照片管理系统 - 基础功能测试
### 服务器地址
@baseUrl = http://localhost:8080
@apiBase = {{baseUrl}}/api/v1
### ===== 健康检查 =====
### 健康检查
GET {{baseUrl}}/health
### API 基础路由
GET {{apiBase}}
### ===== 环境变量示例 =====
### 在http-client.env.json中配置:
### {
### "dev": {
### "baseUrl": "http://localhost:8080",
### "sampleHash": "your_actual_hash_here"
### },
### "prod": {
### "baseUrl": "https://your-production-domain.com",
### "sampleHash": "production_hash_here"
### }
### }
+131
View File
@@ -0,0 +1,131 @@
### Argus 照片管理系统 - 开发和调试接口测试
### 服务器地址
@baseUrl = http://localhost:8080
@apiBase = {{baseUrl}}/api/v1
### ===== 开发工具接口 =====
### 开发工具 - 获取EXIF信息
GET {{baseUrl}}/dev/tool/exiftool/get_exif?imagePath=D:/Pictures/sample.jpg
### 开发工具 - 获取EXIF信息 (Linux路径示例)
GET {{baseUrl}}/dev/tool/exiftool/get_exif?imagePath=/home/user/Pictures/sample.jpg
### 开发工具 - 获取EXIF信息 (相对路径)
GET {{baseUrl}}/dev/tool/exiftool/get_exif?imagePath=./examples/sample.jpg
### ===== 系统状态和调试 =====
### 系统信息
GET {{baseUrl}}/dev/system/info
### 内存使用情况
GET {{baseUrl}}/dev/system/memory
### 数据库连接状态
GET {{baseUrl}}/dev/database/status
### 数据库表信息
GET {{baseUrl}}/dev/database/tables
### 缓存状态
GET {{baseUrl}}/dev/cache/status
### 清理缓存
DELETE {{baseUrl}}/dev/cache/clear
### ===== 日志和监控 =====
### 获取系统日志
GET {{baseUrl}}/dev/logs?level=info&limit=100
### 获取错误日志
GET {{baseUrl}}/dev/logs?level=error&limit=50
### 获取最近的处理任务
GET {{baseUrl}}/dev/tasks/recent
### 获取失败的任务
GET {{baseUrl}}/dev/tasks/failed
### ===== 测试数据生成 =====
### 生成测试用户
POST {{baseUrl}}/dev/test/users
Content-Type: application/json
{
"count": 5
}
### 生成测试图像记录
POST {{baseUrl}}/dev/test/photos
Content-Type: application/json
{
"count": 10,
"with_exif": true
}
### 清理测试数据
DELETE {{baseUrl}}/dev/test/cleanup
### ===== 性能测试 =====
### 数据库性能测试
GET {{baseUrl}}/dev/benchmark/database
### 图像处理性能测试
GET {{baseUrl}}/dev/benchmark/image?size=1920x1080
### EXIF解析性能测试
GET {{baseUrl}}/dev/benchmark/exif
### ===== 配置管理 =====
### 获取当前配置
GET {{baseUrl}}/dev/config
### 重新加载配置
POST {{baseUrl}}/dev/config/reload
### 验证配置
GET {{baseUrl}}/dev/config/validate
### ===== 错误模拟 =====
### 模拟数据库错误
GET {{baseUrl}}/dev/simulate/db_error
### 模拟文件系统错误
GET {{baseUrl}}/dev/simulate/fs_error
### 模拟内存不足
GET {{baseUrl}}/dev/simulate/memory_error
### ===== 安全测试 =====
### 测试文件路径遍历
GET {{baseUrl}}/dev/tool/exiftool/get_exif?imagePath=../../../etc/passwd
### 测试SQL注入 (应该被阻止)
GET {{apiBase}}/exif/search?q='; DROP TABLE users; --
### 测试XSS (应该被转义)
GET {{apiBase}}/exif/search?q=<script>alert('xss')</script>
### ===== 使用说明 =====
###
### 开发接口仅用于开发和调试环境
### 生产环境应该禁用这些接口
###
### 1. 工具接口用于测试ExifTool功能
### 2. 系统接口用于监控应用状态
### 3. 测试数据接口用于快速生成测试环境
### 4. 性能测试接口用于基准测试
### 5. 安全测试用于验证安全措施
###
### 注意:
### - 替换imagePath为实际存在的图像文件路径
### - 测试数据生成会修改数据库
### - 清理操作不可逆,请谨慎使用
+116
View File
@@ -0,0 +1,116 @@
### Argus 照片管理系统 - EXIF信息接口测试
### 服务器地址
@baseUrl = http://localhost:8080
@apiBase = {{baseUrl}}/api/v1
### 注意:需要先运行图像库索引获得实际的hash值
@sampleHash = sample_hash_12345
### ===== EXIF信息基础查询 =====
### 获取所有EXIF记录
GET {{apiBase}}/exif
### 根据hash获取EXIF信息
GET {{apiBase}}/exif/{{sampleHash}}
### 获取EXIF统计信息
GET {{apiBase}}/exif/statistics
### ===== 相机相关查询 =====
### 获取相机列表
GET {{apiBase}}/exif/cameras
### 获取相机统计
GET {{apiBase}}/exif/cameras/stats
### 根据相机品牌筛选
GET {{apiBase}}/exif/camera?make=Canon
### 根据相机型号筛选
GET {{apiBase}}/exif/camera?model=EOS
### 根据相机品牌和型号筛选
GET {{apiBase}}/exif/camera?make=Canon&model=EOS
### ===== 拍摄参数查询 =====
### 根据ISO筛选
GET {{apiBase}}/exif/iso?min=100&max=800
### 根据ISO范围筛选 (高ISO)
GET {{apiBase}}/exif/iso?min=1600&max=6400
### 根据光圈筛选
GET {{apiBase}}/exif/aperture?min=1.4&max=5.6
### 根据光圈筛选 (大光圈)
GET {{apiBase}}/exif/aperture?min=1.0&max=2.8
### 根据焦距筛选
GET {{apiBase}}/exif/focal?min=85&max=200
### ===== GPS和位置信息 =====
### 获取有GPS信息的照片
GET {{apiBase}}/exif/gps
### 根据GPS坐标范围筛选
GET {{apiBase}}/exif/gps?lat_min=39.0&lat_max=40.0&lng_min=116.0&lng_max=117.0
### ===== 搜索功能 =====
### 搜索EXIF信息 (相机品牌)
GET {{apiBase}}/exif/search?q=Canon
### 搜索EXIF信息 (相机型号)
GET {{apiBase}}/exif/search?q=EOS
### 搜索EXIF信息 (镜头信息)
GET {{apiBase}}/exif/search?q=85mm
### ===== 高级筛选 =====
### 组合查询:特定相机+ISO范围
GET {{apiBase}}/exif?make=Canon&model=EOS&iso_min=100&iso_max=400
### 组合查询:光圈+焦距范围
GET {{apiBase}}/exif?aperture_min=2.8&aperture_max=5.6&focal_min=50&focal_max=100
### 组合查询:带GPS的Canon照片
GET {{apiBase}}/exif?make=Canon&has_gps=true
### ===== 分页和排序 =====
### 分页获取EXIF数据
GET {{apiBase}}/exif?page=1&limit=20
### 按拍摄时间排序
GET {{apiBase}}/exif?sort=datetime&order=desc
### 按ISO排序
GET {{apiBase}}/exif?sort=iso&order=asc
### ===== 错误测试 =====
### 测试不存在的hash
GET {{apiBase}}/exif/nonexistent_hash
### 测试无效的ISO范围
GET {{apiBase}}/exif/iso?min=abc&max=xyz
### 测试无效的光圈值
GET {{apiBase}}/exif/aperture?min=-1&max=100
### 测试无效的GPS坐标
GET {{apiBase}}/exif/gps?lat_min=invalid&lng_max=invalid
### ===== 使用说明 =====
###
### 1. 将 {{sampleHash}} 替换为实际的图像hash
### 2. EXIF接口返回JSON格式的详细信息
### 3. 筛选参数可以组合使用
### 4. 支持分页和排序功能
### 5. GPS查询需要照片包含位置信息
### 6. 搜索功能支持模糊匹配
+39
View File
@@ -0,0 +1,39 @@
### Argus 照片管理系统 - 图像库管理测试
### 服务器地址
@baseUrl = http://localhost:8080
@apiBase = {{baseUrl}}/api/v1
### ===== 图像库管理 =====
### 查看当前图像库
GET {{apiBase}}/library
### 添加图像库路径 (需要修改为实际存在的路径)
POST {{apiBase}}/library
Content-Type: application/json
{
"path": "E:/整合/kkimkkimmy/Pic"
}
### 更新图像库状态
PUT {{apiBase}}/library
Content-Type: application/json
{
"path": "E:/整合/kkimkkimmy/Pic",
"is_enable": true
}
### 开始图像索引 (需要先添加图像库)
POST {{apiBase}}/library/indexed
### 删除图像库
DELETE {{apiBase}}/library?path=D:/Pictures
### ===== 使用说明 =====
###
### 1. 首先添加图像库路径,确保路径存在且包含图像文件
### 2. 启用图像库 (is_enable: true)
### 3. 开始索引获得图像hash值
### 4. 使用获得的hash值进行其他接口测试
+74
View File
@@ -0,0 +1,74 @@
### Argus 照片管理系统 - 照片接口测试
### 服务器地址
@baseUrl = http://localhost:8080
@apiBase = {{baseUrl}}/api/v1
### 注意:需要先运行图像库索引获得实际的hash值
@sampleHash = sample_hash_12345
### ===== 照片文件访问测试 =====
### 获取原图
GET {{apiBase}}/photo/{{sampleHash}}?format=original
### 获取默认尺寸缩略图
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail
### 获取指定尺寸缩略图 (400x400)
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=400x400
### 获取指定尺寸缩略图 (800x600)
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=800x600
### 获取正方形缩略图 (500x500)
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=500x500
### 获取小尺寸缩略图 (200x200)
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=200x200
### 获取高分辨率缩略图 (1920x1080)
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=1920x1080
### ===== 照片详情信息测试 =====
### 获取图像详细信息
GET {{apiBase}}/assets/{{sampleHash}}
### ===== 错误测试 =====
### 测试不存在的hash - 图像文件
GET {{apiBase}}/photo/nonexistent_hash
### 测试不存在的hash - 图像详情
GET {{apiBase}}/assets/nonexistent_hash
### 测试无效的格式参数
GET {{apiBase}}/photo/{{sampleHash}}?format=invalid_format
### 测试无效的尺寸参数
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=invalid_size
### 测试负数尺寸
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=-100x-100
### 测试错误的尺寸格式
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=400x
### 测试空尺寸参数
GET {{apiBase}}/photo/{{sampleHash}}?format=thumbnail&size=
### ===== 性能测试示例 =====
### 并发获取缩略图测试 (可以在终端中使用curl并发执行)
# curl "{{apiBase}}/photo/hash1?format=thumbnail&size=400x400" &
# curl "{{apiBase}}/photo/hash2?format=thumbnail&size=400x400" &
# curl "{{apiBase}}/photo/hash3?format=thumbnail&size=400x400" &
### ===== 使用说明 =====
###
### 1. 将 {{sampleHash}} 替换为实际的图像hash
### 2. 图像文件接口会返回二进制图像数据
### 3. 详情接口会返回JSON格式的完整信息
### 4. 错误情况会返回包含错误信息的JSON
### 5. 缩略图生成可能需要一些时间
### 6. API会自动更新图像的访问计数和访问时间
+159
View File
@@ -0,0 +1,159 @@
### Argus 照片管理系统 - 用户管理接口测试
### 服务器地址
@baseUrl = http://localhost:8080
@apiBase = {{baseUrl}}/api/v1
### ===== 用户管理接口测试 =====
### 获取所有用户
GET {{apiBase}}/users
### 获取指定用户
GET {{apiBase}}/users/1
### 创建新用户
POST {{apiBase}}/users
Content-Type: application/json
{
"username": "testuser",
"email": "test@example.com",
"password": "password123",
"age": 25
}
### 创建新用户 (测试用户2)
POST {{apiBase}}/users
Content-Type: application/json
{
"username": "photographer",
"email": "photo@example.com",
"password": "photo123",
"age": 30
}
### 更新用户信息
PUT {{apiBase}}/users/1
Content-Type: application/json
{
"username": "updateduser",
"email": "updated@example.com",
"age": 26
}
### 更新用户密码
PUT {{apiBase}}/users/1
Content-Type: application/json
{
"password": "newpassword123"
}
### 部分更新用户信息
PATCH {{apiBase}}/users/1
Content-Type: application/json
{
"age": 27
}
### 删除用户
DELETE {{apiBase}}/users/1
### ===== 用户验证和认证 =====
### 用户登录 (如果有认证系统)
POST {{apiBase}}/auth/login
Content-Type: application/json
{
"username": "testuser",
"password": "password123"
}
### 用户注册 (如果有认证系统)
POST {{apiBase}}/auth/register
Content-Type: application/json
{
"username": "newuser",
"email": "new@example.com",
"password": "newpass123",
"age": 28
}
### ===== 用户查询和筛选 =====
### 根据用户名搜索
GET {{apiBase}}/users?username=test
### 根据邮箱搜索
GET {{apiBase}}/users?email=example.com
### 分页获取用户
GET {{apiBase}}/users?page=1&limit=10
### 按年龄范围筛选用户
GET {{apiBase}}/users?age_min=20&age_max=40
### 按创建时间排序
GET {{apiBase}}/users?sort=created_at&order=desc
### ===== 错误测试 =====
### 测试获取不存在的用户
GET {{apiBase}}/users/999
### 测试创建用户 - 缺少必需字段
POST {{apiBase}}/users
Content-Type: application/json
{
"username": "incomplete"
}
### 测试创建用户 - 无效邮箱格式
POST {{apiBase}}/users
Content-Type: application/json
{
"username": "testuser2",
"email": "invalid-email",
"password": "password123",
"age": 25
}
### 测试创建用户 - 重复用户名
POST {{apiBase}}/users
Content-Type: application/json
{
"username": "testuser",
"email": "another@example.com",
"password": "password123",
"age": 25
}
### 测试更新不存在的用户
PUT {{apiBase}}/users/999
Content-Type: application/json
{
"username": "nonexistent",
"email": "none@example.com",
"age": 30
}
### 测试删除不存在的用户
DELETE {{apiBase}}/users/999
### ===== 使用说明 =====
###
### 1. 用户管理接口用于管理系统用户
### 2. 创建用户时确保提供所有必需字段
### 3. 邮箱地址需要符合有效格式
### 4. 用户名在系统中应该是唯一的
### 5. 支持分页、搜索和排序功能
### 6. 删除用户操作不可逆,请谨慎使用