mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
[功能] 实现数据库版本管理与迁移逻辑,确保数据库结构与版本一致性
This commit is contained in:
+2
-1
@@ -46,4 +46,5 @@ go.work.sum
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.codex-cache
|
.codex-cache
|
||||||
|
/.gomodcache/
|
||||||
|
|||||||
@@ -126,6 +126,18 @@
|
|||||||
* 快照与聚合结果采用追加式模型,不覆盖历史
|
* 快照与聚合结果采用追加式模型,不覆盖历史
|
||||||
* 原始访问明细必须有受控保留策略
|
* 原始访问明细必须有受控保留策略
|
||||||
|
|
||||||
|
### 3.1 数据库版本与迁移
|
||||||
|
|
||||||
|
* 任何涉及表结构、索引、列类型、分表规则或内部持久化元数据的修改,都必须同步提升数据库版本号
|
||||||
|
* 数据库版本号定义在 `openflare_server/model`,不得只依赖 `AutoMigrate` 隐式升级存量数据库
|
||||||
|
* 每次提升数据库版本号时,必须补充从上一版本升级到新版本的显式迁移方法
|
||||||
|
* 迁移方法必须包含升级后的校验逻辑;只有校验通过,才能写入新的数据库版本记录
|
||||||
|
* 新包启动后必须先检查数据库当前版本,再按顺序逐步升级到目标版本;禁止跳过中间升级步骤直接写目标版本
|
||||||
|
* 空库初始化可以直接建立当前版本结构,但初始化完成后仍必须执行同版本校验,并落库当前数据库版本
|
||||||
|
* 数据库版本元数据属于内部控制信息,必须保存在独立内部表中,不能混入业务配置表
|
||||||
|
* 如果迁移失败或校验失败,启动流程必须中止,且不得提升数据库版本记录
|
||||||
|
* 涉及数据库版本变更的提交,必须补充对应的迁移测试或等效回归测试
|
||||||
|
|
||||||
## 4. API 与鉴权规范
|
## 4. API 与鉴权规范
|
||||||
|
|
||||||
### 4.1 API
|
### 4.1 API
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
legacyDatabaseSchemaVersion = 1
|
||||||
|
currentDatabaseSchemaVersion = 2
|
||||||
|
databaseSchemaVersionRowID = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
type DatabaseSchemaVersion struct {
|
||||||
|
ID uint `json:"id" gorm:"primaryKey"`
|
||||||
|
Version int `json:"version" gorm:"not null"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DatabaseSchemaVersion) TableName() string {
|
||||||
|
return "database_schema_versions"
|
||||||
|
}
|
||||||
+212
-15
@@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
@@ -23,6 +24,13 @@ type dbModel struct {
|
|||||||
hasIDPK bool
|
hasIDPK bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type databaseSchemaMigration struct {
|
||||||
|
fromVersion int
|
||||||
|
toVersion int
|
||||||
|
migrate func(db *gorm.DB, backend string) error
|
||||||
|
validate func(db *gorm.DB, backend string) error
|
||||||
|
}
|
||||||
|
|
||||||
func registeredModels() []any {
|
func registeredModels() []any {
|
||||||
return []any{
|
return []any{
|
||||||
&File{},
|
&File{},
|
||||||
@@ -42,6 +50,12 @@ func registeredModels() []any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func schemaMetadataModels() []any {
|
||||||
|
return []any{
|
||||||
|
&DatabaseSchemaVersion{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildDBModels() ([]dbModel, error) {
|
func buildDBModels() ([]dbModel, error) {
|
||||||
models := registeredModels()
|
models := registeredModels()
|
||||||
result := make([]dbModel, 0, len(models))
|
result := make([]dbModel, 0, len(models))
|
||||||
@@ -123,6 +137,15 @@ func autoMigrateAll(db *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func autoMigrateSchemaMetadata(db *gorm.DB) error {
|
||||||
|
for _, item := range schemaMetadataModels() {
|
||||||
|
if err := db.AutoMigrate(item); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func migrateTextColumns(db *gorm.DB, backend string) error {
|
func migrateTextColumns(db *gorm.DB, backend string) error {
|
||||||
if backend != "postgres" {
|
if backend != "postgres" {
|
||||||
return nil
|
return nil
|
||||||
@@ -194,10 +217,196 @@ func migrateObservabilityLegacyColumns(db *gorm.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyCurrentSchema(db *gorm.DB, backend string) error {
|
||||||
|
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := migrateProxyRouteEnableHTTPSColumn(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := autoMigrateAll(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := migrateTextColumns(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := migrateObservabilityLegacyColumns(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDatabaseSchemaVersion(db *gorm.DB) (int, bool, error) {
|
||||||
|
if db == nil {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
var state DatabaseSchemaVersion
|
||||||
|
err := db.Where("id = ?", databaseSchemaVersionRowID).First(&state).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, false, err
|
||||||
|
}
|
||||||
|
return state.Version, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDatabaseSchemaVersion(db *gorm.DB, version int) error {
|
||||||
|
return db.Save(&DatabaseSchemaVersion{
|
||||||
|
ID: databaseSchemaVersionRowID,
|
||||||
|
Version: version,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDatabaseSchemaV2(db *gorm.DB, backend string) error {
|
||||||
|
if db == nil {
|
||||||
|
return fmt.Errorf("database handle is nil")
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(&DatabaseSchemaVersion{}) {
|
||||||
|
return fmt.Errorf("table %s is missing", (&DatabaseSchemaVersion{}).TableName())
|
||||||
|
}
|
||||||
|
models, err := buildDBModels()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, item := range models {
|
||||||
|
if isShardedObservabilityTable(item.tableName) {
|
||||||
|
for _, table := range observabilityShardTables(item.tableName) {
|
||||||
|
if !db.Migrator().HasTable(table) {
|
||||||
|
return fmt.Errorf("sharded table %s is missing", table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(item.value) {
|
||||||
|
return fmt.Errorf("table %s is missing", item.tableName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasColumn(&NodeHealthEvent{}, "metadata_json") {
|
||||||
|
return fmt.Errorf("column node_health_events.metadata_json is missing")
|
||||||
|
}
|
||||||
|
_ = backend
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||||
|
return []databaseSchemaMigration{
|
||||||
|
{
|
||||||
|
fromVersion: 1,
|
||||||
|
toVersion: 2,
|
||||||
|
migrate: applyCurrentSchema,
|
||||||
|
validate: validateDatabaseSchemaV2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func databaseSchemaMigrationMap() map[int]databaseSchemaMigration {
|
||||||
|
migrations := make(map[int]databaseSchemaMigration, len(databaseSchemaMigrations()))
|
||||||
|
for _, item := range databaseSchemaMigrations() {
|
||||||
|
migrations[item.fromVersion] = item
|
||||||
|
}
|
||||||
|
return migrations
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDatabaseSchemaMigration(db *gorm.DB, backend string, migration databaseSchemaMigration) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := migration.migrate(tx, backend); err != nil {
|
||||||
|
return fmt.Errorf("migrate database schema from v%d to v%d failed: %w", migration.fromVersion, migration.toVersion, err)
|
||||||
|
}
|
||||||
|
if err := migration.validate(tx, backend); err != nil {
|
||||||
|
return fmt.Errorf("validate database schema v%d failed: %w", migration.toVersion, err)
|
||||||
|
}
|
||||||
|
if err := saveDatabaseSchemaVersion(tx, migration.toVersion); err != nil {
|
||||||
|
return fmt.Errorf("persist database schema version v%d failed: %w", migration.toVersion, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func upgradeDatabaseSchema(db *gorm.DB, backend string, version int) error {
|
||||||
|
if version > currentDatabaseSchemaVersion {
|
||||||
|
return fmt.Errorf("database schema version %d is newer than application version %d", version, currentDatabaseSchemaVersion)
|
||||||
|
}
|
||||||
|
if version == currentDatabaseSchemaVersion {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
migrationMap := databaseSchemaMigrationMap()
|
||||||
|
for version < currentDatabaseSchemaVersion {
|
||||||
|
migration, ok := migrationMap[version]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("database schema migration from v%d is not defined", version)
|
||||||
|
}
|
||||||
|
if err := runDatabaseSchemaMigration(db, backend, migration); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version = migration.toVersion
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
|
||||||
|
if err := applyCurrentSchema(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := migrateSQLiteDataIfNeeded(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateDatabaseSchemaV2(db, backend); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDatabaseSchemaUpToDate(db *gorm.DB, backend string) error {
|
||||||
|
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return upgradeDatabaseSchema(db, backend, version)
|
||||||
|
}
|
||||||
|
empty, err := isDatabaseEmpty(db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if empty {
|
||||||
|
return initializeFreshDatabaseSchema(db, backend)
|
||||||
|
}
|
||||||
|
if err := autoMigrateSchemaMetadata(db); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return upgradeDatabaseSchema(db, backend, legacyDatabaseSchemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
|
func isDatabaseEmpty(db *gorm.DB) (bool, error) {
|
||||||
for _, item := range registeredModels() {
|
models, err := buildDBModels()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, item := range models {
|
||||||
|
if isShardedObservabilityTable(item.tableName) {
|
||||||
|
for _, table := range observabilityShardTables(item.tableName) {
|
||||||
|
if !db.Migrator().HasTable(table) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Table(table).Limit(1).Count(&count).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !db.Migrator().HasTable(item.value) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.Model(item).Limit(1).Count(&count).Error; err != nil {
|
if err := db.Model(item.value).Limit(1).Count(&count).Error; err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
@@ -346,19 +555,7 @@ func InitDB() (err error) {
|
|||||||
if err = registerSharding(db, backend); err != nil {
|
if err = registerSharding(db, backend); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = migrateProxyRouteEnableHTTPSColumn(db); err != nil {
|
if err = ensureDatabaseSchemaUpToDate(db, backend); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = autoMigrateAll(db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = migrateTextColumns(db, backend); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = migrateObservabilityLegacyColumns(db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err = migrateSQLiteDataIfNeeded(db, backend); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return createRootAccountIfNeed()
|
return createRootAccountIfNeed()
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
func openBareTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite db: %v", err)
|
t.Fatalf("open sqlite db: %v", err)
|
||||||
}
|
}
|
||||||
if err := autoMigrateAll(db); err != nil {
|
|
||||||
t.Fatalf("auto migrate db: %v", err)
|
|
||||||
}
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get sql db: %v", err)
|
t.Fatalf("get sql db: %v", err)
|
||||||
@@ -30,6 +27,16 @@ func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openTestSQLiteDB(t *testing.T, name string) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db := openBareTestSQLiteDB(t, name)
|
||||||
|
if err := autoMigrateAll(db); err != nil {
|
||||||
|
t.Fatalf("auto migrate db: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
func findDBModelByTableName(t *testing.T, tableName string) dbModel {
|
func findDBModelByTableName(t *testing.T, tableName string) dbModel {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -125,17 +132,7 @@ func TestMigrateTableDataCopiesRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRegisterShardingAutoMigratesShardTables(t *testing.T) {
|
func TestRegisterShardingAutoMigratesShardTables(t *testing.T) {
|
||||||
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "sharded.db")), &gorm.Config{})
|
db := openBareTestSQLiteDB(t, "sharded.db")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("open sqlite db: %v", err)
|
|
||||||
}
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get sql db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
})
|
|
||||||
if err := registerSharding(db, "sqlite"); err != nil {
|
if err := registerSharding(db, "sqlite"); err != nil {
|
||||||
t.Fatalf("register sharding: %v", err)
|
t.Fatalf("register sharding: %v", err)
|
||||||
}
|
}
|
||||||
@@ -202,3 +199,85 @@ func TestMigrateObservabilityLegacyColumnsBackfillsHealthEventMetadata(t *testin
|
|||||||
t.Fatal("expected metadata_json to be backfilled")
|
t.Fatal("expected metadata_json to be backfilled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureDatabaseSchemaUpToDateInitializesFreshDatabase(t *testing.T) {
|
||||||
|
db := openBareTestSQLiteDB(t, "fresh-schema.db")
|
||||||
|
if err := registerSharding(db, "sqlite"); err != nil {
|
||||||
|
t.Fatalf("register sharding: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||||
|
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("expected database schema version to be recorded")
|
||||||
|
}
|
||||||
|
if version != currentDatabaseSchemaVersion {
|
||||||
|
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDatabaseSchemaUpToDateUpgradesLegacyDatabase(t *testing.T) {
|
||||||
|
db := openBareTestSQLiteDB(t, "legacy-schema.db")
|
||||||
|
if err := registerSharding(db, "sqlite"); err != nil {
|
||||||
|
t.Fatalf("register sharding: %v", err)
|
||||||
|
}
|
||||||
|
if err := autoMigrateAll(db); err != nil {
|
||||||
|
t.Fatalf("auto migrate db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Create(&User{
|
||||||
|
Username: "legacy",
|
||||||
|
Password: "secret",
|
||||||
|
DisplayName: "Legacy User",
|
||||||
|
Role: 1,
|
||||||
|
Status: 1,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed legacy user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureDatabaseSchemaUpToDate(db, "sqlite"); err != nil {
|
||||||
|
t.Fatalf("ensureDatabaseSchemaUpToDate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, exists, err := loadDatabaseSchemaVersion(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadDatabaseSchemaVersion: %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Fatal("expected legacy database to gain a schema version record")
|
||||||
|
}
|
||||||
|
if version != currentDatabaseSchemaVersion {
|
||||||
|
t.Fatalf("unexpected schema version: got %d want %d", version, currentDatabaseSchemaVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunDatabaseSchemaMigrationDoesNotAdvanceVersionWhenValidationFails(t *testing.T) {
|
||||||
|
db := openBareTestSQLiteDB(t, "failed-validation.db")
|
||||||
|
|
||||||
|
err := runDatabaseSchemaMigration(db, "sqlite", databaseSchemaMigration{
|
||||||
|
fromVersion: legacyDatabaseSchemaVersion,
|
||||||
|
toVersion: currentDatabaseSchemaVersion,
|
||||||
|
migrate: func(tx *gorm.DB, backend string) error {
|
||||||
|
return autoMigrateSchemaMetadata(tx)
|
||||||
|
},
|
||||||
|
validate: func(tx *gorm.DB, backend string) error {
|
||||||
|
return gorm.ErrInvalidDB
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected migration validation to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, exists, loadErr := loadDatabaseSchemaVersion(db)
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("loadDatabaseSchemaVersion: %v", loadErr)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Fatal("expected schema version to remain unset after failed validation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user