优化结构

This commit is contained in:
Yvan
2026-08-21 00:01:48 +08:00
parent e65cf0b1d0
commit 746e33e027
344 changed files with 1021 additions and 578 deletions
+5
View File
@@ -0,0 +1,5 @@
# GORM Helpers
This package owns storage-specific GORM types and logging integration used by
the data repositories. It is intentionally internal because its JSON value and
logger are persistence concerns, not general application utilities.
+105
View File
@@ -0,0 +1,105 @@
// Package gormkit contains reusable GORM integration helpers.
package gormkit
import (
"context"
"database/sql/driver"
"errors"
"fmt"
"io"
"log/slog"
"time"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
// JSON is a portable JSON value for GORM models. It selects a native JSON
// column where the driver supports one and a textual fallback elsewhere.
type JSON []byte
func (JSON) GormDataType() string { return "json" }
func (JSON) GormDBDataType(db *gorm.DB, _ *schema.Field) string {
switch db.Dialector.Name() {
case "mysql", "sqlite":
return "JSON"
case "postgres":
return "JSONB"
case "sqlserver":
return "NVARCHAR(MAX)"
case "oracle":
return "CLOB"
default:
return "TEXT"
}
}
func (j JSON) Value() (driver.Value, error) {
if len(j) == 0 {
return nil, nil
}
return string(j), nil
}
func (j *JSON) Scan(value any) error {
switch raw := value.(type) {
case nil:
*j = nil
case []byte:
*j = append((*j)[:0], raw...)
case string:
*j = append((*j)[:0], raw...)
default:
return fmt.Errorf("cannot scan JSON from %T", value)
}
return nil
}
// Logger forwards GORM diagnostics through an application slog logger.
type Logger struct {
logger *slog.Logger
slowThreshold time.Duration
level logger.LogLevel
}
func NewLogger(log *slog.Logger, level logger.LogLevel) *Logger {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return &Logger{logger: log, slowThreshold: 200 * time.Millisecond, level: level}
}
func (g *Logger) LogMode(level logger.LogLevel) logger.Interface {
next := *g
next.level = level
return &next
}
func (g *Logger) Info(ctx context.Context, message string, args ...any) {
g.logger.InfoContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *Logger) Warn(ctx context.Context, message string, args ...any) {
g.logger.WarnContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *Logger) Error(ctx context.Context, message string, args ...any) {
g.logger.ErrorContext(ctx, fmt.Sprintf(message, args...), "mod", "sql", "gorm_logger", true)
}
func (g *Logger) Trace(ctx context.Context, begin time.Time, query func() (string, int64), queryErr error) {
if g.level <= logger.Silent {
return
}
elapsed := time.Since(begin)
sql, rows := query()
fields := []any{"mod", "sql", "gorm_logger", true, "sql", sql, "rows", rows, "elapsed_ms", elapsed.Milliseconds()}
switch {
case queryErr != nil && g.level >= logger.Error && !errors.Is(queryErr, logger.ErrRecordNotFound):
fields = append(fields, "error", queryErr)
g.logger.ErrorContext(ctx, "SQL 执行错误", fields...)
case elapsed > g.slowThreshold && g.level >= logger.Warn:
g.logger.WarnContext(ctx, "SQL 慢查询", fields...)
case g.level >= logger.Info:
g.logger.InfoContext(ctx, "SQL", fields...)
}
}
+121
View File
@@ -0,0 +1,121 @@
// Package migration owns the application database migration runner. Each data
// module declares its own steps; the root data package only orders and runs
// them.
package migration
import (
"fmt"
"strings"
"gorm.io/gorm"
)
const TableName = "sys_schema_migrations"
type Step struct {
ID string
Migrate func(*gorm.DB) error
}
type record struct {
ID string `gorm:"primaryKey;size:255"`
}
func (record) TableName() string { return TableName }
func Run(db *gorm.DB, steps []Step) error {
if db == nil {
return fmt.Errorf("database is nil")
}
db = db.Session(&gorm.Session{NewDB: true})
if err := repairMigrationTable(db); err != nil {
return err
}
if !db.Session(&gorm.Session{NewDB: true}).Migrator().HasTable(&record{}) {
if err := db.Session(&gorm.Session{NewDB: true}).Migrator().CreateTable(&record{}); err != nil {
return fmt.Errorf("create migration table: %w", err)
}
}
var appliedRows []record
if err := db.Session(&gorm.Session{NewDB: true}).Find(&appliedRows).Error; err != nil {
return fmt.Errorf("load applied migrations: %w", err)
}
applied := make(map[string]struct{}, len(appliedRows))
known := make(map[string]struct{}, len(steps))
for _, row := range appliedRows {
applied[row.ID] = struct{}{}
}
for _, step := range steps {
if step.ID == "" {
return fmt.Errorf("migration id is empty")
}
if _, duplicate := known[step.ID]; duplicate {
return fmt.Errorf("duplicate migration id %q", step.ID)
}
known[step.ID] = struct{}{}
}
for id := range applied {
if _, ok := known[id]; !ok {
return fmt.Errorf("unknown applied migration %q", id)
}
}
for _, step := range steps {
if _, ok := applied[step.ID]; ok {
continue
}
if step.Migrate == nil {
return fmt.Errorf("migration %q has no function", step.ID)
}
if err := step.Migrate(db.Session(&gorm.Session{NewDB: true})); err != nil {
return fmt.Errorf("apply migration %q: %w", step.ID, err)
}
if err := db.Session(&gorm.Session{NewDB: true}).Create(&record{ID: step.ID}).Error; err != nil {
return fmt.Errorf("record migration %q: %w", step.ID, err)
}
}
return nil
}
func repairMigrationTable(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
if !clean.Migrator().HasTable(TableName) {
return nil
}
var dataType string
switch clean.Dialector.Name() {
case "mysql":
err := clean.Raw("SELECT DATA_TYPE FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?", TableName, "id").Scan(&dataType).Error
if err != nil {
return fmt.Errorf("inspect migration table: %w", err)
}
case "postgres":
err := clean.Raw("SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?", TableName, "id").Scan(&dataType).Error
if err != nil {
return fmt.Errorf("inspect migration table: %w", err)
}
case "sqlite":
var rows []struct {
Name string `gorm:"column:name"`
Type string `gorm:"column:type"`
}
if err := clean.Raw("PRAGMA table_info(" + TableName + ")").Scan(&rows).Error; err != nil {
return fmt.Errorf("inspect migration table: %w", err)
}
for _, row := range rows {
if strings.EqualFold(row.Name, "id") {
dataType = row.Type
break
}
}
default:
return nil
}
normalized := strings.ToLower(dataType)
if strings.Contains(normalized, "char") || strings.Contains(normalized, "text") || strings.Contains(normalized, "clob") {
return nil
}
if err := clean.Migrator().DropTable(TableName); err != nil {
return fmt.Errorf("repair invalid migration table: %w", err)
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
package migration
import "gorm.io/gorm"
// CreateMissingTables creates only schemas that are absent. Existing tables
// are intentionally left untouched: GORM's MySQL column diff path can panic
// when older servers return incomplete information_schema metadata, and this
// project does not support implicit legacy-schema upgrades through AutoMigrate.
func CreateMissingTables(db *gorm.DB, models ...any) error {
missing := make([]any, 0, len(models))
for _, model := range models {
if model != nil && !db.Migrator().HasTable(model) {
missing = append(missing, model)
}
}
if len(missing) == 0 {
return nil
}
return db.Migrator().CreateTable(missing...)
}
+104
View File
@@ -0,0 +1,104 @@
package migration
import (
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type existingSchema struct {
ID uint `gorm:"primaryKey"`
Name string
}
func (existingSchema) TableName() string { return "existing_schema" }
func TestCreateMissingTablesDoesNotAlterExistingTables(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE TABLE existing_schema (id integer primary key)").Error; err != nil {
t.Fatal(err)
}
if err = CreateMissingTables(db, &existingSchema{}); err != nil {
t.Fatal(err)
}
if db.Migrator().HasColumn(&existingSchema{}, "name") {
t.Fatal("existing schema was altered")
}
}
func TestCreateMissingTablesCreatesAbsentTables(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = CreateMissingTables(db, &existingSchema{}); err != nil {
t.Fatal(err)
}
if !db.Migrator().HasTable(&existingSchema{}) || !db.Migrator().HasColumn(&existingSchema{}, "name") {
t.Fatal("missing schema was not created")
}
}
func TestRunRepairsInvalidMigrationMetadataTable(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE TABLE sys_schema_migrations (id integer primary key, title text)").Error; err != nil {
t.Fatal(err)
}
called := false
if err = Run(db, []Step{{ID: "202608200001_test", Migrate: func(*gorm.DB) error {
called = true
return nil
}}}); err != nil {
t.Fatal(err)
}
if !called {
t.Fatal("migration was not rerun after metadata repair")
}
var dataType string
if err = db.Raw("SELECT type FROM pragma_table_info(?) WHERE name = 'id'", TableName).Scan(&dataType).Error; err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.ToLower(dataType), "text") {
t.Fatalf("migration id type = %q", dataType)
}
}
func TestRunDoesNotLeakStatementState(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = Run(db, []Step{{ID: "202608200001_test", Migrate: func(tx *gorm.DB) error {
return CreateMissingTables(tx, &existingSchema{})
}}}); err != nil {
t.Fatal(err)
}
var count int64
if err = db.Model(&existingSchema{}).Count(&count).Error; err != nil {
t.Fatalf("shared database retained migration statement state: %v", err)
}
}
func TestRunRejectsUnknownAppliedMigration(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.Migrator().CreateTable(&record{}); err != nil {
t.Fatal(err)
}
if err = db.Create(&record{ID: "unknown"}).Error; err != nil {
t.Fatal(err)
}
if err = Run(db, []Step{{ID: "known", Migrate: func(*gorm.DB) error { return nil }}}); err == nil {
t.Fatal("unknown migration was accepted")
}
}
+37
View File
@@ -0,0 +1,37 @@
// Package pagination contains GORM pagination helpers shared by repository
// implementations in the data layer.
package pagination
import "gorm.io/gorm"
// LimitOffset normalizes a page request and applies the optional maximum page
// size. A non-positive size means that no limit should be applied.
func LimitOffset(page, size, maxSize int) (limit, offset int) {
limit = size
if maxSize > 0 && size > maxSize {
limit = maxSize
}
if limit <= 0 {
return 0, 0
}
if page <= 0 {
page = 1
}
return limit, (page - 1) * limit
}
// Apply applies pagination only when the caller supplied a positive size.
func Apply(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := LimitOffset(page, size, maxSize)
if limit == 0 {
return db
}
return db.Offset(offset).Limit(limit)
}
// ApplyRequired always applies LIMIT/OFFSET, including LIMIT 0. This keeps
// the existing repository contract for APIs whose page size is mandatory.
func ApplyRequired(db *gorm.DB, page, size, maxSize int) *gorm.DB {
limit, offset := LimitOffset(page, size, maxSize)
return db.Offset(offset).Limit(limit)
}