package dao import ( "errors" "fmt" "strings" "time" "github.com/sirupsen/logrus" "gorm.io/gorm" "hy2xs-admin/model/entity" ) func SaveConfig(config entity.Config) (int64, error) { return saveConfigOn(sqliteDB, config) } func saveConfigOn(db *gorm.DB, config entity.Config) (int64, error) { if tx := db.Save(&config); tx.Error != nil { logrus.Errorf("%v", tx.Error) return 0, ErrStorage } return *config.Id, nil } func UpdateConfig(keys []string, updates map[string]interface{}) error { return updateConfigOn(sqliteDB, keys, updates) } // UpsertConfigValue записывает значение ключа, создавая строку, если её нет. // // Заменяет две зеркальные самодельные схемы, которые жили в sqlite.go и обе // были неверны: // // 1. «сначала INSERT, при ошибке UPDATE» — работало, но на КАЖДОЙ первой // загрузке печатало в журнал `duplicated key not allowed` уровня error, по // одной строке на секрет. Оператор видел три ошибки на совершенно здоровом // старте и не имел способа отличить их от настоящих; // // 2. «сначала UPDATE, при ошибке INSERT» — тихо не делало НИЧЕГО, если строки // не было. UPDATE без совпавших строк не является ошибкой: gorm возвращает // nil и RowsAffected = 0, поэтому ветка INSERT не выполнялась, а вызывающий // получал сгенерированный секрет как успешно сохранённый. Для JWT_SECRET // это означало бы подпись токенов ключом, которого нет в базе. Спасала // только предварительная вставка пустых строк в seedBaseConfig — то есть // корректность держалась на порядке выполнения миграций. // // Здесь решение принимается по RowsAffected, а не по наличию ошибки. func UpsertConfigValue(key string, value string) error { if sqliteDB == nil { return errors.New("sqlite is not initialized") } return upsertConfigValueOn(sqliteDB, key, value) } // upsertConfigValueOn — та же запись, но по переданному соединению. // // Нужна для операций, которые обязаны быть неделимыми вместе с записью в другую // таблицу: пир установщика и отметка о его создании (см. SeedBootstrapPeerOnce). func upsertConfigValueOn(db *gorm.DB, key string, value string) error { tx := db.Model(&entity.Config{}). Where("key = ?", key). Updates(map[string]interface{}{ "value": value, "update_time": time.Now().Format("2006-01-02 15:04:05"), }) if tx.Error != nil { logrus.Errorf("%v", tx.Error) return ErrStorage } if tx.RowsAffected > 0 { return nil } remark := key if _, err := saveConfigOn(db, entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil { return err } return nil } // ConfigTx — записи в таблицу `config` в границах одной единицы работы. // // Интерфейс повторяет PeerTx и по той же причине: транзакция должна быть // доступна слою service, а *gorm.DB туда протекать не должен. Реализация одна // и та же для глобального соединения и для транзакции, поэтому «обычный» и // «транзакционный» пути не могут разойтись в поведении. type ConfigTx interface { UpdateConfig(keys []string, updates map[string]interface{}) error } type configTx struct { db *gorm.DB } // WithConfigTx выполняет fn в одной транзакции: либо применяются все записи // партии, либо ни одной. // // Что было. updateConfigs валидировал и записывал настройки в одном цикле: // проверил элемент — записал, проверил следующий. Партия из разрешённого // ключа и запрещённого применяла первый и возвращала ошибку на втором, то есть // оператор получал отказ на запрос, который систему уже изменил. Тест на этот // случай существовал, но ставил запрещённый ключ ПЕРВЫМ и не смотрел в базу, // поэтому поймать частичное применение был неспособен по построению. func WithConfigTx(fn func(tx ConfigTx) error) error { if sqliteDB == nil { return errors.New("sqlite is not initialized") } return sqliteDB.Transaction(func(tx *gorm.DB) error { return fn(configTx{db: tx}) }) } func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) error { return updateConfigOn(c.db, keys, updates) } // updateConfigOn обновляет существующие строки и отказывает, если обновлять // было нечего. // // UPDATE без совпавших строк — не ошибка SQL: gorm возвращает nil и // RowsAffected = 0. Пока результат определялся только по tx.Error, отсутствие // строки выглядело успешной записью, и это не теоретическая аккуратность. // Путь оператора выглядит так: // // controller.UpdateConfigs → service.UpdateConfigs (транзакция) // → applyRuntimeConfigUpdates → RescheduleResetTraffic // // Применение к рантайму идёт ПОСЛЕ записи и полагается на её успех. При // отсутствующей строке RESET_TRAFFIC_CRON планировщик получал новое расписание, // API отвечал успехом, а база сохраняла старое значение — и ближайший // перезапуск сервиса молча возвращал прежний cron. Оператор при этом дважды // видел подтверждение изменения, которого не произошло. // // Здесь решение принимается по RowsAffected — ровно так же, как в // UpsertConfigValue ниже, где эта же ошибка уже была разобрана и исправлена. // Разница между двумя функциями одного файла и была настоящим дефектом. func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error { if len(updates) == 0 { return nil } updates["update_time"] = time.Now().Format("2006-01-02 15:04:05") tx := db.Model(&entity.Config{}). Where("key in ?", keys). Updates(updates) if tx.Error != nil { logrus.Errorf("%v", tx.Error) return ErrStorage } if tx.RowsAffected == 0 { return fmt.Errorf( "config: не найдено ни одной строки для ключей %s; настройка не сохранена", strings.Join(keys, ", "), ) } return nil } func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) { var config entity.Config if tx := sqliteDB.Model(&entity.Config{}). Where(query, args...).First(&config); tx.Error != nil { if errors.Is(tx.Error, gorm.ErrRecordNotFound) { return config, ErrConfigNotFound } logrus.Errorf("%v", tx.Error) return config, ErrStorage } return config, nil } func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error) { var configs []entity.Config if tx := sqliteDB.Model(&entity.Config{}). Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil { logrus.Errorf("%v", tx.Error) return configs, ErrStorage } return configs, nil }