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

2133 lines
92 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 "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;
} // 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_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<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);
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<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() {
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<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() {
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<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)};
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<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) {
const QList<BankProfileInfo> apps = BankProfileDAO::getApplicationsByDeviceId(dev.id);
for (const BankProfileInfo &app : apps) {
if (app.bankProfileId.isEmpty()) continue; // не синхронизирован с сервером
QSharedPointer<QMutex> 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<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::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());
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<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();
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, "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);
}
reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
qWarning() << "[NetworkService] postMonitoringLog timeout attempt"
<< attempt << "/" << kMaxRequestAttempts;
reply->abort();
}
if (timedOut) {
qWarning() << "[NetworkService] postMonitoringLog timeout after"
<< kMaxRequestAttempts << "attempts";
} else if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[NetworkService] postMonitoringLog failed:" << reply->errorString()
<< "\n response body:" << reply->readAll();
}
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::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 и локально, и на сервере.
// Материалы локально не трогаем (правило 4-A), но на сервере удаляем все
// синхронизированные материалы (инвариант: материал живёт на сервере только
// при локально active профиле).
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());
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 << ")";
}
// 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<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 всех материалов профиля — инвариант «материал живёт на
// сервере только при локально 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": {"<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);
}