字节笔记本
2026年7月20日
用 GORM 设计任务管理系统:模型定义与 CRUD 实战
本文以一个"用户 - 分类 - 任务 - 附件"四层结构的任务管理系统为例,介绍如何使用 GORM 进行模型设计,并实现一套基于 OpenID 的多用户数据隔离 Service 层。重点演示如何在不使用 ORM 关联字段的前提下,通过业务字段实现数据归属与级联操作,涵盖完整的 CRUD、事务处理、查询过滤和所有权校验。
模型设计思路
在面向终端用户(小程序、移动端)的应用中,用户身份通常由第三方平台下发,业务侧拿到的是一个稳定的 OpenID。在这种场景下,模型设计有几个取舍:
- 不使用 GORM 的关联字段(
foreignKey/relation):避免预加载带来的复杂性和潜在的 N+1 问题,关系完全通过业务字段来表达。 - 所有业务表都冗余一个
OpenID字段:作为用户归属的"硬约束",任何查询、更新、删除都强制带上它,从数据库层面保证用户之间的数据隔离。 - 层级关系用 ID 字段表达:
Task.CategoryID指向所属分类,Attachment.TaskID指向所属任务,配合OpenID共同定位数据。 - 统一的基础字段:每张表都包含
ID、CreatedAt、UpdatedAt、DeletedAt,并启用软删除。 - 支持自定义排序:每张业务表加
Sort字段,方便用户拖拽调整顺序。
数据库模型定义
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.OpenID用uniqueIndex保证一个 OpenID 只能注册一次。Category、Task、Attachment的OpenID用普通index,因为要按用户做高频过滤。Task.CompletedAt用*time.Time,未完成时为nil,完成时写入时间戳。- 所有外键 ID(
CategoryID、TaskID)都加了索引,组合OpenID查询时性能更好。
迁移时只需一行:
db.AutoMigrate(&User{}, &Category{}, &Task{}, &Attachment{})Service 层架构
Service 层统一接收 *gorm.DB 和 context.Context,所有方法的第一参数都是 openID,作为数据归属的"钥匙"。错误通过预定义的哨兵错误返回,方便上层(HTTP handler、RPC handler)做映射。
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(...) 组合,一条语句完成"存在则更新昵称头像、不存在则创建"的逻辑,避免先查后写的竞态。
// 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 中最复杂的方法:需要先校验是否有未完成任务,再级联删除该分类下的所有附件、任务,最后删除分类本身,整个过程包在一个事务里。
// 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,还提供独立的 CompleteTask 和 UncompleteTask 用于状态流转,这样调用方不必为了切换状态而构造完整的 Task 对象。
// 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
})
}任务列表查询支持多条件组合过滤。Completed 和 Urgent 用 *bool 而不是 bool,这样能区分"未传"和"传 false"——前者不过滤,后者只看未完成项。
// 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 把附件挂到别人的任务上。
// 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),把"不存在"和"数据库错误"区分开,方便上层给出准确的错误信息。
// 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 层即可快速上线。