Refine transaction parsing and handling logic:
- Add fast path for avoiding unnecessary app restarts if already on the home or PIN screen. - Improve PIN code and home screen detection with fallback mechanisms. - Implement URL and error sanitization for network messages to hide sensitive data from logs. - Add embedded transaction parsing from content description with adjusted tap positioning logic. - Refactor transaction fetching to use direct signal-based parsed data instead of database queries.
This commit is contained in:
parent
4407f999c7
commit
33c8420910
@ -42,6 +42,11 @@ Node CommonScript::swipeToButton(
|
||||
}
|
||||
|
||||
bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode) {
|
||||
if (pinCode.isEmpty()) {
|
||||
qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!";
|
||||
return false;
|
||||
}
|
||||
qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits";
|
||||
// DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9")
|
||||
for (const QChar &digit : pinCode) {
|
||||
Node button = xmlScreenParser.findNodeByContentDesc(QString(digit));
|
||||
@ -64,9 +69,32 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
||||
) {
|
||||
AdbUtils::unlockScreen(deviceId, width, height);
|
||||
|
||||
// Быстрый path: если приложение уже запущено — не перезапускаем сразу,
|
||||
// а смотрим текущий экран. Если это Home — готово. Если PIN — вводим
|
||||
// и ждём Home. Только если не смогли — убиваем и рестартим.
|
||||
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
||||
qDebug() << "[Dushanbe] Process already running, pid:" << pid;
|
||||
AdbUtils::getScreenDumpAsXml(deviceId, 1000);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
if (xmlScreenParser.isHomeScreen()) {
|
||||
qDebug() << "[Dushanbe] Fast path: already on Home screen";
|
||||
return true;
|
||||
}
|
||||
if (xmlScreenParser.isPinCodeScreen()) {
|
||||
qDebug() << "[Dushanbe] Fast path: PIN screen, entering PIN";
|
||||
if (inputPinCode(deviceId, pinCode)) {
|
||||
QThread::msleep(3000);
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
xmlScreenParser.parseAndSaveXml(
|
||||
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
if (xmlScreenParser.isHomeScreen()) {
|
||||
qDebug() << "[Dushanbe] Fast path: Home reached after PIN";
|
||||
return true;
|
||||
}
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
qDebug() << "[Dushanbe] Fast path failed, restarting app";
|
||||
AdbUtils::tryToKillApplication(deviceId, packageName);
|
||||
}
|
||||
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
||||
@ -82,23 +110,39 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
||||
while (true) {
|
||||
counter++;
|
||||
|
||||
if (counter > 10) {
|
||||
if (counter > 20) {
|
||||
qWarning() << "[Dushanbe] runAppAndGoToHomeScreen: too many attempts, giving up";
|
||||
AdbUtils::takeScreenshot(deviceId, "too_many_attempts");
|
||||
return false;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
|
||||
if (xmlScreenParser.isHomeScreen()) {
|
||||
qDebug() << "[Dushanbe] Home reached on attempt" << counter;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
|
||||
inputPinCode(deviceId, pinCode);
|
||||
qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN";
|
||||
if (!inputPinCode(deviceId, pinCode)) {
|
||||
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter;
|
||||
}
|
||||
findAndInputPincode = true;
|
||||
QThread::msleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Диагностика: что реально на экране?
|
||||
QStringList sample;
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (n.contentDesc.isEmpty() && n.text.isEmpty()) continue;
|
||||
QString s = !n.contentDesc.isEmpty() ? n.contentDesc : n.text;
|
||||
s.replace('\n', ' ');
|
||||
if (s.size() > 60) s = s.left(60) + "...";
|
||||
sample.append(s);
|
||||
if (sample.size() >= 8) break;
|
||||
}
|
||||
qDebug() << "[Dushanbe] attempt" << counter << "not home, not PIN. Sample:" << sample;
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#include "GetLastTransactionsScript.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QRegularExpression>
|
||||
#include <QThread>
|
||||
#include <QTimeZone>
|
||||
@ -11,7 +12,6 @@
|
||||
#include "dao/MaterialDAO.h"
|
||||
#include "dao/BankProfileDAO.h"
|
||||
#include "dao/DeviceDAO.h"
|
||||
#include "dao/TransactionDAO.h"
|
||||
#include "AppLogger.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
@ -244,6 +244,9 @@ GetLastTransactionsScript::~GetLastTransactionsScript() = default;
|
||||
void GetLastTransactionsScript::doStart() {
|
||||
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] device.id=" << device.id << "adbSerial=" << device.adbSerial
|
||||
<< "appCode=" << m_appCode << "app.id=" << app.id
|
||||
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
|
||||
|
||||
if (device.id.isEmpty() || app.id == -1) {
|
||||
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||
@ -367,15 +370,22 @@ void GetLastTransactionsScript::doStart() {
|
||||
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
|
||||
// 1. Собираем список дат-групп из родительского ImageView "Обновлено: ...\n05.04.2026\n..."
|
||||
// Поддерживаем "DD.MM.YYYY", "Сегодня", "Вчера"
|
||||
// 1. Собираем список дат-групп + извлекаем "встроенную" верхнюю транзакцию,
|
||||
// которая впечатана в content-desc родительского ImageView "Обновлено: ..."
|
||||
// вместо отдельного android.view.View (самая свежая запись в Выписке).
|
||||
const QDate todayLocal = QDateTime::currentDateTimeUtc().toTimeZone(tjTz).date();
|
||||
QStringList groupDates;
|
||||
static const QRegularExpression reDate(R"(^\d{2}\.\d{2}\.\d{4}$)");
|
||||
static const QRegularExpression reEmbTime(R"(^\s*(\d{2}:\d{2}:\d{2})\s*$)");
|
||||
Node updatedImgNode;
|
||||
VypiskaItem embedded;
|
||||
bool hasEmbedded = false;
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (n.className != "android.widget.ImageView") continue;
|
||||
if (!n.contentDesc.startsWith(QString::fromUtf8("Обновлено"))) continue;
|
||||
for (const QString &line : n.contentDesc.split('\n')) {
|
||||
updatedImgNode = n;
|
||||
const QStringList descLines = n.contentDesc.split('\n');
|
||||
for (const QString &line : descLines) {
|
||||
const QString t = line.trimmed();
|
||||
if (reDate.match(t).hasMatch()) {
|
||||
groupDates.append(t);
|
||||
@ -385,6 +395,19 @@ void GetLastTransactionsScript::doStart() {
|
||||
groupDates.append(todayLocal.addDays(-1).toString("dd.MM.yyyy"));
|
||||
}
|
||||
}
|
||||
// Паттерн встроенной: ...\ncard\ncard\namount\nописание\n HH:MM:SS\n...
|
||||
for (int i = 4; i < descLines.size(); ++i) {
|
||||
const auto tm = reEmbTime.match(descLines[i]);
|
||||
if (!tm.hasMatch()) continue;
|
||||
bool ok = false;
|
||||
const double amt = descLines[i - 2].trimmed().toDouble(&ok);
|
||||
if (!ok) continue;
|
||||
embedded.amount = amt;
|
||||
embedded.time = tm.captured(1);
|
||||
if (!groupDates.isEmpty()) embedded.date = groupDates.first();
|
||||
hasEmbedded = true;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@ -408,6 +431,55 @@ void GetLastTransactionsScript::doStart() {
|
||||
items.append(it);
|
||||
}
|
||||
|
||||
// 2a. Если нашли встроенную транзакцию — создаём синтетический Node с
|
||||
// координатами тапа над первой "реальной" View-записью и добавляем
|
||||
// как первый элемент списка.
|
||||
if (hasEmbedded && !updatedImgNode.isEmpty()) {
|
||||
int firstViewY1 = -1;
|
||||
int firstViewRowH = 0;
|
||||
for (const VypiskaItem &v : items) {
|
||||
if (firstViewY1 < 0 || v.node.y1() < firstViewY1) {
|
||||
firstViewY1 = v.node.y1();
|
||||
firstViewRowH = v.node.height();
|
||||
}
|
||||
}
|
||||
const int topY = updatedImgNode.y1();
|
||||
// Если первая реальная View-запись уже находится у верха ImageView
|
||||
// (значит скролл поднял контент и встроенная запись ушла за экран),
|
||||
// пропускаем embedded — её визуально нет.
|
||||
const int minEmbeddedSpace = 120;
|
||||
const bool embeddedVisible = firstViewY1 < 0 || (firstViewY1 - topY) >= minEmbeddedSpace;
|
||||
if (embeddedVisible) {
|
||||
int tapY;
|
||||
if (firstViewY1 > 0 && firstViewRowH > 0) {
|
||||
// Между встроенной записью и первой "реальной" View лежит
|
||||
// заголовок даты (~0.4 * rowH). Ставим тап по центру
|
||||
// строки: firstViewY1 - headerH - rowH/2 ≈ firstViewY1 - rowH * 0.9.
|
||||
// Дополнительно ограничиваем диапазоном [topY + rowH/2 .. firstViewY1 - 40].
|
||||
tapY = firstViewY1 - (firstViewRowH * 9 / 10);
|
||||
const int minY = topY + firstViewRowH / 2;
|
||||
const int maxY = firstViewY1 - 40;
|
||||
if (tapY < minY) tapY = minY;
|
||||
if (tapY > maxY) tapY = maxY;
|
||||
} else {
|
||||
tapY = topY + 200;
|
||||
}
|
||||
Node synth;
|
||||
synth.setBounds(updatedImgNode.x1(), tapY - 10, updatedImgNode.x2(), tapY + 10);
|
||||
synth.className = "android.view.View";
|
||||
synth.contentDesc = updatedImgNode.contentDesc;
|
||||
synth.clickable = true;
|
||||
embedded.node = synth;
|
||||
items.prepend(embedded);
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx added:"
|
||||
<< "amount=" << embedded.amount << "time=" << embedded.time
|
||||
<< "date=" << embedded.date << "tapY=" << tapY;
|
||||
} else {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Embedded tx skipped "
|
||||
"(scrolled off): firstViewY1=" << firstViewY1 << "topY=" << topY;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Сортируем по Y и группируем по разрывам (> 50px → новая группа-день)
|
||||
std::sort(items.begin(), items.end(), [](const VypiskaItem &a, const VypiskaItem &b) {
|
||||
return a.node.y1() < b.node.y1();
|
||||
@ -423,10 +495,16 @@ void GetLastTransactionsScript::doStart() {
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Vypiska items:" << items.size()
|
||||
<< "groups:" << groupDates;
|
||||
|
||||
const double searchAbs = qAbs(m_searchAmount);
|
||||
for (const auto &tx : items) {
|
||||
if (qAbs(tx.amount - m_searchAmount) > 0.01) continue;
|
||||
const double absListAmt = qAbs(tx.amount);
|
||||
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
|
||||
const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01;
|
||||
if (!amountDirect && !amountX100) continue;
|
||||
const bool matchedX100 = amountX100 && !amountDirect;
|
||||
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Amount match:" << tx.amount
|
||||
<< (matchedX100 ? "(scaled x100)" : "(direct)")
|
||||
<< "date:" << tx.date << "time:" << tx.time;
|
||||
|
||||
// Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи)
|
||||
@ -488,9 +566,13 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
|
||||
if (!detailLoaded) {
|
||||
qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded";
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1000);
|
||||
qWarning() << "[Dushanbe::GetLastTransactions] Detail screen not loaded "
|
||||
"(tap missed or detail format unknown); staying on Выписка";
|
||||
// Не вызываем goBack — после промаха тапа мы всё ещё на Выписке.
|
||||
// goBack тут закрывает Выписку и ломает дальнейший скролл.
|
||||
// Если же тап сработал и открылся НЕ-распознанный экран — следующая
|
||||
// итерация parse обнаружит это, и мы обработаем штатно.
|
||||
QThread::msleep(500);
|
||||
continue;
|
||||
}
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Detail parsed:"
|
||||
@ -524,30 +606,41 @@ void GetLastTransactionsScript::doStart() {
|
||||
<< "receiver=" << detail.receiverAccount
|
||||
<< "status=" << detail.status;
|
||||
|
||||
if (accountId != -1) {
|
||||
TransactionInfo saveTx;
|
||||
saveTx.accountId = accountId;
|
||||
saveTx.amount = detail.amount;
|
||||
saveTx.fee = detail.fee;
|
||||
saveTx.bankName = m_appCode;
|
||||
saveTx.bankTime = detail.date + " " + detail.time;
|
||||
saveTx.bankTrExternalId = "dushanbe_" + detail.date + "_" + detail.time;
|
||||
saveTx.description = detail.provider;
|
||||
saveTx.phone = "";
|
||||
saveTx.name = detail.receiverAccount;
|
||||
saveTx.status = detail.status.contains(QString::fromUtf8("Успешн"))
|
||||
? TransactionStatus::Complete : TransactionStatus::Unknown;
|
||||
saveTx.type = TransactionType::Phone;
|
||||
saveTx.timestamp = QDateTime::currentDateTimeUtc();
|
||||
// Формируем task_result напрямую из распарсенных данных и отдаём
|
||||
// EventHandler через сигнал — без записи/чтения БД.
|
||||
{
|
||||
const double finalAmount = matchedX100 ? detail.amount * 100.0 : detail.amount;
|
||||
const double finalFee = matchedX100 ? detail.fee * 100.0 : detail.fee;
|
||||
|
||||
QDateTime txTime = QDateTime::fromString(
|
||||
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||
if (txTime.isValid()) {
|
||||
txTime.setTimeZone(tjTz);
|
||||
saveTx.completeTime = txTime;
|
||||
if (!txTime.isValid()) {
|
||||
txTime = QDateTime::fromString(detail.date + " " + detail.time, "dd.MM.yyyy HH:mm");
|
||||
}
|
||||
if (txTime.isValid()) txTime.setTimeZone(tjTz);
|
||||
|
||||
TransactionDAO::insertTransactionIfNotExists(saveTx);
|
||||
QJsonObject txJson;
|
||||
txJson["bank_transaction_id"] = detail.operationId.isEmpty()
|
||||
? QString("dushanbe_") + detail.date + "_" + detail.time
|
||||
: detail.operationId;
|
||||
// Списание хранится с отрицательным знаком, пополнение — положительным.
|
||||
// detail.amount всегда положителен (OCR), знак берём из списка Выписки.
|
||||
const double signedAmount = (tx.amount < 0 ? -1.0 : 1.0) * qAbs(finalAmount);
|
||||
txJson["amount"] = static_cast<int>(signedAmount * 100);
|
||||
txJson["currency"] = 972; // TJS
|
||||
txJson["transaction_type"] = "bank";
|
||||
txJson["bank_participator_id"] = detail.receiverAccount;
|
||||
txJson["status"] = detail.status.contains(QString::fromUtf8("Успешн"))
|
||||
? QStringLiteral("completed") : QStringLiteral("progress");
|
||||
QJsonObject extraObj;
|
||||
extraObj["fee"] = static_cast<int>(finalFee * 100);
|
||||
txJson["extra"] = extraObj;
|
||||
txJson["created_at"] = txTime.isValid()
|
||||
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
|
||||
|
||||
qDebug() << "[Dushanbe::GetLastTransactions] Emitting parsed tx:"
|
||||
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
|
||||
emit transactionParsed(txJson);
|
||||
}
|
||||
|
||||
// Назад к истории
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "CommonScript.h"
|
||||
#include <QDateTime>
|
||||
#include <QJsonObject>
|
||||
#include "db/DeviceInfo.h"
|
||||
#include "db/BankProfileInfo.h"
|
||||
|
||||
@ -20,6 +21,12 @@ public:
|
||||
|
||||
~GetLastTransactionsScript() override;
|
||||
|
||||
signals:
|
||||
// Испускается, когда транзакция найдена и распарсена с экрана деталей.
|
||||
// EventHandler использует эти данные напрямую для task_result вместо
|
||||
// повторного чтения из БД.
|
||||
void transactionParsed(const QJsonObject &tx);
|
||||
|
||||
protected:
|
||||
void doStart() override;
|
||||
|
||||
|
||||
@ -24,8 +24,14 @@ LoginAndCheckAccountsScript::LoginAndCheckAccountsScript(
|
||||
LoginAndCheckAccountsScript::~LoginAndCheckAccountsScript() = default;
|
||||
|
||||
void LoginAndCheckAccountsScript::doStart() {
|
||||
// Канонический ключ материала (device.id в БД) — для лукапов BankProfile;
|
||||
// m_deviceId же перезаписываем ADB-серийником для команд.
|
||||
const QString canonicalDeviceId = m_deviceId;
|
||||
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId); if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId); if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
|
||||
qDebug() << "[Dushanbe::Login] device.id=" << device.id << "adbSerial=" << device.adbSerial
|
||||
<< "appCode=" << m_appCode << "app.id=" << app.id
|
||||
<< "pinCode.length=" << app.pinCode.length() << "package=" << app.package;
|
||||
|
||||
if (device.id.isEmpty() || app.id == -1) {
|
||||
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
|
||||
@ -53,7 +59,9 @@ void LoginAndCheckAccountsScript::doStart() {
|
||||
return;
|
||||
}
|
||||
|
||||
GetProfileInfoScript profileScript(m_deviceId, m_appCode);
|
||||
// В GetProfileInfoScript отдаём КАНОНИЧЕСКИЙ ключ материала, а не ADB-серийник,
|
||||
// чтобы BankProfileDAO::getApplication нашла правильную запись с bank_profile_id.
|
||||
GetProfileInfoScript profileScript(canonicalDeviceId, m_appCode);
|
||||
profileScript.start();
|
||||
|
||||
emit finishedWithResult(m_error);
|
||||
|
||||
@ -129,12 +129,29 @@ Node ScreenXmlParser::findFirstEditText() {
|
||||
}
|
||||
|
||||
bool ScreenXmlParser::isPinCodeScreen() {
|
||||
// 1. Прямое совпадение по тексту заголовка
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа"))) {
|
||||
if (node.contentDesc.contains(QString::fromUtf8("Введите код доступа")) ||
|
||||
node.contentDesc.contains(QString::fromUtf8("Введите ПИН")) ||
|
||||
node.contentDesc.contains(QString::fromUtf8("код доступа")) ||
|
||||
node.text.contains(QString::fromUtf8("код доступа"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
// 2. Fallback: если на экране есть полная цифровая клавиатура 0-9
|
||||
// (content-desc каждой кнопки = одна цифра) — это PIN-экран.
|
||||
int digitButtons = 0;
|
||||
bool seen[10] = {false};
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc.size() != 1) continue;
|
||||
const QChar c = node.contentDesc.at(0);
|
||||
if (!c.isDigit()) continue;
|
||||
const int d = c.digitValue();
|
||||
if (d < 0 || d > 9 || seen[d]) continue;
|
||||
seen[d] = true;
|
||||
++digitButtons;
|
||||
}
|
||||
return digitButtons >= 10;
|
||||
}
|
||||
|
||||
Node ScreenXmlParser::findPinCodeEditText() {
|
||||
@ -148,15 +165,19 @@ Node ScreenXmlParser::findPinCodeEditText() {
|
||||
|
||||
bool ScreenXmlParser::isHomeScreen() {
|
||||
bool hasGlavnaya = false;
|
||||
bool hasTJS = false;
|
||||
bool hasBottomNav = false;
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc == QString::fromUtf8("Главная")) {
|
||||
if (node.contentDesc.contains(QString::fromUtf8("Главная"))) {
|
||||
hasGlavnaya = true;
|
||||
}
|
||||
if (node.contentDesc.contains("TJS")) {
|
||||
hasTJS = true;
|
||||
// Нижняя навигация — любая из соседних вкладок указывает, что мы на
|
||||
// основной странице приложения, а не на splash/PIN/диалоге.
|
||||
if (node.contentDesc == QString::fromUtf8("Переводы") ||
|
||||
node.contentDesc == QString::fromUtf8("Сервисы") ||
|
||||
node.contentDesc == QString::fromUtf8("История")) {
|
||||
hasBottomNav = true;
|
||||
}
|
||||
if (hasGlavnaya && hasTJS) {
|
||||
if (hasGlavnaya && hasBottomNav) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,10 +62,37 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
||||
// Разблокируем экран перед запуском
|
||||
AdbUtils::unlockScreen(deviceId, width, height);
|
||||
|
||||
// Запустить устройство
|
||||
// Быстрый path: если приложение уже запущено — не перезапускаем сразу,
|
||||
// а смотрим текущий экран. Home → готово. PIN → вводим и ждём Home.
|
||||
// Только если не смогли — убиваем и рестартим.
|
||||
if (const QString pid = AdbUtils::tryToGetRunningAppPid(deviceId, packageName); !pid.isEmpty()) {
|
||||
qDebug() << "Process already running, pid: " << pid;
|
||||
AdbUtils::getScreenDumpAsXml(deviceId, 1000);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
if (xmlScreenParser.isHomeScreen()) {
|
||||
qDebug() << "[Ozon] Fast path: already on Home screen";
|
||||
return true;
|
||||
}
|
||||
{
|
||||
const QList<Node> fastPincode = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН");
|
||||
if (fastPincode.size() == pinCode.length() && fastPincode.size() >= 4) {
|
||||
qDebug() << "[Ozon] Fast path: PIN screen, entering PIN";
|
||||
if (Node wake = xmlScreenParser.findTextNode("Введите ПИН"); !wake.isEmpty()) {
|
||||
AdbUtils::makeTap(deviceId, wake.x(), wake.y());
|
||||
}
|
||||
inputPinCode(deviceId, fastPincode);
|
||||
QThread::msleep(3000);
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
xmlScreenParser.parseAndSaveXml(
|
||||
AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
if (xmlScreenParser.isHomeScreen()) {
|
||||
qDebug() << "[Ozon] Fast path: Home reached after PIN";
|
||||
return true;
|
||||
}
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
qDebug() << "[Ozon] Fast path failed, restarting app";
|
||||
AdbUtils::tryToKillApplication(deviceId, packageName);
|
||||
}
|
||||
if (!AdbUtils::tryToStartApplication(deviceId, packageName)) {
|
||||
|
||||
@ -281,121 +281,38 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
|
||||
QJsonValue taskData;
|
||||
if (event.type == EventType::FetchProfile) {
|
||||
if (event.bankName == "ozon") {
|
||||
EventDAO::updateEventAmount(event.id, account.amount);
|
||||
// FETCH_PROFILE всегда один объект. Amount читаем актуальный:
|
||||
// - для Ozon скрипт обновляет MaterialDAO → берём из account;
|
||||
// - для остальных (Dushanbe и т.п.) скрипт обновляет BankProfileDAO
|
||||
// через updateAmount → берём из bank_profiles.
|
||||
double freshAmount = account.amount;
|
||||
if (event.bankName != "ozon") {
|
||||
const BankProfileInfo freshApp = BankProfileDAO::getApplication(
|
||||
account.deviceId, event.bankName);
|
||||
if (freshApp.id != -1) freshAmount = freshApp.amount;
|
||||
}
|
||||
EventDAO::updateEventAmount(event.id, freshAmount);
|
||||
QJsonObject obj;
|
||||
obj["bank_account_id"] = account.materialId;
|
||||
obj["amount"] = static_cast<int>(account.amount * 100);
|
||||
obj["amount"] = static_cast<int>(freshAmount * 100);
|
||||
obj["currency"] = currencyCode;
|
||||
taskData = obj;
|
||||
} else {
|
||||
// Отправляем суммы всех материалов устройства для этого банка
|
||||
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(event.deviceId, event.bankName);
|
||||
if (!allAccounts.isEmpty()) {
|
||||
QJsonArray accountsArr;
|
||||
for (const auto &acc : allAccounts) {
|
||||
const int accCurrency = currencyCodes.value(acc.currency.toUpper(), 643);
|
||||
QJsonObject accObj;
|
||||
accObj["bank_account_id"] = acc.materialId;
|
||||
accObj["amount"] = static_cast<int>(acc.amount * 100);
|
||||
accObj["currency"] = accCurrency;
|
||||
accountsArr.append(accObj);
|
||||
}
|
||||
taskData = accountsArr;
|
||||
} else {
|
||||
EventDAO::updateEventAmount(event.id, account.amount);
|
||||
QJsonObject obj;
|
||||
obj["bank_account_id"] = account.materialId;
|
||||
obj["amount"] = static_cast<int>(account.amount * 100);
|
||||
obj["currency"] = currencyCode;
|
||||
taskData = obj;
|
||||
}
|
||||
}
|
||||
} else if (event.type == EventType::FetchTransactions) {
|
||||
QList<TransactionInfo> txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 24);
|
||||
// Оставляем только совпадения по сумме
|
||||
txList.erase(std::remove_if(txList.begin(), txList.end(),
|
||||
[&](const TransactionInfo &tx) {
|
||||
return qAbs(tx.amount - event.amount) > 0.01;
|
||||
}), txList.end());
|
||||
// Если больше одной — берём ближайшую по времени к запросу сервера
|
||||
if (txList.size() > 1) {
|
||||
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
|
||||
const QDateTime target = QDateTime::fromString(
|
||||
taskJson["body"].toObject()["created_at"].toString(), Qt::ISODate);
|
||||
if (target.isValid()) {
|
||||
auto best = std::min_element(txList.begin(), txList.end(),
|
||||
[&](const TransactionInfo &a, const TransactionInfo &b) {
|
||||
const QDateTime ta = a.completeTime.isValid() ? a.completeTime : a.timestamp;
|
||||
const QDateTime tb = b.completeTime.isValid() ? b.completeTime : b.timestamp;
|
||||
return qAbs(ta.toUTC().secsTo(target)) < qAbs(tb.toUTC().secsTo(target));
|
||||
});
|
||||
const TransactionInfo chosen = *best;
|
||||
txList.clear();
|
||||
txList.append(chosen);
|
||||
}
|
||||
}
|
||||
// Фильтруем: отправляем только транзакции с bank_transaction_id
|
||||
bool hasWithoutId = false;
|
||||
QJsonArray arr;
|
||||
for (const auto &tx : txList) {
|
||||
if (tx.bankTrExternalId.isEmpty()) {
|
||||
hasWithoutId = true;
|
||||
continue;
|
||||
}
|
||||
QJsonObject txObj;
|
||||
txObj["bank_transaction_id"] = tx.bankTrExternalId;
|
||||
txObj["amount"] = static_cast<int>(tx.amount * 100);
|
||||
txObj["currency"] = currencyCode;
|
||||
txObj["transaction_type"] = (tx.type == TransactionType::Phone) ? "top-up" : "bank";
|
||||
txObj["bank_participator_id"]= tx.phone;
|
||||
txObj["status"] = tx.status == TransactionStatus::Complete ? "completed" : (tx.status == TransactionStatus::Wait ? "progress" : "completed");
|
||||
QJsonObject extraObj;
|
||||
extraObj["fee"] = static_cast<int>(tx.fee * 100);
|
||||
txObj["extra"] = extraObj;
|
||||
// Банковские приложения показывают время в таймзоне устройства
|
||||
const DeviceInfo bankDevice = DeviceDAO::getDeviceById(event.deviceId);
|
||||
const QString bankAdbSerial = bankDevice.adbSerial.isEmpty()
|
||||
? event.deviceId : bankDevice.adbSerial;
|
||||
const QString bankTzName = AdbUtils::getDeviceTimezone(bankAdbSerial);
|
||||
QTimeZone bankTz(bankTzName.toUtf8());
|
||||
if (!bankTz.isValid()) {
|
||||
bankTz = QTimeZone(event.bankName == "dushanbe_city_bank"
|
||||
? "Asia/Dushanbe" : "Europe/Moscow");
|
||||
}
|
||||
// Приоритет: bankTime (время операции) > timestamp (время сохранения)
|
||||
// Сервер ожидает UTC ("Z")
|
||||
bool createdAtSet = false;
|
||||
if (!tx.bankTime.isEmpty()) {
|
||||
QDateTime dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy, HH:mm");
|
||||
if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm");
|
||||
if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm:ss");
|
||||
if (dt.isValid()) {
|
||||
dt.setTimeZone(bankTz);
|
||||
txObj["created_at"] = dt.toUTC().toString(Qt::ISODate);
|
||||
createdAtSet = true;
|
||||
}
|
||||
}
|
||||
if (!createdAtSet && tx.timestamp.isValid()) {
|
||||
txObj["created_at"] = tx.timestamp.toUTC().toString(Qt::ISODate);
|
||||
createdAtSet = true;
|
||||
}
|
||||
if (!createdAtSet) {
|
||||
txObj["created_at"] = QJsonValue(QJsonValue::Null);
|
||||
}
|
||||
arr.append(txObj);
|
||||
}
|
||||
if (arr.isEmpty() && hasWithoutId) {
|
||||
// Транзакция найдена но без bank_transaction_id — ошибка
|
||||
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: tx found but no bank_transaction_id";
|
||||
// FETCH_TRANSACTIONS больше НЕ читает из БД — берём то, что
|
||||
// скрипт распарсил вживую и передал через сигнал transactionParsed.
|
||||
const QJsonObject parsedTx = m_parsedTxs.take(key);
|
||||
if (parsedTx.isEmpty()) {
|
||||
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
|
||||
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
|
||||
"Transaction found but bank_transaction_id is empty");
|
||||
"Transaction not parsed from screen");
|
||||
EventDAO::updateEvent(event.id, EventStatus::Error,
|
||||
"Transaction found but bank_transaction_id is empty (OCR failed)");
|
||||
"Transaction not parsed from screen");
|
||||
EventDAO::markSentToServer(event.id);
|
||||
m_threads.remove(key);
|
||||
return;
|
||||
}
|
||||
QJsonArray arr;
|
||||
arr.append(parsedTx);
|
||||
taskData = arr;
|
||||
} else if (event.type == EventType::CreateTransaction) {
|
||||
// Определяем тип транзакции из JSON задачи
|
||||
@ -506,9 +423,10 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
};
|
||||
|
||||
const QString &appCode = account.appCode;
|
||||
// Скриптам нужен ADB serial для команд, не android_id
|
||||
const DeviceInfo device = DeviceDAO::getDeviceById(account.deviceId);
|
||||
const QString adbSerial = device.adbSerial.isEmpty() ? account.deviceId : device.adbSerial;
|
||||
// Передаём в скрипт канонический device_id из материала — скрипт сам
|
||||
// разрезолвит его в adb serial через DeviceDAO и использует ТОТ ЖЕ ключ
|
||||
// для поиска BankProfileInfo (чтобы PIN поднимался из правильной записи).
|
||||
const QString adbSerial = account.deviceId;
|
||||
|
||||
auto connectScreenshot = [this, key](Ozon::CommonScript *w) {
|
||||
connect(w, &Ozon::CommonScript::screenshotReady, this,
|
||||
@ -539,8 +457,13 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
}
|
||||
else if (appCode == "black")
|
||||
setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
|
||||
else if (appCode == "dushanbe_city_bank")
|
||||
setup(new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime));
|
||||
else if (appCode == "dushanbe_city_bank") {
|
||||
auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime);
|
||||
setup(w);
|
||||
connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this,
|
||||
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; },
|
||||
Qt::DirectConnection);
|
||||
}
|
||||
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
|
||||
}
|
||||
|
||||
@ -658,6 +581,8 @@ void EventHandler::clearAll() {
|
||||
timer->deleteLater();
|
||||
}
|
||||
m_timers.clear();
|
||||
m_screenshots.clear();
|
||||
m_parsedTxs.clear();
|
||||
}
|
||||
|
||||
void EventHandler::stop() {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
#include <QTimer>
|
||||
@ -33,6 +34,7 @@ private:
|
||||
QMap<QString, QThread *> m_threads;
|
||||
QMap<QString, QTimer *> m_timers;
|
||||
QMap<QString, QByteArray> m_screenshots; // deviceId → скриншот от скрипта
|
||||
QMap<QString, QJsonObject> m_parsedTxs; // key → распарсенная транзакция для FETCH_TRANSACTIONS
|
||||
QTimer *m_pollTimer = nullptr;
|
||||
NetworkService *m_network = nullptr;
|
||||
|
||||
|
||||
@ -21,6 +21,26 @@
|
||||
#include "dao/TransactionDAO.h"
|
||||
#include "db/EventInfo.h"
|
||||
|
||||
// Убирает полный URL (хост:порт) из сообщений об ошибках, оставляя только
|
||||
// путь эндпоинта и HTTP-код. Нужно, чтобы адрес сервера не попадал в
|
||||
// логи/UI/general_logs/application.log.
|
||||
static QString sanitizePath(const QString &fullUrl) {
|
||||
const int schemeEnd = fullUrl.indexOf("://");
|
||||
if (schemeEnd < 0) return fullUrl;
|
||||
const int pathStart = fullUrl.indexOf('/', schemeEnd + 3);
|
||||
return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/");
|
||||
}
|
||||
|
||||
static QString sanitizeNetError(QNetworkReply *reply, const QString &path) {
|
||||
const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QString endpoint = sanitizePath(path);
|
||||
if (httpCode > 0) {
|
||||
return QStringLiteral("HTTP %1 %2").arg(httpCode).arg(endpoint);
|
||||
}
|
||||
// Сетевой уровень (не HTTP) — выводим enum-код вместо строки с URL.
|
||||
return QStringLiteral("net_err=%1 %2").arg(static_cast<int>(reply->error())).arg(endpoint);
|
||||
}
|
||||
|
||||
NetworkService::NetworkService(QObject *parent) : QObject(parent) {
|
||||
const QSettings settings("config.ini", QSettings::IniFormat);
|
||||
m_urlAccountUpdate = settings.value("net/accountUpdate").toString();
|
||||
@ -90,7 +110,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path)
|
||||
|
||||
QJsonValue result;
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "Таймаут запроса:" << path;
|
||||
qCritical() << "Таймаут запроса:" << sanitizePath(path);
|
||||
reply->abort();
|
||||
} else if (reply->error() == QNetworkReply::NoError) {
|
||||
const QByteArray raw = reply->readAll();
|
||||
@ -105,7 +125,7 @@ QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
qCritical() << "Ошибка:" << reply->errorString() << path;
|
||||
qCritical() << "Ошибка:" << sanitizeNetError(reply, path);
|
||||
}
|
||||
reply->deleteLater();
|
||||
return result;
|
||||
@ -136,7 +156,7 @@ QJsonArray NetworkService::get(const QString &path, const QMap<QString, QString>
|
||||
|
||||
QJsonArray result;
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "Таймаут запроса (GET):" << path;
|
||||
qCritical() << "Таймаут запроса (GET):" << sanitizePath(path);
|
||||
reply->abort();
|
||||
} else if (reply->error() == QNetworkReply::NoError) {
|
||||
const QByteArray raw = reply->readAll();
|
||||
@ -235,19 +255,21 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
|
||||
|
||||
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
|
||||
QJsonValue result;
|
||||
const QString shortPath = sanitizePath(path);
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "Таймаут запроса (postJson):" << path;
|
||||
qCritical() << "Таймаут запроса (postJson):" << shortPath;
|
||||
reply->abort();
|
||||
GeneralLogDAO::insert("CRITICAL", "NetworkService",
|
||||
"Timeout (POST): " + path, {}, reqBodyStr, {});
|
||||
"Timeout (POST): " + shortPath, {}, reqBodyStr, {});
|
||||
result = QJsonValue::Undefined;
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
const QByteArray responseBody = reply->readAll();
|
||||
qCritical().noquote() << "Network error (POST):" << reply->errorString() << path
|
||||
const QString errSummary = sanitizeNetError(reply, path);
|
||||
qCritical().noquote() << "Network error (POST):" << errSummary
|
||||
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId)
|
||||
<< "\n response body:" << responseBody;
|
||||
GeneralLogDAO::insert("CRITICAL", "NetworkService",
|
||||
"Network error (POST): " + reply->errorString() + " " + path, {},
|
||||
"Network error (POST): " + errSummary, {},
|
||||
reqBodyStr, QString::fromUtf8(responseBody));
|
||||
result = QJsonValue::Undefined;
|
||||
} else {
|
||||
@ -261,7 +283,7 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
|
||||
} else {
|
||||
qCritical() << "API error:" << root;
|
||||
GeneralLogDAO::insert("WARNING", "NetworkService",
|
||||
"API error (POST): " + path, {},
|
||||
"API error (POST): " + shortPath, {},
|
||||
reqBodyStr, QString::fromUtf8(raw));
|
||||
result = QJsonValue::Null;
|
||||
}
|
||||
@ -453,17 +475,19 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod
|
||||
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
|
||||
QJsonValue result;
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "Таймаут запроса (PATCH):" << path;
|
||||
const QString shortPath = sanitizePath(path);
|
||||
qCritical() << "Таймаут запроса (PATCH):" << shortPath;
|
||||
reply->abort();
|
||||
GeneralLogDAO::insert("CRITICAL", "NetworkService",
|
||||
"Timeout (PATCH): " + path, {}, reqBodyStr, {});
|
||||
"Timeout (PATCH): " + shortPath, {}, reqBodyStr, {});
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
const QByteArray responseBody = reply->readAll();
|
||||
qCritical().noquote() << "Network error (PATCH):" << reply->errorString() << path
|
||||
const QString errSummary = sanitizeNetError(reply, path);
|
||||
qCritical().noquote() << "Network error (PATCH):" << errSummary
|
||||
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId)
|
||||
<< "\n response body:" << responseBody;
|
||||
GeneralLogDAO::insert("CRITICAL", "NetworkService",
|
||||
"Network error (PATCH): " + reply->errorString() + " " + path, {},
|
||||
"Network error (PATCH): " + errSummary, {},
|
||||
reqBodyStr, QString::fromUtf8(responseBody));
|
||||
} else {
|
||||
const QByteArray raw = reply->readAll();
|
||||
@ -476,7 +500,7 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod
|
||||
} else {
|
||||
qCritical() << "API error (PATCH):" << root;
|
||||
GeneralLogDAO::insert("WARNING", "NetworkService",
|
||||
"API error (PATCH): " + path, {},
|
||||
"API error (PATCH): " + sanitizePath(path), {},
|
||||
reqBodyStr, QString::fromUtf8(raw));
|
||||
}
|
||||
}
|
||||
@ -504,11 +528,11 @@ QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) {
|
||||
|
||||
QJsonValue result;
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "Таймаут запроса (DELETE):" << path;
|
||||
qCritical() << "Таймаут запроса (DELETE):" << sanitizePath(path);
|
||||
reply->abort();
|
||||
result = QJsonValue::Undefined;
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
qCritical().noquote() << "Network error (DELETE):" << reply->errorString() << path
|
||||
qCritical().noquote() << "Network error (DELETE):" << sanitizeNetError(reply, path)
|
||||
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId);
|
||||
result = QJsonValue::Undefined;
|
||||
} else {
|
||||
@ -550,11 +574,11 @@ QJsonValue NetworkService::getJson(const QString &path, bool withAuth) {
|
||||
|
||||
QJsonValue result;
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "Таймаут запроса (getJson):" << path;
|
||||
qCritical() << "Таймаут запроса (getJson):" << sanitizePath(path);
|
||||
reply->abort();
|
||||
result = QJsonValue::Undefined;
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
qCritical().noquote() << "Network error (GET):" << reply->errorString() << path
|
||||
qCritical().noquote() << "Network error (GET):" << sanitizeNetError(reply, path)
|
||||
<< (m_deviceId.isEmpty() ? "" : " device=" + m_deviceId);
|
||||
result = QJsonValue::Undefined;
|
||||
} else {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user