1
0
forked from BRT/arc
arc/services/EventHandler.cpp

1010 lines
56 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "EventHandler.h"
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QMap>
#include <QSettings>
#include <QThread>
#include <QTimer>
#include <QTimeZone>
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/EventDAO.h"
#include "dao/SettingsDAO.h"
#include "dao/TransactionDAO.h"
#include "db/BankProfileInfo.h"
#include "adb/AdbUtils.h"
#include "update/UpdatePause.h"
#include "dao/GeneralLogDAO.h"
#include "net/NetworkService.h"
#include "black/GetLastTransactionsScript.h"
#include "black/LoginAndCheckAccountsScript.h"
#include "ozon/GetLastTransactionsScript.h"
#include "ozon/LoginAndCheckAccountsScript.h"
#include "black/PayByCardScript.h"
#include "ozon/PayByCardScript.h"
#include "ozon/PayByPhoneScript.h"
#include "dushanbe/LoginAndCheckAccountsScript.h"
#include "dushanbe/GetLastTransactionsScript.h"
#include "dushanbe/PayByCardScript.h"
#include "dushanbe/PayByPhoneScript.h"
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1, bool updateStatus = true);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {}, int accountId = -1, const QString &taskJson = {});
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false);
static QString foundTxKey(const QString &materialId, const QJsonObject &tx) {
return materialId + "|" + tx["real_created_at"].toString()
+ "|" + QString::number(qAbs(tx["amount"].toInt()));
}
EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
EventHandler::~EventHandler() = default;
void EventHandler::start() {
qDebug() << "[EventHandler] start() called, thread:" << QThread::currentThread();
m_network = new NetworkService(this);
connect(m_network, &NetworkService::tasksReceived, this, &EventHandler::onTasksReceived);
m_pollTimer = new QTimer(this);
m_pollTimer->setInterval(5000);
connect(m_pollTimer, &QTimer::timeout, this, [this]() {
// На время установки обновления не трогаем устройства и не запрашиваем задачи.
if (UpdatePause::isPaused()) return;
// Удаляем устаревшие записи кэша найденных транзакций (TTL 20 минут)
{
const qint64 now = QDateTime::currentSecsSinceEpoch();
for (auto it = m_foundTxCache.begin(); it != m_foundTxCache.end(); )
it = (now - it.value() > 1200) ? m_foundTxCache.erase(it) : ++it;
}
// Пробуем запустить задачи из очереди для свободных устройств
const QStringList onlineDevices = DeviceDAO::getOnlineDeviceCodes();
for (const QString &deviceId : onlineDevices) {
processNextTask(deviceId);
}
// Запрашиваем новые задачи только для устройств без pending events
const QSet<QString> busyDevices = EventDAO::getDevicesWithPendingEvents();
const QStringList materialIds = MaterialDAO::getActiveMaterialIdsExcludingDevices(busyDevices);
if (!materialIds.isEmpty()) {
m_network->getTasks(materialIds);
} else {
// qDebug() << "[EventHandler] No free materials to poll."
// << "busyDevices:" << busyDevices
// << "onlineDevices:" << onlineDevices;
}
});
m_pollTimer->start();
// Закрываем зависшие события с прошлого запуска (только не отправленные на сервер)
for (const EventStatus st : {EventStatus::Wait, EventStatus::InProgress}) {
const QList<EventInfo> stale = EventDAO::getAllEvents(st);
for (const EventInfo &e : stale) {
if (e.sentToServer) continue;
EventDAO::updateEvent(e.id, EventStatus::Error, "App restarted");
EventDAO::markSentToServer(e.id);
sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, e.id, e.transactionId, e.accountId, e.json);
qDebug() << "[EventHandler] Closed stale event id:" << e.id;
}
}
// Первый запрос сразу при старте
qDebug() << "[EventHandler] starting first getTasks()";
m_network->getTasks();
}
void EventHandler::onTasksReceived(const QJsonArray &tasks) {
if (!m_running) return;
if (!tasks.isEmpty())
qDebug() << "[EventHandler] onTasksReceived, count:" << tasks.size();
// Получаем список онлайн устройств один раз
const QStringList onlineDevices = DeviceDAO::getOnlineDeviceCodes();
// 1. Сохраняем новые задачи в БД
for (const auto &taskVal : tasks) {
const QJsonObject task = taskVal.toObject();
const QString bankName = task["bank_name"].toString();
const QString materialId = task["material_id"].isString()
? task["material_id"].toString()
: QString::number(task["material_id"].toInt());
const QString typeStr = task["type"].toString();
const EventType eventType = eventTypeFromString(typeStr);
if (eventType == EventType::Unknown) {
qWarning() << "[EventHandler] Unknown task type:" << typeStr;
continue;
}
// transaction_id присутствует только в CREATE_TRANSACTION; для других типов это пусто.
const QString incomingTransactionId = task["body"].toObject()["transaction_id"].toString();
// Ищем аккаунт по material_id напрямую
const MaterialInfo account = MaterialDAO::findByMaterialId(materialId);
if (account.id == -1) {
qWarning() << "[EventHandler] Account not found for bank:" << bankName << "material_id:" << materialId;
sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId, -1, QString::fromUtf8(QJsonDocument(task).toJson(QJsonDocument::Compact)));
continue;
}
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа для этого материала
if (eventType == EventType::FetchProfile) {
const QList<EventInfo> cancelled = EventDAO::cancelWaitingByMaterial(materialId, EventType::FetchProfile);
for (const EventInfo &c : cancelled) {
sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, c.id, c.transactionId, c.accountId, c.json);
EventDAO::markSentToServer(c.id);
}
}
EventInfo event;
event.status = EventStatus::Wait;
event.type = eventType;
event.externalId = materialId;
event.accountId = account.id;
event.deviceId = account.deviceId;
event.bankName = bankName;
event.timestamp = QDateTime::currentDateTimeUtc();
event.json = QJsonDocument(task).toJson(QJsonDocument::Compact);
const QJsonObject body = task["body"].toObject();
if (eventType == EventType::FetchTransactions) {
event.amount = body["amount"].toDouble() / 100.0;
}
if (eventType == EventType::CreateTransaction) {
event.amount = body["amount"].toDouble() / 100.0;
event.phone = body["bank_participator_id"].toString();
event.transactionId = body["transaction_id"].toString();
// Название банка-получателя из extra
const QString bankNameRu = body["extra"].toObject()["bank_participator_name_ru"].toString();
if (!bankNameRu.isEmpty()) {
event.comment = bankNameRu;
}
}
const int eventId = EventDAO::insertEvent(event);
if (eventId == -1) {
qCritical() << "[EventHandler] Failed to insert event, external_id:" << materialId;
continue;
}
// Если устройство офлайн — сразу отправляем ошибку на сервер
if (!onlineDevices.contains(account.deviceId)) {
qWarning() << "[EventHandler] Device offline for material_id:" << materialId << "device:" << account.deviceId;
EventDAO::updateEvent(eventId, EventStatus::Error, "Device is offline");
sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, eventId, incomingTransactionId, account.id, event.json);
}
}
// 2. Для каждого онлайн устройства без активного потока — запускаем следующую задачу
for (const QString &deviceId : onlineDevices) {
processNextTask(deviceId);
}
}
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName,
const QJsonValue &data, int eventId, bool updateStatus) {
QJsonObject obj;
obj["type"] = type;
obj["material_id"] = materialId; // UUID string
obj["bank_name"] = bankName;
obj["data"] = data;
if (eventId > 0)
obj["internal_task_id"] = eventId;
auto *thread = new QThread;
auto *networkService = new NetworkService;
networkService->moveToThread(thread);
networkService->setPayload(QJsonDocument(obj).toJson());
QObject::connect(thread, &QThread::started, networkService, &NetworkService::postTaskResult);
QObject::connect(networkService, &NetworkService::finishedWithResult, networkService,
[eventId, updateStatus](int result) {
// updateStatus=false для потоковых per-tx результатов FETCH_TRANSACTIONS:
// статус события финализируется один раз в updateEventThread, а не на
// каждой отдельной отправке найденной транзакции.
if (eventId != -1 && updateStatus) {
if (result == 0) {
EventDAO::markSentToServer(eventId);
} else {
EventDAO::updateEvent(eventId, EventStatus::Error, "Failed to send result to server");
qWarning() << "[EventHandler] Task result not sent, marking event" << eventId << "as error";
}
}
});
QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit);
QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
const QString &description, const QByteArray &screenshot, int eventId,
const QString &transactionId, int accountId, const QString &taskJson) {
QJsonObject errorData;
errorData["status"] = QStringLiteral("error");
errorData["event_type"] = type;
errorData["description"] = description;
if (!transactionId.isEmpty())
errorData["transaction_id"] = transactionId;
sendTaskResult(QStringLiteral("ERROR"), materialId, bankName, errorData, eventId);
// Шлём в мониторинг всегда — даже без скриншота (offline / expired /
// disabled / account not found и т.п.), чтобы такие ошибки не «молчали» на
// релее. Скриншот прикладываем, если он есть.
// api-токен общий на нескольких операторов — по нему не отличить, чей
// профиль упал. Резолвим банковский профиль по accountId и пишем его
// серверный UUID + материал + телефон профиля.
QString profileUuid, profilePhone, appVersion;
if (accountId > 0) {
const MaterialInfo acc = MaterialDAO::getAccountById(accountId);
if (acc.id != -1) {
const BankProfileInfo app = BankProfileDAO::getApplication(acc.deviceId, acc.appCode);
profileUuid = app.bankProfileId;
profilePhone = app.phone;
appVersion = app.appVersion;
}
}
const QString desktopId = SettingsDAO::get("desktop_id");
// Версия самого ARC-десктопа (клиента), не банковского приложения.
const QString clientVersion =
QSettings("config.ini", QSettings::IniFormat).value("common/version").toString();
QStringList lines;
lines << QStringLiteral("<b>[TASK_ERROR] [%1] [%2]</b>")
.arg(type.toHtmlEscaped(), bankName.toUpper().toHtmlEscaped());
if (!desktopId.isEmpty())
lines << QStringLiteral("Desktop: %1").arg(desktopId.toHtmlEscaped());
if (!clientVersion.isEmpty())
lines << QStringLiteral("DesktopVersion: %1").arg(clientVersion.toHtmlEscaped());
if (!profileUuid.isEmpty())
lines << QStringLiteral("Profile: %1").arg(profileUuid.toHtmlEscaped());
if (!materialId.isEmpty())
lines << QStringLiteral("Material: %1").arg(materialId.toHtmlEscaped());
if (!profilePhone.isEmpty())
lines << QStringLiteral("Phone: %1").arg(profilePhone.toHtmlEscaped());
if (!appVersion.isEmpty())
lines << QStringLiteral("AppVersion: %1").arg(appVersion.toHtmlEscaped());
lines << QStringLiteral("Error: %1").arg(description.toHtmlEscaped());
// JSON задачи, пришедшей с сервера (pretty-print). НЕ экранируем — админка
// (_render_msg) экранирует один раз и выносит блок после «JSON: » в <pre>.
if (!taskJson.isEmpty()) {
const QJsonDocument doc = QJsonDocument::fromJson(taskJson.toUtf8());
const QString pretty = doc.isNull()
? taskJson
: QString::fromUtf8(doc.toJson(QJsonDocument::Indented));
lines << QStringLiteral("JSON: %1").arg(pretty);
}
const QString message = lines.join(QStringLiteral("\n"));
sendMonitoringLog("error", "task_error", message, screenshot, eventId);
// application.log + БД отправляются из TaskDumpRecorder::finishInternal,
// чтобы покрывать также non-event-driven задачи (login flow и т.п.).
}
// Успешное завершение задачи в arc-monitoring (мониторинговый дроплет, не apiBase).
// Бизнес-результат уходит отдельно (sendTaskResult / per-tx streaming) — здесь
// только человекочитаемый лог в админку с теми же метаданными, что у task_error
// (Desktop/Profile/Material/Phone/AppVersion), чтобы корректно парсились и
// фильтровались. `summary` — операционные детали (Amount/Found/…).
static void sendTaskSuccess(const QString &type, const QString &materialId, const QString &bankName,
const QString &summary, const QByteArray &screenshot, int eventId,
int accountId) {
QString profileUuid, profilePhone, appVersion;
if (accountId > 0) {
const MaterialInfo acc = MaterialDAO::getAccountById(accountId);
if (acc.id != -1) {
const BankProfileInfo app = BankProfileDAO::getApplication(acc.deviceId, acc.appCode);
profileUuid = app.bankProfileId;
profilePhone = app.phone;
appVersion = app.appVersion;
}
}
const QString desktopId = SettingsDAO::get("desktop_id");
const QString clientVersion =
QSettings("config.ini", QSettings::IniFormat).value("common/version").toString();
QStringList lines;
lines << QStringLiteral("<b>[TASK_SUCCESS] [%1] [%2]</b>")
.arg(type.toHtmlEscaped(), bankName.toUpper().toHtmlEscaped());
if (!desktopId.isEmpty())
lines << QStringLiteral("Desktop: %1").arg(desktopId.toHtmlEscaped());
if (!clientVersion.isEmpty())
lines << QStringLiteral("DesktopVersion: %1").arg(clientVersion.toHtmlEscaped());
if (!profileUuid.isEmpty())
lines << QStringLiteral("Profile: %1").arg(profileUuid.toHtmlEscaped());
if (!materialId.isEmpty())
lines << QStringLiteral("Material: %1").arg(materialId.toHtmlEscaped());
if (!profilePhone.isEmpty())
lines << QStringLiteral("Phone: %1").arg(profilePhone.toHtmlEscaped());
if (!appVersion.isEmpty())
lines << QStringLiteral("AppVersion: %1").arg(appVersion.toHtmlEscaped());
// summary содержит JSON — НЕ экранируем: админка (_render_msg) экранирует
// ровно один раз сама. Двойной toHtmlEscaped превратил бы " в &quot; в тексте.
if (!summary.isEmpty())
lines << summary;
const QString message = lines.join(QStringLiteral("\n"));
sendMonitoringLog("info", "task_success", message, screenshot, eventId, /*toApiBase=*/false);
}
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message,
const QByteArray &image, int eventId, bool toApiBase) {
auto *thread = new QThread;
auto *networkService = new NetworkService;
networkService->moveToThread(thread);
if (eventId > 0)
networkService->addExtra("internal_task_id", eventId);
QObject::connect(thread, &QThread::started, networkService, [networkService, type, event, message, image, toApiBase]() {
networkService->postMonitoringLog(type, event, message, image, toApiBase);
emit networkService->finished();
});
QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit);
QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater);
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
thread->start();
}
static void sendEventStatus(const QString &deviceId, const QString &externalId, const int eventId,
const QString &status, const QString &error) {
QJsonObject obj;
obj["id"] = externalId;
obj["externalId"] = eventId;
obj["status"] = status;
obj["error"] = error;
auto *paymentThread = new QThread;
auto *networkService = new NetworkService;
networkService->moveToThread(paymentThread);
networkService->setDeviceId(deviceId);
networkService->setPayload(QJsonDocument(obj).toJson());
QObject::connect(paymentThread, &QThread::started, networkService, &NetworkService::postEventStatus);
QObject::connect(networkService, &NetworkService::finished, paymentThread, &QThread::quit);
QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater);
QObject::connect(paymentThread, &QThread::finished, paymentThread, &QThread::deleteLater);
paymentThread->start();
}
bool EventHandler::tryRetry(const QString &key, const EventInfo &event, const QString &reason) {
constexpr int kMaxRetries = 2; // ещё 2 попытки сверх первой → всего 3 запуска
const int used = m_retryCount.value(event.id, 0);
if (used >= kMaxRetries) {
m_retryCount.remove(event.id); // попытки исчерпаны — пусть caller обработает как ошибку
return false;
}
const int attempt = used + 1;
m_retryCount[event.id] = attempt;
qWarning() << "[EventHandler] FETCH_TRANSACTIONS retry" << attempt << "/" << kMaxRetries
<< "for event" << event.id << "reason:" << reason;
GeneralLogDAO::insert("WARNING", "EventHandler",
QString("[FETCH_TRANSACTIONS] retry %1/%2 device=%3 material=%4: %5")
.arg(QString::number(attempt), QString::number(kMaxRetries),
event.deviceId, event.externalId, reason));
// Чистим артефакты прошлой попытки. Ретрай запускается только когда ещё
// ничего не отправлено (см. guard в updateEventThread), так что сбрасываем
// счётчик отправленных в 0.
m_screenshots.remove(key);
m_parsedTxCount.remove(key);
m_parsedTxJson.remove(key);
// Снимаем текущий поток/таймер (в timeout-ветке они уже сняты — take() вернёт null)
if (const auto tm = m_timers.take(key)) {
tm->stop();
tm->deleteLater();
}
m_threads.remove(key);
// Сбрасываем банковское приложение, чтобы попытка стартовала с чистого экрана
const MaterialInfo account = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, account.appCode);
if (!app.package.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
AdbUtils::tryToKillApplication(adbSerial, app.package);
}
// Держим событие InProgress и перезапускаем скрипт
EventDAO::updateEvent(event.id, EventStatus::InProgress, "");
startThreadForTask(account, event);
return true;
}
void EventHandler::updateEventThread(const QString &key, const EventInfo &event, const QString &result) {
// Поиск транзакции: при ошибке скрипта перезапускаем (ещё до 2 раз) вместо
// немедленной отправки ошибки на сервер. Но только если НИ ОДНОЙ транзакции
// ещё не отправлено: при частичном успехе (часть списка найдена и уже
// улетела на сервер) ретрай бессмыслен — переотправит дубли, поэтому
// ненайденные просто репортим ошибкой ниже.
// Ретраи отключены: при ошибке сразу репортим как Error без повторов.
// if (!result.isEmpty() && event.type == EventType::FetchTransactions
// && m_parsedTxCount.value(key, 0) == 0
// && tryRetry(key, event, result)) {
// return;
// }
const EventStatus newStatus = result.isEmpty() ? EventStatus::Complete : EventStatus::Error;
if (!EventDAO::updateEvent(event.id, newStatus, result)) {
qCritical() << "[EventHandler] Failed to update event:" << event.id;
} else {
if (newStatus == EventStatus::Error) {
// Используем скриншот из скрипта если есть, иначе снимаем новый
QByteArray screenshot = m_screenshots.take(key);
// "Interrupted" — не самостоятельная ошибка, а следствие внешнего
// requestInterruption(): его выставляет либо watchdog (он уже шлёт
// "Script timeout (N min)" в startThreadForTask), либо clearAll() при
// остановке/выходе (намеренная остановка). Реальную причину репортит
// инициатор прерывания, поэтому здесь Interrupted на сервер НЕ
// дублируем — иначе на один таймаут выходит три записи в мониторинге
// (timeout-log + interrupted-log + interrupted-dump). Логику declined
// для CREATE_TRANSACTION ниже это не затрагивает.
if (result != "Interrupted") {
if (screenshot.isEmpty() && !event.deviceId.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
screenshot = AdbUtils::captureScreenshotBytes(adbSerial);
}
if (!screenshot.isEmpty()) {
EventDAO::updateScreenshot(event.id, screenshot);
}
// Дублируем ошибку в general_logs со скриншотом
const DeviceInfo logDevice = DeviceDAO::getDeviceById(event.deviceId);
const QString deviceLabel = logDevice.name.isEmpty() ? event.deviceId : logDevice.name;
const QString logMsg = QString("[%1] device=%2 bank=%3: %4")
.arg(eventTypeToString(event.type), deviceLabel, event.bankName, result);
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg, screenshot);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, result, screenshot, event.id, event.transactionId, event.accountId, event.json);
} else {
qDebug() << "[EventHandler] Interrupted — server error report skipped (cause already reported by interrupter), event id:" << event.id;
}
if (event.type == EventType::CreateTransaction) {
const MaterialInfo declAcc = MaterialDAO::getAccountById(event.accountId);
const QMap<QString, int> declCurrencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int declCurrencyCode = declCurrencyCodes.value(declAcc.currency.toUpper(), 643);
const QString declTxType = QJsonDocument::fromJson(event.json.toUtf8())
.object()["body"].toObject()["type"].toString();
QString declTxId;
const QList<TransactionInfo> declTxList = TransactionDAO::getTransactionsWithinHours(event.accountId, 1);
for (const auto &tx : declTxList) {
if (!tx.bankTrExternalId.isEmpty() && qAbs(tx.amount) == event.amount && tx.phone == event.phone) {
declTxId = tx.bankTrExternalId;
break;
}
}
// Скрипт мог упасть до записи транзакции в БД — сервер всё равно
// ждёт уникальный bank_transaction_id. Генерируем по той же схеме,
// что и в success-ветке скриптов: <bankName>_<bankProfilePhone>_<unix>.
if (declTxId.isEmpty()) {
const BankProfileInfo declApp = BankProfileDAO::getApplication(declAcc.deviceId, event.bankName);
declTxId = event.bankName + "_" + declApp.phone + "_"
+ QString::number(QDateTime::currentSecsSinceEpoch());
}
QJsonObject declined;
if (!event.transactionId.isEmpty())
declined["transaction_id"] = event.transactionId;
declined["bank_transaction_id"] = declTxId;
declined["amount"] = -static_cast<int>(event.amount * 100);
declined["currency"] = declCurrencyCode;
declined["transaction_type"] = declTxType.isEmpty() ? QStringLiteral("top-up") : declTxType;
declined["bank_participator_id"] = event.phone;
declined["status"] = QStringLiteral("declined");
declined["extra"] = QJsonObject();
declined["created_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
sendTaskResult(QStringLiteral("CREATE_TRANSACTION"), event.externalId, event.bankName, declined, event.id);
}
EventDAO::markSentToServer(event.id);
}
// JSON результата, ушедшего на apiBase — кладём в текст task_success ниже.
QString apiResultJson;
if (newStatus != EventStatus::Error) {
const MaterialInfo account = MaterialDAO::getAccountById(event.accountId);
const QMap<QString, int> currencyCodes = {
{"RUB", 643}, {"USD", 840}, {"KRW", 410}, {"AZN", 944}, {"ARS", 32}, {"TJS", 972}
};
const int currencyCode = currencyCodes.value(account.currency.toUpper(), 643);
QJsonValue taskData;
if (event.type == EventType::FetchProfile) {
// FETCH_PROFILE: баланс из bank_profiles (скрипт обновляет через
// BankProfileDAO::updateAmount), fallback на materials.
const BankProfileInfo freshApp = BankProfileDAO::getApplication(
account.deviceId, event.bankName);
double freshAmount = (freshApp.id != -1 && freshApp.amount > 0.0)
? freshApp.amount : account.amount;
EventDAO::updateEventAmount(event.id, freshAmount);
QJsonObject obj;
obj["bank_account_id"] = account.materialId;
obj["amount"] = static_cast<int>(freshAmount * 100);
obj["currency"] = currencyCode;
taskData = obj;
} else if (event.type == EventType::FetchTransactions) {
// FETCH_TRANSACTIONS шлёт результаты потоково: каждая найденная
// транзакция уже отправлена отдельным task_result по сигналу
// transactionParsed (см. startThreadForTask). Здесь, при полном
// успехе (m_error пуст → найдены все цели списка), отдельный
// финальный task_result НЕ отправляем — только финализируем
// событие, чтобы оно не считалось «зависшим» при перезапуске.
EventDAO::markSentToServer(event.id);
} else if (event.type == EventType::CreateTransaction) {
// Определяем тип транзакции из JSON задачи
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
const QString txType = taskJson["body"].toObject()["type"].toString();
QJsonObject obj;
if (!event.transactionId.isEmpty())
obj["transaction_id"] = event.transactionId;
obj["amount"] = -static_cast<int>(event.amount * 100);
obj["currency"] = currencyCode;
obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType;
obj["bank_participator_id"]= event.phone;
obj["status"] = "completed";
obj["created_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
// Берём bank_transaction_id и fee из последней сохранённой транзакции
QList<TransactionInfo> txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 1);
int feeInCents = 0;
for (const auto &tx : txList) {
if (!tx.bankTrExternalId.isEmpty() && qAbs(tx.amount) == event.amount && tx.phone == event.phone) {
obj["bank_transaction_id"] = tx.bankTrExternalId;
feeInCents = static_cast<int>(tx.fee * 100);
break;
}
}
QJsonObject extraObj;
extraObj["fee"] = feeInCents;
obj["extra"] = extraObj;
taskData = obj;
apiResultJson = QString::fromUtf8(
QJsonDocument(obj).toJson(QJsonDocument::Indented));
} else {
QJsonObject obj;
obj["status"] = "completed";
taskData = obj;
}
// FETCH_TRANSACTIONS уже отправил данные потоково (per-tx) —
// финальный агрегирующий результат не нужен.
if (event.type != EventType::FetchTransactions) {
sendTaskResult(
eventTypeToString(event.type),
event.externalId,
event.bankName,
taskData,
event.id
);
}
}
if (newStatus == EventStatus::Complete) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
if (event.type == EventType::CreateTransaction) {
QString msg = QStringLiteral("[CREATE_TRANSACTION] bank=%1 material=%2 amount=%3 phone=%4")
.arg(event.bankName, event.externalId, QString::number(event.amount, 'f', 2), event.phone);
if (!apiResultJson.isEmpty())
msg += QStringLiteral("\nJSON: %1").arg(apiResultJson);
QByteArray screenshot;
const QString tmpPath = QDir::tempPath() + "/arc_tx_" + QString::number(event.id);
if (AdbUtils::takeScreenshot(event.deviceId, tmpPath)) {
QFile f(tmpPath + ".png");
if (f.open(QIODevice::ReadOnly)) {
screenshot = f.readAll();
f.close();
}
f.remove();
}
sendMonitoringLog("info", "task_success", msg, screenshot, event.id, /*toApiBase=*/true);
// Дублируем успех в arc-monitoring (см. sendTaskSuccess) — туда же,
// куда уходят ошибки, чтобы видеть и удачные создания транзакций.
QString createSummary = QStringLiteral("Amount: %1\nRecipient: %2")
.arg(QString::number(event.amount, 'f', 2), event.phone);
if (!apiResultJson.isEmpty())
createSummary += QStringLiteral("\nJSON: %1").arg(apiResultJson);
sendTaskSuccess("CREATE_TRANSACTION", event.externalId, event.bankName,
createSummary, screenshot, event.id, event.accountId);
// Декрементируем материальный/профильный баланс на сумму расхода + комиссию.
// Между запусками FETCH_PROFILE это держит UI/локальный баланс
// в актуальном виде; drift по входящим всё равно сбросит следующий re-parse.
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
if (acc.id != -1) {
double fee = 0.0;
const QList<TransactionInfo> txList =
TransactionDAO::getTransactionsWithinHours(event.accountId, 1);
for (const auto &tx : txList) {
if (!tx.bankTrExternalId.isEmpty()
&& qAbs(tx.amount) == event.amount
&& tx.phone == event.phone) {
fee = tx.fee;
break;
}
}
const double delta = event.amount + fee;
// Для Dushanbe все карты сидят на одном счёте — двигаем
// все материалы (см. memory: project_dushanbe_single_account).
if (acc.appCode == "dushanbe_city_bank") {
if (!MaterialDAO::adjustAmountByApp(acc.deviceId, acc.appCode, delta)) {
qWarning() << "[EventHandler] adjustAmountByApp failed for"
<< acc.deviceId << acc.appCode;
}
} else {
if (!MaterialDAO::adjustAmount(acc.id, delta)) {
qWarning() << "[EventHandler] adjustAmount failed for material" << acc.id;
}
}
const BankProfileInfo app = BankProfileDAO::getApplication(acc.deviceId, acc.appCode);
if (app.id != -1) {
if (!BankProfileDAO::adjustAmount(app.id, delta)) {
qWarning() << "[EventHandler] BankProfileDAO::adjustAmount failed for app" << app.id;
}
}
qDebug() << "[EventHandler] balance decremented: material" << acc.id
<< "app" << acc.appCode << "delta=" << delta
<< "(amount=" << event.amount << "fee=" << fee << ")";
}
}
if (event.type == EventType::FetchTransactions) {
// JSON для текста: массив транзакций, отправленных потоково на apiBase.
const QJsonArray txArr = m_parsedTxJson.value(key);
const QString txJson = QString::fromUtf8(
QJsonDocument(txArr).toJson(QJsonDocument::Indented));
QString msg = QStringLiteral("[FETCH_TRANSACTIONS] bank=%1 material=%2 found=%3")
.arg(event.bankName, event.externalId, QString::number(txArr.size()));
if (!txArr.isEmpty())
msg += QStringLiteral("\nJSON: %1").arg(txJson);
const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true);
// arc-monitoring: каждая транзакция уже отправлена по мере нахождения
// в streamParsedTx — батчевая отправка здесь не нужна.
}
if (event.type == EventType::FetchProfile) {
const QString msg = QStringLiteral("[FETCH_PROFILE] bank=%1 material=%2")
.arg(event.bankName, event.externalId);
const QByteArray scr = m_screenshots.take(key);
sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true);
}
}
}
if (const auto tm = m_timers.take(key)) {
tm->stop();
tm->deleteLater();
}
m_threads.remove(key);
m_retryCount.remove(event.id);
m_parsedTxCount.remove(key);
m_parsedTxJson.remove(key);
// Убиваем банковское приложение на устройстве после завершения задачи
{
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
if (!app.package.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
AdbUtils::tryToKillApplication(adbSerial, app.package);
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial;
}
}
// Сразу пробуем запустить следующую задачу для этого устройства
processNextTask(key);
}
void EventHandler::startThreadForTask(const MaterialInfo &account, const EventInfo &event) {
const auto thr = new QThread(this);
const QString key = account.deviceId;
// Generic helper: подключает сигналы и переносит worker в поток
auto setup = [&](auto *worker) {
using W = std::remove_pointer_t<decltype(worker)>;
worker->moveToThread(thr);
connect(thr, &QThread::started, worker, &W::start);
connect(worker, &W::finished, thr, &QThread::quit);
connect(worker, &W::finished, worker, &QObject::deleteLater);
connect(thr, &QThread::finished, thr, &QObject::deleteLater);
connect(worker, &W::finishedWithResult, this,
[this, key, event](const QString &result) {
updateEventThread(key, event, result);
}
);
};
const QString &appCode = account.appCode;
// Передаём в скрипт канонический device_id из материала — скрипт сам
// разрезолвит его в adb serial через DeviceDAO и использует ТОТ ЖЕ ключ
// для поиска BankProfileInfo (чтобы PIN поднимался из правильной записи).
const QString adbSerial = account.deviceId;
// AutoConnection: worker emits из своего потока, лямбда выполняется в потоке
// EventHandler (где live `this`), там же где take()/clear() — m_screenshots
// не нужен mutex.
auto connectScreenshot = [this, key](Ozon::CommonScript *w) {
connect(w, &Ozon::CommonScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; });
};
auto connectScreenshotDushanbe = [this, key](Dushanbe::CommonScript *w) {
connect(w, &Dushanbe::CommonScript::screenshotReady, this,
[this, key](const QByteArray &s) { m_screenshots[key] = s; });
};
if (event.type == EventType::FetchProfile) {
if (appCode == "ozon") {
auto *w = new Ozon::LoginAndCheckAccountsScript(adbSerial, appCode, nullptr, true);
setup(w); connectScreenshot(w);
}
else if (appCode == "black") setup(new Black::LoginAndCheckAccountsScript(adbSerial, appCode));
else if (appCode == "dushanbe_city_bank") {
auto *w = new Dushanbe::LoginAndCheckAccountsScript(adbSerial, appCode);
setup(w); connectScreenshotDushanbe(w);
}
else qWarning() << "[EventHandler] FETCH_PROFILE: unknown appCode:" << appCode;
}
if (event.type == EventType::FetchTransactions) {
// body теперь — массив транзакций для поиска: [{amount, created_at}, ...].
// Для обратной совместимости поддерживаем и старый формат — одиночный
// объект {amount, created_at} (оборачиваем в массив из одного элемента).
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
QJsonArray bodyArr = taskJson["body"].toArray();
if (bodyArr.isEmpty() && taskJson["body"].isObject())
bodyArr.append(taskJson["body"]);
QList<QPair<double, QDateTime>> targets;
for (const auto &v : bodyArr) {
const QJsonObject item = v.toObject();
const double searchAmount = item["amount"].toDouble() / 100.0; // копейки → рубли (с учётом знака)
QDateTime searchTime = QDateTime::fromString(item["created_at"].toString(), Qt::ISODate);
// Backend контракт — UTC ISO с суффиксом 'Z'. Если 'Z'/смещение
// отсутствует — Qt парсит как LocalTime, что даёт off-by-one для
// вечерних транзакций (см. task_dump (26)). Жёстко интерпретируем как UTC.
if (searchTime.isValid() && searchTime.timeSpec() == Qt::LocalTime) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: created_at без TZ-маркера"
<< item["created_at"].toString() << "— интерпретирую как UTC";
searchTime.setTimeZone(QTimeZone::utc());
}
targets.append({searchAmount, searchTime});
}
qDebug() << "[EventHandler] FETCH_TRANSACTIONS: searching" << targets.size() << "transaction(s)";
// Потоковая отправка: каждую найденную транзакцию шлём на сервер сразу
// отдельным task_result (updateStatus=false — статус события финализирует
// updateEventThread). m_parsedTxCount считает отправленные, чтобы не
// ретраить уже частично отработанную задачу.
auto streamParsedTx = [this, key, event](const QJsonObject &tx) {
m_parsedTxCount[key] = m_parsedTxCount.value(key, 0) + 1;
m_parsedTxJson[key].append(tx); // копим для показа JSON в task_success
QJsonArray arr;
arr.append(tx);
sendTaskResult(eventTypeToString(EventType::FetchTransactions),
event.externalId, event.bankName, arr, event.id, /*updateStatus=*/false);
// Логируем в arc-monitoring сразу по каждой транзакции — как на apiBase.
sendTaskSuccess("FETCH_TRANSACTIONS", event.externalId, event.bankName,
QStringLiteral("JSON: %1").arg(QString::fromUtf8(QJsonDocument(tx).toJson(QJsonDocument::Indented))),
{}, event.id, event.accountId);
// Фиксируем в глобальном кэше: будущие задачи того же материала
// не будут повторно подтверждать эту транзакцию (TTL 20 минут).
const QString txKey = foundTxKey(event.externalId, tx);
if (!txKey.startsWith("|")) // real_created_at непустой
m_foundTxCache[txKey] = QDateTime::currentSecsSinceEpoch();
};
// Снимок кэша найденных транзакций для этого материала (без prefix).
// Скрипт использует его для пропуска уже подтверждённых транзакций.
QSet<QString> foundCacheSnapshot;
{
const qint64 now = QDateTime::currentSecsSinceEpoch();
const QString prefix = event.externalId + "|";
for (auto it = m_foundTxCache.cbegin(); it != m_foundTxCache.cend(); ++it)
if (now - it.value() < 1200 && it.key().startsWith(prefix))
foundCacheSnapshot.insert(it.key().mid(prefix.size()));
}
if (appCode == "ozon") {
auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, targets, account.materialId);
setup(w); connectScreenshot(w);
w->setFoundCache(foundCacheSnapshot);
connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this, streamParsedTx);
}
else if (appCode == "black")
setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
else if (appCode == "dushanbe_city_bank") {
auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, targets, account.materialId);
setup(w); connectScreenshotDushanbe(w);
w->setFoundCache(foundCacheSnapshot);
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, streamParsedTx);
}
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
}
if (event.type == EventType::CreateTransaction) {
const QString recipientBank = event.comment; // резолвленное имя банка-получателя
// Определяем тип транзакции из JSON задачи
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
const QString txType = taskJson["body"].toObject()["type"].toString();
if (txType == "bank") {
// Перевод по номеру карты
if (appCode == "ozon") {
auto *w = new Ozon::PayByCardScript(account, event.phone, event.amount);
setup(w); connectScreenshot(w);
}
else if (appCode == "black") setup(new Black::PayByCardScript(account, event.phone, event.amount));
else if (appCode == "dushanbe_city_bank") {
auto *w = new Dushanbe::PayByCardScript(account, event.phone, event.amount);
setup(w); connectScreenshotDushanbe(w);
}
else qWarning() << "[EventHandler] CREATE_TRANSACTION bank: unknown appCode:" << appCode;
} else {
// Перевод по номеру телефона (top-up)
if (appCode == "ozon") {
auto *w = new Ozon::PayByPhoneScript(account, event.phone, recipientBank, event.amount);
setup(w); connectScreenshot(w);
}
else if (appCode == "dushanbe_city_bank") {
auto *w = new Dushanbe::PayByPhoneScript(account, event.phone, event.amount);
setup(w); connectScreenshotDushanbe(w);
}
else qWarning() << "[EventHandler] CREATE_TRANSACTION: unknown appCode:" << appCode;
}
}
// Watchdog-таймер — убиваем зависший поток.
// FETCH_TRANSACTIONS листает историю транзакций и не успевает за 5 минут
// на длинных списках — даём ему 10 минут. Остальным задачам — 5 минут.
const int timeoutMin = (event.type == EventType::FetchTransactions) ? 10 : 5;
const QString timeoutMsg = QString("Script timeout (%1 min)").arg(timeoutMin);
const auto timer = new QTimer(this);
timer->setSingleShot(true);
connect(timer, &QTimer::timeout, this, [this, key, event, timeoutMsg]() {
qWarning() << "[EventHandler] Timeout for key" << key << "- killing stuck thread.";
if (QThread *thread = m_threads.value(key); thread && thread->isRunning()) {
thread->requestInterruption();
if (!thread->wait(15000)) {
qWarning() << "[EventHandler] Thread still running after 15s for key" << key
<< "— abandoning (no terminate to avoid heap corruption)";
}
}
if (const auto tm = m_timers.take(key)) {
tm->deleteLater();
}
m_threads.remove(key);
// Поиск транзакции: таймаут тоже ретраим (ещё до 2 раз), но только если
// ещё ни одной транзакции не отправлено (иначе ретрай переотправит дубли).
// Ретраи отключены: при таймауте сразу репортим как Error без повторов.
// if (event.type == EventType::FetchTransactions
// && m_parsedTxCount.value(key, 0) == 0
// && tryRetry(key, event, "Script timeout (5 min)")) {
// return;
// }
// Помечаем событие как ошибку
EventDAO::updateEvent(event.id, EventStatus::Error, timeoutMsg);
const QString logMsg = QString("[%1] bank=%2 material=%3: Script timeout")
.arg(eventTypeToString(event.type), event.bankName, event.externalId);
GeneralLogDAO::insert("CRITICAL", "EventHandler", logMsg);
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, timeoutMsg, {}, event.id, event.transactionId, event.accountId, event.json);
EventDAO::markSentToServer(event.id);
// Убиваем банковское приложение на устройстве после таймаута
{
const MaterialInfo acc = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(event.deviceId, acc.appCode);
if (!app.package.isEmpty()) {
const DeviceInfo device = DeviceDAO::getDeviceById(event.deviceId);
const QString adbSerial = device.adbSerial.isEmpty() ? event.deviceId : device.adbSerial;
AdbUtils::tryToKillApplication(adbSerial, app.package);
qDebug() << "[EventHandler] Killed app" << app.package << "on device" << adbSerial << "(timeout)";
}
}
processNextTask(key);
});
m_threads[key] = thr;
m_timers[key] = timer;
timer->start(timeoutMin * 60 * 1000);
thr->start();
}
void EventHandler::processNextTask(const QString &deviceId) {
if (!m_running) return;
if (const QThread *thr = m_threads.value(deviceId, nullptr); thr && thr->isRunning()) return;
while (true) {
const EventInfo event = EventDAO::getFirstWaitEventByDeviceId(deviceId);
if (event.id == -1) break;
const qint64 waitSecs = event.timestamp.secsTo(QDateTime::currentDateTimeUtc());
if (waitSecs > 5 * 60) {
qWarning() << "[EventHandler] Task expired after" << waitSecs << "s, event id:" << event.id;
EventDAO::updateEvent(event.id, EventStatus::Error, "Task expired: waited " + QString::number(waitSecs) + "s");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, event.id, event.transactionId, event.accountId, event.json);
continue;
}
const MaterialInfo account = MaterialDAO::getAccountById(event.accountId);
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, account.appCode);
if (app.status != "active") {
qWarning() << "[EventHandler] Bank profile disabled for" << account.appCode << "device:" << deviceId;
EventDAO::updateEvent(event.id, EventStatus::Error, "Bank profile is disabled");
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Bank profile is disabled: " + account.appCode, {}, event.id, event.transactionId, event.accountId, event.json);
continue;
}
if (!EventDAO::updateEvent(event.id, EventStatus::InProgress, "")) {
qCritical() << "[EventHandler] Failed to set InProgress, event id:" << event.id;
break;
}
startThreadForTask(account, event);
break;
}
}
void EventHandler::clearAll() {
// Параллельная остановка: сначала всем requestInterruption + quit,
// потом ждём каждый. terminate() намеренно НЕ используем — он опасен
// (heap corruption, утечки DB-локов); если поток не успел остановиться,
// abandon'им — ОС зачистит ресурсы при выходе процесса. Этот же подход
// применяет watchdog в startThreadForTask.
for (QThread *thread : m_threads) {
if (thread->isRunning()) {
thread->requestInterruption();
thread->quit();
}
}
for (QThread *thread : m_threads) {
if (thread->isRunning() && !thread->wait(5000)) {
qWarning() << "[EventHandler] Thread did not stop in 5s on shutdown,"
<< "abandoning (no terminate to avoid heap corruption)";
continue; // не deleteLater для всё ещё работающего QThread
}
thread->deleteLater();
}
m_threads.clear();
for (QTimer *timer : m_timers) {
timer->stop();
timer->deleteLater();
}
m_timers.clear();
m_screenshots.clear();
m_parsedTxCount.clear();
m_parsedTxJson.clear();
}
void EventHandler::stop() {
m_running = false;
if (m_pollTimer) m_pollTimer->stop();
clearAll();
}