From 07399670c0f69290f5d09c134485af618c1fe116 Mon Sep 17 00:00:00 2001 From: slava Date: Wed, 27 May 2026 13:29:07 +0500 Subject: [PATCH] Introduce `MaterialPayload` utility for consistent material data serialization and API operations. Refactor profile and material handling logic across scripts and UI components to ensure invariant enforcement and improved server synchronization. Add `deleteMaterial` operation to `NetworkService`. --- android/black/CommonScript.cpp | 7 +- android/black/GetCardInfoScript.cpp | 42 +++--- android/dushanbe/CommonScript.cpp | 7 +- android/dushanbe/GetCardInfoScript.cpp | 38 ++--- android/ozon/CommonScript.cpp | 7 +- android/ozon/GetCardInfoScript.cpp | 51 +++---- android/ozon/GetProfileInfoScript.cpp | 7 +- services/net/MaterialPayload.h | 38 +++++ services/net/NetworkService.cpp | 146 ++++++++++++++++++-- services/net/NetworkService.h | 12 ++ views/bank/AccountSettingsWindow.cpp | 184 +++++++++++++++++-------- views/bank/BankSetupWizard.cpp | 74 +++++++--- 12 files changed, 456 insertions(+), 157 deletions(-) create mode 100644 services/net/MaterialPayload.h diff --git a/android/black/CommonScript.cpp b/android/black/CommonScript.cpp index 8cdf649..5c674e3 100644 --- a/android/black/CommonScript.cpp +++ b/android/black/CommonScript.cpp @@ -251,7 +251,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q ns.postBankProfile(); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); if (afterPost.bankProfileId.isEmpty()) { - const QString err = "postBankProfile failed for " + deviceId + " " + appCode; + QString err; + if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) { + err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage(); + } else { + err = "postBankProfile failed for " + deviceId + " " + appCode; + } qWarning() << "[Black] createBankProfileIfNeeded:" << err; return err; } diff --git a/android/black/GetCardInfoScript.cpp b/android/black/GetCardInfoScript.cpp index 181a9d4..b37cd23 100644 --- a/android/black/GetCardInfoScript.cpp +++ b/android/black/GetCardInfoScript.cpp @@ -11,6 +11,7 @@ #include "dao/DeviceDAO.h" #include "dump/TaskDumpRecorder.h" #include "net/NetworkService.h" +#include "net/MaterialPayload.h" namespace Black { @@ -126,30 +127,33 @@ void GetCardInfoScript::doStart() { qWarning() << "[Black::GetCardInfo] Failed to upsert account"; } - // 6. Отправляем материал на сервер (если ещё нет material_id) + // 6. Отправляем материал на сервер. + // Инвариант: на сервере одновременно живёт максимум один материал на (deviceId, appCode) — + // тот, что локально active. Пушим только active-карту и только если другой + // материал ещё не синхронизирован. const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers); const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) { - if (savedAccount.materialId.isEmpty()) { - NetworkService ns; - ns.setDeviceId(m_deviceId); - - const QMap currencyCodes = { - {"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972} - }; - QJsonObject materialBody; - materialBody["bank_profile_id"] = updatedApp.bankProfileId; - materialBody["card_number"] = savedAccount.cardNumber; - materialBody["name"] = updatedApp.fullName; - materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled"; - materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 32); - ns.setPayload(QJsonDocument(materialBody).toJson()); - ns.addExtra("account_id", savedAccount.id); - ns.postMaterial(); - qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers; + if (savedAccount.status != MaterialStatus::Active) { + qDebug() << "[Black::GetCardInfo] CVU" << lastNumbers << "is off, not pushing to server"; + } else if (!savedAccount.materialId.isEmpty()) { + qDebug() << "[Black::GetCardInfo] material already synced for CVU" << lastNumbers << ", skipping"; } else { - qDebug() << "[Black::GetCardInfo] material already exists for CVU" << lastNumbers << ", skipping"; + bool otherSynced = false; + for (const MaterialInfo &m : MaterialDAO::getAccounts(device.id, m_appCode)) { + if (m.id != savedAccount.id && !m.materialId.isEmpty()) { otherSynced = true; break; } + } + if (otherSynced) { + qDebug() << "[Black::GetCardInfo] another material already synced in app, skipping POST for CVU" << lastNumbers; + } else { + NetworkService ns; + ns.setDeviceId(m_deviceId); + ns.setPayload(buildMaterialPayload(savedAccount, updatedApp)); + ns.addExtra("account_id", savedAccount.id); + ns.postMaterial(); + qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers; + } } } else { qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material"; diff --git a/android/dushanbe/CommonScript.cpp b/android/dushanbe/CommonScript.cpp index 27bc7b8..27c2d96 100644 --- a/android/dushanbe/CommonScript.cpp +++ b/android/dushanbe/CommonScript.cpp @@ -317,7 +317,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q ns.postBankProfile(); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); if (afterPost.bankProfileId.isEmpty()) { - const QString err = "postBankProfile failed for " + deviceId + " " + appCode; + QString err; + if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) { + err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage(); + } else { + err = "postBankProfile failed for " + deviceId + " " + appCode; + } qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err; return err; } diff --git a/android/dushanbe/GetCardInfoScript.cpp b/android/dushanbe/GetCardInfoScript.cpp index 44d39c9..5c4ba63 100644 --- a/android/dushanbe/GetCardInfoScript.cpp +++ b/android/dushanbe/GetCardInfoScript.cpp @@ -10,6 +10,7 @@ #include "dao/DeviceDAO.h" #include "dump/TaskDumpRecorder.h" #include "net/NetworkService.h" +#include "net/MaterialPayload.h" namespace Dushanbe { @@ -263,27 +264,28 @@ void GetCardInfoScript::doStart() { qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers; } - // Отправляем на сервер если нет material_id + // Отправляем на сервер: инвариант — только active-карта и только если + // другая карта в (deviceId, appCode) ещё не синхронизирована. const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); if (!updatedApp.bankProfileId.isEmpty()) { const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers); - if (savedAccount.id != -1 && savedAccount.materialId.isEmpty()) { - NetworkService ns; - ns.setDeviceId(m_deviceId); - const QMap currencyCodes = { - {"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972} - }; - QJsonObject materialBody; - materialBody["bank_profile_id"] = updatedApp.bankProfileId; - materialBody["card_number"] = QString(fullNumber).remove(' '); - materialBody["account_number"] = accountNumber; - materialBody["name"] = updatedApp.fullName; - materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled"; - materialBody["currency"] = currencyCodes.value(savedAccount.currency.toUpper(), 972); - ns.setPayload(QJsonDocument(materialBody).toJson()); - ns.addExtra("account_id", savedAccount.id); - ns.postMaterial(); - qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers; + if (savedAccount.id != -1 + && savedAccount.status == MaterialStatus::Active + && savedAccount.materialId.isEmpty()) { + bool otherSynced = false; + for (const MaterialInfo &m : MaterialDAO::getAccounts(device.id, m_appCode)) { + if (m.id != savedAccount.id && !m.materialId.isEmpty()) { otherSynced = true; break; } + } + if (otherSynced) { + qDebug() << "[Dushanbe::GetCardInfo] another material already synced, skipping POST for" << lastNumbers; + } else { + NetworkService ns; + ns.setDeviceId(m_deviceId); + ns.setPayload(buildMaterialPayload(savedAccount, updatedApp)); + ns.addExtra("account_id", savedAccount.id); + ns.postMaterial(); + qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers; + } } } diff --git a/android/ozon/CommonScript.cpp b/android/ozon/CommonScript.cpp index c77ea3d..bc36917 100644 --- a/android/ozon/CommonScript.cpp +++ b/android/ozon/CommonScript.cpp @@ -452,7 +452,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q ns.postBankProfile(); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); if (afterPost.bankProfileId.isEmpty()) { - const QString err = "postBankProfile failed for " + deviceId + " " + appCode; + QString err; + if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) { + err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage(); + } else { + err = "postBankProfile failed for " + deviceId + " " + appCode; + } qWarning() << "[Ozon] createBankProfileIfNeeded:" << err; return err; } diff --git a/android/ozon/GetCardInfoScript.cpp b/android/ozon/GetCardInfoScript.cpp index d8cb0bd..7b44ec5 100644 --- a/android/ozon/GetCardInfoScript.cpp +++ b/android/ozon/GetCardInfoScript.cpp @@ -13,6 +13,7 @@ #include "dao/SettingsDAO.h" #include "dump/TaskDumpRecorder.h" #include "net/NetworkService.h" +#include "net/MaterialPayload.h" #include "AppLogger.h" namespace Ozon { @@ -234,7 +235,12 @@ void GetCardInfoScript::doStart() { // Проверяем что bankProfileId сохранился const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode); if (afterPost.bankProfileId.isEmpty()) { - const QString err = "postBankProfile failed for " + m_deviceId; + QString err; + if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) { + err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage(); + } else { + err = "postBankProfile failed for " + m_deviceId; + } qWarning() << "[GetCardInfo]" << err; AppLogger::log("ozon/GetCardInfo", m_deviceId, err, AdbUtils::captureScreenshotBytes(m_deviceId), @@ -541,31 +547,30 @@ void GetCardInfoScript::doStart() { // } // } - // 3. Отправляем материалы (карты) — после OCR, когда номера карт уже в БД + // 3. Отправляем материалы (карты) на сервер. + // Инвариант: одновременно живёт максимум один материал на (deviceId, appCode) — + // тот, что локально active. Пушим только active-карту и только если другой + // материал ещё не синхронизирован (нет с непустым materialId). const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); if (!updatedApp.bankProfileId.isEmpty()) { - for (const MaterialInfo &card : cards) { - const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers); - if (dbCard.cardNumber.isEmpty()) continue; - if (!dbCard.materialId.isEmpty()) { - qDebug() << "[GetCardInfo] material already exists for card" << dbCard.lastNumbers << ", skipping"; - continue; + const QList dbCards = MaterialDAO::getAccounts(device.id, m_appCode); + bool alreadySynced = false; + for (const MaterialInfo &m : dbCards) { + if (!m.materialId.isEmpty()) { alreadySynced = true; break; } + } + if (alreadySynced) { + qDebug() << "[GetCardInfo] another material already synced, skipping POST for app" << m_appCode; + } else { + for (const MaterialInfo &card : cards) { + const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers); + if (dbCard.cardNumber.isEmpty()) continue; + if (dbCard.status != MaterialStatus::Active) continue; + ns.setPayload(buildMaterialPayload(dbCard, updatedApp)); + ns.addExtra("account_id", dbCard.id); + ns.postMaterial(); + qDebug() << "[GetCardInfo] postMaterial done for active card" << dbCard.lastNumbers; + break; // не больше одного материала на сервер } - - const QMap currencyCodes = { - {"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972} - }; - QJsonObject materialBody; - materialBody["bank_profile_id"] = updatedApp.bankProfileId; - materialBody["card_number"] = dbCard.cardNumber; - materialBody["account_number"] = dbCard.accountNumber; - materialBody["name"] = updatedApp.fullName; - materialBody["state"] = materialStatusToString(dbCard.status) == "active" ? "enabled" : "disabled"; - materialBody["currency"] = currencyCodes.value(dbCard.currency.toUpper(), 643); - ns.setPayload(QJsonDocument(materialBody).toJson()); - ns.addExtra("account_id", dbCard.id); - ns.postMaterial(); - qDebug() << "[GetCardInfo] postMaterial done for card" << dbCard.lastNumbers; } } else { qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials"; diff --git a/android/ozon/GetProfileInfoScript.cpp b/android/ozon/GetProfileInfoScript.cpp index 0273976..afb3309 100644 --- a/android/ozon/GetProfileInfoScript.cpp +++ b/android/ozon/GetProfileInfoScript.cpp @@ -169,7 +169,12 @@ void GetProfileInfoScript::doStart() { ns.postBankProfile(); const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode); if (afterPost.bankProfileId.isEmpty()) { - const QString err = "postBankProfile failed for " + m_deviceId; + QString err; + if (ns.lastResponseBody().contains("already exists", Qt::CaseInsensitive)) { + err = "PROFILE_ALREADY_EXISTS: " + ns.lastServerMessage(); + } else { + err = "postBankProfile failed for " + m_deviceId; + } qWarning() << "[GetProfileInfo]" << err; AppLogger::log("ozon/GetProfileInfo", m_deviceId, err, AdbUtils::captureScreenshotBytes(m_deviceId), diff --git a/services/net/MaterialPayload.h b/services/net/MaterialPayload.h new file mode 100644 index 0000000..f6296cd --- /dev/null +++ b/services/net/MaterialPayload.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +#include "db/MaterialInfo.h" +#include "db/BankProfileInfo.h" + +// Числовые коды валют для поля `currency` в POST /api/v1/profile/material. +inline int materialCurrencyCode(const QString ¤cy) { + static const QMap kCurrencyCodes = { + {"RUB", 643}, {"USD", 840}, {"KRW", 410}, + {"AZN", 944}, {"ARS", 32}, {"TJS", 972} + }; + return kCurrencyCodes.value(currency.toUpper(), 643); +} + +// Собирает тело для POST /api/v1/profile/material. Используется и скриптами +// сбора карт (android/.../GetCardInfoScript.cpp), и UI-кодом при ручном +// переключении активной карты (AccountSettingsWindow::toggleMaterialStatus). +inline QJsonObject buildMaterialBody(const MaterialInfo &material, const BankProfileInfo &app) { + QJsonObject body; + body["bank_profile_id"] = app.bankProfileId; + body["card_number"] = material.cardNumber; + if (!material.accountNumber.isEmpty()) { + body["account_number"] = material.accountNumber; + } + body["name"] = app.fullName; + body["state"] = (material.status == MaterialStatus::Active) ? "enabled" : "disabled"; + body["currency"] = materialCurrencyCode(material.currency); + return body; +} + +inline QByteArray buildMaterialPayload(const MaterialInfo &material, const BankProfileInfo &app) { + return QJsonDocument(buildMaterialBody(material, app)).toJson(QJsonDocument::Compact); +} diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index fda2f09..c845ebf 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -1208,6 +1208,52 @@ void NetworkService::toggleMaterial() { emit finished(); } +void NetworkService::deleteMaterial() { + if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; } + + const QString materialId = m_extraData.value("material_id").toString(); + const int accountId = m_extraData.value("account_id").toInt(); + + if (materialId.isEmpty()) { + qWarning() << "[NetworkService] deleteMaterial: material_id is empty"; + emit finishedWithResult(-1); emit finished(); + return; + } + + // Резолвим (deviceId, bankProfileId) для сериализации операций по профилю + // через keyMutex (тот же замок, что и в toggleMaterial — см. правило #5). + QString deviceId; + QString bankProfileId; + const MaterialInfo localMaterial = MaterialDAO::findByMaterialId(materialId); + if (localMaterial.id >= 0) { + deviceId = localMaterial.deviceId; + const BankProfileInfo app = BankProfileDAO::getApplication(localMaterial.deviceId, localMaterial.appCode); + if (app.id >= 0) bankProfileId = app.bankProfileId; + } + + QSharedPointer mtx = keyMutex(deviceId, bankProfileId); + QMutexLocker locker(mtx.data()); + + const QString path = m_apiBase + m_pathMaterial + "/" + materialId; + const QJsonValue result = deleteJson(path, true); + if (result.isUndefined()) { + qWarning() << "[NetworkService] deleteMaterial failed:" << materialId; + emit finishedWithResult(-1); + emit finished(); + return; + } + + // Локаль — источник истины: даже если сервер вернул не-success (например 404, + // материала уже нет), очищаем локальный materialId, чтобы не пытаться его + // удалить повторно. На успех приходит null/object — оба считаем ОК. + if (accountId > 0) { + (void) MaterialDAO::updateMaterialId(accountId, QString()); + } + qDebug() << "[NetworkService] deleteMaterial ok:" << materialId << "account_id=" << accountId; + emit finishedWithResult(0); + emit finished(); +} + void NetworkService::toggleBankProfile() { if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; } @@ -1247,6 +1293,15 @@ void NetworkService::postMaterial() { const QJsonObject body = QJsonDocument::fromJson(m_json).object(); qDebug() << "[NetworkService] postMaterial body:" << QJsonDocument(body).toJson(QJsonDocument::Compact); + + // Сериализуем POST через keyMutex(deviceId, bankProfileId) — тот же замок, + // что в deleteMaterial/toggleMaterial. Это закрывает race «клиент получил + // OK на DELETE предыдущего материала, но сервер ещё не освободил место под + // POST нового» — сервер успеет обработать DELETE до того, как стартует POST. + const QString bankProfileId = body.value("bank_profile_id").toString(); + QSharedPointer mtx = keyMutex(m_deviceId, bankProfileId); + QMutexLocker locker(mtx.data()); + const QJsonValue result = postJson(m_apiBase + m_pathMaterial, body, true); if (result.isNull() || result.isUndefined()) { qWarning() << "[NetworkService] postMaterial failed, card:" << body["card_number"].toString(); @@ -1763,7 +1818,9 @@ QSharedPointer NetworkService::keyMutex(const QString &deviceId, const Q void NetworkService::onDeviceWentOffline(const QString &deviceId) { // Правило #1 (device→OFFLINE): выключаем bank_profile и локально, и на сервере. - // Материалы локально не трогаем (правило 4-A). + // Материалы локально не трогаем (правило 4-A), но на сервере удаляем все + // синхронизированные материалы (инвариант: материал живёт на сервере только + // при локально active профиле). if (deviceId.isEmpty()) return; if (!ensureValidToken()) return; @@ -1771,7 +1828,8 @@ void NetworkService::onDeviceWentOffline(const QString &deviceId) { const QList apps = BankProfileDAO::getApplicationsByDeviceId(deviceId); - // 1) Сервер: для каждого активного профиля с bank_profile_id → PATCH disable. + // 1) Сервер: для каждого активного профиля с bank_profile_id — + // сначала DELETE всех его материалов, затем PATCH bank_profile в disabled. for (const BankProfileInfo &app : apps) { if (app.status != "active") continue; if (app.bankProfileId.isEmpty()) continue; @@ -1779,6 +1837,20 @@ void NetworkService::onDeviceWentOffline(const QString &deviceId) { QSharedPointer mtx = keyMutex(deviceId, app.bankProfileId); QMutexLocker locker(mtx.data()); + const QList materials = MaterialDAO::getAccounts(deviceId, app.code); + for (const MaterialInfo &mat : materials) { + if (mat.materialId.isEmpty()) continue; + const QJsonValue delRes = deleteJson(m_apiBase + m_pathMaterial + "/" + mat.materialId, true); + if (!delRes.isUndefined()) { + (void) MaterialDAO::updateMaterialId(mat.id, QString()); + qDebug() << "[NetworkService] onDeviceWentOffline: deleted material on server:" + << mat.materialId; + } else { + qWarning() << "[NetworkService] onDeviceWentOffline: DELETE failed for material" + << mat.materialId; + } + } + const QString path = m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/false"; patchJson(path, QJsonObject(), true); qDebug() << "[NetworkService] onDeviceWentOffline: disabled bank_profile on server:" @@ -1910,21 +1982,30 @@ void NetworkService::disableAllProfilesOnServer() { const QJsonObject bp = bpVal.toObject(); const QString bpId = bp["id"].toString(); const QString bpState = bp["state"].toString(); - if (bpId.isEmpty() || bpState == "disabled") continue; + if (bpId.isEmpty()) continue; - patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true); - qDebug() << "[shutdown] disabled bank_profile" << bpId; + // Сперва DELETE всех материалов профиля — инвариант «материал живёт на + // сервере только при локально active профиле». Идём по списку с сервера, + // чтобы зацепить и те, что локально потеряли materialId, но на бэке остались. + const QJsonArray materials = bp["materials"].toArray(); + for (const QJsonValue &matVal : materials) { + const QJsonObject mat = matVal.toObject(); + const QString matId = mat["id"].toString(); + if (matId.isEmpty()) continue; + const QJsonValue delRes = deleteJson(m_apiBase + m_pathMaterial + "/" + matId, true); + if (!delRes.isUndefined()) { + const MaterialInfo local = MaterialDAO::findByMaterialId(matId); + if (local.id >= 0) (void) MaterialDAO::updateMaterialId(local.id, QString()); + qDebug() << "[shutdown] deleted material" << matId; + } else { + qWarning() << "[shutdown] DELETE failed for material" << matId; + } + } - // const QJsonArray materials = bp["materials"].toArray(); - // for (const QJsonValue &matVal : materials) { - // const QJsonObject mat = matVal.toObject(); - // const QString matId = mat["id"].toString(); - // const QString matState = mat["state"].toString(); - // if (matId.isEmpty() || matState == "disabled") continue; - // - // patchJson(m_apiBase + m_pathMaterial + "/" + matId + "/false", QJsonObject(), true); - // qDebug() << "[shutdown] disabled material" << matId; - // } + if (bpState != "disabled") { + patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true); + qDebug() << "[shutdown] disabled bank_profile" << bpId; + } } } @@ -1936,6 +2017,41 @@ void NetworkService::stop() { }, Qt::QueuedConnection); } +QString NetworkService::lastServerMessage() const { + if (m_lastResponseBody.isEmpty()) return {}; + const QJsonDocument doc = QJsonDocument::fromJson(m_lastResponseBody.toUtf8()); + if (!doc.isObject()) return m_lastResponseBody; + const QJsonObject obj = doc.object(); + + // Формат с валидационными ошибками: {"errors": {"": ["msg", ...]}} — + // берём первое непустое сообщение. Это покрывает "bank profile with this + // phone number already exists" и подобные ответы от бэка. + const QJsonValue errorsVal = obj.value("errors"); + if (errorsVal.isObject()) { + const QJsonObject errors = errorsVal.toObject(); + for (auto it = errors.begin(); it != errors.end(); ++it) { + if (it.value().isArray()) { + const QJsonArray arr = it.value().toArray(); + for (const QJsonValue &v : arr) { + const QString s = v.toString(); + if (!s.isEmpty()) return s; + } + } else { + const QString s = it.value().toString(); + if (!s.isEmpty()) return s; + } + } + } + + for (const QString &k : {QStringLiteral("error"), + QStringLiteral("message"), + QStringLiteral("detail")}) { + const QString v = obj.value(k).toString(); + if (!v.isEmpty()) return v; + } + return m_lastResponseBody; +} + void NetworkService::connectErrorDialog(QWidget *parent, NetworkService *net) { if (!net) return; // Если parent есть — connect привязан к нему (авто-disconnect при уничтожении). diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index 47614c9..53f4734 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -31,6 +31,14 @@ public: // в UI-коде, до запуска треда. static void connectErrorDialog(QWidget *parent, NetworkService *net); + // Тело ответа сервера от последнего postJson/patchJson/getJson — пусто на + // успех. Используется скриптами, чтобы распознать сообщение об ошибке (напр. + // "already exists") и отдать UI осмысленный код. + QString lastResponseBody() const { return m_lastResponseBody; } + // Достаёт message/error/detail из JSON в lastResponseBody(). Если тело не + // JSON — возвращает его как есть; на пустом — пустую строку. + QString lastServerMessage() const; + public slots: void postAccountsData(); @@ -46,6 +54,10 @@ public slots: void toggleMaterial(); + // DELETE /api/v1/profile/material/{material_id}. На успехе очищает + // локальный materialId у переданного account_id (если задан). + void deleteMaterial(); + void postDeviceScreenshot(); void getPayments(); diff --git a/views/bank/AccountSettingsWindow.cpp b/views/bank/AccountSettingsWindow.cpp index f833f98..942528d 100644 --- a/views/bank/AccountSettingsWindow.cpp +++ b/views/bank/AccountSettingsWindow.cpp @@ -26,6 +26,7 @@ #include "dao/BankProfileDAO.h" #include "dao/MaterialDAO.h" #include "net/NetworkService.h" +#include "net/MaterialPayload.h" AccountSettingsWindow::AccountSettingsWindow( QWidget *parent, QString deviceId, QString deviceName, const BankProfileInfo &app @@ -248,31 +249,53 @@ void AccountSettingsWindow::toggleProfileStatus() { m_app.status = newStatus; showOverlay(isActive ? "Выключение профиля..." : "Включение профиля..."); - // При включении профиля — материалы включаем ТОЛЬКО после успешного - // включения bank_profile на сервере; иначе пользователь получит подряд - // несколько одинаковых ошибок ("Invalid or disabled connection") от - // независимых PATCH'ей. + // Инвариант: материал живёт на сервере только при локально active профиле. + // - При включении профиля: после успеха toggleBankProfile → POST единственной + // active-карты (у неё после предыдущего выключения профиля пустой materialId). + // - При выключении профиля: после успеха toggleBankProfile → DELETE всех + // синхронизированных материалов и очистка их materialId локально. std::function onSuccess; + const QString deviceId = m_deviceId; + const QString appCode = m_app.code; if (!isActive) { - const QString deviceId = m_deviceId; - const QString appCode = m_app.code; onSuccess = [this, deviceId, appCode]() { const QList materials = MaterialDAO::getAccounts(deviceId, appCode); for (const auto &mat : materials) { - if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) { - auto *thread = new QThread; - auto *net = new NetworkService; - net->moveToThread(thread); - net->addExtra("material_id", mat.materialId); - net->addExtra("enable", true); - NetworkService::connectErrorDialog(this, net); - connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); - connect(net, &NetworkService::finished, thread, &QThread::quit); - connect(net, &NetworkService::finished, net, &QObject::deleteLater); - connect(thread, &QThread::finished, thread, &QThread::deleteLater); - thread->start(); - qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId; - } + if (mat.status != MaterialStatus::Active) continue; + if (!mat.materialId.isEmpty()) continue; // уже синхронизирован + auto *thread = new QThread; + auto *net = new NetworkService; + net->moveToThread(thread); + net->setDeviceId(deviceId); + net->setPayload(buildMaterialPayload(mat, m_app)); + net->addExtra("account_id", mat.id); + NetworkService::connectErrorDialog(this, net); + connect(thread, &QThread::started, net, &NetworkService::postMaterial); + connect(net, &NetworkService::finished, thread, &QThread::quit); + connect(net, &NetworkService::finished, net, &QObject::deleteLater); + connect(thread, &QThread::finished, thread, &QThread::deleteLater); + thread->start(); + qDebug() << "[AccountSettings] Posting active material on profile-enable:" << mat.id; + break; // инвариант: одна active карта на профиль + } + }; + } else { + onSuccess = [this, deviceId, appCode]() { + const QList materials = MaterialDAO::getAccounts(deviceId, appCode); + for (const auto &mat : materials) { + if (mat.materialId.isEmpty()) continue; + auto *thread = new QThread; + auto *net = new NetworkService; + net->moveToThread(thread); + net->addExtra("material_id", mat.materialId); + net->addExtra("account_id", mat.id); + NetworkService::connectErrorDialog(this, net); + connect(thread, &QThread::started, net, &NetworkService::deleteMaterial); + connect(net, &NetworkService::finished, thread, &QThread::quit); + connect(net, &NetworkService::finished, net, &QObject::deleteLater); + connect(thread, &QThread::finished, thread, &QThread::deleteLater); + thread->start(); + qDebug() << "[AccountSettings] Deleting material on profile-disable:" << mat.materialId; } }; } @@ -569,55 +592,26 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) { reloadCards(); }; - // Сервер не даёт включать материалы при выключенном профиле — возвращает их - // в `disabled`, и client-side sync затирает локальный active. Поэтому пока - // профиль off, синхронизация только локальная; материалы зальются на сервер - // когда юзер включит профиль (см. toggleProfileStatus). - const bool profileActive = m_app.status == "active"; - if (!profileActive) { - hideOverlay(true, "Сохранено локально", finishToggle); - return; - } - - // Выключаем на сервере остальные (fire-and-forget) — только при включении этой карты - if (!isActive) { - for (const MaterialInfo &m : deactivated) { - if (m.materialId.isEmpty()) continue; - auto *t = new QThread; - auto *n = new NetworkService; - n->moveToThread(t); - n->addExtra("material_id", m.materialId); - n->addExtra("enable", false); - NetworkService::connectErrorDialog(this, n); - connect(t, &QThread::started, n, &NetworkService::toggleMaterial); - connect(n, &NetworkService::finished, t, &QThread::quit); - connect(n, &NetworkService::finished, n, &QObject::deleteLater); - connect(t, &QThread::finished, t, &QThread::deleteLater); - t->start(); + // Сценарий A — выключение активной карты: DELETE её материала на сервере. + if (isActive) { + if (account.materialId.isEmpty()) { + hideOverlay(true, "Сохранено локально", finishToggle); + return; } - } - - if (!account.materialId.isEmpty()) { auto *thread = new QThread; auto *net = new NetworkService; net->moveToThread(thread); net->addExtra("material_id", account.materialId); - net->addExtra("enable", !isActive); + net->addExtra("account_id", account.id); NetworkService::connectErrorDialog(this, net); - connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); - const bool willEnable = !isActive; - const int materialDbId = account.id; + connect(thread, &QThread::started, net, &NetworkService::deleteMaterial); connect(net, &NetworkService::finishedWithResult, this, - [this, finishToggle, willEnable, materialDbId](int result) { + [this, finishToggle](int result) { if (result == 0) { hideOverlay(true, "Успешно", finishToggle); - return; - } - // Правило #3: при неудачном включении карты — откат локально в off. - if (willEnable) { - MaterialDAO::updateStatus(materialDbId, "off"); - hideOverlay(false, "Не удалось включить — возвращено в выключено", finishToggle); } else { + // Локаль — источник истины: off остаётся, серверная очистка дотянется + // ретраем при следующем переключении / выключении профиля. hideOverlay(false, "Локально выключено, сервер дотянется автоматически", finishToggle); } }, Qt::QueuedConnection); @@ -625,8 +619,76 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) { connect(net, &NetworkService::finished, net, &QObject::deleteLater); connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); - } else { - hideOverlay(true, "Сохранено локально", finishToggle); + return; + } + + // Сценарий B — включение карты: DELETE предыдущих синхронизированных материалов + // (deactivated + сама account если у неё непустой materialId), затем POST этой. + QList toDelete; + for (const MaterialInfo &m : deactivated) { + if (!m.materialId.isEmpty()) toDelete.append(m); + } + if (!account.materialId.isEmpty()) { + toDelete.append(account); // defensive: не должно случаться при соблюдении инварианта + } + + const int materialDbId = account.id; + auto launchPost = [this, finishToggle, materialDbId]() { + const MaterialInfo fresh = MaterialDAO::getAccountById(materialDbId); + if (fresh.id < 0) { + hideOverlay(false, "Карта не найдена локально", finishToggle); + return; + } + auto *thread = new QThread; + auto *net = new NetworkService; + net->moveToThread(thread); + net->setDeviceId(m_deviceId); + net->setPayload(buildMaterialPayload(fresh, m_app)); + net->addExtra("account_id", materialDbId); + NetworkService::connectErrorDialog(this, net); + connect(thread, &QThread::started, net, &NetworkService::postMaterial); + connect(net, &NetworkService::finished, this, [this, finishToggle, materialDbId]() { + // postMaterial не эмитит finishedWithResult — успехом считаем непустой + // materialId в БД после ответа сервера. Это согласовано с правилом #3: + // на отказ — локальный откат в off. + const MaterialInfo after = MaterialDAO::getAccountById(materialDbId); + if (!after.materialId.isEmpty()) { + hideOverlay(true, "Успешно", finishToggle); + } else { + (void) MaterialDAO::updateStatus(materialDbId, "off"); + hideOverlay(false, "Не удалось включить — возвращено в выключено", finishToggle); + } + }, Qt::QueuedConnection); + connect(net, &NetworkService::finished, thread, &QThread::quit); + connect(net, &NetworkService::finished, net, &QObject::deleteLater); + connect(thread, &QThread::finished, thread, &QThread::deleteLater); + thread->start(); + }; + + if (toDelete.isEmpty()) { + launchPost(); + return; + } + + // Запускаем DELETE'ы и ждём завершения всех перед POST. + // keyMutex(deviceId, bankProfileId) гарантирует, что DELETE'ы не пересекаются + // друг с другом на бэке, а POST не стартует до их завершения (счётчик). + auto remaining = QSharedPointer::create(toDelete.size()); + for (const MaterialInfo &m : toDelete) { + auto *t = new QThread; + auto *n = new NetworkService; + n->moveToThread(t); + n->addExtra("material_id", m.materialId); + n->addExtra("account_id", m.id); + NetworkService::connectErrorDialog(this, n); + connect(t, &QThread::started, n, &NetworkService::deleteMaterial); + connect(n, &NetworkService::finished, this, [remaining, launchPost]() { + if (--(*remaining) == 0) launchPost(); + }, Qt::QueuedConnection); + connect(n, &NetworkService::finished, t, &QThread::quit); + connect(n, &NetworkService::finished, n, &QObject::deleteLater); + connect(t, &QThread::finished, t, &QThread::deleteLater); + t->start(); } } diff --git a/views/bank/BankSetupWizard.cpp b/views/bank/BankSetupWizard.cpp index 3a346a1..809e400 100644 --- a/views/bank/BankSetupWizard.cpp +++ b/views/bank/BankSetupWizard.cpp @@ -19,6 +19,7 @@ #include "dao/DeviceDAO.h" #include "dao/MaterialDAO.h" #include "net/NetworkService.h" +#include "net/MaterialPayload.h" BankSetupWizard::BankSetupWizard(QWidget *parent, const QString &deviceId, const BankProfileInfo &app) : QDialog(parent), m_deviceId(deviceId), m_app(app) { @@ -509,14 +510,15 @@ void BankSetupWizard::runCardCheck() { onVerificationError("Ошибка получения карт: " + result); return; } - // Проверяем что хотя бы одна карта синхронизирована с сервером - const QList materials = MaterialDAO::getAccounts(deviceId, appCode); - bool hasSynced = false; - for (const auto &m : materials) { - if (!m.materialId.isEmpty()) { hasSynced = true; break; } - } - if (!hasSynced && !materials.isEmpty()) { - onVerificationError("Не удалось отправить данные карт на сервер.\n" + // Инвариант: материал живёт на сервере только при активном профиле, + // и одновременно только один (active-карта). На этапе сбора карты + // лежат локально как Off, materialId пустой. Фактическая отправка на + // сервер происходит позже — в applyActiveCardSelection после выбора + // юзером активной карты. Поэтому здесь проверяем только что профиль + // зарегистрирован — этого достаточно для дальнейшего POST'а. + const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode); + if (app.bankProfileId.isEmpty()) { + onVerificationError("Не удалось зарегистрировать банковский профиль на сервере.\n" "Проверьте подключение к серверу."); return; } @@ -631,33 +633,71 @@ void BankSetupWizard::onVerificationSuccess() { void BankSetupWizard::applyActiveCardSelection() { if (m_selectedMaterialId < 0) return; + // Локально помечаем выбранную карту active. На сервере соблюдаем инвариант + // «один материал»: DELETE всех ранее синхронизированных (deactivated + + // сама selected на случай повторного входа в wizard), затем POST выбранной. const QList deactivated = MaterialDAO::activateExclusive(m_deviceId, m_app.code, m_selectedMaterialId); - const MaterialInfo selected = MaterialDAO::getAccountById(m_selectedMaterialId); + QList toDelete; + for (const MaterialInfo &m : deactivated) { + if (!m.materialId.isEmpty()) toDelete.append(m); + } + const MaterialInfo selectedNow = MaterialDAO::getAccountById(m_selectedMaterialId); + if (!selectedNow.materialId.isEmpty()) { + toDelete.append(selectedNow); // defensive: повторный wizard + } - auto sendToggle = [this](const QString &materialId, bool enable) { - if (materialId.isEmpty()) return; + const int selectedId = m_selectedMaterialId; + const QString deviceId = m_deviceId; + const BankProfileInfo app = m_app; + + auto launchPost = [this, selectedId, deviceId, app]() { + const MaterialInfo fresh = MaterialDAO::getAccountById(selectedId); + if (fresh.id < 0) return; auto *thread = new QThread; auto *net = new NetworkService; net->moveToThread(thread); - net->addExtra("material_id", materialId); - net->addExtra("enable", enable); + net->setDeviceId(deviceId); + net->setPayload(buildMaterialPayload(fresh, app)); + net->addExtra("account_id", selectedId); NetworkService::connectErrorDialog(this, net); - QObject::connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); + QObject::connect(thread, &QThread::started, net, &NetworkService::postMaterial); QObject::connect(net, &NetworkService::finished, thread, &QThread::quit); QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); }; - sendToggle(selected.materialId, true); - for (const MaterialInfo &m : deactivated) { - sendToggle(m.materialId, false); + if (toDelete.isEmpty()) { + launchPost(); + return; + } + + auto remaining = QSharedPointer::create(toDelete.size()); + for (const MaterialInfo &m : toDelete) { + auto *t = new QThread; + auto *n = new NetworkService; + n->moveToThread(t); + n->addExtra("material_id", m.materialId); + n->addExtra("account_id", m.id); + NetworkService::connectErrorDialog(this, n); + QObject::connect(t, &QThread::started, n, &NetworkService::deleteMaterial); + QObject::connect(n, &NetworkService::finished, this, [remaining, launchPost]() { + if (--(*remaining) == 0) launchPost(); + }, Qt::QueuedConnection); + QObject::connect(n, &NetworkService::finished, t, &QThread::quit); + QObject::connect(n, &NetworkService::finished, n, &QObject::deleteLater); + QObject::connect(t, &QThread::finished, t, &QThread::deleteLater); + t->start(); } } static QString humanReadableError(const QString &error) { + if (error.contains(QStringLiteral("PROFILE_ALREADY_EXISTS"))) { + return QStringLiteral("Такой профиль уже существует в системе."); + } + // Определяем этап QString stage; if (error.contains("пин-кода"))