修复历史迁移兼容和数据库页面布局
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-31 17:56:16 +08:00
parent 8390e27e6c
commit 57f1dab894
9 changed files with 235 additions and 21 deletions

View File

@ -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;

View File

@ -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)

View File

@ -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()),

View File

@ -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)
}
})
}
}

View File

@ -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)
}
}

View File

@ -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)"),

View File

@ -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)`,
},

View File

@ -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
}

View File

@ -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")
}
}