From 57f1dab894c5397625bacf7323f2630ae565935c Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 31 Aug 2026 17:56:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8E=86=E5=8F=B2=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=85=BC=E5=AE=B9=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fe/src/views/Setup/Setup.css | 25 +++++++- server/db/migration/migration.go | 45 +++++++++++-- server/db/migration/registry.go | 5 +- server/db/migration/registry_test.go | 78 +++++++++++++++++++++++ server/db/migration/runner.go | 2 +- server/db/migration/runner_test.go | 50 +++++++++++++++ server/db/migration/schema.go | 16 ++--- server/services/setup/db.go | 10 ++- server/services/setup/db_database_test.go | 25 ++++++++ 9 files changed, 235 insertions(+), 21 deletions(-) diff --git a/fe/src/views/Setup/Setup.css b/fe/src/views/Setup/Setup.css index 461437d6..9957457a 100644 --- a/fe/src/views/Setup/Setup.css +++ b/fe/src/views/Setup/Setup.css @@ -12,6 +12,8 @@ } .desc { + flex: 1 1 320px; + min-width: 0; padding-right: 20px; margin-bottom: 18px; } @@ -95,10 +97,15 @@ } .database-form { + flex: 1 1 620px; width: min(100%, 620px); + min-width: 520px; + max-width: 620px; } -.database-form .el-form { +.database-form .el-form, +.database-form :deep(.el-select), +.database-form :deep(.el-input) { width: 100%; } @@ -129,7 +136,7 @@ .database-inline-fields { display: grid; - grid-template-columns: minmax(0, 1fr) 180px; + grid-template-columns: minmax(260px, 1fr) minmax(170px, 0.55fr); gap: 16px; } @@ -137,6 +144,14 @@ min-width: 0; } +.database-host-field :deep(.el-form-item__label) { + width: 88px !important; +} + +.database-port-field :deep(.el-form-item__label) { + width: 56px !important; +} + .database-host-field, .database-port-field { margin-bottom: 18px; @@ -628,6 +643,12 @@ padding: 18px; } + .database-form { + width: 100%; + min-width: 0; + max-width: none; + } + .database-inline-fields { grid-template-columns: 1fr; gap: 0; diff --git a/server/db/migration/migration.go b/server/db/migration/migration.go index 742fee6c..1839d8fa 100644 --- a/server/db/migration/migration.go +++ b/server/db/migration/migration.go @@ -10,12 +10,13 @@ import ( ) type Migration struct { - Version int64 - Name string - Statements []string - DialectStatements map[Dialect][]string - ChecksumSeed string - Apply func(context.Context, executor, Dialect) error + Version int64 + Name string + Statements []string + DialectStatements map[Dialect][]string + ChecksumSeed string + Apply func(context.Context, executor, Dialect) error + CompatibleChecksums []string } func Define(version int64, name string, statements ...string) Migration { @@ -47,6 +48,26 @@ func (m Migration) StatementsFor(dialect Dialect) []string { return append([]string(nil), m.Statements...) } +func WithCompatibleChecksums(migration Migration, checksums ...string) Migration { + migration.CompatibleChecksums = make([]string, len(checksums)) + for index, checksum := range checksums { + migration.CompatibleChecksums[index] = strings.TrimSpace(checksum) + } + return migration +} + +func (m Migration) AcceptsChecksum(checksum string) bool { + if checksum == m.Checksum() { + return true + } + for _, compatible := range m.CompatibleChecksums { + if checksum == compatible { + return true + } + } + return false +} + func (m Migration) Checksum() string { hash := sha256.New() _, _ = fmt.Fprintf(hash, "%d\x00%s\x00", m.Version, m.Name) @@ -87,6 +108,18 @@ func normalize(migrations []Migration) ([]Migration, error) { if item.Version <= 0 || item.Name == "" || !hasStatements(item) { return nil, fmt.Errorf("invalid migration definition at index %d", i) } + knownChecksums := map[string]struct{}{item.Checksum(): {}} + for _, checksum := range item.CompatibleChecksums { + checksum = strings.TrimSpace(checksum) + decoded, decodeErr := hex.DecodeString(checksum) + if decodeErr != nil || len(decoded) != sha256.Size { + return nil, fmt.Errorf("migration %d contains an invalid compatible checksum", item.Version) + } + if _, exists := knownChecksums[checksum]; exists { + return nil, fmt.Errorf("migration %d contains a duplicate compatible checksum", item.Version) + } + knownChecksums[checksum] = struct{}{} + } for _, statement := range item.Statements { if strings.TrimSpace(statement) == "" { return nil, fmt.Errorf("migration %d contains an empty statement", item.Version) diff --git a/server/db/migration/registry.go b/server/db/migration/registry.go index 0c038070..8fa919b2 100644 --- a/server/db/migration/registry.go +++ b/server/db/migration/registry.go @@ -2,7 +2,10 @@ package migration func DefaultMigrations() []Migration { return []Migration{ - DefineForDialects(1, "create_core_schema", coreSchemaStatements()), + WithCompatibleChecksums( + DefineForDialects(1, "create_core_schema", coreSchemaStatements()), + "57b378b273f243bae44e92cad161f297248e5226e96fbd7905815aa8604146bc", + ), DefineCustom(2, "expand_user_security", "expand_user_security_v1", applyUserSecuritySchema), DefineForDialects(3, "create_security_email_tables", securityEmailStatements()), DefineForDialects(4, "create_account_recovery_tables", credentialStatements()), diff --git a/server/db/migration/registry_test.go b/server/db/migration/registry_test.go index 2332a9ab..713942e6 100644 --- a/server/db/migration/registry_test.go +++ b/server/db/migration/registry_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "strings" "testing" + "time" ) func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) { @@ -198,3 +199,80 @@ func TestDefaultMigrationsAreContiguousAndDialectComplete(t *testing.T) { t.Fatalf("default migrations failed normalization: %v", err) } } + +func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) { + expected := map[int64]string{ + 1: "bb9bbf1ef17f1430900b8c5bed3539d01f72d49bdfedc2528dd3cf089b2a00d8", + 2: "b91524dc66ba86962372368ed7254e0c33eda6f76d487d6d3d291574cbf41345", + 3: "675251af1f5cd5d03f645c4b4ac15db2b488f8a3c9a972e836a136450da67c52", + 4: "30d34ff976ada3fec17173556f1b3a2798e03db6bce9fc93df6bd370f9044e7c", + 5: "678da8a46273d17e5f85a12c6c3ca8d254af25ccd3c48245b11d8f80e1dba2c3", + 6: "95b0ca429b6d237b1c905e8b5756363a242ead7fc0cf53783199d3e9ac317dc8", + 7: "ae1e63c75bf6bde508d8711689db2d5a36f6619885fb3b0a28c1f26b9710143d", + 8: "6c0697b86c9a57e5dc5b1a38bf18dc1893d850a62229c95134d931b6f4a9d59a", + 9: "b600b7bd4d7f0aa72ca12f261d2019bc419500b6fde099bba7c3ccf0448f5860", + 10: "701167503d518151ee73ff2ba32f561194538cc46f630551b50b36a483c77c12", + 11: "b7203bbb965b5e5760704ff495c58a8938e97c52ac068e2b0e7c522a2874b3ed", + 12: "b746b89a15262ecedacf8725148d77be22caa57e4dfb60a209854ecdec98382b", + 13: "c51604f27347d0a1b93acad1e33af53463fceb7b6573960f881f95f29c5628ed", + 14: "531398c2b8fc83a65753fae08666946bb253ac3da6596829e71cc8ccc4ddf62f", + } + migrations := DefaultMigrations() + if len(migrations) != len(expected) { + t.Fatalf("migration count = %d, want %d", len(migrations), len(expected)) + } + if got := migrations[0].CompatibleChecksums; len(got) != 1 || got[0] != "57b378b273f243bae44e92cad161f297248e5226e96fbd7905815aa8604146bc" { + t.Fatalf("migration 1 compatible checksums = %v", got) + } + for _, item := range migrations { + want, ok := expected[item.Version] + if !ok { + t.Fatalf("migration version %d is not registered", item.Version) + } + if got := item.Checksum(); got != want { + t.Fatalf("migration %d (%s) checksum = %s, want %s; create a new migration instead of changing an applied migration", item.Version, item.Name, got, want) + } + } +} + +func TestDefaultMigrationsResumePublishedVersionOne(t *testing.T) { + migrations := DefaultMigrations() + published := migrations[0] + checksums := append([]string{published.Checksum()}, published.CompatibleChecksums...) + for _, checksum := range checksums { + checksum := checksum + t.Run(checksum[:8], func(t *testing.T) { + database := openSQLite(t) + for _, statement := range published.StatementsFor(DialectSQLite) { + if _, err := database.Exec(statement); err != nil { + t.Fatal(err) + } + } + ddl, err := schemaTableDDL(DialectSQLite) + if err != nil { + t.Fatal(err) + } + if _, err = database.Exec(ddl); err != nil { + t.Fatal(err) + } + if _, err = database.Exec( + `INSERT INTO schema_migration(version, name, checksum, applied_at, execution_ms) VALUES(?, ?, ?, ?, ?)`, + published.Version, + published.Name, + checksum, + time.Now().UTC(), + 0, + ); err != nil { + t.Fatal(err) + } + + report, err := (Runner{DB: database, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if report.CurrentVersion != int64(len(migrations)) || len(report.Applied) != len(migrations)-1 { + t.Fatalf("report = %+v", report) + } + }) + } +} diff --git a/server/db/migration/runner.go b/server/db/migration/runner.go index 06b505a7..1d33efe9 100644 --- a/server/db/migration/runner.go +++ b/server/db/migration/runner.go @@ -257,7 +257,7 @@ func verifyApplied(applied map[int64]Applied, migrations []Migration) error { if !ok { return fmt.Errorf("%w: version %d", ErrUnknownAppliedMigration, version) } - if stored.Name != item.Name || stored.Checksum != item.Checksum() { + if stored.Name != item.Name || !item.AcceptsChecksum(stored.Checksum) { return fmt.Errorf("%w: version %d", ErrChecksumMismatch, version) } } diff --git a/server/db/migration/runner_test.go b/server/db/migration/runner_test.go index 90306b5a..bfc3a5c4 100644 --- a/server/db/migration/runner_test.go +++ b/server/db/migration/runner_test.go @@ -54,6 +54,44 @@ func TestSQLiteChecksumConflict(t *testing.T) { } } +func TestSQLiteCompatibleChecksumAccepted(t *testing.T) { + db := openSQLite(t) + migrations := testMigrations() + if _, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background()); err != nil { + t.Fatal(err) + } + + const compatibleChecksum = "1111111111111111111111111111111111111111111111111111111111111111" + if _, err := db.Exec("UPDATE schema_migration SET checksum = ? WHERE version = 1", compatibleChecksum); err != nil { + t.Fatal(err) + } + migrations[0] = WithCompatibleChecksums(migrations[0], compatibleChecksum) + report, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background()) + if err != nil { + t.Fatal(err) + } + if report.CurrentVersion != 2 || len(report.Applied) != 0 { + t.Fatalf("report = %+v", report) + } +} + +func TestSQLiteCompatibleChecksumStillRequiresMatchingName(t *testing.T) { + db := openSQLite(t) + migrations := testMigrations() + if _, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background()); err != nil { + t.Fatal(err) + } + + const compatibleChecksum = "2222222222222222222222222222222222222222222222222222222222222222" + if _, err := db.Exec("UPDATE schema_migration SET name = 'changed', checksum = ? WHERE version = 1", compatibleChecksum); err != nil { + t.Fatal(err) + } + migrations[0] = WithCompatibleChecksums(migrations[0], compatibleChecksum) + _, err := (Runner{DB: db, Dialect: DialectSQLite, Migrations: migrations}).Run(context.Background()) + if !errors.Is(err, ErrChecksumMismatch) { + t.Fatalf("error = %v, want checksum mismatch", err) + } +} func TestSQLiteFailedMigrationRollsBack(t *testing.T) { db := openSQLite(t) runner := Runner{ @@ -142,6 +180,18 @@ func TestNormalizeRejectsInvalidDefinitions(t *testing.T) { } } +func TestNormalizeRejectsInvalidCompatibleChecksums(t *testing.T) { + base := Define(1, "create_sample", "CREATE TABLE sample (id INTEGER PRIMARY KEY)") + migrations := [][]Migration{ + {WithCompatibleChecksums(base, "invalid")}, + {WithCompatibleChecksums(base, base.Checksum())}, + } + for index, items := range migrations { + if _, err := normalize(items); err == nil { + t.Fatalf("case %d accepted", index) + } + } +} func testMigrations() []Migration { return []Migration{ Define(1, "create_sample", "CREATE TABLE sample (id INTEGER PRIMARY KEY, value TEXT NOT NULL)"), diff --git a/server/db/migration/schema.go b/server/db/migration/schema.go index 3bcd4baa..ea578b70 100644 --- a/server/db/migration/schema.go +++ b/server/db/migration/schema.go @@ -9,39 +9,35 @@ import ( func coreSchemaStatements() map[Dialect][]string { return map[Dialect][]string{ DialectSQLite: { - `CREATE TABLE IF NOT EXISTS "user" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "account" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL, "password" TEXT NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" TEXT NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1, "security_version" INTEGER NOT NULL DEFAULT 1, "must_change_password" INTEGER NOT NULL DEFAULT 0, "password_changed_at" DATETIME NULL)`, + `CREATE TABLE IF NOT EXISTS "user" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "account" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL, "password" TEXT NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" TEXT NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1)`, `CREATE TABLE IF NOT EXISTS "email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "type" INTEGER NOT NULL DEFAULT 0, "subject" TEXT NOT NULL DEFAULT '', "reply_to" TEXT NULL, "from_name" TEXT NOT NULL DEFAULT '', "from_address" TEXT NOT NULL DEFAULT '', "to" TEXT NULL, "bcc" TEXT NULL, "cc" TEXT NULL, "text" TEXT NULL, "html" TEXT NULL, "sender" TEXT NULL, "attachments" TEXT NULL, "spf_check" INTEGER NOT NULL DEFAULT 0, "dkim_check" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "cron_send_time" DATETIME NULL, "update_time" DATETIME NULL, "send_user_id" INTEGER NOT NULL DEFAULT 0, "size" INTEGER NOT NULL DEFAULT 1000, "error" TEXT NULL, "send_date" DATETIME NULL, "create_time" DATETIME NULL, "msg_id" TEXT NOT NULL DEFAULT '')`, `CREATE TABLE IF NOT EXISTS "group" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL DEFAULT '', "parent_id" INTEGER NOT NULL DEFAULT 0, "user_id" INTEGER NOT NULL DEFAULT 0, "full_path" TEXT NULL)`, `CREATE TABLE IF NOT EXISTS "rule" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL DEFAULT 0, "name" TEXT NOT NULL DEFAULT '', "value" TEXT NULL, "action" INTEGER NOT NULL DEFAULT 0, "params" TEXT NOT NULL DEFAULT '', "sort" INTEGER NOT NULL DEFAULT 0)`, `CREATE TABLE IF NOT EXISTS "sessions" ("token" TEXT PRIMARY KEY, "data" BLOB NOT NULL, "expiry" REAL NOT NULL)`, `CREATE INDEX IF NOT EXISTS "idx_sessions_expiry" ON "sessions"("expiry")`, - `CREATE TABLE IF NOT EXISTS "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "address" TEXT NULL, "address_canonical" TEXT NULL, "is_primary" INTEGER NOT NULL DEFAULT 0, "verified_at" DATETIME NULL, "created_at" DATETIME NULL, "updated_at" DATETIME NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`, + `CREATE TABLE IF NOT EXISTS "user_email" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "user_id" INTEGER NOT NULL, "email_id" INTEGER NOT NULL, "is_read" INTEGER NOT NULL DEFAULT 0, "group_id" INTEGER NOT NULL DEFAULT 0, "status" INTEGER NOT NULL DEFAULT 0, "create" DATETIME NULL)`, `CREATE INDEX IF NOT EXISTS "idx_eid" ON "user_email"("user_id", "email_id")`, - `CREATE UNIQUE INDEX IF NOT EXISTS "uq_user_email_address_canonical" ON "user_email"("address_canonical")`, - `CREATE INDEX IF NOT EXISTS "idx_user_email_user_primary" ON "user_email"("user_id", "is_primary")`, `CREATE INDEX IF NOT EXISTS "idx_create_time" ON "user_email"("create")`, `CREATE TABLE IF NOT EXISTS "version" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "info" TEXT NOT NULL)`, }, DialectMySQL: { - "CREATE TABLE IF NOT EXISTS `user` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `account` VARCHAR(20) NOT NULL, `name` VARCHAR(10) NOT NULL, `password` VARCHAR(255) NOT NULL, `disabled` INT UNSIGNED NOT NULL DEFAULT 0, `role` VARCHAR(20) NOT NULL DEFAULT 'user', `is_admin` INT UNSIGNED NOT NULL DEFAULT 0, `session_version` INT UNSIGNED NOT NULL DEFAULT 1, `security_version` INT UNSIGNED NOT NULL DEFAULT 1, `must_change_password` INT UNSIGNED NOT NULL DEFAULT 0, `password_changed_at` DATETIME(6) NULL, UNIQUE KEY `uq_user_account` (`account`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `user` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `account` VARCHAR(20) NOT NULL, `name` VARCHAR(10) NOT NULL, `password` VARCHAR(255) NOT NULL, `disabled` INT UNSIGNED NOT NULL DEFAULT 0, `role` VARCHAR(20) NOT NULL DEFAULT 'user', `is_admin` INT UNSIGNED NOT NULL DEFAULT 0, `session_version` INT UNSIGNED NOT NULL DEFAULT 1, UNIQUE KEY `uq_user_account` (`account`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `type` TINYINT NOT NULL DEFAULT 0, `subject` VARCHAR(1000) NOT NULL DEFAULT '', `reply_to` TEXT NULL, `from_name` VARCHAR(50) NOT NULL DEFAULT '', `from_address` VARCHAR(100) NOT NULL DEFAULT '', `to` TEXT NULL, `bcc` TEXT NULL, `cc` TEXT NULL, `text` TEXT NULL, `html` MEDIUMTEXT NULL, `sender` TEXT NULL, `attachments` LONGTEXT NULL, `spf_check` TINYINT NOT NULL DEFAULT 0, `dkim_check` TINYINT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `cron_send_time` DATETIME(6) NULL, `update_time` DATETIME(6) NULL, `send_user_id` INT UNSIGNED NOT NULL DEFAULT 0, `size` INT UNSIGNED NOT NULL DEFAULT 1000, `error` TEXT NULL, `send_date` DATETIME(6) NULL, `create_time` DATETIME(6) NULL, `msg_id` VARCHAR(255) NOT NULL DEFAULT '') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `group` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(10) NOT NULL DEFAULT '', `parent_id` INT UNSIGNED NOT NULL DEFAULT 0, `user_id` INT UNSIGNED NOT NULL DEFAULT 0, `full_path` VARCHAR(600) NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `rule` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL DEFAULT 0, `name` VARCHAR(255) NOT NULL DEFAULT '', `value` TEXT NULL, `action` INT NOT NULL DEFAULT 0, `params` VARCHAR(255) NOT NULL DEFAULT '', `sort` INT NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `sessions` (`token` CHAR(43) NOT NULL PRIMARY KEY, `data` BLOB NOT NULL, `expiry` TIMESTAMP(6) NOT NULL, KEY `idx_sessions_expiry` (`expiry`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", - "CREATE TABLE IF NOT EXISTS `user_email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `email_id` INT NOT NULL, `address` VARCHAR(254) NULL, `address_canonical` VARCHAR(254) NULL, `is_primary` TINYINT NOT NULL DEFAULT 0, `verified_at` DATETIME(6) NULL, `created_at` DATETIME(6) NULL, `updated_at` DATETIME(6) NULL, `is_read` TINYINT NOT NULL DEFAULT 0, `group_id` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `create` DATETIME(6) NULL, KEY `idx_eid` (`user_id`, `email_id`), UNIQUE KEY `uq_user_email_address_canonical` (`address_canonical`), KEY `idx_user_email_user_primary` (`user_id`, `is_primary`), KEY `idx_create_time` (`create`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", + "CREATE TABLE IF NOT EXISTS `user_email` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `email_id` INT NOT NULL, `is_read` TINYINT NOT NULL DEFAULT 0, `group_id` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 0, `create` DATETIME(6) NULL, KEY `idx_eid` (`user_id`, `email_id`), KEY `idx_create_time` (`create`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", "CREATE TABLE IF NOT EXISTS `version` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, `info` VARCHAR(255) NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", }, DialectPostgres: { - `CREATE TABLE IF NOT EXISTS "user" ("id" BIGSERIAL PRIMARY KEY, "account" VARCHAR(20) NOT NULL UNIQUE, "name" VARCHAR(10) NOT NULL, "password" VARCHAR(255) NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" VARCHAR(20) NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1, "security_version" INTEGER NOT NULL DEFAULT 1, "must_change_password" INTEGER NOT NULL DEFAULT 0, "password_changed_at" TIMESTAMP NULL)`, + `CREATE TABLE IF NOT EXISTS "user" ("id" BIGSERIAL PRIMARY KEY, "account" VARCHAR(20) NOT NULL UNIQUE, "name" VARCHAR(10) NOT NULL, "password" VARCHAR(255) NOT NULL, "disabled" INTEGER NOT NULL DEFAULT 0, "role" VARCHAR(20) NOT NULL DEFAULT 'user', "is_admin" INTEGER NOT NULL DEFAULT 0, "session_version" INTEGER NOT NULL DEFAULT 1)`, `CREATE TABLE IF NOT EXISTS "email" ("id" BIGSERIAL PRIMARY KEY, "type" SMALLINT NOT NULL DEFAULT 0, "subject" VARCHAR(1000) NOT NULL DEFAULT '', "reply_to" TEXT NULL, "from_name" VARCHAR(50) NOT NULL DEFAULT '', "from_address" VARCHAR(100) NOT NULL DEFAULT '', "to" TEXT NULL, "bcc" TEXT NULL, "cc" TEXT NULL, "text" TEXT NULL, "html" TEXT NULL, "sender" TEXT NULL, "attachments" TEXT NULL, "spf_check" SMALLINT NOT NULL DEFAULT 0, "dkim_check" SMALLINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "cron_send_time" TIMESTAMPTZ NULL, "update_time" TIMESTAMPTZ NULL, "send_user_id" BIGINT NOT NULL DEFAULT 0, "size" BIGINT NOT NULL DEFAULT 1000, "error" TEXT NULL, "send_date" TIMESTAMPTZ NULL, "create_time" TIMESTAMPTZ NULL, "msg_id" VARCHAR(255) NOT NULL DEFAULT '')`, `CREATE TABLE IF NOT EXISTS "group" ("id" BIGSERIAL PRIMARY KEY, "name" VARCHAR(10) NOT NULL DEFAULT '', "parent_id" BIGINT NOT NULL DEFAULT 0, "user_id" BIGINT NOT NULL DEFAULT 0, "full_path" VARCHAR(600) NULL)`, `CREATE TABLE IF NOT EXISTS "rule" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL DEFAULT 0, "name" VARCHAR(255) NOT NULL DEFAULT '', "value" TEXT NULL, "action" INTEGER NOT NULL DEFAULT 0, "params" VARCHAR(255) NOT NULL DEFAULT '', "sort" INTEGER NOT NULL DEFAULT 0)`, `CREATE TABLE IF NOT EXISTS "sessions" ("token" VARCHAR(43) PRIMARY KEY, "data" BYTEA NOT NULL, "expiry" TIMESTAMPTZ NOT NULL)`, `CREATE INDEX IF NOT EXISTS "idx_sessions_expiry" ON "sessions"("expiry")`, - `CREATE TABLE IF NOT EXISTS "user_email" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email_id" BIGINT NOT NULL, "address" VARCHAR(254) NULL, "address_canonical" VARCHAR(254) NULL, "is_primary" SMALLINT NOT NULL DEFAULT 0, "verified_at" TIMESTAMPTZ NULL, "created_at" TIMESTAMPTZ NULL, "updated_at" TIMESTAMPTZ NULL, "is_read" SMALLINT NOT NULL DEFAULT 0, "group_id" BIGINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "create" TIMESTAMPTZ NULL)`, + `CREATE TABLE IF NOT EXISTS "user_email" ("id" BIGSERIAL PRIMARY KEY, "user_id" BIGINT NOT NULL, "email_id" BIGINT NOT NULL, "is_read" SMALLINT NOT NULL DEFAULT 0, "group_id" BIGINT NOT NULL DEFAULT 0, "status" SMALLINT NOT NULL DEFAULT 0, "create" TIMESTAMPTZ NULL)`, `CREATE INDEX IF NOT EXISTS "idx_eid" ON "user_email"("user_id", "email_id")`, - `CREATE UNIQUE INDEX IF NOT EXISTS "uq_user_email_address_canonical" ON "user_email"("address_canonical")`, - `CREATE INDEX IF NOT EXISTS "idx_user_email_user_primary" ON "user_email"("user_id", "is_primary")`, `CREATE INDEX IF NOT EXISTS "idx_create_time" ON "user_email"("create")`, `CREATE TABLE IF NOT EXISTS "version" ("id" BIGSERIAL PRIMARY KEY, "info" VARCHAR(255) NOT NULL)`, }, diff --git a/server/services/setup/db.go b/server/services/setup/db.go index 4e59343d..df2d1094 100644 --- a/server/services/setup/db.go +++ b/server/services/setup/db.go @@ -16,6 +16,7 @@ import ( "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/db/migration" "git.pcux.cn/xuans/Smail/models" "git.pcux.cn/xuans/Smail/services/auth" "git.pcux.cn/xuans/Smail/utils/array" @@ -58,6 +59,13 @@ func databaseError(message string, cause error) error { return &databaseSetupError{Message: message, Cause: cause} } +func databaseInitializationError(err error) error { + if stderrors.Is(err, migration.ErrChecksumMismatch) || stderrors.Is(err, migration.ErrUnknownAppliedMigration) { + return databaseError("数据库连接成功,但检测到历史迁移记录与当前版本不兼容,请先备份数据库后再执行兼容升级", err) + } + return errors.Wrap(err) +} + func DatabaseSetupErrorMessage(err error) string { var setupErr *databaseSetupError if errors.As(err, &setupErr) { @@ -265,7 +273,7 @@ func SetDatabaseSettingsWithOptions(ctx *context.Context, settings DatabaseSetti closeDatabaseInstance() if err = db.Init(""); err != nil { restoreDatabaseConfig(&previousConfig) - return errors.Wrap(err) + return databaseInitializationError(err) } return nil } diff --git a/server/services/setup/db_database_test.go b/server/services/setup/db_database_test.go index df4e9a50..c8a466bf 100644 --- a/server/services/setup/db_database_test.go +++ b/server/services/setup/db_database_test.go @@ -11,6 +11,7 @@ import ( "time" "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db/migration" "github.com/go-sql-driver/mysql" "github.com/lib/pq" ) @@ -369,3 +370,27 @@ func TestPrepareSQLiteDatabaseCreatesParentDirectory(t *testing.T) { t.Fatalf("SQLite parent directory was not prepared: %v", err) } } + +func TestDatabaseInitializationErrorReturnsMigrationGuidance(t *testing.T) { + for _, cause := range []error{migration.ErrChecksumMismatch, migration.ErrUnknownAppliedMigration} { + err := databaseInitializationError(cause) + message := DatabaseSetupErrorMessage(err) + if message != "数据库连接成功,但检测到历史迁移记录与当前版本不兼容,请先备份数据库后再执行兼容升级" { + t.Fatalf("message = %q", message) + } + if !stderrors.Is(err, cause) { + t.Fatalf("error does not preserve cause %v", cause) + } + } +} + +func TestDatabaseInitializationErrorKeepsGenericErrorsInternal(t *testing.T) { + cause := stderrors.New("internal database detail") + err := databaseInitializationError(cause) + if message := DatabaseSetupErrorMessage(err); message != "" { + t.Fatalf("unexpected user-facing message = %q", message) + } + if !stderrors.Is(err, cause) { + t.Fatal("generic initialization error does not preserve its cause") + } +}