文档目录
glen
· 预计阅读 4 分钟

第 7 章 数据模型与迁移

本文导航
第 7 章 数据模型与迁移

第 7 章 数据模型与迁移

Owl 用 GORM 管理数据库,并在启动时统一执行 AutoMigrate。这一章说明模型怎么写、迁移什么时候执行、种子数据放哪里。

基础模型

大多数业务表嵌入 db.BaseModel:

type BaseModel struct {
	ID          uint            `gorm:"primarykey" json:"id,string"`
	CreatedAt   *time.Time      `json:"createdAt,omitempty"`
	UpdatedAt   *time.Time      `json:"updatedAt,omitempty"`
	DeletedAt   *gorm.DeletedAt `gorm:"index" json:"deletedAt,omitempty"`
	CreatorID   string          `gorm:"comment:创建人" json:"creatorID,omitempty"`
	CreatorName string          `gorm:"comment:创建人名称" json:"creatorName,omitempty"`
	UpdaterID   string          `gorm:"comment:修改人" json:"updaterID,omitempty"`
	UpdaterName string          `gorm:"comment:修改人名称" json:"updaterName,omitempty"`
}

它提供:

  • 雪花 ID:BeforeCreate 自动生成,手动设置了 ID 则不覆盖;
  • 创建/更新时间与软删除;
  • 从请求上下文自动写入创建人、修改人(键为 user_id、username)。

ID 的 JSON 标签是 id,string,避免前端 JS 处理雪花 ID 时丢精度。

写一个模型

package model

import (
	"bit-labs.cn/owl/provider/db"
	"gorm.io/gorm/schema"
)

const (
	OrderStatusPending   = 1 // 待支付
	OrderStatusPaid      = 2 // 已支付
	OrderStatusCancelled = 3 // 已取消
)

var _ schema.Tabler = (*Order)(nil)

type Order struct {
	db.BaseModel
	OrderNo string `gorm:"comment:订单号;size:64;uniqueIndex" json:"orderNo"`
	Title   string `gorm:"comment:标题;size:255" json:"title"`
	Amount  int64  `gorm:"comment:金额,分" json:"amount"`
	Status  int    `gorm:"comment:状态" json:"status"`
}

func (i *Order) TableName() string { return "order_order" }

约定:

  • 状态、类型、来源等有业务含义的值必须定义具名常量,禁止裸数字(OrderStatusPaid,不要 Status: 2);
  • 枚举值不要用 0,从 1 开始;
  • 模型保持数据库无关,不要写死 MySQL/PostgreSQL 专有类型;
  • 是否软删除取决于是否嵌入 BaseModel,不需要软删除时可以自定义主键与时间字段。

迁移怎么执行

子应用实现:

func (i *SubAppOrder) RegisterMigrate() []any {
	return []any{
		&model.Order{},
		&model.OrderItem{},
	}
}

启动时框架:

  1. 执行所有子应用的 BeforeMigrate(串行);
  2. 收集所有 RegisterMigrate() 的模型;
  3. 按类型去重,计算 schema hash(并行);
  4. 只对 hash 变化的模型执行 AutoMigrate(串行);
  5. 把 hash 写入 storage/migrate_hash.txt;
  6. 执行所有子应用的 AfterMigrate(并行)。

hash 文件示例:

bit-labs.cn/my-order/app/model.Order=8f14e45fceea167a
bit-labs.cn/my-order/app/model.OrderItem=2c9a1b0e3d4f5a6b

注意:

  • 结构没变就不会重复迁移;
  • BeforeMigrate / AfterMigrate 每次启动都执行;
  • 想强制重迁某个模型,删掉 hash 文件里对应行;
  • 不要在 Bootstrap() 里手动 AutoMigrate,否则会绕过 hash 门控。

种子数据放哪里

幂等种子、修补逻辑放 AfterMigrate:

func (i *SubAppOrder) AfterMigrate(gdb *gorm.DB) error {
	return seeder.InitOrderStatus(gdb)
}

func InitOrderStatus(gdb *gorm.DB) error {
	var count int64
	gdb.Model(&model.Order{}).Where("code = ?", "DEFAULT").Count(&count)
	if count > 0 {
		return nil
	}
	return gdb.Create(&model.Order{OrderNo: "DEFAULT", Title: "默认订单"}).Error
}

如果种子可以延迟执行,可以在 AfterMigrate 内自行 go,框架不会替你异步化。

Repository:复用泛型基类

package repository

import (
	"context"

	"bit-labs.cn/my-order/app/model"
	"bit-labs.cn/owl/provider/db"
	"gorm.io/gorm"
)

type OrderRepositoryInterface interface {
	FindById(id any) (*model.Order, error)
	Save(o *model.Order) error
	Retrieve(page, pageSize int, fn func(db *gorm.DB)) (int64, []model.Order, error)
	WithContext(ctx context.Context) OrderRepositoryInterface
}

type OrderRepository struct {
	db.BaseRepository[model.Order]
	ctx context.Context
}

func NewOrderRepository(gdb *gorm.DB) *OrderRepository {
	return &OrderRepository{BaseRepository: db.NewBaseRepository[model.Order](gdb)}
}

func (i *OrderRepository) WithContext(ctx context.Context) OrderRepositoryInterface {
	i.BaseRepository.WithContext(ctx)
	i.ctx = ctx
	return i
}

func (i *OrderRepository) FindById(id any) (*model.Order, error) {
	return i.Detail(id)
}

func (i *OrderRepository) Save(o *model.Order) error {
	return i.BaseRepository.Save(o)
}

func (i *OrderRepository) Retrieve(page, pageSize int, fn func(db *gorm.DB)) (int64, []model.Order, error) {
	return i.BaseRepository.Retrieve(page, pageSize, fn)
}

BaseRepository 已提供 Save、Delete、Detail、Retrieve、Unique、ChangeStatus。仓储层只写业务特有的查询,通用的增删改查直接复用。

分页查询的常见写法

count, list, err := repo.WithContext(ctx).Retrieve(req.Page, req.PageSize, func(tx *gorm.DB) {
	db.AppendWhereFromStruct(tx, req) // 按结构体字段拼条件
	tx.Order("created_at desc")
})

AppendWhereFromStruct 会读取结构体中的非零字段,把字段名转成下划线列名拼条件;字段名以 Like、Gt、Gte、Lt、Lte、In 结尾时会转成对应的比较操作。分页参数用 router.PageReq,会被自动跳过。

自检

  • [ ] 模型实现了 TableName();
  • [ ] 状态常量与模型同包定义,且从 1 开始;
  • [ ] 新模型已加入 RegisterMigrate();
  • [ ] 没有在 Bootstrap() 里手动迁移;
  • [ ] 种子逻辑幂等;
  • [ ] 仓储接口有 WithContext,查询带上上下文。

评论

加载评论中…

上一篇 第 6 章 配置、路径与运行目录 下一篇 第 8 章 从零创建子系统实战:订单模块

BitLabs © 2026 · 论坛讨论 · 博客长文 · 文档手册