diff --git a/database/dao/BankProfileDAO.cpp b/database/dao/BankProfileDAO.cpp index 21cd676..1943527 100644 --- a/database/dao/BankProfileDAO.cpp +++ b/database/dao/BankProfileDAO.cpp @@ -369,6 +369,19 @@ bool BankProfileDAO::deactivateAppsForDevice(const QString &deviceId) { return true; } +bool BankProfileDAO::deactivateAllApps() { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + UPDATE bank_profiles SET status = 'off' + WHERE install = 'yes' + )"); + if (!query.exec()) { + qWarning() << "Ошибка при деактивации всех приложений:" << query.lastError().text(); + return false; + } + return true; +} + bool BankProfileDAO::checkInstalledApps(const QString &deviceId, const QSet &apps) { QSqlQuery query(DatabaseManager::instance().database()); const QSettings settings("config.ini", QSettings::IniFormat); diff --git a/database/dao/BankProfileDAO.h b/database/dao/BankProfileDAO.h index 1eac238..7ec1996 100644 --- a/database/dao/BankProfileDAO.h +++ b/database/dao/BankProfileDAO.h @@ -56,6 +56,10 @@ public: [[nodiscard]] static bool deactivateAppsForDevice(const QString &deviceId); + // Деактивирует ВСЕ установленные профили (status='off'). Используется при + // выключении приложения (graceful shutdown) — гасим профили и локально. + [[nodiscard]] static bool deactivateAllApps(); + [[nodiscard]] static bool migrateDeviceId(const QString &oldDeviceId, const QString &newDeviceId); [[nodiscard]] static bool deleteProfile(const QString &deviceId, const QString &appCode); diff --git a/docs/status-sync.md b/docs/status-sync.md index c1dd054..e1fc631 100644 --- a/docs/status-sync.md +++ b/docs/status-sync.md @@ -32,12 +32,19 @@ Pull с сервера (`syncBankProfilesFromServer`) используется ## Основные правила ### 1. Смена онлайн-статуса устройства -- **Device → OFFLINE:** bank_profile этого устройства выключается **и локально, и на сервере** (`PATCH bank_profile/{id}/false`). Материалы локально не трогаем — они остаются в том состоянии, в котором были (см. правило 4-А ниже). -- **Device → ONLINE:** локальный статус **не меняется**. На сервер пушится текущее локальное состояние bank_profile и всех его материалов, если оно расходится с серверным. +- **Device → OFFLINE:** bank_profile этого устройства выключается **только на сервере** (`PATCH bank_profile/{id}/false`). Локальный статус **не трогаем** — это намерение пользователя; его меняет лишь ручной тумблер или выключение приложения (см. правило 1-А). Дисконнект — не «выключение пользователем». Материалы локально не трогаем (см. правило 4-А ниже). +- **Device → ONLINE:** локальный статус **не меняется**. На сервер пушится текущее локальное состояние bank_profile и всех его материалов, если оно расходится с серверным. Т.к. при дисконнекте локаль осталась `active`, профиль снова включается на сервере (`PATCH .../true`). + +> Концептуально: серверное состояние bank_profile = `(локально active) AND (устройство online)`. Локаль хранит намерение пользователя, сервер дополнительно отражает достижимость устройства. + +### 1-А. Выключение приложения (graceful shutdown) +- При штатном выключении приложения (`NetworkService::stop`) профили гасятся **и на сервере, и локально** (`BankProfileDAO::deactivateAllApps`). Это единственный, кроме ручного тумблера, путь, который ставит локальный `off`. +- Отличие от паузы обновления: пауза (`disableAllProfilesOnServer`) гасит **только на сервере**, локаль остаётся `active`, чтобы профили воскресли после апдейта. ### 2. Периодическая синхронизация -- Каждые **60 секунд** запускается фоновая задача, которая сравнивает локальные статусы bank_profile и материалов с серверными и отправляет `PATCH` только для расходящихся. -- Локаль не трогается. Цель — компенсировать сетевые сбои, перезапуски сервера и т.п. +- Каждые **60 секунд** запускается фоновая задача, которая сравнивает **желаемое** серверное состояние с фактическим и отправляет `PATCH` только для расходящихся. +- Желаемое серверное состояние bank_profile = `(локально active) AND (устройство online)`: профиль офлайн-устройства держится `disabled` и **не реактивируется**, пока устройство не вернётся онлайн. Материалы при этом не пушатся (гард, правило 4-B). +- Локаль не трогается. Цель — компенсировать сетевые сбои, перезапуски сервера, непрошедшие PATCH и т.п. ### 3. Обработка ошибок при включении - Когда пользователь включает bank_profile или material и `PATCH .../true` падает (network error / HTTP не-2xx / `success:false`): diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index ff5f13f..940e5e2 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -1085,21 +1085,29 @@ bool NetworkService::syncCurrentProfile() { // Идём по локальным устройствам и их bank_profiles → пушим diff. const QList allDevices = DeviceDAO::getAllDevices(false); for (const DeviceInfo &dev : allDevices) { + // Серверное состояние профиля = (локально active) AND (устройство online). + // Офлайн-устройство держим disabled на сервере и НЕ реактивируем, даже + // если локально профиль остался active (правило 1/2: server отражает + // намерение пользователя И достижимость устройства). + const bool online = (dev.status == "device"); const QList apps = BankProfileDAO::getApplicationsByDeviceId(dev.id); for (const BankProfileInfo &app : apps) { if (app.bankProfileId.isEmpty()) continue; // не синхронизирован с сервером + const bool desiredEnabled = (app.status == "active") && online; + QSharedPointer 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"; + const QString desiredBpState = desiredEnabled ? "enabled" : "disabled"; + const QString srvBpState = serverProfileState.value(app.bankProfileId); + if (srvBpState != desiredBpState) { + const QString enableStr = desiredEnabled ? "true" : "false"; qDebug() << "[syncCurrentProfile] pushing bank_profile" << app.bankProfileId - << "→" << enableStr << "(local=" << localBpState << "srv=" << srvBpState << ")"; + << "→" << enableStr << "(desired=" << desiredBpState << "srv=" << srvBpState + << "local=" << app.status << "online=" << online << ")"; patchJson(m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/" + enableStr, QJsonObject(), true); ++pushedProfiles; @@ -1110,8 +1118,10 @@ bool NetworkService::syncCurrentProfile() { continue; } - // Гард #3 (rule 4-B): материалы не пушим при локально выключенном профиле. - if (app.status != "active") continue; + // Гард #3 (rule 4-B): материалы не пушим, если профиль эффективно + // disabled на сервере (локально off ИЛИ устройство offline) — иначе + // бесконечный diff-цикл из-за инварианта material.state ≤ bank_profile.state. + if (!desiredEnabled) continue; const QList materials = MaterialDAO::getAccounts(dev.id, app.code); for (const MaterialInfo &mat : materials) { @@ -1936,10 +1946,11 @@ QSharedPointer NetworkService::keyMutex(const QString &deviceId, const Q } void NetworkService::onDeviceWentOffline(const QString &deviceId) { - // Правило #1 (device→OFFLINE): выключаем bank_profile и локально, и на сервере. - // Материалы локально не трогаем (правило 4-A), но на сервере удаляем все - // синхронизированные материалы (инвариант: материал живёт на сервере только - // при локально active профиле). + // Правило #1 (device→OFFLINE): выключаем bank_profile ТОЛЬКО на сервере. + // Локальный статус НЕ трогаем — это намерение пользователя, его меняет лишь + // ручной тумблер или выключение приложения (см. stop()). Когда устройство + // вернётся онлайн, профиль снова включится на сервере (onDeviceWentOnline + + // периодический sync). Материалы локально не трогаем (правило 4-A). if (deviceId.isEmpty()) return; if (!ensureValidToken()) return; @@ -1981,10 +1992,8 @@ void NetworkService::onDeviceWentOffline(const QString &deviceId) { << 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) { @@ -2141,7 +2150,13 @@ void NetworkService::disableAllProfilesOnServer() { void NetworkService::stop() { m_running = false; QMetaObject::invokeMethod(this, [this]() { + // При выключении приложения гасим профили и на сервере, и локально. + // (В отличие от дисконнекта устройства и паузы обновления, где локаль + // не трогаем — disableAllProfilesOnServer переиспользуется и там.) disableAllProfilesOnServer(); + if (!BankProfileDAO::deactivateAllApps()) { + qWarning() << "[NetworkService] stop: failed to deactivate local profiles"; + } emit finished(); }, Qt::QueuedConnection); }