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

View File

@ -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<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.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";

View File

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

View File

@ -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<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"] = 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;
}
}
}

View File

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

View File

@ -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<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) {
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<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.postMaterial();
qDebug() << "[GetCardInfo] postMaterial done for card" << dbCard.lastNumbers;
}
} else {
qWarning() << "[GetCardInfo] bankProfileId empty, skipping materials";

View File

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

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();
}
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() {
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<QMutex> 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<QMutex> 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<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) {
if (app.status != "active") continue;
if (app.bankProfileId.isEmpty()) continue;
@ -1779,6 +1837,20 @@ void NetworkService::onDeviceWentOffline(const QString &deviceId) {
QSharedPointer<QMutex> mtx = keyMutex(deviceId, app.bankProfileId);
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";
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": {"<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) {
if (!net) return;
// Если parent есть — connect привязан к нему (авто-disconnect при уничтожении).

View File

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

View File

@ -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<void()> 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<MaterialInfo> 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<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();
};
// Сервер не даёт включать материалы при выключенном профиле — возвращает их
// в `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<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 {
(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/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<MaterialInfo> 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<MaterialInfo> deactivated =
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) {
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<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) {
if (error.contains(QStringLiteral("PROFILE_ALREADY_EXISTS"))) {
return QStringLiteral("Такой профиль уже существует в системе.");
}
// Определяем этап
QString stage;
if (error.contains("пин-кода"))