358 lines
14 KiB
C++
358 lines
14 KiB
C++
#include "DatabaseManager.h"
|
||
#include <QCoreApplication>
|
||
#include <QSqlError>
|
||
#include <QSqlQuery>
|
||
#include <QDebug>
|
||
#include <QDir>
|
||
#include <QFileInfo>
|
||
#include <QSettings>
|
||
#include <QThread>
|
||
#include <QRegularExpression>
|
||
|
||
DatabaseManager::DatabaseManager() = default;
|
||
|
||
DatabaseManager::~DatabaseManager() {
|
||
QMutexLocker<QRecursiveMutex> 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<QRecursiveMutex> 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<QRecursiveMutex> 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<quintptr>(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, code — одноразовый
|
||
// pairing-код (нужен только для первого adb pair; реконнект идёт по ip:port).
|
||
q.exec(R"(CREATE TABLE IF NOT EXISTS wifi_connections (
|
||
android_id TEXT PRIMARY KEY,
|
||
ip TEXT,
|
||
port INTEGER,
|
||
code TEXT,
|
||
updated_at DATETIME,
|
||
FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE
|
||
))");
|
||
|
||
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<QRecursiveMutex> locker(&mutex);
|
||
if (const QString name = connectionName(); connectionPool.contains(name)) {
|
||
connectionPool.remove(name);
|
||
QSqlDatabase::removeDatabase(name);
|
||
qDebug() << "Соединение" << name << "удалено вручную";
|
||
}
|
||
}
|