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

1845 lines
79 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 <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"
// Убирает полный URL (хост:порт) из сообщений об ошибках, оставляя только
// путь эндпоинта и HTTP-код. Нужно, чтобы адрес сервера не попадал в
// логи/UI/general_logs/application.log.
static QString sanitizePath(const QString &fullUrl) {
const int schemeEnd = fullUrl.indexOf("://");
if (schemeEnd < 0) return fullUrl;
const int pathStart = fullUrl.indexOf('/', schemeEnd + 3);
return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/");
}
static QString sanitizeNetError(QNetworkReply *reply, const QString &path) {
const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString endpoint = sanitizePath(path);
if (httpCode > 0) {
return QStringLiteral("HTTP %1 %2").arg(httpCode).arg(endpoint);
}
// Сетевой уровень (не HTTP) — выводим enum-код вместо строки с URL.
return QStringLiteral("net_err=%1 %2").arg(static_cast<int>(reply->error())).arg(endpoint);
}
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_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() << "Таймаут запроса:" << sanitizePath(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() << "Ошибка:" << sanitizeNetError(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 = m_manager->get(request);
QScopedPointer<QNetworkReply, QScopedPointerDeleteLater> guard(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();
QJsonArray result;
if (!timeout.isActive()) {
qCritical() << "Таймаут запроса (GET):" << sanitizePath(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").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());
}
QNetworkReply *reply = m_manager->post(request, QJsonDocument(body).toJson());
QEventLoop loop;
QTimer timeout;
timeout.setSingleShot(true);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) {
loop.quit();
});
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
QJsonValue result;
const QString shortPath = sanitizePath(path);
if (!timeout.isActive()) {
qCritical() << "Таймаут запроса (postJson):" << shortPath;
reply->abort();
GeneralLogDAO::insert("CRITICAL", "NetworkService",
"Timeout (POST): " + shortPath, {}, reqBodyStr, {});
result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) {
const QByteArray responseBody = reply->readAll();
const QString errSummary = sanitizeNetError(reply, path);
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 {
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: получаем актуальный desktop с сервера
QString desktopId = SettingsDAO::get("desktop_id");
// Всегда проверяем список десктопов — desktop_id мог устареть или быть удалён
const QJsonValue existingDesktops = getJson(m_apiBase + m_pathDesktopCreate, true);
if (!existingDesktops.isUndefined() && !existingDesktops.isNull() && existingDesktops.isArray()) {
const QJsonArray desktopsArray = existingDesktops.toArray();
QString foundId;
QString foundProfileId;
for (const QJsonValue &dVal : desktopsArray) {
const QJsonObject d = dVal.toObject();
if (d["hidden"].toBool(false)) continue;
foundId = d["id"].toString();
foundProfileId = d["profile_id"].toString();
if (!foundId.isEmpty()) break;
}
if (!foundId.isEmpty() && foundId != desktopId) {
// Нашли актуальный десктоп, отличается от сохранённого — обновляем
qDebug() << "[loginAndSetup] desktop_id updated:" << desktopId << "->" << foundId;
desktopId = foundId;
SettingsDAO::set("desktop_id", desktopId);
SettingsDAO::set("profile_id", foundProfileId);
} else if (!foundId.isEmpty()) {
qDebug() << "[loginAndSetup] desktop_id confirmed:" << desktopId;
}
}
if (desktopId.isEmpty()) {
// Десктопов на сервере нет — создаём новый
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()) {
emit finishedWithResult(-1);
emit finished();
return;
}
const QJsonObject desktopData = desktopResult.toObject();
desktopId = desktopData["id"].toString();
SettingsDAO::set("desktop_id", desktopId);
SettingsDAO::set("profile_id", desktopData["profile_id"].toString());
}
// Шаг 3: синхронизация устройств с сервера
syncDevicesFromServer(desktopId);
// Шаг 4: синхронизация банковских профилей и материалов с сервера
syncBankProfilesFromServer();
// Шаг 5: убран (bank_participator_name_ru приходит в задаче)
// Шаг 6: синхронизация статусов профилей/материалов с учётом онлайн-устройств
syncCurrentProfile();
// Шаг 7: отправляем 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() << "[loginAndSetup] 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());
}
QNetworkReply *reply = m_manager->sendCustomRequest(request, "PATCH", QJsonDocument(body).toJson());
QEventLoop loop;
QTimer timeout;
timeout.setSingleShot(true);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { loop.quit(); });
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
QJsonValue result;
if (!timeout.isActive()) {
const QString shortPath = sanitizePath(path);
qCritical() << "Таймаут запроса (PATCH):" << shortPath;
reply->abort();
GeneralLogDAO::insert("CRITICAL", "NetworkService",
"Timeout (PATCH): " + shortPath, {}, reqBodyStr, {});
} else if (reply->error() != QNetworkReply::NoError) {
const QByteArray responseBody = reply->readAll();
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString errSummary = sanitizeNetError(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 = m_manager->deleteResource(request);
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() << "Таймаут запроса (DELETE):" << sanitizePath(path);
reply->abort();
result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) {
qCritical().noquote() << "Network error (DELETE):" << sanitizeNetError(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 = m_manager->get(request);
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() << "Таймаут запроса (getJson):" << sanitizePath(path);
reply->abort();
result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) {
qCritical().noquote() << "Network error (GET):" << sanitizeNetError(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...";
QNetworkRequest request(QUrl("https://api.brtservice.io/api/rest/info/"));
QNetworkReply *reply = m_manager->get(request);
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();
if (!timeout.isActive()) {
qWarning() << "[syncBanksIfEmpty] timeout";
reply->abort();
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;
}
}
}
void 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;
}
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";
}
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::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);
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;
}
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);
const QUrl url(m_apiBase + m_pathDeviceScreenshot + device.apiId);
QNetworkRequest request(url);
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
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();
if (!timeout.isActive()) {
qWarning() << "[NetworkService] postDeviceScreenshot timeout for device" << device.apiId;
reply->abort();
} 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) {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormField(multiPart, "type", type.toUpper());
addFormField(multiPart, "event", event);
addFormField(multiPart, "message", message);
const int internalTaskId = m_extraData.value("internal_task_id").toInt();
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);
}
const QUrl url(m_monitoringBase + m_pathMonitoringLog);
QNetworkRequest request(url);
if (!m_monitoringToken.isEmpty()) {
request.setRawHeader("X-Monitoring-Token", m_monitoringToken.toUtf8());
}
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(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
if (!timeout.isActive()) {
qWarning() << "[NetworkService] postMonitoringLog timeout";
reply->abort();
} 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;
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);
}
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 = m_manager->post(request, multiPart);
multiPart->setParent(reply);
QEventLoop loop;
QTimer timeout;
timeout.setSingleShot(true);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(120000);
loop.exec();
if (!timeout.isActive()) {
qWarning() << "[NetworkService] postMonitoringDump timeout";
reply->abort();
} 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».
}
void NetworkService::startPeriodicSync() {
if (m_periodicSyncEnabled) return;
m_periodicSyncEnabled = true;
qDebug() << "[NetworkService] periodic sync enabled (first tick in" << (kPeriodicSyncMs / 1000) << "s)";
scheduleNextStatusSync();
}
void NetworkService::scheduleNextStatusSync() {
if (!m_running || !m_periodicSyncEnabled) return;
QTimer::singleShot(kPeriodicSyncMs, this, [this]() {
if (!m_running) 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() {
// Фаза 2 — обёртка с грубым detect-успех/неудача. В фазе 3 syncCurrentProfile
// будет переписан и сам вернёт bool; пока считаем тик неуспешным только при
// протухшем токене (более тонкая детекция придёт с рефакторингом).
if (!ensureValidToken()) return false;
syncCurrentProfile();
return true;
}
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).
if (deviceId.isEmpty()) return;
if (!ensureValidToken()) return;
qDebug() << "[NetworkService] onDeviceWentOffline:" << deviceId;
const QList<BankProfileInfo> apps = BankProfileDAO::getApplicationsByDeviceId(deviceId);
// 1) Сервер: для каждого активного профиля с bank_profile_id → PATCH disable.
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 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() || bpState == "disabled") continue;
patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true);
qDebug() << "[shutdown] disabled bank_profile" << bpId;
// const QJsonArray materials = bp["materials"].toArray();
// for (const QJsonValue &matVal : materials) {
// const QJsonObject mat = matVal.toObject();
// const QString matId = mat["id"].toString();
// const QString matState = mat["state"].toString();
// if (matId.isEmpty() || matState == "disabled") continue;
//
// patchJson(m_apiBase + m_pathMaterial + "/" + matId + "/false", QJsonObject(), true);
// qDebug() << "[shutdown] disabled material" << matId;
// }
}
}
void NetworkService::stop() {
m_running = false;
QMetaObject::invokeMethod(this, [this]() {
disableAllProfilesOnServer();
emit finished();
}, Qt::QueuedConnection);
}
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);
}