From 067b16f7003505701bfd7601b858ffc9c0abd06f Mon Sep 17 00:00:00 2001 From: trnsmkot Date: Tue, 26 May 2026 19:34:16 +0500 Subject: [PATCH] Add `transactionId` support to `EventHandler`, `EventDAO`, and related components for improved event tracking and error handling. Update database queries, error reporting, and UI logic accordingly. --- config.ini | 1 + database/DatabaseManager.cpp | 32 +++++----- database/dao/EventDAO.cpp | 32 +++++----- migrations.sql | 31 +++++----- models/db/EventInfo.h | 1 + services/EventHandler.cpp | 33 +++++++---- services/net/NetworkService.cpp | 75 +++++++++++++++++++++++ services/net/NetworkService.h | 21 +++++++ views/bank/AccountSettingsWindow.cpp | 49 ++++++++------- views/bank/AccountSettingsWindow.h | 2 +- views/bank/BankSettingsWindow.cpp | 7 ++- views/device/DeviceWidget.cpp | 89 +++++++++++++++------------- 12 files changed, 253 insertions(+), 120 deletions(-) diff --git a/config.ini b/config.ini index 7590a89..07fb67c 100644 --- a/config.ini +++ b/config.ini @@ -18,6 +18,7 @@ pathDesktopCreate=/api/v1/desktop/ pathDevice=/api/v1/device/ pathDeviceScreenshot=/api/v1/device/add_screenshot/ pathMaterial=/api/v1/profile/material +pathProfilePing=/api/v1/profile/ping pathMonitoringLog=/monitoring/log pathMonitoringDump=/monitoring/dump monitoringBase=http://142.93.102.40:8080 diff --git a/database/DatabaseManager.cpp b/database/DatabaseManager.cpp index 18c975d..a1d88f9 100644 --- a/database/DatabaseManager.cpp +++ b/database/DatabaseManager.cpp @@ -173,25 +173,27 @@ void DatabaseManager::initSchema() { ))"); q.exec(R"(CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER, - external_id TEXT, - device_id TEXT, - amount REAL, - phone TEXT, - bank_name TEXT, - comment TEXT, - status TEXT, - type TEXT, - update_time DATETIME, - complete_time DATETIME, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - json TEXT DEFAULT '', + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER, + external_id TEXT, + transaction_id TEXT, + device_id TEXT, + amount REAL, + phone TEXT, + bank_name TEXT, + comment TEXT, + status TEXT, + type TEXT, + update_time DATETIME, + complete_time DATETIME, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + json TEXT DEFAULT '', sent_to_server INTEGER DEFAULT 0, - screenshot BLOB, + screenshot BLOB, FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE, FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE ))"); + q.exec("ALTER TABLE events ADD COLUMN transaction_id TEXT"); q.exec(R"(CREATE TABLE IF NOT EXISTS banks ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/database/dao/EventDAO.cpp b/database/dao/EventDAO.cpp index 8789ebf..b7dff2a 100644 --- a/database/dao/EventDAO.cpp +++ b/database/dao/EventDAO.cpp @@ -13,6 +13,7 @@ EventInfo parseEvent(const QSqlQuery &query) { event.status = eventStatusFromString(query.value("status").toString()); event.type = eventTypeFromString(query.value("type").toString()); event.externalId = query.value("external_id").toString(); + event.transactionId = query.value("transaction_id").toString(); event.accountId = query.value("account_id").toInt(); event.deviceId = query.value("device_id").toString(); event.amount = query.value("amount").toDouble(); @@ -36,15 +37,16 @@ int EventDAO::insertEvent(const EventInfo &event) { query.prepare(R"( INSERT INTO events ( - status, external_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type + status, external_id, transaction_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type ) VALUES ( - :status, :external_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type + :status, :external_id, :transaction_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type ) )"); query.bindValue(":status", eventStatusToString(event.status)); query.bindValue(":type", eventTypeToString(event.type)); query.bindValue(":external_id", event.externalId); + query.bindValue(":transaction_id", event.transactionId); query.bindValue(":account_id", event.accountId); query.bindValue(":device_id", event.deviceId); query.bindValue(":amount", event.amount); @@ -139,7 +141,7 @@ QList EventDAO::getAllEvents(const EventStatus &status) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( - SELECT id, account_id, device_id, external_id, amount, phone, bank_name, + SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, status, type, comment, json, sent_to_server, update_time, complete_time, timestamp FROM events WHERE status = :status @@ -161,7 +163,7 @@ QList EventDAO::getAllEvents(const EventStatus &status) { EventInfo EventDAO::getFirstWaitEventByDeviceId(const QString &key) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( - SELECT id, account_id, device_id, external_id, amount, phone, bank_name, + SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, status, type, comment, json, sent_to_server, update_time, complete_time, timestamp FROM events WHERE status = :status AND device_id = :device_id @@ -193,7 +195,7 @@ QList EventDAO::getEvents(int page, int pageSize) { QList events; QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( - SELECT id, account_id, device_id, external_id, amount, phone, bank_name, + SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, status, type, comment, json, sent_to_server, update_time, complete_time, timestamp, (LENGTH(screenshot) > 0) as has_screenshot FROM events @@ -276,7 +278,7 @@ QList EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev QList cancelled; QSqlQuery selectQuery(DatabaseManager::instance().database()); selectQuery.prepare(R"( - SELECT id, external_id, bank_name, type FROM events + SELECT id, external_id, transaction_id, bank_name, type FROM events WHERE external_id = :material_id AND type = :type AND status = :wait )"); selectQuery.bindValue(":material_id", materialId); @@ -288,8 +290,9 @@ QList EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev EventInfo e; e.id = selectQuery.value(0).toInt(); e.externalId = selectQuery.value(1).toString(); - e.bankName = selectQuery.value(2).toString(); - e.type = eventTypeFromString(selectQuery.value(3).toString()); + e.transactionId = selectQuery.value(2).toString(); + e.bankName = selectQuery.value(3).toString(); + e.type = eventTypeFromString(selectQuery.value(4).toString()); cancelled.append(e); } } @@ -315,12 +318,12 @@ QList EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp QSqlQuery selectQuery(DatabaseManager::instance().database()); if (bankName.isEmpty()) { selectQuery.prepare(R"( - SELECT id, external_id, bank_name, type FROM events + SELECT id, external_id, transaction_id, bank_name, type FROM events WHERE device_id = :device_id AND type = :type AND status = :wait )"); } else { selectQuery.prepare(R"( - SELECT id, external_id, bank_name, type FROM events + SELECT id, external_id, transaction_id, bank_name, type FROM events WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name )"); selectQuery.bindValue(":bank_name", bankName); @@ -334,8 +337,9 @@ QList EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp EventInfo e; e.id = selectQuery.value(0).toInt(); e.externalId = selectQuery.value(1).toString(); - e.bankName = selectQuery.value(2).toString(); - e.type = eventTypeFromString(selectQuery.value(3).toString()); + e.transactionId = selectQuery.value(2).toString(); + e.bankName = selectQuery.value(3).toString(); + e.type = eventTypeFromString(selectQuery.value(4).toString()); cancelled.append(e); } } @@ -369,7 +373,7 @@ QList EventDAO::cancelWaitingByBank(const QString &deviceId, const QS QList cancelled; QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( - SELECT id, account_id, device_id, external_id, amount, phone, bank_name, + SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, status, type, comment, json, sent_to_server, update_time, complete_time, timestamp FROM events WHERE device_id = :device_id AND bank_name = :bank_name AND status = :wait @@ -425,7 +429,7 @@ QList EventDAO::getRecentEvents(int hours) { QList events; QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( - SELECT id, account_id, device_id, external_id, amount, phone, bank_name, + SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name, status, type, comment, json, sent_to_server, update_time, complete_time, timestamp FROM events WHERE timestamp >= datetime('now', :hours) diff --git a/migrations.sql b/migrations.sql index a6019ab..8627f1d 100644 --- a/migrations.sql +++ b/migrations.sql @@ -88,22 +88,23 @@ CREATE TABLE IF NOT EXISTS transactions CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - account_id INTEGER, - external_id TEXT, - device_id TEXT, - amount REAL, - phone TEXT, - bank_name TEXT, - comment TEXT, - status TEXT, - type TEXT, - update_time DATETIME, - complete_time DATETIME, - timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, - json TEXT DEFAULT '', + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER, + external_id TEXT, + transaction_id TEXT, + device_id TEXT, + amount REAL, + phone TEXT, + bank_name TEXT, + comment TEXT, + status TEXT, + type TEXT, + update_time DATETIME, + complete_time DATETIME, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, + json TEXT DEFAULT '', sent_to_server INTEGER DEFAULT 0, - screenshot BLOB, + screenshot BLOB, FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE, FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE ); diff --git a/models/db/EventInfo.h b/models/db/EventInfo.h index 68fae83..d7fef18 100644 --- a/models/db/EventInfo.h +++ b/models/db/EventInfo.h @@ -56,6 +56,7 @@ inline EventType eventTypeFromString(const QString &str) { struct EventInfo { int id = -1; QString externalId; + QString transactionId; int accountId = -1; QString deviceId; diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index de89dfc..6286450 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -33,7 +33,7 @@ #include "dushanbe/PayByPhoneScript.h" 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 sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {}); 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) {} @@ -74,7 +74,7 @@ void EventHandler::start() { if (e.sentToServer) continue; EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted"); EventDAO::markSentToServer(e.id); - sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending"); + sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, -1, e.transactionId); qDebug() << "[EventHandler] Closed stale event id:" << e.id; } } @@ -108,11 +108,14 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { continue; } + // transaction_id присутствует только в CREATE_TRANSACTION; для других типов это пусто. + const QString incomingTransactionId = task["body"].toObject()["transaction_id"].toString(); + // Ищем аккаунт по material_id напрямую const MaterialInfo account = MaterialDAO::findByMaterialId(materialId); if (account.id == -1) { qWarning() << "[EventHandler] Account not found for bank:" << bankName << "material_id:" << materialId; - sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId); + sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId); continue; } @@ -120,7 +123,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { if (eventType == EventType::FetchProfile) { const QList cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile); for (const EventInfo &c : cancelled) { - sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task"); + sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, -1, c.transactionId); EventDAO::markSentToServer(c.id); } } @@ -142,6 +145,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { if (eventType == EventType::CreateTransaction) { event.amount = body["amount"].toDouble() / 100.0; event.phone = body["bank_participator_id"].toString(); + event.transactionId = body["transaction_id"].toString(); // Название банка-получателя из extra const QString bankNameRu = body["extra"].toObject()["bank_participator_name_ru"].toString(); @@ -160,7 +164,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { if (!onlineDevices.contains(account.deviceId)) { qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId; EventDAO::updateEvent(eventId, EventStatus::Error, "Device is offline"); - sendTaskError(typeStr, materialId, bankName, "Device is offline"); + sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, -1, incomingTransactionId); } } @@ -203,11 +207,14 @@ static void sendTaskResult(const QString &type, const QString &materialId, const } static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, - const QString &description, const QByteArray &screenshot, int eventId) { + const QString &description, const QByteArray &screenshot, int eventId, + const QString &transactionId) { QJsonObject errorData; errorData["status"] = QStringLiteral("error"); errorData["event_type"] = type; errorData["description"] = description; + if (!transactionId.isEmpty()) + errorData["transaction_id"] = transactionId; sendTaskResult(QStringLiteral("ERROR"), materialId, bankName, errorData, eventId); if (!screenshot.isEmpty()) { @@ -289,7 +296,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, .arg(eventTypeToString(event.type), deviceLabel, event.bankName, result); GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot); - sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id); + sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id, event.transactionId); if (event.type == EventType::CreateTransaction) { const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId); @@ -318,6 +325,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, } QJsonObject declined; + if (!event.transactionId.isEmpty()) + declined["transaction_id"] = event.transactionId; declined["bank_transaction_id"] = declTxId; declined["amount"] = -static_cast(event.amount * 100); declined["currency"] = declCurrencyCode; @@ -360,7 +369,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, if (parsedTx.isEmpty()) { qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx"; sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, - "Transaction not parsed from screen"); + "Transaction not parsed from screen", {}, -1, event.transactionId); EventDAO::updateEvent(event.id, EventStatus::Error, "Transaction not parsed from screen"); EventDAO::markSentToServer(event.id); @@ -376,6 +385,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, const QString txType = taskJson["body"].toObject()["type"].toString(); QJsonObject obj; + if (!event.transactionId.isEmpty()) + obj["transaction_id"] = event.transactionId; obj["amount"] = -static_cast(event.amount * 100); obj["currency"] = currencyCode; obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType; @@ -653,7 +664,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn const QString logMsg = QString("[%1] bank=%2 material=%3: Script timeout") .arg(eventTypeToString(event.type), event.bankName, event.externalId); GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg); - sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)"); + sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)", {}, -1, event.transactionId); EventDAO::markSentToServer(event.id); // Убиваем банковское приложение на устройстве после таймаута @@ -690,7 +701,7 @@ void EventHandler::processNextTask(const QString &deviceId) { if (waitSecs > 5 * 60) { qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id; EventDAO::updateEvent(event.id, EventStatus::Error, "Task expired: waited " + QString::number(waitSecs) + "s"); - sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes"); + sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, -1, event.transactionId); continue; } @@ -700,7 +711,7 @@ void EventHandler::processNextTask(const QString &deviceId) { qWarning() << "[EventHandler] Bank profile disabled for" << account.appCode << "device:" << deviceId; EventDAO::updateEvent(event.id, EventStatus::Error, "Bank profile is disabled"); sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, - "Bank profile is disabled: " + account.appCode); + "Bank profile is disabled: " + account.appCode, {}, -1, event.transactionId); continue; } diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 06b2119..fda2f09 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -81,6 +81,7 @@ NetworkService::NetworkService(QObject *parent) : QObject(parent) { m_monitoringToken = settings.value("net/monitoringToken").toString(); m_pathCurrentProfile = settings.value("net/pathCurrentProfile", "/api/v1/profile/current_profile").toString(); m_pathProfileGetInfo = settings.value("net/pathProfileGetInfo", "/api/v1/profile/get_info").toString(); + m_pathProfilePing = settings.value("net/pathProfilePing", "/api/v1/profile/ping").toString(); m_manager = new QNetworkAccessManager(this); m_token = settings.value("common/token").toString(); m_accessToken = SettingsDAO::get("access_token"); @@ -279,7 +280,14 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body } QJsonValue result; + m_lastErrorSummary.clear(); + m_lastResponseBody.clear(); + m_lastHttpStatus = timedOut + ? 0 + : reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (timedOut) { + m_lastErrorSummary = "Timeout after " + QString::number(kMaxRequestAttempts) + + " attempts: " + lastTimeoutDetail; GeneralLogDAO::insert("CRITICAL", "NetworkService", "Timeout (POST) after " + QString::number(kMaxRequestAttempts) + " attempts: " + lastTimeoutDetail, {}, reqBodyStr, {}); @@ -287,6 +295,8 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body } else if (reply->error() != QNetworkReply::NoError) { const QByteArray responseBody = reply->readAll(); const QString errSummary = describeNetError(reply, path); + m_lastErrorSummary = errSummary; + m_lastResponseBody = QString::fromUtf8(responseBody); qCritical().noquote() << "Network error (POST):" << errSummary << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << "\n response body:" << responseBody; @@ -303,6 +313,8 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body if (root["success"].toBool()) { result = root["data"]; } else { + m_lastErrorSummary = "API error (success=false)"; + m_lastResponseBody = QString::fromUtf8(raw); qCritical() << "API error:" << root; GeneralLogDAO::insert("WARNING", "NetworkService", "API error (POST): " + shortPath, {}, @@ -1034,6 +1046,38 @@ bool NetworkService::syncCurrentProfile() { return true; } +bool NetworkService::doPingProfile() { + if (!ensureValidToken()) return false; + + const QString desktopId = SettingsDAO::get("desktop_id"); + if (desktopId.isEmpty()) { + qWarning() << "[NetworkService] pingProfile: desktop_id is empty, skip"; + return false; + } + + const QSettings settings("config.ini", QSettings::IniFormat); + QJsonObject body; + body["desktop_id"] = desktopId; + body["version"] = settings.value("common/version").toString(); + + const QJsonValue result = postJson(m_apiBase + m_pathProfilePing, body, true); + Q_UNUSED(result); + // Критерий успеха для пинга — HTTP 200, тело не важно. + if (m_lastHttpStatus != 200) { + qWarning().noquote() << "[NetworkService] pingProfile failed: HTTP" + << m_lastHttpStatus + << m_lastErrorSummary + << "| response body:" << m_lastResponseBody; + return false; + } + return true; +} + +void NetworkService::pingProfile() { + doPingProfile(); + emit finished(); +} + void NetworkService::postBankProfile() { if (!ensureValidToken()) { emit finished(); return; } @@ -1619,6 +1663,10 @@ void NetworkService::start() { // быть вызван явно после firstPassDone от DeviceScreener (см. правило #6 // в docs/status-sync.md). Без firstPassDone мы не знаем, какие устройства // действительно онлайн, и можем ошибочно сработать как «device→offline». + + // Heartbeat не зависит от firstPassDone — стартуем сразу. Если desktop_id + // ещё не получен (до loginAndSetup), первые тики просто скипнутся. + startPeriodicPing(); } void NetworkService::startPeriodicSync() { @@ -1628,6 +1676,33 @@ void NetworkService::startPeriodicSync() { scheduleNextStatusSync(); } +void NetworkService::startPeriodicPing() { + if (m_pingEnabled) return; + m_pingEnabled = true; + qDebug() << "[NetworkService] periodic ping enabled (every" << (kPingProfileMs / 1000) << "s)"; + scheduleNextPing(); +} + +void NetworkService::scheduleNextPing() { + if (!m_running || !m_pingEnabled) return; + + QTimer::singleShot(kPingProfileMs, this, [this]() { + if (!m_running) return; + + // Если предыдущий ping ещё в пути — пропускаем тик, чтобы не наслаивать. + if (m_pingInProgress.exchange(true)) { + qDebug() << "[NetworkService] ping tick skipped — previous still in progress"; + scheduleNextPing(); + return; + } + + doPingProfile(); + m_pingInProgress.store(false); + + scheduleNextPing(); + }); +} + void NetworkService::scheduleNextStatusSync() { if (!m_running || !m_periodicSyncEnabled) return; diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index 2a7761b..47614c9 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -63,6 +63,13 @@ public slots: // health-счётчиком в runPeriodicSyncTick (правило #9). bool syncCurrentProfile(); + // POST /api/v1/profile/ping — heartbeat: даёт серверу понять, что десктоп + // онлайн. В теле уходят desktop_id и версия клиента. Вызывать периодически. + void pingProfile(); + + // Включает периодический ping (kPingProfileMs). Идемпотентен. + void startPeriodicPing(); + // Включает периодическую синхронизацию статусов (60s). Должно быть вызвано // ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener. void startPeriodicSync(); @@ -142,8 +149,15 @@ private: QString m_monitoringToken; QString m_pathCurrentProfile; QString m_pathProfileGetInfo; + QString m_pathProfilePing; bool m_fetchingTasks = false; + // Последняя ошибка от postJson/patchJson/getJson — чтобы вызывающий код + // мог логировать тело ответа сервера, не дублируя парсинг ошибки. + QString m_lastErrorSummary; + QString m_lastResponseBody; + int m_lastHttpStatus = 0; // 0 если ответа не было (timeout); иначе HTTP-код + // Периодическая синхронизация bool m_periodicSyncEnabled = false; // включается только после firstPassDone std::atomic m_syncInProgress{false}; // защита от наложения тиков @@ -151,6 +165,11 @@ private: bool m_unhealthyEmitted = false; // чтобы не спамить syncHealthChanged static constexpr int kSyncFailureThreshold = 3; static constexpr int kPeriodicSyncMs = 60 * 1000; + static constexpr int kPingProfileMs = 5 * 1000; + + // Heartbeat (ping) + bool m_pingEnabled = false; + std::atomic m_pingInProgress{false}; // Реестр мьютексов на пару (deviceId, bankProfileId) — общий для всех // инстансов NetworkService. Используется для сериализации PATCH-операций. @@ -171,6 +190,8 @@ private: void scheduleNextRefresh(); void scheduleNextStatusSync(); bool runPeriodicSyncTick(); // делает один тик, возвращает true при успехе + bool doPingProfile(); // тело ping без emit finished — для периодических тиков + void scheduleNextPing(); void syncDevicesFromServer(const QString &desktopId); void syncBankProfilesFromServer(); void syncBanksIfEmpty(); diff --git a/views/bank/AccountSettingsWindow.cpp b/views/bank/AccountSettingsWindow.cpp index 36b38fe..f833f98 100644 --- a/views/bank/AccountSettingsWindow.cpp +++ b/views/bank/AccountSettingsWindow.cpp @@ -247,28 +247,36 @@ void AccountSettingsWindow::toggleProfileStatus() { m_app.status = newStatus; showOverlay(isActive ? "Выключение профиля..." : "Включение профиля..."); - sendAppStatus(newStatus); - // При включении профиля — включаем на сервере все локально активные материалы + // При включении профиля — материалы включаем ТОЛЬКО после успешного + // включения bank_profile на сервере; иначе пользователь получит подряд + // несколько одинаковых ошибок ("Invalid or disabled connection") от + // независимых PATCH'ей. + std::function onSuccess; if (!isActive) { - const QList materials = MaterialDAO::getAccounts(m_deviceId, m_app.code); - for (const auto &mat : materials) { - if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) { - auto *thread = new QThread; - auto *net = new NetworkService; - net->moveToThread(thread); - net->addExtra("material_id", mat.materialId); - net->addExtra("enable", true); - NetworkService::connectErrorDialog(this, net); - connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); - connect(net, &NetworkService::finished, thread, &QThread::quit); - connect(net, &NetworkService::finished, net, &QObject::deleteLater); - connect(thread, &QThread::finished, thread, &QThread::deleteLater); - thread->start(); - qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId; + const QString deviceId = m_deviceId; + const QString appCode = m_app.code; + onSuccess = [this, deviceId, appCode]() { + const QList materials = MaterialDAO::getAccounts(deviceId, appCode); + for (const auto &mat : materials) { + if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) { + auto *thread = new QThread; + auto *net = new NetworkService; + net->moveToThread(thread); + net->addExtra("material_id", mat.materialId); + net->addExtra("enable", true); + NetworkService::connectErrorDialog(this, net); + connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); + connect(net, &NetworkService::finished, thread, &QThread::quit); + connect(net, &NetworkService::finished, net, &QObject::deleteLater); + connect(thread, &QThread::finished, thread, &QThread::deleteLater); + thread->start(); + qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId; + } } - } + }; } + sendAppStatus(newStatus, std::move(onSuccess)); } // ── Edit PIN ─────────────────────────────────────────────────────────────── @@ -463,7 +471,7 @@ void AccountSettingsWindow::startGetCardInfo() { // ── Send status to server ────────────────────────────────────────────────── -void AccountSettingsWindow::sendAppStatus(const QString &status) { +void AccountSettingsWindow::sendAppStatus(const QString &status, std::function onSuccess) { if (m_app.bankProfileId.isEmpty()) { qWarning() << "[AccountSettings] sendAppStatus: no bankProfileId for" << m_app.code; hideOverlay(true, "Сохранено локально", [this]() { updateStatusUI(); }); @@ -478,9 +486,10 @@ void AccountSettingsWindow::sendAppStatus(const QString &status) { net->addExtra("enable", enable); NetworkService::connectErrorDialog(this, net); connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile); - connect(net, &NetworkService::finishedWithResult, this, [this, enable](int result) { + connect(net, &NetworkService::finishedWithResult, this, [this, enable, onSuccess = std::move(onSuccess)](int result) { if (result == 0) { hideOverlay(true, "Успешно", [this]() { updateStatusUI(); }); + if (onSuccess) onSuccess(); return; } // Правило #3: при неудачном включении — откатываем локаль в off и diff --git a/views/bank/AccountSettingsWindow.h b/views/bank/AccountSettingsWindow.h index d292cee..7eccfa7 100644 --- a/views/bank/AccountSettingsWindow.h +++ b/views/bank/AccountSettingsWindow.h @@ -44,7 +44,7 @@ private: void startCheckPinCode(); void startGetProfileInfo(); void startGetCardInfo(); - void sendAppStatus(const QString &status); + void sendAppStatus(const QString &status, std::function onSuccess = {}); void toggleMaterialStatus(const MaterialInfo &account); void reloadCards(); diff --git a/views/bank/BankSettingsWindow.cpp b/views/bank/BankSettingsWindow.cpp index be58e73..b4c4497 100644 --- a/views/bank/BankSettingsWindow.cpp +++ b/views/bank/BankSettingsWindow.cpp @@ -92,11 +92,14 @@ void BankSettingsWindow::startCheckPinCode(const QString &appCode) { thread->start(); } -static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName) { +static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, + const QString &transactionId = {}) { QJsonObject errData; errData["status"] = "error"; errData["event_type"] = type; errData["description"] = "Bank profile disabled"; + if (!transactionId.isEmpty()) + errData["transaction_id"] = transactionId; QJsonObject obj; obj["type"] = "ERROR"; @@ -126,7 +129,7 @@ void sendAppStatus(const QString &deviceId, const QString &code, const QString & if (status != "active") { const QList cancelled = EventDAO::cancelWaitingByBank(deviceId, code); for (const EventInfo &e : cancelled) { - sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName); + sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, e.transactionId); EventDAO::markSentToServer(e.id); qDebug() << "[BankSettings] cancelled task" << e.id << eventTypeToString(e.type); } diff --git a/views/device/DeviceWidget.cpp b/views/device/DeviceWidget.cpp index 01e3f6c..8f69b29 100644 --- a/views/device/DeviceWidget.cpp +++ b/views/device/DeviceWidget.cpp @@ -58,11 +58,14 @@ QString getColorByStatus(const BankProfileInfo &app) { } } -static void sendTaskErrorToServer(const QString &type, const QString &materialId, const QString &bankName) { +static void sendTaskErrorToServer(const QString &type, const QString &materialId, const QString &bankName, + const QString &transactionId = {}) { QJsonObject errData; errData["status"] = "error"; errData["event_type"] = type; errData["description"] = "Bank profile disabled"; + if (!transactionId.isEmpty()) + errData["transaction_id"] = transactionId; QJsonObject obj; obj["type"] = "ERROR"; @@ -94,7 +97,7 @@ void sendBankStatus(const QString &deviceId, const QString &code, const QString if (status != "active") { const QList cancelled = EventDAO::cancelWaitingByBank(deviceId, code); for (const EventInfo &e : cancelled) { - sendTaskErrorToServer(eventTypeToString(e.type), e.externalId, e.bankName); + sendTaskErrorToServer(eventTypeToString(e.type), e.externalId, e.bankName, e.transactionId); EventDAO::markSentToServer(e.id); } } @@ -107,7 +110,9 @@ void sendBankStatus(const QString &deviceId, const QString &code, const QString net->addExtra("enable", enable); QObject::connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile); if (onResult) { - QObject::connect(net, &NetworkService::finishedWithResult, net, + // Контекст — qApp (main thread), иначе onResult выполнится в worker-треде + // и QMessageBox внутри колбэка упадёт на NSAlert init (см. crashlog 2026-05-26). + QObject::connect(net, &NetworkService::finishedWithResult, qApp, [onResult](int result) { onResult(result); }, Qt::QueuedConnection); } @@ -368,44 +373,44 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p window->activateWindow(); }); - auto *deleteBtn = new QPushButton(this); - deleteBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/delete.png")); - deleteBtn->setIconSize(QSize(20, 20)); - deleteBtn->setFixedSize(28, 28); - deleteBtn->setFlat(true); - deleteBtn->setStyleSheet( - "background-color: rgba(0, 0, 0, 0.5);" - "border: none;" - "border-radius: 4px;" - ); - connect(deleteBtn, &QPushButton::clicked, this, [this]() { - const QString deviceId = m_device.id; - const QString deviceName = m_device.name; - QPointer guard(this); - - QMessageBox msgBox(nullptr); - msgBox.setWindowTitle(tr("Удалить устройство?")); - msgBox.setText( - "Будет удалено устройство " + deviceName + - " и все связанные данные: банки, аккаунты, транзакции и события."); - QPushButton *confirmBtn = msgBox.addButton(tr("Удалить"), QMessageBox::AcceptRole); - QPushButton *cancelBtn = msgBox.addButton(tr("Отмена"), QMessageBox::RejectRole); - msgBox.setDefaultButton(cancelBtn); - msgBox.setModal(true); - msgBox.exec(); - - if (msgBox.clickedButton() == confirmBtn) { - auto *thread = new QThread; - auto *net = new NetworkService; - net->setDeviceId(deviceId); - net->moveToThread(thread); - QObject::connect(thread, &QThread::started, net, &NetworkService::deleteDeviceFromServer); - QObject::connect(net, &NetworkService::finished, thread, &QThread::quit); - QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); - QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); - thread->start(); - } - }); + // auto *deleteBtn = new QPushButton(this); + // deleteBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/delete.png")); + // deleteBtn->setIconSize(QSize(20, 20)); + // deleteBtn->setFixedSize(28, 28); + // deleteBtn->setFlat(true); + // deleteBtn->setStyleSheet( + // "background-color: rgba(0, 0, 0, 0.5);" + // "border: none;" + // "border-radius: 4px;" + // ); + // connect(deleteBtn, &QPushButton::clicked, this, [this]() { + // const QString deviceId = m_device.id; + // const QString deviceName = m_device.name; + // QPointer guard(this); + // + // QMessageBox msgBox(nullptr); + // msgBox.setWindowTitle(tr("Удалить устройство?")); + // msgBox.setText( + // "Будет удалено устройство " + deviceName + + // " и все связанные данные: банки, аккаунты, транзакции и события."); + // QPushButton *confirmBtn = msgBox.addButton(tr("Удалить"), QMessageBox::AcceptRole); + // QPushButton *cancelBtn = msgBox.addButton(tr("Отмена"), QMessageBox::RejectRole); + // msgBox.setDefaultButton(cancelBtn); + // msgBox.setModal(true); + // msgBox.exec(); + // + // if (msgBox.clickedButton() == confirmBtn) { + // auto *thread = new QThread; + // auto *net = new NetworkService; + // net->setDeviceId(deviceId); + // net->moveToThread(thread); + // QObject::connect(thread, &QThread::started, net, &NetworkService::deleteDeviceFromServer); + // QObject::connect(net, &NetworkService::finished, thread, &QThread::quit); + // QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); + // QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); + // thread->start(); + // } + // }); auto *codeBtn = new QPushButton(this); codeBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/code.png")); @@ -620,7 +625,7 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p }); topRightLayout->addWidget(settingsBtn); - topRightLayout->addWidget(deleteBtn); + // topRightLayout->addWidget(deleteBtn); topRightLayout->addWidget(codeBtn); layout->addWidget(topRightWidget, 0, 0, Qt::AlignTop | Qt::AlignRight);