1940 lines
61 KiB
Markdown
1940 lines
61 KiB
Markdown
# 摄影作品集网站 - 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 总结
|
||
|
||
<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"}] |