1
0
forked from BRT/arc
arc/services/net/NetworkService.cpp

2231 lines
99 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "NetworkService.h"
#include "NetworkErrorUtils.h"
#include <QApplication>
#include <QFile>
#include <QMessageBox>
#include <QUrlQuery>
#include <QHttpPart>
#include <QNetworkReply>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QThread>
#include <QSettings>
#include <QWidget>
#include <QTimer>
#include <QTimeZone>
#include <QMutexLocker>
#include <QEventLoop>
#include <functional>
#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;
using NetworkErrorUtils::isConnectPhaseTransient;
using NetworkErrorUtils::isIdempotentTransient;
namespace {
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(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
if (!timer.isActive()) return AwaitOutcome::ClientTimeout;
return reply->error() == QNetworkReply::NoError
? AwaitOutcome::FinishedClean
: AwaitOutcome::TransportError;
}
// Ретраим при (а) нашем 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 = 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<QNetworkReply*()> &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) {
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(const std::function<QHttpMultiPart*()> &buildMultiPart,
const QString &path, bool idempotent) {
const QUrl url(path);
const QNetworkRequest request(url);
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 (outcome.clientTimedOut) {
qCritical().noquote() << "Network timeout:" << outcome.timeoutDetail;
} 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<QString, QString> &params) {
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);
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) {
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<QString, QString> 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() {
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";
}
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);
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();
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<qint64>(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<DeviceInfo> 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() {
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";
}
emit finished();
}
void NetworkService::updateTransactionData() {
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";
}
emit finished();
}
void NetworkService::insertTransactionData() {
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)) {
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);
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) {
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());
}
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) {
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());
}
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->get(request); },
kDefaultTimeoutMs, path, "getJson", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
QJsonValue result;
if (outcome.clientTimedOut) {
qCritical().noquote() << "Network timeout (getJson) after"
<< kMaxRequestAttempts << "attempts:" << outcome.timeoutDetail;
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<DeviceInfo> allDevices = DeviceDAO::getAllDevices(false);
QMap<QString, QString> 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<int, QString> 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<BankInfo> 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)};
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->get(request); },
kDefaultTimeoutMs, path, "syncBanksIfEmpty", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
if (outcome.clientTimedOut) {
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<BankInfo> 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<DeviceInfo> allDevices = DeviceDAO::getAllDevices(false);
QSet<QString> 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<QString, QString> serverProfileState; // bankProfileId → "enabled"/"disabled"
QMap<QString, QString> 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<DeviceInfo> allDevices = DeviceDAO::getAllDevices(false);
for (const DeviceInfo &dev : allDevices) {
// Серверное состояние профиля = (локально active) AND (устройство online).
// Офлайн-устройство держим disabled на сервере и НЕ реактивируем, даже
// если локально профиль остался active (правило 1/2: server отражает
// намерение пользователя И достижимость устройства).
const bool online = (dev.status == "device");
const QList<BankProfileInfo> apps = BankProfileDAO::getApplicationsByDeviceId(dev.id);
for (const BankProfileInfo &app : apps) {
if (app.bankProfileId.isEmpty()) continue; // не синхронизирован с сервером
const bool desiredEnabled = (app.status == "active") && online;
QSharedPointer<QMutex> mtx = keyMutex(dev.id, app.bankProfileId);
QMutexLocker locker(mtx.data());
// bank_profile diff
if (serverProfileState.contains(app.bankProfileId)) {
const QString desiredBpState = desiredEnabled ? "enabled" : "disabled";
const QString srvBpState = serverProfileState.value(app.bankProfileId);
if (srvBpState != desiredBpState) {
const QString enableStr = desiredEnabled ? "true" : "false";
qDebug() << "[syncCurrentProfile] pushing bank_profile" << app.bankProfileId
<< "" << enableStr << "(desired=" << desiredBpState << "srv=" << srvBpState
<< "local=" << app.status << "online=" << online << ")";
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): материалы не пушим, если профиль эффективно
// disabled на сервере (локально off ИЛИ устройство offline) — иначе
// бесконечный diff-цикл из-за инварианта material.state ≤ bank_profile.state.
if (!desiredEnabled) continue;
const QList<MaterialInfo> 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::deleteBankProfileFromServer() {
if (!ensureValidToken()) { emit finished(); return; }
const QString bankProfileId = m_extraData.value("bank_profile_id").toString();
if (bankProfileId.isEmpty()) {
qWarning() << "[NetworkService] deleteBankProfileFromServer: bank_profile_id is empty";
emit finished();
return;
}
const QJsonValue result = deleteJson(m_apiBase + m_pathBankProfile + "/" + bankProfileId, true);
if (result.isUndefined()) {
qWarning() << "[NetworkService] deleteBankProfileFromServer failed for id:" << bankProfileId;
} else {
qDebug() << "[NetworkService] deleteBankProfileFromServer 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<QMutex> 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<QMutex> 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<BankProfileInfo> apps = BankProfileDAO::findAllByBankProfileId(bankProfileId);
if (!apps.isEmpty()) deviceId = apps.first().deviceId;
QSharedPointer<QMutex> 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<QMutex> 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 /<id>/<true|false>.
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());
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;
if (outcome.clientTimedOut) {
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<qint64>(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<qint64>(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) == AwaitOutcome::ClientTimeout) {
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();
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;
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) {
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,
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) == AwaitOutcome::ClientTimeout) {
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<QString, QSharedPointer<QMutex>> NetworkService::s_keyMutexes;
QSharedPointer<QMutex> 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<QMutex>::create());
}
return *it;
}
void NetworkService::onDeviceWentOffline(const QString &deviceId) {
// Правило #1 (device→OFFLINE): выключаем bank_profile ТОЛЬКО на сервере.
// Локальный статус НЕ трогаем — это намерение пользователя, его меняет лишь
// ручной тумблер или выключение приложения (см. stop()). Когда устройство
// вернётся онлайн, профиль снова включится на сервере (onDeviceWentOnline +
// периодический sync). Материалы локально не трогаем (правило 4-A).
if (deviceId.isEmpty()) return;
if (!ensureValidToken()) return;
qDebug() << "[NetworkService] onDeviceWentOffline:" << deviceId;
const QList<BankProfileInfo> 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<QMutex> mtx = keyMutex(deviceId, app.bankProfileId);
QMutexLocker locker(mtx.data());
// [ОТКЛЮЧЕНО] Серверный DELETE материала при уходе устройства в офлайн убран.
// DELETE материала на сервере выполняется только при переключении карты в
// настройках банка (AccountSettingsWindow::toggleMaterialStatus).
/*
const QList<MaterialInfo> 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 << ")";
}
// Локальный статус НАМЕРЕННО не меняем: дисконнект устройства не должен
// выключать профиль локально (см. комментарий в начале функции).
}
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<QString, QString> serverProfileState;
QMap<QString, QString> 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<BankProfileInfo> 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<QMutex> 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<MaterialInfo> 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<BankProfileInfo> 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 переиспользуется и там.)
disableAllProfilesOnServer();
if (!BankProfileDAO::deactivateAllApps()) {
qWarning() << "[NetworkService] stop: failed to deactivate local profiles";
}
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": {"<field>": ["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<QObject *>(parent) : static_cast<QObject *>(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);
}