## 主要变更 - 创建版本化文档目录结构 (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和数据库设计文档 - 版本化管理便于迭代开发
58 KiB
58 KiB
摄影作品集网站 - Golang项目架构文档
1. 项目概述
1.1 架构设计理念
- Clean Architecture: 采用整洁架构,分离业务逻辑和技术实现
- Domain-Driven Design: 以领域为核心的设计方法
- 微服务友好: 模块化设计,便于后续拆分为微服务
- 高性能: 充分利用Go语言的并发特性
- 可测试: 依赖注入和接口抽象,便于单元测试
1.2 技术栈选择
// 核心框架
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 核心领域实体
// 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 领域服务
// 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 应用服务接口
// 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接口
// 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实现
// 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 数据库模型
// 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 依赖注入容器
// 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 配置结构
// 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
.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
# 多阶段构建
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
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> [{"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"}]