Go 项目实战:从零构建完整的 Web 应用

完整的项目实战教程,涵盖架构设计、数据库设计、RESTful API 实现、认证系统、测试策略、Docker 部署和监控

Go 项目实战:从零构建完整的 Web 应用

学了这么多 Go 的理论和零散的知识点,是时候来一个完整的项目实战了。今天,我们将从零开始构建一个真实的项目——在线任务管理系统(TaskMaster)

这个项目将涵盖:

  • 项目规划与架构设计
  • 数据库设计与迁移
  • RESTful API 实现
  • JWT 认证系统
  • 中间件链
  • 单元测试与集成测试
  • Docker 容器化
  • Kubernetes 部署
  • 监控与日志
  • 性能优化

项目规划

在开始写代码之前,让我们先明确项目需求:

功能需求

  1. 用户管理:注册、登录、个人资料
  2. 任务管理:创建、查看、更新、删除任务
  3. 任务分类:标签和优先级
  4. 任务协作:分享任务、评论
  5. 搜索与过滤:按标签、状态、日期过滤
  6. 通知系统:任务到期提醒

技术选型

  • 后端框架:Gin(高性能 HTTP 框架)
  • 数据库:PostgreSQL
  • ORM:GORM
  • 缓存:Redis
  • 消息队列:RabbitMQ(异步任务)
  • 认证:JWT + Refresh Token
  • API 文档:Swagger
  • 容器化:Docker + Docker Compose
  • 部署:Kubernetes
  • 监控:Prometheus + Grafana

项目结构设计

好的项目结构是可维护性的基础:

taskmaster/
├── cmd/
│   └── server/
│       └── main.go              # 应用入口
├── internal/
│   ├── config/
│   │   └── config.go            # 配置管理
│   ├── domain/
│   │   ├── user.go              # 用户领域模型
│   │   ├── task.go              # 任务领域模型
│   │   └── comment.go           # 评论领域模型
│   ├── handler/
│   │   ├── user_handler.go      # 用户 HTTP 处理器
│   │   ├── task_handler.go      # 任务 HTTP 处理器
│   │   └── auth_handler.go      # 认证处理器
│   ├── middleware/
│   │   ├── auth.go              # JWT 认证中间件
│   │   ├── cors.go              # CORS 中间件
│   │   ├── logger.go            # 日志中间件
│   │   └── recovery.go          # 异常恢复中间件
│   ├── repository/
│   │   ├── user_repo.go         # 用户数据访问层
│   │   └── task_repo.go         # 任务数据访问层
│   ├── service/
│   │   ├── user_service.go      # 用户业务逻辑
│   │   ├── task_service.go      # 任务业务逻辑
│   │   └── auth_service.go      # 认证业务逻辑
│   └── transport/
│       └── http/
│           ├── server.go        # HTTP 服务器
│           └── router.go        # 路由配置
├── pkg/
│   ├── database/
│   │   └── postgres.go          # 数据库连接
│   ├── cache/
│   │   └── redis.go             # Redis 客户端
│   ├── jwt/
│   │   └── jwt.go               # JWT 工具
│   ├── validator/
│   │   └── validator.go         # 输入验证
│   └── response/
│       └── response.go          # 统一响应格式
├── migrations/
│   ├── 000001_create_users.up.sql
│   ├── 000001_create_users.down.sql
│   ├── 000002_create_tasks.up.sql
│   └── 000002_create_tasks.down.sql
├── deployments/
│   ├── docker/
│   │   ├── Dockerfile
│   │   └── docker-compose.yml
│   └── k8s/
│       ├── deployment.yaml
│       ├── service.yaml
│       └── configmap.yaml
├── configs/
│   ├── config.yaml
│   └── config.example.yaml
├── scripts/
│   ├── migrate.sh
│   └── seed.sh
├── docs/
│   └── api.md
├── go.mod
├── go.sum
├── Makefile
└── README.md

配置管理

使用 Viper 管理配置:

// internal/config/config.go
package config

import (
	"fmt"
	"log"
	"time"

	"github.com/spf13/viper"
)

// Config 应用配置
type Config struct {
	App      AppConfig
	Server   ServerConfig
	Database DatabaseConfig
	Redis    RedisConfig
	JWT      JWTConfig
}

type AppConfig struct {
	Name    string
	Version string
	Env     string // development, production, test
	Debug   bool
}

type ServerConfig struct {
	Host string
	Port int
}

type DatabaseConfig struct {
	Host     string
	Port     int
	User     string
	Password string
	Name     string
	SSLMode  string
}

type RedisConfig struct {
	Host     string
	Port     int
	Password string
	DB       int
}

type JWTConfig struct {
	Secret          string
	ExpirationHours int
	Issuer          string
}

// Load 加载配置
func Load() (*Config, error) {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath("./configs")
	viper.AddConfigPath(".")

	// 环境变量覆盖
	viper.AutomaticEnv()

	if err := viper.ReadInConfig(); err != nil {
		return nil, fmt.Errorf("error reading config file: %w", err)
	}

	var config Config
	if err := viper.Unmarshal(&config); err != nil {
		return nil, fmt.Errorf("error unmarshaling config: %w", err)
	}

	// 验证配置
	if err := config.Validate(); err != nil {
		return nil, err
	}

	return &config, nil
}

// Validate 验证配置
func (c *Config) Validate() error {
	if c.JWT.Secret == "" || c.JWT.Secret == "change-me-in-production" {
		log.Println("WARNING: JWT secret is not set or using default value")
	}

	if c.Database.Password == "" {
		return fmt.Errorf("database password is required")
	}

	return nil
}

// GetDSN 获取数据库连接字符串
func (c *DatabaseConfig) GetDSN() string {
	return fmt.Sprintf(
		"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
		c.Host, c.Port, c.User, c.Password, c.Name, c.SSLMode,
	)
}

// GetAddr 获取 Redis 地址
func (c *RedisConfig) GetAddr() string {
	return fmt.Sprintf("%s:%d", c.Host, c.Port)
}

配置文件示例:

# configs/config.yaml
app:
  name: "TaskMaster"
  version: "1.0.0"
  env: "development"
  debug: true

server:
  host: "0.0.0.0"
  port: 8080

database:
  host: "localhost"
  port: 5432
  user: "taskmaster"
  password: "secret"
  name: "taskmaster_db"
  sslmode: "disable"

redis:
  host: "localhost"
  port: 6379
  password: ""
  db: 0

jwt:
  secret: "your-super-secret-key-change-in-production"
  expiration_hours: 24
  issuer: "taskmaster"

领域模型设计

定义核心业务实体:

// internal/domain/user.go
package domain

import (
	"time"
)

// User 用户模型
type User struct {
	ID        uint      `json:"id" gorm:"primarykey"`
	Username  string    `json:"username" gorm:"uniqueIndex;size:50;not null"`
	Email     string    `json:"email" gorm:"uniqueIndex;size:100;not null"`
	Password  string    `json:"-" gorm:"not null"` // 不暴露密码
	FullName  string    `json:"full_name" gorm:"size:100"`
	AvatarURL string    `json:"avatar_url" gorm:"size:255"`
	IsActive  bool      `json:"is_active" gorm:"default:true"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	
	// 关联
	Tasks     []Task     `json:"tasks,omitempty" gorm:"foreignKey:UserID"`
	Comments  []Comment  `json:"comments,omitempty" gorm:"foreignKey:UserID"`
}

// TableName 指定表名
func (User) TableName() string {
	return "users"
}

// UserResponse 用户响应 DTO
type UserResponse struct {
	ID        uint      `json:"id"`
	Username  string    `json:"username"`
	Email     string    `json:"email"`
	FullName  string    `json:"full_name"`
	AvatarURL string    `json:"avatar_url"`
	IsActive  bool      `json:"is_active"`
	CreatedAt time.Time `json:"created_at"`
}

// ToResponse 转换为响应 DTO
func (u *User) ToResponse() *UserResponse {
	return &UserResponse{
		ID:        u.ID,
		Username:  u.Username,
		Email:     u.Email,
		FullName:  u.FullName,
		AvatarURL: u.AvatarURL,
		IsActive:  u.IsActive,
		CreatedAt: u.CreatedAt,
	}
}

// CreateUserRequest 创建用户请求
type CreateUserRequest struct {
	Username string `json:"username" binding:"required,min=3,max=50"`
	Email    string `json:"email" binding:"required,email"`
	Password string `json:"password" binding:"required,min=8"`
	FullName string `json:"full_name" binding:"max=100"`
}

// UpdateUserRequest 更新用户请求
type UpdateUserRequest struct {
	FullName  string `json:"full_name" binding:"max=100"`
	AvatarURL string `json:"avatar_url" binding:"url"`
}
// internal/domain/task.go
package domain

import (
	"time"
)

// TaskPriority 任务优先级
type TaskPriority string

const (
	PriorityLow    TaskPriority = "low"
	PriorityMedium TaskPriority = "medium"
	PriorityHigh   TaskPriority = "high"
	PriorityUrgent TaskPriority = "urgent"
)

// TaskStatus 任务状态
type TaskStatus string

const (
	StatusTodo       TaskStatus = "todo"
	StatusInProgress TaskStatus = "in_progress"
	StatusDone       TaskStatus = "done"
	StatusCancelled  TaskStatus = "cancelled"
)

// Task 任务模型
type Task struct {
	ID          uint         `json:"id" gorm:"primarykey"`
	Title       string       `json:"title" gorm:"size:200;not null"`
	Description string       `json:"description" gorm:"type:text"`
	Status      TaskStatus   `json:"status" gorm:"size:20;default:todo"`
	Priority    TaskPriority `json:"priority" gorm:"size:20;default:medium"`
	DueDate     *time.Time   `json:"due_date"`
	Tags        []string     `json:"tags" gorm:"type:jsonb"`
	
	// 关联
	UserID      uint      `json:"user_id" gorm:"index;not null"`
	User        User      `json:"user,omitempty" gorm:"foreignKey:UserID"`
	AssignedTo  *uint     `json:"assigned_to" gorm:"index"`
	Comments    []Comment `json:"comments,omitempty" gorm:"foreignKey:TaskID"`
	
	// 时间戳
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
	CompletedAt *time.Time `json:"completed_at"`
}

// TableName 指定表名
func (Task) TableName() string {
	return "tasks"
}

// TaskResponse 任务响应 DTO
type TaskResponse struct {
	ID          uint         `json:"id"`
	Title       string       `json:"title"`
	Description string       `json:"description"`
	Status      TaskStatus   `json:"status"`
	Priority    TaskPriority `json:"priority"`
	DueDate     *time.Time   `json:"due_date"`
	Tags        []string     `json:"tags"`
	UserID      uint         `json:"user_id"`
	AssignedTo  *uint        `json:"assigned_to"`
	CreatedAt   time.Time    `json:"created_at"`
	UpdatedAt   time.Time    `json:"updated_at"`
	CompletedAt *time.Time   `json:"completed_at"`
	CommentCount int         `json:"comment_count"`
}

// ToResponse 转换为响应 DTO
func (t *Task) ToResponse() *TaskResponse {
	return &TaskResponse{
		ID:          t.ID,
		Title:       t.Title,
		Description: t.Description,
		Status:      t.Status,
		Priority:    t.Priority,
		DueDate:     t.DueDate,
		Tags:        t.Tags,
		UserID:      t.UserID,
		AssignedTo:  t.AssignedTo,
		CreatedAt:   t.CreatedAt,
		UpdatedAt:   t.UpdatedAt,
		CompletedAt: t.CompletedAt,
		CommentCount: len(t.Comments),
	}
}

// CreateTaskRequest 创建任务请求
type CreateTaskRequest struct {
	Title       string       `json:"title" binding:"required,min=1,max=200"`
	Description string       `json:"description"`
	Priority    TaskPriority `json:"priority" binding:"omitempty,oneof=low medium high urgent"`
	DueDate     *time.Time   `json:"due_date"`
	Tags        []string     `json:"tags"`
	AssignedTo  *uint        `json:"assigned_to"`
}

// UpdateTaskRequest 更新任务请求
type UpdateTaskRequest struct {
	Title       *string       `json:"title" binding:"omitempty,min=1,max=200"`
	Description *string       `json:"description"`
	Status      *TaskStatus   `json:"status" binding:"omitempty,oneof=todo in_progress done cancelled"`
	Priority    *TaskPriority `json:"priority" binding:"omitempty,oneof=low medium high urgent"`
	DueDate     *time.Time    `json:"due_date"`
	Tags        []string      `json:"tags"`
	AssignedTo  *uint         `json:"assigned_to"`
}

// TaskFilter 任务过滤条件
type TaskFilter struct {
	Status     []TaskStatus `form:"status"`
	Priority   []TaskPriority `form:"priority"`
	Tags       []string     `form:"tags"`
	DueBefore  *time.Time   `form:"due_before"`
	DueAfter   *time.Time   `form:"due_after"`
	AssignedTo *uint        `form:"assigned_to"`
	Search     string       `form:"search"`
	Page       int          `form:"page,default=1"`
	PageSize   int          `form:"page_size,default=20"`
}
// internal/domain/comment.go
package domain

import (
	"time"
)

// Comment 评论模型
type Comment struct {
	ID        uint      `json:"id" gorm:"primarykey"`
	Content   string    `json:"content" gorm:"type:text;not null"`
	
	// 关联
	TaskID    uint      `json:"task_id" gorm:"index;not null"`
	Task      Task      `json:"task,omitempty" gorm:"foreignKey:TaskID"`
	UserID    uint      `json:"user_id" gorm:"index;not null"`
	User      User      `json:"user,omitempty" gorm:"foreignKey:UserID"`
	
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

// TableName 指定表名
func (Comment) TableName() string {
	return "comments"
}

// CommentResponse 评论响应 DTO
type CommentResponse struct {
	ID        uint           `json:"id"`
	Content   string         `json:"content"`
	TaskID    uint           `json:"task_id"`
	User      *UserResponse  `json:"user"`
	CreatedAt time.Time      `json:"created_at"`
	UpdatedAt time.Time      `json:"updated_at"`
}

// ToResponse 转换为响应 DTO
func (c *Comment) ToResponse() *CommentResponse {
	return &CommentResponse{
		ID:        c.ID,
		Content:   c.Content,
		TaskID:    c.TaskID,
		User:      c.User.ToResponse(),
		CreatedAt: c.CreatedAt,
		UpdatedAt: c.UpdatedAt,
	}
}

// CreateCommentRequest 创建评论请求
type CreateCommentRequest struct {
	Content string `json:"content" binding:"required,min=1"`
}

数据库迁移

使用 golang-migrate 管理数据库版本:

-- migrations/000001_create_users.up.sql
CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    full_name VARCHAR(100),
    avatar_url VARCHAR(255),
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);

-- 触发器:自动更新 updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ language 'plpgsql';

CREATE TRIGGER update_users_updated_at BEFORE UPDATE
    ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- migrations/000001_create_users.down.sql
DROP TABLE IF EXISTS users;
-- migrations/000002_create_tasks.up.sql
CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    status VARCHAR(20) DEFAULT 'todo',
    priority VARCHAR(20) DEFAULT 'medium',
    due_date TIMESTAMP,
    tags JSONB,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    completed_at TIMESTAMP
);

CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_priority ON tasks(priority);
CREATE INDEX idx_tasks_due_date ON tasks(due_date);
CREATE INDEX idx_tasks_tags ON tasks USING GIN(tags);

CREATE TRIGGER update_tasks_updated_at BEFORE UPDATE
    ON tasks FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- migrations/000003_create_comments.up.sql
CREATE TABLE IF NOT EXISTS comments (
    id SERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_comments_task_id ON comments(task_id);
CREATE INDEX idx_comments_user_id ON comments(user_id);

CREATE TRIGGER update_comments_updated_at BEFORE UPDATE
    ON comments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

迁移脚本:

#!/bin/bash
# scripts/migrate.sh

DB_URL="postgres://taskmaster:secret@localhost:5432/taskmaster_db?sslmode=disable"

case "$1" in
  up)
    migrate -path migrations -database "$DB_URL" up
    ;;
  down)
    migrate -path migrations -database "$DB_URL" down
    ;;
  create)
    migrate create -ext sql -dir migrations -seq "$2"
    ;;
  *)
    echo "Usage: $0 {up|down|create <name>}"
    exit 1
    ;;
esac

数据访问层(Repository)

实现 Repository 模式,分离数据访问逻辑:

// internal/repository/user_repo.go
package repository

import (
	"context"
	"errors"

	"gorm.io/gorm"

	"taskmaster/internal/domain"
)

// UserRepository 用户仓库接口
type UserRepository interface {
	Create(ctx context.Context, user *domain.User) error
	GetByID(ctx context.Context, id uint) (*domain.User, error)
	GetByEmail(ctx context.Context, email string) (*domain.User, error)
	GetByUsername(ctx context.Context, username string) (*domain.User, error)
	Update(ctx context.Context, user *domain.User) error
	Delete(ctx context.Context, id uint) error
	List(ctx context.Context, page, pageSize int) ([]*domain.User, int64, error)
}

// userRepo 用户仓库实现
type userRepo struct {
	db *gorm.DB
}

// NewUserRepository 创建用户仓库
func NewUserRepository(db *gorm.DB) UserRepository {
	return &userRepo{db: db}
}

func (r *userRepo) Create(ctx context.Context, user *domain.User) error {
	return r.db.WithContext(ctx).Create(user).Error
}

func (r *userRepo) GetByID(ctx context.Context, id uint) (*domain.User, error) {
	var user domain.User
	err := r.db.WithContext(ctx).First(&user, id).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, err
	}
	return &user, nil
}

func (r *userRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
	var user domain.User
	err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, err
	}
	return &user, nil
}

func (r *userRepo) GetByUsername(ctx context.Context, username string) (*domain.User, error) {
	var user domain.User
	err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, err
	}
	return &user, nil
}

func (r *userRepo) Update(ctx context.Context, user *domain.User) error {
	return r.db.WithContext(ctx).Save(user).Error
}

func (r *userRepo) Delete(ctx context.Context, id uint) error {
	return r.db.WithContext(ctx).Delete(&domain.User{}, id).Error
}

func (r *userRepo) List(ctx context.Context, page, pageSize int) ([]*domain.User, int64, error) {
	var users []*domain.User
	var total int64

	offset := (page - 1) * pageSize

	err := r.db.WithContext(ctx).Model(&domain.User{}).Count(&total).Error
	if err != nil {
		return nil, 0, err
	}

	err = r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&users).Error
	return users, total, err
}
// internal/repository/task_repo.go
package repository

import (
	"context"
	"errors"

	"gorm.io/gorm"

	"taskmaster/internal/domain"
)

// TaskRepository 任务仓库接口
type TaskRepository interface {
	Create(ctx context.Context, task *domain.Task) error
	GetByID(ctx context.Context, id uint) (*domain.Task, error)
	Update(ctx context.Context, task *domain.Task) error
	Delete(ctx context.Context, id uint) error
	List(ctx context.Context, userID uint, filter *domain.TaskFilter) ([]*domain.Task, int64, error)
	GetByIDs(ctx context.Context, ids []uint) ([]*domain.Task, error)
}

// taskRepo 任务仓库实现
type taskRepo struct {
	db *gorm.DB
}

// NewTaskRepository 创建任务仓库
func NewTaskRepository(db *gorm.DB) TaskRepository {
	return &taskRepo{db: db}
}

func (r *taskRepo) Create(ctx context.Context, task *domain.Task) error {
	return r.db.WithContext(ctx).Create(task).Error
}

func (r *taskRepo) GetByID(ctx context.Context, id uint) (*domain.Task, error) {
	var task domain.Task
	err := r.db.WithContext(ctx).
		Preload("User").
		Preload("Comments.User").
		First(&task, id).Error
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, err
	}
	return &task, nil
}

func (r *taskRepo) Update(ctx context.Context, task *domain.Task) error {
	return r.db.WithContext(ctx).Save(task).Error
}

func (r *taskRepo) Delete(ctx context.Context, id uint) error {
	return r.db.WithContext(ctx).Delete(&domain.Task{}, id).Error
}

func (r *taskRepo) List(ctx context.Context, userID uint, filter *domain.TaskFilter) ([]*domain.Task, int64, error) {
	var tasks []*domain.Task
	var total int64

	query := r.db.WithContext(ctx).Model(&domain.Task{}).Where("user_id = ? OR assigned_to = ?", userID, userID)

	// 应用过滤条件
	if len(filter.Status) > 0 {
		query = query.Where("status IN ?", filter.Status)
	}
	if len(filter.Priority) > 0 {
		query = query.Where("priority IN ?", filter.Priority)
	}
	if len(filter.Tags) > 0 {
		query = query.Where("tags ?| ?", filter.Tags)
	}
	if filter.DueBefore != nil {
		query = query.Where("due_date < ?", filter.DueBefore)
	}
	if filter.DueAfter != nil {
		query = query.Where("due_date > ?", filter.DueAfter)
	}
	if filter.AssignedTo != nil {
		query = query.Where("assigned_to = ?", filter.AssignedTo)
	}
	if filter.Search != "" {
		search := "%" + filter.Search + "%"
		query = query.Where("title ILIKE ? OR description ILIKE ?", search, search)
	}

	// 统计总数
	err := query.Count(&total).Error
	if err != nil {
		return nil, 0, err
	}

	// 分页
	offset := (filter.Page - 1) * filter.PageSize
	err = query.
		Preload("User").
		Order("created_at DESC").
		Offset(offset).
		Limit(filter.PageSize).
		Find(&tasks).Error

	return tasks, total, err
}

func (r *taskRepo) GetByIDs(ctx context.Context, ids []uint) ([]*domain.Task, error) {
	var tasks []*domain.Task
	err := r.db.WithContext(ctx).
		Where("id IN ?", ids).
		Preload("User").
		Find(&tasks).Error
	return tasks, err
}

业务逻辑层(Service)

Service 层封装业务规则,协调 Repository 完成复杂操作:

// internal/service/auth_service.go
package service

import (
	"context"
	"errors"
	"time"

	"golang.org/x/crypto/bcrypt"

	"taskmaster/internal/domain"
	"taskmaster/internal/repository"
	"taskmaster/pkg/jwt"
)

// AuthService 认证服务接口
type AuthService interface {
	Register(ctx context.Context, req *domain.CreateUserRequest) (*domain.UserResponse, error)
	Login(ctx context.Context, email, password string) (string, *domain.UserResponse, error)
	RefreshToken(ctx context.Context, userID uint) (string, error)
}

// authService 认证服务实现
type authService struct {
	userRepo   repository.UserRepository
	jwtUtil    *jwt.JWTUtil
}

// NewAuthService 创建认证服务
func NewAuthService(userRepo repository.UserRepository, jwtUtil *jwt.JWTUtil) AuthService {
	return &authService{
		userRepo: userRepo,
		jwtUtil:  jwtUtil,
	}
}

func (s *authService) Register(ctx context.Context, req *domain.CreateUserRequest) (*domain.UserResponse, error) {
	// 检查邮箱是否已存在
	existingUser, err := s.userRepo.GetByEmail(ctx, req.Email)
	if err != nil {
		return nil, err
	}
	if existingUser != nil {
		return nil, errors.New("email already exists")
	}

	// 检查用户名是否已存在
	existingUser, err = s.userRepo.GetByUsername(ctx, req.Username)
	if err != nil {
		return nil, err
	}
	if existingUser != nil {
		return nil, errors.New("username already exists")
	}

	// 加密密码
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
	if err != nil {
		return nil, err
	}

	// 创建用户
	user := &domain.User{
		Username: req.Username,
		Email:    req.Email,
		Password: string(hashedPassword),
		FullName: req.FullName,
		IsActive: true,
	}

	if err := s.userRepo.Create(ctx, user); err != nil {
		return nil, err
	}

	return user.ToResponse(), nil
}

func (s *authService) Login(ctx context.Context, email, password string) (string, *domain.UserResponse, error) {
	// 查找用户
	user, err := s.userRepo.GetByEmail(ctx, email)
	if err != nil {
		return "", nil, err
	}
	if user == nil {
		return "", nil, errors.New("invalid credentials")
	}

	// 验证密码
	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
		return "", nil, errors.New("invalid credentials")
	}

	// 检查账户状态
	if !user.IsActive {
		return "", nil, errors.New("account is deactivated")
	}

	// 生成 JWT
	token, err := s.jwtUtil.GenerateToken(user.ID, user.Email)
	if err != nil {
		return "", nil, err
	}

	return token, user.ToResponse(), nil
}

func (s *authService) RefreshToken(ctx context.Context, userID uint) (string, error) {
	user, err := s.userRepo.GetByID(ctx, userID)
	if err != nil {
		return "", err
	}
	if user == nil {
		return "", errors.New("user not found")
	}

	return s.jwtUtil.GenerateToken(user.ID, user.Email)
}
// internal/service/task_service.go
package service

import (
	"context"
	"errors"
	"time"

	"taskmaster/internal/domain"
	"taskmaster/internal/repository"
)

// TaskService 任务服务接口
type TaskService interface {
	Create(ctx context.Context, userID uint, req *domain.CreateTaskRequest) (*domain.TaskResponse, error)
	GetByID(ctx context.Context, userID, taskID uint) (*domain.TaskResponse, error)
	Update(ctx context.Context, userID, taskID uint, req *domain.UpdateTaskRequest) (*domain.TaskResponse, error)
	Delete(ctx context.Context, userID, taskID uint) error
	List(ctx context.Context, userID uint, filter *domain.TaskFilter) ([]*domain.TaskResponse, int64, error)
	Complete(ctx context.Context, userID, taskID uint) (*domain.TaskResponse, error)
}

// taskService 任务服务实现
type taskService struct {
	taskRepo repository.TaskRepository
}

// NewTaskService 创建任务服务
func NewTaskService(taskRepo repository.TaskRepository) TaskService {
	return &taskService{taskRepo: taskRepo}
}

func (s *taskService) Create(ctx context.Context, userID uint, req *domain.CreateTaskRequest) (*domain.TaskResponse, error) {
	task := &domain.Task{
		Title:       req.Title,
		Description: req.Description,
		Status:      domain.StatusTodo,
		Priority:    req.Priority,
		DueDate:     req.DueDate,
		Tags:        req.Tags,
		UserID:      userID,
		AssignedTo:  req.AssignedTo,
	}

	// 默认优先级
	if task.Priority == "" {
		task.Priority = domain.PriorityMedium
	}

	if err := s.taskRepo.Create(ctx, task); err != nil {
		return nil, err
	}

	return task.ToResponse(), nil
}

func (s *taskService) GetByID(ctx context.Context, userID, taskID uint) (*domain.TaskResponse, error) {
	task, err := s.taskRepo.GetByID(ctx, taskID)
	if err != nil {
		return nil, err
	}
	if task == nil {
		return nil, errors.New("task not found")
	}

	// 权限检查:只有创建者或被分配者可以查看
	if task.UserID != userID && (task.AssignedTo == nil || *task.AssignedTo != userID) {
		return nil, errors.New("forbidden")
	}

	return task.ToResponse(), nil
}

func (s *taskService) Update(ctx context.Context, userID, taskID uint, req *domain.UpdateTaskRequest) (*domain.TaskResponse, error) {
	task, err := s.taskRepo.GetByID(ctx, taskID)
	if err != nil {
		return nil, err
	}
	if task == nil {
		return nil, errors.New("task not found")
	}

	// 权限检查:只有创建者可以修改
	if task.UserID != userID {
		return nil, errors.New("forbidden")
	}

	// 更新字段
	if req.Title != nil {
		task.Title = *req.Title
	}
	if req.Description != nil {
		task.Description = *req.Description
	}
	if req.Status != nil {
		task.Status = *req.Status
		// 如果状态改为完成,设置完成时间
		if *req.Status == domain.StatusDone && task.CompletedAt == nil {
			now := time.Now()
			task.CompletedAt = &now
		}
	}
	if req.Priority != nil {
		task.Priority = *req.Priority
	}
	if req.DueDate != nil {
		task.DueDate = req.DueDate
	}
	if req.Tags != nil {
		task.Tags = req.Tags
	}
	if req.AssignedTo != nil {
		task.AssignedTo = req.AssignedTo
	}

	if err := s.taskRepo.Update(ctx, task); err != nil {
		return nil, err
	}

	return task.ToResponse(), nil
}

func (s *taskService) Delete(ctx context.Context, userID, taskID uint) error {
	task, err := s.taskRepo.GetByID(ctx, taskID)
	if err != nil {
		return err
	}
	if task == nil {
		return errors.New("task not found")
	}

	if task.UserID != userID {
		return errors.New("forbidden")
	}

	return s.taskRepo.Delete(ctx, taskID)
}

func (s *taskService) List(ctx context.Context, userID uint, filter *domain.TaskFilter) ([]*domain.TaskResponse, int64, error) {
	// 默认分页
	if filter.Page < 1 {
		filter.Page = 1
	}
	if filter.PageSize < 1 || filter.PageSize > 100 {
		filter.PageSize = 20
	}

	tasks, total, err := s.taskRepo.List(ctx, userID, filter)
	if err != nil {
		return nil, 0, err
	}

	var responses []*domain.TaskResponse
	for _, task := range tasks {
		responses = append(responses, task.ToResponse())
	}

	return responses, total, nil
}

func (s *taskService) Complete(ctx context.Context, userID, taskID uint) (*domain.TaskResponse, error) {
	task, err := s.taskRepo.GetByID(ctx, taskID)
	if err != nil {
		return nil, err
	}
	if task == nil {
		return nil, errors.New("task not found")
	}

	if task.UserID != userID && (task.AssignedTo == nil || *task.AssignedTo != userID) {
		return nil, errors.New("forbidden")
	}

	task.Status = domain.StatusDone
	now := time.Now()
	task.CompletedAt = &now

	if err := s.taskRepo.Update(ctx, task); err != nil {
		return nil, err
	}

	return task.ToResponse(), nil
}

JWT 认证工具

// pkg/jwt/jwt.go
package jwt

import (
	"errors"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// JWTUtil JWT 工具
type JWTUtil struct {
	secret     string
	expiration time.Duration
	issuer     string
}

// Claims JWT Claims
type Claims struct {
	UserID uint   `json:"user_id"`
	Email  string `json:"email"`
	jwt.RegisteredClaims
}

// NewJWTUtil 创建 JWT 工具
func NewJWTUtil(secret string, expirationHours int, issuer string) *JWTUtil {
	return &JWTUtil{
		secret:     secret,
		expiration: time.Duration(expirationHours) * time.Hour,
		issuer:     issuer,
	}
}

// GenerateToken 生成 JWT
func (j *JWTUtil) GenerateToken(userID uint, email string) (string, error) {
	claims := &Claims{
		UserID: userID,
		Email:  email,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.expiration)),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Issuer:    j.issuer,
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	return token.SignedString([]byte(j.secret))
}

// ValidateToken 验证 JWT
func (j *JWTUtil) ValidateToken(tokenString string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		// 验证签名算法
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, errors.New("unexpected signing method")
		}
		return []byte(j.secret), nil
	})

	if err != nil {
		return nil, err
	}

	claims, ok := token.Claims.(*Claims)
	if !ok || !token.Valid {
		return nil, errors.New("invalid token")
	}

	return claims, nil
}

HTTP Handler 层

Handler 层处理 HTTP 请求和响应:

// internal/handler/auth_handler.go
package handler

import (
	"net/http"

	"github.com/gin-gonic/gin"

	"taskmaster/internal/domain"
	"taskmaster/internal/service"
	"taskmaster/pkg/response"
)

// AuthHandler 认证处理器
type AuthHandler struct {
	authService service.AuthService
}

// NewAuthHandler 创建认证处理器
func NewAuthHandler(authService service.AuthService) *AuthHandler {
	return &AuthHandler{authService: authService}
}

// Register 注册
func (h *AuthHandler) Register(c *gin.Context) {
	var req domain.CreateUserRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	user, err := h.authService.Register(c.Request.Context(), &req)
	if err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	response.Success(c, http.StatusCreated, user)
}

// Login 登录
func (h *AuthHandler) Login(c *gin.Context) {
	var req struct {
		Email    string `json:"email" binding:"required,email"`
		Password string `json:"password" binding:"required"`
	}

	if err := c.ShouldBindJSON(&req); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	token, user, err := h.authService.Login(c.Request.Context(), req.Email, req.Password)
	if err != nil {
		response.Error(c, http.StatusUnauthorized, err.Error())
		return
	}

	response.Success(c, http.StatusOK, gin.H{
		"token": token,
		"user":  user,
	})
}

// Me 获取当前用户
func (h *AuthHandler) Me(c *gin.Context) {
	userID, exists := c.Get("user_id")
	if !exists {
		response.Error(c, http.StatusUnauthorized, "unauthorized")
		return
	}

	// 从上下文获取用户信息
	response.Success(c, http.StatusOK, gin.H{
		"user_id": userID,
	})
}
// internal/handler/task_handler.go
package handler

import (
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"

	"taskmaster/internal/domain"
	"taskmaster/internal/service"
	"taskmaster/pkg/response"
)

// TaskHandler 任务处理器
type TaskHandler struct {
	taskService service.TaskService
}

// NewTaskHandler 创建任务处理器
func NewTaskHandler(taskService service.TaskService) *TaskHandler {
	return &TaskHandler{taskService: taskService}
}

// Create 创建任务
func (h *TaskHandler) Create(c *gin.Context) {
	var req domain.CreateTaskRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	userID := c.GetUint("user_id")
	task, err := h.taskService.Create(c.Request.Context(), userID, &req)
	if err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	response.Success(c, http.StatusCreated, task)
}

// Get 获取任务详情
func (h *TaskHandler) Get(c *gin.Context) {
	taskID, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		response.Error(c, http.StatusBadRequest, "invalid task id")
		return
	}

	userID := c.GetUint("user_id")
	task, err := h.taskService.GetByID(c.Request.Context(), userID, uint(taskID))
	if err != nil {
		status := http.StatusBadRequest
		if err.Error() == "task not found" {
			status = http.StatusNotFound
		} else if err.Error() == "forbidden" {
			status = http.StatusForbidden
		}
		response.Error(c, status, err.Error())
		return
	}

	response.Success(c, http.StatusOK, task)
}

// Update 更新任务
func (h *TaskHandler) Update(c *gin.Context) {
	taskID, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		response.Error(c, http.StatusBadRequest, "invalid task id")
		return
	}

	var req domain.UpdateTaskRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	userID := c.GetUint("user_id")
	task, err := h.taskService.Update(c.Request.Context(), userID, uint(taskID), &req)
	if err != nil {
		status := http.StatusBadRequest
		if err.Error() == "task not found" {
			status = http.StatusNotFound
		} else if err.Error() == "forbidden" {
			status = http.StatusForbidden
		}
		response.Error(c, status, err.Error())
		return
	}

	response.Success(c, http.StatusOK, task)
}

// Delete 删除任务
func (h *TaskHandler) Delete(c *gin.Context) {
	taskID, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		response.Error(c, http.StatusBadRequest, "invalid task id")
		return
	}

	userID := c.GetUint("user_id")
	if err := h.taskService.Delete(c.Request.Context(), userID, uint(taskID)); err != nil {
		status := http.StatusBadRequest
		if err.Error() == "task not found" {
			status = http.StatusNotFound
		} else if err.Error() == "forbidden" {
			status = http.StatusForbidden
		}
		response.Error(c, status, err.Error())
		return
	}

	response.Success(c, http.StatusNoContent, nil)
}

// List 获取任务列表
func (h *TaskHandler) List(c *gin.Context) {
	var filter domain.TaskFilter
	if err := c.ShouldBindQuery(&filter); err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	userID := c.GetUint("user_id")
	tasks, total, err := h.taskService.List(c.Request.Context(), userID, &filter)
	if err != nil {
		response.Error(c, http.StatusBadRequest, err.Error())
		return
	}

	response.Success(c, http.StatusOK, gin.H{
		"tasks":      tasks,
		"total":      total,
		"page":       filter.Page,
		"page_size":  filter.PageSize,
		"total_pages": (total + int64(filter.PageSize) - 1) / int64(filter.PageSize),
	})
}

// Complete 完成任务
func (h *TaskHandler) Complete(c *gin.Context) {
	taskID, err := strconv.ParseUint(c.Param("id"), 10, 64)
	if err != nil {
		response.Error(c, http.StatusBadRequest, "invalid task id")
		return
	}

	userID := c.GetUint("user_id")
	task, err := h.taskService.Complete(c.Request.Context(), userID, uint(taskID))
	if err != nil {
		status := http.StatusBadRequest
		if err.Error() == "task not found" {
			status = http.StatusNotFound
		} else if err.Error() == "forbidden" {
			status = http.StatusForbidden
		}
		response.Error(c, status, err.Error())
		return
	}

	response.Success(c, http.StatusOK, task)
}

中间件链

实现关键的中间件:

// internal/middleware/auth.go
package middleware

import (
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"

	"taskmaster/pkg/jwt"
)

// AuthMiddleware JWT 认证中间件
func AuthMiddleware(jwtUtil *jwt.JWTUtil) gin.HandlerFunc {
	return func(c *gin.Context) {
		// 从 Authorization header 获取 token
		authHeader := c.GetHeader("Authorization")
		if authHeader == "" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "authorization header required",
			})
			return
		}

		parts := strings.SplitN(authHeader, " ", 2)
		if len(parts) != 2 || parts[0] != "Bearer" {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "invalid authorization header format",
			})
			return
		}

		tokenString := parts[1]
		claims, err := jwtUtil.ValidateToken(tokenString)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
				"error": "invalid or expired token",
			})
			return
		}

		// 将用户信息存入上下文
		c.Set("user_id", claims.UserID)
		c.Set("email", claims.Email)

		c.Next()
	}
}
// internal/middleware/logger.go
package middleware

import (
	"log"
	"time"

	"github.com/gin-gonic/gin"
)

// LoggerMiddleware 请求日志中间件
func LoggerMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		path := c.Request.URL.Path
		method := c.Request.Method

		// 执行后续处理器
		c.Next()

		// 计算耗时
		latency := time.Since(start)
		status := c.Writer.Status()

		log.Printf("[%s] %s %s - %d (%v)",
			method, path, c.ClientIP(), status, latency)
	}
}
// internal/middleware/recovery.go
package middleware

import (
	"log"
	"net/http"
	"runtime/debug"

	"github.com/gin-gonic/gin"
)

// RecoveryMiddleware 异常恢复中间件
func RecoveryMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("Panic recovered: %v\n%s", err, debug.Stack())
				c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
					"error": "internal server error",
				})
			}
		}()
		c.Next()
	}
}

统一响应格式

// pkg/response/response.go
package response

import (
	"github.com/gin-gonic/gin"
)

// Response 统一响应结构
type Response struct {
	Success bool        `json:"success"`
	Data    interface{} `json:"data,omitempty"`
	Error   string      `json:"error,omitempty"`
}

// Success 成功响应
func Success(c *gin.Context, statusCode int, data interface{}) {
	c.JSON(statusCode, Response{
		Success: true,
		Data:    data,
	})
}

// Error 错误响应
func Error(c *gin.Context, statusCode int, message string) {
	c.JSON(statusCode, Response{
		Success: false,
		Error:   message,
	})
}

// PaginatedResponse 分页响应
type PaginatedResponse struct {
	Success    bool        `json:"success"`
	Data       interface{} `json:"data"`
	Total      int64       `json:"total"`
	Page       int         `json:"page"`
	PageSize   int         `json:"page_size"`
	TotalPages int64       `json:"total_pages"`
}

// Paginated 分页响应
func Paginated(c *gin.Context, data interface{}, total int64, page, pageSize int) {
	totalPages := (total + int64(pageSize) - 1) / int64(pageSize)
	c.JSON(200, PaginatedResponse{
		Success:    true,
		Data:       data,
		Total:      total,
		Page:       page,
		PageSize:   pageSize,
		TotalPages: totalPages,
	})
}

路由配置

// internal/transport/http/router.go
package http

import (
	"github.com/gin-gonic/gin"

	"taskmaster/internal/handler"
	"taskmaster/internal/middleware"
	"taskmaster/pkg/jwt"
)

// SetupRouter 配置路由
func SetupRouter(
	authHandler *handler.AuthHandler,
	taskHandler *handler.TaskHandler,
	jwtUtil *jwt.JWTUtil,
) *gin.Engine {
	router := gin.New()

	// 全局中间件
	router.Use(middleware.LoggerMiddleware())
	router.Use(middleware.RecoveryMiddleware())

	// 健康检查
	router.GET("/health", func(c *gin.Context) {
		c.JSON(200, gin.H{"status": "ok"})
	})

	// API v1
	v1 := router.Group("/api/v1")
	{
		// 公开路由
		auth := v1.Group("/auth")
		{
			auth.POST("/register", authHandler.Register)
			auth.POST("/login", authHandler.Login)
		}

		// 需要认证的路由
		protected := v1.Group("/")
		protected.Use(middleware.AuthMiddleware(jwtUtil))
		{
			// 用户相关
			protected.GET("/me", authHandler.Me)

			// 任务相关
			tasks := protected.Group("/tasks")
			{
				tasks.POST("", taskHandler.Create)
				tasks.GET("", taskHandler.List)
				tasks.GET("/:id", taskHandler.Get)
				tasks.PUT("/:id", taskHandler.Update)
				tasks.DELETE("/:id", taskHandler.Delete)
				tasks.POST("/:id/complete", taskHandler.Complete)
			}
		}
	}

	return router
}

应用入口

// cmd/server/main.go
package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"gorm.io/driver/postgres"
	"gorm.io/gorm"

	"taskmaster/internal/config"
	"taskmaster/internal/handler"
	"taskmaster/internal/repository"
	"taskmaster/internal/service"
	httptransport "taskmaster/internal/transport/http"
	"taskmaster/pkg/jwt"
)

func main() {
	// 加载配置
	cfg, err := config.Load()
	if err != nil {
		log.Fatalf("Failed to load config: %v", err)
	}

	// 连接数据库
	db, err := gorm.Open(postgres.Open(cfg.Database.GetDSN()), &gorm.Config{})
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}

	// 初始化组件
	jwtUtil := jwt.NewJWTUtil(cfg.JWT.Secret, cfg.JWT.ExpirationHours, cfg.JWT.Issuer)

	userRepo := repository.NewUserRepository(db)
	taskRepo := repository.NewTaskRepository(db)

	authService := service.NewAuthService(userRepo, jwtUtil)
	taskService := service.NewTaskService(taskRepo)

	authHandler := handler.NewAuthHandler(authService)
	taskHandler := handler.NewTaskHandler(taskService)

	// 配置路由
	router := httptransport.SetupRouter(authHandler, taskHandler, jwtUtil)

	// 创建 HTTP 服务器
	srv := &http.Server{
		Addr:    ":8080",
		Handler: router,
	}

	// 优雅关闭
	go func() {
		log.Printf("Server starting on %s", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("Failed to start server: %v", err)
		}
	}()

	// 等待中断信号
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	log.Println("Shutting down server...")

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Fatalf("Server forced to shutdown: %v", err)
	}

	log.Println("Server exited")
}

单元测试

编写测试确保代码质量:

// internal/service/task_service_test.go
package service

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"

	"taskmaster/internal/domain"
	"taskmaster/internal/repository/mocks"
)

func TestTaskService_Create(t *testing.T) {
	mockRepo := new(mocks.TaskRepository)
	service := NewTaskService(mockRepo)

	req := &domain.CreateTaskRequest{
		Title:    "Test Task",
		Priority: domain.PriorityHigh,
	}

	mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*domain.Task")).
		Return(nil)

	task, err := service.Create(context.Background(), 1, req)

	assert.NoError(t, err)
	assert.NotNil(t, task)
	assert.Equal(t, "Test Task", task.Title)
	assert.Equal(t, domain.PriorityHigh, task.Priority)
	mockRepo.AssertExpectations(t)
}

func TestTaskService_GetByID_NotFound(t *testing.T) {
	mockRepo := new(mocks.TaskRepository)
	service := NewTaskService(mockRepo)

	mockRepo.On("GetByID", mock.Anything, uint(999)).
		Return(nil, nil)

	task, err := service.GetByID(context.Background(), 1, 999)

	assert.Error(t, err)
	assert.Nil(t, task)
	assert.Equal(t, "task not found", err.Error())
}

func TestTaskService_Update_Forbidden(t *testing.T) {
	mockRepo := new(mocks.TaskRepository)
	service := NewTaskService(mockRepo)

	existingTask := &domain.Task{
		ID:     1,
		Title:  "Original",
		UserID: 2, // 不同用户
	}

	mockRepo.On("GetByID", mock.Anything, uint(1)).
		Return(existingTask, nil)

	req := &domain.UpdateTaskRequest{
		Title: strPtr("Updated"),
	}

	task, err := service.Update(context.Background(), 1, 1, req)

	assert.Error(t, err)
	assert.Nil(t, task)
	assert.Equal(t, "forbidden", err.Error())
}

func strPtr(s string) *string {
	return &s
}

Docker 容器化

# deployments/docker/Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app

# 安装依赖
RUN apk add --no-cache git

# 复制 go.mod 和 go.sum
COPY go.mod go.sum ./
RUN go mod download

# 复制源代码
COPY . .

# 构建
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server

# 最终镜像
FROM alpine:latest

RUN apk --no-cache add ca-certificates tzdata

WORKDIR /root/

# 从构建阶段复制二进制
COPY --from=builder /app/server .
COPY --from=builder /app/configs ./configs
COPY --from=builder /app/migrations ./migrations

EXPOSE 8080

CMD ["./server"]
# deployments/docker/docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: ../..
      dockerfile: deployments/docker/Dockerfile
    ports:
      - "8080:8080"
    environment:
      - DATABASE_HOST=postgres
      - DATABASE_PORT=5432
      - DATABASE_USER=taskmaster
      - DATABASE_PASSWORD=secret
      - DATABASE_NAME=taskmaster_db
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    depends_on:
      - postgres
      - redis
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: taskmaster
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: taskmaster_db
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

  migrate:
    image: migrate/migrate
    volumes:
      - ../../migrations:/migrations
    command: ["-path", "/migrations", "-database", "postgres://taskmaster:secret@postgres:5432/taskmaster_db?sslmode=disable", "up"]
    depends_on:
      - postgres

volumes:
  postgres_data:
  redis_data:

Kubernetes 部署

# deployments/k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskmaster
  labels:
    app: taskmaster
spec:
  replicas: 3
  selector:
    matchLabels:
      app: taskmaster
  template:
    metadata:
      labels:
        app: taskmaster
    spec:
      containers:
      - name: taskmaster
        image: taskmaster:latest
        ports:
        - containerPort: 8080
        env:
        - name: DATABASE_HOST
          valueFrom:
            configMapKeyRef:
              name: taskmaster-config
              key: database_host
        - name: DATABASE_PASSWORD
          valueFrom:
            secretKeyRef:
              name: taskmaster-secret
              key: database_password
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
# deployments/k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: taskmaster
spec:
  selector:
    app: taskmaster
  ports:
  - port: 80
    targetPort: 8080
  type: LoadBalancer
# deployments/k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: taskmaster-config
data:
  database_host: "postgres-service"
  database_port: "5432"
  database_name: "taskmaster_db"
  database_user: "taskmaster"
  redis_host: "redis-service"
  redis_port: "6379"

监控与日志

使用 Prometheus 收集指标:

// internal/middleware/metrics.go
package middleware

import (
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promauto"
)

var (
	httpRequestsTotal = promauto.NewCounterVec(
		prometheus.CounterOpts{
			Name: "http_requests_total",
			Help: "Total number of HTTP requests",
		},
		[]string{"method", "path", "status"},
	)

	httpRequestDuration = promauto.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_seconds",
			Help:    "HTTP request duration in seconds",
			Buckets: prometheus.DefBuckets,
		},
		[]string{"method", "path"},
	)
)

// MetricsMiddleware Prometheus 指标中间件
func MetricsMiddleware() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		path := c.Request.URL.Path
		method := c.Request.Method

		c.Next()

		duration := time.Since(start).Seconds()
		status := strconv.Itoa(c.Writer.Status())

		httpRequestsTotal.WithLabelValues(method, path, status).Inc()
		httpRequestDuration.WithLabelValues(method, path).Observe(duration)
	}
}

总结

恭喜你完成了这个完整的 Go Web 项目实战!我们从零开始构建了一个生产级别的任务管理系统,涵盖了:

  1. 清晰的架构设计:分层架构(Handler → Service → Repository)
  2. 完善的数据库设计:使用迁移管理 schema 变更
  3. RESTful API:符合标准的接口设计
  4. JWT 认证:安全的身份验证机制
  5. 中间件链:日志、认证、异常恢复
  6. 单元测试:使用 mock 进行隔离测试
  7. 容器化部署:Docker + Docker Compose
  8. Kubernetes 编排:生产环境部署
  9. 监控指标:Prometheus 集成

这个项目展示了 Go 在构建现代 Web 应用方面的优势:

  • 高性能:编译型语言,并发模型优秀
  • 强类型:编译时捕获错误
  • 标准库丰富:减少第三方依赖
  • 部署简单:单一二进制文件

希望这个实战项目能帮助你掌握 Go Web 开发的完整流程。至此,Go 语言入门系列 90 篇文章全部完成!感谢你的阅读,祝你在 Go 的世界里越走越远!

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页