From 82ac22f6353dcb9a68fb318cc25ab3c07d5391d0 Mon Sep 17 00:00:00 2001 From: slava Date: Sat, 23 May 2026 18:37:49 +0500 Subject: [PATCH] Remove outdated AppLogDAO, LogWindow, and related functionalities. Introduce `NetworkErrorUtils` for detailed error categorization and enhanced logging. Refactor `EventHandler` to support balance adjustments and optimize thread cleanup. Add retry and timeout mechanisms to network services with improved error handling. --- android/dushanbe/GetProfileInfoScript.cpp | 4 + database/dao/AppLogDAO.cpp | 97 ---- database/dao/AppLogDAO.h | 20 - database/dao/BankDAO.cpp | 33 +- database/dao/BankProfileDAO.cpp | 12 + database/dao/BankProfileDAO.h | 4 + database/dao/MaterialDAO.cpp | 44 ++ database/dao/MaterialDAO.h | 12 + docs/api/network.md | 51 ++ models/db/AppLogEntry.h | 13 - services/DeviceScreener.cpp | 25 +- services/EventHandler.cpp | 88 +++- services/net/NetworkErrorUtils.h | 80 ++++ services/net/NetworkService.cpp | 449 ++++++++++-------- services/net/NetworkService.h | 10 +- tests/mock_server/default_profile.json | 48 +- .../pixel_7_ozon_9865_fetch_profile.json | 6 - .../tecno_km4_ozon_0982_fetch_profile.json | 6 - views/MainWindow.cpp | 1 - views/log/LogWindow.cpp | 154 ------ views/log/LogWindow.h | 33 -- 21 files changed, 569 insertions(+), 621 deletions(-) delete mode 100644 database/dao/AppLogDAO.cpp delete mode 100644 database/dao/AppLogDAO.h delete mode 100644 models/db/AppLogEntry.h create mode 100644 services/net/NetworkErrorUtils.h delete mode 100644 tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json delete mode 100644 tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json delete mode 100644 views/log/LogWindow.cpp delete mode 100644 views/log/LogWindow.h diff --git a/android/dushanbe/GetProfileInfoScript.cpp b/android/dushanbe/GetProfileInfoScript.cpp index 2bd7fb6..0fab758 100644 --- a/android/dushanbe/GetProfileInfoScript.cpp +++ b/android/dushanbe/GetProfileInfoScript.cpp @@ -77,6 +77,10 @@ void GetProfileInfoScript::doStart() { return; } BankProfileDAO::updateAmount(app.id, parsedBalance); + // У Душанбе все карты сидят на одном счёте, а экран деталей карты + // отдаёт placeholder "0,00 TJS" — единственный источник правды + // для materials.amount это баланс home-screen. + MaterialDAO::setAmountByApp(device.id, m_appCode, parsedBalance); // Тапаем по кнопке меню (гамбургер, левый верхний угол) // Это первый clickable ImageView в верхней панели diff --git a/database/dao/AppLogDAO.cpp b/database/dao/AppLogDAO.cpp deleted file mode 100644 index 990f58a..0000000 --- a/database/dao/AppLogDAO.cpp +++ /dev/null @@ -1,97 +0,0 @@ -#include "AppLogDAO.h" - -#include -#include -#include - -#include "DatabaseManager.h" -#include "time/DateUtils.h" - -bool AppLogDAO::insert(const AppLogEntry &entry) { - QSqlQuery q(DatabaseManager::instance().database()); - q.prepare(R"( - INSERT INTO app_logs (source, device_id, message, screenshot, timestamp) - VALUES (:source, :device_id, :message, :screenshot, :timestamp) - )"); - q.bindValue(":source", entry.source); - q.bindValue(":device_id", entry.deviceId); - q.bindValue(":message", entry.message); - q.bindValue(":screenshot", entry.screenshot.isEmpty() - ? QVariant(QMetaType(QMetaType::QByteArray)) - : QVariant(entry.screenshot)); - q.bindValue(":timestamp", DateUtils::toUtcString(entry.timestamp)); - if (!q.exec()) { - qWarning() << "[AppLogDAO] insert failed:" << q.lastError().text(); - return false; - } - return true; -} - -QList AppLogDAO::getLogs(int page, int pageSize) { - QList result; - QSqlQuery q(DatabaseManager::instance().database()); - q.prepare(R"( - SELECT id, source, device_id, message, timestamp - FROM app_logs - ORDER BY id DESC - LIMIT :limit OFFSET :offset - )"); - q.bindValue(":limit", pageSize); - q.bindValue(":offset", page * pageSize); - if (!q.exec()) { - qWarning() << "[AppLogDAO] getLogs failed:" << q.lastError().text(); - return result; - } - while (q.next()) { - AppLogEntry e; - e.id = q.value("id").toInt(); - e.source = q.value("source").toString(); - e.deviceId = q.value("device_id").toString(); - e.message = q.value("message").toString(); - e.timestamp = DateUtils::fromUtcString(q.value("timestamp").toString()); - result.append(e); - } - return result; -} - -int AppLogDAO::getTotalCount() { - QSqlQuery q(DatabaseManager::instance().database()); - q.exec("SELECT COUNT(*) FROM app_logs"); - if (q.next()) return q.value(0).toInt(); - return 0; -} - -QByteArray AppLogDAO::getScreenshot(int id) { - QSqlQuery q(DatabaseManager::instance().database()); - q.prepare("SELECT screenshot FROM app_logs WHERE id = :id"); - q.bindValue(":id", id); - if (!q.exec() || !q.next()) return {}; - return q.value(0).toByteArray(); -} - -QList AppLogDAO::getLogsWithScreenshots(int limit) { - QList result; - QSqlQuery q(DatabaseManager::instance().database()); - q.prepare(R"( - SELECT id, source, device_id, message, screenshot, timestamp - FROM app_logs - ORDER BY id DESC - LIMIT :limit - )"); - q.bindValue(":limit", limit); - if (!q.exec()) { - qWarning() << "[AppLogDAO] getLogsWithScreenshots failed:" << q.lastError().text(); - return result; - } - while (q.next()) { - AppLogEntry e; - e.id = q.value("id").toInt(); - e.source = q.value("source").toString(); - e.deviceId = q.value("device_id").toString(); - e.message = q.value("message").toString(); - e.screenshot = q.value("screenshot").toByteArray(); - e.timestamp = DateUtils::fromUtcString(q.value("timestamp").toString()); - result.append(e); - } - return result; -} diff --git a/database/dao/AppLogDAO.h b/database/dao/AppLogDAO.h deleted file mode 100644 index e03bbad..0000000 --- a/database/dao/AppLogDAO.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once -#include -#include -#include "db/AppLogEntry.h" - -class AppLogDAO { -public: - [[nodiscard]] static bool insert(const AppLogEntry &entry); - - // Возвращает записи без BLOB-скриншота (лёгкая загрузка для таблицы) - [[nodiscard]] static QList getLogs(int page, int pageSize); - - [[nodiscard]] static int getTotalCount(); - - // Загружает скриншот отдельно по id (по клику в UI) - [[nodiscard]] static QByteArray getScreenshot(int id); - - // Все записи со скриншотами (для экспорта) - [[nodiscard]] static QList getLogsWithScreenshots(int limit = 100); -}; diff --git a/database/dao/BankDAO.cpp b/database/dao/BankDAO.cpp index 53eb335..1d4aaf3 100644 --- a/database/dao/BankDAO.cpp +++ b/database/dao/BankDAO.cpp @@ -44,25 +44,42 @@ BankInfo BankDAO::findByAlias(const QString &alias) { void BankDAO::replaceAll(const QList &banks) { QSqlDatabase db = DatabaseManager::instance().database(); + + if (!db.transaction()) { + qWarning() << "[BankDAO] replaceAll: cannot begin transaction:" << db.lastError().text(); + return; + } + QSqlQuery query(db); + if (!query.exec("DELETE FROM banks")) { + qWarning() << "[BankDAO] replaceAll: DELETE failed:" << query.lastError().text(); + db.rollback(); + return; + } - db.transaction(); - - query.exec("DELETE FROM banks"); - - query.prepare(R"( + if (!query.prepare(R"( INSERT INTO banks (alias, name, nspk_name) VALUES (:alias, :name, :nspk_name) - )"); + )")) { + qWarning() << "[BankDAO] replaceAll: prepare INSERT failed:" << query.lastError().text(); + db.rollback(); + return; + } for (const BankInfo &bank : banks) { query.bindValue(":alias", bank.alias); query.bindValue(":name", bank.name); query.bindValue(":nspk_name", bank.nspkName); if (!query.exec()) { - qWarning() << "Ошибка при вставке bank:" << query.lastError().text(); + qWarning() << "[BankDAO] replaceAll: INSERT failed for alias" << bank.alias + << ":" << query.lastError().text() << "— rolling back"; + db.rollback(); + return; } } - db.commit(); + if (!db.commit()) { + qWarning() << "[BankDAO] replaceAll: commit failed:" << db.lastError().text(); + db.rollback(); + } } diff --git a/database/dao/BankProfileDAO.cpp b/database/dao/BankProfileDAO.cpp index 075d775..f8f0d8a 100644 --- a/database/dao/BankProfileDAO.cpp +++ b/database/dao/BankProfileDAO.cpp @@ -264,6 +264,18 @@ bool BankProfileDAO::updateAmount(const int appId, const double amount) { return true; } +bool BankProfileDAO::adjustAmount(const int appId, const double delta) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare("UPDATE bank_profiles SET amount = amount - :delta WHERE id = :id"); + query.bindValue(":delta", delta); + query.bindValue(":id", appId); + if (!query.exec()) { + qWarning() << "[BankProfileDAO] adjustAmount failed:" << query.lastError().text(); + return false; + } + return true; +} + bool BankProfileDAO::updateAppVersion(const int appId, const QString &appVersion) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( diff --git a/database/dao/BankProfileDAO.h b/database/dao/BankProfileDAO.h index e0b8d53..bb1c14b 100644 --- a/database/dao/BankProfileDAO.h +++ b/database/dao/BankProfileDAO.h @@ -36,6 +36,10 @@ public: [[nodiscard]] static bool updateAmount(int appId, double amount); + // Атомарно вычитает delta из bank_profiles.amount. + // delta > 0 — расход, delta < 0 — пополнение. + [[nodiscard]] static bool adjustAmount(int appId, double delta); + [[nodiscard]] static bool updateAppVersion(int appId, const QString &appVersion); [[nodiscard]] static BankProfileInfo getApplication(const QString &deviceId, const QString &appCode); diff --git a/database/dao/MaterialDAO.cpp b/database/dao/MaterialDAO.cpp index c8d1429..e772136 100644 --- a/database/dao/MaterialDAO.cpp +++ b/database/dao/MaterialDAO.cpp @@ -128,6 +128,50 @@ bool MaterialDAO::updateMaterialId(const int accountId, const QString &materialI return true; } +bool MaterialDAO::adjustAmount(const int id, const double delta) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare("UPDATE materials SET amount = amount - :delta WHERE id = :id"); + query.bindValue(":delta", delta); + query.bindValue(":id", id); + if (!query.exec()) { + qWarning() << "[MaterialDAO] adjustAmount failed:" << query.lastError().text(); + return false; + } + return true; +} + +bool MaterialDAO::adjustAmountByApp(const QString &deviceId, const QString &appCode, const double delta) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + UPDATE materials SET amount = amount - :delta + WHERE device_id = :device_id AND app_code = :app_code + )"); + query.bindValue(":delta", delta); + query.bindValue(":device_id", deviceId); + query.bindValue(":app_code", appCode); + if (!query.exec()) { + qWarning() << "[MaterialDAO] adjustAmountByApp failed:" << query.lastError().text(); + return false; + } + return true; +} + +bool MaterialDAO::setAmountByApp(const QString &deviceId, const QString &appCode, const double amount) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + UPDATE materials SET amount = :amount + WHERE device_id = :device_id AND app_code = :app_code + )"); + query.bindValue(":amount", amount); + query.bindValue(":device_id", deviceId); + query.bindValue(":app_code", appCode); + if (!query.exec()) { + qWarning() << "[MaterialDAO] setAmountByApp failed:" << query.lastError().text(); + return false; + } + return true; +} + MaterialInfo parseAccount(const QSqlQuery &query) { MaterialInfo acc; acc.id = query.value("id").toInt(); diff --git a/database/dao/MaterialDAO.h b/database/dao/MaterialDAO.h index bdf2ab2..e8b98f1 100644 --- a/database/dao/MaterialDAO.h +++ b/database/dao/MaterialDAO.h @@ -26,6 +26,18 @@ public: [[nodiscard]] static bool updateMaterialId(int accountId, const QString &materialId); + // Атомарно вычитает delta из materials.amount для одного материала. + // delta > 0 — расход, delta < 0 — пополнение. + [[nodiscard]] static bool adjustAmount(int id, double delta); + + // Атомарно вычитает delta из materials.amount для всех материалов + // в (deviceId, appCode) — для банков с одним счётом на все карты (Dushanbe). + [[nodiscard]] static bool adjustAmountByApp(const QString &deviceId, const QString &appCode, double delta); + + // Перезаписывает materials.amount абсолютным значением для всех материалов + // в (deviceId, appCode). Используется после re-parse экрана. + [[nodiscard]] static bool setAmountByApp(const QString &deviceId, const QString &appCode, double amount); + [[nodiscard]] static QList getAccounts(const QString &deviceId, const QString &appName); [[nodiscard]] static MaterialInfo getAccountById(int accountId); diff --git a/docs/api/network.md b/docs/api/network.md index 0a9258b..b02017d 100644 --- a/docs/api/network.md +++ b/docs/api/network.md @@ -102,3 +102,54 @@ pathTaskResult = /api/v1/task/task_result - **Bearer** — `Authorization: Bearer ` из `config.ini [common/access_token]` - **Legacy** — поле `token` в multipart-теле (из `config.ini [common/token]`) + +--- + +## Таймауты и ретраи + +Каждый синхронный запрос крутится в локальном `QEventLoop` под охраной `QTimer`. Если таймер сработал раньше, чем `reply->finished` — это **client timeout**: запрос аборитится и повторяется заново. + +### Параметры + +| Параметр | Значение | Где | +|----------|----------|-----| +| Лимит ожидания | **30 секунд** | `kDefaultTimeoutMs` — все запросы | +| Лимит ожидания (dump) | **120 секунд** | `kDumpTimeoutMs` — только `postMonitoringDump` (большой zip-архив) | +| Макс. попыток | **3** | `kMaxRequestAttempts` — 1 основная + 2 повтора | +| Пауза между попытками | **0** | Ретрай идёт сразу после abort | + +Константы и helper `awaitReply()` — в анонимном namespace в `services/net/NetworkService.cpp`. + +### Когда ретраим + +Ретрай срабатывает **только** при client timeout (наш `QTimer` сработал, reply не успел завершиться). На любых других исходах ретрая нет: + +| Исход | Ретрай? | Почему | +|-------|---------|--------| +| Client timeout (наш QTimer) | ✅ да | сеть моргнула — стоит попробовать | +| HTTP 4xx (401/404/409/…) | ❌ нет | детерминированная ошибка, повтор не поможет | +| HTTP 5xx | ❌ нет | сервер ответил — это бизнес-сбой, не транспортный | +| `ConnectionRefused` / DNS / SSL | ❌ нет | повтор не починит инфраструктурную проблему | +| `success: false` в JSON | ❌ нет | прикладная ошибка | + +После каждой неудачной попытки в лог пишется `Network timeout (POST) attempt N/3:
`; если все 3 попытки таймаутнулись — финальная строка `Timeout (POST) after 3 attempts:
` плюс запись в `GeneralLogDAO` (для `postJson`/`patchJson`). + +### Покрытие методов + +| Метод | Ретрай | Примечание | +|-------|--------|------------| +| `postJson` | ✅ | | +| `patchJson` | ✅ | | +| `getJson` | ✅ | | +| `deleteJson` | ✅ | | +| `get(path, params)` | ✅ | используется `getPayments()` (legacy) | +| `syncBanksIfEmpty()` инлайн-GET | ✅ | | +| `postDeviceScreenshot()` | ✅ | multipart пересобирается на каждой попытке | +| `postMonitoringLog()` | ✅ | multipart пересобирается на каждой попытке | +| `postMonitoringDump()` | ✅ | timeout 120s; multipart пересобирается | +| `post(QHttpMultiPart*, path)` | ❌ | multipart передаётся снаружи и удаляется вместе с `reply`; колл-сайты (`postEventStatus`/`insertTransactionData`/…) — legacy | +| `getTasks()` | ❌ | асинхронный (без `QEventLoop`); таймаут просто аборитит reply | + +### Идемпотентность + +⚠️ POST-запросы, создающие ресурсы (`/desktop/`, `/device/`, `/profile/bank_profile`, `/profile/material`), при ретрае могут создать дубль, если первая попытка дошла до сервера, но ответ потерялся. Сейчас защиты от этого нет — полагаемся на то, что 3 timeout-а подряд это редкая ситуация. Если станет проблемой — добавлять идемпотентные ключи на стороне бэкенда. diff --git a/models/db/AppLogEntry.h b/models/db/AppLogEntry.h deleted file mode 100644 index 7656e57..0000000 --- a/models/db/AppLogEntry.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once -#include -#include -#include - -struct AppLogEntry { - int id = -1; - QString source; // "ozon/LoginAndCheck", "ozon/GetLastTransactions", etc. - QString deviceId; // ADB device id, может быть пустым - QString message; // текст ошибки - QByteArray screenshot; // PNG-байты скриншота (может быть пустым) - QDateTime timestamp; -}; diff --git a/services/DeviceScreener.cpp b/services/DeviceScreener.cpp index dea3dc4..919f92d 100644 --- a/services/DeviceScreener.cpp +++ b/services/DeviceScreener.cpp @@ -22,6 +22,7 @@ #include "dao/BankProfileDAO.h" #include "dao/MaterialDAO.h" #include "dao/SettingsDAO.h" +#include "net/NetworkErrorUtils.h" DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) { const QSettings settings("config.ini", QSettings::IniFormat); @@ -131,7 +132,8 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body) QJsonValue result; if (!timeout.isActive()) { - qCritical() << "DeviceScreener apiPost timeout:" << path; + qCritical().noquote() << "DeviceScreener apiPost timeout:" + << NetworkErrorUtils::describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); @@ -141,7 +143,8 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body) else qCritical() << "DeviceScreener apiPost error:" << root; } } else { - qCritical() << "DeviceScreener apiPost network error:" << reply->errorString(); + qCritical().noquote() << "DeviceScreener apiPost error:" + << NetworkErrorUtils::describeNetError(reply, path); } reply->deleteLater(); return result; @@ -166,10 +169,12 @@ void DeviceScreener::apiPatch(const QString &path, const QJsonObject &body) { loop.exec(); if (!timeout.isActive()) { - qCritical() << "DeviceScreener apiPatch timeout:" << path; + qCritical().noquote() << "DeviceScreener apiPatch timeout:" + << NetworkErrorUtils::describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { - qCritical() << "DeviceScreener apiPatch network error:" << reply->errorString(); + qCritical().noquote() << "DeviceScreener apiPatch error:" + << NetworkErrorUtils::describeNetError(reply, path); } reply->deleteLater(); } @@ -225,10 +230,12 @@ void DeviceScreener::postScreenshotToApi(const QString &apiId, const QByteArray loop.exec(); if (!timeout.isActive()) { - qWarning() << "postScreenshotToApi timeout for device" << apiId; + qWarning().noquote() << "postScreenshotToApi timeout:" << apiId + << NetworkErrorUtils::describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { - qWarning() << "postScreenshotToApi network error:" << reply->errorString(); + qWarning().noquote() << "postScreenshotToApi error:" << apiId + << NetworkErrorUtils::describeNetError(reply, path); } else { const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); if (doc.isObject() && doc.object()["success"].toBool()) { @@ -278,7 +285,8 @@ QJsonValue DeviceScreener::apiGet(const QString &path) { QJsonValue result; if (!timeout.isActive()) { - qCritical() << "DeviceScreener apiGet timeout:" << path; + qCritical().noquote() << "DeviceScreener apiGet timeout:" + << NetworkErrorUtils::describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); @@ -288,7 +296,8 @@ QJsonValue DeviceScreener::apiGet(const QString &path) { else qCritical() << "DeviceScreener apiGet error:" << root; } } else { - qCritical() << "DeviceScreener apiGet network error:" << reply->errorString(); + qCritical().noquote() << "DeviceScreener apiGet error:" + << NetworkErrorUtils::describeNetError(reply, path); } reply->deleteLater(); return result; diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 3fa4504..de89dfc 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -34,7 +34,7 @@ static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1); static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1); -static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1); +static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false); EventHandler::EventHandler(QObject *parent) : QObject(parent) {} @@ -228,14 +228,14 @@ static void sendTaskError(const QString &type, const QString &materialId, const } static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, - const QByteArray &image, int eventId) { + const QByteArray &image, int eventId, bool toApiBase) { auto *thread = new QThread; auto *networkService = new NetworkService; networkService->moveToThread(thread); if (eventId > 0) networkService->addExtra("internal_task_id", eventId); - QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image]() { - networkService->postMonitoringLog(type, event, message, image); + QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image, toApiBase]() { + networkService->postMonitoringLog(type, event, message, image, toApiBase); emit networkService->finished(); }); QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit); @@ -430,21 +430,63 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, } f.remove(); } - sendMonitoringLog("info", "task_success", msg, screenshot, event.id); + sendMonitoringLog("info", "task_success", msg, screenshot, event.id, /*toApiBase=*/true); + + // Декрементируем материальный/профильный баланс на сумму расхода + комиссию. + // Между запусками FETCH_PROFILE это держит UI/локальный баланс + // в актуальном виде; drift по входящим всё равно сбросит следующий re-parse. + const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId); + if (acc.id != -1) { + double fee = 0.0; + const QList txList = + TransactionDAO::getTransactionsWithinHours(event.accountId, 1); + for (const auto &tx : txList) { + if (!tx.bankTrExternalId.isEmpty() + && qAbs(tx.amount) == event.amount + && tx.phone == event.phone) { + fee = tx.fee; + break; + } + } + const double delta = event.amount + fee; + + // Для Dushanbe все карты сидят на одном счёте — двигаем + // все материалы (см. memory: project_dushanbe_single_account). + if (acc.appCode == "dushanbe_city_bank") { + if (!MaterialDAO::adjustAmountByApp(acc.deviceId, acc.appCode, delta)) { + qWarning() << "[EventHandler] adjustAmountByApp failed for" + << acc.deviceId << acc.appCode; + } + } else { + if (!MaterialDAO::adjustAmount(acc.id, delta)) { + qWarning() << "[EventHandler] adjustAmount failed for material" << acc.id; + } + } + + const BankProfileInfo app = BankProfileDAO::getApplication(acc.deviceId, acc.appCode); + if (app.id != -1) { + if (!BankProfileDAO::adjustAmount(app.id, delta)) { + qWarning() << "[EventHandler] BankProfileDAO::adjustAmount failed for app" << app.id; + } + } + qDebug() << "[EventHandler] balance decremented: material" << acc.id + << "app" << acc.appCode << "delta=" << delta + << "(amount=" << event.amount << "fee=" << fee << ")"; + } } if (event.type == EventType::FetchTransactions) { const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2") .arg(event.bankName, event.externalId); const QByteArray scr = m_screenshots.take(key); - sendMonitoringLog("info", "task_success", msg, scr, event.id); + sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true); } if (event.type == EventType::FetchProfile) { const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2") .arg(event.bankName, event.externalId); const QByteArray scr = m_screenshots.take(key); - sendMonitoringLog("info", "task_success", msg, scr, event.id); + sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true); } } } @@ -496,15 +538,16 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn // для поиска BankProfileInfo (чтобы PIN поднимался из правильной записи). const QString adbSerial = account.deviceId; + // AutoConnection: worker emits из своего потока, лямбда выполняется в потоке + // EventHandler (где live `this`), там же где take()/clear() — m_screenshots + // не нужен mutex. auto connectScreenshot = [this, key](Ozon::CommonScript *w) { connect(w, &Ozon::CommonScript::screenshotReady, this, - [this, key](const QByteArray &s) { m_screenshots[key] = s; }, - Qt::DirectConnection); + [this, key](const QByteArray &s) { m_screenshots[key] = s; }); }; auto connectScreenshotDushanbe = [this, key](Dushanbe::CommonScript *w) { connect(w, &Dushanbe::CommonScript::screenshotReady, this, - [this, key](const QByteArray &s) { m_screenshots[key] = s; }, - Qt::DirectConnection); + [this, key](const QByteArray &s) { m_screenshots[key] = s; }); }; if (event.type == EventType::FetchProfile) { @@ -542,8 +585,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId); setup(w); connectScreenshot(w); connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this, - [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, - Qt::DirectConnection); + [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }); } else if (appCode == "black") setup(new Black::GetLastTransactionsScript(adbSerial, appCode)); @@ -551,8 +593,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime, account.materialId); setup(w); connectScreenshotDushanbe(w); connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, - [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, - Qt::DirectConnection); + [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }); } else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; } @@ -674,15 +715,22 @@ void EventHandler::processNextTask(const QString &deviceId) { } void EventHandler::clearAll() { + // Параллельная остановка: сначала всем requestInterruption + quit, + // потом ждём каждый. terminate() намеренно НЕ используем — он опасен + // (heap corruption, утечки DB-локов); если поток не успел остановиться, + // abandon'им — ОС зачистит ресурсы при выходе процесса. Этот же подход + // применяет watchdog в startThreadForTask. for (QThread *thread : m_threads) { if (thread->isRunning()) { thread->requestInterruption(); thread->quit(); - if (!thread->wait(3000)) { - qWarning() << "[EventHandler] Thread did not stop in 3s, terminating"; - thread->terminate(); - thread->wait(2000); - } + } + } + for (QThread *thread : m_threads) { + if (thread->isRunning() && !thread->wait(5000)) { + qWarning() << "[EventHandler] Thread did not stop in 5s on shutdown," + << "abandoning (no terminate to avoid heap corruption)"; + continue; // не deleteLater для всё ещё работающего QThread } thread->deleteLater(); } diff --git a/services/net/NetworkErrorUtils.h b/services/net/NetworkErrorUtils.h new file mode 100644 index 0000000..2a11f3c --- /dev/null +++ b/services/net/NetworkErrorUtils.h @@ -0,0 +1,80 @@ +#pragma once +#include +#include + +// Хелперы для информативного логирования сетевых ошибок. Используются +// и в NetworkService, и в DeviceScreener — категория сразу показывает +// характер сбоя: timeout / refused / DNS / SSL / HTTP-уровень. + +namespace NetworkErrorUtils { + +// Удаляет схему+хост из URL, оставляет только путь (для логов/UI без секретов). +inline QString sanitizePath(const QString &fullUrl) { + const int schemeEnd = fullUrl.indexOf(QStringLiteral("://")); + if (schemeEnd < 0) return fullUrl; + const int pathStart = fullUrl.indexOf(QLatin1Char('/'), schemeEnd + 3); + return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/"); +} + +// Категория ошибки в виде короткого ярлыка. См. QNetworkReply::NetworkError. +inline QString networkErrorCategory(QNetworkReply::NetworkError err) { + switch (err) { + case QNetworkReply::NoError: return QStringLiteral("NO_ERROR"); + case QNetworkReply::ConnectionRefusedError: return QStringLiteral("CONNECTION_REFUSED"); + case QNetworkReply::RemoteHostClosedError: return QStringLiteral("REMOTE_CLOSED"); + case QNetworkReply::HostNotFoundError: return QStringLiteral("DNS_FAIL"); + case QNetworkReply::TimeoutError: return QStringLiteral("QT_TIMEOUT"); + case QNetworkReply::OperationCanceledError: return QStringLiteral("CANCELLED"); + case QNetworkReply::SslHandshakeFailedError: return QStringLiteral("SSL_HANDSHAKE"); + case QNetworkReply::TemporaryNetworkFailureError: return QStringLiteral("TEMP_NET_FAIL"); + case QNetworkReply::NetworkSessionFailedError: return QStringLiteral("NO_NETWORK_SESSION"); + case QNetworkReply::BackgroundRequestNotAllowedError: return QStringLiteral("BG_NOT_ALLOWED"); + case QNetworkReply::TooManyRedirectsError: return QStringLiteral("TOO_MANY_REDIRECTS"); + case QNetworkReply::InsecureRedirectError: return QStringLiteral("INSECURE_REDIRECT"); + case QNetworkReply::UnknownNetworkError: return QStringLiteral("UNKNOWN_NETWORK"); + case QNetworkReply::ProxyConnectionRefusedError: return QStringLiteral("PROXY_REFUSED"); + case QNetworkReply::ProxyConnectionClosedError: return QStringLiteral("PROXY_CLOSED"); + case QNetworkReply::ProxyNotFoundError: return QStringLiteral("PROXY_NOT_FOUND"); + case QNetworkReply::ProxyTimeoutError: return QStringLiteral("PROXY_TIMEOUT"); + case QNetworkReply::ProxyAuthenticationRequiredError: return QStringLiteral("PROXY_AUTH_REQ"); + case QNetworkReply::ContentAccessDenied: return QStringLiteral("HTTP_403"); + case QNetworkReply::ContentOperationNotPermittedError:return QStringLiteral("OP_NOT_PERMITTED"); + case QNetworkReply::ContentNotFoundError: return QStringLiteral("HTTP_404"); + case QNetworkReply::AuthenticationRequiredError: return QStringLiteral("HTTP_401"); + case QNetworkReply::ContentReSendError: return QStringLiteral("RESEND_REQUIRED"); + case QNetworkReply::ContentConflictError: return QStringLiteral("HTTP_409"); + case QNetworkReply::ContentGoneError: return QStringLiteral("HTTP_410"); + case QNetworkReply::InternalServerError: return QStringLiteral("HTTP_500"); + case QNetworkReply::OperationNotImplementedError: return QStringLiteral("HTTP_501"); + case QNetworkReply::ServiceUnavailableError: return QStringLiteral("HTTP_503"); + case QNetworkReply::UnknownServerError: return QStringLiteral("UNKNOWN_SERVER"); + case QNetworkReply::ProtocolUnknownError: return QStringLiteral("PROTO_UNKNOWN"); + case QNetworkReply::ProtocolInvalidOperationError: return QStringLiteral("PROTO_INVALID_OP"); + case QNetworkReply::UnknownContentError: return QStringLiteral("UNKNOWN_CONTENT"); + case QNetworkReply::ProtocolFailure: return QStringLiteral("PROTO_FAILURE"); + default: return QStringLiteral("err_%1").arg(static_cast(err)); + } +} + +// Развёрнутое описание сетевой ошибки: HTTP-код + категория или просто +// категория + Qt errorString. Эндпоинт — без хоста. +inline QString describeNetError(QNetworkReply *reply, const QString &path) { + const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QString endpoint = sanitizePath(path); + const QString category = networkErrorCategory(reply->error()); + if (httpCode > 0) { + return QStringLiteral("HTTP %1 [%2] %3").arg(httpCode).arg(category, endpoint); + } + return QStringLiteral("[%1] %2 (qt: %3)").arg(category, endpoint, reply->errorString()); +} + +// Случай нашего QTimer-таймаута (reply ещё не завершился). reply->error() +// обычно ещё NoError — помечаем категорию явно как CLIENT_TIMEOUT_30s. +inline QString describeClientTimeout(QNetworkReply *reply, const QString &path) { + const QString endpoint = sanitizePath(path); + const QString qtCategory = networkErrorCategory(reply->error()); + return QStringLiteral("[CLIENT_TIMEOUT_30s] %1 (qt-state=%2 errorString=\"%3\")") + .arg(endpoint, qtCategory, reply->errorString()); +} + +} // namespace NetworkErrorUtils diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 4c3cc78..06b2119 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -1,4 +1,5 @@ #include "NetworkService.h" +#include "NetworkErrorUtils.h" #include #include @@ -27,24 +28,34 @@ #include "db/EventInfo.h" // Убирает полный URL (хост:порт) из сообщений об ошибках, оставляя только -// путь эндпоинта и HTTP-код. Нужно, чтобы адрес сервера не попадал в -// логи/UI/general_logs/application.log. -static QString sanitizePath(const QString &fullUrl) { - const int schemeEnd = fullUrl.indexOf("://"); - if (schemeEnd < 0) return fullUrl; - const int pathStart = fullUrl.indexOf('/', schemeEnd + 3); - return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/"); +// Хелперы для логирования сетевых ошибок вынесены в NetworkErrorUtils.h +// чтобы переиспользовать их в DeviceScreener. +using NetworkErrorUtils::sanitizePath; +using NetworkErrorUtils::describeNetError; +using NetworkErrorUtils::describeClientTimeout; + +namespace { +// Прокручивает event loop пока reply не завершится либо не сработает client timeout. +// true — reply finished (success или network error), false — сработал наш QTimer. +bool awaitReply(QNetworkReply *reply, int timeoutMs) { + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QObject::connect(reply, &QNetworkReply::errorOccurred, &loop, + [&loop](QNetworkReply::NetworkError) { loop.quit(); }); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(timeoutMs); + loop.exec(); + return timer.isActive(); } -static QString sanitizeNetError(QNetworkReply *reply, const QString &path) { - const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const QString endpoint = sanitizePath(path); - if (httpCode > 0) { - return QStringLiteral("HTTP %1 %2").arg(httpCode).arg(endpoint); - } - // Сетевой уровень (не HTTP) — выводим enum-код вместо строки с URL. - return QStringLiteral("net_err=%1 %2").arg(static_cast(reply->error())).arg(endpoint); -} +// На client timeout повторяем запрос ещё 2 раза (всего до 3 попыток). +// На HTTP/network ошибках не ретраим — они детерминированы (401/409/refused/etc). +constexpr int kMaxRequestAttempts = 3; +constexpr int kDefaultTimeoutMs = 30000; +constexpr int kDumpTimeoutMs = 120000; +} // namespace NetworkService::NetworkService(QObject *parent) : QObject(parent) { const QSettings settings("config.ini", QSettings::IniFormat); @@ -119,7 +130,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path) QJsonValue result; if (!timeout.isActive()) { - qCritical() << "Таймаут запроса:" << sanitizePath(path); + qCritical().noquote() << "Network timeout:" << describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QByteArray raw = reply->readAll(); @@ -134,7 +145,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path) } } } else { - qCritical() << "Ошибка:" << sanitizeNetError(reply, path); + qCritical() << "Ошибка:" << describeNetError(reply, path); } reply->deleteLater(); return result; @@ -149,24 +160,24 @@ QJsonArray NetworkService::get(const QString &path, const QMap url.setQuery(query); const QNetworkRequest request(url); - QNetworkReply *reply = m_manager->get(request); - QScopedPointer guard(reply); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { - loop.quit(); - }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); + QNetworkReply *reply = nullptr; + bool timedOut = true; + QString lastTimeoutDetail; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } + reply = m_manager->get(request); + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + lastTimeoutDetail = describeClientTimeout(reply, path); + qCritical().noquote() << "Network timeout (GET) attempt" << attempt + << "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail; + reply->abort(); + } QJsonArray result; - if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (GET):" << sanitizePath(path); - reply->abort(); + if (timedOut) { + qCritical().noquote() << "Network timeout (GET) after" + << kMaxRequestAttempts << "attempts:" << lastTimeoutDetail; } else if (reply->error() == QNetworkReply::NoError) { const QByteArray raw = reply->readAll(); QJsonParseError err; @@ -250,30 +261,32 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } - QNetworkReply *reply = m_manager->post(request, QJsonDocument(body).toJson()); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { - loop.quit(); - }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); - + const QByteArray bodyBytes = QJsonDocument(body).toJson(); const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); - QJsonValue result; const QString shortPath = sanitizePath(path); - if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (postJson):" << shortPath; + + QNetworkReply *reply = nullptr; + bool timedOut = true; + QString lastTimeoutDetail; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } + reply = m_manager->post(request, bodyBytes); + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + lastTimeoutDetail = describeClientTimeout(reply, path); + qCritical().noquote() << "Network timeout (POST) attempt" << attempt + << "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail; reply->abort(); + } + + QJsonValue result; + if (timedOut) { GeneralLogDAO::insert("CRITICAL", "NetworkService", - "Timeout (POST): " + shortPath, {}, reqBodyStr, {}); + "Timeout (POST) after " + QString::number(kMaxRequestAttempts) + + " attempts: " + lastTimeoutDetail, {}, reqBodyStr, {}); result = QJsonValue::Undefined; } else if (reply->error() != QNetworkReply::NoError) { const QByteArray responseBody = reply->readAll(); - const QString errSummary = sanitizeNetError(reply, path); + const QString errSummary = describeNetError(reply, path); qCritical().noquote() << "Network error (POST):" << errSummary << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << "\n response body:" << responseBody; @@ -482,28 +495,31 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } - QNetworkReply *reply = m_manager->sendCustomRequest(request, "PATCH", QJsonDocument(body).toJson()); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { loop.quit(); }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); - + const QByteArray bodyBytes = QJsonDocument(body).toJson(); const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); - QJsonValue result; - if (!timeout.isActive()) { - const QString shortPath = sanitizePath(path); - qCritical() << "Таймаут запроса (PATCH):" << shortPath; + + QNetworkReply *reply = nullptr; + bool timedOut = true; + QString lastTimeoutDetail; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } + reply = m_manager->sendCustomRequest(request, "PATCH", bodyBytes); + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + lastTimeoutDetail = describeClientTimeout(reply, path); + qCritical().noquote() << "Network timeout (PATCH) attempt" << attempt + << "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail; reply->abort(); + } + + QJsonValue result; + if (timedOut) { GeneralLogDAO::insert("CRITICAL", "NetworkService", - "Timeout (PATCH): " + shortPath, {}, reqBodyStr, {}); + "Timeout (PATCH) after " + QString::number(kMaxRequestAttempts) + + " attempts: " + lastTimeoutDetail, {}, reqBodyStr, {}); } else if (reply->error() != QNetworkReply::NoError) { const QByteArray responseBody = reply->readAll(); const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const QString errSummary = sanitizeNetError(reply, path); + const QString errSummary = describeNetError(reply, path); qCritical().noquote() << "Network error (PATCH):" << errSummary << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << "\n response body:" << responseBody; @@ -567,23 +583,26 @@ QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) { request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } - QNetworkReply *reply = m_manager->deleteResource(request); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { loop.quit(); }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); + QNetworkReply *reply = nullptr; + bool timedOut = true; + QString lastTimeoutDetail; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } + reply = m_manager->deleteResource(request); + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + lastTimeoutDetail = describeClientTimeout(reply, path); + qCritical().noquote() << "Network timeout (DELETE) attempt" << attempt + << "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail; + reply->abort(); + } QJsonValue result; - if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (DELETE):" << sanitizePath(path); - reply->abort(); + if (timedOut) { + qCritical().noquote() << "Network timeout (DELETE) after" + << kMaxRequestAttempts << "attempts:" << lastTimeoutDetail; result = QJsonValue::Undefined; } else if (reply->error() != QNetworkReply::NoError) { - qCritical().noquote() << "Network error (DELETE):" << sanitizeNetError(reply, path) + qCritical().noquote() << "Network error (DELETE):" << describeNetError(reply, path) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); result = QJsonValue::Undefined; } else { @@ -611,25 +630,26 @@ QJsonValue NetworkService::getJson(const QString &path, bool withAuth) { request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } - QNetworkReply *reply = m_manager->get(request); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { - loop.quit(); - }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); + QNetworkReply *reply = nullptr; + bool timedOut = true; + QString lastTimeoutDetail; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } + reply = m_manager->get(request); + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + lastTimeoutDetail = describeClientTimeout(reply, path); + qCritical().noquote() << "Network timeout (getJson) attempt" << attempt + << "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail; + reply->abort(); + } QJsonValue result; - if (!timeout.isActive()) { - qCritical() << "Таймаут запроса (getJson):" << sanitizePath(path); - reply->abort(); + if (timedOut) { + qCritical().noquote() << "Network timeout (getJson) after" + << kMaxRequestAttempts << "attempts:" << lastTimeoutDetail; result = QJsonValue::Undefined; } else if (reply->error() != QNetworkReply::NoError) { - qCritical().noquote() << "Network error (GET):" << sanitizeNetError(reply, path) + qCritical().noquote() << "Network error (GET):" << describeNetError(reply, path) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); result = QJsonValue::Undefined; } else { @@ -803,20 +823,22 @@ void NetworkService::syncBanksIfEmpty() { qDebug() << "[syncBanksIfEmpty] banks table empty, downloading..."; - QNetworkRequest request(QUrl("https://api.brtservice.io/api/rest/info/")); - QNetworkReply *reply = m_manager->get(request); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { loop.quit(); }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); + const QString path = QStringLiteral("https://api.brtservice.io/api/rest/info/"); + QNetworkRequest request{QUrl(path)}; - if (!timeout.isActive()) { - qWarning() << "[syncBanksIfEmpty] timeout"; + QNetworkReply *reply = nullptr; + bool timedOut = true; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } + reply = m_manager->get(request); + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + qWarning().noquote() << "[syncBanksIfEmpty] timeout attempt" << attempt + << "/" << kMaxRequestAttempts; reply->abort(); + } + + if (timedOut) { + qWarning() << "[syncBanksIfEmpty] timeout after" << kMaxRequestAttempts << "attempts"; reply->deleteLater(); return; } @@ -920,7 +942,7 @@ void NetworkService::syncDevicesFromServer(const QString &desktopId) { } } -void NetworkService::syncCurrentProfile() { +bool NetworkService::syncCurrentProfile() { // Локаль — источник истины. Логика: GET current_profile, построить // map id→state, пройти по локальным bank_profiles/materials, для // каждого расхождения отправить PATCH с локальным значением. @@ -928,7 +950,7 @@ void NetworkService::syncCurrentProfile() { const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true); if (profileResult.isUndefined() || profileResult.isNull()) { qWarning() << "[syncCurrentProfile] GET current_profile failed"; - return; + return false; } const QJsonObject profileData = profileResult.toObject(); @@ -1009,6 +1031,7 @@ void NetworkService::syncCurrentProfile() { qDebug() << "[syncCurrentProfile] done — pushed" << pushedProfiles << "profiles," << pushedMaterials << "materials"; + return true; } void NetworkService::postBankProfile() { @@ -1227,37 +1250,37 @@ void NetworkService::postDeviceScreenshot() { return; } - auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - QHttpPart filePart; - filePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg"); - filePart.setHeader( - QNetworkRequest::ContentDispositionHeader, - QStringLiteral("form-data; name=\"file\"; filename=\"%1.jpg\"").arg(m_deviceId) - ); - filePart.setBody(m_json); - multiPart->append(filePart); - const QUrl url(m_apiBase + m_pathDeviceScreenshot + device.apiId); QNetworkRequest request(url); request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); - QNetworkReply *reply = m_manager->post(request, multiPart); - multiPart->setParent(reply); + QNetworkReply *reply = nullptr; + bool timedOut = true; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { - loop.quit(); - }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + QHttpPart filePart; + filePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg"); + filePart.setHeader( + QNetworkRequest::ContentDispositionHeader, + QStringLiteral("form-data; name=\"file\"; filename=\"%1.jpg\"").arg(m_deviceId) + ); + filePart.setBody(m_json); + multiPart->append(filePart); - if (!timeout.isActive()) { - qWarning() << "[NetworkService] postDeviceScreenshot timeout for device" << device.apiId; + reply = m_manager->post(request, multiPart); + multiPart->setParent(reply); + + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + qWarning() << "[NetworkService] postDeviceScreenshot timeout attempt" + << attempt << "/" << kMaxRequestAttempts << "for device" << device.apiId; reply->abort(); + } + + if (timedOut) { + qWarning() << "[NetworkService] postDeviceScreenshot timeout after" + << kMaxRequestAttempts << "attempts for device" << device.apiId; } else if (reply->error() != QNetworkReply::NoError) { qWarning() << "[NetworkService] postDeviceScreenshot failed:" << reply->errorString(); } else { @@ -1455,49 +1478,59 @@ void NetworkService::postTaskResult() { } void NetworkService::postMonitoringLog(const QString &type, const QString &event, - const QString &message, const QByteArray &image) { - auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - addFormField(multiPart, "type", type.toUpper()); - addFormField(multiPart, "event", event); - addFormField(multiPart, "message", message); + const QString &message, const QByteArray &image, + bool toApiBase) { + QNetworkRequest request; + if (toApiBase) { + ensureValidToken(); + request.setUrl(QUrl(m_apiBase + m_pathMonitoringLog)); + if (!m_accessToken.isEmpty()) { + request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); + } + } else { + request.setUrl(QUrl(m_monitoringBase + m_pathMonitoringLog)); + if (!m_monitoringToken.isEmpty()) { + request.setRawHeader("X-Monitoring-Token", m_monitoringToken.toUtf8()); + } + } const int internalTaskId = m_extraData.value("internal_task_id").toInt(); - if (internalTaskId > 0) { - addFormField(multiPart, "internal_task_id", QString::number(internalTaskId)); - } - if (!image.isEmpty()) { - QHttpPart imagePart; - imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); - imagePart.setHeader( - QNetworkRequest::ContentDispositionHeader, - QStringLiteral("form-data; name=\"image\"; filename=\"screenshot.png\"") - ); - imagePart.setBody(image); - multiPart->append(imagePart); - } + QNetworkReply *reply = nullptr; + bool timedOut = true; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } - const QUrl url(m_monitoringBase + m_pathMonitoringLog); - QNetworkRequest request(url); - if (!m_monitoringToken.isEmpty()) { - request.setRawHeader("X-Monitoring-Token", m_monitoringToken.toUtf8()); - } + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + addFormField(multiPart, "type", type.toUpper()); + addFormField(multiPart, "event", event); + addFormField(multiPart, "message", message); + if (internalTaskId > 0) { + addFormField(multiPart, "internal_task_id", QString::number(internalTaskId)); + } + if (!image.isEmpty()) { + QHttpPart imagePart; + imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); + imagePart.setHeader( + QNetworkRequest::ContentDispositionHeader, + QStringLiteral("form-data; name=\"image\"; filename=\"screenshot.png\"") + ); + imagePart.setBody(image); + multiPart->append(imagePart); + } - QNetworkReply *reply = m_manager->post(request, multiPart); - multiPart->setParent(reply); + reply = m_manager->post(request, multiPart); + multiPart->setParent(reply); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); - - if (!timeout.isActive()) { - qWarning() << "[NetworkService] postMonitoringLog timeout"; + if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; } + qWarning() << "[NetworkService] postMonitoringLog timeout attempt" + << attempt << "/" << kMaxRequestAttempts; reply->abort(); + } + + if (timedOut) { + qWarning() << "[NetworkService] postMonitoringLog timeout after" + << kMaxRequestAttempts << "attempts"; } else if (reply->error() != QNetworkReply::NoError) { qWarning() << "[NetworkService] postMonitoringLog failed:" << reply->errorString() << "\n response body:" << reply->readAll(); @@ -1511,31 +1544,6 @@ void NetworkService::postMonitoringDump(const QString &text, const QByteArray &a qDebug() << "[NetworkService] postMonitoringDump enter — text.size=" << text.size() << "archive.size=" << archive.size() << "image.size=" << image.size() << "archiveFilename=" << archiveFilename; - auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - addFormField(multiPart, "text", text); - - if (!archive.isEmpty()) { - QHttpPart filePart; - filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/zip"); - filePart.setHeader( - QNetworkRequest::ContentDispositionHeader, - QStringLiteral("form-data; name=\"file\"; filename=\"%1\"").arg(archiveFilename) - ); - filePart.setBody(archive); - multiPart->append(filePart); - } - - if (!image.isEmpty()) { - QHttpPart imagePart; - imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); - imagePart.setHeader( - QNetworkRequest::ContentDispositionHeader, - QStringLiteral("form-data; name=\"image\"; filename=\"final_screen.png\"") - ); - imagePart.setBody(image); - multiPart->append(imagePart); - } const QUrl url(m_monitoringBase + m_pathMonitoringDump); QNetworkRequest request(url); @@ -1544,20 +1552,47 @@ void NetworkService::postMonitoringDump(const QString &text, const QByteArray &a } qDebug() << "[NetworkService] postMonitoringDump POST" << url.toString(); - QNetworkReply *reply = m_manager->post(request, multiPart); - multiPart->setParent(reply); - QEventLoop loop; - QTimer timeout; - timeout.setSingleShot(true); - connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(120000); - loop.exec(); + QNetworkReply *reply = nullptr; + bool timedOut = true; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } - if (!timeout.isActive()) { - qWarning() << "[NetworkService] postMonitoringDump timeout"; + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + addFormField(multiPart, "text", text); + if (!archive.isEmpty()) { + QHttpPart filePart; + filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/zip"); + filePart.setHeader( + QNetworkRequest::ContentDispositionHeader, + QStringLiteral("form-data; name=\"file\"; filename=\"%1\"").arg(archiveFilename) + ); + filePart.setBody(archive); + multiPart->append(filePart); + } + if (!image.isEmpty()) { + QHttpPart imagePart; + imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png"); + imagePart.setHeader( + QNetworkRequest::ContentDispositionHeader, + QStringLiteral("form-data; name=\"image\"; filename=\"final_screen.png\"") + ); + imagePart.setBody(image); + multiPart->append(imagePart); + } + + reply = m_manager->post(request, multiPart); + multiPart->setParent(reply); + + if (awaitReply(reply, kDumpTimeoutMs)) { timedOut = false; break; } + qWarning() << "[NetworkService] postMonitoringDump timeout attempt" + << attempt << "/" << kMaxRequestAttempts; reply->abort(); + } + + if (timedOut) { + qWarning() << "[NetworkService] postMonitoringDump timeout after" + << kMaxRequestAttempts << "attempts"; } else if (reply->error() != QNetworkReply::NoError) { qWarning() << "[NetworkService] postMonitoringDump failed:" << reply->errorString() << "\n response body:" << reply->readAll(); @@ -1634,12 +1669,8 @@ void NetworkService::scheduleNextStatusSync() { } bool NetworkService::runPeriodicSyncTick() { - // Фаза 2 — обёртка с грубым detect-успех/неудача. В фазе 3 syncCurrentProfile - // будет переписан и сам вернёт bool; пока считаем тик неуспешным только при - // протухшем токене (более тонкая детекция придёт с рефакторингом). if (!ensureValidToken()) return false; - syncCurrentProfile(); - return true; + return syncCurrentProfile(); } QMutex NetworkService::s_keyRegistryMutex; diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index a027364..2a7761b 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -59,7 +59,9 @@ public slots: void updateTransactionData(); void loginAndSetup(); - void syncCurrentProfile(); + // true — GET current_profile прошёл; false — таймаут/сеть. Используется + // health-счётчиком в runPeriodicSyncTick (правило #9). + bool syncCurrentProfile(); // Включает периодическую синхронизацию статусов (60s). Должно быть вызвано // ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener. @@ -81,8 +83,12 @@ public slots: void postTaskResult(); + // toApiBase=true — POST на m_apiBase + /monitoring/log с Bearer-токеном + // (старый эндпоинт, используется для task_success). По умолчанию false — + // monitoringBase + X-Monitoring-Token (Telegram-релей, для ошибок). void postMonitoringLog(const QString &type, const QString &event, - const QString &message, const QByteArray &image); + const QString &message, const QByteArray &image, + bool toApiBase = false); void postMonitoringDump(const QString &text, const QByteArray &archive, const QByteArray &image, diff --git a/tests/mock_server/default_profile.json b/tests/mock_server/default_profile.json index 39a5514..0519d5c 100644 --- a/tests/mock_server/default_profile.json +++ b/tests/mock_server/default_profile.json @@ -1,27 +1,7 @@ { "bank_profiles": [ { - "id": "047fd3d6-f2a2-40e0-9a39-1d11422e8832", - "bank_name": "dushanbe_city_bank", - "state": "enabled", - "phone_number": "+992185555519", - "first_name": "Мехрубон", - "middle_name": null, - "last_name": "Имомкулзода", - "currency": 972, - "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", - "device_label": "Pixel 7", - "materials": [ - { - "id": "83606831-7688-42cf-b2d7-2034e04f68f2", - "state": "enabled", - "card_number": "9762000207113258", - "currency": 972 - } - ] - }, - { - "id": "21aa26c8-ea89-45ea-a283-ee6d55ead82b", + "id": "9b2f1a38-190c-4cb0-b026-eef721db36ed", "bank_name": "ozon", "state": "enabled", "phone_number": "+79538038318", @@ -29,33 +9,13 @@ "middle_name": null, "last_name": "Терехина", "currency": 643, - "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", + "device_id": "cca4ec91-26e4-42e0-81ce-5226382b6cbc", "device_label": "Pixel 7", "materials": [ { - "id": "3a2ca76d-f735-4006-b521-66bccaf821a4", + "id": "e1234f11-b99d-4930-9f86-75148601f683", "state": "enabled", - "card_number": "2204321086109865", - "currency": 643 - } - ] - }, - { - "id": "524c4cfe-9522-4434-90d1-9ee80aae92b2", - "bank_name": "ozon", - "state": "enabled", - "phone_number": "+79855532896", - "first_name": "Владислав", - "middle_name": null, - "last_name": "Касаткин", - "currency": 643, - "device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f", - "device_label": "TECNO KM4", - "materials": [ - { - "id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e", - "state": "enabled", - "card_number": "2204320439130982", + "card_number": "2204321035207919", "currency": 643 } ] diff --git a/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json deleted file mode 100644 index 0b84a81..0000000 --- a/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "bank_name": "ozon", - "material_id": "3a2ca76d-f735-4006-b521-66bccaf821a4", - "type": "FETCH_PROFILE", - "body": {} -} diff --git a/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json b/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json deleted file mode 100644 index ccd4e81..0000000 --- a/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "bank_name": "ozon", - "material_id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e", - "type": "FETCH_PROFILE", - "body": {} -} diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index cefc14b..5dde497 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -15,7 +15,6 @@ #include "TutorialWindow.h" #include "bank/BankListWindow.h" #include "event/EventWindow.h" -#include "log/LogWindow.h" #include "log/FileLogWindow.h" #include "dao/GeneralLogDAO.h" #include "net/NetworkService.h" diff --git a/views/log/LogWindow.cpp b/views/log/LogWindow.cpp deleted file mode 100644 index 72901d1..0000000 --- a/views/log/LogWindow.cpp +++ /dev/null @@ -1,154 +0,0 @@ -#include "LogWindow.h" - -#include -#include -#include -#include -#include -#include -#include - -#include "dao/AppLogDAO.h" - -static constexpr int COL_ID = 0; -static constexpr int COL_TIME = 1; -static constexpr int COL_SOURCE = 2; -static constexpr int COL_DEVICE = 3; -static constexpr int COL_MESSAGE = 4; - -LogWindow::LogWindow(QWidget *parent) : QDialog(parent) { - setWindowTitle("Логи ошибок"); - resize(1100, 650); - - // ── Таблица ─────────────────────────────────────────────────────────────── - m_table = new QTableWidget(this); - m_table->setColumnCount(5); - m_table->setHorizontalHeaderLabels({"#", "Время", "Источник", "Устройство", "Сообщение"}); - m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); - m_table->setSelectionBehavior(QAbstractItemView::SelectRows); - m_table->setSelectionMode(QAbstractItemView::SingleSelection); - m_table->verticalHeader()->hide(); - m_table->horizontalHeader()->setStretchLastSection(true); - m_table->setColumnWidth(COL_ID, 45); - m_table->setColumnWidth(COL_TIME, 145); - m_table->setColumnWidth(COL_SOURCE, 170); - m_table->setColumnWidth(COL_DEVICE, 120); - - // ── Панель скриншота ────────────────────────────────────────────────────── - m_screenshotLabel = new QLabel("Выберите строку для просмотра скриншота"); - m_screenshotLabel->setAlignment(Qt::AlignCenter); - m_screenshotLabel->setMinimumWidth(320); - m_screenshotLabel->setWordWrap(true); - m_screenshotLabel->setStyleSheet("background: #1a1a1a; color: #888; border-radius: 4px;"); - - auto *scrollArea = new QScrollArea(this); - scrollArea->setWidget(m_screenshotLabel); - scrollArea->setWidgetResizable(true); - scrollArea->setMinimumWidth(320); - - // ── Сплиттер ───────────────────────────────────────────────────────────── - auto *splitter = new QSplitter(Qt::Horizontal, this); - splitter->addWidget(m_table); - splitter->addWidget(scrollArea); - splitter->setStretchFactor(0, 7); - splitter->setStretchFactor(1, 3); - - // ── Пагинация ───────────────────────────────────────────────────────────── - m_prevBtn = new QPushButton("←", this); - m_nextBtn = new QPushButton("→", this); - m_pageLabel = new QLabel(this); - m_pageLabel->setAlignment(Qt::AlignCenter); - m_pageLabel->setMinimumWidth(140); - - auto *refreshBtn = new QPushButton("Обновить", this); - - auto *paginationLayout = new QHBoxLayout; - paginationLayout->addWidget(m_prevBtn); - paginationLayout->addWidget(m_pageLabel); - paginationLayout->addWidget(m_nextBtn); - paginationLayout->addStretch(); - paginationLayout->addWidget(refreshBtn); - - auto *mainLayout = new QVBoxLayout(this); - mainLayout->addWidget(splitter, 1); - mainLayout->addLayout(paginationLayout); - - // ── Сигналы ─────────────────────────────────────────────────────────────── - connect(m_prevBtn, &QPushButton::clicked, this, [this]() { - if (m_page > 0) { --m_page; loadPage(); } - }); - connect(m_nextBtn, &QPushButton::clicked, this, [this]() { - if (m_page < m_totalPages - 1) { ++m_page; loadPage(); } - }); - connect(refreshBtn, &QPushButton::clicked, this, [this]() { - m_page = 0; - loadPage(); - }); - connect(m_table, &QTableWidget::currentCellChanged, this, - [this](int row, int, int, int) { - if (row < 0) return; - const int logId = m_table->item(row, COL_ID)->text().toInt(); - showScreenshot(logId); - }); - - loadPage(); -} - -void LogWindow::loadPage() { - const int total = AppLogDAO::getTotalCount(); - m_totalPages = qMax(1, (total + m_pageSize - 1) / m_pageSize); - m_page = qBound(0, m_page, m_totalPages - 1); - - const QList entries = AppLogDAO::getLogs(m_page, m_pageSize); - - m_table->setRowCount(static_cast(entries.size())); - for (int i = 0; i < entries.size(); ++i) { - const auto &e = entries[i]; - m_table->setItem(i, COL_ID, new QTableWidgetItem(QString::number(e.id))); - m_table->setItem(i, COL_TIME, new QTableWidgetItem( - e.timestamp.toLocalTime().toString("dd.MM.yy HH:mm:ss"))); - m_table->setItem(i, COL_SOURCE, new QTableWidgetItem(e.source)); - m_table->setItem(i, COL_DEVICE, new QTableWidgetItem(e.deviceId)); - m_table->setItem(i, COL_MESSAGE, new QTableWidgetItem(e.message)); - } - - // Сбрасываем скриншот при смене страницы - m_screenshotLabel->setPixmap({}); - m_screenshotLabel->setText("Выберите строку для просмотра скриншота"); - - updatePagination(); -} - -void LogWindow::updatePagination() { - m_pageLabel->setText(QString("Страница %1 из %2").arg(m_page + 1).arg(m_totalPages)); - m_prevBtn->setEnabled(m_page > 0); - m_nextBtn->setEnabled(m_page < m_totalPages - 1); -} - -void LogWindow::showScreenshot(int logId) { - const QByteArray bytes = AppLogDAO::getScreenshot(logId); - if (bytes.isEmpty()) { - m_originalPixmap = {}; - m_screenshotLabel->setPixmap({}); - m_screenshotLabel->setText("Скриншот отсутствует"); - return; - } - if (!m_originalPixmap.loadFromData(bytes)) { - m_originalPixmap = {}; - m_screenshotLabel->setText("Не удалось загрузить скриншот"); - return; - } - rescaleScreenshot(); -} - -void LogWindow::rescaleScreenshot() { - if (m_originalPixmap.isNull()) return; - m_screenshotLabel->setPixmap( - m_originalPixmap.scaled(m_screenshotLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation) - ); -} - -void LogWindow::resizeEvent(QResizeEvent *event) { - QDialog::resizeEvent(event); - rescaleScreenshot(); -} diff --git a/views/log/LogWindow.h b/views/log/LogWindow.h deleted file mode 100644 index d1a2837..0000000 --- a/views/log/LogWindow.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once -#include -#include -#include -#include - -class LogWindow final : public QDialog { - Q_OBJECT - -public: - explicit LogWindow(QWidget *parent = nullptr); - -private: - QTableWidget *m_table; - QLabel *m_screenshotLabel; - QLabel *m_pageLabel; - QPushButton *m_prevBtn; - QPushButton *m_nextBtn; - - int m_page = 0; - int m_pageSize = 50; - int m_totalPages = 1; - - QPixmap m_originalPixmap; - - void loadPage(); - void updatePagination(); - void showScreenshot(int logId); - void rescaleScreenshot(); - -protected: - void resizeEvent(QResizeEvent *event) override; -};