refactor: 重构后端架构,采用 Go 风格四层设计模式
## 主要变更 ### 🏗️ 架构重构 - 采用简洁的四层架构:API → Service → Repository → Model - 遵循 Go 语言最佳实践和命名规范 - 实现依赖注入和接口导向设计 - 统一错误处理和响应格式 ### 📁 目录结构优化 - 删除重复模块 (application/, domain/, infrastructure/ 等) - 规范化命名 (使用 Go 风格的 snake_case) - 清理无关文件 (package.json, node_modules/ 等) - 新增规范化的测试目录结构 ### 📚 文档系统 - 为每个模块创建详细的 CLAUDE.md 指导文件 - 包含开发规范、最佳实践和使用示例 - 支持模块化开发,缩短上下文长度 ### 🔧 开发规范 - 统一接口命名规范 (UserServicer, PhotoRepositoryr) - 标准化错误处理机制 - 完善的测试策略 (单元测试、集成测试、性能测试) - 规范化的配置管理 ### 🗂️ 新增文件 - cmd/server/ - 服务启动入口和配置 - internal/model/ - 数据模型层 (entity, dto, request) - pkg/ - 共享工具包 (logger, response, validator) - tests/ - 完整测试结构 - docs/ - API 文档和架构设计 - .gitignore - Git 忽略文件配置 ### 🗑️ 清理内容 - 删除 Node.js 相关文件 (package.json, node_modules/) - 移除重复的架构目录 - 清理临时文件和构建产物 - 删除重复的文档文件 ## 影响 - 提高代码可维护性和可扩展性 - 统一开发规范,提升团队协作效率 - 优化项目结构,符合 Go 语言生态标准 - 完善文档体系,降低上手难度
This commit is contained in:
313
backend/internal/CLAUDE.md
Normal file
313
backend/internal/CLAUDE.md
Normal file
@ -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),
|
||||
)
|
||||
```
|
||||
|
||||
这个架构设计确保了代码的可维护性、可测试性和可扩展性。遵循这些指导原则,可以构建出高质量的后端服务。
|
||||
565
backend/internal/api/CLAUDE.md
Normal file
565
backend/internal/api/CLAUDE.md
Normal file
@ -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 的入口和门面,确保接口设计合理、响应格式统一、错误处理完善是项目成功的关键。
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
684
backend/internal/model/CLAUDE.md
Normal file
684
backend/internal/model/CLAUDE.md
Normal file
@ -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 注入
|
||||
|
||||
本模块是数据层的基础,确保模型设计的合理性和一致性是关键。
|
||||
@ -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"`
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
873
backend/internal/repository/CLAUDE.md
Normal file
873
backend/internal/repository/CLAUDE.md
Normal file
@ -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. **性能测试**: 测试查询性能和并发性能
|
||||
|
||||
本模块是数据访问的核心,确保数据操作的正确性和性能是关键。
|
||||
@ -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)
|
||||
|
||||
// 计算最大层级
|
||||
// 这里简化处理,实际应用中可能需要递归查询
|
||||
|
||||
549
backend/internal/service/CLAUDE.md
Normal file
549
backend/internal/service/CLAUDE.md
Normal file
@ -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 注入
|
||||
|
||||
本模块是业务逻辑的核心,确保代码质量和测试覆盖率是关键。
|
||||
@ -2,7 +2,6 @@ package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"photography-backend/internal/models"
|
||||
"photography-backend/internal/repository/postgres"
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
187
backend/internal/service/upload/upload_service.go
Normal file
187
backend/internal/service/upload/upload_service.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user