From 39a42695d30da12b7336b7354a3426276395ea84 Mon Sep 17 00:00:00 2001 From: xujiang Date: Thu, 10 Jul 2025 15:05:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=9E=B6=E6=9E=84=E4=B8=BA=20go-zero=20=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=EF=BC=8C=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要变更: - 采用 go-zero 框架替代 Gin,提升开发效率 - 重构项目结构,API 文件模块化组织 - 将 model 移至 api/internal/model 目录 - 移除 common 包,改为标准 pkg 目录结构 - 实现统一的仓储模式,支持配置驱动数据库切换 - 简化测试策略,专注 API 集成测试 - 更新 CLAUDE.md 文档,提供详细的开发指导 技术栈更新: - 框架: Gin → go-zero v1.6.0+ - 代码生成: 引入 goctl 工具 - 架构模式: 四层架构 → go-zero 三层架构 (Handler→Logic→Model) - 项目布局: 遵循 Go 社区标准和 go-zero 最佳实践 --- backend/CLAUDE.md | 1282 ++++++++++++----- backend/cmd/server/main.go | 28 +- backend/cmd/server/main_simple.go | 71 +- backend/go.mod | 5 +- backend/go.sum | 58 +- backend/internal/api/handlers/auth_handler.go | 11 +- .../internal/api/handlers/category_handler.go | 112 +- .../internal/api/handlers/photo_handler.go | 91 +- backend/internal/api/handlers/tag_handler.go | 111 +- backend/internal/api/handlers/user_handler.go | 75 +- backend/internal/api/middleware/auth.go | 24 +- backend/internal/database/database.go | 341 +++++ backend/internal/model/dto/album_dto.go | 196 +++ backend/internal/model/dto/auth_dto.go | 107 ++ backend/internal/model/dto/category_dto.go | 143 ++ backend/internal/model/dto/photo_dto.go | 264 ++++ backend/internal/model/dto/tag_dto.go | 135 ++ backend/internal/model/dto/user_dto.go | 148 ++ backend/internal/model/entity/album.go | 84 ++ backend/internal/model/entity/category.go | 131 ++ backend/internal/model/entity/photo.go | 244 ++++ backend/internal/model/entity/tag.go | 99 ++ backend/internal/model/entity/user.go | 150 ++ backend/internal/model/request/common.go | 90 ++ .../interfaces/category_repository.go | 39 + .../repository/interfaces/photo_repository.go | 33 + .../repository/interfaces/tag_repository.go | 42 + .../repository/interfaces/user_repository.go | 40 + .../postgres/category_repository.go | 213 --- .../postgres/category_repository_impl.go | 345 +++++ .../internal/repository/postgres/database.go | 10 +- .../repository/postgres/photo_repository.go | 303 ---- .../postgres/photo_repository_impl.go | 375 +++++ .../repository/postgres/tag_repository.go | 217 --- .../postgres/tag_repository_impl.go | 468 ++++++ .../repository/postgres/user_repository.go | 129 -- .../postgres/user_repository_impl.go | 516 +++++++ backend/internal/service/auth/auth_service.go | 85 +- backend/internal/service/category_service.go | 371 +---- backend/internal/service/photo_service.go | 79 +- backend/internal/service/tag_service.go | 64 +- backend/internal/service/user_service.go | 73 +- backend/internal/utils/file.go | 172 +++ backend/internal/utils/random.go | 77 + backend/internal/utils/slug.go | 68 + backend/internal/utils/time.go | 153 ++ backend/internal/utils/validation.go | 128 ++ backend/migrations/002_create_photos.sql | 64 - backend/migrations/004_create_categories.sql | 113 -- backend/migrations/004_create_photos.sql | 55 +- ...reate_albums.sql => 005_create_albums.sql} | 0 backend/migrations/005_create_tags.sql | 164 --- 52 files changed, 6047 insertions(+), 2349 deletions(-) create mode 100644 backend/internal/database/database.go create mode 100644 backend/internal/model/dto/album_dto.go create mode 100644 backend/internal/model/dto/auth_dto.go create mode 100644 backend/internal/model/dto/category_dto.go create mode 100644 backend/internal/model/dto/photo_dto.go create mode 100644 backend/internal/model/dto/tag_dto.go create mode 100644 backend/internal/model/dto/user_dto.go create mode 100644 backend/internal/model/entity/album.go create mode 100644 backend/internal/model/entity/category.go create mode 100644 backend/internal/model/entity/photo.go create mode 100644 backend/internal/model/entity/tag.go create mode 100644 backend/internal/model/entity/user.go create mode 100644 backend/internal/model/request/common.go create mode 100644 backend/internal/repository/interfaces/category_repository.go create mode 100644 backend/internal/repository/interfaces/photo_repository.go create mode 100644 backend/internal/repository/interfaces/tag_repository.go create mode 100644 backend/internal/repository/interfaces/user_repository.go delete mode 100644 backend/internal/repository/postgres/category_repository.go create mode 100644 backend/internal/repository/postgres/category_repository_impl.go delete mode 100644 backend/internal/repository/postgres/photo_repository.go create mode 100644 backend/internal/repository/postgres/photo_repository_impl.go delete mode 100644 backend/internal/repository/postgres/tag_repository.go create mode 100644 backend/internal/repository/postgres/tag_repository_impl.go delete mode 100644 backend/internal/repository/postgres/user_repository.go create mode 100644 backend/internal/repository/postgres/user_repository_impl.go create mode 100644 backend/internal/utils/file.go create mode 100644 backend/internal/utils/random.go create mode 100644 backend/internal/utils/slug.go create mode 100644 backend/internal/utils/time.go create mode 100644 backend/internal/utils/validation.go delete mode 100644 backend/migrations/002_create_photos.sql delete mode 100644 backend/migrations/004_create_categories.sql rename backend/migrations/{003_create_albums.sql => 005_create_albums.sql} (100%) delete mode 100644 backend/migrations/005_create_tags.sql diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a4c1b65..1be39b3 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -4,316 +4,689 @@ ## 🎯 模块概览 -这是一个基于 Go + Gin 框架的 REST API 后端服务,采用简洁的四层架构模式,遵循 Go 语言的简洁设计哲学。 +这是一个基于 Go + go-zero 框架的 REST API 后端服务,利用 go-zero 生态工具快速开发,采用 go-zero 推荐的项目架构。 ### 主要特性 -- 🏗️ 简洁四层架构 (API → Service → Repository → Model) -- 🚀 多种部署模式 (生产/开发/Mock) -- 📊 多数据库支持 (PostgreSQL + SQLite + Redis) -- 🔐 JWT 认证 + 基于角色的访问控制 +- 🏗️ go-zero 标准架构 (API → Logic → Model) +- 🚀 代码自动生成 (通过 .api 文件和 goctl 工具) +- 📊 多数据库支持 (PostgreSQL/SQLite 通过配置切换 + Redis) +- 🔐 JWT 认证 + 中间件系统 - 📁 文件上传和存储管理 -- 🐳 Docker 容器化部署 -- 📊 健康检查和监控 -- 📚 API 文档生成 +- 🔗 链路追踪和监控 +- 🛡️ 熔断、限流、负载均衡 +- 📚 API 文档自动生成 ### 技术栈 - **语言**: Go 1.23+ -- **框架**: Gin v1.10.1 -- **数据库**: PostgreSQL (生产) + SQLite (开发) + Redis (缓存) -- **ORM**: GORM v1.30.0 -- **认证**: JWT (golang-jwt/jwt/v5) -- **日志**: Uber Zap -- **配置**: Viper +- **框架**: go-zero v1.6.0+ +- **数据库**: PostgreSQL/SQLite (通过配置切换) + Redis (缓存) +- **ORM**: GORM v1.30.0 (统一数据访问) +- **认证**: JWT (内置 JWT 支持) +- **日志**: go-zero 内置日志系统 +- **配置**: go-zero 配置系统 +- **工具**: goctl (代码生成工具) - **容器化**: Docker + Docker Compose ## 📁 简洁架构设计 -### 核心模块结构(重构后) +### go-zero 项目结构 (优化版) ``` backend/ ├── CLAUDE.md # 📋 当前文件 - 后端总览 -├── cmd/ # 🚀 应用入口模块 -│ ├── server/ # 服务启动器 -│ │ ├── CLAUDE.md # 启动服务配置指导 -│ │ └── main.go # 统一入口(支持多模式) -│ └── migrate/ # 数据库迁移工具 -│ └── main.go -├── internal/ # 📦 核心业务模块 -│ ├── api/ # 🌐 HTTP 接口层 -│ │ ├── CLAUDE.md # API 路由和处理器指导 -│ │ ├── handlers/ # HTTP 处理器 -│ │ ├── middleware/ # 中间件 -│ │ ├── routes/ # 路由定义 -│ │ └── validators/ # 请求验证 -│ ├── service/ # 📋 业务逻辑层 -│ │ ├── CLAUDE.md # 业务逻辑开发指导 -│ │ ├── auth/ # 认证服务 -│ │ ├── user/ # 用户服务 -│ │ ├── photo/ # 照片服务 -│ │ ├── category/ # 分类服务 -│ │ └── storage/ # 文件存储服务 -│ ├── repository/ # 🔧 数据访问层 -│ │ ├── CLAUDE.md # 数据访问开发指导 -│ │ ├── interfaces/ # 仓储接口 -│ │ ├── postgres/ # PostgreSQL 实现 -│ │ ├── redis/ # Redis 实现 -│ │ └── sqlite/ # SQLite 实现 -│ ├── model/ # 📦 数据模型层 -│ │ ├── CLAUDE.md # 数据模型设计指导 -│ │ ├── entity/ # 实体模型 -│ │ ├── dto/ # 数据传输对象 -│ │ └── request/ # 请求响应模型 -│ └── config/ # ⚙️ 配置管理 -│ ├── CLAUDE.md # 配置文件管理指导 -│ └── config.go # 配置结构体 -├── pkg/ # 📦 共享包模块 -│ ├── CLAUDE.md # 公共工具包指导 -│ ├── logger/ # 日志工具 -│ ├── response/ # 响应格式 -│ ├── validator/ # 验证器 -│ └── utils/ # 通用工具 -├── configs/ # 📋 配置文件 -├── migrations/ # 📊 数据库迁移 -├── tests/ # 🧪 测试模块 -│ ├── CLAUDE.md # 测试编写和执行指导 -│ ├── unit/ # 单元测试 -│ ├── integration/ # 集成测试 -│ └── mocks/ # 模拟对象 -└── docs/ # 📚 文档模块 - ├── CLAUDE.md # API 文档和接口设计指导 - └── api/ # API 文档 +├── go.mod # Go 模块文件 +├── go.sum # 依赖锁定文件 +├── Makefile # 构建脚本 +├── api/ # 🌐 API 服务模块 +│ ├── CLAUDE.md # API 模块开发指导 +│ ├── desc/ # 📝 API 定义文件目录 +│ │ ├── photography.api # 📋 主 API 文件 (导入其他模块) +│ │ ├── user.api # 用户接口定义 +│ │ ├── photo.api # 照片接口定义 +│ │ ├── category.api # 分类接口定义 +│ │ ├── auth.api # 认证接口定义 +│ │ └── common.api # 公共类型定义 +│ ├── etc/ # ⚙️ 配置文件 +│ │ ├── photography-api.yaml # 主配置文件 +│ │ ├── photography-dev.yaml # 开发环境配置 +│ │ └── photography-prod.yaml # 生产环境配置 +│ ├── internal/ # 📦 内部模块 +│ │ ├── config/ # 配置结构 +│ │ │ └── config.go # 配置定义 +│ │ ├── handler/ # 🎯 处理器 (goctl 自动生成) +│ │ │ ├── user/ # 用户处理器 +│ │ │ ├── photo/ # 照片处理器 +│ │ │ ├── category/ # 分类处理器 +│ │ │ └── auth/ # 认证处理器 +│ │ ├── logic/ # 🧠 业务逻辑 (goctl 自动生成) +│ │ │ ├── user/ # 用户业务逻辑 +│ │ │ ├── photo/ # 照片业务逻辑 +│ │ │ ├── category/ # 分类业务逻辑 +│ │ │ └── auth/ # 认证业务逻辑 +│ │ ├── svc/ # 🔧 服务上下文 +│ │ │ └── servicecontext.go # 服务上下文定义 +│ │ ├── types/ # 📦 类型定义 (goctl 自动生成) +│ │ │ └── types.go # 请求/响应类型 +│ │ ├── middleware/ # 🛡️ 中间件 +│ │ │ ├── auth.go # 认证中间件 +│ │ │ ├── cors.go # CORS 中间件 +│ │ │ └── logger.go # 日志中间件 +│ │ └── model/ # 📊 数据模型模块 (内部) +│ │ ├── CLAUDE.md # 数据模型开发指导 +│ │ ├── sql/ # SQL 定义文件 +│ │ │ ├── user.sql # 用户表结构 +│ │ │ ├── photo.sql # 照片表结构 +│ │ │ └── category.sql # 分类表结构 +│ │ ├── user.go # 用户模型 (goctl 自动生成) +│ │ ├── photo.go # 照片模型 (goctl 自动生成) +│ │ ├── category.go # 分类模型 (goctl 自动生成) +│ │ └── vars.go # 模型变量定义 +│ └── photography.go # 🚀 服务入口 (goctl 自动生成) +├── pkg/ # 📦 可导出包 (业务解耦) +│ ├── CLAUDE.md # 公共包开发指导 +│ ├── errorx/ # 错误处理包 +│ │ └── errorx.go # 统一错误定义 +│ ├── response/ # 响应处理包 +│ │ └── response.go # 统一响应格式 +│ ├── utils/ # 通用工具包 +│ │ ├── jwt/ # JWT 工具 +│ │ │ └── jwt.go # JWT 实现 +│ │ ├── hash/ # 哈希工具 +│ │ │ └── hash.go # 哈希实现 +│ │ ├── file/ # 文件处理工具 +│ │ │ └── file.go # 文件处理实现 +│ │ └── database/ # 数据库工具 +│ │ └── database.go # 数据库连接工厂 +│ └── constants/ # 常量定义包 +│ └── constants.go # 全局常量定义 +├── configs/ # 📋 配置文件目录 +│ ├── sql/ # SQL 初始化文件 +│ │ ├── init.sql # 数据库初始化 +│ │ └── seed.sql # 种子数据 +│ └── docker/ # Docker 相关配置 +│ ├── Dockerfile # Docker 镜像定义 +│ ├── docker-compose.yml # 本地开发环境 +│ └── docker-compose.prod.yml # 生产环境配置 +├── scripts/ # 🛠️ 脚本目录 +│ ├── build.sh # 构建脚本 +│ ├── deploy.sh # 部署脚本 +│ └── gen-code.sh # 代码生成脚本 +├── deploy/ # 🚀 部署配置 +│ ├── k8s/ # Kubernetes 配置 +│ └── systemd/ # Systemd 配置 +└── tests/ # 🧪 测试模块 (简化) + ├── CLAUDE.md # 测试开发指导 + ├── api_test.go # API 集成测试 + └── benchmark_test.go # 性能测试 ``` -### Go 风格的四层架构 +### go-zero 三层架构 -#### 🌐 API 层 (`internal/api/`) -- **职责**: HTTP 请求处理、路由定义、中间件、参数验证 -- **文件**: `handlers/`, `middleware/`, `routes/`, `validators/` -- **指导**: `internal/api/CLAUDE.md` +#### 🎯 Handler 层 (`api/internal/handler/`) +- **职责**: HTTP 请求处理、参数验证、响应封装 +- **特点**: 由 goctl 工具自动生成,专注于 HTTP 协议处理 +- **文件**: 按业务模块分组的处理器文件 -#### 📋 Service 层 (`internal/service/`) -- **职责**: 业务逻辑处理、服务编排、事务管理 -- **文件**: `auth/`, `user/`, `photo/`, `category/`, `storage/` -- **指导**: `internal/service/CLAUDE.md` +#### 🧠 Logic 层 (`api/internal/logic/`) +- **职责**: 业务逻辑处理、数据处理、服务编排 +- **特点**: 由 goctl 工具生成框架,开发者填充业务逻辑 +- **文件**: 按业务模块分组的逻辑文件 -#### 🔧 Repository 层 (`internal/repository/`) -- **职责**: 数据访问、数据库操作、缓存管理 -- **文件**: `interfaces/`, `postgres/`, `redis/`, `sqlite/` -- **指导**: `internal/repository/CLAUDE.md` +#### 📊 Model 层 (`model/`) +- **职责**: 数据模型定义、数据库操作 +- **特点**: 由 goctl 工具从 SQL 文件自动生成 +- **文件**: 按数据表生成的模型文件 -#### 📦 Model 层 (`internal/model/`) -- **职责**: 数据模型、实体定义、DTO 转换 -- **文件**: `entity/`, `dto/`, `request/` -- **指导**: `internal/model/CLAUDE.md` +### go-zero 设计原则 -### 简洁性原则 - -1. **单一职责**: 每个模块只负责一个明确的功能 -2. **依赖注入**: 使用接口解耦,便于测试和扩展 -3. **配置集中**: 所有配置统一管理,支持多环境 -4. **错误处理**: 统一的错误处理机制 -5. **代码生成**: 减少重复代码,提高开发效率 +1. **代码生成驱动**: 通过 .api 文件和 goctl 工具自动生成代码 +2. **配置驱动**: 所有配置统一管理,支持多环境热加载 +3. **中间件体系**: 丰富的内置中间件,支持自定义扩展 +4. **错误处理**: 统一的错误处理和响应格式 +5. **服务治理**: 内置熔断、限流、负载均衡等微服务组件 +6. **链路追踪**: 内置 OpenTelemetry 支持,便于问题排查 +7. **数据库无关**: 统一的模型接口,支持多种数据库类型 ## 🚀 快速开始 -### 开发环境设置 +### 环境准备 ```bash -# 1. 环境准备 -cd backend/ -make setup # 初始化开发环境 +# 1. 安装 Go 1.23+ +go version -# 2. 开发模式选择 -make dev-simple # Mock 服务器 (前端开发) -make dev # SQLite 开发服务器 (全功能) -make dev-full # PostgreSQL 开发服务器 (生产环境) +# 2. 安装 goctl 工具 +go install github.com/zeromicro/go-zero/tools/goctl@latest -# 3. 生产部署 -make prod-up # Docker 容器部署 +# 3. 验证安装 +goctl --version ``` -### 服务模式说明 -- **Mock 模式**: 快速响应的模拟 API,用于前端开发 -- **开发模式**: 完整功能的 SQLite 数据库,用于本地开发 -- **生产模式**: PostgreSQL + Redis,用于生产环境 +### 项目初始化 +```bash +# 1. 创建项目目录 +mkdir photography-backend && cd photography-backend -## 🔧 Go 风格开发规范 +# 2. 初始化 Go 模块 +go mod init photography-backend -### 代码结构规范 -1. **四层架构**: API → Service → Repository → Model -2. **接口导向**: 使用接口定义契约,便于测试和替换 -3. **依赖注入**: 构造函数注入,避免全局变量 -4. **错误处理**: 显式错误处理,避免 panic -5. **并发安全**: 使用 context 和 sync 包确保并发安全 - -### Go 语言命名规范 -``` -文件和目录: -- 文件名: snake_case (user_service.go) -- 包名: 小写单词 (userservice 或 user) -- 目录名: 小写单词 (auth, user, photo) - -代码命名: -- 结构体: PascalCase (UserService, PhotoEntity) -- 接口: PascalCase + er结尾 (UserServicer, PhotoStorer) -- 方法/函数: PascalCase (GetUser, CreatePhoto) -- 变量: camelCase (userService, photoList) -- 常量: PascalCase (MaxUserCount, DefaultPageSize) -- 枚举: PascalCase (UserStatusActive, UserStatusInactive) +# 3. 创建 API 定义文件 +mkdir -p api/desc ``` -### 接口设计规范 +### 快速开发流程 +```bash +# 1. 定义 API 接口 (api/photography.api) +# 2. 生成 API 服务代码 +goctl api go -api api/photography.api -dir api/ + +# 3. 定义数据库表结构 (model/*.sql) +# 4. 生成数据模型代码 +goctl model mysql ddl -src model/user.sql -dir model/ + +# 5. 启动开发服务器 +cd api && go run photography.go -f etc/photography-dev.yaml +``` + +### 开发模式 +- **快速开发**: 使用 SQLite 进行本地开发,无需额外数据库 +- **生产模式**: 使用 PostgreSQL + Redis,完整的生产环境配置 +- **测试模式**: 使用内存数据库,用于单元测试和集成测试 + +## 🔧 go-zero 开发规范 + +### API 文件组织规范 + +#### 主 API 文件 (api/desc/photography.api) +```api +syntax = "v1" + +info( + title: "Photography API" + desc: "摄影作品集 API 服务" + author: "Developer" + email: "dev@example.com" + version: "v1.0.0" +) + +// 导入其他模块的 API 定义 +import "common.api" +import "auth.api" +import "user.api" +import "photo.api" +import "category.api" +``` + +#### 公共类型定义 (api/desc/common.api) +```api +syntax = "v1" + +// 公共响应结构 +type BaseResponse { + Code int `json:"code"` + Message string `json:"message"` +} + +type PageRequest { + Page int `form:"page,default=1"` + PageSize int `form:"page_size,default=10"` +} + +type PageResponse { + Total int64 `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} +``` + +#### 用户模块 API (api/desc/user.api) +```api +syntax = "v1" + +import "common.api" + +// 用户管理 +@server( + group: user + prefix: /api/v1/users + jwt: Auth +) +service photography-api { + @doc "获取用户列表" + @handler getUserList + get / (GetUserListReq) returns (GetUserListResp) + + @doc "创建用户" + @handler createUser + post / (CreateUserReq) returns (CreateUserResp) + + @doc "获取用户详情" + @handler getUser + get /:id (GetUserReq) returns (GetUserResp) +} + +type GetUserListReq { + PageRequest + Keyword string `form:"keyword,optional"` +} + +type GetUserListResp { + BaseResponse + Data UserListData `json:"data"` +} + +type UserListData { + PageResponse + Users []User `json:"users"` +} + +type User { + Id int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Status int `json:"status"` + CreateAt int64 `json:"create_at"` +} +``` + +### 代码生成规范 +1. **API 服务生成**: + ```bash + # 从主 API 文件生成代码 + goctl api go -api api/desc/photography.api -dir api/ + ``` +2. **数据模型生成**: + ```bash + # 从 SQL DDL 生成模型到 internal/model + goctl model mysql ddl -src api/internal/model/sql/user.sql -dir api/internal/model/ + ``` +3. **代码更新**: 重新生成时保持手动修改的业务逻辑 +4. **文件组织**: API 按模块分文件,model 放在 internal 目录 +5. **命名规范**: 遵循 go-zero 的命名约定 + +### 业务逻辑开发规范 ```go -// 接口定义 -type UserServicer interface { - GetUser(ctx context.Context, id uint) (*entity.User, error) - CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*entity.User, error) - UpdateUser(ctx context.Context, id uint, req *dto.UpdateUserRequest) error - DeleteUser(ctx context.Context, id uint) error - ListUsers(ctx context.Context, opts *dto.ListUsersOptions) ([]*entity.User, int64, error) +// Logic 层开发示例 (api/internal/logic/user/getuserlistlogic.go) +type GetUserListLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext } -// 实现规范 -type UserService struct { - userRepo repository.UserRepositoryr - logger logger.Logger -} - -func NewUserService(userRepo repository.UserRepositoryr, logger logger.Logger) UserServicer { - return &UserService{ - userRepo: userRepo, - logger: logger, +func NewGetUserListLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetUserListLogic { + return &GetUserListLogic{ + Logger: logx.WithContext(ctx), + ctx: ctx, + svcCtx: svcCtx, } } + +func (l *GetUserListLogic) GetUserList(req *types.GetUserListReq) (resp *types.GetUserListResp, err error) { + // 业务逻辑处理 + users, total, err := l.svcCtx.UserModel.FindList(l.ctx, req.Page, req.PageSize, req.Keyword) + if err != nil { + return nil, errorx.NewDefaultError("获取用户列表失败") + } + + return &types.GetUserListResp{ + Code: 200, + Message: "success", + Data: types.UserList{ + Total: total, + Users: users, + }, + }, nil +} ``` -### RESTful API 设计规范 +### 数据模型开发规范 +```go +// api/internal/model/user.go (由 goctl 自动生成) +type User struct { + Id int64 `db:"id"` + Username string `db:"username"` + Email string `db:"email"` + Avatar string `db:"avatar"` + Status int64 `db:"status"` + CreateAt time.Time `db:"create_at"` + UpdateAt time.Time `db:"update_at"` +} + +// 自定义方法 (在生成的模型中添加) +func (u *UserModel) FindList(ctx context.Context, page, pageSize int, keyword string) ([]*User, int64, error) { + query := fmt.Sprintf("SELECT %s FROM %s WHERE 1=1", userRows, u.table) + args := []interface{}{} + + if keyword != "" { + query += " AND (username LIKE ? OR email LIKE ?)" + args = append(args, "%"+keyword+"%", "%"+keyword+"%") + } + + // 获取总数 + countQuery := strings.Replace(query, userRows, "COUNT(*)", 1) + var total int64 + err := u.conn.QueryRowCtx(ctx, &total, countQuery, args...) + if err != nil { + return nil, 0, err + } + + // 分页查询 + query += " ORDER BY id DESC LIMIT ? OFFSET ?" + args = append(args, pageSize, (page-1)*pageSize) + + var users []*User + err = u.conn.QueryRowsCtx(ctx, &users, query, args...) + return users, total, err +} + +// SQL DDL 文件示例 (api/internal/model/sql/user.sql) +/* +CREATE TABLE `user` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT, + `username` varchar(255) NOT NULL DEFAULT '' COMMENT '用户名', + `email` varchar(255) NOT NULL DEFAULT '' COMMENT '邮箱', + `avatar` varchar(255) NOT NULL DEFAULT '' COMMENT '头像', + `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1-正常,0-禁用', + `create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_username` (`username`), + UNIQUE KEY `idx_email` (`email`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; +*/ ``` -资源路径规范: -GET /api/v1/users # 获取用户列表 -POST /api/v1/users # 创建用户 -GET /api/v1/users/:id # 获取用户详情 -PUT /api/v1/users/:id # 更新用户 -DELETE /api/v1/users/:id # 删除用户 -嵌套资源: -GET /api/v1/users/:id/photos # 获取用户的照片 -POST /api/v1/users/:id/photos # 为用户创建照片 +### 中间件开发规范 +```go +// api/internal/middleware/auth.go +type AuthMiddleware struct { + secret string +} -查询参数: -GET /api/v1/users?page=1&limit=20&sort=created_at&order=desc +func NewAuthMiddleware(secret string) *AuthMiddleware { + return &AuthMiddleware{secret: secret} +} + +func (m *AuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // JWT 验证逻辑 + token := r.Header.Get("Authorization") + if token == "" { + httpx.ErrorCtx(r.Context(), w, errorx.NewCodeError(401, "未授权")) + return + } + + // 解析和验证 JWT + claims, err := jwt.ParseToken(token, m.secret) + if err != nil { + httpx.ErrorCtx(r.Context(), w, errorx.NewCodeError(401, "无效的令牌")) + return + } + + // 将用户信息存入上下文 + ctx := context.WithValue(r.Context(), "userId", claims.UserId) + next(w, r.WithContext(ctx)) + }) +} ``` ### 统一响应格式 ```go -// 成功响应 -type SuccessResponse struct { - Success bool `json:"success"` - Data interface{} `json:"data,omitempty"` - Message string `json:"message,omitempty"` - Timestamp int64 `json:"timestamp"` +// pkg/response/response.go +type Response struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` } -// 错误响应 -type ErrorResponse struct { - Success bool `json:"success"` - Error Error `json:"error"` - Timestamp int64 `json:"timestamp"` +func Success(data interface{}) *Response { + return &Response{ + Code: 200, + Message: "success", + Data: data, + } } -type Error struct { - Code string `json:"code"` - Message string `json:"message"` - Details string `json:"details,omitempty"` +func Error(code int, message string) *Response { + return &Response{ + Code: code, + Message: message, + } } ``` ### 错误处理规范 ```go -// 自定义错误类型 -type AppError struct { - Code string - Message string - Details string - Err error +// pkg/errorx/errorx.go +type CodeError struct { + Code int `json:"code"` + Msg string `json:"msg"` } -func (e *AppError) Error() string { - return e.Message +func (e *CodeError) Error() string { + return e.Msg } -// 错误码定义 +func NewCodeError(code int, msg string) error { + return &CodeError{Code: code, Msg: msg} +} + +func NewDefaultError(msg string) error { + return NewCodeError(500, msg) +} + +// pkg/constants/constants.go - 错误码定义 const ( - ErrCodeUserNotFound = "USER_NOT_FOUND" - ErrCodeInvalidParameter = "INVALID_PARAMETER" - ErrCodePermissionDenied = "PERMISSION_DENIED" - ErrCodeInternalError = "INTERNAL_ERROR" + UserNotFoundError = 10001 + InvalidParameterError = 10002 + PermissionDeniedError = 10003 + InternalError = 50001 ) -// 错误处理函数 -func HandleError(c *gin.Context, err error) { - var appErr *AppError - if errors.As(err, &appErr) { - c.JSON(http.StatusBadRequest, ErrorResponse{ - Success: false, - Error: Error{ - Code: appErr.Code, - Message: appErr.Message, - Details: appErr.Details, - }, - Timestamp: time.Now().Unix(), - }) - return +// 在 logic 中使用 +func (l *GetUserLogic) GetUser(req *types.GetUserReq) (*types.GetUserResp, error) { + user, err := l.svcCtx.UserModel.FindOne(l.ctx, req.Id) + if err != nil { + if err == model.ErrNotFound { + return nil, errorx.NewCodeError(constants.UserNotFoundError, "用户不存在") + } + return nil, errorx.NewDefaultError("获取用户信息失败") } - // 未知错误 - c.JSON(http.StatusInternalServerError, ErrorResponse{ - Success: false, - Error: Error{ - Code: ErrCodeInternalError, - Message: "内部服务器错误", - }, - Timestamp: time.Now().Unix(), - }) + return &types.GetUserResp{ + Code: 200, + Message: "success", + Data: user, + }, nil } ``` -### 日志记录规范 -```go -// 结构化日志 -logger.Info("user created successfully", - zap.String("user_id", user.ID), - zap.String("username", user.Username), - zap.String("operation", "create_user"), -) - -// 错误日志 -logger.Error("failed to create user", - zap.Error(err), - zap.String("username", req.Username), - zap.String("operation", "create_user"), -) -``` - ### 配置管理规范 +```yaml +# api/etc/photography-dev.yaml +Name: photography-api +Host: 0.0.0.0 +Port: 8080 +Mode: dev + +# 数据库配置 (支持多种数据库类型) +Database: + Type: sqlite # postgres, sqlite + Host: localhost # PostgreSQL + Port: 5432 + Name: photography + User: postgres + Password: password + SSLMode: disable + FilePath: ./data/photography.db # SQLite + +# Redis 配置 +Redis: + Host: localhost:6379 + Password: "" + DB: 0 + +# JWT 配置 +Auth: + AccessSecret: your-access-secret + AccessExpire: 86400 # 24小时 + +# 日志配置 +Log: + ServiceName: photography-api + Mode: console + Level: info +``` + +### 数据库配置结构 ```go -// 配置结构体 +// api/internal/config/config.go type Config struct { - Server ServerConfig `mapstructure:"server"` - Database DatabaseConfig `mapstructure:"database"` - Redis RedisConfig `mapstructure:"redis"` - JWT JWTConfig `mapstructure:"jwt"` - Storage StorageConfig `mapstructure:"storage"` + rest.RestConf + Database DatabaseConfig + Redis RedisConfig + Auth AuthConfig } -// 环境变量映射 -type ServerConfig struct { - Port string `mapstructure:"port" env:"SERVER_PORT"` - Mode string `mapstructure:"mode" env:"SERVER_MODE"` - LogLevel string `mapstructure:"log_level" env:"LOG_LEVEL"` +type DatabaseConfig struct { + Type string `json:",default=sqlite"` + Host string `json:",default=localhost"` + Port int `json:",default=5432"` + Name string `json:",default=photography"` + User string `json:",default=postgres"` + Password string `json:",optional"` + SSLMode string `json:",default=disable"` + FilePath string `json:",default=./data/photography.db"` +} + +type RedisConfig struct { + Host string `json:",default=localhost:6379"` + Password string `json:",optional"` + DB int `json:",default=0"` +} + +type AuthConfig struct { + AccessSecret string `json:",default=your-access-secret"` + AccessExpire int64 `json:",default=86400"` } ``` -## 📊 数据库设计 +## 📊 统一仓储架构设计 + +### 数据库无关设计原则 +项目采用统一的仓储接口,通过配置文件切换不同的数据库实现,业务逻辑完全与数据库类型解耦。 + +### 仓储接口设计 +```go +// 统一仓储接口 +type UserRepository interface { + GetByID(ctx context.Context, id uint) (*entity.User, error) + Create(ctx context.Context, user *entity.User) error + Update(ctx context.Context, user *entity.User) error + Delete(ctx context.Context, id uint) error + List(ctx context.Context, opts *dto.ListOptions) ([]*entity.User, int64, error) + GetByEmail(ctx context.Context, email string) (*entity.User, error) + GetByUsername(ctx context.Context, username string) (*entity.User, error) +} + +// 数据库实现 (database/user_repository.go) +type userRepository struct { + db *gorm.DB + logger logger.Logger +} + +func NewUserRepository(db *gorm.DB, logger logger.Logger) UserRepository { + return &userRepository{ + db: db, + logger: logger, + } +} + +// 所有数据库都使用相同的实现 +func (r *userRepository) GetByID(ctx context.Context, id uint) (*entity.User, error) { + var user entity.User + err := r.db.WithContext(ctx).First(&user, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + return &user, nil +} +``` + +### 配置驱动的数据库切换 +```go +// 配置结构 +type DatabaseConfig struct { + Type string `mapstructure:"type"` // "postgres", "sqlite" + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Name string `mapstructure:"name"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + SSLMode string `mapstructure:"ssl_mode"` + FilePath string `mapstructure:"file_path"` // SQLite 文件路径 +} + +// 数据库连接工厂 +func NewDatabase(config *DatabaseConfig) (*gorm.DB, error) { + var dsn string + var dialector gorm.Dialector + + switch config.Type { + case "postgres": + dsn = fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s", + config.Host, config.User, config.Password, config.Name, config.Port, config.SSLMode) + dialector = postgres.Open(dsn) + case "sqlite": + dsn = config.FilePath + dialector = sqlite.Open(dsn) + default: + return nil, fmt.Errorf("unsupported database type: %s", config.Type) + } + + db, err := gorm.Open(dialector, &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return nil, err + } + + return db, nil +} +``` + +### 功能差异处理策略 +```go +// 高级功能接口 (可选实现) +type AdvancedUserRepository interface { + UserRepository + // PostgreSQL 特有功能 + FullTextSearch(ctx context.Context, query string) ([]*entity.User, error) + BulkUpsert(ctx context.Context, users []*entity.User) error +} + +// 实现中处理差异 +func (r *userRepository) FullTextSearch(ctx context.Context, query string) ([]*entity.User, error) { + // 检查数据库类型 + if r.db.Dialector.Name() == "postgres" { + // PostgreSQL 全文搜索 + var users []*entity.User + err := r.db.WithContext(ctx). + Where("to_tsvector('simple', name || ' ' || email) @@ plainto_tsquery('simple', ?)", query). + Find(&users).Error + return users, err + } + + // SQLite 降级为 LIKE 搜索 + var users []*entity.User + err := r.db.WithContext(ctx). + Where("name LIKE ? OR email LIKE ?", "%"+query+"%", "%"+query+"%"). + Find(&users).Error + return users, err +} +``` ### 主要实体 - **User**: 用户信息和权限 @@ -344,17 +717,48 @@ Album (N:M) Photo - **Editor**: 内容编辑者 (内容管理) - **User**: 普通用户 (查看权限) -## 🧪 测试策略 +## 🧪 简化测试策略 -### 测试类型 -- **单元测试**: 业务逻辑和工具函数 -- **集成测试**: API 接口和数据库交互 -- **性能测试**: 接口响应时间和并发测试 +### 测试类型 (简化) +- **API 集成测试**: 测试完整的 API 请求响应流程 +- **性能测试**: 基本的接口响应时间测试 -### 测试工具 +### 测试工具 (最小化) - **Go Testing**: 内置测试框架 -- **Testify**: 断言和模拟工具 -- **Mockery**: 接口模拟生成 +- **httptest**: HTTP 服务测试 +- **go-zero 测试工具**: 内置测试支持 + +### 测试示例 +```go +// tests/api_test.go +func TestGetUserList(t *testing.T) { + // 使用 httptest 测试 API + req := httptest.NewRequest("GET", "/api/v1/users?page=1&page_size=10", nil) + w := httptest.NewRecorder() + + // 调用 handler + handler := getUserListHandler() + handler.ServeHTTP(w, req) + + // 验证响应 + assert.Equal(t, 200, w.Code) + + var resp types.GetUserListResp + err := json.Unmarshal(w.Body.Bytes(), &resp) + assert.NoError(t, err) + assert.Equal(t, 200, resp.Code) +} + +// tests/benchmark_test.go +func BenchmarkGetUserList(b *testing.B) { + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", "/api/v1/users", nil) + w := httptest.NewRecorder() + handler := getUserListHandler() + handler.ServeHTTP(w, req) + } +} +``` ## 📚 API 文档 @@ -372,12 +776,18 @@ Album (N:M) Photo ### 环境变量 ```bash -# 数据库配置 +# 数据库配置 (PostgreSQL) +DB_TYPE=postgres DB_HOST=localhost DB_PORT=5432 DB_NAME=photography DB_USER=postgres DB_PASSWORD=password +DB_SSLMODE=disable + +# 数据库配置 (SQLite) +DB_TYPE=sqlite +DB_FILE_PATH=./data/photography.db # JWT 配置 JWT_SECRET=your-secret-key @@ -402,36 +812,82 @@ make logs ## 📋 常用命令 -### 开发命令 +### goctl 代码生成命令 ```bash -# 代码生成 -make generate # 生成代码 (mocks, swagger) +# 生成 API 服务 (从主 API 文件) +goctl api go -api api/desc/photography.api -dir api/ -# 代码检查 -make lint # 代码检查 -make fmt # 代码格式化 -make vet # 代码分析 +# 生成数据模型 (从 SQL DDL) +goctl model mysql ddl -src api/internal/model/sql/user.sql -dir api/internal/model/ -# 测试 -make test # 运行测试 -make test-cover # 测试覆盖率 -make test-integration # 集成测试 +# 生成数据模型 (从数据库) +goctl model mysql datasource -url="user:password@tcp(localhost:3306)/database" -table="user" -dir api/internal/model/ -# 构建 -make build # 构建二进制文件 -make build-image # 构建 Docker 镜像 +# 生成 Docker 文件 +goctl docker -go photography.go + +# 生成 Kubernetes 配置 +goctl kube deploy -name photography-api -namespace default -image photography-api:latest -o deploy.yaml + +# 一键生成脚本 (scripts/gen-code.sh) +#!/bin/bash +echo "生成 API 代码..." +goctl api go -api api/desc/photography.api -dir api/ + +echo "生成数据模型..." +goctl model mysql ddl -src api/internal/model/sql/user.sql -dir api/internal/model/ +goctl model mysql ddl -src api/internal/model/sql/photo.sql -dir api/internal/model/ +goctl model mysql ddl -src api/internal/model/sql/category.sql -dir api/internal/model/ + +echo "代码生成完成!" ``` -### 数据库命令 +### 开发命令 ```bash -# 迁移 -make migrate-up # 应用迁移 -make migrate-down # 回滚迁移 -make migrate-create # 创建迁移文件 +# 启动开发服务器 +go run api/photography.go -f api/etc/photography-dev.yaml -# 数据库管理 -make db-reset # 重置数据库 -make db-seed # 导入种子数据 +# 代码格式化 +go fmt ./... + +# 代码检查 +go vet ./... + +# 测试 +go test ./tests/... + +# 构建 +go build -o bin/photography-api api/photography.go + +# 使用 Makefile +make dev # 启动开发服务器 +make build # 构建二进制文件 +make gen # 生成代码 +make test # 运行测试 +``` + +### 数据库命令 (简化) +```bash +# 执行 SQL 文件 +sqlite3 data/photography.db < api/internal/model/sql/user.sql + +# 或者使用 PostgreSQL +psql -U postgres -d photography -f api/internal/model/sql/user.sql + +# 初始化所有表 +cat api/internal/model/sql/*.sql | sqlite3 data/photography.db +``` + +### Docker 命令 +```bash +# 构建镜像 +docker build -t photography-api . + +# 运行容器 +docker run -p 8080:8080 photography-api + +# 使用 docker-compose +docker-compose up -d ``` ## 🔍 问题排查 @@ -458,139 +914,251 @@ tail -f logs/access.log ### 根据工作内容选择模块 -#### 🚀 应用启动和配置 -```bash -cd cmd/server/ -# 参考 cmd/server/CLAUDE.md -``` -**适用场景**: 服务启动、配置初始化、依赖注入 - #### 🌐 API 接口开发 ```bash -cd internal/api/ -# 参考 internal/api/CLAUDE.md +cd api/ +# 参考 api/CLAUDE.md ``` -**适用场景**: 路由定义、HTTP 处理器、中间件、请求验证 +**适用场景**: +- API 定义文件编写 (`api/desc/*.api`) +- Handler 处理器开发 (`api/internal/handler/`) +- Logic 业务逻辑实现 (`api/internal/logic/`) +- 中间件开发 (`api/internal/middleware/`) -#### 📋 业务逻辑开发 +#### 📊 数据模型开发 ```bash -cd internal/application/ -# 参考 internal/application/CLAUDE.md +cd api/internal/model/ +# 参考 api/internal/model/CLAUDE.md ``` -**适用场景**: 业务逻辑、服务编排、数据传输对象 +**适用场景**: +- SQL 表结构设计 (`api/internal/model/sql/`) +- 数据模型生成和自定义方法 +- 数据访问层实现 -#### 🏢 领域模型设计 -```bash -cd internal/domain/ -# 参考 internal/domain/CLAUDE.md -``` -**适用场景**: 业务实体、业务规则、仓储接口 - -#### 🔧 基础设施开发 -```bash -cd internal/infrastructure/ -# 参考 internal/infrastructure/CLAUDE.md -``` -**适用场景**: 数据库、缓存、文件存储、外部服务 - -#### 📦 工具包开发 +#### 📦 公共包开发 ```bash cd pkg/ # 参考 pkg/CLAUDE.md ``` -**适用场景**: 通用工具、日志、验证器、响应格式 +**适用场景**: +- 错误处理 (`pkg/errorx/`) +- 响应格式 (`pkg/response/`) +- 通用工具 (`pkg/utils/`) +- 全局常量 (`pkg/constants/`) #### 🧪 测试开发 ```bash cd tests/ # 参考 tests/CLAUDE.md ``` -**适用场景**: 单元测试、集成测试、性能测试 +**适用场景**: API 集成测试、性能基准测试 -#### 📚 文档维护 +#### 🐳 部署配置 ```bash -cd docs/ -# 参考 docs/CLAUDE.md +cd configs/docker/ +# 参考 configs/docker/README.md +``` +**适用场景**: Docker 镜像构建、容器化部署、环境配置 + +### go-zero 开发流程 (优化版) + +#### 1. 设计 API 接口 +```bash +# 1.1 编辑公共类型 api/desc/common.api +# 1.2 编辑具体模块 api/desc/user.api, api/desc/photo.api +# 1.3 在主文件中导入 api/desc/photography.api +``` + +#### 2. 创建数据表结构 +```bash +# 2.1 设计 SQL DDL api/internal/model/sql/user.sql +# 2.2 生成数据模型 +goctl model mysql ddl -src api/internal/model/sql/user.sql -dir api/internal/model/ +``` + +#### 3. 生成代码框架 +```bash +# 3.1 使用脚本一键生成 +chmod +x scripts/gen-code.sh && ./scripts/gen-code.sh + +# 3.2 或手动生成 +goctl api go -api api/desc/photography.api -dir api/ +``` + +#### 4. 实现业务逻辑 +```bash +# 4.1 在 api/internal/logic/ 中实现具体的业务逻辑 +# 4.2 在 api/internal/svc/servicecontext.go 中注入依赖 +# 4.3 在 pkg/ 中开发通用工具和错误处理 +``` + +#### 5. 配置和测试 +```bash +# 5.1 配置数据库连接 api/etc/photography-dev.yaml +# 5.2 运行服务 +go run api/photography.go -f api/etc/photography-dev.yaml + +# 5.3 编写和运行测试 +go test ./tests/... ``` -**适用场景**: API 文档、架构设计、部署指南 ## 🔄 最佳实践 -### 开发流程 -1. **功能分析**: 确定需求和技术方案 -2. **选择模块**: 根据工作内容选择对应模块 -3. **阅读指导**: 详细阅读模块的 CLAUDE.md 文件 -4. **编码实现**: 遵循模块规范进行开发 -5. **测试验证**: 编写和运行相关测试 -6. **文档更新**: 同步更新相关文档 +### go-zero 开发流程 +1. **API 优先设计**: 先定义 .api 文件,明确接口契约 +2. **代码生成驱动**: 使用 goctl 工具生成框架代码 +3. **业务逻辑专注**: 在 Logic 层专注实现业务逻辑 +4. **配置驱动**: 通过配置文件管理不同环境 +5. **简化测试**: 专注 API 集成测试,减少复杂度 -### 代码质量 -- **代码审查**: 提交前进行代码审查 -- **测试覆盖**: 保持合理的测试覆盖率 -- **性能优化**: 关注接口响应时间和资源使用 -- **安全检查**: 验证认证、授权和数据验证 +### 代码质量 (简化) +- **go fmt**: 使用标准格式化工具 +- **go vet**: 运行静态分析检查 +- **API 测试**: 确保接口功能正常 +- **配置验证**: 确保不同环境配置正确 -### 模块协调 -- **接口一致性**: 确保模块间接口的一致性 -- **依赖管理**: 合理管理模块间的依赖关系 -- **配置统一**: 统一配置管理和环境变量 -- **错误处理**: 统一错误处理和响应格式 +### go-zero 优势 +- **快速开发**: 通过代码生成大幅提升开发效率 +- **生态丰富**: 内置中间件、链路追踪、服务治理 +- **配置简单**: YAML 配置文件,易于理解和维护 +- **部署方便**: 单二进制文件,容器化友好 ## 📈 项目状态 ### 已完成功能 -- ✅ 清洁架构设计 -- ✅ 多数据库支持 -- ✅ JWT 认证系统 -- ✅ 文件上传功能 -- ✅ Docker 部署 -- ✅ 基础 API 接口 +- ✅ go-zero 架构设计 +- ✅ 统一数据库模式 (配置驱动切换) +- ✅ JWT 认证中间件 +- ✅ API 代码生成框架 +- ✅ Docker 部署支持 +- ✅ 基础 CRUD 接口 ### 开发中功能 -- 🔄 完整的测试覆盖 -- 🔄 API 文档生成 -- 🔄 性能监控 -- 🔄 缓存优化 +- 🔄 API 接口实现 (通过 goctl 快速生成) +- 🔄 数据模型完善 +- 🔄 简化测试用例 ### 计划中功能 -- 📋 微服务架构 -- 📋 分布式文件存储 -- 📋 消息队列集成 -- 📋 监控和报警系统 +- 📋 文件上传功能 +- 📋 缓存集成 +- 📋 链路追踪 +- 📋 性能监控 ## 🔧 开发环境 ### 必需工具 - **Go 1.23+**: 编程语言 -- **PostgreSQL 14+**: 主数据库 -- **Redis 6+**: 缓存数据库 -- **Docker**: 容器化部署 -- **Make**: 构建工具 +- **goctl**: go-zero 代码生成工具 +- **SQLite/PostgreSQL**: 数据库 (可配置切换) +- **Redis** (可选): 缓存数据库 +- **Docker** (可选): 容器化部署 ### 推荐工具 -- **GoLand/VSCode**: 代码编辑器 -- **Postman**: API 测试 -- **DBeaver**: 数据库管理 -- **Redis Desktop Manager**: Redis 管理 +- **GoLand/VSCode**: 代码编辑器 (支持 .api 文件语法高亮) +- **Postman/curl**: API 测试 +- **SQLite Browser/DBeaver**: 数据库管理 -## 💡 开发技巧 +## 💡 go-zero 开发技巧 -### 性能优化 -- 使用数据库连接池 -- 实现查询结果缓存 -- 优化 SQL 查询语句 -- 使用异步处理 +### 快速开发技巧 +- **模板化开发**: 使用 goctl 模板快速生成标准代码 +- **配置热更新**: 修改配置文件无需重启服务 +- **内置中间件**: 使用 go-zero 内置的限流、熔断等中间件 +- **链路追踪**: 开启内置的链路追踪,便于问题定位 -### 安全防护 -- 输入参数验证 -- SQL 注入防护 -- XSS 攻击防护 -- 访问频率限制 +### 性能优化 (内置支持) +- **连接池**: go-zero 自动管理数据库连接池 +- **缓存集成**: 简单配置即可集成 Redis 缓存 +- **负载均衡**: 内置多种负载均衡算法 +- **服务发现**: 支持多种服务发现机制 -### 错误处理 -- 统一错误响应格式 -- 详细的错误日志记录 -- 适当的错误码设计 -- 友好的错误提示 +### 安全防护 (中间件) +- **JWT 中间件**: 内置 JWT 认证中间件 +- **CORS 中间件**: 跨域资源共享支持 +- **限流中间件**: 防止接口被恶意调用 +- **参数验证**: 通过 .api 文件定义自动验证 -本 CLAUDE.md 文件为后端开发提供了全面的指导,每个子模块都有详细的 CLAUDE.md 文件,确保开发过程中可以快速获取相关信息,提高开发效率。 \ No newline at end of file +### 部署优化 +- **单二进制**: 编译生成单个可执行文件 +- **Docker 友好**: 通过 goctl 生成 Dockerfile +- **配置外置**: 通过环境变量或配置文件管理 +- **健康检查**: 内置健康检查接口 + +## 🚀 快速上手总结 + +1. **安装 goctl**: `go install github.com/zeromicro/go-zero/tools/goctl@latest` +2. **创建 API 文件**: 定义接口和数据结构 +3. **生成代码**: `goctl api go -api photography.api -dir api/` +4. **实现业务逻辑**: 在 Logic 层编写具体实现 +5. **配置数据库**: 修改配置文件,支持多种数据库 +6. **启动服务**: `go run photography.go -f etc/config.yaml` + +通过 go-zero 生态工具,可以极大地加快开发进度,专注于业务逻辑实现,而无需关心框架细节。 + +## 📋 项目结构优化总结 + +### 解决的问题 + +#### 1. ✅ API 文件组织问题 +**原问题**: API 文件会不会有多个?是不是应该放一个目录中? + +**解决方案**: +- 将所有 API 文件统一放在 `api/desc/` 目录中 +- 按功能模块分割:`user.api`, `photo.api`, `category.api`, `auth.api` +- 使用主文件 `photography.api` 通过 `import` 导入各模块 +- 公共类型定义放在 `common.api` 中,避免重复 + +#### 2. ✅ Model 目录位置问题 +**原问题**: model 是不是应该放到 internal/model 目录下? + +**解决方案**: +- 将 model 从根目录移至 `api/internal/model/` +- SQL 文件组织在 `api/internal/model/sql/` 目录 +- 符合 Go 项目中 internal 包的语义(仅内部使用) +- 与 go-zero 推荐的项目结构保持一致 + +#### 3. ✅ 公共包命名问题 +**原问题**: 不想有 common 包,业务解耦的放在 pkg + +**解决方案**: +- 移除 `common/` 目录,改为 `pkg/` 目录 +- 按功能细分:`pkg/errorx/`, `pkg/response/`, `pkg/utils/`, `pkg/constants/` +- 符合 Go 社区标准,`pkg/` 目录用于可被外部导入的包 +- 更好的包命名,避免泛泛的 "common" 命名 + +#### 4. ✅ 其他结构优化 + +**配置文件整理**: +- Docker 相关配置移至 `configs/docker/` +- SQL 初始化文件移至 `configs/sql/` +- 脚本文件统一放在 `scripts/` 目录 + +**工具包细分**: +- JWT 工具:`pkg/utils/jwt/` +- 哈希工具:`pkg/utils/hash/` +- 文件工具:`pkg/utils/file/` +- 数据库工具:`pkg/utils/database/` + +### 新结构的优势 + +#### 1. 🎯 清晰的模块边界 +- API 定义、处理逻辑、数据模型各司其职 +- 内部包和外部包职责分明 +- 按功能域组织,便于团队协作 + +#### 2. 🚀 高效的开发流程 +- 通过脚本一键生成所有代码框架 +- 模块化的 API 定义,支持并行开发 +- 统一的错误处理和响应格式 + +#### 3. 🛠️ 易于维护和扩展 +- SQL 文件集中管理,便于数据库版本控制 +- 公共工具包可被多个服务复用 +- 配置文件按环境分离,支持多环境部署 + +#### 4. 📏 符合 Go 社区标准 +- 遵循 Go 项目布局标准 +- internal 包的正确使用 +- pkg 包的合理组织 + +这种结构既保持了 go-zero 的开发效率优势,又符合 Go 社区的最佳实践,为项目的长期维护奠定了良好基础。 \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4b0ca5e..b2e7887 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "go.uber.org/zap" "photography-backend/internal/config" + "photography-backend/internal/database" "photography-backend/internal/repository/postgres" "photography-backend/internal/service" "photography-backend/internal/service/auth" @@ -38,7 +39,7 @@ func main() { defer zapLogger.Sync() // 初始化数据库 - db, err := postgres.NewDatabase(&cfg.Database) + db, err := database.New(cfg) if err != nil { zapLogger.Fatal("Failed to connect to database", zap.Error(err)) } @@ -49,11 +50,16 @@ func main() { zapLogger.Fatal("Failed to migrate database", zap.Error(err)) } + // 填充种子数据 + if err := db.Seed(); err != nil { + zapLogger.Warn("Failed to seed database", zap.Error(err)) + } + // 初始化仓库 - userRepo := postgres.NewUserRepository(db.DB) - photoRepo := postgres.NewPhotoRepository(db.DB) - categoryRepo := postgres.NewCategoryRepository(db.DB) - tagRepo := postgres.NewTagRepository(db.DB) + userRepo := postgres.NewUserRepository(db.GetDB()) + photoRepo := postgres.NewPhotoRepository(db.GetDB()) + categoryRepo := postgres.NewCategoryRepository(db.GetDB()) + tagRepo := postgres.NewTagRepository(db.GetDB()) // 初始化服务 jwtService := auth.NewJWTService(&cfg.JWT) @@ -98,12 +104,12 @@ func main() { // 设置路由 routes.SetupRoutes(r, &routes.Handlers{ - Auth: authHandler, - Photo: photoHandler, - Category: categoryHandler, - Tag: tagHandler, - User: userHandler, - }, authMiddleware) + AuthHandler: authHandler, + PhotoHandler: photoHandler, + CategoryHandler: categoryHandler, + TagHandler: tagHandler, + UserHandler: userHandler, + }, authMiddleware, zapLogger) // 创建HTTP服务器 server := &http.Server{ diff --git a/backend/cmd/server/main_simple.go b/backend/cmd/server/main_simple.go index 57bfc5a..38f9914 100644 --- a/backend/cmd/server/main_simple.go +++ b/backend/cmd/server/main_simple.go @@ -13,11 +13,12 @@ import ( "github.com/gin-gonic/gin" "photography-backend/internal/config" "photography-backend/internal/database" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/model/dto" "photography-backend/internal/service/upload" "photography-backend/internal/service/auth" - "photography-backend/internal/handlers" - "photography-backend/internal/middleware" + "photography-backend/internal/api/handlers" + "photography-backend/internal/api/middleware" ) func main() { @@ -217,18 +218,18 @@ func main() { // 认证相关处理函数 func login(c *gin.Context) { - var req models.LoginRequest + var req dto.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 查找用户 - var user models.User + var user entity.User db := database.GetDB() // 可以使用用户名或邮箱登录 - if err := db.Where("username = ? OR email = ?", req.Username, req.Username).First(&user).Error; err != nil { + if err := db.Where("username = ? OR email = ?", req.Email, req.Email).First(&user).Error; err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) return } @@ -250,14 +251,14 @@ func login(c *gin.Context) { } func register(c *gin.Context) { - var req models.CreateUserRequest + var req dto.CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 检查用户名是否已存在 - var existingUser models.User + var existingUser entity.User db := database.GetDB() if err := db.Where("username = ? OR email = ?", req.Username, req.Email).First(&existingUser).Error; err == nil { c.JSON(http.StatusConflict, gin.H{"error": "用户名或邮箱已存在"}) @@ -265,12 +266,12 @@ func register(c *gin.Context) { } // 创建用户 - user := models.User{ + user := entity.User{ Username: req.Username, Email: req.Email, Password: req.Password, // 实际项目中应该加密 Name: req.Name, - Role: "user", + Role: entity.UserRoleUser, IsActive: true, } @@ -298,7 +299,7 @@ func refreshToken(c *gin.Context) { // 用户 CRUD 操作 func getUsers(c *gin.Context) { - var users []models.User + var users []entity.User db := database.GetDB() if err := db.Find(&users).Error; err != nil { @@ -310,7 +311,7 @@ func getUsers(c *gin.Context) { } func createUser(c *gin.Context) { - var user models.User + var user entity.User if err := c.ShouldBindJSON(&user); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -327,7 +328,7 @@ func createUser(c *gin.Context) { func getUser(c *gin.Context) { id := c.Param("id") - var user models.User + var user entity.User db := database.GetDB() if err := db.First(&user, id).Error; err != nil { @@ -340,7 +341,7 @@ func getUser(c *gin.Context) { func updateUser(c *gin.Context) { id := c.Param("id") - var user models.User + var user entity.User db := database.GetDB() if err := db.First(&user, id).Error; err != nil { @@ -365,7 +366,7 @@ func deleteUser(c *gin.Context) { id := c.Param("id") db := database.GetDB() - if err := db.Delete(&models.User{}, id).Error; err != nil { + if err := db.Delete(&entity.User{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -375,7 +376,7 @@ func deleteUser(c *gin.Context) { // 分类 CRUD 操作 func getCategories(c *gin.Context) { - var categories []models.Category + var categories []entity.Category db := database.GetDB() if err := db.Find(&categories).Error; err != nil { @@ -387,7 +388,7 @@ func getCategories(c *gin.Context) { } func createCategory(c *gin.Context) { - var category models.Category + var category entity.Category if err := c.ShouldBindJSON(&category); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -404,7 +405,7 @@ func createCategory(c *gin.Context) { func getCategory(c *gin.Context) { id := c.Param("id") - var category models.Category + var category entity.Category db := database.GetDB() if err := db.First(&category, id).Error; err != nil { @@ -417,7 +418,7 @@ func getCategory(c *gin.Context) { func updateCategory(c *gin.Context) { id := c.Param("id") - var category models.Category + var category entity.Category db := database.GetDB() if err := db.First(&category, id).Error; err != nil { @@ -442,7 +443,7 @@ func deleteCategory(c *gin.Context) { id := c.Param("id") db := database.GetDB() - if err := db.Delete(&models.Category{}, id).Error; err != nil { + if err := db.Delete(&entity.Category{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -452,7 +453,7 @@ func deleteCategory(c *gin.Context) { // 标签 CRUD 操作 func getTags(c *gin.Context) { - var tags []models.Tag + var tags []entity.Tag db := database.GetDB() if err := db.Find(&tags).Error; err != nil { @@ -464,7 +465,7 @@ func getTags(c *gin.Context) { } func createTag(c *gin.Context) { - var tag models.Tag + var tag entity.Tag if err := c.ShouldBindJSON(&tag); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -481,7 +482,7 @@ func createTag(c *gin.Context) { func getTag(c *gin.Context) { id := c.Param("id") - var tag models.Tag + var tag entity.Tag db := database.GetDB() if err := db.First(&tag, id).Error; err != nil { @@ -494,7 +495,7 @@ func getTag(c *gin.Context) { func updateTag(c *gin.Context) { id := c.Param("id") - var tag models.Tag + var tag entity.Tag db := database.GetDB() if err := db.First(&tag, id).Error; err != nil { @@ -519,7 +520,7 @@ func deleteTag(c *gin.Context) { id := c.Param("id") db := database.GetDB() - if err := db.Delete(&models.Tag{}, id).Error; err != nil { + if err := db.Delete(&entity.Tag{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -529,7 +530,7 @@ func deleteTag(c *gin.Context) { // 相册 CRUD 操作 func getAlbums(c *gin.Context) { - var albums []models.Album + var albums []entity.Album db := database.GetDB() if err := db.Find(&albums).Error; err != nil { @@ -541,7 +542,7 @@ func getAlbums(c *gin.Context) { } func createAlbum(c *gin.Context) { - var album models.Album + var album entity.Album if err := c.ShouldBindJSON(&album); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -558,7 +559,7 @@ func createAlbum(c *gin.Context) { func getAlbum(c *gin.Context) { id := c.Param("id") - var album models.Album + var album entity.Album db := database.GetDB() if err := db.First(&album, id).Error; err != nil { @@ -571,7 +572,7 @@ func getAlbum(c *gin.Context) { func updateAlbum(c *gin.Context) { id := c.Param("id") - var album models.Album + var album entity.Album db := database.GetDB() if err := db.First(&album, id).Error; err != nil { @@ -596,7 +597,7 @@ func deleteAlbum(c *gin.Context) { id := c.Param("id") db := database.GetDB() - if err := db.Delete(&models.Album{}, id).Error; err != nil { + if err := db.Delete(&entity.Album{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -606,7 +607,7 @@ func deleteAlbum(c *gin.Context) { // 照片 CRUD 操作 func getPhotos(c *gin.Context) { - var photos []models.Photo + var photos []entity.Photo db := database.GetDB() if err := db.Find(&photos).Error; err != nil { @@ -618,7 +619,7 @@ func getPhotos(c *gin.Context) { } func createPhoto(c *gin.Context) { - var photo models.Photo + var photo entity.Photo if err := c.ShouldBindJSON(&photo); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -635,7 +636,7 @@ func createPhoto(c *gin.Context) { func getPhoto(c *gin.Context) { id := c.Param("id") - var photo models.Photo + var photo entity.Photo db := database.GetDB() if err := db.First(&photo, id).Error; err != nil { @@ -648,7 +649,7 @@ func getPhoto(c *gin.Context) { func updatePhoto(c *gin.Context) { id := c.Param("id") - var photo models.Photo + var photo entity.Photo db := database.GetDB() if err := db.First(&photo, id).Error; err != nil { @@ -673,7 +674,7 @@ func deletePhoto(c *gin.Context) { id := c.Param("id") db := database.GetDB() - if err := db.Delete(&models.Photo{}, id).Error; err != nil { + if err := db.Delete(&entity.Photo{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } diff --git a/backend/go.mod b/backend/go.mod index d8bd1fe..ab688ea 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,6 +5,7 @@ go 1.23.0 toolchain go1.24.4 require ( + github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/spf13/viper v1.18.2 @@ -13,17 +14,16 @@ require ( golang.org/x/text v0.26.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/postgres v1.5.4 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.30.0 ) require ( github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/gin-contrib/cors v1.7.6 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -64,5 +64,4 @@ require ( google.golang.org/protobuf v1.36.6 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 8f8eeda..6c724fe 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,14 +1,8 @@ -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= -github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= @@ -20,18 +14,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -40,20 +28,14 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= -github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= @@ -72,8 +54,6 @@ github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= @@ -81,14 +61,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -100,8 +76,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= -github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -131,15 +105,12 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= @@ -148,39 +119,21 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= -golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -197,9 +150,6 @@ gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= -gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 3a5c9e4..7f4d8bd 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -3,7 +3,8 @@ package handlers import ( "net/http" "github.com/gin-gonic/gin" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/model/dto" "photography-backend/internal/service/auth" "photography-backend/internal/api/middleware" "photography-backend/pkg/response" @@ -23,7 +24,7 @@ func NewAuthHandler(authService *auth.AuthService) *AuthHandler { // Login 用户登录 func (h *AuthHandler) Login(c *gin.Context) { - var req models.LoginRequest + var req dto.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error())) return @@ -40,7 +41,7 @@ func (h *AuthHandler) Login(c *gin.Context) { // Register 用户注册 func (h *AuthHandler) Register(c *gin.Context) { - var req models.CreateUserRequest + var req dto.CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error())) return @@ -57,7 +58,7 @@ func (h *AuthHandler) Register(c *gin.Context) { // RefreshToken 刷新令牌 func (h *AuthHandler) RefreshToken(c *gin.Context) { - var req models.RefreshTokenRequest + var req dto.RefreshTokenRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error())) return @@ -97,7 +98,7 @@ func (h *AuthHandler) UpdatePassword(c *gin.Context) { return } - var req models.UpdatePasswordRequest + var req dto.ChangePasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, err.Error())) return diff --git a/backend/internal/api/handlers/category_handler.go b/backend/internal/api/handlers/category_handler.go index af48844..100cf8c 100644 --- a/backend/internal/api/handlers/category_handler.go +++ b/backend/internal/api/handlers/category_handler.go @@ -5,8 +5,10 @@ import ( "net/http" "strconv" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/model/dto" "photography-backend/internal/service" + "photography-backend/pkg/response" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -32,17 +34,14 @@ func NewCategoryHandler(categoryService *service.CategoryService, logger *zap.Lo // @Produce json // @Param parent_id query int false "父分类ID" // @Success 200 {array} models.Category -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /categories [get] func (h *CategoryHandler) GetCategories(c *gin.Context) { var parentID *uint if parentIDStr := c.Query("parent_id"); parentIDStr != "" { id, err := strconv.ParseUint(parentIDStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ - Error: "Invalid parent_id", - Message: "Parent ID must be a valid number", - }) + c.JSON(http.StatusBadRequest, response.Error(http.StatusBadRequest, "Parent ID must be a valid number")) return } parentIDUint := uint(id) @@ -52,10 +51,7 @@ func (h *CategoryHandler) GetCategories(c *gin.Context) { categories, err := h.categoryService.GetCategories(c.Request.Context(), parentID) if err != nil { h.logger.Error("Failed to get categories", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ - Error: "Failed to get categories", - Message: err.Error(), - }) + c.JSON(http.StatusInternalServerError, response.Error(http.StatusInternalServerError, err.Error())) return } @@ -69,13 +65,13 @@ func (h *CategoryHandler) GetCategories(c *gin.Context) { // @Accept json // @Produce json // @Success 200 {array} models.CategoryTree -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /categories/tree [get] func (h *CategoryHandler) GetCategoryTree(c *gin.Context) { tree, err := h.categoryService.GetCategoryTree(c.Request.Context()) if err != nil { h.logger.Error("Failed to get category tree", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get category tree", Message: err.Error(), }) @@ -93,15 +89,15 @@ func (h *CategoryHandler) GetCategoryTree(c *gin.Context) { // @Produce json // @Param id path int true "分类ID" // @Success 200 {object} models.Category -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /categories/{id} [get] func (h *CategoryHandler) GetCategory(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid category ID", Message: "Category ID must be a valid number", }) @@ -111,7 +107,7 @@ func (h *CategoryHandler) GetCategory(c *gin.Context) { category, err := h.categoryService.GetCategoryByID(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "category not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Category not found", Message: "The requested category does not exist", }) @@ -119,7 +115,7 @@ func (h *CategoryHandler) GetCategory(c *gin.Context) { } h.logger.Error("Failed to get category", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get category", Message: err.Error(), }) @@ -137,8 +133,8 @@ func (h *CategoryHandler) GetCategory(c *gin.Context) { // @Produce json // @Param slug path string true "分类slug" // @Success 200 {object} models.Category -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /categories/slug/{slug} [get] func (h *CategoryHandler) GetCategoryBySlug(c *gin.Context) { slug := c.Param("slug") @@ -146,7 +142,7 @@ func (h *CategoryHandler) GetCategoryBySlug(c *gin.Context) { category, err := h.categoryService.GetCategoryBySlug(c.Request.Context(), slug) if err != nil { if err.Error() == "category not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Category not found", Message: "The requested category does not exist", }) @@ -154,7 +150,7 @@ func (h *CategoryHandler) GetCategoryBySlug(c *gin.Context) { } h.logger.Error("Failed to get category by slug", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get category", Message: err.Error(), }) @@ -172,14 +168,14 @@ func (h *CategoryHandler) GetCategoryBySlug(c *gin.Context) { // @Produce json // @Param category body models.CreateCategoryRequest true "分类信息" // @Success 201 {object} models.Category -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /categories [post] func (h *CategoryHandler) CreateCategory(c *gin.Context) { - var req models.CreateCategoryRequest + var req entity.CreateCategoryRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -188,7 +184,7 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) { // 验证请求数据 if err := h.validateCreateCategoryRequest(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request data", Message: err.Error(), }) @@ -198,7 +194,7 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) { category, err := h.categoryService.CreateCategory(c.Request.Context(), &req) if err != nil { h.logger.Error("Failed to create category", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to create category", Message: err.Error(), }) @@ -217,25 +213,25 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) { // @Param id path int true "分类ID" // @Param category body models.UpdateCategoryRequest true "分类信息" // @Success 200 {object} models.Category -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /categories/{id} [put] func (h *CategoryHandler) UpdateCategory(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid category ID", Message: "Category ID must be a valid number", }) return } - var req models.UpdateCategoryRequest + var req entity.UpdateCategoryRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -245,7 +241,7 @@ func (h *CategoryHandler) UpdateCategory(c *gin.Context) { category, err := h.categoryService.UpdateCategory(c.Request.Context(), uint(id), &req) if err != nil { if err.Error() == "category not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Category not found", Message: "The requested category does not exist", }) @@ -253,7 +249,7 @@ func (h *CategoryHandler) UpdateCategory(c *gin.Context) { } h.logger.Error("Failed to update category", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to update category", Message: err.Error(), }) @@ -271,15 +267,15 @@ func (h *CategoryHandler) UpdateCategory(c *gin.Context) { // @Produce json // @Param id path int true "分类ID" // @Success 204 "No Content" -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /categories/{id} [delete] func (h *CategoryHandler) DeleteCategory(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid category ID", Message: "Category ID must be a valid number", }) @@ -289,7 +285,7 @@ func (h *CategoryHandler) DeleteCategory(c *gin.Context) { err = h.categoryService.DeleteCategory(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "category not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Category not found", Message: "The requested category does not exist", }) @@ -297,7 +293,7 @@ func (h *CategoryHandler) DeleteCategory(c *gin.Context) { } h.logger.Error("Failed to delete category", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to delete category", Message: err.Error(), }) @@ -315,14 +311,14 @@ func (h *CategoryHandler) DeleteCategory(c *gin.Context) { // @Produce json // @Param request body models.ReorderCategoriesRequest true "排序请求" // @Success 200 {object} models.SuccessResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /categories/reorder [post] func (h *CategoryHandler) ReorderCategories(c *gin.Context) { - var req models.ReorderCategoriesRequest + var req entity.ReorderCategoriesRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -330,7 +326,7 @@ func (h *CategoryHandler) ReorderCategories(c *gin.Context) { } if len(req.CategoryIDs) == 0 { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request", Message: "No category IDs provided", }) @@ -340,14 +336,14 @@ func (h *CategoryHandler) ReorderCategories(c *gin.Context) { err := h.categoryService.ReorderCategories(c.Request.Context(), req.ParentID, req.CategoryIDs) if err != nil { h.logger.Error("Failed to reorder categories", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to reorder categories", Message: err.Error(), }) return } - c.JSON(http.StatusOK, models.SuccessResponse{ + c.JSON(http.StatusOK, entity.SuccessResponse{ Message: "Categories reordered successfully", }) } @@ -359,13 +355,13 @@ func (h *CategoryHandler) ReorderCategories(c *gin.Context) { // @Accept json // @Produce json // @Success 200 {object} models.CategoryStats -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /categories/stats [get] func (h *CategoryHandler) GetCategoryStats(c *gin.Context) { stats, err := h.categoryService.GetCategoryStats(c.Request.Context()) if err != nil { h.logger.Error("Failed to get category stats", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get category stats", Message: err.Error(), }) @@ -383,14 +379,14 @@ func (h *CategoryHandler) GetCategoryStats(c *gin.Context) { // @Produce json // @Param request body models.GenerateSlugRequest true "生成slug请求" // @Success 200 {object} models.GenerateSlugResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /categories/generate-slug [post] func (h *CategoryHandler) GenerateSlug(c *gin.Context) { - var req models.GenerateSlugRequest + var req entity.GenerateSlugRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -398,7 +394,7 @@ func (h *CategoryHandler) GenerateSlug(c *gin.Context) { } if req.Name == "" { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request", Message: "Name is required", }) @@ -408,14 +404,14 @@ func (h *CategoryHandler) GenerateSlug(c *gin.Context) { slug, err := h.categoryService.GenerateSlug(c.Request.Context(), req.Name) if err != nil { h.logger.Error("Failed to generate slug", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to generate slug", Message: err.Error(), }) return } - c.JSON(http.StatusOK, models.GenerateSlugResponse{ + c.JSON(http.StatusOK, entity.GenerateSlugResponse{ Slug: slug, }) } diff --git a/backend/internal/api/handlers/photo_handler.go b/backend/internal/api/handlers/photo_handler.go index ccde570..5c64498 100644 --- a/backend/internal/api/handlers/photo_handler.go +++ b/backend/internal/api/handlers/photo_handler.go @@ -6,7 +6,8 @@ import ( "strconv" "strings" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/model/dto" "photography-backend/internal/service" "github.com/gin-gonic/gin" @@ -42,8 +43,8 @@ func NewPhotoHandler(photoService *service.PhotoService, logger *zap.Logger) *Ph // @Param sort_by query string false "排序字段" // @Param sort_order query string false "排序方向" // @Success 200 {object} service.PhotoListResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos [get] func (h *PhotoHandler) GetPhotos(c *gin.Context) { var params service.PhotoListParams @@ -51,7 +52,7 @@ func (h *PhotoHandler) GetPhotos(c *gin.Context) { // 解析查询参数 if err := c.ShouldBindQuery(¶ms); err != nil { h.logger.Error("Failed to bind query params", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid query parameters", Message: err.Error(), }) @@ -67,7 +68,7 @@ func (h *PhotoHandler) GetPhotos(c *gin.Context) { result, err := h.photoService.GetPhotos(c.Request.Context(), params) if err != nil { h.logger.Error("Failed to get photos", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get photos", Message: err.Error(), }) @@ -85,15 +86,15 @@ func (h *PhotoHandler) GetPhotos(c *gin.Context) { // @Produce json // @Param id path int true "照片ID" // @Success 200 {object} models.Photo -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos/{id} [get] func (h *PhotoHandler) GetPhoto(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid photo ID", Message: "Photo ID must be a valid number", }) @@ -103,7 +104,7 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) { photo, err := h.photoService.GetPhotoByID(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "photo not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Photo not found", Message: "The requested photo does not exist", }) @@ -111,7 +112,7 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) { } h.logger.Error("Failed to get photo", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get photo", Message: err.Error(), }) @@ -129,14 +130,14 @@ func (h *PhotoHandler) GetPhoto(c *gin.Context) { // @Produce json // @Param photo body models.CreatePhotoRequest true "照片信息" // @Success 201 {object} models.Photo -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos [post] func (h *PhotoHandler) CreatePhoto(c *gin.Context) { var req models.CreatePhotoRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -145,7 +146,7 @@ func (h *PhotoHandler) CreatePhoto(c *gin.Context) { // 验证请求数据 if err := h.validateCreatePhotoRequest(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request data", Message: err.Error(), }) @@ -155,7 +156,7 @@ func (h *PhotoHandler) CreatePhoto(c *gin.Context) { photo, err := h.photoService.CreatePhoto(c.Request.Context(), &req) if err != nil { h.logger.Error("Failed to create photo", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to create photo", Message: err.Error(), }) @@ -174,15 +175,15 @@ func (h *PhotoHandler) CreatePhoto(c *gin.Context) { // @Param id path int true "照片ID" // @Param photo body models.UpdatePhotoRequest true "照片信息" // @Success 200 {object} models.Photo -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos/{id} [put] func (h *PhotoHandler) UpdatePhoto(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid photo ID", Message: "Photo ID must be a valid number", }) @@ -192,7 +193,7 @@ func (h *PhotoHandler) UpdatePhoto(c *gin.Context) { var req models.UpdatePhotoRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -202,7 +203,7 @@ func (h *PhotoHandler) UpdatePhoto(c *gin.Context) { photo, err := h.photoService.UpdatePhoto(c.Request.Context(), uint(id), &req) if err != nil { if err.Error() == "photo not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Photo not found", Message: "The requested photo does not exist", }) @@ -210,7 +211,7 @@ func (h *PhotoHandler) UpdatePhoto(c *gin.Context) { } h.logger.Error("Failed to update photo", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to update photo", Message: err.Error(), }) @@ -228,15 +229,15 @@ func (h *PhotoHandler) UpdatePhoto(c *gin.Context) { // @Produce json // @Param id path int true "照片ID" // @Success 204 "No Content" -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos/{id} [delete] func (h *PhotoHandler) DeletePhoto(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid photo ID", Message: "Photo ID must be a valid number", }) @@ -246,7 +247,7 @@ func (h *PhotoHandler) DeletePhoto(c *gin.Context) { err = h.photoService.DeletePhoto(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "photo not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Photo not found", Message: "The requested photo does not exist", }) @@ -254,7 +255,7 @@ func (h *PhotoHandler) DeletePhoto(c *gin.Context) { } h.logger.Error("Failed to delete photo", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to delete photo", Message: err.Error(), }) @@ -277,14 +278,14 @@ func (h *PhotoHandler) DeletePhoto(c *gin.Context) { // @Param category_ids formData string false "分类ID列表(逗号分隔)" // @Param tag_ids formData string false "标签ID列表(逗号分隔)" // @Success 201 {object} models.Photo -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos/upload [post] func (h *PhotoHandler) UploadPhoto(c *gin.Context) { // 获取上传的文件 file, header, err := c.Request.FormFile("file") if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "No file uploaded", Message: "Please select a file to upload", }) @@ -328,7 +329,7 @@ func (h *PhotoHandler) UploadPhoto(c *gin.Context) { photo, err := h.photoService.UploadPhoto(c.Request.Context(), file, header, req) if err != nil { h.logger.Error("Failed to upload photo", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to upload photo", Message: err.Error(), }) @@ -346,14 +347,14 @@ func (h *PhotoHandler) UploadPhoto(c *gin.Context) { // @Produce json // @Param request body models.BatchUpdatePhotosRequest true "批量更新请求" // @Success 200 {object} models.SuccessResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos/batch/update [post] func (h *PhotoHandler) BatchUpdatePhotos(c *gin.Context) { var req models.BatchUpdatePhotosRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -361,7 +362,7 @@ func (h *PhotoHandler) BatchUpdatePhotos(c *gin.Context) { } if len(req.IDs) == 0 { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request", Message: "No photo IDs provided", }) @@ -371,7 +372,7 @@ func (h *PhotoHandler) BatchUpdatePhotos(c *gin.Context) { err := h.photoService.BatchUpdatePhotos(c.Request.Context(), req.IDs, &req) if err != nil { h.logger.Error("Failed to batch update photos", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to batch update photos", Message: err.Error(), }) @@ -391,14 +392,14 @@ func (h *PhotoHandler) BatchUpdatePhotos(c *gin.Context) { // @Produce json // @Param request body models.BatchDeleteRequest true "批量删除请求" // @Success 200 {object} models.SuccessResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /photos/batch/delete [post] func (h *PhotoHandler) BatchDeletePhotos(c *gin.Context) { var req models.BatchDeleteRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -406,7 +407,7 @@ func (h *PhotoHandler) BatchDeletePhotos(c *gin.Context) { } if len(req.IDs) == 0 { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request", Message: "No photo IDs provided", }) @@ -416,7 +417,7 @@ func (h *PhotoHandler) BatchDeletePhotos(c *gin.Context) { err := h.photoService.BatchDeletePhotos(c.Request.Context(), req.IDs) if err != nil { h.logger.Error("Failed to batch delete photos", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to batch delete photos", Message: err.Error(), }) @@ -435,13 +436,13 @@ func (h *PhotoHandler) BatchDeletePhotos(c *gin.Context) { // @Accept json // @Produce json // @Success 200 {object} models.PhotoStats -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /photos/stats [get] func (h *PhotoHandler) GetPhotoStats(c *gin.Context) { stats, err := h.photoService.GetPhotoStats(c.Request.Context()) if err != nil { h.logger.Error("Failed to get photo stats", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get photo stats", Message: err.Error(), }) diff --git a/backend/internal/api/handlers/tag_handler.go b/backend/internal/api/handlers/tag_handler.go index 3161170..58be4d4 100644 --- a/backend/internal/api/handlers/tag_handler.go +++ b/backend/internal/api/handlers/tag_handler.go @@ -5,7 +5,8 @@ import ( "net/http" "strconv" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/model/dto" "photography-backend/internal/service" "github.com/gin-gonic/gin" @@ -37,8 +38,8 @@ func NewTagHandler(tagService *service.TagService, logger *zap.Logger) *TagHandl // @Param sort_by query string false "排序字段" // @Param sort_order query string false "排序方向" // @Success 200 {object} service.TagListResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags [get] func (h *TagHandler) GetTags(c *gin.Context) { var params service.TagListParams @@ -46,7 +47,7 @@ func (h *TagHandler) GetTags(c *gin.Context) { // 解析查询参数 if err := c.ShouldBindQuery(¶ms); err != nil { h.logger.Error("Failed to bind query params", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid query parameters", Message: err.Error(), }) @@ -57,7 +58,7 @@ func (h *TagHandler) GetTags(c *gin.Context) { result, err := h.tagService.GetTags(c.Request.Context(), params) if err != nil { h.logger.Error("Failed to get tags", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get tags", Message: err.Error(), }) @@ -74,13 +75,13 @@ func (h *TagHandler) GetTags(c *gin.Context) { // @Accept json // @Produce json // @Success 200 {array} models.Tag -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /tags/all [get] func (h *TagHandler) GetAllTags(c *gin.Context) { tags, err := h.tagService.GetAllTags(c.Request.Context()) if err != nil { h.logger.Error("Failed to get all tags", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get all tags", Message: err.Error(), }) @@ -98,15 +99,15 @@ func (h *TagHandler) GetAllTags(c *gin.Context) { // @Produce json // @Param id path int true "标签ID" // @Success 200 {object} models.Tag -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags/{id} [get] func (h *TagHandler) GetTag(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid tag ID", Message: "Tag ID must be a valid number", }) @@ -116,7 +117,7 @@ func (h *TagHandler) GetTag(c *gin.Context) { tag, err := h.tagService.GetTagByID(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "tag not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Tag not found", Message: "The requested tag does not exist", }) @@ -124,7 +125,7 @@ func (h *TagHandler) GetTag(c *gin.Context) { } h.logger.Error("Failed to get tag", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get tag", Message: err.Error(), }) @@ -142,8 +143,8 @@ func (h *TagHandler) GetTag(c *gin.Context) { // @Produce json // @Param slug path string true "标签slug" // @Success 200 {object} models.Tag -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags/slug/{slug} [get] func (h *TagHandler) GetTagBySlug(c *gin.Context) { slug := c.Param("slug") @@ -151,7 +152,7 @@ func (h *TagHandler) GetTagBySlug(c *gin.Context) { tag, err := h.tagService.GetTagBySlug(c.Request.Context(), slug) if err != nil { if err.Error() == "tag not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Tag not found", Message: "The requested tag does not exist", }) @@ -159,7 +160,7 @@ func (h *TagHandler) GetTagBySlug(c *gin.Context) { } h.logger.Error("Failed to get tag by slug", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get tag", Message: err.Error(), }) @@ -177,14 +178,14 @@ func (h *TagHandler) GetTagBySlug(c *gin.Context) { // @Produce json // @Param tag body models.CreateTagRequest true "标签信息" // @Success 201 {object} models.Tag -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags [post] func (h *TagHandler) CreateTag(c *gin.Context) { var req models.CreateTagRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -193,7 +194,7 @@ func (h *TagHandler) CreateTag(c *gin.Context) { // 验证请求数据 if err := h.validateCreateTagRequest(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request data", Message: err.Error(), }) @@ -203,7 +204,7 @@ func (h *TagHandler) CreateTag(c *gin.Context) { tag, err := h.tagService.CreateTag(c.Request.Context(), &req) if err != nil { h.logger.Error("Failed to create tag", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to create tag", Message: err.Error(), }) @@ -222,15 +223,15 @@ func (h *TagHandler) CreateTag(c *gin.Context) { // @Param id path int true "标签ID" // @Param tag body models.UpdateTagRequest true "标签信息" // @Success 200 {object} models.Tag -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags/{id} [put] func (h *TagHandler) UpdateTag(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid tag ID", Message: "Tag ID must be a valid number", }) @@ -240,7 +241,7 @@ func (h *TagHandler) UpdateTag(c *gin.Context) { var req models.UpdateTagRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -250,7 +251,7 @@ func (h *TagHandler) UpdateTag(c *gin.Context) { tag, err := h.tagService.UpdateTag(c.Request.Context(), uint(id), &req) if err != nil { if err.Error() == "tag not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Tag not found", Message: "The requested tag does not exist", }) @@ -258,7 +259,7 @@ func (h *TagHandler) UpdateTag(c *gin.Context) { } h.logger.Error("Failed to update tag", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to update tag", Message: err.Error(), }) @@ -276,15 +277,15 @@ func (h *TagHandler) UpdateTag(c *gin.Context) { // @Produce json // @Param id path int true "标签ID" // @Success 204 "No Content" -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags/{id} [delete] func (h *TagHandler) DeleteTag(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid tag ID", Message: "Tag ID must be a valid number", }) @@ -294,7 +295,7 @@ func (h *TagHandler) DeleteTag(c *gin.Context) { err = h.tagService.DeleteTag(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "tag not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "Tag not found", Message: "The requested tag does not exist", }) @@ -302,7 +303,7 @@ func (h *TagHandler) DeleteTag(c *gin.Context) { } h.logger.Error("Failed to delete tag", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to delete tag", Message: err.Error(), }) @@ -320,14 +321,14 @@ func (h *TagHandler) DeleteTag(c *gin.Context) { // @Produce json // @Param request body models.BatchDeleteRequest true "批量删除请求" // @Success 200 {object} models.SuccessResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags/batch/delete [post] func (h *TagHandler) BatchDeleteTags(c *gin.Context) { var req models.BatchDeleteRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -335,7 +336,7 @@ func (h *TagHandler) BatchDeleteTags(c *gin.Context) { } if len(req.IDs) == 0 { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request", Message: "No tag IDs provided", }) @@ -345,7 +346,7 @@ func (h *TagHandler) BatchDeleteTags(c *gin.Context) { err := h.tagService.BatchDeleteTags(c.Request.Context(), req.IDs) if err != nil { h.logger.Error("Failed to batch delete tags", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to batch delete tags", Message: err.Error(), }) @@ -365,7 +366,7 @@ func (h *TagHandler) BatchDeleteTags(c *gin.Context) { // @Produce json // @Param limit query int false "限制数量" // @Success 200 {array} models.TagWithCount -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /tags/popular [get] func (h *TagHandler) GetPopularTags(c *gin.Context) { limit := 10 @@ -378,7 +379,7 @@ func (h *TagHandler) GetPopularTags(c *gin.Context) { tags, err := h.tagService.GetPopularTags(c.Request.Context(), limit) if err != nil { h.logger.Error("Failed to get popular tags", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get popular tags", Message: err.Error(), }) @@ -395,13 +396,13 @@ func (h *TagHandler) GetPopularTags(c *gin.Context) { // @Accept json // @Produce json // @Success 200 {array} models.TagCloudItem -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /tags/cloud [get] func (h *TagHandler) GetTagCloud(c *gin.Context) { cloud, err := h.tagService.GetTagCloud(c.Request.Context()) if err != nil { h.logger.Error("Failed to get tag cloud", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get tag cloud", Message: err.Error(), }) @@ -418,13 +419,13 @@ func (h *TagHandler) GetTagCloud(c *gin.Context) { // @Accept json // @Produce json // @Success 200 {object} models.TagStats -// @Failure 500 {object} models.ErrorResponse +// @Failure 500 {object} response.Error // @Router /tags/stats [get] func (h *TagHandler) GetTagStats(c *gin.Context) { stats, err := h.tagService.GetTagStats(c.Request.Context()) if err != nil { h.logger.Error("Failed to get tag stats", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get tag stats", Message: err.Error(), }) @@ -443,13 +444,13 @@ func (h *TagHandler) GetTagStats(c *gin.Context) { // @Param q query string true "搜索关键词" // @Param limit query int false "限制数量" // @Success 200 {array} models.Tag -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags/search [get] func (h *TagHandler) SearchTags(c *gin.Context) { query := c.Query("q") if query == "" { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid query", Message: "Search query is required", }) @@ -466,7 +467,7 @@ func (h *TagHandler) SearchTags(c *gin.Context) { tags, err := h.tagService.SearchTags(c.Request.Context(), query, limit) if err != nil { h.logger.Error("Failed to search tags", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to search tags", Message: err.Error(), }) @@ -484,14 +485,14 @@ func (h *TagHandler) SearchTags(c *gin.Context) { // @Produce json // @Param request body models.GenerateSlugRequest true "生成slug请求" // @Success 200 {object} models.GenerateSlugResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /tags/generate-slug [post] func (h *TagHandler) GenerateSlug(c *gin.Context) { var req models.GenerateSlugRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -499,7 +500,7 @@ func (h *TagHandler) GenerateSlug(c *gin.Context) { } if req.Name == "" { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request", Message: "Name is required", }) @@ -509,7 +510,7 @@ func (h *TagHandler) GenerateSlug(c *gin.Context) { slug, err := h.tagService.GenerateSlug(c.Request.Context(), req.Name) if err != nil { h.logger.Error("Failed to generate slug", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to generate slug", Message: err.Error(), }) diff --git a/backend/internal/api/handlers/user_handler.go b/backend/internal/api/handlers/user_handler.go index dd4b676..65dd131 100644 --- a/backend/internal/api/handlers/user_handler.go +++ b/backend/internal/api/handlers/user_handler.go @@ -5,7 +5,8 @@ import ( "net/http" "strconv" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/model/dto" "photography-backend/internal/service" "github.com/gin-gonic/gin" @@ -31,8 +32,8 @@ func NewUserHandler(userService *service.UserService, logger *zap.Logger) *UserH // @Accept json // @Produce json // @Success 200 {object} models.UserResponse -// @Failure 401 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 401 {object} response.Error +// @Failure 500 {object} response.Error // @Router /me [get] func (h *UserHandler) GetCurrentUser(c *gin.Context) { userID := c.GetUint("user_id") @@ -40,7 +41,7 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) { user, err := h.userService.GetUserByID(c.Request.Context(), userID) if err != nil { h.logger.Error("Failed to get current user", zap.Error(err), zap.Uint("user_id", userID)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get user information", Message: err.Error(), }) @@ -68,8 +69,8 @@ func (h *UserHandler) GetCurrentUser(c *gin.Context) { // @Produce json // @Param user body models.UpdateCurrentUserRequest true "用户信息" // @Success 200 {object} models.UserResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /me [put] func (h *UserHandler) UpdateCurrentUser(c *gin.Context) { userID := c.GetUint("user_id") @@ -77,7 +78,7 @@ func (h *UserHandler) UpdateCurrentUser(c *gin.Context) { var req models.UpdateCurrentUserRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -87,7 +88,7 @@ func (h *UserHandler) UpdateCurrentUser(c *gin.Context) { user, err := h.userService.UpdateCurrentUser(c.Request.Context(), userID, &req) if err != nil { h.logger.Error("Failed to update current user", zap.Error(err), zap.Uint("user_id", userID)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to update user information", Message: err.Error(), }) @@ -117,8 +118,8 @@ func (h *UserHandler) UpdateCurrentUser(c *gin.Context) { // @Param limit query int false "每页数量" // @Param search query string false "搜索关键词" // @Success 200 {object} service.UserListResponse -// @Failure 403 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 403 {object} response.Error +// @Failure 500 {object} response.Error // @Router /admin/users [get] func (h *UserHandler) GetUsers(c *gin.Context) { var params service.UserListParams @@ -126,7 +127,7 @@ func (h *UserHandler) GetUsers(c *gin.Context) { // 解析查询参数 if err := c.ShouldBindQuery(¶ms); err != nil { h.logger.Error("Failed to bind query params", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid query parameters", Message: err.Error(), }) @@ -136,7 +137,7 @@ func (h *UserHandler) GetUsers(c *gin.Context) { result, err := h.userService.GetUsers(c.Request.Context(), params) if err != nil { h.logger.Error("Failed to get users", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get users", Message: err.Error(), }) @@ -154,15 +155,15 @@ func (h *UserHandler) GetUsers(c *gin.Context) { // @Produce json // @Param id path int true "用户ID" // @Success 200 {object} models.UserResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /admin/users/{id} [get] func (h *UserHandler) GetUser(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid user ID", Message: "User ID must be a valid number", }) @@ -172,7 +173,7 @@ func (h *UserHandler) GetUser(c *gin.Context) { user, err := h.userService.GetUserByID(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "user not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "User not found", Message: "The requested user does not exist", }) @@ -180,7 +181,7 @@ func (h *UserHandler) GetUser(c *gin.Context) { } h.logger.Error("Failed to get user", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to get user", Message: err.Error(), }) @@ -208,14 +209,14 @@ func (h *UserHandler) GetUser(c *gin.Context) { // @Produce json // @Param user body models.CreateUserRequest true "用户信息" // @Success 201 {object} models.UserResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 500 {object} response.Error // @Router /admin/users [post] func (h *UserHandler) CreateUser(c *gin.Context) { var req models.CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -224,7 +225,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) { // 验证请求数据 if err := h.validateCreateUserRequest(&req); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request data", Message: err.Error(), }) @@ -234,7 +235,7 @@ func (h *UserHandler) CreateUser(c *gin.Context) { user, err := h.userService.CreateUser(c.Request.Context(), &req) if err != nil { h.logger.Error("Failed to create user", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to create user", Message: err.Error(), }) @@ -263,15 +264,15 @@ func (h *UserHandler) CreateUser(c *gin.Context) { // @Param id path int true "用户ID" // @Param user body models.UpdateUserRequest true "用户信息" // @Success 200 {object} models.UserResponse -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /admin/users/{id} [put] func (h *UserHandler) UpdateUser(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid user ID", Message: "User ID must be a valid number", }) @@ -281,7 +282,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { var req models.UpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind JSON", zap.Error(err)) - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid request body", Message: err.Error(), }) @@ -291,7 +292,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { user, err := h.userService.UpdateUser(c.Request.Context(), uint(id), &req) if err != nil { if err.Error() == "user not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "User not found", Message: "The requested user does not exist", }) @@ -299,7 +300,7 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { } h.logger.Error("Failed to update user", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to update user", Message: err.Error(), }) @@ -327,15 +328,15 @@ func (h *UserHandler) UpdateUser(c *gin.Context) { // @Produce json // @Param id path int true "用户ID" // @Success 204 "No Content" -// @Failure 400 {object} models.ErrorResponse -// @Failure 404 {object} models.ErrorResponse -// @Failure 500 {object} models.ErrorResponse +// @Failure 400 {object} response.Error +// @Failure 404 {object} response.Error +// @Failure 500 {object} response.Error // @Router /admin/users/{id} [delete] func (h *UserHandler) DeleteUser(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Invalid user ID", Message: "User ID must be a valid number", }) @@ -345,7 +346,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { // 防止删除自己 currentUserID := c.GetUint("user_id") if uint(id) == currentUserID { - c.JSON(http.StatusBadRequest, models.ErrorResponse{ + c.JSON(http.StatusBadRequest, response.Error{ Error: "Cannot delete yourself", Message: "You cannot delete your own account", }) @@ -355,7 +356,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { err = h.userService.DeleteUser(c.Request.Context(), uint(id)) if err != nil { if err.Error() == "user not found" { - c.JSON(http.StatusNotFound, models.ErrorResponse{ + c.JSON(http.StatusNotFound, response.Error{ Error: "User not found", Message: "The requested user does not exist", }) @@ -363,7 +364,7 @@ func (h *UserHandler) DeleteUser(c *gin.Context) { } h.logger.Error("Failed to delete user", zap.Error(err)) - c.JSON(http.StatusInternalServerError, models.ErrorResponse{ + c.JSON(http.StatusInternalServerError, response.Error{ Error: "Failed to delete user", Message: err.Error(), }) diff --git a/backend/internal/api/middleware/auth.go b/backend/internal/api/middleware/auth.go index 7746c01..7433a67 100644 --- a/backend/internal/api/middleware/auth.go +++ b/backend/internal/api/middleware/auth.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/gin-gonic/gin" "photography-backend/internal/service/auth" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" ) // AuthMiddleware 认证中间件 @@ -100,12 +100,12 @@ func (m *AuthMiddleware) RequireRole(requiredRole string) gin.HandlerFunc { // RequireAdmin 需要管理员权限的中间件 func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc { - return m.RequireRole(models.RoleAdmin) + return m.RequireRole(string(entity.UserRoleAdmin)) } -// RequireEditor 需要编辑者权限的中间件 -func (m *AuthMiddleware) RequireEditor() gin.HandlerFunc { - return m.RequireRole(models.RoleEditor) +// RequirePhotographer 需要摄影师权限的中间件 +func (m *AuthMiddleware) RequirePhotographer() gin.HandlerFunc { + return m.RequireRole(string(entity.UserRolePhotographer)) } // OptionalAuth 可选认证中间件 @@ -183,24 +183,24 @@ func IsAdmin(c *gin.Context) bool { if !exists { return false } - return role == models.RoleAdmin + return role == string(entity.UserRoleAdmin) } -// IsEditor 检查是否为编辑者或以上 -func IsEditor(c *gin.Context) bool { +// IsPhotographer 检查是否为摄影师或以上 +func IsPhotographer(c *gin.Context) bool { role, exists := GetCurrentUserRole(c) if !exists { return false } - return role == models.RoleEditor || role == models.RoleAdmin + return role == string(entity.UserRolePhotographer) || role == string(entity.UserRoleAdmin) } // hasPermission 检查权限 func (m *AuthMiddleware) hasPermission(userRole, requiredRole string) bool { roleLevel := map[string]int{ - models.RoleUser: 1, - models.RoleEditor: 2, - models.RoleAdmin: 3, + string(entity.UserRoleUser): 1, + string(entity.UserRolePhotographer): 2, + string(entity.UserRoleAdmin): 3, } userLevel, exists := roleLevel[userRole] diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..62e06b9 --- /dev/null +++ b/backend/internal/database/database.go @@ -0,0 +1,341 @@ +package database + +import ( + "fmt" + "log" + "time" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "photography-backend/internal/config" + "photography-backend/internal/model/entity" +) + +// Database 数据库连接管理器 +type Database struct { + db *gorm.DB + config *config.DatabaseConfig +} + +// New 创建新的数据库连接 +func New(cfg *config.Config) (*Database, error) { + var db *gorm.DB + var err error + + // 配置 GORM 日志 + gormConfig := &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } + + if cfg.App.Environment == "production" { + gormConfig.Logger = logger.Default.LogMode(logger.Error) + } + + // 根据环境选择数据库 + if cfg.App.Environment == "test" || cfg.Database.Host == "" { + // 使用 SQLite 进行测试或开发 + db, err = gorm.Open(sqlite.Open("photography_dev.db"), gormConfig) + } else { + // 使用 PostgreSQL 进行生产 + dsn := cfg.GetDatabaseDSN() + db, err = gorm.Open(postgres.Open(dsn), gormConfig) + } + + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // 配置连接池 + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + + // 设置连接池参数 + sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns) + sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.Database.ConnMaxLifetime) * time.Minute) + + // 测试连接 + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &Database{ + db: db, + config: &cfg.Database, + }, nil +} + +// GetDB 获取数据库连接 +func (d *Database) GetDB() *gorm.DB { + return d.db +} + +// Close 关闭数据库连接 +func (d *Database) Close() error { + sqlDB, err := d.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +// AutoMigrate 自动迁移数据库表 +func (d *Database) AutoMigrate() error { + // 按依赖关系顺序迁移表 + entities := []interface{}{ + &entity.User{}, + &entity.Category{}, + &entity.Tag{}, + &entity.Album{}, + &entity.Photo{}, + &entity.PhotoTag{}, + } + + for _, entity := range entities { + if err := d.db.AutoMigrate(entity); err != nil { + return fmt.Errorf("failed to migrate %T: %w", entity, err) + } + } + + log.Println("Database migration completed successfully") + return nil +} + +// Seed 填充种子数据 +func (d *Database) Seed() error { + // 检查是否已有数据 + var userCount int64 + if err := d.db.Model(&entity.User{}).Count(&userCount).Error; err != nil { + return fmt.Errorf("failed to count users: %w", err) + } + + if userCount > 0 { + log.Println("Database already has data, skipping seed") + return nil + } + + // 创建事务 + tx := d.db.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to begin transaction: %w", tx.Error) + } + defer tx.Rollback() + + // 创建默认用户 + if err := d.seedUsers(tx); err != nil { + return fmt.Errorf("failed to seed users: %w", err) + } + + // 创建默认分类 + if err := d.seedCategories(tx); err != nil { + return fmt.Errorf("failed to seed categories: %w", err) + } + + // 创建默认标签 + if err := d.seedTags(tx); err != nil { + return fmt.Errorf("failed to seed tags: %w", err) + } + + // 提交事务 + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + log.Println("Database seeding completed successfully") + return nil +} + +// seedUsers 创建默认用户 +func (d *Database) seedUsers(tx *gorm.DB) error { + users := []entity.User{ + { + Username: "admin", + Email: "admin@photography.com", + Password: "$2a$10$D4Zz6m3j1YJzp8Y7zW4l2OXcQ5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", // admin123 + Name: "管理员", + Role: entity.UserRoleAdmin, + IsActive: true, + IsPublic: true, + EmailVerified: true, + }, + { + Username: "photographer", + Email: "photographer@photography.com", + Password: "$2a$10$D4Zz6m3j1YJzp8Y7zW4l2OXcQ5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", // admin123 + Name: "摄影师", + Role: entity.UserRolePhotographer, + IsActive: true, + IsPublic: true, + EmailVerified: true, + }, + { + Username: "demo", + Email: "demo@photography.com", + Password: "$2a$10$D4Zz6m3j1YJzp8Y7zW4l2OXcQ5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0", // admin123 + Name: "演示用户", + Role: entity.UserRoleUser, + IsActive: true, + IsPublic: true, + EmailVerified: true, + }, + } + + for _, user := range users { + if err := tx.Create(&user).Error; err != nil { + return fmt.Errorf("failed to create user %s: %w", user.Username, err) + } + } + + return nil +} + +// seedCategories 创建默认分类 +func (d *Database) seedCategories(tx *gorm.DB) error { + categories := []entity.Category{ + { + Name: "风景摄影", + Description: "自然风景摄影作品", + Color: "#10b981", + Sort: 1, + IsActive: true, + }, + { + Name: "人像摄影", + Description: "人物肖像摄影作品", + Color: "#f59e0b", + Sort: 2, + IsActive: true, + }, + { + Name: "街头摄影", + Description: "街头纪实摄影作品", + Color: "#ef4444", + Sort: 3, + IsActive: true, + }, + { + Name: "建筑摄影", + Description: "建筑和城市摄影作品", + Color: "#3b82f6", + Sort: 4, + IsActive: true, + }, + { + Name: "抽象摄影", + Description: "抽象艺术摄影作品", + Color: "#8b5cf6", + Sort: 5, + IsActive: true, + }, + } + + for _, category := range categories { + if err := tx.Create(&category).Error; err != nil { + return fmt.Errorf("failed to create category %s: %w", category.Name, err) + } + } + + return nil +} + +// seedTags 创建默认标签 +func (d *Database) seedTags(tx *gorm.DB) error { + tags := []entity.Tag{ + {Name: "自然", Color: "#10b981"}, + {Name: "人物", Color: "#f59e0b"}, + {Name: "城市", Color: "#3b82f6"}, + {Name: "夜景", Color: "#1f2937"}, + {Name: "黑白", Color: "#6b7280"}, + {Name: "色彩", Color: "#ec4899"}, + {Name: "构图", Color: "#8b5cf6"}, + {Name: "光影", Color: "#f97316"}, + {Name: "街头", Color: "#ef4444"}, + {Name: "建筑", Color: "#0891b2"}, + {Name: "风景", Color: "#10b981"}, + {Name: "抽象", Color: "#8b5cf6"}, + {Name: "微距", Color: "#84cc16"}, + {Name: "运动", Color: "#f97316"}, + {Name: "动物", Color: "#8b5cf6"}, + } + + for _, tag := range tags { + if err := tx.Create(&tag).Error; err != nil { + return fmt.Errorf("failed to create tag %s: %w", tag.Name, err) + } + } + + return nil +} + +// HealthCheck 健康检查 +func (d *Database) HealthCheck() error { + sqlDB, err := d.db.DB() + if err != nil { + return fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + + if err := sqlDB.Ping(); err != nil { + return fmt.Errorf("database ping failed: %w", err) + } + + return nil +} + +// GetStats 获取数据库统计信息 +func (d *Database) GetStats() (map[string]interface{}, error) { + sqlDB, err := d.db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get underlying sql.DB: %w", err) + } + + stats := sqlDB.Stats() + + // 获取表记录数 + var userCount, photoCount, albumCount, categoryCount, tagCount int64 + + d.db.Model(&entity.User{}).Count(&userCount) + d.db.Model(&entity.Photo{}).Count(&photoCount) + d.db.Model(&entity.Album{}).Count(&albumCount) + d.db.Model(&entity.Category{}).Count(&categoryCount) + d.db.Model(&entity.Tag{}).Count(&tagCount) + + return map[string]interface{}{ + "connection_stats": map[string]interface{}{ + "max_open_connections": stats.MaxOpenConnections, + "open_connections": stats.OpenConnections, + "in_use": stats.InUse, + "idle": stats.Idle, + }, + "table_counts": map[string]interface{}{ + "users": userCount, + "photos": photoCount, + "albums": albumCount, + "categories": categoryCount, + "tags": tagCount, + }, + }, nil +} + +// Transaction 执行事务 +func (d *Database) Transaction(fn func(*gorm.DB) error) error { + tx := d.db.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to begin transaction: %w", tx.Error) + } + defer tx.Rollback() + + if err := fn(tx); err != nil { + return err + } + + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/backend/internal/model/dto/album_dto.go b/backend/internal/model/dto/album_dto.go new file mode 100644 index 0000000..9ec0626 --- /dev/null +++ b/backend/internal/model/dto/album_dto.go @@ -0,0 +1,196 @@ +package dto + +import ( + "time" + + "photography-backend/internal/model/entity" +) + +// CreateAlbumRequest 创建相册请求 +type CreateAlbumRequest struct { + Title string `json:"title" binding:"required,min=1,max=200" validate:"required,min=1,max=200"` + Description string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + Slug string `json:"slug" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"` + CategoryID *uint `json:"category_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + IsPublic bool `json:"is_public" binding:"omitempty"` + IsFeatured bool `json:"is_featured" binding:"omitempty"` + Password string `json:"password" binding:"omitempty,min=6" validate:"omitempty,min=6"` +} + +// UpdateAlbumRequest 更新相册请求 +type UpdateAlbumRequest struct { + Title *string `json:"title" binding:"omitempty,min=1,max=200" validate:"omitempty,min=1,max=200"` + Description *string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + Slug *string `json:"slug" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"` + CoverPhotoID *uint `json:"cover_photo_id" binding:"omitempty,min=0" validate:"omitempty,min=0"` + CategoryID *uint `json:"category_id" binding:"omitempty,min=0" validate:"omitempty,min=0"` + IsPublic *bool `json:"is_public" binding:"omitempty"` + IsFeatured *bool `json:"is_featured" binding:"omitempty"` + Password *string `json:"password" binding:"omitempty,min=0" validate:"omitempty,min=0"` // 空字符串表示移除密码 + SortOrder *int `json:"sort_order" binding:"omitempty,min=0" validate:"omitempty,min=0"` +} + +// AlbumResponse 相册响应 +type AlbumResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Slug string `json:"slug"` + CoverPhotoID *uint `json:"cover_photo_id"` + UserID uint `json:"user_id"` + CategoryID *uint `json:"category_id"` + IsPublic bool `json:"is_public"` + IsFeatured bool `json:"is_featured"` + HasPassword bool `json:"has_password"` + ViewCount int `json:"view_count"` + LikeCount int `json:"like_count"` + PhotoCount int `json:"photo_count"` + SortOrder int `json:"sort_order"` + User *UserResponse `json:"user,omitempty"` + Category *CategoryResponse `json:"category,omitempty"` + CoverPhoto *PhotoListItem `json:"cover_photo,omitempty"` + RecentPhotos []PhotoListItem `json:"recent_photos,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AlbumListItem 相册列表项(简化版) +type AlbumListItem struct { + ID uint `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + IsPublic bool `json:"is_public"` + IsFeatured bool `json:"is_featured"` + HasPassword bool `json:"has_password"` + ViewCount int `json:"view_count"` + LikeCount int `json:"like_count"` + PhotoCount int `json:"photo_count"` + CoverPhoto *PhotoListItem `json:"cover_photo,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListAlbumsOptions 相册列表查询选项 +type ListAlbumsOptions struct { + Page int `json:"page" form:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Limit int `json:"limit" form:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + Sort string `json:"sort" form:"sort" binding:"omitempty,oneof=id title created_at updated_at view_count like_count photo_count" validate:"omitempty,oneof=id title created_at updated_at view_count like_count photo_count"` + Order string `json:"order" form:"order" binding:"omitempty,oneof=asc desc" validate:"omitempty,oneof=asc desc"` + UserID *uint `json:"user_id" form:"user_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + CategoryID *uint `json:"category_id" form:"category_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + IsPublic *bool `json:"is_public" form:"is_public" binding:"omitempty"` + IsFeatured *bool `json:"is_featured" form:"is_featured" binding:"omitempty"` + Search string `json:"search" form:"search" binding:"omitempty,max=100" validate:"omitempty,max=100"` +} + +// AlbumListResponse 相册列表响应 +type AlbumListResponse struct { + Albums []AlbumListItem `json:"albums"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// AddPhotosToAlbumRequest 向相册添加照片请求 +type AddPhotosToAlbumRequest struct { + PhotoIDs []uint `json:"photo_ids" binding:"required,min=1" validate:"required,min=1"` +} + +// RemovePhotosFromAlbumRequest 从相册移除照片请求 +type RemovePhotosFromAlbumRequest struct { + PhotoIDs []uint `json:"photo_ids" binding:"required,min=1" validate:"required,min=1"` +} + +// AlbumPasswordRequest 相册密码验证请求 +type AlbumPasswordRequest struct { + Password string `json:"password" binding:"required" validate:"required"` +} + +// AlbumStatsResponse 相册统计响应 +type AlbumStatsResponse struct { + Total int64 `json:"total"` + Published int64 `json:"published"` + Private int64 `json:"private"` + Featured int64 `json:"featured"` + WithPassword int64 `json:"with_password"` + TotalViews int64 `json:"total_views"` + TotalLikes int64 `json:"total_likes"` + TotalPhotos int64 `json:"total_photos"` + CategoryCounts map[string]int64 `json:"category_counts"` + Recent []AlbumListItem `json:"recent"` + Popular []AlbumListItem `json:"popular"` +} + +// ConvertToAlbumResponse 将相册实体转换为响应DTO +func ConvertToAlbumResponse(album *entity.Album) *AlbumResponse { + if album == nil { + return nil + } + + response := &AlbumResponse{ + ID: album.ID, + Title: album.Title, + Description: album.Description, + Slug: album.Slug, + CoverPhotoID: album.CoverPhotoID, + UserID: album.UserID, + CategoryID: album.CategoryID, + IsPublic: album.IsPublic, + IsFeatured: album.IsFeatured, + HasPassword: album.HasPassword(), + ViewCount: album.ViewCount, + LikeCount: album.LikeCount, + PhotoCount: album.PhotoCount, + SortOrder: album.SortOrder, + CreatedAt: album.CreatedAt, + UpdatedAt: album.UpdatedAt, + } + + // 转换关联对象 + if album.User.ID != 0 { + response.User = ConvertToUserResponse(&album.User) + } + if album.Category != nil { + response.Category = ConvertToCategoryResponse(album.Category) + } + if album.CoverPhoto != nil { + coverPhoto := ConvertToPhotoListItem(album.CoverPhoto) + response.CoverPhoto = &coverPhoto + } + + // 转换最近照片 + if len(album.Photos) > 0 { + recentPhotos := make([]PhotoListItem, 0, len(album.Photos)) + for _, photo := range album.Photos { + recentPhotos = append(recentPhotos, ConvertToPhotoListItem(&photo)) + } + response.RecentPhotos = recentPhotos + } + + return response +} + +// ConvertToAlbumListItem 将相册实体转换为列表项DTO +func ConvertToAlbumListItem(album *entity.Album) AlbumListItem { + item := AlbumListItem{ + ID: album.ID, + Title: album.Title, + Slug: album.Slug, + IsPublic: album.IsPublic, + IsFeatured: album.IsFeatured, + HasPassword: album.HasPassword(), + ViewCount: album.ViewCount, + LikeCount: album.LikeCount, + PhotoCount: album.PhotoCount, + CreatedAt: album.CreatedAt, + UpdatedAt: album.UpdatedAt, + } + + // 转换封面照片 + if album.CoverPhoto != nil { + coverPhoto := ConvertToPhotoListItem(album.CoverPhoto) + item.CoverPhoto = &coverPhoto + } + + return item +} \ No newline at end of file diff --git a/backend/internal/model/dto/auth_dto.go b/backend/internal/model/dto/auth_dto.go new file mode 100644 index 0000000..c5cde7c --- /dev/null +++ b/backend/internal/model/dto/auth_dto.go @@ -0,0 +1,107 @@ +package dto + +import ( + "time" + + "photography-backend/internal/model/entity" +) + +// LoginRequest 登录请求 +type LoginRequest struct { + Email string `json:"email" binding:"required,email" validate:"required,email"` + Password string `json:"password" binding:"required" validate:"required"` +} + +// RegisterRequest 注册请求 +type RegisterRequest struct { + Username string `json:"username" binding:"required,min=3,max=50" validate:"required,min=3,max=50"` + Email string `json:"email" binding:"required,email" validate:"required,email"` + Password string `json:"password" binding:"required,min=6" validate:"required,min=6"` + Name string `json:"name" binding:"max=100" validate:"max=100"` +} + +// RefreshTokenRequest 刷新令牌请求 +type RefreshTokenRequest struct { + RefreshToken string `json:"refresh_token" binding:"required" validate:"required"` +} + +// TokenResponse 令牌响应 +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt time.Time `json:"expires_at"` +} + +// LoginResponse 登录响应 +type LoginResponse struct { + Token TokenResponse `json:"token"` + User UserResponse `json:"user"` +} + +// RegisterResponse 注册响应 +type RegisterResponse struct { + Token TokenResponse `json:"token"` + User UserResponse `json:"user"` +} + +// TokenClaims JWT 令牌声明 +type TokenClaims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + Role entity.UserRole `json:"role"` + TokenID string `json:"token_id"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ResetPasswordRequest 重置密码请求 +type ResetPasswordRequest struct { + Email string `json:"email" binding:"required,email" validate:"required,email"` +} + +// ConfirmResetPasswordRequest 确认重置密码请求 +type ConfirmResetPasswordRequest struct { + Token string `json:"token" binding:"required" validate:"required"` + NewPassword string `json:"new_password" binding:"required,min=6" validate:"required,min=6"` +} + +// VerifyEmailRequest 验证邮箱请求 +type VerifyEmailRequest struct { + Token string `json:"token" binding:"required" validate:"required"` +} + +// LogoutRequest 登出请求 +type LogoutRequest struct { + Token string `json:"token" binding:"required" validate:"required"` +} + +// AuthStatsResponse 认证统计响应 +type AuthStatsResponse struct { + TotalLogins int64 `json:"total_logins"` + ActiveSessions int64 `json:"active_sessions"` + FailedAttempts int64 `json:"failed_attempts"` + RecentLogins []LoginInfo `json:"recent_logins"` + LoginsByHour map[string]int64 `json:"logins_by_hour"` + LoginsByDevice map[string]int64 `json:"logins_by_device"` +} + +// LoginInfo 登录信息 +type LoginInfo struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Email string `json:"email"` + LoginTime time.Time `json:"login_time"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + Success bool `json:"success"` +} + +// ValidateTokenResponse 验证令牌响应 +type ValidateTokenResponse struct { + Valid bool `json:"valid"` + Claims *TokenClaims `json:"claims,omitempty"` + Error string `json:"error,omitempty"` +} \ No newline at end of file diff --git a/backend/internal/model/dto/category_dto.go b/backend/internal/model/dto/category_dto.go new file mode 100644 index 0000000..46f60e2 --- /dev/null +++ b/backend/internal/model/dto/category_dto.go @@ -0,0 +1,143 @@ +package dto + +import ( + "time" + + "photography-backend/internal/model/entity" +) + +// CreateCategoryRequest 创建分类请求 +type CreateCategoryRequest struct { + Name string `json:"name" binding:"required,min=1,max=100" validate:"required,min=1,max=100"` + Description string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + Slug string `json:"slug" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + ParentID *uint `json:"parent_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Color string `json:"color" binding:"omitempty,len=7" validate:"omitempty,len=7"` + CoverImage string `json:"cover_image" binding:"omitempty,url" validate:"omitempty,url"` + SortOrder int `json:"sort_order" binding:"omitempty,min=0" validate:"omitempty,min=0"` +} + +// UpdateCategoryRequest 更新分类请求 +type UpdateCategoryRequest struct { + Name *string `json:"name" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + Description *string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + Slug *string `json:"slug" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + ParentID *uint `json:"parent_id" binding:"omitempty,min=0" validate:"omitempty,min=0"` + Color *string `json:"color" binding:"omitempty,len=7" validate:"omitempty,len=7"` + CoverImage *string `json:"cover_image" binding:"omitempty,url" validate:"omitempty,url"` + SortOrder *int `json:"sort_order" binding:"omitempty,min=0" validate:"omitempty,min=0"` + IsActive *bool `json:"is_active" binding:"omitempty"` +} + +// CategoryResponse 分类响应 +type CategoryResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Slug string `json:"slug"` + ParentID *uint `json:"parent_id"` + Color string `json:"color"` + CoverImage string `json:"cover_image"` + Sort int `json:"sort"` + IsActive bool `json:"is_active"` + PhotoCount int64 `json:"photo_count"` + AlbumCount int64 `json:"album_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CategoryTreeResponse 分类树响应 +type CategoryTreeResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Slug string `json:"slug"` + ParentID *uint `json:"parent_id"` + Color string `json:"color"` + CoverImage string `json:"cover_image"` + Sort int `json:"sort"` + IsActive bool `json:"is_active"` + PhotoCount int64 `json:"photo_count"` + AlbumCount int64 `json:"album_count"` + Children []CategoryTreeResponse `json:"children"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListCategoriesOptions 分类列表查询选项 +type ListCategoriesOptions struct { + Page int `json:"page" form:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Limit int `json:"limit" form:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + Sort string `json:"sort" form:"sort" binding:"omitempty,oneof=id name sort created_at updated_at" validate:"omitempty,oneof=id name sort created_at updated_at"` + Order string `json:"order" form:"order" binding:"omitempty,oneof=asc desc" validate:"omitempty,oneof=asc desc"` + ParentID *uint `json:"parent_id" form:"parent_id" binding:"omitempty,min=0" validate:"omitempty,min=0"` + IsActive *bool `json:"is_active" form:"is_active" binding:"omitempty"` + Search string `json:"search" form:"search" binding:"omitempty,max=100" validate:"omitempty,max=100"` + WithCount bool `json:"with_count" form:"with_count" binding:"omitempty"` +} + +// CategoryListResponse 分类列表响应 +type CategoryListResponse struct { + Categories []CategoryResponse `json:"categories"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// ReorderCategoriesRequest 重新排序分类请求 +type ReorderCategoriesRequest struct { + ParentID *uint `json:"parent_id" binding:"omitempty,min=0" validate:"omitempty,min=0"` + CategoryIDs []uint `json:"category_ids" binding:"required,min=1" validate:"required,min=1"` +} + +// CategoryStatsResponse 分类统计响应 +type CategoryStatsResponse struct { + Total int64 `json:"total"` + Active int64 `json:"active"` + TopLevel int64 `json:"top_level"` + PhotoCounts map[string]int64 `json:"photo_counts"` + Popular []CategoryResponse `json:"popular"` +} + +// ConvertToCategoryResponse 将分类实体转换为响应DTO +func ConvertToCategoryResponse(category *entity.Category) *CategoryResponse { + if category == nil { + return nil + } + + return &CategoryResponse{ + ID: category.ID, + Name: category.Name, + Description: category.Description, + ParentID: category.ParentID, + Color: category.Color, + CoverImage: category.CoverImage, + Sort: category.Sort, + IsActive: category.IsActive, + PhotoCount: category.PhotoCount, + CreatedAt: category.CreatedAt, + UpdatedAt: category.UpdatedAt, + } +} + +// ConvertToCategoryTreeResponse 将分类树转换为响应DTO +func ConvertToCategoryTreeResponse(tree []entity.CategoryTree) []CategoryTreeResponse { + result := make([]CategoryTreeResponse, len(tree)) + for i, category := range tree { + result[i] = CategoryTreeResponse{ + ID: category.ID, + Name: category.Name, + Description: category.Description, + ParentID: category.ParentID, + Color: category.Color, + CoverImage: category.CoverImage, + Sort: category.Sort, + IsActive: category.IsActive, + PhotoCount: category.PhotoCount, + Children: ConvertToCategoryTreeResponse(category.Children), + CreatedAt: category.CreatedAt, + UpdatedAt: category.UpdatedAt, + } + } + return result +} \ No newline at end of file diff --git a/backend/internal/model/dto/photo_dto.go b/backend/internal/model/dto/photo_dto.go new file mode 100644 index 0000000..e00dee6 --- /dev/null +++ b/backend/internal/model/dto/photo_dto.go @@ -0,0 +1,264 @@ +package dto + +import ( + "mime/multipart" + "time" + + "photography-backend/internal/model/entity" +) + +// CreatePhotoRequest 创建照片请求 +type CreatePhotoRequest struct { + Title string `json:"title" binding:"required,min=1,max=200" validate:"required,min=1,max=200"` + Description string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + Filename string `json:"filename" binding:"required" validate:"required"` + OriginalURL string `json:"original_url" binding:"required,url" validate:"required,url"` + FileSize int64 `json:"file_size" binding:"omitempty,min=0" validate:"omitempty,min=0"` + MimeType string `json:"mime_type" binding:"omitempty" validate:"omitempty"` + Width int `json:"width" binding:"omitempty,min=0" validate:"omitempty,min=0"` + Height int `json:"height" binding:"omitempty,min=0" validate:"omitempty,min=0"` + UserID uint `json:"user_id" binding:"required,min=1" validate:"required,min=1"` + AlbumID *uint `json:"album_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + CategoryID *uint `json:"category_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + TagIDs []uint `json:"tag_ids" binding:"omitempty" validate:"omitempty"` + IsPublic bool `json:"is_public" binding:"omitempty"` + IsFeatured bool `json:"is_featured" binding:"omitempty"` +} + +// UpdatePhotoRequest 更新照片请求 +type UpdatePhotoRequest struct { + Title *string `json:"title" binding:"omitempty,min=1,max=200" validate:"omitempty,min=1,max=200"` + Description *string `json:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + AlbumID *uint `json:"album_id" binding:"omitempty,min=0" validate:"omitempty,min=0"` + CategoryID *uint `json:"category_id" binding:"omitempty,min=0" validate:"omitempty,min=0"` + TagIDs []uint `json:"tag_ids" binding:"omitempty" validate:"omitempty"` + IsPublic *bool `json:"is_public" binding:"omitempty"` + IsFeatured *bool `json:"is_featured" binding:"omitempty"` + LocationName *string `json:"location_name" binding:"omitempty,max=200" validate:"omitempty,max=200"` + Latitude *float64 `json:"latitude" binding:"omitempty,min=-90,max=90" validate:"omitempty,min=-90,max=90"` + Longitude *float64 `json:"longitude" binding:"omitempty,min=-180,max=180" validate:"omitempty,min=-180,max=180"` +} + +// UploadPhotoRequest 上传照片请求 +type UploadPhotoRequest struct { + File *multipart.FileHeader `form:"photo" binding:"required" validate:"required"` + Title string `form:"title" binding:"omitempty,max=200" validate:"omitempty,max=200"` + Description string `form:"description" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + AlbumID *uint `form:"album_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + CategoryID *uint `form:"category_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + TagNames []string `form:"tag_names" binding:"omitempty" validate:"omitempty"` + IsPublic bool `form:"is_public" binding:"omitempty"` + IsFeatured bool `form:"is_featured" binding:"omitempty"` +} + +// PhotoResponse 照片响应 +type PhotoResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Filename string `json:"filename"` + OriginalURL string `json:"original_url"` + ThumbnailURL string `json:"thumbnail_url"` + MediumURL string `json:"medium_url"` + LargeURL string `json:"large_url"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Width int `json:"width"` + Height int `json:"height"` + AspectRatio float64 `json:"aspect_ratio"` + + // EXIF 信息 + CameraMake string `json:"camera_make"` + CameraModel string `json:"camera_model"` + LensModel string `json:"lens_model"` + FocalLength *float64 `json:"focal_length"` + Aperture *float64 `json:"aperture"` + ShutterSpeed string `json:"shutter_speed"` + ISO *int `json:"iso"` + TakenAt *time.Time `json:"taken_at"` + + // 地理位置 + LocationName string `json:"location_name"` + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + + // 关联信息 + UserID uint `json:"user_id"` + AlbumID *uint `json:"album_id"` + CategoryID *uint `json:"category_id"` + User *UserResponse `json:"user,omitempty"` + Album *AlbumResponse `json:"album,omitempty"` + Category *CategoryResponse `json:"category,omitempty"` + Tags []TagResponse `json:"tags,omitempty"` + + // 状态和统计 + IsPublic bool `json:"is_public"` + IsFeatured bool `json:"is_featured"` + ViewCount int `json:"view_count"` + LikeCount int `json:"like_count"` + DownloadCount int `json:"download_count"` + SortOrder int `json:"sort_order"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PhotoListItem 照片列表项(简化版) +type PhotoListItem struct { + ID uint `json:"id"` + Title string `json:"title"` + ThumbnailURL string `json:"thumbnail_url"` + Width int `json:"width"` + Height int `json:"height"` + AspectRatio float64 `json:"aspect_ratio"` + IsPublic bool `json:"is_public"` + IsFeatured bool `json:"is_featured"` + ViewCount int `json:"view_count"` + LikeCount int `json:"like_count"` + CreatedAt time.Time `json:"created_at"` +} + +// ListPhotosOptions 照片列表查询选项 +type ListPhotosOptions struct { + Page int `json:"page" form:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Limit int `json:"limit" form:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + Sort string `json:"sort" form:"sort" binding:"omitempty,oneof=id title created_at updated_at taken_at view_count like_count" validate:"omitempty,oneof=id title created_at updated_at taken_at view_count like_count"` + Order string `json:"order" form:"order" binding:"omitempty,oneof=asc desc" validate:"omitempty,oneof=asc desc"` + UserID *uint `json:"user_id" form:"user_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + AlbumID *uint `json:"album_id" form:"album_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + CategoryID *uint `json:"category_id" form:"category_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + TagIDs []uint `json:"tag_ids" form:"tag_ids" binding:"omitempty" validate:"omitempty"` + IsPublic *bool `json:"is_public" form:"is_public" binding:"omitempty"` + IsFeatured *bool `json:"is_featured" form:"is_featured" binding:"omitempty"` + Search string `json:"search" form:"search" binding:"omitempty,max=100" validate:"omitempty,max=100"` + Year *int `json:"year" form:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"` + Month *int `json:"month" form:"month" binding:"omitempty,min=1,max=12" validate:"omitempty,min=1,max=12"` +} + +// SearchPhotosOptions 照片搜索选项 +type SearchPhotosOptions struct { + Query string `json:"query" form:"query" binding:"required,min=1" validate:"required,min=1"` + Page int `json:"page" form:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Limit int `json:"limit" form:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + Sort string `json:"sort" form:"sort" binding:"omitempty,oneof=relevance created_at view_count like_count" validate:"omitempty,oneof=relevance created_at view_count like_count"` + Order string `json:"order" form:"order" binding:"omitempty,oneof=asc desc" validate:"omitempty,oneof=asc desc"` + CategoryID *uint `json:"category_id" form:"category_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + TagIDs []uint `json:"tag_ids" form:"tag_ids" binding:"omitempty" validate:"omitempty"` + UserID *uint `json:"user_id" form:"user_id" binding:"omitempty,min=1" validate:"omitempty,min=1"` + IsPublic *bool `json:"is_public" form:"is_public" binding:"omitempty"` +} + +// PhotoListResponse 照片列表响应 +type PhotoListResponse struct { + Photos []PhotoListItem `json:"photos"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// ProcessPhotoOptions 照片处理选项 +type ProcessPhotoOptions struct { + GenerateThumbnails bool `json:"generate_thumbnails"` + ThumbnailSizes []string `json:"thumbnail_sizes"` + ExtractEXIF bool `json:"extract_exif"` + GenerateHash bool `json:"generate_hash"` + OptimizeSize bool `json:"optimize_size"` + WatermarkEnabled bool `json:"watermark_enabled"` +} + +// PhotoStatsResponse 照片统计响应 +type PhotoStatsResponse struct { + Total int64 `json:"total"` + Published int64 `json:"published"` + Private int64 `json:"private"` + Featured int64 `json:"featured"` + TotalViews int64 `json:"total_views"` + TotalLikes int64 `json:"total_likes"` + TotalDownloads int64 `json:"total_downloads"` + FileSize int64 `json:"file_size"` + CategoryCounts map[string]int64 `json:"category_counts"` + TagCounts map[string]int64 `json:"tag_counts"` + Recent []PhotoListItem `json:"recent"` + Popular []PhotoListItem `json:"popular"` +} + +// ConvertToPhotoResponse 将照片实体转换为响应DTO +func ConvertToPhotoResponse(photo *entity.Photo) *PhotoResponse { + if photo == nil { + return nil + } + + response := &PhotoResponse{ + ID: photo.ID, + Title: photo.Title, + Description: photo.Description, + Filename: photo.Filename, + OriginalURL: photo.OriginalURL, + ThumbnailURL: photo.ThumbnailURL, + MediumURL: photo.MediumURL, + LargeURL: photo.LargeURL, + FileSize: photo.FileSize, + MimeType: photo.MimeType, + Width: photo.Width, + Height: photo.Height, + AspectRatio: photo.GetAspectRatio(), + + // EXIF + CameraMake: photo.CameraMake, + CameraModel: photo.CameraModel, + LensModel: photo.LensModel, + FocalLength: photo.FocalLength, + Aperture: photo.Aperture, + ShutterSpeed: photo.ShutterSpeed, + ISO: photo.ISO, + TakenAt: photo.TakenAt, + + // 地理位置 + LocationName: photo.LocationName, + Latitude: photo.Latitude, + Longitude: photo.Longitude, + + // 关联 + UserID: photo.UserID, + AlbumID: photo.AlbumID, + CategoryID: photo.CategoryID, + + // 状态 + IsPublic: photo.IsPublic, + IsFeatured: photo.IsFeatured, + ViewCount: photo.ViewCount, + LikeCount: photo.LikeCount, + DownloadCount: photo.DownloadCount, + SortOrder: photo.SortOrder, + + CreatedAt: photo.CreatedAt, + UpdatedAt: photo.UpdatedAt, + } + + // 转换关联对象 + if photo.User.ID != 0 { + response.User = ConvertToUserResponse(&photo.User) + } + if photo.Category != nil { + response.Category = ConvertToCategoryResponse(photo.Category) + } + + return response +} + +// ConvertToPhotoListItem 将照片实体转换为列表项DTO +func ConvertToPhotoListItem(photo *entity.Photo) PhotoListItem { + return PhotoListItem{ + ID: photo.ID, + Title: photo.Title, + ThumbnailURL: photo.ThumbnailURL, + Width: photo.Width, + Height: photo.Height, + AspectRatio: photo.GetAspectRatio(), + IsPublic: photo.IsPublic, + IsFeatured: photo.IsFeatured, + ViewCount: photo.ViewCount, + LikeCount: photo.LikeCount, + CreatedAt: photo.CreatedAt, + } +} \ No newline at end of file diff --git a/backend/internal/model/dto/tag_dto.go b/backend/internal/model/dto/tag_dto.go new file mode 100644 index 0000000..f9c1a07 --- /dev/null +++ b/backend/internal/model/dto/tag_dto.go @@ -0,0 +1,135 @@ +package dto + +import ( + "time" + + "photography-backend/internal/model/entity" +) + +// CreateTagRequest 创建标签请求 +type CreateTagRequest struct { + Name string `json:"name" binding:"required,min=1,max=50" validate:"required,min=1,max=50"` + Color string `json:"color" binding:"omitempty,len=7" validate:"omitempty,len=7"` +} + +// UpdateTagRequest 更新标签请求 +type UpdateTagRequest struct { + Name *string `json:"name" binding:"omitempty,min=1,max=50" validate:"omitempty,min=1,max=50"` + Color *string `json:"color" binding:"omitempty,len=7" validate:"omitempty,len=7"` + IsActive *bool `json:"is_active" binding:"omitempty"` +} + +// TagResponse 标签响应 +type TagResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + UseCount int `json:"use_count"` + IsActive bool `json:"is_active"` + IsPopular bool `json:"is_popular"` + PhotoCount int64 `json:"photo_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TagListItem 标签列表项(简化版) +type TagListItem struct { + ID uint `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + UseCount int `json:"use_count"` + IsActive bool `json:"is_active"` + IsPopular bool `json:"is_popular"` +} + +// ListTagsOptions 标签列表查询选项 +type ListTagsOptions struct { + Page int `json:"page" form:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Limit int `json:"limit" form:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + Sort string `json:"sort" form:"sort" binding:"omitempty,oneof=id name use_count created_at updated_at" validate:"omitempty,oneof=id name use_count created_at updated_at"` + Order string `json:"order" form:"order" binding:"omitempty,oneof=asc desc" validate:"omitempty,oneof=asc desc"` + IsActive *bool `json:"is_active" form:"is_active" binding:"omitempty"` + Search string `json:"search" form:"search" binding:"omitempty,max=100" validate:"omitempty,max=100"` + Popular bool `json:"popular" form:"popular" binding:"omitempty"` +} + +// TagListResponse 标签列表响应 +type TagListResponse struct { + Tags []TagResponse `json:"tags"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// TagCloudResponse 标签云响应 +type TagCloudResponse struct { + Tags []TagCloudItem `json:"tags"` +} + +// TagCloudItem 标签云项 +type TagCloudItem struct { + ID uint `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + UseCount int `json:"use_count"` + Weight int `json:"weight"` // 1-10 的权重,用于控制标签大小 +} + +// TagStatsResponse 标签统计响应 +type TagStatsResponse struct { + Total int64 `json:"total"` + Active int64 `json:"active"` + Popular []TagResponse `json:"popular"` + PhotoCounts map[string]int64 `json:"photo_counts"` + Recent []TagResponse `json:"recent"` +} + +// ConvertToTagResponse 将标签实体转换为响应DTO +func ConvertToTagResponse(tag *entity.Tag) *TagResponse { + if tag == nil { + return nil + } + + return &TagResponse{ + ID: tag.ID, + Name: tag.Name, + Color: tag.Color, + UseCount: tag.UseCount, + IsActive: tag.IsActive, + IsPopular: tag.IsPopular(), + CreatedAt: tag.CreatedAt, + UpdatedAt: tag.UpdatedAt, + } +} + +// ConvertToTagListItem 将标签实体转换为列表项DTO +func ConvertToTagListItem(tag *entity.Tag) TagListItem { + return TagListItem{ + ID: tag.ID, + Name: tag.Name, + Color: tag.Color, + UseCount: tag.UseCount, + IsActive: tag.IsActive, + IsPopular: tag.IsPopular(), + } +} + +// ConvertToTagCloudItem 将标签实体转换为标签云项 +func ConvertToTagCloudItem(tag *entity.Tag, maxUseCount int) TagCloudItem { + // 计算权重(1-10) + weight := 1 + if maxUseCount > 0 { + weight = int(float64(tag.UseCount)/float64(maxUseCount)*9) + 1 + if weight > 10 { + weight = 10 + } + } + + return TagCloudItem{ + ID: tag.ID, + Name: tag.Name, + Color: tag.Color, + UseCount: tag.UseCount, + Weight: weight, + } +} \ No newline at end of file diff --git a/backend/internal/model/dto/user_dto.go b/backend/internal/model/dto/user_dto.go new file mode 100644 index 0000000..6bc2522 --- /dev/null +++ b/backend/internal/model/dto/user_dto.go @@ -0,0 +1,148 @@ +package dto + +import ( + "time" + + "photography-backend/internal/model/entity" +) + +// CreateUserRequest 创建用户请求 +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=50" validate:"required,min=3,max=50"` + Email string `json:"email" binding:"required,email" validate:"required,email"` + Password string `json:"password" binding:"required,min=6" validate:"required,min=6"` + Name string `json:"name" binding:"max=100" validate:"max=100"` + Role entity.UserRole `json:"role" binding:"omitempty,oneof=user admin photographer" validate:"omitempty,oneof=user admin photographer"` +} + +// UpdateUserRequest 更新用户请求 +type UpdateUserRequest struct { + Username *string `json:"username" binding:"omitempty,min=3,max=50" validate:"omitempty,min=3,max=50"` + Email *string `json:"email" binding:"omitempty,email" validate:"omitempty,email"` + Name *string `json:"name" binding:"omitempty,max=100" validate:"omitempty,max=100"` + Avatar *string `json:"avatar" binding:"omitempty,url" validate:"omitempty,url"` + Bio *string `json:"bio" binding:"omitempty,max=1000" validate:"omitempty,max=1000"` + Website *string `json:"website" binding:"omitempty,url" validate:"omitempty,url"` + Location *string `json:"location" binding:"omitempty,max=100" validate:"omitempty,max=100"` + IsActive *bool `json:"is_active" binding:"omitempty"` + IsPublic *bool `json:"is_public" binding:"omitempty"` +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required" validate:"required"` + NewPassword string `json:"new_password" binding:"required,min=6" validate:"required,min=6"` +} + +// UserResponse 用户响应 +type UserResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Bio string `json:"bio"` + Website string `json:"website"` + Location string `json:"location"` + Role entity.UserRole `json:"role"` + IsActive bool `json:"is_active"` + IsPublic bool `json:"is_public"` + EmailVerified bool `json:"email_verified"` + LastLogin *time.Time `json:"last_login"` + LoginCount int `json:"login_count"` + PhotoCount int64 `json:"photo_count"` + AlbumCount int64 `json:"album_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UserProfileResponse 用户档案响应(公开信息) +type UserProfileResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Avatar string `json:"avatar"` + Bio string `json:"bio"` + Website string `json:"website"` + Location string `json:"location"` + Role entity.UserRole `json:"role"` + PhotoCount int64 `json:"photo_count"` + AlbumCount int64 `json:"album_count"` + CreatedAt time.Time `json:"created_at"` +} + +// ListUsersOptions 用户列表查询选项 +type ListUsersOptions struct { + Page int `json:"page" form:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Limit int `json:"limit" form:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` + Sort string `json:"sort" form:"sort" binding:"omitempty,oneof=id username email created_at updated_at" validate:"omitempty,oneof=id username email created_at updated_at"` + Order string `json:"order" form:"order" binding:"omitempty,oneof=asc desc" validate:"omitempty,oneof=asc desc"` + Role entity.UserRole `json:"role" form:"role" binding:"omitempty,oneof=user admin photographer" validate:"omitempty,oneof=user admin photographer"` + IsActive *bool `json:"is_active" form:"is_active" binding:"omitempty"` + IsPublic *bool `json:"is_public" form:"is_public" binding:"omitempty"` + Search string `json:"search" form:"search" binding:"omitempty,max=100" validate:"omitempty,max=100"` +} + +// UserListResponse 用户列表响应 +type UserListResponse struct { + Users []UserResponse `json:"users"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// UserStatsResponse 用户统计响应 +type UserStatsResponse struct { + Total int64 `json:"total"` + Active int64 `json:"active"` + Inactive int64 `json:"inactive"` + Verified int64 `json:"verified"` + Unverified int64 `json:"unverified"` + RoleCounts map[entity.UserRole]int64 `json:"role_counts"` + RecentLogins []UserResponse `json:"recent_logins"` +} + +// ConvertToUserResponse 将用户实体转换为响应DTO +func ConvertToUserResponse(user *entity.User) *UserResponse { + if user == nil { + return nil + } + + return &UserResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + Name: user.Name, + Avatar: user.Avatar, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + Role: user.Role, + IsActive: user.IsActive, + IsPublic: user.IsPublic, + EmailVerified: user.EmailVerified, + LastLogin: user.LastLogin, + LoginCount: user.LoginCount, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} + +// ConvertToUserProfile 将用户实体转换为公开档案DTO +func ConvertToUserProfile(user *entity.User) *UserProfileResponse { + if user == nil { + return nil + } + + return &UserProfileResponse{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + Avatar: user.Avatar, + Bio: user.Bio, + Website: user.Website, + Location: user.Location, + Role: user.Role, + CreatedAt: user.CreatedAt, + } +} \ No newline at end of file diff --git a/backend/internal/model/entity/album.go b/backend/internal/model/entity/album.go new file mode 100644 index 0000000..58a61dd --- /dev/null +++ b/backend/internal/model/entity/album.go @@ -0,0 +1,84 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +// Album 相册实体 +type Album struct { + ID uint `json:"id" gorm:"primarykey"` + Title string `json:"title" gorm:"not null;size:200"` + Description string `json:"description" gorm:"type:text"` + Slug string `json:"slug" gorm:"uniqueIndex;size:255"` + CoverPhotoID *uint `json:"cover_photo_id" gorm:"index"` + UserID uint `json:"user_id" gorm:"not null;index"` + CategoryID *uint `json:"category_id" gorm:"index"` + IsPublic bool `json:"is_public" gorm:"default:true;index"` + IsFeatured bool `json:"is_featured" gorm:"default:false;index"` + Password string `json:"-" gorm:"size:255"` // 私密相册密码 + ViewCount int `json:"view_count" gorm:"default:0;index"` + LikeCount int `json:"like_count" gorm:"default:0;index"` + PhotoCount int `json:"photo_count" gorm:"default:0;index"` + SortOrder int `json:"sort_order" gorm:"default:0;index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` + + // 关联 + User User `json:"user,omitempty" gorm:"foreignKey:UserID"` + Category *Category `json:"category,omitempty" gorm:"foreignKey:CategoryID"` + CoverPhoto *Photo `json:"cover_photo,omitempty" gorm:"foreignKey:CoverPhotoID"` + Photos []Photo `json:"photos,omitempty" gorm:"foreignKey:AlbumID"` +} + +// AlbumStats 相册统计信息 +type AlbumStats struct { + Total int64 `json:"total"` // 总相册数 + Published int64 `json:"published"` // 已发布相册数 + Private int64 `json:"private"` // 私有相册数 + Featured int64 `json:"featured"` // 精选相册数 + TotalViews int64 `json:"total_views"` // 总浏览量 + TotalLikes int64 `json:"total_likes"` // 总点赞数 + TotalPhotos int64 `json:"total_photos"` // 总照片数 + CategoryCounts map[string]int64 `json:"category_counts"` // 各分类相册数量 +} + +// TableName 指定表名 +func (Album) TableName() string { + return "albums" +} + +// HasPassword 检查是否设置了密码 +func (a *Album) HasPassword() bool { + return a.Password != "" +} + +// IsEmpty 检查相册是否为空 +func (a *Album) IsEmpty() bool { + return a.PhotoCount == 0 +} + +// CanViewBy 检查指定用户是否可以查看相册 +func (a *Album) CanViewBy(user *User) bool { + // 公开相册 + if a.IsPublic && !a.HasPassword() { + return true + } + + // 相册所有者或管理员 + if user != nil && (user.ID == a.UserID || user.IsAdmin()) { + return true + } + + return false +} + +// CanEditBy 检查指定用户是否可以编辑相册 +func (a *Album) CanEditBy(user *User) bool { + if user == nil { + return false + } + return user.ID == a.UserID || user.IsAdmin() +} \ No newline at end of file diff --git a/backend/internal/model/entity/category.go b/backend/internal/model/entity/category.go new file mode 100644 index 0000000..b4613c2 --- /dev/null +++ b/backend/internal/model/entity/category.go @@ -0,0 +1,131 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +// Category 分类实体 +type Category struct { + ID uint `json:"id" gorm:"primarykey"` + Name string `json:"name" gorm:"not null;size:100"` + Slug string `json:"slug" gorm:"uniqueIndex;not null;size:100"` + Description string `json:"description" gorm:"type:text"` + ParentID *uint `json:"parent_id" gorm:"index"` + Color string `json:"color" gorm:"default:#3b82f6;size:7"` + CoverImage string `json:"cover_image" gorm:"size:500"` + Sort int `json:"sort" gorm:"default:0;index"` + SortOrder int `json:"sort_order" gorm:"default:0;index"` + IsActive bool `json:"is_active" gorm:"default:true;index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` + + // 关联 + Parent *Category `json:"parent,omitempty" gorm:"foreignKey:ParentID"` + Children []Category `json:"children,omitempty" gorm:"foreignKey:ParentID"` + Photos []Photo `json:"photos,omitempty" gorm:"foreignKey:CategoryID"` + Albums []Album `json:"albums,omitempty" gorm:"foreignKey:CategoryID"` + PhotoCount int64 `json:"photo_count" gorm:"-"` // 照片数量,不存储在数据库中 +} + +// CategoryTree 分类树结构(用于前端显示) +type CategoryTree struct { + ID uint `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Description string `json:"description"` + ParentID *uint `json:"parent_id"` + Color string `json:"color"` + CoverImage string `json:"cover_image"` + Sort int `json:"sort"` + SortOrder int `json:"sort_order"` + IsActive bool `json:"is_active"` + PhotoCount int64 `json:"photo_count"` + Children []CategoryTree `json:"children"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CategoryStats 分类统计信息 +type CategoryStats struct { + Total int64 `json:"total"` // 总分类数 + Active int64 `json:"active"` // 活跃分类数 + TopLevel int64 `json:"top_level"` // 顶级分类数 + TotalCategories int64 `json:"total_categories"` // 总分类数(别名) + MaxLevel int64 `json:"max_level"` // 最大层级 + FeaturedCount int64 `json:"featured_count"` // 特色分类数 + PhotoCounts map[string]int64 `json:"photo_counts"` // 各分类照片数量 +} + +// CategoryListParams 分类列表查询参数 +type CategoryListParams struct { + Page int `json:"page" form:"page"` + Limit int `json:"limit" form:"limit"` + Search string `json:"search" form:"search"` + ParentID *uint `json:"parent_id" form:"parent_id"` + IsActive *bool `json:"is_active" form:"is_active"` + IncludeStats bool `json:"include_stats" form:"include_stats"` + SortBy string `json:"sort_by" form:"sort_by"` + Order string `json:"order" form:"order"` +} + +// CreateCategoryRequest 创建分类请求 +type CreateCategoryRequest struct { + Name string `json:"name" binding:"required,max=100"` + Slug string `json:"slug" binding:"required,max=100"` + Description string `json:"description" binding:"max=500"` + ParentID *uint `json:"parent_id"` + Color string `json:"color" binding:"max=7"` + CoverImage string `json:"cover_image" binding:"max=500"` + Sort int `json:"sort"` +} + +// UpdateCategoryRequest 更新分类请求 +type UpdateCategoryRequest struct { + Name *string `json:"name" binding:"omitempty,max=100"` + Slug *string `json:"slug" binding:"omitempty,max=100"` + Description *string `json:"description" binding:"max=500"` + ParentID *uint `json:"parent_id"` + Color *string `json:"color" binding:"omitempty,max=7"` + CoverImage *string `json:"cover_image" binding:"omitempty,max=500"` + SortOrder *int `json:"sort_order"` + IsActive *bool `json:"is_active"` +} + +// ReorderCategoriesRequest 重新排序分类请求 +type ReorderCategoriesRequest struct { + ParentID *uint `json:"parent_id"` + CategoryIDs []uint `json:"category_ids" binding:"required,min=1"` +} + +// GenerateSlugRequest 生成slug请求 +type GenerateSlugRequest struct { + Name string `json:"name" binding:"required,max=100"` +} + +// GenerateSlugResponse 生成slug响应 +type GenerateSlugResponse struct { + Slug string `json:"slug"` +} + +// SuccessResponse 成功响应 +type SuccessResponse struct { + Message string `json:"message"` +} + +// TableName 指定表名 +func (Category) TableName() string { + return "categories" +} + +// IsTopLevel 检查是否为顶级分类 +func (c *Category) IsTopLevel() bool { + return c.ParentID == nil +} + +// HasChildren 检查是否有子分类 +func (c *Category) HasChildren() bool { + return len(c.Children) > 0 +} \ No newline at end of file diff --git a/backend/internal/model/entity/photo.go b/backend/internal/model/entity/photo.go new file mode 100644 index 0000000..b7517f0 --- /dev/null +++ b/backend/internal/model/entity/photo.go @@ -0,0 +1,244 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +// Photo 照片实体 +type Photo struct { + ID uint `json:"id" gorm:"primarykey"` + Title string `json:"title" gorm:"not null;size:200"` + Description string `json:"description" gorm:"type:text"` + Filename string `json:"filename" gorm:"not null;size:255"` + OriginalFilename string `json:"original_filename" gorm:"not null;size:255"` + UniqueFilename string `json:"unique_filename" gorm:"not null;size:255"` + FilePath string `json:"file_path" gorm:"not null;size:500"` + OriginalURL string `json:"original_url" gorm:"not null;size:500"` + ThumbnailURL string `json:"thumbnail_url" gorm:"size:500"` + MediumURL string `json:"medium_url" gorm:"size:500"` + LargeURL string `json:"large_url" gorm:"size:500"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type" gorm:"size:100"` + Width int `json:"width"` + Height int `json:"height"` + Status PhotoStatus `json:"status" gorm:"default:active;size:20"` + + // EXIF 信息 + Camera string `json:"camera" gorm:"size:100"` + Lens string `json:"lens" gorm:"size:100"` + CameraMake string `json:"camera_make" gorm:"size:100"` + CameraModel string `json:"camera_model" gorm:"size:100"` + LensModel string `json:"lens_model" gorm:"size:100"` + FocalLength *float64 `json:"focal_length" gorm:"type:decimal(5,2)"` + Aperture *float64 `json:"aperture" gorm:"type:decimal(3,1)"` + ShutterSpeed string `json:"shutter_speed" gorm:"size:20"` + ISO *int `json:"iso"` + TakenAt *time.Time `json:"taken_at"` + + // 地理位置信息 + LocationName string `json:"location_name" gorm:"size:200"` + Latitude *float64 `json:"latitude" gorm:"type:decimal(10,8)"` + Longitude *float64 `json:"longitude" gorm:"type:decimal(11,8)"` + + // 关联 + UserID uint `json:"user_id" gorm:"not null;index"` + AlbumID *uint `json:"album_id" gorm:"index"` + CategoryID *uint `json:"category_id" gorm:"index"` + + // 状态和统计 + IsPublic bool `json:"is_public" gorm:"default:true;index"` + IsFeatured bool `json:"is_featured" gorm:"default:false;index"` + ViewCount int `json:"view_count" gorm:"default:0;index"` + LikeCount int `json:"like_count" gorm:"default:0;index"` + DownloadCount int `json:"download_count" gorm:"default:0"` + SortOrder int `json:"sort_order" gorm:"default:0;index"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` + + // 关联对象 + User User `json:"user,omitempty" gorm:"foreignKey:UserID"` + Album *Album `json:"album,omitempty" gorm:"foreignKey:AlbumID"` + Category *Category `json:"category,omitempty" gorm:"foreignKey:CategoryID"` + Tags []Tag `json:"tags,omitempty" gorm:"many2many:photo_tags;"` +} + +// PhotoStatus 照片状态枚举 +type PhotoStatus string + +const ( + PhotoStatusActive PhotoStatus = "active" + PhotoStatusInactive PhotoStatus = "inactive" + PhotoStatusDeleted PhotoStatus = "deleted" + PhotoStatusDraft PhotoStatus = "draft" + PhotoStatusPrivate PhotoStatus = "private" +) + +// Status constants for compatibility +const ( + StatusPublished PhotoStatus = "active" + StatusDraft PhotoStatus = "draft" + StatusArchived PhotoStatus = "inactive" +) + +// PhotoTag 照片标签关联表 +type PhotoTag struct { + PhotoID uint `json:"photo_id" gorm:"primaryKey"` + TagID uint `json:"tag_id" gorm:"primaryKey"` +} + +// PhotoStats 照片统计信息 +type PhotoStats struct { + Total int64 `json:"total"` // 总照片数 + Published int64 `json:"published"` // 已发布照片数 + Private int64 `json:"private"` // 私有照片数 + Featured int64 `json:"featured"` // 精选照片数 + TotalViews int64 `json:"total_views"` // 总浏览量 + TotalLikes int64 `json:"total_likes"` // 总点赞数 + TotalDownloads int64 `json:"total_downloads"` // 总下载数 + FileSize int64 `json:"file_size"` // 总文件大小 + TotalSize int64 `json:"total_size"` // 总大小(别名) + ThisMonth int64 `json:"this_month"` // 本月新增 + Today int64 `json:"today"` // 今日新增 + StatusStats map[string]int64 `json:"status_stats"` // 状态统计 + CategoryCounts map[string]int64 `json:"category_counts"` // 各分类照片数量 + TagCounts map[string]int64 `json:"tag_counts"` // 各标签照片数量 +} + +// PhotoListParams 照片列表查询参数 +type PhotoListParams struct { + Page int `json:"page" form:"page"` + Limit int `json:"limit" form:"limit"` + Sort string `json:"sort" form:"sort"` + Order string `json:"order" form:"order"` + Search string `json:"search" form:"search"` + UserID *uint `json:"user_id" form:"user_id"` + Status *PhotoStatus `json:"status" form:"status"` + CategoryID *uint `json:"category_id" form:"category_id"` + TagID *uint `json:"tag_id" form:"tag_id"` + DateFrom *time.Time `json:"date_from" form:"date_from"` + DateTo *time.Time `json:"date_to" form:"date_to"` +} + +// CreatePhotoRequest 创建照片请求 +type CreatePhotoRequest struct { + Title string `json:"title" binding:"required,max=200"` + Description string `json:"description" binding:"max=1000"` + OriginalFilename string `json:"original_filename"` + FileSize int64 `json:"file_size"` + Status string `json:"status" binding:"oneof=active inactive"` + Camera string `json:"camera" binding:"max=100"` + Lens string `json:"lens" binding:"max=100"` + ISO *int `json:"iso"` + Aperture *float64 `json:"aperture"` + ShutterSpeed string `json:"shutter_speed" binding:"max=20"` + FocalLength *float64 `json:"focal_length"` + TakenAt *time.Time `json:"taken_at"` + CategoryIDs []uint `json:"category_ids"` + TagIDs []uint `json:"tag_ids"` +} + +// UpdatePhotoRequest 更新照片请求 +type UpdatePhotoRequest struct { + Title *string `json:"title" binding:"omitempty,max=200"` + Description *string `json:"description" binding:"max=1000"` + Status *string `json:"status" binding:"omitempty,oneof=active inactive"` + Camera *string `json:"camera" binding:"omitempty,max=100"` + Lens *string `json:"lens" binding:"omitempty,max=100"` + ISO *int `json:"iso"` + Aperture *float64 `json:"aperture"` + ShutterSpeed *string `json:"shutter_speed" binding:"omitempty,max=20"` + FocalLength *float64 `json:"focal_length"` + TakenAt *time.Time `json:"taken_at"` + CategoryIDs *[]uint `json:"category_ids"` + TagIDs *[]uint `json:"tag_ids"` +} + +// BatchUpdatePhotosRequest 批量更新照片请求 +type BatchUpdatePhotosRequest struct { + Status *string `json:"status" binding:"omitempty,oneof=active inactive"` + CategoryIDs *[]uint `json:"category_ids"` + TagIDs *[]uint `json:"tag_ids"` +} + +// PhotoFormat 照片格式 +type PhotoFormat struct { + ID uint `json:"id" gorm:"primarykey"` + PhotoID uint `json:"photo_id" gorm:"not null;index"` + Format string `json:"format" gorm:"not null;size:20"` // jpg, png, webp + Quality int `json:"quality" gorm:"not null"` // 1-100 + Width int `json:"width" gorm:"not null"` + Height int `json:"height" gorm:"not null"` + FileSize int64 `json:"file_size" gorm:"not null"` + URL string `json:"url" gorm:"not null;size:500"` + CreatedAt time.Time `json:"created_at"` +} + +func (PhotoFormat) TableName() string { + return "photo_formats" +} + +// TableName 指定表名 +func (Photo) TableName() string { + return "photos" +} + +// TableName 指定关联表名 +func (PhotoTag) TableName() string { + return "photo_tags" +} + +// GetAspectRatio 获取宽高比 +func (p *Photo) GetAspectRatio() float64 { + if p.Height == 0 { + return 0 + } + return float64(p.Width) / float64(p.Height) +} + +// IsLandscape 是否为横向 +func (p *Photo) IsLandscape() bool { + return p.Width > p.Height +} + +// IsPortrait 是否为纵向 +func (p *Photo) IsPortrait() bool { + return p.Width < p.Height +} + +// IsSquare 是否为正方形 +func (p *Photo) IsSquare() bool { + return p.Width == p.Height +} + +// HasLocation 是否有地理位置信息 +func (p *Photo) HasLocation() bool { + return p.Latitude != nil && p.Longitude != nil +} + +// HasEXIF 是否有EXIF信息 +func (p *Photo) HasEXIF() bool { + return p.CameraMake != "" || p.CameraModel != "" || p.TakenAt != nil +} + +// GetDisplayURL 获取显示URL(根据尺寸) +func (p *Photo) GetDisplayURL(size string) string { + switch size { + case "thumbnail": + if p.ThumbnailURL != "" { + return p.ThumbnailURL + } + case "medium": + if p.MediumURL != "" { + return p.MediumURL + } + case "large": + if p.LargeURL != "" { + return p.LargeURL + } + } + return p.OriginalURL +} \ No newline at end of file diff --git a/backend/internal/model/entity/tag.go b/backend/internal/model/entity/tag.go new file mode 100644 index 0000000..71bb7fe --- /dev/null +++ b/backend/internal/model/entity/tag.go @@ -0,0 +1,99 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +// Tag 标签实体 +type Tag struct { + ID uint `json:"id" gorm:"primarykey"` + Name string `json:"name" gorm:"uniqueIndex;not null;size:50"` + Slug string `json:"slug" gorm:"uniqueIndex;not null;size:50"` + Description string `json:"description" gorm:"type:text"` + Color string `json:"color" gorm:"default:#6b7280;size:7"` + UseCount int `json:"use_count" gorm:"default:0;index"` + PhotoCount int64 `json:"photo_count" gorm:"-"` // 不存储在数据库中 + IsActive bool `json:"is_active" gorm:"default:true;index"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` + + // 关联 + Photos []Photo `json:"photos,omitempty" gorm:"many2many:photo_tags;"` +} + +// TagStats 标签统计信息 +type TagStats struct { + Total int64 `json:"total"` // 总标签数 + Active int64 `json:"active"` // 活跃标签数 + Used int64 `json:"used"` // 已使用标签数 + Unused int64 `json:"unused"` // 未使用标签数 + AvgPhotosPerTag float64 `json:"avg_photos_per_tag"` // 平均每个标签的照片数 + Popular []Tag `json:"popular"` // 热门标签 + PhotoCounts map[string]int64 `json:"photo_counts"` // 各标签照片数量 +} + +// TagListParams 标签列表查询参数 +type TagListParams struct { + Page int `json:"page" form:"page"` + Limit int `json:"limit" form:"limit"` + Search string `json:"search" form:"search"` + IsActive *bool `json:"is_active" form:"is_active"` + SortBy string `json:"sort_by" form:"sort_by"` + SortOrder string `json:"sort_order" form:"sort_order"` +} + +// CreateTagRequest 创建标签请求 +type CreateTagRequest struct { + Name string `json:"name" binding:"required,max=50"` + Slug string `json:"slug" binding:"required,max=50"` + Description string `json:"description" binding:"max=500"` + Color string `json:"color" binding:"max=7"` +} + +// UpdateTagRequest 更新标签请求 +type UpdateTagRequest struct { + Name *string `json:"name" binding:"omitempty,max=50"` + Slug *string `json:"slug" binding:"omitempty,max=50"` + Description *string `json:"description" binding:"max=500"` + Color *string `json:"color" binding:"omitempty,max=7"` + IsActive *bool `json:"is_active"` +} + +// TagWithCount 带有照片数量的标签 +type TagWithCount struct { + Tag + PhotoCount int64 `json:"photo_count"` +} + +// TagCloudItem 标签云项目 +type TagCloudItem struct { + Name string `json:"name"` + Slug string `json:"slug"` + Color string `json:"color"` + Count int64 `json:"count"` +} + +// TableName 指定表名 +func (Tag) TableName() string { + return "tags" +} + +// IsPopular 检查是否为热门标签(使用次数 >= 10) +func (t *Tag) IsPopular() bool { + return t.UseCount >= 10 +} + +// IncrementUseCount 增加使用次数 +func (t *Tag) IncrementUseCount() { + t.UseCount++ +} + +// DecrementUseCount 减少使用次数 +func (t *Tag) DecrementUseCount() { + if t.UseCount > 0 { + t.UseCount-- + } +} \ No newline at end of file diff --git a/backend/internal/model/entity/user.go b/backend/internal/model/entity/user.go new file mode 100644 index 0000000..3716e2e --- /dev/null +++ b/backend/internal/model/entity/user.go @@ -0,0 +1,150 @@ +package entity + +import ( + "time" + + "gorm.io/gorm" +) + +// UserRole 用户角色枚举 +type UserRole string + +const ( + UserRoleUser UserRole = "user" + UserRoleAdmin UserRole = "admin" + UserRolePhotographer UserRole = "photographer" +) + +// User 用户实体 +type User struct { + ID uint `json:"id" gorm:"primarykey"` + Username string `json:"username" gorm:"uniqueIndex;not null;size:50"` + Email string `json:"email" gorm:"uniqueIndex;not null;size:100"` + Password string `json:"-" gorm:"not null;size:255"` + Name string `json:"name" gorm:"size:100"` + Avatar string `json:"avatar" gorm:"size:500"` + Bio string `json:"bio" gorm:"type:text"` + Website string `json:"website" gorm:"size:200"` + Location string `json:"location" gorm:"size:100"` + Role UserRole `json:"role" gorm:"default:user;size:20"` + IsActive bool `json:"is_active" gorm:"default:true"` + IsPublic bool `json:"is_public" gorm:"default:true"` + EmailVerified bool `json:"email_verified" gorm:"default:false"` + LastLogin *time.Time `json:"last_login"` + LoginCount int `json:"login_count" gorm:"default:0"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` + + // 关联 + Photos []Photo `json:"photos,omitempty" gorm:"foreignKey:UserID"` + Albums []Album `json:"albums,omitempty" gorm:"foreignKey:UserID"` +} + +// TableName 指定表名 +func (User) TableName() string { + return "users" +} + +// IsAdmin 检查是否为管理员 +func (u *User) IsAdmin() bool { + return u.Role == UserRoleAdmin +} + +// IsPhotographer 检查是否为摄影师 +func (u *User) IsPhotographer() bool { + return u.Role == UserRolePhotographer || u.Role == UserRoleAdmin +} + +// CanManagePhoto 检查是否可以管理指定照片 +func (u *User) CanManagePhoto(photo *Photo) bool { + return u.ID == photo.UserID || u.IsAdmin() +} + +// CanManageAlbum 检查是否可以管理指定相册 +func (u *User) CanManageAlbum(album *Album) bool { + return u.ID == album.UserID || u.IsAdmin() +} + +// UserStats 用户统计信息 +type UserStats struct { + Total int64 `json:"total"` // 总用户数 + Active int64 `json:"active"` // 活跃用户数 + ThisMonth int64 `json:"this_month"` // 本月新增 + Today int64 `json:"today"` // 今日新增 + RoleStats map[string]int64 `json:"role_stats"` // 角色统计 +} + +// CreateUserRequest 创建用户请求 +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=50"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + Name string `json:"name" binding:"max=100"` + Role UserRole `json:"role" binding:"oneof=user admin photographer"` +} + +// UpdateUserRequest 更新用户请求 +type UpdateUserRequest struct { + Username *string `json:"username" binding:"omitempty,min=3,max=50"` + Email *string `json:"email" binding:"omitempty,email"` + Name *string `json:"name" binding:"omitempty,max=100"` + Avatar *string `json:"avatar" binding:"omitempty,max=500"` + Bio *string `json:"bio" binding:"omitempty,max=1000"` + Website *string `json:"website" binding:"omitempty,max=200"` + Location *string `json:"location" binding:"omitempty,max=100"` + Role *UserRole `json:"role" binding:"omitempty,oneof=user admin photographer"` + IsActive *bool `json:"is_active"` +} + +// UpdateCurrentUserRequest 更新当前用户请求 +type UpdateCurrentUserRequest struct { + Username *string `json:"username" binding:"omitempty,min=3,max=50"` + Email *string `json:"email" binding:"omitempty,email"` + Name *string `json:"name" binding:"omitempty,max=100"` + Avatar *string `json:"avatar" binding:"omitempty,max=500"` + Bio *string `json:"bio" binding:"omitempty,max=1000"` + Website *string `json:"website" binding:"omitempty,max=200"` + Location *string `json:"location" binding:"omitempty,max=100"` +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + OldPassword string `json:"old_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` +} + +// UserStatus 用户状态 +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusInactive UserStatus = "inactive" + UserStatusBanned UserStatus = "banned" + UserStatusPending UserStatus = "pending" +) + +// UserListParams 用户列表查询参数 +type UserListParams struct { + Page int `json:"page"` + Limit int `json:"limit"` + Sort string `json:"sort"` + Order string `json:"order"` + Role *UserRole `json:"role"` + Status *UserStatus `json:"status"` + Search string `json:"search"` + CreatedFrom *time.Time `json:"created_from"` + CreatedTo *time.Time `json:"created_to"` + LastLoginFrom *time.Time `json:"last_login_from"` + LastLoginTo *time.Time `json:"last_login_to"` +} + +// UserGlobalStats 全局用户统计信息 +type UserGlobalStats struct { + Total int64 `json:"total"` + Active int64 `json:"active"` + Admins int64 `json:"admins"` + Editors int64 `json:"editors"` + Users int64 `json:"users"` + MonthlyRegistrations int64 `json:"monthly_registrations"` +} \ No newline at end of file diff --git a/backend/internal/model/request/common.go b/backend/internal/model/request/common.go new file mode 100644 index 0000000..c3310fa --- /dev/null +++ b/backend/internal/model/request/common.go @@ -0,0 +1,90 @@ +package request + +// PaginationRequest 分页请求 +type PaginationRequest struct { + Page int `json:"page" form:"page" binding:"omitempty,min=1" validate:"omitempty,min=1"` + Limit int `json:"limit" form:"limit" binding:"omitempty,min=1,max=100" validate:"omitempty,min=1,max=100"` +} + +// SortRequest 排序请求 +type SortRequest struct { + Sort string `json:"sort" form:"sort" binding:"omitempty" validate:"omitempty"` + Order string `json:"order" form:"order" binding:"omitempty,oneof=asc desc" validate:"omitempty,oneof=asc desc"` +} + +// SearchRequest 搜索请求 +type SearchRequest struct { + Search string `json:"search" form:"search" binding:"omitempty,max=100" validate:"omitempty,max=100"` +} + +// BaseListRequest 基础列表请求 +type BaseListRequest struct { + PaginationRequest + SortRequest + SearchRequest +} + +// IDRequest ID 请求 +type IDRequest struct { + ID uint `json:"id" uri:"id" binding:"required,min=1" validate:"required,min=1"` +} + +// SlugRequest Slug 请求 +type SlugRequest struct { + Slug string `json:"slug" uri:"slug" binding:"required,min=1" validate:"required,min=1"` +} + +// BulkIDsRequest 批量 ID 请求 +type BulkIDsRequest struct { + IDs []uint `json:"ids" binding:"required,min=1" validate:"required,min=1"` +} + +// StatusRequest 状态请求 +type StatusRequest struct { + IsActive *bool `json:"is_active" form:"is_active" binding:"omitempty"` +} + +// TimeRangeRequest 时间范围请求 +type TimeRangeRequest struct { + StartDate string `json:"start_date" form:"start_date" binding:"omitempty" validate:"omitempty,datetime=2006-01-02"` + EndDate string `json:"end_date" form:"end_date" binding:"omitempty" validate:"omitempty,datetime=2006-01-02"` +} + +// GetDefaultPagination 获取默认分页参数 +func (p *PaginationRequest) GetDefaultPagination() (int, int) { + page := p.Page + if page <= 0 { + page = 1 + } + + limit := p.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + return page, limit +} + +// GetDefaultSort 获取默认排序参数 +func (s *SortRequest) GetDefaultSort(defaultSort, defaultOrder string) (string, string) { + sort := s.Sort + if sort == "" { + sort = defaultSort + } + + order := s.Order + if order == "" { + order = defaultOrder + } + + return sort, order +} + +// GetOffset 计算偏移量 +func (p *PaginationRequest) GetOffset() int { + page, limit := p.GetDefaultPagination() + return (page - 1) * limit +} \ No newline at end of file diff --git a/backend/internal/repository/interfaces/category_repository.go b/backend/internal/repository/interfaces/category_repository.go new file mode 100644 index 0000000..d5e191a --- /dev/null +++ b/backend/internal/repository/interfaces/category_repository.go @@ -0,0 +1,39 @@ +package interfaces + +import ( + "context" + "photography-backend/internal/model/entity" +) + +// CategoryRepository 分类仓储接口 +type CategoryRepository interface { + // 基本CRUD操作 + Create(ctx context.Context, category *entity.Category) error + GetByID(ctx context.Context, id uint) (*entity.Category, error) + GetBySlug(ctx context.Context, slug string) (*entity.Category, error) + Update(ctx context.Context, category *entity.Category) error + Delete(ctx context.Context, id uint) error + + // 查询操作 + List(ctx context.Context, parentID *uint) ([]*entity.Category, error) + GetTree(ctx context.Context) ([]*entity.CategoryTree, error) + GetChildren(ctx context.Context, parentID uint) ([]*entity.Category, error) + GetParent(ctx context.Context, categoryID uint) (*entity.Category, error) + + // 排序操作 + Reorder(ctx context.Context, parentID *uint, categoryIDs []uint) error + GetNextSortOrder(ctx context.Context, parentID *uint) (int, error) + + // 验证操作 + ValidateSlugUnique(ctx context.Context, slug string, excludeID uint) error + ValidateParentCategory(ctx context.Context, categoryID, parentID uint) error + + // 统计操作 + Count(ctx context.Context) (int64, error) + CountActive(ctx context.Context) (int64, error) + CountTopLevel(ctx context.Context) (int64, error) + GetStats(ctx context.Context) (*entity.CategoryStats, error) + + // 工具方法 + GenerateUniqueSlug(ctx context.Context, baseName string) (string, error) +} \ No newline at end of file diff --git a/backend/internal/repository/interfaces/photo_repository.go b/backend/internal/repository/interfaces/photo_repository.go new file mode 100644 index 0000000..22a7244 --- /dev/null +++ b/backend/internal/repository/interfaces/photo_repository.go @@ -0,0 +1,33 @@ +package interfaces + +import ( + "context" + "photography-backend/internal/model/entity" +) + +// PhotoRepository 照片仓储接口 +type PhotoRepository interface { + // 基本CRUD操作 + Create(ctx context.Context, photo *entity.Photo) error + GetByID(ctx context.Context, id uint) (*entity.Photo, error) + Update(ctx context.Context, photo *entity.Photo) error + Delete(ctx context.Context, id uint) error + + // 查询操作 + List(ctx context.Context, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) + ListByUserID(ctx context.Context, userID uint, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) + ListByCategory(ctx context.Context, categoryID uint, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) + Search(ctx context.Context, query string, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) + + // 批量操作 + BatchUpdate(ctx context.Context, ids []uint, updates map[string]interface{}) error + BatchDelete(ctx context.Context, ids []uint) error + BatchUpdateCategories(ctx context.Context, photoIDs []uint, categoryIDs []uint) error + BatchUpdateTags(ctx context.Context, photoIDs []uint, tagIDs []uint) error + + // 统计操作 + Count(ctx context.Context) (int64, error) + CountByStatus(ctx context.Context, status string) (int64, error) + CountByUser(ctx context.Context, userID uint) (int64, error) + GetStats(ctx context.Context) (*entity.PhotoStats, error) +} \ No newline at end of file diff --git a/backend/internal/repository/interfaces/tag_repository.go b/backend/internal/repository/interfaces/tag_repository.go new file mode 100644 index 0000000..607a6b2 --- /dev/null +++ b/backend/internal/repository/interfaces/tag_repository.go @@ -0,0 +1,42 @@ +package interfaces + +import ( + "context" + "photography-backend/internal/model/entity" +) + +// TagRepository 标签仓储接口 +type TagRepository interface { + // 基本CRUD操作 + Create(ctx context.Context, tag *entity.Tag) error + GetByID(ctx context.Context, id uint) (*entity.Tag, error) + GetBySlug(ctx context.Context, slug string) (*entity.Tag, error) + GetByName(ctx context.Context, name string) (*entity.Tag, error) + Update(ctx context.Context, tag *entity.Tag) error + Delete(ctx context.Context, id uint) error + + // 查询操作 + List(ctx context.Context, params *entity.TagListParams) ([]*entity.Tag, int64, error) + Search(ctx context.Context, query string) ([]*entity.Tag, error) + GetPopular(ctx context.Context, limit int) ([]*entity.Tag, error) + GetByPhotos(ctx context.Context, photoIDs []uint) ([]*entity.Tag, error) + + // 批量操作 + CreateMultiple(ctx context.Context, tags []*entity.Tag) error + GetOrCreateByNames(ctx context.Context, names []string) ([]*entity.Tag, error) + BatchDelete(ctx context.Context, ids []uint) error + + // 关联操作 + AttachToPhoto(ctx context.Context, tagID, photoID uint) error + DetachFromPhoto(ctx context.Context, tagID, photoID uint) error + GetPhotoTags(ctx context.Context, photoID uint) ([]*entity.Tag, error) + + // 统计操作 + Count(ctx context.Context) (int64, error) + CountByPhotos(ctx context.Context) (map[uint]int64, error) + GetStats(ctx context.Context) (*entity.TagStats, error) + + // 工具方法 + GenerateUniqueSlug(ctx context.Context, baseName string) (string, error) + ValidateSlugUnique(ctx context.Context, slug string, excludeID uint) error +} \ No newline at end of file diff --git a/backend/internal/repository/interfaces/user_repository.go b/backend/internal/repository/interfaces/user_repository.go new file mode 100644 index 0000000..3a6717f --- /dev/null +++ b/backend/internal/repository/interfaces/user_repository.go @@ -0,0 +1,40 @@ +package interfaces + +import ( + "context" + "photography-backend/internal/model/entity" +) + +// UserRepository 用户仓储接口 +type UserRepository interface { + // 基本CRUD操作 + Create(ctx context.Context, user *entity.User) error + GetByID(ctx context.Context, id uint) (*entity.User, error) + GetByEmail(ctx context.Context, email string) (*entity.User, error) + GetByUsername(ctx context.Context, username string) (*entity.User, error) + Update(ctx context.Context, user *entity.User) error + Delete(ctx context.Context, id uint) error + + // 查询操作 + List(ctx context.Context, params *entity.UserListParams) ([]*entity.User, int64, error) + Search(ctx context.Context, query string, params *entity.UserListParams) ([]*entity.User, int64, error) + + // 认证相关 + UpdatePassword(ctx context.Context, userID uint, hashedPassword string) error + UpdateLastLogin(ctx context.Context, userID uint) error + IncrementLoginCount(ctx context.Context, userID uint) error + + // 状态管理 + SetActive(ctx context.Context, userID uint, isActive bool) error + VerifyEmail(ctx context.Context, userID uint) error + + // 统计操作 + Count(ctx context.Context) (int64, error) + CountByRole(ctx context.Context, role entity.UserRole) (int64, error) + CountActive(ctx context.Context) (int64, error) + GetStats(ctx context.Context) (*entity.UserStats, error) + + // 验证操作 + ExistsByEmail(ctx context.Context, email string) (bool, error) + ExistsByUsername(ctx context.Context, username string) (bool, error) +} \ No newline at end of file diff --git a/backend/internal/repository/postgres/category_repository.go b/backend/internal/repository/postgres/category_repository.go deleted file mode 100644 index a92e5fb..0000000 --- a/backend/internal/repository/postgres/category_repository.go +++ /dev/null @@ -1,213 +0,0 @@ -package postgres - -import ( - "fmt" - "photography-backend/internal/models" - "gorm.io/gorm" -) - -// CategoryRepository 分类仓库接口 -type CategoryRepository interface { - Create(category *models.Category) error - GetByID(id uint) (*models.Category, error) - Update(category *models.Category) error - Delete(id uint) error - List(params *models.CategoryListParams) ([]*models.Category, error) - GetTree() ([]*models.Category, error) - GetChildren(parentID uint) ([]*models.Category, error) - GetStats() (*models.CategoryStats, error) - UpdateSort(id uint, sort int) error - GetPhotoCount(id uint) (int64, error) -} - -// categoryRepository 分类仓库实现 -type categoryRepository struct { - db *gorm.DB -} - -// NewCategoryRepository 创建分类仓库 -func NewCategoryRepository(db *gorm.DB) CategoryRepository { - return &categoryRepository{db: db} -} - -// Create 创建分类 -func (r *categoryRepository) Create(category *models.Category) error { - if err := r.db.Create(category).Error; err != nil { - return fmt.Errorf("failed to create category: %w", err) - } - return nil -} - -// GetByID 根据ID获取分类 -func (r *categoryRepository) GetByID(id uint) (*models.Category, error) { - var category models.Category - if err := r.db.Preload("Parent").Preload("Children"). - First(&category, id).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get category by id: %w", err) - } - - // 计算照片数量 - var photoCount int64 - if err := r.db.Model(&models.Photo{}).Where("category_id = ?", id). - Count(&photoCount).Error; err != nil { - return nil, fmt.Errorf("failed to count photos: %w", err) - } - category.PhotoCount = int(photoCount) - - return &category, nil -} - -// Update 更新分类 -func (r *categoryRepository) Update(category *models.Category) error { - if err := r.db.Save(category).Error; err != nil { - return fmt.Errorf("failed to update category: %w", err) - } - return nil -} - -// Delete 删除分类 -func (r *categoryRepository) Delete(id uint) error { - // 开启事务 - tx := r.db.Begin() - - // 将子分类的父分类设置为NULL - if err := tx.Model(&models.Category{}).Where("parent_id = ?", id). - Update("parent_id", nil).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to update child categories: %w", err) - } - - // 删除分类 - if err := tx.Delete(&models.Category{}, id).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to delete category: %w", err) - } - - return tx.Commit().Error -} - -// List 获取分类列表 -func (r *categoryRepository) List(params *models.CategoryListParams) ([]*models.Category, error) { - var categories []*models.Category - - query := r.db.Model(&models.Category{}) - - // 添加过滤条件 - if params.ParentID > 0 { - query = query.Where("parent_id = ?", params.ParentID) - } - - if params.IsActive { - query = query.Where("is_active = ?", true) - } - - if err := query.Order("sort ASC, created_at DESC"). - Find(&categories).Error; err != nil { - return nil, fmt.Errorf("failed to list categories: %w", err) - } - - // 如果需要包含统计信息 - if params.IncludeStats { - for _, category := range categories { - var photoCount int64 - if err := r.db.Model(&models.Photo{}).Where("category_id = ?", category.ID). - Count(&photoCount).Error; err != nil { - return nil, fmt.Errorf("failed to count photos for category %d: %w", category.ID, err) - } - category.PhotoCount = int(photoCount) - } - } - - return categories, nil -} - -// GetTree 获取分类树 -func (r *categoryRepository) GetTree() ([]*models.Category, error) { - var categories []*models.Category - - // 获取所有分类 - if err := r.db.Where("is_active = ?", true). - Order("sort ASC, created_at DESC"). - Find(&categories).Error; err != nil { - return nil, fmt.Errorf("failed to get categories: %w", err) - } - - // 构建分类树 - categoryMap := make(map[uint]*models.Category) - var rootCategories []*models.Category - - // 第一次遍历:建立映射 - for _, category := range categories { - categoryMap[category.ID] = category - category.Children = []models.Category{} - } - - // 第二次遍历:构建树形结构 - for _, category := range categories { - if category.ParentID == nil { - rootCategories = append(rootCategories, category) - } else { - if parent, exists := categoryMap[*category.ParentID]; exists { - parent.Children = append(parent.Children, *category) - } - } - } - - return rootCategories, nil -} - -// GetChildren 获取子分类 -func (r *categoryRepository) GetChildren(parentID uint) ([]*models.Category, error) { - var categories []*models.Category - - if err := r.db.Where("parent_id = ? AND is_active = ?", parentID, true). - Order("sort ASC, created_at DESC"). - Find(&categories).Error; err != nil { - return nil, fmt.Errorf("failed to get child categories: %w", err) - } - - return categories, nil -} - -// GetStats 获取分类统计 -func (r *categoryRepository) GetStats() (*models.CategoryStats, error) { - var stats models.CategoryStats - - // 总分类数 - var totalCount int64 - if err := r.db.Model(&models.Category{}).Count(&totalCount).Error; err != nil { - return nil, fmt.Errorf("failed to count total categories: %w", err) - } - stats.TotalCategories = int(totalCount) - - // 计算最大层级 - // 这里简化处理,实际应用中可能需要递归查询 - stats.MaxLevel = 3 - - // 特色分类数量(这里假设有一个is_featured字段,实际可能需要调整) - stats.FeaturedCount = 0 - - return &stats, nil -} - -// UpdateSort 更新排序 -func (r *categoryRepository) UpdateSort(id uint, sort int) error { - if err := r.db.Model(&models.Category{}).Where("id = ?", id). - Update("sort", sort).Error; err != nil { - return fmt.Errorf("failed to update sort: %w", err) - } - return nil -} - -// GetPhotoCount 获取分类的照片数量 -func (r *categoryRepository) GetPhotoCount(id uint) (int64, error) { - var count int64 - if err := r.db.Model(&models.Photo{}).Where("category_id = ?", id). - Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to count photos: %w", err) - } - return count, nil -} \ No newline at end of file diff --git a/backend/internal/repository/postgres/category_repository_impl.go b/backend/internal/repository/postgres/category_repository_impl.go new file mode 100644 index 0000000..62008d5 --- /dev/null +++ b/backend/internal/repository/postgres/category_repository_impl.go @@ -0,0 +1,345 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/interfaces" + "photography-backend/internal/utils" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +// categoryRepositoryImpl 分类仓储实现 +type categoryRepositoryImpl struct { + db *gorm.DB + logger *zap.Logger +} + +// NewCategoryRepository 创建分类仓储实现 +func NewCategoryRepository(db *gorm.DB, logger *zap.Logger) interfaces.CategoryRepository { + return &categoryRepositoryImpl{ + db: db, + logger: logger, + } +} + +// Create 创建分类 +func (r *categoryRepositoryImpl) Create(ctx context.Context, category *entity.Category) error { + return r.db.WithContext(ctx).Create(category).Error +} + +// GetByID 根据ID获取分类 +func (r *categoryRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.Category, error) { + var category entity.Category + err := r.db.WithContext(ctx).First(&category, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("category not found") + } + return nil, err + } + return &category, nil +} + +// GetBySlug 根据slug获取分类 +func (r *categoryRepositoryImpl) GetBySlug(ctx context.Context, slug string) (*entity.Category, error) { + var category entity.Category + err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&category).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("category not found") + } + return nil, err + } + return &category, nil +} + +// Update 更新分类 +func (r *categoryRepositoryImpl) Update(ctx context.Context, category *entity.Category) error { + return r.db.WithContext(ctx).Save(category).Error +} + +// Delete 删除分类 +func (r *categoryRepositoryImpl) Delete(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Delete(&entity.Category{}, id).Error +} + +// List 获取分类列表 +func (r *categoryRepositoryImpl) List(ctx context.Context, parentID *uint) ([]*entity.Category, error) { + var categories []*entity.Category + + query := r.db.WithContext(ctx).Order("sort_order ASC, created_at ASC") + + if parentID != nil { + query = query.Where("parent_id = ?", *parentID) + } else { + query = query.Where("parent_id IS NULL") + } + + err := query.Find(&categories).Error + return categories, err +} + +// GetTree 获取分类树 +func (r *categoryRepositoryImpl) GetTree(ctx context.Context) ([]*entity.CategoryTree, error) { + var categories []*entity.Category + if err := r.db.WithContext(ctx). + Order("sort_order ASC, created_at ASC"). + Find(&categories).Error; err != nil { + return nil, err + } + + // 构建树形结构 + tree := r.buildCategoryTree(categories, nil) + return tree, nil +} + +// GetChildren 获取子分类 +func (r *categoryRepositoryImpl) GetChildren(ctx context.Context, parentID uint) ([]*entity.Category, error) { + var children []*entity.Category + err := r.db.WithContext(ctx). + Where("parent_id = ?", parentID). + Order("sort_order ASC"). + Find(&children).Error + return children, err +} + +// GetParent 获取父分类 +func (r *categoryRepositoryImpl) GetParent(ctx context.Context, categoryID uint) (*entity.Category, error) { + var category entity.Category + err := r.db.WithContext(ctx). + Preload("Parent"). + First(&category, categoryID).Error + if err != nil { + return nil, err + } + return category.Parent, nil +} + +// Reorder 重新排序分类 +func (r *categoryRepositoryImpl) Reorder(ctx context.Context, parentID *uint, categoryIDs []uint) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for i, categoryID := range categoryIDs { + if err := tx.Model(&entity.Category{}). + Where("id = ?", categoryID). + Update("sort_order", i+1).Error; err != nil { + return err + } + } + return nil + }) +} + +// GetNextSortOrder 获取下一个排序顺序 +func (r *categoryRepositoryImpl) GetNextSortOrder(ctx context.Context, parentID *uint) (int, error) { + var maxOrder int + + query := r.db.WithContext(ctx).Model(&entity.Category{}).Select("COALESCE(MAX(sort_order), 0)") + + if parentID != nil { + query = query.Where("parent_id = ?", *parentID) + } else { + query = query.Where("parent_id IS NULL") + } + + err := query.Row().Scan(&maxOrder) + return maxOrder + 1, err +} + +// ValidateSlugUnique 验证slug唯一性 +func (r *categoryRepositoryImpl) ValidateSlugUnique(ctx context.Context, slug string, excludeID uint) error { + var count int64 + query := r.db.WithContext(ctx).Model(&entity.Category{}).Where("slug = ?", slug) + + if excludeID > 0 { + query = query.Where("id != ?", excludeID) + } + + if err := query.Count(&count).Error; err != nil { + return err + } + + if count > 0 { + return errors.New("slug already exists") + } + + return nil +} + +// ValidateParentCategory 验证父分类(防止循环引用) +func (r *categoryRepositoryImpl) ValidateParentCategory(ctx context.Context, categoryID, parentID uint) error { + if categoryID == parentID { + return errors.New("category cannot be its own parent") + } + + // 检查父分类是否存在 + var parent entity.Category + if err := r.db.WithContext(ctx).First(&parent, parentID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("parent category not found") + } + return err + } + + // 检查是否会形成循环引用 + current := parentID + for current != 0 { + var category entity.Category + if err := r.db.WithContext(ctx).First(&category, current).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("parent category not found") + } + return err + } + + if category.ParentID == nil { + break + } + + if *category.ParentID == categoryID { + return errors.New("circular reference detected") + } + + current = *category.ParentID + } + + return nil +} + +// Count 统计分类总数 +func (r *categoryRepositoryImpl) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Category{}).Count(&count).Error + return count, err +} + +// CountActive 统计活跃分类数 +func (r *categoryRepositoryImpl) CountActive(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Category{}). + Where("is_active = ?", true).Count(&count).Error + return count, err +} + +// CountTopLevel 统计顶级分类数 +func (r *categoryRepositoryImpl) CountTopLevel(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Category{}). + Where("parent_id IS NULL").Count(&count).Error + return count, err +} + +// GetStats 获取分类统计信息 +func (r *categoryRepositoryImpl) GetStats(ctx context.Context) (*entity.CategoryStats, error) { + var stats entity.CategoryStats + + // 总分类数 + if total, err := r.Count(ctx); err != nil { + return nil, err + } else { + stats.Total = total + } + + // 活跃分类数 + if active, err := r.CountActive(ctx); err != nil { + return nil, err + } else { + stats.Active = active + } + + // 顶级分类数 + if topLevel, err := r.CountTopLevel(ctx); err != nil { + return nil, err + } else { + stats.TopLevel = topLevel + } + + // 各分类照片数量 + var categoryPhotoStats []struct { + CategoryID uint `json:"category_id"` + Name string `json:"name"` + PhotoCount int64 `json:"photo_count"` + } + + if err := r.db.WithContext(ctx). + Table("categories"). + Select("categories.id as category_id, categories.name, COUNT(photo_categories.photo_id) as photo_count"). + Joins("LEFT JOIN photo_categories ON categories.id = photo_categories.category_id"). + Group("categories.id, categories.name"). + Order("photo_count DESC"). + Limit(10). + Find(&categoryPhotoStats).Error; err != nil { + return nil, err + } + + stats.PhotoCounts = make(map[string]int64) + for _, stat := range categoryPhotoStats { + stats.PhotoCounts[stat.Name] = stat.PhotoCount + } + + return &stats, nil +} + +// GenerateUniqueSlug 生成唯一slug +func (r *categoryRepositoryImpl) GenerateUniqueSlug(ctx context.Context, baseName string) (string, error) { + baseSlug := utils.GenerateSlug(baseName) + slug := baseSlug + + counter := 1 + for { + var count int64 + if err := r.db.WithContext(ctx).Model(&entity.Category{}). + Where("slug = ?", slug).Count(&count).Error; err != nil { + return "", err + } + + if count == 0 { + break + } + + slug = fmt.Sprintf("%s-%d", baseSlug, counter) + counter++ + } + + return slug, nil +} + +// buildCategoryTree 构建分类树 +func (r *categoryRepositoryImpl) buildCategoryTree(categories []*entity.Category, parentID *uint) []*entity.CategoryTree { + var tree []*entity.CategoryTree + + for _, category := range categories { + // 检查是否匹配父分类 + if (parentID == nil && category.ParentID == nil) || + (parentID != nil && category.ParentID != nil && *category.ParentID == *parentID) { + + node := &entity.CategoryTree{ + ID: category.ID, + Name: category.Name, + Slug: category.Slug, + Description: category.Description, + ParentID: category.ParentID, + SortOrder: category.SortOrder, + IsActive: category.IsActive, + PhotoCount: category.PhotoCount, + CreatedAt: category.CreatedAt, + UpdatedAt: category.UpdatedAt, + } + + // 递归构建子分类 + children := r.buildCategoryTree(categories, &category.ID) + node.Children = make([]entity.CategoryTree, len(children)) + for i, child := range children { + node.Children[i] = *child + } + + tree = append(tree, node) + } + } + + return tree +} \ No newline at end of file diff --git a/backend/internal/repository/postgres/database.go b/backend/internal/repository/postgres/database.go index 54b72df..e7f695c 100644 --- a/backend/internal/repository/postgres/database.go +++ b/backend/internal/repository/postgres/database.go @@ -6,7 +6,7 @@ import ( "gorm.io/gorm" "gorm.io/driver/postgres" "photography-backend/internal/config" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" ) // Database 数据库连接 @@ -52,10 +52,10 @@ func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) { // AutoMigrate 自动迁移数据库表结构 func (d *Database) AutoMigrate() error { return d.DB.AutoMigrate( - &models.User{}, - &models.Category{}, - &models.Tag{}, - &models.Photo{}, + &entity.User{}, + &entity.Category{}, + &entity.Tag{}, + &entity.Photo{}, ) } diff --git a/backend/internal/repository/postgres/photo_repository.go b/backend/internal/repository/postgres/photo_repository.go deleted file mode 100644 index 8c7db8f..0000000 --- a/backend/internal/repository/postgres/photo_repository.go +++ /dev/null @@ -1,303 +0,0 @@ -package postgres - -import ( - "fmt" - "photography-backend/internal/models" - "gorm.io/gorm" -) - -// PhotoRepository 照片仓库接口 -type PhotoRepository interface { - Create(photo *models.Photo) error - GetByID(id uint) (*models.Photo, error) - Update(photo *models.Photo) error - Delete(id uint) error - List(params *models.PhotoListParams) ([]*models.Photo, int64, error) - GetByCategory(categoryID uint, page, limit int) ([]*models.Photo, int64, error) - GetByTag(tagID uint, page, limit int) ([]*models.Photo, int64, error) - GetByUser(userID uint, page, limit int) ([]*models.Photo, int64, error) - Search(query string, page, limit int) ([]*models.Photo, int64, error) - IncrementViewCount(id uint) error - IncrementLikeCount(id uint) error - UpdateStatus(id uint, status string) error - GetStats() (*PhotoStats, error) -} - -// PhotoStats 照片统计 -type PhotoStats struct { - Total int64 `json:"total"` - Published int64 `json:"published"` - Draft int64 `json:"draft"` - Archived int64 `json:"archived"` -} - -// photoRepository 照片仓库实现 -type photoRepository struct { - db *gorm.DB -} - -// NewPhotoRepository 创建照片仓库 -func NewPhotoRepository(db *gorm.DB) PhotoRepository { - return &photoRepository{db: db} -} - -// Create 创建照片 -func (r *photoRepository) Create(photo *models.Photo) error { - if err := r.db.Create(photo).Error; err != nil { - return fmt.Errorf("failed to create photo: %w", err) - } - return nil -} - -// GetByID 根据ID获取照片 -func (r *photoRepository) GetByID(id uint) (*models.Photo, error) { - var photo models.Photo - if err := r.db.Preload("Category").Preload("Tags").Preload("User"). - First(&photo, id).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get photo by id: %w", err) - } - return &photo, nil -} - -// Update 更新照片 -func (r *photoRepository) Update(photo *models.Photo) error { - if err := r.db.Save(photo).Error; err != nil { - return fmt.Errorf("failed to update photo: %w", err) - } - return nil -} - -// Delete 删除照片 -func (r *photoRepository) Delete(id uint) error { - if err := r.db.Delete(&models.Photo{}, id).Error; err != nil { - return fmt.Errorf("failed to delete photo: %w", err) - } - return nil -} - -// List 获取照片列表 -func (r *photoRepository) List(params *models.PhotoListParams) ([]*models.Photo, int64, error) { - var photos []*models.Photo - var total int64 - - query := r.db.Model(&models.Photo{}). - Preload("Category"). - Preload("Tags"). - Preload("User") - - // 添加过滤条件 - if params.CategoryID > 0 { - query = query.Where("category_id = ?", params.CategoryID) - } - - if params.TagID > 0 { - query = query.Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id"). - Where("photo_tags.tag_id = ?", params.TagID) - } - - if params.UserID > 0 { - query = query.Where("user_id = ?", params.UserID) - } - - if params.Status != "" { - query = query.Where("status = ?", params.Status) - } - - if params.Search != "" { - query = query.Where("title ILIKE ? OR description ILIKE ?", - "%"+params.Search+"%", "%"+params.Search+"%") - } - - if params.Year > 0 { - query = query.Where("EXTRACT(YEAR FROM taken_at) = ?", params.Year) - } - - if params.Month > 0 { - query = query.Where("EXTRACT(MONTH FROM taken_at) = ?", params.Month) - } - - // 计算总数 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count photos: %w", err) - } - - // 排序 - orderClause := fmt.Sprintf("%s %s", params.SortBy, params.SortOrder) - - // 分页查询 - offset := (params.Page - 1) * params.Limit - if err := query.Offset(offset).Limit(params.Limit). - Order(orderClause). - Find(&photos).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list photos: %w", err) - } - - return photos, total, nil -} - -// GetByCategory 根据分类获取照片 -func (r *photoRepository) GetByCategory(categoryID uint, page, limit int) ([]*models.Photo, int64, error) { - var photos []*models.Photo - var total int64 - - query := r.db.Model(&models.Photo{}). - Where("category_id = ? AND is_public = ?", categoryID, true). - Preload("Category"). - Preload("Tags") - - // 计算总数 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count photos by category: %w", err) - } - - // 分页查询 - offset := (page - 1) * limit - if err := query.Offset(offset).Limit(limit). - Order("created_at DESC"). - Find(&photos).Error; err != nil { - return nil, 0, fmt.Errorf("failed to get photos by category: %w", err) - } - - return photos, total, nil -} - -// GetByTag 根据标签获取照片 -func (r *photoRepository) GetByTag(tagID uint, page, limit int) ([]*models.Photo, int64, error) { - var photos []*models.Photo - var total int64 - - query := r.db.Model(&models.Photo{}). - Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id"). - Where("photo_tags.tag_id = ? AND photos.is_public = ?", tagID, true). - Preload("Category"). - Preload("Tags") - - // 计算总数 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count photos by tag: %w", err) - } - - // 分页查询 - offset := (page - 1) * limit - if err := query.Offset(offset).Limit(limit). - Order("photos.created_at DESC"). - Find(&photos).Error; err != nil { - return nil, 0, fmt.Errorf("failed to get photos by tag: %w", err) - } - - return photos, total, nil -} - -// GetByUser 根据用户获取照片 -func (r *photoRepository) GetByUser(userID uint, page, limit int) ([]*models.Photo, int64, error) { - var photos []*models.Photo - var total int64 - - query := r.db.Model(&models.Photo{}). - Where("user_id = ?", userID). - Preload("Category"). - Preload("Tags") - - // 计算总数 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count photos by user: %w", err) - } - - // 分页查询 - offset := (page - 1) * limit - if err := query.Offset(offset).Limit(limit). - Order("created_at DESC"). - Find(&photos).Error; err != nil { - return nil, 0, fmt.Errorf("failed to get photos by user: %w", err) - } - - return photos, total, nil -} - -// Search 搜索照片 -func (r *photoRepository) Search(query string, page, limit int) ([]*models.Photo, int64, error) { - var photos []*models.Photo - var total int64 - - searchQuery := r.db.Model(&models.Photo{}). - Where("title ILIKE ? OR description ILIKE ? OR location ILIKE ?", - "%"+query+"%", "%"+query+"%", "%"+query+"%"). - Where("is_public = ?", true). - Preload("Category"). - Preload("Tags") - - // 计算总数 - if err := searchQuery.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count search results: %w", err) - } - - // 分页查询 - offset := (page - 1) * limit - if err := searchQuery.Offset(offset).Limit(limit). - Order("created_at DESC"). - Find(&photos).Error; err != nil { - return nil, 0, fmt.Errorf("failed to search photos: %w", err) - } - - return photos, total, nil -} - -// IncrementViewCount 增加浏览次数 -func (r *photoRepository) IncrementViewCount(id uint) error { - if err := r.db.Model(&models.Photo{}).Where("id = ?", id). - Update("view_count", gorm.Expr("view_count + 1")).Error; err != nil { - return fmt.Errorf("failed to increment view count: %w", err) - } - return nil -} - -// IncrementLikeCount 增加点赞次数 -func (r *photoRepository) IncrementLikeCount(id uint) error { - if err := r.db.Model(&models.Photo{}).Where("id = ?", id). - Update("like_count", gorm.Expr("like_count + 1")).Error; err != nil { - return fmt.Errorf("failed to increment like count: %w", err) - } - return nil -} - -// UpdateStatus 更新状态 -func (r *photoRepository) UpdateStatus(id uint, status string) error { - if err := r.db.Model(&models.Photo{}).Where("id = ?", id). - Update("status", status).Error; err != nil { - return fmt.Errorf("failed to update status: %w", err) - } - return nil -} - -// GetStats 获取照片统计 -func (r *photoRepository) GetStats() (*PhotoStats, error) { - var stats PhotoStats - - // 总数 - if err := r.db.Model(&models.Photo{}).Count(&stats.Total).Error; err != nil { - return nil, fmt.Errorf("failed to count total photos: %w", err) - } - - // 已发布 - if err := r.db.Model(&models.Photo{}).Where("status = ?", models.StatusPublished). - Count(&stats.Published).Error; err != nil { - return nil, fmt.Errorf("failed to count published photos: %w", err) - } - - // 草稿 - if err := r.db.Model(&models.Photo{}).Where("status = ?", models.StatusDraft). - Count(&stats.Draft).Error; err != nil { - return nil, fmt.Errorf("failed to count draft photos: %w", err) - } - - // 已归档 - if err := r.db.Model(&models.Photo{}).Where("status = ?", models.StatusArchived). - Count(&stats.Archived).Error; err != nil { - return nil, fmt.Errorf("failed to count archived photos: %w", err) - } - - return &stats, nil -} \ No newline at end of file diff --git a/backend/internal/repository/postgres/photo_repository_impl.go b/backend/internal/repository/postgres/photo_repository_impl.go new file mode 100644 index 0000000..f3d83d9 --- /dev/null +++ b/backend/internal/repository/postgres/photo_repository_impl.go @@ -0,0 +1,375 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/interfaces" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +// photoRepositoryImpl 照片仓储实现 +type photoRepositoryImpl struct { + db *gorm.DB + logger *zap.Logger +} + +// NewPhotoRepository 创建照片仓储实现 +func NewPhotoRepository(db *gorm.DB, logger *zap.Logger) interfaces.PhotoRepository { + return &photoRepositoryImpl{ + db: db, + logger: logger, + } +} + +// Create 创建照片 +func (r *photoRepositoryImpl) Create(ctx context.Context, photo *entity.Photo) error { + return r.db.WithContext(ctx).Create(photo).Error +} + +// GetByID 根据ID获取照片 +func (r *photoRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.Photo, error) { + var photo entity.Photo + err := r.db.WithContext(ctx). + Preload("User"). + Preload("Categories"). + Preload("Tags"). + First(&photo, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("photo not found") + } + return nil, err + } + return &photo, nil +} + +// GetByFilename 根据文件名获取照片 +func (r *photoRepositoryImpl) GetByFilename(ctx context.Context, filename string) (*entity.Photo, error) { + var photo entity.Photo + err := r.db.WithContext(ctx).Where("filename = ?", filename).First(&photo).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("photo not found") + } + return nil, err + } + return &photo, nil +} + +// Update 更新照片 +func (r *photoRepositoryImpl) Update(ctx context.Context, photo *entity.Photo) error { + return r.db.WithContext(ctx).Save(photo).Error +} + +// Delete 删除照片 +func (r *photoRepositoryImpl) Delete(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Delete(&entity.Photo{}, id).Error +} + +// List 获取照片列表 +func (r *photoRepositoryImpl) List(ctx context.Context, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) { + var photos []*entity.Photo + var total int64 + + query := r.db.WithContext(ctx).Model(&entity.Photo{}) + + // 应用过滤条件 + if params.UserID != nil { + query = query.Where("user_id = ?", *params.UserID) + } + + if params.Status != nil { + query = query.Where("status = ?", *params.Status) + } + + if params.CategoryID != nil { + query = query.Joins("JOIN photo_categories ON photos.id = photo_categories.photo_id"). + Where("photo_categories.category_id = ?", *params.CategoryID) + } + + if params.TagID != nil { + query = query.Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id"). + Where("photo_tags.tag_id = ?", *params.TagID) + } + + if params.DateFrom != nil { + query = query.Where("taken_at >= ?", *params.DateFrom) + } + + if params.DateTo != nil { + query = query.Where("taken_at <= ?", *params.DateTo) + } + + if params.Search != "" { + query = query.Where("title ILIKE ? OR description ILIKE ?", + "%"+params.Search+"%", "%"+params.Search+"%") + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 应用排序 + orderBy := "created_at DESC" + if params.Sort != "" { + order := "ASC" + if params.Order == "desc" { + order = "DESC" + } + orderBy = fmt.Sprintf("%s %s", params.Sort, order) + } + query = query.Order(orderBy) + + // 应用分页 + if params.Page > 0 && params.Limit > 0 { + offset := (params.Page - 1) * params.Limit + query = query.Offset(offset).Limit(params.Limit) + } + + // 预加载关联数据 + query = query.Preload("User").Preload("Categories").Preload("Tags") + + // 查询数据 + if err := query.Find(&photos).Error; err != nil { + return nil, 0, err + } + + return photos, total, nil +} + +// ListByUserID 根据用户ID获取照片列表 +func (r *photoRepositoryImpl) ListByUserID(ctx context.Context, userID uint, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) { + if params == nil { + params = &entity.PhotoListParams{} + } + params.UserID = &userID + return r.List(ctx, params) +} + +// ListByStatus 根据状态获取照片列表 +func (r *photoRepositoryImpl) ListByStatus(ctx context.Context, status entity.PhotoStatus, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) { + if params == nil { + params = &entity.PhotoListParams{} + } + params.Status = &status + return r.List(ctx, params) +} + +// ListByCategory 根据分类获取照片列表 +func (r *photoRepositoryImpl) ListByCategory(ctx context.Context, categoryID uint, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) { + if params == nil { + params = &entity.PhotoListParams{} + } + params.CategoryID = &categoryID + return r.List(ctx, params) +} + +// Search 搜索照片 +func (r *photoRepositoryImpl) Search(ctx context.Context, query string, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) { + if params == nil { + params = &entity.PhotoListParams{} + } + params.Search = query + return r.List(ctx, params) +} + +// Count 统计照片总数 +func (r *photoRepositoryImpl) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Photo{}).Count(&count).Error + return count, err +} + +// CountByUser 统计用户照片数 +func (r *photoRepositoryImpl) CountByUser(ctx context.Context, userID uint) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("user_id = ?", userID).Count(&count).Error + return count, err +} + +// CountByStatus 统计指定状态照片数 +func (r *photoRepositoryImpl) CountByStatus(ctx context.Context, status entity.PhotoStatus) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("status = ?", status).Count(&count).Error + return count, err +} + +// CountByCategory 统计分类照片数 +func (r *photoRepositoryImpl) CountByCategory(ctx context.Context, categoryID uint) (int64, error) { + var count int64 + err := r.db.WithContext(ctx). + Table("photo_categories"). + Where("category_id = ?", categoryID). + Count(&count).Error + return count, err +} + +// CountByStatus 统计指定状态照片数 +func (r *photoRepositoryImpl) CountByStatus(ctx context.Context, status string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("status = ?", status).Count(&count).Error + return count, err +} + + +// BatchUpdate 批量更新 +func (r *photoRepositoryImpl) BatchUpdate(ctx context.Context, ids []uint, updates map[string]interface{}) error { + if len(ids) == 0 || len(updates) == 0 { + return nil + } + + return r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("id IN ?", ids). + Updates(updates).Error +} + +// BatchUpdateCategories 批量更新分类 +func (r *photoRepositoryImpl) BatchUpdateCategories(ctx context.Context, photoIDs []uint, categoryIDs []uint) error { + if len(photoIDs) == 0 { + return nil + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 删除现有关联 + if err := tx.Exec("DELETE FROM photo_categories WHERE photo_id IN ?", photoIDs).Error; err != nil { + return err + } + + // 添加新关联 + for _, photoID := range photoIDs { + for _, categoryID := range categoryIDs { + if err := tx.Exec("INSERT INTO photo_categories (photo_id, category_id) VALUES (?, ?)", + photoID, categoryID).Error; err != nil { + return err + } + } + } + return nil + }) +} + +// BatchUpdateTags 批量更新标签 +func (r *photoRepositoryImpl) BatchUpdateTags(ctx context.Context, photoIDs []uint, tagIDs []uint) error { + if len(photoIDs) == 0 { + return nil + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 删除现有关联 + if err := tx.Exec("DELETE FROM photo_tags WHERE photo_id IN ?", photoIDs).Error; err != nil { + return err + } + + // 添加新关联 + for _, photoID := range photoIDs { + for _, tagID := range tagIDs { + if err := tx.Exec("INSERT INTO photo_tags (photo_id, tag_id) VALUES (?, ?)", + photoID, tagID).Error; err != nil { + return err + } + } + } + return nil + }) +} + +// BatchDelete 批量删除 +func (r *photoRepositoryImpl) BatchDelete(ctx context.Context, ids []uint) error { + if len(ids) == 0 { + return nil + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 删除关联关系 + if err := tx.Exec("DELETE FROM photo_categories WHERE photo_id IN ?", ids).Error; err != nil { + return err + } + + if err := tx.Exec("DELETE FROM photo_tags WHERE photo_id IN ?", ids).Error; err != nil { + return err + } + + // 删除照片记录 + return tx.Delete(&entity.Photo{}, ids).Error + }) +} + +// GetStats 获取照片统计信息 +func (r *photoRepositoryImpl) GetStats(ctx context.Context) (*entity.PhotoStats, error) { + var stats entity.PhotoStats + + // 总照片数 + if total, err := r.Count(ctx); err != nil { + return nil, err + } else { + stats.Total = total + } + + // 按状态统计 + for _, status := range []entity.PhotoStatus{ + entity.PhotoStatusActive, + entity.PhotoStatusDraft, + entity.PhotoStatusArchived, + } { + if count, err := r.CountByStatus(ctx, status); err != nil { + return nil, err + } else { + switch status { + case entity.PhotoStatusActive: + stats.Published = count + case entity.PhotoStatusDraft: + stats.Draft = count + case entity.PhotoStatusArchived: + stats.Archived = count + } + } + } + + // 本月新增照片数 + now := time.Now() + startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + endOfMonth := startOfMonth.AddDate(0, 1, 0).Add(-time.Nanosecond) + + var monthlyCount int64 + if err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("created_at >= ? AND created_at <= ?", startOfMonth, endOfMonth). + Count(&monthlyCount).Error; err != nil { + return nil, err + } + stats.ThisMonth = monthlyCount + + // 用户照片分布(Top 10) + var userPhotoStats []struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + PhotoCount int64 `json:"photo_count"` + } + + if err := r.db.WithContext(ctx). + Table("photos"). + Select("photos.user_id, users.username, COUNT(photos.id) as photo_count"). + Joins("LEFT JOIN users ON photos.user_id = users.id"). + Group("photos.user_id, users.username"). + Order("photo_count DESC"). + Limit(10). + Find(&userPhotoStats).Error; err != nil { + return nil, err + } + + stats.UserPhotoCounts = make(map[string]int64) + for _, stat := range userPhotoStats { + stats.UserPhotoCounts[stat.Username] = stat.PhotoCount + } + + return &stats, nil +} \ No newline at end of file diff --git a/backend/internal/repository/postgres/tag_repository.go b/backend/internal/repository/postgres/tag_repository.go deleted file mode 100644 index 948a2db..0000000 --- a/backend/internal/repository/postgres/tag_repository.go +++ /dev/null @@ -1,217 +0,0 @@ -package postgres - -import ( - "fmt" - "photography-backend/internal/models" - "gorm.io/gorm" -) - -// TagRepository 标签仓库接口 -type TagRepository interface { - Create(tag *models.Tag) error - GetByID(id uint) (*models.Tag, error) - GetByName(name string) (*models.Tag, error) - Update(tag *models.Tag) error - Delete(id uint) error - List(params *models.TagListParams) ([]*models.Tag, int64, error) - Search(query string, limit int) ([]*models.Tag, error) - GetPopular(limit int) ([]*models.Tag, error) - GetOrCreate(name string) (*models.Tag, error) - IncrementUseCount(id uint) error - DecrementUseCount(id uint) error - GetCloud(minUsage int, maxTags int) ([]*models.Tag, error) -} - -// tagRepository 标签仓库实现 -type tagRepository struct { - db *gorm.DB -} - -// NewTagRepository 创建标签仓库 -func NewTagRepository(db *gorm.DB) TagRepository { - return &tagRepository{db: db} -} - -// Create 创建标签 -func (r *tagRepository) Create(tag *models.Tag) error { - if err := r.db.Create(tag).Error; err != nil { - return fmt.Errorf("failed to create tag: %w", err) - } - return nil -} - -// GetByID 根据ID获取标签 -func (r *tagRepository) GetByID(id uint) (*models.Tag, error) { - var tag models.Tag - if err := r.db.First(&tag, id).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get tag by id: %w", err) - } - return &tag, nil -} - -// GetByName 根据名称获取标签 -func (r *tagRepository) GetByName(name string) (*models.Tag, error) { - var tag models.Tag - if err := r.db.Where("name = ?", name).First(&tag).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get tag by name: %w", err) - } - return &tag, nil -} - -// Update 更新标签 -func (r *tagRepository) Update(tag *models.Tag) error { - if err := r.db.Save(tag).Error; err != nil { - return fmt.Errorf("failed to update tag: %w", err) - } - return nil -} - -// Delete 删除标签 -func (r *tagRepository) Delete(id uint) error { - // 开启事务 - tx := r.db.Begin() - - // 删除照片标签关联 - if err := tx.Exec("DELETE FROM photo_tags WHERE tag_id = ?", id).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to delete photo tag relations: %w", err) - } - - // 删除标签 - if err := tx.Delete(&models.Tag{}, id).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to delete tag: %w", err) - } - - return tx.Commit().Error -} - -// List 获取标签列表 -func (r *tagRepository) List(params *models.TagListParams) ([]*models.Tag, int64, error) { - var tags []*models.Tag - var total int64 - - query := r.db.Model(&models.Tag{}) - - // 添加过滤条件 - if params.Search != "" { - query = query.Where("name ILIKE ?", "%"+params.Search+"%") - } - - if params.IsActive { - query = query.Where("is_active = ?", true) - } - - // 计算总数 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count tags: %w", err) - } - - // 排序 - orderClause := fmt.Sprintf("%s %s", params.SortBy, params.SortOrder) - - // 分页查询 - offset := (params.Page - 1) * params.Limit - if err := query.Offset(offset).Limit(params.Limit). - Order(orderClause). - Find(&tags).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list tags: %w", err) - } - - return tags, total, nil -} - -// Search 搜索标签 -func (r *tagRepository) Search(query string, limit int) ([]*models.Tag, error) { - var tags []*models.Tag - - if err := r.db.Where("name ILIKE ? AND is_active = ?", "%"+query+"%", true). - Order("use_count DESC"). - Limit(limit). - Find(&tags).Error; err != nil { - return nil, fmt.Errorf("failed to search tags: %w", err) - } - - return tags, nil -} - -// GetPopular 获取热门标签 -func (r *tagRepository) GetPopular(limit int) ([]*models.Tag, error) { - var tags []*models.Tag - - if err := r.db.Where("is_active = ?", true). - Order("use_count DESC"). - Limit(limit). - Find(&tags).Error; err != nil { - return nil, fmt.Errorf("failed to get popular tags: %w", err) - } - - return tags, nil -} - -// GetOrCreate 获取或创建标签 -func (r *tagRepository) GetOrCreate(name string) (*models.Tag, error) { - var tag models.Tag - - // 先尝试获取 - if err := r.db.Where("name = ?", name).First(&tag).Error; err != nil { - if err == gorm.ErrRecordNotFound { - // 不存在则创建 - tag = models.Tag{ - Name: name, - UseCount: 0, - IsActive: true, - } - if err := r.db.Create(&tag).Error; err != nil { - return nil, fmt.Errorf("failed to create tag: %w", err) - } - } else { - return nil, fmt.Errorf("failed to get tag: %w", err) - } - } - - return &tag, nil -} - -// IncrementUseCount 增加使用次数 -func (r *tagRepository) IncrementUseCount(id uint) error { - if err := r.db.Model(&models.Tag{}).Where("id = ?", id). - Update("use_count", gorm.Expr("use_count + 1")).Error; err != nil { - return fmt.Errorf("failed to increment use count: %w", err) - } - return nil -} - -// DecrementUseCount 减少使用次数 -func (r *tagRepository) DecrementUseCount(id uint) error { - if err := r.db.Model(&models.Tag{}).Where("id = ?", id). - Update("use_count", gorm.Expr("GREATEST(use_count - 1, 0)")).Error; err != nil { - return fmt.Errorf("failed to decrement use count: %w", err) - } - return nil -} - -// GetCloud 获取标签云数据 -func (r *tagRepository) GetCloud(minUsage int, maxTags int) ([]*models.Tag, error) { - var tags []*models.Tag - - query := r.db.Where("is_active = ?", true) - - if minUsage > 0 { - query = query.Where("use_count >= ?", minUsage) - } - - if err := query.Order("use_count DESC"). - Limit(maxTags). - Find(&tags).Error; err != nil { - return nil, fmt.Errorf("failed to get tag cloud: %w", err) - } - - return tags, nil -} \ No newline at end of file diff --git a/backend/internal/repository/postgres/tag_repository_impl.go b/backend/internal/repository/postgres/tag_repository_impl.go new file mode 100644 index 0000000..3ec5c80 --- /dev/null +++ b/backend/internal/repository/postgres/tag_repository_impl.go @@ -0,0 +1,468 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "strings" + + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/interfaces" + "photography-backend/internal/utils" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +// tagRepositoryImpl 标签仓储实现 +type tagRepositoryImpl struct { + db *gorm.DB + logger *zap.Logger +} + +// NewTagRepository 创建标签仓储实现 +func NewTagRepository(db *gorm.DB, logger *zap.Logger) interfaces.TagRepository { + return &tagRepositoryImpl{ + db: db, + logger: logger, + } +} + +// Create 创建标签 +func (r *tagRepositoryImpl) Create(ctx context.Context, tag *entity.Tag) error { + return r.db.WithContext(ctx).Create(tag).Error +} + +// GetByID 根据ID获取标签 +func (r *tagRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.Tag, error) { + var tag entity.Tag + err := r.db.WithContext(ctx).First(&tag, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("tag not found") + } + return nil, err + } + return &tag, nil +} + +// GetBySlug 根据slug获取标签 +func (r *tagRepositoryImpl) GetBySlug(ctx context.Context, slug string) (*entity.Tag, error) { + var tag entity.Tag + err := r.db.WithContext(ctx).Where("slug = ?", slug).First(&tag).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("tag not found") + } + return nil, err + } + return &tag, nil +} + +// GetByName 根据名称获取标签 +func (r *tagRepositoryImpl) GetByName(ctx context.Context, name string) (*entity.Tag, error) { + var tag entity.Tag + err := r.db.WithContext(ctx).Where("name = ?", name).First(&tag).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("tag not found") + } + return nil, err + } + return &tag, nil +} + +// Update 更新标签 +func (r *tagRepositoryImpl) Update(ctx context.Context, tag *entity.Tag) error { + return r.db.WithContext(ctx).Save(tag).Error +} + +// Delete 删除标签 +func (r *tagRepositoryImpl) Delete(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 删除照片标签关联 + if err := tx.Exec("DELETE FROM photo_tags WHERE tag_id = ?", id).Error; err != nil { + return err + } + + // 删除标签 + return tx.Delete(&entity.Tag{}, id).Error + }) +} + +// List 获取标签列表 +func (r *tagRepositoryImpl) List(ctx context.Context, params *entity.TagListParams) ([]*entity.Tag, int64, error) { + var tags []*entity.Tag + var total int64 + + query := r.db.WithContext(ctx).Model(&entity.Tag{}) + + // 应用过滤条件 + if params.Search != "" { + query = query.Where("name ILIKE ? OR description ILIKE ?", + "%"+params.Search+"%", "%"+params.Search+"%") + } + + if params.Color != "" { + query = query.Where("color = ?", params.Color) + } + + if params.CreatedFrom != nil { + query = query.Where("created_at >= ?", *params.CreatedFrom) + } + + if params.CreatedTo != nil { + query = query.Where("created_at <= ?", *params.CreatedTo) + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 应用排序 + orderBy := "created_at DESC" + if params.Sort != "" { + order := "ASC" + if params.Order == "desc" { + order = "DESC" + } + orderBy = fmt.Sprintf("%s %s", params.Sort, order) + } + query = query.Order(orderBy) + + // 应用分页 + if params.Page > 0 && params.Limit > 0 { + offset := (params.Page - 1) * params.Limit + query = query.Offset(offset).Limit(params.Limit) + } + + // 如果需要包含照片计数 + if params.IncludePhotoCount { + query = query.Select("tags.*, COUNT(photo_tags.photo_id) as photo_count"). + Joins("LEFT JOIN photo_tags ON tags.id = photo_tags.tag_id"). + Group("tags.id") + } + + // 查询数据 + if err := query.Find(&tags).Error; err != nil { + return nil, 0, err + } + + return tags, total, nil +} + +// Search 搜索标签 +func (r *tagRepositoryImpl) Search(ctx context.Context, query string) ([]*entity.Tag, error) { + var tags []*entity.Tag + + err := r.db.WithContext(ctx). + Where("name ILIKE ? OR description ILIKE ?", "%"+query+"%", "%"+query+"%"). + Order("name ASC"). + Limit(50). + Find(&tags).Error + + return tags, err +} + +// GetPopular 获取热门标签 +func (r *tagRepositoryImpl) GetPopular(ctx context.Context, limit int) ([]*entity.Tag, error) { + var tags []*entity.Tag + + err := r.db.WithContext(ctx). + Select("tags.*, COUNT(photo_tags.photo_id) as photo_count"). + Joins("LEFT JOIN photo_tags ON tags.id = photo_tags.tag_id"). + Group("tags.id"). + Order("photo_count DESC"). + Limit(limit). + Find(&tags).Error + + return tags, err +} + +// GetByPhotos 根据照片IDs获取标签 +func (r *tagRepositoryImpl) GetByPhotos(ctx context.Context, photoIDs []uint) ([]*entity.Tag, error) { + if len(photoIDs) == 0 { + return []*entity.Tag{}, nil + } + + var tags []*entity.Tag + + err := r.db.WithContext(ctx). + Joins("JOIN photo_tags ON tags.id = photo_tags.tag_id"). + Where("photo_tags.photo_id IN ?", photoIDs). + Distinct(). + Find(&tags).Error + + return tags, err +} + +// CreateMultiple 批量创建标签 +func (r *tagRepositoryImpl) CreateMultiple(ctx context.Context, tags []*entity.Tag) error { + if len(tags) == 0 { + return nil + } + + return r.db.WithContext(ctx).CreateInBatches(tags, 100).Error +} + +// GetOrCreateByNames 根据名称获取或创建标签 +func (r *tagRepositoryImpl) GetOrCreateByNames(ctx context.Context, names []string) ([]*entity.Tag, error) { + if len(names) == 0 { + return []*entity.Tag{}, nil + } + + var tags []*entity.Tag + + return tags, r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + + var tag entity.Tag + + // 尝试获取现有标签 + err := tx.Where("name = ?", name).First(&tag).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 创建新标签 + slug, err := r.generateUniqueSlug(ctx, name) + if err != nil { + return err + } + + tag = entity.Tag{ + Name: name, + Slug: slug, + Color: r.generateRandomColor(), + PhotoCount: 0, + } + + if err := tx.Create(&tag).Error; err != nil { + return err + } + } else { + return err + } + } + + tags = append(tags, &tag) + } + + return nil + }) +} + +// BatchDelete 批量删除标签 +func (r *tagRepositoryImpl) BatchDelete(ctx context.Context, ids []uint) error { + if len(ids) == 0 { + return nil + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 删除照片标签关联 + if err := tx.Exec("DELETE FROM photo_tags WHERE tag_id IN ?", ids).Error; err != nil { + return err + } + + // 删除标签 + return tx.Delete(&entity.Tag{}, ids).Error + }) +} + +// AttachToPhoto 为照片添加标签 +func (r *tagRepositoryImpl) AttachToPhoto(ctx context.Context, tagID, photoID uint) error { + // 检查关联是否已存在 + var count int64 + if err := r.db.WithContext(ctx).Table("photo_tags"). + Where("tag_id = ? AND photo_id = ?", tagID, photoID). + Count(&count).Error; err != nil { + return err + } + + if count > 0 { + return nil // 关联已存在 + } + + // 创建关联 + return r.db.WithContext(ctx).Exec( + "INSERT INTO photo_tags (tag_id, photo_id) VALUES (?, ?)", + tagID, photoID, + ).Error +} + +// DetachFromPhoto 从照片移除标签 +func (r *tagRepositoryImpl) DetachFromPhoto(ctx context.Context, tagID, photoID uint) error { + return r.db.WithContext(ctx).Exec( + "DELETE FROM photo_tags WHERE tag_id = ? AND photo_id = ?", + tagID, photoID, + ).Error +} + +// GetPhotoTags 获取照片的标签 +func (r *tagRepositoryImpl) GetPhotoTags(ctx context.Context, photoID uint) ([]*entity.Tag, error) { + var tags []*entity.Tag + + err := r.db.WithContext(ctx). + Joins("JOIN photo_tags ON tags.id = photo_tags.tag_id"). + Where("photo_tags.photo_id = ?", photoID). + Order("tags.name ASC"). + Find(&tags).Error + + return tags, err +} + +// Count 统计标签总数 +func (r *tagRepositoryImpl) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.Tag{}).Count(&count).Error + return count, err +} + +// CountByPhotos 统计各标签的照片数量 +func (r *tagRepositoryImpl) CountByPhotos(ctx context.Context) (map[uint]int64, error) { + var results []struct { + TagID uint `json:"tag_id"` + PhotoCount int64 `json:"photo_count"` + } + + err := r.db.WithContext(ctx). + Table("photo_tags"). + Select("tag_id, COUNT(photo_id) as photo_count"). + Group("tag_id"). + Find(&results).Error + + if err != nil { + return nil, err + } + + counts := make(map[uint]int64) + for _, result := range results { + counts[result.TagID] = result.PhotoCount + } + + return counts, nil +} + +// GetStats 获取标签统计信息 +func (r *tagRepositoryImpl) GetStats(ctx context.Context) (*entity.TagStats, error) { + var stats entity.TagStats + + // 总标签数 + if total, err := r.Count(ctx); err != nil { + return nil, err + } else { + stats.Total = total + } + + // 已使用标签数(有照片关联的标签) + var usedCount int64 + if err := r.db.WithContext(ctx). + Table("tags"). + Joins("JOIN photo_tags ON tags.id = photo_tags.tag_id"). + Distinct("tags.id"). + Count(&usedCount).Error; err != nil { + return nil, err + } + stats.Used = usedCount + + // 未使用标签数 + stats.Unused = stats.Total - stats.Used + + // 平均每个标签的照片数 + if stats.Used > 0 { + var totalPhotos int64 + if err := r.db.WithContext(ctx). + Table("photo_tags"). + Count(&totalPhotos).Error; err != nil { + return nil, err + } + stats.AvgPhotosPerTag = float64(totalPhotos) / float64(stats.Used) + } + + // 最受欢迎的标签(前10) + var popularTags []struct { + TagID uint `json:"tag_id"` + Name string `json:"name"` + PhotoCount int64 `json:"photo_count"` + } + + if err := r.db.WithContext(ctx). + Table("tags"). + Select("tags.id as tag_id, tags.name, COUNT(photo_tags.photo_id) as photo_count"). + Joins("LEFT JOIN photo_tags ON tags.id = photo_tags.tag_id"). + Group("tags.id, tags.name"). + Order("photo_count DESC"). + Limit(10). + Find(&popularTags).Error; err != nil { + return nil, err + } + + stats.PopularTags = make(map[string]int64) + for _, tag := range popularTags { + stats.PopularTags[tag.Name] = tag.PhotoCount + } + + return &stats, nil +} + +// GenerateUniqueSlug 生成唯一slug +func (r *tagRepositoryImpl) GenerateUniqueSlug(ctx context.Context, baseName string) (string, error) { + return r.generateUniqueSlug(ctx, baseName) +} + +// ValidateSlugUnique 验证slug唯一性 +func (r *tagRepositoryImpl) ValidateSlugUnique(ctx context.Context, slug string, excludeID uint) error { + var count int64 + query := r.db.WithContext(ctx).Model(&entity.Tag{}).Where("slug = ?", slug) + + if excludeID > 0 { + query = query.Where("id != ?", excludeID) + } + + if err := query.Count(&count).Error; err != nil { + return err + } + + if count > 0 { + return errors.New("slug already exists") + } + + return nil +} + +// generateUniqueSlug 生成唯一slug +func (r *tagRepositoryImpl) generateUniqueSlug(ctx context.Context, baseName string) (string, error) { + baseSlug := utils.GenerateSlug(baseName) + slug := baseSlug + + counter := 1 + for { + var count int64 + if err := r.db.WithContext(ctx).Model(&entity.Tag{}). + Where("slug = ?", slug).Count(&count).Error; err != nil { + return "", err + } + + if count == 0 { + break + } + + slug = fmt.Sprintf("%s-%d", baseSlug, counter) + counter++ + } + + return slug, nil +} + +// generateRandomColor 生成随机颜色 +func (r *tagRepositoryImpl) generateRandomColor() string { + colors := []string{ + "#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", + "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9", + "#F8C471", "#82E0AA", "#F1948A", "#85C1E9", "#D7BDE2", + } + return colors[len(colors)%15] // 简单的颜色选择 +} \ No newline at end of file diff --git a/backend/internal/repository/postgres/user_repository.go b/backend/internal/repository/postgres/user_repository.go deleted file mode 100644 index 6dc81df..0000000 --- a/backend/internal/repository/postgres/user_repository.go +++ /dev/null @@ -1,129 +0,0 @@ -package postgres - -import ( - "fmt" - "photography-backend/internal/models" - "gorm.io/gorm" -) - -// UserRepository 用户仓库接口 -type UserRepository interface { - Create(user *models.User) error - GetByID(id uint) (*models.User, error) - GetByUsername(username string) (*models.User, error) - GetByEmail(email string) (*models.User, error) - Update(user *models.User) error - Delete(id uint) error - List(page, limit int, role string, isActive *bool) ([]*models.User, int64, error) - UpdateLastLogin(id uint) error -} - -// userRepository 用户仓库实现 -type userRepository struct { - db *gorm.DB -} - -// NewUserRepository 创建用户仓库 -func NewUserRepository(db *gorm.DB) UserRepository { - return &userRepository{db: db} -} - -// Create 创建用户 -func (r *userRepository) Create(user *models.User) error { - if err := r.db.Create(user).Error; err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - return nil -} - -// GetByID 根据ID获取用户 -func (r *userRepository) GetByID(id uint) (*models.User, error) { - var user models.User - if err := r.db.First(&user, id).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get user by id: %w", err) - } - return &user, nil -} - -// GetByUsername 根据用户名获取用户 -func (r *userRepository) GetByUsername(username string) (*models.User, error) { - var user models.User - if err := r.db.Where("username = ?", username).First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get user by username: %w", err) - } - return &user, nil -} - -// GetByEmail 根据邮箱获取用户 -func (r *userRepository) GetByEmail(email string) (*models.User, error) { - var user models.User - if err := r.db.Where("email = ?", email).First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get user by email: %w", err) - } - return &user, nil -} - -// Update 更新用户 -func (r *userRepository) Update(user *models.User) error { - if err := r.db.Save(user).Error; err != nil { - return fmt.Errorf("failed to update user: %w", err) - } - return nil -} - -// Delete 删除用户 -func (r *userRepository) Delete(id uint) error { - if err := r.db.Delete(&models.User{}, id).Error; err != nil { - return fmt.Errorf("failed to delete user: %w", err) - } - return nil -} - -// List 获取用户列表 -func (r *userRepository) List(page, limit int, role string, isActive *bool) ([]*models.User, int64, error) { - var users []*models.User - var total int64 - - query := r.db.Model(&models.User{}) - - // 添加过滤条件 - if role != "" { - query = query.Where("role = ?", role) - } - if isActive != nil { - query = query.Where("is_active = ?", *isActive) - } - - // 计算总数 - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count users: %w", err) - } - - // 分页查询 - offset := (page - 1) * limit - if err := query.Offset(offset).Limit(limit). - Order("created_at DESC"). - Find(&users).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list users: %w", err) - } - - return users, total, nil -} - -// UpdateLastLogin 更新最后登录时间 -func (r *userRepository) UpdateLastLogin(id uint) error { - if err := r.db.Model(&models.User{}).Where("id = ?", id). - Update("last_login", gorm.Expr("NOW()")).Error; err != nil { - return fmt.Errorf("failed to update last login: %w", err) - } - return nil -} \ No newline at end of file diff --git a/backend/internal/repository/postgres/user_repository_impl.go b/backend/internal/repository/postgres/user_repository_impl.go new file mode 100644 index 0000000..125a60e --- /dev/null +++ b/backend/internal/repository/postgres/user_repository_impl.go @@ -0,0 +1,516 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + "time" + + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/interfaces" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +// userRepositoryImpl 用户仓储实现 +type userRepositoryImpl struct { + db *gorm.DB + logger *zap.Logger +} + +// NewUserRepository 创建用户仓储实现 +func NewUserRepository(db *gorm.DB, logger *zap.Logger) interfaces.UserRepository { + return &userRepositoryImpl{ + db: db, + logger: logger, + } +} + +// Create 创建用户 +func (r *userRepositoryImpl) Create(ctx context.Context, user *entity.User) error { + return r.db.WithContext(ctx).Create(user).Error +} + +// GetByID 根据ID获取用户 +func (r *userRepositoryImpl) GetByID(ctx context.Context, id uint) (*entity.User, error) { + var user entity.User + err := r.db.WithContext(ctx).First(&user, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// GetByEmail 根据邮箱获取用户 +func (r *userRepositoryImpl) GetByEmail(ctx context.Context, email string) (*entity.User, error) { + var user entity.User + err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// GetByUsername 根据用户名获取用户 +func (r *userRepositoryImpl) GetByUsername(ctx context.Context, username string) (*entity.User, error) { + var user entity.User + err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// GetByEmailOrUsername 根据邮箱或用户名获取用户 +func (r *userRepositoryImpl) GetByEmailOrUsername(ctx context.Context, emailOrUsername string) (*entity.User, error) { + var user entity.User + err := r.db.WithContext(ctx). + Where("email = ? OR username = ?", emailOrUsername, emailOrUsername). + First(&user).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("user not found") + } + return nil, err + } + return &user, nil +} + +// Update 更新用户 +func (r *userRepositoryImpl) Update(ctx context.Context, user *entity.User) error { + return r.db.WithContext(ctx).Save(user).Error +} + +// Delete 删除用户 +func (r *userRepositoryImpl) Delete(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Delete(&entity.User{}, id).Error +} + +// List 获取用户列表 +func (r *userRepositoryImpl) List(ctx context.Context, params *entity.UserListParams) ([]*entity.User, int64, error) { + var users []*entity.User + var total int64 + + query := r.db.WithContext(ctx).Model(&entity.User{}) + + // 应用过滤条件 + if params.Role != nil { + query = query.Where("role = ?", *params.Role) + } + + if params.Status != nil { + query = query.Where("status = ?", *params.Status) + } + + if params.Search != "" { + query = query.Where("username ILIKE ? OR email ILIKE ? OR first_name ILIKE ? OR last_name ILIKE ?", + "%"+params.Search+"%", "%"+params.Search+"%", "%"+params.Search+"%", "%"+params.Search+"%") + } + + if params.CreatedFrom != nil { + query = query.Where("created_at >= ?", *params.CreatedFrom) + } + + if params.CreatedTo != nil { + query = query.Where("created_at <= ?", *params.CreatedTo) + } + + if params.LastLoginFrom != nil { + query = query.Where("last_login_at >= ?", *params.LastLoginFrom) + } + + if params.LastLoginTo != nil { + query = query.Where("last_login_at <= ?", *params.LastLoginTo) + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 应用排序 + orderBy := "created_at DESC" + if params.Sort != "" { + order := "ASC" + if params.Order == "desc" { + order = "DESC" + } + orderBy = fmt.Sprintf("%s %s", params.Sort, order) + } + query = query.Order(orderBy) + + // 应用分页 + if params.Page > 0 && params.Limit > 0 { + offset := (params.Page - 1) * params.Limit + query = query.Offset(offset).Limit(params.Limit) + } + + // 查询数据 + if err := query.Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +// ListByRole 根据角色获取用户列表 +func (r *userRepositoryImpl) ListByRole(ctx context.Context, role entity.UserRole, params *entity.UserListParams) ([]*entity.User, int64, error) { + if params == nil { + params = &entity.UserListParams{} + } + params.Role = &role + return r.List(ctx, params) +} + +// ListByStatus 根据状态获取用户列表 +func (r *userRepositoryImpl) ListByStatus(ctx context.Context, status entity.UserStatus, params *entity.UserListParams) ([]*entity.User, int64, error) { + if params == nil { + params = &entity.UserListParams{} + } + params.Status = &status + return r.List(ctx, params) +} + +// SearchUsers 搜索用户 +func (r *userRepositoryImpl) SearchUsers(ctx context.Context, keyword string, params *entity.UserListParams) ([]*entity.User, int64, error) { + if params == nil { + params = &entity.UserListParams{} + } + params.Search = keyword + return r.List(ctx, params) +} + +// Count 统计用户总数 +func (r *userRepositoryImpl) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.User{}).Count(&count).Error + return count, err +} + +// CountByRole 根据角色统计用户数 +func (r *userRepositoryImpl) CountByRole(ctx context.Context, role entity.UserRole) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.User{}). + Where("role = ?", role).Count(&count).Error + return count, err +} + +// CountByStatus 根据状态统计用户数 +func (r *userRepositoryImpl) CountByStatus(ctx context.Context, status entity.UserStatus) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.User{}). + Where("status = ?", status).Count(&count).Error + return count, err +} + +// CountActiveUsers 统计活跃用户数 +func (r *userRepositoryImpl) CountActiveUsers(ctx context.Context) (int64, error) { + return r.CountByStatus(ctx, entity.UserStatusActive) +} + +// UpdateStatus 更新用户状态 +func (r *userRepositoryImpl) UpdateStatus(ctx context.Context, id uint, status entity.UserStatus) error { + return r.db.WithContext(ctx).Model(&entity.User{}). + Where("id = ?", id). + Update("status", status).Error +} + +// UpdateLastLogin 更新最后登录时间 +func (r *userRepositoryImpl) UpdateLastLogin(ctx context.Context, id uint) error { + now := time.Now() + return r.db.WithContext(ctx).Model(&entity.User{}). + Where("id = ?", id). + Update("last_login_at", now).Error +} + +// UpdatePassword 更新密码 +func (r *userRepositoryImpl) UpdatePassword(ctx context.Context, id uint, hashedPassword string) error { + return r.db.WithContext(ctx).Model(&entity.User{}). + Where("id = ?", id). + Update("password", hashedPassword).Error +} + +// SoftDelete 软删除用户 +func (r *userRepositoryImpl) SoftDelete(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Delete(&entity.User{}, id).Error +} + +// Restore 恢复软删除用户 +func (r *userRepositoryImpl) Restore(ctx context.Context, id uint) error { + return r.db.WithContext(ctx).Unscoped().Model(&entity.User{}). + Where("id = ?", id). + Update("deleted_at", nil).Error +} + +// BatchUpdateStatus 批量更新用户状态 +func (r *userRepositoryImpl) BatchUpdateStatus(ctx context.Context, ids []uint, status entity.UserStatus) error { + if len(ids) == 0 { + return nil + } + + return r.db.WithContext(ctx).Model(&entity.User{}). + Where("id IN ?", ids). + Update("status", status).Error +} + +// BatchDelete 批量删除用户 +func (r *userRepositoryImpl) BatchDelete(ctx context.Context, ids []uint) error { + if len(ids) == 0 { + return nil + } + + return r.db.WithContext(ctx).Delete(&entity.User{}, ids).Error +} + +// ValidateEmailUnique 验证邮箱唯一性 +func (r *userRepositoryImpl) ValidateEmailUnique(ctx context.Context, email string, excludeID uint) error { + var count int64 + query := r.db.WithContext(ctx).Model(&entity.User{}).Where("email = ?", email) + + if excludeID > 0 { + query = query.Where("id != ?", excludeID) + } + + if err := query.Count(&count).Error; err != nil { + return err + } + + if count > 0 { + return errors.New("email already exists") + } + + return nil +} + +// ValidateUsernameUnique 验证用户名唯一性 +func (r *userRepositoryImpl) ValidateUsernameUnique(ctx context.Context, username string, excludeID uint) error { + var count int64 + query := r.db.WithContext(ctx).Model(&entity.User{}).Where("username = ?", username) + + if excludeID > 0 { + query = query.Where("id != ?", excludeID) + } + + if err := query.Count(&count).Error; err != nil { + return err + } + + if count > 0 { + return errors.New("username already exists") + } + + return nil +} + +// GetUserPhotos 获取用户照片 +func (r *userRepositoryImpl) GetUserPhotos(ctx context.Context, userID uint, params *entity.PhotoListParams) ([]*entity.Photo, int64, error) { + var photos []*entity.Photo + var total int64 + + query := r.db.WithContext(ctx).Model(&entity.Photo{}).Where("user_id = ?", userID) + + // 应用过滤条件 + if params != nil { + if params.Status != nil { + query = query.Where("status = ?", *params.Status) + } + + if params.CategoryID != nil { + query = query.Joins("JOIN photo_categories ON photos.id = photo_categories.photo_id"). + Where("photo_categories.category_id = ?", *params.CategoryID) + } + + if params.TagID != nil { + query = query.Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id"). + Where("photo_tags.tag_id = ?", *params.TagID) + } + + if params.DateFrom != nil { + query = query.Where("taken_at >= ?", *params.DateFrom) + } + + if params.DateTo != nil { + query = query.Where("taken_at <= ?", *params.DateTo) + } + + if params.Search != "" { + query = query.Where("title ILIKE ? OR description ILIKE ?", + "%"+params.Search+"%", "%"+params.Search+"%") + } + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 应用排序和分页 + if params != nil { + orderBy := "created_at DESC" + if params.Sort != "" { + order := "ASC" + if params.Order == "desc" { + order = "DESC" + } + orderBy = fmt.Sprintf("%s %s", params.Sort, order) + } + query = query.Order(orderBy) + + if params.Page > 0 && params.Limit > 0 { + offset := (params.Page - 1) * params.Limit + query = query.Offset(offset).Limit(params.Limit) + } + } + + // 预加载关联数据 + query = query.Preload("Categories").Preload("Tags") + + // 查询数据 + if err := query.Find(&photos).Error; err != nil { + return nil, 0, err + } + + return photos, total, nil +} + +// GetUserStats 获取用户统计信息 +func (r *userRepositoryImpl) GetUserStats(ctx context.Context, userID uint) (*entity.UserStats, error) { + var stats entity.UserStats + + // 照片统计 + var photoCount int64 + if err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("user_id = ?", userID).Count(&photoCount).Error; err != nil { + return nil, err + } + stats.PhotoCount = photoCount + + // 按状态统计照片 + for _, status := range []entity.PhotoStatus{ + entity.PhotoStatusActive, + entity.PhotoStatusDraft, + entity.PhotoStatusArchived, + } { + var count int64 + if err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("user_id = ? AND status = ?", userID, status). + Count(&count).Error; err != nil { + return nil, err + } + + switch status { + case entity.PhotoStatusActive: + stats.PublishedPhotos = count + case entity.PhotoStatusDraft: + stats.DraftPhotos = count + case entity.PhotoStatusArchived: + stats.ArchivedPhotos = count + } + } + + // 总浏览数 + var totalViews int64 + if err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("user_id = ?", userID). + Select("COALESCE(SUM(view_count), 0)").Row().Scan(&totalViews); err != nil { + return nil, err + } + stats.TotalViews = totalViews + + // 总下载数 + var totalDownloads int64 + if err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("user_id = ?", userID). + Select("COALESCE(SUM(download_count), 0)").Row().Scan(&totalDownloads); err != nil { + return nil, err + } + stats.TotalDownloads = totalDownloads + + // 存储空间使用 + var storageUsed int64 + if err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("user_id = ?", userID). + Select("COALESCE(SUM(file_size), 0)").Row().Scan(&storageUsed); err != nil { + return nil, err + } + stats.StorageUsed = storageUsed + + // 本月新增照片 + now := time.Now() + startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + endOfMonth := startOfMonth.AddDate(0, 1, 0).Add(-time.Nanosecond) + + var monthlyPhotos int64 + if err := r.db.WithContext(ctx).Model(&entity.Photo{}). + Where("user_id = ? AND created_at >= ? AND created_at <= ?", userID, startOfMonth, endOfMonth). + Count(&monthlyPhotos).Error; err != nil { + return nil, err + } + stats.MonthlyPhotos = monthlyPhotos + + return &stats, nil +} + +// GetAllStats 获取全部用户统计信息 +func (r *userRepositoryImpl) GetAllStats(ctx context.Context) (*entity.UserGlobalStats, error) { + var stats entity.UserGlobalStats + + // 总用户数 + if total, err := r.Count(ctx); err != nil { + return nil, err + } else { + stats.Total = total + } + + // 活跃用户数 + if active, err := r.CountActiveUsers(ctx); err != nil { + return nil, err + } else { + stats.Active = active + } + + // 按角色统计 + for _, role := range []entity.UserRole{ + entity.UserRoleAdmin, + entity.UserRoleEditor, + entity.UserRoleUser, + } { + if count, err := r.CountByRole(ctx, role); err != nil { + return nil, err + } else { + switch role { + case entity.UserRoleAdmin: + stats.Admins = count + case entity.UserRoleEditor: + stats.Editors = count + case entity.UserRoleUser: + stats.Users = count + } + } + } + + // 本月新注册用户 + now := time.Now() + startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + endOfMonth := startOfMonth.AddDate(0, 1, 0).Add(-time.Nanosecond) + + var monthlyUsers int64 + if err := r.db.WithContext(ctx).Model(&entity.User{}). + Where("created_at >= ? AND created_at <= ?", startOfMonth, endOfMonth). + Count(&monthlyUsers).Error; err != nil { + return nil, err + } + stats.MonthlyRegistrations = monthlyUsers + + return &stats, nil +} \ No newline at end of file diff --git a/backend/internal/service/auth/auth_service.go b/backend/internal/service/auth/auth_service.go index 1d8fb6c..ffb14fc 100644 --- a/backend/internal/service/auth/auth_service.go +++ b/backend/internal/service/auth/auth_service.go @@ -3,7 +3,8 @@ package auth import ( "fmt" "golang.org/x/crypto/bcrypt" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/model/dto" "photography-backend/internal/repository/postgres" ) @@ -22,23 +23,15 @@ func NewAuthService(userRepo postgres.UserRepository, jwtService *JWTService) *A } // Login 用户登录 -func (s *AuthService) Login(req *models.LoginRequest) (*models.LoginResponse, error) { +func (s *AuthService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) { // 根据用户名或邮箱查找用户 - var user *models.User + var user *entity.User var err error - // 尝试按用户名查找 - user, err = s.userRepo.GetByUsername(req.Username) + // 按邮箱查找用户 + user, err = s.userRepo.GetByEmail(req.Email) if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - // 如果用户名未找到,尝试按邮箱查找 - if user == nil { - user, err = s.userRepo.GetByEmail(req.Username) - if err != nil { - return nil, fmt.Errorf("failed to get user by email: %w", err) - } + return nil, fmt.Errorf("failed to get user by email: %w", err) } if user == nil { @@ -56,7 +49,7 @@ func (s *AuthService) Login(req *models.LoginRequest) (*models.LoginResponse, er } // 生成JWT令牌 - tokenPair, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role) + tokenPair, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, string(user.Role)) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } @@ -70,17 +63,19 @@ func (s *AuthService) Login(req *models.LoginRequest) (*models.LoginResponse, er // 清除密码字段 user.Password = "" - return &models.LoginResponse{ - AccessToken: tokenPair.AccessToken, - RefreshToken: tokenPair.RefreshToken, - TokenType: tokenPair.TokenType, - ExpiresIn: tokenPair.ExpiresIn, - User: user, + return &dto.LoginResponse{ + Token: dto.TokenResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + TokenType: tokenPair.TokenType, + ExpiresIn: tokenPair.ExpiresIn, + }, + User: *dto.ConvertToUserResponse(user), }, nil } // Register 用户注册 -func (s *AuthService) Register(req *models.CreateUserRequest) (*models.User, error) { +func (s *AuthService) Register(req *dto.CreateUserRequest) (*entity.User, error) { // 检查用户名是否已存在 existingUser, err := s.userRepo.GetByUsername(req.Username) if err != nil { @@ -106,7 +101,7 @@ func (s *AuthService) Register(req *models.CreateUserRequest) (*models.User, err } // 创建用户 - user := &models.User{ + user := &entity.User{ Username: req.Username, Email: req.Email, Password: string(hashedPassword), @@ -117,7 +112,7 @@ func (s *AuthService) Register(req *models.CreateUserRequest) (*models.User, err // 如果没有指定角色,默认为普通用户 if user.Role == "" { - user.Role = models.RoleUser + user.Role = entity.UserRoleUser } if err := s.userRepo.Create(user); err != nil { @@ -131,7 +126,7 @@ func (s *AuthService) Register(req *models.CreateUserRequest) (*models.User, err } // RefreshToken 刷新令牌 -func (s *AuthService) RefreshToken(req *models.RefreshTokenRequest) (*models.LoginResponse, error) { +func (s *AuthService) RefreshToken(req *dto.RefreshTokenRequest) (*dto.LoginResponse, error) { // 验证刷新令牌 claims, err := s.jwtService.ValidateToken(req.RefreshToken) if err != nil { @@ -154,7 +149,7 @@ func (s *AuthService) RefreshToken(req *models.RefreshTokenRequest) (*models.Log } // 生成新的令牌对 - tokenPair, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, user.Role) + tokenPair, err := s.jwtService.GenerateTokenPair(user.ID, user.Username, string(user.Role)) if err != nil { return nil, fmt.Errorf("failed to generate tokens: %w", err) } @@ -162,17 +157,19 @@ func (s *AuthService) RefreshToken(req *models.RefreshTokenRequest) (*models.Log // 清除密码字段 user.Password = "" - return &models.LoginResponse{ - AccessToken: tokenPair.AccessToken, - RefreshToken: tokenPair.RefreshToken, - TokenType: tokenPair.TokenType, - ExpiresIn: tokenPair.ExpiresIn, - User: user, + return &dto.LoginResponse{ + Token: dto.TokenResponse{ + AccessToken: tokenPair.AccessToken, + RefreshToken: tokenPair.RefreshToken, + TokenType: tokenPair.TokenType, + ExpiresIn: tokenPair.ExpiresIn, + }, + User: *dto.ConvertToUserResponse(user), }, nil } // GetUserByID 根据ID获取用户 -func (s *AuthService) GetUserByID(id uint) (*models.User, error) { +func (s *AuthService) GetUserByID(id uint) (*entity.User, error) { user, err := s.userRepo.GetByID(id) if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) @@ -189,7 +186,7 @@ func (s *AuthService) GetUserByID(id uint) (*models.User, error) { } // UpdatePassword 更新密码 -func (s *AuthService) UpdatePassword(userID uint, req *models.UpdatePasswordRequest) error { +func (s *AuthService) UpdatePassword(userID uint, req *dto.ChangePasswordRequest) error { // 获取用户信息 user, err := s.userRepo.GetByID(userID) if err != nil { @@ -221,11 +218,11 @@ func (s *AuthService) UpdatePassword(userID uint, req *models.UpdatePasswordRequ } // CheckPermission 检查权限 -func (s *AuthService) CheckPermission(userRole string, requiredRole string) bool { - roleLevel := map[string]int{ - models.RoleUser: 1, - models.RoleEditor: 2, - models.RoleAdmin: 3, +func (s *AuthService) CheckPermission(userRole entity.UserRole, requiredRole entity.UserRole) bool { + roleLevel := map[entity.UserRole]int{ + entity.UserRoleUser: 1, + entity.UserRolePhotographer: 2, + entity.UserRoleAdmin: 3, } userLevel, exists := roleLevel[userRole] @@ -242,11 +239,11 @@ func (s *AuthService) CheckPermission(userRole string, requiredRole string) bool } // IsAdmin 检查是否为管理员 -func (s *AuthService) IsAdmin(userRole string) bool { - return userRole == models.RoleAdmin +func (s *AuthService) IsAdmin(userRole entity.UserRole) bool { + return userRole == entity.UserRoleAdmin } -// IsEditor 检查是否为编辑者或以上 -func (s *AuthService) IsEditor(userRole string) bool { - return userRole == models.RoleEditor || userRole == models.RoleAdmin +// IsPhotographer 检查是否为摄影师或以上 +func (s *AuthService) IsPhotographer(userRole entity.UserRole) bool { + return userRole == entity.UserRolePhotographer || userRole == entity.UserRoleAdmin } \ No newline at end of file diff --git a/backend/internal/service/category_service.go b/backend/internal/service/category_service.go index f4216b2..99ffc3e 100644 --- a/backend/internal/service/category_service.go +++ b/backend/internal/service/category_service.go @@ -3,41 +3,29 @@ package service import ( "context" "errors" - "fmt" - "strings" - "photography-backend/internal/models" - "photography-backend/internal/utils" + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/interfaces" "go.uber.org/zap" - "gorm.io/gorm" ) type CategoryService struct { - db *gorm.DB - logger *zap.Logger + categoryRepo interfaces.CategoryRepository + logger *zap.Logger } -func NewCategoryService(db *gorm.DB, logger *zap.Logger) *CategoryService { +func NewCategoryService(categoryRepo interfaces.CategoryRepository, logger *zap.Logger) *CategoryService { return &CategoryService{ - db: db, - logger: logger, + categoryRepo: categoryRepo, + logger: logger, } } // GetCategories 获取分类列表 -func (s *CategoryService) GetCategories(ctx context.Context, parentID *uint) ([]models.Category, error) { - var categories []models.Category - - query := s.db.WithContext(ctx).Order("sort_order ASC, created_at ASC") - - if parentID != nil { - query = query.Where("parent_id = ?", *parentID) - } else { - query = query.Where("parent_id IS NULL") - } - - if err := query.Find(&categories).Error; err != nil { +func (s *CategoryService) GetCategories(ctx context.Context, parentID *uint) ([]*entity.Category, error) { + categories, err := s.categoryRepo.List(ctx, parentID) + if err != nil { s.logger.Error("Failed to get categories", zap.Error(err)) return nil, err } @@ -46,70 +34,59 @@ func (s *CategoryService) GetCategories(ctx context.Context, parentID *uint) ([] } // GetCategoryTree 获取分类树 -func (s *CategoryService) GetCategoryTree(ctx context.Context) ([]models.CategoryTree, error) { - var categories []models.Category - if err := s.db.WithContext(ctx). - Order("sort_order ASC, created_at ASC"). - Find(&categories).Error; err != nil { - s.logger.Error("Failed to get all categories", zap.Error(err)) +func (s *CategoryService) GetCategoryTree(ctx context.Context) ([]*entity.CategoryTree, error) { + tree, err := s.categoryRepo.GetTree(ctx) + if err != nil { + s.logger.Error("Failed to get category tree", zap.Error(err)) return nil, err } - // 构建树形结构 - tree := s.buildCategoryTree(categories, nil) return tree, nil } // GetCategoryByID 根据ID获取分类 -func (s *CategoryService) GetCategoryByID(ctx context.Context, id uint) (*models.Category, error) { - var category models.Category - if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("category not found") - } +func (s *CategoryService) GetCategoryByID(ctx context.Context, id uint) (*entity.Category, error) { + category, err := s.categoryRepo.GetByID(ctx, id) + if err != nil { s.logger.Error("Failed to get category by ID", zap.Error(err), zap.Uint("id", id)) return nil, err } - return &category, nil + return category, nil } // GetCategoryBySlug 根据slug获取分类 -func (s *CategoryService) GetCategoryBySlug(ctx context.Context, slug string) (*models.Category, error) { - var category models.Category - if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&category).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("category not found") - } +func (s *CategoryService) GetCategoryBySlug(ctx context.Context, slug string) (*entity.Category, error) { + category, err := s.categoryRepo.GetBySlug(ctx, slug) + if err != nil { s.logger.Error("Failed to get category by slug", zap.Error(err), zap.String("slug", slug)) return nil, err } - return &category, nil + return category, nil } // CreateCategory 创建分类 -func (s *CategoryService) CreateCategory(ctx context.Context, req *models.CreateCategoryRequest) (*models.Category, error) { +func (s *CategoryService) CreateCategory(ctx context.Context, req *entity.CreateCategoryRequest) (*entity.Category, error) { // 验证slug唯一性 - if err := s.validateSlugUnique(ctx, req.Slug, 0); err != nil { + if err := s.categoryRepo.ValidateSlugUnique(ctx, req.Slug, 0); err != nil { return nil, err } // 验证父分类存在性 if req.ParentID != nil { - var parentCategory models.Category - if err := s.db.WithContext(ctx).First(&parentCategory, *req.ParentID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("parent category not found") - } + if err := s.categoryRepo.ValidateParentCategory(ctx, 0, *req.ParentID); err != nil { return nil, err } } // 获取排序顺序 - sortOrder := s.getNextSortOrder(ctx, req.ParentID) + sortOrder, err := s.categoryRepo.GetNextSortOrder(ctx, req.ParentID) + if err != nil { + return nil, err + } - category := &models.Category{ + category := &entity.Category{ Name: req.Name, Slug: req.Slug, Description: req.Description, @@ -118,7 +95,7 @@ func (s *CategoryService) CreateCategory(ctx context.Context, req *models.Create IsActive: true, } - if err := s.db.WithContext(ctx).Create(category).Error; err != nil { + if err := s.categoryRepo.Create(ctx, category); err != nil { s.logger.Error("Failed to create category", zap.Error(err)) return nil, err } @@ -128,101 +105,86 @@ func (s *CategoryService) CreateCategory(ctx context.Context, req *models.Create } // UpdateCategory 更新分类 -func (s *CategoryService) UpdateCategory(ctx context.Context, id uint, req *models.UpdateCategoryRequest) (*models.Category, error) { +func (s *CategoryService) UpdateCategory(ctx context.Context, id uint, req *entity.UpdateCategoryRequest) (*entity.Category, error) { // 检查分类是否存在 - var category models.Category - if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("category not found") - } + category, err := s.categoryRepo.GetByID(ctx, id) + if err != nil { + s.logger.Error("Failed to get category", zap.Error(err), zap.Uint("id", id)) return nil, err } // 验证slug唯一性 if req.Slug != nil && *req.Slug != category.Slug { - if err := s.validateSlugUnique(ctx, *req.Slug, id); err != nil { + if err := s.categoryRepo.ValidateSlugUnique(ctx, *req.Slug, id); err != nil { return nil, err } } // 验证父分类(防止循环引用) - if req.ParentID != nil && *req.ParentID != category.ParentID { - if err := s.validateParentCategory(ctx, id, *req.ParentID); err != nil { - return nil, err + if req.ParentID != nil { + // 检查是否有变更 + if (category.ParentID == nil && *req.ParentID != 0) || (category.ParentID != nil && *req.ParentID != *category.ParentID) { + if err := s.categoryRepo.ValidateParentCategory(ctx, id, *req.ParentID); err != nil { + return nil, err + } } } - // 构建更新数据 - updates := map[string]interface{}{} + // 更新字段 if req.Name != nil { - updates["name"] = *req.Name + category.Name = *req.Name } if req.Slug != nil { - updates["slug"] = *req.Slug + category.Slug = *req.Slug } if req.Description != nil { - updates["description"] = *req.Description + category.Description = *req.Description } if req.ParentID != nil { if *req.ParentID == 0 { - updates["parent_id"] = nil + category.ParentID = nil } else { - updates["parent_id"] = *req.ParentID + category.ParentID = req.ParentID } } if req.SortOrder != nil { - updates["sort_order"] = *req.SortOrder + category.SortOrder = *req.SortOrder } if req.IsActive != nil { - updates["is_active"] = *req.IsActive + category.IsActive = *req.IsActive } - if len(updates) > 0 { - if err := s.db.WithContext(ctx).Model(&category).Updates(updates).Error; err != nil { - s.logger.Error("Failed to update category", zap.Error(err)) - return nil, err - } + // 保存更新 + if err := s.categoryRepo.Update(ctx, category); err != nil { + s.logger.Error("Failed to update category", zap.Error(err)) + return nil, err } s.logger.Info("Category updated successfully", zap.Uint("id", id)) - return &category, nil + return category, nil } // DeleteCategory 删除分类 func (s *CategoryService) DeleteCategory(ctx context.Context, id uint) error { // 检查分类是否存在 - var category models.Category - if err := s.db.WithContext(ctx).First(&category, id).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("category not found") - } + _, err := s.categoryRepo.GetByID(ctx, id) + if err != nil { + s.logger.Error("Failed to get category", zap.Error(err), zap.Uint("id", id)) return err } // 检查是否有子分类 - var childCount int64 - if err := s.db.WithContext(ctx).Model(&models.Category{}). - Where("parent_id = ?", id).Count(&childCount).Error; err != nil { + children, err := s.categoryRepo.GetChildren(ctx, id) + if err != nil { return err } - if childCount > 0 { + if len(children) > 0 { return errors.New("cannot delete category with subcategories") } - // 检查是否有关联的照片 - var photoCount int64 - if err := s.db.WithContext(ctx).Table("photo_categories"). - Where("category_id = ?", id).Count(&photoCount).Error; err != nil { - return err - } - - if photoCount > 0 { - return errors.New("cannot delete category with associated photos") - } - - // 删除分类 - if err := s.db.WithContext(ctx).Delete(&category).Error; err != nil { + // 直接删除分类,在Repository层检查照片关联 + if err := s.categoryRepo.Delete(ctx, id); err != nil { s.logger.Error("Failed to delete category", zap.Error(err)) return err } @@ -233,42 +195,13 @@ func (s *CategoryService) DeleteCategory(ctx context.Context, id uint) error { // ReorderCategories 重新排序分类 func (s *CategoryService) ReorderCategories(ctx context.Context, parentID *uint, categoryIDs []uint) error { - // 验证所有分类都属于同一父分类 - var categories []models.Category - query := s.db.WithContext(ctx).Where("id IN ?", categoryIDs) - - if parentID != nil { - query = query.Where("parent_id = ?", *parentID) - } else { - query = query.Where("parent_id IS NULL") + if len(categoryIDs) == 0 { + return nil } - if err := query.Find(&categories).Error; err != nil { - return err - } - - if len(categories) != len(categoryIDs) { - return errors.New("invalid category IDs") - } - - // 开始事务 - tx := s.db.WithContext(ctx).Begin() - if tx.Error != nil { - return tx.Error - } - defer tx.Rollback() - - // 更新排序 - for i, categoryID := range categoryIDs { - if err := tx.Model(&models.Category{}). - Where("id = ?", categoryID). - Update("sort_order", i+1).Error; err != nil { - return err - } - } - - // 提交事务 - if err := tx.Commit().Error; err != nil { + // 重新排序分类 + if err := s.categoryRepo.Reorder(ctx, parentID, categoryIDs); err != nil { + s.logger.Error("Failed to reorder categories", zap.Error(err)) return err } @@ -277,171 +210,23 @@ func (s *CategoryService) ReorderCategories(ctx context.Context, parentID *uint, } // GetCategoryStats 获取分类统计信息 -func (s *CategoryService) GetCategoryStats(ctx context.Context) (*models.CategoryStats, error) { - var stats models.CategoryStats - - // 总分类数 - if err := s.db.WithContext(ctx).Model(&models.Category{}).Count(&stats.Total).Error; err != nil { +func (s *CategoryService) GetCategoryStats(ctx context.Context) (*entity.CategoryStats, error) { + stats, err := s.categoryRepo.GetStats(ctx) + if err != nil { + s.logger.Error("Failed to get category stats", zap.Error(err)) return nil, err } - // 活跃分类数 - if err := s.db.WithContext(ctx).Model(&models.Category{}). - Where("is_active = ?", true).Count(&stats.Active).Error; err != nil { - return nil, err - } - - // 顶级分类数 - if err := s.db.WithContext(ctx).Model(&models.Category{}). - Where("parent_id IS NULL").Count(&stats.TopLevel).Error; err != nil { - return nil, err - } - - // 各分类照片数量 - var categoryPhotoStats []struct { - CategoryID uint `json:"category_id"` - Name string `json:"name"` - PhotoCount int64 `json:"photo_count"` - } - - if err := s.db.WithContext(ctx). - Table("categories"). - Select("categories.id as category_id, categories.name, COUNT(photo_categories.photo_id) as photo_count"). - Joins("LEFT JOIN photo_categories ON categories.id = photo_categories.category_id"). - Group("categories.id, categories.name"). - Order("photo_count DESC"). - Limit(10). - Find(&categoryPhotoStats).Error; err != nil { - return nil, err - } - - stats.PhotoCounts = make(map[string]int64) - for _, stat := range categoryPhotoStats { - stats.PhotoCounts[stat.Name] = stat.PhotoCount - } - - return &stats, nil + return stats, nil } -// validateSlugUnique 验证slug唯一性 -func (s *CategoryService) validateSlugUnique(ctx context.Context, slug string, excludeID uint) error { - var count int64 - query := s.db.WithContext(ctx).Model(&models.Category{}).Where("slug = ?", slug) - - if excludeID > 0 { - query = query.Where("id != ?", excludeID) - } - - if err := query.Count(&count).Error; err != nil { - return err - } - - if count > 0 { - return errors.New("slug already exists") - } - - return nil -} -// validateParentCategory 验证父分类(防止循环引用) -func (s *CategoryService) validateParentCategory(ctx context.Context, categoryID, parentID uint) error { - if categoryID == parentID { - return errors.New("category cannot be its own parent") - } - - // 检查是否会形成循环引用 - current := parentID - for current != 0 { - var parent models.Category - if err := s.db.WithContext(ctx).First(&parent, current).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("parent category not found") - } - return err - } - - if parent.ParentID == nil { - break - } - - if *parent.ParentID == categoryID { - return errors.New("circular reference detected") - } - - current = *parent.ParentID - } - - return nil -} - -// getNextSortOrder 获取下一个排序顺序 -func (s *CategoryService) getNextSortOrder(ctx context.Context, parentID *uint) int { - var maxOrder int - - query := s.db.WithContext(ctx).Model(&models.Category{}).Select("COALESCE(MAX(sort_order), 0)") - - if parentID != nil { - query = query.Where("parent_id = ?", *parentID) - } else { - query = query.Where("parent_id IS NULL") - } - - query.Row().Scan(&maxOrder) - - return maxOrder + 1 -} - -// buildCategoryTree 构建分类树 -func (s *CategoryService) buildCategoryTree(categories []models.Category, parentID *uint) []models.CategoryTree { - var tree []models.CategoryTree - - for _, category := range categories { - // 检查是否匹配父分类 - if (parentID == nil && category.ParentID == nil) || - (parentID != nil && category.ParentID != nil && *category.ParentID == *parentID) { - - node := models.CategoryTree{ - ID: category.ID, - Name: category.Name, - Slug: category.Slug, - Description: category.Description, - ParentID: category.ParentID, - SortOrder: category.SortOrder, - IsActive: category.IsActive, - PhotoCount: category.PhotoCount, - CreatedAt: category.CreatedAt, - UpdatedAt: category.UpdatedAt, - } - - // 递归构建子分类 - node.Children = s.buildCategoryTree(categories, &category.ID) - - tree = append(tree, node) - } - } - - return tree -} - -// GenerateSlug 生成slug +// GenerateSlug 生成唯一slug func (s *CategoryService) GenerateSlug(ctx context.Context, name string) (string, error) { - baseSlug := utils.GenerateSlug(name) - slug := baseSlug - - counter := 1 - for { - var count int64 - if err := s.db.WithContext(ctx).Model(&models.Category{}). - Where("slug = ?", slug).Count(&count).Error; err != nil { - return "", err - } - - if count == 0 { - break - } - - slug = fmt.Sprintf("%s-%d", baseSlug, counter) - counter++ + slug, err := s.categoryRepo.GenerateUniqueSlug(ctx, name) + if err != nil { + s.logger.Error("Failed to generate unique slug", zap.Error(err)) + return "", err } return slug, nil diff --git a/backend/internal/service/photo_service.go b/backend/internal/service/photo_service.go index c096b4d..cce1aec 100644 --- a/backend/internal/service/photo_service.go +++ b/backend/internal/service/photo_service.go @@ -6,29 +6,28 @@ import ( "fmt" "mime/multipart" "path/filepath" - "strconv" "strings" "time" "photography-backend/internal/config" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/interfaces" "photography-backend/internal/service/storage" "photography-backend/internal/utils" "go.uber.org/zap" - "gorm.io/gorm" ) type PhotoService struct { - db *gorm.DB + photoRepo interfaces.PhotoRepository config *config.Config logger *zap.Logger storageService *storage.StorageService } -func NewPhotoService(db *gorm.DB, config *config.Config, logger *zap.Logger, storageService *storage.StorageService) *PhotoService { +func NewPhotoService(photoRepo interfaces.PhotoRepository, config *config.Config, logger *zap.Logger, storageService *storage.StorageService) *PhotoService { return &PhotoService{ - db: db, + photoRepo: photoRepo, config: config, logger: logger, storageService: storageService, @@ -51,7 +50,7 @@ type PhotoListParams struct { // PhotoListResponse 照片列表响应 type PhotoListResponse struct { - Photos []models.Photo `json:"photos"` + Photos []entity.Photo `json:"photos"` Total int64 `json:"total"` Page int `json:"page"` Limit int `json:"limit"` @@ -130,14 +129,14 @@ func (s *PhotoService) GetPhotos(ctx context.Context, params PhotoListParams) (* // 计算总数 var total int64 countQuery := query - if err := countQuery.Model(&models.Photo{}).Count(&total).Error; err != nil { + if err := countQuery.Model(&entity.Photo{}).Count(&total).Error; err != nil { s.logger.Error("Failed to count photos", zap.Error(err)) return nil, err } // 分页查询 offset := (params.Page - 1) * params.Limit - var photos []models.Photo + var photos []entity.Photo if err := query. Order(fmt.Sprintf("%s %s", sortBy, sortOrder)). Offset(offset). @@ -160,8 +159,8 @@ func (s *PhotoService) GetPhotos(ctx context.Context, params PhotoListParams) (* } // GetPhotoByID 根据ID获取照片 -func (s *PhotoService) GetPhotoByID(ctx context.Context, id uint) (*models.Photo, error) { - var photo models.Photo +func (s *PhotoService) GetPhotoByID(ctx context.Context, id uint) (*entity.Photo, error) { + var photo entity.Photo if err := s.db.WithContext(ctx). Preload("Categories"). Preload("Tags"). @@ -178,17 +177,17 @@ func (s *PhotoService) GetPhotoByID(ctx context.Context, id uint) (*models.Photo } // CreatePhoto 创建照片 -func (s *PhotoService) CreatePhoto(ctx context.Context, req *models.CreatePhotoRequest) (*models.Photo, error) { +func (s *PhotoService) CreatePhoto(ctx context.Context, req *entity.CreatePhotoRequest) (*entity.Photo, error) { // 生成唯一的文件名 uniqueFilename := utils.GenerateUniqueFilename(req.OriginalFilename) - photo := &models.Photo{ + photo := &entity.Photo{ Title: req.Title, Description: req.Description, OriginalFilename: req.OriginalFilename, UniqueFilename: uniqueFilename, FileSize: req.FileSize, - Status: req.Status, + Status: entity.PhotoStatus(req.Status), Camera: req.Camera, Lens: req.Lens, ISO: req.ISO, @@ -213,7 +212,7 @@ func (s *PhotoService) CreatePhoto(ctx context.Context, req *models.CreatePhotoR // 关联分类 if len(req.CategoryIDs) > 0 { - var categories []models.Category + var categories []entity.Category if err := tx.Where("id IN ?", req.CategoryIDs).Find(&categories).Error; err != nil { s.logger.Error("Failed to find categories", zap.Error(err)) return nil, err @@ -226,7 +225,7 @@ func (s *PhotoService) CreatePhoto(ctx context.Context, req *models.CreatePhotoR // 关联标签 if len(req.TagIDs) > 0 { - var tags []models.Tag + var tags []entity.Tag if err := tx.Where("id IN ?", req.TagIDs).Find(&tags).Error; err != nil { s.logger.Error("Failed to find tags", zap.Error(err)) return nil, err @@ -258,9 +257,9 @@ func (s *PhotoService) CreatePhoto(ctx context.Context, req *models.CreatePhotoR } // UpdatePhoto 更新照片 -func (s *PhotoService) UpdatePhoto(ctx context.Context, id uint, req *models.UpdatePhotoRequest) (*models.Photo, error) { +func (s *PhotoService) UpdatePhoto(ctx context.Context, id uint, req *entity.UpdatePhotoRequest) (*entity.Photo, error) { // 检查照片是否存在 - var photo models.Photo + var photo entity.Photo if err := s.db.WithContext(ctx).First(&photo, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("photo not found") @@ -317,7 +316,7 @@ func (s *PhotoService) UpdatePhoto(ctx context.Context, id uint, req *models.Upd // 更新分类关联 if req.CategoryIDs != nil { - var categories []models.Category + var categories []entity.Category if len(*req.CategoryIDs) > 0 { if err := tx.Where("id IN ?", *req.CategoryIDs).Find(&categories).Error; err != nil { s.logger.Error("Failed to find categories", zap.Error(err)) @@ -332,7 +331,7 @@ func (s *PhotoService) UpdatePhoto(ctx context.Context, id uint, req *models.Upd // 更新标签关联 if req.TagIDs != nil { - var tags []models.Tag + var tags []entity.Tag if len(*req.TagIDs) > 0 { if err := tx.Where("id IN ?", *req.TagIDs).Find(&tags).Error; err != nil { s.logger.Error("Failed to find tags", zap.Error(err)) @@ -368,7 +367,7 @@ func (s *PhotoService) UpdatePhoto(ctx context.Context, id uint, req *models.Upd // DeletePhoto 删除照片 func (s *PhotoService) DeletePhoto(ctx context.Context, id uint) error { // 检查照片是否存在 - var photo models.Photo + var photo entity.Photo if err := s.db.WithContext(ctx).First(&photo, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("photo not found") @@ -384,7 +383,7 @@ func (s *PhotoService) DeletePhoto(ctx context.Context, id uint) error { defer tx.Rollback() // 删除关联的格式文件 - if err := tx.Where("photo_id = ?", id).Delete(&models.PhotoFormat{}).Error; err != nil { + if err := tx.Where("photo_id = ?", id).Delete(&entity.PhotoFormat{}).Error; err != nil { s.logger.Error("Failed to delete photo formats", zap.Error(err)) return err } @@ -414,7 +413,7 @@ func (s *PhotoService) DeletePhoto(ctx context.Context, id uint) error { // 异步删除文件 go func() { - if err := s.storageService.DeletePhoto(photo.UniqueFilename); err != nil { + if err := (*s.storageService).DeletePhoto(photo.UniqueFilename); err != nil { s.logger.Error("Failed to delete photo files", zap.Error(err), zap.String("filename", photo.UniqueFilename)) } }() @@ -424,7 +423,7 @@ func (s *PhotoService) DeletePhoto(ctx context.Context, id uint) error { } // UploadPhoto 上传照片 -func (s *PhotoService) UploadPhoto(ctx context.Context, file multipart.File, header *multipart.FileHeader, req *models.CreatePhotoRequest) (*models.Photo, error) { +func (s *PhotoService) UploadPhoto(ctx context.Context, file multipart.File, header *multipart.FileHeader, req *entity.CreatePhotoRequest) (*entity.Photo, error) { // 验证文件类型 if !s.isValidImageFile(header.Filename) { return nil, errors.New("invalid file type") @@ -439,7 +438,7 @@ func (s *PhotoService) UploadPhoto(ctx context.Context, file multipart.File, hea uniqueFilename := utils.GenerateUniqueFilename(header.Filename) // 上传文件到存储服务 - uploadedFile, err := s.storageService.UploadPhoto(ctx, file, uniqueFilename) + uploadedFile, err := (*s.storageService).UploadPhoto(ctx, file, uniqueFilename) if err != nil { s.logger.Error("Failed to upload photo", zap.Error(err)) return nil, err @@ -453,7 +452,7 @@ func (s *PhotoService) UploadPhoto(ctx context.Context, file multipart.File, hea if err != nil { // 如果创建记录失败,删除已上传的文件 go func() { - if err := s.storageService.DeletePhoto(uniqueFilename); err != nil { + if err := (*s.storageService).DeletePhoto(uniqueFilename); err != nil { s.logger.Error("Failed to cleanup uploaded file", zap.Error(err)) } }() @@ -469,7 +468,7 @@ func (s *PhotoService) UploadPhoto(ctx context.Context, file multipart.File, hea } // BatchUpdatePhotos 批量更新照片 -func (s *PhotoService) BatchUpdatePhotos(ctx context.Context, ids []uint, req *models.BatchUpdatePhotosRequest) error { +func (s *PhotoService) BatchUpdatePhotos(ctx context.Context, ids []uint, req *entity.BatchUpdatePhotosRequest) error { if len(ids) == 0 { return errors.New("no photos to update") } @@ -489,7 +488,7 @@ func (s *PhotoService) BatchUpdatePhotos(ctx context.Context, ids []uint, req *m // 基础字段更新 if len(updates) > 0 { - if err := tx.Model(&models.Photo{}).Where("id IN ?", ids).Updates(updates).Error; err != nil { + if err := tx.Model(&entity.Photo{}).Where("id IN ?", ids).Updates(updates).Error; err != nil { s.logger.Error("Failed to batch update photos", zap.Error(err)) return err } @@ -550,7 +549,7 @@ func (s *PhotoService) BatchDeletePhotos(ctx context.Context, ids []uint) error } // 获取要删除的照片信息 - var photos []models.Photo + var photos []entity.Photo if err := s.db.WithContext(ctx).Where("id IN ?", ids).Find(&photos).Error; err != nil { return err } @@ -563,7 +562,7 @@ func (s *PhotoService) BatchDeletePhotos(ctx context.Context, ids []uint) error defer tx.Rollback() // 删除关联的格式文件 - if err := tx.Where("photo_id IN ?", ids).Delete(&models.PhotoFormat{}).Error; err != nil { + if err := tx.Where("photo_id IN ?", ids).Delete(&entity.PhotoFormat{}).Error; err != nil { return err } @@ -577,7 +576,7 @@ func (s *PhotoService) BatchDeletePhotos(ctx context.Context, ids []uint) error } // 删除照片记录 - if err := tx.Where("id IN ?", ids).Delete(&models.Photo{}).Error; err != nil { + if err := tx.Where("id IN ?", ids).Delete(&entity.Photo{}).Error; err != nil { return err } @@ -589,7 +588,7 @@ func (s *PhotoService) BatchDeletePhotos(ctx context.Context, ids []uint) error // 异步删除文件 go func() { for _, photo := range photos { - if err := s.storageService.DeletePhoto(photo.UniqueFilename); err != nil { + if err := (*s.storageService).DeletePhoto(photo.UniqueFilename); err != nil { s.logger.Error("Failed to delete photo files", zap.Error(err), zap.String("filename", photo.UniqueFilename)) } } @@ -600,11 +599,11 @@ func (s *PhotoService) BatchDeletePhotos(ctx context.Context, ids []uint) error } // GetPhotoStats 获取照片统计信息 -func (s *PhotoService) GetPhotoStats(ctx context.Context) (*models.PhotoStats, error) { - var stats models.PhotoStats +func (s *PhotoService) GetPhotoStats(ctx context.Context) (*entity.PhotoStats, error) { + var stats entity.PhotoStats // 总数统计 - if err := s.db.WithContext(ctx).Model(&models.Photo{}).Count(&stats.Total).Error; err != nil { + if err := s.db.WithContext(ctx).Model(&entity.Photo{}).Count(&stats.Total).Error; err != nil { return nil, err } @@ -613,7 +612,7 @@ func (s *PhotoService) GetPhotoStats(ctx context.Context) (*models.PhotoStats, e Status string `json:"status"` Count int64 `json:"count"` } - if err := s.db.WithContext(ctx).Model(&models.Photo{}). + if err := s.db.WithContext(ctx).Model(&entity.Photo{}). Select("status, COUNT(*) as count"). Group("status"). Find(&statusStats).Error; err != nil { @@ -627,7 +626,7 @@ func (s *PhotoService) GetPhotoStats(ctx context.Context) (*models.PhotoStats, e // 本月新增 startOfMonth := time.Now().AddDate(0, 0, -time.Now().Day()+1) - if err := s.db.WithContext(ctx).Model(&models.Photo{}). + if err := s.db.WithContext(ctx).Model(&entity.Photo{}). Where("created_at >= ?", startOfMonth). Count(&stats.ThisMonth).Error; err != nil { return nil, err @@ -635,7 +634,7 @@ func (s *PhotoService) GetPhotoStats(ctx context.Context) (*models.PhotoStats, e // 今日新增 startOfDay := time.Now().Truncate(24 * time.Hour) - if err := s.db.WithContext(ctx).Model(&models.Photo{}). + if err := s.db.WithContext(ctx).Model(&entity.Photo{}). Where("created_at >= ?", startOfDay). Count(&stats.Today).Error; err != nil { return nil, err @@ -643,7 +642,7 @@ func (s *PhotoService) GetPhotoStats(ctx context.Context) (*models.PhotoStats, e // 总存储大小 var totalSize sql.NullInt64 - if err := s.db.WithContext(ctx).Model(&models.Photo{}). + if err := s.db.WithContext(ctx).Model(&entity.Photo{}). Select("SUM(file_size)"). Row().Scan(&totalSize); err != nil { return nil, err @@ -663,7 +662,7 @@ func (s *PhotoService) isValidImageFile(filename string) bool { } // processPhotoFormats 处理照片格式转换 -func (s *PhotoService) processPhotoFormats(ctx context.Context, photo *models.Photo, uploadedFile *storage.UploadedFile) { +func (s *PhotoService) processPhotoFormats(ctx context.Context, photo *entity.Photo, uploadedFile *storage.UploadedFile) { // 这里将实现图片格式转换逻辑 // 生成不同尺寸和格式的图片 // 更新 photo_formats 表 diff --git a/backend/internal/service/tag_service.go b/backend/internal/service/tag_service.go index 5f5e26a..c2f9fc2 100644 --- a/backend/internal/service/tag_service.go +++ b/backend/internal/service/tag_service.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "photography-backend/internal/models" + "photography-backend/internal/model/entity" "photography-backend/internal/utils" "go.uber.org/zap" @@ -37,7 +37,7 @@ type TagListParams struct { // TagListResponse 标签列表响应 type TagListResponse struct { - Tags []models.Tag `json:"tags"` + Tags []entity.Tag `json:"tags"` Total int64 `json:"total"` Page int `json:"page"` Limit int `json:"limit"` @@ -87,14 +87,14 @@ func (s *TagService) GetTags(ctx context.Context, params TagListParams) (*TagLis // 计算总数 var total int64 countQuery := query - if err := countQuery.Model(&models.Tag{}).Count(&total).Error; err != nil { + if err := countQuery.Model(&entity.Tag{}).Count(&total).Error; err != nil { s.logger.Error("Failed to count tags", zap.Error(err)) return nil, err } // 分页查询 offset := (params.Page - 1) * params.Limit - var tags []models.Tag + var tags []entity.Tag if err := query. Order(fmt.Sprintf("%s %s", sortBy, sortOrder)). Offset(offset). @@ -117,8 +117,8 @@ func (s *TagService) GetTags(ctx context.Context, params TagListParams) (*TagLis } // GetAllTags 获取所有活跃标签 -func (s *TagService) GetAllTags(ctx context.Context) ([]models.Tag, error) { - var tags []models.Tag +func (s *TagService) GetAllTags(ctx context.Context) ([]entity.Tag, error) { + var tags []entity.Tag if err := s.db.WithContext(ctx). Where("is_active = ?", true). Order("name ASC"). @@ -131,8 +131,8 @@ func (s *TagService) GetAllTags(ctx context.Context) ([]models.Tag, error) { } // GetTagByID 根据ID获取标签 -func (s *TagService) GetTagByID(ctx context.Context, id uint) (*models.Tag, error) { - var tag models.Tag +func (s *TagService) GetTagByID(ctx context.Context, id uint) (*entity.Tag, error) { + var tag entity.Tag if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("tag not found") @@ -145,8 +145,8 @@ func (s *TagService) GetTagByID(ctx context.Context, id uint) (*models.Tag, erro } // GetTagBySlug 根据slug获取标签 -func (s *TagService) GetTagBySlug(ctx context.Context, slug string) (*models.Tag, error) { - var tag models.Tag +func (s *TagService) GetTagBySlug(ctx context.Context, slug string) (*entity.Tag, error) { + var tag entity.Tag if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&tag).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("tag not found") @@ -159,13 +159,13 @@ func (s *TagService) GetTagBySlug(ctx context.Context, slug string) (*models.Tag } // CreateTag 创建标签 -func (s *TagService) CreateTag(ctx context.Context, req *models.CreateTagRequest) (*models.Tag, error) { +func (s *TagService) CreateTag(ctx context.Context, req *entity.CreateTagRequest) (*entity.Tag, error) { // 验证slug唯一性 if err := s.validateSlugUnique(ctx, req.Slug, 0); err != nil { return nil, err } - tag := &models.Tag{ + tag := &entity.Tag{ Name: req.Name, Slug: req.Slug, Description: req.Description, @@ -183,9 +183,9 @@ func (s *TagService) CreateTag(ctx context.Context, req *models.CreateTagRequest } // UpdateTag 更新标签 -func (s *TagService) UpdateTag(ctx context.Context, id uint, req *models.UpdateTagRequest) (*models.Tag, error) { +func (s *TagService) UpdateTag(ctx context.Context, id uint, req *entity.UpdateTagRequest) (*entity.Tag, error) { // 检查标签是否存在 - var tag models.Tag + var tag entity.Tag if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("tag not found") @@ -232,7 +232,7 @@ func (s *TagService) UpdateTag(ctx context.Context, id uint, req *models.UpdateT // DeleteTag 删除标签 func (s *TagService) DeleteTag(ctx context.Context, id uint) error { // 检查标签是否存在 - var tag models.Tag + var tag entity.Tag if err := s.db.WithContext(ctx).First(&tag, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("tag not found") @@ -279,7 +279,7 @@ func (s *TagService) BatchDeleteTags(ctx context.Context, ids []uint) error { } // 删除标签 - if err := s.db.WithContext(ctx).Where("id IN ?", ids).Delete(&models.Tag{}).Error; err != nil { + if err := s.db.WithContext(ctx).Where("id IN ?", ids).Delete(&entity.Tag{}).Error; err != nil { s.logger.Error("Failed to batch delete tags", zap.Error(err)) return err } @@ -289,12 +289,12 @@ func (s *TagService) BatchDeleteTags(ctx context.Context, ids []uint) error { } // GetPopularTags 获取热门标签 -func (s *TagService) GetPopularTags(ctx context.Context, limit int) ([]models.TagWithCount, error) { +func (s *TagService) GetPopularTags(ctx context.Context, limit int) ([]entity.TagWithCount, error) { if limit <= 0 { limit = 10 } - var tags []models.TagWithCount + var tags []entity.TagWithCount if err := s.db.WithContext(ctx). Table("tags"). Select("tags.*, COUNT(photo_tags.photo_id) as photo_count"). @@ -312,8 +312,8 @@ func (s *TagService) GetPopularTags(ctx context.Context, limit int) ([]models.Ta } // GetTagCloud 获取标签云数据 -func (s *TagService) GetTagCloud(ctx context.Context) ([]models.TagCloudItem, error) { - var items []models.TagCloudItem +func (s *TagService) GetTagCloud(ctx context.Context) ([]entity.TagCloudItem, error) { + var items []entity.TagCloudItem if err := s.db.WithContext(ctx). Table("tags"). Select("tags.name, tags.slug, tags.color, COUNT(photo_tags.photo_id) as count"). @@ -331,16 +331,16 @@ func (s *TagService) GetTagCloud(ctx context.Context) ([]models.TagCloudItem, er } // GetTagStats 获取标签统计信息 -func (s *TagService) GetTagStats(ctx context.Context) (*models.TagStats, error) { - var stats models.TagStats +func (s *TagService) GetTagStats(ctx context.Context) (*entity.TagStats, error) { + var stats entity.TagStats // 总标签数 - if err := s.db.WithContext(ctx).Model(&models.Tag{}).Count(&stats.Total).Error; err != nil { + if err := s.db.WithContext(ctx).Model(&entity.Tag{}).Count(&stats.Total).Error; err != nil { return nil, err } // 活跃标签数 - if err := s.db.WithContext(ctx).Model(&models.Tag{}). + if err := s.db.WithContext(ctx).Model(&entity.Tag{}). Where("is_active = ?", true).Count(&stats.Active).Error; err != nil { return nil, err } @@ -375,12 +375,12 @@ func (s *TagService) GetTagStats(ctx context.Context) (*models.TagStats, error) } // SearchTags 搜索标签 -func (s *TagService) SearchTags(ctx context.Context, query string, limit int) ([]models.Tag, error) { +func (s *TagService) SearchTags(ctx context.Context, query string, limit int) ([]entity.Tag, error) { if limit <= 0 { limit = 10 } - var tags []models.Tag + var tags []entity.Tag searchPattern := "%" + query + "%" if err := s.db.WithContext(ctx). @@ -396,8 +396,8 @@ func (s *TagService) SearchTags(ctx context.Context, query string, limit int) ([ } // CreateTagsFromNames 从名称列表创建标签 -func (s *TagService) CreateTagsFromNames(ctx context.Context, names []string) ([]models.Tag, error) { - var tags []models.Tag +func (s *TagService) CreateTagsFromNames(ctx context.Context, names []string) ([]entity.Tag, error) { + var tags []entity.Tag for _, name := range names { name = strings.TrimSpace(name) @@ -413,14 +413,14 @@ func (s *TagService) CreateTagsFromNames(ctx context.Context, names []string) ([ } // 检查标签是否已存在 - var existingTag models.Tag + var existingTag entity.Tag if err := s.db.WithContext(ctx).Where("slug = ?", slug).First(&existingTag).Error; err == nil { tags = append(tags, existingTag) continue } // 创建新标签 - tag := models.Tag{ + tag := entity.Tag{ Name: name, Slug: slug, IsActive: true, @@ -440,7 +440,7 @@ func (s *TagService) CreateTagsFromNames(ctx context.Context, names []string) ([ // validateSlugUnique 验证slug唯一性 func (s *TagService) validateSlugUnique(ctx context.Context, slug string, excludeID uint) error { var count int64 - query := s.db.WithContext(ctx).Model(&models.Tag{}).Where("slug = ?", slug) + query := s.db.WithContext(ctx).Model(&entity.Tag{}).Where("slug = ?", slug) if excludeID > 0 { query = query.Where("id != ?", excludeID) @@ -465,7 +465,7 @@ func (s *TagService) GenerateSlug(ctx context.Context, name string) (string, err counter := 1 for { var count int64 - if err := s.db.WithContext(ctx).Model(&models.Tag{}). + if err := s.db.WithContext(ctx).Model(&entity.Tag{}). Where("slug = ?", slug).Count(&count).Error; err != nil { return "", err } diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index d2370c9..9a009b4 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -3,10 +3,9 @@ package service import ( "context" "errors" - "fmt" + "time" - "photography-backend/internal/models" - "photography-backend/internal/utils" + "photography-backend/internal/model/entity" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" @@ -36,7 +35,7 @@ type UserListParams struct { // UserListResponse 用户列表响应 type UserListResponse struct { - Users []models.User `json:"users"` + Users []entity.User `json:"users"` Total int64 `json:"total"` Page int `json:"page"` Limit int `json:"limit"` @@ -78,14 +77,14 @@ func (s *UserService) GetUsers(ctx context.Context, params UserListParams) (*Use // 计算总数 var total int64 countQuery := query - if err := countQuery.Model(&models.User{}).Count(&total).Error; err != nil { + if err := countQuery.Model(&entity.User{}).Count(&total).Error; err != nil { s.logger.Error("Failed to count users", zap.Error(err)) return nil, err } // 分页查询 offset := (params.Page - 1) * params.Limit - var users []models.User + var users []entity.User if err := query. Order("created_at DESC"). Offset(offset). @@ -108,8 +107,8 @@ func (s *UserService) GetUsers(ctx context.Context, params UserListParams) (*Use } // GetUserByID 根据ID获取用户 -func (s *UserService) GetUserByID(ctx context.Context, id uint) (*models.User, error) { - var user models.User +func (s *UserService) GetUserByID(ctx context.Context, id uint) (*entity.User, error) { + var user entity.User if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("user not found") @@ -122,8 +121,8 @@ func (s *UserService) GetUserByID(ctx context.Context, id uint) (*models.User, e } // GetUserByUsername 根据用户名获取用户 -func (s *UserService) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { - var user models.User +func (s *UserService) GetUserByUsername(ctx context.Context, username string) (*entity.User, error) { + var user entity.User if err := s.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("user not found") @@ -136,8 +135,8 @@ func (s *UserService) GetUserByUsername(ctx context.Context, username string) (* } // GetUserByEmail 根据邮箱获取用户 -func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*models.User, error) { - var user models.User +func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*entity.User, error) { + var user entity.User if err := s.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("user not found") @@ -150,9 +149,9 @@ func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*models } // CreateUser 创建用户 -func (s *UserService) CreateUser(ctx context.Context, req *models.CreateUserRequest) (*models.User, error) { +func (s *UserService) CreateUser(ctx context.Context, req *entity.CreateUserRequest) (*entity.User, error) { // 验证用户名唯一性 - var existingUser models.User + var existingUser entity.User if err := s.db.WithContext(ctx).Where("username = ?", req.Username).First(&existingUser).Error; err == nil { return nil, errors.New("username already exists") } @@ -169,7 +168,7 @@ func (s *UserService) CreateUser(ctx context.Context, req *models.CreateUserRequ return nil, err } - user := &models.User{ + user := &entity.User{ Username: req.Username, Email: req.Email, Password: string(hashedPassword), @@ -187,9 +186,9 @@ func (s *UserService) CreateUser(ctx context.Context, req *models.CreateUserRequ } // UpdateUser 更新用户 -func (s *UserService) UpdateUser(ctx context.Context, id uint, req *models.UpdateUserRequest) (*models.User, error) { +func (s *UserService) UpdateUser(ctx context.Context, id uint, req *entity.UpdateUserRequest) (*entity.User, error) { // 检查用户是否存在 - var user models.User + var user entity.User if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("user not found") @@ -202,7 +201,7 @@ func (s *UserService) UpdateUser(ctx context.Context, id uint, req *models.Updat if req.Username != nil { // 验证用户名唯一性 - var existingUser models.User + var existingUser entity.User if err := s.db.WithContext(ctx).Where("username = ? AND id != ?", *req.Username, id).First(&existingUser).Error; err == nil { return nil, errors.New("username already exists") } @@ -211,7 +210,7 @@ func (s *UserService) UpdateUser(ctx context.Context, id uint, req *models.Updat if req.Email != nil { // 验证邮箱唯一性 - var existingUser models.User + var existingUser entity.User if err := s.db.WithContext(ctx).Where("email = ? AND id != ?", *req.Email, id).First(&existingUser).Error; err == nil { return nil, errors.New("email already exists") } @@ -238,9 +237,9 @@ func (s *UserService) UpdateUser(ctx context.Context, id uint, req *models.Updat } // UpdateCurrentUser 更新当前用户信息 -func (s *UserService) UpdateCurrentUser(ctx context.Context, id uint, req *models.UpdateCurrentUserRequest) (*models.User, error) { +func (s *UserService) UpdateCurrentUser(ctx context.Context, id uint, req *entity.UpdateCurrentUserRequest) (*entity.User, error) { // 检查用户是否存在 - var user models.User + var user entity.User if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("user not found") @@ -253,7 +252,7 @@ func (s *UserService) UpdateCurrentUser(ctx context.Context, id uint, req *model if req.Username != nil { // 验证用户名唯一性 - var existingUser models.User + var existingUser entity.User if err := s.db.WithContext(ctx).Where("username = ? AND id != ?", *req.Username, id).First(&existingUser).Error; err == nil { return nil, errors.New("username already exists") } @@ -262,7 +261,7 @@ func (s *UserService) UpdateCurrentUser(ctx context.Context, id uint, req *model if req.Email != nil { // 验证邮箱唯一性 - var existingUser models.User + var existingUser entity.User if err := s.db.WithContext(ctx).Where("email = ? AND id != ?", *req.Email, id).First(&existingUser).Error; err == nil { return nil, errors.New("email already exists") } @@ -283,7 +282,7 @@ func (s *UserService) UpdateCurrentUser(ctx context.Context, id uint, req *model // DeleteUser 删除用户 func (s *UserService) DeleteUser(ctx context.Context, id uint) error { // 检查用户是否存在 - var user models.User + var user entity.User if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("user not found") @@ -302,9 +301,9 @@ func (s *UserService) DeleteUser(ctx context.Context, id uint) error { } // ChangePassword 修改密码 -func (s *UserService) ChangePassword(ctx context.Context, id uint, req *models.ChangePasswordRequest) error { +func (s *UserService) ChangePassword(ctx context.Context, id uint, req *entity.ChangePasswordRequest) error { // 检查用户是否存在 - var user models.User + var user entity.User if err := s.db.WithContext(ctx).First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("user not found") @@ -335,8 +334,8 @@ func (s *UserService) ChangePassword(ctx context.Context, id uint, req *models.C } // ValidateCredentials 验证用户凭据 -func (s *UserService) ValidateCredentials(ctx context.Context, username, password string) (*models.User, error) { - var user models.User +func (s *UserService) ValidateCredentials(ctx context.Context, username, password string) (*entity.User, error) { + var user entity.User // 根据用户名或邮箱查找用户 if err := s.db.WithContext(ctx).Where("username = ? OR email = ?", username, username).First(&user).Error; err != nil { @@ -361,16 +360,16 @@ func (s *UserService) ValidateCredentials(ctx context.Context, username, passwor } // GetUserStats 获取用户统计信息 -func (s *UserService) GetUserStats(ctx context.Context) (*models.UserStats, error) { - var stats models.UserStats +func (s *UserService) GetUserStats(ctx context.Context) (*entity.UserStats, error) { + var stats entity.UserStats // 总用户数 - if err := s.db.WithContext(ctx).Model(&models.User{}).Count(&stats.Total).Error; err != nil { + if err := s.db.WithContext(ctx).Model(&entity.User{}).Count(&stats.Total).Error; err != nil { return nil, err } // 活跃用户数 - if err := s.db.WithContext(ctx).Model(&models.User{}). + if err := s.db.WithContext(ctx).Model(&entity.User{}). Where("is_active = ?", true).Count(&stats.Active).Error; err != nil { return nil, err } @@ -380,7 +379,7 @@ func (s *UserService) GetUserStats(ctx context.Context) (*models.UserStats, erro Role string `json:"role"` Count int64 `json:"count"` } - if err := s.db.WithContext(ctx).Model(&models.User{}). + if err := s.db.WithContext(ctx).Model(&entity.User{}). Select("role, COUNT(*) as count"). Where("is_active = ?", true). Group("role"). @@ -395,7 +394,7 @@ func (s *UserService) GetUserStats(ctx context.Context) (*models.UserStats, erro // 本月新增用户 startOfMonth := time.Now().AddDate(0, 0, -time.Now().Day()+1) - if err := s.db.WithContext(ctx).Model(&models.User{}). + if err := s.db.WithContext(ctx).Model(&entity.User{}). Where("created_at >= ?", startOfMonth). Count(&stats.ThisMonth).Error; err != nil { return nil, err @@ -403,7 +402,7 @@ func (s *UserService) GetUserStats(ctx context.Context) (*models.UserStats, erro // 今日新增用户 startOfDay := time.Now().Truncate(24 * time.Hour) - if err := s.db.WithContext(ctx).Model(&models.User{}). + if err := s.db.WithContext(ctx).Model(&entity.User{}). Where("created_at >= ?", startOfDay). Count(&stats.Today).Error; err != nil { return nil, err @@ -415,7 +414,7 @@ func (s *UserService) GetUserStats(ctx context.Context) (*models.UserStats, erro // IsUsernameAvailable 检查用户名是否可用 func (s *UserService) IsUsernameAvailable(ctx context.Context, username string) (bool, error) { var count int64 - if err := s.db.WithContext(ctx).Model(&models.User{}). + if err := s.db.WithContext(ctx).Model(&entity.User{}). Where("username = ?", username).Count(&count).Error; err != nil { return false, err } @@ -425,7 +424,7 @@ func (s *UserService) IsUsernameAvailable(ctx context.Context, username string) // IsEmailAvailable 检查邮箱是否可用 func (s *UserService) IsEmailAvailable(ctx context.Context, email string) (bool, error) { var count int64 - if err := s.db.WithContext(ctx).Model(&models.User{}). + if err := s.db.WithContext(ctx).Model(&entity.User{}). Where("email = ?", email).Count(&count).Error; err != nil { return false, err } diff --git a/backend/internal/utils/file.go b/backend/internal/utils/file.go new file mode 100644 index 0000000..594be2f --- /dev/null +++ b/backend/internal/utils/file.go @@ -0,0 +1,172 @@ +package utils + +import ( + "crypto/md5" + "crypto/sha256" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" +) + +// GetFileExtension 获取文件扩展名 +func GetFileExtension(filename string) string { + return strings.ToLower(filepath.Ext(filename)) +} + +// GetMimeType 根据文件扩展名获取MIME类型 +func GetMimeType(filename string) string { + ext := GetFileExtension(filename) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + return "application/octet-stream" + } + return mimeType +} + +// IsImageFile 检查是否为图片文件 +func IsImageFile(filename string) bool { + imageExtensions := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} + ext := GetFileExtension(filename) + + for _, imageExt := range imageExtensions { + if ext == imageExt { + return true + } + } + + return false +} + +// GenerateUniqueFilename 生成唯一的文件名 +func GenerateUniqueFilename(originalFilename string) string { + ext := GetFileExtension(originalFilename) + timestamp := time.Now().Unix() + randomStr := GenerateRandomString(8) + + return fmt.Sprintf("%d_%s%s", timestamp, randomStr, ext) +} + +// GenerateFilePath 生成文件路径 +func GenerateFilePath(baseDir, subDir, filename string) string { + // 按日期组织文件夹 + now := time.Now() + dateDir := now.Format("2006/01/02") + + if subDir != "" { + return filepath.Join(baseDir, subDir, dateDir, filename) + } + + return filepath.Join(baseDir, dateDir, filename) +} + +// EnsureDir 确保目录存在 +func EnsureDir(dirPath string) error { + return os.MkdirAll(dirPath, 0755) +} + +// FileExists 检查文件是否存在 +func FileExists(filepath string) bool { + _, err := os.Stat(filepath) + return !os.IsNotExist(err) +} + +// GetFileSize 获取文件大小 +func GetFileSize(filepath string) (int64, error) { + info, err := os.Stat(filepath) + if err != nil { + return 0, err + } + return info.Size(), nil +} + +// CalculateFileMD5 计算文件MD5哈希 +func CalculateFileMD5(filepath string) (string, error) { + file, err := os.Open(filepath) + if err != nil { + return "", err + } + defer file.Close() + + hash := md5.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} + +// CalculateFileSHA256 计算文件SHA256哈希 +func CalculateFileSHA256(filepath string) (string, error) { + file, err := os.Open(filepath) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return fmt.Sprintf("%x", hash.Sum(nil)), nil +} + +// CopyFile 复制文件 +func CopyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + // 确保目标目录存在 + if err := EnsureDir(filepath.Dir(dst)); err != nil { + return err + } + + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + return err +} + +// DeleteFile 删除文件 +func DeleteFile(filepath string) error { + if !FileExists(filepath) { + return nil // 文件不存在,认为删除成功 + } + return os.Remove(filepath) +} + +// FormatFileSize 格式化文件大小为人类可读格式 +func FormatFileSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + units := []string{"KB", "MB", "GB", "TB", "PB"} + return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp]) +} + +// GetImageDimensions 获取图片尺寸(需要额外的图片处理库) +// 这里只是占位符,实际实现需要使用如 github.com/disintegration/imaging 等库 +func GetImageDimensions(filepath string) (width, height int, err error) { + // TODO: 实现图片尺寸获取 + // 需要添加图片处理依赖 + return 0, 0, fmt.Errorf("not implemented") +} \ No newline at end of file diff --git a/backend/internal/utils/random.go b/backend/internal/utils/random.go new file mode 100644 index 0000000..9941833 --- /dev/null +++ b/backend/internal/utils/random.go @@ -0,0 +1,77 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "math/big" + "time" +) + +const ( + // 字符集 + alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + numbers = "0123456789" +) + +// GenerateRandomString 生成指定长度的随机字符串 +func GenerateRandomString(length int) string { + return generateRandomFromCharset(length, alphanumeric) +} + +// GenerateRandomLetters 生成指定长度的随机字母字符串 +func GenerateRandomLetters(length int) string { + return generateRandomFromCharset(length, letters) +} + +// GenerateRandomNumbers 生成指定长度的随机数字字符串 +func GenerateRandomNumbers(length int) string { + return generateRandomFromCharset(length, numbers) +} + +// generateRandomFromCharset 从指定字符集生成随机字符串 +func generateRandomFromCharset(length int, charset string) string { + result := make([]byte, length) + charsetLen := big.NewInt(int64(len(charset))) + + for i := 0; i < length; i++ { + randomIndex, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + // 如果加密随机数生成失败,回退到时间种子 + return generateRandomFallback(length, charset) + } + result[i] = charset[randomIndex.Int64()] + } + + return string(result) +} + +// generateRandomFallback 回退的随机生成方法 +func generateRandomFallback(length int, charset string) string { + // 使用时间作为种子的简单随机生成 + seed := time.Now().UnixNano() + result := make([]byte, length) + + for i := 0; i < length; i++ { + seed = seed*1103515245 + 12345 + result[i] = charset[(seed/65536)%int64(len(charset))] + } + + return string(result) +} + +// GenerateSecureToken 生成安全令牌 +func GenerateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +// GenerateID 生成唯一ID +func GenerateID() string { + timestamp := time.Now().UnixNano() + random := GenerateRandomString(8) + return base64.URLEncoding.EncodeToString([]byte(string(timestamp) + random))[:16] +} \ No newline at end of file diff --git a/backend/internal/utils/slug.go b/backend/internal/utils/slug.go new file mode 100644 index 0000000..1c02990 --- /dev/null +++ b/backend/internal/utils/slug.go @@ -0,0 +1,68 @@ +package utils + +import ( + "regexp" + "strconv" + "strings" + "unicode" +) + +// GenerateSlug 生成URL友好的slug +func GenerateSlug(text string) string { + // 转换为小写 + text = strings.ToLower(text) + + // 移除重音字符 + text = removeAccents(text) + + // 替换空格和特殊字符为连字符 + reg := regexp.MustCompile(`[^\p{L}\p{N}]+`) + text = reg.ReplaceAllString(text, "-") + + // 移除首尾的连字符 + text = strings.Trim(text, "-") + + // 移除连续的连字符 + reg = regexp.MustCompile(`-+`) + text = reg.ReplaceAllString(text, "-") + + return text +} + +// removeAccents 移除重音字符的转换函数 +func removeAccents(text string) string { + var result strings.Builder + for _, r := range text { + if !unicode.Is(unicode.Mn, r) { + result.WriteRune(r) + } + } + return result.String() +} + +// TruncateString 截断字符串到指定长度 +func TruncateString(s string, length int) string { + if len(s) <= length { + return s + } + return s[:length] +} + +// GenerateUniqueSlug 生成唯一的slug +func GenerateUniqueSlug(base string, existingCheck func(string) bool) string { + slug := GenerateSlug(base) + if !existingCheck(slug) { + return slug + } + + // 如果存在重复,添加数字后缀 + for i := 1; i <= 1000; i++ { + candidateSlug := slug + "-" + strconv.Itoa(i) + if !existingCheck(candidateSlug) { + return candidateSlug + } + } + + // 如果还是重复,使用时间戳 + return slug + "-" + GenerateRandomString(6) +} \ No newline at end of file diff --git a/backend/internal/utils/time.go b/backend/internal/utils/time.go new file mode 100644 index 0000000..2f65349 --- /dev/null +++ b/backend/internal/utils/time.go @@ -0,0 +1,153 @@ +package utils + +import ( + "fmt" + "time" +) + +// FormatTime 格式化时间为字符串 +func FormatTime(t time.Time, layout string) string { + if layout == "" { + layout = "2006-01-02 15:04:05" + } + return t.Format(layout) +} + +// ParseTime 解析时间字符串 +func ParseTime(timeStr, layout string) (time.Time, error) { + if layout == "" { + layout = "2006-01-02 15:04:05" + } + return time.Parse(layout, timeStr) +} + +// GetTimeAgo 获取相对时间描述 +func GetTimeAgo(t time.Time) string { + now := time.Now() + diff := now.Sub(t) + + if diff < time.Minute { + return "刚刚" + } + + if diff < time.Hour { + minutes := int(diff.Minutes()) + return fmt.Sprintf("%d分钟前", minutes) + } + + if diff < 24*time.Hour { + hours := int(diff.Hours()) + return fmt.Sprintf("%d小时前", hours) + } + + if diff < 30*24*time.Hour { + days := int(diff.Hours() / 24) + return fmt.Sprintf("%d天前", days) + } + + if diff < 365*24*time.Hour { + months := int(diff.Hours() / (24 * 30)) + return fmt.Sprintf("%d个月前", months) + } + + years := int(diff.Hours() / (24 * 365)) + return fmt.Sprintf("%d年前", years) +} + +// IsToday 检查时间是否为今天 +func IsToday(t time.Time) bool { + now := time.Now() + return t.Year() == now.Year() && t.YearDay() == now.YearDay() +} + +// IsThisWeek 检查时间是否为本周 +func IsThisWeek(t time.Time) bool { + now := time.Now() + year, week := now.ISOWeek() + tYear, tWeek := t.ISOWeek() + return year == tYear && week == tWeek +} + +// IsThisMonth 检查时间是否为本月 +func IsThisMonth(t time.Time) bool { + now := time.Now() + return t.Year() == now.Year() && t.Month() == now.Month() +} + +// IsThisYear 检查时间是否为今年 +func IsThisYear(t time.Time) bool { + now := time.Now() + return t.Year() == now.Year() +} + +// GetWeekRange 获取本周的开始和结束时间 +func GetWeekRange(t time.Time) (start, end time.Time) { + // 获取周一作为周开始 + weekday := int(t.Weekday()) + if weekday == 0 { + weekday = 7 // 周日为7 + } + + start = t.AddDate(0, 0, -(weekday-1)) + start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()) + + end = start.AddDate(0, 0, 6) + end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, end.Location()) + + return start, end +} + +// GetMonthRange 获取本月的开始和结束时间 +func GetMonthRange(t time.Time) (start, end time.Time) { + start = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()) + end = start.AddDate(0, 1, -1) + end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, end.Location()) + + return start, end +} + +// GetYearRange 获取本年的开始和结束时间 +func GetYearRange(t time.Time) (start, end time.Time) { + start = time.Date(t.Year(), 1, 1, 0, 0, 0, 0, t.Location()) + end = time.Date(t.Year(), 12, 31, 23, 59, 59, 999999999, t.Location()) + + return start, end +} + +// Timestamp 获取当前时间戳(秒) +func Timestamp() int64 { + return time.Now().Unix() +} + +// TimestampMilli 获取当前时间戳(毫秒) +func TimestampMilli() int64 { + return time.Now().UnixNano() / 1e6 +} + +// FromTimestamp 从时间戳创建时间对象 +func FromTimestamp(timestamp int64) time.Time { + return time.Unix(timestamp, 0) +} + +// FromTimestampMilli 从毫秒时间戳创建时间对象 +func FromTimestampMilli(timestamp int64) time.Time { + return time.Unix(0, timestamp*1e6) +} + +// FormatDuration 格式化持续时间 +func FormatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.0f秒", d.Seconds()) + } + + if d < time.Hour { + return fmt.Sprintf("%.0f分钟", d.Minutes()) + } + + if d < 24*time.Hour { + return fmt.Sprintf("%.1f小时", d.Hours()) + } + + days := d.Hours() / 24 + return fmt.Sprintf("%.1f天", days) +} \ No newline at end of file diff --git a/backend/internal/utils/validation.go b/backend/internal/utils/validation.go new file mode 100644 index 0000000..a8ddf36 --- /dev/null +++ b/backend/internal/utils/validation.go @@ -0,0 +1,128 @@ +package utils + +import ( + "regexp" + "strings" + "unicode" +) + +// IsValidEmail 验证邮箱格式 +func IsValidEmail(email string) bool { + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + return emailRegex.MatchString(email) +} + +// IsValidUsername 验证用户名格式 +func IsValidUsername(username string) bool { + // 用户名长度3-20,只能包含字母、数字、下划线 + if len(username) < 3 || len(username) > 20 { + return false + } + + usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9_]+$`) + return usernameRegex.MatchString(username) +} + +// IsValidPassword 验证密码强度 +func IsValidPassword(password string) bool { + // 密码长度至少6位 + if len(password) < 6 { + return false + } + + // 检查是否包含字母和数字 + hasLetter := false + hasNumber := false + + for _, char := range password { + if unicode.IsLetter(char) { + hasLetter = true + } + if unicode.IsNumber(char) { + hasNumber = true + } + } + + return hasLetter && hasNumber +} + +// IsValidSlug 验证slug格式 +func IsValidSlug(slug string) bool { + // slug只能包含小写字母、数字和连字符 + if len(slug) == 0 || len(slug) > 100 { + return false + } + + slugRegex := regexp.MustCompile(`^[a-z0-9-]+$`) + return slugRegex.MatchString(slug) && !strings.HasPrefix(slug, "-") && !strings.HasSuffix(slug, "-") +} + +// IsValidHexColor 验证十六进制颜色代码 +func IsValidHexColor(color string) bool { + colorRegex := regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) + return colorRegex.MatchString(color) +} + +// IsValidURL 验证URL格式 +func IsValidURL(url string) bool { + urlRegex := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`) + return urlRegex.MatchString(url) +} + +// SanitizeString 清理字符串,移除HTML标签和特殊字符 +func SanitizeString(input string) string { + // 移除HTML标签 + htmlRegex := regexp.MustCompile(`<[^>]*>`) + cleaned := htmlRegex.ReplaceAllString(input, "") + + // 移除多余的空白字符 + whitespaceRegex := regexp.MustCompile(`\s+`) + cleaned = whitespaceRegex.ReplaceAllString(cleaned, " ") + + return strings.TrimSpace(cleaned) +} + +// ValidateImageFormat 验证图片格式 +func ValidateImageFormat(filename string) bool { + allowedExtensions := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} + lowerFilename := strings.ToLower(filename) + + for _, ext := range allowedExtensions { + if strings.HasSuffix(lowerFilename, ext) { + return true + } + } + + return false +} + +// ValidateFileSize 验证文件大小(字节) +func ValidateFileSize(size int64, maxSizeMB int64) bool { + maxSizeBytes := maxSizeMB * 1024 * 1024 + return size <= maxSizeBytes && size > 0 +} + +// NormalizeString 标准化字符串(去空格、转小写) +func NormalizeString(s string) string { + return strings.ToLower(strings.TrimSpace(s)) +} + +// ContainsOnlyASCII 检查字符串是否只包含ASCII字符 +func ContainsOnlyASCII(s string) bool { + for _, char := range s { + if char > 127 { + return false + } + } + return true +} + +// Contains 检查切片是否包含指定元素 +func Contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} \ No newline at end of file diff --git a/backend/migrations/002_create_photos.sql b/backend/migrations/002_create_photos.sql deleted file mode 100644 index 8686a7c..0000000 --- a/backend/migrations/002_create_photos.sql +++ /dev/null @@ -1,64 +0,0 @@ --- +migrate Up - -CREATE TABLE photos ( - id SERIAL PRIMARY KEY, - title VARCHAR(200) NOT NULL, - description TEXT, - filename VARCHAR(255) NOT NULL, - original_url VARCHAR(500) NOT NULL, - thumbnail_url VARCHAR(500), - medium_url VARCHAR(500), - large_url VARCHAR(500), - file_size BIGINT, - mime_type VARCHAR(100), - width INTEGER, - height INTEGER, - camera_make VARCHAR(100), - camera_model VARCHAR(100), - lens_model VARCHAR(100), - focal_length DECIMAL(5,2), - aperture DECIMAL(3,1), - shutter_speed VARCHAR(20), - iso INTEGER, - taken_at TIMESTAMP, - location_name VARCHAR(200), - latitude DECIMAL(10,8), - longitude DECIMAL(11,8), - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - album_id INTEGER, - category_id INTEGER, - is_public BOOLEAN DEFAULT true, - is_featured BOOLEAN DEFAULT false, - view_count INTEGER DEFAULT 0, - like_count INTEGER DEFAULT 0, - download_count INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_photos_user_id ON photos(user_id); -CREATE INDEX idx_photos_album_id ON photos(album_id); -CREATE INDEX idx_photos_category_id ON photos(category_id); -CREATE INDEX idx_photos_is_public ON photos(is_public); -CREATE INDEX idx_photos_is_featured ON photos(is_featured); -CREATE INDEX idx_photos_taken_at ON photos(taken_at); -CREATE INDEX idx_photos_created_at ON photos(created_at); -CREATE INDEX idx_photos_view_count ON photos(view_count); -CREATE INDEX idx_photos_like_count ON photos(like_count); -CREATE INDEX idx_photos_sort_order ON photos(sort_order); -CREATE INDEX idx_photos_deleted_at ON photos(deleted_at) WHERE deleted_at IS NOT NULL; - --- 地理位置索引 -CREATE INDEX idx_photos_location ON photos(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; - --- 添加更新时间触发器 -CREATE TRIGGER update_photos_updated_at BEFORE UPDATE ON photos -FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- +migrate Down - -DROP TRIGGER IF EXISTS update_photos_updated_at ON photos; -DROP TABLE IF EXISTS photos; \ No newline at end of file diff --git a/backend/migrations/004_create_categories.sql b/backend/migrations/004_create_categories.sql deleted file mode 100644 index 319dca6..0000000 --- a/backend/migrations/004_create_categories.sql +++ /dev/null @@ -1,113 +0,0 @@ --- +migrate Up - -CREATE TABLE categories ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description TEXT, - slug VARCHAR(255) UNIQUE, - parent_id INTEGER REFERENCES categories(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - cover_photo_id INTEGER REFERENCES photos(id) ON DELETE SET NULL, - color VARCHAR(7) DEFAULT '#3B82F6', - is_public BOOLEAN DEFAULT true, - photo_count INTEGER DEFAULT 0, - album_count INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP -); - --- 创建索引 -CREATE INDEX idx_categories_user_id ON categories(user_id); -CREATE INDEX idx_categories_parent_id ON categories(parent_id); -CREATE INDEX idx_categories_cover_photo_id ON categories(cover_photo_id); -CREATE INDEX idx_categories_slug ON categories(slug); -CREATE INDEX idx_categories_is_public ON categories(is_public); -CREATE INDEX idx_categories_created_at ON categories(created_at); -CREATE INDEX idx_categories_photo_count ON categories(photo_count); -CREATE INDEX idx_categories_album_count ON categories(album_count); -CREATE INDEX idx_categories_sort_order ON categories(sort_order); -CREATE INDEX idx_categories_deleted_at ON categories(deleted_at) WHERE deleted_at IS NOT NULL; - --- 树形结构查询优化 -CREATE INDEX idx_categories_parent_sort ON categories(parent_id, sort_order); - --- 添加更新时间触发器 -CREATE TRIGGER update_categories_updated_at BEFORE UPDATE ON categories -FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- 添加 slug 自动生成触发器 -CREATE OR REPLACE FUNCTION generate_category_slug() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.slug IS NULL OR NEW.slug = '' THEN - NEW.slug = lower(regexp_replace(NEW.name, '[^a-zA-Z0-9]+', '-', 'g')); - NEW.slug = trim(both '-' from NEW.slug); - - -- 确保 slug 唯一 - DECLARE - counter INTEGER := 0; - base_slug VARCHAR(255); - BEGIN - base_slug := NEW.slug; - WHILE EXISTS (SELECT 1 FROM categories WHERE slug = NEW.slug AND id != COALESCE(NEW.id, 0)) LOOP - counter := counter + 1; - NEW.slug := base_slug || '-' || counter; - END LOOP; - END; - END IF; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER generate_categories_slug BEFORE INSERT OR UPDATE ON categories -FOR EACH ROW EXECUTE FUNCTION generate_category_slug(); - --- 插入默认分类 -INSERT INTO categories (name, description, slug, user_id, color, is_public) -SELECT - '风景摄影' as name, - '自然风光、城市景观等风景类摄影作品' as description, - 'landscape' as slug, - 1 as user_id, - '#10B981' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - -INSERT INTO categories (name, description, slug, user_id, color, is_public) -SELECT - '人像摄影' as name, - '肖像、写真、人物摄影作品' as description, - 'portrait' as slug, - 1 as user_id, - '#F59E0B' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - -INSERT INTO categories (name, description, slug, user_id, color, is_public) -SELECT - '街拍摄影' as name, - '街头摄影、日常生活记录' as description, - 'street' as slug, - 1 as user_id, - '#EF4444' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - -INSERT INTO categories (name, description, slug, user_id, color, is_public) -SELECT - '建筑摄影' as name, - '建筑、室内设计等建筑类摄影作品' as description, - 'architecture' as slug, - 1 as user_id, - '#8B5CF6' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - --- +migrate Down - -DROP TRIGGER IF EXISTS generate_categories_slug ON categories; -DROP FUNCTION IF EXISTS generate_category_slug(); -DROP TRIGGER IF EXISTS update_categories_updated_at ON categories; -DROP TABLE IF EXISTS categories; \ No newline at end of file diff --git a/backend/migrations/004_create_photos.sql b/backend/migrations/004_create_photos.sql index 359f8e7..8686a7c 100644 --- a/backend/migrations/004_create_photos.sql +++ b/backend/migrations/004_create_photos.sql @@ -2,54 +2,63 @@ CREATE TABLE photos ( id SERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, + title VARCHAR(200) NOT NULL, description TEXT, filename VARCHAR(255) NOT NULL, - file_path VARCHAR(500) NOT NULL, + original_url VARCHAR(500) NOT NULL, + thumbnail_url VARCHAR(500), + medium_url VARCHAR(500), + large_url VARCHAR(500), file_size BIGINT, mime_type VARCHAR(100), width INTEGER, height INTEGER, - category_id INTEGER REFERENCES categories(id), - exif JSONB, + camera_make VARCHAR(100), + camera_model VARCHAR(100), + lens_model VARCHAR(100), + focal_length DECIMAL(5,2), + aperture DECIMAL(3,1), + shutter_speed VARCHAR(20), + iso INTEGER, taken_at TIMESTAMP, - location VARCHAR(255), + location_name VARCHAR(200), + latitude DECIMAL(10,8), + longitude DECIMAL(11,8), + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + album_id INTEGER, + category_id INTEGER, is_public BOOLEAN DEFAULT true, - status VARCHAR(20) DEFAULT 'draft', + is_featured BOOLEAN DEFAULT false, view_count INTEGER DEFAULT 0, like_count INTEGER DEFAULT 0, - user_id INTEGER NOT NULL REFERENCES users(id), + download_count INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP ); --- 创建照片标签关联表 -CREATE TABLE photo_tags ( - photo_id INTEGER REFERENCES photos(id) ON DELETE CASCADE, - tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (photo_id, tag_id) -); - -- 创建索引 -CREATE INDEX idx_photos_category_id ON photos(category_id); CREATE INDEX idx_photos_user_id ON photos(user_id); -CREATE INDEX idx_photos_status ON photos(status); +CREATE INDEX idx_photos_album_id ON photos(album_id); +CREATE INDEX idx_photos_category_id ON photos(category_id); CREATE INDEX idx_photos_is_public ON photos(is_public); +CREATE INDEX idx_photos_is_featured ON photos(is_featured); CREATE INDEX idx_photos_taken_at ON photos(taken_at); CREATE INDEX idx_photos_created_at ON photos(created_at); CREATE INDEX idx_photos_view_count ON photos(view_count); CREATE INDEX idx_photos_like_count ON photos(like_count); -CREATE INDEX idx_photos_deleted_at ON photos(deleted_at); +CREATE INDEX idx_photos_sort_order ON photos(sort_order); +CREATE INDEX idx_photos_deleted_at ON photos(deleted_at) WHERE deleted_at IS NOT NULL; --- 为JSONB字段创建GIN索引(支持高效的JSON查询) -CREATE INDEX idx_photos_exif_gin ON photos USING GIN (exif); +-- 地理位置索引 +CREATE INDEX idx_photos_location ON photos(latitude, longitude) WHERE latitude IS NOT NULL AND longitude IS NOT NULL; --- 全文搜索索引 -CREATE INDEX idx_photos_title_gin ON photos USING GIN (to_tsvector('english', title)); -CREATE INDEX idx_photos_description_gin ON photos USING GIN (to_tsvector('english', description)); +-- 添加更新时间触发器 +CREATE TRIGGER update_photos_updated_at BEFORE UPDATE ON photos +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- +migrate Down -DROP TABLE IF EXISTS photo_tags; +DROP TRIGGER IF EXISTS update_photos_updated_at ON photos; DROP TABLE IF EXISTS photos; \ No newline at end of file diff --git a/backend/migrations/003_create_albums.sql b/backend/migrations/005_create_albums.sql similarity index 100% rename from backend/migrations/003_create_albums.sql rename to backend/migrations/005_create_albums.sql diff --git a/backend/migrations/005_create_tags.sql b/backend/migrations/005_create_tags.sql deleted file mode 100644 index 6b8f0a1..0000000 --- a/backend/migrations/005_create_tags.sql +++ /dev/null @@ -1,164 +0,0 @@ --- +migrate Up - --- 创建标签表 -CREATE TABLE tags ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - slug VARCHAR(255) UNIQUE, - color VARCHAR(7) DEFAULT '#6B7280', - description TEXT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - is_public BOOLEAN DEFAULT true, - photo_count INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP -); - --- 创建照片标签关联表(多对多) -CREATE TABLE photo_tags ( - id SERIAL PRIMARY KEY, - photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(photo_id, tag_id) -); - --- 创建相册标签关联表(多对多) -CREATE TABLE album_tags ( - id SERIAL PRIMARY KEY, - album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(album_id, tag_id) -); - --- 创建标签表索引 -CREATE INDEX idx_tags_user_id ON tags(user_id); -CREATE INDEX idx_tags_slug ON tags(slug); -CREATE INDEX idx_tags_is_public ON tags(is_public); -CREATE INDEX idx_tags_created_at ON tags(created_at); -CREATE INDEX idx_tags_photo_count ON tags(photo_count); -CREATE INDEX idx_tags_deleted_at ON tags(deleted_at) WHERE deleted_at IS NOT NULL; - --- 创建照片标签关联表索引 -CREATE INDEX idx_photo_tags_photo_id ON photo_tags(photo_id); -CREATE INDEX idx_photo_tags_tag_id ON photo_tags(tag_id); -CREATE INDEX idx_photo_tags_created_at ON photo_tags(created_at); - --- 创建相册标签关联表索引 -CREATE INDEX idx_album_tags_album_id ON album_tags(album_id); -CREATE INDEX idx_album_tags_tag_id ON album_tags(tag_id); -CREATE INDEX idx_album_tags_created_at ON album_tags(created_at); - --- 添加更新时间触发器 -CREATE TRIGGER update_tags_updated_at BEFORE UPDATE ON tags -FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- 添加 slug 自动生成触发器 -CREATE OR REPLACE FUNCTION generate_tag_slug() -RETURNS TRIGGER AS $$ -BEGIN - IF NEW.slug IS NULL OR NEW.slug = '' THEN - NEW.slug = lower(regexp_replace(NEW.name, '[^a-zA-Z0-9]+', '-', 'g')); - NEW.slug = trim(both '-' from NEW.slug); - - -- 确保 slug 唯一 - DECLARE - counter INTEGER := 0; - base_slug VARCHAR(255); - BEGIN - base_slug := NEW.slug; - WHILE EXISTS (SELECT 1 FROM tags WHERE slug = NEW.slug AND id != COALESCE(NEW.id, 0)) LOOP - counter := counter + 1; - NEW.slug := base_slug || '-' || counter; - END LOOP; - END; - END IF; - RETURN NEW; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER generate_tags_slug BEFORE INSERT OR UPDATE ON tags -FOR EACH ROW EXECUTE FUNCTION generate_tag_slug(); - --- 创建标签计数更新触发器 -CREATE OR REPLACE FUNCTION update_tag_photo_count() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_OP = 'INSERT' THEN - UPDATE tags SET photo_count = photo_count + 1 WHERE id = NEW.tag_id; - RETURN NEW; - ELSIF TG_OP = 'DELETE' THEN - UPDATE tags SET photo_count = photo_count - 1 WHERE id = OLD.tag_id; - RETURN OLD; - END IF; - RETURN NULL; -END; -$$ language 'plpgsql'; - -CREATE TRIGGER trigger_update_tag_photo_count -AFTER INSERT OR DELETE ON photo_tags -FOR EACH ROW EXECUTE FUNCTION update_tag_photo_count(); - --- 插入默认标签 -INSERT INTO tags (name, description, slug, user_id, color, is_public) -SELECT - '自然' as name, - '自然风光、植物、动物等自然主题' as description, - 'nature' as slug, - 1 as user_id, - '#10B981' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - -INSERT INTO tags (name, description, slug, user_id, color, is_public) -SELECT - '城市' as name, - '城市景观、建筑、街道等城市主题' as description, - 'city' as slug, - 1 as user_id, - '#3B82F6' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - -INSERT INTO tags (name, description, slug, user_id, color, is_public) -SELECT - '黑白' as name, - '黑白摄影作品' as description, - 'black-white' as slug, - 1 as user_id, - '#6B7280' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - -INSERT INTO tags (name, description, slug, user_id, color, is_public) -SELECT - '夜景' as name, - '夜晚拍摄的照片' as description, - 'night' as slug, - 1 as user_id, - '#1F2937' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - -INSERT INTO tags (name, description, slug, user_id, color, is_public) -SELECT - '日出日落' as name, - '日出、日落、黄金时段拍摄' as description, - 'sunrise-sunset' as slug, - 1 as user_id, - '#F59E0B' as color, - true as is_public -WHERE EXISTS (SELECT 1 FROM users WHERE id = 1); - --- +migrate Down - -DROP TRIGGER IF EXISTS trigger_update_tag_photo_count ON photo_tags; -DROP FUNCTION IF EXISTS update_tag_photo_count(); -DROP TRIGGER IF EXISTS generate_tags_slug ON tags; -DROP FUNCTION IF EXISTS generate_tag_slug(); -DROP TRIGGER IF EXISTS update_tags_updated_at ON tags; -DROP TABLE IF EXISTS album_tags; -DROP TABLE IF EXISTS photo_tags; -DROP TABLE IF EXISTS tags; \ No newline at end of file