refactor(数据库): 迁移持久层到 Ent 并清理 GORM

将仓储层/基础设施改为 Ent + 原生 SQL 执行路径,并移除 AutoMigrate 与 GORM 依赖。
重构内容包括:
- 仓储层改用 Ent/SQL(含 usage_log/account 等复杂查询),统一错误映射
- 基础设施与 setup 初始化切换为 Ent + SQL migrations
- 集成测试与 fixtures 迁移到 Ent 事务模型
- 清理遗留 GORM 模型/依赖,补充迁移与文档说明
- 增加根目录 Makefile 便于前后端编译

测试:
- go test -tags unit ./...
- go test -tags integration ./...
This commit is contained in:
yangjianbo
2025-12-29 10:03:27 +08:00
parent fd51ff6970
commit 3d617de577
149 changed files with 62892 additions and 3212 deletions

View File

@@ -4,25 +4,35 @@ import (
"context"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"gorm.io/gorm"
)
type redeemCodeRepository struct {
db *gorm.DB
client *dbent.Client
}
func NewRedeemCodeRepository(db *gorm.DB) service.RedeemCodeRepository {
return &redeemCodeRepository{db: db}
func NewRedeemCodeRepository(client *dbent.Client) service.RedeemCodeRepository {
return &redeemCodeRepository{client: client}
}
func (r *redeemCodeRepository) Create(ctx context.Context, code *service.RedeemCode) error {
m := redeemCodeModelFromService(code)
err := r.db.WithContext(ctx).Create(m).Error
created, err := r.client.RedeemCode.Create().
SetCode(code.Code).
SetType(code.Type).
SetValue(code.Value).
SetStatus(code.Status).
SetNotes(code.Notes).
SetValidityDays(code.ValidityDays).
SetNillableUsedBy(code.UsedBy).
SetNillableUsedAt(code.UsedAt).
SetNillableGroupID(code.GroupID).
Save(ctx)
if err == nil {
applyRedeemCodeModelToService(code, m)
code.ID = created.ID
code.CreatedAt = created.CreatedAt
}
return err
}
@@ -31,36 +41,55 @@ func (r *redeemCodeRepository) CreateBatch(ctx context.Context, codes []service.
if len(codes) == 0 {
return nil
}
models := make([]redeemCodeModel, 0, len(codes))
builders := make([]*dbent.RedeemCodeCreate, 0, len(codes))
for i := range codes {
m := redeemCodeModelFromService(&codes[i])
if m != nil {
models = append(models, *m)
}
c := &codes[i]
b := r.client.RedeemCode.Create().
SetCode(c.Code).
SetType(c.Type).
SetValue(c.Value).
SetStatus(c.Status).
SetNotes(c.Notes).
SetValidityDays(c.ValidityDays).
SetNillableUsedBy(c.UsedBy).
SetNillableUsedAt(c.UsedAt).
SetNillableGroupID(c.GroupID)
builders = append(builders, b)
}
return r.db.WithContext(ctx).Create(&models).Error
return r.client.RedeemCode.CreateBulk(builders...).Exec(ctx)
}
func (r *redeemCodeRepository) GetByID(ctx context.Context, id int64) (*service.RedeemCode, error) {
var m redeemCodeModel
err := r.db.WithContext(ctx).First(&m, id).Error
m, err := r.client.RedeemCode.Query().
Where(redeemcode.IDEQ(id)).
Only(ctx)
if err != nil {
return nil, translatePersistenceError(err, service.ErrRedeemCodeNotFound, nil)
if dbent.IsNotFound(err) {
return nil, service.ErrRedeemCodeNotFound
}
return nil, err
}
return redeemCodeModelToService(&m), nil
return redeemCodeEntityToService(m), nil
}
func (r *redeemCodeRepository) GetByCode(ctx context.Context, code string) (*service.RedeemCode, error) {
var m redeemCodeModel
err := r.db.WithContext(ctx).Where("code = ?", code).First(&m).Error
m, err := r.client.RedeemCode.Query().
Where(redeemcode.CodeEQ(code)).
Only(ctx)
if err != nil {
return nil, translatePersistenceError(err, service.ErrRedeemCodeNotFound, nil)
if dbent.IsNotFound(err) {
return nil, service.ErrRedeemCodeNotFound
}
return nil, err
}
return redeemCodeModelToService(&m), nil
return redeemCodeEntityToService(m), nil
}
func (r *redeemCodeRepository) Delete(ctx context.Context, id int64) error {
return r.db.WithContext(ctx).Delete(&redeemCodeModel{}, id).Error
_, err := r.client.RedeemCode.Delete().Where(redeemcode.IDEQ(id)).Exec(ctx)
return err
}
func (r *redeemCodeRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.RedeemCode, *pagination.PaginationResult, error) {
@@ -68,61 +97,88 @@ func (r *redeemCodeRepository) List(ctx context.Context, params pagination.Pagin
}
func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, codeType, status, search string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
var codes []redeemCodeModel
var total int64
db := r.db.WithContext(ctx).Model(&redeemCodeModel{})
q := r.client.RedeemCode.Query()
if codeType != "" {
db = db.Where("type = ?", codeType)
q = q.Where(redeemcode.TypeEQ(codeType))
}
if status != "" {
db = db.Where("status = ?", status)
q = q.Where(redeemcode.StatusEQ(status))
}
if search != "" {
searchPattern := "%" + search + "%"
db = db.Where("code ILIKE ?", searchPattern)
q = q.Where(redeemcode.CodeContainsFold(search))
}
if err := db.Count(&total).Error; err != nil {
total, err := q.Count(ctx)
if err != nil {
return nil, nil, err
}
if err := db.Preload("User").Preload("Group").Offset(params.Offset()).Limit(params.Limit()).Order("id DESC").Find(&codes).Error; err != nil {
codes, err := q.
WithUser().
WithGroup().
Offset(params.Offset()).
Limit(params.Limit()).
Order(dbent.Desc(redeemcode.FieldID)).
All(ctx)
if err != nil {
return nil, nil, err
}
outCodes := make([]service.RedeemCode, 0, len(codes))
for i := range codes {
outCodes = append(outCodes, *redeemCodeModelToService(&codes[i]))
}
outCodes := redeemCodeEntitiesToService(codes)
return outCodes, paginationResultFromTotal(total, params), nil
return outCodes, paginationResultFromTotal(int64(total), params), nil
}
func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemCode) error {
m := redeemCodeModelFromService(code)
err := r.db.WithContext(ctx).Save(m).Error
if err == nil {
applyRedeemCodeModelToService(code, m)
up := r.client.RedeemCode.UpdateOneID(code.ID).
SetCode(code.Code).
SetType(code.Type).
SetValue(code.Value).
SetStatus(code.Status).
SetNotes(code.Notes).
SetValidityDays(code.ValidityDays)
if code.UsedBy != nil {
up.SetUsedBy(*code.UsedBy)
} else {
up.ClearUsedBy()
}
return err
if code.UsedAt != nil {
up.SetUsedAt(*code.UsedAt)
} else {
up.ClearUsedAt()
}
if code.GroupID != nil {
up.SetGroupID(*code.GroupID)
} else {
up.ClearGroupID()
}
updated, err := up.Save(ctx)
if err != nil {
if dbent.IsNotFound(err) {
return service.ErrRedeemCodeNotFound
}
return err
}
code.CreatedAt = updated.CreatedAt
return nil
}
func (r *redeemCodeRepository) Use(ctx context.Context, id, userID int64) error {
now := time.Now()
result := r.db.WithContext(ctx).Model(&redeemCodeModel{}).
Where("id = ? AND status = ?", id, service.StatusUnused).
Updates(map[string]any{
"status": service.StatusUsed,
"used_by": userID,
"used_at": now,
})
if result.Error != nil {
return result.Error
affected, err := r.client.RedeemCode.Update().
Where(redeemcode.IDEQ(id), redeemcode.StatusEQ(service.StatusUnused)).
SetStatus(service.StatusUsed).
SetUsedBy(userID).
SetUsedAt(now).
Save(ctx)
if err != nil {
return err
}
if result.RowsAffected == 0 {
return service.ErrRedeemCodeUsed.WithCause(gorm.ErrRecordNotFound)
if affected == 0 {
return service.ErrRedeemCodeUsed
}
return nil
}
@@ -132,49 +188,24 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
limit = 10
}
var codes []redeemCodeModel
err := r.db.WithContext(ctx).
Preload("Group").
Where("used_by = ?", userID).
Order("used_at DESC").
codes, err := r.client.RedeemCode.Query().
Where(redeemcode.UsedByEQ(userID)).
WithGroup().
Order(dbent.Desc(redeemcode.FieldUsedAt)).
Limit(limit).
Find(&codes).Error
All(ctx)
if err != nil {
return nil, err
}
outCodes := make([]service.RedeemCode, 0, len(codes))
for i := range codes {
outCodes = append(outCodes, *redeemCodeModelToService(&codes[i]))
}
return outCodes, nil
return redeemCodeEntitiesToService(codes), nil
}
type redeemCodeModel struct {
ID int64 `gorm:"primaryKey"`
Code string `gorm:"uniqueIndex;size:32;not null"`
Type string `gorm:"size:20;default:balance;not null"`
Value float64 `gorm:"type:decimal(20,8);not null"`
Status string `gorm:"size:20;default:unused;not null"`
UsedBy *int64 `gorm:"index"`
UsedAt *time.Time
Notes string `gorm:"type:text"`
CreatedAt time.Time `gorm:"not null"`
GroupID *int64 `gorm:"index"`
ValidityDays int `gorm:"default:30"`
User *userModel `gorm:"foreignKey:UsedBy"`
Group *groupModel `gorm:"foreignKey:GroupID"`
}
func (redeemCodeModel) TableName() string { return "redeem_codes" }
func redeemCodeModelToService(m *redeemCodeModel) *service.RedeemCode {
func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode {
if m == nil {
return nil
}
return &service.RedeemCode{
out := &service.RedeemCode{
ID: m.ID,
Code: m.Code,
Type: m.Type,
@@ -182,38 +213,26 @@ func redeemCodeModelToService(m *redeemCodeModel) *service.RedeemCode {
Status: m.Status,
UsedBy: m.UsedBy,
UsedAt: m.UsedAt,
Notes: m.Notes,
Notes: derefString(m.Notes),
CreatedAt: m.CreatedAt,
GroupID: m.GroupID,
ValidityDays: m.ValidityDays,
User: userModelToService(m.User),
Group: groupModelToService(m.Group),
}
if m.Edges.User != nil {
out.User = userEntityToService(m.Edges.User)
}
if m.Edges.Group != nil {
out.Group = groupEntityToService(m.Edges.Group)
}
return out
}
func redeemCodeModelFromService(r *service.RedeemCode) *redeemCodeModel {
if r == nil {
return nil
}
return &redeemCodeModel{
ID: r.ID,
Code: r.Code,
Type: r.Type,
Value: r.Value,
Status: r.Status,
UsedBy: r.UsedBy,
UsedAt: r.UsedAt,
Notes: r.Notes,
CreatedAt: r.CreatedAt,
GroupID: r.GroupID,
ValidityDays: r.ValidityDays,
func redeemCodeEntitiesToService(models []*dbent.RedeemCode) []service.RedeemCode {
out := make([]service.RedeemCode, 0, len(models))
for i := range models {
if s := redeemCodeEntityToService(models[i]); s != nil {
out = append(out, *s)
}
}
}
func applyRedeemCodeModelToService(code *service.RedeemCode, m *redeemCodeModel) {
if code == nil || m == nil {
return
}
code.ID = m.ID
code.CreatedAt = m.CreatedAt
return out
}