chore(init): 初始化解忧商行项目并完成 Phase 0 基座

This commit is contained in:
yml2213
2026-10-30 11:46:10 +08:00
commit 8d9481c251
689 changed files with 138703 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
package database
import (
"context"
"errors"
"fmt"
"strings"
"time"
appLogging "hfb_sys/backend/internal/logging"
"go.uber.org/zap"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
type structuredGormLogger struct {
logger *zap.Logger
level gormLogger.LogLevel
slowThreshold time.Duration
}
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond)
}
func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface {
level := gormLogger.Warn
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
level = gormLogger.Info
}
if logger == nil {
logger = zap.L()
}
if slowThreshold <= 0 {
slowThreshold = 500 * time.Millisecond
}
return &structuredGormLogger{
logger: logger.With(zap.String("module", "database")),
level: level,
slowThreshold: slowThreshold,
}
}
func (l *structuredGormLogger) LogMode(level gormLogger.LogLevel) gormLogger.Interface {
cloned := *l
cloned.level = level
return &cloned
}
func (l *structuredGormLogger) Info(ctx context.Context, message string, args ...any) {
if l.level >= gormLogger.Info {
l.withContext(ctx).Debug("数据库信息", zap.String("detail", fmt.Sprintf(message, args...)))
}
}
func (l *structuredGormLogger) Warn(ctx context.Context, message string, args ...any) {
if l.level >= gormLogger.Warn {
l.withContext(ctx).Warn("数据库警告", zap.String("detail", fmt.Sprintf(message, args...)))
}
}
func (l *structuredGormLogger) Error(ctx context.Context, message string, args ...any) {
if l.level >= gormLogger.Error {
l.withContext(ctx).Error("数据库错误", zap.String("detail", fmt.Sprintf(message, args...)))
}
}
func (l *structuredGormLogger) Trace(ctx context.Context, begin time.Time, query func() (string, int64), err error) {
if l.level == gormLogger.Silent {
return
}
elapsed := time.Since(begin)
switch {
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && !errors.Is(err, context.Canceled) && l.level >= gormLogger.Error:
sql, rows := query()
l.withContext(ctx).Error("数据库查询失败", queryFields(sql, rows, elapsed, err)...)
case errors.Is(err, context.Canceled) && l.level >= gormLogger.Info:
sql, rows := query()
l.withContext(ctx).Debug("数据库查询被客户端取消", queryFields(sql, rows, elapsed, err)...)
case elapsed >= l.slowThreshold && l.level >= gormLogger.Warn:
sql, rows := query()
l.withContext(ctx).Warn("数据库慢查询", queryFields(sql, rows, elapsed, nil)...)
case l.level == gormLogger.Info:
sql, rows := query()
l.withContext(ctx).Debug("数据库查询", queryFields(sql, rows, elapsed, nil)...)
}
}
// ParamsFilter 让 GORM 保留 SQL 占位符,避免查询参数进入日志。
func (l *structuredGormLogger) ParamsFilter(_ context.Context, sql string, _ ...any) (string, []any) {
return sql, nil
}
func (l *structuredGormLogger) withContext(ctx context.Context) *zap.Logger {
fields := make([]zap.Field, 0, 2)
if requestID := appLogging.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
if adminID := appLogging.AdminIDFromContext(ctx); adminID != 0 {
fields = append(fields, zap.Uint64("admin_id", adminID))
}
return l.logger.With(fields...)
}
func queryFields(sql string, rows int64, elapsed time.Duration, err error) []zap.Field {
fields := []zap.Field{
zap.Float64("duration_ms", float64(elapsed.Microseconds())/1000),
zap.Int64("rows", rows),
zap.String("sql", sql),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
return fields
}
@@ -0,0 +1,43 @@
package database
import (
"strings"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"gorm.io/gorm"
)
func TestGormLoggerDoesNotLogQueryParameters(t *testing.T) {
core, observed := observer.New(zap.DebugLevel)
logger := zap.New(core)
// 复用 MySQL 测试库,只替换 logger 以观察日志脱敏行为。
db := NewTestDB().Session(&gorm.Session{Logger: newGormLogger("debug", logger)})
type secretRecord struct {
ID uint64
Phone string
}
if err := AutoMigrateIfMissing(db, &secretRecord{}); err != nil {
t.Fatal(err)
}
const secretPhone = "13812345678"
if err := db.Create(&secretRecord{Phone: secretPhone}).Error; err != nil {
t.Fatal(err)
}
for _, entry := range observed.All() {
for _, value := range entry.ContextMap() {
if strings.Contains(valueString(value), secretPhone) {
t.Fatalf("query parameter leaked into log: %v", entry.ContextMap())
}
}
}
}
func valueString(value any) string {
if text, ok := value.(string); ok {
return text
}
return ""
}
+71
View File
@@ -0,0 +1,71 @@
package database
import (
"time"
"go.uber.org/zap"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type MySQLOptions struct {
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
SlowQueryThreshold time.Duration
}
func DefaultMySQLOptions() MySQLOptions {
return MySQLOptions{
MaxOpenConns: 50,
MaxIdleConns: 10,
ConnMaxLifetime: 30 * time.Minute,
ConnMaxIdleTime: 5 * time.Minute,
SlowQueryThreshold: 500 * time.Millisecond,
}
}
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions())
}
func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) {
defaults := DefaultMySQLOptions()
if options.MaxOpenConns < 1 {
options.MaxOpenConns = defaults.MaxOpenConns
}
if options.MaxIdleConns < 0 {
options.MaxIdleConns = defaults.MaxIdleConns
}
if options.ConnMaxLifetime <= 0 {
options.ConnMaxLifetime = defaults.ConnMaxLifetime
}
if options.ConnMaxIdleTime < 0 {
options.ConnMaxIdleTime = defaults.ConnMaxIdleTime
}
if options.SlowQueryThreshold <= 0 {
options.SlowQueryThreshold = defaults.SlowQueryThreshold
}
if options.MaxIdleConns > options.MaxOpenConns {
options.MaxIdleConns = options.MaxOpenConns
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold),
})
if err != nil {
return nil, err
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
// 限制连接池,避免单个实例耗尽 MySQL max_connections。
sqlDB.SetMaxOpenConns(options.MaxOpenConns)
sqlDB.SetMaxIdleConns(options.MaxIdleConns)
sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime)
return db, nil
}
+25
View File
@@ -0,0 +1,25 @@
package database
import (
"context"
"github.com/redis/go-redis/v9"
)
type RedisConfig struct {
Addr string
Password string
DB int
}
func OpenRedis(ctx context.Context, cfg RedisConfig) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
})
if err := client.Ping(ctx).Err(); err != nil {
return nil, err
}
return client, nil
}
+55
View File
@@ -0,0 +1,55 @@
package database
import (
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
// MigrateListingLifecycleTestSchema 创建商品生命周期测试所需的共享表结构。
// 真实表结构已由 migrations 建好,这里的 AutoMigrate 只做幂等补齐。
func MigrateListingLifecycleTestSchema(db *gorm.DB) error {
return AutoMigrateIfMissing(db,
&model.User{},
&model.AdminUser{},
&model.GameAccount{},
&model.RentalListing{},
&model.ListingUpload{},
&model.ListingStatusEvent{},
&model.DistributionSite{},
&model.DistributionSiteAccount{},
)
}
// MigrateRentalTransactionTestSchema 在商品生命周期基础上创建订单、支付和交接测试所需的共享表结构。
func MigrateRentalTransactionTestSchema(db *gorm.DB) error {
if err := MigrateListingLifecycleTestSchema(db); err != nil {
return err
}
return AutoMigrateIfMissing(db,
&model.RentalOrder{},
&model.SystemConfig{},
&model.PaymentOrder{},
&model.Notification{},
&model.AdminNotification{},
&model.HandoffRecord{},
&model.OrderCheckout{},
&model.WalletAccount{},
&model.WalletLedger{},
&model.RenterGrowthLedger{},
&model.AuditLog{},
&model.ProcessEvent{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatAdminConversationState{},
&model.ChatMessage{},
)
}
// MigrateRentalDisputeTestSchema 在交易链路基础上创建申诉测试所需的表结构。
func MigrateRentalDisputeTestSchema(db *gorm.DB) error {
if err := MigrateRentalTransactionTestSchema(db); err != nil {
return err
}
return AutoMigrateIfMissing(db, &model.Dispute{})
}
+546
View File
@@ -0,0 +1,546 @@
package database
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// DefaultTestDSN 指向本地 Docker 开发环境的 MySQL 测试库。
// 可通过 HFB_TEST_DSN 覆盖,库名必须包含 test 或 e2e。
const DefaultTestDSN = "hfb:secret@tcp(127.0.0.1:23306)/hfb_jysh_test?charset=utf8mb4&parseTime=True&loc=Local"
const (
schemaMetaTable = "_test_schema_meta"
templateSuffix = "__template"
)
var (
testPrepareOnce sync.Once
testConn *gorm.DB
testPrepareErr error
)
// NewTestDB 返回指向 MySQL 测试库的干净连接。
//
// 表结构来自 backend/migrations 的真实迁移,与生产一致。
// 迁移只在模板库上执行一次;各测试包克隆模板库,指纹未变则跨 `go test`
// 调用复用,避免每个包、每次运行都 DROP + 重放 68 条迁移。
// 每次调用仍会清空业务表,使测试从空库开始。
func NewTestDB() *gorm.DB {
db, err := sharedTestDB()
if err != nil {
panic(fmt.Sprintf("初始化 MySQL 测试库失败: %v", err))
}
if err := truncateAllTables(db); err != nil {
panic(fmt.Sprintf("清空 MySQL 测试库失败: %v", err))
}
return db
}
func sharedTestDB() (*gorm.DB, error) {
testPrepareOnce.Do(func() {
testConn, testPrepareErr = openTestDatabase()
})
return testConn, testPrepareErr
}
// NewDryRunDB 返回 MySQL 方言的 DryRun 连接:只生成 SQL 不执行,也无需数据库运行。
// 用于断言查询语句形态的测试。
func NewDryRunDB() *gorm.DB {
db, err := gorm.Open(mysql.New(mysql.Config{
DSN: DefaultTestDSN,
SkipInitializeWithVersion: true,
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
panic(fmt.Sprintf("初始化 MySQL DryRun 连接失败: %v", err))
}
return db
}
// AutoMigrateIfMissing 只对尚不存在的表执行 AutoMigrate。
//
// 测试库的表结构由 migrations 建立,直接 AutoMigrate 会去改迁移定义的
// 索引与约束(例如 DROP 掉被外键依赖的唯一索引),因此已存在的表一律跳过,
// 仅保留给测试专用的临时表使用。
func AutoMigrateIfMissing(db *gorm.DB, models ...any) error {
for _, target := range models {
if db.Migrator().HasTable(target) {
continue
}
if err := db.AutoMigrate(target); err != nil {
return err
}
}
return nil
}
func openTestDatabase() (*gorm.DB, error) {
dsn := strings.TrimSpace(os.Getenv("HFB_TEST_DSN"))
if dsn == "" {
dsn = DefaultTestDSN
}
cfg, err := mysqldriver.ParseDSN(dsn)
if err != nil {
return nil, fmt.Errorf("解析测试库 DSN 失败: %w", err)
}
baseName := cfg.DBName
templateName := baseName + templateSuffix
packageName := baseName + "_" + packageSuffix()
if err := assertTestDatabaseName(templateName); err != nil {
return nil, err
}
if err := assertTestDatabaseName(packageName); err != nil {
return nil, err
}
dir, err := migrationDir()
if err != nil {
return nil, err
}
fingerprint, err := migrationFingerprint(dir)
if err != nil {
return nil, err
}
serverCfg := cfg.Clone()
serverCfg.DBName = ""
serverCfg.MultiStatements = false
serverDB, err := sql.Open("mysql", serverCfg.FormatDSN())
if err != nil {
return nil, fmt.Errorf("连接 MySQL 服务失败: %w", err)
}
defer func() { _ = serverDB.Close() }()
if err := serverDB.Ping(); err != nil {
return nil, fmt.Errorf("连接 MySQL 服务失败: %w", err)
}
if err := ensureTemplate(serverDB, templateName, dir, fingerprint); err != nil {
return nil, err
}
if err := ensurePackageClone(serverDB, templateName, packageName, fingerprint); err != nil {
return nil, err
}
cfg.DBName = packageName
cfg.ParseTime = true
cfg.MultiStatements = true
sqlDB, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return nil, fmt.Errorf("连接测试库失败: %w", err)
}
sqlDB.SetMaxOpenConns(8)
sqlDB.SetMaxIdleConns(8)
sqlDB.SetConnMaxLifetime(30 * time.Minute)
gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf("打开 gorm 连接失败: %w", err)
}
return gormDB, nil
}
func ensureTemplate(serverDB *sql.DB, name, dir, fingerprint string) error {
return withMySQLLock(serverDB, mysqlLockName("tpl", name), func() error {
if readFingerprint(serverDB, name) == fingerprint {
return nil
}
if _, err := serverDB.Exec("DROP DATABASE IF EXISTS `" + name + "`"); err != nil {
return fmt.Errorf("删除模板库失败: %w", err)
}
if _, err := serverDB.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
return fmt.Errorf("创建模板库失败: %w", err)
}
if err := applyMigrationsTo(name, dir); err != nil {
return err
}
return writeFingerprint(serverDB, name, fingerprint)
})
}
func ensurePackageClone(serverDB *sql.DB, templateName, packageName, fingerprint string) error {
return withMySQLLock(serverDB, mysqlLockName("pkg", packageName), func() error {
if readFingerprint(serverDB, packageName) == fingerprint {
return nil
}
return cloneDatabase(serverDB, templateName, packageName)
})
}
func applyMigrationsTo(dbName, dir string) error {
cfg, err := parseTestDSN()
if err != nil {
return err
}
cfg.DBName = dbName
cfg.ParseTime = true
cfg.MultiStatements = true
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return fmt.Errorf("连接模板库失败: %w", err)
}
defer func() { _ = db.Close() }()
return applyMigrations(db, dir)
}
func parseTestDSN() (*mysqldriver.Config, error) {
rawDSN := strings.TrimSpace(os.Getenv("HFB_TEST_DSN"))
if rawDSN == "" {
rawDSN = DefaultTestDSN
}
cfg, err := mysqldriver.ParseDSN(rawDSN)
if err != nil {
return nil, fmt.Errorf("解析测试库 DSN 失败: %w", err)
}
return cfg, nil
}
func applyMigrations(db *sql.DB, dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("读取迁移目录失败: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
return fmt.Errorf("读取迁移 %s 失败: %w", entry.Name(), err)
}
if _, err := db.Exec(gooseUpSQL(string(raw))); err != nil {
return fmt.Errorf("执行迁移 %s 失败: %w", entry.Name(), err)
}
}
return nil
}
func cloneDatabase(serverDB *sql.DB, src, dest string) error {
if _, err := serverDB.Exec("DROP DATABASE IF EXISTS `" + dest + "`"); err != nil {
return fmt.Errorf("删除测试库 %s 失败: %w", dest, err)
}
if _, err := serverDB.Exec("CREATE DATABASE `" + dest + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
return fmt.Errorf("创建测试库 %s 失败: %w", dest, err)
}
tables, err := listBaseTables(serverDB, src)
if err != nil {
return err
}
cfg, err := parseTestDSN()
if err != nil {
return err
}
cfg.DBName = dest
cfg.MultiStatements = false
destDB, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
return fmt.Errorf("连接测试库 %s 失败: %w", dest, err)
}
defer func() { _ = destDB.Close() }()
ctx := context.Background()
conn, err := destDB.Conn(ctx)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
if _, err := conn.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 0"); err != nil {
return err
}
defer func() { _, _ = conn.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 1") }()
for _, table := range tables {
var name, create string
if err := serverDB.QueryRow("SHOW CREATE TABLE `"+src+"`.`"+table+"`").Scan(&name, &create); err != nil {
return fmt.Errorf("读取表结构 %s.%s 失败: %w", src, table, err)
}
if _, err := conn.ExecContext(ctx, create); err != nil {
return fmt.Errorf("克隆表 %s 失败: %w", table, err)
}
if _, err := conn.ExecContext(ctx, "INSERT INTO `"+table+"` SELECT * FROM `"+src+"`.`"+table+"`"); err != nil {
return fmt.Errorf("克隆表数据 %s 失败: %w", table, err)
}
}
return nil
}
func listBaseTables(db *sql.DB, schema string) ([]string, error) {
rows, err := db.Query(
"SELECT table_name FROM information_schema.tables "+
"WHERE table_schema = ? AND table_type = 'BASE TABLE' ORDER BY table_name",
schema,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
tables = append(tables, name)
}
return tables, rows.Err()
}
func writeFingerprint(db *sql.DB, schema, fingerprint string) error {
ddl := "CREATE TABLE IF NOT EXISTS `" + schema + "`.`" + schemaMetaTable + "` (" +
"k VARCHAR(64) NOT NULL PRIMARY KEY," +
"v VARCHAR(128) NOT NULL" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
if _, err := db.Exec(ddl); err != nil {
return fmt.Errorf("创建模板指纹表失败: %w", err)
}
if _, err := db.Exec(
"REPLACE INTO `"+schema+"`.`"+schemaMetaTable+"` (k, v) VALUES (?, ?)",
"fingerprint", fingerprint,
); err != nil {
return fmt.Errorf("写入模板指纹失败: %w", err)
}
return nil
}
func readFingerprint(db *sql.DB, schema string) string {
var value string
err := db.QueryRow(
"SELECT v FROM `" + schema + "`.`" + schemaMetaTable + "` WHERE k = 'fingerprint'",
).Scan(&value)
if err != nil {
return ""
}
return value
}
func withMySQLLock(db *sql.DB, name string, fn func() error) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
conn, err := db.Conn(ctx)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
var got int
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 120)", name).Scan(&got); err != nil {
return fmt.Errorf("GET_LOCK %s 失败: %w", name, err)
}
if got != 1 {
return fmt.Errorf("GET_LOCK %s 未拿到锁", name)
}
defer func() {
_, _ = conn.ExecContext(context.Background(), "SELECT RELEASE_LOCK(?)", name)
}()
return fn()
}
func mysqlLockName(kind, dbName string) string {
raw := "hfb:" + kind + ":" + dbName
if len(raw) <= 64 {
return raw
}
sum := sha256.Sum256([]byte(raw))
return ("hfb:" + kind + ":") + hex.EncodeToString(sum[:12])
}
func migrationFingerprint(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("读取迁移目录失败: %w", err)
}
h := sha256.New()
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
_, _ = fmt.Fprintf(h, "%s\n", entry.Name())
raw, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
return "", fmt.Errorf("读取迁移 %s 失败: %w", entry.Name(), err)
}
_, _ = h.Write(raw)
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// assertTestDatabaseName 拒绝重置非测试库,避免误删生产数据。
func assertTestDatabaseName(name string) error {
if name == "" {
return errors.New("测试库 DSN 必须包含数据库名")
}
if !regexp.MustCompile(`^[A-Za-z0-9_]+$`).MatchString(name) {
return fmt.Errorf("测试库名只允许字母、数字、下划线: %q", name)
}
if len(name) > 64 {
return fmt.Errorf("测试库名超过 MySQL 上限 64 字符: %q", name)
}
lower := strings.ToLower(name)
if !strings.Contains(lower, "test") && !strings.Contains(lower, "e2e") {
return fmt.Errorf("拒绝重置非测试库 %q,库名需包含 test 或 e2e", name)
}
return nil
}
// packageSuffix 用当前测试进程的工作目录名(即被测包目录)作为库名后缀。
// go test 会把工作目录设为包的源目录,因此同包共享一个库、不同包互相隔离。
func packageSuffix() string {
wd, err := os.Getwd()
if err != nil {
return "unknown"
}
suffix := regexp.MustCompile(`[^A-Za-z0-9_]`).ReplaceAllString(filepath.Base(wd), "_")
if suffix == "" {
return "unknown"
}
return suffix
}
func migrationDir() (string, error) {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
return "", errors.New("无法定位 database 包路径")
}
return filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations"), nil
}
// gooseUpSQL 截取 goose 迁移文件的 Up 段,Down 段与 goose 指令行原样保留,
// 后者在 MySQL 中属于注释,不影响执行。
func gooseUpSQL(raw string) string {
if i := strings.Index(raw, "-- +goose Up"); i >= 0 {
raw = raw[i+len("-- +goose Up"):]
}
if i := strings.Index(raw, "-- +goose Down"); i >= 0 {
raw = raw[:i]
}
return raw
}
// truncateAllTables 清空测试库中的全部业务表,使每个测试从空库开始。
// 指纹表保留,以便跨进程复用已克隆的库。
//
// 先用一条多语句 DELETE 清数据(测试数据量很小,通常几十毫秒),再把用过的
// AUTO_INCREMENT 表打回 1。全量 TRUNCATE 61 张表是 DDL,单次约 0.6s–1.4s,
// 而不少测试硬编码了 id=1,所以自增必须重置。
func truncateAllTables(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
tables, err := listCurrentDatabaseTables(sqlDB)
if err != nil {
return err
}
var b strings.Builder
b.WriteString("SET FOREIGN_KEY_CHECKS=0;")
count := 0
for _, table := range tables {
if table == schemaMetaTable {
continue
}
b.WriteString("DELETE FROM `")
b.WriteString(table)
b.WriteString("`;")
count++
}
if count == 0 {
return nil
}
b.WriteString("SET FOREIGN_KEY_CHECKS=1;")
if _, err := sqlDB.Exec(b.String()); err != nil {
return fmt.Errorf("清空测试表失败: %w", err)
}
aiTables, err := listAutoIncrementTables(sqlDB)
if err != nil {
return err
}
if len(aiTables) == 0 {
return nil
}
b.Reset()
for _, table := range aiTables {
b.WriteString("ALTER TABLE `")
b.WriteString(table)
b.WriteString("` AUTO_INCREMENT=1;")
}
if _, err := sqlDB.Exec(b.String()); err != nil {
return fmt.Errorf("重置自增失败: %w", err)
}
return nil
}
func listAutoIncrementTables(db *sql.DB) ([]string, error) {
ctx := context.Background()
conn, err := db.Conn(ctx)
if err != nil {
return nil, err
}
defer func() { _ = conn.Close() }()
// MySQL 8 默认缓存 information_schema 的统计信息(86400s),
// 直接读取 auto_increment 可能拿到陈旧值并跳过重置,必须在本连接关闭缓存后再查。
if _, err := conn.ExecContext(ctx, "SET SESSION information_schema_stats_expiry = 0"); err != nil {
return nil, err
}
rows, err := conn.QueryContext(ctx,
"SELECT table_name FROM information_schema.tables "+
"WHERE table_schema = DATABASE() AND table_name <> ? AND auto_increment > 1",
schemaMetaTable,
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
tables = append(tables, name)
}
return tables, rows.Err()
}
func listCurrentDatabaseTables(db *sql.DB) ([]string, error) {
rows, err := db.Query(
"SELECT table_name FROM information_schema.tables " +
"WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'",
)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
tables = append(tables, name)
}
return tables, rows.Err()
}
+101
View File
@@ -0,0 +1,101 @@
package database
import (
"strings"
"testing"
)
func TestAssertTestDatabaseName(t *testing.T) {
t.Parallel()
cases := []struct {
name string
wantErr string
}{
{name: "hfb_sys_test", wantErr: ""},
{name: "hfb_sys_test_order", wantErr: ""},
{name: "hfb_e2e_flow", wantErr: ""},
{name: "", wantErr: "必须包含数据库名"},
{name: "hfb-sys-test", wantErr: "只允许字母、数字、下划线"},
{name: "hfb_sys", wantErr: "需包含 test 或 e2e"},
{name: strings.Repeat("test_", 20), wantErr: "超过 MySQL 上限"},
}
for _, item := range cases {
err := assertTestDatabaseName(item.name)
if item.wantErr == "" {
if err != nil {
t.Fatalf("%q: unexpected error %v", item.name, err)
}
continue
}
if err == nil || !strings.Contains(err.Error(), item.wantErr) {
t.Fatalf("%q: error = %v, want containing %q", item.name, err, item.wantErr)
}
}
}
func TestGooseUpSQL(t *testing.T) {
t.Parallel()
raw := "-- +goose Up\nCREATE TABLE t (id INT);\n-- +goose Down\nDROP TABLE t;\n"
got := strings.TrimSpace(gooseUpSQL(raw))
if got != "CREATE TABLE t (id INT);" {
t.Fatalf("gooseUpSQL = %q", got)
}
}
func TestNewDryRunDB(t *testing.T) {
t.Parallel()
if NewDryRunDB() == nil {
t.Fatal("NewDryRunDB returned nil")
}
}
func TestNewTestDBTruncatesBetweenCalls(t *testing.T) {
db := NewTestDB()
if err := db.Exec("INSERT INTO users (phone) VALUES ('13900001111')").Error; err != nil {
t.Fatalf("insert user: %v", err)
}
var n int64
if err := db.Raw("SELECT COUNT(*) FROM users").Scan(&n).Error; err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("users count = %d, want 1", n)
}
db = NewTestDB()
if err := db.Raw("SELECT COUNT(*) FROM users").Scan(&n).Error; err != nil {
t.Fatal(err)
}
if n != 0 {
t.Fatalf("users count after reset = %d, want 0", n)
}
}
func TestNewTestDBKeepsSchemaFingerprint(t *testing.T) {
db := NewTestDB()
var fingerprint string
if err := db.Raw("SELECT v FROM " + schemaMetaTable + " WHERE k = 'fingerprint'").Scan(&fingerprint).Error; err != nil {
t.Fatalf("read fingerprint: %v", err)
}
if fingerprint == "" {
t.Fatal("expected schema fingerprint to survive truncate")
}
}
func TestNewTestDBResetsAutoIncrement(t *testing.T) {
db := NewTestDB()
if err := db.Exec("INSERT INTO users (phone) VALUES ('13900002222')").Error; err != nil {
t.Fatal(err)
}
db = NewTestDB()
if err := db.Exec("INSERT INTO users (phone) VALUES ('13900003333')").Error; err != nil {
t.Fatal(err)
}
var id uint64
if err := db.Raw("SELECT id FROM users WHERE phone = '13900003333'").Scan(&id).Error; err != nil {
t.Fatal(err)
}
if id != 1 {
t.Fatalf("id after reset = %d, want 1", id)
}
}