diff --git a/backend/services/setup/db.go b/backend/services/setup/db.go index 4efac5c3..1a785f4a 100644 --- a/backend/services/setup/db.go +++ b/backend/services/setup/db.go @@ -434,10 +434,16 @@ func prepareSQLiteDatabase(dsn string) error { return nil } directory := filepath.Dir(path) - if directory == "." || directory == "" { - return nil + if directory != "." && directory != "" { + if err := os.MkdirAll(directory, 0750); err != nil { + return err + } } - return os.MkdirAll(directory, 0750) + // 全新部署自动清空 SQLite 历史数据库文件及临时文件 + for _, ext := range []string{"", "-wal", "-shm"} { + _ = os.Remove(path + ext) + } + return nil } func buildMySQLDSN(settings DatabaseSettings, previousDSN string) (string, error) { @@ -582,6 +588,26 @@ func (database *sqlSetupDatabase) TableCountContext(ctx stdcontext.Context, quer return count, nil } +func (database *sqlSetupDatabase) TableNamesContext(ctx stdcontext.Context, query string) ([]string, error) { + rows, err := database.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + names = append(names, name) + } + if err := rows.Err(); err != nil { + return nil, err + } + return names, nil +} + var openMySQLSetupDatabase = func(dsn string) (setupDatabase, error) { database, err := sql.Open("mysql", dsn) if err != nil { @@ -616,7 +642,7 @@ func ensurePostgresDatabase(dsn string) error { } defer func() { _ = target.Close() }() if err = target.PingContext(requestContext); err == nil { - return requireEmptyExternalDatabase(target, requestContext, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") + return clearPostgresDatabase(target, requestContext) } else { var postgresErr *pq.Error if !stderrors.As(err, &postgresErr) || string(postgresErr.Code) != "3D000" { @@ -652,16 +678,45 @@ type setupDatabase interface { PingContext(stdcontext.Context) error ExecContext(stdcontext.Context, string, ...any) (sql.Result, error) TableCountContext(stdcontext.Context, string) (int, error) + TableNamesContext(stdcontext.Context, string) ([]string, error) Close() error } -func requireEmptyExternalDatabase(database setupDatabase, ctx stdcontext.Context, query string) error { - count, err := database.TableCountContext(ctx, query) +func clearPostgresDatabase(database setupDatabase, ctx stdcontext.Context) error { + count, err := database.TableCountContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')") if err != nil { - return databaseError("检查外部数据库是否为空失败", err) + return databaseError("检查 PostgreSQL 数据库表失败", err) } - if count > 0 { - return databaseError("全新部署要求外部数据库为空,请使用新数据库或先清理 Smail 专属数据库", nil) + if count == 0 { + return nil + } + if _, err := database.ExecContext(ctx, "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"); err != nil { + return databaseError("清空 PostgreSQL 数据库失败", err) + } + return nil +} + +func clearMySQLDatabase(database setupDatabase, ctx stdcontext.Context) error { + tableNames, err := database.TableNamesContext(ctx, "SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'") + if err != nil { + return databaseError("检查 MySQL 数据库表失败", err) + } + if len(tableNames) == 0 { + return nil + } + if _, err := database.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 0;"); err != nil { + return databaseError("清空 MySQL 数据库表失败", err) + } + defer func() { + _, _ = database.ExecContext(ctx, "SET FOREIGN_KEY_CHECKS = 1;") + }() + for _, tableName := range tableNames { + if !validMySQLIdentifier(tableName) { + continue + } + if _, err := database.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS `%s` CASCADE;", tableName)); err != nil { + return databaseError("清空 MySQL 数据库表失败", err) + } } return nil } @@ -684,7 +739,7 @@ func ensureMySQLDatabase(dsn string) error { } defer func() { _ = target.Close() }() if err = target.PingContext(requestContext); err == nil { - return requireEmptyExternalDatabase(target, requestContext, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()") + return clearMySQLDatabase(target, requestContext) } else { var mysqlErr *mysql.MySQLError if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1049 { diff --git a/backend/services/setup/db_database_test.go b/backend/services/setup/db_database_test.go index 65bbdda5..edeb2348 100644 --- a/backend/services/setup/db_database_test.go +++ b/backend/services/setup/db_database_test.go @@ -50,6 +50,8 @@ type fakeSetupDB struct { tableCount int tableCountErr error tableQueries []string + tableNames []string + tableNamesErr error closed bool } @@ -75,6 +77,14 @@ func (database *fakeSetupDB) TableCountContext(_ context.Context, query string) return database.tableCount, nil } +func (database *fakeSetupDB) TableNamesContext(_ context.Context, query string) ([]string, error) { + if database.tableNamesErr != nil { + return nil, database.tableNamesErr + } + database.tableQueries = append(database.tableQueries, query) + return database.tableNames, nil +} + func (database *fakeSetupDB) Close() error { database.closed = true return nil @@ -293,29 +303,31 @@ func TestEnsureMySQLDatabaseSkipsCreationWhenDatabaseExists(t *testing.T) { } } -func TestEnsureMySQLDatabaseRejectsExistingNonEmptyDatabase(t *testing.T) { - target := &fakeSetupDB{tableCount: 1} +func TestEnsureMySQLDatabaseClearsExistingNonEmptyDatabase(t *testing.T) { + target := &fakeSetupDB{tableNames: []string{"user", "mail"}} withMySQLSetupOpener(t, func(string) (setupDatabase, error) { return target, nil }) - if err := ensureMySQLDatabase(testMySQLDSN(t, "secret")); err == nil { - t.Fatal("non-empty MySQL database was accepted for a fresh deployment") - } else if message := DatabaseSetupErrorMessage(err); message == "" { - t.Fatal("fresh deployment error message was empty") + if err := ensureMySQLDatabase(testMySQLDSN(t, "secret")); err != nil { + t.Fatalf("expected non-empty MySQL database to be cleared successfully, got: %v", err) + } + if len(target.execQueries) == 0 { + t.Fatal("expected DROP TABLE queries to be executed on MySQL database") } } -func TestEnsurePostgresDatabaseRejectsExistingNonEmptyDatabase(t *testing.T) { - target := &fakeSetupDB{tableCount: 1} +func TestEnsurePostgresDatabaseClearsExistingNonEmptyDatabase(t *testing.T) { + target := &fakeSetupDB{tableCount: 2} withPostgresSetupOpener(t, func(string) (setupDatabase, error) { return target, nil }) - if err := ensurePostgresDatabase("postgres://smail:secret@127.0.0.1:5432/smail?sslmode=disable"); err == nil { - t.Fatal("non-empty PostgreSQL database was accepted for a fresh deployment") - } else if message := DatabaseSetupErrorMessage(err); message == "" { - t.Fatal("fresh deployment error message was empty") + if err := ensurePostgresDatabase("postgres://smail:secret@127.0.0.1:5432/smail?sslmode=disable"); err != nil { + t.Fatalf("expected non-empty PostgreSQL database to be cleared successfully, got: %v", err) + } + if len(target.execQueries) == 0 { + t.Fatal("expected DROP SCHEMA queries to be executed on PostgreSQL database") } } @@ -399,13 +411,27 @@ func testMySQLDSN(t *testing.T, password string) string { } func TestPrepareSQLiteDatabaseCreatesParentDirectory(t *testing.T) { - databasePath := filepath.Join(t.TempDir(), "nested", "config", "smail.db") + tempDir := t.TempDir() + databasePath := filepath.Join(tempDir, "nested", "config", "smail.db") if err := prepareSQLiteDatabase(databasePath); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Dir(databasePath)); err != nil { t.Fatalf("SQLite parent directory was not prepared: %v", err) } + + // 测试全新部署自动清空已存在的数据文件和 wal/shm 临时文件 + _ = os.WriteFile(databasePath, []byte("legacy data"), 0644) + _ = os.WriteFile(databasePath+"-wal", []byte("wal data"), 0644) + _ = os.WriteFile(databasePath+"-shm", []byte("shm data"), 0644) + if err := prepareSQLiteDatabase(databasePath); err != nil { + t.Fatal(err) + } + for _, ext := range []string{"", "-wal", "-shm"} { + if _, err := os.Stat(databasePath + ext); !os.IsNotExist(err) { + t.Fatalf("expected SQLite file %s to be removed, but still exists", databasePath+ext) + } + } } func TestDatabaseInitializationErrorReturnsMigrationGuidance(t *testing.T) { diff --git a/frontend/src/views/components/HomeAside/HomeAside.vue b/frontend/src/views/components/HomeAside/HomeAside.vue index 866a949d..ce18f38b 100644 --- a/frontend/src/views/components/HomeAside/HomeAside.vue +++ b/frontend/src/views/components/HomeAside/HomeAside.vue @@ -57,7 +57,7 @@ {{ lang.settings }} - V2.10.6 + V2.10.7