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:
jimmyandClaude committed 2025-08-16 15:07:45 +08:00
1 parent 6ae86592f4
commit cf3f269b7e
30 files changed
+2385 -58

No files matched your search

+19
View File
@@ -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": []
}
}
View File
Whitespace-only changes.
+8
View File
@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -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>
+9
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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配置**: 跨域请求控制
- **请求限制**: 防止恶意请求
- **日志审计**: 完整的访问日志记录
这个项目是一个功能完整、架构清晰的照片管理系统,具有良好的扩展性和维护性。
+5
View File
@@ -0,0 +1,5 @@
2025年8月16日 13点56分
1. 空文件夹自动删除
2. 不支持的文件通过ICON或文字配置可展示对应的图标或内容
3. ZIP、Tar 等常见压缩格式,支持自动解压或者手动解压
4. 重复照片检测并存储,方便删除
+150
View File
@@ -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()
}
+561
View File
@@ -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")
}
}
+2
View File
@@ -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(),
}
}
+5 -3
View File
@@ -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{},
// 在这里添加其他模型
)
}
+272
View File
@@ -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,
})
}
+2 -1
View File
@@ -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,
}
+267
View File
@@ -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()
}
}
+5 -4
View File
@@ -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,
-15
View File
@@ -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,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}
}
@@ -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 {
// 创建用户
+16
View File
@@ -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")
+48
View File
@@ -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"}
BIN
View File
Binary file not shown.