Files
photography/docs/v1/backend/Golang项目架构文档.md
xujiang 21b1581bdb docs: 重构文档结构,按版本划分组织文档
## 主要变更
- 创建版本化文档目录结构 (v1/, v2/)
- 移动核心设计文档到对应版本目录
- 更新文档总览和版本说明
- 保留原有目录结构的兼容性

## 新增文档
- docs/v1/README.md - v1.0版本开发指南
- docs/v2/README.md - v2.0版本规划文档
- docs/v1/admin/管理后台开发文档.md
- docs/v1/backend/Golang项目架构文档.md
- docs/v1/database/数据库设计文档.md
- docs/v1/api/API接口设计文档.md

## 文档结构优化
- 清晰的版本划分,便于开发者快速定位
- 完整的开发进度跟踪
- 详细的技术栈说明和架构设计
- 未来版本功能规划和技术演进路径

## 开发者体验提升
- 角色导向的文档导航
- 快速开始指南
- 详细的API和数据库设计文档
- 版本化管理便于迭代开发
2025-07-09 12:41:16 +08:00

1844 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 摄影作品集网站 - 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 // 日志轮转
// 验证和工具
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/ # 日志系统
│ ├── 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"
)
// 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) {
// 参数验证
if err := req.Validate(); err != nil {
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 {
s.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)
}
return response, nil
}
// CreatePhoto 创建照片
func (s *photoServiceImpl) CreatePhoto(ctx context.Context, req dto.CreatePhotoRequest) (*dto.PhotoResponse, error) {
// 参数验证
if err := req.Validate(); err != nil {
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 {
s.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:*")
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"`
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"`
}
// 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("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
# 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 总结
<function_calls>
<invoke name="TodoWrite">
<parameter name="todos">[{"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"}]