ByteNoteByteNote

字节笔记本

2026年7月20日

用 GORM 设计任务管理系统:模型定义与 CRUD 实战

API中转
¥120

本文以一个"用户 - 分类 - 任务 - 附件"四层结构的任务管理系统为例,介绍如何使用 GORM 进行模型设计,并实现一套基于 OpenID 的多用户数据隔离 Service 层。重点演示如何在不使用 ORM 关联字段的前提下,通过业务字段实现数据归属与级联操作,涵盖完整的 CRUD、事务处理、查询过滤和所有权校验。

模型设计思路

在面向终端用户(小程序、移动端)的应用中,用户身份通常由第三方平台下发,业务侧拿到的是一个稳定的 OpenID。在这种场景下,模型设计有几个取舍:

  • 不使用 GORM 的关联字段(foreignKey/relation:避免预加载带来的复杂性和潜在的 N+1 问题,关系完全通过业务字段来表达。
  • 所有业务表都冗余一个 OpenID 字段:作为用户归属的"硬约束",任何查询、更新、删除都强制带上它,从数据库层面保证用户之间的数据隔离。
  • 层级关系用 ID 字段表达Task.CategoryID 指向所属分类,Attachment.TaskID 指向所属任务,配合 OpenID 共同定位数据。
  • 统一的基础字段:每张表都包含 IDCreatedAtUpdatedAtDeletedAt,并启用软删除。
  • 支持自定义排序:每张业务表加 Sort 字段,方便用户拖拽调整顺序。

数据库模型定义

go
package models

import (
	"time"
	"gorm.io/gorm"
)

// User 用户模型
type User struct {
	ID        uint           `gorm:"primaryKey" json:"id"`
	CreatedAt time.Time      `json:"created_at"`
	UpdatedAt time.Time      `json:"updated_at"`
	DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
	Username  string         `gorm:"size:100;not null;comment:用户名" json:"username"`
	Avatar    string         `gorm:"size:255;comment:头像" json:"avatar"`
	OpenID    string         `gorm:"uniqueIndex;size:100;not null;comment:OpenID" json:"openid"`
}

// Category 分类模型
type Category struct {
	ID          uint           `gorm:"primaryKey" json:"id"`
	CreatedAt   time.Time      `json:"created_at"`
	UpdatedAt   time.Time      `json:"updated_at"`
	DeletedAt   gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
	Name        string         `gorm:"size:50;not null;comment:分类名称" json:"name"`
	Color       string         `gorm:"size:30;not null;comment:分类颜色" json:"color"`
	Description string         `gorm:"size:200;comment:分类描述" json:"description"`
	Sort        int32          `gorm:"default:0;comment:排序" json:"sort"`
	OpenID      string         `gorm:"index;size:100;not null;comment:所属用户" json:"openid"`
}

// Task 任务模型
type Task struct {
	ID          uint           `gorm:"primaryKey" json:"id"`
	CreatedAt   time.Time      `json:"created_at"`
	UpdatedAt   time.Time      `json:"updated_at"`
	DeletedAt   gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
	Title       string         `gorm:"size:100;not null;comment:任务标题" json:"title"`
	Description string         `gorm:"type:text;comment:任务描述" json:"description"`
	Deadline    time.Time      `gorm:"comment:截止时间" json:"deadline"`
	Completed   bool           `gorm:"default:false;comment:是否完成" json:"completed"`
	CompletedAt *time.Time     `gorm:"comment:完成时间" json:"completed_at"`
	Urgent      bool           `gorm:"default:false;comment:是否紧急" json:"urgent"`
	Sort        int32          `gorm:"default:0;comment:排序" json:"sort"`
	CategoryID  uint           `gorm:"not null;index;comment:所属分类ID" json:"category_id"`
	OpenID      string         `gorm:"index;size:100;not null;comment:所属用户" json:"openid"`
}

// Attachment 附件模型
type Attachment struct {
	ID        uint           `gorm:"primaryKey" json:"id"`
	CreatedAt time.Time      `json:"created_at"`
	UpdatedAt time.Time      `json:"updated_at"`
	DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
	FileName  string         `gorm:"size:255;not null;comment:文件名" json:"file_name"`
	FilePath  string         `gorm:"size:500;not null;comment:文件路径" json:"file_path"`
	FileSize  int64          `gorm:"comment:文件大小" json:"file_size"`
	FileType  string         `gorm:"size:50;comment:文件类型" json:"file_type"`
	Sort      int32          `gorm:"default:0;comment:排序" json:"sort"`
	TaskID    uint           `gorm:"not null;index;comment:所属任务ID" json:"task_id"`
	OpenID    string         `gorm:"index;size:100;not null;comment:所属用户" json:"openid"`
}

func (User) TableName() string       { return "users" }
func (Category) TableName() string   { return "categories" }
func (Task) TableName() string       { return "tasks" }
func (Attachment) TableName() string { return "attachments" }

几点说明:

  • User.OpenIDuniqueIndex 保证一个 OpenID 只能注册一次。
  • CategoryTaskAttachmentOpenID 用普通 index,因为要按用户做高频过滤。
  • Task.CompletedAt*time.Time,未完成时为 nil,完成时写入时间戳。
  • 所有外键 ID(CategoryIDTaskID)都加了索引,组合 OpenID 查询时性能更好。

迁移时只需一行:

go
db.AutoMigrate(&User{}, &Category{}, &Task{}, &Attachment{})

Service 层架构

Service 层统一接收 *gorm.DBcontext.Context,所有方法的第一参数都是 openID,作为数据归属的"钥匙"。错误通过预定义的哨兵错误返回,方便上层(HTTP handler、RPC handler)做映射。

go
package service

import (
	"context"
	"errors"
	"fmt"
	"time"
	"gorm.io/gorm"
)

var (
	ErrNotFound     = errors.New("record not found")
	ErrInvalidParam = errors.New("invalid parameter")
)

// TaskService 任务服务
type TaskService struct {
	db *gorm.DB
}

func NewTaskService(db *gorm.DB) *TaskService {
	return &TaskService{db: db}
}

// TaskQueryParam 任务列表查询参数
type TaskQueryParam struct {
	CategoryID uint
	Completed  *bool
	Urgent     *bool
	StartDate  *time.Time
	EndDate    *time.Time
	Keyword    string
	Sort       string
	Order      string
}

用户管理

用户管理只提供两个方法:基于 OpenID 的 upsert 和查询。用 Assign(...).FirstOrCreate(...) 组合,一条语句完成"存在则更新昵称头像、不存在则创建"的逻辑,避免先查后写的竞态。

go
// UpsertUser 创建或更新用户
func (s *TaskService) UpsertUser(ctx context.Context, openid, username, avatar string) (*User, error) {
	user := &User{
		OpenID:   openid,
		Username: username,
		Avatar:   avatar,
	}

	err := s.db.WithContext(ctx).
		Where("open_id = ?", openid).
		Assign(User{Username: username, Avatar: avatar}).
		FirstOrCreate(user).Error

	return user, err
}

// GetUser 获取用户信息
func (s *TaskService) GetUser(ctx context.Context, openid string) (*User, error) {
	var user User
	err := s.db.WithContext(ctx).Where("open_id = ?", openid).First(&user).Error
	if err != nil {
		return nil, err
	}
	return &user, nil
}

分类管理(CRUD)

分类是用户组织任务的第一层容器。删除分类是整个 Service 中最复杂的方法:需要先校验是否有未完成任务,再级联删除该分类下的所有附件、任务,最后删除分类本身,整个过程包在一个事务里。

go
// CreateCategory 创建分类
func (s *TaskService) CreateCategory(ctx context.Context, openid string, category *Category) error {
	category.OpenID = openid
	return s.db.WithContext(ctx).Create(category).Error
}

// UpdateCategory 更新分类
func (s *TaskService) UpdateCategory(ctx context.Context, openid string, id uint, category *Category) error {
	result := s.db.WithContext(ctx).
		Where("id = ? AND open_id = ?", id, openid).
		Updates(map[string]interface{}{
			"name":        category.Name,
			"color":       category.Color,
			"description": category.Description,
			"sort":        category.Sort,
		})

	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return ErrNotFound
	}
	return nil
}

// DeleteCategory 删除分类(含级联删除)
func (s *TaskService) DeleteCategory(ctx context.Context, openid string, id uint) error {
	return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		// 检查是否存在未完成的任务
		var unfinishedCount int64
		if err := tx.Model(&Task{}).
			Where("category_id = ? AND open_id = ? AND completed = ?", id, openid, false).
			Count(&unfinishedCount).Error; err != nil {
			return err
		}
		if unfinishedCount > 0 {
			return errors.New("category contains unfinished tasks")
		}

		// 删除分类下的所有附件
		if err := tx.Where("task_id IN (?)",
			tx.Model(&Task{}).Select("id").Where("category_id = ? AND open_id = ?", id, openid),
		).Delete(&Attachment{}).Error; err != nil {
			return err
		}

		// 删除分类下的所有任务
		if err := tx.Where("category_id = ? AND open_id = ?", id, openid).Delete(&Task{}).Error; err != nil {
			return err
		}

		// 删除分类
		if err := tx.Where("id = ? AND open_id = ?", id, openid).Delete(&Category{}).Error; err != nil {
			return err
		}

		return nil
	})
}

// ListCategories 获取分类列表
func (s *TaskService) ListCategories(ctx context.Context, openid string) ([]Category, error) {
	var categories []Category
	err := s.db.WithContext(ctx).
		Where("open_id = ?", openid).
		Order("sort desc, id desc").
		Find(&categories).Error
	return categories, err
}

级联删除附件时用了子查询 task_id IN (...),让数据库一次性定位要删除的附件,避免在应用层拉取所有任务 ID 再循环。因为没有定义外键约束,软删除不会自动级联,必须显式处理。

任务管理(CRUD + 状态流转)

任务是系统的核心实体。除了常规 CRUD,还提供独立的 CompleteTaskUncompleteTask 用于状态流转,这样调用方不必为了切换状态而构造完整的 Task 对象。

go
// CreateTask 创建任务
func (s *TaskService) CreateTask(ctx context.Context, openid string, task *Task) error {
	exists, err := s.categoryExists(ctx, openid, task.CategoryID)
	if err != nil {
		return err
	}
	if !exists {
		return errors.New("category not found or unauthorized")
	}

	task.OpenID = openid
	return s.db.WithContext(ctx).Create(task).Error
}

// UpdateTask 更新任务
func (s *TaskService) UpdateTask(ctx context.Context, openid string, id uint, task *Task) error {
	if task.CategoryID != 0 {
		exists, err := s.categoryExists(ctx, openid, task.CategoryID)
		if err != nil {
			return err
		}
		if !exists {
			return errors.New("category not found or unauthorized")
		}
	}

	updateFields := map[string]interface{}{
		"title":       task.Title,
		"description": task.Description,
		"deadline":    task.Deadline,
		"urgent":      task.Urgent,
		"sort":        task.Sort,
	}

	if task.CategoryID != 0 {
		updateFields["category_id"] = task.CategoryID
	}

	result := s.db.WithContext(ctx).
		Model(&Task{}).
		Where("id = ? AND open_id = ?", id, openid).
		Updates(updateFields)

	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return ErrNotFound
	}
	return nil
}

// DeleteTask 删除任务(含级联删除附件)
func (s *TaskService) DeleteTask(ctx context.Context, openid string, id uint) error {
	return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		if err := tx.Where("task_id = ? AND open_id = ?", id, openid).Delete(&Attachment{}).Error; err != nil {
			return err
		}

		result := tx.Where("id = ? AND open_id = ?", id, openid).Delete(&Task{})
		if result.Error != nil {
			return result.Error
		}
		if result.RowsAffected == 0 {
			return ErrNotFound
		}
		return nil
	})
}

任务列表查询支持多条件组合过滤。CompletedUrgent*bool 而不是 bool,这样能区分"未传"和"传 false"——前者不过滤,后者只看未完成项。

go
// ListTasks 获取任务列表
func (s *TaskService) ListTasks(ctx context.Context, openid string, param TaskQueryParam) ([]Task, error) {
	query := s.db.WithContext(ctx).Where("open_id = ?", openid)

	if param.CategoryID > 0 {
		query = query.Where("category_id = ?", param.CategoryID)
	}
	if param.Completed != nil {
		query = query.Where("completed = ?", *param.Completed)
	}
	if param.Urgent != nil {
		query = query.Where("urgent = ?", *param.Urgent)
	}
	if param.StartDate != nil {
		query = query.Where("deadline >= ?", param.StartDate)
	}
	if param.EndDate != nil {
		query = query.Where("deadline <= ?", param.EndDate)
	}
	if param.Keyword != "" {
		query = query.Where("title LIKE ? OR description LIKE ?",
			"%"+param.Keyword+"%", "%"+param.Keyword+"%")
	}

	order := "sort DESC, created_at DESC"
	if param.Sort != "" && param.Order != "" {
		order = fmt.Sprintf("%s %s", param.Sort, param.Order)
	}
	query = query.Order(order)

	var tasks []Task
	err := query.Find(&tasks).Error
	return tasks, err
}

// CompleteTask 完成任务
func (s *TaskService) CompleteTask(ctx context.Context, openid string, id uint) error {
	now := time.Now()
	result := s.db.WithContext(ctx).
		Model(&Task{}).
		Where("id = ? AND open_id = ?", id, openid).
		Updates(map[string]interface{}{
			"completed":    true,
			"completed_at": &now,
		})

	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return ErrNotFound
	}
	return nil
}

// UncompleteTask 取消完成任务
func (s *TaskService) UncompleteTask(ctx context.Context, openid string, id uint) error {
	result := s.db.WithContext(ctx).
		Model(&Task{}).
		Where("id = ? AND open_id = ?", id, openid).
		Updates(map[string]interface{}{
			"completed":    false,
			"completed_at": nil,
		})

	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return ErrNotFound
	}
	return nil
}

取消完成时把 completed_at 设为 nil,GORM 用 map[string]interface{} 更新时能正确写入 NULL(如果用 struct 更新,零值字段会被忽略)。

附件管理

创建附件前必须验证目标 TaskID 属于当前用户,否则可以通过猜测 task_id 把附件挂到别人的任务上。

go
// CreateAttachment 创建附件
func (s *TaskService) CreateAttachment(ctx context.Context, openid string, attachment *Attachment) error {
	exists, err := s.taskExists(ctx, openid, attachment.TaskID)
	if err != nil {
		return err
	}
	if !exists {
		return errors.New("task not found or unauthorized")
	}

	attachment.OpenID = openid
	return s.db.WithContext(ctx).Create(attachment).Error
}

// DeleteAttachment 删除附件
func (s *TaskService) DeleteAttachment(ctx context.Context, openid string, id uint) error {
	result := s.db.WithContext(ctx).
		Where("id = ? AND open_id = ?", id, openid).
		Delete(&Attachment{})

	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return ErrNotFound
	}
	return nil
}

// ListAttachments 获取任务的附件列表
func (s *TaskService) ListAttachments(ctx context.Context, openid string, taskID uint) ([]Attachment, error) {
	var attachments []Attachment
	err := s.db.WithContext(ctx).
		Where("task_id = ? AND open_id = ?", taskID, openid).
		Order("sort desc, id desc").
		Find(&attachments).Error
	return attachments, err
}

数据所有权验证

所有跨实体操作都走这两个辅助方法。返回 (bool, error),把"不存在"和"数据库错误"区分开,方便上层给出准确的错误信息。

go
// categoryExists 检查分类是否存在且属于该用户
func (s *TaskService) categoryExists(ctx context.Context, openid string, categoryID uint) (bool, error) {
	var count int64
	err := s.db.WithContext(ctx).
		Model(&Category{}).
		Where("id = ? AND open_id = ?", categoryID, openid).
		Count(&count).Error
	return count > 0, err
}

// taskExists 检查任务是否存在且属于该用户
func (s *TaskService) taskExists(ctx context.Context, openid string, taskID uint) (bool, error) {
	var count int64
	err := s.db.WithContext(ctx).
		Model(&Task{}).
		Where("id = ? AND open_id = ?", taskID, openid).
		Count(&count).Error
	return count > 0, err
}

关键设计点小结

  • 用 OpenID 而不是 UserID 做关联:所有查询直接用它做 where 条件,省一次 User 表 JOIN。User 表只承担"展示用户信息"的职责。
  • WHERE 条件统一带上 open_id:这是多租户隔离的根本保障。即使用了主键 ID 查询,也要带上 open_id,防止 ID 被枚举越权。
  • 关键写入用事务:删除分类、删除任务这种级联操作必须放在事务里,避免中途失败留下脏数据。
  • bool 过滤用指针*bool 三态(true / false / 不传)比 bool 二态更灵活。
  • 更新用 map[string]interface{}:避免 struct 更新时零值字段被忽略的坑。
  • 不定义 GORM 关联:层级关系用业务字段 + 显式查询表达,预加载、级联都自己控制,行为更可预测。

这套模型和 Service 层可以直接作为任务管理类应用的骨架,配合 Gin、Echo 等 Web 框架的 handler 层即可快速上线。

分享: