1
0
forked from BRT/arc

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.

This commit is contained in:
slava 2026-05-27 13:29:07 +05:00
parent a7840b640d
commit 07399670c0
12 changed files with 456 additions and 157 deletions

View File

@ -251,7 +251,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) { 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; qWarning() << "[Black] createBankProfileIfNeeded:" << err;
return err; return err;
} }

View File

@ -11,6 +11,7 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
namespace Black { namespace Black {
@ -126,30 +127,33 @@ void GetCardInfoScript::doStart() {
qWarning() << "[Black::GetCardInfo] Failed to upsert account"; 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 MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) { if (savedAccount.id != -1 && !updatedApp.bankProfileId.isEmpty()) {
if (savedAccount.materialId.isEmpty()) { 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 {
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; NetworkService ns;
ns.setDeviceId(m_deviceId); ns.setDeviceId(m_deviceId);
ns.setPayload(buildMaterialPayload(savedAccount, updatedApp));
const QMap<QString, int> 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.addExtra("account_id", savedAccount.id);
ns.postMaterial(); ns.postMaterial();
qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers; qDebug() << "[Black::GetCardInfo] postMaterial done for CVU" << lastNumbers;
} else { }
qDebug() << "[Black::GetCardInfo] material already exists for CVU" << lastNumbers << ", skipping";
} }
} else { } else {
qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material"; qWarning() << "[Black::GetCardInfo] bankProfileId empty or account not found, skipping material";

View File

@ -317,7 +317,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) { 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; qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
return err; return err;
} }

View File

@ -10,6 +10,7 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
namespace Dushanbe { namespace Dushanbe {
@ -263,29 +264,30 @@ void GetCardInfoScript::doStart() {
qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers; qWarning() << "[Dushanbe::GetCardInfo] Failed to upsert account" << lastNumbers;
} }
// Отправляем на сервер если нет material_id // Отправляем на сервер: инвариант — только active-карта и только если
// другая карта в (deviceId, appCode) ещё не синхронизирована.
const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (!updatedApp.bankProfileId.isEmpty()) { if (!updatedApp.bankProfileId.isEmpty()) {
const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers); const MaterialInfo savedAccount = MaterialDAO::findAppAccount(device.id, m_appCode, lastNumbers);
if (savedAccount.id != -1 && savedAccount.materialId.isEmpty()) { 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; NetworkService ns;
ns.setDeviceId(m_deviceId); ns.setDeviceId(m_deviceId);
const QMap<QString, int> currencyCodes = { ns.setPayload(buildMaterialPayload(savedAccount, updatedApp));
{"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.addExtra("account_id", savedAccount.id);
ns.postMaterial(); ns.postMaterial();
qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers; qDebug() << "[Dushanbe::GetCardInfo] postMaterial done for" << lastNumbers;
} }
} }
}
// Назад к списку карт // Назад к списку карт
AdbUtils::goBack(m_deviceId); AdbUtils::goBack(m_deviceId);

View File

@ -452,7 +452,12 @@ QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const Q
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
if (afterPost.bankProfileId.isEmpty()) { 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; qWarning() << "[Ozon] createBankProfileIfNeeded:" << err;
return err; return err;
} }

View File

@ -13,6 +13,7 @@
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
#include "AppLogger.h" #include "AppLogger.h"
namespace Ozon { namespace Ozon {
@ -234,7 +235,12 @@ void GetCardInfoScript::doStart() {
// Проверяем что bankProfileId сохранился // Проверяем что bankProfileId сохранился
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
if (afterPost.bankProfileId.isEmpty()) { 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; qWarning() << "[GetCardInfo]" << err;
AppLogger::log("ozon/GetCardInfo", m_deviceId, err, AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), 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); const BankProfileInfo updatedApp = BankProfileDAO::getApplication(device.id, m_appCode);
if (!updatedApp.bankProfileId.isEmpty()) { if (!updatedApp.bankProfileId.isEmpty()) {
const QList<MaterialInfo> 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) { for (const MaterialInfo &card : cards) {
const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers); const MaterialInfo dbCard = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
if (dbCard.cardNumber.isEmpty()) continue; if (dbCard.cardNumber.isEmpty()) continue;
if (!dbCard.materialId.isEmpty()) { if (dbCard.status != MaterialStatus::Active) continue;
qDebug() << "[GetCardInfo] material already exists for card" << dbCard.lastNumbers << ", skipping"; ns.setPayload(buildMaterialPayload(dbCard, updatedApp));
continue;
}
const QMap<QString, int> 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.addExtra("account_id", dbCard.id);
ns.postMaterial(); ns.postMaterial();
qDebug() << "[GetCardInfo] postMaterial done for card" << dbCard.lastNumbers; qDebug() << "[GetCardInfo] postMaterial done for active card" << dbCard.lastNumbers;
break; // не больше одного материала на сервер
}
} }
} else { } else {
qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials"; qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials";

View File

@ -169,7 +169,12 @@ void GetProfileInfoScript::doStart() {
ns.postBankProfile(); ns.postBankProfile();
const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode); const BankProfileInfo afterPost = BankProfileDAO::getApplication(device.id, m_appCode);
if (afterPost.bankProfileId.isEmpty()) { 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; qWarning() << "[GetProfileInfo]" << err;
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err, AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
AdbUtils::captureScreenshotBytes(m_deviceId), AdbUtils::captureScreenshotBytes(m_deviceId),

View File

@ -0,0 +1,38 @@
#pragma once
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <QString>
#include "db/MaterialInfo.h"
#include "db/BankProfileInfo.h"
// Числовые коды валют для поля `currency` в POST /api/v1/profile/material.
inline int materialCurrencyCode(const QString &currency) {
static const QMap<QString, int> 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);
}

View File

@ -1208,6 +1208,52 @@ void NetworkService::toggleMaterial() {
emit finished(); 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<QMutex> 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() { void NetworkService::toggleBankProfile() {
if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; } if (!ensureValidToken()) { emit finishedWithResult(-1); emit finished(); return; }
@ -1247,6 +1293,15 @@ void NetworkService::postMaterial() {
const QJsonObject body = QJsonDocument::fromJson(m_json).object(); const QJsonObject body = QJsonDocument::fromJson(m_json).object();
qDebug() << "[NetworkService] postMaterial body:" << QJsonDocument(body).toJson(QJsonDocument::Compact); 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<QMutex> mtx = keyMutex(m_deviceId, bankProfileId);
QMutexLocker locker(mtx.data());
const QJsonValue result = postJson(m_apiBase + m_pathMaterial, body, true); const QJsonValue result = postJson(m_apiBase + m_pathMaterial, body, true);
if (result.isNull() || result.isUndefined()) { if (result.isNull() || result.isUndefined()) {
qWarning() << "[NetworkService] postMaterial failed, card:" << body["card_number"].toString(); qWarning() << "[NetworkService] postMaterial failed, card:" << body["card_number"].toString();
@ -1763,7 +1818,9 @@ QSharedPointer<QMutex> NetworkService::keyMutex(const QString &deviceId, const Q
void NetworkService::onDeviceWentOffline(const QString &deviceId) { void NetworkService::onDeviceWentOffline(const QString &deviceId) {
// Правило #1 (device→OFFLINE): выключаем bank_profile и локально, и на сервере. // Правило #1 (device→OFFLINE): выключаем bank_profile и локально, и на сервере.
// Материалы локально не трогаем (правило 4-A). // Материалы локально не трогаем (правило 4-A), но на сервере удаляем все
// синхронизированные материалы (инвариант: материал живёт на сервере только
// при локально active профиле).
if (deviceId.isEmpty()) return; if (deviceId.isEmpty()) return;
if (!ensureValidToken()) return; if (!ensureValidToken()) return;
@ -1771,7 +1828,8 @@ void NetworkService::onDeviceWentOffline(const QString &deviceId) {
const QList<BankProfileInfo> apps = BankProfileDAO::getApplicationsByDeviceId(deviceId); const QList<BankProfileInfo> apps = BankProfileDAO::getApplicationsByDeviceId(deviceId);
// 1) Сервер: для каждого активного профиля с bank_profile_id → PATCH disable. // 1) Сервер: для каждого активного профиля с bank_profile_id —
// сначала DELETE всех его материалов, затем PATCH bank_profile в disabled.
for (const BankProfileInfo &app : apps) { for (const BankProfileInfo &app : apps) {
if (app.status != "active") continue; if (app.status != "active") continue;
if (app.bankProfileId.isEmpty()) continue; if (app.bankProfileId.isEmpty()) continue;
@ -1779,6 +1837,20 @@ void NetworkService::onDeviceWentOffline(const QString &deviceId) {
QSharedPointer<QMutex> mtx = keyMutex(deviceId, app.bankProfileId); QSharedPointer<QMutex> mtx = keyMutex(deviceId, app.bankProfileId);
QMutexLocker locker(mtx.data()); QMutexLocker locker(mtx.data());
const QList<MaterialInfo> 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"; const QString path = m_apiBase + m_pathBankProfile + "/" + app.bankProfileId + "/false";
patchJson(path, QJsonObject(), true); patchJson(path, QJsonObject(), true);
qDebug() << "[NetworkService] onDeviceWentOffline: disabled bank_profile on server:" qDebug() << "[NetworkService] onDeviceWentOffline: disabled bank_profile on server:"
@ -1910,21 +1982,30 @@ void NetworkService::disableAllProfilesOnServer() {
const QJsonObject bp = bpVal.toObject(); const QJsonObject bp = bpVal.toObject();
const QString bpId = bp["id"].toString(); const QString bpId = bp["id"].toString();
const QString bpState = bp["state"].toString(); const QString bpState = bp["state"].toString();
if (bpId.isEmpty() || bpState == "disabled") continue; if (bpId.isEmpty()) continue;
// Сперва 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;
}
}
if (bpState != "disabled") {
patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true); patchJson(m_apiBase + m_pathBankProfile + "/" + bpId + "/false", QJsonObject(), true);
qDebug() << "[shutdown] disabled bank_profile" << bpId; qDebug() << "[shutdown] disabled bank_profile" << bpId;
}
// 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;
// }
} }
} }
@ -1936,6 +2017,41 @@ void NetworkService::stop() {
}, Qt::QueuedConnection); }, 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": {"<field>": ["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) { void NetworkService::connectErrorDialog(QWidget *parent, NetworkService *net) {
if (!net) return; if (!net) return;
// Если parent есть — connect привязан к нему (авто-disconnect при уничтожении). // Если parent есть — connect привязан к нему (авто-disconnect при уничтожении).

View File

@ -31,6 +31,14 @@ public:
// в UI-коде, до запуска треда. // в UI-коде, до запуска треда.
static void connectErrorDialog(QWidget *parent, NetworkService *net); 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: public slots:
void postAccountsData(); void postAccountsData();
@ -46,6 +54,10 @@ public slots:
void toggleMaterial(); void toggleMaterial();
// DELETE /api/v1/profile/material/{material_id}. На успехе очищает
// локальный materialId у переданного account_id (если задан).
void deleteMaterial();
void postDeviceScreenshot(); void postDeviceScreenshot();
void getPayments(); void getPayments();

View File

@ -26,6 +26,7 @@
#include "dao/BankProfileDAO.h" #include "dao/BankProfileDAO.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
AccountSettingsWindow::AccountSettingsWindow( AccountSettingsWindow::AccountSettingsWindow(
QWidget *parent, QString deviceId, QString deviceName, const BankProfileInfo &app QWidget *parent, QString deviceId, QString deviceName, const BankProfileInfo &app
@ -248,31 +249,53 @@ void AccountSettingsWindow::toggleProfileStatus() {
m_app.status = newStatus; m_app.status = newStatus;
showOverlay(isActive ? "Выключение профиля..." : "Включение профиля..."); showOverlay(isActive ? "Выключение профиля..." : "Включение профиля...");
// При включении профиля — материалы включаем ТОЛЬКО после успешного // Инвариант: материал живёт на сервере только при локально active профиле.
// включения bank_profile на сервере; иначе пользователь получит подряд // - При включении профиля: после успеха toggleBankProfile → POST единственной
// несколько одинаковых ошибок ("Invalid or disabled connection") от // active-карты (у неё после предыдущего выключения профиля пустой materialId).
// независимых PATCH'ей. // - При выключении профиля: после успеха toggleBankProfile → DELETE всех
// синхронизированных материалов и очистка их materialId локально.
std::function<void()> onSuccess; std::function<void()> onSuccess;
if (!isActive) {
const QString deviceId = m_deviceId; const QString deviceId = m_deviceId;
const QString appCode = m_app.code; const QString appCode = m_app.code;
if (!isActive) {
onSuccess = [this, deviceId, appCode]() { onSuccess = [this, deviceId, appCode]() {
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, appCode); const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, appCode);
for (const auto &mat : materials) { for (const auto &mat : materials) {
if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) { if (mat.status != MaterialStatus::Active) continue;
if (!mat.materialId.isEmpty()) continue; // уже синхронизирован
auto *thread = new QThread; auto *thread = new QThread;
auto *net = new NetworkService; auto *net = new NetworkService;
net->moveToThread(thread); net->moveToThread(thread);
net->addExtra("material_id", mat.materialId); net->setDeviceId(deviceId);
net->addExtra("enable", true); net->setPayload(buildMaterialPayload(mat, m_app));
net->addExtra("account_id", mat.id);
NetworkService::connectErrorDialog(this, net); NetworkService::connectErrorDialog(this, net);
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); connect(thread, &QThread::started, net, &NetworkService::postMaterial);
connect(net, &NetworkService::finished, thread, &QThread::quit); connect(net, &NetworkService::finished, thread, &QThread::quit);
connect(net, &NetworkService::finished, net, &QObject::deleteLater); connect(net, &NetworkService::finished, net, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater); connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start(); thread->start();
qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId; qDebug() << "[AccountSettings] Posting active material on profile-enable:" << mat.id;
break; // инвариант: одна active карта на профиль
} }
};
} else {
onSuccess = [this, deviceId, appCode]() {
const QList<MaterialInfo> 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(); reloadCards();
}; };
// Сервер не даёт включать материалы при выключенном профиле — возвращает их // Сценарий A — выключение активной карты: DELETE её материала на сервере.
// в `disabled`, и client-side sync затирает локальный active. Поэтому пока if (isActive) {
// профиль off, синхронизация только локальная; материалы зальются на сервер if (account.materialId.isEmpty()) {
// когда юзер включит профиль (см. toggleProfileStatus).
const bool profileActive = m_app.status == "active";
if (!profileActive) {
hideOverlay(true, "Сохранено локально", finishToggle); hideOverlay(true, "Сохранено локально", finishToggle);
return; 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();
}
}
if (!account.materialId.isEmpty()) {
auto *thread = new QThread; auto *thread = new QThread;
auto *net = new NetworkService; auto *net = new NetworkService;
net->moveToThread(thread); net->moveToThread(thread);
net->addExtra("material_id", account.materialId); net->addExtra("material_id", account.materialId);
net->addExtra("enable", !isActive); net->addExtra("account_id", account.id);
NetworkService::connectErrorDialog(this, net); NetworkService::connectErrorDialog(this, net);
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial); connect(thread, &QThread::started, net, &NetworkService::deleteMaterial);
const bool willEnable = !isActive;
const int materialDbId = account.id;
connect(net, &NetworkService::finishedWithResult, this, connect(net, &NetworkService::finishedWithResult, this,
[this, finishToggle, willEnable, materialDbId](int result) { [this, finishToggle](int result) {
if (result == 0) { if (result == 0) {
hideOverlay(true, "Успешно", finishToggle); hideOverlay(true, "Успешно", finishToggle);
return;
}
// Правило #3: при неудачном включении карты — откат локально в off.
if (willEnable) {
MaterialDAO::updateStatus(materialDbId, "off");
hideOverlay(false, "Не удалось включить — возвращено в выключено", finishToggle);
} else { } else {
// Локаль — источник истины: off остаётся, серверная очистка дотянется
// ретраем при следующем переключении / выключении профиля.
hideOverlay(false, "Локально выключено, сервер дотянется автоматически", finishToggle); hideOverlay(false, "Локально выключено, сервер дотянется автоматически", finishToggle);
} }
}, Qt::QueuedConnection); }, Qt::QueuedConnection);
@ -625,8 +619,76 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
connect(net, &NetworkService::finished, net, &QObject::deleteLater); connect(net, &NetworkService::finished, net, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QThread::deleteLater); connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start(); thread->start();
return;
}
// Сценарий B — включение карты: DELETE предыдущих синхронизированных материалов
// (deactivated + сама account если у неё непустой materialId), затем POST этой.
QList<MaterialInfo> 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 { } else {
hideOverlay(true, "Сохранено локально", finishToggle); (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<int>::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();
} }
} }

View File

@ -19,6 +19,7 @@
#include "dao/DeviceDAO.h" #include "dao/DeviceDAO.h"
#include "dao/MaterialDAO.h" #include "dao/MaterialDAO.h"
#include "net/NetworkService.h" #include "net/NetworkService.h"
#include "net/MaterialPayload.h"
BankSetupWizard::BankSetupWizard(QWidget *parent, const QString &deviceId, const BankProfileInfo &app) BankSetupWizard::BankSetupWizard(QWidget *parent, const QString &deviceId, const BankProfileInfo &app)
: QDialog(parent), m_deviceId(deviceId), m_app(app) { : QDialog(parent), m_deviceId(deviceId), m_app(app) {
@ -509,14 +510,15 @@ void BankSetupWizard::runCardCheck() {
onVerificationError("Ошибка получения карт: " + result); onVerificationError("Ошибка получения карт: " + result);
return; return;
} }
// Проверяем что хотя бы одна карта синхронизирована с сервером // Инвариант: материал живёт на сервере только при активном профиле,
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, appCode); // и одновременно только один (active-карта). На этапе сбора карты
bool hasSynced = false; // лежат локально как Off, materialId пустой. Фактическая отправка на
for (const auto &m : materials) { // сервер происходит позже — в applyActiveCardSelection после выбора
if (!m.materialId.isEmpty()) { hasSynced = true; break; } // юзером активной карты. Поэтому здесь проверяем только что профиль
} // зарегистрирован — этого достаточно для дальнейшего POST'а.
if (!hasSynced && !materials.isEmpty()) { const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
onVerificationError("Не удалось отправить данные карт на сервер.\n" if (app.bankProfileId.isEmpty()) {
onVerificationError("Не удалось зарегистрировать банковский профиль на сервере.\n"
"Проверьте подключение к серверу."); "Проверьте подключение к серверу.");
return; return;
} }
@ -631,33 +633,71 @@ void BankSetupWizard::onVerificationSuccess() {
void BankSetupWizard::applyActiveCardSelection() { void BankSetupWizard::applyActiveCardSelection() {
if (m_selectedMaterialId < 0) return; if (m_selectedMaterialId < 0) return;
// Локально помечаем выбранную карту active. На сервере соблюдаем инвариант
// «один материал»: DELETE всех ранее синхронизированных (deactivated +
// сама selected на случай повторного входа в wizard), затем POST выбранной.
const QList<MaterialInfo> deactivated = const QList<MaterialInfo> deactivated =
MaterialDAO::activateExclusive(m_deviceId, m_app.code, m_selectedMaterialId); MaterialDAO::activateExclusive(m_deviceId, m_app.code, m_selectedMaterialId);
const MaterialInfo selected = MaterialDAO::getAccountById(m_selectedMaterialId); QList<MaterialInfo> 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) { const int selectedId = m_selectedMaterialId;
if (materialId.isEmpty()) return; 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 *thread = new QThread;
auto *net = new NetworkService; auto *net = new NetworkService;
net->moveToThread(thread); net->moveToThread(thread);
net->addExtra("material_id", materialId); net->setDeviceId(deviceId);
net->addExtra("enable", enable); net->setPayload(buildMaterialPayload(fresh, app));
net->addExtra("account_id", selectedId);
NetworkService::connectErrorDialog(this, net); 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, thread, &QThread::quit);
QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater); QObject::connect(net, &NetworkService::finished, net, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start(); thread->start();
}; };
sendToggle(selected.materialId, true); if (toDelete.isEmpty()) {
for (const MaterialInfo &m : deactivated) { launchPost();
sendToggle(m.materialId, false); return;
}
auto remaining = QSharedPointer<int>::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) { static QString humanReadableError(const QString &error) {
if (error.contains(QStringLiteral("PROFILE_ALREADY_EXISTS"))) {
return QStringLiteral("Такой профиль уже существует в системе.");
}
// Определяем этап // Определяем этап
QString stage; QString stage;
if (error.contains("пин-кода")) if (error.contains("пин-кода"))