diff --git a/services/net/NetworkErrorUtils.h b/services/net/NetworkErrorUtils.h index 2a11f3c..f1b2b8f 100644 --- a/services/net/NetworkErrorUtils.h +++ b/services/net/NetworkErrorUtils.h @@ -56,6 +56,42 @@ inline QString networkErrorCategory(QNetworkReply::NetworkError err) { } } +// Транзиентная ошибка CONNECT-фазы: сбой произошёл ДО того, как байты запроса +// ушли на сервер (соединение не установилось), поэтому повтор безопасен даже для +// неидемпотентных POST — дубля быть не может. Сюда же ETIMEDOUT-connect, который +// Qt отдаёт как UnknownNetworkError "Connection timed out". +inline bool isConnectPhaseTransient(QNetworkReply::NetworkError err) { + switch (err) { + case QNetworkReply::ConnectionRefusedError: + case QNetworkReply::HostNotFoundError: + case QNetworkReply::ProxyConnectionRefusedError: + case QNetworkReply::ProxyNotFoundError: + case QNetworkReply::ProxyTimeoutError: + case QNetworkReply::UnknownNetworkError: + case QNetworkReply::TemporaryNetworkFailureError: + case QNetworkReply::NetworkSessionFailedError: + return true; + default: + return false; + } +} + +// Более широкий набор транзиентных ошибок — безопасен ТОЛЬКО для идемпотентных +// запросов (GET/PATCH/DELETE, аплоады), т.к. сбой мог случиться уже ПОСЛЕ отправки +// тела (сервер мог его обработать) → повтор неидемпотентного POST дал бы дубль. +inline bool isIdempotentTransient(QNetworkReply::NetworkError err) { + if (isConnectPhaseTransient(err)) return true; + switch (err) { + case QNetworkReply::RemoteHostClosedError: + case QNetworkReply::TimeoutError: + case QNetworkReply::ProxyConnectionClosedError: + case QNetworkReply::ServiceUnavailableError: + return true; + default: + return false; + } +} + // Развёрнутое описание сетевой ошибки: HTTP-код + категория или просто // категория + Qt errorString. Эндпоинт — без хоста. inline QString describeNetError(QNetworkReply *reply, const QString &path) { diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 8e2b94e..ff5f13f 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include "dao/BankDAO.h" #include "dao/GeneralLogDAO.h" @@ -34,31 +36,101 @@ using NetworkErrorUtils::sanitizePath; using NetworkErrorUtils::describeNetError; using NetworkErrorUtils::describeClientTimeout; +using NetworkErrorUtils::isConnectPhaseTransient; +using NetworkErrorUtils::isIdempotentTransient; namespace { -// Прокручивает event loop пока reply не завершится либо не сработает client timeout. -// true — reply finished (success или network error), false — сработал наш QTimer. -bool awaitReply(QNetworkReply *reply, int timeoutMs) { +enum class AwaitOutcome { FinishedClean, TransportError, ClientTimeout }; + +// Прокручивает event loop пока reply не завершится (finished приходит и на успех, +// и сразу после errorOccurred) либо не сработает наш QTimer. Раньше здесь отдельно +// коннектился errorOccurred → loop.quit(), из-за чего транспортная ошибка считалась +// «успешным завершением» и цикл ретраев делал break на первой же Connection timed out. +// FinishedClean — reply завершился без ошибки; +// TransportError — reply завершился, но reply->error() != NoError; +// ClientTimeout — наш таймер сработал раньше, reply ещё в полёте (нужен abort()). +AwaitOutcome 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(); + if (!timer.isActive()) return AwaitOutcome::ClientTimeout; + return reply->error() == QNetworkReply::NoError + ? AwaitOutcome::FinishedClean + : AwaitOutcome::TransportError; } -// На client timeout повторяем запрос ещё 2 раза (всего до 3 попыток). -// На HTTP/network ошибках не ретраим — они детерминированы (401/409/refused/etc). -constexpr int kMaxRequestAttempts = 3; +// Ретраим при (а) нашем client-timeout и (б) ТРАНЗИЕНТНЫХ транспортных ошибках. +// Для неидемпотентных запросов (POST) — только connect-phase сбои (соединение не +// установилось ⇒ сервер запрос не видел ⇒ дубля быть не может); для идемпотентных +// (GET/PATCH/DELETE/аплоады) набор шире (см. NetworkErrorUtils.h). Детерминированные +// HTTP-ошибки (401/404/409, SSL) НЕ ретраим — их разбирает вызывающий. +constexpr int kMaxRequestAttempts = 5; constexpr int kDefaultTimeoutMs = 30000; -constexpr int kDumpTimeoutMs = 120000; +constexpr int kDumpTimeoutMs = 300000; // архив с БД по медленному каналу // Мониторинг — best-effort: одна попытка, короткий таймаут. Failure не должен // блокировать потоки и накапливать QThread'ы при недоступном сервере. constexpr int kMonitoringTimeoutMs = 10000; +constexpr int kBackoffBaseMs = 1000; +constexpr int kBackoffMaxMs = 4000; + +// Пауза между попытками: 1с, 2с, 4с (cap). Через QEventLoop, а не QThread::sleep — +// event loop потока остаётся живым (abort/deleteLater/queued-слоты продолжают идти). +void backoffSleep(int attempt) { + const int ms = qMin(kBackoffBaseMs << (attempt - 1), kBackoffMaxMs); + QEventLoop loop; + QTimer::singleShot(ms, &loop, &QEventLoop::quit); + loop.exec(); +} + +struct SendOutcome { + QNetworkReply *reply = nullptr; // последний reply; вызывающий парсит и делает deleteLater + bool clientTimedOut = false; // финальная попытка упёрлась в наш QTimer (reply aborted) + QString timeoutDetail; // describeClientTimeout последнего таймаута +}; + +// Выполняет issueRequest() с таймаутом и ретраями транзиентных сбоев + бэкоффом. +// issueRequest должен КАЖДЫЙ раз создавать новый reply (и, для multipart, новый +// QHttpMultiPart — он потребляется за попытку). Возвращает последний reply, чтобы +// вызывающий сохранил свой разбор ответа/ошибки. +SendOutcome sendWithRetry(const std::function &issueRequest, + int timeoutMs, const QString &path, + const char *method, bool idempotent) { + QNetworkReply *reply = nullptr; + QString timeoutDetail; + for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { + if (reply) { reply->deleteLater(); reply = nullptr; } + reply = issueRequest(); + switch (awaitReply(reply, timeoutMs)) { + case AwaitOutcome::FinishedClean: + return { reply, false, {} }; + case AwaitOutcome::TransportError: { + const QNetworkReply::NetworkError err = reply->error(); + const bool transient = isConnectPhaseTransient(err) + || (idempotent && isIdempotentTransient(err)); + if (transient && attempt < kMaxRequestAttempts) { + qCritical().noquote() << "Network transient error (" << method << ") attempt" + << attempt << "/" << kMaxRequestAttempts << ":" + << describeNetError(reply, path) << "— retrying after backoff"; + backoffSleep(attempt); + continue; + } + return { reply, false, {} }; // детерминированная ошибка или попытки исчерпаны + } + case AwaitOutcome::ClientTimeout: + timeoutDetail = describeClientTimeout(reply, path); + qCritical().noquote() << "Network timeout (" << method << ") attempt" << attempt + << "/" << kMaxRequestAttempts << ":" << timeoutDetail; + reply->abort(); + if (attempt < kMaxRequestAttempts) { backoffSleep(attempt); continue; } + return { reply, true, timeoutDetail }; + } + } + return { reply, true, timeoutDetail }; // недостижимо +} } // namespace NetworkService::NetworkService(QObject *parent) : QObject(parent) { @@ -116,28 +188,24 @@ void addFormMetadata(QHttpMultiPart *multiPart, const QByteArray &fieldValue) { multiPart->append(jsonPart); } -QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path) { +QJsonValue NetworkService::post(const std::function &buildMultiPart, + const QString &path, bool idempotent) { const QUrl url(path); const QNetworkRequest request(url); - 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(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { - loop.quit(); - }); - connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); - timeout.start(30000); - loop.exec(); + const SendOutcome outcome = sendWithRetry( + [&] { + QHttpMultiPart *multiPart = buildMultiPart(); + QNetworkReply *reply = m_manager->post(request, multiPart); + multiPart->setParent(reply); + return reply; + }, + kDefaultTimeoutMs, path, "POST(mp)", idempotent); + QNetworkReply *reply = outcome.reply; QJsonValue result; - if (!timeout.isActive()) { - qCritical().noquote() << "Network timeout:" << describeClientTimeout(reply, path); - reply->abort(); + if (outcome.clientTimedOut) { + qCritical().noquote() << "Network timeout:" << outcome.timeoutDetail; } else if (reply->error() == QNetworkReply::NoError) { const QByteArray raw = reply->readAll(); QJsonParseError err; @@ -167,18 +235,12 @@ QJsonArray NetworkService::get(const QString &path, const QMap const QNetworkRequest request(url); - 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(); - } + const SendOutcome outcome = sendWithRetry( + [&] { return m_manager->get(request); }, + kDefaultTimeoutMs, path, "GET", /*idempotent=*/true); + QNetworkReply *reply = outcome.reply; + const bool timedOut = outcome.clientTimedOut; + const QString lastTimeoutDetail = outcome.timeoutDetail; QJsonArray result; if (timedOut) { @@ -245,13 +307,13 @@ void NetworkService::getPayments() { } void NetworkService::postAppStatus() { - QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - addFormMetadata(multiPart, m_json); - addFormField(multiPart, "token", m_token); - addFormField(multiPart, "androidUniqueName", m_deviceId); - - const bool isSuccess = post(multiPart, m_urlAppStatusUpdate).toBool(); + const bool isSuccess = post([this] { + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + addFormMetadata(multiPart, m_json); + addFormField(multiPart, "token", m_token); + addFormField(multiPart, "androidUniqueName", m_deviceId); + return multiPart; + }, m_urlAppStatusUpdate, /*idempotent=*/false).toBool(); if (!isSuccess) { qCritical() << "Cant post data: postAppStatus"; } @@ -271,18 +333,12 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); const QString shortPath = sanitizePath(path); - 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(); - } + const SendOutcome outcome = sendWithRetry( + [&] { return m_manager->post(request, bodyBytes); }, + kDefaultTimeoutMs, path, "POST", /*idempotent=*/false); + QNetworkReply *reply = outcome.reply; + const bool timedOut = outcome.clientTimedOut; + const QString lastTimeoutDetail = outcome.timeoutDetail; QJsonValue result; m_lastErrorSummary.clear(); @@ -515,11 +571,11 @@ void NetworkService::finishSetupWithDesktop(const QString &desktopId) { void NetworkService::postEventStatus() { - QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - addFormMetadata(multiPart, m_json); - - const bool isSuccess = post(multiPart, m_urlEventStatusUpdate).toBool(); + const bool isSuccess = post([this] { + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + addFormMetadata(multiPart, m_json); + return multiPart; + }, m_urlEventStatusUpdate, /*idempotent=*/false).toBool(); if (!isSuccess) { qCritical() << "Cant post data: postEventStatus"; } @@ -528,14 +584,14 @@ void NetworkService::postEventStatus() { } void NetworkService::updateTransactionData() { - QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - addFormMetadata(multiPart, m_json); - addFormField(multiPart, "token", m_token); - addFormField(multiPart, "androidUniqueName", m_deviceId); - addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString()); - - const bool isSuccess = post(multiPart, m_urlTransactionUpdate).toBool(); + const bool isSuccess = post([this] { + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + addFormMetadata(multiPart, m_json); + addFormField(multiPart, "token", m_token); + addFormField(multiPart, "androidUniqueName", m_deviceId); + addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString()); + return multiPart; + }, m_urlTransactionUpdate, /*idempotent=*/false).toBool(); if (!isSuccess) { qCritical() << "Cant update transaction: updateTransactionData"; } @@ -544,14 +600,14 @@ void NetworkService::updateTransactionData() { } void NetworkService::insertTransactionData() { - QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); - - addFormMetadata(multiPart, m_json); - addFormField(multiPart, "token", m_token); - addFormField(multiPart, "androidUniqueName", m_deviceId); - addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString()); - - const int id = post(multiPart, m_urlTransactionInsert).toInt(); + const int id = post([this] { + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + addFormMetadata(multiPart, m_json); + addFormField(multiPart, "token", m_token); + addFormField(multiPart, "androidUniqueName", m_deviceId); + addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString()); + return multiPart; + }, m_urlTransactionInsert, /*idempotent=*/false).toInt(); if (id > 0) { const int transactionId = m_extraData["transactionId"].toInt(); if (!TransactionDAO::updateExternalTransactionId(transactionId, id)) { @@ -572,18 +628,12 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod const QByteArray bodyBytes = QJsonDocument(body).toJson(); const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); - 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(); - } + const SendOutcome outcome = sendWithRetry( + [&] { return m_manager->sendCustomRequest(request, "PATCH", bodyBytes); }, + kDefaultTimeoutMs, path, "PATCH", /*idempotent=*/true); + QNetworkReply *reply = outcome.reply; + const bool timedOut = outcome.clientTimedOut; + const QString lastTimeoutDetail = outcome.timeoutDetail; QJsonValue result; if (timedOut) { @@ -657,18 +707,12 @@ QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) { request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } - 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(); - } + const SendOutcome outcome = sendWithRetry( + [&] { return m_manager->deleteResource(request); }, + kDefaultTimeoutMs, path, "DELETE", /*idempotent=*/true); + QNetworkReply *reply = outcome.reply; + const bool timedOut = outcome.clientTimedOut; + const QString lastTimeoutDetail = outcome.timeoutDetail; QJsonValue result; if (timedOut) { @@ -704,23 +748,15 @@ QJsonValue NetworkService::getJson(const QString &path, bool withAuth) { request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } - 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(); - } + const SendOutcome outcome = sendWithRetry( + [&] { return m_manager->get(request); }, + kDefaultTimeoutMs, path, "getJson", /*idempotent=*/true); + QNetworkReply *reply = outcome.reply; QJsonValue result; - if (timedOut) { + if (outcome.clientTimedOut) { qCritical().noquote() << "Network timeout (getJson) after" - << kMaxRequestAttempts << "attempts:" << lastTimeoutDetail; + << kMaxRequestAttempts << "attempts:" << outcome.timeoutDetail; result = QJsonValue::Undefined; } else if (reply->error() != QNetworkReply::NoError) { qCritical().noquote() << "Network error (GET):" << describeNetError(reply, path) @@ -900,18 +936,12 @@ void NetworkService::syncBanksIfEmpty() { const QString path = QStringLiteral("https://api.brtservice.io/api/rest/info/"); QNetworkRequest request{QUrl(path)}; - 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(); - } + const SendOutcome outcome = sendWithRetry( + [&] { return m_manager->get(request); }, + kDefaultTimeoutMs, path, "syncBanksIfEmpty", /*idempotent=*/true); + QNetworkReply *reply = outcome.reply; - if (timedOut) { + if (outcome.clientTimedOut) { qWarning() << "[syncBanksIfEmpty] timeout after" << kMaxRequestAttempts << "attempts"; reply->deleteLater(); return; @@ -1416,31 +1446,25 @@ void NetworkService::postDeviceScreenshot() { QNetworkRequest request(url); request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); - QNetworkReply *reply = nullptr; - bool timedOut = true; - for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { - if (reply) { reply->deleteLater(); reply = nullptr; } + const SendOutcome outcome = sendWithRetry( + [&] { + 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); + QNetworkReply *reply = m_manager->post(request, multiPart); + multiPart->setParent(reply); + return reply; + }, + kDefaultTimeoutMs, url.toString(), "postDeviceScreenshot", /*idempotent=*/true); + QNetworkReply *reply = outcome.reply; - 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); - - 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) { + if (outcome.clientTimedOut) { qWarning() << "[NetworkService] postDeviceScreenshot timeout after" << kMaxRequestAttempts << "attempts for device" << device.apiId; } else if (reply->error() != QNetworkReply::NoError) { @@ -1679,7 +1703,7 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event QNetworkReply *reply = m_manager->post(request, multiPart); multiPart->setParent(reply); - if (!awaitReply(reply, kMonitoringTimeoutMs)) { + if (awaitReply(reply, kMonitoringTimeoutMs) == AwaitOutcome::ClientTimeout) { qWarning() << "[NetworkService] postMonitoringLog timeout"; reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { @@ -1703,59 +1727,60 @@ void NetworkService::postMonitoringDump(const QString &text, const QByteArray &a qDebug() << "[NetworkService] postMonitoringDump POST" << url.toString(); - QNetworkReply *reply = nullptr; - bool timedOut = true; - for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { - if (reply) { reply->deleteLater(); reply = nullptr; } + const SendOutcome outcome = sendWithRetry( + [&] { + 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); + } + QNetworkReply *reply = m_manager->post(request, multiPart); + multiPart->setParent(reply); + return reply; + }, + kDumpTimeoutMs, url.toString(), "postMonitoringDump", /*idempotent=*/true); + QNetworkReply *reply = outcome.reply; - 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) { + bool ok = false; + QString detail; + if (outcome.clientTimedOut) { + detail = "timeout: " + outcome.timeoutDetail; qWarning() << "[NetworkService] postMonitoringDump timeout after" << kMaxRequestAttempts << "attempts"; } else if (reply->error() != QNetworkReply::NoError) { - qWarning() << "[NetworkService] postMonitoringDump failed:" << reply->errorString() + detail = describeNetError(reply, url.toString()); + qWarning() << "[NetworkService] postMonitoringDump failed:" << detail << "\n response body:" << reply->readAll(); } else { const QByteArray raw = reply->readAll(); const QJsonDocument doc = QJsonDocument::fromJson(raw); if (doc.isObject() && !doc.object().value("success").toBool()) { + detail = "сервер отклонил: " + QString::fromUtf8(raw).left(300); qWarning() << "[NetworkService] postMonitoringDump rejected:" << raw; } else { + ok = true; qDebug() << "[NetworkService] postMonitoringDump OK:" << raw; } } reply->deleteLater(); + emit monitoringDumpFinished(ok, detail, archiveFilename); } void NetworkService::postMonitoringScreenshot(const QString &desktopId, @@ -1785,7 +1810,7 @@ void NetworkService::postMonitoringScreenshot(const QString &desktopId, QNetworkReply *reply = m_manager->post(request, multiPart); multiPart->setParent(reply); - if (!awaitReply(reply, kMonitoringTimeoutMs)) { + if (awaitReply(reply, kMonitoringTimeoutMs) == AwaitOutcome::ClientTimeout) { qWarning() << "[NetworkService] postMonitoringScreenshot timeout"; reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index 4e55137..65594c8 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -7,6 +7,7 @@ #include #include #include +#include class QWidget; @@ -145,6 +146,10 @@ signals: // healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true // при первом успехе после такой серии. consecutiveFailures даёт текущий счёт. void syncHealthChanged(bool healthy, int consecutiveFailures); + // Результат postMonitoringDump (включая ручную отправку логов из меню). + // archiveFilename — ключ, по которому инициатор отличает свой аплоад (синглтон + // общий). ok=false ⇒ detail содержит причину (таймаут/сетевая/HTTP-ошибка). + void monitoringDumpFinished(bool ok, QString detail, QString archiveFilename); private: QByteArray m_json; @@ -207,7 +212,10 @@ private: QNetworkAccessManager *m_manager; - QJsonValue post(QHttpMultiPart *multiPart, const QString &path); + // buildMultiPart вызывается на КАЖДУЮ попытку (multipart потребляется за запрос). + // idempotent=false ⇒ ретраим только connect-phase сбои (без риска двойной отправки). + QJsonValue post(const std::function &buildMultiPart, + const QString &path, bool idempotent); QJsonArray get(const QString &path, const QMap ¶ms); QJsonValue getJson(const QString &path, bool withAuth = false); QJsonValue postJson(const QString &path, const QJsonObject &body, bool withAuth = false); diff --git a/views/log/FileLogWindow.cpp b/views/log/FileLogWindow.cpp index 3a1db67..2d74bff 100644 --- a/views/log/FileLogWindow.cpp +++ b/views/log/FileLogWindow.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include "dump/TaskDumpRecorder.h" #include "dao/GeneralLogDAO.h" @@ -284,6 +285,32 @@ void FileLogWindow::sendLogsToBackend() { qDebug() << "[sendLogsToBackend] dispatching to NetworkService::postMonitoringDump" << "archive.size=" << archiveBytes.size() << "filename=" << archiveFilename; + + // Подписываемся на результат ДО диспатча. svc живёт в потоке paymentThread — + // соединение автоматически становится QueuedConnection, лямбда исполнится в + // UI-потоке. Контекст = this ⇒ при закрытии окна Qt сам отключит слот (без + // обращения к висячему указателю). Фильтруем по своему archiveFilename: + // синглтон общий, параллельно могут уходить и другие дампы (TaskDumpRecorder). + auto handled = std::make_shared(false); + auto conn = std::make_shared(); + *conn = connect(svc, &NetworkService::monitoringDumpFinished, this, + [this, conn, handled, expected = archiveFilename]( + bool ok, const QString &detail, const QString &fn) { + if (fn != expected || *handled) return; + *handled = true; + QObject::disconnect(*conn); + m_sendBtn->setEnabled(true); + m_sendBtn->setText("Отправить"); + if (ok) { + QMessageBox::information(this, "Готово", "Логи отправлены на бэкенд."); + } else { + QMessageBox::warning(this, "Ошибка", + "Не удалось отправить логи: " + detail); + } + }); + + m_sendBtn->setText("Отправка..."); + const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump", Qt::QueuedConnection, Q_ARG(QString, text), @@ -291,15 +318,12 @@ void FileLogWindow::sendLogsToBackend() { Q_ARG(QByteArray, QByteArray()), Q_ARG(QString, archiveFilename)); - m_sendBtn->setEnabled(true); - m_sendBtn->setText("Отправить"); - if (!dispatched) { + *handled = true; + QObject::disconnect(*conn); + m_sendBtn->setEnabled(true); + m_sendBtn->setText("Отправить"); QMessageBox::warning(this, "Ошибка", "Не удалось поставить отправку в очередь NetworkService."); - return; } - - QMessageBox::information(this, "Готово", - "Логи поставлены в очередь на отправку на бэкенд."); }