# 摄影作品集网站 - Golang项目架构文档 ## 1. 项目概述 ### 1.1 架构设计理念 - **Clean Architecture**: 采用整洁架构,分离业务逻辑和技术实现 - **Domain-Driven Design**: 以领域为核心的设计方法 - **微服务友好**: 模块化设计,便于后续拆分为微服务 - **高性能**: 充分利用Go语言的并发特性 - **可测试**: 依赖注入和接口抽象,便于单元测试 ### 1.2 技术栈选择 ```go // 核心框架 gin-gonic/gin // Web框架 gorm.io/gorm // ORM框架 redis/go-redis // Redis客户端 golang-jwt/jwt // JWT认证 // 数据库 gorm.io/driver/postgres // PostgreSQL驱动 golang-migrate/migrate // 数据库迁移 // 图片处理 h2non/bimg // 图片处理 (基于libvips) disintegration/imaging // 图片处理备选方案 // 文件存储 minio/minio-go // MinIO/S3客户端 aws/aws-sdk-go // AWS SDK // 配置管理 spf13/viper // 配置管理 joho/godotenv // 环境变量 // 日志系统 sirupsen/logrus // 结构化日志 lumberjack.v2 // 日志轮转 opentracing/opentracing-go // 链路追踪 jaegertracing/jaeger-client-go // Jaeger客户端 // 验证和工具 go-playground/validator // 数据验证 google/uuid // UUID生成 shopspring/decimal // 精确小数 // 测试和Mock stretchr/testify // 测试框架 golang/mock // Mock生成 ``` ### 1.3 项目结构概览 ``` photography-backend/ ├── cmd/ # 应用程序入口 │ └── server/ │ └── main.go ├── internal/ # 私有应用代码 │ ├── api/ # API层 │ ├── service/ # 业务逻辑层 │ ├── repository/ # 数据访问层 │ ├── domain/ # 领域模型 │ ├── dto/ # 数据传输对象 │ └── infrastructure/ # 基础设施层 ├── pkg/ # 公共库代码 │ ├── config/ # 配置管理 │ ├── database/ # 数据库连接 │ ├── logger/ # 日志系统 │ ├── tracing/ # 链路追踪 │ ├── middleware/ # 中间件 │ ├── storage/ # 存储服务 │ ├── cache/ # 缓存服务 │ ├── queue/ # 队列服务 │ └── utils/ # 工具函数 ├── migrations/ # 数据库迁移 ├── scripts/ # 构建和部署脚本 ├── docker/ # Docker配置 ├── docs/ # 项目文档 ├── test/ # 集成测试 ├── go.mod # Go模块定义 ├── go.sum # 依赖版本锁定 ├── Makefile # 构建任务 └── README.md # 项目说明 ``` ## 2. 详细架构设计 ### 2.1 分层架构 #### 2.1.1 架构图 ``` ┌─────────────────────────────────────────────────────────┐ │ API Layer (Gin) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ REST API │ │ WebSocket │ │ GraphQL │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ ├─────────────────────────────────────────────────────────┤ │ Service Layer (Business Logic) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ PhotoService│ │CategorySvc │ │ AuthService│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ ├─────────────────────────────────────────────────────────┤ │ Repository Layer (Data Access) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ PhotoRepo │ │CategoryRepo │ │ UserRepo │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ ├─────────────────────────────────────────────────────────┤ │ Infrastructure Layer │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ PostgreSQL │ │ Redis │ │ MinIO/S3 │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` #### 2.1.2 依赖关系 ``` API Layer ──→ Service Layer ──→ Repository Layer ──→ Infrastructure Layer ↑ ↑ ↑ ↑ │ │ │ │ Handlers Business Logic Data Access External Services ``` ### 2.2 领域模型设计 #### 2.2.1 核心领域实体 ```go // internal/domain/photo.go package domain import ( "time" "github.com/google/uuid" "github.com/opentracing/opentracing-go" ) // Photo 照片领域实体 type Photo struct { ID PhotoID `json:"id"` Title string `json:"title"` Description string `json:"description"` Slug string `json:"slug"` Status PhotoStatus `json:"status"` Visibility Visibility `json:"visibility"` // 文件信息 FileInfo FileInfo `json:"file_info"` Formats []PhotoFormat `json:"formats"` // EXIF数据 EXIF *EXIFData `json:"exif,omitempty"` // 位置信息 Location *Location `json:"location,omitempty"` // 关联关系 Categories []CategoryID `json:"categories"` Tags []TagID `json:"tags"` // 统计信息 Stats PhotoStats `json:"stats"` // 时间信息 TakenAt *time.Time `json:"taken_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // 元数据 Metadata map[string]interface{} `json:"metadata,omitempty"` } // PhotoID 照片ID值对象 type PhotoID struct { value uint } func NewPhotoID(id uint) PhotoID { return PhotoID{value: id} } func (p PhotoID) Value() uint { return p.value } func (p PhotoID) String() string { return fmt.Sprintf("photo-%d", p.value) } // PhotoStatus 照片状态枚举 type PhotoStatus string const ( PhotoStatusDraft PhotoStatus = "draft" PhotoStatusPublished PhotoStatus = "published" PhotoStatusArchived PhotoStatus = "archived" PhotoStatusProcessing PhotoStatus = "processing" ) func (s PhotoStatus) IsValid() bool { switch s { case PhotoStatusDraft, PhotoStatusPublished, PhotoStatusArchived, PhotoStatusProcessing: return true default: return false } } // Visibility 可见性枚举 type Visibility string const ( VisibilityPublic Visibility = "public" VisibilityPrivate Visibility = "private" VisibilityPassword Visibility = "password" ) // FileInfo 文件信息值对象 type FileInfo struct { OriginalFilename string `json:"original_filename"` FileSize int64 `json:"file_size"` MimeType string `json:"mime_type"` FileHash string `json:"file_hash"` } // PhotoFormat 照片格式值对象 type PhotoFormat struct { Type FormatType `json:"type"` FilePath string `json:"file_path"` FileSize int64 `json:"file_size"` Width int `json:"width"` Height int `json:"height"` Quality int `json:"quality"` } type FormatType string const ( FormatOriginal FormatType = "original" FormatJPG FormatType = "jpg" FormatWebP FormatType = "webp" FormatThumbSmall FormatType = "thumb_small" FormatThumbMedium FormatType = "thumb_medium" FormatThumbLarge FormatType = "thumb_large" FormatDisplay FormatType = "display" ) // EXIFData EXIF数据值对象 type EXIFData struct { Camera string `json:"camera,omitempty"` Lens string `json:"lens,omitempty"` ISO int `json:"iso,omitempty"` Aperture string `json:"aperture,omitempty"` ShutterSpeed string `json:"shutter_speed,omitempty"` FocalLength string `json:"focal_length,omitempty"` } // Location 位置信息值对象 type Location struct { Name string `json:"name"` Latitude float64 `json:"latitude"` Longitude float64 `json:"longitude"` Country string `json:"country,omitempty"` City string `json:"city,omitempty"` } // PhotoStats 照片统计值对象 type PhotoStats struct { ViewCount int `json:"view_count"` LikeCount int `json:"like_count"` DownloadCount int `json:"download_count"` } ``` #### 2.2.2 领域服务 ```go // internal/domain/service/photo_domain_service.go package service import ( "context" "fmt" "strings" "photography-backend/internal/domain" ) // PhotoDomainService 照片领域服务 type PhotoDomainService struct { slugGenerator SlugGenerator exifExtractor EXIFExtractor } // SlugGenerator 别名生成器接口 type SlugGenerator interface { Generate(title string) string } // EXIFExtractor EXIF提取器接口 type EXIFExtractor interface { Extract(filePath string) (*domain.EXIFData, error) } func NewPhotoDomainService(slugGen SlugGenerator, exifExt EXIFExtractor) *PhotoDomainService { return &PhotoDomainService{ slugGenerator: slugGen, exifExtractor: exifExt, } } // CreatePhoto 创建照片聚合根 func (s *PhotoDomainService) CreatePhoto(cmd CreatePhotoCommand) (*domain.Photo, error) { // 生成唯一别名 slug := s.slugGenerator.Generate(cmd.Title) // 提取EXIF数据 exif, err := s.exifExtractor.Extract(cmd.FilePath) if err != nil { // EXIF提取失败不影响照片创建 exif = nil } // 创建照片实体 photo := &domain.Photo{ ID: domain.NewPhotoID(0), // 由数据库生成 Title: cmd.Title, Description: cmd.Description, Slug: slug, Status: domain.PhotoStatusProcessing, Visibility: domain.VisibilityPublic, FileInfo: domain.FileInfo{ OriginalFilename: cmd.OriginalFilename, FileSize: cmd.FileSize, MimeType: cmd.MimeType, }, EXIF: exif, Location: cmd.Location, Categories: cmd.Categories, Tags: cmd.Tags, TakenAt: cmd.TakenAt, Metadata: cmd.Metadata, } // 验证照片数据 if err := s.validatePhoto(photo); err != nil { return nil, fmt.Errorf("photo validation failed: %w", err) } return photo, nil } // validatePhoto 验证照片数据 func (s *PhotoDomainService) validatePhoto(photo *domain.Photo) error { if strings.TrimSpace(photo.Title) == "" { return fmt.Errorf("title cannot be empty") } if !photo.Status.IsValid() { return fmt.Errorf("invalid photo status: %s", photo.Status) } // 验证文件信息 if photo.FileInfo.FileSize <= 0 { return fmt.Errorf("invalid file size") } return nil } // UpdatePhotoStatus 更新照片状态 func (s *PhotoDomainService) UpdatePhotoStatus(photo *domain.Photo, newStatus domain.PhotoStatus) error { if !newStatus.IsValid() { return fmt.Errorf("invalid status: %s", newStatus) } // 业务规则:已归档的照片不能直接发布 if photo.Status == domain.PhotoStatusArchived && newStatus == domain.PhotoStatusPublished { return fmt.Errorf("archived photo cannot be published directly") } photo.Status = newStatus return nil } // CreatePhotoCommand 创建照片命令 type CreatePhotoCommand struct { Title string Description string FilePath string OriginalFilename string FileSize int64 MimeType string Location *domain.Location Categories []domain.CategoryID Tags []domain.TagID TakenAt *time.Time Metadata map[string]interface{} } ``` ### 2.3 服务层设计 #### 2.3.1 应用服务接口 ```go // internal/service/photo_service.go package service import ( "context" "photography-backend/internal/domain" "photography-backend/internal/dto" ) // PhotoService 照片应用服务接口 type PhotoService interface { // 查询操作 GetPhotos(ctx context.Context, req dto.PhotoListRequest) (*dto.PhotoListResponse, error) GetPhotoByID(ctx context.Context, id uint) (*dto.PhotoResponse, error) GetPhotoBySlug(ctx context.Context, slug string) (*dto.PhotoResponse, error) SearchPhotos(ctx context.Context, req dto.PhotoSearchRequest) (*dto.PhotoSearchResponse, error) // 命令操作 CreatePhoto(ctx context.Context, req dto.CreatePhotoRequest) (*dto.PhotoResponse, error) UpdatePhoto(ctx context.Context, id uint, req dto.UpdatePhotoRequest) (*dto.PhotoResponse, error) DeletePhoto(ctx context.Context, id uint) error UpdatePhotoStatus(ctx context.Context, id uint, status string) error // 批量操作 BatchUpdatePhotos(ctx context.Context, req dto.BatchUpdatePhotosRequest) error BatchDeletePhotos(ctx context.Context, photoIDs []uint) error // 统计操作 GetPhotoStats(ctx context.Context) (*dto.PhotoStatsResponse, error) } // photoServiceImpl 照片应用服务实现 type photoServiceImpl struct { photoRepo repository.PhotoRepository categoryRepo repository.CategoryRepository tagRepo repository.TagRepository photoDomainSvc *service.PhotoDomainService imageProcessor ImageProcessor cacheService cache.Service eventPublisher event.Publisher logger *logrus.Logger } func NewPhotoService( photoRepo repository.PhotoRepository, categoryRepo repository.CategoryRepository, tagRepo repository.TagRepository, photoDomainSvc *service.PhotoDomainService, imageProcessor ImageProcessor, cacheService cache.Service, eventPublisher event.Publisher, logger *logrus.Logger, ) PhotoService { return &photoServiceImpl{ photoRepo: photoRepo, categoryRepo: categoryRepo, tagRepo: tagRepo, photoDomainSvc: photoDomainSvc, imageProcessor: imageProcessor, cacheService: cacheService, eventPublisher: eventPublisher, logger: logger, } } // GetPhotos 获取照片列表 func (s *photoServiceImpl) GetPhotos(ctx context.Context, req dto.PhotoListRequest) (*dto.PhotoListResponse, error) { // 创建span用于链路追踪 span, ctx := opentracing.StartSpanFromContext(ctx, "PhotoService.GetPhotos") defer span.Finish() // 获取trace ID traceID := s.getTraceID(span) logger := s.logger.WithField("trace_id", traceID) // 参数验证 if err := req.Validate(); err != nil { span.SetTag("error", true) span.LogKV("event", "validation_error", "error", err.Error()) logger.WithError(err).Error("Invalid request parameters") return nil, fmt.Errorf("invalid request: %w", err) } // 构建查询条件 filter := s.buildPhotoFilter(req) // 尝试从缓存获取 cacheKey := s.buildCacheKey("photos:list", filter) if cached, err := s.cacheService.Get(ctx, cacheKey); err == nil { var response dto.PhotoListResponse if err := json.Unmarshal(cached, &response); err == nil { return &response, nil } } // 从数据库查询 photos, total, err := s.photoRepo.FindWithPagination(ctx, filter) if err != nil { span.SetTag("error", true) span.LogKV("event", "repository_error", "error", err.Error()) logger.WithError(err).Error("Failed to get photos from repository") return nil, fmt.Errorf("failed to get photos: %w", err) } // 转换为DTO photoResponses := make([]dto.PhotoSummary, 0, len(photos)) for _, photo := range photos { photoResponses = append(photoResponses, dto.NewPhotoSummary(photo)) } response := &dto.PhotoListResponse{ Photos: photoResponses, Pagination: dto.PaginationResponse{ Page: req.Page, Limit: req.Limit, Total: total, TotalPages: (total + int64(req.Limit) - 1) / int64(req.Limit), HasNext: req.Page < int((total+int64(req.Limit)-1)/int64(req.Limit)), HasPrev: req.Page > 1, }, } // 缓存结果 if data, err := json.Marshal(response); err == nil { s.cacheService.Set(ctx, cacheKey, data, 10*time.Minute) } // 记录成功日志 span.SetTag("photos_count", len(photos)) span.SetTag("total_count", total) logger.WithFields(logrus.Fields{ "photos_count": len(photos), "total_count": total, "page": req.Page, "limit": req.Limit, }).Info("Photos retrieved successfully") return response, nil } // getTraceID 获取trace ID func (s *photoServiceImpl) getTraceID(span opentracing.Span) string { if span == nil { return "" } // 从span context获取trace ID spanContext := span.Context() if jaegerSpanContext, ok := spanContext.(interface{ TraceID() string }); ok { return jaegerSpanContext.TraceID() } // 如果无法获取trace ID,生成一个UUID作为fallback return uuid.New().String() } // CreatePhoto 创建照片 func (s *photoServiceImpl) CreatePhoto(ctx context.Context, req dto.CreatePhotoRequest) (*dto.PhotoResponse, error) { // 创建span用于链路追踪 span, ctx := opentracing.StartSpanFromContext(ctx, "PhotoService.CreatePhoto") defer span.Finish() // 获取trace ID traceID := s.getTraceID(span) logger := s.logger.WithField("trace_id", traceID) // 参数验证 if err := req.Validate(); err != nil { span.SetTag("error", true) span.LogKV("event", "validation_error", "error", err.Error()) logger.WithError(err).Error("Invalid request parameters") return nil, fmt.Errorf("invalid request: %w", err) } // 构建领域命令 cmd := service.CreatePhotoCommand{ Title: req.Title, Description: req.Description, FilePath: req.FilePath, OriginalFilename: req.OriginalFilename, FileSize: req.FileSize, MimeType: req.MimeType, Location: req.Location, Categories: req.Categories, Tags: req.Tags, TakenAt: req.TakenAt, Metadata: req.Metadata, } // 使用领域服务创建照片 photo, err := s.photoDomainSvc.CreatePhoto(cmd) if err != nil { return nil, fmt.Errorf("failed to create photo: %w", err) } // 开始数据库事务 tx, err := s.photoRepo.BeginTx(ctx) if err != nil { return nil, fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() // 保存照片到数据库 if err := s.photoRepo.Create(ctx, tx, photo); err != nil { span.SetTag("error", true) span.LogKV("event", "database_error", "error", err.Error()) logger.WithError(err).Error("Failed to save photo to database") return nil, fmt.Errorf("failed to save photo: %w", err) } // 处理分类关联 if len(req.Categories) > 0 { if err := s.photoRepo.UpdateCategories(ctx, tx, photo.ID, req.Categories); err != nil { return nil, fmt.Errorf("failed to update categories: %w", err) } } // 处理标签关联 if len(req.Tags) > 0 { if err := s.photoRepo.UpdateTags(ctx, tx, photo.ID, req.Tags); err != nil { return nil, fmt.Errorf("failed to update tags: %w", err) } } // 提交事务 if err := tx.Commit(); err != nil { return nil, fmt.Errorf("failed to commit transaction: %w", err) } // 异步处理图片 go func() { if err := s.imageProcessor.ProcessPhoto(photo); err != nil { s.logger.WithError(err).WithField("photo_id", photo.ID).Error("Failed to process photo") } }() // 发布领域事件 event := event.PhotoCreated{ PhotoID: photo.ID.Value(), Title: photo.Title, CreatedAt: photo.CreatedAt, } s.eventPublisher.Publish(ctx, event) // 清除相关缓存 s.invalidateCache(ctx, "photos:*", "categories:*", "stats:*") // 记录成功日志 span.SetTag("photo_id", photo.ID.Value()) span.SetTag("photo_title", photo.Title) logger.WithFields(logrus.Fields{ "photo_id": photo.ID.Value(), "photo_title": photo.Title, "photo_slug": photo.Slug, }).Info("Photo created successfully") return dto.NewPhotoResponse(photo), nil } ``` ### 2.4 数据访问层设计 #### 2.4.1 Repository接口 ```go // internal/repository/photo_repository.go package repository import ( "context" "database/sql" "photography-backend/internal/domain" ) // PhotoRepository 照片数据访问接口 type PhotoRepository interface { // 基础CRUD Create(ctx context.Context, tx *sql.Tx, photo *domain.Photo) error Update(ctx context.Context, tx *sql.Tx, photo *domain.Photo) error Delete(ctx context.Context, tx *sql.Tx, id domain.PhotoID) error // 查询操作 FindByID(ctx context.Context, id domain.PhotoID) (*domain.Photo, error) FindBySlug(ctx context.Context, slug string) (*domain.Photo, error) FindWithPagination(ctx context.Context, filter PhotoFilter) ([]*domain.Photo, int64, error) Search(ctx context.Context, query string, filter PhotoFilter) ([]*domain.Photo, int64, error) // 关联操作 UpdateCategories(ctx context.Context, tx *sql.Tx, photoID domain.PhotoID, categoryIDs []domain.CategoryID) error UpdateTags(ctx context.Context, tx *sql.Tx, photoID domain.PhotoID, tagIDs []domain.TagID) error UpdateFormats(ctx context.Context, tx *sql.Tx, photoID domain.PhotoID, formats []domain.PhotoFormat) error // 批量操作 BatchUpdate(ctx context.Context, tx *sql.Tx, photoIDs []domain.PhotoID, updates map[string]interface{}) error BatchDelete(ctx context.Context, tx *sql.Tx, photoIDs []domain.PhotoID) error // 统计操作 Count(ctx context.Context, filter PhotoFilter) (int64, error) GetStats(ctx context.Context) (*PhotoStats, error) // 事务管理 BeginTx(ctx context.Context) (*sql.Tx, error) } // PhotoFilter 照片查询过滤器 type PhotoFilter struct { Status *domain.PhotoStatus Visibility *domain.Visibility CategoryIDs []domain.CategoryID TagIDs []domain.TagID Search string DateFrom *time.Time DateTo *time.Time Page int Limit int SortBy string SortOrder string } // PhotoStats 照片统计数据 type PhotoStats struct { TotalPhotos int64 PublishedPhotos int64 DraftPhotos int64 ArchivedPhotos int64 TotalViews int64 TotalLikes int64 StorageUsed int64 } ``` #### 2.4.2 GORM实现 ```go // internal/infrastructure/persistence/photo_repository_impl.go package persistence import ( "context" "database/sql" "fmt" "strings" "time" "gorm.io/gorm" "photography-backend/internal/domain" "photography-backend/internal/repository" "photography-backend/internal/infrastructure/persistence/model" ) // photoRepositoryImpl GORM实现的照片仓库 type photoRepositoryImpl struct { db *gorm.DB } func NewPhotoRepository(db *gorm.DB) repository.PhotoRepository { return &photoRepositoryImpl{db: db} } // Create 创建照片 func (r *photoRepositoryImpl) Create(ctx context.Context, tx *sql.Tx, photo *domain.Photo) error { // 转换为数据库模型 photoModel := model.FromDomainPhoto(photo) // 使用事务或普通连接 db := r.getDB(tx) // 创建照片记录 if err := db.WithContext(ctx).Create(&photoModel).Error; err != nil { return fmt.Errorf("failed to create photo: %w", err) } // 更新领域对象ID photo.ID = domain.NewPhotoID(photoModel.ID) return nil } // FindWithPagination 分页查询照片 func (r *photoRepositoryImpl) FindWithPagination(ctx context.Context, filter repository.PhotoFilter) ([]*domain.Photo, int64, error) { var photoModels []model.Photo var total int64 // 构建查询 query := r.db.WithContext(ctx).Model(&model.Photo{}) // 应用过滤条件 query = r.applyFilters(query, filter) // 预加载关联数据 query = query.Preload("Categories").Preload("Tags").Preload("Formats") // 统计总数 if err := query.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count photos: %w", err) } // 应用分页和排序 offset := (filter.Page - 1) * filter.Limit query = query.Offset(offset).Limit(filter.Limit) if filter.SortBy != "" { order := fmt.Sprintf("%s %s", filter.SortBy, filter.SortOrder) query = query.Order(order) } else { query = query.Order("created_at DESC") } // 执行查询 if err := query.Find(&photoModels).Error; err != nil { return nil, 0, fmt.Errorf("failed to find photos: %w", err) } // 转换为领域对象 photos := make([]*domain.Photo, 0, len(photoModels)) for _, photoModel := range photoModels { photo := model.ToDomainPhoto(&photoModel) photos = append(photos, photo) } return photos, total, nil } // applyFilters 应用查询过滤条件 func (r *photoRepositoryImpl) applyFilters(query *gorm.DB, filter repository.PhotoFilter) *gorm.DB { // 状态过滤 if filter.Status != nil { query = query.Where("status = ?", *filter.Status) } // 可见性过滤 if filter.Visibility != nil { query = query.Where("visibility = ?", *filter.Visibility) } // 分类过滤 if len(filter.CategoryIDs) > 0 { categoryIDs := make([]uint, len(filter.CategoryIDs)) for i, id := range filter.CategoryIDs { categoryIDs[i] = id.Value() } query = query.Joins("JOIN photo_categories ON photos.id = photo_categories.photo_id"). Where("photo_categories.category_id IN ?", categoryIDs) } // 标签过滤 if len(filter.TagIDs) > 0 { tagIDs := make([]uint, len(filter.TagIDs)) for i, id := range filter.TagIDs { tagIDs[i] = id.Value() } query = query.Joins("JOIN photo_tags ON photos.id = photo_tags.photo_id"). Where("photo_tags.tag_id IN ?", tagIDs) } // 搜索过滤 if filter.Search != "" { searchTerm := "%" + strings.ToLower(filter.Search) + "%" query = query.Where("LOWER(title) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm) } // 日期范围过滤 if filter.DateFrom != nil { query = query.Where("taken_at >= ?", *filter.DateFrom) } if filter.DateTo != nil { query = query.Where("taken_at <= ?", *filter.DateTo) } return query } // getDB 获取数据库连接(事务或普通连接) func (r *photoRepositoryImpl) getDB(tx *sql.Tx) *gorm.DB { if tx != nil { return r.db.Set("gorm:tx", tx) } return r.db } ``` ### 2.5 数据模型映射 #### 2.5.1 数据库模型 ```go // internal/infrastructure/persistence/model/photo.go package model import ( "time" "gorm.io/gorm" "photography-backend/internal/domain" ) // 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"` Slug string `gorm:"size:255;uniqueIndex" json:"slug"` Status string `gorm:"size:20;default:published" json:"status"` Visibility string `gorm:"size:20;default:public" json:"visibility"` SortOrder int `gorm:"default:0" json:"sort_order"` // 文件信息 OriginalFilename string `gorm:"size:255" json:"original_filename"` FileSize int64 `json:"file_size"` MimeType string `gorm:"size:100" json:"mime_type"` FileHash string `gorm:"size:64" json:"file_hash"` // EXIF数据 Camera string `gorm:"size:100" json:"camera"` Lens string `gorm:"size:100" json:"lens"` ISO int `json:"iso"` Aperture string `gorm:"size:10" json:"aperture"` ShutterSpeed string `gorm:"size:20" json:"shutter_speed"` FocalLength string `gorm:"size:20" json:"focal_length"` // 位置信息 Latitude *float64 `gorm:"type:decimal(10,8)" json:"latitude"` Longitude *float64 `gorm:"type:decimal(11,8)" json:"longitude"` LocationName string `gorm:"size:255" json:"location_name"` Country string `gorm:"size:100" json:"country"` City string `gorm:"size:100" json:"city"` // 统计信息 ViewCount int `gorm:"default:0" json:"view_count"` LikeCount int `gorm:"default:0" json:"like_count"` DownloadCount int `gorm:"default:0" json:"download_count"` // 时间信息 TakenAt *time.Time `json:"taken_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 元数据 Metadata JSON `gorm:"type:jsonb" json:"metadata"` // 关联关系 Categories []Category `gorm:"many2many:photo_categories" json:"categories"` Tags []Tag `gorm:"many2many:photo_tags" json:"tags"` Formats []PhotoFormat `gorm:"foreignKey:PhotoID" json:"formats"` } // PhotoFormat 照片格式数据库模型 type PhotoFormat struct { ID uint `gorm:"primaryKey" json:"id"` PhotoID uint `gorm:"not null;index" json:"photo_id"` FormatType string `gorm:"size:20;not null" json:"format_type"` FilePath string `gorm:"size:500;not null" json:"file_path"` FileSize int64 `json:"file_size"` Width int `json:"width"` Height int `json:"height"` Quality int `json:"quality"` CreatedAt time.Time `json:"created_at"` // 唯一约束 Photo Photo `gorm:"foreignKey:PhotoID" json:"-"` } // JSON 自定义JSON类型 type JSON map[string]interface{} // 实现GORM的Valuer和Scanner接口 func (j JSON) Value() (driver.Value, error) { if len(j) == 0 { return nil, nil } return json.Marshal(j) } func (j *JSON) Scan(value interface{}) error { if value == nil { *j = nil return nil } bytes, ok := value.([]byte) if !ok { return fmt.Errorf("cannot scan %T into JSON", value) } return json.Unmarshal(bytes, j) } // FromDomainPhoto 从领域对象转换为数据库模型 func FromDomainPhoto(photo *domain.Photo) *Photo { model := &Photo{ ID: photo.ID.Value(), Title: photo.Title, Description: photo.Description, Slug: photo.Slug, Status: string(photo.Status), Visibility: string(photo.Visibility), OriginalFilename: photo.FileInfo.OriginalFilename, FileSize: photo.FileInfo.FileSize, MimeType: photo.FileInfo.MimeType, FileHash: photo.FileInfo.FileHash, ViewCount: photo.Stats.ViewCount, LikeCount: photo.Stats.LikeCount, DownloadCount: photo.Stats.DownloadCount, TakenAt: photo.TakenAt, CreatedAt: photo.CreatedAt, UpdatedAt: photo.UpdatedAt, Metadata: JSON(photo.Metadata), } // EXIF数据 if photo.EXIF != nil { model.Camera = photo.EXIF.Camera model.Lens = photo.EXIF.Lens model.ISO = photo.EXIF.ISO model.Aperture = photo.EXIF.Aperture model.ShutterSpeed = photo.EXIF.ShutterSpeed model.FocalLength = photo.EXIF.FocalLength } // 位置信息 if photo.Location != nil { model.Latitude = &photo.Location.Latitude model.Longitude = &photo.Location.Longitude model.LocationName = photo.Location.Name model.Country = photo.Location.Country model.City = photo.Location.City } // 转换格式信息 formats := make([]PhotoFormat, 0, len(photo.Formats)) for _, format := range photo.Formats { formats = append(formats, PhotoFormat{ PhotoID: photo.ID.Value(), FormatType: string(format.Type), FilePath: format.FilePath, FileSize: format.FileSize, Width: format.Width, Height: format.Height, Quality: format.Quality, }) } model.Formats = formats return model } // ToDomainPhoto 从数据库模型转换为领域对象 func ToDomainPhoto(model *Photo) *domain.Photo { photo := &domain.Photo{ ID: domain.NewPhotoID(model.ID), Title: model.Title, Description: model.Description, Slug: model.Slug, Status: domain.PhotoStatus(model.Status), Visibility: domain.Visibility(model.Visibility), FileInfo: domain.FileInfo{ OriginalFilename: model.OriginalFilename, FileSize: model.FileSize, MimeType: model.MimeType, FileHash: model.FileHash, }, Stats: domain.PhotoStats{ ViewCount: model.ViewCount, LikeCount: model.LikeCount, DownloadCount: model.DownloadCount, }, TakenAt: model.TakenAt, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, Metadata: map[string]interface{}(model.Metadata), } // EXIF数据 if model.Camera != "" || model.Lens != "" || model.ISO != 0 { photo.EXIF = &domain.EXIFData{ Camera: model.Camera, Lens: model.Lens, ISO: model.ISO, Aperture: model.Aperture, ShutterSpeed: model.ShutterSpeed, FocalLength: model.FocalLength, } } // 位置信息 if model.Latitude != nil && model.Longitude != nil { photo.Location = &domain.Location{ Name: model.LocationName, Latitude: *model.Latitude, Longitude: *model.Longitude, Country: model.Country, City: model.City, } } // 转换格式信息 formats := make([]domain.PhotoFormat, 0, len(model.Formats)) for _, format := range model.Formats { formats = append(formats, domain.PhotoFormat{ Type: domain.FormatType(format.FormatType), FilePath: format.FilePath, FileSize: format.FileSize, Width: format.Width, Height: format.Height, Quality: format.Quality, }) } photo.Formats = formats // 转换分类ID categoryIDs := make([]domain.CategoryID, 0, len(model.Categories)) for _, category := range model.Categories { categoryIDs = append(categoryIDs, domain.NewCategoryID(category.ID)) } photo.Categories = categoryIDs // 转换标签ID tagIDs := make([]domain.TagID, 0, len(model.Tags)) for _, tag := range model.Tags { tagIDs = append(tagIDs, domain.NewTagID(tag.ID)) } photo.Tags = tagIDs return photo } ``` ### 2.6 依赖注入与容器 #### 2.6.1 依赖注入容器 ```go // pkg/container/container.go package container import ( "log" "photography-backend/internal/api/handlers" "photography-backend/internal/service" "photography-backend/internal/repository" "photography-backend/internal/infrastructure/persistence" "photography-backend/pkg/config" "photography-backend/pkg/database" "photography-backend/pkg/cache" "photography-backend/pkg/storage" "photography-backend/pkg/logger" ) // Container 依赖注入容器 type Container struct { config *config.Config // 基础设施 db *gorm.DB redisClient *redis.Client logger *logrus.Logger // 服务 cacheService cache.Service storageService storage.Service // 仓库 photoRepo repository.PhotoRepository categoryRepo repository.CategoryRepository tagRepo repository.TagRepository userRepo repository.UserRepository // 应用服务 photoService service.PhotoService categoryService service.CategoryService tagService service.TagService authService service.AuthService uploadService service.UploadService // 处理器 photoHandler *handlers.PhotoHandler categoryHandler *handlers.CategoryHandler tagHandler *handlers.TagHandler authHandler *handlers.AuthHandler uploadHandler *handlers.UploadHandler } // NewContainer 创建新的容器 func NewContainer(cfg *config.Config) *Container { container := &Container{ config: cfg, } container.initInfrastructure() container.initRepositories() container.initServices() container.initHandlers() return container } // initInfrastructure 初始化基础设施 func (c *Container) initInfrastructure() { // 初始化日志器 c.logger = logger.NewLogger(c.config.Logger) // 初始化数据库 db, err := database.NewPostgresDB(c.config.Database) if err != nil { log.Fatal("Failed to connect to database:", err) } c.db = db // 初始化Redis redisClient, err := cache.NewRedisClient(c.config.Redis) if err != nil { log.Fatal("Failed to connect to Redis:", err) } c.redisClient = redisClient // 初始化缓存服务 c.cacheService = cache.NewCacheService(c.redisClient, c.logger) // 初始化存储服务 c.storageService = storage.NewStorageService(c.config.Storage, c.logger) } // initRepositories 初始化仓库 func (c *Container) initRepositories() { c.photoRepo = persistence.NewPhotoRepository(c.db) c.categoryRepo = persistence.NewCategoryRepository(c.db) c.tagRepo = persistence.NewTagRepository(c.db) c.userRepo = persistence.NewUserRepository(c.db) } // initServices 初始化服务 func (c *Container) initServices() { // 领域服务 slugGenerator := service.NewSlugGenerator() exifExtractor := service.NewEXIFExtractor() photoDomainService := service.NewPhotoDomainService(slugGenerator, exifExtractor) // 图片处理器 imageProcessor := service.NewImageProcessor(c.storageService, c.logger) // 事件发布器 eventPublisher := event.NewEventPublisher(c.redisClient, c.logger) // 应用服务 c.photoService = service.NewPhotoService( c.photoRepo, c.categoryRepo, c.tagRepo, photoDomainService, imageProcessor, c.cacheService, eventPublisher, c.logger, ) c.categoryService = service.NewCategoryService( c.categoryRepo, c.photoRepo, c.cacheService, c.logger, ) c.tagService = service.NewTagService( c.tagRepo, c.photoRepo, c.cacheService, c.logger, ) c.authService = service.NewAuthService( c.userRepo, c.config.JWT, c.logger, ) c.uploadService = service.NewUploadService( c.storageService, imageProcessor, c.logger, ) } // initHandlers 初始化处理器 func (c *Container) initHandlers() { c.photoHandler = handlers.NewPhotoHandler(c.photoService, c.logger) c.categoryHandler = handlers.NewCategoryHandler(c.categoryService, c.logger) c.tagHandler = handlers.NewTagHandler(c.tagService, c.logger) c.authHandler = handlers.NewAuthHandler(c.authService, c.logger) c.uploadHandler = handlers.NewUploadHandler(c.uploadService, c.logger) } // GetPhotoHandler 获取照片处理器 func (c *Container) GetPhotoHandler() *handlers.PhotoHandler { return c.photoHandler } // ... 其他getter方法 // Close 关闭容器资源 func (c *Container) Close() error { if c.redisClient != nil { c.redisClient.Close() } if c.db != nil { sqlDB, err := c.db.DB() if err == nil { sqlDB.Close() } } return nil } ``` ### 2.7 配置管理 #### 2.7.1 配置结构 ```go // pkg/config/config.go package config import ( "fmt" "time" "github.com/spf13/viper" ) // Config 应用配置 type Config struct { App AppConfig `mapstructure:"app"` Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Redis RedisConfig `mapstructure:"redis"` Storage StorageConfig `mapstructure:"storage"` JWT JWTConfig `mapstructure:"jwt"` Logger LoggerConfig `mapstructure:"logger"` Tracing TracingConfig `mapstructure:"tracing"` Upload UploadConfig `mapstructure:"upload"` Image ImageConfig `mapstructure:"image"` } // AppConfig 应用配置 type AppConfig struct { Name string `mapstructure:"name"` Version string `mapstructure:"version"` Environment string `mapstructure:"environment"` Debug bool `mapstructure:"debug"` } // ServerConfig 服务器配置 type ServerConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` ReadTimeout time.Duration `mapstructure:"read_timeout"` WriteTimeout time.Duration `mapstructure:"write_timeout"` ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` CORS CORSConfig `mapstructure:"cors"` } // CORSConfig CORS配置 type CORSConfig struct { AllowOrigins []string `mapstructure:"allow_origins"` AllowMethods []string `mapstructure:"allow_methods"` AllowHeaders []string `mapstructure:"allow_headers"` ExposeHeaders []string `mapstructure:"expose_headers"` AllowCredentials bool `mapstructure:"allow_credentials"` MaxAge int `mapstructure:"max_age"` } // DatabaseConfig 数据库配置 type DatabaseConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` User string `mapstructure:"user"` Password string `mapstructure:"password"` Database string `mapstructure:"database"` SSLMode string `mapstructure:"ssl_mode"` MaxOpenConns int `mapstructure:"max_open_conns"` MaxIdleConns int `mapstructure:"max_idle_conns"` ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` LogLevel string `mapstructure:"log_level"` } // RedisConfig Redis配置 type RedisConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Password string `mapstructure:"password"` Database int `mapstructure:"database"` MaxRetries int `mapstructure:"max_retries"` PoolSize int `mapstructure:"pool_size"` IdleTimeout time.Duration `mapstructure:"idle_timeout"` } // StorageConfig 存储配置 type StorageConfig struct { Type string `mapstructure:"type"` // local, s3, minio Local LocalConfig `mapstructure:"local"` S3 S3Config `mapstructure:"s3"` CDNBaseURL string `mapstructure:"cdn_base_url"` } // LocalConfig 本地存储配置 type LocalConfig struct { UploadDir string `mapstructure:"upload_dir"` BaseURL string `mapstructure:"base_url"` } // S3Config S3/MinIO配置 type S3Config struct { Endpoint string `mapstructure:"endpoint"` Region string `mapstructure:"region"` AccessKeyID string `mapstructure:"access_key_id"` SecretAccessKey string `mapstructure:"secret_access_key"` Bucket string `mapstructure:"bucket"` UseSSL bool `mapstructure:"use_ssl"` } // JWTConfig JWT配置 type JWTConfig struct { SecretKey string `mapstructure:"secret_key"` Issuer string `mapstructure:"issuer"` AccessDuration time.Duration `mapstructure:"access_duration"` RefreshDuration time.Duration `mapstructure:"refresh_duration"` } // LoggerConfig 日志配置 type LoggerConfig struct { Level string `mapstructure:"level"` Format string `mapstructure:"format"` // json, text Output string `mapstructure:"output"` // stdout, file Filename string `mapstructure:"filename"` MaxSize int `mapstructure:"max_size"` MaxAge int `mapstructure:"max_age"` Compress bool `mapstructure:"compress"` } // TracingConfig 链路追踪配置 type TracingConfig struct { Enabled bool `mapstructure:"enabled"` ServiceName string `mapstructure:"service_name"` Jaeger struct { Endpoint string `mapstructure:"endpoint"` Username string `mapstructure:"username"` Password string `mapstructure:"password"` } `mapstructure:"jaeger"` SamplingRate float64 `mapstructure:"sampling_rate"` } // UploadConfig 上传配置 type UploadConfig struct { MaxFileSize int64 `mapstructure:"max_file_size"` AllowedTypes []string `mapstructure:"allowed_types"` MaxFilesPerBatch int `mapstructure:"max_files_per_batch"` TempDir string `mapstructure:"temp_dir"` CleanupInterval time.Duration `mapstructure:"cleanup_interval"` } // ImageConfig 图片处理配置 type ImageConfig struct { QualityJPG int `mapstructure:"quality_jpg"` QualityWebP int `mapstructure:"quality_webp"` MaxWidth int `mapstructure:"max_width"` MaxHeight int `mapstructure:"max_height"` ThumbnailSizes map[string]ImageSize `mapstructure:"thumbnail_sizes"` WatermarkEnabled bool `mapstructure:"watermark_enabled"` WatermarkText string `mapstructure:"watermark_text"` WatermarkOpacity int `mapstructure:"watermark_opacity"` } // ImageSize 图片尺寸配置 type ImageSize struct { Width int `mapstructure:"width"` Height int `mapstructure:"height"` Quality int `mapstructure:"quality"` } // LoadConfig 加载配置 func LoadConfig(configPath string) (*Config, error) { viper.SetConfigName("config") viper.SetConfigType("yaml") viper.AddConfigPath(configPath) viper.AddConfigPath(".") viper.AddConfigPath("./config") // 设置环境变量 viper.AutomaticEnv() viper.SetEnvPrefix("PHOTOGRAPHY") // 设置默认值 setDefaults() // 读取配置文件 if err := viper.ReadInConfig(); err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } var config Config if err := viper.Unmarshal(&config); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } // 验证配置 if err := validateConfig(&config); err != nil { return nil, fmt.Errorf("invalid config: %w", err) } return &config, nil } // setDefaults 设置默认配置值 func setDefaults() { // 应用默认配置 viper.SetDefault("app.name", "photography-backend") viper.SetDefault("app.version", "1.0.0") viper.SetDefault("app.environment", "development") viper.SetDefault("app.debug", false) // 服务器默认配置 viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.port", 8080) viper.SetDefault("server.read_timeout", "30s") viper.SetDefault("server.write_timeout", "30s") viper.SetDefault("server.shutdown_timeout", "10s") // CORS默认配置 viper.SetDefault("server.cors.allow_origins", []string{"*"}) viper.SetDefault("server.cors.allow_methods", []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}) viper.SetDefault("server.cors.allow_headers", []string{"Content-Type", "Authorization"}) viper.SetDefault("server.cors.max_age", 3600) // 数据库默认配置 viper.SetDefault("database.host", "localhost") viper.SetDefault("database.port", 5432) viper.SetDefault("database.ssl_mode", "disable") viper.SetDefault("database.max_open_conns", 25) viper.SetDefault("database.max_idle_conns", 5) viper.SetDefault("database.conn_max_lifetime", "3600s") viper.SetDefault("database.log_level", "warn") // Redis默认配置 viper.SetDefault("redis.host", "localhost") viper.SetDefault("redis.port", 6379) viper.SetDefault("redis.database", 0) viper.SetDefault("redis.max_retries", 3) viper.SetDefault("redis.pool_size", 10) viper.SetDefault("redis.idle_timeout", "300s") // 存储默认配置 viper.SetDefault("storage.type", "local") viper.SetDefault("storage.local.upload_dir", "./uploads") viper.SetDefault("storage.local.base_url", "http://localhost:8080") // JWT默认配置 viper.SetDefault("jwt.issuer", "photography-backend") viper.SetDefault("jwt.access_duration", "24h") viper.SetDefault("jwt.refresh_duration", "168h") // 日志默认配置 viper.SetDefault("logger.level", "info") viper.SetDefault("logger.format", "json") viper.SetDefault("logger.output", "stdout") viper.SetDefault("logger.max_size", 100) viper.SetDefault("logger.max_age", 30) viper.SetDefault("logger.compress", true) // 链路追踪默认配置 viper.SetDefault("tracing.enabled", true) viper.SetDefault("tracing.service_name", "photography-backend") viper.SetDefault("tracing.jaeger.endpoint", "http://localhost:14268/api/traces") viper.SetDefault("tracing.sampling_rate", 1.0) // 上传默认配置 viper.SetDefault("upload.max_file_size", 52428800) // 50MB viper.SetDefault("upload.allowed_types", []string{"image/jpeg", "image/png", "image/raw"}) viper.SetDefault("upload.max_files_per_batch", 50) viper.SetDefault("upload.temp_dir", "./temp") viper.SetDefault("upload.cleanup_interval", "1h") // 图片处理默认配置 viper.SetDefault("image.quality_jpg", 85) viper.SetDefault("image.quality_webp", 80) viper.SetDefault("image.max_width", 1920) viper.SetDefault("image.max_height", 1080) viper.SetDefault("image.watermark_enabled", false) viper.SetDefault("image.watermark_opacity", 50) // 缩略图尺寸默认配置 viper.SetDefault("image.thumbnail_sizes.thumb_small.width", 150) viper.SetDefault("image.thumbnail_sizes.thumb_small.height", 150) viper.SetDefault("image.thumbnail_sizes.thumb_small.quality", 80) viper.SetDefault("image.thumbnail_sizes.thumb_medium.width", 300) viper.SetDefault("image.thumbnail_sizes.thumb_medium.height", 300) viper.SetDefault("image.thumbnail_sizes.thumb_medium.quality", 85) viper.SetDefault("image.thumbnail_sizes.thumb_large.width", 600) viper.SetDefault("image.thumbnail_sizes.thumb_large.height", 600) viper.SetDefault("image.thumbnail_sizes.thumb_large.quality", 90) } // validateConfig 验证配置 func validateConfig(config *Config) error { if config.App.Name == "" { return fmt.Errorf("app name cannot be empty") } if config.Server.Port <= 0 || config.Server.Port > 65535 { return fmt.Errorf("invalid server port: %d", config.Server.Port) } if config.Database.Host == "" { return fmt.Errorf("database host cannot be empty") } if config.JWT.SecretKey == "" { return fmt.Errorf("JWT secret key cannot be empty") } return nil } ``` ### 2.8 构建和部署 #### 2.8.1 Makefile ```makefile # Makefile .PHONY: build test clean run docker-build docker-run migrate-up migrate-down # 变量定义 APP_NAME = photography-backend VERSION = $(shell git describe --tags --always --dirty) BUILD_TIME = $(shell date +%Y-%m-%dT%H:%M:%S) GO_VERSION = $(shell go version | awk '{print $$3}') LDFLAGS = -ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" # 构建 build: @echo "Building $(APP_NAME)..." @go build $(LDFLAGS) -o bin/$(APP_NAME) cmd/server/main.go # 运行 run: @echo "Running $(APP_NAME)..." @go run cmd/server/main.go # 测试 test: @echo "Running tests..." @go test -v -race -coverprofile=coverage.out ./... # 测试覆盖率 test-coverage: test @go tool cover -html=coverage.out -o coverage.html @echo "Coverage report generated: coverage.html" # 清理 clean: @echo "Cleaning..." @rm -rf bin/ @rm -f coverage.out coverage.html @go clean -cache # 代码检查 lint: @echo "Running linter..." @golangci-lint run # 格式化代码 fmt: @echo "Formatting code..." @go fmt ./... @goimports -w . # 生成模拟代码 generate: @echo "Generating mocks..." @go generate ./... # 数据库迁移 migrate-up: @echo "Running database migrations..." @migrate -path migrations -database "postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable" up migrate-down: @echo "Rolling back database migrations..." @migrate -path migrations -database "postgres://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable" down # 创建新的迁移文件 migrate-create: @echo "Creating new migration: $(NAME)" @migrate create -ext sql -dir migrations $(NAME) # Docker构建 docker-build: @echo "Building Docker image..." @docker build -t $(APP_NAME):$(VERSION) -t $(APP_NAME):latest . # Docker运行 docker-run: @echo "Running Docker container..." @docker-compose up -d # Docker停止 docker-stop: @echo "Stopping Docker containers..." @docker-compose down # 安装依赖 deps: @echo "Installing dependencies..." @go mod download @go mod tidy # 安装开发工具 install-tools: @echo "Installing development tools..." @go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest @go install golang.org/x/tools/cmd/goimports@latest @go install github.com/golang/mock/mockgen@latest @go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest # 开发环境设置 dev-setup: install-tools deps @echo "Setting up development environment..." @cp config/config.example.yaml config/config.yaml @mkdir -p uploads temp logs # 生产构建 build-prod: @echo "Building for production..." @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -a -installsuffix cgo -o bin/$(APP_NAME) cmd/server/main.go # 帮助信息 help: @echo "Available commands:" @echo " build - Build the application" @echo " run - Run the application" @echo " test - Run tests" @echo " test-coverage - Run tests with coverage report" @echo " clean - Clean build artifacts" @echo " lint - Run linter" @echo " fmt - Format code" @echo " generate - Generate mocks" @echo " migrate-up - Run database migrations" @echo " migrate-down - Rollback database migrations" @echo " migrate-create- Create new migration file (NAME=migration_name)" @echo " docker-build - Build Docker image" @echo " docker-run - Run with Docker Compose" @echo " docker-stop - Stop Docker containers" @echo " deps - Install dependencies" @echo " install-tools - Install development tools" @echo " dev-setup - Setup development environment" @echo " build-prod - Build for production" @echo " help - Show this help message" ``` #### 2.8.2 Dockerfile ```dockerfile # 多阶段构建 FROM golang:1.21-alpine AS builder # 安装必要的包 RUN apk add --no-cache git ca-certificates tzdata vips-dev gcc musl-dev # 设置工作目录 WORKDIR /app # 复制go.mod和go.sum COPY go.mod go.sum ./ # 下载依赖 RUN go mod download # 复制源代码 COPY . . # 构建应用 RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go # 最终镜像 FROM alpine:latest # 安装运行时依赖 RUN apk --no-cache add ca-certificates vips-dev tzdata # 创建非root用户 RUN addgroup -g 1000 appgroup && \ adduser -u 1000 -G appgroup -s /bin/sh -D appuser # 设置工作目录 WORKDIR /app # 从构建阶段复制二进制文件 COPY --from=builder /app/main . # 复制配置文件和迁移文件 COPY --from=builder /app/config ./config COPY --from=builder /app/migrations ./migrations # 创建必要的目录 RUN mkdir -p uploads temp logs && \ chown -R appuser:appgroup /app # 切换到非root用户 USER appuser # 暴露端口 EXPOSE 8080 # 健康检查 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 # 运行应用 CMD ["./main"] ``` #### 2.8.3 docker-compose.yml ```yaml version: '3.8' services: # 应用服务 app: build: . ports: - "8080:8080" environment: - PHOTOGRAPHY_DATABASE_HOST=postgres - PHOTOGRAPHY_DATABASE_PORT=5432 - PHOTOGRAPHY_DATABASE_USER=postgres - PHOTOGRAPHY_DATABASE_PASSWORD=password - PHOTOGRAPHY_DATABASE_DATABASE=photography - PHOTOGRAPHY_REDIS_HOST=redis - PHOTOGRAPHY_REDIS_PORT=6379 - PHOTOGRAPHY_STORAGE_TYPE=minio - PHOTOGRAPHY_STORAGE_S3_ENDPOINT=minio:9000 - PHOTOGRAPHY_STORAGE_S3_ACCESS_KEY_ID=minioadmin - PHOTOGRAPHY_STORAGE_S3_SECRET_ACCESS_KEY=minioadmin - PHOTOGRAPHY_STORAGE_S3_BUCKET=photography - PHOTOGRAPHY_STORAGE_S3_USE_SSL=false depends_on: - postgres - redis - minio volumes: - ./uploads:/app/uploads - ./logs:/app/logs restart: unless-stopped networks: - photography-network # PostgreSQL数据库 postgres: image: postgres:15-alpine environment: - POSTGRES_DB=photography - POSTGRES_USER=postgres - POSTGRES_PASSWORD=password volumes: - postgres_data:/var/lib/postgresql/data - ./init.sql:/docker-entrypoint-initdb.d/init.sql ports: - "5432:5432" restart: unless-stopped networks: - photography-network # Redis缓存 redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis_data:/data ports: - "6379:6379" restart: unless-stopped networks: - photography-network # MinIO对象存储 minio: image: minio/minio:latest command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER=minioadmin - MINIO_ROOT_PASSWORD=minioadmin volumes: - minio_data:/data ports: - "9000:9000" - "9001:9001" restart: unless-stopped networks: - photography-network # Jaeger链路追踪 jaeger: image: jaegertracing/all-in-one:latest ports: - "16686:16686" - "14268:14268" environment: - COLLECTOR_OTLP_ENABLED=true restart: unless-stopped networks: - photography-network # Nginx反向代理(可选) nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./ssl:/etc/nginx/ssl depends_on: - app restart: unless-stopped networks: - photography-network volumes: postgres_data: redis_data: minio_data: networks: photography-network: driver: bridge ``` ### 2.9 总结 [{"id": "1", "content": "\u521b\u5efa\u8be6\u7ec6\u7684\u540e\u7aef\u8bbe\u8ba1\u6587\u6863", "status": "completed", "priority": "high"}, {"id": "2", "content": "\u521b\u5efa\u7ba1\u7406\u540e\u53f0\u529f\u80fd\u8bbe\u8ba1\u6587\u6863", "status": "completed", "priority": "high"}, {"id": "3", "content": "\u521b\u5efa\u6570\u636e\u5e93\u8bbe\u8ba1\u6587\u6863", "status": "completed", "priority": "high"}, {"id": "4", "content": "\u521b\u5efaAPI\u63a5\u53e3\u8bbe\u8ba1\u6587\u6863", "status": "completed", "priority": "high"}, {"id": "5", "content": "\u521b\u5efaGolang\u9879\u76ee\u67b6\u6784\u6587\u6863", "status": "completed", "priority": "high"}]