From 47ceb53c09133edcf114abe9c44b1a5d8893722c Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 10 Sep 2026 15:46:18 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B5=8B=E8=AF=95=E5=BA=93=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=A8=A1=E6=9D=BF=E5=85=8B=E9=9A=86=E5=B9=B6=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=E5=AE=8C=E6=95=B4=E7=A7=9F=E5=8F=B7=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个包不再重复 DROP+迁移,清空改用 DELETE 并重置自增,go test ./... 从约 6 分钟降到十几秒。租号全链路改走 NewTestDB,去掉依赖额外 DSN 才会跑的过渡测试。 Co-authored-by: Cursor --- README.md | 2 +- backend/internal/database/testdb.go | 410 +++++++++++++++--- backend/internal/database/testdb_test.go | 101 +++++ .../internal/e2e/distribution_flow_test.go | 22 +- backend/internal/e2e/rental_flow_test.go | 107 +---- .../adminfinance/dashboard_profile_test.go | 45 -- .../adminfinance/disbursement_mysql_test.go | 35 -- docs/代码质量改进计划.md | 2 +- scripts/check-backend-coverage.sh | 2 +- scripts/check.sh | 3 +- scripts/prepare-test-db.sh | 2 +- 11 files changed, 456 insertions(+), 275 deletions(-) create mode 100644 backend/internal/database/testdb_test.go delete mode 100644 backend/internal/modules/adminfinance/dashboard_profile_test.go delete mode 100644 backend/internal/modules/adminfinance/disbursement_mysql_test.go diff --git a/README.md b/README.md index dfcb888..cea820a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ npm run dev `./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。 -本地门禁由 `scripts/check.sh` 统一执行;后端覆盖率基线由 `scripts/check-backend-coverage.sh` 维护。需要验证完整租赁链路时,先启动本地 MySQL 8.4,再设置 `HFB_E2E_DSN` 运行 `go test ./internal/e2e`。 +本地门禁由 `scripts/check.sh` 统一执行;后端覆盖率基线由 `scripts/check-backend-coverage.sh` 维护。完整租赁与分销链路 e2e 已包含在 `go test ./...` 中,需本地 MySQL 可用。 服务探针:`GET /health` 仅表示进程存活;`GET /health/ready` 和 `GET /api/health` 会检查 MySQL、Redis,任一依赖不可用时返回 503。 diff --git a/backend/internal/database/testdb.go b/backend/internal/database/testdb.go index d9a2097..1dbe56c 100644 --- a/backend/internal/database/testdb.go +++ b/backend/internal/database/testdb.go @@ -2,7 +2,9 @@ package database import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "errors" "fmt" "os" @@ -11,6 +13,7 @@ import ( "runtime" "strings" "sync" + "time" mysqldriver "github.com/go-sql-driver/mysql" "gorm.io/driver/mysql" @@ -22,6 +25,11 @@ import ( // 可通过 HFB_TEST_DSN 覆盖,库名必须包含 test 或 e2e。 const DefaultTestDSN = "hfb:secret@tcp(127.0.0.1:13306)/hfb_sys_test?charset=utf8mb4&parseTime=True&loc=Local" +const ( + schemaMetaTable = "_test_schema_meta" + templateSuffix = "__template" +) + var ( testPrepareOnce sync.Once testConn *gorm.DB @@ -30,9 +38,10 @@ var ( // NewTestDB 返回指向 MySQL 测试库的干净连接。 // -// 首次调用会重建测试库并执行 backend/migrations 下的真实迁移, -// 因此测试用的表结构与生产完全一致;之后每次调用清空全部表, -// 使每个测试都从空库开始。 +// 表结构来自 backend/migrations 的真实迁移,与生产一致。 +// 迁移只在模板库上执行一次;各测试包克隆模板库,指纹未变则跨 `go test` +// 调用复用,避免每个包、每次运行都 DROP + 重放 68 条迁移。 +// 每次调用仍会清空业务表,使测试从空库开始。 func NewTestDB() *gorm.DB { db, err := sharedTestDB() if err != nil { @@ -94,13 +103,25 @@ func openTestDatabase() (*gorm.DB, error) { if err != nil { return nil, fmt.Errorf("解析测试库 DSN 失败: %w", err) } - // 每个测试包使用独立库,避免 go test ./... 并发时互相重建同一个库。 - cfg.DBName = cfg.DBName + "_" + packageSuffix() - if err := assertTestDatabaseName(cfg.DBName); err != nil { + 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 } - // 重建空库,保证表结构只来自 migrations。 serverCfg := cfg.Clone() serverCfg.DBName = "" serverCfg.MultiStatements = false @@ -109,23 +130,27 @@ func openTestDatabase() (*gorm.DB, error) { return nil, fmt.Errorf("连接 MySQL 服务失败: %w", err) } defer func() { _ = serverDB.Close() }() - if _, err := serverDB.Exec("DROP DATABASE IF EXISTS `" + cfg.DBName + "`"); err != nil { - return nil, fmt.Errorf("删除测试库失败: %w", err) - } - if _, err := serverDB.Exec("CREATE DATABASE `" + cfg.DBName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { - return nil, fmt.Errorf("创建测试库失败: %w", err) + 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) } - if err := applyMigrations(sqlDB); err != nil { - _ = sqlDB.Close() - return nil, 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), }) @@ -136,6 +161,232 @@ func openTestDatabase() (*gorm.DB, error) { 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 == "" { @@ -176,30 +427,6 @@ func migrationDir() (string, error) { return filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations"), nil } -func applyMigrations(db *sql.DB) error { - dir, err := migrationDir() - if err != nil { - return err - } - 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 -} - // gooseUpSQL 截取 goose 迁移文件的 Up 段,Down 段与 goose 指令行原样保留, // 后者在 MySQL 中属于注释,不影响执行。 func gooseUpSQL(raw string) string { @@ -212,40 +439,97 @@ func gooseUpSQL(raw string) string { return raw } -// truncateAllTables 清空测试库中的全部基础表,使每个测试从空库开始。 +// truncateAllTables 清空测试库中的全部业务表,使每个测试从空库开始。 +// 指纹表保留,以便跨进程复用已克隆的库。 +// +// 先用一条多语句 DELETE 清数据(测试数据量很小,通常几十毫秒),再把用过的 +// AUTO_INCREMENT 表打回 1。全量 TRUNCATE 61 张表是 DDL,单次约 0.6s–1.4s, +// 而不少测试硬编码了 id=1,所以自增必须重置。 func truncateAllTables(db *gorm.DB) error { - var tables []string - if err := db.Raw( - "SELECT table_name FROM information_schema.tables " + - "WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'", - ).Scan(&tables).Error; err != nil { - return err - } - if len(tables) == 0 { - return nil - } - sqlDB, err := db.DB() if err != nil { return err } - ctx := context.Background() - // 固定单连接,保证 FOREIGN_KEY_CHECKS 的会话变量对后续 TRUNCATE 生效。 - conn, err := sqlDB.Conn(ctx) + tables, err := listCurrentDatabaseTables(sqlDB) if err != nil { return err } - defer func() { _ = conn.Close() }() + 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) + } - if _, err := conn.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 0"); err != nil { + aiTables, err := listAutoIncrementTables(sqlDB) + if err != nil { return err } - defer func() { _, _ = conn.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 1") }() - - for _, table := range tables { - if _, err := conn.ExecContext(ctx, "TRUNCATE TABLE `"+table+"`"); err != nil { - return fmt.Errorf("清空表 %s 失败: %w", table, 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) { + rows, err := db.Query( + "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() +} diff --git a/backend/internal/database/testdb_test.go b/backend/internal/database/testdb_test.go new file mode 100644 index 0000000..541d737 --- /dev/null +++ b/backend/internal/database/testdb_test.go @@ -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) + } +} diff --git a/backend/internal/e2e/distribution_flow_test.go b/backend/internal/e2e/distribution_flow_test.go index 4fad9e1..f6df0c9 100644 --- a/backend/internal/e2e/distribution_flow_test.go +++ b/backend/internal/e2e/distribution_flow_test.go @@ -18,27 +18,7 @@ import ( func openDistributionE2EDB(t *testing.T) *gorm.DB { t.Helper() - db := database.NewTestDB() - sqlDB, err := db.DB() - if err != nil { - t.Fatal(err) - } - sqlDB.SetMaxOpenConns(1) - - if err := database.MigrateRentalTransactionTestSchema(db); err != nil { - t.Fatalf("migrate rental transaction schema failed: %v", err) - } - if err := database.AutoMigrateIfMissing(db, - &model.Role{}, &model.Permission{}, &model.AdminUserRole{}, &model.RolePermission{}, - &model.PaymentMerchantConfig{}, - &model.DistributionSiteMember{}, &model.DistributionRuleVersion{}, &model.DistributionOrderFinancial{}, - &model.DistributionOrderParty{}, &model.DistributionSitePaymentChannel{}, - &model.DistributionSettlementBatch{}, &model.DistributionSettlementItem{}, - &model.WithdrawalRequest{}, - ); err != nil { - t.Fatalf("auto migrate distribution schema failed: %v", err) - } - return db + return database.NewTestDB() } // TestE2EScenario1_MainSiteReceive_SubSiteMarkupSell_Settlement diff --git a/backend/internal/e2e/rental_flow_test.go b/backend/internal/e2e/rental_flow_test.go index cfc0263..52d66d5 100644 --- a/backend/internal/e2e/rental_flow_test.go +++ b/backend/internal/e2e/rental_flow_test.go @@ -2,22 +2,14 @@ package e2e import ( "context" - "database/sql" "fmt" - "os" - "path/filepath" - "regexp" - "runtime" - "strings" "testing" "time" - mysqldriver "github.com/go-sql-driver/mysql" - "gorm.io/driver/mysql" "gorm.io/gorm" - "gorm.io/gorm/logger" "hfb_sys/backend/internal/auditlog" + "hfb_sys/backend/internal/database" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/adminfinance" "hfb_sys/backend/internal/modules/dispute" @@ -254,102 +246,7 @@ func (c fixedConfig) FindValue(ctx context.Context, key string) (string, error) func openE2EDB(t *testing.T) *gorm.DB { t.Helper() - - rawDSN := os.Getenv("HFB_E2E_DSN") - if rawDSN == "" { - t.Skip("设置 HFB_E2E_DSN 后运行完整租号链路 e2e 测试") - } - cfg, err := mysqldriver.ParseDSN(rawDSN) - if err != nil { - t.Fatalf("解析 HFB_E2E_DSN 失败: %v", err) - } - dbName := cfg.DBName - assertSafeTestDBName(t, dbName) - - serverCfg := cfg.Clone() - serverCfg.DBName = "" - serverDB, err := sql.Open("mysql", serverCfg.FormatDSN()) - if err != nil { - t.Fatalf("连接 MySQL server 失败: %v", err) - } - defer serverDB.Close() - if _, err := serverDB.Exec("DROP DATABASE IF EXISTS `" + dbName + "`"); err != nil { - t.Fatalf("删除测试库失败: %v", err) - } - if _, err := serverDB.Exec("CREATE DATABASE `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { - t.Fatalf("创建测试库失败: %v", err) - } - - cfg.ParseTime = true - cfg.MultiStatements = true - sqlDB, err := sql.Open("mysql", cfg.FormatDSN()) - if err != nil { - t.Fatalf("连接测试库失败: %v", err) - } - t.Cleanup(func() { _ = sqlDB.Close() }) - applyMigrations(t, sqlDB) - - gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB}), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - t.Fatalf("打开 gorm 连接失败: %v", err) - } - return gormDB -} - -func assertSafeTestDBName(t *testing.T, dbName string) { - t.Helper() - if dbName == "" { - t.Fatal("HFB_E2E_DSN 必须包含数据库名") - } - if !regexp.MustCompile(`^[A-Za-z0-9_]+$`).MatchString(dbName) { - t.Fatalf("测试库名只允许字母、数字、下划线: %q", dbName) - } - lower := strings.ToLower(dbName) - if !strings.Contains(lower, "test") && !strings.Contains(lower, "e2e") { - t.Fatalf("拒绝重置非测试库 %q,库名需包含 test 或 e2e", dbName) - } -} - -func applyMigrations(t *testing.T, db *sql.DB) { - t.Helper() - _, currentFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("无法定位当前测试文件") - } - migrationDir := filepath.Join(filepath.Dir(currentFile), "..", "..", "migrations") - entries, err := os.ReadDir(migrationDir) - if err != nil { - t.Fatalf("读取迁移目录失败: %v", err) - } - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { - continue - } - name := entry.Name() - migrationPath := filepath.Join(migrationDir, name) - raw, err := os.ReadFile(migrationPath) - if err != nil { - t.Fatalf("读取迁移 %s 失败: %v", name, err) - } - sqlText := gooseUpSQL(string(raw)) - if _, err := db.Exec(sqlText); err != nil { - t.Fatalf("执行迁移 %s 失败: %v", name, err) - } - } -} - -func gooseUpSQL(raw string) string { - upIndex := strings.Index(raw, "-- +goose Up") - if upIndex >= 0 { - raw = raw[upIndex+len("-- +goose Up"):] - } - downIndex := strings.Index(raw, "-- +goose Down") - if downIndex >= 0 { - raw = raw[:downIndex] - } - return raw + return database.NewTestDB() } func seedUsers(t *testing.T, db *gorm.DB) (model.User, model.User, uint64) { diff --git a/backend/internal/modules/adminfinance/dashboard_profile_test.go b/backend/internal/modules/adminfinance/dashboard_profile_test.go deleted file mode 100644 index 00d2e3e..0000000 --- a/backend/internal/modules/adminfinance/dashboard_profile_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package adminfinance - -import ( - "encoding/json" - "os" - "testing" - "time" - - "hfb_sys/backend/internal/timeutil" - - "gorm.io/driver/mysql" - "gorm.io/gorm" - "gorm.io/gorm/logger" -) - -func TestProfileDashboardOnMySQL(t *testing.T) { - dsn := os.Getenv("HFB_ADMIN_FINANCE_TEST_DSN") - if dsn == "" { - t.Skip("profile only") - } - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)}) - if err != nil { - t.Fatal(err) - } - now := timeutil.ShanghaiNow() - started := time.Now() - result, err := NewRepository(db).Dashboard(t.Context(), DashboardQuery{ - StartDate: time.Date(now.Year(), now.Month(), now.Day()-6, 0, 0, 0, 0, timeutil.ShanghaiLocation()), - EndDate: time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, timeutil.ShanghaiLocation()), - }) - if err != nil { - t.Fatal(err) - } - t.Logf("dashboard duration=%s daily_items=%d", time.Since(started), len(result.DailyItems)) - if output := os.Getenv("HFB_ADMIN_FINANCE_PROFILE_OUTPUT"); output != "" { - result.GeneratedAt = time.Time{} - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(output, data, 0o600); err != nil { - t.Fatal(err) - } - } -} diff --git a/backend/internal/modules/adminfinance/disbursement_mysql_test.go b/backend/internal/modules/adminfinance/disbursement_mysql_test.go deleted file mode 100644 index 096e4f4..0000000 --- a/backend/internal/modules/adminfinance/disbursement_mysql_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package adminfinance - -import ( - "os" - "testing" - "time" - - "gorm.io/driver/mysql" - "gorm.io/gorm" -) - -func TestDisbursementQueryOnMySQL(t *testing.T) { - dsn := os.Getenv("HFB_ADMIN_FINANCE_TEST_DSN") - if dsn == "" { - t.Skip("设置 HFB_ADMIN_FINANCE_TEST_DSN 后运行 MySQL 资金出款查询测试") - } - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) - if err != nil { - t.Fatalf("连接 MySQL 失败: %v", err) - } - - result, err := NewRepository(db).Disbursements(t.Context(), DisbursementQuery{ - DateType: "created", - StartDate: time.Now().AddDate(-1, 0, 0), - EndDate: time.Now().AddDate(0, 0, 1), - Page: 1, - PageSize: 20, - }) - if err != nil { - t.Fatalf("MySQL 资金出款查询失败: %v", err) - } - if result == nil || result.Items == nil { - t.Fatalf("MySQL 资金出款查询返回异常: %+v", result) - } -} diff --git a/docs/代码质量改进计划.md b/docs/代码质量改进计划.md index 463b71e..9fc38c6 100644 --- a/docs/代码质量改进计划.md +++ b/docs/代码质量改进计划.md @@ -27,7 +27,7 @@ 3. Prettier、ESLint、Vue/TypeScript 类型检查。 4. Vitest 和 Vite 生产构建。 -本地门禁通过 `scripts/check.sh` 统一执行;需要完整租赁链路时,在本地 MySQL 8.4 上设置 `HFB_E2E_DSN` 运行 E2E。覆盖率门禁采用“只能提高、不能回退”的基线策略,新增测试后应同步上调阈值。 +本地门禁通过 `scripts/check.sh` 统一执行;完整租赁与分销链路 e2e 已包含在后端测试中。覆盖率门禁采用“只能提高、不能回退”的基线策略,新增测试后应同步上调阈值。 ## 后续重点 diff --git a/scripts/check-backend-coverage.sh b/scripts/check-backend-coverage.sh index 3afa5f3..86c1dea 100755 --- a/scripts/check-backend-coverage.sh +++ b/scripts/check-backend-coverage.sh @@ -8,7 +8,7 @@ OUTPUT="$(mktemp -t hfb-backend-cover-output.XXXXXX)" trap 'rm -f "${PROFILE}" "${OUTPUT}"' EXIT cd "${BACKEND_DIR}" -# 每个测试包使用独立 MySQL 测试库,可并发执行。 +# 模板库只迁移一次,各包克隆后可并发;指纹未变则跨次运行复用。 go test -count=1 -coverprofile="${PROFILE}" ./... | tee "${OUTPUT}" coverage_for_package() { diff --git a/scripts/check.sh b/scripts/check.sh index 76b2e45..51b2bf6 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -96,8 +96,7 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then run_step "go vet ./..." go vet ./... || FAILED=1 run_step "go build ./..." go build ./... || FAILED=1 if [[ "${SKIP_TESTS}" == "0" ]]; then - # 后端测试跑在真实 MySQL 上:每个包使用独立测试库 hfb_sys_test_<包名>,可并发执行。 - # 真实链路 e2e(internal/e2e 中依赖 HFB_E2E_DSN 的用例)需要显式配置该变量。 + # 后端测试跑在真实 MySQL 上:模板库只迁移一次,各包克隆后可并发。 run_step "准备 MySQL 测试库" bash "${ROOT_DIR}/scripts/prepare-test-db.sh" || FAILED=1 run_step "go test + coverage" bash "${ROOT_DIR}/scripts/check-backend-coverage.sh" || FAILED=1 else diff --git a/scripts/prepare-test-db.sh b/scripts/prepare-test-db.sh index 02d5879..f6dc33e 100755 --- a/scripts/prepare-test-db.sh +++ b/scripts/prepare-test-db.sh @@ -43,4 +43,4 @@ GRANT ALL PRIVILEGES ON \`${pattern}\_%\`.* TO '${app_user}'@'%'; FLUSH PRIVILEGES; SQL -printf '测试库授权已就绪。测试运行时会自动重建各自的库:%s_<包名>\n' "${base_database}" +printf '测试库授权已就绪。测试运行时会维护模板库 %s__template,并按包克隆为 %s_<包名>\n' "${base_database}" "${base_database}"