From 709c7d04903a09cfd0ab3dbabaddd1321d615632 Mon Sep 17 00:00:00 2001 From: slava Date: Fri, 29 May 2026 21:01:09 +0500 Subject: [PATCH] Streamline `FETCH_TRANSACTIONS` to support per-transaction streaming, handle partial successes, and prevent duplicate retries. Update scripts, task lifecycles, and error reporting with multi-target processing. --- .../dushanbe/GetLastTransactionsScript.cpp | 115 ++++++++++----- android/dushanbe/GetLastTransactionsScript.h | 20 ++- android/ozon/CommonScript.cpp | 14 ++ android/ozon/GetLastTransactionsScript.cpp | 96 +++++++++---- android/ozon/GetLastTransactionsScript.h | 20 ++- android/ozon/ScreenXmlParser.cpp | 22 +++ android/ozon/ScreenXmlParser.h | 5 + services/EventHandler.cpp | 131 ++++++++++-------- services/EventHandler.h | 2 +- 9 files changed, 293 insertions(+), 132 deletions(-) diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index 2a78ce9..8ace818 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -249,15 +249,13 @@ namespace Dushanbe { GetLastTransactionsScript::GetLastTransactionsScript( QString deviceId, QString appCode, - double searchAmount, - QDateTime searchTime, + QList> targets, QString materialId, QObject *parent ) : CommonScript(parent), m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)), - m_searchAmount(searchAmount), - m_searchTime(std::move(searchTime)), + m_targets(std::move(targets)), m_materialId(std::move(materialId)) { } @@ -277,7 +275,8 @@ void GetLastTransactionsScript::doStart() { return; } - const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}}; + const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first; + const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}}; TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); @@ -378,9 +377,6 @@ void GetLastTransactionsScript::doStart() { } } - // Скриншот списка транзакций — отправим при ошибке, удалим при успехе - const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); - // Находим accountId int accountId = -1; const QList accounts = MaterialDAO::getAccounts(device.id, m_appCode); @@ -390,8 +386,62 @@ void GetLastTransactionsScript::doStart() { qWarning() << "[Dushanbe::GetLastTransactions] No account found"; } - qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << m_searchAmount - << "time=" << m_searchTime.toString(Qt::ISODate); + // Ищем каждую цель из списка. Найденные отдаём сразу через transactionParsed; + // ненайденные копим и репортим одной агрегированной ошибкой в конце. + QStringList notFound; + for (int i = 0; i < m_targets.size(); ++i) { + if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; break; } + const double amount = m_targets[i].first; + const QDateTime time = m_targets[i].second; + // Перед каждой целью (кроме первой) возвращаемся к началу Выписки. + if (i > 0) scrollHistoryToTop(device); + if (!searchOne(device, accountId, amount, time)) { + if (m_error == "Interrupted") break; + notFound << QString("amount=%1 time=%2") + .arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate)); + } + } + if (m_error != "Interrupted") { + m_error = notFound.isEmpty() + ? QString() + : QString("Transactions not found (%1/%2): %3") + .arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; ")); + } + + // Возвращаемся на главную + { + Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная")); + if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) { + AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y()); + } else { + AdbUtils::goBack(m_deviceId); + } + QThread::msleep(1000); + } + + emit finishedWithResult(m_error); +} + +void GetLastTransactionsScript::scrollHistoryToTop(const DeviceInfo &device) { + // Свайпы сверху-вниз поднимают список Выписки к началу. + for (int i = 0; i < 4; ++i) { + if (QThread::currentThread()->isInterruptionRequested()) return; + AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, + device.screenHeight / 3, + device.screenWidth / 2, + device.screenHeight * 2 / 3, 300); + QThread::msleep(500); + } + QThread::msleep(800); +} + +bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId, + double searchAmount, const QDateTime &searchTime) { + // Скриншот списка транзакций — отправим при неудаче + const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); + + qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << searchAmount + << "time=" << searchTime.toString(Qt::ISODate); // Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства const QTimeZone tjTz("Asia/Dushanbe"); @@ -412,8 +462,7 @@ void GetLastTransactionsScript::doStart() { for (int scroll = 0; scroll <= maxScrolls; ++scroll) { if (interrupted()) { m_error = "Interrupted"; - emit finishedWithResult(m_error); - return; + return false; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); @@ -602,7 +651,7 @@ void GetLastTransactionsScript::doStart() { << "y=" << items[i].node.y(); } - const double searchAbs = qAbs(m_searchAmount); + const double searchAbs = qAbs(searchAmount); for (const auto &tx : items) { const double absListAmt = qAbs(tx.amount); const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01; @@ -617,9 +666,9 @@ void GetLastTransactionsScript::doStart() { // Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата). // DCWallet транзакции получают дату из groupDates ImageView, которая // неправильно присваивается при скролле — не фильтруем по ней. - if (tx.dateReliable && m_searchTime.isValid() && !tx.date.isEmpty()) { + if (tx.dateReliable && searchTime.isValid() && !tx.date.isEmpty()) { const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy"); - const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date(); + const QDate searchLocalDate = searchTime.toTimeZone(tjTz).date(); if (listDate.isValid() && searchLocalDate.isValid()) { const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate)); if (dayDiff > 3) { @@ -632,9 +681,9 @@ void GetLastTransactionsScript::doStart() { } // Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи) - if (m_searchTime.isValid() && !tx.time.isEmpty()) { + if (searchTime.isValid() && !tx.time.isEmpty()) { const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss"); - const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time(); + const QTime searchLocalTime = searchTime.toTimeZone(tjTz).time(); if (listTime.isValid() && searchLocalTime.isValid()) { const int diffSecs = qAbs(listTime.secsTo(searchLocalTime)); if (diffSecs > 600 && diffSecs < 85800) { // 85800 = 23h50m @@ -665,13 +714,13 @@ void GetLastTransactionsScript::doStart() { const int tapX = txNode.x1() + 80; AdbUtils::makeTap(m_deviceId, tapX, txNode.y()); QThread::msleep(3000); - if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } + if (interrupted()) { m_error = "Interrupted"; return false; } // Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж" OcrTransactionDetail detail; bool detailLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { - if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } + if (interrupted()) { m_error = "Interrupted"; return false; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); // 1) Bottom-sheet формат (3.2.33, см. assets/dushanbe/3.2.33/tx_detail.xml): @@ -800,7 +849,7 @@ void GetLastTransactionsScript::doStart() { << "amount=" << detail.amount << "receiver=" << detail.receiverAccount; // Проверяем время ±10 минут - if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { + if (searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { QDateTime txTime = QDateTime::fromString( detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss"); if (!txTime.isValid()) { @@ -808,7 +857,7 @@ void GetLastTransactionsScript::doStart() { } if (txTime.isValid()) { txTime.setTimeZone(tjTz); - const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC())); + const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(searchTime.toUTC())); if (diffSecs > 600) { qDebug() << "[Dushanbe::GetLastTransactions] Time mismatch: diff=" << diffSecs << "sec (>10min), skipping"; AdbUtils::goBack(m_deviceId); @@ -877,13 +926,12 @@ void GetLastTransactionsScript::doStart() { } QThread::msleep(1000); - emit finishedWithResult(m_error); // m_error пустой — успех - return; + return true; // успех — транзакция найдена и отдана через transactionParsed } // Скроллим вниз — свайп внутри области транзакций if (scroll < maxScrolls) { - if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } + if (interrupted()) { m_error = "Interrupted"; return false; } const int x = device.screenWidth / 2; // Определяем область контента из реальных Y позиций items int contentBottom = device.screenHeight * 2 / 3; @@ -900,25 +948,18 @@ void GetLastTransactionsScript::doStart() { } } - m_error = "Transaction not found: amount=" + QString::number(m_searchAmount, 'f', 2) - + " time=" + m_searchTime.toString(Qt::ISODate); - qWarning() << "[Dushanbe::GetLastTransactions]" << m_error; + // Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем + // ненайденным целям соберёт оркестратор в doStart (он же вернёт на главную). + qWarning() << "[Dushanbe::GetLastTransactions] Transaction not found: amount=" + << QString::number(searchAmount, 'f', 2) + << "time=" << searchTime.toString(Qt::ISODate); // Отправляем скриншот списка транзакций, сделанный до начала поиска m_resultScreenshot = listScreenshot.isEmpty() ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); - // Возвращаемся на главную - Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная")); - if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) { - AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y()); - } else { - AdbUtils::goBack(m_deviceId); - } - QThread::msleep(1000); - - emit finishedWithResult(m_error); + return false; } } // namespace Dushanbe diff --git a/android/dushanbe/GetLastTransactionsScript.h b/android/dushanbe/GetLastTransactionsScript.h index a8ffbfa..12dfa90 100644 --- a/android/dushanbe/GetLastTransactionsScript.h +++ b/android/dushanbe/GetLastTransactionsScript.h @@ -2,6 +2,8 @@ #include "CommonScript.h" #include #include +#include +#include #include "db/DeviceInfo.h" #include "db/BankProfileInfo.h" @@ -14,8 +16,7 @@ public: explicit GetLastTransactionsScript( QString deviceId, QString appCode, - double searchAmount = 0, - QDateTime searchTime = {}, + QList> targets = {}, QString materialId = {}, QObject *parent = nullptr ); @@ -23,7 +24,7 @@ public: ~GetLastTransactionsScript() override; signals: - // Испускается, когда транзакция найдена и распарсена с экрана деталей. + // Испускается на КАЖДУЮ найденную транзакцию из списка целей. // EventHandler использует эти данные напрямую для task_result вместо // повторного чтения из БД. void transactionParsed(const QJsonObject &tx); @@ -32,10 +33,19 @@ protected: void doStart() override; private: + // Поиск одной цели по сумме и времени на экране "Выписка". Возвращает true + // и эмитит transactionParsed, если нашли; false — если нет. Прерывание + // выставляет m_error="Interrupted". + bool searchOne(const DeviceInfo &device, int accountId, + double searchAmount, const QDateTime &searchTime); + + // Возврат к началу списка "Выписка" между поисками целей. + void scrollHistoryToTop(const DeviceInfo &device); + QString m_deviceId; const QString m_appCode; - const double m_searchAmount; // сумма в сомони (0 = все последние) - const QDateTime m_searchTime; // примерное время (UTC) + // Список целей: пары (сумма в сомони со знаком, примерное время UTC). + const QList> m_targets; const QString m_materialId; // UUID задачи (для сохранения дампов) QString m_error; }; diff --git a/android/ozon/CommonScript.cpp b/android/ozon/CommonScript.cpp index bc36917..5c88a7f 100644 --- a/android/ozon/CommonScript.cpp +++ b/android/ozon/CommonScript.cpp @@ -179,6 +179,14 @@ bool CommonScript::runAppAndGoToHomeScreen( continue; } + // Проверяем на онбординг-баннер "Обновили главную" поверх главной + if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) { + qDebug() << "[runAppAndGoToHomeScreen] Onboarding banner detected, tapping 'Пропустить'..."; + AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y()); + QThread::msleep(1500); + continue; + } + // Проверяем, что это экран ввода пин-кода if (!findAndInputPincode) { const QList pincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); @@ -225,6 +233,12 @@ bool CommonScript::tryToCloseAllBannersOnHomePage(const QString &deviceId, const closedAny = true; xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); continue; + } else if (Node onboardingNode = xmlScreenParser.tryToFindOnboardingBanner(); !onboardingNode.isEmpty()) { + // Онбординг "Обновили главную" поверх главной — жмём "Пропустить" + qDebug() << "[tryToCloseAllBannersOnHomePage] Onboarding banner detected, tapping 'Пропустить'..."; + AdbUtils::makeTap(deviceId, onboardingNode.x(), onboardingNode.y()); + QThread::msleep(1500); + closedThis = true; } else if (Node closeBannerOrDialogNode = xmlScreenParser.tryToFindBannerOrDialog(width, height); closeBannerOrDialogNode.x() != -1 && closeBannerOrDialogNode.y() != -1) { AdbUtils::makeTap(deviceId, closeBannerOrDialogNode.x(), closeBannerOrDialogNode.y()); diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index 6ac50c6..f97eb6e 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -82,16 +82,14 @@ GetLastTransactionsScript::GetLastTransactionsScript( QString deviceId, QString appCode, int maxCount, - double searchAmount, - QDateTime searchTime, + QList> targets, QString materialId, QObject *parent ) : CommonScript(parent), m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)), m_maxCount(maxCount), - m_searchAmount(searchAmount), - m_searchTime(std::move(searchTime)), + m_targets(std::move(targets)), m_materialId(std::move(materialId)) { } @@ -110,7 +108,8 @@ void GetLastTransactionsScript::doStart() { return; } - const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}}; + const double firstAmount = m_targets.isEmpty() ? 0.0 : m_targets.first().first; + const TaskDumpMeta dumpMeta{m_materialId, firstAmount, {}, {}}; TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error); if (!navigateToTransactionList(device, app)) { @@ -127,9 +126,31 @@ void GetLastTransactionsScript::doStart() { qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode; } - // Выбор режима: поиск конкретной транзакции или сканирование последних - if (qAbs(m_searchAmount) > 0.01) { - searchTransaction(device, accountId); + // Выбор режима: поиск списка конкретных транзакций или сканирование последних. + if (!m_targets.isEmpty()) { + // Ищем каждую цель из списка. Найденные отдаём сразу через + // transactionParsed; ненайденные копим и репортим одной ошибкой в конце. + QStringList notFound; + for (int i = 0; i < m_targets.size(); ++i) { + if (QThread::currentThread()->isInterruptionRequested()) { + m_error = "Interrupted"; + emit finishedWithResult(m_error); + return; + } + const double amount = m_targets[i].first; + const QDateTime time = m_targets[i].second; + // Перед каждой целью (кроме первой) возвращаемся к началу списка, + // т.к. предыдущий поиск мог проскроллить вниз к другой дате. + if (i > 0) scrollListToTop(device); + if (!searchOne(device, accountId, amount, time)) { + notFound << QString("amount=%1 time=%2") + .arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate)); + } + } + m_error = notFound.isEmpty() + ? QString() + : QString("Transactions not found (%1/%2): %3") + .arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; ")); } else { scanRecentTransactions(device, accountId); } @@ -242,8 +263,9 @@ bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &devi return false; } -void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) { - // Скриншот списка транзакций — отправим при ошибке, удалим при успехе +bool GetLastTransactionsScript::searchOne(const DeviceInfo &device, int accountId, + double searchAmount, const QDateTime &searchTime) { + // Скриншот списка транзакций — отправим при неудаче, удалим при успехе const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); // Время в Ozon показывается в таймзоне устройства, а не в MSK @@ -266,12 +288,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int int navBarTop = detectNavBarTop(); // Целевая дата в таймзоне устройства для поиска заголовка дня в списке - const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz); + const QDateTime searchLocal = searchTime.toTimeZone(deviceTz); const QDate searchDate = searchLocal.date(); qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId - << "amount=" << m_searchAmount - << "time=" << m_searchTime.toString(Qt::ISODate) + << "amount=" << searchAmount + << "time=" << searchTime.toString(Qt::ISODate) << "tz=" << tzName << "date(local)=" << searchDate.toString("dd.MM.yyyy") << "screen=" << device.screenWidth << "x" << device.screenHeight; @@ -348,7 +370,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int for (int scroll = 0; scroll < maxScrolls; ++scroll) { if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; - return; + return false; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); @@ -369,12 +391,12 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int } if (!dateFound) { - m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy"); - qWarning() << "[FetchTransactions]" << m_error; + qWarning() << "[FetchTransactions] Date not found in transaction list: " + << searchDate.toString("dd.MM.yyyy"); m_resultScreenshot = listScreenshot.isEmpty() ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); - return; + return false; } // Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране. @@ -436,7 +458,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int for (int attempt = 0; attempt < 15; ++attempt) { if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; - return; + return false; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); @@ -503,7 +525,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int for (int bi = 0; bi < txButtons.size(); ++bi) { if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; - return; + return false; } // Пропускаем кнопки вне Y-диапазона нужной даты. @@ -517,9 +539,9 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text); qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY - << "parsed amount=" << parsed.amount << "search=" << m_searchAmount + << "parsed amount=" << parsed.amount << "search=" << searchAmount << "text=" << txButtons[bi].text.left(60); - if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue; + if (qAbs(parsed.amount - searchAmount) > 0.01) continue; const Node &visibleBtn = txButtons[bi]; // Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под @@ -543,7 +565,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int for (int da = 0; da < 10; ++da) { if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; - return; + return false; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); @@ -597,8 +619,8 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int } // Проверяем время (±10 минут) - if (txTime.isValid() && m_searchTime.isValid()) { - const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC())); + if (txTime.isValid() && searchTime.isValid()) { + const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(searchTime.toUTC())); if (diffSecs > 600) { qDebug() << "[FetchTransactions] Time mismatch:" << dtStr << "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm") @@ -680,7 +702,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int QThread::msleep(1000); AdbUtils::goBack(m_deviceId); QThread::msleep(1000); - return; // успех + return true; // успех } // Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ @@ -751,14 +773,30 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int } } - m_error = "Transaction not found: device=" + m_deviceId - + " amount=" + QString::number(m_searchAmount, 'f', 2) - + " date=" + searchDate.toString("dd.MM.yyyy"); - qWarning() << "[FetchTransactions]" << m_error; + // Эту цель не нашли. m_error не трогаем — агрегированную ошибку по всем + // ненайденным целям соберёт оркестратор в doStart. + qWarning() << "[FetchTransactions] Transaction not found: device=" << m_deviceId + << "amount=" << QString::number(searchAmount, 'f', 2) + << "date=" << searchDate.toString("dd.MM.yyyy"); // Отправляем скриншот списка транзакций, сделанный до начала поиска m_resultScreenshot = listScreenshot.isEmpty() ? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot; if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot); + return false; +} + +void GetLastTransactionsScript::scrollListToTop(const DeviceInfo &device) { + // Несколько быстрых свайпов вверх, чтобы вернуться к началу списка операций. + for (int i = 0; i < 5; ++i) { + if (QThread::currentThread()->isInterruptionRequested()) return; + AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, + device.screenHeight / 3, + device.screenWidth / 2, + device.screenHeight * 2 / 3, 200); + QThread::msleep(400); + } + QThread::msleep(800); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); } void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) { diff --git a/android/ozon/GetLastTransactionsScript.h b/android/ozon/GetLastTransactionsScript.h index c7557af..6c3a2ef 100644 --- a/android/ozon/GetLastTransactionsScript.h +++ b/android/ozon/GetLastTransactionsScript.h @@ -2,6 +2,8 @@ #include "CommonScript.h" #include #include +#include +#include #include "db/DeviceInfo.h" #include "db/BankProfileInfo.h" @@ -11,6 +13,7 @@ class GetLastTransactionsScript final : public CommonScript { Q_OBJECT signals: + // Испускается на КАЖДУЮ найденную транзакцию из списка целей. void transactionParsed(const QJsonObject &tx); public: @@ -18,8 +21,7 @@ public: QString deviceId, QString appCode, int maxCount = 0, - double searchAmount = 0, - QDateTime searchTime = {}, + QList> targets = {}, QString materialId = {}, QObject *parent = nullptr ); @@ -36,14 +38,20 @@ private: // Сканирование последних транзакций (для UI: 24ч или maxCount) void scanRecentTransactions(const DeviceInfo &device, int accountId); - // Поиск конкретной транзакции по сумме и времени (для задач FETCH_TRANSACTIONS) - void searchTransaction(const DeviceInfo &device, int accountId); + // Поиск одной конкретной транзакции по сумме и времени (FETCH_TRANSACTIONS). + // Возвращает true и эмитит transactionParsed, если нашли; false — если нет. + bool searchOne(const DeviceInfo &device, int accountId, + double searchAmount, const QDateTime &searchTime); + + // Быстрый возврат к началу списка операций (между поисками целей) + void scrollListToTop(const DeviceInfo &device); QString m_deviceId; const QString m_appCode; const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних - const double m_searchAmount; // сумма в рублях (0 = не искать конкретную) - const QDateTime m_searchTime; // примерное время (UTC) + // Список целей поиска: пары (сумма в рублях со знаком, примерное время UTC). + // Пустой список → режим сканирования последних (scanRecentTransactions). + const QList> m_targets; const QString m_materialId; // UUID задачи (для сохранения дампов) QString m_error; }; diff --git a/android/ozon/ScreenXmlParser.cpp b/android/ozon/ScreenXmlParser.cpp index 0d1e12b..e14d480 100644 --- a/android/ozon/ScreenXmlParser.cpp +++ b/android/ozon/ScreenXmlParser.cpp @@ -323,6 +323,28 @@ Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int return {}; } +Node ScreenXmlParser::tryToFindOnboardingBanner() { + // Онбординг "Обновили главную" рендерится во ViewPager с этим resource-id. + // Кнопка "Пропустить" — TextView (text_atom) внутри кликабельного контейнера, + // занимающего всю ширину верхней полоски. Сам TextView некликабельный, но его + // bounds совпадают с кликабельным родителем, поэтому тап по его центру попадает. + bool hasOnboarding = false; + for (const Node &node : m_nodes) { + if (node.resourceId == "ru.ozon.fintech.finance:id/onboarding_viewpager") { + hasOnboarding = true; + break; + } + } + if (!hasOnboarding) return {}; + + for (const Node &node : m_nodes) { + if (node.text.trimmed() == QString::fromUtf8("Пропустить")) { + return node; + } + } + return {}; +} + bool ScreenXmlParser::isProfileScreen() { // Страница профиля Ozon: есть заголовок "Аккаунт" и кнопка "Выйти" bool hasTitle = false; diff --git a/android/ozon/ScreenXmlParser.h b/android/ozon/ScreenXmlParser.h index b6f0089..8a73480 100644 --- a/android/ozon/ScreenXmlParser.h +++ b/android/ozon/ScreenXmlParser.h @@ -50,6 +50,11 @@ public: Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight); + // Онбординг-баннер "Обновили главную" (resource-id onboarding_viewpager), + // появляется поверх главного экрана Ozon. Возвращает кнопку "Пропустить" + // для тапа, либо пустой Node если баннер не виден. + Node tryToFindOnboardingBanner(); + // Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге запроса разрешения Android // (com.google.android.permissioncontroller). Возвращает пустой Node если диалог не виден. Node tryToFindSystemPermissionDenyButton(); diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 5ab15d0..a33d567 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -32,7 +32,7 @@ #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 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 = {}); static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, int eventId = -1, bool toApiBase = false); @@ -175,7 +175,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { } static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, - const QJsonValue &data, int eventId) { + const QJsonValue &data, int eventId, bool updateStatus) { QJsonObject obj; obj["type"] = type; obj["material_id"] = materialId; // UUID string @@ -190,8 +190,11 @@ static void sendTaskResult(const QString &type, const QString &materialId, const 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) { + [eventId, updateStatus](int result) { + // updateStatus=false для потоковых per-tx результатов FETCH_TRANSACTIONS: + // статус события финализируется один раз в updateEventThread, а не на + // каждой отдельной отправке найденной транзакции. + if (eventId != -1 && updateStatus) { if (result == 0) { EventDAO::markSentToServer(eventId); } else { @@ -288,9 +291,11 @@ bool EventHandler::tryRetry(const QString &key, const EventInfo &event, const QS .arg(QString::number(attempt), QString::number(kMaxRetries), event.deviceId, event.externalId, reason)); - // Чистим артефакты прошлой попытки + // Чистим артефакты прошлой попытки. Ретрай запускается только когда ещё + // ничего не отправлено (см. guard в updateEventThread), так что сбрасываем + // счётчик отправленных в 0. m_screenshots.remove(key); - m_parsedTxs.remove(key); + m_parsedTxCount.remove(key); // Снимаем текущий поток/таймер (в timeout-ветке они уже сняты — take() вернёт null) if (const auto tm = m_timers.take(key)) { @@ -317,8 +322,12 @@ bool EventHandler::tryRetry(const QString &key, const EventInfo &event, const QS void EventHandler::updateEventThread(const QString &key, const EventInfo &event, const QString &result) { // Поиск транзакции: при ошибке скрипта перезапускаем (ещё до 2 раз) вместо - // немедленной отправки ошибки на сервер. + // немедленной отправки ошибки на сервер. Но только если НИ ОДНОЙ транзакции + // ещё не отправлено: при частичном успехе (часть списка найдена и уже + // улетела на сервер) ретрай бессмыслен — переотправит дубли, поэтому + // ненайденные просто репортим ошибкой ниже. if (!result.isEmpty() && event.type == EventType::FetchTransactions + && m_parsedTxCount.value(key, 0) == 0 && tryRetry(key, event, result)) { return; } @@ -413,24 +422,13 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, 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; + // 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(); @@ -467,13 +465,17 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, obj["status"] = "completed"; taskData = obj; } - sendTaskResult( - eventTypeToString(event.type), - event.externalId, - event.bankName, - taskData, - event.id - ); + // FETCH_TRANSACTIONS уже отправил данные потоково (per-tx) — + // финальный агрегирующий результат не нужен. + if (event.type != EventType::FetchTransactions) { + sendTaskResult( + eventTypeToString(event.type), + event.externalId, + event.bankName, + taskData, + event.id + ); + } } if (newStatus == EventStatus::Complete) { @@ -560,6 +562,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, } m_threads.remove(key); m_retryCount.remove(event.id); + m_parsedTxCount.remove(key); // Убиваем банковское приложение на устройстве после завершения задачи { @@ -628,36 +631,54 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn } if (event.type == EventType::FetchTransactions) { - // Парсим amount и created_at из body задачи + // body теперь — массив транзакций для поиска: [{amount, created_at}, ...]. + // Для обратной совместимости поддерживаем и старый формат — одиночный + // объект {amount, created_at} (оборачиваем в массив из одного элемента). 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()); + QJsonArray bodyArr = taskJson["body"].toArray(); + if (bodyArr.isEmpty() && taskJson["body"].isObject()) + bodyArr.append(taskJson["body"]); + + QList> 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; + QJsonArray arr; + arr.append(tx); + sendTaskResult(eventTypeToString(EventType::FetchTransactions), + event.externalId, event.bankName, arr, event.id, /*updateStatus=*/false); + }; if (appCode == "ozon") { - auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId); + auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, targets, account.materialId); setup(w); connectScreenshot(w); - connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this, - [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }); + 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, searchAmount, searchTime, account.materialId); + auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, targets, account.materialId); setup(w); connectScreenshotDushanbe(w); - connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, - [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }); + connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, streamParsedTx); } else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; } @@ -712,8 +733,10 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn } m_threads.remove(key); - // Поиск транзакции: таймаут тоже ретраим (ещё до 2 раз) + // Поиск транзакции: таймаут тоже ретраим (ещё до 2 раз), но только если + // ещё ни одной транзакции не отправлено (иначе ретрай переотправит дубли). if (event.type == EventType::FetchTransactions + && m_parsedTxCount.value(key, 0) == 0 && tryRetry(key, event, "Script timeout (5 min)")) { return; } @@ -812,7 +835,7 @@ void EventHandler::clearAll() { } m_timers.clear(); m_screenshots.clear(); - m_parsedTxs.clear(); + m_parsedTxCount.clear(); } void EventHandler::stop() { diff --git a/services/EventHandler.h b/services/EventHandler.h index 580419a..1521571 100644 --- a/services/EventHandler.h +++ b/services/EventHandler.h @@ -34,7 +34,7 @@ private: QMap m_threads; QMap m_timers; QMap m_screenshots; // deviceId → скриншот от скрипта - QMap m_parsedTxs; // key → распарсенная транзакция для FETCH_TRANSACTIONS + QMap m_parsedTxCount; // key → сколько транзакций уже отправлено на сервер (FETCH_TRANSACTIONS, per-tx streaming) QMap m_retryCount; // event.id → число уже сделанных ретраев (только FETCH_TRANSACTIONS) QTimer *m_pollTimer = nullptr; NetworkService *m_network = nullptr;