实现七月迭代公共技术基础
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
This commit is contained in:
324
internal/infrastructure/releasegate/checker.go
Normal file
324
internal/infrastructure/releasegate/checker.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// Package releasegate 提供公共基础数据库对象的发布检查门禁。
|
||||
package releasegate
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PhasePre 表示迁移或发布前检查。
|
||||
PhasePre = "pre"
|
||||
// PhasePost 表示迁移后的定义与冒烟检查。
|
||||
PhasePost = "post"
|
||||
)
|
||||
|
||||
// Severity 表示门禁检查结果级别。
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityInfo 表示安全的计数或对象标识。
|
||||
SeverityInfo Severity = "info"
|
||||
// SeverityBlock 表示必须阻断发布的问题。
|
||||
SeverityBlock Severity = "block"
|
||||
)
|
||||
|
||||
// Finding 是不包含业务敏感值的门禁检查结果。
|
||||
type Finding struct {
|
||||
Code string `json:"code"`
|
||||
Severity Severity `json:"severity"`
|
||||
Object string `json:"object"`
|
||||
Count int64 `json:"count"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Report 是可重复执行的公共基础门禁报告。
|
||||
type Report struct {
|
||||
Phase string `json:"phase"`
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
|
||||
// Passed 判断报告是否允许继续发布。
|
||||
func (r Report) Passed() bool {
|
||||
for _, finding := range r.Findings {
|
||||
if finding.Severity == SeverityBlock {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Checker 使用 PostgreSQL 事实检查公共对象定义与异常数据。
|
||||
type Checker struct {
|
||||
db *gorm.DB
|
||||
schema string
|
||||
}
|
||||
|
||||
// NewChecker 创建公共发布门禁检查器。
|
||||
func NewChecker(db *gorm.DB) *Checker {
|
||||
return &Checker{db: db, schema: "public"}
|
||||
}
|
||||
|
||||
// NewCheckerWithSchema 创建隔离 schema 的迁移验收检查器。
|
||||
func NewCheckerWithSchema(db *gorm.DB, schema string) *Checker {
|
||||
if strings.TrimSpace(schema) == "" {
|
||||
schema = "public"
|
||||
}
|
||||
return &Checker{db: db, schema: schema}
|
||||
}
|
||||
|
||||
type tableContract struct {
|
||||
name string
|
||||
columns map[string]string
|
||||
}
|
||||
|
||||
var publicContracts = []tableContract{
|
||||
{name: "tb_outbox_event", columns: map[string]string{
|
||||
"event_id": "character varying", "event_type": "character varying", "payload": "jsonb",
|
||||
"status": "integer", "retry_count": "integer", "next_attempt_at": "timestamp with time zone",
|
||||
"lease_owner": "character varying", "lease_expires_at": "timestamp with time zone",
|
||||
}},
|
||||
{name: "tb_system_config", columns: map[string]string{
|
||||
"config_key": "character varying", "config_value": "text", "value_type": "character varying",
|
||||
"module": "character varying", "is_readonly": "boolean", "is_sensitive": "boolean",
|
||||
}},
|
||||
}
|
||||
|
||||
// Run 执行指定阶段检查;检查只读且可重复运行。
|
||||
func (c *Checker) Run(ctx context.Context, phase string) (Report, error) {
|
||||
report := Report{Phase: phase, Findings: make([]Finding, 0)}
|
||||
if phase != PhasePre && phase != PhasePost {
|
||||
return report, stderrors.New("公共发布门禁阶段不受支持")
|
||||
}
|
||||
version, dirty, err := c.migrationVersion(ctx)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
minimumVersion := uint(164)
|
||||
if phase == PhasePost {
|
||||
minimumVersion = 166
|
||||
}
|
||||
if dirty || version < minimumVersion {
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "FOUNDATION_DEPENDENCY_VERSION", Severity: SeverityBlock,
|
||||
Object: "schema_migrations", Count: int64(version), Summary: "迁移依赖版本未满足或数据库处于脏状态",
|
||||
})
|
||||
}
|
||||
|
||||
for _, contract := range publicContracts {
|
||||
exists, err := c.tableExists(ctx, contract.name)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !exists {
|
||||
severity := SeverityInfo
|
||||
if phase == PhasePost {
|
||||
severity = SeverityBlock
|
||||
}
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "FOUNDATION_OBJECT_MISSING", Severity: severity, Object: contract.name,
|
||||
Summary: "公共对象尚不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
definitionFindings, err := c.checkColumns(ctx, contract)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Findings = append(report.Findings, definitionFindings...)
|
||||
}
|
||||
if phase == PhasePost {
|
||||
objectFindings, err := c.checkIndexesAndConstraints(ctx)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Findings = append(report.Findings, objectFindings...)
|
||||
if err := c.smokeWrite(ctx); err != nil {
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "FOUNDATION_READ_WRITE_SMOKE_FAILED", Severity: SeverityBlock,
|
||||
Object: "tech-public-foundation", Count: 1, Summary: "公共对象读写冒烟失败",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
anomalyFindings, err := c.checkAnomalies(ctx)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Findings = append(report.Findings, anomalyFindings...)
|
||||
sort.Slice(report.Findings, func(i, j int) bool {
|
||||
if report.Findings[i].Object == report.Findings[j].Object {
|
||||
return report.Findings[i].Code < report.Findings[j].Code
|
||||
}
|
||||
return report.Findings[i].Object < report.Findings[j].Object
|
||||
})
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (c *Checker) migrationVersion(ctx context.Context) (uint, bool, error) {
|
||||
var result struct {
|
||||
Version uint `gorm:"column:version"`
|
||||
Dirty bool `gorm:"column:dirty"`
|
||||
}
|
||||
err := c.db.WithContext(ctx).Raw("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&result).Error
|
||||
return result.Version, result.Dirty, err
|
||||
}
|
||||
|
||||
func (c *Checker) tableExists(ctx context.Context, table string) (bool, error) {
|
||||
var exists bool
|
||||
err := c.db.WithContext(ctx).Raw("SELECT to_regclass(?) IS NOT NULL", c.schema+"."+table).Scan(&exists).Error
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (c *Checker) checkColumns(ctx context.Context, contract tableContract) ([]Finding, error) {
|
||||
var rows []struct {
|
||||
Name string `gorm:"column:column_name"`
|
||||
Type string `gorm:"column:data_type"`
|
||||
}
|
||||
err := c.db.WithContext(ctx).Raw(`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = ? AND table_name = ?`, c.schema, contract.name).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
actual[row.Name] = row.Type
|
||||
}
|
||||
findings := make([]Finding, 0)
|
||||
for column, expectedType := range contract.columns {
|
||||
actualType, exists := actual[column]
|
||||
if !exists || !strings.EqualFold(actualType, expectedType) {
|
||||
findings = append(findings, Finding{
|
||||
Code: "FOUNDATION_DEFINITION_MISMATCH", Severity: SeverityBlock,
|
||||
Object: contract.name + "." + column, Count: 1, Summary: "公共对象字段定义不符合契约",
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (c *Checker) checkAnomalies(ctx context.Context) ([]Finding, error) {
|
||||
checks := []struct {
|
||||
table string
|
||||
code string
|
||||
summary string
|
||||
query string
|
||||
}{
|
||||
{"tb_outbox_event", "OUTBOX_DUPLICATE_EVENT_ID", "存在重复事件 ID", `SELECT COUNT(*) FROM (SELECT event_id FROM tb_outbox_event GROUP BY event_id HAVING COUNT(*) > 1) AS conflicts`},
|
||||
{"tb_outbox_event", "OUTBOX_INVALID_REQUIRED_FIELD", "存在必填字段空值", `SELECT COUNT(*) FROM tb_outbox_event WHERE event_id IS NULL OR event_type IS NULL OR payload IS NULL`},
|
||||
{"tb_outbox_event", "OUTBOX_INVALID_STATUS", "存在非法投递状态", `SELECT COUNT(*) FROM tb_outbox_event WHERE status NOT IN (1,2,3,4)`},
|
||||
{"tb_outbox_event", "OUTBOX_UNDELIVERED", "存在未投递事件", `SELECT COUNT(*) FROM tb_outbox_event WHERE status IN (1,2,4)`},
|
||||
{"tb_outbox_event", "OUTBOX_EXPIRED_LEASE", "存在过期租约", `SELECT COUNT(*) FROM tb_outbox_event WHERE status = 2 AND lease_expires_at < NOW()`},
|
||||
{"tb_system_config", "SYSTEM_CONFIG_DUPLICATE_KEY", "存在重复配置 Key", `SELECT COUNT(*) FROM (SELECT config_key FROM tb_system_config GROUP BY config_key HAVING COUNT(*) > 1) AS conflicts`},
|
||||
{"tb_system_config", "SYSTEM_CONFIG_INVALID_REQUIRED_FIELD", "存在配置必填字段空值", `SELECT COUNT(*) FROM tb_system_config WHERE config_key IS NULL OR config_value IS NULL OR value_type IS NULL OR module IS NULL`},
|
||||
{"tb_system_config", "SYSTEM_CONFIG_INVALID_TYPE", "存在非法配置类型", `SELECT COUNT(*) FROM tb_system_config WHERE value_type NOT IN ('string','int','bool','json')`},
|
||||
}
|
||||
findings := make([]Finding, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
exists, err := c.tableExists(ctx, check.table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Raw(check.query).Scan(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: check.code, Severity: SeverityBlock, Object: check.table,
|
||||
Count: count, Summary: check.summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"tb_export_task", "tb_iot_card_import_task", "tb_device_import_task", "tb_order_package_invalidate_task"} {
|
||||
exists, err := c.tableExists(ctx, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Table(table).Where("status IN ?", []int{1, 2}).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: "ASYNC_TASK_UNFINISHED", Severity: SeverityBlock, Object: table,
|
||||
Count: count, Summary: "存在待处理或处理中任务",
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (c *Checker) checkIndexesAndConstraints(ctx context.Context) ([]Finding, error) {
|
||||
expectedConstraints := map[string][]string{
|
||||
"tb_outbox_event": {"uq_outbox_event_id", "ck_outbox_event_status", "ck_outbox_event_payload_version", "ck_outbox_event_retry_count"},
|
||||
"tb_system_config": {"uq_system_config_key", "ck_system_config_value_type"},
|
||||
}
|
||||
expectedIndexes := map[string][]string{
|
||||
"tb_outbox_event": {"idx_outbox_event_claim", "idx_outbox_event_type_status", "idx_outbox_event_aggregate", "idx_outbox_event_resource", "idx_outbox_event_request_id", "idx_outbox_event_correlation_id"},
|
||||
"tb_system_config": {"idx_system_config_module_key"},
|
||||
}
|
||||
findings := make([]Finding, 0)
|
||||
for table, names := range expectedConstraints {
|
||||
for _, name := range names {
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE table_schema = ? AND table_name = ? AND constraint_name = ?`, c.schema, table, name).Scan(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != 1 {
|
||||
findings = append(findings, Finding{Code: "FOUNDATION_CONSTRAINT_MISSING", Severity: SeverityBlock, Object: table + "." + name, Count: count, Summary: "公共约束缺失或重复"})
|
||||
}
|
||||
}
|
||||
}
|
||||
for table, names := range expectedIndexes {
|
||||
for _, name := range names {
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM pg_indexes
|
||||
WHERE schemaname = ? AND tablename = ? AND indexname = ?`, c.schema, table, name).Scan(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != 1 {
|
||||
findings = append(findings, Finding{Code: "FOUNDATION_INDEX_MISSING", Severity: SeverityBlock, Object: table + "." + name, Count: count, Summary: "公共索引缺失或重复"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
var errSmokeRollback = stderrors.New("公共对象冒烟回滚")
|
||||
|
||||
func (c *Checker) smokeWrite(ctx context.Context) error {
|
||||
err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
eventID := "smoke-" + uuid.NewString()
|
||||
if err := tx.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload)
|
||||
VALUES (?, 'foundation.smoke', 1, 'foundation', 'smoke', 'foundation', 'smoke', '{}'::jsonb)`, eventID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
configKey := "foundation.smoke." + uuid.NewString()
|
||||
if err := tx.Exec(`INSERT INTO tb_system_config
|
||||
(config_key, config_value, value_type, module, description)
|
||||
VALUES (?, 'true', 'bool', 'foundation', '公共对象读写冒烟')`, configKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return errSmokeRollback
|
||||
})
|
||||
if stderrors.Is(err, errSmokeRollback) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
20
internal/infrastructure/releasegate/checker_test.go
Normal file
20
internal/infrastructure/releasegate/checker_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package releasegate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReportBlocksOnlyOnBlockingFindings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
report := Report{Phase: PhasePre, Findings: []Finding{{
|
||||
Code: "FOUNDATION_OBJECT_MISSING", Severity: SeverityInfo, Object: "tb_outbox_event",
|
||||
}}}
|
||||
if !report.Passed() {
|
||||
t.Fatal("迁移前公共对象尚未创建不应单独阻断发布")
|
||||
}
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "OUTBOX_UNDELIVERED", Severity: SeverityBlock, Object: "tb_outbox_event", Count: 1,
|
||||
})
|
||||
if report.Passed() {
|
||||
t.Fatal("存在未投递事件时必须阻断发布")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package releasegate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/releasegate"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
)
|
||||
|
||||
func TestPublicFoundationMigrationsOnEmptyAndCompatibleDatabase(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 164)
|
||||
checker := releasegate.NewCheckerWithSchema(db, schema)
|
||||
|
||||
pre, err := checker.Run(context.Background(), releasegate.PhasePre)
|
||||
if err != nil {
|
||||
t.Fatalf("执行迁移前检查失败:%v", err)
|
||||
}
|
||||
if !pre.Passed() {
|
||||
t.Fatalf("空数据库迁移前检查不应阻断:%+v", pre.Findings)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE compatible_existing_data (id bigint PRIMARY KEY, value text NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("准备兼容存量表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO compatible_existing_data (id, value) VALUES (1, 'keep')").Error; err != nil {
|
||||
t.Fatalf("准备兼容存量数据失败:%v", err)
|
||||
}
|
||||
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec("UPDATE schema_migrations SET version = 166, dirty = false").Error; err != nil {
|
||||
t.Fatalf("更新隔离迁移版本失败:%v", err)
|
||||
}
|
||||
|
||||
post, err := checker.Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil {
|
||||
t.Fatalf("执行迁移后检查失败:%v", err)
|
||||
}
|
||||
if !post.Passed() {
|
||||
t.Fatalf("迁移后检查应通过:%+v", post.Findings)
|
||||
}
|
||||
postAgain, err := checker.Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil || !postAgain.Passed() {
|
||||
t.Fatalf("迁移后检查必须可重复执行:%v,结果:%+v", err, postAgain.Findings)
|
||||
}
|
||||
var compatibleCount int64
|
||||
if err := db.Table("compatible_existing_data").Count(&compatibleCount).Error; err != nil || compatibleCount != 1 {
|
||||
t.Fatalf("迁移破坏了兼容存量数据:%v,数量:%d", err, compatibleCount)
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return executeMigrationWithError(tx, "000165_create_public_outbox.up.sql")
|
||||
}); err == nil {
|
||||
t.Fatal("公共对象已创建时不得通过重复 DDL 静默掩盖定义")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationGateBlocksAnomaliesWithoutDestructiveWrites(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload)
|
||||
VALUES ('blocked-event', 'foundation.blocked', 1, 'example', '1', 'example', '1', '{}'::jsonb)`).Error; err != nil {
|
||||
t.Fatalf("准备未投递异常失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE tb_outbox_event DROP CONSTRAINT ck_outbox_event_status").Error; err != nil {
|
||||
t.Fatalf("准备非法状态定义失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload, status)
|
||||
VALUES ('invalid-status', 'foundation.invalid', 1, 'example', '2', 'example', '2', '{}'::jsonb, 9)`).Error; err != nil {
|
||||
t.Fatalf("准备非法状态数据失败:%v", err)
|
||||
}
|
||||
|
||||
report, err := releasegate.NewCheckerWithSchema(db, schema).Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil {
|
||||
t.Fatalf("执行异常门禁失败:%v", err)
|
||||
}
|
||||
if report.Passed() || !hasFinding(report, "OUTBOX_UNDELIVERED") || !hasFinding(report, "OUTBOX_INVALID_STATUS") || !hasFinding(report, "FOUNDATION_CONSTRAINT_MISSING") {
|
||||
t.Fatalf("异常数据和定义未被完整阻断:%+v", report.Findings)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("tb_outbox_event").Count(&count).Error; err != nil || count != 2 {
|
||||
t.Fatalf("只读门禁修改了异常数据:%v,数量:%d", err, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownMigrationAllowsEmptyStructuresAndRejectsExistingFacts(t *testing.T) {
|
||||
t.Run("空结构可回滚", func(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.down.sql")
|
||||
executeMigration(t, db, "000165_create_public_outbox.down.sql")
|
||||
for _, table := range []string{"tb_outbox_event", "tb_system_config"} {
|
||||
var exists bool
|
||||
if err := db.Raw("SELECT to_regclass(?) IS NOT NULL", schema+"."+table).Scan(&exists).Error; err != nil || exists {
|
||||
t.Fatalf("空结构回滚失败:%s,错误:%v", table, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("已有事实拒绝删表", func(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
_ = prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec(`INSERT INTO tb_system_config
|
||||
(config_key, config_value, value_type, module, description)
|
||||
VALUES ('foundation.test.fact', 'true', 'bool', 'foundation', '回滚保护测试')`).Error; err != nil {
|
||||
t.Fatalf("准备配置事实失败:%v", err)
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return executeMigrationWithError(tx, "000166_create_system_config.down.sql")
|
||||
}); err == nil || !strings.Contains(err.Error(), "禁止删表回滚") {
|
||||
t.Fatalf("已有事实时 down 迁移必须明确拒绝:%v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("tb_system_config").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("拒绝回滚后配置事实丢失:%v,数量:%d", err, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func prepareMigrationSchema(t *testing.T, db *gorm.DB, version uint) string {
|
||||
t.Helper()
|
||||
schema := "foundation_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if err := db.Exec(fmt.Sprintf(`CREATE SCHEMA %s`, schema)).Error; err != nil {
|
||||
t.Fatalf("创建隔离迁移 schema 失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`SET LOCAL search_path TO %s`, schema)).Error; err != nil {
|
||||
t.Fatalf("切换隔离迁移 schema 失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE schema_migrations (version bigint NOT NULL, dirty boolean NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("创建隔离迁移版本表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO schema_migrations (version, dirty) VALUES (?, false)", version).Error; err != nil {
|
||||
t.Fatalf("写入隔离迁移版本失败:%v", err)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func executeMigration(t *testing.T, db *gorm.DB, name string) {
|
||||
t.Helper()
|
||||
if err := executeMigrationWithError(db, name); err != nil {
|
||||
t.Fatalf("执行迁移 %s 失败:%v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeMigrationWithError(db *gorm.DB, name string) error {
|
||||
path := filepath.Join("..", "..", "..", "migrations", name)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range splitSQLStatements(string(content)) {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQLStatements(content string) []string {
|
||||
statements := make([]string, 0)
|
||||
start := 0
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
inDollarBlock := false
|
||||
for index := 0; index < len(content); index++ {
|
||||
if index+1 < len(content) && content[index:index+2] == "$$" && !inSingleQuote && !inDoubleQuote {
|
||||
inDollarBlock = !inDollarBlock
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if inDollarBlock {
|
||||
continue
|
||||
}
|
||||
switch content[index] {
|
||||
case '\'':
|
||||
if !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
}
|
||||
case '"':
|
||||
if !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
}
|
||||
case ';':
|
||||
if !inSingleQuote && !inDoubleQuote {
|
||||
statement := strings.TrimSpace(content[start : index+1])
|
||||
if statement != "" {
|
||||
statements = append(statements, statement)
|
||||
}
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if tail := strings.TrimSpace(content[start:]); tail != "" {
|
||||
statements = append(statements, tail)
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
func hasFinding(report releasegate.Report, code string) bool {
|
||||
for _, finding := range report.Findings {
|
||||
if finding.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user