chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线 - 保留迁移、数据保留、AWS 账单与部署安全能力 - 建立 SDD 规格和 CI 验证闭环
This commit is contained in:
commit
fd60361d93
460 files changed
+37492
-23871
No files matched your search
@@ -0,0 +1,15 @@
|
||||
-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li
|
||||
CREATE TABLE IF NOT EXISTS monitor_agent_credentials (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
hostname VARCHAR(255) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL UNIQUE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
issued_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
last_used_at DATETIME NULL,
|
||||
expires_at DATETIME NULL,
|
||||
rotated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
INDEX idx_monitor_agent_credentials_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li
|
||||
CREATE TABLE IF NOT EXISTS monitor_agent_credentials (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
hostname VARCHAR(255) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL UNIQUE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
issued_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
rotated_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_agent_credentials_status ON monitor_agent_credentials(status);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li
|
||||
CREATE TABLE IF NOT EXISTS monitor_agent_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname VARCHAR(255) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL UNIQUE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
issued_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
last_used_at DATETIME,
|
||||
expires_at DATETIME,
|
||||
rotated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_agent_credentials_status ON monitor_agent_credentials(status);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Agent 主机绑定凭证:只保存 bcrypt 哈希,明文 Token 仅在签发时出现。 // Logan.Li
|
||||
CREATE TABLE IF NOT EXISTS monitor_agent_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname VARCHAR(255) NOT NULL UNIQUE,
|
||||
token_prefix VARCHAR(32) NOT NULL UNIQUE,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
issued_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
last_used_at DATETIME,
|
||||
expires_at DATETIME,
|
||||
rotated_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_agent_credentials_status ON monitor_agent_credentials(status);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 云账单轻量汇率设置,只维护 USD/CNY。 // Logan.Li
|
||||
CREATE TABLE IF NOT EXISTS asset_cloud_billing_settings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
usd_cny_rate DECIMAL(12,6) NOT NULL DEFAULT 7.2,
|
||||
updated_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 云账单轻量汇率设置,只维护 USD/CNY。 // Logan.Li
|
||||
CREATE TABLE IF NOT EXISTS asset_cloud_billing_settings (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
usd_cny_rate DECIMAL(12,6) NOT NULL DEFAULT 7.2,
|
||||
updated_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 云账单轻量汇率设置,只维护 USD/CNY。 // Logan.Li
|
||||
CREATE TABLE IF NOT EXISTS asset_cloud_billing_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
usd_cny_rate DECIMAL(12,6) NOT NULL DEFAULT 7.2,
|
||||
updated_by VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
// Package migrations 提供仅由命令行显式触发的版本化数据库迁移。 // Logan.Li
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/pkg/database"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// BaselineVersion 标识当前模型集合的初始基线,不应在已有库上自动执行。 // Logan.Li
|
||||
BaselineVersion = "0000_baseline_schema"
|
||||
migrationTable = "sys_schema_migrations"
|
||||
)
|
||||
|
||||
//go:embed *.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
// Migration 是一个不可变、更名即视为兼容性破坏的数据库变更单元。 // Logan.Li
|
||||
type Migration struct {
|
||||
Version string
|
||||
Description string
|
||||
Apply func(*gorm.DB) error
|
||||
}
|
||||
|
||||
// StatusRow 描述一个迁移版本的执行状态。 // Logan.Li
|
||||
type StatusRow struct {
|
||||
Version string
|
||||
Description string
|
||||
Applied bool
|
||||
AppliedAt *time.Time
|
||||
}
|
||||
|
||||
// All 返回按版本排序的所有迁移。新迁移只能追加,禁止修改已发布版本。 // Logan.Li
|
||||
func All() []Migration {
|
||||
return []Migration{
|
||||
{
|
||||
Version: BaselineVersion,
|
||||
Description: "新数据库初始结构基线",
|
||||
Apply: database.BootstrapSchema,
|
||||
},
|
||||
{
|
||||
Version: "0001_agent_credentials",
|
||||
Description: "Agent 主机绑定凭证",
|
||||
Apply: func(db *gorm.DB) error {
|
||||
return executeDialectSQL(db, "0001_agent_credentials")
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0002_legacy_schema_patches",
|
||||
Description: "历史库字段兼容补丁",
|
||||
Apply: database.ApplyLegacySchemaPatches,
|
||||
},
|
||||
{
|
||||
Version: "0003_billing_settings",
|
||||
Description: "云账单汇率设置",
|
||||
Apply: func(db *gorm.DB) error {
|
||||
return executeDialectSQL(db, "0003_billing_settings")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Up 执行尚未应用的迁移。已有业务表但未完成基线登记时拒绝执行,防止误触发结构重建。 // Logan.Li
|
||||
func Up(db *gorm.DB) ([]string, error) {
|
||||
// 旧业务库缺少迁移记录表时必须零写入拒绝,避免误操作留下半初始化痕迹。 // Logan.Li
|
||||
if hasExistingApplicationSchema(db) && !db.Migrator().HasTable(migrationTable) {
|
||||
return nil, fmt.Errorf("检测到已有业务库但缺少 %s;请在备份与维护窗口确认后执行 `aiops migrate baseline`", migrationTable)
|
||||
}
|
||||
if err := ensureMigrationTable(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applied, err := appliedVersions(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, baselineApplied := applied[BaselineVersion]; !baselineApplied && hasExistingApplicationSchema(db) {
|
||||
return nil, fmt.Errorf("检测到已有业务库但未登记 %s;请在备份与维护窗口确认后执行 `aiops migrate baseline`", BaselineVersion)
|
||||
}
|
||||
|
||||
completed := make([]string, 0)
|
||||
for _, migration := range All() {
|
||||
if _, alreadyApplied := applied[migration.Version]; alreadyApplied {
|
||||
continue
|
||||
}
|
||||
if err := migration.Apply(db); err != nil {
|
||||
return completed, fmt.Errorf("迁移 %s(%s)失败: %w", migration.Version, migration.Description, err)
|
||||
}
|
||||
if err := recordApplied(db, migration.Version); err != nil {
|
||||
return completed, fmt.Errorf("登记迁移 %s 失败: %w", migration.Version, err)
|
||||
}
|
||||
completed = append(completed, migration.Version)
|
||||
}
|
||||
return completed, nil
|
||||
}
|
||||
|
||||
// Baseline 仅为已经存在且已人工核验的旧数据库写入初始基线记录,不修改业务表。 // Logan.Li
|
||||
func Baseline(db *gorm.DB) error {
|
||||
if err := ensureMigrationTable(db); err != nil {
|
||||
return err
|
||||
}
|
||||
applied, err := appliedVersions(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, baselineApplied := applied[BaselineVersion]; baselineApplied {
|
||||
return nil
|
||||
}
|
||||
if !hasExistingApplicationSchema(db) {
|
||||
return errors.New("数据库为空,不能执行 baseline;请改用 `aiops migrate up`")
|
||||
}
|
||||
if err := validateLegacyBaseline(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return recordApplied(db, BaselineVersion)
|
||||
}
|
||||
|
||||
// Status 返回每个已知迁移的状态;只读查询,记录表不存在时全部显示 pending。 // Logan.Li
|
||||
func Status(db *gorm.DB) ([]StatusRow, error) {
|
||||
applied := map[string]time.Time{}
|
||||
if db.Migrator().HasTable(migrationTable) {
|
||||
var err error
|
||||
applied, err = appliedVersions(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rows := make([]StatusRow, 0, len(All()))
|
||||
for _, migration := range All() {
|
||||
row := StatusRow{
|
||||
Version: migration.Version,
|
||||
Description: migration.Description,
|
||||
}
|
||||
if appliedAt, ok := applied[migration.Version]; ok {
|
||||
row.Applied = true
|
||||
row.AppliedAt = &appliedAt
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ensureMigrationTable(db *gorm.DB) error {
|
||||
sql := `CREATE TABLE IF NOT EXISTS sys_schema_migrations (
|
||||
version VARCHAR(128) PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL
|
||||
)`
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
sql = `CREATE TABLE IF NOT EXISTS sys_schema_migrations (
|
||||
version VARCHAR(128) PRIMARY KEY,
|
||||
applied_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
} else if db.Dialector.Name() == "postgres" {
|
||||
sql = `CREATE TABLE IF NOT EXISTS sys_schema_migrations (
|
||||
version VARCHAR(128) PRIMARY KEY,
|
||||
applied_at TIMESTAMP NOT NULL
|
||||
)`
|
||||
}
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("创建迁移记录表失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appliedVersions(db *gorm.DB) (map[string]time.Time, error) {
|
||||
type appliedMigration struct {
|
||||
Version string
|
||||
AppliedAt time.Time
|
||||
}
|
||||
var records []appliedMigration
|
||||
if err := db.Table(migrationTable).Select("version, applied_at").Find(&records).Error; err != nil {
|
||||
return nil, fmt.Errorf("读取迁移记录失败: %w", err)
|
||||
}
|
||||
result := make(map[string]time.Time, len(records))
|
||||
for _, record := range records {
|
||||
result[record.Version] = record.AppliedAt
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func recordApplied(db *gorm.DB, version string) error {
|
||||
return db.Exec("INSERT INTO sys_schema_migrations (version, applied_at) VALUES (?, ?)", version, time.Now()).Error
|
||||
}
|
||||
|
||||
func hasExistingApplicationSchema(db *gorm.DB) bool {
|
||||
return db.Migrator().HasTable("iam_users") || db.Migrator().HasTable("asset_servers")
|
||||
}
|
||||
|
||||
func validateLegacyBaseline(db *gorm.DB) error {
|
||||
requiredTables := []string{"iam_users", "iam_roles", "asset_servers", "deploy_pipelines", "monitor_agent_reports"}
|
||||
for _, table := range requiredTables {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
return fmt.Errorf("旧库缺少基线校验表 %s;请先恢复完整数据库或编写专项迁移", table)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeDialectSQL(db *gorm.DB, prefix string) error {
|
||||
files, err := fs.Glob(migrationFiles, prefix+".*.sql")
|
||||
if err != nil {
|
||||
return fmt.Errorf("查找迁移脚本失败: %w", err)
|
||||
}
|
||||
sort.Strings(files)
|
||||
dialect := db.Dialector.Name()
|
||||
fileName := prefix + "." + dialect + ".sql"
|
||||
if !contains(files, fileName) {
|
||||
return fmt.Errorf("迁移 %s 不支持数据库驱动 %s", prefix, dialect)
|
||||
}
|
||||
content, err := migrationFiles.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取迁移脚本失败: %w", err)
|
||||
}
|
||||
statements := splitStatements(string(content))
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return fmt.Errorf("执行 %s 失败: %w", fileName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitStatements(content string) []string {
|
||||
parts := strings.Split(content, ";")
|
||||
statements := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
withoutComments := make([]string, 0)
|
||||
for _, line := range strings.Split(part, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "--") {
|
||||
withoutComments = append(withoutComments, line)
|
||||
}
|
||||
}
|
||||
if statement := strings.TrimSpace(strings.Join(withoutComments, "\n")); statement != "" {
|
||||
statements = append(statements, statement)
|
||||
}
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
func contains(items []string, expected string) bool {
|
||||
for _, item := range items {
|
||||
if item == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/database"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试数据库失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// TestUpInitializesFreshDatabase 验证全新数据库仅通过显式迁移完成初始化。 // Logan.Li
|
||||
func TestUpInitializesFreshDatabase(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
applied, err := Up(db)
|
||||
if err != nil {
|
||||
t.Fatalf("初始化迁移失败: %v", err)
|
||||
}
|
||||
if len(applied) != len(All()) {
|
||||
t.Fatalf("迁移数量不正确: got %d want %d", len(applied), len(All()))
|
||||
}
|
||||
if !db.Migrator().HasTable("monitor_agent_credentials") {
|
||||
t.Fatal("未创建 Agent 凭证表")
|
||||
}
|
||||
if !db.Migrator().HasTable("asset_cloud_billing_settings") {
|
||||
t.Fatal("未创建云账单设置表")
|
||||
}
|
||||
|
||||
applied, err = Up(db)
|
||||
if err != nil {
|
||||
t.Fatalf("重复执行迁移失败: %v", err)
|
||||
}
|
||||
if len(applied) != 0 {
|
||||
t.Fatalf("重复执行不应应用迁移: %v", applied)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaselineProtectsExistingDatabase 验证旧库未登记基线时不会被隐式重建。 // Logan.Li
|
||||
func TestBaselineProtectsExistingDatabase(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
if err := db.Exec("CREATE TABLE iam_users (id INTEGER PRIMARY KEY)").Error; err != nil {
|
||||
t.Fatalf("创建测试表失败: %v", err)
|
||||
}
|
||||
if _, err := Up(db); err == nil {
|
||||
t.Fatal("已有业务库未登记基线时应拒绝迁移")
|
||||
}
|
||||
if db.Migrator().HasTable(migrationTable) {
|
||||
t.Fatal("拒绝旧业务库迁移时不应创建迁移记录表")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusSupportsExistingMigrationTable 验证兼容线上已有的两列迁移记录表。 // Logan.Li
|
||||
func TestStatusSupportsExistingMigrationTable(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
if err := db.Exec("CREATE TABLE sys_schema_migrations (version TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("创建旧版迁移记录表失败: %v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_schema_migrations (version, applied_at) VALUES (?, ?)", "0001_agent_credentials", time.Now()).Error; err != nil {
|
||||
t.Fatalf("写入旧版迁移记录失败: %v", err)
|
||||
}
|
||||
rows, err := Status(db)
|
||||
if err != nil {
|
||||
t.Fatalf("读取兼容迁移状态失败: %v", err)
|
||||
}
|
||||
if !rows[1].Applied || rows[1].Version != "0001_agent_credentials" {
|
||||
t.Fatalf("未识别旧版 0001 迁移记录: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaselineDoesNotAlterProductionLikeSchema 验证旧库登记基线不修改既有业务结构,且已执行的 0001 不会重跑。 // Logan.Li
|
||||
func TestBaselineDoesNotAlterProductionLikeSchema(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
if err := database.BootstrapSchema(db); err != nil {
|
||||
t.Fatalf("创建生产库模拟结构失败: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.AgentCredential{}); err != nil {
|
||||
t.Fatalf("创建已上线 Agent 凭证表失败: %v", err)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE sys_schema_migrations (version TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("创建迁移记录表失败: %v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO sys_schema_migrations (version, applied_at) VALUES (?, ?)", "0001_agent_credentials", time.Now()).Error; err != nil {
|
||||
t.Fatalf("写入已执行迁移记录失败: %v", err)
|
||||
}
|
||||
|
||||
before := schemaDefinitions(t, db)
|
||||
if err := Baseline(db); err != nil {
|
||||
t.Fatalf("旧库登记基线失败: %v", err)
|
||||
}
|
||||
if after := schemaDefinitions(t, db); !sameStrings(before, after) {
|
||||
t.Fatalf("baseline 修改了既有业务表结构\nbefore=%v\nafter=%v", before, after)
|
||||
}
|
||||
|
||||
applied, err := Up(db)
|
||||
if err != nil {
|
||||
t.Fatalf("旧库执行后续迁移失败: %v", err)
|
||||
}
|
||||
if len(applied) != 2 || applied[0] != "0002_legacy_schema_patches" || applied[1] != "0003_billing_settings" {
|
||||
t.Fatalf("旧库不应重跑 0001,实际执行: %v", applied)
|
||||
}
|
||||
}
|
||||
|
||||
// schemaDefinitions 读取 SQLite 表定义,供基线非破坏性回归测试使用。 // Logan.Li
|
||||
func schemaDefinitions(t *testing.T, db *gorm.DB) []string {
|
||||
t.Helper()
|
||||
type schemaRow struct {
|
||||
Name string
|
||||
SQL string
|
||||
}
|
||||
var rows []schemaRow
|
||||
if err := db.Raw("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("读取表定义失败: %v", err)
|
||||
}
|
||||
definitions := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
definitions = append(definitions, row.Name+":"+row.SQL)
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
// sameStrings 比较排序后的结构定义,避免 SQLite 返回顺序影响断言。 // Logan.Li
|
||||
func sameStrings(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
sort.Strings(left)
|
||||
sort.Strings(right)
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user