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

823 lines
42 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 <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 "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);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}, int eventId = -1, const QString &transactionId = {});
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false);
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]() {
// Пробуем запустить задачи из очереди для свободных устройств
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", {}, -1, e.transactionId);
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);
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", {}, -1, c.transactionId);
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", {}, -1, incomingTransactionId);
}
}
// 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) {
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](int result) {
if (eventId != -1) {
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) {
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);
if (!screenshot.isEmpty()) {
const QString apiToken = SettingsDAO::get("api_key");
QStringList lines;
lines << QStringLiteral("<b>[TASK_ERROR] [%1] [%2]</b>")
.arg(type.toHtmlEscaped(), bankName.toUpper().toHtmlEscaped());
if (!apiToken.isEmpty())
lines << QStringLiteral("<b>[%1]</b>").arg(apiToken.toHtmlEscaped());
lines << QStringLiteral("<b>[%1]</b>").arg(materialId.toHtmlEscaped());
lines << description.toHtmlEscaped();
const QString message = lines.join(QStringLiteral("\n"));
sendMonitoringLog("error", "task_error", message, screenshot, eventId);
}
// application.log + БД отправляются из TaskDumpRecorder::finishInternal,
// чтобы покрывать также non-event-driven задачи (login flow и т.п.).
}
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));
// Чистим артефакты прошлой попытки
m_screenshots.remove(key);
m_parsedTxs.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 раз) вместо
// немедленной отправки ошибки на сервер.
if (!result.isEmpty() && event.type == EventType::FetchTransactions
&& 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);
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);
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, -1);
}
EventDAO::markSentToServer(event.id);
}
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 больше НЕ читает из БД — берём то, что
// скрипт распарсил вживую и передал через сигнал transactionParsed.
const QJsonObject parsedTx = m_parsedTxs.take(key);
if (parsedTx.isEmpty()) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
if (tryRetry(key, event, "Transaction not parsed from screen")) return;
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
"Transaction not parsed from screen", {}, -1, event.transactionId);
EventDAO::updateEvent(event.id, EventStatus::Error,
"Transaction not parsed from screen");
EventDAO::markSentToServer(event.id);
m_threads.remove(key);
m_retryCount.remove(event.id);
return;
}
QJsonArray arr;
arr.append(parsedTx);
taskData = arr;
} 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;
} else {
QJsonObject obj;
obj["status"] = "completed";
taskData = obj;
}
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) {
const 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);
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);
// Декрементируем материальный/профильный баланс на сумму расхода + комиссию.
// Между запусками 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) {
const QString msg = QStringLiteral("[FETCH_TRANSACTIONS] 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 (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);
// Убиваем банковское приложение на устройстве после завершения задачи
{
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) {
// Парсим amount и created_at из body задачи
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
const QJsonObject body = taskJson["body"].toObject();
const double searchAmount = body["amount"].toDouble() / 100.0; // копейки → рубли (с учётом знака)
const QString createdAtStr = body["created_at"].toString();
QDateTime searchTime = QDateTime::fromString(createdAtStr, Qt::ISODate);
// Backend контракт — UTC ISO с суффиксом 'Z' (как мы сами пишем
// на строках 317 и 373). Если 'Z'/смещение отсутствует — Qt парсит
// как LocalTime, что даёт off-by-one для вечерних транзакций
// (см. task_dump (26): Ozon группировал +100 на 14.05, backend
// прислал 13.05 без TZ). Жёстко переинтерпретируем как UTC.
if (searchTime.isValid() && searchTime.timeSpec() == Qt::LocalTime) {
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: created_at без TZ-маркера"
<< createdAtStr << "— интерпретирую как UTC";
searchTime.setTimeZone(QTimeZone::utc());
}
if (appCode == "ozon") {
auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId);
setup(w); connectScreenshot(w);
connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
}
else if (appCode == "black")
setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
else if (appCode == "dushanbe_city_bank") {
auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime, account.materialId);
setup(w); connectScreenshotDushanbe(w);
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
}
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-таймер: 5 минут — убиваем зависший поток
const auto timer = new QTimer(this);
timer->setSingleShot(true);
connect(timer, &QTimer::timeout, this, [this, key, event]() {
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 раз)
if (event.type == EventType::FetchTransactions
&& tryRetry(key, event, "Script timeout (5 min)")) {
return;
}
// Помечаем событие как ошибку
EventDAO::updateEvent(event.id, EventStatus::Error, "Script timeout (5 min)");
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, "Script timeout (5 min)", {}, -1, event.transactionId);
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(5 * 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", {}, -1, event.transactionId);
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, {}, -1, event.transactionId);
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_parsedTxs.clear();
}
void EventHandler::stop() {
m_running = false;
if (m_pollTimer) m_pollTimer->stop();
clearAll();
}