test: 测试库改为模板克隆并纳入完整租号 e2e

每个包不再重复 DROP+迁移,清空改用 DELETE 并重置自增,go test ./... 从约 6 分钟降到十几秒。租号全链路改走 NewTestDB,去掉依赖额外 DSN 才会跑的过渡测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-09-10 15:46:18 +08:00
co-authored by Cursor
parent cbd5412315
commit 47ceb53c09
11 changed files with 456 additions and 275 deletions
+1 -1
View File
@@ -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。
+347 -63
View File
@@ -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()
}
+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)
}
}
+1 -21
View File
@@ -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
+2 -105
View File
@@ -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) {
@@ -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)
}
}
}
@@ -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)
}
}
+1 -1
View File
@@ -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 已包含在后端测试中。覆盖率门禁采用“只能提高、不能回退”的基线策略,新增测试后应同步上调阈值。
## 后续重点
+1 -1
View File
@@ -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() {
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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}"