2067 lines
90 KiB
C++
2067 lines
90 KiB
C++
#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"
|
||
|
||
// Убирает полный 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> ¶ms) {
|
||
QUrl url(path);
|
||
QUrlQuery query;
|
||
for (auto it = params.constBegin(); it != params.constEnd(); ++it) {
|
||
query.addQueryItem(it.key(), it.value());
|
||
}
|
||
url.setQuery(query);
|
||
|
||
const QNetworkRequest request(url);
|
||
|
||
QNetworkReply *reply = nullptr;
|
||
bool timedOut = true;
|
||
QString lastTimeoutDetail;
|
||
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
|
||
if (reply) { reply->deleteLater(); reply = nullptr; }
|
||
reply = m_manager->get(request);
|
||
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
|
||
lastTimeoutDetail = describeClientTimeout(reply, path);
|
||
qCritical().noquote() << "Network timeout (GET) attempt" << attempt
|
||
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
|
||
reply->abort();
|
||
}
|
||
|
||
QJsonArray result;
|
||
if (timedOut) {
|
||
qCritical().noquote() << "Network timeout (GET) after"
|
||
<< kMaxRequestAttempts << "attempts:" << lastTimeoutDetail;
|
||
} else if (reply->error() == QNetworkReply::NoError) {
|
||
const QByteArray raw = reply->readAll();
|
||
QJsonParseError err;
|
||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
|
||
if (err.error == QJsonParseError::NoError && doc.isObject()) {
|
||
const QJsonObject root = doc.object();
|
||
if (root.value("success").toBool()) {
|
||
result = root.value("data").toArray();
|
||
} else {
|
||
qWarning() << "Ошибка:" << root;
|
||
}
|
||
}
|
||
} else {
|
||
qWarning() << "Ошибка:" << reply->errorString();
|
||
}
|
||
reply->deleteLater();
|
||
return result;
|
||
}
|
||
|
||
void NetworkService::postAccountsData() {
|
||
// TODO: заменить на новый API
|
||
emit finished();
|
||
}
|
||
|
||
void NetworkService::getPayments() {
|
||
QMap<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: получаем актуальный 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());
|
||
}
|
||
|
||
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;
|
||
|
||
const QString desktopId = SettingsDAO::get("desktop_id");
|
||
if (desktopId.isEmpty()) {
|
||
qWarning() << "[NetworkService] pingProfile: desktop_id is empty, skip";
|
||
return false;
|
||
}
|
||
|
||
const QSettings settings("config.ini", QSettings::IniFormat);
|
||
QJsonObject body;
|
||
body["desktop_id"] = desktopId;
|
||
body["version"] = settings.value("common/version").toString();
|
||
|
||
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;
|
||
|
||
// Если предыдущий тик ещё не завершился — пропускаем (правило #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);
|
||
}
|