#include "NetworkService.h" #include "NetworkErrorUtils.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "dao/BankDAO.h" #include "dao/GeneralLogDAO.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" #include "dao/EventDAO.h" #include "dao/SettingsDAO.h" #include "dao/TransactionDAO.h" #include "db/EventInfo.h" #include "update/UpdatePause.h" // Убирает полный URL (хост:порт) из сообщений об ошибках, оставляя только // Хелперы для логирования сетевых ошибок вынесены в 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(); } // На client timeout повторяем запрос ещё 2 раза (всего до 3 попыток). // На HTTP/network ошибках не ретраим — они детерминированы (401/409/refused/etc). constexpr int kMaxRequestAttempts = 3; constexpr int kDefaultTimeoutMs = 30000; constexpr int kDumpTimeoutMs = 120000; // Мониторинг — best-effort: одна попытка, короткий таймаут. Failure не должен // блокировать потоки и накапливать QThread'ы при недоступном сервере. constexpr int kMonitoringTimeoutMs = 10000; } // namespace NetworkService::NetworkService(QObject *parent) : QObject(parent) { const QSettings settings("config.ini", QSettings::IniFormat); m_urlAccountUpdate = settings.value("net/accountUpdate").toString(); m_urlPaymentsGet = settings.value("net/paymentsGet").toString(); m_urlTransactionUpdate = settings.value("net/transactionUpdate").toString(); m_urlAppStatusUpdate = settings.value("net/appStatusUpdate").toString(); m_urlTransactionInsert = settings.value("net/transactionInsert").toString(); m_urlEventStatusUpdate = settings.value("net/eventStatusUpdate").toString(); m_apiBase = settings.value("net/apiBase").toString(); m_pathAuthLogin = settings.value("net/pathAuthLogin", "/api/v1/auth/login").toString(); m_pathAuthRefresh = settings.value("net/pathAuthRefresh", "/api/v1/auth/refresh_token").toString(); m_pathDesktopCreate = settings.value("net/pathDesktopCreate", "/api/v1/desktop/").toString(); m_pathDevice = settings.value("net/pathDevice", "/api/v1/device/").toString(); m_pathDeviceScreenshot= settings.value("net/pathDeviceScreenshot","/api/v1/device/add_screenshot/").toString(); m_pathBankProfile = settings.value("net/pathBankProfile", "/api/v1/profile/bank_profile").toString(); m_pathMaterial = settings.value("net/pathMaterial", "/api/v1/profile/material").toString(); m_pathGetTasks = settings.value("net/pathGetTasks", "/api/v1/task/get_tasks").toString(); m_pathTaskResult = settings.value("net/pathTaskResult", "/api/v1/task/task_result").toString(); m_pathMonitoringLog = settings.value("net/pathMonitoringLog", "/monitoring/log").toString(); m_pathMonitoringDump = settings.value("net/pathMonitoringDump", "/monitoring/dump").toString(); m_pathMonitoringScreenshot = settings.value("net/pathMonitoringScreenshot", "/monitoring/screenshot").toString(); m_monitoringBase = settings.value("net/monitoringBase", m_apiBase).toString(); 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"); } NetworkService::~NetworkService() = default; // FIXME доделать работу с экстра void NetworkService::addExtra(const QString &key, const QVariant &value) { m_extraData[key] = value; } void addFormField(QHttpMultiPart *multiPart, const QString &fieldName, const QString &fieldValue) { QHttpPart formField; formField.setHeader( QNetworkRequest::ContentDispositionHeader,QStringLiteral("form-data; name=\"%1\"").arg(fieldName) ); formField.setBody(fieldValue.toUtf8()); multiPart->append(formField); } void addFormMetadata(QHttpMultiPart *multiPart, const QByteArray &fieldValue) { QHttpPart jsonPart; jsonPart.setHeader(QNetworkRequest::ContentTypeHeader, "application/json; charset=UTF-8"); jsonPart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="metadata")"); jsonPart.setBody(fieldValue); multiPart->append(jsonPart); } QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path) { 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(); QJsonValue result; if (!timeout.isActive()) { qCritical().noquote() << "Network timeout:" << describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QByteArray raw = reply->readAll(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) { const QJsonObject root = doc.object(); if (root.value("success").toBool()) { result = root.value("data"); } else { qCritical() << "Ошибка:" << root; } } } else { qCritical() << "Ошибка:" << describeNetError(reply, path); } reply->deleteLater(); return result; } QJsonArray NetworkService::get(const QString &path, const QMap ¶ms) { QUrl url(path); QUrlQuery query; for (auto it = params.constBegin(); it != params.constEnd(); ++it) { query.addQueryItem(it.key(), it.value()); } url.setQuery(query); 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(); } QJsonArray result; if (timedOut) { qCritical().noquote() << "Network timeout (GET) after" << kMaxRequestAttempts << "attempts:" << lastTimeoutDetail; } else if (reply->error() == QNetworkReply::NoError) { const QByteArray raw = reply->readAll(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) { const QJsonObject root = doc.object(); if (root.value("success").toBool()) { result = root.value("data").toArray(); } else { qWarning() << "Ошибка:" << root; } } } else { qWarning() << "Ошибка:" << reply->errorString(); } reply->deleteLater(); return result; } void NetworkService::postAccountsData() { // TODO: заменить на новый API emit finished(); } void NetworkService::getPayments() { QMap params; params["token"] = m_token; const QJsonArray data = get(m_urlPaymentsGet, params); for (QJsonValue item: data) { QJsonObject obj = item.toObject(); EventInfo eventInfo; eventInfo.status = EventStatus::Wait; eventInfo.type = eventTypeFromString(obj["type"].toString()); eventInfo.externalId = obj["id"].isString() ? obj["id"].toString() : QString::number(obj["id"].toInt()); eventInfo.timestamp = QDateTime::fromMSecsSinceEpoch(obj["timestamp"].toDouble(), QTimeZone::utc()); eventInfo.amount = obj["amount"].toDouble(); eventInfo.bankName = obj["bankName"].toString(); eventInfo.phone = obj["phone"].toString(); QString deviceId = obj["deviceId"].toString(); QString appName = obj["appName"].toString(); QString lastNumbers = obj["lastNumbers"].toString(); MaterialInfo account = MaterialDAO::findAppAccount(deviceId, appName, lastNumbers); eventInfo.accountId = account.id; eventInfo.deviceId = account.deviceId; if (int id = EventDAO::insertEvent(eventInfo); id == -1) { qCritical() << "Cant insert event: " << eventInfo.externalId; } } if (!m_running) { emit finished(); } } 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(); if (!isSuccess) { qCritical() << "Cant post data: postAppStatus"; } emit finished(); } QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body, bool withAuth) { const QUrl url(path); QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); if (withAuth && !m_accessToken.isEmpty()) { request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } const QByteArray bodyBytes = QJsonDocument(body).toJson(); 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(); } 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, {}); result = QJsonValue::Undefined; } 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; GeneralLogDAO::insert("CRITICAL", "NetworkService", "Network error (POST): " + errSummary, {}, reqBodyStr, QString::fromUtf8(responseBody)); result = QJsonValue::Undefined; } else { const QByteArray raw = reply->readAll(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) { const QJsonObject root = doc.object(); 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, {}, reqBodyStr, QString::fromUtf8(raw)); result = QJsonValue::Null; } } } reply->deleteLater(); return result; } void NetworkService::loginAndSetup() { const QString apiKey = SettingsDAO::get("api_key"); // Шаг 1: логин QJsonObject loginBody; loginBody["api_key"] = apiKey; const QJsonValue loginResult = postJson(m_apiBase + m_pathAuthLogin, loginBody); if (loginResult.isUndefined()) { emit finishedWithResult(-1); // ошибка сети emit finished(); return; } if (loginResult.isNull()) { emit finishedWithResult(0); // неверный api_key emit finished(); return; } const QJsonObject loginData = loginResult.toObject(); m_accessToken = loginData["access_token"].toString(); if (m_accessToken.isEmpty() || !loginData.contains("refresh_token") || !loginData.contains("expires_in")) { qCritical() << "loginAndSetup: ответ не содержит обязательных полей (access_token/refresh_token/expires_in)"; emit finishedWithResult(-1); emit finished(); return; } SettingsDAO::set("access_token", m_accessToken); SettingsDAO::set("refresh_token", loginData["refresh_token"].toString()); const qint64 expiresIn = static_cast(loginData["expires_in"].toDouble()); SettingsDAO::setLong("token_expires_at", QDateTime::currentSecsSinceEpoch() + expiresIn); // Шаг 1.5: получаем страну/валюту профиля const QJsonValue infoResult = getJson(m_apiBase + m_pathProfileGetInfo, true); if (infoResult.isObject()) { const QJsonObject info = infoResult.toObject(); const QString country = info["country"].toString(); const QString currency = info["currency"].toString(); if (!country.isEmpty()) SettingsDAO::set("country", country); if (!currency.isEmpty()) SettingsDAO::set("currency", currency); qDebug() << "[loginAndSetup] profile info: country=" << country << "currency=" << currency; } // Шаг 2: получаем список десктопов с сервера const QString savedDesktopId = SettingsDAO::get("desktop_id"); const QJsonValue existingDesktops = getJson(m_apiBase + m_pathDesktopCreate, true); if (existingDesktops.isUndefined()) { // Сеть/сервер недоступны if (savedDesktopId.isEmpty()) { emit finishedWithResult(-1); emit finished(); return; } // Есть сохранённый выбор — продолжаем с ним (best effort) qDebug() << "[loginAndSetup] desktop list unavailable, using saved" << savedDesktopId; finishSetupWithDesktop(savedDesktopId); return; } // Собираем не скрытые десктопы QJsonArray visibleDesktops; if (existingDesktops.isArray()) { for (const QJsonValue &dVal : existingDesktops.toArray()) { const QJsonObject d = dVal.toObject(); if (d["hidden"].toBool(false)) continue; visibleDesktops.append(d); } } // 1) Сохранённый выбор всё ещё валиден — используем молча (как раньше) if (!savedDesktopId.isEmpty()) { for (const QJsonValue &dVal : visibleDesktops) { const QJsonObject d = dVal.toObject(); if (d["id"].toString() == savedDesktopId) { SettingsDAO::set("profile_id", d["profile_id"].toString()); SettingsDAO::set("desktop_unique_name", d["unique_name"].toString()); qDebug() << "[loginAndSetup] desktop_id confirmed:" << savedDesktopId; finishSetupWithDesktop(savedDesktopId); return; } } } // 2) Валидного сохранённого выбора нет if (visibleDesktops.isEmpty()) { // Десктопов на сервере нет — создаём автоматически и запускаемся const QString newId = createDesktopOnServer(); if (newId.isEmpty()) { emit finishedWithResult(-1); emit finished(); return; } finishSetupWithDesktop(newId); return; } // 3) Есть из чего выбирать — спрашиваем пользователя. // НЕ эмитим finished(): поток ждёт continueWithDesktop() из UI. m_desktops = visibleDesktops; qDebug() << "[loginAndSetup] desktop selection required, count:" << visibleDesktops.size(); emit desktopSelectionRequired(visibleDesktops); } QString NetworkService::createDesktopOnServer() { QJsonObject desktopBody; desktopBody["blue_token"] = QJsonValue::Null; desktopBody["name"] = QJsonValue::Null; desktopBody["contact"] = QJsonValue::Null; const QSettings settings("config.ini", QSettings::IniFormat); desktopBody["app_version"] = settings.value("common/version").toString(); const QJsonValue desktopResult = postJson(m_apiBase + m_pathDesktopCreate, desktopBody, true); if (desktopResult.isUndefined() || desktopResult.isNull()) { qWarning() << "[createDesktopOnServer] failed to create desktop"; return QString(); } const QJsonObject desktopData = desktopResult.toObject(); const QString id = desktopData["id"].toString(); SettingsDAO::set("desktop_id", id); SettingsDAO::set("profile_id", desktopData["profile_id"].toString()); SettingsDAO::set("desktop_unique_name", desktopData["unique_name"].toString()); qDebug() << "[createDesktopOnServer] created desktop" << id; return id; } void NetworkService::continueWithDesktop(const QString &desktopId) { QString resolvedId = desktopId; if (resolvedId.isEmpty()) { // Пользователь выбрал «Создать новый» resolvedId = createDesktopOnServer(); if (resolvedId.isEmpty()) { emit finishedWithResult(-1); emit finished(); return; } } else { SettingsDAO::set("desktop_id", resolvedId); for (const QJsonValue &dVal : m_desktops) { const QJsonObject d = dVal.toObject(); if (d["id"].toString() == resolvedId) { SettingsDAO::set("profile_id", d["profile_id"].toString()); SettingsDAO::set("desktop_unique_name", d["unique_name"].toString()); break; } } qDebug() << "[continueWithDesktop] selected desktop" << resolvedId; } finishSetupWithDesktop(resolvedId); } void NetworkService::finishSetupWithDesktop(const QString &desktopId) { // Шаг 3: синхронизация устройств с сервера (scoped по desktopId) syncDevicesFromServer(desktopId); // Шаг 4: синхронизация банковских профилей и материалов с сервера syncBankProfilesFromServer(); // Шаг 5: синхронизация статусов профилей/материалов с учётом онлайн-устройств syncCurrentProfile(); // Шаг 6: отправляем OFFLINE статус всех устройств (DeviceScreener обновит на ONLINE при обнаружении) { const QList allDevices = DeviceDAO::getAllDevices(false); for (const DeviceInfo &d : allDevices) { if (d.apiId.isEmpty()) continue; QJsonObject body; body["status"] = "OFFLINE"; body["bank_profile_id"] = QJsonValue::Null; body["battery"] = d.battery; body["update_time"] = QDateTime::currentSecsSinceEpoch(); patchJson(m_apiBase + m_pathDevice + d.apiId, body, true); qDebug() << "[finishSetupWithDesktop] sent OFFLINE for device" << d.id << d.apiId; } } emit finishedWithResult(1); emit finished(); } void NetworkService::postEventStatus() { QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); addFormMetadata(multiPart, m_json); const bool isSuccess = post(multiPart, m_urlEventStatusUpdate).toBool(); if (!isSuccess) { qCritical() << "Cant post data: postEventStatus"; } emit finished(); } 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(); if (!isSuccess) { qCritical() << "Cant update transaction: updateTransactionData"; } emit finished(); } 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(); if (id > 0) { const int transactionId = m_extraData["transactionId"].toInt(); if (!TransactionDAO::updateExternalTransactionId(transactionId, id)) { qCritical() << "Cant update transaction id: " << id; } } emit finished(); } QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &body, bool withAuth) { const QUrl url(path); QNetworkRequest request(url); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); if (withAuth && !m_accessToken.isEmpty()) { request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); } 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(); } QJsonValue result; if (timedOut) { GeneralLogDAO::insert("CRITICAL", "NetworkService", "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 = describeNetError(reply, path); qCritical().noquote() << "Network error (PATCH):" << errSummary << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId) << "\n response body:" << responseBody; GeneralLogDAO::insert("CRITICAL", "NetworkService", "Network error (PATCH): " + errSummary, {}, reqBodyStr, QString::fromUtf8(responseBody)); // Достаём текст ошибки из JSON-body и отправляем в UI. // Бэкенд при HTTP 409 шлёт сообщение вида "материал заблокирован". QString uiMessage; QJsonParseError jerr; const QJsonDocument errDoc = QJsonDocument::fromJson(responseBody, &jerr); if (jerr.error == QJsonParseError::NoError && errDoc.isObject()) { const QJsonObject errObj = errDoc.object(); for (const QString &k : {QStringLiteral("error"), QStringLiteral("message"), QStringLiteral("detail")}) { const QString v = errObj.value(k).toString(); if (!v.isEmpty()) { uiMessage = v; break; } } } if (uiMessage.isEmpty() && !responseBody.isEmpty()) { uiMessage = QString::fromUtf8(responseBody).left(300); } if (!uiMessage.isEmpty()) { emit apiPatchError(httpStatus, uiMessage); } } else { const QByteArray raw = reply->readAll(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) { const QJsonObject root = doc.object(); if (root["success"].toBool()) { result = root["data"]; } else { qCritical() << "API error (PATCH):" << root; GeneralLogDAO::insert("WARNING", "NetworkService", "API error (PATCH): " + sanitizePath(path), {}, reqBodyStr, QString::fromUtf8(raw)); // Defense in depth: backend может прислать HTTP 200 + success:false // (например с {message:"Material is blocked"}). Тоже эмитим в UI. const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); QString uiMessage = root.value("message").toString(); if (uiMessage.isEmpty()) uiMessage = root.value("error").toString(); if (uiMessage.isEmpty()) uiMessage = root.value("detail").toString(); if (!uiMessage.isEmpty()) { emit apiPatchError(httpStatus, uiMessage); } } } } reply->deleteLater(); return result; } QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) { const QUrl url(path); QNetworkRequest request(url); if (withAuth && !m_accessToken.isEmpty()) { 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(); } QJsonValue result; 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):" << describeNetError(reply, path) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); result = QJsonValue::Undefined; } else { const QByteArray raw = reply->readAll(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) { const QJsonObject root = doc.object(); if (root["success"].toBool()) { result = root["data"]; } else { qCritical() << "API error (DELETE):" << root; result = QJsonValue::Null; } } } reply->deleteLater(); return result; } QJsonValue NetworkService::getJson(const QString &path, bool withAuth) { const QUrl url(path); QNetworkRequest request(url); if (withAuth && !m_accessToken.isEmpty()) { 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(); } QJsonValue result; 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):" << describeNetError(reply, path) << (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId); result = QJsonValue::Undefined; } else { const QByteArray raw = reply->readAll(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) { const QJsonObject root = doc.object(); if (root["success"].toBool()) { result = root["data"]; } else { qCritical() << "API error (GET):" << root; result = QJsonValue::Null; } } } reply->deleteLater(); return result; } void NetworkService::syncBankProfilesFromServer() { const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true); if (profileResult.isUndefined() || profileResult.isNull()) { qWarning() << "[syncBankProfilesFromServer] failed to get current_profile"; return; } const QJsonObject profileData = profileResult.toObject(); qDebug() << "[syncBankProfilesFromServer] profileData keys:" << profileData.keys() << "bank_profiles isArray:" << profileData["bank_profiles"].isArray() << "bank_profiles size:" << profileData["bank_profiles"].toArray().size(); const QJsonArray bankProfiles = profileData["bank_profiles"].toArray(); // Маппинг apiId → локальный device_id const QList allDevices = DeviceDAO::getAllDevices(false); QMap apiIdToLocalId; for (const DeviceInfo &d : allDevices) { if (!d.apiId.isEmpty()) { apiIdToLocalId[d.apiId] = d.id; } } const QSettings configSettings("config.ini", QSettings::IniFormat); for (const QJsonValue &bpVal : bankProfiles) { const QJsonObject bp = bpVal.toObject(); const QString bpId = bp["id"].toString(); const QString bankName = bp["bank_name"].toString(); const QString bpState = bp["state"].toString(); QString phone = bp["phone_number"].toString(); phone.remove(' '); phone.remove(QChar(0x00A0)); const QString firstName = bp["first_name"].toString(); const QString middleName = bp["middle_name"].toString(); const QString lastName = bp["last_name"].toString(); // Конвертируем числовой ISO 4217 код в строковый QString currency; if (!bp["currency"].isNull()) { static const QMap isoCurrencyMap = { {643, "RUB"}, {840, "USD"}, {410, "KRW"}, {944, "AZN"}, {32, "ARS"}, {972, "TJS"}, {978, "EUR"}, {826, "GBP"}, {156, "CNY"}, {392, "JPY"}, {949, "TRY"}, }; const int code = bp["currency"].toInt(); currency = isoCurrencyMap.value(code, QString::number(code)); } const QString serverDeviceId = bp["device_id"].toString(); // API UUID устройства if (bpId.isEmpty() || bankName.isEmpty()) continue; // Определяем локальный device_id по API UUID const QString localDeviceId = apiIdToLocalId.value(serverDeviceId); if (localDeviceId.isEmpty()) { qDebug() << "[syncBankProfilesFromServer] device not found locally for api_id" << serverDeviceId << ", skipping bp" << bpId; continue; } const QString fullName = (firstName + " " + (middleName.isEmpty() ? "" : middleName + " ") + lastName).trimmed(); BankProfileInfo app = BankProfileDAO::getApplication(localDeviceId, bankName); if (app.id < 0) { // Приложение не обнаружено на устройстве — создаём запись BankProfileInfo newApp; newApp.deviceId = localDeviceId; newApp.code = bankName; newApp.name = configSettings.value(bankName + "/name", bankName).toString(); newApp.package = configSettings.value(bankName + "/android_package_name").toString(); newApp.status = (bpState == "disabled") ? "off" : "active"; newApp.install = true; newApp.pinCodeChecked = false; newApp.bankProfileId = bpId; newApp.fullName = fullName; newApp.phone = phone; newApp.currency = currency.isEmpty() ? configSettings.value(bankName + "/currency").toString() : currency; if (BankProfileDAO::upsertApplication(newApp)) { app = BankProfileDAO::getApplication(localDeviceId, bankName); qDebug() << "[syncBankProfilesFromServer] created app" << app.id << bankName << "device" << localDeviceId; } else { qWarning() << "[syncBankProfilesFromServer] failed to create app" << bankName << "device" << localDeviceId; continue; } } else { // Application существует — привязываем bank_profile_id если нужно if (app.bankProfileId != bpId) { BankProfileDAO::updateBankProfileId(app.id, bpId); qDebug() << "[syncBankProfilesFromServer] linked bp" << bpId << "to app" << app.id; } if (app.fullName.isEmpty() || app.phone.isEmpty()) { BankProfileDAO::updateProfileInfo(app.id, fullName, phone); } } // Синхронизируем materials → accounts const QJsonArray materials = bp["materials"].toArray(); for (const QJsonValue &matVal : materials) { const QJsonObject mat = matVal.toObject(); const QString matId = mat["id"].toString(); const QString cardNumber = mat["card_number"].toString(); if (matId.isEmpty()) continue; // Уже есть account с этим material_id — пропускаем const MaterialInfo existing = MaterialDAO::findByMaterialId(matId); if (existing.id >= 0) continue; const QString lastNumbers = cardNumber.length() >= 4 ? cardNumber.right(4) : cardNumber; // Ищем существующий account по last_numbers MaterialInfo acc = MaterialDAO::findAppAccount(localDeviceId, bankName, lastNumbers); if (acc.id >= 0) { if (acc.materialId.isEmpty()) { MaterialDAO::updateMaterialId(acc.id, matId); if (acc.cardNumber.isEmpty() && !cardNumber.isEmpty()) { MaterialDAO::updateCardNumber(acc.id, cardNumber); } qDebug() << "[syncBankProfilesFromServer] linked material" << matId << "to account" << acc.id; } } else if (!lastNumbers.isEmpty()) { // Account не существует — создаём MaterialInfo newAcc; newAcc.deviceId = localDeviceId; newAcc.appCode = bankName; newAcc.lastNumbers = lastNumbers; newAcc.cardNumber = cardNumber; newAcc.materialId = matId; newAcc.currency = currency.isEmpty() ? configSettings.value(bankName + "/currency").toString() : currency; newAcc.status = MaterialStatus::Off; newAcc.updateTime = QDateTime::currentDateTime(); if (MaterialDAO::upsertAccount(newAcc)) { qDebug() << "[syncBankProfilesFromServer] created account for material" << matId << "card" << lastNumbers; } else { qWarning() << "[syncBankProfilesFromServer] failed to create account for material" << matId; } } } } qDebug() << "[syncBankProfilesFromServer] done"; } void NetworkService::syncBanksIfEmpty() { const QList existing = BankDAO::getAll(); if (!existing.isEmpty()) { qDebug() << "[syncBanksIfEmpty] banks table has" << existing.size() << "entries, skipping"; return; } qDebug() << "[syncBanksIfEmpty] banks table empty, downloading..."; 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(); } if (timedOut) { qWarning() << "[syncBanksIfEmpty] timeout after" << kMaxRequestAttempts << "attempts"; reply->deleteLater(); return; } if (reply->error() != QNetworkReply::NoError) { qWarning() << "[syncBanksIfEmpty] error:" << reply->errorString(); reply->deleteLater(); return; } const QByteArray data = reply->readAll(); reply->deleteLater(); const QJsonDocument doc = QJsonDocument::fromJson(data); QJsonArray array; if (doc.isArray()) { array = doc.array(); } else if (doc.isObject() && doc.object().contains("data")) { array = doc.object()["data"].toArray(); } QList banks; for (const QJsonValue &val : array) { const QJsonObject obj = val.toObject(); BankInfo bank; bank.alias = obj["alias"].toString(); bank.name = obj["name"].toString(); bank.nspkName = obj["nspk_name"].toString(); if (!bank.alias.isEmpty()) { banks.append(bank); } } if (!banks.isEmpty()) { BankDAO::replaceAll(banks); qDebug() << "[syncBanksIfEmpty] synced" << banks.size() << "banks"; } else { qWarning() << "[syncBanksIfEmpty] no banks in response"; } } void NetworkService::syncDevicesFromServer(const QString &desktopId) { if (desktopId.isEmpty()) return; const QJsonValue devicesResult = getJson(m_apiBase + m_pathDevice + desktopId, true); if (devicesResult.isUndefined() || devicesResult.isNull() || !devicesResult.isArray()) { qWarning() << "[syncDevicesFromServer] failed to get devices for desktop" << desktopId; return; } const QJsonArray devicesArray = devicesResult.toArray(); const QList allDevices = DeviceDAO::getAllDevices(false); QSet processedNames; // защита от дублей с сервера for (const QJsonValue &devVal : devicesArray) { const QJsonObject dev = devVal.toObject(); const QString apiId = dev["id"].toString(); const QString name = dev["name"].toString(); if (apiId.isEmpty()) continue; // Пропускаем дубли по name if (processedNames.contains(name)) { qDebug() << "[syncDevicesFromServer] skipping duplicate:" << apiId << name; continue; } processedNames.insert(name); // Проверяем: есть ли уже устройство с таким apiId в локальной БД bool apiIdExists = false; for (const DeviceInfo &d : allDevices) { if (d.apiId == apiId) { apiIdExists = true; break; } } if (apiIdExists) continue; const DeviceInfo existing = DeviceDAO::findByName(name); if (!existing.id.isEmpty() && !existing.apiId.isEmpty()) { continue; } if (!existing.id.isEmpty() && existing.apiId.isEmpty()) { DeviceDAO::updateApiId(existing.id, apiId); qDebug() << "[syncDevicesFromServer] linked apiId" << apiId << "to device" << existing.id; continue; } // Устройства нет в БД — создаём одну запись (DeviceScreener потом подхватит) DeviceInfo deviceInfo; deviceInfo.apiId = apiId; deviceInfo.name = name; deviceInfo.status = dev["status"].toString(); deviceInfo.screenWidth = dev["screen_width"].toInt(); deviceInfo.screenHeight = dev["screen_height"].toInt(); deviceInfo.battery = dev["battery"].toInt(); deviceInfo.id = apiId; if (DeviceDAO::upsertDevice(deviceInfo)) { qDebug() << "[syncDevicesFromServer] restored device:" << apiId << name; } else { qWarning() << "[syncDevicesFromServer] failed to restore device:" << apiId; } } } bool NetworkService::syncCurrentProfile() { // Локаль — источник истины. Логика: GET current_profile, построить // map id→state, пройти по локальным bank_profiles/materials, для // каждого расхождения отправить PATCH с локальным значением. // См. docs/status-sync.md (правила 2, 3, 4-B). const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true); if (profileResult.isUndefined() || profileResult.isNull()) { qWarning() << "[syncCurrentProfile] GET current_profile failed"; return false; } const QJsonObject profileData = profileResult.toObject(); const QString profileId = profileData["id"].toString(); if (!profileId.isEmpty()) { SettingsDAO::set("profile_id", profileId); } // Маппим серверное состояние в плоские map для быстрого lookup. QMap serverProfileState; // bankProfileId → "enabled"/"disabled" QMap serverMaterialState; // materialId → "enabled"/"disabled" const QJsonArray bankProfiles = profileData["bank_profiles"].toArray(); for (const QJsonValue &bpVal : bankProfiles) { const QJsonObject bp = bpVal.toObject(); const QString bpId = bp["id"].toString(); if (!bpId.isEmpty()) serverProfileState[bpId] = bp["state"].toString(); for (const QJsonValue &matVal : bp["materials"].toArray()) { const QJsonObject mat = matVal.toObject(); const QString matId = mat["id"].toString(); if (!matId.isEmpty()) serverMaterialState[matId] = mat["state"].toString(); } } int pushedProfiles = 0; int pushedMaterials = 0; // Идём по локальным устройствам и их bank_profiles → пушим diff. const QList allDevices = DeviceDAO::getAllDevices(false); for (const DeviceInfo &dev : allDevices) { const QList apps = BankProfileDAO::getApplicationsByDeviceId(dev.id); for (const BankProfileInfo &app : apps) { if (app.bankProfileId.isEmpty()) continue; // не синхронизирован с сервером QSharedPointer mtx = keyMutex(dev.id, app.bankProfileId); QMutexLocker locker(mtx.data()); // bank_profile diff if (serverProfileState.contains(app.bankProfileId)) { const QString localBpState = (app.status == "active") ? "enabled" : "disabled"; const QString srvBpState = serverProfileState.value(app.bankProfileId); if (srvBpState != localBpState) { const QString enableStr = (app.status == "active") ? "true" : "false"; qDebug() << "[syncCurrentProfile] pushing bank_profile" << app.bankProfileId << "→" << enableStr << "(local=" << localBpState << "srv=" << srvBpState << ")"; patchJson(m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/" + enableStr, QJsonObject(), true); ++pushedProfiles; } } else { qWarning() << "[syncCurrentProfile] local bankProfileId" << app.bankProfileId << "(device" << dev.id << "app" << app.code << ") not present on server, skipping"; continue; } // Гард #3 (rule 4-B): материалы не пушим при локально выключенном профиле. if (app.status != "active") continue; const QList materials = MaterialDAO::getAccounts(dev.id, app.code); for (const MaterialInfo &mat : materials) { if (mat.materialId.isEmpty()) continue; if (!serverMaterialState.contains(mat.materialId)) continue; const QString localMatState = (materialStatusToString(mat.status) == "active") ? "enabled" : "disabled"; const QString srvMatState = serverMaterialState.value(mat.materialId); if (srvMatState != localMatState) { const QString enableStr = (localMatState == "enabled") ? "true" : "false"; qDebug() << "[syncCurrentProfile] pushing material" << mat.materialId << "→" << enableStr << "(local=" << localMatState << "srv=" << srvMatState << ")"; patchJson(m_apiBase + m_pathMaterial + "/" + mat.materialId + "/" + enableStr, QJsonObject(), true); ++pushedMaterials; } } } } qDebug() << "[syncCurrentProfile] done — pushed" << pushedProfiles << "profiles," << pushedMaterials << "materials"; return true; } bool NetworkService::doPingProfile() { if (!ensureValidToken()) return false; QJsonArray devicesArr; for (const QString &id : DeviceDAO::getOnlineDeviceApiIds()) devicesArr.append(id); QJsonArray bankProfilesArr; for (const QString &id : BankProfileDAO::getActiveBankProfileIds()) bankProfilesArr.append(id); QJsonObject body; body["bank_profiles"] = bankProfilesArr; body["devices"] = devicesArr; 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; } const QJsonObject body = QJsonDocument::fromJson(m_json).object(); qDebug() << "[NetworkService] postBankProfile payload:" << QJsonDocument(body).toJson(QJsonDocument::Compact); const QJsonValue result = postJson(m_apiBase + m_pathBankProfile, body, true); if (result.isNull() || result.isUndefined()) { qWarning() << "[NetworkService] postBankProfile failed"; } else { const QJsonObject data = result.toObject(); const QString bankProfileId = data["id"].toString(); const int appId = m_extraData.value("app_id").toInt(); if (!bankProfileId.isEmpty() && appId > 0) { BankProfileDAO::updateBankProfileId(appId, bankProfileId); qDebug() << "[NetworkService] postBankProfile saved bank_profile_id:" << bankProfileId; } } emit finished(); } void NetworkService::patchBankProfile() { if (!ensureValidToken()) { emit finished(); return; } const QString bankProfileId = m_extraData.value("bank_profile_id").toString(); if (bankProfileId.isEmpty()) { qWarning() << "[NetworkService] patchBankProfile: bank_profile_id is empty"; emit finished(); return; } const QJsonObject body = QJsonDocument::fromJson(m_json).object(); patchJson(m_apiBase + m_pathBankProfile + "/" + bankProfileId, body, true); qDebug() << "[NetworkService] patchBankProfile done for id:" << bankProfileId; emit finished(); } void NetworkService::patchMaterial() { if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; } const QString materialId = m_extraData.value("material_id").toString(); if (materialId.isEmpty()) { qWarning() << "[NetworkService] patchMaterial: material_id is empty"; emit finishedWithResult(-1); emit finished(); return; } QJsonObject body; if (m_extraData.contains("account_number")) { body["account_number"] = m_extraData.value("account_number").toString(); } if (m_extraData.contains("state")) { body["state"] = m_extraData.value("state").toString(); } const QString path = m_apiBase + m_pathMaterial + "/" + materialId; const QByteArray bodyJson = QJsonDocument(body).toJson(QJsonDocument::Indented); qDebug().noquote() << "[NetworkService] patchMaterial PATCH" << path << "\nbody:\n" << bodyJson; const QJsonValue result = patchJson(path, body, true); if (result.isNull() || result.isUndefined()) { qWarning() << "[NetworkService] patchMaterial failed:" << materialId; emit finishedWithResult(-1); } else { qDebug() << "[NetworkService] patchMaterial done:" << materialId; emit finishedWithResult(0); } emit finished(); } void NetworkService::toggleMaterial() { if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; } const QString materialId = m_extraData.value("material_id").toString(); const bool enable = m_extraData.value("enable").toBool(); if (materialId.isEmpty()) { qWarning() << "[NetworkService] toggleMaterial: material_id is empty"; emit finishedWithResult(-1); emit finished(); return; } // Резолвим (deviceId, bankProfileId) по материалу — нужно и для гарда #3, // и для сериализации через keyMutex. const MaterialInfo localMaterial = MaterialDAO::findByMaterialId(materialId); QString deviceId; QString bankProfileId; bool profileActive = true; if (localMaterial.id >= 0) { deviceId = localMaterial.deviceId; const BankProfileInfo app = BankProfileDAO::getApplication(localMaterial.deviceId, localMaterial.appCode); if (app.id >= 0) { bankProfileId = app.bankProfileId; profileActive = (app.status == "active"); } } // Гард #3 (rule 4-B): пока локальный профиль off, на сервер материал // не пушим — сервер всё равно держит его disabled, любое расхождение // дотянет периодический sync после включения профиля. if (!profileActive) { qDebug() << "[NetworkService] toggleMaterial skipped — local profile is off:" << materialId << "(device" << deviceId << ")"; emit finishedWithResult(0); emit finished(); return; } QSharedPointer mtx = keyMutex(deviceId, bankProfileId); QMutexLocker locker(mtx.data()); const QString enableStr = enable ? "true" : "false"; const QString path = m_apiBase + m_pathMaterial + "/" + materialId + "/" + enableStr; const QJsonValue result = patchJson(path, QJsonObject(), true); // Локаль — источник истины (правило-инвариант). Ответ сервера не пишем // обратно в БД, расхождения дотянет периодический sync. if (result.isNull() || result.isUndefined()) { qWarning() << "[NetworkService] toggleMaterial failed:" << materialId; emit finishedWithResult(-1); } else { qDebug() << "[NetworkService] toggleMaterial ok:" << materialId << "enable=" << enable; emit finishedWithResult(0); } emit finished(); } void NetworkService::deleteMaterial() { if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; } const QString materialId = m_extraData.value("material_id").toString(); const int accountId = m_extraData.value("account_id").toInt(); if (materialId.isEmpty()) { qWarning() << "[NetworkService] deleteMaterial: material_id is empty"; emit finishedWithResult(-1); emit finished(); return; } // Резолвим (deviceId, bankProfileId) для сериализации операций по профилю // через keyMutex (тот же замок, что и в toggleMaterial — см. правило #5). QString deviceId; QString bankProfileId; const MaterialInfo localMaterial = MaterialDAO::findByMaterialId(materialId); if (localMaterial.id >= 0) { deviceId = localMaterial.deviceId; const BankProfileInfo app = BankProfileDAO::getApplication(localMaterial.deviceId, localMaterial.appCode); if (app.id >= 0) bankProfileId = app.bankProfileId; } QSharedPointer mtx = keyMutex(deviceId, bankProfileId); QMutexLocker locker(mtx.data()); const QString path = m_apiBase + m_pathMaterial + "/" + materialId; const QJsonValue result = deleteJson(path, true); if (result.isUndefined()) { qWarning() << "[NetworkService] deleteMaterial failed:" << materialId; emit finishedWithResult(-1); emit finished(); return; } // Локаль — источник истины: даже если сервер вернул не-success (например 404, // материала уже нет), очищаем локальный materialId, чтобы не пытаться его // удалить повторно. На успех приходит null/object — оба считаем ОК. if (accountId > 0) { (void) MaterialDAO::updateMaterialId(accountId, QString()); } qDebug() << "[NetworkService] deleteMaterial ok:" << materialId << "account_id=" << accountId; emit finishedWithResult(0); emit finished(); } void NetworkService::toggleBankProfile() { if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; } const QString bankProfileId = m_extraData.value("bank_profile_id").toString(); const bool enable = m_extraData.value("enable").toBool(); if (bankProfileId.isEmpty()) { qWarning() << "[NetworkService] toggleBankProfile: bank_profile_id is empty"; emit finishedWithResult(-1); emit finished(); return; } // Резолвим deviceId для mutex-ключа (правило #5). QString deviceId; const QList apps = BankProfileDAO::findAllByBankProfileId(bankProfileId); if (!apps.isEmpty()) deviceId = apps.first().deviceId; QSharedPointer mtx = keyMutex(deviceId, bankProfileId); QMutexLocker locker(mtx.data()); const QString enableStr = enable ? "true" : "false"; const QString path = m_apiBase + m_pathBankProfile + "/" + bankProfileId + "/" + enableStr; const QJsonValue result = patchJson(path, QJsonObject(), true); if (result.isNull() || result.isUndefined()) { qWarning() << "[NetworkService] toggleBankProfile failed:" << bankProfileId; emit finishedWithResult(-1); } else { qDebug() << "[NetworkService] toggleBankProfile ok:" << bankProfileId << "enable=" << enable; emit finishedWithResult(0); } emit finished(); } void NetworkService::postMaterial() { if (!ensureValidToken()) { emit finished(); return; } const QJsonObject body = QJsonDocument::fromJson(m_json).object(); qDebug() << "[NetworkService] postMaterial body:" << QJsonDocument(body).toJson(QJsonDocument::Compact); // Сериализуем POST через keyMutex(deviceId, bankProfileId) — тот же замок, // что в deleteMaterial/toggleMaterial. Это закрывает race «клиент получил // OK на DELETE предыдущего материала, но сервер ещё не освободил место под // POST нового» — сервер успеет обработать DELETE до того, как стартует POST. const QString bankProfileId = body.value("bank_profile_id").toString(); QSharedPointer mtx = keyMutex(m_deviceId, bankProfileId); QMutexLocker locker(mtx.data()); const QJsonValue result = postJson(m_apiBase + m_pathMaterial, body, true); if (result.isNull() || result.isUndefined()) { qWarning() << "[NetworkService] postMaterial failed, card:" << body["card_number"].toString(); } else { const QString materialId = result.toObject()["id"].toString(); const int accountId = m_extraData.value("account_id").toInt(); if (!materialId.isEmpty() && accountId > 0) { MaterialDAO::updateMaterialId(accountId, materialId); } qDebug() << "[NetworkService] postMaterial ok, card:" << body["card_number"].toString() << "material_id:" << materialId; // Сервер не применяет поле state при POST — отдельный PATCH //. if (!materialId.isEmpty()) { const QString state = body.value("state").toString(); if (!state.isEmpty()) { const QString enableStr = (state == "enabled") ? "true" : "false"; const QString togglePath = m_apiBase + m_pathMaterial + "/" + materialId + "/" + enableStr; const QJsonValue toggleResult = patchJson(togglePath, QJsonObject(), true); if (toggleResult.isNull() || toggleResult.isUndefined()) { qWarning() << "[NetworkService] postMaterial: state enforcement failed for" << materialId << "state=" << state; } else { qDebug() << "[NetworkService] postMaterial: state enforced for" << materialId << "state=" << state; } } } } emit finished(); } void NetworkService::postDeviceScreenshot() { if (!ensureValidToken()) { emit finished(); return; } const DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.apiId.isEmpty()) { qWarning() << "[NetworkService] postDeviceScreenshot: no api_id for" << m_deviceId; emit finished(); return; } if (m_json.isEmpty()) { qWarning() << "[NetworkService] postDeviceScreenshot: no image data"; emit finished(); return; } const QUrl url(m_apiBase + m_pathDeviceScreenshot + device.apiId); 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; } 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) { qWarning() << "[NetworkService] postDeviceScreenshot timeout after" << kMaxRequestAttempts << "attempts for device" << device.apiId; } else if (reply->error() != QNetworkReply::NoError) { qWarning() << "[NetworkService] postDeviceScreenshot failed:" << reply->errorString(); } else { qDebug() << "[NetworkService] postDeviceScreenshot ok for device" << device.apiId; } reply->deleteLater(); emit finished(); } void NetworkService::createDevice() { const QString desktopId = SettingsDAO::get("desktop_id"); if (desktopId.isEmpty()) { qWarning() << "createDevice: desktop_id not set"; emit finished(); return; } const QJsonObject body = QJsonDocument::fromJson(m_json).object(); const QJsonValue result = postJson(m_apiBase + m_pathDevice, body, true); if (!result.isNull() && !result.isUndefined()) { const QString apiId = result.toObject()["id"].toString(); if (!apiId.isEmpty() && !m_deviceId.isEmpty()) { DeviceDAO::updateApiId(m_deviceId, apiId); qDebug() << "createDevice: saved api_id" << apiId << "for" << m_deviceId; } } emit finished(); } void NetworkService::updateDevice() { const DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.apiId.isEmpty()) { qWarning() << "updateDevice: api_id not set for" << m_deviceId; emit finished(); return; } const QJsonObject body = QJsonDocument::fromJson(m_json).object(); patchJson(m_apiBase + m_pathDevice + device.apiId, body, true); emit finished(); } bool NetworkService::refreshToken() { const QString storedRefreshToken = SettingsDAO::get("refresh_token"); if (storedRefreshToken.isEmpty()) { qWarning() << "refreshToken: refresh_token отсутствует"; return false; } QJsonObject body; body["refresh_token"] = storedRefreshToken; const QJsonValue result = postJson(m_apiBase + m_pathAuthRefresh, body); if (result.isNull() || result.isUndefined()) { qCritical() << "refreshToken: не удалось обновить токен"; if (!m_tokenErrorShown) { m_tokenErrorShown = true; emit tokenRefreshFailed(); } return false; } const QJsonObject data = result.toObject(); m_accessToken = data["access_token"].toString(); if (m_accessToken.isEmpty() || !data.contains("refresh_token") || !data.contains("expires_in")) { qCritical() << "refreshToken: ответ не содержит обязательных полей"; return false; } SettingsDAO::set("access_token", m_accessToken); SettingsDAO::set("refresh_token", data["refresh_token"].toString()); const qint64 expiresIn = static_cast(data["expires_in"].toDouble()); SettingsDAO::setLong("token_expires_at", QDateTime::currentSecsSinceEpoch() + expiresIn); m_tokenErrorShown = false; // сбрасываем если снова заработало qDebug() << "refreshToken: токен обновлён, истекает через" << expiresIn << "сек"; return true; } bool NetworkService::ensureValidToken() { const qint64 expiresAt = SettingsDAO::getLong("token_expires_at"); const qint64 now = QDateTime::currentSecsSinceEpoch(); // Обновляем заранее — за 30 минут до истечения if (expiresAt - now < 30 * 60) { qDebug() << "ensureValidToken: токен истекает через" << (expiresAt - now) << "сек, обновляем"; return refreshToken(); } return true; } void NetworkService::scheduleNextRefresh() { if (!m_running) return; const qint64 expiresAt = SettingsDAO::getLong("token_expires_at"); const qint64 now = QDateTime::currentSecsSinceEpoch(); // Обновляем за 30 минут до истечения; если уже пора — сразу const qint64 secsUntilRefresh = qMax(0, expiresAt - now - 30 * 60); qDebug() << "scheduleNextRefresh: следующий рефреш через" << secsUntilRefresh << "сек" << "(" << secsUntilRefresh / 3600 << "ч" << (secsUntilRefresh % 3600) / 60 << "мин)"; QTimer::singleShot(secsUntilRefresh * 1000, this, [this]() { if (!m_running) return; if (refreshToken()) { scheduleNextRefresh(); // успех — планируем следующий } else { // Ошибка — повторяем через 60 секунд qWarning() << "scheduleNextRefresh: повтор через 60 сек"; QTimer::singleShot(60 * 1000, this, [this]() { scheduleNextRefresh(); }); } }); } void NetworkService::getTasks(const QStringList &materialIds) { if (m_fetchingTasks) { qDebug() << "[NetworkService] getTasks skipped: already fetching"; return; } m_fetchingTasks = true; if (!ensureValidToken()) { m_fetchingTasks = false; return; } const QString accessToken = SettingsDAO::get("access_token"); QUrl url(m_apiBase + m_pathGetTasks); QUrlQuery urlQuery; const QStringList ids = materialIds.isEmpty() ? MaterialDAO::getActiveMaterialIds() : materialIds; if (ids.isEmpty()) { m_fetchingTasks = false; return; } for (const QString &mid : ids) { urlQuery.addQueryItem("materials_id", mid); } url.setQuery(urlQuery); QNetworkRequest request(url); request.setRawHeader("Authorization", ("Bearer " + accessToken).toUtf8()); QNetworkReply *reply = m_manager->get(request); // Таймаут 30 сек: если ответ не пришёл, abort reply чтобы m_fetchingTasks не залип auto *timeoutTimer = new QTimer(this); timeoutTimer->setSingleShot(true); connect(timeoutTimer, &QTimer::timeout, reply, [reply]() { reply->abort(); }); connect(reply, &QNetworkReply::finished, timeoutTimer, &QTimer::stop); connect(reply, &QNetworkReply::finished, timeoutTimer, &QObject::deleteLater); timeoutTimer->start(30000); connect(reply, &QNetworkReply::finished, this, [this, reply]() { m_fetchingTasks = false; reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { qWarning() << "[NetworkService] getTasks error:" << reply->errorString() << "status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); return; } const QByteArray raw = reply->readAll(); QJsonParseError err; const QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error != QJsonParseError::NoError || !doc.isObject()) { qWarning() << "[NetworkService] getTasks: invalid JSON"; return; } const QJsonObject root = doc.object(); if (!root.value("success").toBool()) { qWarning() << "[NetworkService] getTasks: success=false" << root; return; } emit tasksReceived(root.value("data").toArray()); }); } void NetworkService::postTaskResult() { const QJsonObject body = QJsonDocument::fromJson(m_json).object(); const QJsonValue result = postJson(m_apiBase + m_pathTaskResult, body, true); if (result.isUndefined()) { qWarning() << "[NetworkService] postTaskResult failed, body:" << QJsonDocument(body).toJson(QJsonDocument::Compact); emit finishedWithResult(-1); } else { emit finishedWithResult(0); } emit finished(); } void NetworkService::postMonitoringLog(const QString &type, const QString &event, 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(); 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); if (!awaitReply(reply, kMonitoringTimeoutMs)) { qWarning() << "[NetworkService] postMonitoringLog timeout"; reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { qWarning() << "[NetworkService] postMonitoringLog failed:" << reply->errorString(); } reply->deleteLater(); } void NetworkService::postMonitoringDump(const QString &text, const QByteArray &archive, const QByteArray &image, const QString &archiveFilename) { qDebug() << "[NetworkService] postMonitoringDump enter — text.size=" << text.size() << "archive.size=" << archive.size() << "image.size=" << image.size() << "archiveFilename=" << archiveFilename; const QUrl url(m_monitoringBase + m_pathMonitoringDump); QNetworkRequest request(url); if (!m_monitoringToken.isEmpty()) { request.setRawHeader("X-Monitoring-Token", m_monitoringToken.toUtf8()); } 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; } 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(); } else { const QByteArray raw = reply->readAll(); const QJsonDocument doc = QJsonDocument::fromJson(raw); if (doc.isObject() && !doc.object().value("success").toBool()) { qWarning() << "[NetworkService] postMonitoringDump rejected:" << raw; } else { qDebug() << "[NetworkService] postMonitoringDump OK:" << raw; } } reply->deleteLater(); } void NetworkService::postMonitoringScreenshot(const QString &desktopId, const QString &deviceId, const QString &name, const QByteArray &jpeg) { if (jpeg.isEmpty()) return; QNetworkRequest request(QUrl(m_monitoringBase + m_pathMonitoringScreenshot)); if (!m_monitoringToken.isEmpty()) { request.setRawHeader("X-Monitoring-Token", m_monitoringToken.toUtf8()); } auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); addFormField(multiPart, "desktop_id", desktopId); addFormField(multiPart, "device_id", deviceId); addFormField(multiPart, "name", name); QHttpPart imagePart; imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg"); imagePart.setHeader( QNetworkRequest::ContentDispositionHeader, QStringLiteral("form-data; name=\"image\"; filename=\"screen.jpg\"") ); imagePart.setBody(jpeg); multiPart->append(imagePart); QNetworkReply *reply = m_manager->post(request, multiPart); multiPart->setParent(reply); if (!awaitReply(reply, kMonitoringTimeoutMs)) { qWarning() << "[NetworkService] postMonitoringScreenshot timeout"; reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { qWarning() << "[NetworkService] postMonitoringScreenshot failed:" << reply->errorString(); } reply->deleteLater(); } void NetworkService::start() { m_running = true; // Проверяем токен при старте и планируем рефреш по времени жизни ensureValidToken(); scheduleNextRefresh(); // Периодическую синхронизацию запускает startPeriodicSync(), который должен // быть вызван явно после firstPassDone от DeviceScreener (см. правило #6 // в docs/status-sync.md). Без firstPassDone мы не знаем, какие устройства // действительно онлайн, и можем ошибочно сработать как «device→offline». // Heartbeat не зависит от firstPassDone — стартуем сразу. Если desktop_id // ещё не получен (до loginAndSetup), первые тики просто скипнутся. startPeriodicPing(); } void NetworkService::startPeriodicSync() { if (m_periodicSyncEnabled) return; m_periodicSyncEnabled = true; qDebug() << "[NetworkService] periodic sync enabled (first tick in" << (kPeriodicSyncMs / 1000) << "s)"; 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; QTimer::singleShot(kPeriodicSyncMs, this, [this]() { if (!m_running) return; // На время установки обновления пропускаем тик: иначе sync перепушит // локальное "active" обратно на сервер и отменит выключение профилей. if (UpdatePause::isPaused()) { scheduleNextStatusSync(); return; } // Если предыдущий тик ещё не завершился — пропускаем (правило #10). if (m_syncInProgress.exchange(true)) { qDebug() << "[NetworkService] periodic sync tick skipped — previous still in progress"; scheduleNextStatusSync(); return; } const bool ok = runPeriodicSyncTick(); m_syncInProgress.store(false); if (ok) { if (m_consecutiveSyncFailures > 0 || m_unhealthyEmitted) { qDebug() << "[NetworkService] periodic sync recovered after" << m_consecutiveSyncFailures << "failures"; } m_consecutiveSyncFailures = 0; if (m_unhealthyEmitted) { m_unhealthyEmitted = false; emit syncHealthChanged(true, 0); } } else { ++m_consecutiveSyncFailures; qWarning() << "[NetworkService] periodic sync tick failed; consecutive failures:" << m_consecutiveSyncFailures; if (!m_unhealthyEmitted && m_consecutiveSyncFailures >= kSyncFailureThreshold) { m_unhealthyEmitted = true; emit syncHealthChanged(false, m_consecutiveSyncFailures); } } scheduleNextStatusSync(); }); } bool NetworkService::runPeriodicSyncTick() { if (!ensureValidToken()) return false; return syncCurrentProfile(); } QMutex NetworkService::s_keyRegistryMutex; QMap> NetworkService::s_keyMutexes; QSharedPointer NetworkService::keyMutex(const QString &deviceId, const QString &bankProfileId) { const QString key = deviceId + "|" + bankProfileId; QMutexLocker locker(&s_keyRegistryMutex); auto it = s_keyMutexes.find(key); if (it == s_keyMutexes.end()) { it = s_keyMutexes.insert(key, QSharedPointer::create()); } return *it; } void NetworkService::onDeviceWentOffline(const QString &deviceId) { // Правило #1 (device→OFFLINE): выключаем bank_profile и локально, и на сервере. // Материалы локально не трогаем (правило 4-A), но на сервере удаляем все // синхронизированные материалы (инвариант: материал живёт на сервере только // при локально active профиле). if (deviceId.isEmpty()) return; if (!ensureValidToken()) return; qDebug() << "[NetworkService] onDeviceWentOffline:" << deviceId; const QList apps = BankProfileDAO::getApplicationsByDeviceId(deviceId); // 1) Сервер: для каждого активного профиля с bank_profile_id — // сначала DELETE всех его материалов, затем PATCH bank_profile в disabled. for (const BankProfileInfo &app : apps) { if (app.status != "active") continue; if (app.bankProfileId.isEmpty()) continue; QSharedPointer mtx = keyMutex(deviceId, app.bankProfileId); QMutexLocker locker(mtx.data()); // [ОТКЛЮЧЕНО] Серверный DELETE материала при уходе устройства в офлайн убран. // DELETE материала на сервере выполняется только при переключении карты в // настройках банка (AccountSettingsWindow::toggleMaterialStatus). /* const QList materials = MaterialDAO::getAccounts(deviceId, app.code); for (const MaterialInfo &mat : materials) { if (mat.materialId.isEmpty()) continue; const QJsonValue delRes = deleteJson(m_apiBase + m_pathMaterial + "/" + mat.materialId, true); if (!delRes.isUndefined()) { (void) MaterialDAO::updateMaterialId(mat.id, QString()); qDebug() << "[NetworkService] onDeviceWentOffline: deleted material on server:" << mat.materialId; } else { qWarning() << "[NetworkService] onDeviceWentOffline: DELETE failed for material" << mat.materialId; } } */ const QString path = m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/false"; patchJson(path, QJsonObject(), true); qDebug() << "[NetworkService] onDeviceWentOffline: disabled bank_profile on server:" << app.bankProfileId << "(" << app.code << ")"; } // 2) Локально: все профили устройства → off. if (!BankProfileDAO::deactivateAppsForDevice(deviceId)) { qWarning() << "[NetworkService] onDeviceWentOffline: failed to deactivate local apps for device" << deviceId; } } void NetworkService::onDeviceWentOnline(const QString &deviceId) { // Правило #1 (device→ONLINE): локаль не меняем, пушим текущее локальное // состояние bank_profile и его материалов на сервер (с учётом гарда #3). if (deviceId.isEmpty()) return; if (!ensureValidToken()) return; qDebug() << "[NetworkService] onDeviceWentOnline:" << deviceId; const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true); if (profileResult.isUndefined() || profileResult.isNull()) { qWarning() << "[NetworkService] onDeviceWentOnline: GET current_profile failed"; return; } QMap serverProfileState; QMap serverMaterialState; const QJsonArray bankProfiles = profileResult.toObject()["bank_profiles"].toArray(); for (const QJsonValue &bpVal : bankProfiles) { const QJsonObject bp = bpVal.toObject(); const QString bpId = bp["id"].toString(); if (!bpId.isEmpty()) serverProfileState[bpId] = bp["state"].toString(); for (const QJsonValue &matVal : bp["materials"].toArray()) { const QJsonObject mat = matVal.toObject(); const QString matId = mat["id"].toString(); if (!matId.isEmpty()) serverMaterialState[matId] = mat["state"].toString(); } } const QList apps = BankProfileDAO::getApplicationsByDeviceId(deviceId); for (const BankProfileInfo &app : apps) { if (app.bankProfileId.isEmpty()) continue; if (!serverProfileState.contains(app.bankProfileId)) { qWarning() << "[NetworkService] onDeviceWentOnline: server lacks bank_profile" << app.bankProfileId; continue; } QSharedPointer mtx = keyMutex(deviceId, app.bankProfileId); QMutexLocker locker(mtx.data()); const QString localBpState = (app.status == "active") ? "enabled" : "disabled"; const QString srvBpState = serverProfileState.value(app.bankProfileId); if (srvBpState != localBpState) { const QString enableStr = (app.status == "active") ? "true" : "false"; patchJson(m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/" + enableStr, QJsonObject(), true); qDebug() << "[NetworkService] onDeviceWentOnline: pushed bank_profile" << app.bankProfileId << "→" << enableStr; } // Гард #3: материалы не пушим при локально выключенном профиле. if (app.status != "active") continue; const QList materials = MaterialDAO::getAccounts(deviceId, app.code); for (const MaterialInfo &mat : materials) { if (mat.materialId.isEmpty()) continue; if (!serverMaterialState.contains(mat.materialId)) continue; const QString localMatState = (materialStatusToString(mat.status) == "active") ? "enabled" : "disabled"; const QString srvMatState = serverMaterialState.value(mat.materialId); if (srvMatState != localMatState) { const QString enableStr = (localMatState == "enabled") ? "true" : "false"; patchJson(m_apiBase + m_pathMaterial + "/" + mat.materialId + "/" + enableStr, QJsonObject(), true); qDebug() << "[NetworkService] onDeviceWentOnline: pushed material" << mat.materialId << "→" << enableStr; } } } } void NetworkService::deleteDeviceFromServer() { if (!ensureValidToken()) { emit finished(); return; } const DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.apiId.isEmpty()) { qWarning() << "[deleteDeviceFromServer] no api_id for device" << m_deviceId; // Удаляем только локально DeviceDAO::deleteDevice(m_deviceId); emit finished(); return; } // Сначала удаляем на сервере все банковские профили девайса, которые уже // были синхронизированы (есть bank_profile_id). Несинхронизированные — // пропускаем, на сервере их нет. const QList profiles = BankProfileDAO::getApplicationsByDeviceId(device.id); for (const BankProfileInfo &bp : profiles) { if (bp.bankProfileId.isEmpty()) continue; const QJsonValue bpResult = deleteJson(m_apiBase + m_pathBankProfile + "/" + bp.bankProfileId, true); if (bpResult.isUndefined()) { qWarning() << "[deleteDeviceFromServer] failed to delete bank_profile on server:" << bp.bankProfileId << bp.code; } else { qDebug() << "[deleteDeviceFromServer] deleted bank_profile on server:" << bp.bankProfileId << bp.code; } } const QJsonValue result = deleteJson(m_apiBase + m_pathDevice + device.apiId, true); if (result.isUndefined()) { qWarning() << "[deleteDeviceFromServer] failed to delete device on server:" << device.apiId; } else { qDebug() << "[deleteDeviceFromServer] deleted device on server:" << device.apiId; } DeviceDAO::deleteDevice(m_deviceId); emit finished(); } void NetworkService::disableAllProfilesOnServer() { if (!ensureValidToken()) return; const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true); if (profileResult.isUndefined() || profileResult.isNull()) return; const QJsonArray bankProfiles = profileResult.toObject()["bank_profiles"].toArray(); for (const QJsonValue &bpVal : bankProfiles) { const QJsonObject bp = bpVal.toObject(); const QString bpId = bp["id"].toString(); const QString bpState = bp["state"].toString(); if (bpId.isEmpty()) continue; // [ОТКЛЮЧЕНО] Серверный DELETE материала при shutdown убран. DELETE материала // на сервере выполняется только при переключении карты в настройках банка // (AccountSettingsWindow::toggleMaterialStatus). /* // Сперва DELETE всех материалов профиля — инвариант «материал живёт на // сервере только при локально active профиле». Идём по списку с сервера, // чтобы зацепить и те, что локально потеряли materialId, но на бэке остались. const QJsonArray materials = bp["materials"].toArray(); for (const QJsonValue &matVal : materials) { const QJsonObject mat = matVal.toObject(); const QString matId = mat["id"].toString(); if (matId.isEmpty()) continue; const QJsonValue delRes = deleteJson(m_apiBase + m_pathMaterial + "/" + matId, true); if (!delRes.isUndefined()) { const MaterialInfo local = MaterialDAO::findByMaterialId(matId); if (local.id >= 0) (void) MaterialDAO::updateMaterialId(local.id, QString()); qDebug() << "[shutdown] deleted material" << matId; } else { qWarning() << "[shutdown] DELETE failed for material" << matId; } } */ if (bpState != "disabled") { patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true); qDebug() << "[shutdown] disabled bank_profile" << bpId; } } } void NetworkService::stop() { m_running = false; QMetaObject::invokeMethod(this, [this]() { disableAllProfilesOnServer(); emit finished(); }, Qt::QueuedConnection); } QString NetworkService::lastServerMessage() const { if (m_lastResponseBody.isEmpty()) return {}; const QJsonDocument doc = QJsonDocument::fromJson(m_lastResponseBody.toUtf8()); if (!doc.isObject()) return m_lastResponseBody; const QJsonObject obj = doc.object(); // Формат с валидационными ошибками: {"errors": {"": ["msg", ...]}} — // берём первое непустое сообщение. Это покрывает "bank profile with this // phone number already exists" и подобные ответы от бэка. const QJsonValue errorsVal = obj.value("errors"); if (errorsVal.isObject()) { const QJsonObject errors = errorsVal.toObject(); for (auto it = errors.begin(); it != errors.end(); ++it) { if (it.value().isArray()) { const QJsonArray arr = it.value().toArray(); for (const QJsonValue &v : arr) { const QString s = v.toString(); if (!s.isEmpty()) return s; } } else { const QString s = it.value().toString(); if (!s.isEmpty()) return s; } } } for (const QString &k : {QStringLiteral("error"), QStringLiteral("message"), QStringLiteral("detail")}) { const QString v = obj.value(k).toString(); if (!v.isEmpty()) return v; } return m_lastResponseBody; } void NetworkService::connectErrorDialog(QWidget *parent, NetworkService *net) { if (!net) return; // Если parent есть — connect привязан к нему (авто-disconnect при уничтожении). // Если nullptr — привязываемся к самому net и берём activeWindow на момент сигнала. QObject *ctx = parent ? static_cast(parent) : static_cast(net); QObject::connect(net, &NetworkService::apiPatchError, ctx, [parent](int httpStatus, const QString &message) { Q_UNUSED(httpStatus); QWidget *p = parent ? parent : qApp->activeWindow(); QMessageBox::warning(p, "Ошибка", message); }, Qt::QueuedConnection); }