404 Not Found

404 Not Found


nginx

综合项目(上)

综合项目(上)

从零搭建一个完整的 Go 项目——博客 API 系统。

本课我们将构建一个博客文章管理系统(Blog API),涵盖需求分析、目录设计、数据模型、业务逻辑、错误处理和单元测试。下一课将完成 HTTP 路由与中间件部分。


项目需求

功能列表

模块 功能 说明
文章 创建、查询、列表、更新、删除 CRUD 全覆盖
分类 创建、查询、列表 文章归属分类
标签 创建、查询、列表 文章可关联多个标签
用户 注册、查询 简化版,不含登录鉴权

非功能需求


系统架构设计

采用经典的三层架构

TEXT
┌─────────────────────────────────────────────┐
│              Handler (HTTP 层)               │
│   接收请求 → 参数解析 → 调用 Service → 返回   │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│             Service (业务层)                  │
│   业务逻辑 → 参数校验 → 调用 Repository       │
└──────────────────────┬──────────────────────┘
                       │
┌──────────────────────▼──────────────────────┐
│           Repository (数据层)                 │
│   数据访问 → 内存存储 / 数据库                 │
└─────────────────────────────────────────────┘

每一层只依赖下一层,通过接口解耦,便于测试和替换实现。


项目目录结构

TEXT
blog-api/
├── cmd/
│   └── server/
│       └── main.go            # 程序入口
├── internal/
│   ├── handler/               # HTTP 处理器(第30课实现)
│   │   ├── article.go
│   │   ├── category.go
│   │   └── response.go
│   ├── service/               # 业务逻辑层
│   │   ├── article.go
│   │   ├── category.go
│   │   └── service.go
│   ├── repository/            # 数据访问层
│   │   ├── article.go
│   │   ├── category.go
│   │   └── store.go
│   └── model/                 # 数据模型
│       ├── article.go
│       ├── category.go
│       └── user.go
├── pkg/
│   └── errcode/               # 统一错误码
│       └── errcode.go
├── go.mod
└── README.md

目录职责说明:

目录 职责 可见性
cmd/ 程序入口,每个子目录对应一个可执行文件 公开
internal/ 项目核心代码,外部包无法导入 私有
pkg/ 可被外部项目复用的工具包 公开

第一步:初始化项目

BASH
mkdir -p blog-api && cd blog-api
go mod init blog-api

创建目录结构:

BASH
mkdir -p cmd/server
mkdir -p internal/model internal/repository internal/service internal/handler
mkdir -p pkg/errcode

第二步:定义数据模型

用户模型

GO
// internal/model/user.go
package model

import "time"

// User 用户模型
type User struct {
	ID        int64     `json:"id"`
	Username  string    `json:"username"`
	Email     string    `json:"email"`
	CreatedAt time.Time `json:"created_at"`
}

分类模型

GO
// internal/model/category.go
package model

import "time"

// Category 文章分类
type Category struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

文章模型

GO
// internal/model/article.go
package model

import "time"

// Article 文章模型
type Article struct {
	ID         int64     `json:"id"`
	Title      string    `json:"title"`
	Content    string    `json:"content"`
	AuthorID   int64     `json:"author_id"`
	CategoryID int64     `json:"category_id"`
	Tags       []string  `json:"tags"`
	Status     int8      `json:"status"` // 0=草稿 1=已发布
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

// CreateArticleRequest 创建文章请求
type CreateArticleRequest struct {
	Title      string   `json:"title"`
	Content    string   `json:"content"`
	AuthorID   int64    `json:"author_id"`
	CategoryID int64    `json:"category_id"`
	Tags       []string `json:"tags"`
}

// UpdateArticleRequest 更新文章请求
type UpdateArticleRequest struct {
	Title      *string  `json:"title,omitempty"`
	Content    *string  `json:"content,omitempty"`
	CategoryID *int64   `json:"category_id,omitempty"`
	Tags       []string `json:"tags,omitempty"`
	Status     *int8    `json:"status,omitempty"`
}

// ListRequest 分页查询请求
type ListRequest struct {
	Page     int `json:"page"`
	PageSize int `json:"page_size"`
}

// ListResponse 分页查询响应
type ListResponse struct {
	Items      interface{} `json:"items"`
	Total      int64       `json:"total"`
	Page       int         `json:"page"`
	PageSize   int         `json:"page_size"`
	TotalPages int         `json:"total_pages"`
}

代码解析:


第三步:统一错误码

GO
// pkg/errcode/errcode.go
package errcode

import "fmt"

// AppError 应用错误
type AppError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

// Error 实现 error 接口
func (e *AppError) Error() string {
	return fmt.Sprintf("code=%d, message=%s", e.Code, e.Message)
}

// New 创建应用错误
func New(code int, message string) *AppError {
	return &AppError{Code: code, Message: message}
}

// 预定义错误码
var (
	ErrNotFound       = New(10001, "资源不存在")
	ErrInvalidParam   = New(10002, "参数错误")
	ErrDuplicate      = New(10003, "资源已存在")
	ErrInternal       = New(10004, "内部错误")
	ErrArticleNotFound = New(20001, "文章不存在")
	ErrCategoryNotFound = New(20002, "分类不存在")
	ErrUserNotFound   = New(20003, "用户不存在")
	ErrTitleRequired  = New(20004, "标题不能为空")
	ErrContentRequired = New(20005, "内容不能为空")
)

代码解析:


第四步:数据访问层(Repository)

存储接口

GO
// internal/repository/store.go
package repository

import "blog-api/internal/model"

// Store 统一存储接口(面向接口编程)
type Store interface {
	ArticleRepo() ArticleRepository
	CategoryRepo() CategoryRepository
	UserRepo() UserRepository
}

// ArticleRepository 文章数据访问接口
type ArticleRepository interface {
	Create(article *model.Article) error
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) ([]*model.Article, int64, error)
	Update(article *model.Article) error
	Delete(id int64) error
}

// CategoryRepository 分类数据访问接口
type CategoryRepository interface {
	Create(category *model.Category) error
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

// UserRepository 用户数据访问接口
type UserRepository interface {
	Create(user *model.User) error
	GetByID(id int64) (*model.User, error)
}

内存实现

GO
// internal/repository/memory_store.go
package repository

import (
	"fmt"
	"sync"
	"sync/atomic"

	"blog-api/internal/model"
)

// MemoryStore 内存存储实现
type MemoryStore struct {
	articles   map[int64]*model.Article
	categories map[int64]*model.Category
	users      map[int64]*model.User
	mu         sync.RWMutex
	nextID     atomic.Int64
}

// NewMemoryStore 创建内存存储
func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		articles:   make(map[int64]*model.Article),
		categories: make(map[int64]*model.Category),
		users:      make(map[int64]*model.User),
	}
}

func (s *MemoryStore) next() int64 {
	return s.nextID.Add(1)
}

// ArticleRepo 返回文章仓库
func (s *MemoryStore) ArticleRepo() ArticleRepository {
	return &memoryArticleRepo{store: s}
}

// CategoryRepo 返回分类仓库
func (s *MemoryStore) CategoryRepo() CategoryRepository {
	return &memoryCategoryRepo{store: s}
}

// UserRepo 返回用户仓库
func (s *MemoryStore) UserRepo() UserRepository {
	return &memoryUserRepo{store: s}
}

// ---- 文章仓库实现 ----

type memoryArticleRepo struct {
	store *MemoryStore
}

func (r *memoryArticleRepo) Create(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	article.ID = r.store.next()
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) GetByID(id int64) (*model.Article, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	article, ok := r.store.articles[id]
	if !ok {
		return nil, fmt.Errorf("article %d not found", id)
	}
	return article, nil
}

func (r *memoryArticleRepo) List(page, pageSize int) ([]*model.Article, int64, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	// 收集所有文章
	all := make([]*model.Article, 0, len(r.store.articles))
	for _, a := range r.store.articles {
		all = append(all, a)
	}

	total := int64(len(all))
	start := (page - 1) * pageSize
	if start >= len(all) {
		return []*model.Article{}, total, nil
	}
	end := start + pageSize
	if end > len(all) {
		end = len(all)
	}

	return all[start:end], total, nil
}

func (r *memoryArticleRepo) Update(article *model.Article) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[article.ID]; !ok {
		return fmt.Errorf("article %d not found", article.ID)
	}
	r.store.articles[article.ID] = article
	return nil
}

func (r *memoryArticleRepo) Delete(id int64) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	if _, ok := r.store.articles[id]; !ok {
		return fmt.Errorf("article %d not found", id)
	}
	delete(r.store.articles, id)
	return nil
}

// ---- 分类仓库实现 ----

type memoryCategoryRepo struct {
	store *MemoryStore
}

func (r *memoryCategoryRepo) Create(category *model.Category) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	category.ID = r.store.next()
	r.store.categories[category.ID] = category
	return nil
}

func (r *memoryCategoryRepo) GetByID(id int64) (*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	cat, ok := r.store.categories[id]
	if !ok {
		return nil, fmt.Errorf("category %d not found", id)
	}
	return cat, nil
}

func (r *memoryCategoryRepo) List() ([]*model.Category, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	all := make([]*model.Category, 0, len(r.store.categories))
	for _, c := range r.store.categories {
		all = append(all, c)
	}
	return all, nil
}

// ---- 用户仓库实现 ----

type memoryUserRepo struct {
	store *MemoryStore
}

func (r *memoryUserRepo) Create(user *model.User) error {
	r.store.mu.Lock()
	defer r.store.mu.Unlock()

	user.ID = r.store.next()
	r.store.users[user.ID] = user
	return nil
}

func (r *memoryUserRepo) GetByID(id int64) (*model.User, error) {
	r.store.mu.RLock()
	defer r.store.mu.RUnlock()

	user, ok := r.store.users[id]
	if !ok {
		return nil, fmt.Errorf("user %d not found", id)
	}
	return user, nil
}

代码解析:


第五步:业务逻辑层(Service)

Service 接口

GO
// internal/service/service.go
package service

import "blog-api/internal/model"

// ArticleService 文章业务接口
type ArticleService interface {
	Create(req *model.CreateArticleRequest) (*model.Article, error)
	GetByID(id int64) (*model.Article, error)
	List(page, pageSize int) (*model.ListResponse, error)
	Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error)
	Delete(id int64) error
}

// CategoryService 分类业务接口
type CategoryService interface {
	Create(name string) (*model.Category, error)
	GetByID(id int64) (*model.Category, error)
	List() ([]*model.Category, error)
}

文章服务实现

GO
// internal/service/article.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type articleService struct {
	store repository.Store
}

// NewArticleService 创建文章服务
func NewArticleService(store repository.Store) ArticleService {
	return &articleService{store: store}
}

// Create 创建文章
func (s *articleService) Create(req *model.CreateArticleRequest) (*model.Article, error) {
	// 参数校验
	if strings.TrimSpace(req.Title) == "" {
		return nil, errcode.ErrTitleRequired
	}
	if strings.TrimSpace(req.Content) == "" {
		return nil, errcode.ErrContentRequired
	}

	// 验证作者是否存在
	_, err := s.store.UserRepo().GetByID(req.AuthorID)
	if err != nil {
		return nil, errcode.ErrUserNotFound
	}

	// 验证分类是否存在
	_, err = s.store.CategoryRepo().GetByID(req.CategoryID)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}

	// 构建文章对象
	now := time.Now()
	article := &model.Article{
		Title:      req.Title,
		Content:    req.Content,
		AuthorID:   req.AuthorID,
		CategoryID: req.CategoryID,
		Tags:       req.Tags,
		Status:     0, // 默认草稿
		CreatedAt:  now,
		UpdatedAt:  now,
	}

	// 持久化
	if err := s.store.ArticleRepo().Create(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// GetByID 根据 ID 获取文章
func (s *articleService) GetByID(id int64) (*model.Article, error) {
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}
	return article, nil
}

// List 分页查询文章列表
func (s *articleService) List(page, pageSize int) (*model.ListResponse, error) {
	// 参数默认值
	if page <= 0 {
		page = 1
	}
	if pageSize <= 0 || pageSize > 100 {
		pageSize = 10
	}

	articles, total, err := s.store.ArticleRepo().List(page, pageSize)
	if err != nil {
		return nil, errcode.ErrInternal
	}

	// 计算总页数
	totalPages := int(total) / pageSize
	if int(total)%pageSize > 0 {
		totalPages++
	}

	return &model.ListResponse{
		Items:      articles,
		Total:      total,
		Page:       page,
		PageSize:   pageSize,
		TotalPages: totalPages,
	}, nil
}

// Update 更新文章
func (s *articleService) Update(id int64, req *model.UpdateArticleRequest) (*model.Article, error) {
	// 查询现有文章
	article, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrArticleNotFound
	}

	// 按需更新字段(只更新传入的非空字段)
	if req.Title != nil {
		if strings.TrimSpace(*req.Title) == "" {
			return nil, errcode.ErrTitleRequired
		}
		article.Title = *req.Title
	}
	if req.Content != nil {
		if strings.TrimSpace(*req.Content) == "" {
			return nil, errcode.ErrContentRequired
		}
		article.Content = *req.Content
	}
	if req.CategoryID != nil {
		// 验证分类是否存在
		_, err := s.store.CategoryRepo().GetByID(*req.CategoryID)
		if err != nil {
			return nil, errcode.ErrCategoryNotFound
		}
		article.CategoryID = *req.CategoryID
	}
	if req.Tags != nil {
		article.Tags = req.Tags
	}
	if req.Status != nil {
		article.Status = *req.Status
	}
	article.UpdatedAt = time.Now()

	// 持久化
	if err := s.store.ArticleRepo().Update(article); err != nil {
		return nil, errcode.ErrInternal
	}

	return article, nil
}

// Delete 删除文章
func (s *articleService) Delete(id int64) error {
	_, err := s.store.ArticleRepo().GetByID(id)
	if err != nil {
		return errcode.ErrArticleNotFound
	}

	if err := s.store.ArticleRepo().Delete(id); err != nil {
		return errcode.ErrInternal
	}

	return nil
}

代码解析:

分类服务实现

GO
// internal/service/category.go
package service

import (
	"strings"
	"time"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

type categoryService struct {
	store repository.Store
}

// NewCategoryService 创建分类服务
func NewCategoryService(store repository.Store) CategoryService {
	return &categoryService{store: store}
}

// Create 创建分类
func (s *categoryService) Create(name string) (*model.Category, error) {
	if strings.TrimSpace(name) == "" {
		return nil, errcode.New(10002, "分类名不能为空")
	}

	category := &model.Category{
		Name:      name,
		CreatedAt: time.Now(),
	}

	if err := s.store.CategoryRepo().Create(category); err != nil {
		return nil, errcode.ErrInternal
	}

	return category, nil
}

// GetByID 根据 ID 获取分类
func (s *categoryService) GetByID(id int64) (*model.Category, error) {
	cat, err := s.store.CategoryRepo().GetByID(id)
	if err != nil {
		return nil, errcode.ErrCategoryNotFound
	}
	return cat, nil
}

// List 获取分类列表
func (s *categoryService) List() ([]*model.Category, error) {
	cats, err := s.store.CategoryRepo().List()
	if err != nil {
		return nil, errcode.ErrInternal
	}
	return cats, nil
}

第六步:程序入口

GO
// cmd/server/main.go
package main

import (
	"fmt"
	"log"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/internal/service"
)

func main() {
	// 初始化存储
	store := repository.NewMemoryStore()

	// 初始化服务
	articleSvc := service.NewArticleService(store)
	categorySvc := service.NewCategoryService(store)

	// 预置测试数据
	seedData(store, articleSvc, categorySvc)

	// 验证业务逻辑
	runDemo(articleSvc, categorySvc)
}

// seedData 填充测试数据
func seedData(store *repository.MemoryStore, articleSvc service.ArticleService, categorySvc service.CategoryService) {
	// 创建用户
	user := &model.User{
		Username: "zhangsan",
		Email:    "zhangsan@example.com",
	}
	if err := store.UserRepo().Create(user); err != nil {
		log.Fatalf("创建用户失败: %v", err)
	}
	fmt.Printf("✅ 用户创建成功: ID=%d, Username=%s\n", user.ID, user.Username)

	// 创建分类
	goCategory, _ := categorySvc.Create("Go 语言")
	pythonCategory, _ := categorySvc.Create("Python")
	fmt.Printf("✅ 分类创建成功: ID=%d, Name=%s\n", goCategory.ID, goCategory.Name)
	fmt.Printf("✅ 分类创建成功: ID=%d, Name=%s\n", pythonCategory.ID, pythonCategory.Name)

	// 创建文章
	article1, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Go 并发编程入门",
		Content:    "本文介绍 goroutine 和 channel 的基本用法...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "并发", "goroutine"},
	})
	if err != nil {
		log.Fatalf("创建文章失败: %v", err)
	}
	fmt.Printf("✅ 文章创建成功: ID=%d, Title=%s\n", article1.ID, article1.Title)

	article2, err := articleSvc.Create(&model.CreateArticleRequest{
		Title:      "Go 接口设计模式",
		Content:    "好的接口设计是 Go 程序的关键...",
		AuthorID:   user.ID,
		CategoryID: goCategory.ID,
		Tags:       []string{"go", "接口", "设计模式"},
	})
	if err != nil {
		log.Fatalf("创建文章失败: %v", err)
	}
	fmt.Printf("✅ 文章创建成功: ID=%d, Title=%s\n", article2.ID, article2.Title)
}

// runDemo 演示核心功能
func runDemo(articleSvc service.ArticleService, categorySvc service.CategoryService) {
	fmt.Println("\n========== 功能演示 ==========")

	// 查询单篇文章
	article, err := articleSvc.GetByID(3)
	if err != nil {
		log.Printf("查询文章失败: %v", err)
	} else {
		fmt.Printf("\n📄 查询文章: %s\n", article.Title)
		fmt.Printf("   内容: %s\n", article.Content)
		fmt.Printf("   标签: %v\n", article.Tags)
	}

	// 分页查询
	fmt.Println("\n📋 文章列表 (第1页, 每页10条):")
	list, err := articleSvc.List(1, 10)
	if err != nil {
		log.Printf("查询列表失败: %v", err)
	} else {
		articles := list.Items.([]*model.Article)
		for _, a := range articles {
			fmt.Printf("   [%d] %s (标签: %v)\n", a.ID, a.Title, a.Tags)
		}
		fmt.Printf("   共 %d 篇, %d 页\n", list.Total, list.TotalPages)
	}

	// 更新文章
	newTitle := "Go 并发编程进阶"
	newStatus := int8(1)
	updated, err := articleSvc.Update(3, &model.UpdateArticleRequest{
		Title:  &newTitle,
		Status: &newStatus,
	})
	if err != nil {
		log.Printf("更新文章失败: %v", err)
	} else {
		fmt.Printf("\n✏️  文章已更新: %s (状态: %d)\n", updated.Title, updated.Status)
	}

	// 删除文章
	err = articleSvc.Delete(4)
	if err != nil {
		log.Printf("删除文章失败: %v", err)
	} else {
		fmt.Println("\n🗑️  文章 ID=4 已删除")
	}

	// 验证删除后再查询
	_, err = articleSvc.GetByID(4)
	if err != nil {
		fmt.Printf("   查询已删除文章: %v ✅\n", err)
	}

	// 分类列表
	categories, _ := categorySvc.List()
	fmt.Println("\n📂 分类列表:")
	for _, c := range categories {
		fmt.Printf("   [%d] %s\n", c.ID, c.Name)
	}

	// 错误处理演示
	fmt.Println("\n❌ 错误处理演示:")

	// 标题为空
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "test",
	})
	fmt.Printf("   空标题: %v\n", err)

	// 不存在的作者
	_, err = articleSvc.Create(&model.CreateArticleRequest{
		Title:      "test",
		Content:    "test",
		AuthorID:   999,
		CategoryID: 2,
	})
	fmt.Printf("   不存在的作者: %v\n", err)

	// 查询不存在的文章
	_, err = articleSvc.GetByID(999)
	fmt.Printf("   不存在的文章: %v\n", err)
}

运行演示

BASH
go run cmd/server/main.go

预期输出:

TEXT
✅ 用户创建成功: ID=1, Username=zhangsan
✅ 分类创建成功: ID=2, Name=Go 语言
✅ 分类创建成功: ID=3, Name=Python
✅ 文章创建成功: ID=4, Title=Go 并发编程入门
✅ 文章创建成功: ID=5, Title=Go 接口设计模式

========== 功能演示 ==========

📄 查询文章: Go 并发编程入门
   内容: 本文介绍 goroutine 和 channel 的基本用法...
   标签: [go 并发 goroutine]

📋 文章列表 (第1页, 每页10条):
   [4] Go 并发编程入门 (标签: [go 并发 goroutine])
   [5] Go 接口设计模式 (标签: [go 接口 设计模式])
   共 2 篇, 1 页

✏️  文章已更新: Go 并发编程进阶 (状态: 1)

🗑️  文章 ID=5 已删除
   查询已删除文章: code=20001, message=文章不存在 ✅

📂 分类列表:
   [2] Go 语言
   [3] Python

❌ 错误处理演示:
   空标题: code=20004, message=标题不能为空
   不存在的作者: code=20003, message=用户不存在
   不存在的文章: code=20001, message=文章不存在

第七步:单元测试

Repository 层测试

GO
// internal/repository/memory_store_test.go
package repository

import (
	"testing"
	"time"

	"blog-api/internal/model"
)

// 辅助函数:创建测试用存储
func newTestStore() *MemoryStore {
	return NewMemoryStore()
}

func TestArticleRepository_Create(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:     "测试文章",
		Content:   "测试内容",
		AuthorID:  1,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	err := repo.Create(article)
	if err != nil {
		t.Fatalf("创建文章失败: %v", err)
	}

	// ID 应该被自动分配
	if article.ID == 0 {
		t.Fatal("文章 ID 不应为 0")
	}
}

func TestArticleRepository_GetByID(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// 创建文章
	article := &model.Article{
		Title:   "测试文章",
		Content: "测试内容",
	}
	_ = repo.Create(article)

	// 查询
	got, err := repo.GetByID(article.ID)
	if err != nil {
		t.Fatalf("查询文章失败: %v", err)
	}
	if got.Title != "测试文章" {
		t.Errorf("期望标题 %q, 实际 %q", "测试文章", got.Title)
	}

	// 查询不存在的文章
	_, err = repo.GetByID(999)
	if err == nil {
		t.Error("查询不存在的文章应该返回错误")
	}
}

func TestArticleRepository_List(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	// 创建 15 篇文章
	for i := 0; i < 15; i++ {
		_ = repo.Create(&model.Article{
			Title:   "文章",
			Content: "内容",
		})
	}

	// 第一页
	articles, total, err := repo.List(1, 10)
	if err != nil {
		t.Fatalf("查询列表失败: %v", err)
	}
	if total != 15 {
		t.Errorf("期望总数 15, 实际 %d", total)
	}
	if len(articles) != 10 {
		t.Errorf("期望 10 条, 实际 %d", len(articles))
	}

	// 第二页
	articles, _, _ = repo.List(2, 10)
	if len(articles) != 5 {
		t.Errorf("期望 5 条, 实际 %d", len(articles))
	}

	// 超出范围的页
	articles, _, _ = repo.List(100, 10)
	if len(articles) != 0 {
		t.Errorf("超出范围应返回空, 实际 %d 条", len(articles))
	}
}

func TestArticleRepository_Update(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "原标题",
		Content: "原内容",
	}
	_ = repo.Create(article)

	// 更新
	article.Title = "新标题"
	err := repo.Update(article)
	if err != nil {
		t.Fatalf("更新失败: %v", err)
	}

	// 验证
	got, _ := repo.GetByID(article.ID)
	if got.Title != "新标题" {
		t.Errorf("期望 %q, 实际 %q", "新标题", got.Title)
	}
}

func TestArticleRepository_Delete(t *testing.T) {
	store := newTestStore()
	repo := store.ArticleRepo()

	article := &model.Article{
		Title:   "待删除",
		Content: "内容",
	}
	_ = repo.Create(article)

	// 删除
	err := repo.Delete(article.ID)
	if err != nil {
		t.Fatalf("删除失败: %v", err)
	}

	// 验证已删除
	_, err = repo.GetByID(article.ID)
	if err == nil {
		t.Error("删除后查询应该返回错误")
	}
}

Service 层测试

GO
// internal/service/article_test.go
package service

import (
	"testing"

	"blog-api/internal/model"
	"blog-api/internal/repository"
	"blog-api/pkg/errcode"
)

// 辅助函数:创建带测试数据的服务
func newTestArticleService() (ArticleService, repository.Store) {
	store := repository.NewMemoryStore()

	// 创建测试用户
	_ = store.UserRepo().Create(&model.User{
		Username: "testuser",
		Email:    "test@example.com",
	})

	// 创建测试分类
	_ = store.CategoryRepo().Create(&model.Category{
		Name: "Go",
	})

	return NewArticleService(store), store
}

func TestArticleService_Create_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, err := svc.Create(&model.CreateArticleRequest{
		Title:      "测试文章",
		Content:    "测试内容",
		AuthorID:   1,
		CategoryID: 2,
		Tags:       []string{"test"},
	})
	if err != nil {
		t.Fatalf("创建文章失败: %v", err)
	}
	if article.Title != "测试文章" {
		t.Errorf("期望 %q, 实际 %q", "测试文章", article.Title)
	}
	if article.Status != 0 {
		t.Errorf("默认状态应为 0, 实际 %d", article.Status)
	}
}

func TestArticleService_Create_EmptyTitle(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:   "",
		Content: "内容",
	})

	// 类型断言检查错误码
	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("期望 AppError, 实际 %T", err)
	}
	if appErr.Code != errcode.ErrTitleRequired.Code {
		t.Errorf("期望错误码 %d, 实际 %d", errcode.ErrTitleRequired.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidAuthor(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "文章",
		Content:    "内容",
		AuthorID:   999,
		CategoryID: 2,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("期望 AppError, 实际 %T", err)
	}
	if appErr.Code != errcode.ErrUserNotFound.Code {
		t.Errorf("期望错误码 %d, 实际 %d", errcode.ErrUserNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Create_InvalidCategory(t *testing.T) {
	svc, _ := newTestArticleService()

	_, err := svc.Create(&model.CreateArticleRequest{
		Title:      "文章",
		Content:    "内容",
		AuthorID:   1,
		CategoryID: 999,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("期望 AppError, 实际 %T", err)
	}
	if appErr.Code != errcode.ErrCategoryNotFound.Code {
		t.Errorf("期望错误码 %d, 实际 %d", errcode.ErrCategoryNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Update_PartialUpdate(t *testing.T) {
	svc, _ := newTestArticleService()

	// 先创建文章
	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "原始标题",
		Content:    "原始内容",
		AuthorID:   1,
		CategoryID: 2,
	})

	// 只更新标题
	newTitle := "新标题"
	updated, err := svc.Update(article.ID, &model.UpdateArticleRequest{
		Title: &newTitle,
	})
	if err != nil {
		t.Fatalf("更新失败: %v", err)
	}
	if updated.Title != "新标题" {
		t.Errorf("期望 %q, 实际 %q", "新标题", updated.Title)
	}
	if updated.Content != "原始内容" {
		t.Errorf("内容不应被修改, 实际 %q", updated.Content)
	}
}

func TestArticleService_Update_NotFound(t *testing.T) {
	svc, _ := newTestArticleService()

	newTitle := "test"
	_, err := svc.Update(999, &model.UpdateArticleRequest{
		Title: &newTitle,
	})

	appErr, ok := err.(*errcode.AppError)
	if !ok {
		t.Fatalf("期望 AppError, 实际 %T", err)
	}
	if appErr.Code != errcode.ErrArticleNotFound.Code {
		t.Errorf("期望错误码 %d, 实际 %d", errcode.ErrArticleNotFound.Code, appErr.Code)
	}
}

func TestArticleService_Delete_Success(t *testing.T) {
	svc, _ := newTestArticleService()

	article, _ := svc.Create(&model.CreateArticleRequest{
		Title:      "待删除",
		Content:    "内容",
		AuthorID:   1,
		CategoryID: 2,
	})

	err := svc.Delete(article.ID)
	if err != nil {
		t.Fatalf("删除失败: %v", err)
	}

	// 验证已删除
	_, err = svc.GetByID(article.ID)
	if err == nil {
		t.Error("删除后查询应该返回错误")
	}
}

func TestArticleService_List_Pagination(t *testing.T) {
	svc, _ := newTestArticleService()

	// 创建 25 篇文章
	for i := 0; i < 25; i++ {
		_, _ = svc.Create(&model.CreateArticleRequest{
			Title:      "文章",
			Content:    "内容",
			AuthorID:   1,
			CategoryID: 2,
		})
	}

	// 第一页
	resp, err := svc.List(1, 10)
	if err != nil {
		t.Fatalf("查询失败: %v", err)
	}
	if resp.Total != 25 {
		t.Errorf("期望总数 25, 实际 %d", resp.Total)
	}
	if resp.TotalPages != 3 {
		t.Errorf("期望总页数 3, 实际 %d", resp.TotalPages)
	}

	// 默认值处理
	resp, _ = svc.List(0, 0)
	if resp.Page != 1 {
		t.Errorf("page=0 应默认为 1, 实际 %d", resp.Page)
	}
	if resp.PageSize != 10 {
		t.Errorf("pageSize=0 应默认为 10, 实际 %d", resp.PageSize)
	}
}

运行测试

BASH
go test ./internal/... -v -cover

预期输出:

TEXT
=== RUN   TestArticleRepository_Create
--- PASS: TestArticleRepository_Create (0.00s)
=== RUN   TestArticleRepository_GetByID
--- PASS: TestArticleRepository_GetByID (0.00s)
=== RUN   TestArticleRepository_List
--- PASS: TestArticleRepository_List (0.00s)
=== RUN   TestArticleRepository_Update
--- PASS: TestArticleRepository_Update (0.00s)
=== RUN   TestArticleRepository_Delete
--- PASS: TestArticleRepository_Delete (0.00s)
=== RUN   TestArticleService_Create_Success
--- PASS: TestArticleService_Create_Success (0.00s)
=== RUN   TestArticleService_Create_EmptyTitle
--- PASS: TestArticleService_Create_EmptyTitle (0.00s)
=== RUN   TestArticleService_Create_InvalidAuthor
--- PASS: TestArticleService_Create_InvalidAuthor (0.00s)
=== RUN   TestArticleService_Create_InvalidCategory
--- PASS: TestArticleService_Create_InvalidCategory (0.00s)
=== RUN   TestArticleService_Update_PartialUpdate
--- PASS: TestArticleService_Update_PartialUpdate (0.00s)
=== RUN   TestArticleService_Update_NotFound
--- PASS: TestArticleService_Update_NotFound (0.00s)
=== RUN   TestArticleService_Delete_Success
--- PASS: TestArticleService_Delete_Success (0.00s)
=== RUN   TestArticleService_List_Pagination
--- PASS: TestArticleService_List_Pagination (0.00s)
PASS
coverage: 82.4% of statements
ok      blog-api/internal/repository    0.002s
ok      blog-api/internal/service       0.002s

代码总结

本课完成了博客 API 的核心部分:

层级 文件 职责
Model internal/model/*.go 数据结构定义,请求/响应体
Repository internal/repository/*.go 数据访问接口 + 内存实现
Service internal/service/*.go 业务逻辑、参数校验、错误转换
Error pkg/errcode/errcode.go 统一错误码定义
Entry cmd/server/main.go 程序入口、初始化、演示

关键设计决策:

  1. 接口解耦:Repository 和 Service 都通过接口交互,方便单元测试(mock)和后续替换存储
  2. 指针更新UpdateArticleRequest 使用指针字段区分零值和未传值
  3. 统一错误AppError 封装错误码和消息,前端可统一解析
  4. 并发安全sync.RWMutex 保护内存数据,读操作用读锁,写操作用写锁

❓ 常见问题

Q1:为什么 Service 层不直接依赖具体的 Repository 实现?

Service 依赖接口而非具体实现,这样在单元测试中可以注入 mock 实现,无需真实存储。同时如果将来要把内存存储换成 MySQL,只需新建一个 mysqlArticleRepo 实现 ArticleRepository 接口,Service 层代码完全不用改。

Q2:UpdateArticleRequest 为什么用指针字段?

Go 中 string 的零值是空字符串 "",如果用普通字段,无法区分"用户没传 title"和"用户传了空 title"。用 *stringnil 表示未传,非 nil 表示有值。这是 Go API 设计中的常见模式。

Q3:内存存储的 sync.RWMutex 会不会成为性能瓶颈?

对于本教程的演示场景足够。如果读多写少(文章列表查询远多于创建更新),RWMutex 的读锁是共享的,多个读操作可以并发执行,只有写操作才互斥。生产环境建议用数据库,连接池本身就是并发安全的。

Q4:测试覆盖率怎么提升?

当前已覆盖 80%+。如需更高覆盖,可以补充:边界值测试(超长标题、特殊字符)、并发安全测试(多个 goroutine 同时创建文章)、分类服务的完整测试用例。


📖 小节

本课我们从零构建了一个完整的 Go 项目骨架:

下一课将完成 HTTP Handler 层,接入路由、中间件,将业务逻辑暴露为 RESTful API。


📝 作业

作业 1:添加标签独立管理

为标签(Tag)实现独立的 Repository 和 Service,支持:

作业 2:实现文章搜索功能

ArticleService 中添加 Search 方法:

提示:内存实现中遍历 map 过滤即可,需注意大小写不敏感匹配。

作业 3:编写并发安全测试

编写一个测试用例,使用 100 个 goroutine 同时调用 ArticleService.Create,验证:


下一课:综合项目(下) — HTTP 路由、中间件与完整 API

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏