1
0
forked from BRT/arc

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:
slava 2026-05-22 16:19:41 +05:00
parent 8abe5a915f
commit 36567e78a4
10 changed files with 597 additions and 137 deletions

105
docs/status-sync.md Normal file
View 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` по дизайну — пользователь должен включить его вручную.

View File

@ -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) {

View File

@ -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);
}

View File

@ -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);
};

View File

@ -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; }

View File

@ -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);
};

View File

@ -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);
}

View File

@ -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();

View File

@ -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);
// Время обновления

View File

@ -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);