package data import ( "context" "database/sql" "os" "path/filepath" "runtime" "strings" "testing" "time" "github.com/gogf/gf/v2/os/gtime" mysql "github.com/go-sql-driver/mysql" _ "github.com/gogf/gf/contrib/drivers/mysql/v2" "github.com/gogf/gf/v2/database/gdb" "gopkg.in/yaml.v3" ) // 仅显式启用时使用已核验的本机专用测试库;认证始终仅在内存中处理。 func testSubBatchMySQL(t *testing.T) gdb.DB { t.Helper() if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { t.Fatal("未显式启用隔离MySQL测试") } _, file, _, _ := runtime.Caller(0) root := filepath.Clean(filepath.Join(filepath.Dir(file), "../../../..")) raw, err := os.ReadFile(filepath.Join(root, "iotlcm-sms/deploy/config.yaml")) if err != nil { t.Fatal("读取本机认证配置失败,未打印凭据") } var config map[string]any if err := yaml.Unmarshal(raw, &config); err != nil { t.Fatal("解析本机认证配置失败,未打印内容") } var dsn string var visit func(map[string]any) visit = func(m map[string]any) { for key, value := range m { if key == "sceneDbDsn" { if text, ok := value.(string); ok { dsn = text } } if nested, ok := value.(map[string]any); ok { visit(nested) } } } visit(config) parsed, err := mysql.ParseDSN(dsn) if err != nil { t.Fatal("本机DSN格式不正确,未打印内容") } if parsed.Net != "tcp" || (parsed.Addr != "127.0.0.1:3306" && parsed.Addr != "localhost:3306") { t.Fatal("认证配置并非允许的本机3306,拒绝连接") } const database = "iotdb_apn_worker_test_20261015" parsed.DBName = database connection, err := sql.Open("mysql", parsed.FormatDSN()) if err != nil { t.Fatal("创建隔离连接失败") } defer connection.Close() ctx := context.Background() var uuid, datadir, name, version string var port int if err := connection.QueryRowContext(ctx, "SELECT @@server_uuid,@@datadir,@@port,DATABASE(),VERSION()").Scan(&uuid, &datadir, &port, &name, &version); err != nil { t.Fatal("只读核验本机实例身份失败") } normalized := strings.TrimRight(strings.ReplaceAll(datadir, "\\", "/"), "/") if uuid != "4fa545af-ab36-11f1-be02-040e3c9de361" || port != 3306 || name != database || !strings.EqualFold(normalized, "D:/MySQL/MySQL Server 8.0/Data") { t.Fatal("实例身份不匹配,拒绝任何写入") } t.Logf("隔离MySQL身份:version=%s uuid=%s port=%d database=%s", version, uuid, port, name) db, err := gdb.New(gdb.ConfigNode{Type: "mysql", Host: "127.0.0.1", Port: "3306", User: parsed.User, Pass: parsed.Passwd, Name: database, Charset: "utf8mb4", Timezone: "Asia/Shanghai", Extra: "parseTime=true"}) if err != nil { t.Fatal("创建GoFrame隔离连接失败,未打印认证") } // 仅重建此专用库中的固定fixture表,不读取或修改任何业务库。 for _, table := range []string{"data_operation_sub_detail", "data_operation_sub_batch", "data_operation_card_claim", "data_operation_parent", "batch_detail", "batch_main", "t_sim_card_info", "sys_oss", "t_resource_batch_download"} { if _, err := db.Exec(ctx, "DROP TABLE IF EXISTS `"+database+"`.`"+table+"`"); err != nil { t.Fatalf("重建专用fixture失败:%s", table) } } migration, err := os.ReadFile(filepath.Join(root, "iotlcm-database/SQL/20261015_data_operation_sub_batch.sql")) if err != nil { t.Fatal("读取worker迁移失败") } for _, statement := range strings.Split(string(migration), ";") { statement = strings.TrimSpace(statement) if statement == "" { continue } if _, err := db.Exec(ctx, statement); err != nil { t.Fatalf("执行专用库worker迁移失败:%v", err) } } return db } func TestSubBatchMySQLMigrationRepeatPreservesAcceptedIntent(t *testing.T) { if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { t.Skip("仅隔离MySQL验证真实迁移重复执行") } repo, ctx := testSubBatchDB(t) b := testSub("S-1", "migration-accepted", "a", []int64{1}, []string{"fake-A"}) if err := repo.PrepareSubBatch(ctx, b); err != nil { t.Fatal(err) } claimed, err := repo.ClaimSubBatch(ctx, b.OperationID, "owner", time.Now()) if err != nil || claimed == nil { t.Fatalf("领取: %+v %v", claimed, err) } if err := repo.UpdateSubBatch(ctx, *claimed, "accepted", "preserved-job", "已受理", time.Now(), 2); err != nil { t.Fatal(err) } query, err := repo.ClaimSubBatch(ctx, b.OperationID, "preserved-query-owner", time.Now().Add(time.Second)) if err != nil || query == nil { t.Fatalf("领取在途查询租约: %+v %v", query, err) } before, err := repo.database().GetOne(ctx, "SELECT request_data,detail_ids,job_id,poll_count,submitted_at,status,lease_token,lease_until,next_check_at FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID) if err != nil { t.Fatal(err) } if before["status"].String() != "accepted" || before["job_id"].String() != "preserved-job" || before["poll_count"].Int() != 3 || before["lease_token"].String() != "preserved-query-owner" || before["lease_until"].IsNil() { t.Fatalf("未实际构造accepted在途租约: %v", before) } _, file, _, _ := runtime.Caller(0) path := filepath.Join(filepath.Dir(file), "../../../../iotlcm-database/SQL/20261015_data_operation_sub_batch.sql") migration, err := os.ReadFile(path) if err != nil { t.Fatal(err) } for repeat := 0; repeat < 2; repeat++ { for _, statement := range strings.Split(string(migration), ";") { if strings.TrimSpace(statement) == "" { continue } if _, err := repo.database().Exec(ctx, statement); err != nil { t.Fatal(err) } } } after, err := repo.database().GetOne(ctx, "SELECT request_data,detail_ids,job_id,poll_count,submitted_at,status,lease_token,lease_until,next_check_at FROM data_operation_sub_batch WHERE operation_id=?", b.OperationID) if err != nil { t.Fatal(err) } for key, value := range before { if value.String() != after[key].String() { t.Fatalf("重复迁移改变受理字段: %s", key) } } n, err := repo.database().Model("data_operation_sub_detail").Ctx(ctx).Where("operation_id", b.OperationID).Count() if err != nil || n != 1 { t.Fatalf("重复迁移丢明细归属: %d %v", n, err) } n, err = repo.database().Model(claimTable).Ctx(ctx).Where("active_operation_id", b.OperationID).Count() if err != nil || n != 1 { t.Fatalf("重复迁移丢active: %d %v", n, err) } } func TestSubBatchMySQLDifferentDatabaseDefaultCollation(t *testing.T) { if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { t.Skip("仅隔离MySQL验证不同默认排序规则") } repo, ctx := testSubBatchDB(t) value, err := repo.database().GetValue(ctx, "SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME=DATABASE()") if err != nil || value.String() != "utf8mb4_0900_ai_ci" { t.Fatalf("未实际覆盖不同DB默认排序规则: %v %v", value, err) } rows, err := repo.database().GetAll(ctx, "SELECT TABLE_NAME,COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME='batch_no' AND TABLE_NAME IN ('batch_main','batch_detail','data_operation_parent','data_operation_sub_batch','data_operation_sub_detail')") if err != nil || len(rows) != 5 { t.Fatalf("关联列元数据: %v %v", rows, err) } for _, row := range rows { if row["COLLATION_NAME"].String() != "utf8mb4_general_ci" { t.Fatalf("关联列未匹配旧基线: %v", rows) } } b := testSub("S-1", "collation-real-join", "a", []int64{1}, []string{"fake-A"}) if err := repo.PrepareSubBatch(ctx, b); err != nil { t.Fatal(err) } if ids, err := repo.ListDueSubBatches(ctx, time.Now(), 128); err != nil || len(ids) != 1 { t.Fatalf("跨新旧表due查询: %v %v", ids, err) } if ids, err := repo.ListRecoverableBatches(ctx, 128); err != nil { t.Fatalf("跨新旧表恢复查询: %v %v", ids, err) } if got, err := repo.ClaimSubBatch(ctx, b.OperationID, "collation-owner", time.Now()); err != nil || got == nil { t.Fatalf("跨新旧表前序锁定JOIN: %+v %v", got, err) } indexRows, err := repo.database().GetAll(ctx, "EXPLAIN SELECT STRAIGHT_JOIN s.operation_id FROM batch_main m FORCE INDEX(PRIMARY) JOIN data_operation_sub_detail sd ON sd.batch_no=m.batch_no AND sd.iccid='fake-A' JOIN data_operation_sub_batch s ON s.operation_id=sd.operation_id JOIN batch_detail d ON d.id=sd.detail_id WHERE m.tenant_id='tenant' AND m.id<11 AND d.detail_status=1 AND s.status IN ('prepared','submitting','accepted','uncertain') LIMIT 1 FOR UPDATE OF sd,s,d") if err != nil || len(indexRows) < 2 || indexRows[0]["table"].String() != "m" || indexRows[1]["table"].String() != "sd" || indexRows[1]["key"].String() != "uk_data_sub_batch_card" { t.Fatalf("前序锁定查询未按父批→同卡映射驱动: %v %v", indexRows, err) } t.Logf("前序EXPLAIN首表=%s key=%s;第二表=%s key=%s", indexRows[0]["table"].String(), indexRows[0]["key"].String(), indexRows[1]["table"].String(), indexRows[1]["key"].String()) t.Logf("DB默认=%s,五张新旧表batch_no均general_ci,真实due/recovery/claim JOIN成功", value.String()) } func logMySQLLease(t *testing.T, repo *Repository, batchID int64, token string) { t.Helper() if os.Getenv("APN_WORKER_MYSQL_TEST") != "1" { return } ctx := context.Background() raw, err := repo.database().GetOne(ctx, "SELECT CAST(lease_until AS CHAR) AS raw_lease, CAST(NOW(6) AS CHAR) AS db_now, CAST(UTC_TIMESTAMP(6) AS CHAR) AS db_utc, @@session.time_zone AS session_zone FROM data_operation_sub_batch WHERE id=?", batchID) if err != nil { t.Fatal(err) } var row subRow if err := repo.database().Model(subTable).Ctx(ctx).Where("id", batchID).Scan(&row); err != nil { t.Fatal(err) } var alternate struct { LeaseUntil *gtime.Time `orm:"lease_until"` } if err := repo.database().Model(subTable).Ctx(ctx).Where("id", batchID).Scan(&alternate); err != nil { t.Fatal(err) } now := time.Now() t.Logf("租约原值 raw=%s dbNow=%s dbUTC=%s sessionZone=%s tokenMatch=%t status=%s nil=%t", raw["raw_lease"].String(), raw["db_now"].String(), raw["db_utc"].String(), raw["session_zone"].String(), row.LeaseToken == token, row.Status, row.LeaseUntil == nil) if row.LeaseUntil != nil { t.Logf("time扫描=%s location=%s unixNano=%d; now=%s location=%s unixNano=%d", row.LeaseUntil.Format(time.RFC3339Nano), row.LeaseUntil.Location(), row.LeaseUntil.UnixNano(), now.Format(time.RFC3339Nano), now.Location(), now.UnixNano()) } if alternate.LeaseUntil != nil { t.Logf("gtime扫描=%s location=%s unixNano=%d", alternate.LeaseUntil.Format("c"), alternate.LeaseUntil.Time.Location(), alternate.LeaseUntil.UnixNano()) } }