1
0
forked from BRT/arc

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.

This commit is contained in:
trnsmkot 2026-05-23 18:37:49 +05:00
parent e4099ce481
commit 71c0ac10f7
21 changed files with 569 additions and 621 deletions

View File

@ -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 в верхней панели

View File

@ -1,97 +0,0 @@
#include "AppLogDAO.h"
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>
#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<AppLogEntry> AppLogDAO::getLogs(int page, int pageSize) {
QList<AppLogEntry> 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<AppLogEntry> AppLogDAO::getLogsWithScreenshots(int limit) {
QList<AppLogEntry> 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;
}

View File

@ -1,20 +0,0 @@
#pragma once
#include <QByteArray>
#include <QList>
#include "db/AppLogEntry.h"
class AppLogDAO {
public:
[[nodiscard]] static bool insert(const AppLogEntry &entry);
// Возвращает записи без BLOB-скриншота (лёгкая загрузка для таблицы)
[[nodiscard]] static QList<AppLogEntry> getLogs(int page, int pageSize);
[[nodiscard]] static int getTotalCount();
// Загружает скриншот отдельно по id (по клику в UI)
[[nodiscard]] static QByteArray getScreenshot(int id);
// Все записи со скриншотами (для экспорта)
[[nodiscard]] static QList<AppLogEntry> getLogsWithScreenshots(int limit = 100);
};

View File

@ -44,25 +44,42 @@ BankInfo BankDAO::findByAlias(const QString &alias) {
void BankDAO::replaceAll(const QList<BankInfo> &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();
}
}

View File

@ -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"(

View File

@ -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);

View File

@ -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();

View File

@ -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<MaterialInfo> getAccounts(const QString &deviceId, const QString &appName);
[[nodiscard]] static MaterialInfo getAccountById(int accountId);

View File

@ -102,3 +102,54 @@ pathTaskResult = /api/v1/task/task_result
- **Bearer**`Authorization: Bearer <access_token>` из `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: <details>`; если все 3 попытки таймаутнулись — финальная строка `Timeout (POST) after 3 attempts: <details>` плюс запись в `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-а подряд это редкая ситуация. Если станет проблемой — добавлять идемпотентные ключи на стороне бэкенда.

View File

@ -1,13 +0,0 @@
#pragma once
#include <QString>
#include <QByteArray>
#include <QDateTime>
struct AppLogEntry {
int id = -1;
QString source; // "ozon/LoginAndCheck", "ozon/GetLastTransactions", etc.
QString deviceId; // ADB device id, может быть пустым
QString message; // текст ошибки
QByteArray screenshot; // PNG-байты скриншота (может быть пустым)
QDateTime timestamp;
};

View File

@ -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;

View File

@ -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<TransactionInfo> 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();
}

View File

@ -0,0 +1,80 @@
#pragma once
#include <QNetworkReply>
#include <QString>
// Хелперы для информативного логирования сетевых ошибок. Используются
// и в 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<int>(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

View File

@ -1,4 +1,5 @@
#include "NetworkService.h"
#include "NetworkErrorUtils.h"
#include <QApplication>
#include <QFile>
@ -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<int>(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<QString, QString>
url.setQuery(query);
const QNetworkRequest request(url);
QNetworkReply *reply = m_manager->get(request);
QScopedPointer<QNetworkReply, QScopedPointerDeleteLater> 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;

View File

@ -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,

View File

@ -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
}
]

View File

@ -1,6 +0,0 @@
{
"bank_name": "ozon",
"material_id": "3a2ca76d-f735-4006-b521-66bccaf821a4",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -1,6 +0,0 @@
{
"bank_name": "ozon",
"material_id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -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"

View File

@ -1,154 +0,0 @@
#include "LogWindow.h"
#include <QHBoxLayout>
#include <QHeaderView>
#include <QPixmap>
#include <QResizeEvent>
#include <QScrollArea>
#include <QSplitter>
#include <QVBoxLayout>
#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<AppLogEntry> entries = AppLogDAO::getLogs(m_page, m_pageSize);
m_table->setRowCount(static_cast<int>(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();
}

View File

@ -1,33 +0,0 @@
#pragma once
#include <QDialog>
#include <QLabel>
#include <QPushButton>
#include <QTableWidget>
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;
};