Integrate DeviceScreener with NetworkService for status synchronization, including debounce logic for online/offline transitions and periodic sync initiation. Refactor startDeviceScreener and startNetworkService to enable inter-component interaction.
This commit is contained in:
parent
790b0b1847
commit
e4099ce481
105
docs/status-sync.md
Normal file
105
docs/status-sync.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Синхронизация статусов bank_profile и material
|
||||
|
||||
## Определения
|
||||
|
||||
### Локальные статусы (SQLite)
|
||||
| Таблица | Поле | Значения |
|
||||
|---|---|---|
|
||||
| `bank_profiles` | `status` | `"active"` \| `"off"` |
|
||||
| `materials` | `status` | `"active"` \| `"off"` (enum `MaterialStatus::Active` / `MaterialStatus::Off`) |
|
||||
|
||||
### Серверные статусы (REST API)
|
||||
| Сущность | Поле | Значения |
|
||||
|---|---|---|
|
||||
| bank_profile | `state` | `"enabled"` \| `"disabled"` |
|
||||
| material | `state` | `"enabled"` \| `"disabled"` |
|
||||
|
||||
### Маппинг
|
||||
| Локально | Сервер |
|
||||
|---|---|
|
||||
| `active` | `enabled` |
|
||||
| `off` | `disabled` |
|
||||
|
||||
---
|
||||
|
||||
## Инвариант
|
||||
**Локальное состояние — единственный источник истины.** Сервер всегда отражает локаль. При любых расхождениях побеждает локальная БД, и сервер дотягивается до неё (а не наоборот).
|
||||
|
||||
Pull с сервера (`syncBankProfilesFromServer`) используется только для **создания новых записей** (новые bank_profile / новые материалы) и привязки серверных UUID. Статусы существующих записей с сервера в локаль никогда не пишутся.
|
||||
|
||||
---
|
||||
|
||||
## Основные правила
|
||||
|
||||
### 1. Смена онлайн-статуса устройства
|
||||
- **Device → OFFLINE:** bank_profile этого устройства выключается **и локально, и на сервере** (`PATCH bank_profile/{id}/false`). Материалы локально не трогаем — они остаются в том состоянии, в котором были (см. правило 4-А ниже).
|
||||
- **Device → ONLINE:** локальный статус **не меняется**. На сервер пушится текущее локальное состояние bank_profile и всех его материалов, если оно расходится с серверным.
|
||||
|
||||
### 2. Периодическая синхронизация
|
||||
- Каждые **60 секунд** запускается фоновая задача, которая сравнивает локальные статусы bank_profile и материалов с серверными и отправляет `PATCH` только для расходящихся.
|
||||
- Локаль не трогается. Цель — компенсировать сетевые сбои, перезапуски сервера и т.п.
|
||||
|
||||
### 3. Обработка ошибок при включении
|
||||
- Когда пользователь включает bank_profile или material и `PATCH .../true` падает (network error / HTTP не-2xx / `success:false`):
|
||||
- локально оставляем `off`;
|
||||
- показываем пользователю сообщение об ошибке (см. правило 6 про UI).
|
||||
- При неудачном **выключении** локально оставляем `off`; рассинхрон с сервером устранит минутный таймер из правила 2.
|
||||
|
||||
### 4. Реконсиляция материалов после включения профиля
|
||||
- Срабатывает **только при включении** профиля (`PATCH bank_profile/{id}/true`), при выключении — нет (см. правило 4-B про гард ниже).
|
||||
- Отдельной реконсиляции после PATCH не делаем — расхождения материалов подхватит 60s-таймер из правила 2 (в худшем случае — через минуту).
|
||||
- Допустима оптимизация: «дёрнуть» периодический sync сразу после успешного включения профиля, чтобы не ждать.
|
||||
|
||||
---
|
||||
|
||||
## Дополнительные правила и инварианты
|
||||
|
||||
### 4-A. Материалы при device → OFFLINE
|
||||
- При переходе device → OFFLINE правило 1 выключает только профиль. Материалы локально не трогаем — после повторного включения профиля они вернутся к работе без лишних кликов пользователя.
|
||||
- В UI материалы при `bank_profile.status == "off"` отображаются **приглушённо** или с пометкой «профиль выключен», но их `status` в БД остаётся как есть.
|
||||
|
||||
### 4-B. Гард на пуш материала при выключенном профиле
|
||||
- На сервере действует инвариант: `material.state ≤ bank_profile.state`. Если профиль `disabled`, сервер принудительно держит материалы в `disabled`.
|
||||
- Чтобы не получить бесконечный diff-цикл, **при любом пуше статуса материала** (toggle в UI, периодический sync, follow-up после toggle профиля) проверяем локальный `bank_profile.status`. Если профиль `off` — `PATCH` материала **не отправляем**, локальный статус не меняем.
|
||||
- Этот гард уже частично реализован в `AccountSettingsWindow.cpp:556`; нужно поднять его на уровень `NetworkService`, чтобы покрыть все пути пуша.
|
||||
|
||||
### 5. Дебаунс ADB-флапа
|
||||
- ADB может мерцать (USB-хаб, спящий экран, Doze). Без дебаунса каждое мерцание сработает как device → OFFLINE.
|
||||
- **Online — мгновенно** (0 секунд). Как только устройство видно — сразу триггерим логику device → ONLINE.
|
||||
- **Offline — с задержкой 30 секунд.** Устройство считается офлайн только после того, как ADB **не** показывает его как `device` непрерывно в течение 30 секунд. Если за этот период оно вернулось — считаем что флапа не было.
|
||||
|
||||
### 6. Стартовый порядок (избегание гонки)
|
||||
- `DeviceScreener` эмитит сигналы `deviceWentOnline(deviceId)` / `deviceWentOffline(deviceId)` **только при смене** статуса. На «никогда не видел в этой сессии» никаких событий нет.
|
||||
- `NetworkService` стартует 60s-таймер только **после первого успешного прохода** `DeviceScreener` (сигнал `firstPassDone`). До этого никаких offline-эвентов не обрабатывает.
|
||||
- Это исключает ситуацию «на старте все устройства выглядят офлайн → массово выключаем профили».
|
||||
|
||||
### 7. Сериализация PATCH-ов
|
||||
- Mutex на пару `(deviceId, bankProfileId)`: внутри одного устройства+профиля PATCH-операции последовательны, между разными — могут идти параллельно.
|
||||
- Защищает от гонки: одновременно прилетели device-event, юзер-toggle и периодический sync для одного профиля → они выстраиваются в очередь.
|
||||
|
||||
### 8. UI-обработка ошибок (overlay-pattern)
|
||||
- При любом PATCH статуса (профиля или материала) UI показывает оверлей «Сохранение…» поверх тоггла.
|
||||
- Тоггл **двигается только после ответа сервера**:
|
||||
- успех → переходит в новое положение, оверлей скрывается;
|
||||
- ошибка → остаётся в исходном положении, оверлей сменяется сообщением «Ошибка: …», локальный статус не меняется.
|
||||
- Сейчас частично реализовано через `hideOverlay` в `AccountSettingsWindow.cpp:559`. Применить тот же pattern в `DeviceWidget`, `BankSetupWizard` и всех других местах с toggle.
|
||||
|
||||
### 9. Индикация неработающей синхронизации
|
||||
- В `NetworkService` ведём счётчик `m_consecutiveSyncFailures`, инкрементируем при каждом проваленном тике (правило 2) или провале `GET /current_profile`. Сбрасываем при первом успехе.
|
||||
- При превышении порога (например, **3 тика подряд**) эмитим сигнал в `MainWindow`, который показывает индикатор «Нет связи с сервером, последняя успешная синхронизация: HH:MM:SS».
|
||||
- При следующем успехе индикатор скрывается.
|
||||
|
||||
### 10. Параллелизм и ограничения нагрузки
|
||||
- В одном тике периодического sync может образоваться большой объём расхождений (5 устройств × 6 профилей × 4 материала = до 120 PATCH).
|
||||
- Параллелизм с **лимитом 4-8 одновременных PATCH**; mutex из правила 7 соблюдается.
|
||||
- Флаг `m_syncInProgress`: если предыдущий тик ещё не завершился, новый тик **пропускается**. Это предотвращает накопление очереди.
|
||||
|
||||
### 11. Поведение при провале `GET /current_profile`
|
||||
- Если периодический `GET /current_profile` упал (таймаут/сеть/5xx) — тик **пропускается тихо**, локаль не трогаем, никакие PATCH не шлём.
|
||||
- Сам факт неудачи учитывается в счётчике из правила 9.
|
||||
|
||||
---
|
||||
|
||||
## Связанные существующие правила (контекст)
|
||||
- Toggle материала, когда `bank_profile.status == "off"`, **не идёт на сервер** — сохраняется только локально (см. правило 4-B). Серверная синхронизация произойдёт после включения профиля и следующего 60s-тика.
|
||||
- После `BankSetupWizard` `bank_profile` остаётся `off` по дизайну — пользователь должен включить его вручную.
|
||||
40
main.cpp
40
main.cpp
@ -65,7 +65,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
|
||||
#include <QPushButton>
|
||||
#include <QFileDialog>
|
||||
|
||||
void startDeviceScreener(const QCoreApplication &app) {
|
||||
void startDeviceScreener(const QCoreApplication &app, NetworkService *networkService = nullptr) {
|
||||
auto *thread = new QThread();
|
||||
auto *deviceScreenerWorker = new DeviceScreener;
|
||||
|
||||
@ -76,6 +76,20 @@ void startDeviceScreener(const QCoreApplication &app) {
|
||||
// Когда поток запустится, автоматически вызовется worker->start() внутри фонового потока.
|
||||
QObject::connect(thread, &QThread::started, deviceScreenerWorker, &DeviceScreener::start);
|
||||
|
||||
// Связь с NetworkService: первый проход → старт периодической синхронизации;
|
||||
// переходы online/offline → реакция в NetworkService (правило #1, #6).
|
||||
if (networkService) {
|
||||
QObject::connect(deviceScreenerWorker, &DeviceScreener::firstPassDone,
|
||||
networkService, &NetworkService::startPeriodicSync,
|
||||
Qt::QueuedConnection);
|
||||
QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOnline,
|
||||
networkService, &NetworkService::onDeviceWentOnline,
|
||||
Qt::QueuedConnection);
|
||||
QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOffline,
|
||||
networkService, &NetworkService::onDeviceWentOffline,
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
// 🔁 Эти 3 строки делают автоматическую зачистку:
|
||||
// Когда worker закончит работу (emit finished()), мы:
|
||||
// - останавливаем поток (thread->quit())
|
||||
@ -165,7 +179,7 @@ void startBlackPayByCardTest() {
|
||||
thread->start();
|
||||
}
|
||||
|
||||
void startNetworkService(const QCoreApplication &app) {
|
||||
NetworkService* startNetworkService(const QCoreApplication &app) {
|
||||
auto *paymentThread = new QThread;
|
||||
auto *networkService = new NetworkService;
|
||||
AppLogger::setNetworkService(networkService);
|
||||
@ -209,6 +223,7 @@ void startNetworkService(const QCoreApplication &app) {
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
paymentThread->start();
|
||||
return networkService;
|
||||
}
|
||||
|
||||
static QFile logFile;
|
||||
@ -357,8 +372,11 @@ int main(int argc, char *argv[]) {
|
||||
auto *mainWindow = new MainWindow();
|
||||
mainWindow->show();
|
||||
registrationWindow->close();
|
||||
startNetworkService(app);
|
||||
startDeviceScreener(app);
|
||||
NetworkService *net = startNetworkService(app);
|
||||
QObject::connect(net, &NetworkService::syncHealthChanged,
|
||||
mainWindow, &MainWindow::setSyncHealth,
|
||||
Qt::QueuedConnection);
|
||||
startDeviceScreener(app, net);
|
||||
startEventHandler(app);
|
||||
// startBlackPayByCardTest();
|
||||
}, Qt::QueuedConnection);
|
||||
@ -388,8 +406,11 @@ int main(int argc, char *argv[]) {
|
||||
if (r == 1) {
|
||||
auto *mainWindow = new MainWindow();
|
||||
mainWindow->show();
|
||||
startNetworkService(app);
|
||||
startDeviceScreener(app);
|
||||
NetworkService *net = startNetworkService(app);
|
||||
QObject::connect(net, &NetworkService::syncHealthChanged,
|
||||
mainWindow, &MainWindow::setSyncHealth,
|
||||
Qt::QueuedConnection);
|
||||
startDeviceScreener(app, net);
|
||||
startEventHandler(app);
|
||||
// startBlackPayByCardTest();
|
||||
} else if (r == 0) {
|
||||
@ -409,8 +430,11 @@ int main(int argc, char *argv[]) {
|
||||
} else if (result == 1) {
|
||||
auto *mainWindow = new MainWindow();
|
||||
mainWindow->show();
|
||||
startNetworkService(app);
|
||||
startDeviceScreener(app);
|
||||
NetworkService *net = startNetworkService(app);
|
||||
QObject::connect(net, &NetworkService::syncHealthChanged,
|
||||
mainWindow, &MainWindow::setSyncHealth,
|
||||
Qt::QueuedConnection);
|
||||
startDeviceScreener(app, net);
|
||||
startEventHandler(app);
|
||||
// startBlackPayByCardTest();
|
||||
} else if (result == 0) {
|
||||
|
||||
@ -259,26 +259,6 @@ void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) {
|
||||
apiPatch("/api/v1/device/" + device.apiId, body);
|
||||
}
|
||||
|
||||
void DeviceScreener::toggleBankProfilesOnApi(const QString &deviceId, bool enable) {
|
||||
if (enable) return; // не включаем автоматически при появлении устройства
|
||||
|
||||
const QList<BankProfileInfo> apps = BankProfileDAO::getAllApplicationsByDeviceId(deviceId);
|
||||
for (const BankProfileInfo &app : apps) {
|
||||
if (app.bankProfileId.isEmpty()) continue;
|
||||
if (app.status != "active") continue;
|
||||
|
||||
apiPatch("/api/v1/profile/bank_profile/" + app.bankProfileId + "/false", QJsonObject());
|
||||
|
||||
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, app.code);
|
||||
for (const MaterialInfo &mat : materials) {
|
||||
if (mat.materialId.isEmpty()) continue;
|
||||
apiPatch("/api/v1/profile/material/" + mat.materialId + "/false", QJsonObject());
|
||||
}
|
||||
}
|
||||
|
||||
BankProfileDAO::deactivateAppsForDevice(deviceId);
|
||||
}
|
||||
|
||||
QJsonValue DeviceScreener::apiGet(const QString &path) {
|
||||
if (!m_manager) m_manager = new QNetworkAccessManager(this);
|
||||
|
||||
@ -317,15 +297,12 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
|
||||
|
||||
void DeviceScreener::start() {
|
||||
while (m_running) {
|
||||
// Устройства, которые были онлайн в БД до текущего скана
|
||||
const QStringList prevOnline = DeviceDAO::getOnlineDeviceCodes();
|
||||
|
||||
QList<QPair<QString, QString> > devices = readAdbDevices();
|
||||
QStringList onlineIds;
|
||||
QList<DeviceInfo> deviceInfos;
|
||||
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
|
||||
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
|
||||
|
||||
for (const auto &[adbSerial, status]: devices) {
|
||||
DeviceInfo device;
|
||||
bool isChecked = true;
|
||||
|
||||
if (status == "device") {
|
||||
device = readDeviceInfo(adbSerial);
|
||||
@ -353,8 +330,7 @@ void DeviceScreener::start() {
|
||||
}
|
||||
}
|
||||
|
||||
onlineIds << device.id;
|
||||
isChecked = prevOnline.contains(device.id);
|
||||
liveSet.insert(device.id);
|
||||
|
||||
device.image = takeScreenshot(adbSerial);
|
||||
if (!device.image.isEmpty()) {
|
||||
@ -367,7 +343,6 @@ void DeviceScreener::start() {
|
||||
}
|
||||
device.status = status;
|
||||
device.updateTime = QDateTime::currentDateTime();
|
||||
deviceInfos.append(device);
|
||||
if (status == "device") {
|
||||
if (!DeviceDAO::upsertDevice(device)) {
|
||||
qDebug() << "Cant update device";
|
||||
@ -421,26 +396,57 @@ void DeviceScreener::start() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!DeviceDAO::markDevicesOfflineExcept(onlineIds)) {
|
||||
qDebug() << "Cant mark devices offline";
|
||||
// Detect transitions:
|
||||
// 1) Online: устройство есть в liveSet, но ещё не в m_onlineDevices — мгновенно
|
||||
for (const QString &id : liveSet) {
|
||||
if (!m_onlineDevices.contains(id)) {
|
||||
m_onlineDevices.insert(id);
|
||||
m_offlineSince.remove(id);
|
||||
qDebug() << "[DeviceScreener] device went ONLINE:" << id;
|
||||
emit deviceWentOnline(id);
|
||||
} else {
|
||||
// Устройство всё ещё видно — сбрасываем pending offline-дебаунс (флап)
|
||||
m_offlineSince.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Было онлайн в БД, сейчас офлайн — выключаем профили и обновляем статус на сервере
|
||||
for (const QString &id : prevOnline) {
|
||||
if (!onlineIds.contains(id)) {
|
||||
toggleBankProfilesOnApi(id, false);
|
||||
// Отправляем OFFLINE статус на сервер
|
||||
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id);
|
||||
if (!offlineDevice.apiId.isEmpty()) {
|
||||
DeviceInfo offlineCopy = offlineDevice;
|
||||
offlineCopy.status = "offline";
|
||||
updateDeviceOnApi(offlineCopy);
|
||||
}
|
||||
// 2) Offline: устройство было в m_onlineDevices, но больше не видно
|
||||
// Засекаем m_offlineSince; срабатывает после 30s непрерывного отсутствия.
|
||||
QSet<QString> goneOffline;
|
||||
for (const QString &id : m_onlineDevices) {
|
||||
if (liveSet.contains(id)) continue;
|
||||
if (!m_offlineSince.contains(id)) {
|
||||
m_offlineSince[id] = nowSec;
|
||||
qDebug() << "[DeviceScreener] device missing, offline debounce started:" << id;
|
||||
} else if (nowSec - m_offlineSince[id] >= kOfflineDebounceSecs) {
|
||||
goneOffline.insert(id);
|
||||
}
|
||||
}
|
||||
for (const QString &id : goneOffline) {
|
||||
m_onlineDevices.remove(id);
|
||||
m_offlineSince.remove(id);
|
||||
qDebug() << "[DeviceScreener] device went OFFLINE (debounced):" << id;
|
||||
// Сообщаем серверу что устройство офлайн (отдельно от bank_profile-синка)
|
||||
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id);
|
||||
if (!offlineDevice.apiId.isEmpty()) {
|
||||
DeviceInfo offlineCopy = offlineDevice;
|
||||
offlineCopy.status = "offline";
|
||||
updateDeviceOnApi(offlineCopy);
|
||||
}
|
||||
emit deviceWentOffline(id);
|
||||
}
|
||||
|
||||
// БД status: считаем онлайном только debounced-онлайн.
|
||||
QStringList debouncedOnlineList;
|
||||
for (const QString &id : m_onlineDevices) debouncedOnlineList << id;
|
||||
if (!DeviceDAO::markDevicesOfflineExcept(debouncedOnlineList)) {
|
||||
qDebug() << "Cant mark devices offline";
|
||||
}
|
||||
|
||||
if (m_firstScan) {
|
||||
m_firstScan = false;
|
||||
qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices;
|
||||
emit firstPassDone();
|
||||
}
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QSet>
|
||||
#include <QMap>
|
||||
#include <dao/DeviceDAO.h>
|
||||
#include <db/DeviceInfo.h>
|
||||
|
||||
@ -12,6 +14,8 @@ public:
|
||||
|
||||
~DeviceScreener() override;
|
||||
|
||||
static constexpr qint64 kOfflineDebounceSecs = 30;
|
||||
|
||||
public slots:
|
||||
void start();
|
||||
|
||||
@ -19,6 +23,9 @@ public slots:
|
||||
|
||||
signals:
|
||||
void finished();
|
||||
void firstPassDone();
|
||||
void deviceWentOnline(const QString &deviceId);
|
||||
void deviceWentOffline(const QString &deviceId);
|
||||
|
||||
private:
|
||||
bool m_running = true;
|
||||
@ -28,6 +35,8 @@ private:
|
||||
QString m_apiBase;
|
||||
QNetworkAccessManager *m_manager = nullptr;
|
||||
QMap<QString, qint64> m_lastApiUpdate; // deviceId → unix timestamp последнего PATCH
|
||||
QSet<QString> m_onlineDevices; // дебаунс-онлайн (true online после dedup)
|
||||
QMap<QString, qint64> m_offlineSince; // deviceId → когда пропал из ADB (для дебаунса)
|
||||
|
||||
static QList<QPair<QString, QString> > readAdbDevices();
|
||||
|
||||
@ -44,5 +53,4 @@ private:
|
||||
QString createDeviceOnApi(const DeviceInfo &device);
|
||||
void updateDeviceOnApi(const DeviceInfo &device);
|
||||
void postScreenshotToApi(const QString &apiId, const QByteArray &image);
|
||||
void toggleBankProfilesOnApi(const QString &deviceId, bool enable);
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
#include <QWidget>
|
||||
#include <QTimer>
|
||||
#include <QTimeZone>
|
||||
#include <QMutexLocker>
|
||||
|
||||
#include "dao/BankDAO.h"
|
||||
#include "dao/GeneralLogDAO.h"
|
||||
@ -920,92 +921,94 @@ void NetworkService::syncDevicesFromServer(const QString &desktopId) {
|
||||
}
|
||||
|
||||
void NetworkService::syncCurrentProfile() {
|
||||
// 1. Определяем онлайн-устройства по БД (DeviceScreener обновит позже)
|
||||
const QList<DeviceInfo> allDevices = DeviceDAO::getAllDevices(false);
|
||||
QSet<QString> onlineDeviceIds;
|
||||
for (const DeviceInfo &dev : allDevices) {
|
||||
if (dev.status == "device" && !dev.image.isEmpty()) {
|
||||
onlineDeviceIds.insert(dev.id);
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "[syncCurrentProfile] online devices:" << onlineDeviceIds;
|
||||
|
||||
// 3. GET current_profile
|
||||
// Локаль — источник истины. Логика: 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] failed to get current_profile";
|
||||
qWarning() << "[syncCurrentProfile] GET current_profile failed";
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject profileData = profileResult.toObject();
|
||||
|
||||
// 4. Сохраняем profile_id
|
||||
const QString profileId = profileData["id"].toString();
|
||||
if (!profileId.isEmpty()) {
|
||||
SettingsDAO::set("profile_id", profileId);
|
||||
}
|
||||
|
||||
// 5. Синхронизируем bank_profiles и materials
|
||||
// Маппим серверное состояние в плоские 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();
|
||||
const QString serverBpState = bp["state"].toString(); // "enabled" / "disabled"
|
||||
|
||||
// Находим applications с этим bank_profile_id
|
||||
const QList<BankProfileInfo> apps = BankProfileDAO::findAllByBankProfileId(bpId);
|
||||
for (const BankProfileInfo &app : apps) {
|
||||
const bool deviceOnline = onlineDeviceIds.contains(app.deviceId);
|
||||
const QString localStatus = app.status; // "active" / "off"
|
||||
const QString serverStatus = (serverBpState == "disabled") ? "off" : "active";
|
||||
|
||||
if (!deviceOnline) {
|
||||
// Офлайн: если на сервере enabled → PATCH disable
|
||||
if (serverBpState != "disabled") {
|
||||
qDebug() << "[syncCurrentProfile] device" << app.deviceId << "offline, disabling bank_profile" << bpId;
|
||||
patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true);
|
||||
}
|
||||
} else {
|
||||
// Онлайн: если локальный status != серверный → PATCH локальное значение на сервер
|
||||
if (localStatus != serverStatus) {
|
||||
const QString enableStr = (localStatus == "active") ? "true" : "false";
|
||||
qDebug() << "[syncCurrentProfile] device" << app.deviceId << "online, syncing bank_profile" << bpId << "to" << enableStr;
|
||||
patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/" + enableStr, QJsonObject(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Синхронизируем materials
|
||||
const QJsonArray materials = bp["materials"].toArray();
|
||||
for (const QJsonValue &matVal : materials) {
|
||||
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();
|
||||
const QString serverMatState = mat["state"].toString();
|
||||
if (!matId.isEmpty()) serverMaterialState[matId] = mat["state"].toString();
|
||||
}
|
||||
}
|
||||
|
||||
const MaterialInfo account = MaterialDAO::findByMaterialId(matId);
|
||||
if (account.id < 0) continue; // не найден в локальной БД
|
||||
int pushedProfiles = 0;
|
||||
int pushedMaterials = 0;
|
||||
|
||||
const bool deviceOnline = onlineDeviceIds.contains(account.deviceId);
|
||||
const QString localStatus = materialStatusToString(account.status);
|
||||
const QString serverStatus = (serverMatState == "disabled") ? "off" : "active";
|
||||
// Идём по локальным устройствам и их 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; // не синхронизирован с сервером
|
||||
|
||||
if (!deviceOnline) {
|
||||
if (serverMatState != "disabled") {
|
||||
qDebug() << "[syncCurrentProfile] device" << account.deviceId << "offline, disabling material" << matId;
|
||||
patchJson(m_apiBase + m_pathMaterial + "/" + matId + "/false", QJsonObject(), true);
|
||||
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 {
|
||||
if (localStatus != serverStatus) {
|
||||
const QString enableStr = (localStatus == "active") ? "true" : "false";
|
||||
qDebug() << "[syncCurrentProfile] device" << account.deviceId << "online, syncing material" << matId << "to" << enableStr;
|
||||
patchJson(m_apiBase + m_pathMaterial + "/" + matId + "/" + enableStr, QJsonObject(), true);
|
||||
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";
|
||||
qDebug() << "[syncCurrentProfile] done — pushed" << pushedProfiles << "profiles,"
|
||||
<< pushedMaterials << "materials";
|
||||
}
|
||||
|
||||
void NetworkService::postBankProfile() {
|
||||
@ -1093,28 +1096,45 @@ void NetworkService::toggleMaterial() {
|
||||
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 {
|
||||
const QJsonArray materials = result.toArray();
|
||||
for (const QJsonValue &v : materials) {
|
||||
const QJsonObject mat = v.toObject();
|
||||
const QString matId = mat["id"].toString();
|
||||
const QString serverState = mat["state"].toString();
|
||||
if (matId.isEmpty()) continue;
|
||||
const MaterialInfo local = MaterialDAO::findByMaterialId(matId);
|
||||
if (local.id < 0) continue;
|
||||
const QString newStatus = (serverState == "disabled") ? "off" : "active";
|
||||
if (materialStatusToString(local.status) != newStatus) {
|
||||
MaterialDAO::updateStatus(local.id, newStatus);
|
||||
}
|
||||
}
|
||||
qDebug() << "[NetworkService] toggleMaterial done:" << materialId << "enable=" << enable
|
||||
<< "synced" << materials.size() << "materials";
|
||||
qDebug() << "[NetworkService] toggleMaterial ok:" << materialId << "enable=" << enable;
|
||||
emit finishedWithResult(0);
|
||||
}
|
||||
|
||||
@ -1133,6 +1153,14 @@ void NetworkService::toggleBankProfile() {
|
||||
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);
|
||||
@ -1140,7 +1168,7 @@ void NetworkService::toggleBankProfile() {
|
||||
qWarning() << "[NetworkService] toggleBankProfile failed:" << bankProfileId;
|
||||
emit finishedWithResult(-1);
|
||||
} else {
|
||||
qDebug() << "[NetworkService] toggleBankProfile done:" << bankProfileId << "enable=" << enable;
|
||||
qDebug() << "[NetworkService] toggleBankProfile ok:" << bankProfileId << "enable=" << enable;
|
||||
emit finishedWithResult(0);
|
||||
}
|
||||
|
||||
@ -1552,20 +1580,182 @@ void NetworkService::start() {
|
||||
ensureValidToken();
|
||||
scheduleNextRefresh();
|
||||
|
||||
// Периодическая синхронизация статусов bank_profiles/materials
|
||||
// Периодическую синхронизацию запускает startPeriodicSync(), который должен
|
||||
// быть вызван явно после firstPassDone от DeviceScreener (см. правило #6
|
||||
// в docs/status-sync.md). Без firstPassDone мы не знаем, какие устройства
|
||||
// действительно онлайн, и можем ошибочно сработать как «device→offline».
|
||||
}
|
||||
|
||||
void NetworkService::startPeriodicSync() {
|
||||
if (m_periodicSyncEnabled) return;
|
||||
m_periodicSyncEnabled = true;
|
||||
qDebug() << "[NetworkService] periodic sync enabled (first tick in" << (kPeriodicSyncMs / 1000) << "s)";
|
||||
scheduleNextStatusSync();
|
||||
}
|
||||
|
||||
void NetworkService::scheduleNextStatusSync() {
|
||||
if (!m_running) return;
|
||||
if (!m_running || !m_periodicSyncEnabled) return;
|
||||
|
||||
QTimer::singleShot(60 * 1000, this, [this]() {
|
||||
QTimer::singleShot(kPeriodicSyncMs, this, [this]() {
|
||||
if (!m_running) return;
|
||||
syncCurrentProfile();
|
||||
|
||||
// Если предыдущий тик ещё не завершился — пропускаем (правило #10).
|
||||
if (m_syncInProgress.exchange(true)) {
|
||||
qDebug() << "[NetworkService] periodic sync tick skipped — previous still in progress";
|
||||
scheduleNextStatusSync();
|
||||
return;
|
||||
}
|
||||
|
||||
const bool ok = runPeriodicSyncTick();
|
||||
m_syncInProgress.store(false);
|
||||
|
||||
if (ok) {
|
||||
if (m_consecutiveSyncFailures > 0 || m_unhealthyEmitted) {
|
||||
qDebug() << "[NetworkService] periodic sync recovered after"
|
||||
<< m_consecutiveSyncFailures << "failures";
|
||||
}
|
||||
m_consecutiveSyncFailures = 0;
|
||||
if (m_unhealthyEmitted) {
|
||||
m_unhealthyEmitted = false;
|
||||
emit syncHealthChanged(true, 0);
|
||||
}
|
||||
} else {
|
||||
++m_consecutiveSyncFailures;
|
||||
qWarning() << "[NetworkService] periodic sync tick failed; consecutive failures:"
|
||||
<< m_consecutiveSyncFailures;
|
||||
if (!m_unhealthyEmitted && m_consecutiveSyncFailures >= kSyncFailureThreshold) {
|
||||
m_unhealthyEmitted = true;
|
||||
emit syncHealthChanged(false, m_consecutiveSyncFailures);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleNextStatusSync();
|
||||
});
|
||||
}
|
||||
|
||||
bool NetworkService::runPeriodicSyncTick() {
|
||||
// Фаза 2 — обёртка с грубым detect-успех/неудача. В фазе 3 syncCurrentProfile
|
||||
// будет переписан и сам вернёт bool; пока считаем тик неуспешным только при
|
||||
// протухшем токене (более тонкая детекция придёт с рефакторингом).
|
||||
if (!ensureValidToken()) return false;
|
||||
syncCurrentProfile();
|
||||
return true;
|
||||
}
|
||||
|
||||
QMutex NetworkService::s_keyRegistryMutex;
|
||||
QMap<QString, QSharedPointer<QMutex>> NetworkService::s_keyMutexes;
|
||||
|
||||
QSharedPointer<QMutex> NetworkService::keyMutex(const QString &deviceId, const QString &bankProfileId) {
|
||||
const QString key = deviceId + "|" + bankProfileId;
|
||||
QMutexLocker locker(&s_keyRegistryMutex);
|
||||
auto it = s_keyMutexes.find(key);
|
||||
if (it == s_keyMutexes.end()) {
|
||||
it = s_keyMutexes.insert(key, QSharedPointer<QMutex>::create());
|
||||
}
|
||||
return *it;
|
||||
}
|
||||
|
||||
void NetworkService::onDeviceWentOffline(const QString &deviceId) {
|
||||
// Правило #1 (device→OFFLINE): выключаем bank_profile и локально, и на сервере.
|
||||
// Материалы локально не трогаем (правило 4-A).
|
||||
if (deviceId.isEmpty()) return;
|
||||
if (!ensureValidToken()) return;
|
||||
|
||||
qDebug() << "[NetworkService] onDeviceWentOffline:" << deviceId;
|
||||
|
||||
const QList<BankProfileInfo> apps = BankProfileDAO::getApplicationsByDeviceId(deviceId);
|
||||
|
||||
// 1) Сервер: для каждого активного профиля с bank_profile_id → PATCH disable.
|
||||
for (const BankProfileInfo &app : apps) {
|
||||
if (app.status != "active") continue;
|
||||
if (app.bankProfileId.isEmpty()) continue;
|
||||
|
||||
QSharedPointer<QMutex> mtx = keyMutex(deviceId, app.bankProfileId);
|
||||
QMutexLocker locker(mtx.data());
|
||||
|
||||
const QString path = m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/false";
|
||||
patchJson(path, QJsonObject(), true);
|
||||
qDebug() << "[NetworkService] onDeviceWentOffline: disabled bank_profile on server:"
|
||||
<< app.bankProfileId << "(" << app.code << ")";
|
||||
}
|
||||
|
||||
// 2) Локально: все профили устройства → off.
|
||||
if (!BankProfileDAO::deactivateAppsForDevice(deviceId)) {
|
||||
qWarning() << "[NetworkService] onDeviceWentOffline: failed to deactivate local apps for device" << deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkService::onDeviceWentOnline(const QString &deviceId) {
|
||||
// Правило #1 (device→ONLINE): локаль не меняем, пушим текущее локальное
|
||||
// состояние bank_profile и его материалов на сервер (с учётом гарда #3).
|
||||
if (deviceId.isEmpty()) return;
|
||||
if (!ensureValidToken()) return;
|
||||
|
||||
qDebug() << "[NetworkService] onDeviceWentOnline:" << deviceId;
|
||||
|
||||
const QJsonValue profileResult = getJson(m_apiBase + m_pathCurrentProfile, true);
|
||||
if (profileResult.isUndefined() || profileResult.isNull()) {
|
||||
qWarning() << "[NetworkService] onDeviceWentOnline: GET current_profile failed";
|
||||
return;
|
||||
}
|
||||
|
||||
QMap<QString, QString> serverProfileState;
|
||||
QMap<QString, QString> serverMaterialState;
|
||||
const QJsonArray bankProfiles = profileResult.toObject()["bank_profiles"].toArray();
|
||||
for (const QJsonValue &bpVal : bankProfiles) {
|
||||
const QJsonObject bp = bpVal.toObject();
|
||||
const QString bpId = bp["id"].toString();
|
||||
if (!bpId.isEmpty()) serverProfileState[bpId] = bp["state"].toString();
|
||||
for (const QJsonValue &matVal : bp["materials"].toArray()) {
|
||||
const QJsonObject mat = matVal.toObject();
|
||||
const QString matId = mat["id"].toString();
|
||||
if (!matId.isEmpty()) serverMaterialState[matId] = mat["state"].toString();
|
||||
}
|
||||
}
|
||||
|
||||
const QList<BankProfileInfo> apps = BankProfileDAO::getApplicationsByDeviceId(deviceId);
|
||||
for (const BankProfileInfo &app : apps) {
|
||||
if (app.bankProfileId.isEmpty()) continue;
|
||||
if (!serverProfileState.contains(app.bankProfileId)) {
|
||||
qWarning() << "[NetworkService] onDeviceWentOnline: server lacks bank_profile" << app.bankProfileId;
|
||||
continue;
|
||||
}
|
||||
|
||||
QSharedPointer<QMutex> mtx = keyMutex(deviceId, app.bankProfileId);
|
||||
QMutexLocker locker(mtx.data());
|
||||
|
||||
const QString localBpState = (app.status == "active") ? "enabled" : "disabled";
|
||||
const QString srvBpState = serverProfileState.value(app.bankProfileId);
|
||||
if (srvBpState != localBpState) {
|
||||
const QString enableStr = (app.status == "active") ? "true" : "false";
|
||||
patchJson(m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/" + enableStr,
|
||||
QJsonObject(), true);
|
||||
qDebug() << "[NetworkService] onDeviceWentOnline: pushed bank_profile" << app.bankProfileId
|
||||
<< "→" << enableStr;
|
||||
}
|
||||
|
||||
// Гард #3: материалы не пушим при локально выключенном профиле.
|
||||
if (app.status != "active") continue;
|
||||
|
||||
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, app.code);
|
||||
for (const MaterialInfo &mat : materials) {
|
||||
if (mat.materialId.isEmpty()) continue;
|
||||
if (!serverMaterialState.contains(mat.materialId)) continue;
|
||||
|
||||
const QString localMatState = (materialStatusToString(mat.status) == "active")
|
||||
? "enabled" : "disabled";
|
||||
const QString srvMatState = serverMaterialState.value(mat.materialId);
|
||||
if (srvMatState != localMatState) {
|
||||
const QString enableStr = (localMatState == "enabled") ? "true" : "false";
|
||||
patchJson(m_apiBase + m_pathMaterial + "/" + mat.materialId + "/" + enableStr,
|
||||
QJsonObject(), true);
|
||||
qDebug() << "[NetworkService] onDeviceWentOnline: pushed material" << mat.materialId
|
||||
<< "→" << enableStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkService::deleteDeviceFromServer() {
|
||||
if (!ensureValidToken()) { emit finished(); return; }
|
||||
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QMap>
|
||||
#include <QMutex>
|
||||
#include <QSharedPointer>
|
||||
#include <atomic>
|
||||
|
||||
class QWidget;
|
||||
|
||||
@ -58,6 +61,18 @@ public slots:
|
||||
void loginAndSetup();
|
||||
void syncCurrentProfile();
|
||||
|
||||
// Включает периодическую синхронизацию статусов (60s). Должно быть вызвано
|
||||
// ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener.
|
||||
void startPeriodicSync();
|
||||
|
||||
// Обрабатывает переход устройства в офлайн (дебаунс-сигнал от DeviceScreener):
|
||||
// выключает bank_profile локально и на сервере.
|
||||
void onDeviceWentOffline(const QString &deviceId);
|
||||
|
||||
// Обрабатывает переход устройства в онлайн: пушит текущее локальное
|
||||
// состояние профиля и материалов на сервер (локаль не меняем).
|
||||
void onDeviceWentOnline(const QString &deviceId);
|
||||
|
||||
void createDevice();
|
||||
|
||||
void updateDevice();
|
||||
@ -86,6 +101,9 @@ signals:
|
||||
// Эмитится при HTTP-ошибке PATCH-запроса (например, 409 'материал заблокирован').
|
||||
// UI подписывается и показывает QMessageBox.
|
||||
void apiPatchError(int httpStatus, QString message);
|
||||
// healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true
|
||||
// при первом успехе после такой серии. consecutiveFailures даёт текущий счёт.
|
||||
void syncHealthChanged(bool healthy, int consecutiveFailures);
|
||||
|
||||
private:
|
||||
QByteArray m_json;
|
||||
@ -120,6 +138,19 @@ private:
|
||||
QString m_pathProfileGetInfo;
|
||||
bool m_fetchingTasks = false;
|
||||
|
||||
// Периодическая синхронизация
|
||||
bool m_periodicSyncEnabled = false; // включается только после firstPassDone
|
||||
std::atomic<bool> m_syncInProgress{false}; // защита от наложения тиков
|
||||
int m_consecutiveSyncFailures = 0; // счётчик подряд неуспешных тиков
|
||||
bool m_unhealthyEmitted = false; // чтобы не спамить syncHealthChanged
|
||||
static constexpr int kSyncFailureThreshold = 3;
|
||||
static constexpr int kPeriodicSyncMs = 60 * 1000;
|
||||
|
||||
// Реестр мьютексов на пару (deviceId, bankProfileId) — общий для всех
|
||||
// инстансов NetworkService. Используется для сериализации PATCH-операций.
|
||||
static QMutex s_keyRegistryMutex;
|
||||
static QMap<QString, QSharedPointer<QMutex>> s_keyMutexes;
|
||||
|
||||
QNetworkAccessManager *m_manager;
|
||||
|
||||
QJsonValue post(QHttpMultiPart *multiPart, const QString &path);
|
||||
@ -133,7 +164,13 @@ private:
|
||||
bool ensureValidToken();
|
||||
void scheduleNextRefresh();
|
||||
void scheduleNextStatusSync();
|
||||
bool runPeriodicSyncTick(); // делает один тик, возвращает true при успехе
|
||||
void syncDevicesFromServer(const QString &desktopId);
|
||||
void syncBankProfilesFromServer();
|
||||
void syncBanksIfEmpty();
|
||||
|
||||
public:
|
||||
// Возвращает (лениво создавая) shared mutex для пары (deviceId, bankProfileId).
|
||||
// Используется через QMutexLocker для сериализации PATCH-ов по одному профилю.
|
||||
static QSharedPointer<QMutex> keyMutex(const QString &deviceId, const QString &bankProfileId);
|
||||
};
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
#include <QStackedWidget>
|
||||
|
||||
#include <QMessageBox>
|
||||
#include <QStatusBar>
|
||||
#include <QThread>
|
||||
|
||||
#include "TutorialWindow.h"
|
||||
@ -119,6 +120,27 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
|
||||
SettingsDAO::set("tutorial_shown", "1");
|
||||
});
|
||||
}
|
||||
|
||||
// Sync-health индикатор в статус-баре (правило #9). По умолчанию скрыт —
|
||||
// показывается только когда NetworkService сообщил unhealthy.
|
||||
m_syncHealthLabel = new QLabel(this);
|
||||
m_syncHealthLabel->setVisible(false);
|
||||
m_syncHealthLabel->setStyleSheet("color: #d9534f; padding: 0 8px;");
|
||||
statusBar()->addPermanentWidget(m_syncHealthLabel);
|
||||
}
|
||||
|
||||
void MainWindow::setSyncHealth(bool healthy, int consecutiveFailures) {
|
||||
if (!m_syncHealthLabel) return;
|
||||
if (healthy) {
|
||||
m_syncHealthLabel->setVisible(false);
|
||||
m_syncHealthLabel->setToolTip(QString());
|
||||
return;
|
||||
}
|
||||
m_syncHealthLabel->setText(QStringLiteral("⚠ Нет связи с сервером"));
|
||||
m_syncHealthLabel->setToolTip(
|
||||
QStringLiteral("Подряд %1 неудачных синхронизаций. Проверьте подключение.")
|
||||
.arg(consecutiveFailures));
|
||||
m_syncHealthLabel->setVisible(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -21,6 +21,11 @@ public:
|
||||
|
||||
void loadDevices();
|
||||
|
||||
public slots:
|
||||
// Подключается к NetworkService::syncHealthChanged. healthy=false показывает
|
||||
// индикатор «нет связи» в status bar, healthy=true прячет (правило #9).
|
||||
void setSyncHealth(bool healthy, int consecutiveFailures);
|
||||
|
||||
private:
|
||||
QWidget *central;
|
||||
FlowLayout *flowLayout;
|
||||
@ -38,6 +43,7 @@ private:
|
||||
QWidget *m_eventWindow = nullptr;
|
||||
|
||||
QListWidget *m_logList = nullptr;
|
||||
QLabel *m_syncHealthLabel = nullptr;
|
||||
|
||||
void createDevicePage();
|
||||
void deleteDevicePage();
|
||||
|
||||
@ -478,11 +478,22 @@ void AccountSettingsWindow::sendAppStatus(const QString &status) {
|
||||
net->addExtra("enable", enable);
|
||||
NetworkService::connectErrorDialog(this, net);
|
||||
connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile);
|
||||
connect(net, &NetworkService::finishedWithResult, this, [this](int result) {
|
||||
connect(net, &NetworkService::finishedWithResult, this, [this, enable](int result) {
|
||||
if (result == 0) {
|
||||
hideOverlay(true, "Успешно", [this]() { updateStatusUI(); });
|
||||
return;
|
||||
}
|
||||
// Правило #3: при неудачном включении — откатываем локаль в off и
|
||||
// сообщаем юзеру. При неудачном выключении — локаль остаётся off,
|
||||
// сервер дотянется периодическим sync-ом (правило #2).
|
||||
if (enable) {
|
||||
BankProfileDAO::update(m_app.id, m_app.pinCode, m_app.comment, "off");
|
||||
m_app.status = "off";
|
||||
hideOverlay(false, "Не удалось включить — возвращено в выключено",
|
||||
[this]() { updateStatusUI(); });
|
||||
} else {
|
||||
hideOverlay(false, "Ошибка отправки на сервер", [this]() { updateStatusUI(); });
|
||||
hideOverlay(false, "Локально выключено, сервер дотянется автоматически",
|
||||
[this]() { updateStatusUI(); });
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
connect(net, &NetworkService::finished, thread, &QThread::quit);
|
||||
@ -585,11 +596,20 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
|
||||
net->addExtra("enable", !isActive);
|
||||
NetworkService::connectErrorDialog(this, net);
|
||||
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial);
|
||||
connect(net, &NetworkService::finishedWithResult, this, [this, finishToggle](int result) {
|
||||
const bool willEnable = !isActive;
|
||||
const int materialDbId = account.id;
|
||||
connect(net, &NetworkService::finishedWithResult, this,
|
||||
[this, finishToggle, willEnable, materialDbId](int result) {
|
||||
if (result == 0) {
|
||||
hideOverlay(true, "Успешно", finishToggle);
|
||||
return;
|
||||
}
|
||||
// Правило #3: при неудачном включении карты — откат локально в off.
|
||||
if (willEnable) {
|
||||
MaterialDAO::updateStatus(materialDbId, "off");
|
||||
hideOverlay(false, "Не удалось включить — возвращено в выключено", finishToggle);
|
||||
} else {
|
||||
hideOverlay(false, "Ошибка отправки на сервер", finishToggle);
|
||||
hideOverlay(false, "Локально выключено, сервер дотянется автоматически", finishToggle);
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
connect(net, &NetworkService::finished, thread, &QThread::quit);
|
||||
@ -617,20 +637,39 @@ void AccountSettingsWindow::reloadCards() {
|
||||
});
|
||||
m_cardsTable->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
|
||||
// Правило 4-A: при выключенном профиле материалы фактически недоступны —
|
||||
// отображаем приглушённо (gray) и добавляем подсказку, локальный status в БД не трогаем.
|
||||
const bool profileOff = (m_app.status != "active");
|
||||
const QBrush dimmedBrush = QBrush(QColor(128, 128, 128));
|
||||
const QString dimmedTooltip = profileOff
|
||||
? QStringLiteral("Профиль банка выключен — карта фактически недоступна. "
|
||||
"Включите профиль, чтобы карта заработала.")
|
||||
: QString();
|
||||
|
||||
for (int k = 0; k < accounts.size(); ++k) {
|
||||
const MaterialInfo &acc = accounts[k];
|
||||
|
||||
// Сервер
|
||||
auto *syncItem = new QTableWidgetItem(acc.materialId.isEmpty() ? "Не синхр." : "Синхр.");
|
||||
syncItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
syncItem->setForeground(acc.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen));
|
||||
if (profileOff) {
|
||||
syncItem->setForeground(dimmedBrush);
|
||||
syncItem->setToolTip(dimmedTooltip);
|
||||
} else {
|
||||
syncItem->setForeground(acc.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen));
|
||||
}
|
||||
m_cardsTable->setItem(k, 0, syncItem);
|
||||
|
||||
// Статус
|
||||
const bool isActive = acc.status == MaterialStatus::Active;
|
||||
auto *statusItem = new QTableWidgetItem(materialStatusToUiString(acc.status));
|
||||
statusItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
statusItem->setForeground(isActive ? QBrush(Qt::darkGreen) : QBrush(Qt::red));
|
||||
if (profileOff) {
|
||||
statusItem->setForeground(dimmedBrush);
|
||||
statusItem->setToolTip(dimmedTooltip);
|
||||
} else {
|
||||
statusItem->setForeground(isActive ? QBrush(Qt::darkGreen) : QBrush(Qt::red));
|
||||
}
|
||||
m_cardsTable->setItem(k, 1, statusItem);
|
||||
|
||||
// Время обновления
|
||||
|
||||
@ -81,10 +81,12 @@ static void sendTaskErrorToServer(const QString &type, const QString &materialId
|
||||
thread->start();
|
||||
}
|
||||
|
||||
void sendBankStatus(const QString &deviceId, const QString &code, const QString &status) {
|
||||
void sendBankStatus(const QString &deviceId, const QString &code, const QString &status,
|
||||
const std::function<void(int)> &onResult = nullptr) {
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, code);
|
||||
if (app.bankProfileId.isEmpty()) {
|
||||
qWarning() << "[DeviceWidget] sendBankStatus: no bankProfileId for" << code;
|
||||
if (onResult) onResult(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -104,6 +106,11 @@ void sendBankStatus(const QString &deviceId, const QString &code, const QString
|
||||
net->addExtra("bank_profile_id", app.bankProfileId);
|
||||
net->addExtra("enable", enable);
|
||||
QObject::connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile);
|
||||
if (onResult) {
|
||||
QObject::connect(net, &NetworkService::finishedWithResult, net,
|
||||
[onResult](int result) { onResult(result); },
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
QObject::connect(net, &NetworkService::finished, thread, &QThread::quit);
|
||||
QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater);
|
||||
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||
@ -208,7 +215,23 @@ void DeviceWidget::buildBankRow(const BankProfileInfo &app, QWidget *rowWidget,
|
||||
if (!BankProfileDAO::update(app.id, app.pinCode, app.comment, newStatus)) {
|
||||
qDebug() << "Cant update bank data";
|
||||
} else {
|
||||
sendBankStatus(app.deviceId, app.code, newStatus);
|
||||
// Правило #3: при неудачном включении — откат локально + сообщение.
|
||||
// При неудачном выключении — локаль остаётся off (источник истины),
|
||||
// 60s sync дотянет сервер.
|
||||
const bool enable = (newStatus == "active");
|
||||
sendBankStatus(app.deviceId, app.code, newStatus,
|
||||
[app, enable](int result) {
|
||||
if (result == 0) return;
|
||||
if (enable) {
|
||||
BankProfileDAO::update(app.id, app.pinCode, app.comment, "off");
|
||||
QMessageBox::warning(nullptr, "Ошибка",
|
||||
"Не удалось включить банк на сервере.\n"
|
||||
"Локальное состояние возвращено в выключено.");
|
||||
} else {
|
||||
qWarning() << "[DeviceWidget] PATCH disable failed for"
|
||||
<< app.code << "— sync retries via 60s timer";
|
||||
}
|
||||
});
|
||||
// При включении — включаем на сервере все локально активные материалы
|
||||
if (newStatus == "active") {
|
||||
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(app.deviceId, app.code);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user