feat: 完善EXIF管理和SSE实时通信功能
主要功能: - 新增完整的EXIF数据管理功能 * PhotoExif表结构设计和模型定义 * EXIF数据提取、存储和查询接口 * 支持相机信息、GPS、拍摄参数等多维度查询 - 实现SSE实时通信系统 * 支持广播消息和定向发送 * 客户端连接管理和心跳检测 * Web管理面板用于监控和测试 - 代码结构重构 * 模型文件重新组织到tables目录 * 新增repositories和handlers层 * 完善依赖注入容器配置 - 示例代码和文档 * EXIF使用示例和集成指南 * SSE管理面板和测试工具 * 更新项目文档和待办事项 技术细节: - 使用GORM进行数据库操作和迁移 - 基于Gin框架的HTTP API设计 - 支持多种EXIF查询和统计功能 - 实时事件推送和客户端状态管理 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
6ae86592f4
commit
cf3f269b7e
30 files changed
+2385
-58
No files matched your search
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(go mod:*)",
|
||||
"Bash(go build -v .)",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(go build:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(go run:*)",
|
||||
"Bash(del test_platform.go)",
|
||||
"Bash(rm test_platform.go)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(go test:*)",
|
||||
"Bash(git add:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/rear.iml" filepath="$PROJECT_DIR$/.idea/rear.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
+283
-1
@@ -1 +1,283 @@
|
||||
这是一个GO的服务端项目,主要完成的是照片管理功能,为前端提供 HTTP 服务
|
||||
# Argus 照片管理系统 - 项目详细分析
|
||||
|
||||
这是一个基于Go语言开发的照片管理系统,主要功能是提供照片管理服务,为前端提供HTTP API接口。
|
||||
|
||||
## 项目基本信息
|
||||
|
||||
- **项目名称**: Argus 照片管理系统
|
||||
- **开发语言**: Go 1.24
|
||||
- **项目类型**: HTTP 服务端应用
|
||||
- **主要功能**: 照片管理、图像处理、缩略图生成、EXIF信息提取
|
||||
- **部署方式**: 独立可执行文件
|
||||
|
||||
## 项目结构分析
|
||||
|
||||
### 主要目录结构
|
||||
```
|
||||
D:\go-argus\src\rear\
|
||||
├── main.go # 应用主入口
|
||||
├── go.mod/go.sum # Go模块依赖文件
|
||||
├── config.yaml # 配置文件
|
||||
├── README.md # 项目说明文档
|
||||
├── internal/ # 内部业务代码
|
||||
│ ├── api/ # API层
|
||||
│ ├── config/ # 配置管理
|
||||
│ ├── container/ # 依赖注入容器
|
||||
│ ├── db/ # 数据库操作
|
||||
│ ├── handler/ # HTTP处理器
|
||||
│ ├── model/ # 数据模型
|
||||
│ ├── repositories/ # 数据访问层
|
||||
│ ├── router/ # 路由配置
|
||||
│ ├── service/ # 业务服务
|
||||
│ ├── utils/ # 内部工具函数
|
||||
│ └── workflow/ # 工作流程
|
||||
├── pkg/ # 公共包
|
||||
│ ├── img_utils/ # 图像工具
|
||||
│ ├── logger/ # 日志工具
|
||||
│ ├── sse/ # Server-Sent Events
|
||||
│ └── utils/ # 通用工具
|
||||
├── tools/ # 外部工具链
|
||||
├── scripts/ # 构建脚本
|
||||
├── logs/ # 日志文件
|
||||
├── thumbnail/ # 缩略图存储
|
||||
└── examples/ # 示例代码
|
||||
```
|
||||
|
||||
## 技术栈详细分析
|
||||
|
||||
### 核心Go模块依赖
|
||||
|
||||
#### Web框架
|
||||
- **gin-gonic/gin v1.10.1** - 主要的HTTP Web框架
|
||||
- **gin-contrib/cors v1.7.5** - CORS跨域处理
|
||||
- **gin-contrib/pprof v1.5.3** - 性能分析工具
|
||||
|
||||
#### 数据库ORM
|
||||
- **gorm.io/gorm v1.30.0** - 主要ORM框架
|
||||
- **gorm.io/driver/sqlite v1.6.0** - SQLite数据库驱动
|
||||
- **gorm.io/driver/mysql v1.6.0** - MySQL数据库驱动
|
||||
|
||||
#### 日志系统
|
||||
- **go.uber.org/zap v1.27.0** - 结构化日志库
|
||||
- **gopkg.in/natefinch/lumberjack.v2 v2.2.1** - 日志轮转
|
||||
|
||||
#### 工具库
|
||||
- **google/uuid v1.6.0** - UUID生成
|
||||
- **h2non/filetype v1.1.3** - 文件类型检测
|
||||
- **gopkg.in/yaml.v3 v3.0.1** - YAML配置文件解析
|
||||
|
||||
### 外部工具依赖
|
||||
|
||||
#### 图像处理工具
|
||||
1. **ExifTool** - EXIF信息提取工具
|
||||
2. **ImageMagick** - 图像处理和格式转换
|
||||
3. **LibVips** - 高性能图像处理库
|
||||
|
||||
## 核心功能模块分析
|
||||
|
||||
### 1. 配置管理模块 (internal/config/)
|
||||
- **config.go** - 主配置管理,支持多种配置项
|
||||
- **database.go** - 数据库配置
|
||||
- **loader.go** - 配置文件加载器
|
||||
|
||||
#### 主要配置结构:
|
||||
- **App配置**: 端口、模式、超时设置
|
||||
- **数据库配置**: 支持SQLite和MySQL
|
||||
- **图像配置**: 缩略图格式、质量、大小设置
|
||||
- **工具配置**: 外部工具路径配置
|
||||
- **任务配置**: 并发数、队列容量等
|
||||
|
||||
### 2. 数据模型 (internal/model/)
|
||||
#### 核心数据表:
|
||||
- **Photo表** (tables/photo_table.go) - 照片基础信息 (路径、哈希、尺寸、格式等)
|
||||
- **PhotoExif表** (tables/exif_table.go) - EXIF元数据信息,通过PhotoHash关联Photo表
|
||||
- **LibraryTable** - 图片库路径管理
|
||||
- **User表** - 用户信息
|
||||
|
||||
#### EXIF表设计详情:
|
||||
**PhotoExif表结构**包含以下主要字段组:
|
||||
- **主键**: Hash (直接使用照片Hash,不使用外键关联)
|
||||
- **基础文件信息**: 文件名、大小、尺寸、类型、修改时间等
|
||||
- **摄影参数**: 相机厂商型号、ISO、曝光时间、光圈、焦距等
|
||||
- **GPS信息**: 经纬度坐标
|
||||
- **描述信息**: 标题、描述
|
||||
- **扩展字段**: JSON格式存储其他EXIF字段
|
||||
- **系统字段**: CreatedAt, UpdatedAt
|
||||
|
||||
**设计特点**:
|
||||
- 使用Hash作为主键,简化表关系
|
||||
- 提供直接的model转换方法 (FromParsedExif/ToParsedExif)
|
||||
- 支持便捷查询方法 (HasGPS, HasCameraInfo等)
|
||||
- 无复杂的处理状态管理,专注于存储和展示
|
||||
|
||||
### 3. 数据访问层 (internal/repositories/)
|
||||
- **BaseService** - 基础服务,处理写操作协程
|
||||
- **LibraryRepository** - 图片库数据访问
|
||||
- **UserRepository** - 用户数据访问
|
||||
- **ExifRepository** - EXIF数据访问,支持多种查询方式
|
||||
|
||||
### 4. 业务处理器 (internal/handler/)
|
||||
- **LibraryHandler** - 图片库管理 (添加、删除、更新、索引)
|
||||
- **DevImageHandler** - 开发工具,EXIF信息获取
|
||||
- **ExifHandler** - EXIF信息管理 (查询、筛选、统计)
|
||||
- **SSEHandler** - Server-Sent Events实时通信
|
||||
- **UserHandler** - 用户管理
|
||||
|
||||
### 5. 图像处理工作流 (internal/workflow/)
|
||||
#### 图像任务处理 (img_task.go):
|
||||
- **任务状态管理**: pending, running, paused, failed, done
|
||||
- **处理步骤**:
|
||||
1. 初始化 → 2. 验证格式 → 3. 读取文件 → 4. 计算哈希
|
||||
5. 提取EXIF → 6. 格式转换 → 7. 生成缩略图 → 8. 保存数据库 → 9. 智能分析
|
||||
- **任务管理器**: 支持并发处理、暂停恢复、自动调优
|
||||
- **系统监控**: CPU/内存使用率监控,动态调整并发数
|
||||
|
||||
### 6. 工具管理 (internal/utils/tools/)
|
||||
- **ExifTool** - EXIF数据提取
|
||||
- **ImageMagick** - 图像格式转换
|
||||
- **LibVips** - 高性能图像处理
|
||||
|
||||
### 7. SSE实时通信 (pkg/sse/)
|
||||
- **Manager** - SSE连接管理器
|
||||
- **Client** - 客户端连接管理
|
||||
- **Event** - 事件数据结构
|
||||
- 支持广播、单点发送、心跳检测
|
||||
|
||||
## API接口梳理
|
||||
|
||||
### 基础接口
|
||||
- `GET /` - 基础响应
|
||||
- `GET /health` - 健康检查
|
||||
|
||||
### API v1接口组 (/api/v1)
|
||||
- `GET /api/v1` - API版本信息
|
||||
|
||||
#### 用户管理 (/api/v1/users)
|
||||
- `GET /api/v1/users` - 获取用户列表
|
||||
- `GET /api/v1/users/:id` - 获取特定用户
|
||||
- `POST /api/v1/users` - 创建用户
|
||||
- `PUT /api/v1/users/:id` - 更新用户
|
||||
- `DELETE /api/v1/users/:id` - 删除用户
|
||||
|
||||
#### 图片库管理 (/api/v1/library)
|
||||
- `GET /api/v1/library` - 获取图片库列表
|
||||
- `POST /api/v1/library` - 添加图片库路径
|
||||
- `PUT /api/v1/library` - 更新图片库
|
||||
- `DELETE /api/v1/library` - 删除图片库
|
||||
- `POST /api/v1/library/indexed` - 启动图片索引任务
|
||||
|
||||
#### EXIF信息管理 (/api/v1/exif)
|
||||
- `GET /api/v1/exif` - 获取所有EXIF记录 (支持分页)
|
||||
- `GET /api/v1/exif/:hash` - 根据照片哈希获取EXIF信息
|
||||
- `GET /api/v1/exif/statistics` - 获取EXIF统计信息
|
||||
- `GET /api/v1/exif/cameras` - 获取所有相机型号列表
|
||||
- `GET /api/v1/exif/cameras/stats` - 获取相机使用统计
|
||||
- `GET /api/v1/exif/search` - 搜索EXIF信息 (关键词搜索)
|
||||
- `GET /api/v1/exif/gps` - 获取包含GPS信息的照片
|
||||
- `GET /api/v1/exif/iso` - 根据ISO范围筛选照片
|
||||
- `GET /api/v1/exif/aperture` - 根据光圈范围筛选照片
|
||||
- `GET /api/v1/exif/camera` - 根据相机型号筛选照片
|
||||
|
||||
### 开发接口组 (/dev)
|
||||
- `GET /dev/tool/exiftool/get_exif` - 获取图片EXIF信息
|
||||
|
||||
## 图像处理流程分析
|
||||
|
||||
### 缩略图生成策略
|
||||
1. **路径结构**: `/thumbnail/{hash前2位}/{hash第3-4位}/{hash剩余部分}/{尺寸}.jpg`
|
||||
2. **支持格式**: JPG、WebP
|
||||
3. **尺寸配置**: 可配置多种缩略图尺寸
|
||||
4. **质量控制**: 可配置图像质量参数
|
||||
|
||||
### 图像处理工作流
|
||||
1. **文件验证** - 检查文件格式和有效性
|
||||
2. **哈希计算** - 生成文件唯一标识
|
||||
3. **EXIF提取** - 获取图像元数据信息
|
||||
4. **格式转换** - 统一转换为支持的格式
|
||||
5. **缩略图生成** - 生成多尺寸缩略图
|
||||
6. **数据库存储** - 保存图像信息和元数据
|
||||
7. **智能分析** - 异步调用AI分析服务
|
||||
|
||||
### 任务并发控制
|
||||
- **动态并发数**: 根据CPU核心数自动调整
|
||||
- **内存监控**: 基于内存使用率调整处理速度
|
||||
- **队列管理**: 支持任务队列和优先级
|
||||
- **错误处理**: 失败重试和错误日志记录
|
||||
|
||||
## 开发和运维要点
|
||||
|
||||
### 配置文件重要设置
|
||||
```yaml
|
||||
app:
|
||||
port: "8080" # 服务端口
|
||||
mode: "release" # 运行模式 (debug/release)
|
||||
development: true # 开发模式开关
|
||||
|
||||
logging:
|
||||
level: "debug" # 日志级别
|
||||
|
||||
database:
|
||||
type: "sqlite" # 数据库类型
|
||||
```
|
||||
|
||||
### 启动流程
|
||||
1. **配置加载** - 加载config.yaml配置文件
|
||||
2. **日志初始化** - 设置日志系统
|
||||
3. **工具初始化** - 检查和初始化外部工具
|
||||
4. **数据库初始化** - 连接数据库并执行迁移
|
||||
5. **容器初始化** - 依赖注入容器设置
|
||||
6. **任务管理器** - 启动图像处理任务管理器
|
||||
7. **HTTP服务** - 启动Web服务器
|
||||
|
||||
### 构建部署
|
||||
- **构建脚本**: `scripts/build.sh`
|
||||
- **支持平台**: Windows (amd64), Linux (amd64), macOS (amd64/arm64)
|
||||
- **工具链管理**: 自动解压和配置外部工具
|
||||
|
||||
## 性能特性
|
||||
|
||||
### 高并发处理
|
||||
- **协程池**: 基于Go协程的高并发处理
|
||||
- **任务队列**: 支持大容量任务队列
|
||||
- **负载均衡**: 自动调整并发数以优化性能
|
||||
|
||||
### 内存管理
|
||||
- **内存监控**: 实时监控内存使用情况
|
||||
- **垃圾回收**: 优化的内存回收策略
|
||||
- **缓存管理**: 缩略图缓存和文件缓存
|
||||
|
||||
### 监控调试
|
||||
- **性能分析**: 集成pprof性能分析工具
|
||||
- **结构化日志**: 使用zap高性能日志库
|
||||
- **系统监控**: CPU、内存、协程数量监控
|
||||
|
||||
## 扩展性设计
|
||||
|
||||
### 数据库支持
|
||||
- **多数据库**: 同时支持SQLite和MySQL
|
||||
- **ORM抽象**: 使用GORM进行数据库操作抽象
|
||||
- **迁移管理**: 自动化数据库结构迁移
|
||||
|
||||
### 图像格式扩展
|
||||
- **格式检测**: 自动识别图像格式
|
||||
- **转换引擎**: 多种图像处理引擎支持
|
||||
- **插件架构**: 支持新格式插件扩展
|
||||
|
||||
### API版本管理
|
||||
- **版本隔离**: API接口版本化管理
|
||||
- **向后兼容**: 保持API向后兼容性
|
||||
- **文档化**: 完整的API文档
|
||||
|
||||
## 安全特性
|
||||
|
||||
### 文件安全
|
||||
- **路径验证**: 防止路径遍历攻击
|
||||
- **文件类型检查**: 严格的文件类型验证
|
||||
- **哈希验证**: 文件完整性检查
|
||||
|
||||
### 网络安全
|
||||
- **CORS配置**: 跨域请求控制
|
||||
- **请求限制**: 防止恶意请求
|
||||
- **日志审计**: 完整的访问日志记录
|
||||
|
||||
这个项目是一个功能完整、架构清晰的照片管理系统,具有良好的扩展性和维护性。
|
||||
@@ -0,0 +1,5 @@
|
||||
2025年8月16日 13点56分
|
||||
1. 空文件夹自动删除
|
||||
2. 不支持的文件通过ICON或文字配置可展示对应的图标或内容
|
||||
3. ZIP、Tar 等常见压缩格式,支持自动解压或者手动解压
|
||||
4. 重复照片检测并存储,方便删除
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"rear/internal/model"
|
||||
"rear/internal/model/tables"
|
||||
)
|
||||
|
||||
// ExifUsageExample 展示如何使用EXIF表结构
|
||||
func ExifUsageExample() {
|
||||
fmt.Println("=== EXIF 表使用示例 ===")
|
||||
|
||||
// 1. 模拟从ExifTool获取的原始数据
|
||||
rawExifData := map[string]interface{}{
|
||||
"FileName": "IMG_1234.jpg",
|
||||
"FileSize": 2048000,
|
||||
"ImageWidth": 3840,
|
||||
"ImageHeight": 2160,
|
||||
"ImageSize": "3840x2160",
|
||||
"MIMEType": "image/jpeg",
|
||||
"FileType": "JPEG",
|
||||
"FileTypeExtension": "jpg",
|
||||
"FileModifyDate": "2024:01:15 14:30:25",
|
||||
"FileCreateDate": "2024:01:15 14:30:25",
|
||||
"ColorSpace": "sRGB",
|
||||
"BitsPerSample": 8,
|
||||
"XResolution": "300",
|
||||
"JPEGQuality": 95,
|
||||
"Make": "Canon",
|
||||
"Model": "EOS R5",
|
||||
"ISO": 400,
|
||||
"GPSLatitude": 39.9042,
|
||||
"GPSLongitude": 116.4074,
|
||||
"ExposureTime": 0.008, // 1/125
|
||||
"Aperture": 5.6,
|
||||
"FNumber": 5.6,
|
||||
"FocalLength": 85.0,
|
||||
"LensID": "Canon RF 85mm f/1.2L USM",
|
||||
"Title": "Beautiful Sunset",
|
||||
"Description": "A stunning sunset captured at Beijing",
|
||||
"DateTimeOriginal": "2024:01:15 14:30:25",
|
||||
"CustomField1": "Some custom data",
|
||||
"CustomField2": 123,
|
||||
}
|
||||
|
||||
photoHash := "abc123def456hash789"
|
||||
|
||||
// 2. 方法一:从原始数据直接创建PhotoExif
|
||||
fmt.Println("\n方法一:从原始EXIF数据创建PhotoExif")
|
||||
photoExif1 := tables.NewPhotoExifFromRawData(photoHash, rawExifData)
|
||||
fmt.Printf("创建的PhotoExif - 相机: %s %s, ISO: %d\n",
|
||||
photoExif1.Make, photoExif1.Model, photoExif1.ISO)
|
||||
|
||||
// 3. 方法二:先解析为ParsedExif,再转换为PhotoExif
|
||||
fmt.Println("\n方法二:通过ParsedExif转换")
|
||||
parsedExif := model.SplitExifData(rawExifData)
|
||||
photoExif2 := tables.NewPhotoExifFromParsed(photoHash, parsedExif)
|
||||
|
||||
// 4. 展示PhotoExif的便捷方法
|
||||
fmt.Println("\n便捷方法示例:")
|
||||
fmt.Printf("是否有GPS信息: %t\n", photoExif2.HasGPS())
|
||||
fmt.Printf("是否有相机信息: %t\n", photoExif2.HasCameraInfo())
|
||||
fmt.Printf("是否有拍摄参数: %t\n", photoExif2.HasShootingParams())
|
||||
fmt.Printf("图片宽高比: %.2f\n", photoExif2.GetImageRatio())
|
||||
|
||||
// 5. 转换回ParsedExif用于API返回
|
||||
fmt.Println("\n转换回ParsedExif:")
|
||||
convertedBack := photoExif2.ToParsedExif()
|
||||
fmt.Printf("转换后的相机信息: %s %s\n",
|
||||
convertedBack.Exif.Make, convertedBack.Exif.Model)
|
||||
fmt.Printf("其他字段数量: %d\n", len(convertedBack.OtherFields))
|
||||
|
||||
// 6. 展示如何使用Repository (伪代码)
|
||||
fmt.Println("\n数据库操作示例 (伪代码):")
|
||||
fmt.Printf(`
|
||||
// 保存到数据库
|
||||
exifRepo := repositories.NewExifRepository()
|
||||
err := exifRepo.CreateOrUpdate(photoExif2)
|
||||
|
||||
// 从数据库查询
|
||||
savedExif, err := exifRepo.GetByHash("%s")
|
||||
|
||||
// 查询特定相机的照片
|
||||
canonPhotos, err := exifRepo.GetExifsByCamera("Canon", "EOS R5", 10, 0)
|
||||
|
||||
// 查询有GPS的照片
|
||||
gpsPhotos, err := exifRepo.GetExifsWithGPS(10, 0)
|
||||
|
||||
// 根据ISO范围查询
|
||||
highISOPhotos, err := exifRepo.GetExifsByISO(800, 6400, 10, 0)
|
||||
`, photoHash)
|
||||
|
||||
fmt.Println("\n=== 示例完成 ===")
|
||||
}
|
||||
|
||||
// 展示实际使用场景
|
||||
func RealisticsUsageScenario() {
|
||||
fmt.Println("\n=== 实际使用场景 ===")
|
||||
|
||||
// 假设我们正在处理一批照片
|
||||
photos := []map[string]interface{}{
|
||||
{
|
||||
"hash": "photo1_hash",
|
||||
"exif": map[string]interface{}{
|
||||
"Make": "Canon",
|
||||
"Model": "EOS R6",
|
||||
"ISO": 800,
|
||||
"FNumber": 2.8,
|
||||
"FileName": "portrait.jpg",
|
||||
},
|
||||
},
|
||||
{
|
||||
"hash": "photo2_hash",
|
||||
"exif": map[string]interface{}{
|
||||
"Make": "Sony",
|
||||
"Model": "Alpha 7R V",
|
||||
"ISO": 100,
|
||||
"FNumber": 8.0,
|
||||
"GPSLatitude": 40.7128,
|
||||
"GPSLongitude": -74.0060,
|
||||
"FileName": "landscape.jpg",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var photoExifs []*tables.PhotoExif
|
||||
|
||||
for _, photo := range photos {
|
||||
hash := photo["hash"].(string)
|
||||
exifData := photo["exif"].(map[string]interface{})
|
||||
|
||||
// 创建PhotoExif
|
||||
photoExif := tables.NewPhotoExifFromRawData(hash, exifData)
|
||||
photoExifs = append(photoExifs, photoExif)
|
||||
|
||||
fmt.Printf("处理照片: %s, 相机: %s %s\n",
|
||||
photoExif.FileName, photoExif.Make, photoExif.Model)
|
||||
}
|
||||
|
||||
fmt.Printf("批量处理完成,共处理 %d 张照片\n", len(photoExifs))
|
||||
|
||||
// 模拟批量保存到数据库
|
||||
fmt.Println("准备批量保存到数据库...")
|
||||
fmt.Println("// exifRepo.BatchCreate(photoExifs)")
|
||||
}
|
||||
|
||||
func main() {
|
||||
ExifUsageExample()
|
||||
RealisticsUsageScenario()
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SSE 管理面板</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.control-panel {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
button.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
button.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.events {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.events-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.event-log {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
.event-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.event-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.event-timestamp {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.event-type {
|
||||
color: #007bff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.event-data {
|
||||
background: #f8f9fa;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-top: 5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status.connected {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status.disconnected {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.clients-list {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.client-item {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.client-info {
|
||||
flex: 1;
|
||||
}
|
||||
.client-id {
|
||||
font-family: monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>SSE 服务器管理面板</h1>
|
||||
<p>实时监控和管理 Server-Sent Events 连接</p>
|
||||
<div class="status connected" id="connectionStatus">连接状态: 断开</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="clientCount">0</div>
|
||||
<div>活跃连接</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="eventCount">0</div>
|
||||
<div>接收事件数</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="uptime">00:00:00</div>
|
||||
<div>运行时间</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-panel">
|
||||
<h3>广播消息</h3>
|
||||
<div class="form-group">
|
||||
<label for="broadcastMessage">消息内容:</label>
|
||||
<textarea id="broadcastMessage" rows="3" placeholder="输入要广播的消息..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="eventType">事件类型 (可选):</label>
|
||||
<input type="text" id="eventType" placeholder="例如: notification, update">
|
||||
</div>
|
||||
<button onclick="broadcastMessage()">广播消息</button>
|
||||
<button onclick="startTestEvents()">开始测试事件</button>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<h3>定向发送</h3>
|
||||
<div class="form-group">
|
||||
<label for="targetClientId">客户端 ID:</label>
|
||||
<select id="targetClientId">
|
||||
<option value="">选择客户端...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="targetMessage">消息内容:</label>
|
||||
<textarea id="targetMessage" rows="2" placeholder="输入消息..."></textarea>
|
||||
</div>
|
||||
<button onclick="sendToClient()">发送消息</button>
|
||||
<button onclick="refreshClients()">刷新客户端列表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="events">
|
||||
<div class="events-header">
|
||||
<h3>实时事件日志</h3>
|
||||
<div>
|
||||
<button onclick="clearEventLog()">清空日志</button>
|
||||
<button onclick="toggleConnection()" id="toggleBtn">连接 SSE</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-log" id="eventLog">
|
||||
<div class="event-item">
|
||||
<div class="event-timestamp">等待连接...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clients-list">
|
||||
<div class="events-header">
|
||||
<h3>客户端列表</h3>
|
||||
<button onclick="refreshClients()">刷新</button>
|
||||
</div>
|
||||
<div id="clientsList">
|
||||
<div class="client-item">
|
||||
<div class="client-info">暂无客户端连接</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let eventSource = null;
|
||||
let eventCount = 0;
|
||||
let startTime = Date.now();
|
||||
let uptimeInterval = null;
|
||||
|
||||
function updateUptime() {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const hours = Math.floor(elapsed / 3600000).toString().padStart(2, '0');
|
||||
const minutes = Math.floor((elapsed % 3600000) / 60000).toString().padStart(2, '0');
|
||||
const seconds = Math.floor((elapsed % 60000) / 1000).toString().padStart(2, '0');
|
||||
document.getElementById('uptime').textContent = `${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/api/v1/events');
|
||||
|
||||
eventSource.onopen = function(event) {
|
||||
document.getElementById('connectionStatus').textContent = '连接状态: 已连接';
|
||||
document.getElementById('connectionStatus').className = 'status connected';
|
||||
document.getElementById('toggleBtn').textContent = '断开连接';
|
||||
|
||||
if (!uptimeInterval) {
|
||||
startTime = Date.now();
|
||||
uptimeInterval = setInterval(updateUptime, 1000);
|
||||
}
|
||||
|
||||
addEventToLog('系统', '连接', 'SSE 连接已建立', {});
|
||||
};
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
eventCount++;
|
||||
document.getElementById('eventCount').textContent = eventCount;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
addEventToLog(data.type || 'message', 'data', data.message || event.data, data);
|
||||
} catch (e) {
|
||||
addEventToLog('message', 'data', event.data, {});
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.addEventListener('ping', function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
addEventToLog('ping', 'ping', `服务器心跳: ${data.time}`, data);
|
||||
} catch (e) {
|
||||
addEventToLog('ping', 'ping', event.data, {});
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('business_update', function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
addEventToLog('business_update', 'business', data.message, data);
|
||||
} catch (e) {
|
||||
addEventToLog('business_update', 'business', event.data, {});
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('image_processed', function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
addEventToLog('image_processed', 'image', data.message, data);
|
||||
} catch (e) {
|
||||
addEventToLog('image_processed', 'image', event.data, {});
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('user_activity', function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
addEventToLog('user_activity', 'user', data.message, data);
|
||||
} catch (e) {
|
||||
addEventToLog('user_activity', 'user', event.data, {});
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = function(event) {
|
||||
document.getElementById('connectionStatus').textContent = '连接状态: 连接错误';
|
||||
document.getElementById('connectionStatus').className = 'status disconnected';
|
||||
document.getElementById('toggleBtn').textContent = '重新连接';
|
||||
addEventToLog('系统', '错误', 'SSE 连接发生错误', {});
|
||||
};
|
||||
}
|
||||
|
||||
function disconnectSSE() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
|
||||
document.getElementById('connectionStatus').textContent = '连接状态: 已断开';
|
||||
document.getElementById('connectionStatus').className = 'status disconnected';
|
||||
document.getElementById('toggleBtn').textContent = '连接 SSE';
|
||||
|
||||
if (uptimeInterval) {
|
||||
clearInterval(uptimeInterval);
|
||||
uptimeInterval = null;
|
||||
}
|
||||
|
||||
addEventToLog('系统', '断开', 'SSE 连接已断开', {});
|
||||
}
|
||||
|
||||
function toggleConnection() {
|
||||
if (eventSource && eventSource.readyState === EventSource.OPEN) {
|
||||
disconnectSSE();
|
||||
} else {
|
||||
connectSSE();
|
||||
}
|
||||
}
|
||||
|
||||
function addEventToLog(type, category, message, data) {
|
||||
const eventLog = document.getElementById('eventLog');
|
||||
const eventItem = document.createElement('div');
|
||||
eventItem.className = 'event-item';
|
||||
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
|
||||
eventItem.innerHTML = `
|
||||
<div class="event-timestamp">[${timestamp}]</div>
|
||||
<div class="event-type">${type}</div>
|
||||
<div>${message}</div>
|
||||
${Object.keys(data).length > 0 ? `<div class="event-data">${JSON.stringify(data, null, 2)}</div>` : ''}
|
||||
`;
|
||||
|
||||
eventLog.insertBefore(eventItem, eventLog.firstChild);
|
||||
|
||||
// 限制日志条数
|
||||
if (eventLog.children.length > 100) {
|
||||
eventLog.removeChild(eventLog.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
function clearEventLog() {
|
||||
document.getElementById('eventLog').innerHTML = '';
|
||||
eventCount = 0;
|
||||
document.getElementById('eventCount').textContent = eventCount;
|
||||
}
|
||||
|
||||
async function broadcastMessage() {
|
||||
const message = document.getElementById('broadcastMessage').value;
|
||||
const eventType = document.getElementById('eventType').value;
|
||||
|
||||
if (!message.trim()) {
|
||||
alert('请输入消息内容');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/events/broadcast', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
event_type: eventType || undefined
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
document.getElementById('broadcastMessage').value = '';
|
||||
document.getElementById('eventType').value = '';
|
||||
addEventToLog('系统', '广播', `消息已广播到 ${result.data.client_count} 个客户端`, result.data);
|
||||
} else {
|
||||
alert('发送失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('发送失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToClient() {
|
||||
const clientId = document.getElementById('targetClientId').value;
|
||||
const message = document.getElementById('targetMessage').value;
|
||||
|
||||
if (!clientId || !message.trim()) {
|
||||
alert('请选择客户端并输入消息内容');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/events/send/${clientId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
document.getElementById('targetMessage').value = '';
|
||||
addEventToLog('系统', '定向发送', `消息已发送到客户端 ${clientId}`, result.data);
|
||||
} else {
|
||||
alert('发送失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('发送失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTestEvents() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/events/test?type=admin_test&interval=3&count=5', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addEventToLog('系统', '测试', '测试事件序列已启动', result.data);
|
||||
} else {
|
||||
alert('启动失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('启动失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshClients() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/events/clients');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
document.getElementById('clientCount').textContent = result.data.total_clients;
|
||||
|
||||
// 更新客户端下拉列表
|
||||
const select = document.getElementById('targetClientId');
|
||||
select.innerHTML = '<option value="">选择客户端...</option>';
|
||||
|
||||
// 更新客户端列表
|
||||
const clientsList = document.getElementById('clientsList');
|
||||
if (result.data.clients.length === 0) {
|
||||
clientsList.innerHTML = '<div class="client-item"><div class="client-info">暂无客户端连接</div></div>';
|
||||
} else {
|
||||
clientsList.innerHTML = result.data.clients.map(client => {
|
||||
const option = document.createElement('option');
|
||||
option.value = client.id;
|
||||
option.textContent = `${client.id.substring(0, 8)}... (${client.remote_addr})`;
|
||||
select.appendChild(option);
|
||||
|
||||
const connectedTime = new Date(client.connected_at).toLocaleString();
|
||||
const lastPing = new Date(client.last_ping).toLocaleString();
|
||||
|
||||
return `
|
||||
<div class="client-item">
|
||||
<div class="client-info">
|
||||
<div><strong>客户端 ID:</strong> <span class="client-id">${client.id}</span></div>
|
||||
<div><strong>IP 地址:</strong> ${client.remote_addr}</div>
|
||||
<div><strong>连接时间:</strong> ${connectedTime}</div>
|
||||
<div><strong>最后心跳:</strong> ${lastPing}</div>
|
||||
</div>
|
||||
<button class="danger" onclick="disconnectClient('${client.id}')">断开</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('刷新客户端列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectClient(clientId) {
|
||||
if (!confirm('确定要断开此客户端连接吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/events/clients/${clientId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
addEventToLog('系统', '断开客户端', `客户端 ${clientId} 已断开`, result.data);
|
||||
refreshClients();
|
||||
} else {
|
||||
alert('断开失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('断开失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时自动连接并刷新客户端列表
|
||||
window.addEventListener('load', function() {
|
||||
connectSSE();
|
||||
refreshClients();
|
||||
setInterval(refreshClients, 10000); // 每10秒刷新一次客户端列表
|
||||
});
|
||||
|
||||
// 页面卸载时断开连接
|
||||
window.addEventListener('beforeunload', function() {
|
||||
disconnectSSE();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"rear/internal/handler"
|
||||
)
|
||||
|
||||
func main1() {
|
||||
r := gin.Default()
|
||||
|
||||
corsConfig := cors.DefaultConfig()
|
||||
corsConfig.AllowAllOrigins = true
|
||||
corsConfig.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
||||
corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "X-Request-ID"}
|
||||
r.Use(cors.New(corsConfig))
|
||||
|
||||
sseHandler := handler.NewSSEHandler()
|
||||
|
||||
setupSSERoutes(r, sseHandler)
|
||||
|
||||
simulateBusinessEvents(sseHandler.GetEventChannel())
|
||||
|
||||
fmt.Println("Server starting on :8080")
|
||||
fmt.Println("SSE endpoint: http://localhost:8080/api/v1/events")
|
||||
fmt.Println("Admin panel: http://localhost:8080/admin/sse/stats")
|
||||
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupSSERoutes(r *gin.Engine, sseHandler *handler.SSEHandler) {
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
api.GET("/events", sseHandler.HandleSSEConnection)
|
||||
|
||||
api.POST("/events/broadcast", sseHandler.BroadcastMessage)
|
||||
|
||||
api.POST("/events/send/:clientId", sseHandler.SendToClient)
|
||||
|
||||
api.GET("/events/clients", sseHandler.GetClients)
|
||||
api.GET("/events/clients/:clientId", sseHandler.GetClientInfo)
|
||||
api.DELETE("/events/clients/:clientId", sseHandler.DisconnectClient)
|
||||
|
||||
api.GET("/events/stats", sseHandler.GetStats)
|
||||
|
||||
api.POST("/events/test", sseHandler.TestEvent)
|
||||
}
|
||||
|
||||
admin := r.Group("/admin")
|
||||
{
|
||||
admin.Static("/sse", "./examples/sse_admin")
|
||||
|
||||
admin.GET("/sse/stats", sseHandler.GetStats)
|
||||
}
|
||||
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"message": "SSE Server is running",
|
||||
"endpoints": map[string]string{
|
||||
"sse_connection": "/api/v1/events",
|
||||
"broadcast": "POST /api/v1/events/broadcast",
|
||||
"send_to_client": "POST /api/v1/events/send/:clientId",
|
||||
"get_clients": "GET /api/v1/events/clients",
|
||||
"stats": "GET /api/v1/events/stats",
|
||||
"test_events": "POST /api/v1/events/test",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func simulateBusinessEvents(eventChannel chan<- string) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
counter := 0
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
counter++
|
||||
event := map[string]interface{}{
|
||||
"id": fmt.Sprintf("business-event-%d", counter),
|
||||
"type": "business_update",
|
||||
"message": fmt.Sprintf("定期业务更新 #%d", counter),
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"data": map[string]interface{}{
|
||||
"counter": counter,
|
||||
"server_time": time.Now().Unix(),
|
||||
"status": "active",
|
||||
},
|
||||
}
|
||||
|
||||
eventJSON, _ := json.Marshal(event)
|
||||
|
||||
select {
|
||||
case eventChannel <- string(eventJSON):
|
||||
fmt.Printf("Business event %d sent to SSE manager\n", counter)
|
||||
case <-time.After(1 * time.Second):
|
||||
fmt.Printf("Failed to send business event %d: timeout\n", counter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
imageTicker := time.NewTicker(30 * time.Second)
|
||||
defer imageTicker.Stop()
|
||||
|
||||
imageCounter := 0
|
||||
for {
|
||||
select {
|
||||
case <-imageTicker.C:
|
||||
imageCounter++
|
||||
imageEvent := map[string]interface{}{
|
||||
"id": fmt.Sprintf("image-processed-%d", imageCounter),
|
||||
"type": "image_processed",
|
||||
"message": fmt.Sprintf("图片处理完成 #%d", imageCounter),
|
||||
"data": map[string]interface{}{
|
||||
"image_id": fmt.Sprintf("img_%d", imageCounter),
|
||||
"filename": fmt.Sprintf("photo_%d.jpg", imageCounter),
|
||||
"status": "completed",
|
||||
"thumbnail": fmt.Sprintf("/thumbnails/photo_%d_thumb.jpg", imageCounter),
|
||||
"process_time": fmt.Sprintf("%.2fs", float64(imageCounter)*0.5),
|
||||
},
|
||||
}
|
||||
|
||||
eventJSON, _ := json.Marshal(imageEvent)
|
||||
|
||||
select {
|
||||
case eventChannel <- string(eventJSON):
|
||||
fmt.Printf("Image processing event %d sent\n", imageCounter)
|
||||
case <-time.After(1 * time.Second):
|
||||
fmt.Printf("Failed to send image event %d\n", imageCounter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
userTicker := time.NewTicker(45 * time.Second)
|
||||
defer userTicker.Stop()
|
||||
|
||||
actions := []string{"登录", "上传照片", "创建相册", "分享照片", "删除照片"}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-userTicker.C:
|
||||
action := actions[time.Now().Second()%len(actions)]
|
||||
userEvent := map[string]interface{}{
|
||||
"id": fmt.Sprintf("user-action-%d", time.Now().Unix()),
|
||||
"type": "user_activity",
|
||||
"message": fmt.Sprintf("用户活动: %s", action),
|
||||
"data": map[string]interface{}{
|
||||
"action": action,
|
||||
"user_id": fmt.Sprintf("user_%d", (time.Now().Unix()%1000)+1),
|
||||
"timestamp": time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
eventJSON, _ := json.Marshal(userEvent)
|
||||
|
||||
select {
|
||||
case eventChannel <- string(eventJSON):
|
||||
fmt.Printf("User activity event sent: %s\n", action)
|
||||
case <-time.After(1 * time.Second):
|
||||
fmt.Printf("Failed to send user activity event\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
type PhotoProcessingService struct {
|
||||
eventChannel chan<- string
|
||||
}
|
||||
|
||||
func NewPhotoProcessingService(eventChannel chan<- string) *PhotoProcessingService {
|
||||
return &PhotoProcessingService{
|
||||
eventChannel: eventChannel,
|
||||
}
|
||||
}
|
||||
|
||||
func (pps *PhotoProcessingService) ProcessPhoto(photoID, filename string) {
|
||||
fmt.Printf("开始处理照片: %s\n", filename)
|
||||
|
||||
startEvent := map[string]interface{}{
|
||||
"type": "photo_processing_started",
|
||||
"message": fmt.Sprintf("开始处理照片: %s", filename),
|
||||
"data": map[string]interface{}{
|
||||
"photo_id": photoID,
|
||||
"filename": filename,
|
||||
"status": "processing",
|
||||
},
|
||||
}
|
||||
pps.sendEvent(startEvent)
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
completedEvent := map[string]interface{}{
|
||||
"type": "photo_processing_completed",
|
||||
"message": fmt.Sprintf("照片处理完成: %s", filename),
|
||||
"data": map[string]interface{}{
|
||||
"photo_id": photoID,
|
||||
"filename": filename,
|
||||
"status": "completed",
|
||||
"thumbnail": fmt.Sprintf("/thumbnails/%s_thumb.jpg", photoID),
|
||||
},
|
||||
}
|
||||
pps.sendEvent(completedEvent)
|
||||
}
|
||||
|
||||
func (pps *PhotoProcessingService) sendEvent(event map[string]interface{}) {
|
||||
event["id"] = fmt.Sprintf("photo-event-%d", time.Now().UnixNano())
|
||||
event["timestamp"] = time.Now().Format(time.RFC3339)
|
||||
|
||||
eventJSON, _ := json.Marshal(event)
|
||||
|
||||
select {
|
||||
case pps.eventChannel <- string(eventJSON):
|
||||
case <-time.After(1 * time.Second):
|
||||
fmt.Println("Failed to send photo processing event")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import "rear/internal/repositories"
|
||||
type DbContainer struct {
|
||||
LibraryRepo *repositories.LibraryRepository
|
||||
UserRepo *repositories.UserService
|
||||
ExifRepo *repositories.ExifRepository
|
||||
// 其他服务...
|
||||
}
|
||||
|
||||
@@ -12,5 +13,6 @@ func NewContainer() *DbContainer {
|
||||
return &DbContainer{
|
||||
LibraryRepo: repositories.NewLibraryRepository(),
|
||||
UserRepo: repositories.NewUserService(),
|
||||
ExifRepo: repositories.NewExifRepository(),
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"rear/internal/model"
|
||||
"rear/internal/model/tables"
|
||||
)
|
||||
|
||||
func AutoMigrate() error {
|
||||
return DB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.LibraryTable{},
|
||||
&tables.User{},
|
||||
&tables.LibraryTable{},
|
||||
&tables.Photo{},
|
||||
&tables.PhotoExif{},
|
||||
// 在这里添加其他模型
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"rear/internal/container"
|
||||
baseModel "rear/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ExifHandler struct {
|
||||
container *container.DbContainer
|
||||
}
|
||||
|
||||
func NewExifHandler(container *container.DbContainer) *ExifHandler {
|
||||
return &ExifHandler{
|
||||
container: container,
|
||||
}
|
||||
}
|
||||
|
||||
// GetExifByHash 根据Hash获取EXIF信息
|
||||
func (h *ExifHandler) GetExifByHash(c *gin.Context) {
|
||||
hash := c.Param("hash")
|
||||
if hash == "" {
|
||||
c.JSON(http.StatusBadRequest, baseModel.Response{
|
||||
Code: http.StatusBadRequest,
|
||||
Message: "Hash不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
exif, err := h.container.ExifRepo.GetByHash(hash)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, baseModel.Response{
|
||||
Code: http.StatusNotFound,
|
||||
Message: "未找到EXIF信息",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为ParsedExif格式返回
|
||||
parsedExif := exif.ToParsedExif()
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取EXIF信息",
|
||||
Data: parsedExif,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCameraStatistics 获取相机统计信息
|
||||
func (h *ExifHandler) GetCameraStatistics(c *gin.Context) {
|
||||
cameras, err := h.container.ExifRepo.GetCameraStatistics()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取相机统计信息失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取相机统计信息",
|
||||
Data: cameras,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExifsByCamera 根据相机型号获取EXIF记录
|
||||
func (h *ExifHandler) GetExifsByCamera(c *gin.Context) {
|
||||
make := c.Query("make")
|
||||
model := c.Query("model")
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
exifs, err := h.container.ExifRepo.GetExifsByCamera(make, model, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取EXIF记录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取EXIF记录",
|
||||
Data: exifs,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExifsWithGPS 获取包含GPS信息的EXIF记录
|
||||
func (h *ExifHandler) GetExifsWithGPS(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
exifs, err := h.container.ExifRepo.GetExifsWithGPS(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取GPS EXIF记录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取GPS EXIF记录",
|
||||
Data: exifs,
|
||||
})
|
||||
}
|
||||
|
||||
// SearchExifs 搜索EXIF记录
|
||||
func (h *ExifHandler) SearchExifs(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
if keyword == "" {
|
||||
c.JSON(http.StatusBadRequest, baseModel.Response{
|
||||
Code: http.StatusBadRequest,
|
||||
Message: "搜索关键词不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
exifs, err := h.container.ExifRepo.SearchExifs(keyword, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "搜索EXIF记录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功搜索EXIF记录",
|
||||
Data: exifs,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUniqueCameras 获取所有唯一的相机信息
|
||||
func (h *ExifHandler) GetUniqueCameras(c *gin.Context) {
|
||||
cameras, err := h.container.ExifRepo.GetUniqueCameras()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取相机列表失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取相机列表",
|
||||
Data: cameras,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExifsByISO 根据ISO范围获取EXIF记录
|
||||
func (h *ExifHandler) GetExifsByISO(c *gin.Context) {
|
||||
minISOStr := c.DefaultQuery("min_iso", "0")
|
||||
maxISOStr := c.DefaultQuery("max_iso", "51200")
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
minISO, _ := strconv.Atoi(minISOStr)
|
||||
maxISO, _ := strconv.Atoi(maxISOStr)
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
exifs, err := h.container.ExifRepo.GetExifsByISO(minISO, maxISO, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取ISO EXIF记录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取ISO EXIF记录",
|
||||
Data: exifs,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExifsByAperture 根据光圈范围获取EXIF记录
|
||||
func (h *ExifHandler) GetExifsByAperture(c *gin.Context) {
|
||||
minApertureStr := c.DefaultQuery("min_aperture", "1.0")
|
||||
maxApertureStr := c.DefaultQuery("max_aperture", "32.0")
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
minAperture, _ := strconv.ParseFloat(minApertureStr, 64)
|
||||
maxAperture, _ := strconv.ParseFloat(maxApertureStr, 64)
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
exifs, err := h.container.ExifRepo.GetExifsByAperture(minAperture, maxAperture, limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取光圈 EXIF记录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取光圈 EXIF记录",
|
||||
Data: exifs,
|
||||
})
|
||||
}
|
||||
|
||||
// GetExifStatistics 获取EXIF统计信息
|
||||
func (h *ExifHandler) GetExifStatistics(c *gin.Context) {
|
||||
count, err := h.container.ExifRepo.GetCount()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取统计信息失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total_count": count,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取统计信息",
|
||||
Data: stats,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllExifs 获取所有EXIF记录
|
||||
func (h *ExifHandler) GetAllExifs(c *gin.Context) {
|
||||
limitStr := c.DefaultQuery("limit", "20")
|
||||
offsetStr := c.DefaultQuery("offset", "0")
|
||||
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
exifs, err := h.container.ExifRepo.GetAll(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, baseModel.Response{
|
||||
Code: http.StatusInternalServerError,
|
||||
Message: "获取EXIF记录失败",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, baseModel.Response{
|
||||
Code: http.StatusOK,
|
||||
Message: "成功获取EXIF记录",
|
||||
Data: exifs,
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"rear/internal/config"
|
||||
"rear/internal/container"
|
||||
"rear/internal/model"
|
||||
"rear/internal/model/tables"
|
||||
"rear/pkg/logger"
|
||||
"rear/pkg/utils"
|
||||
"strings"
|
||||
@@ -71,7 +72,7 @@ func (h *LibraryHandler) AddLibrary(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
library := &model.LibraryTable{
|
||||
library := &tables.LibraryTable{
|
||||
ImgPath: path,
|
||||
IsEnable: true,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"rear/pkg/sse"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SSEHandler struct {
|
||||
manager *sse.Manager
|
||||
}
|
||||
|
||||
type SSEResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type ClientInfo struct {
|
||||
ID string `json:"id"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
ConnectedAt time.Time `json:"connected_at"`
|
||||
LastPing time.Time `json:"last_ping"`
|
||||
}
|
||||
|
||||
func NewSSEHandler() *SSEHandler {
|
||||
opts := sse.DefaultOptions()
|
||||
opts.PingInterval = 30 * time.Second
|
||||
opts.MaxClients = 500
|
||||
opts.EventBufferSize = 200
|
||||
|
||||
manager := sse.NewManager(opts)
|
||||
|
||||
return &SSEHandler{
|
||||
manager: manager,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SSEHandler) HandleSSEConnection(c *gin.Context) {
|
||||
h.manager.RegisterClient(c)
|
||||
}
|
||||
|
||||
func (h *SSEHandler) BroadcastMessage(c *gin.Context) {
|
||||
var request struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, SSEResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request format: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if request.EventType != "" {
|
||||
h.manager.BroadcastEvent(request.EventType, request.Message)
|
||||
} else {
|
||||
h.manager.Broadcast(request.Message)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SSEResponse{
|
||||
Success: true,
|
||||
Message: "Message broadcasted successfully",
|
||||
Data: map[string]interface{}{
|
||||
"client_count": h.manager.GetClientCount(),
|
||||
"message": request.Message,
|
||||
"event_type": request.EventType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) SendToClient(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, SSEResponse{
|
||||
Success: false,
|
||||
Message: "Client ID is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var request struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
c.JSON(http.StatusBadRequest, SSEResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request format: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
success := h.manager.SendToClient(clientID, request.Message)
|
||||
if !success {
|
||||
c.JSON(http.StatusNotFound, SSEResponse{
|
||||
Success: false,
|
||||
Message: "Client not found or disconnected",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SSEResponse{
|
||||
Success: true,
|
||||
Message: "Message sent successfully",
|
||||
Data: map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
"message": request.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) GetClients(c *gin.Context) {
|
||||
clientIDs := h.manager.GetClientIDs()
|
||||
clients := make([]ClientInfo, 0, len(clientIDs))
|
||||
|
||||
for _, id := range clientIDs {
|
||||
if client, exists := h.manager.GetClientInfo(id); exists {
|
||||
clients = append(clients, ClientInfo{
|
||||
ID: client.ID,
|
||||
RemoteAddr: client.RemoteAddr,
|
||||
UserAgent: client.UserAgent,
|
||||
ConnectedAt: client.ConnectedAt,
|
||||
LastPing: client.LastPing,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SSEResponse{
|
||||
Success: true,
|
||||
Message: "Clients retrieved successfully",
|
||||
Data: map[string]interface{}{
|
||||
"total_clients": len(clients),
|
||||
"clients": clients,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) GetClientInfo(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, SSEResponse{
|
||||
Success: false,
|
||||
Message: "Client ID is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
client, exists := h.manager.GetClientInfo(clientID)
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, SSEResponse{
|
||||
Success: false,
|
||||
Message: "Client not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
clientInfo := ClientInfo{
|
||||
ID: client.ID,
|
||||
RemoteAddr: client.RemoteAddr,
|
||||
UserAgent: client.UserAgent,
|
||||
ConnectedAt: client.ConnectedAt,
|
||||
LastPing: client.LastPing,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SSEResponse{
|
||||
Success: true,
|
||||
Message: "Client info retrieved successfully",
|
||||
Data: clientInfo,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) DisconnectClient(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
if clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, SSEResponse{
|
||||
Success: false,
|
||||
Message: "Client ID is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.manager.UnregisterClient(clientID)
|
||||
|
||||
c.JSON(http.StatusOK, SSEResponse{
|
||||
Success: true,
|
||||
Message: "Client disconnected successfully",
|
||||
Data: map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) GetStats(c *gin.Context) {
|
||||
stats := map[string]interface{}{
|
||||
"total_clients": h.manager.GetClientCount(),
|
||||
"client_ids": h.manager.GetClientIDs(),
|
||||
"server_time": time.Now().Format(time.RFC3339),
|
||||
"uptime_seconds": time.Since(time.Now().Add(-time.Hour)).Seconds(), // 示例值
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SSEResponse{
|
||||
Success: true,
|
||||
Message: "Stats retrieved successfully",
|
||||
Data: stats,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) TestEvent(c *gin.Context) {
|
||||
eventType := c.DefaultQuery("type", "test")
|
||||
interval := c.DefaultQuery("interval", "5")
|
||||
count := c.DefaultQuery("count", "10")
|
||||
|
||||
intervalDur, err := strconv.Atoi(interval)
|
||||
if err != nil {
|
||||
intervalDur = 5
|
||||
}
|
||||
|
||||
maxCount, err := strconv.Atoi(count)
|
||||
if err != nil {
|
||||
maxCount = 10
|
||||
}
|
||||
|
||||
go func() {
|
||||
for i := 0; i < maxCount; i++ {
|
||||
message := map[string]interface{}{
|
||||
"sequence": i + 1,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"message": fmt.Sprintf("Test event %d of %d", i+1, maxCount),
|
||||
}
|
||||
|
||||
messageJSON, _ := json.Marshal(message)
|
||||
h.manager.BroadcastEvent(eventType, string(messageJSON))
|
||||
|
||||
time.Sleep(time.Duration(intervalDur) * time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, SSEResponse{
|
||||
Success: true,
|
||||
Message: "Test events started",
|
||||
Data: map[string]interface{}{
|
||||
"event_type": eventType,
|
||||
"interval": intervalDur,
|
||||
"count": maxCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSEHandler) GetEventChannel() chan<- string {
|
||||
return h.manager.GetEventChannel()
|
||||
}
|
||||
|
||||
func (h *SSEHandler) Close() {
|
||||
if h.manager != nil {
|
||||
h.manager.Close()
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"rear/internal/model"
|
||||
"rear/internal/model/tables"
|
||||
"rear/internal/repositories"
|
||||
)
|
||||
|
||||
// 全局变量
|
||||
var (
|
||||
Users []model.User // 模拟数据存储
|
||||
Users []tables.User // 模拟数据存储
|
||||
userNextID = 1
|
||||
)
|
||||
|
||||
@@ -59,7 +60,7 @@ func GetUserByID(c *gin.Context) {
|
||||
|
||||
// InitSampleData 初始化示例数据
|
||||
func InitSampleData() {
|
||||
Users = []model.User{
|
||||
Users = []tables.User{
|
||||
{Username: "John Doe", Email: "john@example.com"},
|
||||
{Username: "Jane Smith", Email: "jane@example.com"},
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func UpdateUser(c *gin.Context) {
|
||||
|
||||
for i, user := range Users {
|
||||
if fmt.Sprintf("%d", user.ID) == id {
|
||||
var updatedUser model.User
|
||||
var updatedUser tables.User
|
||||
if err := c.ShouldBindJSON(&updatedUser); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{
|
||||
Code: http.StatusBadRequest,
|
||||
@@ -125,7 +126,7 @@ func GetUsers(c *gin.Context) {
|
||||
|
||||
// CreateUser 创建用户
|
||||
func CreateUser(c *gin.Context) {
|
||||
var user model.User
|
||||
var user tables.User
|
||||
if err := c.ShouldBindJSON(&user); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{
|
||||
Code: http.StatusBadRequest,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BaseModel 基础模型,包含通用字段
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// 软删除 【查询时会检测删除时间】
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BaseModel 基础模型,包含通用字段
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// 软删除 【查询时会检测删除时间】
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
/*
|
||||
索引
|
||||
|
||||
给 Hash 建唯一索引(你已经有了)。
|
||||
|
||||
给 TakenAt 建索引(时间线展示时会用到)。
|
||||
|
||||
JSON 字段
|
||||
|
||||
SQLite 3.38+ 原生支持 JSON → 可以直接存 TEXT 并用 json_extract 查询。
|
||||
|
||||
GORM 层建议用 type:JSON 或 type:TEXT。
|
||||
|
||||
大文本/EXIF
|
||||
|
||||
如果 EXIF 很大,建议存 JSON 串在单独表里,不要污染主表。
|
||||
*/
|
||||
@@ -0,0 +1,222 @@
|
||||
package tables
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"rear/internal/model"
|
||||
)
|
||||
|
||||
// PhotoExif 表结构 - 保存图片EXIF信息
|
||||
// 使用Hash作为主键,直接对应exif_model.go中的结构
|
||||
type PhotoExif struct {
|
||||
BaseModel
|
||||
// Hash作为主键,与Photo表的Hash对应,但不使用外键约束
|
||||
Hash string `gorm:"uniqueIndex;size:64" json:"hash"`
|
||||
|
||||
// ========================= 基础文件信息 ==============================
|
||||
// 对应 BaseImageInfo 结构
|
||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||
FileSize int64 `gorm:"column:file_size" json:"fileSize"`
|
||||
ImageWidth int `gorm:"column:image_width" json:"imageWidth"`
|
||||
ImageHeight int `gorm:"column:image_height" json:"imageHeight"`
|
||||
ImageSize string `gorm:"column:image_size;size:50" json:"imageSize"`
|
||||
MIMEType string `gorm:"column:mime_type;size:100" json:"mimeType"`
|
||||
FileType string `gorm:"column:file_type;size:50" json:"fileType"`
|
||||
FileTypeExt string `gorm:"column:file_type_ext;size:10" json:"fileTypeExt"`
|
||||
ModifyDate string `gorm:"column:modify_date;size:50" json:"modifyDate"`
|
||||
CreateDate string `gorm:"column:create_date;size:50" json:"createDate"`
|
||||
ColorSpace string `gorm:"column:color_space;size:50" json:"colorSpace"`
|
||||
BitsPerSample int `gorm:"column:bits_per_sample" json:"bitsPerSample"`
|
||||
Resolution string `gorm:"column:resolution;size:50" json:"resolution"`
|
||||
Quality int `gorm:"column:quality" json:"quality"`
|
||||
|
||||
// ========================= EXIF摄影信息 ==============================
|
||||
// 对应 ExifInfo 结构
|
||||
Model string `gorm:"column:model;size:100;index" json:"model"`
|
||||
Make string `gorm:"column:make;size:100;index" json:"make"`
|
||||
ISO int `gorm:"column:iso;index" json:"iso"`
|
||||
GPSLatitude float64 `gorm:"column:gps_latitude;index" json:"gpsLatitude"`
|
||||
GPSLongitude float64 `gorm:"column:gps_longitude;index" json:"gpsLongitude"`
|
||||
ExposureTime float64 `gorm:"column:exposure_time" json:"exposureTime"`
|
||||
Aperture float64 `gorm:"column:aperture" json:"aperture"`
|
||||
FNumber float64 `gorm:"column:f_number" json:"fNumber"`
|
||||
FocalLength float64 `gorm:"column:focal_length" json:"focalLength"`
|
||||
LensID string `gorm:"column:lens_id;size:200" json:"lensId"`
|
||||
Title string `gorm:"column:title;size:500" json:"title"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
DateTimeOrig string `gorm:"column:datetime_original;size:50;index" json:"datetimeOriginal"`
|
||||
|
||||
// ========================= 扩展信息 ==============================
|
||||
// 其他EXIF字段 (JSON格式存储)
|
||||
OtherFields JSONMap `gorm:"column:other_fields;type:text" json:"otherFields,omitempty"`
|
||||
}
|
||||
|
||||
// JSONMap 用于存储其他EXIF字段的JSON数据
|
||||
type JSONMap map[string]interface{}
|
||||
|
||||
// Scan 实现 sql.Scanner 接口,用于从数据库读取
|
||||
func (j *JSONMap) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = make(JSONMap)
|
||||
return nil
|
||||
}
|
||||
|
||||
bytes, ok := value.([]byte)
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot scan %T into JSONMap", value)
|
||||
}
|
||||
|
||||
if len(bytes) == 0 {
|
||||
*j = make(JSONMap)
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, j)
|
||||
}
|
||||
|
||||
// Value 实现 driver.Valuer 接口,用于写入数据库
|
||||
func (j JSONMap) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(j)
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PhotoExif) TableName() string {
|
||||
return "photo_exif"
|
||||
}
|
||||
|
||||
// FromParsedExif 从ParsedExif转换为PhotoExif表结构
|
||||
func (pe *PhotoExif) FromParsedExif(hash string, parsedExif *model.ParsedExif) {
|
||||
if parsedExif == nil {
|
||||
return
|
||||
}
|
||||
|
||||
pe.Hash = hash
|
||||
|
||||
// 转换基础文件信息
|
||||
base := parsedExif.BaseInfo
|
||||
pe.FileName = base.FileName
|
||||
pe.FileSize = base.FileSize
|
||||
pe.ImageWidth = base.ImageWidth
|
||||
pe.ImageHeight = base.ImageHeight
|
||||
pe.ImageSize = base.ImageSize
|
||||
pe.MIMEType = base.MIMEType
|
||||
pe.FileType = base.FileType
|
||||
pe.FileTypeExt = base.FileTypeExt
|
||||
pe.ModifyDate = base.ModifyDate
|
||||
pe.CreateDate = base.CreateDate
|
||||
pe.ColorSpace = base.ColorSpace
|
||||
pe.BitsPerSample = base.BitsPerSample
|
||||
pe.Resolution = base.Resolution
|
||||
pe.Quality = base.Quality
|
||||
|
||||
// 转换EXIF摄影信息
|
||||
exif := parsedExif.Exif
|
||||
pe.Model = exif.Model
|
||||
pe.Make = exif.Make
|
||||
pe.ISO = exif.ISO
|
||||
pe.GPSLatitude = exif.GPSLatitude
|
||||
pe.GPSLongitude = exif.GPSLongitude
|
||||
pe.ExposureTime = exif.ExposureTime
|
||||
pe.Aperture = exif.Aperture
|
||||
pe.FNumber = exif.FNumber
|
||||
pe.FocalLength = exif.FocalLength
|
||||
pe.LensID = exif.LensID
|
||||
pe.Title = exif.Title
|
||||
pe.Description = exif.Description
|
||||
pe.DateTimeOrig = exif.DateTimeOrig
|
||||
|
||||
// 转换其他字段
|
||||
if len(parsedExif.OtherFields) > 0 {
|
||||
pe.OtherFields = JSONMap(parsedExif.OtherFields)
|
||||
}
|
||||
}
|
||||
|
||||
// ToParsedExif 将PhotoExif转换为ParsedExif结构
|
||||
func (pe *PhotoExif) ToParsedExif() *model.ParsedExif {
|
||||
// 创建基础信息
|
||||
baseInfo := model.BaseImageInfo{
|
||||
FileName: pe.FileName,
|
||||
FileSize: pe.FileSize,
|
||||
ImageWidth: pe.ImageWidth,
|
||||
ImageHeight: pe.ImageHeight,
|
||||
ImageSize: pe.ImageSize,
|
||||
MIMEType: pe.MIMEType,
|
||||
FileType: pe.FileType,
|
||||
FileTypeExt: pe.FileTypeExt,
|
||||
ModifyDate: pe.ModifyDate,
|
||||
CreateDate: pe.CreateDate,
|
||||
ColorSpace: pe.ColorSpace,
|
||||
BitsPerSample: pe.BitsPerSample,
|
||||
Resolution: pe.Resolution,
|
||||
Quality: pe.Quality,
|
||||
}
|
||||
|
||||
// 创建EXIF信息
|
||||
exifInfo := model.ExifInfo{
|
||||
Model: pe.Model,
|
||||
Make: pe.Make,
|
||||
ISO: pe.ISO,
|
||||
GPSLatitude: pe.GPSLatitude,
|
||||
GPSLongitude: pe.GPSLongitude,
|
||||
ExposureTime: pe.ExposureTime,
|
||||
Aperture: pe.Aperture,
|
||||
FNumber: pe.FNumber,
|
||||
FocalLength: pe.FocalLength,
|
||||
LensID: pe.LensID,
|
||||
Title: pe.Title,
|
||||
Description: pe.Description,
|
||||
DateTimeOrig: pe.DateTimeOrig,
|
||||
}
|
||||
|
||||
// 转换其他字段
|
||||
otherFields := make(map[string]interface{})
|
||||
if pe.OtherFields != nil {
|
||||
otherFields = map[string]interface{}(pe.OtherFields)
|
||||
}
|
||||
|
||||
return &model.ParsedExif{
|
||||
BaseInfo: baseInfo,
|
||||
Exif: exifInfo,
|
||||
OtherFields: otherFields,
|
||||
}
|
||||
}
|
||||
|
||||
// NewPhotoExifFromParsed 从ParsedExif创建PhotoExif实例
|
||||
func NewPhotoExifFromParsed(hash string, parsedExif *model.ParsedExif) *PhotoExif {
|
||||
photoExif := &PhotoExif{}
|
||||
photoExif.FromParsedExif(hash, parsedExif)
|
||||
return photoExif
|
||||
}
|
||||
|
||||
// NewPhotoExifFromRawData 从原始EXIF数据创建PhotoExif实例
|
||||
func NewPhotoExifFromRawData(hash string, rawData map[string]interface{}) *PhotoExif {
|
||||
parsedExif := model.SplitExifData(rawData)
|
||||
return NewPhotoExifFromParsed(hash, parsedExif)
|
||||
}
|
||||
|
||||
// HasGPS 判断是否有GPS信息
|
||||
func (pe *PhotoExif) HasGPS() bool {
|
||||
return pe.GPSLatitude != 0 || pe.GPSLongitude != 0
|
||||
}
|
||||
|
||||
// HasCameraInfo 判断是否有相机信息
|
||||
func (pe *PhotoExif) HasCameraInfo() bool {
|
||||
return pe.Make != "" || pe.Model != ""
|
||||
}
|
||||
|
||||
// HasShootingParams 判断是否有拍摄参数
|
||||
func (pe *PhotoExif) HasShootingParams() bool {
|
||||
return pe.ISO > 0 || pe.ExposureTime > 0 || pe.FNumber > 0 || pe.FocalLength > 0
|
||||
}
|
||||
|
||||
// GetImageRatio 计算图片宽高比
|
||||
func (pe *PhotoExif) GetImageRatio() float64 {
|
||||
if pe.ImageHeight == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(pe.ImageWidth) / float64(pe.ImageHeight)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package tables
|
||||
|
||||
type LibraryTable struct {
|
||||
BaseModel
|
||||
@@ -0,0 +1,14 @@
|
||||
package tables
|
||||
|
||||
// PhotoAiMetadataTable 表结构 - 保存图片AI分析的元信息
|
||||
type PhotoAiMetadataTable struct {
|
||||
BaseModel
|
||||
Hash string `gorm:"uniqueIndex;not null" json:"hash"`
|
||||
NsfwScore float32 `json:"nsfwScore"`
|
||||
// 人物
|
||||
//Faces JSON, -- [ {x,y,w,h,confidence}, ... ]
|
||||
// 物体
|
||||
//Objects JSON, -- [ {label: "dog", confidence:0.98}, ... ]
|
||||
// 场景
|
||||
//Scene JSON, -- {label:"beach", confidence:0.87}
|
||||
}
|
||||
+12
-6
@@ -1,4 +1,6 @@
|
||||
package model
|
||||
package tables
|
||||
|
||||
import "time"
|
||||
|
||||
// Photo 表结构 - 保存图片基础信息
|
||||
type Photo struct {
|
||||
@@ -14,10 +16,14 @@ type Photo struct {
|
||||
Format string `json:"format"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
|
||||
// 人物数量
|
||||
//
|
||||
// 文件创建时间
|
||||
FileCreatedAt *time.Time `json:"fileCreatedAt,omitempty"`
|
||||
|
||||
IsAlgorithm bool `json:"isAlgorithm"`
|
||||
AlgorithmScore *int `json:"algorithmScore,omitempty"`
|
||||
LastViewedTime *int64 `json:"lastViewedTime,omitempty"`
|
||||
// ========================= 用户行为数据 ==============================
|
||||
|
||||
// 评分、分级
|
||||
Rating int `json:"rating"`
|
||||
LastViewedAt *time.Time `json:"lastViewedAt,omitempty"`
|
||||
// 访问次数
|
||||
ViewCount int `json:"viewCount"`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package tables
|
||||
|
||||
type User struct {
|
||||
BaseModel
|
||||
@@ -0,0 +1,180 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"rear/internal/db"
|
||||
"rear/internal/model/tables"
|
||||
)
|
||||
|
||||
// ExifRepository EXIF数据仓库
|
||||
type ExifRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewExifRepository 创建EXIF仓库实例
|
||||
func NewExifRepository() *ExifRepository {
|
||||
return &ExifRepository{
|
||||
db: db.DB,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建EXIF记录
|
||||
func (r *ExifRepository) Create(exif *tables.PhotoExif) error {
|
||||
return r.db.Create(exif).Error
|
||||
}
|
||||
|
||||
// GetByHash 根据Hash获取EXIF信息
|
||||
func (r *ExifRepository) GetByHash(hash string) (*tables.PhotoExif, error) {
|
||||
var exif tables.PhotoExif
|
||||
err := r.db.Where("hash = ?", hash).First(&exif).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &exif, nil
|
||||
}
|
||||
|
||||
// Update 更新EXIF记录
|
||||
func (r *ExifRepository) Update(exif *tables.PhotoExif) error {
|
||||
return r.db.Save(exif).Error
|
||||
}
|
||||
|
||||
// Delete 删除EXIF记录
|
||||
func (r *ExifRepository) Delete(hash string) error {
|
||||
return r.db.Where("hash = ?", hash).Delete(&tables.PhotoExif{}).Error
|
||||
}
|
||||
|
||||
// Exists 检查EXIF记录是否存在
|
||||
func (r *ExifRepository) Exists(hash string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&tables.PhotoExif{}).Where("hash = ?", hash).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// GetExifsByCamera 根据相机型号获取EXIF记录
|
||||
func (r *ExifRepository) GetExifsByCamera(make, model string, limit int, offset int) ([]*tables.PhotoExif, error) {
|
||||
var exifs []*tables.PhotoExif
|
||||
query := r.db.Model(&tables.PhotoExif{})
|
||||
|
||||
if make != "" {
|
||||
query = query.Where("make = ?", make)
|
||||
}
|
||||
if model != "" {
|
||||
query = query.Where("model = ?", model)
|
||||
}
|
||||
|
||||
err := query.Limit(limit).Offset(offset).Find(&exifs).Error
|
||||
return exifs, err
|
||||
}
|
||||
|
||||
// GetExifsWithGPS 获取包含GPS信息的EXIF记录
|
||||
func (r *ExifRepository) GetExifsWithGPS(limit int, offset int) ([]*tables.PhotoExif, error) {
|
||||
var exifs []*tables.PhotoExif
|
||||
err := r.db.Where("gps_latitude != 0 OR gps_longitude != 0").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&exifs).Error
|
||||
return exifs, err
|
||||
}
|
||||
|
||||
// GetExifsByDateRange 根据拍摄时间范围获取EXIF记录
|
||||
func (r *ExifRepository) GetExifsByDateRange(startDate, endDate string, limit int, offset int) ([]*tables.PhotoExif, error) {
|
||||
var exifs []*tables.PhotoExif
|
||||
err := r.db.Where("datetime_original BETWEEN ? AND ?", startDate, endDate).
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&exifs).Error
|
||||
return exifs, err
|
||||
}
|
||||
|
||||
// GetCameraStatistics 获取相机统计信息
|
||||
func (r *ExifRepository) GetCameraStatistics() ([]map[string]interface{}, error) {
|
||||
var results []map[string]interface{}
|
||||
|
||||
err := r.db.Model(&tables.PhotoExif{}).
|
||||
Select("make, model, count(*) as count").
|
||||
Where("make != '' AND model != ''").
|
||||
Group("make, model").
|
||||
Order("count DESC").
|
||||
Scan(&results).Error
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// BatchCreate 批量创建EXIF记录
|
||||
func (r *ExifRepository) BatchCreate(exifs []*tables.PhotoExif) error {
|
||||
if len(exifs) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(exifs, 100).Error
|
||||
}
|
||||
|
||||
// GetExifsByISO 根据ISO范围获取EXIF记录
|
||||
func (r *ExifRepository) GetExifsByISO(minISO, maxISO int, limit int, offset int) ([]*tables.PhotoExif, error) {
|
||||
var exifs []*tables.PhotoExif
|
||||
err := r.db.Where("iso BETWEEN ? AND ?", minISO, maxISO).
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&exifs).Error
|
||||
return exifs, err
|
||||
}
|
||||
|
||||
// GetExifsByAperture 根据光圈范围获取EXIF记录
|
||||
func (r *ExifRepository) GetExifsByAperture(minAperture, maxAperture float64, limit int, offset int) ([]*tables.PhotoExif, error) {
|
||||
var exifs []*tables.PhotoExif
|
||||
err := r.db.Where("f_number BETWEEN ? AND ?", minAperture, maxAperture).
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&exifs).Error
|
||||
return exifs, err
|
||||
}
|
||||
|
||||
// SearchExifs 搜索EXIF记录
|
||||
func (r *ExifRepository) SearchExifs(keyword string, limit int, offset int) ([]*tables.PhotoExif, error) {
|
||||
var exifs []*tables.PhotoExif
|
||||
err := r.db.Where("title LIKE ? OR description LIKE ? OR make LIKE ? OR model LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&exifs).Error
|
||||
return exifs, err
|
||||
}
|
||||
|
||||
// GetUniqueCameras 获取所有唯一的相机信息
|
||||
func (r *ExifRepository) GetUniqueCameras() ([]map[string]string, error) {
|
||||
var cameras []map[string]string
|
||||
err := r.db.Model(&tables.PhotoExif{}).
|
||||
Select("DISTINCT make, model").
|
||||
Where("make != '' AND model != ''").
|
||||
Order("make, model").
|
||||
Scan(&cameras).Error
|
||||
return cameras, err
|
||||
}
|
||||
|
||||
// GetCount 获取总记录数
|
||||
func (r *ExifRepository) GetCount() (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&tables.PhotoExif{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetAll 获取所有EXIF记录
|
||||
func (r *ExifRepository) GetAll(limit int, offset int) ([]*tables.PhotoExif, error) {
|
||||
var exifs []*tables.PhotoExif
|
||||
err := r.db.Limit(limit).Offset(offset).Find(&exifs).Error
|
||||
return exifs, err
|
||||
}
|
||||
|
||||
// CreateOrUpdate 创建或更新EXIF记录
|
||||
func (r *ExifRepository) CreateOrUpdate(exif *tables.PhotoExif) error {
|
||||
// 先检查是否存在
|
||||
exists, err := r.Exists(exif.Hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exists {
|
||||
return r.Update(exif)
|
||||
} else {
|
||||
return r.Create(exif)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package repositories
|
||||
import (
|
||||
"fmt"
|
||||
"rear/internal/db"
|
||||
"rear/internal/model"
|
||||
"rear/internal/model/tables"
|
||||
"rear/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ func NewLibraryRepository() *LibraryRepository {
|
||||
return &LibraryRepository{}
|
||||
}
|
||||
|
||||
func (s *LibraryRepository) AddLibrary(lib *model.LibraryTable) error {
|
||||
var result model.LibraryTable
|
||||
func (s *LibraryRepository) AddLibrary(lib *tables.LibraryTable) error {
|
||||
var result tables.LibraryTable
|
||||
|
||||
err := db.GetDB().Where("img_path = ?", lib.ImgPath).FirstOrCreate(&result, lib).Error
|
||||
if err != nil {
|
||||
@@ -37,7 +37,7 @@ func (s *LibraryRepository) DeleteLibrary(imgPath string) error {
|
||||
result := fmt.Sprintf("DeleteLibrary called with oldImgPath: %s", imgPath)
|
||||
logger.Warn(result)
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Where("img_path = ?", imgPath).Delete(&model.LibraryTable{}).Error
|
||||
return db.GetDB().Where("img_path = ?", imgPath).Delete(&tables.LibraryTable{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,12 +55,12 @@ func (s *LibraryRepository) UpdateLibrary(oldImgPath string, isEnable bool) erro
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Where("img_path = ?", oldImgPath).
|
||||
Select("is_enable"). // 明确指定要更新的字段
|
||||
Updates(&model.LibraryTable{IsEnable: isEnable}).Error
|
||||
Updates(&tables.LibraryTable{IsEnable: isEnable}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LibraryRepository) GetAllLibrary() ([]model.LibraryTable, error) {
|
||||
var library []model.LibraryTable
|
||||
func (s *LibraryRepository) GetAllLibrary() ([]tables.LibraryTable, error) {
|
||||
var library []tables.LibraryTable
|
||||
err := ExecuteRead(func() error {
|
||||
return db.GetDB().Find(&library).Error
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ package repositories
|
||||
import (
|
||||
"errors"
|
||||
"rear/internal/db"
|
||||
"rear/internal/model"
|
||||
"rear/internal/model/tables"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -15,7 +15,7 @@ func NewUserService() *UserService {
|
||||
}
|
||||
|
||||
// 创建用户(写操作)
|
||||
func (s *UserService) CreateUser(user *model.User) error {
|
||||
func (s *UserService) CreateUser(user *tables.User) error {
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Create(user).Error
|
||||
})
|
||||
@@ -24,7 +24,7 @@ func (s *UserService) CreateUser(user *model.User) error {
|
||||
// 更新用户(写操作)
|
||||
func (s *UserService) UpdateUser(id uint, updates map[string]interface{}) error {
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Model(&model.User{}).
|
||||
return db.GetDB().Model(&tables.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(updates).Error
|
||||
})
|
||||
@@ -51,7 +51,7 @@ goreturn db.GetDB().Table("users").
|
||||
// 更新用户名称(写操作)
|
||||
func (s *UserService) UpdateUserName(id uint, name string) error {
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Model(&model.User{}).
|
||||
return db.GetDB().Model(&tables.User{}).
|
||||
Where("id = ?", id).
|
||||
Update("name", name).Error
|
||||
})
|
||||
@@ -60,19 +60,19 @@ func (s *UserService) UpdateUserName(id uint, name string) error {
|
||||
// 删除用户(写操作)
|
||||
func (s *UserService) DeleteUser(id uint) error {
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Delete(&model.User{}, id).Error
|
||||
return db.GetDB().Delete(&tables.User{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// 软删除用户(写操作)
|
||||
func (s *UserService) SoftDeleteUser(id uint) error {
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Delete(&model.User{}, id).Error
|
||||
return db.GetDB().Delete(&tables.User{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// 批量创建用户(写操作)
|
||||
func (s *UserService) CreateUsers(users []model.User) error {
|
||||
func (s *UserService) CreateUsers(users []tables.User) error {
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Create(&users).Error
|
||||
})
|
||||
@@ -81,8 +81,8 @@ func (s *UserService) CreateUsers(users []model.User) error {
|
||||
// ============ 读操作(可以并发)============
|
||||
|
||||
// 根据 ID 获取用户
|
||||
func (s *UserService) GetUserByID(id uint) (*model.User, error) {
|
||||
var user model.User
|
||||
func (s *UserService) GetUserByID(id uint) (*tables.User, error) {
|
||||
var user tables.User
|
||||
err := ExecuteRead(func() error {
|
||||
return db.GetDB().First(&user, id).Error
|
||||
})
|
||||
@@ -98,8 +98,8 @@ func (s *UserService) GetUserByID(id uint) (*model.User, error) {
|
||||
}
|
||||
|
||||
// 根据用户名获取用户
|
||||
func (s *UserService) GetUserByUsername(username string) (*model.User, error) {
|
||||
var user model.User
|
||||
func (s *UserService) GetUserByUsername(username string) (*tables.User, error) {
|
||||
var user tables.User
|
||||
err := ExecuteRead(func() error {
|
||||
return db.GetDB().Where("username = ?", username).First(&user).Error
|
||||
})
|
||||
@@ -115,8 +115,8 @@ func (s *UserService) GetUserByUsername(username string) (*model.User, error) {
|
||||
}
|
||||
|
||||
// 获取所有用户
|
||||
func (s *UserService) GetAllUsers() ([]model.User, error) {
|
||||
var users []model.User
|
||||
func (s *UserService) GetAllUsers() ([]tables.User, error) {
|
||||
var users []tables.User
|
||||
err := ExecuteRead(func() error {
|
||||
return db.GetDB().Find(&users).Error
|
||||
})
|
||||
@@ -125,13 +125,13 @@ func (s *UserService) GetAllUsers() ([]model.User, error) {
|
||||
}
|
||||
|
||||
// 分页获取用户
|
||||
func (s *UserService) GetUsersPaginated(offset, limit int) ([]model.User, int64, error) {
|
||||
var users []model.User
|
||||
func (s *UserService) GetUsersPaginated(offset, limit int) ([]tables.User, int64, error) {
|
||||
var users []tables.User
|
||||
var total int64
|
||||
|
||||
err := ExecuteRead(func() error {
|
||||
// 获取总数
|
||||
if err := db.GetDB().Model(&model.User{}).Count(&total).Error; err != nil {
|
||||
if err := db.GetDB().Model(&tables.User{}).Count(&total).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -143,8 +143,8 @@ func (s *UserService) GetUsersPaginated(offset, limit int) ([]model.User, int64,
|
||||
}
|
||||
|
||||
// 根据条件查询用户
|
||||
func (s *UserService) GetUsersByCondition(condition map[string]interface{}) ([]model.User, error) {
|
||||
var users []model.User
|
||||
func (s *UserService) GetUsersByCondition(condition map[string]interface{}) ([]tables.User, error) {
|
||||
var users []tables.User
|
||||
err := ExecuteRead(func() error {
|
||||
query := db.GetDB()
|
||||
for key, value := range condition {
|
||||
@@ -157,7 +157,7 @@ func (s *UserService) GetUsersByCondition(condition map[string]interface{}) ([]m
|
||||
}
|
||||
|
||||
// 事务示例(写操作)
|
||||
func (s *UserService) CreateUserWithTransaction(user *model.User, callback func(tx *gorm.DB) error) error {
|
||||
func (s *UserService) CreateUserWithTransaction(user *tables.User, callback func(tx *gorm.DB) error) error {
|
||||
return ExecuteWrite(func() error {
|
||||
return db.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
// 创建用户
|
||||
|
||||
@@ -18,6 +18,8 @@ func SetupRoutes(r *gin.Engine, contain *container.DbContainer, imgContain *cont
|
||||
// 资料库处理
|
||||
libraryHandler := handler.NewLibraryHandler(contain, imgContain)
|
||||
devImageHandler := handler.NewDevImageHandler(contain)
|
||||
exifHandler := handler.NewExifHandler(contain)
|
||||
|
||||
// API版本组
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
@@ -41,6 +43,20 @@ func SetupRoutes(r *gin.Engine, contain *container.DbContainer, imgContain *cont
|
||||
// 执行检索任务
|
||||
library.POST("indexed", libraryHandler.LibraryIndex)
|
||||
}
|
||||
// EXIF信息相关路由
|
||||
exif := v1.Group("/exif")
|
||||
{
|
||||
exif.GET("", exifHandler.GetAllExifs) // 获取所有EXIF记录
|
||||
exif.GET("/:hash", exifHandler.GetExifByHash) // 根据照片哈希获取EXIF
|
||||
exif.GET("/statistics", exifHandler.GetExifStatistics) // 获取统计信息
|
||||
exif.GET("/cameras", exifHandler.GetUniqueCameras) // 获取相机列表
|
||||
exif.GET("/cameras/stats", exifHandler.GetCameraStatistics) // 获取相机统计
|
||||
exif.GET("/search", exifHandler.SearchExifs) // 搜索EXIF
|
||||
exif.GET("/gps", exifHandler.GetExifsWithGPS) // 获取GPS信息
|
||||
exif.GET("/iso", exifHandler.GetExifsByISO) // 根据ISO筛选
|
||||
exif.GET("/aperture", exifHandler.GetExifsByAperture) // 根据光圈筛选
|
||||
exif.GET("/camera", exifHandler.GetExifsByCamera) // 根据相机筛选
|
||||
}
|
||||
}
|
||||
// 开发组
|
||||
dev := r.Group("/dev")
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{"level":"info","time":"2025-08-02 15:53:42.674","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:07:00.963","caller":"config/config.go:84","msg":"加载配置文件成功","path":"D:\\go-argus\\src\\rear\\config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:07:45.569","caller":"config/config.go:84","msg":"加载配置文件成功","path":"D:\\go-argus\\src\\rear\\config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:14:22.743","caller":"config/config.go:84","msg":"加载配置文件成功","path":"D:\\go-argus\\src\\rear\\config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:15:37.201","caller":"config/config.go:84","msg":"加载配置文件成功","path":"D:\\go-argus\\src\\rear\\config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:16:12.251","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:18:26.313","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:20:53.099","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:21:13.925","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:24:31.154","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:24:54.718","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 16:59:18.856","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:03:05.291","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:11:22.797","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:14:42.855","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:15:59.122","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:19:58.356","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:21:11.689","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:22:11.999","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:36:50.736","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:37:39.831","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:44:35.649","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:44:59.991","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:45:23.632","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:50:44.033","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:51:15.516","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:56:23.861","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 17:58:46.092","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:00:18.092","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:00:35.616","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:00:53.762","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:00:59.522","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:01:14.639","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:02:36.302","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:10:18.190","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:10:26.294","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:10:56.150","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:12:46.934","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:13:09.629","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:16:27.433","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:28:10.591","caller":"config/config.go:84","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-02 18:42:34.413","caller":"config/config.go:86","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-03 19:04:12.271","caller":"config/config.go:88","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-03 19:07:59.754","caller":"config/config.go:88","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-03 19:11:55.439","caller":"config/config.go:88","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-03 19:33:26.570","caller":"config/config.go:88","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-03 19:46:30.012","caller":"config/config.go:88","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
{"level":"info","time":"2025-08-16 13:17:41.764","caller":"config/config.go:88","msg":"加载配置文件成功","path":"config.yaml"}
|
||||
Binary file not shown.
Reference in new issue
Block a user