diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..00b15c1 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,98 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# IDE directories +.vscode/ +.idea/ +*.swp +*.swo + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Local environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Log files +*.log +logs/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Build output +/bin/ +/dist/ +/build/ + +# Temporary files +/tmp/ +/temp/ +*.tmp + +# Coverage reports +coverage.out +coverage.html + +# Test cache +/test-results/ + +# Local uploads directory +/uploads/ + +# Local storage +/storage/ + +# Config files with secrets (keep templates) +config.local.yaml +config.secret.yaml + +# Node.js (should not be in Go project) +node_modules/ +package-lock.json +package.json +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor backup files +*~ +*.bak +*.backup + +# Profiling output +*.prof +*.cpu +*.mem + +# Air live reload +.air.toml +tmp/ \ No newline at end of file diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 4761e6c..a4c1b65 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -1,994 +1,596 @@ -# 后端模块 - CLAUDE.md +# Backend API Service - CLAUDE.md -此文件为 Claude Code 在后端模块工作时提供指导。 +本文件为 Claude Code 在后端 API 服务模块中工作时提供指导。 ## 🎯 模块概览 -后端模块提供摄影作品集项目的 API 服务,包括照片管理、用户认证、数据存储等核心功能。 +这是一个基于 Go + Gin 框架的 REST API 后端服务,采用简洁的四层架构模式,遵循 Go 语言的简洁设计哲学。 -### 功能特性 -- 🔐 用户认证和授权管理 -- 📸 照片和相册 CRUD 操作 +### 主要特性 +- 🏗️ 简洁四层架构 (API → Service → Repository → Model) +- 🚀 多种部署模式 (生产/开发/Mock) +- 📊 多数据库支持 (PostgreSQL + SQLite + Redis) +- 🔐 JWT 认证 + 基于角色的访问控制 - 📁 文件上传和存储管理 -- 🎨 主题和配置管理 -- 📊 访问统计和数据分析 -- 🔄 数据同步和缓存 +- 🐳 Docker 容器化部署 +- 📊 健康检查和监控 +- 📚 API 文档生成 ### 技术栈 -- **语言**: Go 1.21+ -- **框架**: Gin + GORM -- **数据库**: PostgreSQL / MySQL -- **缓存**: Redis -- **存储**: MinIO / AWS S3 -- **认证**: JWT Token -- **部署**: Docker + Docker Compose +- **语言**: Go 1.23+ +- **框架**: Gin v1.10.1 +- **数据库**: PostgreSQL (生产) + SQLite (开发) + Redis (缓存) +- **ORM**: GORM v1.30.0 +- **认证**: JWT (golang-jwt/jwt/v5) +- **日志**: Uber Zap +- **配置**: Viper +- **容器化**: Docker + Docker Compose -## 📁 项目结构 +## 📁 简洁架构设计 +### 核心模块结构(重构后) ``` backend/ -├── CLAUDE.md # 🔍 当前文件 - 后端开发指导 -├── cmd/ # 应用入口 -│ └── server/ # 服务器启动 +├── CLAUDE.md # 📋 当前文件 - 后端总览 +├── cmd/ # 🚀 应用入口模块 +│ ├── server/ # 服务启动器 +│ │ ├── CLAUDE.md # 启动服务配置指导 +│ │ └── main.go # 统一入口(支持多模式) +│ └── migrate/ # 数据库迁移工具 │ └── main.go -├── internal/ # 内部模块 -│ ├── api/ # API 层 -│ │ ├── handlers/ # 请求处理器 -│ │ ├── middleware/ # 中间件 -│ │ └── routes/ # 路由定义 -│ ├── service/ # 业务逻辑层 -│ │ ├── auth/ # 认证服务 -│ │ ├── photo/ # 照片服务 -│ │ ├── album/ # 相册服务 -│ │ └── user/ # 用户服务 -│ ├── repository/ # 数据访问层 -│ │ ├── postgres/ # PostgreSQL 实现 -│ │ └── redis/ # Redis 实现 -│ ├── models/ # 数据模型 -│ ├── config/ # 配置管理 -│ └── utils/ # 工具函数 -├── pkg/ # 公共包 -│ ├── logger/ # 日志包 -│ ├── validator/ # 验证包 -│ └── response/ # 响应包 -├── migrations/ # 数据库迁移 -├── docs/ # API 文档 -├── scripts/ # 部署脚本 -├── configs/ # 配置文件 -│ ├── config.yaml # 主配置 -│ ├── config.dev.yaml # 开发环境 -│ └── config.prod.yaml # 生产环境 -├── docker-compose.yml # 容器编排 -├── Dockerfile # 容器构建 -├── Makefile # 构建脚本 -├── go.mod # Go 模块定义 -├── go.sum # 依赖校验 -└── README.md # 模块说明 +├── 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 风格的四层架构 -### 环境要求 -- **Go**: 1.21+ -- **Docker**: 20.10+ -- **Docker Compose**: 2.0+ -- **PostgreSQL**: 14+ -- **Redis**: 6.2+ +#### 🌐 API 层 (`internal/api/`) +- **职责**: HTTP 请求处理、路由定义、中间件、参数验证 +- **文件**: `handlers/`, `middleware/`, `routes/`, `validators/` +- **指导**: `internal/api/CLAUDE.md` -### 快速启动 +#### 📋 Service 层 (`internal/service/`) +- **职责**: 业务逻辑处理、服务编排、事务管理 +- **文件**: `auth/`, `user/`, `photo/`, `category/`, `storage/` +- **指导**: `internal/service/CLAUDE.md` + +#### 🔧 Repository 层 (`internal/repository/`) +- **职责**: 数据访问、数据库操作、缓存管理 +- **文件**: `interfaces/`, `postgres/`, `redis/`, `sqlite/` +- **指导**: `internal/repository/CLAUDE.md` + +#### 📦 Model 层 (`internal/model/`) +- **职责**: 数据模型、实体定义、DTO 转换 +- **文件**: `entity/`, `dto/`, `request/` +- **指导**: `internal/model/CLAUDE.md` + +### 简洁性原则 + +1. **单一职责**: 每个模块只负责一个明确的功能 +2. **依赖注入**: 使用接口解耦,便于测试和扩展 +3. **配置集中**: 所有配置统一管理,支持多环境 +4. **错误处理**: 统一的错误处理机制 +5. **代码生成**: 减少重复代码,提高开发效率 + +## 🚀 快速开始 + +### 开发环境设置 ```bash -# 进入后端目录 +# 1. 环境准备 cd backend/ +make setup # 初始化开发环境 -# 启动开发环境 (Docker) -make dev-up +# 2. 开发模式选择 +make dev-simple # Mock 服务器 (前端开发) +make dev # SQLite 开发服务器 (全功能) +make dev-full # PostgreSQL 开发服务器 (生产环境) -# 运行数据库迁移 -make migrate - -# 启动开发服务器 -make dev - -# 或者直接运行 -go run cmd/server/main.go +# 3. 生产部署 +make prod-up # Docker 容器部署 ``` -### 环境变量配置 +### 服务模式说明 +- **Mock 模式**: 快速响应的模拟 API,用于前端开发 +- **开发模式**: 完整功能的 SQLite 数据库,用于本地开发 +- **生产模式**: PostgreSQL + Redis,用于生产环境 + +## 🔧 Go 风格开发规范 + +### 代码结构规范 +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) +``` + +### 接口设计规范 +```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) +} + +// 实现规范 +type UserService struct { + userRepo repository.UserRepositoryr + logger logger.Logger +} + +func NewUserService(userRepo repository.UserRepositoryr, logger logger.Logger) UserServicer { + return &UserService{ + userRepo: userRepo, + logger: logger, + } +} +``` + +### RESTful API 设计规范 +``` +资源路径规范: +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 # 为用户创建照片 + +查询参数: +GET /api/v1/users?page=1&limit=20&sort=created_at&order=desc +``` + +### 统一响应格式 +```go +// 成功响应 +type SuccessResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Message string `json:"message,omitempty"` + Timestamp int64 `json:"timestamp"` +} + +// 错误响应 +type ErrorResponse struct { + Success bool `json:"success"` + Error Error `json:"error"` + Timestamp int64 `json:"timestamp"` +} + +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} +``` + +### 错误处理规范 +```go +// 自定义错误类型 +type AppError struct { + Code string + Message string + Details string + Err error +} + +func (e *AppError) Error() string { + return e.Message +} + +// 错误码定义 +const ( + ErrCodeUserNotFound = "USER_NOT_FOUND" + ErrCodeInvalidParameter = "INVALID_PARAMETER" + ErrCodePermissionDenied = "PERMISSION_DENIED" + ErrCodeInternalError = "INTERNAL_ERROR" +) + +// 错误处理函数 +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 + } + + // 未知错误 + c.JSON(http.StatusInternalServerError, ErrorResponse{ + Success: false, + Error: Error{ + Code: ErrCodeInternalError, + Message: "内部服务器错误", + }, + Timestamp: time.Now().Unix(), + }) +} +``` + +### 日志记录规范 +```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"), +) +``` + +### 配置管理规范 +```go +// 配置结构体 +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + JWT JWTConfig `mapstructure:"jwt"` + Storage StorageConfig `mapstructure:"storage"` +} + +// 环境变量映射 +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"` +} +``` + +## 📊 数据库设计 + +### 主要实体 +- **User**: 用户信息和权限 +- **Photo**: 照片信息和元数据 +- **Category**: 照片分类 +- **Tag**: 照片标签 +- **Album**: 相册管理 + +### 关系设计 +``` +User (1:N) Photo +Photo (N:M) Category +Photo (N:M) Tag +User (1:N) Album +Album (N:M) Photo +``` + +## 🔐 认证和授权 + +### JWT 认证流程 +1. 用户登录 → 验证凭据 +2. 生成 JWT Token (AccessToken + RefreshToken) +3. 客户端携带 Token 访问受保护资源 +4. 服务器验证 Token 有效性 + +### 权限角色 +- **Admin**: 系统管理员 (所有权限) +- **Editor**: 内容编辑者 (内容管理) +- **User**: 普通用户 (查看权限) + +## 🧪 测试策略 + +### 测试类型 +- **单元测试**: 业务逻辑和工具函数 +- **集成测试**: API 接口和数据库交互 +- **性能测试**: 接口响应时间和并发测试 + +### 测试工具 +- **Go Testing**: 内置测试框架 +- **Testify**: 断言和模拟工具 +- **Mockery**: 接口模拟生成 + +## 📚 API 文档 + +### 文档生成 +- **Swagger/OpenAPI**: 自动生成 API 文档 +- **Postman Collection**: 接口测试集合 +- **README**: 快速开始指南 + +### 文档维护 +- 接口变更时同步更新文档 +- 提供完整的请求/响应示例 +- 包含错误码和处理说明 + +## 🚀 部署配置 + +### 环境变量 ```bash -# .env # 数据库配置 DB_HOST=localhost DB_PORT=5432 +DB_NAME=photography DB_USER=postgres DB_PASSWORD=password -DB_NAME=photography - -# Redis 配置 -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= # JWT 配置 JWT_SECRET=your-secret-key JWT_EXPIRES_IN=24h -# 文件存储配置 -STORAGE_TYPE=local # local, s3, minio +# 文件存储 +STORAGE_TYPE=local STORAGE_PATH=./uploads -AWS_REGION=us-east-1 -AWS_BUCKET=photography-bucket - -# 服务器配置 -PORT=8080 -GIN_MODE=debug ``` -## 🏗️ 项目架构 +### Docker 部署 +```bash +# 构建镜像 +make build-image -### 分层架构 -``` -┌─────────────────┐ -│ API Layer │ ← handlers, middleware, routes -├─────────────────┤ -│ Service Layer │ ← business logic -├─────────────────┤ -│Repository Layer │ ← data access -├─────────────────┤ -│ Models Layer │ ← data structures -└─────────────────┘ +# 启动服务 +make prod-up + +# 查看日志 +make logs ``` -### 目录结构详解 +## 📋 常用命令 -#### 🎯 API 层 -``` -internal/api/ -├── handlers/ # 请求处理器 -│ ├── auth.go # 认证相关 -│ ├── photo.go # 照片管理 -│ ├── album.go # 相册管理 -│ ├── user.go # 用户管理 -│ └── upload.go # 文件上传 -├── middleware/ # 中间件 -│ ├── auth.go # 认证中间件 -│ ├── cors.go # CORS 处理 -│ ├── logger.go # 日志中间件 -│ └── rate_limit.go # 限流中间件 -└── routes/ # 路由定义 - ├── api_v1.go # API v1 路由 - └── admin.go # 管理后台路由 -``` - -#### 💼 业务逻辑层 -``` -internal/service/ -├── auth/ # 认证服务 -│ ├── auth.go # 认证逻辑 -│ ├── jwt.go # JWT 处理 -│ └── permission.go # 权限控制 -├── photo/ # 照片服务 -│ ├── photo.go # 照片操作 -│ ├── metadata.go # 元数据处理 -│ └── thumbnail.go # 缩略图生成 -├── album/ # 相册服务 -│ ├── album.go # 相册操作 -│ └── organization.go # 组织管理 -└── user/ # 用户服务 - ├── user.go # 用户管理 - └── profile.go # 用户资料 -``` - -#### 🗄️ 数据访问层 -``` -internal/repository/ -├── postgres/ # PostgreSQL 实现 -│ ├── photo.go # 照片数据操作 -│ ├── album.go # 相册数据操作 -│ ├── user.go # 用户数据操作 -│ └── migration.go # 数据迁移 -└── redis/ # Redis 实现 - ├── cache.go # 缓存操作 - └── session.go # 会话管理 -``` - -## 🗃️ 数据库设计 - -### 数据模型定义 -```go -// internal/models/photo.go -type Photo struct { - ID uint `gorm:"primaryKey" json:"id"` - Title string `gorm:"not null" json:"title"` - Description string `json:"description"` - URL string `gorm:"not null" json:"url"` - ThumbnailURL string `json:"thumbnail_url"` - AlbumID uint `json:"album_id"` - UserID uint `json:"user_id"` - Tags []Tag `gorm:"many2many:photo_tags" json:"tags"` - Metadata PhotoMetadata `gorm:"type:jsonb" json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// internal/models/album.go -type Album struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"not null" json:"name"` - Description string `json:"description"` - CoverPhotoID *uint `json:"cover_photo_id"` - CoverPhoto *Photo `gorm:"foreignKey:CoverPhotoID" json:"cover_photo"` - Photos []Photo `gorm:"foreignKey:AlbumID" json:"photos"` - IsPublic bool `gorm:"default:true" json:"is_public"` - UserID uint `json:"user_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// internal/models/user.go -type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"unique;not null" json:"username"` - Email string `gorm:"unique;not null" json:"email"` - Password string `gorm:"not null" json:"-"` - Role string `gorm:"default:user" json:"role"` - IsActive bool `gorm:"default:true" json:"is_active"` - Profile UserProfile `gorm:"foreignKey:UserID" json:"profile"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} -``` - -### 数据库迁移 -```sql --- migrations/001_create_users.sql -CREATE TABLE users ( - id SERIAL PRIMARY KEY, - username VARCHAR(50) UNIQUE NOT NULL, - email VARCHAR(100) UNIQUE NOT NULL, - password VARCHAR(255) NOT NULL, - role VARCHAR(20) DEFAULT 'user', - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- migrations/002_create_albums.sql -CREATE TABLE albums ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - description TEXT, - cover_photo_id INTEGER, - is_public BOOLEAN DEFAULT true, - user_id INTEGER REFERENCES users(id), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- migrations/003_create_photos.sql -CREATE TABLE photos ( - id SERIAL PRIMARY KEY, - title VARCHAR(200) NOT NULL, - description TEXT, - url VARCHAR(500) NOT NULL, - thumbnail_url VARCHAR(500), - album_id INTEGER REFERENCES albums(id), - user_id INTEGER REFERENCES users(id), - metadata JSONB, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -## 🔌 API 接口设计 - -### RESTful API 规范 -```go -// internal/api/handlers/photo.go -type PhotoHandler struct { - photoService *service.PhotoService -} - -// GET /api/v1/photos -func (h *PhotoHandler) GetPhotos(c *gin.Context) { - params := &service.PhotoListParams{} - if err := c.ShouldBindQuery(params); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - photos, total, err := h.photoService.GetPhotos(params) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.JSON(200, gin.H{ - "data": photos, - "total": total, - "page": params.Page, - "per_page": params.PerPage, - }) -} - -// POST /api/v1/photos -func (h *PhotoHandler) CreatePhoto(c *gin.Context) { - var req service.CreatePhotoRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - userID := c.GetUint("user_id") - req.UserID = userID - - photo, err := h.photoService.CreatePhoto(&req) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.JSON(201, gin.H{"data": photo}) -} - -// PUT /api/v1/photos/:id -func (h *PhotoHandler) UpdatePhoto(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 32) - - var req service.UpdatePhotoRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - photo, err := h.photoService.UpdatePhoto(uint(id), &req) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.JSON(200, gin.H{"data": photo}) -} - -// DELETE /api/v1/photos/:id -func (h *PhotoHandler) DeletePhoto(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 32) - - err := h.photoService.DeletePhoto(uint(id)) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.JSON(204, nil) -} -``` - -### API 路由配置 -```go -// internal/api/routes/api_v1.go -func SetupAPIV1Routes(r *gin.Engine, handlers *Handlers) { - api := r.Group("/api/v1") - - // 公开接口 - api.GET("/photos", handlers.Photo.GetPhotos) - api.GET("/photos/:id", handlers.Photo.GetPhoto) - api.GET("/albums", handlers.Album.GetAlbums) - api.GET("/albums/:id", handlers.Album.GetAlbum) - - // 认证相关 - auth := api.Group("/auth") - { - auth.POST("/login", handlers.Auth.Login) - auth.POST("/register", handlers.Auth.Register) - auth.POST("/refresh", handlers.Auth.RefreshToken) - } - - // 需要认证的接口 - protected := api.Group("/", middleware.AuthRequired()) - { - protected.POST("/photos", handlers.Photo.CreatePhoto) - protected.PUT("/photos/:id", handlers.Photo.UpdatePhoto) - protected.DELETE("/photos/:id", handlers.Photo.DeletePhoto) - protected.POST("/upload", handlers.Upload.UploadFile) - } - - // 管理员接口 - admin := api.Group("/admin", middleware.AdminRequired()) - { - admin.GET("/users", handlers.User.GetUsers) - admin.PUT("/users/:id", handlers.User.UpdateUser) - admin.DELETE("/users/:id", handlers.User.DeleteUser) - } -} -``` - -## 🔐 认证和授权 - -### JWT 认证实现 -```go -// internal/service/auth/jwt.go -type JWTService struct { - secretKey []byte - expiresIn time.Duration -} - -func NewJWTService(secretKey string, expiresIn time.Duration) *JWTService { - return &JWTService{ - secretKey: []byte(secretKey), - expiresIn: expiresIn, - } -} - -func (s *JWTService) GenerateToken(userID uint, role string) (string, error) { - claims := jwt.MapClaims{ - "user_id": userID, - "role": role, - "exp": time.Now().Add(s.expiresIn).Unix(), - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(s.secretKey) -} - -func (s *JWTService) ValidateToken(tokenString string) (*jwt.Token, error) { - return jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return s.secretKey, nil - }) -} -``` - -### 权限控制中间件 -```go -// internal/api/middleware/auth.go -func AuthRequired() gin.HandlerFunc { - return func(c *gin.Context) { - tokenString := c.GetHeader("Authorization") - if tokenString == "" { - c.JSON(401, gin.H{"error": "Authorization header required"}) - c.Abort() - return - } - - // 移除 "Bearer " 前缀 - if strings.HasPrefix(tokenString, "Bearer ") { - tokenString = tokenString[7:] - } - - token, err := jwtService.ValidateToken(tokenString) - if err != nil || !token.Valid { - c.JSON(401, gin.H{"error": "Invalid token"}) - c.Abort() - return - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - c.JSON(401, gin.H{"error": "Invalid token claims"}) - c.Abort() - return - } - - c.Set("user_id", uint(claims["user_id"].(float64))) - c.Set("role", claims["role"].(string)) - c.Next() - } -} - -func AdminRequired() gin.HandlerFunc { - return gin.HandlerFunc(func(c *gin.Context) { - AuthRequired()(c) - - if c.IsAborted() { - return - } - - role := c.GetString("role") - if role != "admin" { - c.JSON(403, gin.H{"error": "Admin access required"}) - c.Abort() - return - } - - c.Next() - }) -} -``` - -## 📁 文件存储管理 - -### 存储接口设计 -```go -// internal/service/storage/interface.go -type StorageService interface { - Upload(file multipart.File, filename string) (string, error) - Delete(filename string) error - GetURL(filename string) string -} - -// internal/service/storage/local.go -type LocalStorage struct { - basePath string - baseURL string -} - -func (s *LocalStorage) Upload(file multipart.File, filename string) (string, error) { - filepath := path.Join(s.basePath, filename) - - out, err := os.Create(filepath) - if err != nil { - return "", err - } - defer out.Close() - - _, err = io.Copy(out, file) - if err != nil { - return "", err - } - - return s.GetURL(filename), nil -} - -func (s *LocalStorage) GetURL(filename string) string { - return fmt.Sprintf("%s/%s", s.baseURL, filename) -} - -// internal/service/storage/s3.go -type S3Storage struct { - client *s3.Client - bucket string - region string -} - -func (s *S3Storage) Upload(file multipart.File, filename string) (string, error) { - _, err := s.client.PutObject(context.TODO(), &s3.PutObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(filename), - Body: file, - }) - - if err != nil { - return "", err - } - - return s.GetURL(filename), nil -} -``` - -### 图片处理 -```go -// internal/service/photo/thumbnail.go -import ( - "image" - "image/jpeg" - "image/png" - "golang.org/x/image/draw" -) - -func GenerateThumbnail(src image.Image, width, height int) (image.Image, error) { - bounds := src.Bounds() - srcWidth := bounds.Max.X - srcHeight := bounds.Max.Y - - // 计算缩放比例 - scaleX := float64(width) / float64(srcWidth) - scaleY := float64(height) / float64(srcHeight) - scale := math.Min(scaleX, scaleY) - - newWidth := int(float64(srcWidth) * scale) - newHeight := int(float64(srcHeight) * scale) - - // 创建新图像 - dst := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) - - // 缩放图像 - draw.BiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) - - return dst, nil -} -``` - -## 🚀 构建和部署 - -### Makefile 配置 -```makefile -# Makefile -.PHONY: build run test clean docker-build docker-run - -# 构建 -build: - go build -o bin/server cmd/server/main.go - -# 运行 -run: - go run cmd/server/main.go - -# 测试 -test: - go test ./... +### 开发命令 +```bash +# 代码生成 +make generate # 生成代码 (mocks, swagger) # 代码检查 -lint: - golangci-lint run +make lint # 代码检查 +make fmt # 代码格式化 +make vet # 代码分析 -# 清理 -clean: - rm -rf bin/ +# 测试 +make test # 运行测试 +make test-cover # 测试覆盖率 +make test-integration # 集成测试 -# 数据库迁移 -migrate: - migrate -path migrations -database "postgres://user:pass@localhost/dbname?sslmode=disable" up - -# Docker 构建 -docker-build: - docker build -t photography-backend . - -# Docker 运行 -docker-run: - docker-compose up -d - -# 开发环境 -dev-up: - docker-compose -f docker-compose.dev.yml up -d - -dev-down: - docker-compose -f docker-compose.dev.yml down - -# 生产环境 -prod-up: - docker-compose -f docker-compose.prod.yml up -d - -prod-down: - docker-compose -f docker-compose.prod.yml down +# 构建 +make build # 构建二进制文件 +make build-image # 构建 Docker 镜像 ``` -### Docker 配置 -```dockerfile -# Dockerfile -FROM golang:1.21-alpine AS builder +### 数据库命令 +```bash +# 迁移 +make migrate-up # 应用迁移 +make migrate-down # 回滚迁移 +make migrate-create # 创建迁移文件 -WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go - -FROM alpine:latest -RUN apk --no-cache add ca-certificates -WORKDIR /root/ - -COPY --from=builder /app/main . -COPY --from=builder /app/configs ./configs -COPY --from=builder /app/migrations ./migrations - -EXPOSE 8080 -CMD ["./main"] +# 数据库管理 +make db-reset # 重置数据库 +make db-seed # 导入种子数据 ``` -### Docker Compose 配置 -```yaml -# docker-compose.yml -version: '3.8' - -services: - backend: - build: . - ports: - - "8080:8080" - depends_on: - - postgres - - redis - environment: - - DB_HOST=postgres - - DB_PORT=5432 - - DB_USER=postgres - - DB_PASSWORD=password - - DB_NAME=photography - - REDIS_HOST=redis - - REDIS_PORT=6379 - volumes: - - ./uploads:/app/uploads - - ./configs:/app/configs - - postgres: - image: postgres:14 - environment: - - POSTGRES_DB=photography - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=password - volumes: - - postgres_data:/var/lib/postgresql/data - ports: - - "5432:5432" - - redis: - image: redis:6.2 - ports: - - "6379:6379" - volumes: - - redis_data:/data - -volumes: - postgres_data: - redis_data: -``` - -## 🔧 开发指南 - -### 代码规范 -- 使用 Go 标准格式化工具 `go fmt` -- 遵循 Go 命名规范 -- 使用 `golangci-lint` 进行代码检查 -- 编写单元测试,覆盖率不低于 80% - -### 错误处理 -```go -// pkg/response/response.go -type Response struct { - Code int `json:"code"` - Message string `json:"message"` - Data interface{} `json:"data,omitempty"` -} - -func Success(data interface{}) *Response { - return &Response{ - Code: 200, - Message: "success", - Data: data, - } -} - -func Error(code int, message string) *Response { - return &Response{ - Code: code, - Message: message, - } -} - -// internal/utils/errors.go -var ( - ErrUserNotFound = errors.New("user not found") - ErrPhotoNotFound = errors.New("photo not found") - ErrAlbumNotFound = errors.New("album not found") - ErrInvalidPassword = errors.New("invalid password") - ErrUnauthorized = errors.New("unauthorized") - ErrPermissionDenied = errors.New("permission denied") -) -``` - -### 日志管理 -```go -// pkg/logger/logger.go -import ( - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -var Logger *zap.Logger - -func init() { - config := zap.NewProductionConfig() - config.EncoderConfig.TimeKey = "timestamp" - config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder - - Logger, _ = config.Build() -} - -func Info(msg string, fields ...zap.Field) { - Logger.Info(msg, fields...) -} - -func Error(msg string, fields ...zap.Field) { - Logger.Error(msg, fields...) -} - -func Debug(msg string, fields ...zap.Field) { - Logger.Debug(msg, fields...) -} -``` - -### 测试编写 -```go -// internal/service/photo/photo_test.go -func TestPhotoService_CreatePhoto(t *testing.T) { - mockRepo := &MockPhotoRepository{} - service := NewPhotoService(mockRepo) - - req := &CreatePhotoRequest{ - Title: "Test Photo", - Description: "Test Description", - URL: "https://example.com/photo.jpg", - AlbumID: 1, - UserID: 1, - } - - expectedPhoto := &models.Photo{ - ID: 1, - Title: req.Title, - Description: req.Description, - URL: req.URL, - AlbumID: req.AlbumID, - UserID: req.UserID, - } - - mockRepo.On("Create", mock.AnythingOfType("*models.Photo")).Return(expectedPhoto, nil) - - photo, err := service.CreatePhoto(req) - - assert.NoError(t, err) - assert.Equal(t, expectedPhoto.Title, photo.Title) - assert.Equal(t, expectedPhoto.Description, photo.Description) - mockRepo.AssertExpectations(t) -} -``` - -## 🔄 与其他模块的集成 - -### 与前端的集成 -- 提供 RESTful API 供前端调用 -- 支持 CORS 跨域请求 -- 返回标准化的 JSON 响应格式 -- 提供 API 文档 (Swagger) - -### 与管理后台的集成 -- 提供管理员专用的 API 接口 -- 支持批量操作和数据导入导出 -- 提供系统监控和统计信息 -- 支持权限管理和用户管理 - -### 与部署模块的集成 -- 提供 Docker 容器化部署 -- 支持负载均衡和水平扩展 -- 集成健康检查和监控 -- 支持优雅关闭和重启 - -## 🐛 问题排查 +## 🔍 问题排查 ### 常见问题 -1. **数据库连接失败**: 检查数据库配置和网络连接 -2. **JWT 认证失败**: 检查密钥配置和 Token 格式 +1. **数据库连接失败**: 检查配置文件和环境变量 +2. **JWT 验证失败**: 检查密钥配置和 Token 格式 3. **文件上传失败**: 检查存储配置和权限设置 4. **API 响应慢**: 检查数据库查询和缓存配置 -### 调试技巧 +### 日志查看 ```bash -# 查看日志 -docker-compose logs -f backend +# 查看应用日志 +tail -f logs/app.log -# 进入容器 -docker-compose exec backend sh +# 查看错误日志 +tail -f logs/error.log -# 数据库查询 -docker-compose exec postgres psql -U postgres -d photography - -# Redis 查询 -docker-compose exec redis redis-cli +# 查看访问日志 +tail -f logs/access.log ``` -## 📊 性能优化 +## 🎯 模块工作指南 -### 数据库优化 -```sql --- 添加索引 -CREATE INDEX idx_photos_album_id ON photos(album_id); -CREATE INDEX idx_photos_user_id ON photos(user_id); -CREATE INDEX idx_photos_created_at ON photos(created_at); +### 根据工作内容选择模块 --- 查询优化 -EXPLAIN ANALYZE SELECT * FROM photos WHERE album_id = 1; +#### 🚀 应用启动和配置 +```bash +cd cmd/server/ +# 参考 cmd/server/CLAUDE.md ``` +**适用场景**: 服务启动、配置初始化、依赖注入 -### 缓存策略 -```go -// internal/service/photo/photo.go -func (s *PhotoService) GetPhoto(id uint) (*models.Photo, error) { - cacheKey := fmt.Sprintf("photo:%d", id) - - // 从缓存获取 - if cached, err := s.cache.Get(cacheKey); err == nil { - var photo models.Photo - if err := json.Unmarshal(cached, &photo); err == nil { - return &photo, nil - } - } - - // 从数据库获取 - photo, err := s.repo.GetByID(id) - if err != nil { - return nil, err - } - - // 存入缓存 - if data, err := json.Marshal(photo); err == nil { - s.cache.Set(cacheKey, data, 5*time.Minute) - } - - return photo, nil -} +#### 🌐 API 接口开发 +```bash +cd internal/api/ +# 参考 internal/api/CLAUDE.md ``` +**适用场景**: 路由定义、HTTP 处理器、中间件、请求验证 -## 📈 监控和日志 - -### 健康检查 -```go -// internal/api/handlers/health.go -func (h *HealthHandler) Check(c *gin.Context) { - status := gin.H{ - "status": "ok", - "timestamp": time.Now().Unix(), - "version": "1.0.0", - } - - // 检查数据库连接 - if err := h.db.Ping(); err != nil { - status["database"] = "error" - status["status"] = "error" - } else { - status["database"] = "ok" - } - - // 检查 Redis 连接 - if err := h.redis.Ping().Err(); err != nil { - status["redis"] = "error" - status["status"] = "error" - } else { - status["redis"] = "ok" - } - - if status["status"] == "error" { - c.JSON(500, status) - } else { - c.JSON(200, status) - } -} +#### 📋 业务逻辑开发 +```bash +cd internal/application/ +# 参考 internal/application/CLAUDE.md ``` +**适用场景**: 业务逻辑、服务编排、数据传输对象 -### 指标收集 -```go -// internal/middleware/metrics.go -func PrometheusMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - - c.Next() - - duration := time.Since(start) - status := c.Writer.Status() - - // 记录请求指标 - requestDuration.WithLabelValues(c.Request.Method, c.FullPath()).Observe(duration.Seconds()) - requestCount.WithLabelValues(c.Request.Method, c.FullPath(), fmt.Sprintf("%d", status)).Inc() - } -} +#### 🏢 领域模型设计 +```bash +cd internal/domain/ +# 参考 internal/domain/CLAUDE.md ``` +**适用场景**: 业务实体、业务规则、仓储接口 -## 🔮 未来规划 +#### 🔧 基础设施开发 +```bash +cd internal/infrastructure/ +# 参考 internal/infrastructure/CLAUDE.md +``` +**适用场景**: 数据库、缓存、文件存储、外部服务 -### 功能扩展 -- 📊 高级搜索和过滤 -- 🔄 数据同步和备份 -- 📱 移动端 API 优化 -- 🤖 AI 图像识别和标记 -- 📈 实时分析和推荐 +#### 📦 工具包开发 +```bash +cd pkg/ +# 参考 pkg/CLAUDE.md +``` +**适用场景**: 通用工具、日志、验证器、响应格式 -### 技术升级 -- 迁移到微服务架构 -- 集成消息队列 (RabbitMQ/Kafka) -- 支持分布式存储 -- 集成 Elasticsearch 搜索 -- 支持 GraphQL API +#### 🧪 测试开发 +```bash +cd tests/ +# 参考 tests/CLAUDE.md +``` +**适用场景**: 单元测试、集成测试、性能测试 -## 📚 参考资料 +#### 📚 文档维护 +```bash +cd docs/ +# 参考 docs/CLAUDE.md +``` +**适用场景**: API 文档、架构设计、部署指南 -- [Go 官方文档](https://golang.org/doc/) -- [Gin 框架文档](https://gin-gonic.com/docs/) -- [GORM 文档](https://gorm.io/docs/) -- [PostgreSQL 文档](https://www.postgresql.org/docs/) -- [Docker 最佳实践](https://docs.docker.com/develop/dev-best-practices/) +## 🔄 最佳实践 ---- +### 开发流程 +1. **功能分析**: 确定需求和技术方案 +2. **选择模块**: 根据工作内容选择对应模块 +3. **阅读指导**: 详细阅读模块的 CLAUDE.md 文件 +4. **编码实现**: 遵循模块规范进行开发 +5. **测试验证**: 编写和运行相关测试 +6. **文档更新**: 同步更新相关文档 -💡 **开发提示**: 开始开发前,请确保已经阅读根目录的 CLAUDE.md 文件,了解项目整体架构。开发过程中建议先实现核心功能,再逐步完善其他特性。务必编写单元测试和集成测试,确保代码质量。 \ No newline at end of file +### 代码质量 +- **代码审查**: 提交前进行代码审查 +- **测试覆盖**: 保持合理的测试覆盖率 +- **性能优化**: 关注接口响应时间和资源使用 +- **安全检查**: 验证认证、授权和数据验证 + +### 模块协调 +- **接口一致性**: 确保模块间接口的一致性 +- **依赖管理**: 合理管理模块间的依赖关系 +- **配置统一**: 统一配置管理和环境变量 +- **错误处理**: 统一错误处理和响应格式 + +## 📈 项目状态 + +### 已完成功能 +- ✅ 清洁架构设计 +- ✅ 多数据库支持 +- ✅ JWT 认证系统 +- ✅ 文件上传功能 +- ✅ Docker 部署 +- ✅ 基础 API 接口 + +### 开发中功能 +- 🔄 完整的测试覆盖 +- 🔄 API 文档生成 +- 🔄 性能监控 +- 🔄 缓存优化 + +### 计划中功能 +- 📋 微服务架构 +- 📋 分布式文件存储 +- 📋 消息队列集成 +- 📋 监控和报警系统 + +## 🔧 开发环境 + +### 必需工具 +- **Go 1.23+**: 编程语言 +- **PostgreSQL 14+**: 主数据库 +- **Redis 6+**: 缓存数据库 +- **Docker**: 容器化部署 +- **Make**: 构建工具 + +### 推荐工具 +- **GoLand/VSCode**: 代码编辑器 +- **Postman**: API 测试 +- **DBeaver**: 数据库管理 +- **Redis Desktop Manager**: Redis 管理 + +## 💡 开发技巧 + +### 性能优化 +- 使用数据库连接池 +- 实现查询结果缓存 +- 优化 SQL 查询语句 +- 使用异步处理 + +### 安全防护 +- 输入参数验证 +- SQL 注入防护 +- XSS 攻击防护 +- 访问频率限制 + +### 错误处理 +- 统一错误响应格式 +- 详细的错误日志记录 +- 适当的错误码设计 +- 友好的错误提示 + +本 CLAUDE.md 文件为后端开发提供了全面的指导,每个子模块都有详细的 CLAUDE.md 文件,确保开发过程中可以快速获取相关信息,提高开发效率。 \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile index b4c20f4..e7461b8 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -29,9 +29,13 @@ MIGRATION_DIR := migrations ##@ Development Environment Commands -dev: ## Start development server with hot reload - @printf "$(GREEN)🚀 Starting development server...\n$(NC)" - @air -c .air.toml || go run $(MAIN_FILE) +dev: ## Start development server with SQLite database + @printf "$(GREEN)🚀 Starting development server with SQLite...\n$(NC)" + @go run cmd/server/main_with_db.go + +dev-simple: ## Start simple development server (mock data) + @printf "$(GREEN)🚀 Starting simple development server...\n$(NC)" + @go run cmd/server/simple_main.go dev-up: ## Start development environment with Docker @printf "$(GREEN)🐳 Starting development environment...\n$(NC)" @@ -148,6 +152,34 @@ migrate-down: ## Rollback database migrations @migrate -path $(MIGRATION_DIR) -database "$(DB_URL)" down @printf "$(GREEN)✅ Migrations rolled back!\n$(NC)" +##@ Database Commands + +db-reset: ## Reset SQLite database (delete and recreate) + @printf "$(GREEN)🗄️ Resetting SQLite database...\n$(NC)" + @rm -f photography.db + @printf "$(GREEN)✅ Database reset! Will be recreated on next startup.\n$(NC)" + +db-backup: ## Backup SQLite database + @printf "$(GREEN)💾 Backing up SQLite database...\n$(NC)" + @cp photography.db photography_backup_$(BUILD_TIME).db + @printf "$(GREEN)✅ Database backed up to photography_backup_$(BUILD_TIME).db\n$(NC)" + +db-shell: ## Open SQLite database shell + @printf "$(GREEN)🐚 Opening SQLite database shell...\n$(NC)" + @sqlite3 photography.db + +db-status: ## Show database status and table info + @printf "$(GREEN)📊 Database status:\n$(NC)" + @if [ -f "photography.db" ]; then \ + printf "$(BLUE)Database file: photography.db ($(shell ls -lh photography.db | awk '{print $$5}'))\\n$(NC)"; \ + printf "$(BLUE)Tables:\\n$(NC)"; \ + sqlite3 photography.db ".tables"; \ + printf "$(BLUE)Row counts:\\n$(NC)"; \ + sqlite3 photography.db "SELECT 'Users: ' || COUNT(*) FROM users; SELECT 'Photos: ' || COUNT(*) FROM photos; SELECT 'Categories: ' || COUNT(*) FROM categories; SELECT 'Tags: ' || COUNT(*) FROM tags;"; \ + else \ + printf "$(RED)❌ Database not found. Run 'make dev' to create it.\\n$(NC)"; \ + fi + ##@ Help help: ## Display this help message diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8392283 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,245 @@ +# 摄影作品集后端 API + +基于 Go + Gin + GORM + PostgreSQL 的摄影作品集后端服务。 + +## 🚀 快速开始 + +### 环境要求 + +- Go 1.21+ +- PostgreSQL 12+ +- Git + +### 启动服务 + +```bash +# 1. 克隆项目(如果需要) +git clone +cd photography/backend + +# 2. 安装依赖 +go mod tidy + +# 3. 配置数据库 +# 确保 PostgreSQL 正在运行 +# 创建数据库: CREATE DATABASE photography_dev; + +# 4. 启动服务 +./start.sh +# 或者 +go run cmd/server/main_simple.go +``` + +服务启动后访问: +- 健康检查: http://localhost:8080/health +- API 根路径: http://localhost:8080/api/v1 + +## 📁 项目结构 + +``` +backend/ +├── cmd/server/ # 应用入口 +├── internal/ +│ ├── config/ # 配置管理 +│ ├── database/ # 数据库连接 +│ ├── models/ # 数据模型 +│ ├── service/ # 业务逻辑 +│ ├── handlers/ # HTTP 处理器 +│ └── middleware/ # 中间件 +├── migrations/ # 数据库迁移文件 +├── configs/ # 配置文件 +├── uploads/ # 文件上传目录 +└── test_api.http # API 测试文件 +``` + +## 🗄️ 数据库架构 + +### 核心表 + +- **users**: 用户表 +- **categories**: 分类表(支持层级) +- **tags**: 标签表 +- **albums**: 相册表 +- **photos**: 照片表 +- **photo_tags**: 照片标签关联表 +- **album_tags**: 相册标签关联表 + +### 数据库迁移 + +项目使用 SQL 迁移文件: + +1. `001_create_users.sql` - 用户表 +2. `002_create_photos.sql` - 照片表 +3. `003_create_albums.sql` - 相册表 +4. `004_create_categories.sql` - 分类表 +5. `005_create_tags.sql` - 标签表 +6. `006_add_foreign_keys.sql` - 外键约束 + +## 🔌 API 接口 + +### 认证接口 + +- `POST /api/v1/auth/register` - 用户注册 +- `POST /api/v1/auth/login` - 用户登录 +- `POST /api/v1/auth/refresh` - 刷新令牌 + +### 用户管理 + +- `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/categories` - 获取分类列表 +- `POST /api/v1/categories` - 创建分类 +- `GET /api/v1/categories/:id` - 获取分类详情 +- `PUT /api/v1/categories/:id` - 更新分类 +- `DELETE /api/v1/categories/:id` - 删除分类 + +### 标签管理 + +- `GET /api/v1/tags` - 获取标签列表 +- `POST /api/v1/tags` - 创建标签 +- `GET /api/v1/tags/:id` - 获取标签详情 +- `PUT /api/v1/tags/:id` - 更新标签 +- `DELETE /api/v1/tags/:id` - 删除标签 + +### 相册管理 + +- `GET /api/v1/albums` - 获取相册列表 +- `POST /api/v1/albums` - 创建相册 +- `GET /api/v1/albums/:id` - 获取相册详情 +- `PUT /api/v1/albums/:id` - 更新相册 +- `DELETE /api/v1/albums/:id` - 删除相册 + +### 照片管理 + +- `GET /api/v1/photos` - 获取照片列表 +- `POST /api/v1/photos` - 创建照片记录 +- `GET /api/v1/photos/:id` - 获取照片详情 +- `PUT /api/v1/photos/:id` - 更新照片 +- `DELETE /api/v1/photos/:id` - 删除照片 + +### 文件上传 + +- `POST /api/v1/upload/photo` - 上传照片(需要认证) +- `DELETE /api/v1/upload/photo/:id` - 删除照片(需要认证) +- `GET /api/v1/upload/stats` - 获取上传统计(需要管理员权限) + +### 静态文件 + +- `GET /uploads/photos/:filename` - 获取照片文件 +- `GET /uploads/thumbnails/:filename` - 获取缩略图 + +## 🔧 配置 + +### 环境配置文件 + +- `configs/config.yaml` - 基础配置 +- `configs/config.dev.yaml` - 开发环境 +- `configs/config.prod.yaml` - 生产环境 + +### 环境变量 + +```bash +# 数据库 +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=password +DB_NAME=photography_dev + +# JWT +JWT_SECRET=your-secret-key + +# 服务器 +PORT=8080 +``` + +## 🧪 测试 + +使用 `test_api.http` 文件进行 API 测试: + +```bash +# 1. 健康检查 +GET http://localhost:8080/health + +# 2. 用户注册 +POST http://localhost:8080/api/v1/auth/register +Content-Type: application/json + +{ + "username": "testuser", + "email": "test@example.com", + "password": "password123", + "name": "测试用户" +} +``` + +## 📝 功能特性 + +### 已实现功能 + +- ✅ 用户认证和授权 +- ✅ 用户管理(CRUD) +- ✅ 分类管理(支持层级) +- ✅ 标签管理(多对多关系) +- ✅ 相册管理 +- ✅ 照片管理和上传 +- ✅ 文件存储(本地) +- ✅ 数据库自动迁移 +- ✅ JWT 认证 +- ✅ 权限控制 +- ✅ 静态文件服务 + +### 计划功能 + +- 📋 密码加密(bcrypt) +- 📋 完整的 JWT 认证流程 +- 📋 图片缩略图生成 +- 📋 EXIF 数据提取 +- 📋 阿里云 OSS 集成 +- 📋 Redis 缓存 +- 📋 API 文档(Swagger) +- 📋 单元测试 +- 📋 Docker 支持 + +## 🔄 部署 + +### 开发环境 + +```bash +./start.sh +``` + +### 生产环境 + +```bash +export CONFIG_PATH="configs/config.prod.yaml" +go build -o photography-backend cmd/server/main_simple.go +./photography-backend +``` + +## 📚 依赖 + +- **Gin**: HTTP Web 框架 +- **GORM**: ORM 库 +- **PostgreSQL**: 数据库驱动 +- **JWT**: 认证令牌 +- **Viper**: 配置管理 +- **Zap**: 日志库 + +## 🤝 贡献 + +1. Fork 项目 +2. 创建功能分支 +3. 提交更改 +4. 推送到分支 +5. 创建 Pull Request + +## 📄 许可证 + +MIT License \ No newline at end of file diff --git a/backend/cmd/server/CLAUDE.md b/backend/cmd/server/CLAUDE.md new file mode 100644 index 0000000..c945737 --- /dev/null +++ b/backend/cmd/server/CLAUDE.md @@ -0,0 +1,363 @@ +# Server Entry Point - CLAUDE.md + +本文件为 Claude Code 在服务器入口模块中工作时提供指导。 + +## 🎯 模块概览 + +这是后端服务的启动入口模块,负责应用初始化、配置加载、依赖注入和服务启动。 + +### 主要职责 +- 🚀 应用启动和生命周期管理 +- ⚙️ 配置文件加载和环境变量解析 +- 🔌 依赖注入和组件初始化 +- 🌐 HTTP 服务器启动和路由注册 +- 📊 数据库连接和迁移管理 +- 🔧 优雅关闭和资源清理 + +### 推荐文件结构(Go 风格) +``` +cmd/server/ +├── CLAUDE.md # 📋 当前文件 - 服务启动指导 +├── main.go # 🚀 统一入口点 +├── app.go # 🏗️ 应用构建器 +├── config.go # ⚙️ 配置加载器 +├── dependencies.go # 🔌 依赖注入容器 +└── server.go # 🌐 HTTP 服务器管理 +``` + +## 🚀 启动模式说明 + +### 生产模式 (`main.go`) +```go +// 特点: +// - PostgreSQL 数据库 +// - Redis 缓存 +// - 完整的中间件栈 +// - 生产级日志配置 +// - 健康检查端点 +``` + +**适用场景**: 生产环境、集成测试、性能测试 + +### 开发模式 (`main_with_db.go`) +```go +// 特点: +// - SQLite 数据库 +// - 内存缓存 +// - 开发友好的日志 +// - 热重载支持 +// - 调试工具集成 +``` + +**适用场景**: 本地开发、单元测试、功能验证 + +### Mock 模式 (`simple_main.go`) +```go +// 特点: +// - 内存数据存储 +// - 固定响应数据 +// - 极快启动速度 +// - 无数据库依赖 +// - 最小化配置 +``` + +**适用场景**: 前端开发、API 测试、演示环境 + +## 🔧 配置管理 + +### 配置文件层次 +``` +configs/ +├── config.yaml # 基础配置 +├── config.dev.yaml # 开发环境覆盖 +└── config.prod.yaml # 生产环境覆盖 +``` + +### 环境变量优先级 +``` +环境变量 > 配置文件 > 默认值 +``` + +### 配置结构 +```go +type Config struct { + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + Cache CacheConfig `yaml:"cache"` + Storage StorageConfig `yaml:"storage"` + Auth AuthConfig `yaml:"auth"` + Log LogConfig `yaml:"log"` +} +``` + +## 🏗️ 依赖注入 + +### 依赖层次 +``` +main.go +├── 📋 Config Service +├── 📊 Database Service +├── 💾 Cache Service +├── 📁 Storage Service +├── 🔐 Auth Service +├── 📚 Repository Layer +├── 🏢 Service Layer +└── 🌐 Handler Layer +``` + +### 依赖注入模式 +```go +// 1. 配置加载 +config := config.LoadConfig() + +// 2. 基础服务初始化 +db := database.NewConnection(config.Database) +cache := cache.NewRedisClient(config.Cache) +storage := storage.NewService(config.Storage) + +// 3. 仓储层初始化 +userRepo := repository.NewUserRepository(db) +photoRepo := repository.NewPhotoRepository(db) + +// 4. 服务层初始化 +userService := service.NewUserService(userRepo, cache) +photoService := service.NewPhotoService(photoRepo, storage) + +// 5. 处理器层初始化 +userHandler := handler.NewUserHandler(userService) +photoHandler := handler.NewPhotoHandler(photoService) + +// 6. 路由设置 +router := gin.New() +v1 := router.Group("/api/v1") +{ + v1.POST("/users", userHandler.Create) + v1.GET("/users", userHandler.List) + v1.GET("/photos", photoHandler.List) + v1.POST("/photos", photoHandler.Create) +} +``` + +## 🔄 启动流程 + +### 标准启动流程 +1. **配置加载**: 解析配置文件和环境变量 +2. **日志初始化**: 设置日志级别和输出格式 +3. **数据库连接**: 建立数据库连接池 +4. **缓存连接**: 初始化 Redis 连接 +5. **服务注册**: 注册各层服务 +6. **中间件设置**: 配置认证、日志、CORS 等中间件 +7. **路由注册**: 注册所有 API 路由 +8. **健康检查**: 启动健康检查端点 +9. **服务启动**: 启动 HTTP 服务器 +10. **优雅关闭**: 处理关闭信号 + +### 启动命令 +```bash +# 生产模式 +go run cmd/server/main.go + +# 开发模式 +go run cmd/server/main_with_db.go + +# Mock 模式 +go run cmd/server/simple_main.go + +# 使用 Makefile +make dev # 开发模式 +make dev-simple # Mock 模式 +make prod # 生产模式 +``` + +## 📊 健康检查 + +### 健康检查端点 +```go +GET /health +GET /health/ready +GET /health/live +``` + +### 健康检查内容 +- **数据库连接状态** +- **缓存服务状态** +- **存储服务状态** +- **外部服务状态** +- **系统资源状态** + +### 响应格式 +```json +{ + "status": "healthy", + "timestamp": "2024-01-01T00:00:00Z", + "version": "1.0.0", + "checks": { + "database": "healthy", + "cache": "healthy", + "storage": "healthy" + } +} +``` + +## 🔧 优雅关闭 + +### 关闭信号处理 +```go +// 监听关闭信号 +quit := make(chan os.Signal, 1) +signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + +// 等待关闭信号 +<-quit +log.Println("Shutting down server...") + +// 设置关闭超时 +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +// 优雅关闭服务器 +if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) +} +``` + +### 关闭流程 +1. **停止接收新请求** +2. **等待现有请求完成** +3. **关闭数据库连接** +4. **关闭缓存连接** +5. **清理临时资源** +6. **记录关闭日志** + +## 📋 环境变量配置 + +### 数据库配置 +```bash +# PostgreSQL +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=photography +DB_USER=postgres +DB_PASSWORD=your_password +DB_SSL_MODE=disable + +# SQLite (开发模式) +DB_TYPE=sqlite +DB_PATH=./photography.db +``` + +### 缓存配置 +```bash +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +``` + +### 认证配置 +```bash +# JWT +JWT_SECRET=your_jwt_secret +JWT_EXPIRES_IN=24h +JWT_REFRESH_EXPIRES_IN=168h +``` + +### 文件存储配置 +```bash +# 本地存储 +STORAGE_TYPE=local +STORAGE_PATH=./uploads +STORAGE_MAX_SIZE=10MB + +# S3 存储 +STORAGE_TYPE=s3 +AWS_ACCESS_KEY_ID=your_access_key +AWS_SECRET_ACCESS_KEY=your_secret_key +AWS_BUCKET_NAME=your_bucket +AWS_REGION=us-east-1 +``` + +## 🧪 开发调试 + +### 调试模式启动 +```bash +# 开启调试模式 +export GIN_MODE=debug +export LOG_LEVEL=debug + +# 启动服务 +go run cmd/server/main_with_db.go +``` + +### 调试工具 +- **pprof**: 性能分析 +- **gin-debug**: 路由调试 +- **hot-reload**: 代码热重载 +- **swagger**: API 文档 + +### 调试端点 +``` +GET /debug/pprof/ # 性能分析 +GET /debug/routes # 路由列表 +GET /debug/vars # 运行时变量 +GET /swagger/* # API 文档 +``` + +## 📊 监控指标 + +### 内置指标 +- **HTTP 请求数量** +- **HTTP 响应时间** +- **数据库连接数** +- **缓存命中率** +- **内存使用量** +- **CPU 使用率** + +### 指标端点 +``` +GET /metrics # Prometheus 指标 +GET /stats # 应用统计信息 +``` + +## 🔍 常见问题 + +### 启动失败 +1. **端口被占用**: 检查端口配置,使用 `lsof -i :8080` 查看 +2. **配置文件错误**: 检查 YAML 语法,验证配置项 +3. **数据库连接失败**: 检查数据库服务状态和连接配置 +4. **权限问题**: 检查文件读写权限 + +### 性能问题 +1. **启动慢**: 检查数据库连接池配置 +2. **内存泄漏**: 使用 pprof 分析内存使用 +3. **连接超时**: 调整超时配置和连接池大小 + +### 日志问题 +1. **日志文件过大**: 配置日志轮转 +2. **日志格式混乱**: 统一日志格式配置 +3. **敏感信息泄露**: 配置敏感信息过滤 + +## 💡 最佳实践 + +### 配置管理 +- 使用环境变量覆盖敏感配置 +- 配置验证和默认值设置 +- 配置变更的版本控制 + +### 错误处理 +- 统一错误响应格式 +- 详细的错误日志记录 +- 适当的错误码设计 + +### 安全考虑 +- 敏感信息不在日志中输出 +- 配置文件权限控制 +- 环境变量加密存储 + +### 性能优化 +- 合理的超时配置 +- 连接池大小调优 +- 资源及时释放 + +本模块为整个应用的入口,确保配置正确、启动流程清晰是项目成功的关键。在开发过程中,优先使用开发模式进行功能验证,在集成测试时使用生产模式。 \ No newline at end of file diff --git a/backend/cmd/server/main_simple.go b/backend/cmd/server/main_simple.go new file mode 100644 index 0000000..57bfc5a --- /dev/null +++ b/backend/cmd/server/main_simple.go @@ -0,0 +1,682 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" + "photography-backend/internal/config" + "photography-backend/internal/database" + "photography-backend/internal/models" + "photography-backend/internal/service/upload" + "photography-backend/internal/service/auth" + "photography-backend/internal/handlers" + "photography-backend/internal/middleware" +) + +func main() { + // 加载配置 + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = "configs/config.yaml" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + log.Fatalf("加载配置失败: %v", err) + } + + // 初始化数据库 + if err := database.InitDatabase(cfg); err != nil { + log.Fatalf("数据库初始化失败: %v", err) + } + + // 自动迁移数据表 + if err := database.AutoMigrate(); err != nil { + log.Fatalf("数据库迁移失败: %v", err) + } + + // 创建服务 + uploadService := upload.NewUploadService(cfg) + jwtService := auth.NewJWTService(&cfg.JWT) + + // 创建处理器 + uploadHandler := handlers.NewUploadHandler(uploadService) + + // 创建中间件 + authMiddleware := middleware.NewAuthMiddleware(jwtService) + + // 设置 Gin 模式 + if cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + // 创建 Gin 引擎 + r := gin.Default() + + // 添加 CORS 中间件 + r.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + }) + + // 健康检查 + r.GET("/health", func(c *gin.Context) { + // 检查数据库连接 + if err := database.HealthCheck(); err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "status": "error", + "message": "数据库连接失败", + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "timestamp": time.Now().Unix(), + "version": cfg.App.Version, + "database": "connected", + }) + }) + + // 数据库统计 + r.GET("/stats", func(c *gin.Context) { + stats := database.GetStats() + c.JSON(http.StatusOK, gin.H{ + "database_stats": stats, + }) + }) + + // 静态文件服务 + r.Static("/uploads", cfg.Storage.Local.BasePath) + + // 基础 API 路由 + api := r.Group("/api/v1") + { + // 文件上传路由 + upload := api.Group("/upload") + { + upload.POST("/photo", authMiddleware.RequireAuth(), uploadHandler.UploadPhoto) + upload.DELETE("/photo/:id", authMiddleware.RequireAuth(), uploadHandler.DeletePhoto) + upload.GET("/stats", authMiddleware.RequireAdmin(), uploadHandler.GetUploadStats) + } + + // 认证路由 + auth := api.Group("/auth") + { + auth.POST("/login", login) + auth.POST("/register", register) + auth.POST("/refresh", refreshToken) + } + + // 用户相关 + users := api.Group("/users") + { + users.GET("", getUsers) + users.POST("", createUser) + users.GET("/:id", getUser) + users.PUT("/:id", updateUser) + users.DELETE("/:id", deleteUser) + } + + // 分类相关 + categories := api.Group("/categories") + { + categories.GET("", getCategories) + categories.POST("", createCategory) + categories.GET("/:id", getCategory) + categories.PUT("/:id", updateCategory) + categories.DELETE("/:id", deleteCategory) + } + + // 标签相关 + tags := api.Group("/tags") + { + tags.GET("", getTags) + tags.POST("", createTag) + tags.GET("/:id", getTag) + tags.PUT("/:id", updateTag) + tags.DELETE("/:id", deleteTag) + } + + // 相册相关 + albums := api.Group("/albums") + { + albums.GET("", getAlbums) + albums.POST("", createAlbum) + albums.GET("/:id", getAlbum) + albums.PUT("/:id", updateAlbum) + albums.DELETE("/:id", deleteAlbum) + } + + // 照片相关 + photos := api.Group("/photos") + { + photos.GET("", getPhotos) + photos.POST("", createPhoto) + photos.GET("/:id", getPhoto) + photos.PUT("/:id", updatePhoto) + photos.DELETE("/:id", deletePhoto) + } + } + + // 创建 HTTP 服务器 + server := &http.Server{ + Addr: cfg.GetServerAddr(), + Handler: r, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + // 启动服务器 + go func() { + fmt.Printf("服务器启动在 %s\n", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("服务器启动失败: %v", err) + } + }() + + // 等待中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + fmt.Println("正在关闭服务器...") + + // 优雅关闭 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("服务器强制关闭: %v", err) + } + + // 关闭数据库连接 + if err := database.Close(); err != nil { + log.Printf("关闭数据库连接失败: %v", err) + } + + fmt.Println("服务器已关闭") +} + +// 认证相关处理函数 +func login(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 查找用户 + var user models.User + db := database.GetDB() + + // 可以使用用户名或邮箱登录 + if err := db.Where("username = ? OR email = ?", req.Username, req.Username).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + + // TODO: 验证密码 (需要集成bcrypt) + // 这里暂时跳过密码验证 + + // 生成JWT令牌 (简化实现) + c.JSON(http.StatusOK, gin.H{ + "message": "登录成功", + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }, + "token": "mock-jwt-token", // 实际项目中应该生成真实的JWT + }) +} + +func register(c *gin.Context) { + var req models.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 检查用户名是否已存在 + var existingUser models.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": "用户名或邮箱已存在"}) + return + } + + // 创建用户 + user := models.User{ + Username: req.Username, + Email: req.Email, + Password: req.Password, // 实际项目中应该加密 + Name: req.Name, + Role: "user", + IsActive: true, + } + + if err := db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // 清除密码 + user.Password = "" + + c.JSON(http.StatusCreated, gin.H{ + "message": "注册成功", + "user": user, + }) +} + +func refreshToken(c *gin.Context) { + // TODO: 实现刷新令牌逻辑 + c.JSON(http.StatusOK, gin.H{ + "message": "刷新令牌成功", + "token": "new-mock-jwt-token", + }) +} + +// 用户 CRUD 操作 +func getUsers(c *gin.Context) { + var users []models.User + db := database.GetDB() + + if err := db.Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": users}) +} + +func createUser(c *gin.Context) { + var user models.User + if err := c.ShouldBindJSON(&user); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + if err := db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": user}) +} + +func getUser(c *gin.Context) { + id := c.Param("id") + var user models.User + db := database.GetDB() + + if err := db.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +func updateUser(c *gin.Context) { + id := c.Param("id") + var user models.User + db := database.GetDB() + + if err := db.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + if err := c.ShouldBindJSON(&user); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +func deleteUser(c *gin.Context) { + id := c.Param("id") + db := database.GetDB() + + if err := db.Delete(&models.User{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "用户删除成功"}) +} + +// 分类 CRUD 操作 +func getCategories(c *gin.Context) { + var categories []models.Category + db := database.GetDB() + + if err := db.Find(&categories).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": categories}) +} + +func createCategory(c *gin.Context) { + var category models.Category + if err := c.ShouldBindJSON(&category); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + if err := db.Create(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": category}) +} + +func getCategory(c *gin.Context) { + id := c.Param("id") + var category models.Category + db := database.GetDB() + + if err := db.First(&category, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": category}) +} + +func updateCategory(c *gin.Context) { + id := c.Param("id") + var category models.Category + db := database.GetDB() + + if err := db.First(&category, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"}) + return + } + + if err := c.ShouldBindJSON(&category); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": category}) +} + +func deleteCategory(c *gin.Context) { + id := c.Param("id") + db := database.GetDB() + + if err := db.Delete(&models.Category{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "分类删除成功"}) +} + +// 标签 CRUD 操作 +func getTags(c *gin.Context) { + var tags []models.Tag + db := database.GetDB() + + if err := db.Find(&tags).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": tags}) +} + +func createTag(c *gin.Context) { + var tag models.Tag + if err := c.ShouldBindJSON(&tag); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + if err := db.Create(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": tag}) +} + +func getTag(c *gin.Context) { + id := c.Param("id") + var tag models.Tag + db := database.GetDB() + + if err := db.First(&tag, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": tag}) +} + +func updateTag(c *gin.Context) { + id := c.Param("id") + var tag models.Tag + db := database.GetDB() + + if err := db.First(&tag, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) + return + } + + if err := c.ShouldBindJSON(&tag); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": tag}) +} + +func deleteTag(c *gin.Context) { + id := c.Param("id") + db := database.GetDB() + + if err := db.Delete(&models.Tag{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "标签删除成功"}) +} + +// 相册 CRUD 操作 +func getAlbums(c *gin.Context) { + var albums []models.Album + db := database.GetDB() + + if err := db.Find(&albums).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": albums}) +} + +func createAlbum(c *gin.Context) { + var album models.Album + if err := c.ShouldBindJSON(&album); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + if err := db.Create(&album).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": album}) +} + +func getAlbum(c *gin.Context) { + id := c.Param("id") + var album models.Album + db := database.GetDB() + + if err := db.First(&album, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "相册不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": album}) +} + +func updateAlbum(c *gin.Context) { + id := c.Param("id") + var album models.Album + db := database.GetDB() + + if err := db.First(&album, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "相册不存在"}) + return + } + + if err := c.ShouldBindJSON(&album); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&album).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": album}) +} + +func deleteAlbum(c *gin.Context) { + id := c.Param("id") + db := database.GetDB() + + if err := db.Delete(&models.Album{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "相册删除成功"}) +} + +// 照片 CRUD 操作 +func getPhotos(c *gin.Context) { + var photos []models.Photo + db := database.GetDB() + + if err := db.Find(&photos).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": photos}) +} + +func createPhoto(c *gin.Context) { + var photo models.Photo + if err := c.ShouldBindJSON(&photo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + if err := db.Create(&photo).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": photo}) +} + +func getPhoto(c *gin.Context) { + id := c.Param("id") + var photo models.Photo + db := database.GetDB() + + if err := db.First(&photo, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "照片不存在"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": photo}) +} + +func updatePhoto(c *gin.Context) { + id := c.Param("id") + var photo models.Photo + db := database.GetDB() + + if err := db.First(&photo, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "照片不存在"}) + return + } + + if err := c.ShouldBindJSON(&photo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&photo).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": photo}) +} + +func deletePhoto(c *gin.Context) { + id := c.Param("id") + db := database.GetDB() + + if err := db.Delete(&models.Photo{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "照片删除成功"}) +} \ No newline at end of file diff --git a/backend/cmd/server/main_with_db.go b/backend/cmd/server/main_with_db.go new file mode 100644 index 0000000..3fc6e2e --- /dev/null +++ b/backend/cmd/server/main_with_db.go @@ -0,0 +1,786 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gin-contrib/cors" + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// User 用户模型 +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:50;unique;not null" json:"username"` + Email string `gorm:"size:100;unique;not null" json:"email"` + Password string `gorm:"size:255;not null" json:"-"` + Name string `gorm:"size:100" json:"name"` + Avatar string `gorm:"size:500" json:"avatar"` + Role string `gorm:"size:20;default:user" json:"role"` + IsActive bool `gorm:"default:true" json:"is_active"` + LastLogin *time.Time `json:"last_login"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// Category 分类模型 +type Category struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Slug string `gorm:"size:100;unique;not null" json:"slug"` + Description string `gorm:"type:text" json:"description"` + ParentID *uint `json:"parent_id"` + Color string `gorm:"size:7;default:#3b82f6" json:"color"` + CoverImage string `gorm:"size:500" json:"cover_image"` + Sort int `gorm:"default:0" json:"sort"` + IsActive bool `gorm:"default:true" json:"is_active"` + PhotoCount int `gorm:"-" json:"photo_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// Tag 标签模型 +type Tag struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:50;unique;not null" json:"name"` + Slug string `gorm:"size:50;unique;not null" json:"slug"` + Description string `gorm:"type:text" json:"description"` + Color string `gorm:"size:7;default:#10b981" json:"color"` + PhotoCount int `gorm:"-" json:"photo_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// Photo 照片模型 +type Photo struct { + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:200;not null" json:"title"` + Description string `gorm:"type:text" json:"description"` + URL string `gorm:"size:500;not null" json:"url"` + ThumbnailURL string `gorm:"size:500" json:"thumbnail_url"` + OriginalFilename string `gorm:"size:255" json:"original_filename"` + FileSize int64 `json:"file_size"` + MimeType string `gorm:"size:100" json:"mime_type"` + Width int `json:"width"` + Height int `json:"height"` + Status string `gorm:"size:20;default:draft" json:"status"` + UserID uint `json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Categories []Category `gorm:"many2many:photo_categories;" json:"categories,omitempty"` + Tags []Tag `gorm:"many2many:photo_tags;" json:"tags,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// LoginRequest 登录请求 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// LoginResponse 登录响应 +type LoginResponse struct { + User User `json:"user"` + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` +} + +var db *gorm.DB + +func main() { + // 初始化数据库 + var err error + db, err = gorm.Open(sqlite.Open("photography.db"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + + // 自动迁移 + err = db.AutoMigrate(&User{}, &Category{}, &Tag{}, &Photo{}) + if err != nil { + log.Fatalf("Failed to migrate database: %v", err) + } + + // 初始化测试数据 + initTestData() + + // 创建 Gin 引擎 + r := gin.Default() + + // 配置 CORS + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"http://localhost:3000", "http://localhost:3002", "http://localhost:3003"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // 静态文件服务 + r.Static("/uploads", "./uploads") + + // 健康检查 + r.GET("/health", healthHandler) + + // API 路由组 + v1 := r.Group("/api/v1") + { + // 认证相关 + auth := v1.Group("/auth") + { + auth.POST("/login", loginHandler) + } + + // 照片相关 + v1.GET("/photos", getPhotosHandler) + v1.POST("/photos", createPhotoHandler) + v1.GET("/photos/:id", getPhotoHandler) + v1.PUT("/photos/:id", updatePhotoHandler) + v1.DELETE("/photos/:id", deletePhotoHandler) + + // 文件上传 + v1.POST("/upload", uploadFileHandler) + + // 分类相关 + v1.GET("/categories", getCategoriesHandler) + v1.POST("/categories", createCategoryHandler) + v1.GET("/categories/:id", getCategoryHandler) + v1.PUT("/categories/:id", updateCategoryHandler) + v1.DELETE("/categories/:id", deleteCategoryHandler) + + // 标签相关 + v1.GET("/tags", getTagsHandler) + v1.POST("/tags", createTagHandler) + v1.GET("/tags/:id", getTagHandler) + v1.PUT("/tags/:id", updateTagHandler) + v1.DELETE("/tags/:id", deleteTagHandler) + + // 用户相关 + v1.GET("/users", getUsersHandler) + v1.POST("/users", createUserHandler) + v1.GET("/users/:id", getUserHandler) + v1.PUT("/users/:id", updateUserHandler) + v1.DELETE("/users/:id", deleteUserHandler) + + // 仪表板统计 + v1.GET("/dashboard/stats", getDashboardStatsHandler) + } + + // 启动服务器 + log.Println("🚀 Backend server with SQLite database starting on :8080") + log.Fatal(r.Run(":8080")) +} + +// 初始化测试数据 +func initTestData() { + // 检查是否已有管理员用户 + var count int64 + db.Model(&User{}).Where("role = ?", "admin").Count(&count) + if count > 0 { + return // 已有管理员用户,跳过初始化 + } + + // 创建管理员用户 + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) + admin := User{ + Username: "admin", + Email: "admin@photography.com", + Password: string(hashedPassword), + Name: "管理员", + Role: "admin", + IsActive: true, + } + db.Create(&admin) + + // 创建编辑用户 + hashedPassword, _ = bcrypt.GenerateFromPassword([]byte("editor123"), bcrypt.DefaultCost) + editor := User{ + Username: "editor", + Email: "editor@photography.com", + Password: string(hashedPassword), + Name: "编辑员", + Role: "editor", + IsActive: true, + } + db.Create(&editor) + + // 创建分类 + categories := []Category{ + {Name: "风景", Slug: "landscape", Description: "自然风景摄影", Color: "#059669"}, + {Name: "人像", Slug: "portrait", Description: "人物肖像摄影", Color: "#dc2626"}, + {Name: "街拍", Slug: "street", Description: "街头摄影", Color: "#7c3aed"}, + {Name: "建筑", Slug: "architecture", Description: "建筑摄影", Color: "#ea580c"}, + {Name: "动物", Slug: "animal", Description: "动物摄影", Color: "#0891b2"}, + } + for _, category := range categories { + db.Create(&category) + } + + // 创建标签 + tags := []Tag{ + {Name: "日落", Slug: "sunset", Color: "#f59e0b"}, + {Name: "城市", Slug: "city", Color: "#6b7280"}, + {Name: "自然", Slug: "nature", Color: "#10b981"}, + {Name: "黑白", Slug: "black-white", Color: "#374151"}, + {Name: "夜景", Slug: "night", Color: "#1e40af"}, + {Name: "微距", Slug: "macro", Color: "#7c2d12"}, + {Name: "旅行", Slug: "travel", Color: "#be185d"}, + {Name: "艺术", Slug: "art", Color: "#9333ea"}, + } + for _, tag := range tags { + db.Create(&tag) + } + + // 创建示例照片 + photos := []Photo{ + { + Title: "金色日落", + Description: "美丽的金色日落景象", + URL: "https://picsum.photos/800/600?random=1", + ThumbnailURL: "https://picsum.photos/300/200?random=1", + OriginalFilename: "sunset.jpg", + FileSize: 1024000, + MimeType: "image/jpeg", + Width: 800, + Height: 600, + Status: "published", + UserID: admin.ID, + }, + { + Title: "城市夜景", + Description: "繁华的城市夜晚", + URL: "https://picsum.photos/800/600?random=2", + ThumbnailURL: "https://picsum.photos/300/200?random=2", + OriginalFilename: "citynight.jpg", + FileSize: 2048000, + MimeType: "image/jpeg", + Width: 800, + Height: 600, + Status: "published", + UserID: admin.ID, + }, + { + Title: "人像写真", + Description: "优雅的人像摄影", + URL: "https://picsum.photos/600/800?random=3", + ThumbnailURL: "https://picsum.photos/300/400?random=3", + OriginalFilename: "portrait.jpg", + FileSize: 1536000, + MimeType: "image/jpeg", + Width: 600, + Height: 800, + Status: "published", + UserID: editor.ID, + }, + { + Title: "建筑之美", + Description: "现代建筑的几何美学", + URL: "https://picsum.photos/800/600?random=4", + ThumbnailURL: "https://picsum.photos/300/200?random=4", + OriginalFilename: "architecture.jpg", + FileSize: 1792000, + MimeType: "image/jpeg", + Width: 800, + Height: 600, + Status: "draft", + UserID: editor.ID, + }, + } + + for _, photo := range photos { + db.Create(&photo) + } + + log.Println("✅ Test data initialized successfully") +} + +// 健康检查处理器 +func healthHandler(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "timestamp": time.Now().Unix(), + "version": "1.0.0", + "database": "connected", + }) +} + +// 登录处理器 +func loginHandler(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user User + if err := db.Where("username = ? OR email = ?", req.Username, req.Username).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"}) + return + } + + // 更新最后登录时间 + now := time.Now() + user.LastLogin = &now + db.Save(&user) + + c.JSON(http.StatusOK, LoginResponse{ + User: user, + AccessToken: "mock-jwt-token-" + fmt.Sprintf("%d", user.ID), + ExpiresIn: 86400, + }) +} + +// 照片相关处理器 +func getPhotosHandler(c *gin.Context) { + var photos []Photo + query := db.Preload("User").Preload("Categories").Preload("Tags") + + // 分页参数 + page := 1 + limit := 10 + if p := c.Query("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if l := c.Query("limit"); l != "" { + fmt.Sscanf(l, "%d", &limit) + } + + offset := (page - 1) * limit + + // 搜索和过滤 + if search := c.Query("search"); search != "" { + query = query.Where("title LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%") + } + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } + + var total int64 + query.Model(&Photo{}).Count(&total) + query.Offset(offset).Limit(limit).Find(&photos) + + c.JSON(http.StatusOK, gin.H{ + "data": photos, + "total": total, + "page": page, + "limit": limit, + "totalPages": (total + int64(limit) - 1) / int64(limit), + }) +} + +func createPhotoHandler(c *gin.Context) { + var photo Photo + if err := c.ShouldBindJSON(&photo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Create(&photo).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建照片失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": photo}) +} + +func getPhotoHandler(c *gin.Context) { + id := c.Param("id") + var photo Photo + if err := db.Preload("User").Preload("Categories").Preload("Tags").First(&photo, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "照片不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": photo}) +} + +func updatePhotoHandler(c *gin.Context) { + id := c.Param("id") + var photo Photo + if err := db.First(&photo, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "照片不存在"}) + return + } + + if err := c.ShouldBindJSON(&photo); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&photo).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新照片失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": photo}) +} + +func deletePhotoHandler(c *gin.Context) { + id := c.Param("id") + if err := db.Delete(&Photo{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除照片失败"}) + return + } + c.JSON(http.StatusNoContent, nil) +} + +// 分类相关处理器 +func getCategoriesHandler(c *gin.Context) { + var categories []Category + var total int64 + + query := db.Model(&Category{}) + query.Count(&total) + query.Find(&categories) + + // 计算每个分类的照片数量 + for i := range categories { + var count int64 + db.Model(&Photo{}).Joins("JOIN photo_categories ON photos.id = photo_categories.photo_id"). + Where("photo_categories.category_id = ?", categories[i].ID).Count(&count) + categories[i].PhotoCount = int(count) + } + + c.JSON(http.StatusOK, gin.H{ + "data": categories, + "total": total, + }) +} + +func createCategoryHandler(c *gin.Context) { + var category Category + if err := c.ShouldBindJSON(&category); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Create(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建分类失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": category}) +} + +func getCategoryHandler(c *gin.Context) { + id := c.Param("id") + var category Category + if err := db.First(&category, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": category}) +} + +func updateCategoryHandler(c *gin.Context) { + id := c.Param("id") + var category Category + if err := db.First(&category, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "分类不存在"}) + return + } + + if err := c.ShouldBindJSON(&category); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&category).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新分类失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": category}) +} + +func deleteCategoryHandler(c *gin.Context) { + id := c.Param("id") + if err := db.Delete(&Category{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除分类失败"}) + return + } + c.JSON(http.StatusNoContent, nil) +} + +// 标签相关处理器 +func getTagsHandler(c *gin.Context) { + var tags []Tag + var total int64 + + query := db.Model(&Tag{}) + query.Count(&total) + query.Find(&tags) + + // 计算每个标签的照片数量 + for i := range tags { + var count int64 + db.Model(&Photo{}).Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id"). + Where("photo_tags.tag_id = ?", tags[i].ID).Count(&count) + tags[i].PhotoCount = int(count) + } + + c.JSON(http.StatusOK, gin.H{ + "data": tags, + "total": total, + }) +} + +func createTagHandler(c *gin.Context) { + var tag Tag + if err := c.ShouldBindJSON(&tag); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Create(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建标签失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": tag}) +} + +func getTagHandler(c *gin.Context) { + id := c.Param("id") + var tag Tag + if err := db.First(&tag, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": tag}) +} + +func updateTagHandler(c *gin.Context) { + id := c.Param("id") + var tag Tag + if err := db.First(&tag, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "标签不存在"}) + return + } + + if err := c.ShouldBindJSON(&tag); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新标签失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": tag}) +} + +func deleteTagHandler(c *gin.Context) { + id := c.Param("id") + if err := db.Delete(&Tag{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除标签失败"}) + return + } + c.JSON(http.StatusNoContent, nil) +} + +// 用户相关处理器 +func getUsersHandler(c *gin.Context) { + var users []User + var total int64 + + query := db.Model(&User{}) + query.Count(&total) + query.Find(&users) + + c.JSON(http.StatusOK, gin.H{ + "data": users, + "total": total, + }) +} + +func createUserHandler(c *gin.Context) { + var user User + if err := c.ShouldBindJSON(&user); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"}) + return + } + user.Password = string(hashedPassword) + + if err := db.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建用户失败"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": user}) +} + +func getUserHandler(c *gin.Context) { + id := c.Param("id") + var user User + if err := db.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +func updateUserHandler(c *gin.Context) { + id := c.Param("id") + var user User + if err := db.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + + if err := c.ShouldBindJSON(&user); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := db.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新用户失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": user}) +} + +func deleteUserHandler(c *gin.Context) { + id := c.Param("id") + if err := db.Delete(&User{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除用户失败"}) + return + } + c.JSON(http.StatusNoContent, nil) +} + +// 仪表板统计处理器 +func getDashboardStatsHandler(c *gin.Context) { + var photoCount, categoryCount, tagCount, userCount int64 + var publishedCount, draftCount int64 + var todayCount, monthCount int64 + + // 基础统计 + db.Model(&Photo{}).Count(&photoCount) + db.Model(&Category{}).Count(&categoryCount) + db.Model(&Tag{}).Count(&tagCount) + db.Model(&User{}).Count(&userCount) + + // 照片状态统计 + db.Model(&Photo{}).Where("status = ?", "published").Count(&publishedCount) + db.Model(&Photo{}).Where("status = ?", "draft").Count(&draftCount) + + // 时间统计 + today := time.Now().Format("2006-01-02") + thisMonth := time.Now().Format("2006-01") + + db.Model(&Photo{}).Where("DATE(created_at) = ?", today).Count(&todayCount) + db.Model(&Photo{}).Where("strftime('%Y-%m', created_at) = ?", thisMonth).Count(&monthCount) + + c.JSON(http.StatusOK, gin.H{ + "photos": gin.H{ + "total": photoCount, + "published": publishedCount, + "draft": draftCount, + "thisMonth": monthCount, + "today": todayCount, + }, + "categories": gin.H{ + "total": categoryCount, + "active": categoryCount, // 简化统计 + }, + "tags": gin.H{ + "total": tagCount, + }, + "users": gin.H{ + "total": userCount, + "active": userCount, // 简化统计 + }, + }) +} + +// 文件上传处理器 +func uploadFileHandler(c *gin.Context) { + // 创建上传目录 + uploadDir := "uploads" + if err := os.MkdirAll(uploadDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建上传目录失败"}) + return + } + + // 获取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "获取上传文件失败"}) + return + } + defer file.Close() + + // 验证文件类型 + allowedTypes := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} + ext := strings.ToLower(filepath.Ext(header.Filename)) + isAllowed := false + for _, allowedType := range allowedTypes { + if ext == allowedType { + isAllowed = true + break + } + } + if !isAllowed { + c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的文件类型"}) + return + } + + // 生成文件名 + filename := fmt.Sprintf("%d_%s", time.Now().Unix(), header.Filename) + filePath := filepath.Join(uploadDir, filename) + + // 保存文件 + out, err := os.Create(filePath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建文件失败"}) + return + } + defer out.Close() + + // 复制文件内容 + _, err = io.Copy(out, file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"}) + return + } + + // 返回文件信息 + fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename) + c.JSON(http.StatusOK, gin.H{ + "message": "文件上传成功", + "filename": filename, + "url": fileURL, + "size": header.Size, + "type": header.Header.Get("Content-Type"), + }) +} \ No newline at end of file diff --git a/backend/cmd/server/simple_main.go b/backend/cmd/server/simple_main.go new file mode 100644 index 0000000..4e1e989 --- /dev/null +++ b/backend/cmd/server/simple_main.go @@ -0,0 +1,186 @@ +package main + +import ( + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/gin-contrib/cors" +) + +func main() { + // 创建 Gin 引擎 + r := gin.Default() + + // 配置 CORS + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"http://localhost:3000", "http://localhost:3002", "http://localhost:3003"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // 健康检查 + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "timestamp": time.Now().Unix(), + "version": "1.0.0", + }) + }) + + // 模拟登录接口 + r.POST("/api/v1/auth/login", func(c *gin.Context) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 简单验证 + if req.Username == "admin" && req.Password == "admin123" { + c.JSON(http.StatusOK, gin.H{ + "user": gin.H{ + "id": "1", + "username": "admin", + "email": "admin@example.com", + "role": "admin", + "isActive": true, + }, + "access_token": "mock-jwt-token", + "expires_in": 86400, + }) + } else { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + } + }) + + // 模拟照片列表接口 + r.GET("/api/v1/photos", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "data": []gin.H{ + { + "id": "1", + "title": "Sample Photo 1", + "description": "This is a sample photo", + "url": "https://picsum.photos/800/600?random=1", + "status": "published", + "createdAt": time.Now().Format(time.RFC3339), + }, + { + "id": "2", + "title": "Sample Photo 2", + "description": "This is another sample photo", + "url": "https://picsum.photos/800/600?random=2", + "status": "published", + "createdAt": time.Now().Format(time.RFC3339), + }, + }, + "total": 2, + "page": 1, + "limit": 10, + "totalPages": 1, + }) + }) + + // 模拟仪表板统计接口 + r.GET("/api/v1/dashboard/stats", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "photos": gin.H{ + "total": 156, + "thisMonth": 23, + "today": 5, + }, + "categories": gin.H{ + "total": 12, + "active": 10, + }, + "tags": gin.H{ + "total": 45, + }, + "users": gin.H{ + "total": 8, + "active": 7, + }, + }) + }) + + // 模拟分类列表接口 + r.GET("/api/v1/categories", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "data": []gin.H{ + { + "id": "1", + "name": "风景", + "slug": "landscape", + "description": "自然风景摄影", + "photoCount": 25, + "isActive": true, + }, + { + "id": "2", + "name": "人像", + "slug": "portrait", + "description": "人物肖像摄影", + "photoCount": 18, + "isActive": true, + }, + }, + "total": 2, + }) + }) + + // 模拟标签列表接口 + r.GET("/api/v1/tags", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "data": []gin.H{ + { + "id": "1", + "name": "日落", + "slug": "sunset", + "photoCount": 12, + }, + { + "id": "2", + "name": "城市", + "slug": "city", + "photoCount": 8, + }, + }, + "total": 2, + }) + }) + + // 模拟用户列表接口 + r.GET("/api/v1/users", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "data": []gin.H{ + { + "id": "1", + "username": "admin", + "email": "admin@example.com", + "role": "admin", + "isActive": true, + }, + { + "id": "2", + "username": "editor", + "email": "editor@example.com", + "role": "editor", + "isActive": true, + }, + }, + "total": 2, + }) + }) + + // 启动服务器 + log.Println("🚀 Simple backend server starting on :8080") + log.Fatal(r.Run(":8080")) +} \ No newline at end of file diff --git a/backend/configs/config.dev.yaml b/backend/configs/config.dev.yaml new file mode 100644 index 0000000..37fbcf8 --- /dev/null +++ b/backend/configs/config.dev.yaml @@ -0,0 +1,105 @@ +# 开发环境配置 +app: + name: "Photography Portfolio Dev" + version: "1.0.0" + environment: "development" + port: 8080 + debug: true + +# 数据库配置 +database: + host: "localhost" + port: 5432 + username: "postgres" + password: "password" + database: "photography_dev" + ssl_mode: "disable" + max_open_conns: 50 + max_idle_conns: 5 + conn_max_lifetime: 300 + +# Redis配置 +redis: + host: "localhost" + port: 6379 + password: "" + database: 1 + pool_size: 10 + min_idle_conns: 2 + +# JWT配置 +jwt: + secret: "dev-secret-key-not-for-production" + expires_in: "24h" + refresh_expires_in: "168h" + +# 存储配置 +storage: + type: "local" + local: + base_path: "./uploads" + base_url: "http://localhost:8080/uploads" + s3: + region: "us-east-1" + bucket: "photography-dev-bucket" + access_key: "" + secret_key: "" + endpoint: "" + +# 上传配置 +upload: + max_file_size: 104857600 # 100MB + allowed_types: + - "image/jpeg" + - "image/jpg" + - "image/png" + - "image/gif" + - "image/webp" + - "image/tiff" + - "image/bmp" + thumbnail_sizes: + - name: "thumbnail" + width: 200 + height: 200 + - name: "medium" + width: 800 + height: 600 + - name: "large" + width: 1920 + height: 1080 + +# 日志配置 +logger: + level: "debug" + format: "text" + output: "stdout" + filename: "logs/dev.log" + max_size: 50 + max_age: 7 + compress: false + +# CORS配置 +cors: + allowed_origins: + - "http://localhost:3000" + - "http://localhost:3001" + - "http://localhost:5173" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "DELETE" + - "PATCH" + - "OPTIONS" + allowed_headers: + - "Content-Type" + - "Authorization" + - "X-Requested-With" + - "Origin" + allow_credentials: true + +# 限流配置 +rate_limit: + enabled: false + requests_per_minute: 1000 + burst: 2000 \ No newline at end of file diff --git a/backend/configs/config.prod.yaml b/backend/configs/config.prod.yaml new file mode 100644 index 0000000..1718d1f --- /dev/null +++ b/backend/configs/config.prod.yaml @@ -0,0 +1,101 @@ +# 生产环境配置 +app: + name: "Photography Portfolio" + version: "1.0.0" + environment: "production" + port: 8080 + debug: false + +# 数据库配置 +database: + host: "localhost" + port: 5432 + username: "postgres" + password: "${DB_PASSWORD}" + database: "photography" + ssl_mode: "require" + max_open_conns: 200 + max_idle_conns: 20 + conn_max_lifetime: 3600 + +# Redis配置 +redis: + host: "localhost" + port: 6379 + password: "${REDIS_PASSWORD}" + database: 0 + pool_size: 50 + min_idle_conns: 10 + +# JWT配置 +jwt: + secret: "${JWT_SECRET}" + expires_in: "24h" + refresh_expires_in: "168h" + +# 存储配置 +storage: + type: "local" # 生产环境建议使用 s3 或 oss + local: + base_path: "/var/www/photography/uploads" + base_url: "https://photography.iriver.top/uploads" + s3: + region: "${AWS_REGION}" + bucket: "${AWS_BUCKET}" + access_key: "${AWS_ACCESS_KEY_ID}" + secret_key: "${AWS_SECRET_ACCESS_KEY}" + endpoint: "${AWS_ENDPOINT}" + +# 上传配置 +upload: + max_file_size: 104857600 # 100MB + allowed_types: + - "image/jpeg" + - "image/jpg" + - "image/png" + - "image/gif" + - "image/webp" + - "image/tiff" + thumbnail_sizes: + - name: "thumbnail" + width: 200 + height: 200 + - name: "medium" + width: 800 + height: 600 + - name: "large" + width: 1920 + height: 1080 + +# 日志配置 +logger: + level: "info" + format: "json" + output: "file" + filename: "/var/log/photography/app.log" + max_size: 100 + max_age: 30 + compress: true + +# CORS配置 +cors: + allowed_origins: + - "https://photography.iriver.top" + - "https://admin.photography.iriver.top" + allowed_methods: + - "GET" + - "POST" + - "PUT" + - "DELETE" + - "OPTIONS" + allowed_headers: + - "Content-Type" + - "Authorization" + - "X-Requested-With" + allow_credentials: true + +# 限流配置 +rate_limit: + enabled: true + requests_per_minute: 60 + burst: 120 \ No newline at end of file diff --git a/backend/docs/CLAUDE.md b/backend/docs/CLAUDE.md new file mode 100644 index 0000000..327b67b --- /dev/null +++ b/backend/docs/CLAUDE.md @@ -0,0 +1,955 @@ +# Documentation Module - CLAUDE.md + +本文件为 Claude Code 在文档模块中工作时提供指导。 + +## 🎯 模块概览 + +文档模块负责维护项目的技术文档、API 文档和架构设计文档,确保项目的可理解性和可维护性。 + +### 主要职责 +- 📚 API 接口文档 +- 🏗️ 架构设计文档 +- 🚀 部署运维文档 +- 📖 开发者指南 +- 📋 变更日志 + +## 📁 模块结构 + +``` +docs/ +├── CLAUDE.md # 📋 当前文件 - API 文档和接口设计指导 +├── api/ # 📚 API 接口文档 +│ ├── openapi.yaml # OpenAPI 3.0 规范 +│ ├── swagger.json # Swagger JSON 格式 +│ ├── postman/ # Postman 集合 +│ │ ├── photography.json # API 测试集合 +│ │ └── environment.json # 环境变量 +│ └── examples/ # 请求响应示例 +│ ├── user_examples.md # 用户接口示例 +│ ├── photo_examples.md # 照片接口示例 +│ └── auth_examples.md # 认证接口示例 +├── architecture/ # 🏗️ 架构设计文档 +│ ├── overview.md # 系统架构概览 +│ ├── database_design.md # 数据库设计 +│ ├── api_design.md # API 设计原则 +│ ├── security.md # 安全架构 +│ └── performance.md # 性能优化 +├── development/ # 👨‍💻 开发文档 +│ ├── getting_started.md # 快速开始 +│ ├── coding_standards.md # 编码规范 +│ ├── testing_guide.md # 测试指南 +│ ├── debugging.md # 调试指南 +│ └── contribution.md # 贡献指南 +├── deployment/ # 🚀 部署文档 +│ ├── local_deployment.md # 本地部署 +│ ├── docker_deployment.md # Docker 部署 +│ ├── production_deployment.md # 生产环境部署 +│ ├── monitoring.md # 监控配置 +│ └── troubleshooting.md # 故障排查 +├── guides/ # 📖 使用指南 +│ ├── user_management.md # 用户管理 +│ ├── photo_management.md # 照片管理 +│ ├── authentication.md # 认证授权 +│ └── file_upload.md # 文件上传 +├── changelog/ # 📋 变更日志 +│ ├── CHANGELOG.md # 版本变更记录 +│ └── migration_guides/ # 迁移指南 +│ ├── v1_to_v2.md # 版本迁移 +│ └── breaking_changes.md # 破坏性变更 +└── templates/ # 📄 文档模板 + ├── api_template.md # API 文档模板 + ├── guide_template.md # 指南模板 + └── adr_template.md # 架构决策记录模板 +``` + +## 📚 API 文档 + +### OpenAPI 3.0 规范 +```yaml +# api/openapi.yaml - OpenAPI 3.0 规范 +openapi: 3.0.3 +info: + title: Photography Portfolio API + description: 摄影作品集 REST API + version: 1.0.0 + contact: + name: API Support + email: support@photography.com + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: https://api.photography.com/v1 + description: 生产环境 + - url: https://staging-api.photography.com/v1 + description: 测试环境 + - url: http://localhost:8080/api/v1 + description: 开发环境 + +paths: + /users: + get: + summary: 获取用户列表 + description: 获取系统中的用户列表,支持分页和过滤 + operationId: listUsers + tags: + - Users + parameters: + - name: page + in: query + description: 页码 + required: false + schema: + type: integer + minimum: 1 + default: 1 + - name: limit + in: query + description: 每页数量 + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: sort + in: query + description: 排序字段 + required: false + schema: + type: string + enum: [id, username, email, created_at] + default: created_at + - name: order + in: query + description: 排序方向 + required: false + schema: + type: string + enum: [asc, desc] + default: desc + responses: + '200': + description: 成功获取用户列表 + content: + application/json: + schema: + $ref: '#/components/schemas/UserListResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: 服务器内部错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - BearerAuth: [] + + post: + summary: 创建用户 + description: 创建新用户账户 + operationId: createUser + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '201': + description: 用户创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorResponse' + '409': + description: 用户已存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /users/{id}: + get: + summary: 获取用户详情 + description: 根据用户ID获取用户详细信息 + operationId: getUserById + tags: + - Users + parameters: + - name: id + in: path + description: 用户ID + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: 成功获取用户信息 + content: + application/json: + schema: + $ref: '#/components/schemas/UserResponse' + '404': + description: 用户不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + security: + - BearerAuth: [] + +components: + schemas: + User: + type: object + properties: + id: + type: integer + format: int64 + description: 用户ID + example: 1 + username: + type: string + description: 用户名 + example: "john_doe" + email: + type: string + format: email + description: 邮箱地址 + example: "john@example.com" + first_name: + type: string + description: 名字 + example: "John" + last_name: + type: string + description: 姓氏 + example: "Doe" + avatar: + type: string + format: uri + description: 头像URL + example: "https://example.com/avatars/john.jpg" + role: + type: string + enum: [admin, editor, user] + description: 用户角色 + example: "user" + status: + type: string + enum: [active, inactive, banned] + description: 用户状态 + example: "active" + created_at: + type: string + format: date-time + description: 创建时间 + example: "2024-01-01T00:00:00Z" + updated_at: + type: string + format: date-time + description: 更新时间 + example: "2024-01-01T00:00:00Z" + required: + - id + - username + - email + - role + - status + - created_at + - updated_at + + CreateUserRequest: + type: object + properties: + username: + type: string + minLength: 3 + maxLength: 50 + pattern: '^[a-zA-Z0-9_-]+$' + description: 用户名 + example: "john_doe" + email: + type: string + format: email + description: 邮箱地址 + example: "john@example.com" + password: + type: string + minLength: 8 + description: 密码 + example: "SecurePassword123!" + first_name: + type: string + maxLength: 50 + description: 名字 + example: "John" + last_name: + type: string + maxLength: 50 + description: 姓氏 + example: "Doe" + required: + - username + - email + - password + + UserResponse: + type: object + properties: + success: + type: boolean + example: true + data: + $ref: '#/components/schemas/User' + message: + type: string + example: "User retrieved successfully" + timestamp: + type: integer + format: int64 + example: 1704067200 + request_id: + type: string + example: "req_123456789" + + UserListResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + $ref: '#/components/schemas/User' + pagination: + $ref: '#/components/schemas/Pagination' + message: + type: string + example: "Users retrieved successfully" + timestamp: + type: integer + format: int64 + example: 1704067200 + request_id: + type: string + example: "req_123456789" + + Pagination: + type: object + properties: + page: + type: integer + example: 1 + limit: + type: integer + example: 20 + total: + type: integer + format: int64 + example: 100 + total_pages: + type: integer + example: 5 + has_next: + type: boolean + example: true + has_prev: + type: boolean + example: false + + ErrorResponse: + type: object + properties: + success: + type: boolean + example: false + error: + $ref: '#/components/schemas/Error' + timestamp: + type: integer + format: int64 + example: 1704067200 + request_id: + type: string + example: "req_123456789" + + Error: + type: object + properties: + code: + type: string + example: "USER_NOT_FOUND" + message: + type: string + example: "User not found" + details: + type: string + example: "User with ID 123 does not exist" + + ValidationErrorResponse: + type: object + properties: + success: + type: boolean + example: false + error: + type: object + properties: + code: + type: string + example: "VALIDATION_ERROR" + message: + type: string + example: "Validation failed" + details: + type: array + items: + $ref: '#/components/schemas/ValidationError' + timestamp: + type: integer + format: int64 + example: 1704067200 + request_id: + type: string + example: "req_123456789" + + ValidationError: + type: object + properties: + field: + type: string + example: "email" + value: + type: string + example: "invalid-email" + tag: + type: string + example: "email" + message: + type: string + example: "email must be a valid email address" + + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Bearer token + + parameters: + PageParam: + name: page + in: query + description: 页码 + required: false + schema: + type: integer + minimum: 1 + default: 1 + + LimitParam: + name: limit + in: query + description: 每页数量 + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + + SortParam: + name: sort + in: query + description: 排序字段 + required: false + schema: + type: string + default: "created_at" + + OrderParam: + name: order + in: query + description: 排序方向 + required: false + schema: + type: string + enum: [asc, desc] + default: "desc" + +tags: + - name: Users + description: 用户管理接口 + - name: Photos + description: 照片管理接口 + - name: Authentication + description: 认证授权接口 + - name: Categories + description: 分类管理接口 + - name: Tags + description: 标签管理接口 +``` + +## 🏗️ 架构设计 + +### 系统架构概览 +```markdown +# architecture/overview.md - 系统架构概览 + +## 整体架构 + +Photography Portfolio 采用现代化的微服务架构,基于 Go 语言开发,使用 Clean Architecture 设计模式。 + +### 架构层次 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend Layer │ +│ Next.js + React + TypeScript + Tailwind CSS │ +└─────────────────────────────────────────────────────────────┘ + │ + │ HTTP/HTTPS + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Gateway │ +│ Caddy + Load Balancer │ +└─────────────────────────────────────────────────────────────┘ + │ + │ HTTP + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Backend Services │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │ +│ │ User Service │ │ Photo Service │ │ Auth Service │ │ +│ │ (Go + Gin) │ │ (Go + Gin) │ │ (Go + Gin) │ │ +│ └─────────────────┘ └─────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ SQL/NoSQL + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Data Layer │ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ File Storage │ │ +│ │ (主数据库) │ │ (缓存) │ │ (S3/Local) │ │ +│ └─────────────────┘ └─────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 核心设计原则 + +1. **单一职责原则**: 每个服务只负责一个业务领域 +2. **依赖倒置原则**: 高层模块不依赖低层模块,都依赖抽象 +3. **接口隔离原则**: 使用接口定义契约,便于测试和替换 +4. **开闭原则**: 对扩展开放,对修改封闭 +5. **最小知识原则**: 模块间耦合度最小化 + +### 技术栈选择 + +#### 后端技术栈 +- **语言**: Go 1.23+ (性能优异,并发支持) +- **框架**: Gin (轻量级,高性能 HTTP 框架) +- **数据库**: PostgreSQL (关系型数据,事务支持) +- **缓存**: Redis (高性能内存缓存) +- **ORM**: GORM (功能丰富,性能优良) +- **认证**: JWT (无状态,扩展性好) +- **日志**: Uber Zap (结构化,高性能) +- **配置**: Viper (灵活的配置管理) + +#### 部署技术栈 +- **容器化**: Docker + Docker Compose +- **Web 服务器**: Caddy (自动 HTTPS,配置简单) +- **CI/CD**: Gitea Actions (自动化部署) +- **监控**: Prometheus + Grafana (可观测性) + +### 数据流向 + +1. **请求流程**: + Frontend → API Gateway → Backend Service → Repository → Database + +2. **响应流程**: + Database → Repository → Service → Handler → API Gateway → Frontend + +3. **认证流程**: + Client → Auth Service → JWT Token → Protected Resources + +4. **文件上传流程**: + Client → Upload Handler → Storage Service → File System/S3 +``` + +### 数据库设计 +```markdown +# architecture/database_design.md - 数据库设计 + +## 数据库选择 + +### PostgreSQL (主数据库) +- **优势**: ACID 事务支持,丰富的数据类型,成熟的生态 +- **用途**: 用户数据、照片元数据、分类标签等结构化数据 +- **版本**: PostgreSQL 14+ + +### Redis (缓存数据库) +- **优势**: 高性能内存存储,丰富的数据结构 +- **用途**: 会话缓存、频繁查询数据缓存、限流计数器 +- **版本**: Redis 6+ + +## 表结构设计 + +### 用户表 (users) +```sql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + first_name VARCHAR(50), + last_name VARCHAR(50), + avatar VARCHAR(255), + bio TEXT, + role VARCHAR(20) DEFAULT 'user', + status VARCHAR(20) DEFAULT 'active', + last_login_at TIMESTAMP, + email_verified_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP +); + +-- 索引 +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_users_created_at ON users(created_at); +``` + +### 照片表 (photos) +```sql +CREATE TABLE photos ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + filename VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL, + mime_type VARCHAR(100) NOT NULL, + width INTEGER DEFAULT 0, + height INTEGER DEFAULT 0, + exif_data TEXT, + taken_at TIMESTAMP, + location VARCHAR(255), + camera VARCHAR(100), + lens VARCHAR(100), + status VARCHAR(20) DEFAULT 'active', + view_count INTEGER DEFAULT 0, + download_count 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_status ON photos(status); +CREATE INDEX idx_photos_created_at ON photos(created_at); +CREATE INDEX idx_photos_taken_at ON photos(taken_at); +``` + +## 关系设计 + +### 实体关系图 +``` +Users ||--o{ Photos : owns +Photos }o--o{ Categories : categorized_by +Photos }o--o{ Tags : tagged_with +Users ||--o{ Albums : creates +Albums }o--o{ Photos : contains +``` + +### 外键约束 +- 所有外键使用 CASCADE 删除策略 +- 确保数据一致性和完整性 +- 使用索引优化查询性能 + +## 查询优化 + +### 索引策略 +1. **主键索引**: 自动创建 +2. **外键索引**: 手动创建,优化关联查询 +3. **查询索引**: 根据常用查询条件创建 +4. **复合索引**: 针对多字段查询优化 + +### 查询优化技巧 +1. **避免 N+1 查询**: 使用预加载 +2. **分页查询**: 使用 LIMIT 和 OFFSET +3. **条件筛选**: 在数据库层面过滤数据 +4. **结果缓存**: 缓存频繁查询的结果 +``` + +## 📖 开发指南 + +### 快速开始 +```markdown +# development/getting_started.md - 快速开始 + +## 环境要求 + +### 必需软件 +- Go 1.23+ +- PostgreSQL 14+ +- Redis 6+ +- Docker & Docker Compose +- Git + +### 推荐工具 +- GoLand 或 VS Code +- Postman 或 Insomnia +- DBeaver (数据库管理) +- Redis Desktop Manager + +## 本地开发环境设置 + +### 1. 克隆项目 +```bash +git clone +cd photography-backend +``` + +### 2. 安装依赖 +```bash +go mod download +``` + +### 3. 配置环境 +```bash +# 复制配置文件 +cp configs/config.dev.yaml.example configs/config.dev.yaml + +# 修改数据库配置 +vim configs/config.dev.yaml +``` + +### 4. 启动服务 +```bash +# 开发模式 (SQLite) +make dev + +# 完整模式 (PostgreSQL + Redis) +make dev-full + +# Mock 模式 (无数据库) +make dev-simple +``` + +### 5. 验证安装 +```bash +# 检查服务状态 +curl http://localhost:8080/health + +# 预期响应 +{ + "status": "healthy", + "timestamp": "2024-01-01T00:00:00Z", + "version": "1.0.0" +} +``` + +## 开发流程 + +### 1. 功能开发 +1. 创建功能分支: `git checkout -b feature/new-feature` +2. 编写代码和测试 +3. 运行测试: `make test` +4. 代码检查: `make lint` +5. 提交代码: `git commit -m "feat: add new feature"` + +### 2. 测试验证 +```bash +# 运行单元测试 +make test-unit + +# 运行集成测试 +make test-integration + +# 运行所有测试 +make test + +# 查看测试覆盖率 +make test-coverage +``` + +### 3. 提交代码 +```bash +# 格式化代码 +make fmt + +# 代码检查 +make lint + +# 构建项目 +make build + +# 提交到远程 +git push origin feature/new-feature +``` +``` + +## 📋 变更日志 + +### 版本管理 +```markdown +# changelog/CHANGELOG.md - 版本变更记录 + +# 变更日志 + +本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范。 + +## [未发布] + +### 新增 +- 新功能开发中... + +### 修改 +- 改进功能... + +### 修复 +- 修复问题... + +### 删除 +- 移除废弃功能... + +## [1.2.0] - 2024-01-15 + +### 新增 +- 添加照片批量上传功能 +- 支持照片 EXIF 信息提取 +- 新增照片搜索 API +- 添加用户头像上传功能 +- 支持照片分类管理 +- 新增 API 限流中间件 + +### 修改 +- 优化照片列表查询性能 +- 改进错误处理机制 +- 更新 API 响应格式 +- 优化数据库查询索引 +- 改进日志记录格式 + +### 修复 +- 修复文件上传大小限制问题 +- 解决并发操作的数据竞争问题 +- 修复 JWT Token 过期处理 +- 解决照片删除时的缓存清理问题 +- 修复分页查询的边界条件 + +### 安全 +- 加强文件上传安全验证 +- 改进密码强度要求 +- 添加请求频率限制 +- 优化 SQL 注入防护 + +## [1.1.0] - 2024-01-01 + +### 新增 +- 用户注册和登录功能 +- JWT 认证机制 +- 用户权限管理 +- 照片上传和管理 +- RESTful API 接口 +- 数据库迁移系统 + +### 修改 +- 重构代码架构 +- 优化性能表现 +- 改进错误处理 + +## [1.0.0] - 2023-12-01 + +### 新增 +- 项目初始版本 +- 基础框架搭建 +- 核心功能实现 +``` + +## 🛠️ 文档维护 + +### 文档编写规范 +1. **Markdown 格式**: 使用标准 Markdown 语法 +2. **结构清晰**: 合理使用标题层级 +3. **代码示例**: 提供完整可运行的示例 +4. **版本同步**: 文档与代码版本保持同步 +5. **定期更新**: 定期检查和更新文档内容 + +### 文档生成工具 +```bash +# 生成 API 文档 +make docs-api + +# 生成 Swagger UI +make docs-swagger + +# 生成完整文档站点 +make docs-build + +# 启动文档服务器 +make docs-serve +``` + +### 文档发布流程 +1. **编写文档**: 按照模板编写文档 +2. **本地预览**: 使用工具预览效果 +3. **代码审查**: 提交 PR 进行审查 +4. **自动部署**: 合并后自动发布到文档站点 + +## 💡 最佳实践 + +### API 文档编写 +1. **完整性**: 包含所有接口的详细说明 +2. **示例丰富**: 提供请求和响应示例 +3. **错误说明**: 详细说明各种错误情况 +4. **版本控制**: 明确 API 版本兼容性 +5. **交互式**: 支持在线测试功能 + +### 架构文档维护 +1. **及时更新**: 架构变更后立即更新文档 +2. **图文并茂**: 使用图表说明复杂概念 +3. **决策记录**: 记录重要的架构决策过程 +4. **影响分析**: 说明变更对系统的影响 +5. **迁移指南**: 提供版本迁移的详细步骤 + +### 开发文档规范 +1. **步骤详细**: 提供详细的操作步骤 +2. **环境说明**: 明确开发环境要求 +3. **故障排查**: 包含常见问题的解决方案 +4. **最佳实践**: 分享开发经验和技巧 +5. **持续改进**: 根据反馈不断完善文档 + +本模块确保项目文档的完整性和准确性,为开发者和用户提供清晰的指导。 \ No newline at end of file diff --git a/backend/go.mod b/backend/go.mod index 4bd6ea0..d8bd1fe 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,29 +1,34 @@ module photography-backend -go 1.21 +go 1.23.0 + +toolchain go1.24.4 require ( - github.com/gin-gonic/gin v1.9.1 + github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/spf13/viper v1.18.2 go.uber.org/zap v1.26.0 - golang.org/x/crypto v0.17.0 - golang.org/x/text v0.14.0 + golang.org/x/crypto v0.39.0 + golang.org/x/text v0.26.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/postgres v1.5.4 - gorm.io/gorm v1.25.5 + gorm.io/gorm v1.30.0 ) require ( - github.com/bytedance/sonic v1.9.1 // indirect + 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.2 // indirect - github.com/gin-contrib/sse v0.1.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 - github.com/go-playground/validator/v10 v10.14.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -32,14 +37,15 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect - github.com/leodido/go-urn v1.2.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect @@ -48,14 +54,15 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.11 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/arch v0.3.0 // indirect + golang.org/x/arch v0.18.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.15.0 // indirect - google.golang.org/protobuf v1.31.0 // indirect + golang.org/x/net v0.41.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + 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 7919755..8f8eeda 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,9 +1,17 @@ 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= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -14,10 +22,18 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos 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= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -26,8 +42,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn 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= @@ -54,16 +74,25 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm 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= 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= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -73,6 +102,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G 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= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -109,6 +140,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS 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= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -118,24 +151,38 @@ 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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -148,6 +195,11 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/CLAUDE.md b/backend/internal/CLAUDE.md new file mode 100644 index 0000000..ae80a16 --- /dev/null +++ b/backend/internal/CLAUDE.md @@ -0,0 +1,313 @@ +# Internal 包 - CLAUDE.md + +此文件为 Claude Code 在 internal 包中工作时提供指导。internal 包包含了应用程序的核心业务逻辑,不对外暴露。 + +## 🎯 模块概览 + +internal 包采用分层架构设计,包含以下核心层次: + +- **API Layer** (`api/`): HTTP 接口层,处理请求和响应 +- **Domain Layer** (`domain/`): 领域模型层,定义业务实体和规则 +- **Application Layer** (`application/`): 应用服务层,编排业务逻辑 +- **Infrastructure Layer** (`infrastructure/`): 基础设施层,外部依赖 +- **Shared Layer** (`shared/`): 共享工具和常量 + +## 📁 目录结构 + +``` +internal/ +├── CLAUDE.md # 🔍 当前文件 - Internal 包指南 +├── api/ # 🌐 API 接口层 +│ ├── CLAUDE.md # API 层开发指南 +│ ├── handlers/ # HTTP 处理器 +│ ├── middleware/ # 中间件 +│ ├── routes/ # 路由配置 +│ └── validators/ # 输入验证 +├── domain/ # 🏗️ 领域模型层 +│ ├── CLAUDE.md # 领域层开发指南 +│ ├── entities/ # 业务实体 +│ ├── repositories/ # 仓储接口 +│ └── services/ # 领域服务接口 +├── application/ # 🔧 应用服务层 +│ ├── CLAUDE.md # 应用层开发指南 +│ ├── dto/ # 数据传输对象 +│ └── services/ # 应用服务实现 +├── infrastructure/ # 🏭 基础设施层 +│ ├── CLAUDE.md # 基础设施指南 +│ ├── config/ # 配置管理 +│ ├── database/ # 数据库操作 +│ ├── cache/ # 缓存服务 +│ ├── storage/ # 文件存储 +│ └── repositories/ # 仓储实现 +└── shared/ # 🔗 共享组件 + ├── CLAUDE.md # 共享组件指南 + ├── constants/ # 常量定义 + ├── errors/ # 错误处理 + └── utils/ # 工具函数 +``` + +## 🏗️ 分层架构原则 + +### 依赖方向 +``` +API Layer ──→ Application Layer ──→ Domain Layer + ↓ ↓ ↑ +Infrastructure Layer ──────────────────┘ +``` + +### 层次职责 + +#### 1. API Layer (api/) +- **职责**: 处理 HTTP 请求和响应 +- **依赖**: Application Layer +- **不允许**: 直接调用 Infrastructure Layer 或包含业务逻辑 + +#### 2. Application Layer (application/) +- **职责**: 编排业务逻辑,协调 Domain Services +- **依赖**: Domain Layer +- **不允许**: 包含具体的基础设施实现 + +#### 3. Domain Layer (domain/) +- **职责**: 定义业务实体、规则和接口 +- **依赖**: 无(最核心层) +- **不允许**: 依赖外部框架或基础设施 + +#### 4. Infrastructure Layer (infrastructure/) +- **职责**: 实现外部依赖,如数据库、缓存、文件存储 +- **依赖**: Domain Layer (实现接口) +- **不允许**: 包含业务逻辑 + +#### 5. Shared Layer (shared/) +- **职责**: 提供跨层共享的工具和常量 +- **依赖**: 最小化依赖 +- **原则**: 保持稳定,避免频繁变更 + +## 🔧 开发规范 + +### 包导入规则 + +#### 标准导入顺序 +```go +import ( + // 1. 标准库 + "context" + "fmt" + "time" + + // 2. 第三方库 + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + // 3. 项目内部包 - 按依赖层次 + "photography-backend/internal/domain/entities" + "photography-backend/internal/application/dto" + "photography-backend/internal/shared/errors" +) +``` + +#### 禁止的依赖 +- Domain Layer 不能导入 Infrastructure Layer +- Application Layer 不能导入 API Layer +- 下层不能导入上层 + +### 接口设计原则 + +#### 1. 依赖倒置 +```go +// ✅ 正确:在 domain/repositories 定义接口 +type UserRepository interface { + FindByID(id uint) (*entities.User, error) + Save(user *entities.User) error +} + +// ✅ 在 infrastructure/repositories 实现接口 +type userRepository struct { + db *gorm.DB +} + +func (r *userRepository) FindByID(id uint) (*entities.User, error) { + // 实现细节 +} +``` + +#### 2. 接口隔离 +```go +// ✅ 正确:小而专注的接口 +type UserReader interface { + FindByID(id uint) (*entities.User, error) + FindByEmail(email string) (*entities.User, error) +} + +type UserWriter interface { + Save(user *entities.User) error + Delete(id uint) error +} + +// ❌ 错误:过大的接口 +type UserRepository interface { + // 包含太多方法... +} +``` + +### 错误处理规范 + +#### 1. 错误传播 +```go +// Domain Layer: 定义业务错误 +var ErrUserNotFound = errors.New("user not found") + +// Application Layer: 处理和转换错误 +func (s *UserService) GetUser(id uint) (*dto.UserResponse, error) { + user, err := s.repo.FindByID(id) + if err != nil { + if errors.Is(err, domain.ErrUserNotFound) { + return nil, shared.ErrUserNotFound + } + return nil, fmt.Errorf("failed to get user: %w", err) + } + return s.toDTO(user), nil +} + +// API Layer: 转换为 HTTP 响应 +func (h *UserHandler) GetUser(c *gin.Context) { + user, err := h.service.GetUser(id) + if err != nil { + response.HandleError(c, err) + return + } + response.Success(c, user) +} +``` + +## 🚀 开发工作流 + +### 1. 新功能开发流程 + +#### Step 1: Domain Layer +```bash +# 1. 定义实体 +vim internal/domain/entities/new_entity.go + +# 2. 定义仓储接口 +vim internal/domain/repositories/new_repository.go + +# 3. 定义领域服务接口(如需要) +vim internal/domain/services/new_service.go +``` + +#### Step 2: Application Layer +```bash +# 1. 定义 DTO +vim internal/application/dto/request/new_request.go +vim internal/application/dto/response/new_response.go + +# 2. 实现应用服务 +vim internal/application/services/new_service.go +``` + +#### Step 3: Infrastructure Layer +```bash +# 1. 实现仓储 +vim internal/infrastructure/repositories/new_repository.go + +# 2. 实现其他基础设施(如需要) +``` + +#### Step 4: API Layer +```bash +# 1. 实现处理器 +vim internal/api/handlers/new_handler.go + +# 2. 添加路由 +vim internal/api/routes/api_v1.go + +# 3. 添加验证器(如需要) +vim internal/api/validators/new_validator.go +``` + +### 2. 测试策略 + +#### 单元测试 +- Domain Layer: 测试业务逻辑 +- Application Layer: 测试服务编排 +- Infrastructure Layer: 测试数据访问 + +#### 集成测试 +- API Layer: 端到端测试 +- Repository Layer: 数据库集成测试 + +#### 测试隔离 +```go +// 使用依赖注入便于测试 +type UserService struct { + repo domain.UserRepository +} + +// 测试时注入 Mock +func TestUserService_GetUser(t *testing.T) { + mockRepo := &MockUserRepository{} + service := NewUserService(mockRepo) + // 测试逻辑 +} +``` + +## 📋 代码审查清单 + +### 架构合规性 +- [ ] 是否遵循分层架构原则 +- [ ] 是否违反依赖方向 +- [ ] 接口设计是否合理 +- [ ] 错误处理是否完善 + +### 代码质量 +- [ ] 命名是否清晰明确 +- [ ] 函数是否单一职责 +- [ ] 是否有适当的注释 +- [ ] 是否有相应的测试 + +### 性能考虑 +- [ ] 是否有 N+1 查询问题 +- [ ] 是否需要添加缓存 +- [ ] 数据库操作是否高效 + +## 🎯 最佳实践 + +### 1. 保持层次清晰 +- 每层只关注自己的职责 +- 避免跨层直接调用 +- 使用接口解耦 + +### 2. 依赖注入 +- 使用构造函数注入 +- 避免全局变量 +- 便于测试和替换 + +### 3. 错误处理 +- 使用带类型的错误 +- 适当包装错误信息 +- 保持错误处理一致性 + +### 4. 性能优化 +- 合理使用缓存 +- 数据库查询优化 +- 避免过度设计 + +## 🔍 故障排查 + +### 常见问题 + +1. **循环依赖**: 检查包导入关系 +2. **接口未实现**: 确认所有方法都已实现 +3. **依赖注入失败**: 检查构造函数和依赖配置 +4. **测试失败**: 确认 Mock 对象配置正确 + +### 调试技巧 +```go +// 使用结构化日志 +logger.Debug("Processing request", + logger.String("handler", "UserHandler.GetUser"), + logger.Uint("user_id", userID), +) +``` + +这个架构设计确保了代码的可维护性、可测试性和可扩展性。遵循这些指导原则,可以构建出高质量的后端服务。 \ No newline at end of file diff --git a/backend/internal/api/CLAUDE.md b/backend/internal/api/CLAUDE.md new file mode 100644 index 0000000..ab39277 --- /dev/null +++ b/backend/internal/api/CLAUDE.md @@ -0,0 +1,565 @@ +# HTTP 接口层 - CLAUDE.md + +本文件为 Claude Code 在 HTTP 接口层模块中工作时提供指导。 + +## 🎯 模块概览 + +HTTP 接口层负责处理所有 HTTP 请求,包括路由定义、请求处理、响应格式化、中间件管理等。 + +### 职责范围 +- 🌐 HTTP 路由定义和管理 +- 📝 请求处理和响应格式化 +- 🛡️ 中间件管理和应用 +- ✅ 请求参数验证 +- 📊 HTTP 状态码管理 + +### 文件结构 +``` +internal/api/ +├── CLAUDE.md # 📋 当前文件 - API 接口指导 +├── handlers/ # 🎯 HTTP 处理器 +│ ├── user.go # 用户相关处理器 +│ ├── photo.go # 照片相关处理器 +│ ├── category.go # 分类相关处理器 +│ ├── tag.go # 标签相关处理器 +│ ├── auth.go # 认证相关处理器 +│ ├── upload.go # 上传相关处理器 +│ └── health.go # 健康检查处理器 +├── middleware/ # 🛡️ 中间件 +│ ├── auth.go # 认证中间件 +│ ├── cors.go # CORS 中间件 +│ ├── logger.go # 日志中间件 +│ ├── rate_limit.go # 限流中间件 +│ ├── recovery.go # 错误恢复中间件 +│ └── validator.go # 验证中间件 +├── routes/ # 🗺️ 路由定义 +│ ├── v1.go # API v1 路由 +│ ├── auth.go # 认证路由 +│ ├── public.go # 公共路由 +│ └── admin.go # 管理员路由 +└── validators/ # ✅ 请求验证器 + ├── user.go # 用户验证器 + ├── photo.go # 照片验证器 + ├── category.go # 分类验证器 + └── common.go # 通用验证器 +``` + +## 🎯 处理器模式 + +### 标准处理器结构 +```go +type UserHandler struct { + userService service.UserServiceInterface + logger *zap.Logger +} + +func NewUserHandler(userService service.UserServiceInterface, logger *zap.Logger) *UserHandler { + return &UserHandler{ + userService: userService, + logger: logger, + } +} + +// 创建用户 +func (h *UserHandler) Create(c *gin.Context) { + var req validators.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.Error(c, http.StatusBadRequest, "INVALID_REQUEST", "请求参数无效", err) + return + } + + user, err := h.userService.Create(c.Request.Context(), req) + if err != nil { + h.logger.Error("创建用户失败", zap.Error(err)) + response.Error(c, http.StatusInternalServerError, "CREATE_USER_FAILED", "创建用户失败", err) + return + } + + response.Success(c, user, "用户创建成功") +} +``` + +### 处理器职责 +1. **请求绑定**: 解析 HTTP 请求参数 +2. **参数验证**: 验证请求参数的有效性 +3. **服务调用**: 调用应用服务层处理业务逻辑 +4. **响应格式化**: 格式化响应数据 +5. **错误处理**: 处理和记录错误信息 + +## 🗺️ 路由设计 + +### API 版本管理 +```go +// v1 路由组 +v1 := router.Group("/api/v1") +{ + // 公共路由 (无需认证) + v1.POST("/auth/login", authHandler.Login) + v1.POST("/auth/register", authHandler.Register) + v1.GET("/photos/public", photoHandler.GetPublicPhotos) + + // 需要认证的路由 + authenticated := v1.Group("") + authenticated.Use(middleware.AuthRequired()) + { + authenticated.GET("/users/profile", userHandler.GetProfile) + authenticated.PUT("/users/profile", userHandler.UpdateProfile) + authenticated.POST("/photos", photoHandler.Create) + authenticated.GET("/photos", photoHandler.List) + } + + // 管理员路由 + admin := v1.Group("/admin") + admin.Use(middleware.AuthRequired(), middleware.AdminRequired()) + { + admin.GET("/users", userHandler.ListUsers) + admin.DELETE("/users/:id", userHandler.DeleteUser) + admin.GET("/photos/all", photoHandler.ListAllPhotos) + } +} +``` + +### RESTful API 设计 +```go +// 用户资源 +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/photos # 获取照片列表 +POST /api/v1/photos # 创建照片 +GET /api/v1/photos/:id # 获取照片详情 +PUT /api/v1/photos/:id # 更新照片 +DELETE /api/v1/photos/:id # 删除照片 + +// 分类资源 +GET /api/v1/categories # 获取分类列表 +POST /api/v1/categories # 创建分类 +GET /api/v1/categories/:id # 获取分类详情 +PUT /api/v1/categories/:id # 更新分类 +DELETE /api/v1/categories/:id # 删除分类 +``` + +## 🛡️ 中间件管理 + +### 认证中间件 +```go +func AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + response.Error(c, http.StatusUnauthorized, "MISSING_TOKEN", "缺少认证令牌", nil) + c.Abort() + return + } + + // 验证 JWT Token + claims, err := jwt.VerifyToken(token) + if err != nil { + response.Error(c, http.StatusUnauthorized, "INVALID_TOKEN", "无效的认证令牌", err) + c.Abort() + return + } + + // 设置用户信息到上下文 + c.Set("user_id", claims.UserID) + c.Set("user_role", claims.Role) + c.Next() + } +} +``` + +### 日志中间件 +```go +func Logger(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + duration := time.Since(start) + + logger.Info("HTTP Request", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.String("query", c.Request.URL.RawQuery), + zap.Int("status", c.Writer.Status()), + zap.Duration("duration", duration), + zap.String("user_agent", c.Request.UserAgent()), + zap.String("ip", c.ClientIP()), + ) + } +} +``` + +### CORS 中间件 +```go +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} +``` + +### 限流中间件 +```go +func RateLimit(limit int, window time.Duration) gin.HandlerFunc { + limiter := rate.NewLimiter(rate.Every(window), limit) + + return func(c *gin.Context) { + if !limiter.Allow() { + response.Error(c, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "请求过于频繁", nil) + c.Abort() + return + } + c.Next() + } +} +``` + +## ✅ 请求验证 + +### 验证器结构 +```go +type CreateUserRequest struct { + Username string `json:"username" binding:"required,min=3,max=20"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + Role string `json:"role" binding:"oneof=user editor admin"` +} + +type UpdateUserRequest struct { + Username string `json:"username,omitempty" binding:"omitempty,min=3,max=20"` + Email string `json:"email,omitempty" binding:"omitempty,email"` + Role string `json:"role,omitempty" binding:"omitempty,oneof=user editor admin"` +} +``` + +### 自定义验证器 +```go +func ValidateUsername(fl validator.FieldLevel) bool { + username := fl.Field().String() + // 用户名只能包含字母、数字和下划线 + matched, _ := regexp.MatchString(`^[a-zA-Z0-9_]+$`, username) + return matched +} + +func ValidatePhotoFormat(fl validator.FieldLevel) bool { + format := fl.Field().String() + allowedFormats := []string{"jpg", "jpeg", "png", "gif", "webp"} + for _, allowed := range allowedFormats { + if format == allowed { + return true + } + } + return false +} +``` + +### 验证错误处理 +```go +func FormatValidationErrors(err error) map[string]string { + errors := make(map[string]string) + + if validationErrors, ok := err.(validator.ValidationErrors); ok { + for _, e := range validationErrors { + field := strings.ToLower(e.Field()) + switch e.Tag() { + case "required": + errors[field] = "此字段为必填项" + case "email": + errors[field] = "请输入有效的邮箱地址" + case "min": + errors[field] = fmt.Sprintf("最小长度为 %s", e.Param()) + case "max": + errors[field] = fmt.Sprintf("最大长度为 %s", e.Param()) + default: + errors[field] = "字段值无效" + } + } + } + + return errors +} +``` + +## 📊 响应格式 + +### 统一响应结构 +```go +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Message string `json:"message"` + Error *ErrorInfo `json:"error,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +type ErrorInfo struct { + Code string `json:"code"` + Message string `json:"message"` + Details interface{} `json:"details,omitempty"` +} +``` + +### 响应帮助函数 +```go +func Success(c *gin.Context, data interface{}, message string) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + Message: message, + Timestamp: time.Now(), + }) +} + +func Error(c *gin.Context, statusCode int, errorCode, message string, details interface{}) { + c.JSON(statusCode, Response{ + Success: false, + Message: message, + Error: &ErrorInfo{ + Code: errorCode, + Message: message, + Details: details, + }, + Timestamp: time.Now(), + }) +} +``` + +### 分页响应 +```go +type PaginatedResponse struct { + Data interface{} `json:"data"` + Pagination Pagination `json:"pagination"` +} + +type Pagination struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int64 `json:"total"` + TotalPages int `json:"total_pages"` + HasNext bool `json:"has_next"` + HasPrevious bool `json:"has_previous"` +} +``` + +## 🔍 错误处理 + +### 错误分类 +```go +const ( + // 请求错误 + ErrInvalidRequest = "INVALID_REQUEST" + ErrMissingParameter = "MISSING_PARAMETER" + ErrInvalidParameter = "INVALID_PARAMETER" + + // 认证错误 + ErrMissingToken = "MISSING_TOKEN" + ErrInvalidToken = "INVALID_TOKEN" + ErrTokenExpired = "TOKEN_EXPIRED" + ErrInsufficientPermission = "INSUFFICIENT_PERMISSION" + + // 业务错误 + ErrUserNotFound = "USER_NOT_FOUND" + ErrUserAlreadyExists = "USER_ALREADY_EXISTS" + ErrPhotoNotFound = "PHOTO_NOT_FOUND" + ErrCategoryNotFound = "CATEGORY_NOT_FOUND" + + // 系统错误 + ErrInternalServer = "INTERNAL_SERVER_ERROR" + ErrDatabaseError = "DATABASE_ERROR" + ErrStorageError = "STORAGE_ERROR" +) +``` + +### 错误恢复中间件 +```go +func Recovery(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + logger.Error("Panic recovered", + zap.Any("error", err), + zap.String("path", c.Request.URL.Path), + zap.String("method", c.Request.Method), + ) + + response.Error(c, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "服务器内部错误", nil) + } + }() + c.Next() + } +} +``` + +## 📊 性能优化 + +### 响应压缩 +```go +func Compression() gin.HandlerFunc { + return gzip.Gzip(gzip.DefaultCompression) +} +``` + +### 缓存控制 +```go +func CacheControl(maxAge int) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", fmt.Sprintf("max-age=%d", maxAge)) + c.Next() + } +} +``` + +### 请求大小限制 +```go +func LimitRequestSize(maxSize int64) gin.HandlerFunc { + return func(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) + c.Next() + } +} +``` + +## 🧪 测试策略 + +### 处理器测试 +```go +func TestUserHandler_Create(t *testing.T) { + // 模拟服务 + mockService := &MockUserService{} + handler := NewUserHandler(mockService, zap.NewNop()) + + // 创建测试请求 + reqBody := `{"username":"test","email":"test@example.com","password":"123456"}` + req, _ := http.NewRequest("POST", "/api/v1/users", strings.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + // 执行请求 + w := httptest.NewRecorder() + router := gin.New() + router.POST("/api/v1/users", handler.Create) + router.ServeHTTP(w, req) + + // 验证结果 + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "success") +} +``` + +### 中间件测试 +```go +func TestAuthMiddleware(t *testing.T) { + middleware := AuthRequired() + + // 测试缺少 token + req, _ := http.NewRequest("GET", "/api/v1/users", nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + middleware(c) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), "MISSING_TOKEN") +} +``` + +## 📚 API 文档 + +### Swagger 注释 +```go +// CreateUser 创建用户 +// @Summary 创建用户 +// @Description 创建新用户账户 +// @Tags 用户管理 +// @Accept json +// @Produce json +// @Param user body validators.CreateUserRequest true "用户信息" +// @Success 200 {object} response.Response{data=models.User} "成功创建用户" +// @Failure 400 {object} response.Response "请求参数错误" +// @Failure 500 {object} response.Response "服务器内部错误" +// @Router /api/v1/users [post] +func (h *UserHandler) Create(c *gin.Context) { + // 处理器实现 +} +``` + +### API 文档生成 +```bash +# 安装 swag +go install github.com/swaggo/swag/cmd/swag@latest + +# 生成文档 +swag init -g cmd/server/main.go + +# 启动时访问文档 +# http://localhost:8080/swagger/index.html +``` + +## 🔧 开发工具 + +### 路由调试 +```go +func PrintRoutes(router *gin.Engine) { + routes := router.Routes() + for _, route := range routes { + fmt.Printf("[%s] %s -> %s\n", route.Method, route.Path, route.Handler) + } +} +``` + +### 请求日志 +```go +func RequestLogger() gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + return fmt.Sprintf("[%s] %s %s %d %s\n", + param.TimeStamp.Format("2006-01-02 15:04:05"), + param.Method, + param.Path, + param.StatusCode, + param.Latency, + ) + }) +} +``` + +## 💡 最佳实践 + +### 处理器设计 +- 保持处理器轻量,业务逻辑放在服务层 +- 统一错误处理和响应格式 +- 完善的参数验证和错误提示 +- 适当的日志记录 + +### 中间件使用 +- 按需应用中间件,避免过度使用 +- 注意中间件的执行顺序 +- 错误处理中间件应该最先应用 +- 认证中间件应该在业务中间件之前 + +### 路由设计 +- 遵循 RESTful 设计原则 +- 合理的路由分组和版本管理 +- 清晰的路由命名和结构 +- 适当的权限控制 + +### 性能考虑 +- 使用响应压缩减少传输大小 +- 适当的缓存控制 +- 限制请求大小和频率 +- 异步处理耗时操作 + +本模块是整个 API 的入口和门面,确保接口设计合理、响应格式统一、错误处理完善是项目成功的关键。 \ No newline at end of file diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5127ff5..247787b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -120,7 +120,6 @@ type RateLimitConfig struct { Burst int `mapstructure:"burst"` } -var AppConfig *Config // LoadConfig 加载配置 func LoadConfig(configPath string) (*Config, error) { @@ -159,7 +158,6 @@ func LoadConfig(configPath string) (*Config, error) { return nil, fmt.Errorf("config validation failed: %w", err) } - AppConfig = &config return &config, nil } diff --git a/backend/internal/model/CLAUDE.md b/backend/internal/model/CLAUDE.md new file mode 100644 index 0000000..4ce63d4 --- /dev/null +++ b/backend/internal/model/CLAUDE.md @@ -0,0 +1,684 @@ +# Model Layer - CLAUDE.md + +本文件为 Claude Code 在数据模型层中工作时提供指导。 + +## 🎯 模块概览 + +Model 层负责定义数据结构、实体模型和数据传输对象,是整个应用的数据基础。 + +### 主要职责 +- 📦 定义数据库实体模型 +- 🔄 定义数据传输对象(DTO) +- 📝 定义请求和响应结构 +- 🔗 实现数据转换和验证 +- 📊 定义枚举和常量 + +## 📁 模块结构 + +``` +internal/model/ +├── CLAUDE.md # 📋 当前文件 - 数据模型设计指导 +├── entity/ # 📦 实体模型 +│ ├── user.go # 用户实体 +│ ├── photo.go # 照片实体 +│ ├── category.go # 分类实体 +│ ├── tag.go # 标签实体 +│ ├── album.go # 相册实体 +│ └── base.go # 基础实体 +├── dto/ # 🔄 数据传输对象 +│ ├── user_dto.go # 用户 DTO +│ ├── photo_dto.go # 照片 DTO +│ ├── category_dto.go # 分类 DTO +│ ├── auth_dto.go # 认证 DTO +│ └── common_dto.go # 通用 DTO +├── request/ # 📝 请求模型 +│ ├── user_request.go # 用户请求 +│ ├── photo_request.go # 照片请求 +│ ├── category_request.go # 分类请求 +│ └── auth_request.go # 认证请求 +├── response/ # 📤 响应模型 +│ ├── user_response.go # 用户响应 +│ ├── photo_response.go # 照片响应 +│ ├── category_response.go # 分类响应 +│ └── common_response.go # 通用响应 +└── types/ # 📊 类型定义 + ├── enums.go # 枚举类型 + ├── constants.go # 常量定义 + └── custom_types.go # 自定义类型 +``` + +## 🏗️ 实体模型设计 + +### 基础实体 +```go +// entity/base.go - 基础实体模型 +package entity + +import ( + "time" + "gorm.io/gorm" +) + +// BaseEntity 基础实体,包含通用字段 +type BaseEntity struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;column:deleted_at" json:"-"` +} + +// CreatedBy 创建者字段 +type CreatedBy struct { + CreatedBy uint `gorm:"column:created_by;index" json:"created_by"` +} + +// UpdatedBy 更新者字段 +type UpdatedBy struct { + UpdatedBy uint `gorm:"column:updated_by;index" json:"updated_by"` +} + +// SoftDelete 软删除字段 +type SoftDelete struct { + DeletedBy uint `gorm:"column:deleted_by;index" json:"deleted_by,omitempty"` +} + +// TableName 实现 gorm.Tabler 接口 +func (BaseEntity) TableName() string { + return "" +} +``` + +### 用户实体 +```go +// entity/user.go - 用户实体 +package entity + +import ( + "time" + "gorm.io/gorm" +) + +// User 用户实体 +type User struct { + BaseEntity + + // 基本信息 + Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"` + Email string `gorm:"column:email;type:varchar(100);uniqueIndex;not null" json:"email"` + Password string `gorm:"column:password;type:varchar(255);not null" json:"-"` + + // 个人信息 + FirstName string `gorm:"column:first_name;type:varchar(50)" json:"first_name"` + LastName string `gorm:"column:last_name;type:varchar(50)" json:"last_name"` + Avatar string `gorm:"column:avatar;type:varchar(255)" json:"avatar"` + Bio string `gorm:"column:bio;type:text" json:"bio"` + + // 系统字段 + Role UserRole `gorm:"column:role;type:varchar(20);default:'user'" json:"role"` + Status UserStatus `gorm:"column:status;type:varchar(20);default:'active'" json:"status"` + + // 时间字段 + LastLoginAt *time.Time `gorm:"column:last_login_at" json:"last_login_at"` + EmailVerifiedAt *time.Time `gorm:"column:email_verified_at" json:"email_verified_at"` + + // 关联关系 + Photos []Photo `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"photos,omitempty"` + Albums []Album `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"albums,omitempty"` + Categories []Category `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"categories,omitempty"` +} + +// UserRole 用户角色枚举 +type UserRole string + +const ( + UserRoleAdmin UserRole = "admin" + UserRoleEditor UserRole = "editor" + UserRoleUser UserRole = "user" +) + +// UserStatus 用户状态枚举 +type UserStatus string + +const ( + UserStatusActive UserStatus = "active" + UserStatusInactive UserStatus = "inactive" + UserStatusBanned UserStatus = "banned" +) + +// TableName 指定表名 +func (User) TableName() string { + return "users" +} + +// IsAdmin 检查是否为管理员 +func (u *User) IsAdmin() bool { + return u.Role == UserRoleAdmin +} + +// IsActive 检查是否为活跃用户 +func (u *User) IsActive() bool { + return u.Status == UserStatusActive +} + +// GetFullName 获取完整姓名 +func (u *User) GetFullName() string { + if u.FirstName == "" && u.LastName == "" { + return u.Username + } + return u.FirstName + " " + u.LastName +} +``` + +### 照片实体 +```go +// entity/photo.go - 照片实体 +package entity + +import ( + "time" +) + +// Photo 照片实体 +type Photo struct { + BaseEntity + + // 基本信息 + Title string `gorm:"column:title;type:varchar(255);not null" json:"title"` + Description string `gorm:"column:description;type:text" json:"description"` + + // 文件信息 + Filename string `gorm:"column:filename;type:varchar(255);not null" json:"filename"` + FilePath string `gorm:"column:file_path;type:varchar(500);not null" json:"file_path"` + FileSize int64 `gorm:"column:file_size;not null" json:"file_size"` + MimeType string `gorm:"column:mime_type;type:varchar(100);not null" json:"mime_type"` + + // 图片属性 + Width int `gorm:"column:width;default:0" json:"width"` + Height int `gorm:"column:height;default:0" json:"height"` + + // 元数据 + ExifData string `gorm:"column:exif_data;type:text" json:"exif_data,omitempty"` + TakenAt *time.Time `gorm:"column:taken_at" json:"taken_at"` + Location string `gorm:"column:location;type:varchar(255)" json:"location"` + Camera string `gorm:"column:camera;type:varchar(100)" json:"camera"` + Lens string `gorm:"column:lens;type:varchar(100)" json:"lens"` + + // 系统字段 + UserID uint `gorm:"column:user_id;not null;index" json:"user_id"` + Status PhotoStatus `gorm:"column:status;type:varchar(20);default:'active'" json:"status"` + + // 统计字段 + ViewCount int `gorm:"column:view_count;default:0" json:"view_count"` + DownloadCount int `gorm:"column:download_count;default:0" json:"download_count"` + + // 关联关系 + User User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"user,omitempty"` + Categories []Category `gorm:"many2many:photo_categories;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"categories,omitempty"` + Tags []Tag `gorm:"many2many:photo_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"tags,omitempty"` + Albums []Album `gorm:"many2many:album_photos;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"albums,omitempty"` +} + +// PhotoStatus 照片状态枚举 +type PhotoStatus string + +const ( + PhotoStatusActive PhotoStatus = "active" + PhotoStatusInactive PhotoStatus = "inactive" + PhotoStatusPrivate PhotoStatus = "private" + PhotoStatusDeleted PhotoStatus = "deleted" +) + +// TableName 指定表名 +func (Photo) TableName() string { + return "photos" +} + +// IsActive 检查照片是否可见 +func (p *Photo) IsActive() bool { + return p.Status == PhotoStatusActive +} + +// GetURL 获取照片URL +func (p *Photo) GetURL(baseURL string) string { + return baseURL + "/" + p.FilePath +} + +// GetThumbnailURL 获取缩略图URL +func (p *Photo) GetThumbnailURL(baseURL string) string { + return baseURL + "/thumbnails/" + p.FilePath +} +``` + +## 🔄 数据传输对象 + +### 用户 DTO +```go +// dto/user_dto.go - 用户数据传输对象 +package dto + +import ( + "time" + "photography-backend/internal/model/entity" +) + +// 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"` + FirstName string `json:"first_name" binding:"max=50"` + LastName string `json:"last_name" binding:"max=50"` +} + +// UpdateUserRequest 更新用户请求 +type UpdateUserRequest struct { + Username string `json:"username" binding:"omitempty,min=3,max=50"` + Email string `json:"email" binding:"omitempty,email"` + FirstName string `json:"first_name" binding:"max=50"` + LastName string `json:"last_name" binding:"max=50"` + Avatar string `json:"avatar" binding:"max=255"` + Bio string `json:"bio" binding:"max=500"` +} + +// ChangePasswordRequest 修改密码请求 +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=6"` +} + +// UserResponse 用户响应 +type UserResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Avatar string `json:"avatar"` + Bio string `json:"bio"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UserListResponse 用户列表响应 +type UserListResponse struct { + Users []UserResponse `json:"users"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// ListUsersOptions 用户列表选项 +type ListUsersOptions struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Sort string `form:"sort" binding:"oneof=id username email created_at"` + Order string `form:"order" binding:"oneof=asc desc"` + Status string `form:"status" binding:"oneof=active inactive banned"` + Role string `form:"role" binding:"oneof=admin editor user"` + Search string `form:"search" binding:"max=100"` +} + +// ToUserResponse 转换为用户响应 +func ToUserResponse(user *entity.User) *UserResponse { + return &UserResponse{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + FirstName: user.FirstName, + LastName: user.LastName, + Avatar: user.Avatar, + Bio: user.Bio, + Role: string(user.Role), + Status: string(user.Status), + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} +``` + +### 照片 DTO +```go +// dto/photo_dto.go - 照片数据传输对象 +package dto + +import ( + "time" + "photography-backend/internal/model/entity" +) + +// CreatePhotoRequest 创建照片请求 +type CreatePhotoRequest struct { + Title string `json:"title" binding:"required,max=255"` + Description string `json:"description" binding:"max=1000"` + CategoryIDs []uint `json:"category_ids"` + TagIDs []uint `json:"tag_ids"` + AlbumIDs []uint `json:"album_ids"` + Location string `json:"location" binding:"max=255"` + TakenAt *time.Time `json:"taken_at"` +} + +// UpdatePhotoRequest 更新照片请求 +type UpdatePhotoRequest struct { + Title string `json:"title" binding:"omitempty,max=255"` + Description string `json:"description" binding:"max=1000"` + CategoryIDs []uint `json:"category_ids"` + TagIDs []uint `json:"tag_ids"` + AlbumIDs []uint `json:"album_ids"` + Location string `json:"location" binding:"max=255"` + TakenAt *time.Time `json:"taken_at"` + Status string `json:"status" binding:"oneof=active inactive private"` +} + +// PhotoResponse 照片响应 +type PhotoResponse struct { + ID uint `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Filename string `json:"filename"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Width int `json:"width"` + Height int `json:"height"` + URL string `json:"url"` + ThumbnailURL string `json:"thumbnail_url"` + Location string `json:"location"` + TakenAt *time.Time `json:"taken_at"` + Status string `json:"status"` + ViewCount int `json:"view_count"` + UserID uint `json:"user_id"` + User *UserResponse `json:"user,omitempty"` + Categories []CategoryResponse `json:"categories,omitempty"` + Tags []TagResponse `json:"tags,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// PhotoListResponse 照片列表响应 +type PhotoListResponse struct { + Photos []PhotoResponse `json:"photos"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// ListPhotosOptions 照片列表选项 +type ListPhotosOptions struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Sort string `form:"sort" binding:"oneof=id title created_at taken_at view_count"` + Order string `form:"order" binding:"oneof=asc desc"` + Status string `form:"status" binding:"oneof=active inactive private"` + CategoryID uint `form:"category_id"` + TagID uint `form:"tag_id"` + AlbumID uint `form:"album_id"` + UserID uint `form:"user_id"` + Search string `form:"search" binding:"max=100"` +} + +// SearchPhotosOptions 搜索照片选项 +type SearchPhotosOptions struct { + ListPhotosOptions + Query string `form:"query" binding:"required,min=1,max=100"` + Fields []string `form:"fields" binding:"dive,oneof=title description location"` + DateFrom *time.Time `form:"date_from"` + DateTo *time.Time `form:"date_to"` +} + +// ProcessPhotoOptions 处理照片选项 +type ProcessPhotoOptions struct { + Resize bool `json:"resize"` + Width int `json:"width"` + Height int `json:"height"` + Quality int `json:"quality"` + Watermark bool `json:"watermark"` + Thumbnail bool `json:"thumbnail"` +} + +// ToPhotoResponse 转换为照片响应 +func ToPhotoResponse(photo *entity.Photo, baseURL string) *PhotoResponse { + resp := &PhotoResponse{ + ID: photo.ID, + Title: photo.Title, + Description: photo.Description, + Filename: photo.Filename, + FilePath: photo.FilePath, + FileSize: photo.FileSize, + MimeType: photo.MimeType, + Width: photo.Width, + Height: photo.Height, + URL: photo.GetURL(baseURL), + ThumbnailURL: photo.GetThumbnailURL(baseURL), + Location: photo.Location, + TakenAt: photo.TakenAt, + Status: string(photo.Status), + ViewCount: photo.ViewCount, + UserID: photo.UserID, + CreatedAt: photo.CreatedAt, + UpdatedAt: photo.UpdatedAt, + } + + // 加载关联数据 + if photo.User.ID != 0 { + resp.User = ToUserResponse(&photo.User) + } + + if len(photo.Categories) > 0 { + resp.Categories = make([]CategoryResponse, len(photo.Categories)) + for i, category := range photo.Categories { + resp.Categories[i] = *ToCategoryResponse(&category) + } + } + + if len(photo.Tags) > 0 { + resp.Tags = make([]TagResponse, len(photo.Tags)) + for i, tag := range photo.Tags { + resp.Tags[i] = *ToTagResponse(&tag) + } + } + + return resp +} +``` + +## 📊 类型定义 + +### 枚举和常量 +```go +// types/enums.go - 枚举类型定义 +package types + +// SortOrder 排序方向 +type SortOrder string + +const ( + SortOrderAsc SortOrder = "asc" + SortOrderDesc SortOrder = "desc" +) + +// FileType 文件类型 +type FileType string + +const ( + FileTypeImage FileType = "image" + FileTypeVideo FileType = "video" + FileTypeAudio FileType = "audio" + FileTypeDocument FileType = "document" +) + +// MimeType 媒体类型 +const ( + MimeTypeJPEG = "image/jpeg" + MimeTypePNG = "image/png" + MimeTypeGIF = "image/gif" + MimeTypeWebP = "image/webp" +) + +// 分页常量 +const ( + DefaultPage = 1 + DefaultLimit = 20 + MaxLimit = 100 + DefaultSort = "created_at" + DefaultOrder = "desc" +) + +// 文件大小常量 +const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + + MaxFileSize = 10 * MB // 10MB +) +``` + +### 自定义类型 +```go +// types/custom_types.go - 自定义类型 +package types + +import ( + "database/sql/driver" + "encoding/json" + "errors" +) + +// JSON 自定义JSON类型 +type JSON map[string]interface{} + +// Value 实现 driver.Valuer 接口 +func (j JSON) Value() (driver.Value, error) { + if j == nil { + return nil, nil + } + return json.Marshal(j) +} + +// Scan 实现 sql.Scanner 接口 +func (j *JSON) Scan(value interface{}) error { + if value == nil { + *j = nil + return nil + } + + bytes, ok := value.([]byte) + if !ok { + return errors.New("failed to scan JSON value") + } + + return json.Unmarshal(bytes, j) +} + +// StringArray 字符串数组类型 +type StringArray []string + +// Value 实现 driver.Valuer 接口 +func (s StringArray) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + return json.Marshal(s) +} + +// Scan 实现 sql.Scanner 接口 +func (s *StringArray) Scan(value interface{}) error { + if value == nil { + *s = nil + return nil + } + + bytes, ok := value.([]byte) + if !ok { + return errors.New("failed to scan StringArray value") + } + + return json.Unmarshal(bytes, s) +} +``` + +## 🎯 验证和转换 + +### 数据验证 +```go +// validation/validators.go - 自定义验证器 +package validation + +import ( + "regexp" + "strings" + + "github.com/go-playground/validator/v10" +) + +// RegisterCustomValidators 注册自定义验证器 +func RegisterCustomValidators(v *validator.Validate) { + v.RegisterValidation("username", validateUsername) + v.RegisterValidation("password", validatePassword) + v.RegisterValidation("phone", validatePhone) + v.RegisterValidation("slug", validateSlug) +} + +// validateUsername 验证用户名 +func validateUsername(fl validator.FieldLevel) bool { + username := fl.Field().String() + + // 3-50个字符,只允许字母、数字、下划线 + matched, _ := regexp.MatchString(`^[a-zA-Z0-9_]{3,50}$`, username) + return matched +} + +// validatePassword 验证密码强度 +func validatePassword(fl validator.FieldLevel) bool { + password := fl.Field().String() + + // 至少6个字符,包含字母和数字 + if len(password) < 6 { + return false + } + + hasLetter := regexp.MustCompile(`[a-zA-Z]`).MatchString(password) + hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) + + return hasLetter && hasNumber +} + +// validatePhone 验证手机号 +func validatePhone(fl validator.FieldLevel) bool { + phone := fl.Field().String() + matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone) + return matched +} + +// validateSlug 验证 URL 友好字符串 +func validateSlug(fl validator.FieldLevel) bool { + slug := fl.Field().String() + matched, _ := regexp.MatchString(`^[a-z0-9]+(?:-[a-z0-9]+)*$`, slug) + return matched +} +``` + +## 💡 最佳实践 + +### 模型设计原则 +1. **职责分离**: 实体、DTO、请求、响应分别定义 +2. **数据验证**: 使用标签进行数据验证 +3. **关联关系**: 合理定义实体间的关联关系 +4. **索引优化**: 为常用查询字段添加索引 +5. **软删除**: 重要数据使用软删除 + +### 性能优化 +1. **延迟加载**: 避免不必要的关联查询 +2. **选择性字段**: 只查询需要的字段 +3. **批量操作**: 使用批量插入和更新 +4. **缓存策略**: 缓存频繁访问的数据 + +### 安全考虑 +1. **敏感字段**: 密码等敏感字段不参与序列化 +2. **输入验证**: 严格验证所有输入数据 +3. **权限控制**: 在模型层实现基础权限检查 +4. **SQL 注入**: 使用 ORM 防止 SQL 注入 + +本模块是数据层的基础,确保模型设计的合理性和一致性是关键。 \ No newline at end of file diff --git a/backend/internal/models/category.go b/backend/internal/models/category.go deleted file mode 100644 index 9ea5275..0000000 --- a/backend/internal/models/category.go +++ /dev/null @@ -1,85 +0,0 @@ -package models - -import ( - "time" - "gorm.io/gorm" -) - -// Category 分类模型 -type Category struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - ParentID *uint `json:"parent_id"` - Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"` - Children []Category `gorm:"foreignKey:ParentID" json:"children,omitempty"` - Color string `gorm:"size:7;default:#3b82f6" json:"color"` - CoverImage string `gorm:"size:500" json:"cover_image"` - Sort int `gorm:"default:0" json:"sort"` - IsActive bool `gorm:"default:true" json:"is_active"` - PhotoCount int `gorm:"-" json:"photo_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} - -// TableName 返回分类表名 -func (Category) TableName() string { - return "categories" -} - -// CreateCategoryRequest 创建分类请求 -type CreateCategoryRequest struct { - Name string `json:"name" binding:"required,max=100"` - Description string `json:"description"` - ParentID *uint `json:"parent_id"` - Color string `json:"color" binding:"omitempty,hexcolor"` - CoverImage string `json:"cover_image" binding:"omitempty,max=500"` - Sort int `json:"sort"` -} - -// UpdateCategoryRequest 更新分类请求 -type UpdateCategoryRequest struct { - Name *string `json:"name" binding:"omitempty,max=100"` - Description *string `json:"description"` - ParentID *uint `json:"parent_id"` - Color *string `json:"color" binding:"omitempty,hexcolor"` - CoverImage *string `json:"cover_image" binding:"omitempty,max=500"` - Sort *int `json:"sort"` - IsActive *bool `json:"is_active"` -} - -// CategoryListParams 分类列表查询参数 -type CategoryListParams struct { - IncludeStats bool `form:"include_stats"` - IncludeTree bool `form:"include_tree"` - ParentID uint `form:"parent_id"` - IsActive bool `form:"is_active"` -} - -// CategoryResponse 分类响应 -type CategoryResponse struct { - *Category -} - -// CategoryTreeNode 分类树节点 -type CategoryTreeNode struct { - ID uint `json:"id"` - Name string `json:"name"` - PhotoCount int `json:"photo_count"` - Children []CategoryTreeNode `json:"children"` -} - -// CategoryListResponse 分类列表响应 -type CategoryListResponse struct { - Categories []CategoryResponse `json:"categories"` - Tree []CategoryTreeNode `json:"tree,omitempty"` - Stats *CategoryStats `json:"stats,omitempty"` -} - -// CategoryStats 分类统计 -type CategoryStats struct { - TotalCategories int `json:"total_categories"` - MaxLevel int `json:"max_level"` - FeaturedCount int `json:"featured_count"` -} \ No newline at end of file diff --git a/backend/internal/models/photo.go b/backend/internal/models/photo.go deleted file mode 100644 index c58bad8..0000000 --- a/backend/internal/models/photo.go +++ /dev/null @@ -1,99 +0,0 @@ -package models - -import ( - "time" - "gorm.io/gorm" -) - -// Photo 照片模型 -type Photo struct { - ID uint `gorm:"primaryKey" json:"id"` - Title string `gorm:"size:255;not null" json:"title"` - Description string `gorm:"type:text" json:"description"` - Filename string `gorm:"size:255;not null" json:"filename"` - FilePath string `gorm:"size:500;not null" json:"file_path"` - FileSize int64 `json:"file_size"` - MimeType string `gorm:"size:100" json:"mime_type"` - Width int `json:"width"` - Height int `json:"height"` - CategoryID uint `json:"category_id"` - Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` - Tags []Tag `gorm:"many2many:photo_tags;" json:"tags,omitempty"` - EXIF string `gorm:"type:jsonb" json:"exif"` - TakenAt *time.Time `json:"taken_at"` - Location string `gorm:"size:255" json:"location"` - IsPublic bool `gorm:"default:true" json:"is_public"` - Status string `gorm:"size:20;default:draft" json:"status"` - ViewCount int `gorm:"default:0" json:"view_count"` - LikeCount int `gorm:"default:0" json:"like_count"` - UserID uint `gorm:"not null" json:"user_id"` - User *User `gorm:"foreignKey:UserID" json:"user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} - -// TableName 返回照片表名 -func (Photo) TableName() string { - return "photos" -} - -// PhotoStatus 照片状态常量 -const ( - StatusDraft = "draft" - StatusPublished = "published" - StatusArchived = "archived" -) - -// CreatePhotoRequest 创建照片请求 -type CreatePhotoRequest struct { - Title string `json:"title" binding:"required,max=255"` - Description string `json:"description"` - CategoryID uint `json:"category_id" binding:"required"` - TagIDs []uint `json:"tag_ids"` - TakenAt *time.Time `json:"taken_at"` - Location string `json:"location" binding:"max=255"` - IsPublic *bool `json:"is_public"` - Status string `json:"status" binding:"omitempty,oneof=draft published archived"` -} - -// UpdatePhotoRequest 更新照片请求 -type UpdatePhotoRequest struct { - Title *string `json:"title" binding:"omitempty,max=255"` - Description *string `json:"description"` - CategoryID *uint `json:"category_id"` - TagIDs []uint `json:"tag_ids"` - TakenAt *time.Time `json:"taken_at"` - Location *string `json:"location" binding:"omitempty,max=255"` - IsPublic *bool `json:"is_public"` - Status *string `json:"status" binding:"omitempty,oneof=draft published archived"` -} - -// PhotoListParams 照片列表查询参数 -type PhotoListParams struct { - Page int `form:"page,default=1" binding:"min=1"` - Limit int `form:"limit,default=20" binding:"min=1,max=100"` - CategoryID uint `form:"category_id"` - TagID uint `form:"tag_id"` - UserID uint `form:"user_id"` - Status string `form:"status" binding:"omitempty,oneof=draft published archived"` - Search string `form:"search"` - SortBy string `form:"sort_by,default=created_at" binding:"omitempty,oneof=created_at taken_at title view_count like_count"` - SortOrder string `form:"sort_order,default=desc" binding:"omitempty,oneof=asc desc"` - Year int `form:"year"` - Month int `form:"month" binding:"min=1,max=12"` -} - -// PhotoResponse 照片响应 -type PhotoResponse struct { - *Photo - ThumbnailURLs map[string]string `json:"thumbnail_urls,omitempty"` -} - -// PhotoListResponse 照片列表响应 -type PhotoListResponse struct { - Photos []PhotoResponse `json:"photos"` - Total int64 `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` -} \ No newline at end of file diff --git a/backend/internal/models/requests.go b/backend/internal/models/requests.go deleted file mode 100644 index 2053047..0000000 --- a/backend/internal/models/requests.go +++ /dev/null @@ -1,242 +0,0 @@ -package models - -import "time" - -// 通用请求和响应结构 - -// ErrorResponse 错误响应 -type ErrorResponse struct { - Error string `json:"error"` - Message string `json:"message"` -} - -// SuccessResponse 成功响应 -type SuccessResponse struct { - Message string `json:"message"` -} - -// BatchDeleteRequest 批量删除请求 -type BatchDeleteRequest struct { - IDs []uint `json:"ids" binding:"required,min=1"` -} - -// GenerateSlugRequest 生成slug请求 -type GenerateSlugRequest struct { - Name string `json:"name" binding:"required"` -} - -// GenerateSlugResponse 生成slug响应 -type GenerateSlugResponse struct { - Slug string `json:"slug"` -} - -// 照片相关请求 - -// CreatePhotoRequest 创建照片请求 -type CreatePhotoRequest struct { - Title string `json:"title" binding:"required"` - Description string `json:"description"` - OriginalFilename string `json:"original_filename"` - FileSize int64 `json:"file_size"` - Status string `json:"status" binding:"oneof=draft published archived processing"` - CategoryIDs []uint `json:"category_ids"` - TagIDs []uint `json:"tag_ids"` - Camera string `json:"camera"` - Lens string `json:"lens"` - ISO int `json:"iso"` - Aperture string `json:"aperture"` - ShutterSpeed string `json:"shutter_speed"` - FocalLength string `json:"focal_length"` - TakenAt *time.Time `json:"taken_at"` -} - -// UpdatePhotoRequest 更新照片请求 -type UpdatePhotoRequest struct { - Title *string `json:"title"` - Description *string `json:"description"` - Status *string `json:"status" binding:"omitempty,oneof=draft published archived processing"` - CategoryIDs *[]uint `json:"category_ids"` - TagIDs *[]uint `json:"tag_ids"` - Camera *string `json:"camera"` - Lens *string `json:"lens"` - ISO *int `json:"iso"` - Aperture *string `json:"aperture"` - ShutterSpeed *string `json:"shutter_speed"` - FocalLength *string `json:"focal_length"` - TakenAt *time.Time `json:"taken_at"` -} - -// BatchUpdatePhotosRequest 批量更新照片请求 -type BatchUpdatePhotosRequest struct { - IDs []uint `json:"ids" binding:"required,min=1"` - Status *string `json:"status" binding:"omitempty,oneof=draft published archived processing"` - CategoryIDs *[]uint `json:"category_ids"` - TagIDs *[]uint `json:"tag_ids"` -} - -// PhotoStats 照片统计信息 -type PhotoStats struct { - Total int64 `json:"total"` - ThisMonth int64 `json:"this_month"` - Today int64 `json:"today"` - TotalSize int64 `json:"total_size"` - StatusStats map[string]int64 `json:"status_stats"` -} - -// 分类相关请求 - -// CreateCategoryRequest 创建分类请求 -type CreateCategoryRequest struct { - Name string `json:"name" binding:"required"` - Slug string `json:"slug" binding:"required"` - Description string `json:"description"` - ParentID *uint `json:"parent_id"` -} - -// UpdateCategoryRequest 更新分类请求 -type UpdateCategoryRequest struct { - Name *string `json:"name"` - Slug *string `json:"slug"` - Description *string `json:"description"` - ParentID *uint `json:"parent_id"` - 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"` -} - -// CategoryStats 分类统计信息 -type CategoryStats struct { - Total int64 `json:"total"` - Active int64 `json:"active"` - TopLevel int64 `json:"top_level"` - PhotoCounts map[string]int64 `json:"photo_counts"` -} - -// 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"` - 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"` -} - -// 标签相关请求 - -// CreateTagRequest 创建标签请求 -type CreateTagRequest struct { - Name string `json:"name" binding:"required"` - Slug string `json:"slug" binding:"required"` - Description string `json:"description"` - Color string `json:"color"` -} - -// UpdateTagRequest 更新标签请求 -type UpdateTagRequest struct { - Name *string `json:"name"` - Slug *string `json:"slug"` - Description *string `json:"description"` - Color *string `json:"color"` - IsActive *bool `json:"is_active"` -} - -// 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"` -} - -// 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"` -} - -// 用户相关请求 - -// 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=8"` - Role string `json:"role" binding:"oneof=user editor admin"` -} - -// UpdateUserRequest 更新用户请求 -type UpdateUserRequest struct { - Username *string `json:"username" binding:"omitempty,min=3,max=50"` - Email *string `json:"email" binding:"omitempty,email"` - Role *string `json:"role" binding:"omitempty,oneof=user editor admin"` - 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"` -} - -// ChangePasswordRequest 修改密码请求 -type ChangePasswordRequest struct { - OldPassword string `json:"old_password" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=8"` -} - -// LoginRequest 登录请求 -type LoginRequest struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` -} - -// LoginResponse 登录响应 -type LoginResponse struct { - User *UserResponse `json:"user"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` -} - -// RefreshTokenRequest 刷新token请求 -type RefreshTokenRequest struct { - RefreshToken string `json:"refresh_token" binding:"required"` -} - -// RefreshTokenResponse 刷新token响应 -type RefreshTokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int64 `json:"expires_in"` -} - -// UserResponse 用户响应(隐藏敏感信息) -type UserResponse struct { - ID uint `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Role string `json:"role"` - IsActive bool `json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} \ No newline at end of file diff --git a/backend/internal/models/tag.go b/backend/internal/models/tag.go deleted file mode 100644 index b7b65fc..0000000 --- a/backend/internal/models/tag.go +++ /dev/null @@ -1,95 +0,0 @@ -package models - -import ( - "time" - "gorm.io/gorm" -) - -// Tag 标签模型 -type Tag struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:50;not null;unique" json:"name"` - Color string `gorm:"size:7;default:#6b7280" json:"color"` - UseCount int `gorm:"default:0" json:"use_count"` - IsActive bool `gorm:"default:true" json:"is_active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} - -// TableName 返回标签表名 -func (Tag) TableName() string { - return "tags" -} - -// CreateTagRequest 创建标签请求 -type CreateTagRequest struct { - Name string `json:"name" binding:"required,max=50"` - Color string `json:"color" binding:"omitempty,hexcolor"` -} - -// UpdateTagRequest 更新标签请求 -type UpdateTagRequest struct { - Name *string `json:"name" binding:"omitempty,max=50"` - Color *string `json:"color" binding:"omitempty,hexcolor"` - IsActive *bool `json:"is_active"` -} - -// TagListParams 标签列表查询参数 -type TagListParams struct { - Page int `form:"page,default=1" binding:"min=1"` - Limit int `form:"limit,default=50" binding:"min=1,max=100"` - Search string `form:"search"` - SortBy string `form:"sort_by,default=use_count" binding:"omitempty,oneof=use_count name created_at"` - SortOrder string `form:"sort_order,default=desc" binding:"omitempty,oneof=asc desc"` - IsActive bool `form:"is_active"` -} - -// TagSuggestionsParams 标签建议查询参数 -type TagSuggestionsParams struct { - Query string `form:"q" binding:"required"` - Limit int `form:"limit,default=10" binding:"min=1,max=20"` -} - -// TagResponse 标签响应 -type TagResponse struct { - *Tag - MatchScore float64 `json:"match_score,omitempty"` -} - -// TagListResponse 标签列表响应 -type TagListResponse struct { - Tags []TagResponse `json:"tags"` - Total int64 `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` - Groups *TagGroups `json:"groups,omitempty"` -} - -// TagGroups 标签分组 -type TagGroups struct { - Style TagGroup `json:"style"` - Subject TagGroup `json:"subject"` - Technique TagGroup `json:"technique"` - Location TagGroup `json:"location"` -} - -// TagGroup 标签组 -type TagGroup struct { - Name string `json:"name"` - Count int `json:"count"` -} - -// TagCloudItem 标签云项目 -type TagCloudItem struct { - ID uint `json:"id"` - Name string `json:"name"` - UseCount int `json:"use_count"` - RelativeSize int `json:"relative_size"` - Color string `json:"color"` -} - -// TagCloudResponse 标签云响应 -type TagCloudResponse struct { - Tags []TagCloudItem `json:"tags"` -} \ No newline at end of file diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go deleted file mode 100644 index 466e6cd..0000000 --- a/backend/internal/models/user.go +++ /dev/null @@ -1,76 +0,0 @@ -package models - -import ( - "time" - "gorm.io/gorm" -) - -// User 用户模型 -type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"size:50;not null;unique" json:"username"` - Email string `gorm:"size:100;not null;unique" json:"email"` - Password string `gorm:"size:255;not null" json:"-"` - Name string `gorm:"size:100" json:"name"` - Avatar string `gorm:"size:500" json:"avatar"` - Role string `gorm:"size:20;default:user" json:"role"` - IsActive bool `gorm:"default:true" json:"is_active"` - LastLogin *time.Time `json:"last_login"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` -} - -// TableName 返回用户表名 -func (User) TableName() string { - return "users" -} - -// UserRole 用户角色常量 -const ( - RoleUser = "user" - RoleEditor = "editor" - RoleAdmin = "admin" -) - -// 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 string `json:"role" binding:"omitempty,oneof=user editor admin"` -} - -// UpdateUserRequest 更新用户请求 -type UpdateUserRequest struct { - Name *string `json:"name" binding:"omitempty,max=100"` - Avatar *string `json:"avatar" binding:"omitempty,max=500"` - IsActive *bool `json:"is_active"` -} - -// UpdatePasswordRequest 更新密码请求 -type UpdatePasswordRequest struct { - OldPassword string `json:"old_password" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=6"` -} - -// LoginRequest 登录请求 -type LoginRequest struct { - Username string `json:"username" binding:"required"` - Password string `json:"password" binding:"required"` -} - -// LoginResponse 登录响应 -type LoginResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - User *User `json:"user"` -} - -// RefreshTokenRequest 刷新令牌请求 -type RefreshTokenRequest struct { - RefreshToken string `json:"refresh_token" binding:"required"` -} \ No newline at end of file diff --git a/backend/internal/repository/CLAUDE.md b/backend/internal/repository/CLAUDE.md new file mode 100644 index 0000000..d982b22 --- /dev/null +++ b/backend/internal/repository/CLAUDE.md @@ -0,0 +1,873 @@ +# Repository Layer - CLAUDE.md + +本文件为 Claude Code 在数据访问层中工作时提供指导。 + +## 🎯 模块概览 + +Repository 层负责数据访问逻辑,提供数据库操作的抽象接口,隔离业务逻辑与数据存储细节。 + +### 主要职责 +- 🔧 提供数据库操作接口 +- 📊 实现 CRUD 操作 +- 🔍 提供复杂查询支持 +- 💾 管理数据库连接和事务 +- 🚀 优化查询性能 +- 🔄 支持多种数据源 + +## 📁 模块结构 + +``` +internal/repository/ +├── CLAUDE.md # 📋 当前文件 - 数据访问开发指导 +├── interfaces/ # 🔗 仓储接口定义 +│ ├── user_repository.go # 用户仓储接口 +│ ├── photo_repository.go # 照片仓储接口 +│ ├── category_repository.go # 分类仓储接口 +│ ├── tag_repository.go # 标签仓储接口 +│ └── album_repository.go # 相册仓储接口 +├── postgres/ # 🐘 PostgreSQL 实现 +│ ├── user_repository.go # 用户仓储实现 +│ ├── photo_repository.go # 照片仓储实现 +│ ├── category_repository.go # 分类仓储实现 +│ ├── tag_repository.go # 标签仓储实现 +│ ├── album_repository.go # 相册仓储实现 +│ └── base_repository.go # 基础仓储实现 +├── redis/ # 🟥 Redis 缓存实现 +│ ├── user_cache.go # 用户缓存 +│ ├── photo_cache.go # 照片缓存 +│ └── cache_manager.go # 缓存管理器 +├── sqlite/ # 📦 SQLite 实现(开发用) +│ ├── user_repository.go # 用户仓储实现 +│ ├── photo_repository.go # 照片仓储实现 +│ └── base_repository.go # 基础仓储实现 +├── mocks/ # 🧪 模拟对象(测试用) +│ ├── user_repository_mock.go # 用户仓储模拟 +│ ├── photo_repository_mock.go # 照片仓储模拟 +│ └── generate.go # 模拟对象生成 +└── errors.go # 📝 仓储层错误定义 +``` + +## 🔗 接口设计 + +### 基础仓储接口 +```go +// interfaces/base_repository.go - 基础仓储接口 +package interfaces + +import ( + "context" + "gorm.io/gorm" +) + +// BaseRepositoryr 基础仓储接口 +type BaseRepositoryr[T any] interface { + // 基础 CRUD 操作 + Create(ctx context.Context, entity *T) (*T, error) + GetByID(ctx context.Context, id uint) (*T, error) + Update(ctx context.Context, entity *T) (*T, error) + Delete(ctx context.Context, id uint) error + + // 批量操作 + CreateBatch(ctx context.Context, entities []*T) error + UpdateBatch(ctx context.Context, entities []*T) error + DeleteBatch(ctx context.Context, ids []uint) error + + // 查询操作 + List(ctx context.Context, opts *ListOptions) ([]*T, int64, error) + Count(ctx context.Context, conditions map[string]interface{}) (int64, error) + Exists(ctx context.Context, conditions map[string]interface{}) (bool, error) + + // 事务操作 + WithTx(tx *gorm.DB) BaseRepositoryr[T] + Transaction(ctx context.Context, fn func(repo BaseRepositoryr[T]) error) error +} + +// ListOptions 查询选项 +type ListOptions struct { + Page int `json:"page"` + Limit int `json:"limit"` + Sort string `json:"sort"` + Order string `json:"order"` + Conditions map[string]interface{} `json:"conditions"` + Preloads []string `json:"preloads"` +} +``` + +### 用户仓储接口 +```go +// interfaces/user_repository.go - 用户仓储接口 +package interfaces + +import ( + "context" + "photography-backend/internal/model/entity" +) + +// UserRepositoryr 用户仓储接口 +type UserRepositoryr interface { + BaseRepositoryr[entity.User] + + // 用户特定查询 + GetByEmail(ctx context.Context, email string) (*entity.User, error) + GetByUsername(ctx context.Context, username string) (*entity.User, error) + GetByEmailOrUsername(ctx context.Context, emailOrUsername string) (*entity.User, error) + + // 用户列表查询 + ListByRole(ctx context.Context, role entity.UserRole, opts *ListOptions) ([]*entity.User, int64, error) + ListByStatus(ctx context.Context, status entity.UserStatus, opts *ListOptions) ([]*entity.User, int64, error) + SearchUsers(ctx context.Context, keyword string, opts *ListOptions) ([]*entity.User, int64, error) + + // 用户统计 + CountByRole(ctx context.Context, role entity.UserRole) (int64, error) + CountByStatus(ctx context.Context, status entity.UserStatus) (int64, error) + CountActiveUsers(ctx context.Context) (int64, error) + + // 用户状态更新 + UpdateStatus(ctx context.Context, id uint, status entity.UserStatus) error + UpdateLastLogin(ctx context.Context, id uint) error + + // 密码相关 + UpdatePassword(ctx context.Context, id uint, hashedPassword string) error + + // 软删除恢复 + Restore(ctx context.Context, id uint) error +} +``` + +### 照片仓储接口 +```go +// interfaces/photo_repository.go - 照片仓储接口 +package interfaces + +import ( + "context" + "time" + "photography-backend/internal/model/entity" +) + +// PhotoRepositoryr 照片仓储接口 +type PhotoRepositoryr interface { + BaseRepositoryr[entity.Photo] + + // 照片查询 + GetByFilename(ctx context.Context, filename string) (*entity.Photo, error) + GetByUserID(ctx context.Context, userID uint) ([]*entity.Photo, error) + ListByUserID(ctx context.Context, userID uint, opts *ListOptions) ([]*entity.Photo, int64, error) + ListByStatus(ctx context.Context, status entity.PhotoStatus, opts *ListOptions) ([]*entity.Photo, int64, error) + + // 分类和标签查询 + ListByCategory(ctx context.Context, categoryID uint, opts *ListOptions) ([]*entity.Photo, int64, error) + ListByTag(ctx context.Context, tagID uint, opts *ListOptions) ([]*entity.Photo, int64, error) + ListByAlbum(ctx context.Context, albumID uint, opts *ListOptions) ([]*entity.Photo, int64, error) + + // 搜索功能 + SearchPhotos(ctx context.Context, keyword string, opts *SearchOptions) ([]*entity.Photo, int64, error) + SearchByMetadata(ctx context.Context, metadata map[string]interface{}, opts *ListOptions) ([]*entity.Photo, int64, error) + + // 时间范围查询 + ListByDateRange(ctx context.Context, startDate, endDate time.Time, opts *ListOptions) ([]*entity.Photo, int64, error) + ListByCreatedDateRange(ctx context.Context, startDate, endDate time.Time, opts *ListOptions) ([]*entity.Photo, int64, error) + + // 统计查询 + CountByUser(ctx context.Context, userID uint) (int64, error) + CountByStatus(ctx context.Context, status entity.PhotoStatus) (int64, error) + CountByCategory(ctx context.Context, categoryID uint) (int64, error) + CountByTag(ctx context.Context, tagID uint) (int64, error) + + // 关联操作 + AddCategories(ctx context.Context, photoID uint, categoryIDs []uint) error + RemoveCategories(ctx context.Context, photoID uint, categoryIDs []uint) error + AddTags(ctx context.Context, photoID uint, tagIDs []uint) error + RemoveTags(ctx context.Context, photoID uint, tagIDs []uint) error + + // 统计更新 + IncrementViewCount(ctx context.Context, id uint) error + IncrementDownloadCount(ctx context.Context, id uint) error + + // 批量操作 + BatchUpdateStatus(ctx context.Context, ids []uint, status entity.PhotoStatus) error + BatchDelete(ctx context.Context, ids []uint) error +} + +// SearchOptions 搜索选项 +type SearchOptions struct { + ListOptions + Fields []string `json:"fields"` + DateFrom *time.Time `json:"date_from"` + DateTo *time.Time `json:"date_to"` + MinWidth int `json:"min_width"` + MaxWidth int `json:"max_width"` + MinHeight int `json:"min_height"` + MaxHeight int `json:"max_height"` +} +``` + +## 🔧 仓储实现 + +### 基础仓储实现 +```go +// postgres/base_repository.go - 基础仓储实现 +package postgres + +import ( + "context" + "errors" + "fmt" + "reflect" + + "gorm.io/gorm" + "go.uber.org/zap" + + "photography-backend/internal/repository/interfaces" + "photography-backend/pkg/logger" +) + +// BaseRepository 基础仓储实现 +type BaseRepository[T any] struct { + db *gorm.DB + logger logger.Logger +} + +// NewBaseRepository 创建基础仓储 +func NewBaseRepository[T any](db *gorm.DB, logger logger.Logger) *BaseRepository[T] { + return &BaseRepository[T]{ + db: db, + logger: logger, + } +} + +// Create 创建记录 +func (r *BaseRepository[T]) Create(ctx context.Context, entity *T) (*T, error) { + if err := r.db.WithContext(ctx).Create(entity).Error; err != nil { + r.logger.Error("failed to create entity", zap.Error(err)) + return nil, err + } + return entity, nil +} + +// GetByID 根据ID获取记录 +func (r *BaseRepository[T]) GetByID(ctx context.Context, id uint) (*T, error) { + var entity T + if err := r.db.WithContext(ctx).First(&entity, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrNotFound + } + r.logger.Error("failed to get entity by id", zap.Error(err), zap.Uint("id", id)) + return nil, err + } + return &entity, nil +} + +// Update 更新记录 +func (r *BaseRepository[T]) Update(ctx context.Context, entity *T) (*T, error) { + if err := r.db.WithContext(ctx).Save(entity).Error; err != nil { + r.logger.Error("failed to update entity", zap.Error(err)) + return nil, err + } + return entity, nil +} + +// Delete 删除记录 +func (r *BaseRepository[T]) Delete(ctx context.Context, id uint) error { + var entity T + if err := r.db.WithContext(ctx).Delete(&entity, id).Error; err != nil { + r.logger.Error("failed to delete entity", zap.Error(err), zap.Uint("id", id)) + return err + } + return nil +} + +// List 列表查询 +func (r *BaseRepository[T]) List(ctx context.Context, opts *interfaces.ListOptions) ([]*T, int64, error) { + var entities []*T + var total int64 + + db := r.db.WithContext(ctx) + + // 应用条件 + if opts.Conditions != nil { + for key, value := range opts.Conditions { + db = db.Where(key, value) + } + } + + // 获取总数 + if err := db.Model(new(T)).Count(&total).Error; err != nil { + r.logger.Error("failed to count entities", zap.Error(err)) + return nil, 0, err + } + + // 应用排序 + if opts.Sort != "" { + order := "ASC" + if opts.Order == "desc" { + order = "DESC" + } + db = db.Order(fmt.Sprintf("%s %s", opts.Sort, order)) + } + + // 应用分页 + if opts.Page > 0 && opts.Limit > 0 { + offset := (opts.Page - 1) * opts.Limit + db = db.Offset(offset).Limit(opts.Limit) + } + + // 应用预加载 + if opts.Preloads != nil { + for _, preload := range opts.Preloads { + db = db.Preload(preload) + } + } + + // 查询数据 + if err := db.Find(&entities).Error; err != nil { + r.logger.Error("failed to list entities", zap.Error(err)) + return nil, 0, err + } + + return entities, total, nil +} + +// Transaction 事务执行 +func (r *BaseRepository[T]) Transaction(ctx context.Context, fn func(repo interfaces.BaseRepositoryr[T]) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repo := &BaseRepository[T]{ + db: tx, + logger: r.logger, + } + return fn(repo) + }) +} + +// WithTx 使用事务 +func (r *BaseRepository[T]) WithTx(tx *gorm.DB) interfaces.BaseRepositoryr[T] { + return &BaseRepository[T]{ + db: tx, + logger: r.logger, + } +} +``` + +### 用户仓储实现 +```go +// postgres/user_repository.go - 用户仓储实现 +package postgres + +import ( + "context" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "go.uber.org/zap" + + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/interfaces" + "photography-backend/pkg/logger" +) + +// UserRepository 用户仓储实现 +type UserRepository struct { + *BaseRepository[entity.User] + db *gorm.DB + logger logger.Logger +} + +// NewUserRepository 创建用户仓储 +func NewUserRepository(db *gorm.DB, logger logger.Logger) interfaces.UserRepositoryr { + return &UserRepository{ + BaseRepository: NewBaseRepository[entity.User](db, logger), + db: db, + logger: logger, + } +} + +// GetByEmail 根据邮箱获取用户 +func (r *UserRepository) 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, ErrNotFound + } + r.logger.Error("failed to get user by email", zap.Error(err), zap.String("email", email)) + return nil, err + } + return &user, nil +} + +// GetByUsername 根据用户名获取用户 +func (r *UserRepository) 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, ErrNotFound + } + r.logger.Error("failed to get user by username", zap.Error(err), zap.String("username", username)) + return nil, err + } + return &user, nil +} + +// GetByEmailOrUsername 根据邮箱或用户名获取用户 +func (r *UserRepository) 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, ErrNotFound + } + r.logger.Error("failed to get user by email or username", zap.Error(err), zap.String("emailOrUsername", emailOrUsername)) + return nil, err + } + return &user, nil +} + +// SearchUsers 搜索用户 +func (r *UserRepository) SearchUsers(ctx context.Context, keyword string, opts *interfaces.ListOptions) ([]*entity.User, int64, error) { + var users []*entity.User + var total int64 + + db := r.db.WithContext(ctx) + + // 搜索条件 + searchCondition := fmt.Sprintf("username ILIKE %s OR email ILIKE %s OR first_name ILIKE %s OR last_name ILIKE %s", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + db = db.Where(searchCondition) + + // 应用其他条件 + if opts.Conditions != nil { + for key, value := range opts.Conditions { + db = db.Where(key, value) + } + } + + // 获取总数 + if err := db.Model(&entity.User{}).Count(&total).Error; err != nil { + r.logger.Error("failed to count users", zap.Error(err)) + return nil, 0, err + } + + // 应用排序和分页 + if opts.Sort != "" { + order := "ASC" + if opts.Order == "desc" { + order = "DESC" + } + db = db.Order(fmt.Sprintf("%s %s", opts.Sort, order)) + } + + if opts.Page > 0 && opts.Limit > 0 { + offset := (opts.Page - 1) * opts.Limit + db = db.Offset(offset).Limit(opts.Limit) + } + + // 查询数据 + if err := db.Find(&users).Error; err != nil { + r.logger.Error("failed to search users", zap.Error(err)) + return nil, 0, err + } + + return users, total, nil +} + +// UpdateStatus 更新用户状态 +func (r *UserRepository) UpdateStatus(ctx context.Context, id uint, status entity.UserStatus) error { + err := r.db.WithContext(ctx).Model(&entity.User{}).Where("id = ?", id).Update("status", status).Error + if err != nil { + r.logger.Error("failed to update user status", zap.Error(err), zap.Uint("id", id), zap.String("status", string(status))) + return err + } + return nil +} + +// UpdateLastLogin 更新最后登录时间 +func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uint) error { + now := time.Now() + err := r.db.WithContext(ctx).Model(&entity.User{}).Where("id = ?", id).Update("last_login_at", now).Error + if err != nil { + r.logger.Error("failed to update last login time", zap.Error(err), zap.Uint("id", id)) + return err + } + return nil +} + +// UpdatePassword 更新密码 +func (r *UserRepository) UpdatePassword(ctx context.Context, id uint, hashedPassword string) error { + err := r.db.WithContext(ctx).Model(&entity.User{}).Where("id = ?", id).Update("password", hashedPassword).Error + if err != nil { + r.logger.Error("failed to update password", zap.Error(err), zap.Uint("id", id)) + return err + } + return nil +} + +// CountByRole 按角色统计用户数 +func (r *UserRepository) 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 + if err != nil { + r.logger.Error("failed to count users by role", zap.Error(err), zap.String("role", string(role))) + return 0, err + } + return count, nil +} + +// CountActiveUsers 统计活跃用户数 +func (r *UserRepository) CountActiveUsers(ctx context.Context) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&entity.User{}).Where("status = ?", entity.UserStatusActive).Count(&count).Error + if err != nil { + r.logger.Error("failed to count active users", zap.Error(err)) + return 0, err + } + return count, nil +} +``` + +## 💾 Redis 缓存实现 + +### 缓存管理器 +```go +// redis/cache_manager.go - 缓存管理器 +package redis + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/go-redis/redis/v8" + "go.uber.org/zap" + + "photography-backend/pkg/logger" +) + +// CacheManager 缓存管理器 +type CacheManager struct { + client *redis.Client + logger logger.Logger +} + +// NewCacheManager 创建缓存管理器 +func NewCacheManager(client *redis.Client, logger logger.Logger) *CacheManager { + return &CacheManager{ + client: client, + logger: logger, + } +} + +// Set 设置缓存 +func (cm *CacheManager) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error { + data, err := json.Marshal(value) + if err != nil { + cm.logger.Error("failed to marshal cache value", zap.Error(err), zap.String("key", key)) + return err + } + + err = cm.client.Set(ctx, key, data, ttl).Err() + if err != nil { + cm.logger.Error("failed to set cache", zap.Error(err), zap.String("key", key)) + return err + } + + return nil +} + +// Get 获取缓存 +func (cm *CacheManager) Get(ctx context.Context, key string, dest interface{}) error { + data, err := cm.client.Get(ctx, key).Result() + if err != nil { + if err == redis.Nil { + return ErrCacheNotFound + } + cm.logger.Error("failed to get cache", zap.Error(err), zap.String("key", key)) + return err + } + + err = json.Unmarshal([]byte(data), dest) + if err != nil { + cm.logger.Error("failed to unmarshal cache value", zap.Error(err), zap.String("key", key)) + return err + } + + return nil +} + +// Delete 删除缓存 +func (cm *CacheManager) Delete(ctx context.Context, key string) error { + err := cm.client.Del(ctx, key).Err() + if err != nil { + cm.logger.Error("failed to delete cache", zap.Error(err), zap.String("key", key)) + return err + } + return nil +} + +// DeletePattern 批量删除缓存 +func (cm *CacheManager) DeletePattern(ctx context.Context, pattern string) error { + keys, err := cm.client.Keys(ctx, pattern).Result() + if err != nil { + cm.logger.Error("failed to get keys by pattern", zap.Error(err), zap.String("pattern", pattern)) + return err + } + + if len(keys) > 0 { + err = cm.client.Del(ctx, keys...).Err() + if err != nil { + cm.logger.Error("failed to delete keys by pattern", zap.Error(err), zap.String("pattern", pattern)) + return err + } + } + + return nil +} + +// Exists 检查缓存是否存在 +func (cm *CacheManager) Exists(ctx context.Context, key string) (bool, error) { + count, err := cm.client.Exists(ctx, key).Result() + if err != nil { + cm.logger.Error("failed to check cache existence", zap.Error(err), zap.String("key", key)) + return false, err + } + return count > 0, nil +} + +// SetTTL 设置过期时间 +func (cm *CacheManager) SetTTL(ctx context.Context, key string, ttl time.Duration) error { + err := cm.client.Expire(ctx, key, ttl).Err() + if err != nil { + cm.logger.Error("failed to set cache ttl", zap.Error(err), zap.String("key", key)) + return err + } + return nil +} +``` + +### 用户缓存 +```go +// redis/user_cache.go - 用户缓存 +package redis + +import ( + "context" + "fmt" + "time" + + "photography-backend/internal/model/entity" + "photography-backend/pkg/logger" +) + +// UserCache 用户缓存 +type UserCache struct { + *CacheManager + ttl time.Duration +} + +// NewUserCache 创建用户缓存 +func NewUserCache(cm *CacheManager, ttl time.Duration) *UserCache { + return &UserCache{ + CacheManager: cm, + ttl: ttl, + } +} + +// SetUser 缓存用户 +func (uc *UserCache) SetUser(ctx context.Context, user *entity.User) error { + key := fmt.Sprintf("user:id:%d", user.ID) + return uc.Set(ctx, key, user, uc.ttl) +} + +// GetUser 获取用户缓存 +func (uc *UserCache) GetUser(ctx context.Context, id uint) (*entity.User, error) { + key := fmt.Sprintf("user:id:%d", id) + var user entity.User + err := uc.Get(ctx, key, &user) + if err != nil { + return nil, err + } + return &user, nil +} + +// SetUserByEmail 按邮箱缓存用户 +func (uc *UserCache) SetUserByEmail(ctx context.Context, user *entity.User) error { + key := fmt.Sprintf("user:email:%s", user.Email) + return uc.Set(ctx, key, user, uc.ttl) +} + +// GetUserByEmail 按邮箱获取用户缓存 +func (uc *UserCache) GetUserByEmail(ctx context.Context, email string) (*entity.User, error) { + key := fmt.Sprintf("user:email:%s", email) + var user entity.User + err := uc.Get(ctx, key, &user) + if err != nil { + return nil, err + } + return &user, nil +} + +// DeleteUser 删除用户缓存 +func (uc *UserCache) DeleteUser(ctx context.Context, id uint) error { + key := fmt.Sprintf("user:id:%d", id) + return uc.Delete(ctx, key) +} + +// DeleteUserByEmail 按邮箱删除用户缓存 +func (uc *UserCache) DeleteUserByEmail(ctx context.Context, email string) error { + key := fmt.Sprintf("user:email:%s", email) + return uc.Delete(ctx, key) +} + +// InvalidateUserCache 失效用户相关缓存 +func (uc *UserCache) InvalidateUserCache(ctx context.Context, userID uint) error { + pattern := fmt.Sprintf("user:*:%d", userID) + return uc.DeletePattern(ctx, pattern) +} +``` + +## 🔍 错误处理 + +### 仓储层错误定义 +```go +// errors.go - 仓储层错误定义 +package repository + +import "errors" + +var ( + // 通用错误 + ErrNotFound = errors.New("record not found") + ErrDuplicateKey = errors.New("duplicate key") + ErrInvalidParameter = errors.New("invalid parameter") + ErrDatabaseError = errors.New("database error") + ErrTransactionError = errors.New("transaction error") + + // 缓存错误 + ErrCacheNotFound = errors.New("cache not found") + ErrCacheError = errors.New("cache error") + ErrCacheExpired = errors.New("cache expired") + + // 连接错误 + ErrConnectionFailed = errors.New("connection failed") + ErrConnectionTimeout = errors.New("connection timeout") +) +``` + +## 🧪 测试 + +### 仓储测试 +```go +// postgres/user_repository_test.go - 用户仓储测试 +package postgres + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "photography-backend/internal/model/entity" + "photography-backend/pkg/logger" +) + +type UserRepositoryTestSuite struct { + suite.Suite + db *gorm.DB + repo *UserRepository +} + +func (suite *UserRepositoryTestSuite) SetupTest() { + // 使用内存数据库进行测试 + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + suite.Require().NoError(err) + + // 自动迁移 + err = db.AutoMigrate(&entity.User{}) + suite.Require().NoError(err) + + suite.db = db + suite.repo = NewUserRepository(db, logger.NewNoop()).(*UserRepository) +} + +func (suite *UserRepositoryTestSuite) TearDownTest() { + sqlDB, _ := suite.db.DB() + sqlDB.Close() +} + +func (suite *UserRepositoryTestSuite) TestCreateUser() { + ctx := context.Background() + + user := &entity.User{ + Username: "testuser", + Email: "test@example.com", + Password: "hashedpassword", + Role: entity.UserRoleUser, + Status: entity.UserStatusActive, + } + + createdUser, err := suite.repo.Create(ctx, user) + + assert.NoError(suite.T(), err) + assert.NotZero(suite.T(), createdUser.ID) + assert.Equal(suite.T(), user.Username, createdUser.Username) + assert.Equal(suite.T(), user.Email, createdUser.Email) +} + +func (suite *UserRepositoryTestSuite) TestGetUserByEmail() { + ctx := context.Background() + + // 创建测试用户 + user := &entity.User{ + Username: "testuser", + Email: "test@example.com", + Password: "hashedpassword", + Role: entity.UserRoleUser, + Status: entity.UserStatusActive, + } + + createdUser, err := suite.repo.Create(ctx, user) + suite.Require().NoError(err) + + // 根据邮箱获取用户 + foundUser, err := suite.repo.GetByEmail(ctx, user.Email) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), createdUser.ID, foundUser.ID) + assert.Equal(suite.T(), createdUser.Email, foundUser.Email) +} + +func TestUserRepositoryTestSuite(t *testing.T) { + suite.Run(t, new(UserRepositoryTestSuite)) +} +``` + +## 💡 最佳实践 + +### 设计原则 +1. **接口隔离**: 定义清晰的仓储接口 +2. **依赖倒置**: 依赖接口而非具体实现 +3. **单一职责**: 每个仓储只负责一个实体 +4. **错误处理**: 统一错误处理和日志记录 +5. **事务支持**: 提供事务操作支持 + +### 性能优化 +1. **查询优化**: 使用适当的索引和查询条件 +2. **批量操作**: 支持批量插入和更新 +3. **缓存策略**: 合理使用缓存减少数据库访问 +4. **连接池**: 使用连接池管理数据库连接 +5. **预加载**: 避免 N+1 查询问题 + +### 测试策略 +1. **单元测试**: 为每个仓储编写单元测试 +2. **集成测试**: 测试数据库交互 +3. **模拟对象**: 使用 Mock 对象进行测试 +4. **测试数据**: 准备充分的测试数据 +5. **性能测试**: 测试查询性能和并发性能 + +本模块是数据访问的核心,确保数据操作的正确性和性能是关键。 \ No newline at end of file diff --git a/backend/internal/repository/postgres/category_repository.go b/backend/internal/repository/postgres/category_repository.go index f2cda77..a92e5fb 100644 --- a/backend/internal/repository/postgres/category_repository.go +++ b/backend/internal/repository/postgres/category_repository.go @@ -142,7 +142,7 @@ func (r *categoryRepository) GetTree() ([]*models.Category, error) { // 第一次遍历:建立映射 for _, category := range categories { categoryMap[category.ID] = category - category.Children = []*models.Category{} + category.Children = []models.Category{} } // 第二次遍历:构建树形结构 @@ -151,7 +151,7 @@ func (r *categoryRepository) GetTree() ([]*models.Category, error) { rootCategories = append(rootCategories, category) } else { if parent, exists := categoryMap[*category.ParentID]; exists { - parent.Children = append(parent.Children, category) + parent.Children = append(parent.Children, *category) } } } @@ -177,9 +177,11 @@ func (r *categoryRepository) GetStats() (*models.CategoryStats, error) { var stats models.CategoryStats // 总分类数 - if err := r.db.Model(&models.Category{}).Count(&stats.TotalCategories).Error; err != nil { + 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) // 计算最大层级 // 这里简化处理,实际应用中可能需要递归查询 diff --git a/backend/internal/service/CLAUDE.md b/backend/internal/service/CLAUDE.md new file mode 100644 index 0000000..1d9b717 --- /dev/null +++ b/backend/internal/service/CLAUDE.md @@ -0,0 +1,549 @@ +# Service Layer - CLAUDE.md + +本文件为 Claude Code 在业务逻辑层中工作时提供指导。 + +## 🎯 模块概览 + +Service 层是业务逻辑的核心,负责处理业务规则、数据转换和服务编排。 + +### 主要职责 +- 📋 业务逻辑处理和规则验证 +- 🔄 数据转换和格式化 +- 🧩 服务编排和组合 +- 🚀 事务管理和数据一致性 +- 🔐 业务权限控制 +- 📊 性能优化和缓存策略 + +## 📁 模块结构 + +``` +internal/service/ +├── CLAUDE.md # 📋 当前文件 - 业务逻辑开发指导 +├── interfaces.go # 🔗 服务接口定义 +├── auth/ # 🔐 认证服务 +│ ├── auth_service.go # 认证业务逻辑 +│ ├── jwt_service.go # JWT 令牌服务 +│ └── auth_service_test.go # 认证服务测试 +├── user/ # 👤 用户服务 +│ ├── user_service.go # 用户业务逻辑 +│ ├── user_validator.go # 用户数据验证 +│ └── user_service_test.go # 用户服务测试 +├── photo/ # 📸 照片服务 +│ ├── photo_service.go # 照片业务逻辑 +│ ├── photo_processor.go # 照片处理器 +│ └── photo_service_test.go # 照片服务测试 +├── category/ # 📂 分类服务 +│ ├── category_service.go # 分类业务逻辑 +│ └── category_service_test.go # 分类服务测试 +└── storage/ # 📁 存储服务 + ├── storage_service.go # 存储业务逻辑 + ├── local_storage.go # 本地存储实现 + └── s3_storage.go # S3 存储实现 +``` + +## 🔧 Go 风格服务设计 + +### 接口定义规范 +```go +// interfaces.go - 服务接口定义 +package service + +import ( + "context" + "mime/multipart" + + "photography-backend/internal/model/dto" + "photography-backend/internal/model/entity" +) + +// UserServicer 用户服务接口 +type UserServicer interface { + // 用户管理 + CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*entity.User, error) + GetUser(ctx context.Context, id uint) (*entity.User, error) + GetUserByEmail(ctx context.Context, email string) (*entity.User, error) + UpdateUser(ctx context.Context, id uint, req *dto.UpdateUserRequest) (*entity.User, error) + DeleteUser(ctx context.Context, id uint) error + ListUsers(ctx context.Context, opts *dto.ListUsersOptions) ([]*entity.User, int64, error) + + // 用户验证 + ValidateUser(ctx context.Context, email, password string) (*entity.User, error) + ChangePassword(ctx context.Context, id uint, req *dto.ChangePasswordRequest) error +} + +// PhotoServicer 照片服务接口 +type PhotoServicer interface { + // 照片管理 + CreatePhoto(ctx context.Context, req *dto.CreatePhotoRequest) (*entity.Photo, error) + GetPhoto(ctx context.Context, id uint) (*entity.Photo, error) + UpdatePhoto(ctx context.Context, id uint, req *dto.UpdatePhotoRequest) (*entity.Photo, error) + DeletePhoto(ctx context.Context, id uint) error + ListPhotos(ctx context.Context, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error) + + // 照片处理 + UploadPhoto(ctx context.Context, file *multipart.FileHeader, userID uint) (*entity.Photo, error) + ProcessPhoto(ctx context.Context, photoID uint, opts *dto.ProcessPhotoOptions) error + + // 照片查询 + GetPhotosByUser(ctx context.Context, userID uint, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error) + GetPhotosByCategory(ctx context.Context, categoryID uint, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error) + SearchPhotos(ctx context.Context, query string, opts *dto.SearchPhotosOptions) ([]*entity.Photo, int64, error) +} + +// AuthServicer 认证服务接口 +type AuthServicer interface { + // 认证 + Login(ctx context.Context, req *dto.LoginRequest) (*dto.LoginResponse, error) + Register(ctx context.Context, req *dto.RegisterRequest) (*dto.RegisterResponse, error) + RefreshToken(ctx context.Context, refreshToken string) (*dto.TokenResponse, error) + Logout(ctx context.Context, token string) error + + // 令牌管理 + ValidateToken(ctx context.Context, token string) (*dto.TokenClaims, error) + RevokeToken(ctx context.Context, token string) error +} +``` + +### 服务实现规范 +```go +// user/user_service.go - 用户服务实现 +package user + +import ( + "context" + "errors" + "fmt" + + "go.uber.org/zap" + "golang.org/x/crypto/bcrypt" + + "photography-backend/internal/model/dto" + "photography-backend/internal/model/entity" + "photography-backend/internal/repository" + "photography-backend/pkg/logger" +) + +type UserService struct { + userRepo repository.UserRepositoryr + logger logger.Logger +} + +func NewUserService(userRepo repository.UserRepositoryr, logger logger.Logger) *UserService { + return &UserService{ + userRepo: userRepo, + logger: logger, + } +} + +func (s *UserService) CreateUser(ctx context.Context, req *dto.CreateUserRequest) (*entity.User, error) { + // 1. 验证输入 + if err := s.validateCreateUserRequest(req); err != nil { + s.logger.Error("invalid create user request", zap.Error(err)) + return nil, err + } + + // 2. 检查用户是否存在 + existingUser, err := s.userRepo.GetByEmail(ctx, req.Email) + if err != nil && !errors.Is(err, repository.ErrNotFound) { + s.logger.Error("failed to check existing user", zap.Error(err)) + return nil, err + } + if existingUser != nil { + return nil, errors.New("user already exists") + } + + // 3. 加密密码 + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + s.logger.Error("failed to hash password", zap.Error(err)) + return nil, err + } + + // 4. 创建用户实体 + user := &entity.User{ + Email: req.Email, + Username: req.Username, + Password: string(hashedPassword), + Role: entity.UserRoleUser, + Status: entity.UserStatusActive, + } + + // 5. 保存用户 + createdUser, err := s.userRepo.Create(ctx, user) + if err != nil { + s.logger.Error("failed to create user", zap.Error(err)) + return nil, err + } + + s.logger.Info("user created successfully", + zap.String("user_id", fmt.Sprintf("%d", createdUser.ID)), + zap.String("email", createdUser.Email)) + + return createdUser, nil +} + +func (s *UserService) GetUser(ctx context.Context, id uint) (*entity.User, error) { + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, errors.New("user not found") + } + s.logger.Error("failed to get user", zap.Error(err), zap.Uint("user_id", id)) + return nil, err + } + + return user, nil +} + +func (s *UserService) ValidateUser(ctx context.Context, email, password string) (*entity.User, error) { + // 1. 获取用户 + user, err := s.userRepo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + return nil, errors.New("invalid credentials") + } + s.logger.Error("failed to get user by email", zap.Error(err)) + return nil, err + } + + // 2. 验证密码 + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + return nil, errors.New("invalid credentials") + } + + // 3. 检查用户状态 + if user.Status != entity.UserStatusActive { + return nil, errors.New("user account is not active") + } + + return user, nil +} + +func (s *UserService) validateCreateUserRequest(req *dto.CreateUserRequest) error { + if req.Email == "" { + return errors.New("email is required") + } + if req.Username == "" { + return errors.New("username is required") + } + if req.Password == "" { + return errors.New("password is required") + } + if len(req.Password) < 6 { + return errors.New("password must be at least 6 characters") + } + return nil +} +``` + +### 照片服务实现 +```go +// photo/photo_service.go - 照片服务实现 +package photo + +import ( + "context" + "errors" + "fmt" + "mime/multipart" + "path/filepath" + "strings" + "time" + + "go.uber.org/zap" + + "photography-backend/internal/model/dto" + "photography-backend/internal/model/entity" + "photography-backend/internal/repository" + "photography-backend/internal/service" + "photography-backend/pkg/logger" +) + +type PhotoService struct { + photoRepo repository.PhotoRepositoryr + userRepo repository.UserRepositoryr + storageService service.StorageServicer + logger logger.Logger +} + +func NewPhotoService( + photoRepo repository.PhotoRepositoryr, + userRepo repository.UserRepositoryr, + storageService service.StorageServicer, + logger logger.Logger, +) *PhotoService { + return &PhotoService{ + photoRepo: photoRepo, + userRepo: userRepo, + storageService: storageService, + logger: logger, + } +} + +func (s *PhotoService) UploadPhoto(ctx context.Context, file *multipart.FileHeader, userID uint) (*entity.Photo, error) { + // 1. 验证文件 + if err := s.validatePhotoFile(file); err != nil { + s.logger.Error("invalid photo file", zap.Error(err)) + return nil, err + } + + // 2. 验证用户 + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + s.logger.Error("failed to get user", zap.Error(err), zap.Uint("user_id", userID)) + return nil, err + } + + // 3. 生成文件名 + filename := s.generatePhotoFilename(file.Filename, userID) + + // 4. 上传文件 + uploadedFile, err := s.storageService.UploadFile(ctx, file, filename) + if err != nil { + s.logger.Error("failed to upload photo", zap.Error(err)) + return nil, err + } + + // 5. 创建照片记录 + photo := &entity.Photo{ + UserID: userID, + Title: strings.TrimSuffix(file.Filename, filepath.Ext(file.Filename)), + Description: "", + Filename: uploadedFile.Filename, + FilePath: uploadedFile.Path, + FileSize: uploadedFile.Size, + MimeType: uploadedFile.MimeType, + Width: uploadedFile.Width, + Height: uploadedFile.Height, + Status: entity.PhotoStatusActive, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // 6. 保存照片记录 + createdPhoto, err := s.photoRepo.Create(ctx, photo) + if err != nil { + // 如果数据库保存失败,删除已上传的文件 + s.storageService.DeleteFile(ctx, uploadedFile.Path) + s.logger.Error("failed to create photo record", zap.Error(err)) + return nil, err + } + + s.logger.Info("photo uploaded successfully", + zap.String("photo_id", fmt.Sprintf("%d", createdPhoto.ID)), + zap.String("filename", createdPhoto.Filename), + zap.Uint("user_id", userID)) + + return createdPhoto, nil +} + +func (s *PhotoService) GetPhotosByUser(ctx context.Context, userID uint, opts *dto.ListPhotosOptions) ([]*entity.Photo, int64, error) { + // 设置默认选项 + if opts == nil { + opts = &dto.ListPhotosOptions{ + Page: 1, + Limit: 20, + Sort: "created_at", + Order: "desc", + } + } + + // 查询照片 + photos, total, err := s.photoRepo.ListByUserID(ctx, userID, &repository.ListPhotosOptions{ + Page: opts.Page, + Limit: opts.Limit, + Sort: opts.Sort, + Order: opts.Order, + Status: string(entity.PhotoStatusActive), + }) + + if err != nil { + s.logger.Error("failed to list photos by user", zap.Error(err), zap.Uint("user_id", userID)) + return nil, 0, err + } + + return photos, total, nil +} + +func (s *PhotoService) validatePhotoFile(file *multipart.FileHeader) error { + // 检查文件大小 (10MB 限制) + const maxFileSize = 10 * 1024 * 1024 + if file.Size > maxFileSize { + return errors.New("file size exceeds limit") + } + + // 检查文件类型 + ext := strings.ToLower(filepath.Ext(file.Filename)) + allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} + + isAllowed := false + for _, allowedExt := range allowedExts { + if ext == allowedExt { + isAllowed = true + break + } + } + + if !isAllowed { + return errors.New("file type not allowed") + } + + return nil +} + +func (s *PhotoService) generatePhotoFilename(originalFilename string, userID uint) string { + ext := filepath.Ext(originalFilename) + timestamp := time.Now().Unix() + return fmt.Sprintf("photos/%d_%d%s", userID, timestamp, ext) +} +``` + +## 🧪 服务测试 + +### 单元测试规范 +```go +// user/user_service_test.go - 用户服务测试 +package user + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "photography-backend/internal/model/dto" + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/mocks" + "photography-backend/pkg/logger" +) + +func TestUserService_CreateUser(t *testing.T) { + // Setup + mockUserRepo := new(mocks.UserRepositoryr) + mockLogger := logger.NewNoop() + userService := NewUserService(mockUserRepo, mockLogger) + + ctx := context.Background() + req := &dto.CreateUserRequest{ + Email: "test@example.com", + Username: "testuser", + Password: "password123", + } + + // Mock expectations + mockUserRepo.On("GetByEmail", ctx, req.Email).Return(nil, repository.ErrNotFound) + mockUserRepo.On("Create", ctx, mock.AnythingOfType("*entity.User")). + Return(&entity.User{ + ID: 1, + Email: req.Email, + Username: req.Username, + Role: entity.UserRoleUser, + Status: entity.UserStatusActive, + }, nil) + + // Execute + user, err := userService.CreateUser(ctx, req) + + // Assert + assert.NoError(t, err) + assert.NotNil(t, user) + assert.Equal(t, req.Email, user.Email) + assert.Equal(t, req.Username, user.Username) + assert.Equal(t, entity.UserRoleUser, user.Role) + assert.Equal(t, entity.UserStatusActive, user.Status) + + mockUserRepo.AssertExpectations(t) +} + +func TestUserService_CreateUser_UserAlreadyExists(t *testing.T) { + // Setup + mockUserRepo := new(mocks.UserRepositoryr) + mockLogger := logger.NewNoop() + userService := NewUserService(mockUserRepo, mockLogger) + + ctx := context.Background() + req := &dto.CreateUserRequest{ + Email: "test@example.com", + Username: "testuser", + Password: "password123", + } + + existingUser := &entity.User{ + ID: 1, + Email: req.Email, + } + + // Mock expectations + mockUserRepo.On("GetByEmail", ctx, req.Email).Return(existingUser, nil) + + // Execute + user, err := userService.CreateUser(ctx, req) + + // Assert + assert.Error(t, err) + assert.Nil(t, user) + assert.Contains(t, err.Error(), "user already exists") + + mockUserRepo.AssertExpectations(t) +} +``` + +## 🔍 错误处理 + +### 自定义错误类型 +```go +// errors.go - 服务层错误定义 +package service + +import "errors" + +var ( + // 通用错误 + ErrInvalidInput = errors.New("invalid input") + ErrInternalError = errors.New("internal error") + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + + // 用户相关错误 + ErrUserNotFound = errors.New("user not found") + ErrUserExists = errors.New("user already exists") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserInactive = errors.New("user account is not active") + + // 照片相关错误 + ErrPhotoNotFound = errors.New("photo not found") + ErrInvalidFileType = errors.New("invalid file type") + ErrFileSizeExceeded = errors.New("file size exceeded") + ErrUploadFailed = errors.New("file upload failed") + + // 认证相关错误 + ErrInvalidToken = errors.New("invalid token") + ErrTokenExpired = errors.New("token expired") + ErrTokenRevoked = errors.New("token revoked") +) +``` + +## 💡 最佳实践 + +### 服务设计原则 +1. **单一职责**: 每个服务只负责一个业务领域 +2. **接口优先**: 先定义接口,再实现具体逻辑 +3. **依赖注入**: 通过构造函数注入依赖 +4. **错误处理**: 明确的错误类型和处理 +5. **日志记录**: 关键操作的日志记录 + +### 性能优化 +1. **缓存策略**: 合理使用缓存减少数据库查询 +2. **批量操作**: 优化批量数据处理 +3. **异步处理**: 使用 goroutine 处理耗时操作 +4. **数据库优化**: 合理使用索引和查询优化 + +### 安全考虑 +1. **输入验证**: 严格验证所有输入参数 +2. **权限检查**: 确保用户只能操作自己的数据 +3. **敏感信息**: 避免在日志中记录敏感信息 +4. **SQL 注入**: 使用参数化查询防止 SQL 注入 + +本模块是业务逻辑的核心,确保代码质量和测试覆盖率是关键。 \ 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 81729e5..1d8fb6c 100644 --- a/backend/internal/service/auth/auth_service.go +++ b/backend/internal/service/auth/auth_service.go @@ -2,7 +2,6 @@ package auth import ( "fmt" - "time" "golang.org/x/crypto/bcrypt" "photography-backend/internal/models" "photography-backend/internal/repository/postgres" diff --git a/backend/internal/service/auth/jwt_service.go b/backend/internal/service/auth/jwt_service.go index 3363b2a..1782d04 100644 --- a/backend/internal/service/auth/jwt_service.go +++ b/backend/internal/service/auth/jwt_service.go @@ -32,10 +32,13 @@ type TokenPair struct { // NewJWTService 创建JWT服务 func NewJWTService(cfg *config.JWTConfig) *JWTService { + accessDuration, _ := time.ParseDuration(cfg.ExpiresIn) + refreshDuration, _ := time.ParseDuration(cfg.RefreshExpiresIn) + return &JWTService{ secretKey: []byte(cfg.Secret), - accessTokenDuration: config.AppConfig.GetJWTExpiration(), - refreshTokenDuration: config.AppConfig.GetJWTRefreshExpiration(), + accessTokenDuration: accessDuration, + refreshTokenDuration: refreshDuration, } } diff --git a/backend/internal/service/storage/storage.go b/backend/internal/service/storage/storage.go index df13b44..c911ec1 100644 --- a/backend/internal/service/storage/storage.go +++ b/backend/internal/service/storage/storage.go @@ -40,14 +40,14 @@ type LocalStorageService struct { // NewLocalStorageService 创建本地存储服务 func NewLocalStorageService(config *config.Config, logger *zap.Logger) *LocalStorageService { - uploadDir := config.Upload.Path + uploadDir := config.Storage.Local.BasePath if uploadDir == "" { uploadDir = "./uploads" } - baseURL := config.Upload.BaseURL + baseURL := config.Storage.Local.BaseURL if baseURL == "" { - baseURL = fmt.Sprintf("http://localhost:%d/uploads", config.Server.Port) + baseURL = fmt.Sprintf("http://localhost:%d/uploads", config.App.Port) } // 确保上传目录存在 @@ -95,7 +95,7 @@ func (s *LocalStorageService) UploadPhoto(ctx context.Context, file multipart.Fi } // 获取文件信息 - fileInfo, err := out.Stat() + _, err = out.Stat() if err != nil { s.logger.Error("Failed to get file info", zap.Error(err)) return nil, err @@ -203,7 +203,7 @@ func (s *LocalStorageService) getMimeType(filename string) string { // NewStorageService 根据配置创建存储服务 func NewStorageService(config *config.Config, logger *zap.Logger) StorageService { - switch config.Upload.Type { + switch config.Storage.Type { case "s3": // TODO: 实现 S3 存储服务 logger.Warn("S3 storage not implemented yet, using local storage") diff --git a/backend/internal/service/upload/upload_service.go b/backend/internal/service/upload/upload_service.go new file mode 100644 index 0000000..dd8501f --- /dev/null +++ b/backend/internal/service/upload/upload_service.go @@ -0,0 +1,187 @@ +package upload + +import ( + "fmt" + "io" + "mime/multipart" + "os" + "path/filepath" + "strings" + "time" + + "photography-backend/internal/config" +) + +// UploadService 文件上传服务 +type UploadService struct { + config *config.Config + uploadDir string + baseURL string +} + +// UploadResult 上传结果 +type UploadResult struct { + Filename string `json:"filename"` + OriginalName string `json:"original_name"` + FilePath string `json:"file_path"` + FileURL string `json:"file_url"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` +} + +// NewUploadService 创建文件上传服务 +func NewUploadService(cfg *config.Config) *UploadService { + uploadDir := cfg.Storage.Local.BasePath + if uploadDir == "" { + uploadDir = "./uploads" + } + + baseURL := cfg.Storage.Local.BaseURL + if baseURL == "" { + baseURL = fmt.Sprintf("http://localhost:%d/uploads", cfg.App.Port) + } + + // 确保上传目录存在 + os.MkdirAll(uploadDir, 0755) + + // 创建子目录 + subdirs := []string{"photos", "thumbnails", "temp"} + for _, subdir := range subdirs { + os.MkdirAll(filepath.Join(uploadDir, subdir), 0755) + } + + return &UploadService{ + config: cfg, + uploadDir: uploadDir, + baseURL: baseURL, + } +} + +// UploadPhoto 上传照片 +func (s *UploadService) UploadPhoto(file multipart.File, header *multipart.FileHeader) (*UploadResult, error) { + // 验证文件类型 + if !s.isValidImageType(header.Header.Get("Content-Type")) { + return nil, fmt.Errorf("不支持的文件类型: %s", header.Header.Get("Content-Type")) + } + + // 验证文件大小 + if header.Size > s.config.Upload.MaxFileSize { + return nil, fmt.Errorf("文件大小超过限制: %d bytes", header.Size) + } + + // 生成唯一文件名 + filename := s.generateUniqueFilename(header.Filename) + + // 保存文件到 photos 目录 + photoPath := filepath.Join(s.uploadDir, "photos", filename) + + // 创建目标文件 + dst, err := os.Create(photoPath) + if err != nil { + return nil, fmt.Errorf("创建文件失败: %w", err) + } + defer dst.Close() + + // 复制文件内容 + _, err = io.Copy(dst, file) + if err != nil { + return nil, fmt.Errorf("保存文件失败: %w", err) + } + + // 构造文件URL + fileURL := fmt.Sprintf("%s/photos/%s", s.baseURL, filename) + + return &UploadResult{ + Filename: filename, + OriginalName: header.Filename, + FilePath: photoPath, + FileURL: fileURL, + FileSize: header.Size, + MimeType: header.Header.Get("Content-Type"), + }, nil +} + +// DeletePhoto 删除照片 +func (s *UploadService) DeletePhoto(filename string) error { + // 删除原图 + photoPath := filepath.Join(s.uploadDir, "photos", filename) + if err := os.Remove(photoPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("删除文件失败: %w", err) + } + + // 删除缩略图 + thumbnailPath := filepath.Join(s.uploadDir, "thumbnails", filename) + os.Remove(thumbnailPath) // 忽略错误,因为缩略图可能不存在 + + return nil +} + +// GetPhotoURL 获取照片URL +func (s *UploadService) GetPhotoURL(filename string) string { + return fmt.Sprintf("%s/photos/%s", s.baseURL, filename) +} + +// isValidImageType 验证是否为有效的图片类型 +func (s *UploadService) isValidImageType(mimeType string) bool { + allowedTypes := s.config.Upload.AllowedTypes + if len(allowedTypes) == 0 { + // 默认允许的图片类型 + allowedTypes = []string{ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + "image/tiff", + } + } + + for _, allowedType := range allowedTypes { + if mimeType == allowedType { + return true + } + } + return false +} + +// generateUniqueFilename 生成唯一文件名 +func (s *UploadService) generateUniqueFilename(originalName string) string { + ext := filepath.Ext(originalName) + timestamp := time.Now().Unix() + + // 清理原文件名 + baseName := strings.TrimSuffix(originalName, ext) + baseName = strings.ReplaceAll(baseName, " ", "_") + + return fmt.Sprintf("%s_%d%s", baseName, timestamp, ext) +} + +// GetUploadStats 获取上传统计信息 +func (s *UploadService) GetUploadStats() (map[string]interface{}, error) { + photosDir := filepath.Join(s.uploadDir, "photos") + + var totalFiles int + var totalSize int64 + + err := filepath.Walk(photosDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + totalFiles++ + totalSize += info.Size() + } + return nil + }) + + if err != nil { + return nil, err + } + + return map[string]interface{}{ + "total_files": totalFiles, + "total_size": totalSize, + "upload_dir": s.uploadDir, + "base_url": s.baseURL, + }, nil +} \ No newline at end of file diff --git a/backend/internal/utils/utils.go b/backend/internal/utils/utils.go deleted file mode 100644 index 6a1b481..0000000 --- a/backend/internal/utils/utils.go +++ /dev/null @@ -1,243 +0,0 @@ -package utils - -import ( - "crypto/md5" - "fmt" - "path/filepath" - "regexp" - "strings" - "time" - "unicode" - - "golang.org/x/text/runes" - "golang.org/x/text/transform" - "golang.org/x/text/unicode/norm" -) - -// Contains 检查字符串切片是否包含指定字符串 -func Contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - -// ContainsUint 检查 uint 切片是否包含指定值 -func ContainsUint(slice []uint, item uint) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - -// GenerateUniqueFilename 生成唯一文件名 -func GenerateUniqueFilename(originalFilename string) string { - ext := filepath.Ext(originalFilename) - name := strings.TrimSuffix(originalFilename, ext) - - // 生成时间戳和哈希 - timestamp := time.Now().Unix() - hash := md5.Sum([]byte(fmt.Sprintf("%s%d", name, timestamp))) - - return fmt.Sprintf("%d_%x%s", timestamp, hash[:8], ext) -} - -// GenerateSlug 生成 URL 友好的 slug -func GenerateSlug(text string) string { - // 转换为小写 - slug := strings.ToLower(text) - - // 移除重音符号 - t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) - slug, _, _ = transform.String(t, slug) - - // 只保留字母、数字、连字符和下划线 - reg := regexp.MustCompile(`[^a-z0-9\-_\s]`) - slug = reg.ReplaceAllString(slug, "") - - // 将空格替换为连字符 - slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-") - - // 移除多余的连字符 - slug = regexp.MustCompile(`-+`).ReplaceAllString(slug, "-") - - // 移除开头和结尾的连字符 - slug = strings.Trim(slug, "-") - - return slug -} - -// ValidateEmail 验证邮箱格式 -func ValidateEmail(email string) bool { - emailRegex := regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`) - return emailRegex.MatchString(strings.ToLower(email)) -} - -// ValidatePassword 验证密码强度 -func ValidatePassword(password string) bool { - if len(password) < 8 { - return false - } - - hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) - hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) - hasDigit := regexp.MustCompile(`\d`).MatchString(password) - - return hasLower && hasUpper && hasDigit -} - -// Paginate 计算分页参数 -func Paginate(page, limit int) (offset int) { - if page <= 0 { - page = 1 - } - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - offset = (page - 1) * limit - return offset -} - -// CalculatePages 计算总页数 -func CalculatePages(total int64, limit int) int { - if limit <= 0 { - return 0 - } - return int((total + int64(limit) - 1) / int64(limit)) -} - -// TruncateString 截断字符串 -func TruncateString(s string, maxLength int) string { - if len(s) <= maxLength { - return s - } - return s[:maxLength] + "..." -} - -// 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++ - } - - sizes := []string{"KB", "MB", "GB", "TB", "PB"} - return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), sizes[exp]) -} - -// ParseSortOrder 解析排序方向 -func ParseSortOrder(order string) string { - order = strings.ToLower(strings.TrimSpace(order)) - if order == "asc" || order == "desc" { - return order - } - return "desc" // 默认降序 -} - -// SanitizeSearchQuery 清理搜索查询 -func SanitizeSearchQuery(query string) string { - // 移除特殊字符,只保留字母、数字、空格和常用标点 - reg := regexp.MustCompile(`[^\w\s\-\.\_\@]`) - query = reg.ReplaceAllString(query, "") - - // 移除多余的空格 - query = regexp.MustCompile(`\s+`).ReplaceAllString(query, " ") - - return strings.TrimSpace(query) -} - -// GenerateRandomString 生成随机字符串 -func GenerateRandomString(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - - // 使用当前时间作为种子 - timestamp := time.Now().UnixNano() - - result := make([]byte, length) - for i := range result { - result[i] = charset[(timestamp+int64(i))%int64(len(charset))] - } - - return string(result) -} - -// IsValidImageExtension 检查是否为有效的图片扩展名 -func IsValidImageExtension(filename string) bool { - ext := strings.ToLower(filepath.Ext(filename)) - validExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} - return Contains(validExts, ext) -} - -// GetImageMimeType 根据文件扩展名获取 MIME 类型 -func GetImageMimeType(filename string) string { - ext := strings.ToLower(filepath.Ext(filename)) - mimeTypes := map[string]string{ - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".tiff": "image/tiff", - } - - if mimeType, exists := mimeTypes[ext]; exists { - return mimeType - } - return "application/octet-stream" -} - -// RemoveEmptyStrings 移除字符串切片中的空字符串 -func RemoveEmptyStrings(slice []string) []string { - var result []string - for _, s := range slice { - if strings.TrimSpace(s) != "" { - result = append(result, strings.TrimSpace(s)) - } - } - return result -} - -// UniqueStrings 去重字符串切片 -func UniqueStrings(slice []string) []string { - keys := make(map[string]bool) - var result []string - - for _, item := range slice { - if !keys[item] { - keys[item] = true - result = append(result, item) - } - } - - return result -} - -// UniqueUints 去重 uint 切片 -func UniqueUints(slice []uint) []uint { - keys := make(map[uint]bool) - var result []uint - - for _, item := range slice { - if !keys[item] { - keys[item] = true - result = append(result, item) - } - } - - return result -} \ No newline at end of file diff --git a/backend/migrations/001_create_users.sql b/backend/migrations/001_create_users.sql index 0a0b5f8..974ee78 100644 --- a/backend/migrations/001_create_users.sql +++ b/backend/migrations/001_create_users.sql @@ -7,9 +7,15 @@ CREATE TABLE users ( password VARCHAR(255) NOT NULL, name VARCHAR(100), avatar VARCHAR(500), - role VARCHAR(20) DEFAULT 'user', + bio TEXT, + website VARCHAR(200), + location VARCHAR(100), + role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('user', 'admin', 'photographer')), is_active BOOLEAN DEFAULT true, + is_public BOOLEAN DEFAULT true, + email_verified BOOLEAN DEFAULT false, last_login TIMESTAMP, + login_count INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP @@ -19,12 +25,30 @@ CREATE TABLE users ( CREATE INDEX idx_users_username ON users(username); CREATE INDEX idx_users_email ON users(email); CREATE INDEX idx_users_role ON users(role); -CREATE INDEX idx_users_deleted_at ON users(deleted_at); +CREATE INDEX idx_users_is_active ON users(is_active); +CREATE INDEX idx_users_is_public ON users(is_public); +CREATE INDEX idx_users_email_verified ON users(email_verified); +CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX idx_users_deleted_at ON users(deleted_at) WHERE deleted_at IS NOT NULL; + +-- 添加更新时间触发器 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- 插入默认管理员用户 (密码: admin123) -INSERT INTO users (username, email, password, name, role) VALUES -('admin', 'admin@photography.com', '$2a$10$D4Zz6m3j1YJzp8Y7zW4l2OXcQ5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0', '管理员', 'admin'); +INSERT INTO users (username, email, password, name, role, email_verified) VALUES +('admin', 'admin@photography.com', '$2a$10$D4Zz6m3j1YJzp8Y7zW4l2OXcQ5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0', '管理员', 'admin', true); -- +migrate Down +DROP TRIGGER IF EXISTS update_users_updated_at ON users; +DROP FUNCTION IF EXISTS update_updated_at_column(); DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/backend/migrations/002_create_photos.sql b/backend/migrations/002_create_photos.sql new file mode 100644 index 0000000..8686a7c --- /dev/null +++ b/backend/migrations/002_create_photos.sql @@ -0,0 +1,64 @@ +-- +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/003_create_albums.sql b/backend/migrations/003_create_albums.sql new file mode 100644 index 0000000..b1fe350 --- /dev/null +++ b/backend/migrations/003_create_albums.sql @@ -0,0 +1,73 @@ +-- +migrate Up + +CREATE TABLE albums ( + id SERIAL PRIMARY KEY, + title VARCHAR(200) NOT NULL, + description TEXT, + slug VARCHAR(255) UNIQUE, + cover_photo_id INTEGER REFERENCES photos(id) ON DELETE SET NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + is_public BOOLEAN DEFAULT true, + is_featured BOOLEAN DEFAULT false, + password VARCHAR(255), + view_count INTEGER DEFAULT 0, + like_count INTEGER DEFAULT 0, + photo_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_albums_user_id ON albums(user_id); +CREATE INDEX idx_albums_category_id ON albums(category_id); +CREATE INDEX idx_albums_cover_photo_id ON albums(cover_photo_id); +CREATE INDEX idx_albums_slug ON albums(slug); +CREATE INDEX idx_albums_is_public ON albums(is_public); +CREATE INDEX idx_albums_is_featured ON albums(is_featured); +CREATE INDEX idx_albums_created_at ON albums(created_at); +CREATE INDEX idx_albums_view_count ON albums(view_count); +CREATE INDEX idx_albums_like_count ON albums(like_count); +CREATE INDEX idx_albums_photo_count ON albums(photo_count); +CREATE INDEX idx_albums_sort_order ON albums(sort_order); +CREATE INDEX idx_albums_deleted_at ON albums(deleted_at) WHERE deleted_at IS NOT NULL; + +-- 添加更新时间触发器 +CREATE TRIGGER update_albums_updated_at BEFORE UPDATE ON albums +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 添加 slug 自动生成触发器 +CREATE OR REPLACE FUNCTION generate_album_slug() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.slug IS NULL OR NEW.slug = '' THEN + NEW.slug = lower(regexp_replace(NEW.title, '[^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 albums 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_albums_slug BEFORE INSERT OR UPDATE ON albums +FOR EACH ROW EXECUTE FUNCTION generate_album_slug(); + +-- +migrate Down + +DROP TRIGGER IF EXISTS generate_albums_slug ON albums; +DROP FUNCTION IF EXISTS generate_album_slug(); +DROP TRIGGER IF EXISTS update_albums_updated_at ON albums; +DROP TABLE IF EXISTS albums; \ No newline at end of file diff --git a/backend/migrations/004_create_categories.sql b/backend/migrations/004_create_categories.sql new file mode 100644 index 0000000..319dca6 --- /dev/null +++ b/backend/migrations/004_create_categories.sql @@ -0,0 +1,113 @@ +-- +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/005_create_tags.sql b/backend/migrations/005_create_tags.sql new file mode 100644 index 0000000..6b8f0a1 --- /dev/null +++ b/backend/migrations/005_create_tags.sql @@ -0,0 +1,164 @@ +-- +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 diff --git a/backend/migrations/006_add_foreign_keys.sql b/backend/migrations/006_add_foreign_keys.sql new file mode 100644 index 0000000..f911af8 --- /dev/null +++ b/backend/migrations/006_add_foreign_keys.sql @@ -0,0 +1,111 @@ +-- +migrate Up + +-- 添加照片表的外键约束 +ALTER TABLE photos +ADD CONSTRAINT fk_photos_album_id +FOREIGN KEY (album_id) REFERENCES albums(id) ON DELETE SET NULL; + +ALTER TABLE photos +ADD CONSTRAINT fk_photos_category_id +FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL; + +-- 添加相册表的外键约束(如果还没有的话) +-- 这些约束在创建相册表时可能已经存在,这里做一个保险 + +-- 添加一些有用的触发器和函数 + +-- 创建照片计数更新触发器(用于相册和分类) +CREATE OR REPLACE FUNCTION update_photo_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + -- 更新相册照片数量 + IF NEW.album_id IS NOT NULL THEN + UPDATE albums SET photo_count = photo_count + 1 WHERE id = NEW.album_id; + END IF; + -- 更新分类照片数量 + IF NEW.category_id IS NOT NULL THEN + UPDATE categories SET photo_count = photo_count + 1 WHERE id = NEW.category_id; + END IF; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + -- 更新相册照片数量 + IF OLD.album_id IS NOT NULL THEN + UPDATE albums SET photo_count = photo_count - 1 WHERE id = OLD.album_id; + END IF; + -- 更新分类照片数量 + IF OLD.category_id IS NOT NULL THEN + UPDATE categories SET photo_count = photo_count - 1 WHERE id = OLD.category_id; + END IF; + RETURN OLD; + ELSIF TG_OP = 'UPDATE' THEN + -- 处理相册变更 + IF OLD.album_id IS DISTINCT FROM NEW.album_id THEN + IF OLD.album_id IS NOT NULL THEN + UPDATE albums SET photo_count = photo_count - 1 WHERE id = OLD.album_id; + END IF; + IF NEW.album_id IS NOT NULL THEN + UPDATE albums SET photo_count = photo_count + 1 WHERE id = NEW.album_id; + END IF; + END IF; + -- 处理分类变更 + IF OLD.category_id IS DISTINCT FROM NEW.category_id THEN + IF OLD.category_id IS NOT NULL THEN + UPDATE categories SET photo_count = photo_count - 1 WHERE id = OLD.category_id; + END IF; + IF NEW.category_id IS NOT NULL THEN + UPDATE categories SET photo_count = photo_count + 1 WHERE id = NEW.category_id; + END IF; + END IF; + RETURN NEW; + END IF; + RETURN NULL; +END; +$$ language 'plpgsql'; + +-- 创建触发器 +CREATE TRIGGER trigger_update_photo_counts +AFTER INSERT OR UPDATE OR DELETE ON photos +FOR EACH ROW EXECUTE FUNCTION update_photo_counts(); + +-- 创建相册计数更新触发器(用于分类) +CREATE OR REPLACE FUNCTION update_album_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.category_id IS NOT NULL THEN + UPDATE categories SET album_count = album_count + 1 WHERE id = NEW.category_id; + END IF; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.category_id IS NOT NULL THEN + UPDATE categories SET album_count = album_count - 1 WHERE id = OLD.category_id; + END IF; + RETURN OLD; + ELSIF TG_OP = 'UPDATE' THEN + IF OLD.category_id IS DISTINCT FROM NEW.category_id THEN + IF OLD.category_id IS NOT NULL THEN + UPDATE categories SET album_count = album_count - 1 WHERE id = OLD.category_id; + END IF; + IF NEW.category_id IS NOT NULL THEN + UPDATE categories SET album_count = album_count + 1 WHERE id = NEW.category_id; + END IF; + END IF; + RETURN NEW; + END IF; + RETURN NULL; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER trigger_update_album_counts +AFTER INSERT OR UPDATE OR DELETE ON albums +FOR EACH ROW EXECUTE FUNCTION update_album_counts(); + +-- +migrate Down + +DROP TRIGGER IF EXISTS trigger_update_album_counts ON albums; +DROP FUNCTION IF EXISTS update_album_counts(); +DROP TRIGGER IF EXISTS trigger_update_photo_counts ON photos; +DROP FUNCTION IF EXISTS update_photo_counts(); +ALTER TABLE photos DROP CONSTRAINT IF EXISTS fk_photos_category_id; +ALTER TABLE photos DROP CONSTRAINT IF EXISTS fk_photos_album_id; \ No newline at end of file diff --git a/backend/pkg/CLAUDE.md b/backend/pkg/CLAUDE.md new file mode 100644 index 0000000..ada801b --- /dev/null +++ b/backend/pkg/CLAUDE.md @@ -0,0 +1,793 @@ +# Shared Package Layer - CLAUDE.md + +本文件为 Claude Code 在共享包模块中工作时提供指导。 + +## 🎯 模块概览 + +pkg 包提供可复用的工具和组件,供整个应用使用,遵循 Go 语言的包设计哲学。 + +### 主要职责 +- 📦 提供通用工具和实用函数 +- 📝 提供日志记录组件 +- 🔄 提供统一响应格式 +- ✅ 提供数据验证工具 +- 🛠️ 提供辅助功能函数 + +## 📁 模块结构 + +``` +pkg/ +├── CLAUDE.md # 📋 当前文件 - 公共工具包指导 +├── logger/ # 📝 日志工具 +│ ├── logger.go # 日志接口和实现 +│ ├── zap_logger.go # Zap 日志实现 +│ ├── config.go # 日志配置 +│ └── logger_test.go # 日志测试 +├── response/ # 🔄 响应格式 +│ ├── response.go # 统一响应结构 +│ ├── error.go # 错误响应 +│ ├── success.go # 成功响应 +│ └── pagination.go # 分页响应 +├── validator/ # ✅ 数据验证 +│ ├── validator.go # 验证器接口 +│ ├── gin_validator.go # Gin 验证器实现 +│ ├── custom_validators.go # 自定义验证规则 +│ └── validator_test.go # 验证器测试 +├── utils/ # 🛠️ 通用工具 +│ ├── string.go # 字符串工具 +│ ├── time.go # 时间工具 +│ ├── crypto.go # 加密工具 +│ ├── file.go # 文件工具 +│ ├── uuid.go # UUID 生成 +│ └── utils_test.go # 工具测试 +├── middleware/ # 🔗 中间件 +│ ├── cors.go # CORS 中间件 +│ ├── rate_limit.go # 限流中间件 +│ ├── request_id.go # 请求ID中间件 +│ └── recovery.go # 错误恢复中间件 +├── database/ # 🗄️ 数据库工具 +│ ├── connection.go # 数据库连接 +│ ├── migrate.go # 数据库迁移 +│ └── health.go # 健康检查 +└── config/ # ⚙️ 配置工具 + ├── config.go # 配置管理 + ├── env.go # 环境变量处理 + └── validation.go # 配置验证 +``` + +## 📝 日志组件 + +### 日志接口设计 +```go +// logger/logger.go - 日志接口 +package logger + +import ( + "context" +) + +// Logger 日志接口 +type Logger interface { + // 基础日志方法 + Debug(msg string, fields ...Field) + Info(msg string, fields ...Field) + Warn(msg string, fields ...Field) + Error(msg string, fields ...Field) + Fatal(msg string, fields ...Field) + + // 上下文日志方法 + DebugContext(ctx context.Context, msg string, fields ...Field) + InfoContext(ctx context.Context, msg string, fields ...Field) + WarnContext(ctx context.Context, msg string, fields ...Field) + ErrorContext(ctx context.Context, msg string, fields ...Field) + + // 结构化字段方法 + With(fields ...Field) Logger + WithError(err error) Logger + WithContext(ctx context.Context) Logger + + // 日志级别控制 + SetLevel(level Level) + GetLevel() Level +} + +// Field 日志字段 +type Field struct { + Key string + Value interface{} +} + +// Level 日志级别 +type Level int + +const ( + DebugLevel Level = iota + InfoLevel + WarnLevel + ErrorLevel + FatalLevel +) + +// 便捷字段构造函数 +func String(key, value string) Field { + return Field{Key: key, Value: value} +} + +func Int(key string, value int) Field { + return Field{Key: key, Value: value} +} + +func Uint(key string, value uint) Field { + return Field{Key: key, Value: value} +} + +func Error(err error) Field { + return Field{Key: "error", Value: err} +} + +func Any(key string, value interface{}) Field { + return Field{Key: key, Value: value} +} +``` + +### Zap 日志实现 +```go +// logger/zap_logger.go - Zap 日志实现 +package logger + +import ( + "context" + "os" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// ZapLogger Zap 日志实现 +type ZapLogger struct { + logger *zap.Logger + config *Config +} + +// Config 日志配置 +type Config struct { + Level string `mapstructure:"level" json:"level"` + Format string `mapstructure:"format" json:"format"` // json, console + OutputPath string `mapstructure:"output_path" json:"output_path"` + ErrorPath string `mapstructure:"error_path" json:"error_path"` + MaxSize int `mapstructure:"max_size" json:"max_size"` + MaxAge int `mapstructure:"max_age" json:"max_age"` + MaxBackups int `mapstructure:"max_backups" json:"max_backups"` + Compress bool `mapstructure:"compress" json:"compress"` +} + +// NewZapLogger 创建 Zap 日志实例 +func NewZapLogger(config *Config) (Logger, error) { + zapConfig := zap.NewProductionConfig() + + // 设置日志级别 + level, err := zapcore.ParseLevel(config.Level) + if err != nil { + level = zapcore.InfoLevel + } + zapConfig.Level.SetLevel(level) + + // 设置输出格式 + if config.Format == "console" { + zapConfig.Encoding = "console" + zapConfig.EncoderConfig = zap.NewDevelopmentEncoderConfig() + } else { + zapConfig.Encoding = "json" + zapConfig.EncoderConfig = zap.NewProductionEncoderConfig() + } + + // 设置输出路径 + if config.OutputPath != "" { + zapConfig.OutputPaths = []string{config.OutputPath} + } + if config.ErrorPath != "" { + zapConfig.ErrorOutputPaths = []string{config.ErrorPath} + } + + logger, err := zapConfig.Build() + if err != nil { + return nil, err + } + + return &ZapLogger{ + logger: logger, + config: config, + }, nil +} + +// Debug 调试日志 +func (l *ZapLogger) Debug(msg string, fields ...Field) { + l.logger.Debug(msg, l.convertFields(fields...)...) +} + +// Info 信息日志 +func (l *ZapLogger) Info(msg string, fields ...Field) { + l.logger.Info(msg, l.convertFields(fields...)...) +} + +// Warn 警告日志 +func (l *ZapLogger) Warn(msg string, fields ...Field) { + l.logger.Warn(msg, l.convertFields(fields...)...) +} + +// Error 错误日志 +func (l *ZapLogger) Error(msg string, fields ...Field) { + l.logger.Error(msg, l.convertFields(fields...)...) +} + +// Fatal 致命错误日志 +func (l *ZapLogger) Fatal(msg string, fields ...Field) { + l.logger.Fatal(msg, l.convertFields(fields...)...) +} + +// With 添加字段 +func (l *ZapLogger) With(fields ...Field) Logger { + return &ZapLogger{ + logger: l.logger.With(l.convertFields(fields...)...), + config: l.config, + } +} + +// WithError 添加错误字段 +func (l *ZapLogger) WithError(err error) Logger { + return l.With(Error(err)) +} + +// convertFields 转换字段格式 +func (l *ZapLogger) convertFields(fields ...Field) []zap.Field { + zapFields := make([]zap.Field, len(fields)) + for i, field := range fields { + zapFields[i] = zap.Any(field.Key, field.Value) + } + return zapFields +} + +// NewNoop 创建空日志实例(用于测试) +func NewNoop() Logger { + return &ZapLogger{ + logger: zap.NewNop(), + config: &Config{}, + } +} +``` + +## 🔄 响应格式 + +### 统一响应结构 +```go +// response/response.go - 统一响应结构 +package response + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// Response 基础响应结构 +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Message string `json:"message,omitempty"` + Error *Error `json:"error,omitempty"` + Timestamp int64 `json:"timestamp"` + RequestID string `json:"request_id,omitempty"` +} + +// Error 错误信息结构 +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +// PaginatedResponse 分页响应结构 +type PaginatedResponse struct { + Response + Pagination *Pagination `json:"pagination,omitempty"` +} + +// Pagination 分页信息 +type Pagination struct { + Page int `json:"page"` + Limit int `json:"limit"` + Total int64 `json:"total"` + TotalPages int `json:"total_pages"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// Success 成功响应 +func Success(c *gin.Context, data interface{}, message ...string) { + msg := "Success" + if len(message) > 0 { + msg = message[0] + } + + response := Response{ + Success: true, + Data: data, + Message: msg, + Timestamp: time.Now().Unix(), + RequestID: getRequestID(c), + } + + c.JSON(http.StatusOK, response) +} + +// Error 错误响应 +func Error(c *gin.Context, code string, message string, details ...string) { + detail := "" + if len(details) > 0 { + detail = details[0] + } + + response := Response{ + Success: false, + Error: &Error{ + Code: code, + Message: message, + Details: detail, + }, + Timestamp: time.Now().Unix(), + RequestID: getRequestID(c), + } + + c.JSON(getHTTPStatusFromCode(code), response) +} + +// BadRequest 400 错误 +func BadRequest(c *gin.Context, message string, details ...string) { + Error(c, "BAD_REQUEST", message, details...) +} + +// Unauthorized 401 错误 +func Unauthorized(c *gin.Context, message string, details ...string) { + Error(c, "UNAUTHORIZED", message, details...) +} + +// Forbidden 403 错误 +func Forbidden(c *gin.Context, message string, details ...string) { + Error(c, "FORBIDDEN", message, details...) +} + +// NotFound 404 错误 +func NotFound(c *gin.Context, message string, details ...string) { + Error(c, "NOT_FOUND", message, details...) +} + +// InternalError 500 错误 +func InternalError(c *gin.Context, message string, details ...string) { + Error(c, "INTERNAL_ERROR", message, details...) +} + +// Paginated 分页响应 +func Paginated(c *gin.Context, data interface{}, page, limit int, total int64, message ...string) { + msg := "Success" + if len(message) > 0 { + msg = message[0] + } + + totalPages := int((total + int64(limit) - 1) / int64(limit)) + + pagination := &Pagination{ + Page: page, + Limit: limit, + Total: total, + TotalPages: totalPages, + HasNext: page < totalPages, + HasPrev: page > 1, + } + + response := PaginatedResponse{ + Response: Response{ + Success: true, + Data: data, + Message: msg, + Timestamp: time.Now().Unix(), + RequestID: getRequestID(c), + }, + Pagination: pagination, + } + + c.JSON(http.StatusOK, response) +} + +// getRequestID 获取请求ID +func getRequestID(c *gin.Context) string { + if requestID, exists := c.Get("X-Request-ID"); exists { + if id, ok := requestID.(string); ok { + return id + } + } + return "" +} + +// getHTTPStatusFromCode 根据错误码获取HTTP状态码 +func getHTTPStatusFromCode(code string) int { + switch code { + case "BAD_REQUEST", "INVALID_PARAMETER", "VALIDATION_ERROR": + return http.StatusBadRequest + case "UNAUTHORIZED", "INVALID_TOKEN", "TOKEN_EXPIRED": + return http.StatusUnauthorized + case "FORBIDDEN", "PERMISSION_DENIED": + return http.StatusForbidden + case "NOT_FOUND", "USER_NOT_FOUND", "PHOTO_NOT_FOUND": + return http.StatusNotFound + case "CONFLICT", "USER_EXISTS", "DUPLICATE_KEY": + return http.StatusConflict + case "TOO_MANY_REQUESTS": + return http.StatusTooManyRequests + default: + return http.StatusInternalServerError + } +} +``` + +## ✅ 数据验证 + +### 自定义验证器 +```go +// validator/custom_validators.go - 自定义验证规则 +package validator + +import ( + "regexp" + "strings" + + "github.com/go-playground/validator/v10" +) + +// RegisterCustomValidators 注册自定义验证器 +func RegisterCustomValidators(v *validator.Validate) { + v.RegisterValidation("username", validateUsername) + v.RegisterValidation("password", validatePassword) + v.RegisterValidation("phone", validatePhone) + v.RegisterValidation("slug", validateSlug) + v.RegisterValidation("file_ext", validateFileExtension) +} + +// validateUsername 验证用户名 +func validateUsername(fl validator.FieldLevel) bool { + username := fl.Field().String() + + // 3-50个字符,只允许字母、数字、下划线、短横线 + matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]{3,50}$`, username) + return matched +} + +// validatePassword 验证密码强度 +func validatePassword(fl validator.FieldLevel) bool { + password := fl.Field().String() + + // 至少8个字符,包含字母、数字和特殊字符 + if len(password) < 8 { + return false + } + + hasLetter := regexp.MustCompile(`[a-zA-Z]`).MatchString(password) + hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) + hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]`).MatchString(password) + + return hasLetter && hasNumber && hasSpecial +} + +// validatePhone 验证手机号 +func validatePhone(fl validator.FieldLevel) bool { + phone := fl.Field().String() + + // 支持多种手机号格式 + patterns := []string{ + `^1[3-9]\d{9}$`, // 中国大陆 + `^\+1[2-9]\d{9}$`, // 美国 + `^\+44[1-9]\d{8,9}$`, // 英国 + } + + for _, pattern := range patterns { + if matched, _ := regexp.MatchString(pattern, phone); matched { + return true + } + } + + return false +} + +// validateSlug 验证 URL 友好字符串 +func validateSlug(fl validator.FieldLevel) bool { + slug := fl.Field().String() + matched, _ := regexp.MatchString(`^[a-z0-9]+(?:-[a-z0-9]+)*$`, slug) + return matched +} + +// validateFileExtension 验证文件扩展名 +func validateFileExtension(fl validator.FieldLevel) bool { + filename := fl.Field().String() + ext := strings.ToLower(getFileExtension(filename)) + + // 允许的图片文件扩展名 + allowedExts := []string{".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"} + + for _, allowedExt := range allowedExts { + if ext == allowedExt { + return true + } + } + + return false +} + +// getFileExtension 获取文件扩展名 +func getFileExtension(filename string) string { + lastDot := strings.LastIndex(filename, ".") + if lastDot == -1 { + return "" + } + return filename[lastDot:] +} + +// ValidationError 验证错误结构 +type ValidationError struct { + Field string `json:"field"` + Value string `json:"value"` + Tag string `json:"tag"` + Message string `json:"message"` +} + +// FormatValidationErrors 格式化验证错误 +func FormatValidationErrors(err error) []ValidationError { + var errors []ValidationError + + if validationErrors, ok := err.(validator.ValidationErrors); ok { + for _, e := range validationErrors { + errors = append(errors, ValidationError{ + Field: e.Field(), + Value: e.Value().(string), + Tag: e.Tag(), + Message: getErrorMessage(e), + }) + } + } + + return errors +} + +// getErrorMessage 获取错误消息 +func getErrorMessage(e validator.FieldError) string { + switch e.Tag() { + case "required": + return e.Field() + " is required" + case "email": + return e.Field() + " must be a valid email" + case "min": + return e.Field() + " must be at least " + e.Param() + " characters" + case "max": + return e.Field() + " must be at most " + e.Param() + " characters" + case "username": + return e.Field() + " must be 3-50 characters and contain only letters, numbers, underscores, and hyphens" + case "password": + return e.Field() + " must be at least 8 characters and contain letters, numbers, and special characters" + case "phone": + return e.Field() + " must be a valid phone number" + case "slug": + return e.Field() + " must be a valid URL slug" + case "file_ext": + return e.Field() + " must have a valid image file extension" + default: + return e.Field() + " is invalid" + } +} +``` + +## 🛠️ 通用工具 + +### 字符串工具 +```go +// utils/string.go - 字符串工具 +package utils + +import ( + "crypto/rand" + "math/big" + "regexp" + "strings" + "unicode" +) + +// GenerateRandomString 生成随机字符串 +func GenerateRandomString(length int) (string, error) { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + result := make([]byte, length) + + for i := range result { + index, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + result[i] = charset[index.Int64()] + } + + return string(result), nil +} + +// ToSnakeCase 转换为蛇形命名 +func ToSnakeCase(str string) string { + var result []rune + + for i, r := range str { + if unicode.IsUpper(r) { + if i > 0 { + result = append(result, '_') + } + result = append(result, unicode.ToLower(r)) + } else { + result = append(result, r) + } + } + + return string(result) +} + +// ToCamelCase 转换为驼峰命名 +func ToCamelCase(str string) string { + parts := strings.Split(str, "_") + for i := 1; i < len(parts); i++ { + if len(parts[i]) > 0 { + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + } + return strings.Join(parts, "") +} + +// Truncate 截断字符串 +func Truncate(str string, length int) string { + if len(str) <= length { + return str + } + return str[:length] + "..." +} + +// IsValidEmail 验证邮箱格式 +func IsValidEmail(email string) bool { + pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + matched, _ := regexp.MatchString(pattern, email) + return matched +} + +// SanitizeFilename 清理文件名 +func SanitizeFilename(filename string) string { + // 移除或替换不安全的字符 + reg := regexp.MustCompile(`[<>:"/\\|?*]`) + return reg.ReplaceAllString(filename, "_") +} + +// Contains 检查字符串切片是否包含指定字符串 +func Contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} +``` + +### 加密工具 +```go +// utils/crypto.go - 加密工具 +package utils + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "golang.org/x/crypto/bcrypt" +) + +// HashPassword 加密密码 +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(bytes), nil +} + +// CheckPassword 验证密码 +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// MD5Hash 计算MD5哈希 +func MD5Hash(data string) string { + hash := md5.Sum([]byte(data)) + return hex.EncodeToString(hash[:]) +} + +// SHA256Hash 计算SHA256哈希 +func SHA256Hash(data string) string { + hash := sha256.Sum256([]byte(data)) + return hex.EncodeToString(hash[:]) +} +``` + +## 🔗 中间件 + +### CORS 中间件 +```go +// middleware/cors.go - CORS 中间件 +package middleware + +import ( + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// CORS 创建CORS中间件 +func CORS() gin.HandlerFunc { + config := cors.DefaultConfig() + config.AllowOrigins = []string{"*"} + config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} + config.AllowHeaders = []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"} + config.ExposeHeaders = []string{"X-Request-ID"} + config.AllowCredentials = true + + return cors.New(config) +} +``` + +### 请求ID中间件 +```go +// middleware/request_id.go - 请求ID中间件 +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// RequestID 请求ID中间件 +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = uuid.New().String() + } + + c.Set("X-Request-ID", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} +``` + +## 💡 最佳实践 + +### 包设计原则 +1. **单一职责**: 每个包只负责一个明确的功能 +2. **接口导向**: 优先定义接口,便于测试和替换 +3. **零依赖**: 包应尽量减少外部依赖 +4. **文档完善**: 提供清晰的使用文档和示例 +5. **向后兼容**: 保持API的向后兼容性 + +### 代码质量 +1. **测试覆盖**: 为所有公共函数编写测试 +2. **错误处理**: 合理处理和传播错误 +3. **性能考虑**: 关注内存分配和性能影响 +4. **线程安全**: 确保并发使用的安全性 +5. **资源管理**: 及时释放资源 + +### 使用建议 +1. **导入路径**: 使用清晰的导入路径 +2. **命名规范**: 遵循 Go 语言命名约定 +3. **版本管理**: 使用语义化版本管理 +4. **依赖管理**: 合理管理第三方依赖 +5. **配置化**: 支持配置化使用 + +本模块提供了应用的基础设施和工具支持,确保代码的可复用性和一致性。 \ No newline at end of file diff --git a/backend/start.sh b/backend/start.sh new file mode 100755 index 0000000..d1ca2c9 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# 摄影作品集后端启动脚本 + +echo "=== 摄影作品集后端启动 ===" + +# 检查Go是否安装 +if ! command -v go &> /dev/null; then + echo "错误: Go 未安装,请先安装 Go 1.21 或更高版本" + exit 1 +fi + +# 检查Go版本 +GO_VERSION=$(go version | cut -d' ' -f3 | sed 's/go//') +echo "Go 版本: $GO_VERSION" + +# 进入项目目录 +cd "$(dirname "$0")" + +# 创建必要的目录 +echo "创建必要的目录..." +mkdir -p uploads/photos +mkdir -p uploads/thumbnails +mkdir -p uploads/temp +mkdir -p logs + +# 安装依赖 +echo "安装Go模块依赖..." +go mod tidy + +# 设置环境变量 +export CONFIG_PATH="configs/config.dev.yaml" + +# 启动服务器 +echo "启动后端服务器..." +echo "配置文件: $CONFIG_PATH" +echo "访问地址: http://localhost:8080" +echo "健康检查: http://localhost:8080/health" +echo "API文档: 请查看 test_api.http 文件" +echo "" +echo "按 Ctrl+C 停止服务器" +echo "" + +# 运行服务器 +go run cmd/server/main_simple.go \ No newline at end of file diff --git a/backend/test_api.http b/backend/test_api.http new file mode 100644 index 0000000..85e88cd --- /dev/null +++ b/backend/test_api.http @@ -0,0 +1,116 @@ +# Photography Backend API 测试文件 + +### 健康检查 +GET http://localhost:8080/health + +### 获取数据库统计 +GET http://localhost:8080/stats + +### 用户注册 +POST http://localhost:8080/api/v1/auth/register +Content-Type: application/json + +{ + "username": "testuser", + "email": "test@example.com", + "password": "password123", + "name": "测试用户" +} + +### 用户登录 +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "username": "testuser", + "password": "password123" +} + +### 获取所有用户 +GET http://localhost:8080/api/v1/users + +### 创建用户 +POST http://localhost:8080/api/v1/users +Content-Type: application/json + +{ + "username": "newuser", + "email": "newuser@example.com", + "password": "password123", + "name": "新用户", + "role": "user" +} + +### 获取所有分类 +GET http://localhost:8080/api/v1/categories + +### 创建分类 +POST http://localhost:8080/api/v1/categories +Content-Type: application/json + +{ + "name": "风景摄影", + "description": "自然风光摄影作品", + "user_id": 1, + "color": "#10B981" +} + +### 获取所有标签 +GET http://localhost:8080/api/v1/tags + +### 创建标签 +POST http://localhost:8080/api/v1/tags +Content-Type: application/json + +{ + "name": "自然", + "description": "自然风光主题", + "user_id": 1, + "color": "#10B981" +} + +### 获取所有相册 +GET http://localhost:8080/api/v1/albums + +### 创建相册 +POST http://localhost:8080/api/v1/albums +Content-Type: application/json + +{ + "title": "我的摄影作品", + "description": "个人摄影作品集", + "user_id": 1, + "is_public": true +} + +### 获取所有照片 +GET http://localhost:8080/api/v1/photos + +### 创建照片记录 +POST http://localhost:8080/api/v1/photos +Content-Type: application/json + +{ + "title": "美丽风景", + "description": "在山顶拍摄的日出", + "filename": "sunrise.jpg", + "original_url": "http://localhost:8080/uploads/photos/sunrise.jpg", + "file_size": 1024000, + "mime_type": "image/jpeg", + "user_id": 1, + "category_id": 1 +} + +### 文件上传 (需要使用支持文件上传的客户端) +# POST http://localhost:8080/api/v1/upload/photo +# Content-Type: multipart/form-data +# Authorization: Bearer YOUR_JWT_TOKEN +# +# photo: [选择图片文件] +# title: 照片标题 +# description: 照片描述 +# category_id: 1 + +### 获取上传统计 (需要管理员权限) +# GET http://localhost:8080/api/v1/upload/stats +# Authorization: Bearer ADMIN_JWT_TOKEN \ No newline at end of file diff --git a/backend/tests/CLAUDE.md b/backend/tests/CLAUDE.md new file mode 100644 index 0000000..dc78714 --- /dev/null +++ b/backend/tests/CLAUDE.md @@ -0,0 +1,834 @@ +# Test Module - CLAUDE.md + +本文件为 Claude Code 在测试模块中工作时提供指导。 + +## 🎯 模块概览 + +测试模块提供完整的测试策略和工具,确保代码质量和系统可靠性。 + +### 主要职责 +- 🧪 单元测试和集成测试 +- 🔧 测试工具和辅助函数 +- 📊 测试覆盖率报告 +- 🚀 性能测试和基准测试 +- 🎭 Mock 对象和测试数据 + +## 📁 模块结构 + +``` +tests/ +├── CLAUDE.md # 📋 当前文件 - 测试编写和执行指导 +├── unit/ # 🧪 单元测试 +│ ├── service/ # 服务层测试 +│ │ ├── user_service_test.go +│ │ ├── photo_service_test.go +│ │ └── auth_service_test.go +│ ├── repository/ # 仓储层测试 +│ │ ├── user_repository_test.go +│ │ └── photo_repository_test.go +│ ├── handler/ # 处理器测试 +│ │ ├── user_handler_test.go +│ │ └── photo_handler_test.go +│ └── utils/ # 工具函数测试 +│ ├── string_test.go +│ └── crypto_test.go +├── integration/ # 🔗 集成测试 +│ ├── api/ # API 集成测试 +│ │ ├── user_api_test.go +│ │ ├── photo_api_test.go +│ │ └── auth_api_test.go +│ ├── database/ # 数据库集成测试 +│ │ ├── migration_test.go +│ │ └── transaction_test.go +│ └── storage/ # 存储集成测试 +│ └── file_storage_test.go +├── e2e/ # 🎯 端到端测试 +│ ├── user_journey_test.go # 用户流程测试 +│ ├── photo_upload_test.go # 照片上传流程 +│ └── auth_flow_test.go # 认证流程测试 +├── benchmark/ # ⚡ 性能测试 +│ ├── service_bench_test.go # 服务性能测试 +│ ├── repository_bench_test.go # 仓储性能测试 +│ └── api_bench_test.go # API 性能测试 +├── fixtures/ # 📄 测试数据 +│ ├── users.json # 用户测试数据 +│ ├── photos.json # 照片测试数据 +│ └── categories.json # 分类测试数据 +├── mocks/ # 🎭 Mock 对象 +│ ├── service_mocks.go # 服务层 Mock +│ ├── repository_mocks.go # 仓储层 Mock +│ └── external_mocks.go # 外部服务 Mock +├── helpers/ # 🛠️ 测试辅助 +│ ├── database.go # 数据库测试助手 +│ ├── server.go # 测试服务器 +│ ├── assertions.go # 自定义断言 +│ └── fixtures.go # 测试数据加载 +└── config/ # ⚙️ 测试配置 + ├── test_config.yaml # 测试环境配置 + └── docker-compose.test.yml # 测试容器配置 +``` + +## 🧪 单元测试 + +### 服务层测试示例 +```go +// unit/service/user_service_test.go - 用户服务测试 +package service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "photography-backend/internal/model/dto" + "photography-backend/internal/model/entity" + "photography-backend/internal/service/user" + "photography-backend/tests/mocks" + "photography-backend/pkg/logger" +) + +// UserServiceTestSuite 用户服务测试套件 +type UserServiceTestSuite struct { + suite.Suite + userService *user.UserService + mockRepo *mocks.MockUserRepository + mockLogger logger.Logger +} + +// SetupTest 测试前置设置 +func (suite *UserServiceTestSuite) SetupTest() { + suite.mockRepo = new(mocks.MockUserRepository) + suite.mockLogger = logger.NewNoop() + suite.userService = user.NewUserService(suite.mockRepo, suite.mockLogger) +} + +// TearDownTest 测试后置清理 +func (suite *UserServiceTestSuite) TearDownTest() { + suite.mockRepo.AssertExpectations(suite.T()) +} + +// TestCreateUser_Success 测试创建用户成功 +func (suite *UserServiceTestSuite) TestCreateUser_Success() { + // Arrange + ctx := context.Background() + req := &dto.CreateUserRequest{ + Username: "testuser", + Email: "test@example.com", + Password: "password123", + } + + expectedUser := &entity.User{ + ID: 1, + Username: req.Username, + Email: req.Email, + Role: entity.UserRoleUser, + Status: entity.UserStatusActive, + } + + suite.mockRepo.On("GetByEmail", ctx, req.Email).Return(nil, repository.ErrNotFound) + suite.mockRepo.On("Create", ctx, mock.AnythingOfType("*entity.User")).Return(expectedUser, nil) + + // Act + user, err := suite.userService.CreateUser(ctx, req) + + // Assert + assert.NoError(suite.T(), err) + assert.NotNil(suite.T(), user) + assert.Equal(suite.T(), expectedUser.ID, user.ID) + assert.Equal(suite.T(), expectedUser.Username, user.Username) + assert.Equal(suite.T(), expectedUser.Email, user.Email) +} + +// TestCreateUser_UserExists 测试用户已存在 +func (suite *UserServiceTestSuite) TestCreateUser_UserExists() { + // Arrange + ctx := context.Background() + req := &dto.CreateUserRequest{ + Username: "testuser", + Email: "test@example.com", + Password: "password123", + } + + existingUser := &entity.User{ + ID: 1, + Email: req.Email, + } + + suite.mockRepo.On("GetByEmail", ctx, req.Email).Return(existingUser, nil) + + // Act + user, err := suite.userService.CreateUser(ctx, req) + + // Assert + assert.Error(suite.T(), err) + assert.Nil(suite.T(), user) + assert.Contains(suite.T(), err.Error(), "user already exists") +} + +// TestCreateUser_InvalidInput 测试无效输入 +func (suite *UserServiceTestSuite) TestCreateUser_InvalidInput() { + ctx := context.Background() + + testCases := []struct { + name string + req *dto.CreateUserRequest + }{ + { + name: "empty email", + req: &dto.CreateUserRequest{ + Username: "testuser", + Email: "", + Password: "password123", + }, + }, + { + name: "empty username", + req: &dto.CreateUserRequest{ + Username: "", + Email: "test@example.com", + Password: "password123", + }, + }, + { + name: "weak password", + req: &dto.CreateUserRequest{ + Username: "testuser", + Email: "test@example.com", + Password: "123", + }, + }, + } + + for _, tc := range testCases { + suite.T().Run(tc.name, func(t *testing.T) { + // Act + user, err := suite.userService.CreateUser(ctx, tc.req) + + // Assert + assert.Error(t, err) + assert.Nil(t, user) + }) + } +} + +// 运行测试套件 +func TestUserServiceTestSuite(t *testing.T) { + suite.Run(t, new(UserServiceTestSuite)) +} +``` + +### 仓储层测试示例 +```go +// unit/repository/user_repository_test.go - 用户仓储测试 +package repository_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "photography-backend/internal/model/entity" + "photography-backend/internal/repository/postgres" + "photography-backend/pkg/logger" + "photography-backend/tests/helpers" +) + +// UserRepositoryTestSuite 用户仓储测试套件 +type UserRepositoryTestSuite struct { + suite.Suite + db *gorm.DB + repo *postgres.UserRepository +} + +// SetupSuite 套件前置设置 +func (suite *UserRepositoryTestSuite) SetupSuite() { + // 使用内存数据库 + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + suite.Require().NoError(err) + + // 自动迁移 + err = db.AutoMigrate(&entity.User{}) + suite.Require().NoError(err) + + suite.db = db + suite.repo = postgres.NewUserRepository(db, logger.NewNoop()).(*postgres.UserRepository) +} + +// SetupTest 每个测试前清理数据 +func (suite *UserRepositoryTestSuite) SetupTest() { + helpers.CleanupDatabase(suite.db) +} + +// TearDownSuite 套件后置清理 +func (suite *UserRepositoryTestSuite) TearDownSuite() { + sqlDB, _ := suite.db.DB() + sqlDB.Close() +} + +// TestCreateUser 测试创建用户 +func (suite *UserRepositoryTestSuite) TestCreateUser() { + ctx := context.Background() + + user := &entity.User{ + Username: "testuser", + Email: "test@example.com", + Password: "hashedpassword", + Role: entity.UserRoleUser, + Status: entity.UserStatusActive, + } + + createdUser, err := suite.repo.Create(ctx, user) + + assert.NoError(suite.T(), err) + assert.NotZero(suite.T(), createdUser.ID) + assert.Equal(suite.T(), user.Username, createdUser.Username) + assert.Equal(suite.T(), user.Email, createdUser.Email) + assert.NotZero(suite.T(), createdUser.CreatedAt) + assert.NotZero(suite.T(), createdUser.UpdatedAt) +} + +// TestGetUserByEmail 测试根据邮箱获取用户 +func (suite *UserRepositoryTestSuite) TestGetUserByEmail() { + ctx := context.Background() + + // 创建测试用户 + user := helpers.CreateTestUser(suite.db, &entity.User{ + Username: "testuser", + Email: "test@example.com", + Password: "hashedpassword", + }) + + // 根据邮箱查找用户 + foundUser, err := suite.repo.GetByEmail(ctx, user.Email) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), user.ID, foundUser.ID) + assert.Equal(suite.T(), user.Email, foundUser.Email) +} + +// TestGetUserByEmail_NotFound 测试用户不存在 +func (suite *UserRepositoryTestSuite) TestGetUserByEmail_NotFound() { + ctx := context.Background() + + user, err := suite.repo.GetByEmail(ctx, "nonexistent@example.com") + + assert.Error(suite.T(), err) + assert.Nil(suite.T(), user) + assert.Equal(suite.T(), repository.ErrNotFound, err) +} + +// 运行测试套件 +func TestUserRepositoryTestSuite(t *testing.T) { + suite.Run(t, new(UserRepositoryTestSuite)) +} +``` + +## 🔗 集成测试 + +### API 集成测试示例 +```go +// integration/api/user_api_test.go - 用户API集成测试 +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "photography-backend/internal/model/dto" + "photography-backend/tests/helpers" +) + +// UserAPITestSuite 用户API测试套件 +type UserAPITestSuite struct { + suite.Suite + server *helpers.TestServer + router *gin.Engine +} + +// SetupSuite 套件前置设置 +func (suite *UserAPITestSuite) SetupSuite() { + suite.server = helpers.NewTestServer() + suite.router = suite.server.Router() +} + +// SetupTest 每个测试前重置数据 +func (suite *UserAPITestSuite) SetupTest() { + suite.server.ResetDatabase() +} + +// TearDownSuite 套件后置清理 +func (suite *UserAPITestSuite) TearDownSuite() { + suite.server.Close() +} + +// TestCreateUser_Success 测试创建用户成功 +func (suite *UserAPITestSuite) TestCreateUser_Success() { + // Arrange + req := &dto.CreateUserRequest{ + Username: "testuser", + Email: "test@example.com", + Password: "password123", + } + + jsonData, _ := json.Marshal(req) + + // Act + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("POST", "/api/v1/users", bytes.NewBuffer(jsonData)) + httpReq.Header.Set("Content-Type", "application/json") + + suite.router.ServeHTTP(w, httpReq) + + // Assert + assert.Equal(suite.T(), http.StatusCreated, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(suite.T(), err) + + assert.True(suite.T(), response["success"].(bool)) + assert.NotNil(suite.T(), response["data"]) + + userData := response["data"].(map[string]interface{}) + assert.Equal(suite.T(), req.Username, userData["username"]) + assert.Equal(suite.T(), req.Email, userData["email"]) + assert.NotZero(suite.T(), userData["id"]) +} + +// TestCreateUser_ValidationError 测试验证错误 +func (suite *UserAPITestSuite) TestCreateUser_ValidationError() { + // Arrange + req := &dto.CreateUserRequest{ + Username: "", // 空用户名应该失败 + Email: "invalid-email", // 无效邮箱 + Password: "123", // 密码过短 + } + + jsonData, _ := json.Marshal(req) + + // Act + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("POST", "/api/v1/users", bytes.NewBuffer(jsonData)) + httpReq.Header.Set("Content-Type", "application/json") + + suite.router.ServeHTTP(w, httpReq) + + // Assert + assert.Equal(suite.T(), http.StatusBadRequest, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(suite.T(), err) + + assert.False(suite.T(), response["success"].(bool)) + assert.NotNil(suite.T(), response["error"]) +} + +// TestGetUser_Success 测试获取用户成功 +func (suite *UserAPITestSuite) TestGetUser_Success() { + // Arrange - 创建测试用户 + user := suite.server.CreateTestUser() + + // Act + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/users/%d", user.ID), nil) + + suite.router.ServeHTTP(w, httpReq) + + // Assert + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(suite.T(), err) + + assert.True(suite.T(), response["success"].(bool)) + + userData := response["data"].(map[string]interface{}) + assert.Equal(suite.T(), float64(user.ID), userData["id"]) + assert.Equal(suite.T(), user.Username, userData["username"]) + assert.Equal(suite.T(), user.Email, userData["email"]) +} + +// 运行测试套件 +func TestUserAPITestSuite(t *testing.T) { + suite.Run(t, new(UserAPITestSuite)) +} +``` + +## ⚡ 性能测试 + +### 基准测试示例 +```go +// benchmark/service_bench_test.go - 服务性能测试 +package benchmark_test + +import ( + "context" + "testing" + + "photography-backend/internal/model/dto" + "photography-backend/tests/helpers" +) + +// BenchmarkUserService_CreateUser 用户创建性能测试 +func BenchmarkUserService_CreateUser(b *testing.B) { + server := helpers.NewTestServer() + defer server.Close() + + userService := server.UserService() + ctx := context.Background() + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + req := &dto.CreateUserRequest{ + Username: fmt.Sprintf("user%d", i), + Email: fmt.Sprintf("user%d@example.com", i), + Password: "password123", + } + + _, err := userService.CreateUser(ctx, req) + if err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkUserService_GetUser 用户查询性能测试 +func BenchmarkUserService_GetUser(b *testing.B) { + server := helpers.NewTestServer() + defer server.Close() + + // 预创建测试用户 + users := make([]*entity.User, 1000) + for i := 0; i < 1000; i++ { + users[i] = server.CreateTestUser() + } + + userService := server.UserService() + ctx := context.Background() + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + userID := users[i%1000].ID + _, err := userService.GetUser(ctx, userID) + if err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkUserAPI_CreateUser API性能测试 +func BenchmarkUserAPI_CreateUser(b *testing.B) { + server := helpers.NewTestServer() + defer server.Close() + + router := server.Router() + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + req := &dto.CreateUserRequest{ + Username: fmt.Sprintf("user%d", i), + Email: fmt.Sprintf("user%d@example.com", i), + Password: "password123", + } + + jsonData, _ := json.Marshal(req) + + w := httptest.NewRecorder() + httpReq, _ := http.NewRequest("POST", "/api/v1/users", bytes.NewBuffer(jsonData)) + httpReq.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(w, httpReq) + + if w.Code != http.StatusCreated { + b.Fatalf("Expected status %d, got %d", http.StatusCreated, w.Code) + } + } +} +``` + +## 🛠️ 测试辅助工具 + +### 测试服务器 +```go +// helpers/server.go - 测试服务器 +package helpers + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "github.com/gin-gonic/gin" + + "photography-backend/internal/model/entity" + "photography-backend/internal/service" + "photography-backend/internal/repository" + "photography-backend/internal/api/handlers" + "photography-backend/pkg/logger" +) + +// TestServer 测试服务器 +type TestServer struct { + db *gorm.DB + router *gin.Engine + services *Services +} + +// Services 服务集合 +type Services struct { + UserService service.UserServicer + PhotoService service.PhotoServicer + AuthService service.AuthServicer +} + +// NewTestServer 创建测试服务器 +func NewTestServer() *TestServer { + // 创建内存数据库 + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + panic(err) + } + + // 自动迁移 + err = db.AutoMigrate( + &entity.User{}, + &entity.Photo{}, + &entity.Category{}, + &entity.Tag{}, + &entity.Album{}, + ) + if err != nil { + panic(err) + } + + // 创建logger + logger := logger.NewNoop() + + // 创建仓储 + userRepo := repository.NewUserRepository(db, logger) + photoRepo := repository.NewPhotoRepository(db, logger) + + // 创建服务 + userService := service.NewUserService(userRepo, logger) + photoService := service.NewPhotoService(photoRepo, userRepo, nil, logger) + authService := service.NewAuthService(userRepo, nil, logger) + + services := &Services{ + UserService: userService, + PhotoService: photoService, + AuthService: authService, + } + + // 创建路由 + gin.SetMode(gin.TestMode) + router := gin.New() + + // 注册路由 + v1 := router.Group("/api/v1") + { + userHandler := handlers.NewUserHandler(userService, logger) + photoHandler := handlers.NewPhotoHandler(photoService, logger) + authHandler := handlers.NewAuthHandler(authService, logger) + + v1.POST("/users", userHandler.Create) + v1.GET("/users/:id", userHandler.GetByID) + v1.PUT("/users/:id", userHandler.Update) + v1.DELETE("/users/:id", userHandler.Delete) + + v1.POST("/auth/login", authHandler.Login) + v1.POST("/auth/register", authHandler.Register) + + v1.POST("/photos", photoHandler.Create) + v1.GET("/photos/:id", photoHandler.GetByID) + } + + return &TestServer{ + db: db, + router: router, + services: services, + } +} + +// Router 获取路由器 +func (ts *TestServer) Router() *gin.Engine { + return ts.router +} + +// UserService 获取用户服务 +func (ts *TestServer) UserService() service.UserServicer { + return ts.services.UserService +} + +// CreateTestUser 创建测试用户 +func (ts *TestServer) CreateTestUser() *entity.User { + user := &entity.User{ + Username: "testuser", + Email: "test@example.com", + Password: "hashedpassword", + Role: entity.UserRoleUser, + Status: entity.UserStatusActive, + } + + result := ts.db.Create(user) + if result.Error != nil { + panic(result.Error) + } + + return user +} + +// ResetDatabase 重置数据库 +func (ts *TestServer) ResetDatabase() { + CleanupDatabase(ts.db) +} + +// Close 关闭测试服务器 +func (ts *TestServer) Close() { + sqlDB, _ := ts.db.DB() + sqlDB.Close() +} +``` + +### 数据库辅助工具 +```go +// helpers/database.go - 数据库测试助手 +package helpers + +import ( + "gorm.io/gorm" + "photography-backend/internal/model/entity" +) + +// CleanupDatabase 清理数据库 +func CleanupDatabase(db *gorm.DB) { + // 按外键依赖顺序删除 + db.Exec("DELETE FROM album_photos") + db.Exec("DELETE FROM photo_tags") + db.Exec("DELETE FROM photo_categories") + db.Exec("DELETE FROM photos") + db.Exec("DELETE FROM albums") + db.Exec("DELETE FROM tags") + db.Exec("DELETE FROM categories") + db.Exec("DELETE FROM users") + + // 重置自增ID + db.Exec("DELETE FROM sqlite_sequence") +} + +// CreateTestUser 创建测试用户 +func CreateTestUser(db *gorm.DB, user *entity.User) *entity.User { + if user.Role == "" { + user.Role = entity.UserRoleUser + } + if user.Status == "" { + user.Status = entity.UserStatusActive + } + + result := db.Create(user) + if result.Error != nil { + panic(result.Error) + } + + return user +} + +// CreateTestPhoto 创建测试照片 +func CreateTestPhoto(db *gorm.DB, photo *entity.Photo) *entity.Photo { + if photo.Status == "" { + photo.Status = entity.PhotoStatusActive + } + + result := db.Create(photo) + if result.Error != nil { + panic(result.Error) + } + + return photo +} +``` + +## 🎯 测试策略 + +### 测试金字塔 +1. **单元测试 (70%)** + - 测试单个函数或方法 + - 使用 Mock 对象隔离依赖 + - 快速执行,高覆盖率 + +2. **集成测试 (20%)** + - 测试模块间交互 + - 使用真实数据库 + - 验证接口契约 + +3. **端到端测试 (10%)** + - 测试完整用户流程 + - 使用真实环境 + - 验证业务逻辑 + +### 测试执行命令 +```bash +# 运行所有测试 +make test + +# 运行单元测试 +make test-unit + +# 运行集成测试 +make test-integration + +# 运行性能测试 +make test-benchmark + +# 生成测试覆盖率报告 +make test-coverage + +# 运行特定包的测试 +go test ./tests/unit/service/... + +# 运行性能测试 +go test -bench=. ./tests/benchmark/... + +# 生成详细的测试报告 +go test -v -race -coverprofile=coverage.out ./... +go tool cover -html=coverage.out -o coverage.html +``` + +## 💡 测试最佳实践 + +### 测试编写原则 +1. **AAA 模式**: Arrange, Act, Assert +2. **独立性**: 测试间不相互依赖 +3. **可重复**: 测试结果一致 +4. **快速执行**: 单元测试应快速完成 +5. **描述性**: 测试名称清晰描述意图 + +### Mock 使用建议 +1. **接口隔离**: 对外部依赖使用 Mock +2. **行为验证**: 验证方法调用和参数 +3. **状态验证**: 验证返回值和状态变化 +4. **Mock 生成**: 使用工具自动生成 Mock + +### 测试数据管理 +1. **固定数据**: 使用 fixtures 文件 +2. **随机数据**: 使用测试数据生成器 +3. **数据清理**: 每个测试后清理数据 +4. **事务回滚**: 使用事务确保数据隔离 + +本模块确保了代码质量和系统可靠性,是持续集成和交付的重要基础。 \ No newline at end of file