#pragma once #include #include #include #include #include #include #include #include #include class QWidget; class NetworkService final : public QObject { Q_OBJECT public: explicit NetworkService(QObject *parent = nullptr); ~NetworkService() override; void setPayload(const QByteArray &json) { m_json = json; } void setDeviceId(const QString &deviceId) { m_deviceId = deviceId; } void addExtra(const QString &key, const QVariant &value); // Подвешивает QMessageBox с текстом ошибки от backend (HTTP 409 — "материал // заблокирован" и подобные) к ближайшему окну. Вызывать после `new NetworkService` // в UI-коде, до запуска треда. static void connectErrorDialog(QWidget *parent, NetworkService *net); // Тело ответа сервера от последнего postJson/patchJson/getJson — пусто на // успех. Используется скриптами, чтобы распознать сообщение об ошибке (напр. // "already exists") и отдать UI осмысленный код. QString lastResponseBody() const { return m_lastResponseBody; } // Достаёт message/error/detail из JSON в lastResponseBody(). Если тело не // JSON — возвращает его как есть; на пустом — пустую строку. QString lastServerMessage() const; public slots: void postAccountsData(); void postBankProfile(); void patchBankProfile(); void toggleBankProfile(); void postMaterial(); void patchMaterial(); void toggleMaterial(); // DELETE /api/v1/profile/material/{material_id}. На успехе очищает // локальный materialId у переданного account_id (если задан). void deleteMaterial(); void postDeviceScreenshot(); void getPayments(); void postAppStatus(); void postEventStatus(); void insertTransactionData(); void updateTransactionData(); void loginAndSetup(); // Продолжение loginAndSetup после выбора десктопа пользователем. // Пустой desktopId — выбран пункт «Создать новый». Вызывать через // QueuedConnection: слот исполняется в потоке NetworkService. void continueWithDesktop(const QString &desktopId); // true — GET current_profile прошёл; false — таймаут/сеть. Используется // health-счётчиком в runPeriodicSyncTick (правило #9). bool syncCurrentProfile(); // POST /api/v1/profile/ping — heartbeat: даёт серверу понять, что десктоп // онлайн. В теле уходят desktop_id и версия клиента. Вызывать периодически. void pingProfile(); // Включает периодический ping (kPingProfileMs). Идемпотентен. void startPeriodicPing(); // Включает периодическую синхронизацию статусов (60s). Должно быть вызвано // ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener. void startPeriodicSync(); // Обрабатывает переход устройства в офлайн (дебаунс-сигнал от DeviceScreener): // выключает bank_profile локально и на сервере. void onDeviceWentOffline(const QString &deviceId); // Обрабатывает переход устройства в онлайн: пушит текущее локальное // состояние профиля и материалов на сервер (локаль не меняем). void onDeviceWentOnline(const QString &deviceId); void createDevice(); void updateDevice(); void getTasks(const QStringList &materialIds = {}); void postTaskResult(); // toApiBase=true — POST на m_apiBase + /monitoring/log с Bearer-токеном // (старый эндпоинт, используется для task_success). По умолчанию false — // monitoringBase + X-Monitoring-Token (Telegram-релей, для ошибок). void postMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image, bool toApiBase = false); void postMonitoringDump(const QString &text, const QByteArray &archive, const QByteArray &image, const QString &archiveFilename = QStringLiteral("task_dump.zip")); // Отправляет JPEG-скриншот девайса на monitoringBase (/monitoring/screenshot, // X-Monitoring-Token) для истории экрана. Q_INVOKABLE — вызывается через // QueuedConnection из fire-and-forget потока DeviceScreener. Q_INVOKABLE void postMonitoringScreenshot(const QString &desktopId, const QString &deviceId, const QString &name, const QByteArray &jpeg); void start(); void stop(); void disableAllProfilesOnServer(); void deleteDeviceFromServer(); signals: void finished(); void finishedWithResult(int value); // Эмитится из loginAndSetup, когда на сервере есть десктопы, но локального // выбора нет — UI показывает окно выбора. После выбора UI вызывает // continueWithDesktop(). До этого finished() НЕ эмитится (поток ждёт). void desktopSelectionRequired(const QJsonArray &desktops); void tokenRefreshFailed(); void tasksReceived(QJsonArray tasks); // Эмитится при HTTP-ошибке PATCH-запроса (например, 409 'материал заблокирован'). // UI подписывается и показывает QMessageBox. void apiPatchError(int httpStatus, QString message); // healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true // при первом успехе после такой серии. consecutiveFailures даёт текущий счёт. void syncHealthChanged(bool healthy, int consecutiveFailures); // Результат postMonitoringDump (включая ручную отправку логов из меню). // archiveFilename — ключ, по которому инициатор отличает свой аплоад (синглтон // общий). ok=false ⇒ detail содержит причину (таймаут/сетевая/HTTP-ошибка). void monitoringDumpFinished(bool ok, QString detail, QString archiveFilename); private: QByteArray m_json; QString m_deviceId; QString m_token; QString m_accessToken; QString m_apiBase; QMap m_extraData = {}; bool m_running = true; bool m_tokenErrorShown = false; QString m_urlAccountUpdate; QString m_urlPaymentsGet; QString m_urlTransactionUpdate; QString m_urlAppStatusUpdate; QString m_urlTransactionInsert; QString m_urlEventStatusUpdate; QString m_pathAuthLogin; QString m_pathAuthRefresh; QString m_pathDesktopCreate; QString m_pathDevice; QString m_pathDeviceScreenshot; QString m_pathBankProfile; QString m_pathMaterial; QString m_pathGetTasks; QString m_pathTaskResult; QString m_pathMonitoringLog; QString m_pathMonitoringDump; QString m_pathMonitoringScreenshot; QString m_monitoringBase; QString m_monitoringToken; QString m_pathCurrentProfile; QString m_pathProfileGetInfo; QString m_pathProfilePing; bool m_fetchingTasks = false; // Последняя ошибка от postJson/patchJson/getJson — чтобы вызывающий код // мог логировать тело ответа сервера, не дублируя парсинг ошибки. QString m_lastErrorSummary; QString m_lastResponseBody; int m_lastHttpStatus = 0; // 0 если ответа не было (timeout); иначе HTTP-код // Периодическая синхронизация bool m_periodicSyncEnabled = false; // включается только после firstPassDone std::atomic m_syncInProgress{false}; // защита от наложения тиков int m_consecutiveSyncFailures = 0; // счётчик подряд неуспешных тиков bool m_unhealthyEmitted = false; // чтобы не спамить syncHealthChanged static constexpr int kSyncFailureThreshold = 3; static constexpr int kPeriodicSyncMs = 60 * 1000; static constexpr int kPingProfileMs = 15 * 1000; // Heartbeat (ping) bool m_pingEnabled = false; std::atomic m_pingInProgress{false}; // Реестр мьютексов на пару (deviceId, bankProfileId) — общий для всех // инстансов NetworkService. Используется для сериализации PATCH-операций. static QMutex s_keyRegistryMutex; static QMap> s_keyMutexes; QNetworkAccessManager *m_manager; // buildMultiPart вызывается на КАЖДУЮ попытку (multipart потребляется за запрос). // idempotent=false ⇒ ретраим только connect-phase сбои (без риска двойной отправки). QJsonValue post(const std::function &buildMultiPart, const QString &path, bool idempotent); QJsonArray get(const QString &path, const QMap ¶ms); QJsonValue getJson(const QString &path, bool withAuth = false); QJsonValue postJson(const QString &path, const QJsonObject &body, bool withAuth = false); QJsonValue patchJson(const QString &path, const QJsonObject &body, bool withAuth = false); QJsonValue deleteJson(const QString &path, bool withAuth = false); bool refreshToken(); bool ensureValidToken(); void scheduleNextRefresh(); void scheduleNextStatusSync(); bool runPeriodicSyncTick(); // делает один тик, возвращает true при успехе bool doPingProfile(); // тело ping без emit finished — для периодических тиков void scheduleNextPing(); void syncDevicesFromServer(const QString &desktopId); void syncBankProfilesFromServer(); void syncBanksIfEmpty(); // POST /api/v1/desktop/ — создаёт новый десктоп, сохраняет desktop_id и // profile_id в настройки. Возвращает id или пустую строку при ошибке. QString createDesktopOnServer(); // Завершающая часть loginAndSetup: синхронизация устройств/профилей/статусов // для выбранного desktopId и эмит finishedWithResult(1)+finished(). void finishSetupWithDesktop(const QString &desktopId); // Не скрытые десктопы с сервера, сохранённые между loginAndSetup и // continueWithDesktop (для поиска profile_id по выбранному id). QJsonArray m_desktops; public: // Возвращает (лениво создавая) shared mutex для пары (deviceId, bankProfileId). // Используется через QMutexLocker для сериализации PATCH-ов по одному профилю. static QSharedPointer keyMutex(const QString &deviceId, const QString &bankProfileId); };