1
0
forked from BRT/arc
arc/database/DatabaseManager.cpp

358 lines
14 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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 << "удалено вручную";
}
}