#include "DatabaseManager.h" #include #include #include #include #include #include #include #include #include DatabaseManager::DatabaseManager() = default; DatabaseManager::~DatabaseManager() { QMutexLocker locker(&mutex); // QSqlDatabase требует живого QCoreApplication — при завершении // статический синглтон может разрушаться после QApplication if (!QCoreApplication::instance()) { connectionPool.clear(); return; } for (auto it = connectionPool.begin(); it != connectionPool.end(); ++it) { QSqlDatabase::removeDatabase(it.key()); } connectionPool.clear(); qDebug() << "Все соединения закрыты"; } DatabaseManager &DatabaseManager::instance() { static DatabaseManager instance; return instance; } QString DatabaseManager::dbFilePath() { const QSettings settings("config.ini", QSettings::IniFormat); QString dbPath = settings.value("db/dbPath").toString(); if (dbPath.startsWith("~")) { dbPath.replace(0, 1, QDir::homePath()); } return dbPath; } void DatabaseManager::wipeDatabaseFiles() { const QString dbPath = dbFilePath(); QFile::remove(dbPath); QFile::remove(dbPath + "-wal"); QFile::remove(dbPath + "-shm"); QFile::remove(dbPath + ".wipe"); } void DatabaseManager::writeWipeSentinel() { QFile marker(dbFilePath() + ".wipe"); if (marker.open(QIODevice::WriteOnly)) { marker.close(); } } QSqlDatabase DatabaseManager::database() { QMutexLocker locker(&mutex); QString name = connectionName(); // Создаем новое соединение, если его ещё нет if (!connectionPool.contains(name)) { QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", name); const QString dbPath = dbFilePath(); // Создаём директорию для БД, если её нет QDir().mkpath(QFileInfo(dbPath).absolutePath()); db.setDatabaseName(dbPath); if (!db.open()) { qWarning() << "Ошибка открытия базы данных:" << db.lastError().text(); } // Включаем каскадное удаление (FK) QSqlQuery pragmaQuery(db); pragmaQuery.exec("PRAGMA foreign_keys = ON"); // WAL: читатели (например, посекундный refresh GUI через getAllDevices) // больше не блокируются писателями (screencap-BLOB каждые 4с/устройство, // события, sync). В прежнем режиме (rollback journal = delete) писатель // брал эксклюзивную блокировку всего файла и стопорил читателей — под // нагрузкой 5-10 устройств это давало нарастающие подвисания GUI. // journal_mode персистентен в файле; busy_timeout/synchronous — пер-конекшн, // поэтому ставим их на каждом новом соединении. pragmaQuery.exec("PRAGMA journal_mode = WAL"); pragmaQuery.exec("PRAGMA busy_timeout = 5000"); pragmaQuery.exec("PRAGMA synchronous = NORMAL"); connectionPool[name] = db; // Автоматическое удаление соединения при завершении потока QObject::connect(QThread::currentThread(), &QThread::finished, [this, name]() { QMutexLocker cLocker(&mutex); if (connectionPool.contains(name)) { connectionPool.remove(name); QSqlDatabase::removeDatabase(name); qDebug() << "Соединение" << name << "удалено"; } }); } return connectionPool[name]; } QString DatabaseManager::connectionName() { return QString("connection_%1").arg(reinterpret_cast(QThread::currentThreadId())); } void DatabaseManager::initSchema() { QSqlDatabase db = database(); QSqlQuery q(db); db.transaction(); q.exec(R"(CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, api_id TEXT, adb_serial TEXT, status TEXT, name TEXT, android TEXT, screen_width INTEGER, screen_height INTEGER, battery INTEGER, update_time DATETIME, timestamp DATETIME DEFAULT (strftime('%d.%m.%Y %H:%M:%S', 'now')), image BLOB ))"); q.exec(R"(CREATE TABLE IF NOT EXISTS bank_profiles ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT, pin_code TEXT, pin_code_checked TEXT DEFAULT 'no', name TEXT, code TEXT, package TEXT, status TEXT DEFAULT 'off', install TEXT DEFAULT 'no', comment TEXT, phone TEXT DEFAULT '', email TEXT DEFAULT '', full_name TEXT DEFAULT '', bank_profile_id TEXT DEFAULT '', currency TEXT DEFAULT '', amount REAL DEFAULT 0, app_version TEXT DEFAULT '', app_version_checked_at DATETIME, FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE, UNIQUE (device_id, code) ))"); // Миграция: добавляем колонки app_version и app_version_checked_at если их нет (ошибка = уже существует) q.exec("ALTER TABLE bank_profiles ADD COLUMN app_version TEXT DEFAULT ''"); q.exec("ALTER TABLE bank_profiles ADD COLUMN app_version_checked_at DATETIME"); q.exec(R"(CREATE TABLE IF NOT EXISTS materials ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT, app_code TEXT, last_numbers TEXT, description TEXT, amount REAL, currency TEXT DEFAULT '', update_time DATETIME DEFAULT (strftime('%d.%m.%Y %H:%M:%S', 'now')), status TEXT DEFAULT 'active', card_number TEXT DEFAULT '', account_number TEXT DEFAULT '', material_id TEXT DEFAULT '', FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE, UNIQUE (device_id, app_code, last_numbers) ))"); q.exec("ALTER TABLE materials ADD COLUMN account_number TEXT DEFAULT ''"); q.exec(R"(CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, external_id INTEGER, amount REAL, fee REAL, status TEXT, type TEXT, phone TEXT, name TEXT, short_description TEXT, updated_short_description TEXT, description TEXT, updated_description TEXT, bank_name TEXT, bank_time TEXT, bank_tr_external_id TEXT, update_time DATETIME, complete_time DATETIME, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, receipt_image BLOB, FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE ))"); q.exec(R"(CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER, external_id TEXT, transaction_id TEXT, device_id TEXT, amount REAL, phone TEXT, bank_name TEXT, comment TEXT, status TEXT, type TEXT, update_time DATETIME, complete_time DATETIME, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, json TEXT DEFAULT '', sent_to_server INTEGER DEFAULT 0, screenshot BLOB, FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE, FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE ))"); q.exec("ALTER TABLE events ADD COLUMN transaction_id TEXT"); q.exec(R"(CREATE TABLE IF NOT EXISTS banks ( id INTEGER PRIMARY KEY AUTOINCREMENT, alias TEXT UNIQUE, name TEXT, nspk_name TEXT ))"); // Таблица app_logs больше не используется — логи пишутся в general_logs // Удаляем если существует (миграция) q.exec("DROP TABLE IF EXISTS app_logs"); q.exec(R"(CREATE TABLE IF NOT EXISTS general_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, level TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT '', message TEXT NOT NULL, timestamp TEXT NOT NULL ))"); q.exec("CREATE INDEX IF NOT EXISTS idx_general_logs_id ON general_logs (id DESC)"); // Миграция: добавляем колонку screenshot если её нет q.exec("ALTER TABLE general_logs ADD COLUMN screenshot BLOB"); // Миграция: добавляем колонки request_body и response_body q.exec("ALTER TABLE general_logs ADD COLUMN request_body TEXT DEFAULT ''"); q.exec("ALTER TABLE general_logs ADD COLUMN response_body TEXT DEFAULT ''"); // Ошибка = колонка уже существует — это нормально q.exec(R"(CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ))"); // Wi-Fi реквизиты подключения per-device (связь по android_id = devices.id). // Авто-реконнект по Wi-Fi: ip+port для adb connect. Pairing-код НЕ храним — он // одноразовый и для реконнекта не нужен (pairing сохраняется на устройстве). q.exec(R"(CREATE TABLE IF NOT EXISTS wifi_connections ( android_id TEXT PRIMARY KEY, ip TEXT, port INTEGER, updated_at DATETIME, FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE ))"); // Миграция: убираем колонку code из старых БД (ошибка = колонки уже нет — норм). q.exec("ALTER TABLE wifi_connections DROP COLUMN code"); db.commit(); } void DatabaseManager::cleanupOldData(int days) { QSqlDatabase db = database(); QSqlQuery q(db); const QString cutoff = QDateTime::currentDateTimeUtc().addDays(-days).toString(Qt::ISODate); db.transaction(); // general_logs q.prepare("DELETE FROM general_logs WHERE timestamp < :cutoff"); q.bindValue(":cutoff", cutoff); if (q.exec()) { qDebug() << "[cleanup] general_logs: deleted" << q.numRowsAffected() << "rows older than" << days << "days"; } // events (завершённые/ошибочные) q.prepare("DELETE FROM events WHERE timestamp < :cutoff AND status IN ('complete', 'error')"); q.bindValue(":cutoff", cutoff); if (q.exec()) { qDebug() << "[cleanup] events: deleted" << q.numRowsAffected() << "rows older than" << days << "days"; } // transactions q.prepare("DELETE FROM transactions WHERE timestamp < :cutoff"); q.bindValue(":cutoff", cutoff); if (q.exec()) { qDebug() << "[cleanup] transactions: deleted" << q.numRowsAffected() << "rows older than" << days << "days"; } db.commit(); // Освобождаем место q.exec("VACUUM"); qDebug() << "[cleanup] VACUUM done"; } void DatabaseManager::performMaintenance() { // Оставляем последние N строк general_logs — защита от роста при всплесках // логирования. Кап по id (а не по timestamp) формат-независим и надёжен. constexpr int kMaxGeneralLogRows = 5000; // Старые завершённые события/транзакции держим максимум столько дней. constexpr int kKeepDays = 2; QSqlDatabase db = database(); QSqlQuery q(db); const QString cutoff = QDateTime::currentDateTimeUtc().addDays(-kKeepDays).toString(Qt::ISODate); db.transaction(); q.prepare("DELETE FROM general_logs WHERE id < " "(SELECT MAX(id) FROM general_logs) - :keep"); q.bindValue(":keep", kMaxGeneralLogRows); if (q.exec() && q.numRowsAffected() > 0) qDebug() << "[maintenance] general_logs: deleted" << q.numRowsAffected() << "rows (cap" << kMaxGeneralLogRows << ")"; q.prepare("DELETE FROM events WHERE timestamp < :cutoff AND status IN ('complete', 'error')"); q.bindValue(":cutoff", cutoff); if (q.exec() && q.numRowsAffected() > 0) qDebug() << "[maintenance] events: deleted" << q.numRowsAffected() << "rows older than" << kKeepDays << "days"; q.prepare("DELETE FROM transactions WHERE timestamp < :cutoff"); q.bindValue(":cutoff", cutoff); if (q.exec() && q.numRowsAffected() > 0) qDebug() << "[maintenance] transactions: deleted" << q.numRowsAffected() << "rows older than" << kKeepDays << "days"; db.commit(); // Подрезаем WAL, чтобы он не рос неограниченно за сессию (TRUNCATE сбрасывает // файл к нулю после переноса кадров в основную БД). Вне транзакции. q.exec("PRAGMA wal_checkpoint(TRUNCATE)"); } void DatabaseManager::closeConnection() { QMutexLocker locker(&mutex); if (const QString name = connectionName(); connectionPool.contains(name)) { connectionPool.remove(name); QSqlDatabase::removeDatabase(name); qDebug() << "Соединение" << name << "удалено вручную"; } }