Compare commits
40 Commits
remote-scr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f9d3eebd0 | ||
|
|
9fb209378f | ||
|
|
cff6ec7216 | ||
|
|
82ac22f635 | ||
|
|
36567e78a4 | ||
|
|
8abe5a915f | ||
|
|
3670e34938 | ||
|
|
9da2a6fc52 | ||
|
|
0045c37d64 | ||
|
|
4ab46b8b49 | ||
|
|
07871231a3 | ||
|
|
56ba89522c | ||
|
|
14e46cac84 | ||
|
|
feb839daf2 | ||
|
|
6b3f275781 | ||
|
|
f65840f28b | ||
|
|
e7cf9a311e | ||
|
|
6c5b5a91c2 | ||
|
|
1457064423 | ||
|
|
542fabf880 | ||
|
|
078aae0d2f | ||
|
|
7bf49ba749 | ||
|
|
0adb57d87e | ||
|
|
01190bfa69 | ||
|
|
f696e816e2 | ||
|
|
373d103212 | ||
|
|
53becd0183 | ||
|
|
ae04874c48 | ||
|
|
a8d41baab7 | ||
|
|
7e5f90a450 | ||
|
|
ff4a468b35 | ||
|
|
98f1d0f9d6 | ||
|
|
72d582f0ab | ||
|
|
c3fed927fc | ||
|
|
675fa7ca5f | ||
|
|
cf543917b4 | ||
|
|
b125c509b3 | ||
|
|
bf976f04c7 | ||
|
|
d52574c50f | ||
|
|
327b206fe8 |
7
.gitignore
vendored
@ -30,3 +30,10 @@ Makefile
|
||||
/database/app_data.db
|
||||
/build_x64/
|
||||
/tests/mock_server/scenarios/pool/
|
||||
/.cache/
|
||||
/.claude/
|
||||
/dumps/
|
||||
/ARC_autogen/
|
||||
/build_test/
|
||||
/build_x64/
|
||||
/tests/mock_server/scenarios/
|
||||
|
||||
136
README.md
Normal file
@ -0,0 +1,136 @@
|
||||
# ARC Desktop
|
||||
|
||||
Десктопное Qt6/C++ приложение для мониторинга и автоматизации Android-приложений мобильных банков через ADB. Принимает задачи с бэкенда (получение профиля/балансов, выписка по транзакциям, создание платежей), исполняет их в реальном приложении банка на подключённом устройстве и возвращает результат.
|
||||
|
||||
> **Репозиторий:** `git@git.brtservice.io:BRT/arc.git`
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
git clone git@git.brtservice.io:BRT/arc.git
|
||||
cd arc
|
||||
|
||||
# macOS
|
||||
brew install qt tesseract leptonica
|
||||
mkdir -p build && cd build
|
||||
cmake .. -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build .
|
||||
./ARCDesktopProject
|
||||
```
|
||||
|
||||
Подробнее — см. [BUILD.md](BUILD.md) (macOS + Windows).
|
||||
|
||||
При первом запуске потребуется токен авторизации; настройки лежат в [`config.ini`](config.ini).
|
||||
|
||||
---
|
||||
|
||||
## Документация
|
||||
|
||||
Все актуальные материалы в каталоге [`docs/`](docs/). Точка входа — [docs/index.md](docs/index.md).
|
||||
|
||||
### Архитектура и фичи
|
||||
|
||||
| Документ | О чём |
|
||||
|---|---|
|
||||
| [docs/index.md](docs/index.md) | Общая схема приложения, потоки, технологии |
|
||||
| [docs/events.md](docs/events.md) | Жизненный цикл задачи: API → БД → выполнение скриптом → отчёт |
|
||||
| [docs/bank-setup-wizard.md](docs/bank-setup-wizard.md) | `BankSetupWizard` — мастер заведения банковского профиля (Ozon, Black, Dushanbe) |
|
||||
| [docs/status-sync.md](docs/status-sync.md) | Синхронизация online/offline статусов устройств и сервиса |
|
||||
| [docs/black_tasks.md](docs/black_tasks.md) | Скрипты для банка Black (Birbank) |
|
||||
| [docs/ozon_tasks.md](docs/ozon_tasks.md) | Скрипты для Ozon |
|
||||
|
||||
### Backend API
|
||||
|
||||
| Документ | О чём |
|
||||
|---|---|
|
||||
| [docs/api/auth.md](docs/api/auth.md) | Логин, refresh, хранение токенов |
|
||||
| [docs/api/profile.md](docs/api/profile.md) | `bank_profile` и `material` (карты) |
|
||||
| [docs/api/device.md](docs/api/device.md) | Регистрация десктопов и Android-устройств, отправка скриншотов |
|
||||
| [docs/api/task.md](docs/api/task.md) | Получение задач, отправка результатов |
|
||||
| [docs/api/network.md](docs/api/network.md) | Полная карта HTTP-вызовов приложения |
|
||||
| [docs/api/openapi.json](docs/api/openapi.json) | OpenAPI спецификация бэкенда |
|
||||
|
||||
### Сборка и тесты
|
||||
|
||||
| Документ | О чём |
|
||||
|---|---|
|
||||
| [BUILD.md](BUILD.md) | Сборка под macOS и Windows |
|
||||
| [tests/mock_server/README.md](tests/mock_server/README.md) | Локальный mock-бэкенд для интеграционных тестов |
|
||||
| [tests/mock_server/WINDOWS.md](tests/mock_server/WINDOWS.md) | Особенности запуска моков на Windows |
|
||||
| [tutorials/ui_info.md](tutorials/ui_info.md) | Туториал по UI приложения |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
MainWindow (Qt6 GUI)
|
||||
├── DeviceScreener (QThread) — опрос ADB-устройств, скриншоты
|
||||
├── EventHandler (QThread) — оркестратор задач, запуск bank-скриптов
|
||||
└── NetworkService (QThread) — REST API + monitoring relay
|
||||
```
|
||||
|
||||
```
|
||||
android/<bank>/ — автоматизация конкретного банка (ozon, black, dushanbe, ...)
|
||||
├── *Script.{h,cpp} — CommonScript, LoginAndCheckAccountsScript, PayByCardScript, GetLastTransactionsScript, ...
|
||||
└── ScreenXmlParser — парсинг ADB UI dump
|
||||
|
||||
android/adb/ — обёртка над adb (скриншот, tap, swipe, input, uiautomator dump)
|
||||
android/ocr/ — Tesseract + утилиты поиска текста на скриншоте
|
||||
android/dump/ — захват UI-дампов
|
||||
|
||||
database/ — SQLite через DatabaseManager (singleton) + DAO-слой
|
||||
Таблицы: devices → applications → accounts → transactions/events
|
||||
|
||||
models/ — POCO: DeviceInfo, AccountInfo, EventInfo, TransactionInfo, ...
|
||||
services/ — фоновые потоки (DeviceScreener, EventHandler, NetworkService, AppLogger, LogArchiveSender)
|
||||
views/ — Qt-виджеты: MainWindow, DeviceWidget, BankSetupWizard, AccountWindow, ...
|
||||
```
|
||||
|
||||
Полная диаграмма потоков и сигналов — [docs/index.md](docs/index.md), детально по задачам — [docs/events.md](docs/events.md).
|
||||
|
||||
---
|
||||
|
||||
## Технологии
|
||||
|
||||
- **Qt 6** (Widgets, Network, Sql, Xml)
|
||||
- **C++17**, CMake (опционально Ninja)
|
||||
- **SQLite** (через QtSql)
|
||||
- **ADB** (платформенные бинари в `tools/adb/`)
|
||||
- **Tesseract OCR** + Leptonica (`eng.traineddata`, `rus.traineddata`)
|
||||
|
||||
Поддерживаемые банки (в текущем `config.ini`): **Ozon**, **Black** (Birbank), **Dushanbe City Bank**. Список расширяется через `[apps] list=` в [`config.ini`](config.ini).
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Главный файл — [`config.ini`](config.ini):
|
||||
|
||||
| Секция | Назначение |
|
||||
|---|---|
|
||||
| `[common]` | Версия, валюты, токен |
|
||||
| `[apps] list=` | Активные банки |
|
||||
| `[db] dbPath=` | Путь к SQLite (`./database/app_data.db`) |
|
||||
| `[net]` | `apiBase`, пути ручек, мониторинг-релей |
|
||||
| `[<bank>]` | `android_package_name`, `currency`, `country`, `app_version`, ... |
|
||||
|
||||
Схема БД — [`migrations.sql`](migrations.sql) (5 таблиц).
|
||||
|
||||
---
|
||||
|
||||
## Полезные пути
|
||||
|
||||
- Логи приложения: `application.log` (в корне), архив отправляется через `LogArchiveSender` на `monitoringBase`
|
||||
- ADB-бинари для Windows: `tools/adb/windows/`
|
||||
- Тестовые скриншоты/XML: `assets/<bank>/`
|
||||
- Иконки и ресурсы: `images/`, `arc.icns`, `arc.ico`, `app.rc`
|
||||
|
||||
---
|
||||
|
||||
## Скрипты бэкенда и операционные ссылки
|
||||
|
||||
- Backend API (prod): см. `apiBase` в [`config.ini`](config.ini)
|
||||
- Monitoring relay: `142.93.102.40:8080` — релеит `/monitoring/log` и `/monitoring/dump` в Telegram (systemd `arc-monitoring.service`)
|
||||
@ -152,7 +152,71 @@ QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &
|
||||
return pid.replace("\n", "");
|
||||
}
|
||||
|
||||
// Логирует длинный вывод построчно с префиксом, иначе Qt-логгер режет строку.
|
||||
static void logMultiline(const char *tag, const QString &text) {
|
||||
if (text.isEmpty()) {
|
||||
qDebug().noquote() << tag << "(empty)";
|
||||
return;
|
||||
}
|
||||
const QStringList lines = text.split(QRegularExpression("\r?\n"));
|
||||
for (const QString &line : lines) {
|
||||
if (!line.isEmpty()) qDebug().noquote() << tag << line;
|
||||
}
|
||||
}
|
||||
|
||||
// Прогоняет adb-команду и возвращает stdout (stderr игнорируется — этим функциям важен только stdout).
|
||||
static QString adbShellStdout(const QStringList &args, int timeoutMs = 10000) {
|
||||
QProcess p;
|
||||
p.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
|
||||
p.start(AdbUtils::adbPath(), args);
|
||||
if (!p.waitForStarted(5000)) return {};
|
||||
p.waitForFinished(timeoutMs);
|
||||
return QString::fromUtf8(p.readAllStandardOutput());
|
||||
}
|
||||
|
||||
// Возвращает {launcherActivity, summary}. launcherActivity вида "pkg/.MainActivity" или пустая строка.
|
||||
static QPair<QString, QString> probePackageState(const QString &deviceId, const QString &packageName) {
|
||||
const QString resolveOut = adbShellStdout(
|
||||
{"-s", deviceId, "shell", "cmd", "package", "resolve-activity", "--brief", packageName});
|
||||
QString launcher;
|
||||
// Формат: первая строка — приоритет, вторая — "pkg/activity".
|
||||
for (const QString &raw : resolveOut.split(QRegularExpression("\r?\n"))) {
|
||||
const QString line = raw.trimmed();
|
||||
if (line.contains('/') && line.startsWith(packageName + "/")) {
|
||||
launcher = line;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const QString dump = adbShellStdout(
|
||||
{"-s", deviceId, "shell", "dumpsys", "package", packageName}, 15000);
|
||||
|
||||
auto pickFlag = [&dump](const QString &key) -> QString {
|
||||
const QRegularExpression re(key + "=(\\S+)");
|
||||
const auto m = re.match(dump);
|
||||
return m.hasMatch() ? m.captured(1) : QStringLiteral("?");
|
||||
};
|
||||
const bool installed = dump.contains("Package [" + packageName + "]");
|
||||
const QString enabled = pickFlag("enabled");
|
||||
const QString stopped = pickFlag("stopped");
|
||||
const QString hidden = pickFlag("hidden");
|
||||
const bool hasLauncherCategory = dump.contains("android.intent.category.LAUNCHER");
|
||||
|
||||
const QString summary = QString(
|
||||
"installed=%1 enabled=%2 stopped=%3 hidden=%4 hasLauncherCategory=%5 resolveActivity=%6")
|
||||
.arg(installed ? "true" : "false", enabled, stopped, hidden,
|
||||
hasLauncherCategory ? "true" : "false",
|
||||
launcher.isEmpty() ? "(none)" : launcher);
|
||||
return {launcher, summary};
|
||||
}
|
||||
|
||||
bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &packageName) {
|
||||
// Pre-flight: состояние пакета. Без этого по логу нельзя отличить
|
||||
// "нет иконки" от "disabled" от "hidden" от "не установлен".
|
||||
const auto [launcherActivity, stateSummary] = probePackageState(deviceId, packageName);
|
||||
qDebug().noquote() << "[AdbUtils::tryToStartApplication] device=" << deviceId
|
||||
<< "pkg=" << packageName << "state:" << stateSummary;
|
||||
|
||||
QProcess process;
|
||||
process.setWorkingDirectory(adbDir());
|
||||
process.start(adbPath(), {
|
||||
@ -167,16 +231,47 @@ bool AdbUtils::tryToStartApplication(const QString &deviceId, const QString &pac
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
const QString output = process.readAllStandardOutput();
|
||||
const QString stdOut = QString::fromUtf8(process.readAllStandardOutput());
|
||||
const QString stdErr = QString::fromUtf8(process.readAllStandardError());
|
||||
const int exitCode = process.exitCode();
|
||||
const QProcess::ExitStatus exitStatus = process.exitStatus();
|
||||
|
||||
// Если процесс запустился, там будет такая строка.
|
||||
// Если нет, то: "** No activities found to run, monkey aborted."
|
||||
if (output.contains("Events injected: 1")) {
|
||||
// Полный вывод monkey пишем построчно — Qt-логгер режет длинные QString,
|
||||
// из-за чего хвост (`Events injected` / `No activities found`) терялся.
|
||||
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey exitStatus="
|
||||
<< exitStatus << "exitCode=" << exitCode;
|
||||
logMultiline("[AdbUtils::tryToStartApplication] monkey stdout:", stdOut);
|
||||
logMultiline("[AdbUtils::tryToStartApplication] monkey stderr:", stdErr);
|
||||
|
||||
if (stdOut.contains("Events injected: 1")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Тут надо в логи писать, почему не стартует
|
||||
qDebug() << "Start app output:" << output;
|
||||
// Фолбэк: am start с конкретной activity. У него осмысленный stderr и exit code,
|
||||
// в отличие от monkey, который при пустом resolve просто молча печатает "No activities found".
|
||||
if (!launcherActivity.isEmpty()) {
|
||||
qDebug().noquote() << "[AdbUtils::tryToStartApplication] monkey не дал Events injected;"
|
||||
<< "пробую am start -n" << launcherActivity;
|
||||
QProcess am;
|
||||
am.setWorkingDirectory(adbDir());
|
||||
am.start(adbPath(),
|
||||
{"-s", deviceId, "shell", "am", "start", "-W", "-n", launcherActivity});
|
||||
am.waitForStarted(5000);
|
||||
am.waitForFinished(15000);
|
||||
const QString amOut = QString::fromUtf8(am.readAllStandardOutput());
|
||||
const QString amErr = QString::fromUtf8(am.readAllStandardError());
|
||||
qDebug().noquote() << "[AdbUtils::tryToStartApplication] am exitCode=" << am.exitCode();
|
||||
logMultiline("[AdbUtils::tryToStartApplication] am stdout:", amOut);
|
||||
logMultiline("[AdbUtils::tryToStartApplication] am stderr:", amErr);
|
||||
// `am start -W` печатает "Status: ok" при успехе.
|
||||
if (am.exitCode() == 0 && amOut.contains("Status: ok")) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
qWarning() << "[AdbUtils::tryToStartApplication] нет launcher-активити для" << packageName
|
||||
<< "— приложение либо не установлено, либо disabled/hidden, либо без LAUNCHER intent-filter";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -479,16 +574,23 @@ bool AdbUtils::enableAdbIMEKeyboard(const QString &deviceId) {
|
||||
bool AdbUtils::inputAdbIMEText(const QString &deviceId, const QString &text) {
|
||||
QProcess process;
|
||||
process.setWorkingDirectory(adbDir());
|
||||
|
||||
// Кодируем UTF-8 → base64 и шлём через ADB_INPUT_B64. Это исключает любые проблемы
|
||||
// shell-кавычек на устройстве с пробелами, скобками и не-ASCII символами
|
||||
// (типа "Озон Банк (Ozon)"). ADBKeyboard декодирует base64 и вызывает commitText.
|
||||
const QString b64 = QString::fromLatin1(text.toUtf8().toBase64());
|
||||
|
||||
process.start(adbPath(), {
|
||||
"-s", deviceId,
|
||||
"shell", "am broadcast -a ADB_INPUT_TEXT --es msg '" + text + "'"
|
||||
"shell", "am broadcast -a ADB_INPUT_B64 --es msg " + b64
|
||||
});
|
||||
if (!process.waitForStarted(5000)) {
|
||||
qWarning() << "[AdbUtils] Failed to start adb broadcast:" << process.errorString();
|
||||
return false;
|
||||
}
|
||||
process.waitForFinished();
|
||||
qDebug() << "[AdbUtils] inputAdbIMEText result:" << process.exitCode()
|
||||
qDebug() << "[AdbUtils] inputAdbIMEText (b64) result:" << process.exitCode()
|
||||
<< "text:" << text
|
||||
<< "output:" << process.readAllStandardOutput().trimmed()
|
||||
<< "error:" << process.readAllStandardError().trimmed();
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
@ -638,6 +740,21 @@ QString AdbUtils::getDeviceTimezone(const QString &deviceId) {
|
||||
return out.trimmed();
|
||||
}
|
||||
|
||||
QString AdbUtils::getAppVersion(const QString &deviceId, const QString &packageName) {
|
||||
if (deviceId.isEmpty() || packageName.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
QString out;
|
||||
if (!startProcess(adbPath(),
|
||||
{"-s", deviceId, "shell", "dumpsys", "package", packageName},
|
||||
&out)) {
|
||||
return {};
|
||||
}
|
||||
static const QRegularExpression re(R"(versionName=([^\s]+))");
|
||||
const QRegularExpressionMatch m = re.match(out);
|
||||
return m.hasMatch() ? m.captured(1).trimmed() : QString();
|
||||
}
|
||||
|
||||
void AdbUtils::unlockScreen(const QString &deviceId, const int screenWidth, const int screenHeight) {
|
||||
const QString adb = adbPath();
|
||||
|
||||
|
||||
@ -56,6 +56,9 @@ public:
|
||||
// Возвращает таймзону устройства (напр. "Asia/Bangkok"), либо пустую строку при ошибке
|
||||
static QString getDeviceTimezone(const QString &deviceId);
|
||||
|
||||
// Возвращает versionName установленного пакета (напр. "19.17.0"), либо пустую строку при ошибке/отсутствии
|
||||
static QString getAppVersion(const QString &deviceId, const QString &packageName);
|
||||
|
||||
private:
|
||||
static QString adbDir();
|
||||
static bool startProcess(const QString &program, const QStringList &args,
|
||||
|
||||
@ -200,12 +200,12 @@ void CommonScript::updateTransactionData(
|
||||
thread->start();
|
||||
}
|
||||
|
||||
void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
|
||||
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
|
||||
qWarning() << "[Black] createBankProfileIfNeeded: skipping — fullName or phone is empty";
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
const QMap<QString, int> currencyCodes = {
|
||||
@ -249,16 +249,45 @@ void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QStr
|
||||
if (app.bankProfileId.isEmpty()) {
|
||||
ns.addExtra("app_id", app.id);
|
||||
ns.postBankProfile();
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||
qWarning() << "[Black] createBankProfileIfNeeded:" << err;
|
||||
return err;
|
||||
}
|
||||
qDebug() << "[Black] createBankProfileIfNeeded: postBankProfile done";
|
||||
} else {
|
||||
ns.addExtra("bank_profile_id", app.bankProfileId);
|
||||
ns.patchBankProfile();
|
||||
qDebug() << "[Black] createBankProfileIfNeeded: patchBankProfile done";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void CommonScript::stop() {
|
||||
m_running = false;
|
||||
}
|
||||
|
||||
bool CommonScript::closeGooglePlayBannerIfVisible(
|
||||
const QString &deviceId, const int width, const int height
|
||||
) {
|
||||
// 1. Системный диалог запроса разрешения Android (permissioncontroller) — тапаем "ЗАПРЕТИТЬ"
|
||||
if (Node denyBtn = xmlScreenParser.tryToFindSystemPermissionDenyButton(); !denyBtn.isEmpty()) {
|
||||
qDebug() << "[Black] System permission dialog detected mid-task, tapping 'ЗАПРЕТИТЬ'...";
|
||||
AdbUtils::makeTap(deviceId, denyBtn.x(), denyBtn.y());
|
||||
QThread::msleep(1500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Google Play in-app update bottom sheet
|
||||
Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(width, height);
|
||||
if (gpNode.isEmpty()) return false;
|
||||
qDebug() << "[Black] Google Play update banner detected mid-task, closing...";
|
||||
AdbUtils::makeTap(deviceId, gpNode.x(), gpNode.y());
|
||||
QThread::msleep(1500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace Black
|
||||
|
||||
@ -46,8 +46,16 @@ protected:
|
||||
const QString &newDesc
|
||||
);
|
||||
|
||||
// Создаёт bank_profile на сервере если ещё не создан
|
||||
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||
// Создаёт bank_profile на сервере если ещё не создан.
|
||||
// Возвращает пустую строку при успехе, текст ошибки при провале.
|
||||
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||
|
||||
// Если виден баннер Google Play "Доступно обновление" или системный
|
||||
// диалог разрешений — закрывает его и перепарсивает XML. Безопасно
|
||||
// вызывать на любом экране; возвращает true если что-то было закрыто.
|
||||
bool closeGooglePlayBannerIfVisible(
|
||||
const QString &deviceId, int width, int height
|
||||
);
|
||||
|
||||
bool m_running = true;
|
||||
ScreenXmlParser xmlScreenParser;
|
||||
|
||||
@ -41,6 +41,7 @@ void GetCardInfoScript::doStart() {
|
||||
|
||||
// Читаем текущий экран
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
|
||||
// Если не на домашнем экране — перезапускаем
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
@ -54,6 +55,7 @@ void GetCardInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
}
|
||||
|
||||
// 1. Парсим баланс с домашнего экрана
|
||||
@ -78,6 +80,7 @@ void GetCardInfoScript::doStart() {
|
||||
bool cvuScreenFound = false;
|
||||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
if (xmlScreenParser.isCVUScreen()) {
|
||||
cvuScreenFound = true;
|
||||
break;
|
||||
@ -116,7 +119,7 @@ void GetCardInfoScript::doStart() {
|
||||
account.amount = balance;
|
||||
account.currency = "ARS";
|
||||
account.description = "CVU";
|
||||
account.status = MaterialStatus::Active;
|
||||
account.status = MaterialStatus::Off;
|
||||
account.updateTime = QDateTime::currentDateTimeUtc();
|
||||
|
||||
if (!MaterialDAO::upsertAccount(account)) {
|
||||
|
||||
@ -164,7 +164,11 @@ void GetProfileInfoScript::doStart() {
|
||||
}
|
||||
|
||||
// 7. Создаём bank profile если нет
|
||||
createBankProfileIfNeeded(m_deviceId, m_appCode);
|
||||
if (const QString err = createBankProfileIfNeeded(m_deviceId, m_appCode); !err.isEmpty()) {
|
||||
m_error = err;
|
||||
emit finishedWithResult(m_error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 8. Отправляем данные аккаунтов на сервер если нет material_id
|
||||
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||
|
||||
@ -542,4 +542,37 @@ Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
|
||||
return {};
|
||||
}
|
||||
|
||||
Node ScreenXmlParser::tryToFindGooglePlayBanner(const int screenWidth, const int screenHeight) {
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc.contains("Закрыть диалоговое окно") && node.clickable) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc.contains("Доступно обновление")) {
|
||||
Node tapNode;
|
||||
const int cx = screenWidth / 2;
|
||||
const int cy = screenHeight / 20;
|
||||
tapNode.setBounds(cx, cy, cx + 1, cy + 1);
|
||||
return tapNode;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"
|
||||
&& node.clickable) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.clickable && node.text.trimmed() == QString::fromUtf8("ЗАПРЕТИТЬ")) {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace Black
|
||||
|
||||
@ -110,6 +110,13 @@ public:
|
||||
// Находит EditText по hint
|
||||
Node findEditTextByHint(const QString &hint);
|
||||
|
||||
// Находит кнопку закрытия Google Play in-app update bottom sheet
|
||||
// ("com.android.vending" оверлей "Доступно обновление"), если он показан
|
||||
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);
|
||||
|
||||
// Находит кнопку "ЗАПРЕТИТЬ" в системном диалоге разрешений Android
|
||||
Node tryToFindSystemPermissionDenyButton();
|
||||
|
||||
private:
|
||||
QList<Node> m_nodes;
|
||||
QDomElement m_xml;
|
||||
|
||||
@ -15,7 +15,9 @@
|
||||
#include <qDebug>
|
||||
|
||||
#include "adb/AdbUtils.h"
|
||||
#include "dao/SettingsDAO.h"
|
||||
#include "net/NetworkService.h"
|
||||
#include "LogArchiveSender.h"
|
||||
|
||||
namespace {
|
||||
|
||||
@ -164,14 +166,28 @@ TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind,
|
||||
}
|
||||
|
||||
QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const {
|
||||
QString s = QString("Bank: %1 | Task: %2 | Device: %3")
|
||||
.arg(m_bank, m_taskKind, m_adbDeviceIdRaw);
|
||||
if (!m_meta.materialId.isEmpty()) s += QString("\nMaterial ID: %1").arg(m_meta.materialId);
|
||||
if (m_meta.amount != 0) s += QString("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
|
||||
if (!m_meta.phone.isEmpty()) s += QString("\nPhone: %1").arg(m_meta.phone);
|
||||
if (!m_meta.card.isEmpty()) s += QString("\nCard: %1").arg(m_meta.card);
|
||||
s += QString("\nError: %1").arg(errorMessage.left(800));
|
||||
return s;
|
||||
const QString apiToken = SettingsDAO::get("api_key");
|
||||
const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate);
|
||||
|
||||
QStringList head{QStringLiteral("[TASK_ERROR]")};
|
||||
if (!apiToken.isEmpty())
|
||||
head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
|
||||
head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped());
|
||||
head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped());
|
||||
|
||||
QString body = QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped());
|
||||
if (!m_meta.materialId.isEmpty())
|
||||
body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped());
|
||||
if (m_meta.amount != 0)
|
||||
body += QStringLiteral("\nAmount: %1").arg(static_cast<qint64>(m_meta.amount));
|
||||
if (!m_meta.phone.isEmpty())
|
||||
body += QStringLiteral("\nPhone: %1").arg(m_meta.phone.toHtmlEscaped());
|
||||
if (!m_meta.card.isEmpty())
|
||||
body += QStringLiteral("\nCard: %1").arg(m_meta.card.toHtmlEscaped());
|
||||
body += QStringLiteral("\nError: %1").arg(errorMessage.left(800).toHtmlEscaped());
|
||||
|
||||
return QStringLiteral("<b>%1</b>\n%2\n%3")
|
||||
.arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped(), body);
|
||||
}
|
||||
|
||||
void TaskDumpRecorder::saveXml(int counter, const QString &xml) {
|
||||
@ -263,24 +279,29 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||
QFile::remove(archivePath);
|
||||
|
||||
NetworkService *svc = networkServiceRef().data();
|
||||
if (!svc) {
|
||||
if (svc) {
|
||||
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
|
||||
<< "archive.size=" << archiveBytes.size()
|
||||
<< "image.size=" << finalScreenshot.size()
|
||||
<< "text.size=" << text.size();
|
||||
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
|
||||
Qt::QueuedConnection,
|
||||
Q_ARG(QString, text),
|
||||
Q_ARG(QByteArray, archiveBytes),
|
||||
Q_ARG(QByteArray, finalScreenshot));
|
||||
if (!dispatched) {
|
||||
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
||||
"slot not found or wrong signature for" << folder;
|
||||
}
|
||||
} else {
|
||||
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
||||
<< "(setNetworkService was not called at app startup?)";
|
||||
return;
|
||||
}
|
||||
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
|
||||
<< "archive.size=" << archiveBytes.size()
|
||||
<< "image.size=" << finalScreenshot.size()
|
||||
<< "text.size=" << text.size();
|
||||
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
|
||||
Qt::QueuedConnection,
|
||||
Q_ARG(QString, text),
|
||||
Q_ARG(QByteArray, archiveBytes),
|
||||
Q_ARG(QByteArray, finalScreenshot));
|
||||
if (!dispatched) {
|
||||
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
||||
"slot not found or wrong signature for" << folder;
|
||||
}
|
||||
|
||||
// Следом — application.log + БД (+ WAL/SHM) отдельным архивом.
|
||||
// Запускаем уже ПОСЛЕ того, как task-дамп поставлен в очередь
|
||||
// отправки, чтобы порядок прихода на relay был "дамп → логи".
|
||||
LogArchiveSender::dispatch(QStringLiteral("TASK_ERROR"));
|
||||
});
|
||||
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
|
||||
bg->start();
|
||||
|
||||
@ -46,6 +46,34 @@ bool CommonScript::inputPinCode(const QString &deviceId, const QString &pinCode)
|
||||
qWarning() << "[Dushanbe::inputPinCode] pinCode is EMPTY — nothing to enter!";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ждём, пока на экране появятся все уникальные цифры из PIN.
|
||||
// DC Next на некоторых устройствах успевает показать заголовок
|
||||
// "Введите код доступа" раньше, чем дорисует кнопочную сетку, —
|
||||
// если начать тапать в этот момент, часть цифр не найдётся и PIN
|
||||
// не введётся целиком.
|
||||
bool seenInPin[10] = {false};
|
||||
for (const QChar &d : pinCode) {
|
||||
if (!d.isDigit()) continue;
|
||||
const int idx = d.digitValue();
|
||||
if (idx >= 0 && idx <= 9) seenInPin[idx] = true;
|
||||
}
|
||||
auto allDigitsPresent = [this, &seenInPin]() {
|
||||
for (int d = 0; d < 10; ++d) {
|
||||
if (!seenInPin[d]) continue;
|
||||
if (xmlScreenParser.findNodeByContentDesc(QString::number(d)).isEmpty()) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (int i = 0; i < 6 && !allDigitsPresent(); ++i) {
|
||||
QThread::msleep(500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
}
|
||||
if (!allDigitsPresent()) {
|
||||
qWarning() << "[Dushanbe::inputPinCode] keypad incomplete after wait; aborting";
|
||||
return false;
|
||||
}
|
||||
|
||||
qDebug() << "[Dushanbe::inputPinCode] entering" << pinCode.length() << "digits";
|
||||
// DC Next использует кнопочную сетку для ввода PIN (content-desc = "0"-"9")
|
||||
for (const QChar &digit : pinCode) {
|
||||
@ -124,10 +152,14 @@ bool CommonScript::runAppAndGoToHomeScreen(
|
||||
|
||||
if (!findAndInputPincode && xmlScreenParser.isPinCodeScreen()) {
|
||||
qDebug() << "[Dushanbe] PIN screen detected on attempt" << counter << "entering PIN";
|
||||
if (!inputPinCode(deviceId, pinCode)) {
|
||||
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter;
|
||||
if (inputPinCode(deviceId, pinCode)) {
|
||||
findAndInputPincode = true;
|
||||
} else {
|
||||
// Клавиатура могла не дорисоваться или PIN ввёлся частично —
|
||||
// оставляем флаг false, следующая итерация повторит ввод,
|
||||
// когда кнопки появятся.
|
||||
qWarning() << "[Dushanbe] inputPinCode failed on attempt" << counter << "— will retry";
|
||||
}
|
||||
findAndInputPincode = true;
|
||||
QThread::msleep(3000);
|
||||
continue;
|
||||
}
|
||||
@ -235,12 +267,12 @@ void CommonScript::updateTransactionData(
|
||||
thread->start();
|
||||
}
|
||||
|
||||
void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
|
||||
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
|
||||
qWarning() << "[Dushanbe] createBankProfileIfNeeded: skipping — fullName or phone is empty";
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
const QMap<QString, int> currencyCodes = {
|
||||
@ -283,12 +315,19 @@ void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QStr
|
||||
if (app.bankProfileId.isEmpty()) {
|
||||
ns.addExtra("app_id", app.id);
|
||||
ns.postBankProfile();
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||
qWarning() << "[Dushanbe] createBankProfileIfNeeded:" << err;
|
||||
return err;
|
||||
}
|
||||
qDebug() << "[Dushanbe] createBankProfileIfNeeded: postBankProfile done";
|
||||
} else {
|
||||
ns.addExtra("bank_profile_id", app.bankProfileId);
|
||||
ns.patchBankProfile();
|
||||
qDebug() << "[Dushanbe] createBankProfileIfNeeded: patchBankProfile done";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void CommonScript::stop() {
|
||||
|
||||
@ -45,7 +45,7 @@ protected:
|
||||
const QString &newDesc
|
||||
);
|
||||
|
||||
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||
|
||||
// Если виден баннер Google Play "Доступно обновление" — закрывает его и перепарсивает XML.
|
||||
// Безопасно вызывать на любом экране; возвращает true если баннер был закрыт.
|
||||
|
||||
@ -39,6 +39,7 @@ void GetCardInfoScript::doStart() {
|
||||
TaskDumpRecorder::Scope dumpScope("dushanbe", "FETCH_CARDS", m_deviceId, dumpMeta, &m_error);
|
||||
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
qDebug() << "[Dushanbe::GetCardInfo] Not on home screen, restarting app...";
|
||||
@ -51,6 +52,7 @@ void GetCardInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
}
|
||||
|
||||
// Парсим баланс с домашнего экрана
|
||||
@ -77,6 +79,7 @@ void GetCardInfoScript::doStart() {
|
||||
bool cardsScreenFound = false;
|
||||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
if (xmlScreenParser.isCardsScreen()) {
|
||||
cardsScreenFound = true;
|
||||
break;
|
||||
@ -127,6 +130,7 @@ void GetCardInfoScript::doStart() {
|
||||
bool detailFound = false;
|
||||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
if (xmlScreenParser.isCardDetailScreen()) {
|
||||
detailFound = true;
|
||||
break;
|
||||
@ -142,25 +146,32 @@ void GetCardInfoScript::doStart() {
|
||||
// Ждём загрузки баланса (может быть 0 пока данные подтягиваются)
|
||||
QThread::msleep(1500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
|
||||
QString fullNumber = xmlScreenParser.parseFullCardNumber();
|
||||
const double cardBalance = xmlScreenParser.parseCardDetailBalance();
|
||||
// Баланс берём с домашнего экрана (parseBalanceFromHomeScreen выше):
|
||||
// в Душанбе все карты привязаны к одному счёту, и на экране детали
|
||||
// карты баланс часто отображается как placeholder "0,00 TJS" пока
|
||||
// данные подгружаются с сервера.
|
||||
const double cardBalance = balance;
|
||||
const QString holder = xmlScreenParser.parseCardDetailHolder();
|
||||
const QString fullNumberClean = QString(fullNumber).remove(' ');
|
||||
const QString lastNumbers = (fullNumberClean.length() >= 4)
|
||||
? fullNumberClean.right(4)
|
||||
: (cards[i].lastNumbers.isEmpty() ? QString() : cards[i].lastNumbers);
|
||||
|
||||
qDebug() << "[Dushanbe::GetCardInfo] Card" << i << ":" << fullNumber
|
||||
<< "balance:" << cardBalance << "holder:" << holder;
|
||||
|
||||
if (lastNumbers.isEmpty()) {
|
||||
qWarning() << "[Dushanbe::GetCardInfo] No card number for card" << i;
|
||||
// Без полного номера карты материал не создаём и на сервер не отправляем —
|
||||
// защита от записей с пустым card_number (см. Ozon::GetCardInfo).
|
||||
if (fullNumberClean.isEmpty()) {
|
||||
qWarning() << "[Dushanbe::GetCardInfo] Full card number not parsed for card" << i
|
||||
<< "(home-screen lastNumbers=" << cards[i].lastNumbers << ") — skipping";
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1500);
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString lastNumbers = fullNumberClean.right(4);
|
||||
|
||||
// Достаём номер счёта: Настройки карты → Реквизиты карты → таб "Реквизиты счета".
|
||||
// ВРЕМЕННО ОТКЛЮЧЕНО
|
||||
QString accountNumber;
|
||||
@ -243,7 +254,7 @@ void GetCardInfoScript::doStart() {
|
||||
account.amount = cardBalance;
|
||||
account.currency = "TJS";
|
||||
account.description = cards[i].description.isEmpty() ? holder : cards[i].description;
|
||||
account.status = MaterialStatus::Active;
|
||||
account.status = MaterialStatus::Off;
|
||||
account.updateTime = QDateTime::currentDateTimeUtc();
|
||||
|
||||
qDebug() << "[Dushanbe::GetCardInfo] Saving card:" << "lastNumbers=" << lastNumbers
|
||||
@ -283,6 +294,7 @@ void GetCardInfoScript::doStart() {
|
||||
// Ждём возврата на экран "Мои карты"
|
||||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
if (xmlScreenParser.isCardsScreen()) break;
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QThread>
|
||||
#include <QTimeZone>
|
||||
|
||||
@ -16,6 +17,24 @@
|
||||
#include "AppLogger.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
|
||||
// Душанбе показывает суммы как "100,50", "100.50 TJS", "1 000,50",
|
||||
// "1 000.50 TJS" и т.п. Голый QString::toDouble на таком вернёт 0,
|
||||
// потому что требует, чтобы вся строка была валидным числом.
|
||||
static double parseDushanbeAmount(const QString &raw) {
|
||||
QString s = raw;
|
||||
s.remove(QRegularExpression(R"([^\d,.\-])"));
|
||||
s.replace(',', '.');
|
||||
const int firstDot = s.indexOf('.');
|
||||
if (firstDot >= 0) {
|
||||
QString head = s.left(firstDot);
|
||||
QString tail = s.mid(firstDot + 1);
|
||||
tail.remove('.');
|
||||
s = head + '.' + tail;
|
||||
}
|
||||
return s.toDouble();
|
||||
}
|
||||
|
||||
static QString recognizeTextFromImage(const QByteArray &pngData) {
|
||||
auto *api = new tesseract::TessBaseAPI();
|
||||
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
|
||||
@ -453,13 +472,17 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
|
||||
// 2. Собираем View-транзакции (формат Выписки)
|
||||
// Два формата content-desc:
|
||||
// a) DCWallet/*** — старый: DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
|
||||
// b) P2P — новый: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
|
||||
// Известные форматы content-desc (всё через '\n'):
|
||||
// a) DCWallet (старый, 5 строк): DCWallet*WDC...\ncard\namount\nописание\n HH:MM:SS
|
||||
// b) DCWallet (новый, 6 строк): DCWallet*WDC...\nphone-card-\ncard_owner\namount\nописание\n HH:MM:SS
|
||||
// c) P2P: Сегодня|dd.MM.yyyy\ncard\ncard\namount\nописание\n HH:MM:SS
|
||||
// Позиция amount уехала между версиями приложения, поэтому ищем по
|
||||
// паттерну "[-]?digits.dd" среди всех строк — устойчиво к перестановке полей.
|
||||
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
|
||||
static const QRegularExpression reDateOrToday(
|
||||
QString("^(?:\\d{2}\\.\\d{2}\\.\\d{4}|%1|%2)$")
|
||||
.arg(QString::fromUtf8("Сегодня"), QString::fromUtf8("Вчера")));
|
||||
static const QRegularExpression reAmountLine(R"(^-?\d+\.\d{2}$)");
|
||||
QList<VypiskaItem> items;
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (n.className != "android.view.View") continue;
|
||||
@ -475,10 +498,14 @@ void GetLastTransactionsScript::doStart() {
|
||||
|
||||
VypiskaItem it;
|
||||
bool ok = false;
|
||||
// В P2P формате amount на позиции [3] (после даты, двух карт), в DCWallet — [2]
|
||||
const int amtIdx = isP2P ? 3 : 2;
|
||||
if (amtIdx >= lines.size()) continue;
|
||||
it.amount = lines[amtIdx].trimmed().toDouble(&ok);
|
||||
// Ищем строку вида "-?\d+\.\d{2}" — устойчиво и к 5-, и к 6-строчному
|
||||
// DCWallet, и к P2P. Карты с пробелами ("9762 0002 ...") и текстовые
|
||||
// поля не подпадают под паттерн.
|
||||
for (const QString &line : lines) {
|
||||
if (!reAmountLine.match(line.trimmed()).hasMatch()) continue;
|
||||
it.amount = line.trimmed().toDouble(&ok);
|
||||
if (ok) break;
|
||||
}
|
||||
if (!ok) continue;
|
||||
it.time = last;
|
||||
it.node = n;
|
||||
@ -598,7 +625,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи)
|
||||
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
|
||||
if (m_searchTime.isValid() && !tx.time.isEmpty()) {
|
||||
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
|
||||
const QTime searchLocalTime = m_searchTime.toTimeZone(tjTz).time();
|
||||
@ -641,30 +668,113 @@ void GetLastTransactionsScript::doStart() {
|
||||
if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; }
|
||||
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):
|
||||
// одна нода со всем content-desc через \n. Лейблы без двоеточий, сумма —
|
||||
// отдельной строкой со знаком сразу после даты+времени.
|
||||
// "Успешный платёж\n21.05.2026 16:58:32\n-10\nКарта\n...\nПолучатель\n...\nЗакрыть"
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (!n.contentDesc.contains(QString::fromUtf8("Успешный платеж"))) continue;
|
||||
if (!n.contentDesc.contains(QString::fromUtf8("Дата:"))) continue;
|
||||
const QStringList lines = n.contentDesc.split('\n');
|
||||
for (int i = 0; i < lines.size(); ++i) {
|
||||
const QString key = lines[i].trimmed();
|
||||
if (i + 1 >= lines.size()) continue;
|
||||
const QString val = lines[i + 1].trimmed();
|
||||
if (key == QString::fromUtf8("Получатель:")) {
|
||||
detail.receiverAccount = val;
|
||||
} else if (key == QString::fromUtf8("Сумма:")) {
|
||||
detail.amount = val.toDouble();
|
||||
} else if (key == QString::fromUtf8("Дата:")) {
|
||||
detail.date = val;
|
||||
} else if (key == QString::fromUtf8("Время:")) {
|
||||
detail.time = val;
|
||||
const QString cd = n.contentDesc;
|
||||
if (!cd.contains(QString::fromUtf8("Карта"))) continue;
|
||||
if (!cd.contains(QString::fromUtf8("Получатель"))) continue;
|
||||
if (!cd.contains(QString::fromUtf8("Операция"))) continue;
|
||||
const QStringList lines = cd.split('\n', Qt::SkipEmptyParts);
|
||||
if (lines.size() < 6) continue;
|
||||
|
||||
// lines[0] — заголовок: "Успешный платёж" / "Поступление" / ...
|
||||
if (lines[0].contains(QString::fromUtf8("Успешн"))
|
||||
|| lines[0].contains(QString::fromUtf8("Поступление"))
|
||||
|| lines[0].contains(QString::fromUtf8("выполнен"))) {
|
||||
detail.status = QString::fromUtf8("Успешный");
|
||||
}
|
||||
// lines[1] — "dd.MM.yyyy HH:mm:ss"
|
||||
{
|
||||
static const QRegularExpression reDt(
|
||||
R"((\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2}))");
|
||||
const auto m = reDt.match(lines[1]);
|
||||
if (m.hasMatch()) {
|
||||
detail.date = m.captured(1);
|
||||
detail.time = m.captured(2);
|
||||
}
|
||||
}
|
||||
if (n.contentDesc.contains(QString::fromUtf8("Успешный"))) {
|
||||
detail.status = QString::fromUtf8("Успешный");
|
||||
// lines[2] — сумма со знаком: "-10" / "30" / "30.50" / "1 000,50".
|
||||
detail.amount = parseDushanbeAmount(lines[2]);
|
||||
|
||||
// Дальше — пары label/value; значение может быть multi-line (Получатель).
|
||||
// На сервер шлём первую строку получателя — таков сложившийся контракт.
|
||||
static const QSet<QString> labels{
|
||||
QString::fromUtf8("Карта"),
|
||||
QString::fromUtf8("Получатель"),
|
||||
QString::fromUtf8("Операция"),
|
||||
QString::fromUtf8("Дата"),
|
||||
QString::fromUtf8("Время"),
|
||||
QString::fromUtf8("Закрыть"),
|
||||
QString::fromUtf8("Назад"),
|
||||
};
|
||||
for (int i = 3; i < lines.size(); ++i) {
|
||||
const QString lbl = lines[i].trimmed();
|
||||
if (!labels.contains(lbl)) continue;
|
||||
QStringList vals;
|
||||
for (int j = i + 1; j < lines.size(); ++j) {
|
||||
const QString cand = lines[j].trimmed();
|
||||
if (labels.contains(cand)) break;
|
||||
vals << cand;
|
||||
}
|
||||
if (vals.isEmpty()) continue;
|
||||
if (lbl == QString::fromUtf8("Получатель")) {
|
||||
detail.receiverAccount = vals.first();
|
||||
} else if (lbl == QString::fromUtf8("Дата") && detail.date.isEmpty()) {
|
||||
detail.date = vals.first();
|
||||
} else if (lbl == QString::fromUtf8("Время") && detail.time.isEmpty()) {
|
||||
detail.time = vals.first();
|
||||
}
|
||||
}
|
||||
detailLoaded = true;
|
||||
break;
|
||||
}
|
||||
// 2) Старый blob-формат (до 3.2.33): "Успешный платеж" / "Поступление" —
|
||||
// одна нода с ключами и значениями через \n.
|
||||
if (!detailLoaded) for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (!n.contentDesc.contains(QString::fromUtf8("Дата"))) continue;
|
||||
if (!n.contentDesc.contains(QString::fromUtf8("Время"))) continue;
|
||||
const QStringList lines = n.contentDesc.split('\n');
|
||||
for (int i = 0; i < lines.size(); ++i) {
|
||||
QString key = lines[i].trimmed();
|
||||
if (key.endsWith(':')) key.chop(1); // нормализуем "Дата:" → "Дата"
|
||||
if (i + 1 >= lines.size()) continue;
|
||||
const QString val = lines[i + 1].trimmed();
|
||||
if (key == QString::fromUtf8("Получатель")) {
|
||||
detail.receiverAccount = val;
|
||||
} else if (key == QString::fromUtf8("Сумма")) {
|
||||
detail.amount = parseDushanbeAmount(val);
|
||||
} else if (key == QString::fromUtf8("Дата")) {
|
||||
detail.date = val;
|
||||
} else if (key == QString::fromUtf8("Время")) {
|
||||
detail.time = val;
|
||||
}
|
||||
}
|
||||
detailLoaded = true;
|
||||
break;
|
||||
}
|
||||
// 3) Split-формат: лейблы (с ':') и значения в отдельных View-нодах
|
||||
// на одной Y-полосе. Промежуточная версия между blob и bottom-sheet.
|
||||
if (!detailLoaded) {
|
||||
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
||||
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
||||
const QString t = pairs.value(QString::fromUtf8("Время"));
|
||||
if (!d.isEmpty() && !t.isEmpty()) {
|
||||
detail.date = d;
|
||||
detail.time = t;
|
||||
detail.receiverAccount = pairs.value(QString::fromUtf8("Получатель"));
|
||||
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("Сумма")));
|
||||
if (detail.amount == 0.0) {
|
||||
detail.amount = parseDushanbeAmount(pairs.value(QString::fromUtf8("К зачислению")));
|
||||
}
|
||||
detail.fee = parseDushanbeAmount(pairs.value(QString::fromUtf8("Комиссия")));
|
||||
detail.operationId = pairs.value(QString::fromUtf8("Номер операции"));
|
||||
detail.status = pairs.value(QString::fromUtf8("Статус"));
|
||||
detailLoaded = true;
|
||||
}
|
||||
}
|
||||
if (detailLoaded) break;
|
||||
QThread::msleep(1500);
|
||||
}
|
||||
@ -683,7 +793,7 @@ void GetLastTransactionsScript::doStart() {
|
||||
<< "date=" << detail.date << "time=" << detail.time
|
||||
<< "amount=" << detail.amount << "receiver=" << detail.receiverAccount;
|
||||
|
||||
// Проверяем время ±1 час
|
||||
// Проверяем время ±10 минут
|
||||
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
|
||||
QDateTime txTime = QDateTime::fromString(
|
||||
detail.date + " " + detail.time, "dd.MM.yyyy HH:mm:ss");
|
||||
|
||||
@ -77,6 +77,10 @@ void GetProfileInfoScript::doStart() {
|
||||
return;
|
||||
}
|
||||
BankProfileDAO::updateAmount(app.id, parsedBalance);
|
||||
// У Душанбе все карты сидят на одном счёте, а экран деталей карты
|
||||
// отдаёт placeholder "0,00 TJS" — единственный источник правды
|
||||
// для materials.amount это баланс home-screen.
|
||||
MaterialDAO::setAmountByApp(device.id, m_appCode, parsedBalance);
|
||||
|
||||
// Тапаем по кнопке меню (гамбургер, левый верхний угол)
|
||||
// Это первый clickable ImageView в верхней панели
|
||||
@ -129,7 +133,11 @@ void GetProfileInfoScript::doStart() {
|
||||
BankProfileDAO::updateProfileInfo(app.id, fullName, phone, "");
|
||||
}
|
||||
|
||||
createBankProfileIfNeeded(device.id, m_appCode, parsedBalance);
|
||||
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
|
||||
m_error = err;
|
||||
emit finishedWithResult(m_error);
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<MaterialInfo> allAccounts = MaterialDAO::getAccounts(device.id, m_appCode);
|
||||
QList<QPair<MaterialInfo, Node>> accountsToPost;
|
||||
|
||||
@ -307,11 +307,33 @@ void PayByCardScript::makePayment(
|
||||
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
|
||||
QThread::msleep(2000);
|
||||
|
||||
// 8. После "Далее" кнопка меняется на "Оплатить" — ждём и нажимаем
|
||||
// 8. После "Далее" возможны три исхода:
|
||||
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
|
||||
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
|
||||
// c) экран ошибки.
|
||||
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
|
||||
bool oplatitFound = false;
|
||||
bool earlySuccess = false;
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
|
||||
if (!xmlScreenParser.findNodeByContentDesc(
|
||||
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
|
||||
qDebug() << "[Dushanbe::PayByCard] Payment completed immediately after 'Далее'";
|
||||
oplatitFound = true;
|
||||
earlySuccess = true;
|
||||
break;
|
||||
}
|
||||
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
|
||||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||
m_error = "Transfer failed: error screen detected after 'Далее'";
|
||||
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||
return;
|
||||
}
|
||||
|
||||
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||
if (oplatitBtn.isEmpty()) {
|
||||
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||
@ -338,8 +360,8 @@ void PayByCardScript::makePayment(
|
||||
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
|
||||
// content-desc содержит "Подтверждение платежа...Подтверждаю"
|
||||
// Кнопка "Подтверждаю" визуально внизу bottom sheet
|
||||
bool confirmed = false;
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
bool confirmed = earlySuccess;
|
||||
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
|
||||
@ -360,10 +382,14 @@ void PayByCardScript::makePayment(
|
||||
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
|
||||
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
|
||||
// нижней четверти sheet, выше системного nav-бара).
|
||||
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
|
||||
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
|
||||
// скриншот и ловит ложные совпадения. Ограничиваем нижней половиной.
|
||||
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
|
||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||
QString::fromUtf8("Подтверждаю"),
|
||||
confirmNode.y1(), confirmNode.y2());
|
||||
ocrYMin, confirmNode.y2());
|
||||
if (tap.x() < 0) {
|
||||
const int tapY = confirmNode.y2() - qMax(150, height / 14);
|
||||
tap = QPoint(confirmNode.x(), tapY);
|
||||
@ -391,8 +417,17 @@ void PayByCardScript::makePayment(
|
||||
// 10. Ждём результат — "Платеж выполнен" или ошибку.
|
||||
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
|
||||
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
|
||||
bool success = false;
|
||||
for (int attempt = 0; attempt < 60; ++attempt) {
|
||||
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
|
||||
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
|
||||
// sheet → пробуем другую Y. Без лимита получали бесконечный
|
||||
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
|
||||
// (security/launcher).
|
||||
bool success = earlySuccess;
|
||||
int confirmRetries = 0;
|
||||
int oplatitRetries = 0;
|
||||
const int maxConfirmRetries = 2;
|
||||
const int maxOplatitRetries = 2;
|
||||
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
|
||||
@ -436,20 +471,62 @@ void PayByCardScript::makePayment(
|
||||
QString::fromUtf8("Подтверждение платежа"), true);
|
||||
if (!retryConfirm.isEmpty() &&
|
||||
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
|
||||
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tapping 'Подтверждаю'";
|
||||
if (confirmRetries >= maxConfirmRetries) {
|
||||
m_error = "Confirm tap missed " + QString::number(confirmRetries)
|
||||
+ " times (sheet still visible)";
|
||||
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||
return;
|
||||
}
|
||||
++confirmRetries;
|
||||
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
|
||||
<< confirmRetries << "/" << maxConfirmRetries;
|
||||
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
|
||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||
QString::fromUtf8("Подтверждаю"),
|
||||
retryConfirm.y1(), retryConfirm.y2());
|
||||
ocrYMin, retryConfirm.y2());
|
||||
if (tap.x() < 0) {
|
||||
const int tapY = retryConfirm.y2() - qMax(150, height / 14);
|
||||
// Варьируем Y: первый retry — выше fallback на 100px,
|
||||
// второй — ещё на 100px выше. Compose-кнопка действия
|
||||
// обычно на y2 - 150..350; перебираем диапазон.
|
||||
const int extra = 100 * confirmRetries;
|
||||
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
|
||||
tap = QPoint(retryConfirm.x(), tapY);
|
||||
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
|
||||
}
|
||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||
QThread::msleep(4000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
|
||||
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
|
||||
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
|
||||
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
|
||||
// выше повторно тапнет "Подтверждаю".
|
||||
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||
if (retryOplatit.isEmpty()) {
|
||||
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||
}
|
||||
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
|
||||
if (oplatitRetries >= maxOplatitRetries) {
|
||||
m_error = "Sheet dismissed and re-tap exhausted ("
|
||||
+ QString::number(oplatitRetries) + " retries)";
|
||||
qWarning() << "[Dushanbe::PayByCard]" << m_error;
|
||||
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
|
||||
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||
return;
|
||||
}
|
||||
++oplatitRetries;
|
||||
qDebug() << "[Dushanbe::PayByCard] Sheet dismissed, re-tap 'Оплатить'"
|
||||
<< oplatitRetries << "/" << maxOplatitRetries;
|
||||
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
|
||||
QThread::msleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
qDebug() << "[Dushanbe::PayByCard] Waiting for result, attempt" << attempt + 1;
|
||||
QThread::msleep(2000);
|
||||
}
|
||||
@ -471,8 +548,9 @@ void PayByCardScript::makePayment(
|
||||
if (!m_resultScreenshot.isEmpty())
|
||||
emit screenshotReady(m_resultScreenshot);
|
||||
|
||||
// Парсим детали из content-desc "Платеж выполнен"
|
||||
// Формат: "Платеж выполнен\nПоставщик:\n...\nКомиссия:\n0.00\nДата:\n05.04.26\nВремя:\n20:19:56\nНа главную"
|
||||
// Парсим детали из экрана "Платеж выполнен".
|
||||
// До 3.2.33 все поля шли одним content-desc с ключами "Дата:" / "Время:" / "Комиссия:".
|
||||
// С 3.2.33 лейблы и значения — отдельные View-ноды в двух колонках.
|
||||
QString bankTime;
|
||||
double fee = 0;
|
||||
|
||||
@ -490,8 +568,19 @@ void PayByCardScript::makePayment(
|
||||
bankTime += " " + lines[i + 1].trimmed();
|
||||
}
|
||||
}
|
||||
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
|
||||
}
|
||||
if (bankTime.isEmpty()) {
|
||||
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
||||
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
||||
const QString t = pairs.value(QString::fromUtf8("Время"));
|
||||
if (!d.isEmpty() || !t.isEmpty()) {
|
||||
bankTime = (d + " " + t).trimmed();
|
||||
}
|
||||
if (fee == 0) {
|
||||
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
|
||||
}
|
||||
}
|
||||
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
|
||||
|
||||
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
||||
// Уникальность за счёт <bankProfilePhone, секунда>.
|
||||
|
||||
@ -307,11 +307,33 @@ void PayByPhoneScript::makePayment(
|
||||
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
|
||||
QThread::msleep(2000);
|
||||
|
||||
// 8. После "Далее" кнопка меняется на "Оплатить"
|
||||
// 8. После "Далее" возможны три исхода:
|
||||
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
|
||||
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
|
||||
// c) экран ошибки.
|
||||
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
|
||||
bool oplatitFound = false;
|
||||
bool earlySuccess = false;
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
|
||||
if (!xmlScreenParser.findNodeByContentDesc(
|
||||
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
|
||||
qDebug() << "[Dushanbe::PayByPhone] Payment completed immediately after 'Далее'";
|
||||
oplatitFound = true;
|
||||
earlySuccess = true;
|
||||
break;
|
||||
}
|
||||
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
|
||||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
|
||||
m_error = "Transfer failed: error screen detected after 'Далее'";
|
||||
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||
return;
|
||||
}
|
||||
|
||||
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||
if (oplatitBtn.isEmpty()) {
|
||||
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||
@ -336,8 +358,8 @@ void PayByPhoneScript::makePayment(
|
||||
}
|
||||
|
||||
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
|
||||
bool confirmed = false;
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
bool confirmed = earlySuccess;
|
||||
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
|
||||
@ -356,10 +378,15 @@ void PayByPhoneScript::makePayment(
|
||||
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
|
||||
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
|
||||
// нижней четверти sheet, выше системного nav-бара).
|
||||
// Compose отдаёт sheet как одну ноду на весь экран ([0,0][720,1640]),
|
||||
// поэтому confirmNode.y1/y2 не сужают поиск — OCR сканирует весь
|
||||
// скриншот и ловит ложные совпадения (буква "в" в шапке).
|
||||
// Ограничиваем нижней половиной — действие-кнопка всегда внизу sheet'a.
|
||||
const int ocrYMin = (confirmNode.y1() + confirmNode.y2()) / 2;
|
||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||
QString::fromUtf8("Подтверждаю"),
|
||||
confirmNode.y1(), confirmNode.y2());
|
||||
ocrYMin, confirmNode.y2());
|
||||
if (tap.x() < 0) {
|
||||
const int tapY = confirmNode.y2() - qMax(150, height / 14);
|
||||
tap = QPoint(confirmNode.x(), tapY);
|
||||
@ -387,8 +414,17 @@ void PayByPhoneScript::makePayment(
|
||||
// 10. Ждём результат — "Платеж выполнен" или ошибку.
|
||||
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
|
||||
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
|
||||
bool success = false;
|
||||
for (int attempt = 0; attempt < 60; ++attempt) {
|
||||
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
|
||||
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
|
||||
// sheet → пробуем другую Y. Без лимита получали бесконечный
|
||||
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
|
||||
// (security/launcher).
|
||||
bool success = earlySuccess;
|
||||
int confirmRetries = 0;
|
||||
int oplatitRetries = 0;
|
||||
const int maxConfirmRetries = 2;
|
||||
const int maxOplatitRetries = 2;
|
||||
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
|
||||
@ -428,20 +464,62 @@ void PayByPhoneScript::makePayment(
|
||||
QString::fromUtf8("Подтверждение платежа"), true);
|
||||
if (!retryConfirm.isEmpty() &&
|
||||
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
|
||||
qDebug() << "[Dushanbe::PayByPhone] Confirm sheet still visible, re-tapping 'Подтверждаю'";
|
||||
if (confirmRetries >= maxConfirmRetries) {
|
||||
m_error = "Confirm tap missed " + QString::number(confirmRetries)
|
||||
+ " times (sheet still visible)";
|
||||
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||
return;
|
||||
}
|
||||
++confirmRetries;
|
||||
qDebug() << "[Dushanbe::PayByPhone] Confirm sheet still visible, re-tap"
|
||||
<< confirmRetries << "/" << maxConfirmRetries;
|
||||
const int ocrYMin = (retryConfirm.y1() + retryConfirm.y2()) / 2;
|
||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||
QString::fromUtf8("Подтверждаю"),
|
||||
retryConfirm.y1(), retryConfirm.y2());
|
||||
ocrYMin, retryConfirm.y2());
|
||||
if (tap.x() < 0) {
|
||||
const int tapY = retryConfirm.y2() - qMax(150, height / 14);
|
||||
// Варьируем Y: первый retry — выше fallback на 100px,
|
||||
// второй — ещё на 100px выше. Compose-кнопка действия
|
||||
// обычно на y2 - 150..350; перебираем диапазон.
|
||||
const int extra = 100 * confirmRetries;
|
||||
const int tapY = retryConfirm.y2() - qMax(150 + extra, height / 14 + extra);
|
||||
tap = QPoint(retryConfirm.x(), tapY);
|
||||
qDebug() << "[Dushanbe::PayByPhone] OCR miss, fallback tap at" << tap;
|
||||
}
|
||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||
QThread::msleep(4000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
|
||||
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
|
||||
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
|
||||
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
|
||||
// выше повторно тапнет "Подтверждаю".
|
||||
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
|
||||
if (retryOplatit.isEmpty()) {
|
||||
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
|
||||
}
|
||||
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
|
||||
if (oplatitRetries >= maxOplatitRetries) {
|
||||
m_error = "Sheet dismissed and re-tap exhausted ("
|
||||
+ QString::number(oplatitRetries) + " retries)";
|
||||
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
|
||||
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
|
||||
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
|
||||
return;
|
||||
}
|
||||
++oplatitRetries;
|
||||
qDebug() << "[Dushanbe::PayByPhone] Sheet dismissed, re-tap 'Оплатить'"
|
||||
<< oplatitRetries << "/" << maxOplatitRetries;
|
||||
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
|
||||
QThread::msleep(3000);
|
||||
continue;
|
||||
}
|
||||
|
||||
qDebug() << "[Dushanbe::PayByPhone] Waiting for result, attempt" << attempt + 1;
|
||||
QThread::msleep(2000);
|
||||
}
|
||||
@ -463,7 +541,8 @@ void PayByPhoneScript::makePayment(
|
||||
if (!m_resultScreenshot.isEmpty())
|
||||
emit screenshotReady(m_resultScreenshot);
|
||||
|
||||
// Парсим детали из content-desc "Платеж выполнен"
|
||||
// Парсим детали из экрана "Платеж выполнен". С 3.2.33 поля разнесены по
|
||||
// отдельным View-узлам — пробуем сначала старый blob-формат, потом fallback.
|
||||
QString bankTime;
|
||||
double fee = 0;
|
||||
|
||||
@ -481,8 +560,19 @@ void PayByPhoneScript::makePayment(
|
||||
bankTime += " " + lines[i + 1].trimmed();
|
||||
}
|
||||
}
|
||||
qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
|
||||
}
|
||||
if (bankTime.isEmpty()) {
|
||||
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
|
||||
const QString d = pairs.value(QString::fromUtf8("Дата"));
|
||||
const QString t = pairs.value(QString::fromUtf8("Время"));
|
||||
if (!d.isEmpty() || !t.isEmpty()) {
|
||||
bankTime = (d + " " + t).trimmed();
|
||||
}
|
||||
if (fee == 0) {
|
||||
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
|
||||
}
|
||||
}
|
||||
qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
|
||||
|
||||
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
|
||||
// Уникальность за счёт <bankProfilePhone, секунда>.
|
||||
|
||||
@ -174,6 +174,7 @@ bool ScreenXmlParser::isHomeScreen() {
|
||||
// основной странице приложения, а не на splash/PIN/диалоге.
|
||||
if (node.contentDesc == QString::fromUtf8("Переводы") ||
|
||||
node.contentDesc == QString::fromUtf8("Сервисы") ||
|
||||
node.contentDesc == QString::fromUtf8("MiniApps") ||
|
||||
node.contentDesc == QString::fromUtf8("История")) {
|
||||
hasBottomNav = true;
|
||||
}
|
||||
@ -243,12 +244,15 @@ bool ScreenXmlParser::isSideMenu() {
|
||||
bool hasVyhod = false;
|
||||
bool hasIdentified = false;
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc == QString::fromUtf8("Выход")) {
|
||||
// До 3.2.33 кнопка была "Выход", с 3.2.33 — "Выйти".
|
||||
if (node.contentDesc == QString::fromUtf8("Выход") ||
|
||||
node.contentDesc == QString::fromUtf8("Выйти")) {
|
||||
hasVyhod = true;
|
||||
}
|
||||
// На реальном экране content-desc = "Идентифицирован\nУспешно",
|
||||
// поэтому строгое равенство не проходит — используем startsWith.
|
||||
if (node.contentDesc.startsWith(QString::fromUtf8("Идентифицирован"))) {
|
||||
// До 3.2.33: "Идентифицирован\nУспешно" — отдельная нода (startsWith).
|
||||
// С 3.2.33: "Идентифицирован" вшито в блок профиля
|
||||
// ("IN\nIsroil Nazarov\n+992 ...\nИдентифицирован\nУспешно") — нужен contains.
|
||||
if (node.contentDesc.contains(QString::fromUtf8("Идентифицирован"))) {
|
||||
hasIdentified = true;
|
||||
}
|
||||
if (hasVyhod && hasIdentified) {
|
||||
@ -259,13 +263,22 @@ bool ScreenXmlParser::isSideMenu() {
|
||||
}
|
||||
|
||||
QString ScreenXmlParser::parseProfileFullName() {
|
||||
// content-desc: "Мехрубон Имомкулзода\n+992 (18) 555-5519\nВерсия: 3.2.5\nСборка: 390:3443"
|
||||
// До 3.2.33 одна нода содержит:
|
||||
// "Имя Фамилия\n+992 (18) 555-5519\nВерсия: 3.2.5\nСборка: 390:3443"
|
||||
// С 3.2.33 "Версия"/"Сборка" вынесены в отдельные ноды, а профиль:
|
||||
// "IN\nIsroil Nazarov\n+992 (17) 682-7668\nИдентифицирован\nУспешно"
|
||||
// Находим ноду по наличию "Идентифицирован" ИЛИ "Версия:+Сборка:", затем
|
||||
// выбираем строку, идущую непосредственно перед строкой с телефоном "+...".
|
||||
static const QRegularExpression phoneRe(R"(^\+\d)");
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
|
||||
node.contentDesc.contains(QString::fromUtf8("Сборка:"))) {
|
||||
QStringList lines = node.contentDesc.split('\n');
|
||||
if (!lines.isEmpty()) {
|
||||
return lines[0].trimmed();
|
||||
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
|
||||
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
|
||||
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
|
||||
if (!isNewProfileBlock && !isOldProfileBlock) continue;
|
||||
const QStringList lines = node.contentDesc.split('\n');
|
||||
for (int i = 0; i < lines.size(); ++i) {
|
||||
if (phoneRe.match(lines[i].trimmed()).hasMatch() && i > 0) {
|
||||
return lines[i - 1].trimmed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -273,18 +286,53 @@ QString ScreenXmlParser::parseProfileFullName() {
|
||||
}
|
||||
|
||||
QString ScreenXmlParser::parseProfilePhone() {
|
||||
static const QRegularExpression phoneRe(R"(^\+\d)");
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
|
||||
node.contentDesc.contains(QString::fromUtf8("Сборка:"))) {
|
||||
QStringList lines = node.contentDesc.split('\n');
|
||||
if (lines.size() >= 2) {
|
||||
return lines[1].trimmed();
|
||||
const bool isNewProfileBlock = node.contentDesc.contains(QString::fromUtf8("Идентифицирован"));
|
||||
const bool isOldProfileBlock = node.contentDesc.contains(QString::fromUtf8("Версия:")) &&
|
||||
node.contentDesc.contains(QString::fromUtf8("Сборка:"));
|
||||
if (!isNewProfileBlock && !isOldProfileBlock) continue;
|
||||
for (const QString &line : node.contentDesc.split('\n')) {
|
||||
const QString trimmed = line.trimmed();
|
||||
if (phoneRe.match(trimmed).hasMatch()) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QMap<QString, QString> ScreenXmlParser::parseLabelValuePairs() {
|
||||
// Применяется к экранам "Платеж выполнен" и подобным, где с 3.2.33
|
||||
// лейблы и значения сидят в отдельных View-нодах: лейбл слева
|
||||
// (content-desc оканчивается ':'), значение — справа на той же Y-полосе.
|
||||
QMap<QString, QString> result;
|
||||
for (int i = 0; i < m_nodes.size(); ++i) {
|
||||
const QString rawLabel = m_nodes[i].contentDesc.trimmed();
|
||||
if (rawLabel.isEmpty() || !rawLabel.endsWith(':')) continue;
|
||||
const QString label = rawLabel.left(rawLabel.size() - 1).trimmed();
|
||||
if (label.isEmpty() || result.contains(label)) continue;
|
||||
|
||||
const Node &lbl = m_nodes[i];
|
||||
int bestIdx = -1;
|
||||
for (int j = 0; j < m_nodes.size(); ++j) {
|
||||
if (j == i) continue;
|
||||
const QString candidate = m_nodes[j].contentDesc.trimmed();
|
||||
if (candidate.isEmpty()) continue;
|
||||
if (candidate.endsWith(':')) continue;
|
||||
if (m_nodes[j].x1() < lbl.x2()) continue;
|
||||
if (qAbs(m_nodes[j].y1() - lbl.y1()) > 8) continue;
|
||||
if (bestIdx < 0 || m_nodes[j].x1() < m_nodes[bestIdx].x1()) {
|
||||
bestIdx = j;
|
||||
}
|
||||
}
|
||||
if (bestIdx >= 0) {
|
||||
result[label] = m_nodes[bestIdx].contentDesc.trimmed();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ScreenXmlParser::isCardsScreen() {
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.contentDesc == QString::fromUtf8("Мои карты")) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
#include <QDomElement>
|
||||
#include "android/xml/Node.h"
|
||||
@ -135,6 +136,12 @@ public:
|
||||
TransactionDetail parseTransactionDetail();
|
||||
|
||||
// Находит EditText по hint
|
||||
// Парсит пары "Лейбл: " → "Значение" для экранов, где поля разложены по
|
||||
// отдельным View-узлам в две колонки (формат с 3.2.33). Метод сканирует
|
||||
// все ноды: лейблом считается контент, оканчивающийся на ':', значением —
|
||||
// ближайший справа узел на той же Y-полосе. Возвращает map без двоеточий.
|
||||
QMap<QString, QString> parseLabelValuePairs();
|
||||
|
||||
Node findEditTextByHint(const QString &hint);
|
||||
|
||||
// Поле ввода суммы на экране перевода. У EditText'а нет hint/content-desc
|
||||
|
||||
@ -70,7 +70,12 @@ bool fuzzyMatch(const QString &word, const QString &needle) {
|
||||
if (word.isEmpty() || needle.isEmpty()) return false;
|
||||
const QString w = word.toLower();
|
||||
const QString n = needle.toLower();
|
||||
if (w.contains(n) || n.contains(w)) return true;
|
||||
// Substring-ветка работает только если word достаточно длинный.
|
||||
// Иначе любая одиночная буква, входящая в needle (для «подтверждаю»:
|
||||
// п/о/д/т/в/е/р/ж/а/ю), даст ложный матч на случайный текст экрана —
|
||||
// ровно так бот тапал в (620,302) по букве «в» где-то в шапке sheet'a.
|
||||
const int minLen = qMax(qMin(n.size(), 3), n.size() * 3 / 5);
|
||||
if (w.size() >= minLen && (w.contains(n) || n.contains(w))) return true;
|
||||
// Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда
|
||||
// даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания.
|
||||
const int tolerance = qMax(1, n.size() / 5);
|
||||
|
||||
@ -401,12 +401,12 @@ void CommonScript::updateTransactionData(
|
||||
thread->start();
|
||||
}
|
||||
|
||||
void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||
QString CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance) {
|
||||
const BankProfileInfo app = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
|
||||
if (app.fullName.trimmed().isEmpty() || app.phone.trimmed().isEmpty()) {
|
||||
qWarning() << "[Ozon] createBankProfileIfNeeded: skipping — fullName or phone is empty";
|
||||
return;
|
||||
return {};
|
||||
}
|
||||
|
||||
const QMap<QString, int> currencyCodes = {
|
||||
@ -450,12 +450,19 @@ void CommonScript::createBankProfileIfNeeded(const QString &deviceId, const QStr
|
||||
if (app.bankProfileId.isEmpty()) {
|
||||
ns.addExtra("app_id", app.id);
|
||||
ns.postBankProfile();
|
||||
const BankProfileInfo afterPost = BankProfileDAO::getApplication(deviceId, appCode);
|
||||
if (afterPost.bankProfileId.isEmpty()) {
|
||||
const QString err = "postBankProfile failed for " + deviceId + " " + appCode;
|
||||
qWarning() << "[Ozon] createBankProfileIfNeeded:" << err;
|
||||
return err;
|
||||
}
|
||||
qDebug() << "[Ozon] createBankProfileIfNeeded: postBankProfile done";
|
||||
} else {
|
||||
ns.addExtra("bank_profile_id", app.bankProfileId);
|
||||
ns.patchBankProfile();
|
||||
qDebug() << "[Ozon] createBankProfileIfNeeded: patchBankProfile done";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageName) {
|
||||
|
||||
@ -63,8 +63,9 @@ protected:
|
||||
const QString &deviceId, int width, int height
|
||||
);
|
||||
|
||||
// Создаёт bank_profile на сервере если ещё не создан
|
||||
void createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||
// Создаёт bank_profile на сервере если ещё не создан.
|
||||
// Возвращает пустую строку при успехе, текст ошибки при провале.
|
||||
QString createBankProfileIfNeeded(const QString &deviceId, const QString &appCode, double parsedBalance = -1.0);
|
||||
|
||||
// Проверяет что приложение ещё запущено на устройстве
|
||||
static bool isAppRunning(const QString &deviceId, const QString &packageName);
|
||||
|
||||
@ -90,6 +90,7 @@ void GetCardInfoScript::doStart() {
|
||||
|
||||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
if (xmlScreenParser.isProfileScreen()) break;
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
@ -106,12 +107,14 @@ void GetCardInfoScript::doStart() {
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
} else {
|
||||
qDebug() << "[GetCardInfo] Profile info already in DB or username not found, skipping profile parse";
|
||||
}
|
||||
|
||||
// Проверяем что на домашнем экране перед поиском карт
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
if (!xmlScreenParser.isHomeScreen()) {
|
||||
qDebug() << "[GetCardInfo] Not on home screen before card search, restarting...";
|
||||
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
|
||||
@ -130,6 +133,9 @@ void GetCardInfoScript::doStart() {
|
||||
QList<MaterialInfo> cards;
|
||||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
if (closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight)) {
|
||||
// баннер закрыт, дамп уже обновлён внутри helper'а
|
||||
}
|
||||
cards = xmlScreenParser.parseCardsOnHomeScreen();
|
||||
if (!cards.isEmpty()) break;
|
||||
qDebug() << "[GetCardInfo] No cards found on home screen, attempt" << attempt + 1 << "/ 5";
|
||||
@ -146,17 +152,30 @@ void GetCardInfoScript::doStart() {
|
||||
|
||||
qDebug() << "[GetCardInfo] Found" << cards.size() << "card(s) on home screen";
|
||||
|
||||
// Апсёртим аккаунты для всех найденных карт
|
||||
// Заполняем метаданные на найденных картах. Новые карты (которых ещё нет в БД)
|
||||
// не создаём здесь — запись появится только после успешного OCR полного номера
|
||||
// в success-ветке ниже. Это защищает от записей из ленты переводов и не оставляет
|
||||
// в БД материал с пустым card_number.
|
||||
//
|
||||
// Для карт, которые уже в БД, обновляем только amount/update_time —
|
||||
// прочие поля (cardNumber, materialId, status, ...) сохраняем как было.
|
||||
for (MaterialInfo &card : cards) {
|
||||
card.deviceId = device.id;
|
||||
card.appCode = m_appCode;
|
||||
card.updateTime = QDateTime::currentDateTimeUtc();
|
||||
card.status = MaterialStatus::Active;
|
||||
if (!MaterialDAO::upsertAccount(card)) {
|
||||
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers;
|
||||
card.status = MaterialStatus::Off;
|
||||
|
||||
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||
if (existing.id == -1) continue;
|
||||
|
||||
MaterialInfo refresh = existing;
|
||||
refresh.amount = card.amount;
|
||||
refresh.updateTime = card.updateTime;
|
||||
if (!MaterialDAO::upsertAccount(refresh)) {
|
||||
qWarning() << "[GetCardInfo] Failed to refresh amount for card" << card.lastNumbers;
|
||||
} else {
|
||||
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
|
||||
<< card.description << card.amount;
|
||||
qDebug() << "[GetCardInfo] Refreshed amount for card"
|
||||
<< card.lastNumbers << "to" << card.amount;
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,9 +239,11 @@ void GetCardInfoScript::doStart() {
|
||||
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
|
||||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||
"FETCH_PROFILE");
|
||||
} else {
|
||||
qDebug() << "[GetCardInfo] postBankProfile done";
|
||||
m_error = err;
|
||||
emit finishedWithResult(m_error);
|
||||
return;
|
||||
}
|
||||
qDebug() << "[GetCardInfo] postBankProfile done";
|
||||
} else {
|
||||
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
|
||||
ns.patchBankProfile();
|
||||
@ -262,6 +283,7 @@ void GetCardInfoScript::doStart() {
|
||||
for (int scrollAttempt = 0; scrollAttempt < 6; ++scrollAttempt) {
|
||||
QThread::msleep(scrollAttempt == 0 ? 2000 : 600);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
|
||||
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
|
||||
if (candidate.isEmpty()) {
|
||||
@ -293,6 +315,7 @@ void GetCardInfoScript::doStart() {
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
QThread::msleep(attempt == 0 ? 2000 : 500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
|
||||
Node candidate = xmlScreenParser.findButtonNode("Показать", false);
|
||||
Node requisites = xmlScreenParser.findTextNode("Реквизиты карты");
|
||||
@ -332,6 +355,7 @@ void GetCardInfoScript::doStart() {
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(1500);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -343,6 +367,7 @@ void GetCardInfoScript::doStart() {
|
||||
for (int attempt = 0; attempt < 10; ++attempt) {
|
||||
QThread::msleep(2000);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
cardNumber = xmlScreenParser.findFullCardNumber();
|
||||
if (!cardNumber.isEmpty()) {
|
||||
qDebug() << "[GetCardInfo] Card number found on attempt" << attempt + 1;
|
||||
@ -388,19 +413,20 @@ void GetCardInfoScript::doStart() {
|
||||
|
||||
if (!cardNumber.isEmpty()) {
|
||||
qDebug() << "[GetCardInfo] Card" << card.lastNumbers << "full number:" << cardNumber;
|
||||
// Получаем id аккаунта из БД и сохраняем номер карты
|
||||
MaterialInfo saved = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||
if (saved.id != -1) {
|
||||
MaterialDAO::updateCardNumber(saved.id, cardNumber);
|
||||
// Апсёртим запись только теперь — с полным номером карты.
|
||||
MaterialInfo toSave = card;
|
||||
toSave.cardNumber = cardNumber;
|
||||
toSave.updateTime = QDateTime::currentDateTimeUtc();
|
||||
if (!MaterialDAO::upsertAccount(toSave)) {
|
||||
qWarning() << "[GetCardInfo] Failed to upsert account for card" << card.lastNumbers;
|
||||
} else {
|
||||
qDebug() << "[GetCardInfo] Upserted account:" << card.lastNumbers
|
||||
<< card.description << card.amount;
|
||||
}
|
||||
} else {
|
||||
const QString err = "Card number not found for " + card.lastNumbers;
|
||||
qWarning() << "[GetCardInfo]" << err;
|
||||
MaterialInfo saved = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||
if (saved.id != -1) {
|
||||
MaterialDAO::updateAccountById(saved.id, materialStatusToString(MaterialStatus::Off),
|
||||
saved.description, saved.currency);
|
||||
}
|
||||
// Без полного номера материал в БД не создаём.
|
||||
AppLogger::log("ozon/GetCardInfo", m_deviceId, err,
|
||||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||
"FETCH_PROFILE");
|
||||
@ -411,6 +437,7 @@ void GetCardInfoScript::doStart() {
|
||||
AdbUtils::goBack(m_deviceId);
|
||||
QThread::msleep(2000);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -379,37 +379,53 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
|
||||
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
|
||||
// Кнопки транзакций между этими Y принадлежат нужному дню.
|
||||
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
|
||||
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
|
||||
// принадлежат нашему дню.
|
||||
// Возвращает специальные коды через dateY:
|
||||
// dateY >= 0 : header виден на этом Y (нормальный случай)
|
||||
// dateY == 0 : header выше viewport, есть более старая видимая дата
|
||||
// dateY == -1 : header не виден / лежит в нижней sticky-пачке
|
||||
// (скролл вниз, чтобы target выехал в видимую часть)
|
||||
// dateY == -2 : header в верхней sticky-пачке (несколько дат свёрнуто
|
||||
// над viewport) — скролл вверх, чтобы пачка распалась
|
||||
auto findDateYRange = [&]() -> QPair<int, int> {
|
||||
const QMap<int, int> yCount = computeDateYCount();
|
||||
auto isVisible = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
|
||||
auto isAnchored = [&](int y) { return y > 0 && yCount.value(y, 0) == 1; };
|
||||
auto isPiled = [&](int y) { return y > 0 && yCount.value(y, 0) > 1; };
|
||||
const int viewportMid = device.screenHeight / 2;
|
||||
|
||||
int dateY = -1;
|
||||
int nextDateY = device.screenHeight;
|
||||
int targetPileY = -1;
|
||||
bool foundOlderDate = false;
|
||||
for (const Node &node : xmlScreenParser.getNodes()) {
|
||||
if (node.className != "android.widget.TextView") continue;
|
||||
const QDate d = parseDateHeader(node.text.trimmed());
|
||||
if (!d.isValid()) continue;
|
||||
if (d == searchDate) {
|
||||
if (isVisible(node.y())) dateY = node.y();
|
||||
if (isAnchored(node.y())) dateY = node.y();
|
||||
else if (isPiled(node.y())) targetPileY = node.y();
|
||||
} else if (d < searchDate) {
|
||||
foundOlderDate = true;
|
||||
if (isVisible(node.y()) && (dateY < 0 || node.y() > dateY)
|
||||
if (isAnchored(node.y())
|
||||
&& (dateY < 0 || node.y() > dateY)
|
||||
&& node.y() < nextDateY) {
|
||||
nextDateY = node.y();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Target в sticky-пачке: пачка над viewport (малый Y) → секция уже
|
||||
// прокручена выше, надо вернуться (dateY = -2). Пачка снизу viewport
|
||||
// (большой Y) → target ещё не доехал до экрана, скролл вниз (dateY = -1).
|
||||
if (dateY < 0 && targetPileY >= 0) {
|
||||
dateY = (targetPileY < viewportMid) ? -2 : -1;
|
||||
}
|
||||
// Target-header не виден, но видна более старая дата → target ушёл вверх:
|
||||
// всё выше nextDateY принадлежит нашему дню.
|
||||
if (dateY < 0 && nextDateY < device.screenHeight) {
|
||||
if (dateY < 0 && nextDateY < device.screenHeight && targetPileY < 0) {
|
||||
dateY = 0;
|
||||
}
|
||||
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
|
||||
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
|
||||
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate
|
||||
<< "targetPileY=" << targetPileY;
|
||||
return {dateY, nextDateY};
|
||||
};
|
||||
|
||||
@ -440,6 +456,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int
|
||||
|
||||
// Находим Y-диапазон заголовка нужной даты
|
||||
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
|
||||
if (dateHeaderY == -2) {
|
||||
// Target застрял в sticky-пачке (Ozon стекает заголовки выше viewport
|
||||
// в одну Y-позицию). Ниже пачки есть anchored более старая дата →
|
||||
// секцию target уже проскроллили мимо. Скроллим обратно вверх,
|
||||
// чтобы target распался из пачки и занял реальный Y.
|
||||
qDebug() << "[FetchTransactions] Target piled in sticky band — scrolling up to detach";
|
||||
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
|
||||
device.screenHeight / 3,
|
||||
device.screenWidth / 2,
|
||||
device.screenHeight * 2 / 3, 600);
|
||||
QThread::msleep(1500);
|
||||
continue;
|
||||
}
|
||||
if (dateHeaderY < 0) {
|
||||
// Target-header не виден и ни одной более старой даты не видно →
|
||||
// target скорее всего ниже вьюпорта. Скроллим вниз (swipe вверх).
|
||||
|
||||
@ -174,9 +174,11 @@ void GetProfileInfoScript::doStart() {
|
||||
AppLogger::log("ozon/GetProfileInfo", m_deviceId, err,
|
||||
AdbUtils::captureScreenshotBytes(m_deviceId),
|
||||
"FETCH_PROFILE");
|
||||
} else {
|
||||
qDebug() << "[GetProfileInfo] postBankProfile done";
|
||||
m_error = err;
|
||||
emit finishedWithResult(m_error);
|
||||
return;
|
||||
}
|
||||
qDebug() << "[GetProfileInfo] postBankProfile done";
|
||||
} else {
|
||||
ns.addExtra("bank_profile_id", freshApp.bankProfileId);
|
||||
ns.patchBankProfile();
|
||||
|
||||
@ -76,8 +76,31 @@ void LoginAndCheckAccountsScript::doStart() {
|
||||
BankProfileDAO::updateAmount(app.id, parsedBalance);
|
||||
}
|
||||
|
||||
// Освежаем amount только для уже существующих материалов (cardNumber/materialId
|
||||
// и прочие поля сохраняем). Новые карты здесь не создаём — запись появится
|
||||
// только после успешного OCR полного номера в GetCardInfoScript.
|
||||
// Если ниже goToAllProducts + parseAccountsInfo отработают — они уточнят
|
||||
// per-card сумму поверх.
|
||||
for (const MaterialInfo &card : parsedCards) {
|
||||
const MaterialInfo existing = MaterialDAO::findAppAccount(device.id, m_appCode, card.lastNumbers);
|
||||
if (existing.id == -1) continue;
|
||||
MaterialInfo refresh = existing;
|
||||
refresh.amount = card.amount;
|
||||
refresh.updateTime = QDateTime::currentDateTimeUtc();
|
||||
if (!MaterialDAO::upsertAccount(refresh)) {
|
||||
qWarning() << "[LoginAndCheck] Failed to refresh amount for card" << card.lastNumbers;
|
||||
} else {
|
||||
qDebug() << "[LoginAndCheck] Refreshed amount for card"
|
||||
<< card.lastNumbers << "to" << card.amount;
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём/обновляем bank profile на сервере
|
||||
createBankProfileIfNeeded(device.id, m_appCode, parsedBalance);
|
||||
if (const QString err = createBankProfileIfNeeded(device.id, m_appCode, parsedBalance); !err.isEmpty()) {
|
||||
m_error = err;
|
||||
emit finishedWithResult(m_error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (goToAllProducts(m_deviceId, device.screenWidth, device.screenHeight)) {
|
||||
QList<QPair<MaterialInfo, Node> > accounts = xmlScreenParser.parseAccountsInfo();
|
||||
|
||||
@ -287,30 +287,17 @@ void PayByCardScript::makePayment(
|
||||
if (!sumInput.isEmpty()) break;
|
||||
|
||||
// Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO).
|
||||
// На экране "Перевод по номеру карты" всегда 2 EditText: карта (сверху)
|
||||
// и сумма (снизу). Стратегии по приоритету:
|
||||
// 1) Svelte генерирует resource-id="input___xxxx" ТОЛЬКО для суммы —
|
||||
// берём этот EditText.
|
||||
// 2) Если rid нет (старая версия Ozon UI) — берём второй EditText
|
||||
// по y-порядку (карта y1=меньше, сумма y1=больше).
|
||||
QList<Node> candidateInputs;
|
||||
// На экране "Перевод по номеру карты" всегда 2 EditText в DOM-порядке:
|
||||
// карта (сверху), сумма (снизу). Оба имеют resource-id="input___xxxx",
|
||||
// поэтому отличать по rid нельзя — берём второй по порядку обхода XML.
|
||||
QList<Node> editTexts;
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (n.className != "android.widget.EditText") continue;
|
||||
if (n.height() <= 0 || n.height() > height * 4 / 5) continue;
|
||||
candidateInputs.append(n);
|
||||
editTexts.append(n);
|
||||
}
|
||||
// Strategy 1: rid="input___"
|
||||
for (const Node &n : candidateInputs) {
|
||||
if (n.resourceId.startsWith("input___")) {
|
||||
sumInput = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Strategy 2: bottom-most по y1 (если их 2 — берём второй)
|
||||
if (sumInput.isEmpty() && candidateInputs.size() >= 2) {
|
||||
std::sort(candidateInputs.begin(), candidateInputs.end(),
|
||||
[](const Node &a, const Node &b) { return a.y1() < b.y1(); });
|
||||
sumInput = candidateInputs.last();
|
||||
if (editTexts.size() >= 2) {
|
||||
sumInput = editTexts[1];
|
||||
}
|
||||
if (!sumInput.isEmpty()) break;
|
||||
|
||||
|
||||
@ -67,6 +67,14 @@ static QString extractTransactionId(const QString &ocrText) {
|
||||
|
||||
namespace Ozon {
|
||||
|
||||
static QString sanitizeBankName(const QString &raw) {
|
||||
// "Озон банк (Ozon)" -> "Озон банк"; убираем содержимое круглых скобок
|
||||
// вместе с предшествующими пробелами и обрезаем края.
|
||||
QString s = raw;
|
||||
s.remove(QRegularExpression(R"(\s*\([^)]*\))"));
|
||||
return s.trimmed();
|
||||
}
|
||||
|
||||
PayByPhoneScript::PayByPhoneScript(
|
||||
MaterialInfo account,
|
||||
QString phone,
|
||||
@ -75,7 +83,7 @@ PayByPhoneScript::PayByPhoneScript(
|
||||
QObject *parent
|
||||
) : CommonScript(parent),
|
||||
m_account(std::move(account)), m_phone(std::move(phone)),
|
||||
m_bankName(std::move(bankName)), m_amount(amount) {
|
||||
m_bankName(sanitizeBankName(bankName)), m_amount(amount) {
|
||||
}
|
||||
|
||||
PayByPhoneScript::~PayByPhoneScript() = default;
|
||||
@ -162,7 +170,11 @@ void PayByPhoneScript::makePayment(
|
||||
}
|
||||
return true;
|
||||
};
|
||||
// 1. Нажимаем "Перевести" на домашнем экране
|
||||
// 1. Нажимаем "Перевести" на домашнем экране.
|
||||
// Внимание: поверх правой части кнопки сидит referral-бейдж "badge icon за друга"
|
||||
// (bounds типа [550,90][602,140]), который перекрывает центр кнопки. Тап в центр
|
||||
// открывает промо "До 3 000 за рекомендацию". Тапаем в левую четверть, чтобы
|
||||
// гарантированно попасть мимо бейджа.
|
||||
Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false);
|
||||
if (transferBtn.isEmpty()) {
|
||||
m_error = "Кнопка 'Перевести' не найдена на домашнем экране";
|
||||
@ -170,8 +182,13 @@ void PayByPhoneScript::makePayment(
|
||||
return;
|
||||
}
|
||||
|
||||
qDebug() << "[PayByPhone] Tapping 'Перевести'...";
|
||||
AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y());
|
||||
// Тапаем в нижнюю часть кнопки (там, где визуально надпись "Перевести"),
|
||||
// ниже бейджа "за друга" (он заканчивается на y≈140).
|
||||
const int transferTapX = transferBtn.x();
|
||||
const int transferTapY = transferBtn.y1() + transferBtn.height() * 4 / 5;
|
||||
qDebug() << "[PayByPhone] Tapping 'Перевести' at" << transferTapX << "," << transferTapY
|
||||
<< "(below 'за друга' badge)";
|
||||
AdbUtils::makeTap(deviceId, transferTapX, transferTapY);
|
||||
QThread::msleep(2000);
|
||||
|
||||
// 2. Ждём экран "Платежи и переводы" с полем "Телефон получателя"
|
||||
@ -296,28 +313,54 @@ void PayByPhoneScript::makePayment(
|
||||
|
||||
// Экран выбора банка: ищем "Другой банк" (текст) или EditText с hint "Название банка"
|
||||
if (!bankSelected) {
|
||||
// Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой)
|
||||
bool foundOtherBank = false;
|
||||
{
|
||||
Node node;
|
||||
// Вариант 1: кнопка "Другой банк" (content-desc="other-bank-item", text пустой).
|
||||
// На устройствах с маленьким экраном кнопка лежит в конце виртуализированного
|
||||
// списка и приходит с bounds=[0,0][0,0] — Node::isEmpty это не ловит, тап
|
||||
// улетает в (0,0). Скроллим список вниз, пока bounds не станут ненулевыми
|
||||
// (после первого скролла кнопка обычно материализуется у нижнего края, но
|
||||
// тап по ней работает — это её настоящая позиция в layout).
|
||||
auto findOtherBank = [&]() -> Node {
|
||||
for (const Node &n : xmlScreenParser.findAllNodes()) {
|
||||
if (n.contentDesc.contains("other-bank-item")) {
|
||||
node = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!node.isEmpty()) {
|
||||
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item)...";
|
||||
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||
QThread::msleep(2000);
|
||||
foundOtherBank = true;
|
||||
otherBankClicked = true;
|
||||
if (n.contentDesc.contains("other-bank-item")) return n;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
bool foundOtherBank = false;
|
||||
Node node = findOtherBank();
|
||||
for (int scrollAttempt = 0;
|
||||
!node.isEmpty() && (node.width() == 0 || node.height() == 0) && scrollAttempt < 6;
|
||||
++scrollAttempt) {
|
||||
qDebug() << "[PayByPhone] 'Другой банк' has collapsed bounds, scrolling list down, attempt"
|
||||
<< scrollAttempt + 1 << "/ 6";
|
||||
AdbUtils::makeSwipe(deviceId, width / 2, height * 3 / 4, width / 2, height / 4);
|
||||
QThread::msleep(800);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
node = findOtherBank();
|
||||
}
|
||||
|
||||
if (!node.isEmpty() && node.width() > 0 && node.height() > 0) {
|
||||
qDebug() << "[PayByPhone] Tapping 'Другой банк' (other-bank-item) at"
|
||||
<< node.x() << "," << node.y();
|
||||
AdbUtils::makeTap(deviceId, node.x(), node.y());
|
||||
QThread::msleep(2000);
|
||||
foundOtherBank = true;
|
||||
otherBankClicked = true;
|
||||
}
|
||||
if (foundOtherBank) continue;
|
||||
|
||||
// Вариант 2: уже на экране поиска банка (EditText) — только после клика на "Другой банк"
|
||||
if (otherBankClicked) {
|
||||
// После перехода на экран поиска банка поле ввода прячется под верхним
|
||||
// тулбаром (рендерится Svelte WebView'ом). Свайпаем сверху вниз, чтобы
|
||||
// подтянуть содержимое и сделать поле физически видимым/активным.
|
||||
qDebug() << "[PayByPhone] Scrolling up to reveal hidden bank search input...";
|
||||
AdbUtils::makeSwipe(deviceId, width / 2, height / 4, width / 2, height * 3 / 4);
|
||||
QThread::msleep(800);
|
||||
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
|
||||
closeGooglePlayBannerIfVisible(deviceId, width, height);
|
||||
|
||||
Node bankInput = xmlScreenParser.findFirstEditText();
|
||||
if (!bankInput.isEmpty()) {
|
||||
qDebug() << "[PayByPhone] Bank search screen detected, entering bank name...";
|
||||
|
||||
@ -426,11 +426,19 @@ Node ScreenXmlParser::findCardButtonOnHomeScreen() {
|
||||
Node ScreenXmlParser::findCardButtonByLastNumbers(const QString &lastNumbers) {
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
||||
if (node.text.trimmed().endsWith(lastNumbers)) {
|
||||
qDebug() << "[findCardBtn] Found by lastNumbers" << lastNumbers
|
||||
<< "text=" << node.text << "at" << node.x() << node.y();
|
||||
return node;
|
||||
const QString t = node.text.trimmed();
|
||||
if (!t.endsWith(lastNumbers)) continue;
|
||||
// Отсекаем строки из ленты переводов (см. parseCardsOnHomeScreen)
|
||||
if (t.contains(QString::fromUtf8("Переводы"))
|
||||
|| t.contains(QStringLiteral("**"))
|
||||
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'))) {
|
||||
qDebug() << "[findCardBtn] Skipping transfer-feed row for lastNumbers"
|
||||
<< lastNumbers << "text=" << node.text;
|
||||
continue;
|
||||
}
|
||||
qDebug() << "[findCardBtn] Found by lastNumbers" << lastNumbers
|
||||
<< "text=" << node.text << "at" << node.x() << node.y();
|
||||
return node;
|
||||
}
|
||||
qWarning() << "[findCardBtn] Not found for lastNumbers=" << lastNumbers
|
||||
<< ". Clickable buttons:";
|
||||
@ -514,13 +522,26 @@ QList<MaterialInfo> ScreenXmlParser::parseCardsOnHomeScreen() {
|
||||
if (commonAmount == 0.0)
|
||||
qWarning() << "[parseCards] Balance button not found or amount=0";
|
||||
|
||||
// 2. Собираем все кнопки карт (текст заканчивается на 4 цифры)
|
||||
// 2. Собираем все кнопки карт (текст заканчивается на 4 цифры).
|
||||
// Записи из ленты переводов выглядят как кнопки и тоже заканчиваются на 4 цифры
|
||||
// ("Евгений Дмитриевич Р. − 100 ₽ Переводы • Карта **1092") — отсеиваем по маркерам.
|
||||
QList<MaterialInfo> cards;
|
||||
for (const Node &node : m_nodes) {
|
||||
if (node.className != "android.widget.Button" || !node.clickable) continue;
|
||||
QString t = node.text.trimmed();
|
||||
if (!endsWithFourDigits.match(t).hasMatch()) continue;
|
||||
|
||||
// Маркеры записи из ленты переводов:
|
||||
// - слово "Переводы"
|
||||
// - маска "**" перед цифрами
|
||||
// - знак суммы транзакции (минус U+2212 или плюс)
|
||||
if (t.contains(QString::fromUtf8("Переводы"))
|
||||
|| t.contains(QStringLiteral("**"))
|
||||
|| t.contains(QChar(0x2212)) || t.contains(QChar('+'))) {
|
||||
qDebug() << "[parseCards] Skipping transfer-feed row:" << t;
|
||||
continue;
|
||||
}
|
||||
|
||||
MaterialInfo acc;
|
||||
acc.lastNumbers = t.right(4);
|
||||
acc.amount = commonAmount;
|
||||
|
||||
BIN
assets/dushanbe/3.2.33/card_detail.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
1
assets/dushanbe/3.2.33/card_detail.xml
Normal file
BIN
assets/dushanbe/3.2.33/card_transfer_confirm.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
1
assets/dushanbe/3.2.33/card_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000186263942 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||
BIN
assets/dushanbe/3.2.33/card_transfer_confirm_2.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
1
assets/dushanbe/3.2.33/card_transfer_confirm_2.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру карты) Номер/Счет 9762000104225205 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||
BIN
assets/dushanbe/3.2.33/card_transfer_input.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
1
assets/dushanbe/3.2.33/card_transfer_input.xml
Normal file
BIN
assets/dushanbe/3.2.33/card_transfer_success.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
1
assets/dushanbe/3.2.33/card_transfer_success.xml
Normal file
BIN
assets/dushanbe/3.2.33/card_transfer_tx_detail.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
1
assets/dushanbe/3.2.33/card_transfer_tx_detail.xml
Normal file
BIN
assets/dushanbe/3.2.33/cards_screen.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
1
assets/dushanbe/3.2.33/cards_screen.xml
Normal file
BIN
assets/dushanbe/3.2.33/history_screen.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
1
assets/dushanbe/3.2.33/history_screen.xml
Normal file
BIN
assets/dushanbe/3.2.33/home_screen.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
1
assets/dushanbe/3.2.33/home_screen.xml
Normal file
BIN
assets/dushanbe/3.2.33/launch_screen.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
1
assets/dushanbe/3.2.33/launch_screen.xml
Normal file
BIN
assets/dushanbe/3.2.33/phone_transfer_confirm.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
1
assets/dushanbe/3.2.33/phone_transfer_confirm.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа 1.00 TJS Поставщик: DC (по номеру телефона) Номер/Счет 559411001 Способ оплаты: 9762 **** **** 8865 Сумма зачисления: 1.00 TJS Комиссия: 0.00 TJS Сумма списания: 1.00 TJS Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||
BIN
assets/dushanbe/3.2.33/phone_transfer_input.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
1
assets/dushanbe/3.2.33/phone_transfer_input.xml
Normal file
BIN
assets/dushanbe/3.2.33/phone_transfer_success.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
1
assets/dushanbe/3.2.33/phone_transfer_success.xml
Normal file
BIN
assets/dushanbe/3.2.33/side_menu.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
1
assets/dushanbe/3.2.33/side_menu.xml
Normal file
BIN
assets/dushanbe/3.2.33/transfers_screen.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
1
assets/dushanbe/3.2.33/transfers_screen.xml
Normal file
BIN
assets/dushanbe/3.2.33/tx_detail.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
1
assets/dushanbe/3.2.33/tx_detail.xml
Normal file
@ -0,0 +1 @@
|
||||
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Успешный платёж 21.05.2026 16:58:32 -10 Карта 9762 0002 2091 6042 Получатель DCWallet*WDC0000**TAJIKISTAN 992176827668-9762000191282739- Операция Дебетная часть P2P/операции Дата 21.05.2026 Время 16:58:32 Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]"><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][720,1640]" /></node></node></node></node></node></node><node index="1" text="" resource-id="android:id/navigationBarBackground" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,1544][720,1640]" /></node></hierarchy>
|
||||
@ -10,7 +10,7 @@ set CONFIG_FILE=%PROJECT_DIR%config.ini
|
||||
set TG_BOT_TOKEN=8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8
|
||||
set TG_CHAT_ID=-5177086220
|
||||
|
||||
for /f "tokens=2 delims==" %%V in ('findstr /i "version=" "%CONFIG_FILE%"') do set OLD_VERSION=%%V
|
||||
for /f "tokens=2 delims==" %%V in ('findstr /b /i "version=" "%CONFIG_FILE%"') do set OLD_VERSION=%%V
|
||||
set CURRENT_VERSION=%OLD_VERSION%
|
||||
|
||||
if "%~1"=="-up-version" (
|
||||
@ -21,7 +21,7 @@ if "%~1"=="-up-version" (
|
||||
set /a PATCH=%%C+1
|
||||
)
|
||||
set CURRENT_VERSION=!MAJOR!.!MINOR!.!PATCH!
|
||||
powershell -NoProfile -Command "(Get-Content '!CONFIG_FILE!') -replace 'version=!OLD_VERSION!', 'version=!CURRENT_VERSION!' | Set-Content '!CONFIG_FILE!'"
|
||||
powershell -NoProfile -Command "(Get-Content '!CONFIG_FILE!') -replace '^version=!OLD_VERSION!$', 'version=!CURRENT_VERSION!' | Set-Content '!CONFIG_FILE!'"
|
||||
echo Version: !OLD_VERSION! -^> !CURRENT_VERSION!
|
||||
) else (
|
||||
echo Version: !CURRENT_VERSION!
|
||||
|
||||
@ -3,7 +3,7 @@ list=ozon, black, dushanbe_city_bank
|
||||
|
||||
[common]
|
||||
currencies=USD, ARS, RUB, TJS
|
||||
version=0.0.9
|
||||
version=1.0.64
|
||||
|
||||
[db]
|
||||
dbPath=./database/app_data.db
|
||||
@ -18,8 +18,11 @@ pathDesktopCreate=/api/v1/desktop/
|
||||
pathDevice=/api/v1/device/
|
||||
pathDeviceScreenshot=/api/v1/device/add_screenshot/
|
||||
pathMaterial=/api/v1/profile/material
|
||||
pathProfilePing=/api/v1/profile/ping
|
||||
pathMonitoringLog=/monitoring/log
|
||||
pathMonitoringDump=/monitoring/dump
|
||||
monitoringBase=http://142.93.102.40:8080
|
||||
monitoringToken=509b91592749959b9ff56f9ed9189e5b3e91e1260003f63c477255e1c187298a
|
||||
|
||||
[black]
|
||||
android_package_name=app.blackwallet.ar
|
||||
@ -27,6 +30,7 @@ currency=ARS
|
||||
country=argentina
|
||||
name=Black
|
||||
phone_code=54
|
||||
app_version=0
|
||||
|
||||
[ozon]
|
||||
android_package_name=ru.ozon.fintech.finance
|
||||
@ -34,6 +38,7 @@ currency=RUB
|
||||
country=russia
|
||||
name=Ozon
|
||||
phone_code=7
|
||||
app_version=19.17.0
|
||||
|
||||
[dushanbe_city_bank]
|
||||
android_package_name=tj.dc.next1
|
||||
@ -41,3 +46,4 @@ currency=TJS
|
||||
country=tajikistan
|
||||
name=Dushanbe City
|
||||
phone_code=992
|
||||
app_version=3.2.34
|
||||
|
||||
@ -119,10 +119,16 @@ void DatabaseManager::initSchema() {
|
||||
bank_profile_id TEXT DEFAULT '',
|
||||
currency TEXT DEFAULT '',
|
||||
amount REAL DEFAULT 0,
|
||||
app_version TEXT DEFAULT '',
|
||||
app_version_checked_at DATETIME,
|
||||
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE,
|
||||
UNIQUE (device_id, code)
|
||||
))");
|
||||
|
||||
// Миграция: добавляем колонки app_version и app_version_checked_at если их нет (ошибка = уже существует)
|
||||
q.exec("ALTER TABLE bank_profiles ADD COLUMN app_version TEXT DEFAULT ''");
|
||||
q.exec("ALTER TABLE bank_profiles ADD COLUMN app_version_checked_at DATETIME");
|
||||
|
||||
q.exec(R"(CREATE TABLE IF NOT EXISTS materials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_id TEXT,
|
||||
@ -167,25 +173,27 @@ void DatabaseManager::initSchema() {
|
||||
))");
|
||||
|
||||
q.exec(R"(CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
external_id TEXT,
|
||||
device_id TEXT,
|
||||
amount REAL,
|
||||
phone TEXT,
|
||||
bank_name TEXT,
|
||||
comment TEXT,
|
||||
status TEXT,
|
||||
type TEXT,
|
||||
update_time DATETIME,
|
||||
complete_time DATETIME,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
json TEXT DEFAULT '',
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
external_id TEXT,
|
||||
transaction_id TEXT,
|
||||
device_id TEXT,
|
||||
amount REAL,
|
||||
phone TEXT,
|
||||
bank_name TEXT,
|
||||
comment TEXT,
|
||||
status TEXT,
|
||||
type TEXT,
|
||||
update_time DATETIME,
|
||||
complete_time DATETIME,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
json TEXT DEFAULT '',
|
||||
sent_to_server INTEGER DEFAULT 0,
|
||||
screenshot BLOB,
|
||||
screenshot BLOB,
|
||||
FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE
|
||||
))");
|
||||
q.exec("ALTER TABLE events ADD COLUMN transaction_id TEXT");
|
||||
|
||||
q.exec(R"(CREATE TABLE IF NOT EXISTS banks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
#include "AppLogDAO.h"
|
||||
|
||||
#include <QSqlQuery>
|
||||
#include <QSqlError>
|
||||
#include <QDebug>
|
||||
|
||||
#include "DatabaseManager.h"
|
||||
#include "time/DateUtils.h"
|
||||
|
||||
bool AppLogDAO::insert(const AppLogEntry &entry) {
|
||||
QSqlQuery q(DatabaseManager::instance().database());
|
||||
q.prepare(R"(
|
||||
INSERT INTO app_logs (source, device_id, message, screenshot, timestamp)
|
||||
VALUES (:source, :device_id, :message, :screenshot, :timestamp)
|
||||
)");
|
||||
q.bindValue(":source", entry.source);
|
||||
q.bindValue(":device_id", entry.deviceId);
|
||||
q.bindValue(":message", entry.message);
|
||||
q.bindValue(":screenshot", entry.screenshot.isEmpty()
|
||||
? QVariant(QMetaType(QMetaType::QByteArray))
|
||||
: QVariant(entry.screenshot));
|
||||
q.bindValue(":timestamp", DateUtils::toUtcString(entry.timestamp));
|
||||
if (!q.exec()) {
|
||||
qWarning() << "[AppLogDAO] insert failed:" << q.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QList<AppLogEntry> AppLogDAO::getLogs(int page, int pageSize) {
|
||||
QList<AppLogEntry> result;
|
||||
QSqlQuery q(DatabaseManager::instance().database());
|
||||
q.prepare(R"(
|
||||
SELECT id, source, device_id, message, timestamp
|
||||
FROM app_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
)");
|
||||
q.bindValue(":limit", pageSize);
|
||||
q.bindValue(":offset", page * pageSize);
|
||||
if (!q.exec()) {
|
||||
qWarning() << "[AppLogDAO] getLogs failed:" << q.lastError().text();
|
||||
return result;
|
||||
}
|
||||
while (q.next()) {
|
||||
AppLogEntry e;
|
||||
e.id = q.value("id").toInt();
|
||||
e.source = q.value("source").toString();
|
||||
e.deviceId = q.value("device_id").toString();
|
||||
e.message = q.value("message").toString();
|
||||
e.timestamp = DateUtils::fromUtcString(q.value("timestamp").toString());
|
||||
result.append(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int AppLogDAO::getTotalCount() {
|
||||
QSqlQuery q(DatabaseManager::instance().database());
|
||||
q.exec("SELECT COUNT(*) FROM app_logs");
|
||||
if (q.next()) return q.value(0).toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
QByteArray AppLogDAO::getScreenshot(int id) {
|
||||
QSqlQuery q(DatabaseManager::instance().database());
|
||||
q.prepare("SELECT screenshot FROM app_logs WHERE id = :id");
|
||||
q.bindValue(":id", id);
|
||||
if (!q.exec() || !q.next()) return {};
|
||||
return q.value(0).toByteArray();
|
||||
}
|
||||
|
||||
QList<AppLogEntry> AppLogDAO::getLogsWithScreenshots(int limit) {
|
||||
QList<AppLogEntry> result;
|
||||
QSqlQuery q(DatabaseManager::instance().database());
|
||||
q.prepare(R"(
|
||||
SELECT id, source, device_id, message, screenshot, timestamp
|
||||
FROM app_logs
|
||||
ORDER BY id DESC
|
||||
LIMIT :limit
|
||||
)");
|
||||
q.bindValue(":limit", limit);
|
||||
if (!q.exec()) {
|
||||
qWarning() << "[AppLogDAO] getLogsWithScreenshots failed:" << q.lastError().text();
|
||||
return result;
|
||||
}
|
||||
while (q.next()) {
|
||||
AppLogEntry e;
|
||||
e.id = q.value("id").toInt();
|
||||
e.source = q.value("source").toString();
|
||||
e.deviceId = q.value("device_id").toString();
|
||||
e.message = q.value("message").toString();
|
||||
e.screenshot = q.value("screenshot").toByteArray();
|
||||
e.timestamp = DateUtils::fromUtcString(q.value("timestamp").toString());
|
||||
result.append(e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
#pragma once
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include "db/AppLogEntry.h"
|
||||
|
||||
class AppLogDAO {
|
||||
public:
|
||||
[[nodiscard]] static bool insert(const AppLogEntry &entry);
|
||||
|
||||
// Возвращает записи без BLOB-скриншота (лёгкая загрузка для таблицы)
|
||||
[[nodiscard]] static QList<AppLogEntry> getLogs(int page, int pageSize);
|
||||
|
||||
[[nodiscard]] static int getTotalCount();
|
||||
|
||||
// Загружает скриншот отдельно по id (по клику в UI)
|
||||
[[nodiscard]] static QByteArray getScreenshot(int id);
|
||||
|
||||
// Все записи со скриншотами (для экспорта)
|
||||
[[nodiscard]] static QList<AppLogEntry> getLogsWithScreenshots(int limit = 100);
|
||||
};
|
||||
@ -44,25 +44,42 @@ BankInfo BankDAO::findByAlias(const QString &alias) {
|
||||
|
||||
void BankDAO::replaceAll(const QList<BankInfo> &banks) {
|
||||
QSqlDatabase db = DatabaseManager::instance().database();
|
||||
|
||||
if (!db.transaction()) {
|
||||
qWarning() << "[BankDAO] replaceAll: cannot begin transaction:" << db.lastError().text();
|
||||
return;
|
||||
}
|
||||
|
||||
QSqlQuery query(db);
|
||||
if (!query.exec("DELETE FROM banks")) {
|
||||
qWarning() << "[BankDAO] replaceAll: DELETE failed:" << query.lastError().text();
|
||||
db.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
db.transaction();
|
||||
|
||||
query.exec("DELETE FROM banks");
|
||||
|
||||
query.prepare(R"(
|
||||
if (!query.prepare(R"(
|
||||
INSERT INTO banks (alias, name, nspk_name)
|
||||
VALUES (:alias, :name, :nspk_name)
|
||||
)");
|
||||
)")) {
|
||||
qWarning() << "[BankDAO] replaceAll: prepare INSERT failed:" << query.lastError().text();
|
||||
db.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const BankInfo &bank : banks) {
|
||||
query.bindValue(":alias", bank.alias);
|
||||
query.bindValue(":name", bank.name);
|
||||
query.bindValue(":nspk_name", bank.nspkName);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "Ошибка при вставке bank:" << query.lastError().text();
|
||||
qWarning() << "[BankDAO] replaceAll: INSERT failed for alias" << bank.alias
|
||||
<< ":" << query.lastError().text() << "— rolling back";
|
||||
db.rollback();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
db.commit();
|
||||
if (!db.commit()) {
|
||||
qWarning() << "[BankDAO] replaceAll: commit failed:" << db.lastError().text();
|
||||
db.rollback();
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,6 +151,8 @@ BankProfileInfo parseApplication(const QSqlQuery &query) {
|
||||
app.bankProfileId = query.value("bank_profile_id").toString();
|
||||
app.currency = query.value("currency").toString();
|
||||
app.amount = query.value("amount").toDouble();
|
||||
app.appVersion = query.value("app_version").toString();
|
||||
app.appVersionCheckedAt = query.value("app_version_checked_at").toDateTime();
|
||||
return app;
|
||||
}
|
||||
|
||||
@ -158,7 +160,7 @@ BankProfileInfo BankProfileDAO::getApplication(const QString &deviceId, const QS
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
|
||||
query.prepare(R"(
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at
|
||||
FROM bank_profiles
|
||||
WHERE device_id = :device_id AND code = :code
|
||||
LIMIT 1
|
||||
@ -183,7 +185,7 @@ QList<BankProfileInfo> BankProfileDAO::getApplicationsByDeviceId(const QString &
|
||||
QList<BankProfileInfo> list;
|
||||
|
||||
query.prepare(R"(
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at
|
||||
FROM bank_profiles
|
||||
WHERE device_id = :device_id AND install = 'yes'
|
||||
)");
|
||||
@ -206,7 +208,7 @@ QList<BankProfileInfo> BankProfileDAO::getAllApplicationsByDeviceId(const QStrin
|
||||
QList<BankProfileInfo> list;
|
||||
|
||||
query.prepare(R"(
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at
|
||||
FROM bank_profiles
|
||||
WHERE device_id = :device_id
|
||||
)");
|
||||
@ -262,6 +264,35 @@ bool BankProfileDAO::updateAmount(const int appId, const double amount) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankProfileDAO::adjustAmount(const int appId, const double delta) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare("UPDATE bank_profiles SET amount = amount - :delta WHERE id = :id");
|
||||
query.bindValue(":delta", delta);
|
||||
query.bindValue(":id", appId);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "[BankProfileDAO] adjustAmount failed:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankProfileDAO::updateAppVersion(const int appId, const QString &appVersion) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
UPDATE bank_profiles
|
||||
SET app_version = :app_version, app_version_checked_at = :checked_at
|
||||
WHERE id = :id
|
||||
)");
|
||||
query.bindValue(":app_version", appVersion);
|
||||
query.bindValue(":checked_at", QDateTime::currentDateTimeUtc());
|
||||
query.bindValue(":id", appId);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "Ошибка при обновлении app_version:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BankProfileDAO::updateBankProfileId(const int appId, const QString &bankProfileId) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare("UPDATE bank_profiles SET bank_profile_id = :bank_profile_id WHERE id = :id");
|
||||
@ -278,7 +309,7 @@ QList<BankProfileInfo> BankProfileDAO::findAllByBankProfileId(const QString &ban
|
||||
QList<BankProfileInfo> list;
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount
|
||||
SELECT id, device_id, pin_code, code, name, package, status, comment, install, pin_code_checked, phone, email, full_name, bank_profile_id, currency, amount, app_version, app_version_checked_at
|
||||
FROM bank_profiles
|
||||
WHERE bank_profile_id = :bank_profile_id
|
||||
)");
|
||||
|
||||
@ -36,6 +36,12 @@ public:
|
||||
|
||||
[[nodiscard]] static bool updateAmount(int appId, double amount);
|
||||
|
||||
// Атомарно вычитает delta из bank_profiles.amount.
|
||||
// delta > 0 — расход, delta < 0 — пополнение.
|
||||
[[nodiscard]] static bool adjustAmount(int appId, double delta);
|
||||
|
||||
[[nodiscard]] static bool updateAppVersion(int appId, const QString &appVersion);
|
||||
|
||||
[[nodiscard]] static BankProfileInfo getApplication(const QString &deviceId, const QString &appCode);
|
||||
|
||||
[[nodiscard]] static QList<BankProfileInfo> getApplicationsByDeviceId(const QString &deviceId);
|
||||
|
||||
@ -13,6 +13,7 @@ EventInfo parseEvent(const QSqlQuery &query) {
|
||||
event.status = eventStatusFromString(query.value("status").toString());
|
||||
event.type = eventTypeFromString(query.value("type").toString());
|
||||
event.externalId = query.value("external_id").toString();
|
||||
event.transactionId = query.value("transaction_id").toString();
|
||||
event.accountId = query.value("account_id").toInt();
|
||||
event.deviceId = query.value("device_id").toString();
|
||||
event.amount = query.value("amount").toDouble();
|
||||
@ -36,15 +37,16 @@ int EventDAO::insertEvent(const EventInfo &event) {
|
||||
|
||||
query.prepare(R"(
|
||||
INSERT INTO events (
|
||||
status, external_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type
|
||||
status, external_id, transaction_id, account_id, amount, phone, bank_name, comment, json, timestamp, device_id, type
|
||||
) VALUES (
|
||||
:status, :external_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type
|
||||
:status, :external_id, :transaction_id, :account_id, :amount, :phone, :bank_name, :comment, :json, :timestamp, :device_id, :type
|
||||
)
|
||||
)");
|
||||
|
||||
query.bindValue(":status", eventStatusToString(event.status));
|
||||
query.bindValue(":type", eventTypeToString(event.type));
|
||||
query.bindValue(":external_id", event.externalId);
|
||||
query.bindValue(":transaction_id", event.transactionId);
|
||||
query.bindValue(":account_id", event.accountId);
|
||||
query.bindValue(":device_id", event.deviceId);
|
||||
query.bindValue(":amount", event.amount);
|
||||
@ -139,7 +141,7 @@ QList<EventInfo> EventDAO::getAllEvents(const EventStatus &status) {
|
||||
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
|
||||
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
|
||||
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
|
||||
FROM events
|
||||
WHERE status = :status
|
||||
@ -161,7 +163,7 @@ QList<EventInfo> EventDAO::getAllEvents(const EventStatus &status) {
|
||||
EventInfo EventDAO::getFirstWaitEventByDeviceId(const QString &key) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
|
||||
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
|
||||
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
|
||||
FROM events
|
||||
WHERE status = :status AND device_id = :device_id
|
||||
@ -193,7 +195,7 @@ QList<EventInfo> EventDAO::getEvents(int page, int pageSize) {
|
||||
QList<EventInfo> events;
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
|
||||
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
|
||||
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp,
|
||||
(LENGTH(screenshot) > 0) as has_screenshot
|
||||
FROM events
|
||||
@ -276,7 +278,7 @@ QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev
|
||||
QList<EventInfo> cancelled;
|
||||
QSqlQuery selectQuery(DatabaseManager::instance().database());
|
||||
selectQuery.prepare(R"(
|
||||
SELECT id, external_id, bank_name, type FROM events
|
||||
SELECT id, external_id, transaction_id, bank_name, type FROM events
|
||||
WHERE external_id = :material_id AND type = :type AND status = :wait
|
||||
)");
|
||||
selectQuery.bindValue(":material_id", materialId);
|
||||
@ -288,8 +290,9 @@ QList<EventInfo> EventDAO::cancelWaitingByMaterial(const QString &materialId, Ev
|
||||
EventInfo e;
|
||||
e.id = selectQuery.value(0).toInt();
|
||||
e.externalId = selectQuery.value(1).toString();
|
||||
e.bankName = selectQuery.value(2).toString();
|
||||
e.type = eventTypeFromString(selectQuery.value(3).toString());
|
||||
e.transactionId = selectQuery.value(2).toString();
|
||||
e.bankName = selectQuery.value(3).toString();
|
||||
e.type = eventTypeFromString(selectQuery.value(4).toString());
|
||||
cancelled.append(e);
|
||||
}
|
||||
}
|
||||
@ -315,12 +318,12 @@ QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp
|
||||
QSqlQuery selectQuery(DatabaseManager::instance().database());
|
||||
if (bankName.isEmpty()) {
|
||||
selectQuery.prepare(R"(
|
||||
SELECT id, external_id, bank_name, type FROM events
|
||||
SELECT id, external_id, transaction_id, bank_name, type FROM events
|
||||
WHERE device_id = :device_id AND type = :type AND status = :wait
|
||||
)");
|
||||
} else {
|
||||
selectQuery.prepare(R"(
|
||||
SELECT id, external_id, bank_name, type FROM events
|
||||
SELECT id, external_id, transaction_id, bank_name, type FROM events
|
||||
WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name
|
||||
)");
|
||||
selectQuery.bindValue(":bank_name", bankName);
|
||||
@ -334,8 +337,9 @@ QList<EventInfo> EventDAO::cancelWaitingByType(const QString &deviceId, EventTyp
|
||||
EventInfo e;
|
||||
e.id = selectQuery.value(0).toInt();
|
||||
e.externalId = selectQuery.value(1).toString();
|
||||
e.bankName = selectQuery.value(2).toString();
|
||||
e.type = eventTypeFromString(selectQuery.value(3).toString());
|
||||
e.transactionId = selectQuery.value(2).toString();
|
||||
e.bankName = selectQuery.value(3).toString();
|
||||
e.type = eventTypeFromString(selectQuery.value(4).toString());
|
||||
cancelled.append(e);
|
||||
}
|
||||
}
|
||||
@ -369,7 +373,7 @@ QList<EventInfo> EventDAO::cancelWaitingByBank(const QString &deviceId, const QS
|
||||
QList<EventInfo> cancelled;
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
|
||||
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
|
||||
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
|
||||
FROM events
|
||||
WHERE device_id = :device_id AND bank_name = :bank_name AND status = :wait
|
||||
@ -425,7 +429,7 @@ QList<EventInfo> EventDAO::getRecentEvents(int hours) {
|
||||
QList<EventInfo> events;
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
|
||||
SELECT id, account_id, device_id, external_id, transaction_id, amount, phone, bank_name,
|
||||
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
|
||||
FROM events
|
||||
WHERE timestamp >= datetime('now', :hours)
|
||||
|
||||
@ -128,6 +128,50 @@ bool MaterialDAO::updateMaterialId(const int accountId, const QString &materialI
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MaterialDAO::adjustAmount(const int id, const double delta) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare("UPDATE materials SET amount = amount - :delta WHERE id = :id");
|
||||
query.bindValue(":delta", delta);
|
||||
query.bindValue(":id", id);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "[MaterialDAO] adjustAmount failed:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MaterialDAO::adjustAmountByApp(const QString &deviceId, const QString &appCode, const double delta) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
UPDATE materials SET amount = amount - :delta
|
||||
WHERE device_id = :device_id AND app_code = :app_code
|
||||
)");
|
||||
query.bindValue(":delta", delta);
|
||||
query.bindValue(":device_id", deviceId);
|
||||
query.bindValue(":app_code", appCode);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "[MaterialDAO] adjustAmountByApp failed:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MaterialDAO::setAmountByApp(const QString &deviceId, const QString &appCode, const double amount) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
UPDATE materials SET amount = :amount
|
||||
WHERE device_id = :device_id AND app_code = :app_code
|
||||
)");
|
||||
query.bindValue(":amount", amount);
|
||||
query.bindValue(":device_id", deviceId);
|
||||
query.bindValue(":app_code", appCode);
|
||||
if (!query.exec()) {
|
||||
qWarning() << "[MaterialDAO] setAmountByApp failed:" << query.lastError().text();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
MaterialInfo parseAccount(const QSqlQuery &query) {
|
||||
MaterialInfo acc;
|
||||
acc.id = query.value("id").toInt();
|
||||
@ -205,6 +249,7 @@ QList<MaterialInfo> MaterialDAO::getAccounts(const QString &deviceId, const QStr
|
||||
SELECT id, device_id, app_code, last_numbers, card_number, account_number, material_id, amount, update_time, status, description, currency
|
||||
FROM materials
|
||||
WHERE device_id = :device_id AND app_code = :app_code
|
||||
ORDER BY id
|
||||
)");
|
||||
|
||||
query.bindValue(":device_id", deviceId);
|
||||
|
||||
@ -26,6 +26,18 @@ public:
|
||||
|
||||
[[nodiscard]] static bool updateMaterialId(int accountId, const QString &materialId);
|
||||
|
||||
// Атомарно вычитает delta из materials.amount для одного материала.
|
||||
// delta > 0 — расход, delta < 0 — пополнение.
|
||||
[[nodiscard]] static bool adjustAmount(int id, double delta);
|
||||
|
||||
// Атомарно вычитает delta из materials.amount для всех материалов
|
||||
// в (deviceId, appCode) — для банков с одним счётом на все карты (Dushanbe).
|
||||
[[nodiscard]] static bool adjustAmountByApp(const QString &deviceId, const QString &appCode, double delta);
|
||||
|
||||
// Перезаписывает materials.amount абсолютным значением для всех материалов
|
||||
// в (deviceId, appCode). Используется после re-parse экрана.
|
||||
[[nodiscard]] static bool setAmountByApp(const QString &deviceId, const QString &appCode, double amount);
|
||||
|
||||
[[nodiscard]] static QList<MaterialInfo> getAccounts(const QString &deviceId, const QString &appName);
|
||||
|
||||
[[nodiscard]] static MaterialInfo getAccountById(int accountId);
|
||||
|
||||
@ -102,3 +102,54 @@ pathTaskResult = /api/v1/task/task_result
|
||||
|
||||
- **Bearer** — `Authorization: Bearer <access_token>` из `config.ini [common/access_token]`
|
||||
- **Legacy** — поле `token` в multipart-теле (из `config.ini [common/token]`)
|
||||
|
||||
---
|
||||
|
||||
## Таймауты и ретраи
|
||||
|
||||
Каждый синхронный запрос крутится в локальном `QEventLoop` под охраной `QTimer`. Если таймер сработал раньше, чем `reply->finished` — это **client timeout**: запрос аборитится и повторяется заново.
|
||||
|
||||
### Параметры
|
||||
|
||||
| Параметр | Значение | Где |
|
||||
|----------|----------|-----|
|
||||
| Лимит ожидания | **30 секунд** | `kDefaultTimeoutMs` — все запросы |
|
||||
| Лимит ожидания (dump) | **120 секунд** | `kDumpTimeoutMs` — только `postMonitoringDump` (большой zip-архив) |
|
||||
| Макс. попыток | **3** | `kMaxRequestAttempts` — 1 основная + 2 повтора |
|
||||
| Пауза между попытками | **0** | Ретрай идёт сразу после abort |
|
||||
|
||||
Константы и helper `awaitReply()` — в анонимном namespace в `services/net/NetworkService.cpp`.
|
||||
|
||||
### Когда ретраим
|
||||
|
||||
Ретрай срабатывает **только** при client timeout (наш `QTimer` сработал, reply не успел завершиться). На любых других исходах ретрая нет:
|
||||
|
||||
| Исход | Ретрай? | Почему |
|
||||
|-------|---------|--------|
|
||||
| Client timeout (наш QTimer) | ✅ да | сеть моргнула — стоит попробовать |
|
||||
| HTTP 4xx (401/404/409/…) | ❌ нет | детерминированная ошибка, повтор не поможет |
|
||||
| HTTP 5xx | ❌ нет | сервер ответил — это бизнес-сбой, не транспортный |
|
||||
| `ConnectionRefused` / DNS / SSL | ❌ нет | повтор не починит инфраструктурную проблему |
|
||||
| `success: false` в JSON | ❌ нет | прикладная ошибка |
|
||||
|
||||
После каждой неудачной попытки в лог пишется `Network timeout (POST) attempt N/3: <details>`; если все 3 попытки таймаутнулись — финальная строка `Timeout (POST) after 3 attempts: <details>` плюс запись в `GeneralLogDAO` (для `postJson`/`patchJson`).
|
||||
|
||||
### Покрытие методов
|
||||
|
||||
| Метод | Ретрай | Примечание |
|
||||
|-------|--------|------------|
|
||||
| `postJson` | ✅ | |
|
||||
| `patchJson` | ✅ | |
|
||||
| `getJson` | ✅ | |
|
||||
| `deleteJson` | ✅ | |
|
||||
| `get(path, params)` | ✅ | используется `getPayments()` (legacy) |
|
||||
| `syncBanksIfEmpty()` инлайн-GET | ✅ | |
|
||||
| `postDeviceScreenshot()` | ✅ | multipart пересобирается на каждой попытке |
|
||||
| `postMonitoringLog()` | ✅ | multipart пересобирается на каждой попытке |
|
||||
| `postMonitoringDump()` | ✅ | timeout 120s; multipart пересобирается |
|
||||
| `post(QHttpMultiPart*, path)` | ❌ | multipart передаётся снаружи и удаляется вместе с `reply`; колл-сайты (`postEventStatus`/`insertTransactionData`/…) — legacy |
|
||||
| `getTasks()` | ❌ | асинхронный (без `QEventLoop`); таймаут просто аборитит reply |
|
||||
|
||||
### Идемпотентность
|
||||
|
||||
⚠️ POST-запросы, создающие ресурсы (`/desktop/`, `/device/`, `/profile/bank_profile`, `/profile/material`), при ретрае могут создать дубль, если первая попытка дошла до сервера, но ответ потерялся. Сейчас защиты от этого нет — полагаемся на то, что 3 timeout-а подряд это редкая ситуация. Если станет проблемой — добавлять идемпотентные ключи на стороне бэкенда.
|
||||
|
||||
120
docs/bank-setup-wizard.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Мастер установки банка (`BankSetupWizard`)
|
||||
|
||||
Файлы: `views/bank/BankSetupWizard.{h,cpp}`
|
||||
|
||||
## Назначение
|
||||
|
||||
Диалоговое окно, проводящее пользователя через настройку нового банковского профиля на конкретном устройстве: ввод пин-кода → автоматический вход в приложение → парсинг профиля → парсинг карт → выбор активной карты. Все данные одновременно сохраняются в локальную БД и регистрируются на бэкенде (`localhost:9999/api/v1/`).
|
||||
|
||||
Поддерживаемые банки: **Ozon**, **Black** (Birbank), **Dushanbe City Bank** (`appCode == "ozon" | "black" | "dushanbe_city_bank"`). Остальные банки в мастере не поддерживаются и сразу падают с ошибкой (`BankSetupWizard.cpp:443, 494, 549`).
|
||||
|
||||
## Где открывается
|
||||
|
||||
Два входа, оба создают `BankSetupWizard` как обычный `QDialog` (немодальный):
|
||||
- `views/device/DeviceSettingsWindow.cpp:176` — из карточки настроек устройства.
|
||||
- `views/device/DeviceWidget.cpp:278` — из плитки устройства в главном окне.
|
||||
|
||||
В обоих местах используется свежая запись из БД, если профиль уже есть; иначе — переданный `BankProfileInfo` шаблон.
|
||||
|
||||
## Структура UI: `QStackedWidget` из 5 страниц
|
||||
|
||||
Индексы страниц: `0` Intro, `1` PIN, `2` Progress, `3` Success, `4` Error (`BankSetupWizard.cpp:32-38`).
|
||||
|
||||
| Шаг | Что показывает | Кнопки |
|
||||
|-----|----------------|--------|
|
||||
| 1 Intro (`createStep1`) | Иконка + название банка + список из 3 шагов | **Далее** → шаг 2 |
|
||||
| 2 PIN (`createStep2`) | Поле ввода пин-кода (max 10 символов) | **Назад**, **Далее** → сохраняет `m_app.pinCode`, переходит на шаг 3 и запускает `startVerification()` |
|
||||
| 3 Progress (`createStep3`) | `QProgressBar` + текстовая метка статуса | **Назад** → `stopWorkerThread()`, возврат на шаг 2 |
|
||||
| 4a Success (`createStep4Success`) | ФИО / Телефон / Email + таблица карт с radio-выбором | **Завершить** → `applyActiveCardSelection()`, `accept()` |
|
||||
| 4b Error (`createStep4Error`) | Человекочитаемый текст ошибки | **Назад** → шаг 2, **Начать заново** → очищает БД-запись (если профиль ещё не отправлен) и возвращает на шаг 1 |
|
||||
|
||||
## Жизненный цикл и пауза других профилей
|
||||
|
||||
В **конструкторе** (`BankSetupWizard.cpp:41`) сразу вызывается `pauseActiveProfiles()`:
|
||||
- Берёт все профили устройства через `BankProfileDAO::getApplicationsByDeviceId`.
|
||||
- Все `status == "active"` переводятся в `"off"` локально и **выключаются на сервере** через `NetworkService::toggleBankProfile(enable=false)` (`BankSetupWizard.cpp:297-321`).
|
||||
- Коды паузованных профилей запоминаются в `m_pausedProfiles`.
|
||||
|
||||
Это сделано, чтобы фоновый `EventHandler` / `DeviceScreener` не дёргали телефон параллельно во время работы мастера.
|
||||
|
||||
В **деструкторе** (`BankSetupWizard.cpp:44-47`): `stopWorkerThread()` + `resumePausedProfiles()` — возвращает паузованные профили в `"active"` и шлёт `toggleBankProfile(enable=true)` на сервер.
|
||||
|
||||
## Цепочка верификации (шаги 3 → 4)
|
||||
|
||||
`startVerification()` → `runPinCheck()` → `runProfileCheck()` → `runCardCheck()` → `onVerificationSuccess()`.
|
||||
|
||||
Каждый шаг запускает соответствующий банк-скрипт в **отдельном `QThread`** (`m_workerThread`), результат принимает лямбдой через `Qt::QueuedConnection`. Если шаг вернул непустую строку — `onVerificationError()`, иначе — следующий шаг.
|
||||
|
||||
### 1. `runPinCheck()` — проверка пин-кода (`BankSetupWizard.cpp:384`)
|
||||
|
||||
Сначала `startVerification()` делает базовую проверку, что устройство онлайн (`device.status == "device"`), затем upsert'ит профиль в БД с `status="off"`, `install=true`, чтобы получить `m_app.id` (`BankSetupWizard.cpp:362-382`).
|
||||
|
||||
Дальше создаётся `LoginAndCheckAccountsScript` с флагом **`pinCheckOnly=true`** (3-й или 4-й аргумент в зависимости от банка). Этот флаг говорит скрипту: только дойти до главного экрана, чтобы убедиться что пин валидный, и выйти. Никаких материалов не создаётся.
|
||||
|
||||
При успехе: `BankProfileDAO::updatePinCodeStatus(appId, "yes", "")`, прогресс → 33%, переход к `runProfileCheck()`.
|
||||
|
||||
### 2. `runProfileCheck()` — данные профиля (`BankSetupWizard.cpp:450`)
|
||||
|
||||
Запускает `GetProfileInfoScript`. Скрипт парсит ФИО/телефон/email с главного экрана, обновляет `bank_profiles` в БД и **отправляет на сервер** `POST/PATCH` через `NetworkService` — на этом этапе бэкенд возвращает `bank_profile_id`.
|
||||
|
||||
После завершения обработчик `onProfileResult` дочитывает запись из БД (`BankProfileDAO::getApplication`) и проверяет, что `bankProfileId` непустой (`BankSetupWizard.cpp:463`). Если бэкенд не вернул id — ошибка "Не удалось зарегистрировать банковский профиль на сервере. Возможно, банк ещё не поддерживается сервером." Прогресс → 66%, переход к `runCardCheck()`.
|
||||
|
||||
### 3. `runCardCheck()` — карты (`BankSetupWizard.cpp:501`)
|
||||
|
||||
Запускает `GetCardInfoScript`. Скрипт обходит все карты в приложении, парсит полный номер карты (для Ozon/Dushanbe — через OCR/XML), складывает в БД и отправляет `POST material` на сервер.
|
||||
|
||||
После завершения `onCardResult` читает `materials = MaterialDAO::getAccounts(...)`, и если хотя бы одна карта есть, но **ни у одной нет `materialId`** (т.е. ничего не синхронизировалось с сервером) — ошибка "Не удалось отправить данные карт на сервер" (`BankSetupWizard.cpp:518`). Прогресс → 100%, вызов `onVerificationSuccess()`.
|
||||
|
||||
### 4. `onVerificationSuccess()` (`BankSetupWizard.cpp:556`)
|
||||
|
||||
- Перечитывает профиль из БД, формирует HTML с ФИО/телефоном/email.
|
||||
- Загружает карты, и **если хоть у одной `cardNumber.isEmpty()` — уходит в Error** ("Не удалось получить полный номер карты, распознаны только последние 4 цифры") (`BankSetupWizard.cpp:574-580`). Это страховка поверх защиты в самих скриптах.
|
||||
- Заполняет таблицу: 4 колонки (Номер / Сумма / Валюта / Активна-radio).
|
||||
- Логика автовыбора:
|
||||
- **0 карт** → строка "Карты не найдены", кнопка Завершить активна.
|
||||
- **1 карта** → автовыбор, колонка radio скрыта.
|
||||
- **≥2 карт** → первая карта выбрана по умолчанию, можно перевыбрать.
|
||||
|
||||
### 5. `applyActiveCardSelection()` (`BankSetupWizard.cpp:631`) — при нажатии Завершить
|
||||
|
||||
- `MaterialDAO::activateExclusive(deviceId, appCode, selectedMaterialId)` — делает выбранную карту единственной активной в этом профиле; возвращает список деактивированных.
|
||||
- Для выбранной карты шлёт `NetworkService::toggleMaterial(enable=true)`, для каждой деактивированной — `toggleMaterial(enable=false)`. Каждый запрос — в своём `QThread`, fire-and-forget.
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
`onVerificationError()` (`BankSetupWizard.cpp:690`) проганяет текст через `humanReadableError()` (`BankSetupWizard.cpp:659`), который:
|
||||
1. Определяет **этап** по подстрокам ("пин-кода" / "профиля" / "карт").
|
||||
2. Распознаёт типовые сообщения скриптов (`"Cant open the home screen"`, `"No cards found"`, `"Cannot find user name"`, и т.д.) и подставляет понятный русский текст с подсказкой что делать.
|
||||
3. Всё незнакомое → "Произошла непредвиденная ошибка. Попробуйте повторить позже."
|
||||
|
||||
Кнопка **«Начать заново»** на странице Error дополнительно чистит БД, но **только если профиль ещё не зарегистрирован на сервере** (`current.bankProfileId.isEmpty()`, `BankSetupWizard.cpp:277-283`): удаляет материалы (`MaterialDAO::deleteAccountsByApp`) и сам профиль (`BankProfileDAO::deleteProfile`), сбрасывает `m_app.id = -1`. Это предотвращает дубликаты при повторной попытке.
|
||||
|
||||
## Управление воркер-потоком
|
||||
|
||||
`stopWorkerThread()` (`BankSetupWizard.cpp:342`) — критичный кусок:
|
||||
1. `requestInterruption()` + `quit()`, ждёт до 3 секунд `wait(3000)`.
|
||||
2. Если поток **залип** (обычно — внутри `QProcess::waitForFinished` в `AdbUtils::startProcess`): **отсоединяет** `QThread` от родителя (`setParent(nullptr)`) и вешает `deleteLater` на сигнал `finished`. Без этого `~QWidget` удалит работающий `QThread` → `qFatal`.
|
||||
|
||||
Каждый `run*Check()` сначала зовёт `stopWorkerThread()`, поэтому переход на следующий шаг безопасен даже при гонках.
|
||||
|
||||
## Краткая последовательность данных
|
||||
|
||||
```
|
||||
Пользователь вводит PIN
|
||||
↓
|
||||
BankProfileDAO::upsertApplication (status=off, install=true)
|
||||
↓
|
||||
LoginAndCheckAccountsScript (pinCheckOnly=true) ─→ только верифицирует вход
|
||||
↓
|
||||
BankProfileDAO::updatePinCodeStatus("yes", "")
|
||||
↓
|
||||
GetProfileInfoScript ─→ парсит профиль → POST/PATCH bank_profile → bankProfileId
|
||||
↓
|
||||
GetCardInfoScript ─→ парсит карты → POST material для каждой → materialId
|
||||
↓
|
||||
Таблица карт + выбор активной
|
||||
↓
|
||||
MaterialDAO::activateExclusive + toggleMaterial на сервере
|
||||
↓
|
||||
accept() → resumePausedProfiles() в деструкторе возвращает остальные профили в active
|
||||
```
|
||||
105
docs/status-sync.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Синхронизация статусов bank_profile и material
|
||||
|
||||
## Определения
|
||||
|
||||
### Локальные статусы (SQLite)
|
||||
| Таблица | Поле | Значения |
|
||||
|---|---|---|
|
||||
| `bank_profiles` | `status` | `"active"` \| `"off"` |
|
||||
| `materials` | `status` | `"active"` \| `"off"` (enum `MaterialStatus::Active` / `MaterialStatus::Off`) |
|
||||
|
||||
### Серверные статусы (REST API)
|
||||
| Сущность | Поле | Значения |
|
||||
|---|---|---|
|
||||
| bank_profile | `state` | `"enabled"` \| `"disabled"` |
|
||||
| material | `state` | `"enabled"` \| `"disabled"` |
|
||||
|
||||
### Маппинг
|
||||
| Локально | Сервер |
|
||||
|---|---|
|
||||
| `active` | `enabled` |
|
||||
| `off` | `disabled` |
|
||||
|
||||
---
|
||||
|
||||
## Инвариант
|
||||
**Локальное состояние — единственный источник истины.** Сервер всегда отражает локаль. При любых расхождениях побеждает локальная БД, и сервер дотягивается до неё (а не наоборот).
|
||||
|
||||
Pull с сервера (`syncBankProfilesFromServer`) используется только для **создания новых записей** (новые bank_profile / новые материалы) и привязки серверных UUID. Статусы существующих записей с сервера в локаль никогда не пишутся.
|
||||
|
||||
---
|
||||
|
||||
## Основные правила
|
||||
|
||||
### 1. Смена онлайн-статуса устройства
|
||||
- **Device → OFFLINE:** bank_profile этого устройства выключается **и локально, и на сервере** (`PATCH bank_profile/{id}/false`). Материалы локально не трогаем — они остаются в том состоянии, в котором были (см. правило 4-А ниже).
|
||||
- **Device → ONLINE:** локальный статус **не меняется**. На сервер пушится текущее локальное состояние bank_profile и всех его материалов, если оно расходится с серверным.
|
||||
|
||||
### 2. Периодическая синхронизация
|
||||
- Каждые **60 секунд** запускается фоновая задача, которая сравнивает локальные статусы bank_profile и материалов с серверными и отправляет `PATCH` только для расходящихся.
|
||||
- Локаль не трогается. Цель — компенсировать сетевые сбои, перезапуски сервера и т.п.
|
||||
|
||||
### 3. Обработка ошибок при включении
|
||||
- Когда пользователь включает bank_profile или material и `PATCH .../true` падает (network error / HTTP не-2xx / `success:false`):
|
||||
- локально оставляем `off`;
|
||||
- показываем пользователю сообщение об ошибке (см. правило 6 про UI).
|
||||
- При неудачном **выключении** локально оставляем `off`; рассинхрон с сервером устранит минутный таймер из правила 2.
|
||||
|
||||
### 4. Реконсиляция материалов после включения профиля
|
||||
- Срабатывает **только при включении** профиля (`PATCH bank_profile/{id}/true`), при выключении — нет (см. правило 4-B про гард ниже).
|
||||
- Отдельной реконсиляции после PATCH не делаем — расхождения материалов подхватит 60s-таймер из правила 2 (в худшем случае — через минуту).
|
||||
- Допустима оптимизация: «дёрнуть» периодический sync сразу после успешного включения профиля, чтобы не ждать.
|
||||
|
||||
---
|
||||
|
||||
## Дополнительные правила и инварианты
|
||||
|
||||
### 4-A. Материалы при device → OFFLINE
|
||||
- При переходе device → OFFLINE правило 1 выключает только профиль. Материалы локально не трогаем — после повторного включения профиля они вернутся к работе без лишних кликов пользователя.
|
||||
- В UI материалы при `bank_profile.status == "off"` отображаются **приглушённо** или с пометкой «профиль выключен», но их `status` в БД остаётся как есть.
|
||||
|
||||
### 4-B. Гард на пуш материала при выключенном профиле
|
||||
- На сервере действует инвариант: `material.state ≤ bank_profile.state`. Если профиль `disabled`, сервер принудительно держит материалы в `disabled`.
|
||||
- Чтобы не получить бесконечный diff-цикл, **при любом пуше статуса материала** (toggle в UI, периодический sync, follow-up после toggle профиля) проверяем локальный `bank_profile.status`. Если профиль `off` — `PATCH` материала **не отправляем**, локальный статус не меняем.
|
||||
- Этот гард уже частично реализован в `AccountSettingsWindow.cpp:556`; нужно поднять его на уровень `NetworkService`, чтобы покрыть все пути пуша.
|
||||
|
||||
### 5. Дебаунс ADB-флапа
|
||||
- ADB может мерцать (USB-хаб, спящий экран, Doze). Без дебаунса каждое мерцание сработает как device → OFFLINE.
|
||||
- **Online — мгновенно** (0 секунд). Как только устройство видно — сразу триггерим логику device → ONLINE.
|
||||
- **Offline — с задержкой 30 секунд.** Устройство считается офлайн только после того, как ADB **не** показывает его как `device` непрерывно в течение 30 секунд. Если за этот период оно вернулось — считаем что флапа не было.
|
||||
|
||||
### 6. Стартовый порядок (избегание гонки)
|
||||
- `DeviceScreener` эмитит сигналы `deviceWentOnline(deviceId)` / `deviceWentOffline(deviceId)` **только при смене** статуса. На «никогда не видел в этой сессии» никаких событий нет.
|
||||
- `NetworkService` стартует 60s-таймер только **после первого успешного прохода** `DeviceScreener` (сигнал `firstPassDone`). До этого никаких offline-эвентов не обрабатывает.
|
||||
- Это исключает ситуацию «на старте все устройства выглядят офлайн → массово выключаем профили».
|
||||
|
||||
### 7. Сериализация PATCH-ов
|
||||
- Mutex на пару `(deviceId, bankProfileId)`: внутри одного устройства+профиля PATCH-операции последовательны, между разными — могут идти параллельно.
|
||||
- Защищает от гонки: одновременно прилетели device-event, юзер-toggle и периодический sync для одного профиля → они выстраиваются в очередь.
|
||||
|
||||
### 8. UI-обработка ошибок (overlay-pattern)
|
||||
- При любом PATCH статуса (профиля или материала) UI показывает оверлей «Сохранение…» поверх тоггла.
|
||||
- Тоггл **двигается только после ответа сервера**:
|
||||
- успех → переходит в новое положение, оверлей скрывается;
|
||||
- ошибка → остаётся в исходном положении, оверлей сменяется сообщением «Ошибка: …», локальный статус не меняется.
|
||||
- Сейчас частично реализовано через `hideOverlay` в `AccountSettingsWindow.cpp:559`. Применить тот же pattern в `DeviceWidget`, `BankSetupWizard` и всех других местах с toggle.
|
||||
|
||||
### 9. Индикация неработающей синхронизации
|
||||
- В `NetworkService` ведём счётчик `m_consecutiveSyncFailures`, инкрементируем при каждом проваленном тике (правило 2) или провале `GET /current_profile`. Сбрасываем при первом успехе.
|
||||
- При превышении порога (например, **3 тика подряд**) эмитим сигнал в `MainWindow`, который показывает индикатор «Нет связи с сервером, последняя успешная синхронизация: HH:MM:SS».
|
||||
- При следующем успехе индикатор скрывается.
|
||||
|
||||
### 10. Параллелизм и ограничения нагрузки
|
||||
- В одном тике периодического sync может образоваться большой объём расхождений (5 устройств × 6 профилей × 4 материала = до 120 PATCH).
|
||||
- Параллелизм с **лимитом 4-8 одновременных PATCH**; mutex из правила 7 соблюдается.
|
||||
- Флаг `m_syncInProgress`: если предыдущий тик ещё не завершился, новый тик **пропускается**. Это предотвращает накопление очереди.
|
||||
|
||||
### 11. Поведение при провале `GET /current_profile`
|
||||
- Если периодический `GET /current_profile` упал (таймаут/сеть/5xx) — тик **пропускается тихо**, локаль не трогаем, никакие PATCH не шлём.
|
||||
- Сам факт неудачи учитывается в счётчике из правила 9.
|
||||
|
||||
---
|
||||
|
||||
## Связанные существующие правила (контекст)
|
||||
- Toggle материала, когда `bank_profile.status == "off"`, **не идёт на сервер** — сохраняется только локально (см. правило 4-B). Серверная синхронизация произойдёт после включения профиля и следующего 60s-тика.
|
||||
- После `BankSetupWizard` `bank_profile` остаётся `off` по дизайну — пользователь должен включить его вручную.
|
||||
BIN
images/copy.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
40
main.cpp
@ -65,7 +65,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
|
||||
#include <QPushButton>
|
||||
#include <QFileDialog>
|
||||
|
||||
void startDeviceScreener(const QCoreApplication &app) {
|
||||
void startDeviceScreener(const QCoreApplication &app, NetworkService *networkService = nullptr) {
|
||||
auto *thread = new QThread();
|
||||
auto *deviceScreenerWorker = new DeviceScreener;
|
||||
|
||||
@ -76,6 +76,20 @@ void startDeviceScreener(const QCoreApplication &app) {
|
||||
// Когда поток запустится, автоматически вызовется worker->start() внутри фонового потока.
|
||||
QObject::connect(thread, &QThread::started, deviceScreenerWorker, &DeviceScreener::start);
|
||||
|
||||
// Связь с NetworkService: первый проход → старт периодической синхронизации;
|
||||
// переходы online/offline → реакция в NetworkService (правило #1, #6).
|
||||
if (networkService) {
|
||||
QObject::connect(deviceScreenerWorker, &DeviceScreener::firstPassDone,
|
||||
networkService, &NetworkService::startPeriodicSync,
|
||||
Qt::QueuedConnection);
|
||||
QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOnline,
|
||||
networkService, &NetworkService::onDeviceWentOnline,
|
||||
Qt::QueuedConnection);
|
||||
QObject::connect(deviceScreenerWorker, &DeviceScreener::deviceWentOffline,
|
||||
networkService, &NetworkService::onDeviceWentOffline,
|
||||
Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
// 🔁 Эти 3 строки делают автоматическую зачистку:
|
||||
// Когда worker закончит работу (emit finished()), мы:
|
||||
// - останавливаем поток (thread->quit())
|
||||
@ -165,7 +179,7 @@ void startBlackPayByCardTest() {
|
||||
thread->start();
|
||||
}
|
||||
|
||||
void startNetworkService(const QCoreApplication &app) {
|
||||
NetworkService* startNetworkService(const QCoreApplication &app) {
|
||||
auto *paymentThread = new QThread;
|
||||
auto *networkService = new NetworkService;
|
||||
AppLogger::setNetworkService(networkService);
|
||||
@ -209,6 +223,7 @@ void startNetworkService(const QCoreApplication &app) {
|
||||
}, Qt::QueuedConnection);
|
||||
|
||||
paymentThread->start();
|
||||
return networkService;
|
||||
}
|
||||
|
||||
static QFile logFile;
|
||||
@ -357,8 +372,11 @@ int main(int argc, char *argv[]) {
|
||||
auto *mainWindow = new MainWindow();
|
||||
mainWindow->show();
|
||||
registrationWindow->close();
|
||||
startNetworkService(app);
|
||||
startDeviceScreener(app);
|
||||
NetworkService *net = startNetworkService(app);
|
||||
QObject::connect(net, &NetworkService::syncHealthChanged,
|
||||
mainWindow, &MainWindow::setSyncHealth,
|
||||
Qt::QueuedConnection);
|
||||
startDeviceScreener(app, net);
|
||||
startEventHandler(app);
|
||||
// startBlackPayByCardTest();
|
||||
}, Qt::QueuedConnection);
|
||||
@ -388,8 +406,11 @@ int main(int argc, char *argv[]) {
|
||||
if (r == 1) {
|
||||
auto *mainWindow = new MainWindow();
|
||||
mainWindow->show();
|
||||
startNetworkService(app);
|
||||
startDeviceScreener(app);
|
||||
NetworkService *net = startNetworkService(app);
|
||||
QObject::connect(net, &NetworkService::syncHealthChanged,
|
||||
mainWindow, &MainWindow::setSyncHealth,
|
||||
Qt::QueuedConnection);
|
||||
startDeviceScreener(app, net);
|
||||
startEventHandler(app);
|
||||
// startBlackPayByCardTest();
|
||||
} else if (r == 0) {
|
||||
@ -409,8 +430,11 @@ int main(int argc, char *argv[]) {
|
||||
} else if (result == 1) {
|
||||
auto *mainWindow = new MainWindow();
|
||||
mainWindow->show();
|
||||
startNetworkService(app);
|
||||
startDeviceScreener(app);
|
||||
NetworkService *net = startNetworkService(app);
|
||||
QObject::connect(net, &NetworkService::syncHealthChanged,
|
||||
mainWindow, &MainWindow::setSyncHealth,
|
||||
Qt::QueuedConnection);
|
||||
startDeviceScreener(app, net);
|
||||
startEventHandler(app);
|
||||
// startBlackPayByCardTest();
|
||||
} else if (result == 0) {
|
||||
|
||||
@ -36,6 +36,8 @@ CREATE TABLE IF NOT EXISTS bank_profiles
|
||||
bank_profile_id TEXT DEFAULT '',
|
||||
currency TEXT DEFAULT '',
|
||||
amount REAL DEFAULT 0,
|
||||
app_version TEXT DEFAULT '',
|
||||
app_version_checked_at DATETIME,
|
||||
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE,
|
||||
UNIQUE (device_id, code)
|
||||
);
|
||||
@ -86,22 +88,23 @@ CREATE TABLE IF NOT EXISTS transactions
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events
|
||||
(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
external_id TEXT,
|
||||
device_id TEXT,
|
||||
amount REAL,
|
||||
phone TEXT,
|
||||
bank_name TEXT,
|
||||
comment TEXT,
|
||||
status TEXT,
|
||||
type TEXT,
|
||||
update_time DATETIME,
|
||||
complete_time DATETIME,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
json TEXT DEFAULT '',
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER,
|
||||
external_id TEXT,
|
||||
transaction_id TEXT,
|
||||
device_id TEXT,
|
||||
amount REAL,
|
||||
phone TEXT,
|
||||
bank_name TEXT,
|
||||
comment TEXT,
|
||||
status TEXT,
|
||||
type TEXT,
|
||||
update_time DATETIME,
|
||||
complete_time DATETIME,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
json TEXT DEFAULT '',
|
||||
sent_to_server INTEGER DEFAULT 0,
|
||||
screenshot BLOB,
|
||||
screenshot BLOB,
|
||||
FOREIGN KEY (account_id) REFERENCES materials (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@ -13,7 +13,7 @@ public:
|
||||
m_y2 = y2;
|
||||
}
|
||||
|
||||
bool isEmpty() {
|
||||
[[nodiscard]] bool isEmpty() const {
|
||||
return m_x1 < 0 && m_y1 < 0 && m_x2 < 0 && m_y2 < 0 && text.isEmpty() && className.isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
|
||||
struct AppLogEntry {
|
||||
int id = -1;
|
||||
QString source; // "ozon/LoginAndCheck", "ozon/GetLastTransactions", etc.
|
||||
QString deviceId; // ADB device id, может быть пустым
|
||||
QString message; // текст ошибки
|
||||
QByteArray screenshot; // PNG-байты скриншота (может быть пустым)
|
||||
QDateTime timestamp;
|
||||
};
|
||||
@ -21,5 +21,7 @@ struct BankProfileInfo {
|
||||
QString bankProfileId;
|
||||
QString currency;
|
||||
double amount = 0.0;
|
||||
QString appVersion;
|
||||
QDateTime appVersionCheckedAt;
|
||||
QDateTime updateTime;
|
||||
};
|
||||
|
||||
@ -56,6 +56,7 @@ inline EventType eventTypeFromString(const QString &str) {
|
||||
struct EventInfo {
|
||||
int id = -1;
|
||||
QString externalId;
|
||||
QString transactionId;
|
||||
int accountId = -1;
|
||||
QString deviceId;
|
||||
|
||||
|
||||
@ -41,5 +41,5 @@ struct MaterialInfo {
|
||||
QString description;
|
||||
double amount = 0.0;
|
||||
QDateTime updateTime;
|
||||
MaterialStatus status = MaterialStatus::Active;
|
||||
MaterialStatus status = MaterialStatus::Off;
|
||||
};
|
||||
@ -22,6 +22,7 @@
|
||||
#include "dao/BankProfileDAO.h"
|
||||
#include "dao/MaterialDAO.h"
|
||||
#include "dao/SettingsDAO.h"
|
||||
#include "net/NetworkErrorUtils.h"
|
||||
|
||||
DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) {
|
||||
const QSettings settings("config.ini", QSettings::IniFormat);
|
||||
@ -131,7 +132,8 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body)
|
||||
|
||||
QJsonValue result;
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "DeviceScreener apiPost timeout:" << path;
|
||||
qCritical().noquote() << "DeviceScreener apiPost timeout:"
|
||||
<< NetworkErrorUtils::describeClientTimeout(reply, path);
|
||||
reply->abort();
|
||||
} else if (reply->error() == QNetworkReply::NoError) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
||||
@ -141,7 +143,8 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body)
|
||||
else qCritical() << "DeviceScreener apiPost error:" << root;
|
||||
}
|
||||
} else {
|
||||
qCritical() << "DeviceScreener apiPost network error:" << reply->errorString();
|
||||
qCritical().noquote() << "DeviceScreener apiPost error:"
|
||||
<< NetworkErrorUtils::describeNetError(reply, path);
|
||||
}
|
||||
reply->deleteLater();
|
||||
return result;
|
||||
@ -166,10 +169,12 @@ void DeviceScreener::apiPatch(const QString &path, const QJsonObject &body) {
|
||||
loop.exec();
|
||||
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "DeviceScreener apiPatch timeout:" << path;
|
||||
qCritical().noquote() << "DeviceScreener apiPatch timeout:"
|
||||
<< NetworkErrorUtils::describeClientTimeout(reply, path);
|
||||
reply->abort();
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
qCritical() << "DeviceScreener apiPatch network error:" << reply->errorString();
|
||||
qCritical().noquote() << "DeviceScreener apiPatch error:"
|
||||
<< NetworkErrorUtils::describeNetError(reply, path);
|
||||
}
|
||||
reply->deleteLater();
|
||||
}
|
||||
@ -225,10 +230,12 @@ void DeviceScreener::postScreenshotToApi(const QString &apiId, const QByteArray
|
||||
loop.exec();
|
||||
|
||||
if (!timeout.isActive()) {
|
||||
qWarning() << "postScreenshotToApi timeout for device" << apiId;
|
||||
qWarning().noquote() << "postScreenshotToApi timeout:" << apiId
|
||||
<< NetworkErrorUtils::describeClientTimeout(reply, path);
|
||||
reply->abort();
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
qWarning() << "postScreenshotToApi network error:" << reply->errorString();
|
||||
qWarning().noquote() << "postScreenshotToApi error:" << apiId
|
||||
<< NetworkErrorUtils::describeNetError(reply, path);
|
||||
} else {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
||||
if (doc.isObject() && doc.object()["success"].toBool()) {
|
||||
@ -259,26 +266,6 @@ void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) {
|
||||
apiPatch("/api/v1/device/" + device.apiId, body);
|
||||
}
|
||||
|
||||
void DeviceScreener::toggleBankProfilesOnApi(const QString &deviceId, bool enable) {
|
||||
if (enable) return; // не включаем автоматически при появлении устройства
|
||||
|
||||
const QList<BankProfileInfo> apps = BankProfileDAO::getAllApplicationsByDeviceId(deviceId);
|
||||
for (const BankProfileInfo &app : apps) {
|
||||
if (app.bankProfileId.isEmpty()) continue;
|
||||
if (app.status != "active") continue;
|
||||
|
||||
apiPatch("/api/v1/profile/bank_profile/" + app.bankProfileId + "/false", QJsonObject());
|
||||
|
||||
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, app.code);
|
||||
for (const MaterialInfo &mat : materials) {
|
||||
if (mat.materialId.isEmpty()) continue;
|
||||
apiPatch("/api/v1/profile/material/" + mat.materialId + "/false", QJsonObject());
|
||||
}
|
||||
}
|
||||
|
||||
BankProfileDAO::deactivateAppsForDevice(deviceId);
|
||||
}
|
||||
|
||||
QJsonValue DeviceScreener::apiGet(const QString &path) {
|
||||
if (!m_manager) m_manager = new QNetworkAccessManager(this);
|
||||
|
||||
@ -298,7 +285,8 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
|
||||
|
||||
QJsonValue result;
|
||||
if (!timeout.isActive()) {
|
||||
qCritical() << "DeviceScreener apiGet timeout:" << path;
|
||||
qCritical().noquote() << "DeviceScreener apiGet timeout:"
|
||||
<< NetworkErrorUtils::describeClientTimeout(reply, path);
|
||||
reply->abort();
|
||||
} else if (reply->error() == QNetworkReply::NoError) {
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
||||
@ -308,7 +296,8 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
|
||||
else qCritical() << "DeviceScreener apiGet error:" << root;
|
||||
}
|
||||
} else {
|
||||
qCritical() << "DeviceScreener apiGet network error:" << reply->errorString();
|
||||
qCritical().noquote() << "DeviceScreener apiGet error:"
|
||||
<< NetworkErrorUtils::describeNetError(reply, path);
|
||||
}
|
||||
reply->deleteLater();
|
||||
return result;
|
||||
@ -317,15 +306,12 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
|
||||
|
||||
void DeviceScreener::start() {
|
||||
while (m_running) {
|
||||
// Устройства, которые были онлайн в БД до текущего скана
|
||||
const QStringList prevOnline = DeviceDAO::getOnlineDeviceCodes();
|
||||
|
||||
QList<QPair<QString, QString> > devices = readAdbDevices();
|
||||
QStringList onlineIds;
|
||||
QList<DeviceInfo> deviceInfos;
|
||||
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
|
||||
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
|
||||
|
||||
for (const auto &[adbSerial, status]: devices) {
|
||||
DeviceInfo device;
|
||||
bool isChecked = true;
|
||||
|
||||
if (status == "device") {
|
||||
device = readDeviceInfo(adbSerial);
|
||||
@ -353,8 +339,7 @@ void DeviceScreener::start() {
|
||||
}
|
||||
}
|
||||
|
||||
onlineIds << device.id;
|
||||
isChecked = prevOnline.contains(device.id);
|
||||
liveSet.insert(device.id);
|
||||
|
||||
device.image = takeScreenshot(adbSerial);
|
||||
if (!device.image.isEmpty()) {
|
||||
@ -367,7 +352,6 @@ void DeviceScreener::start() {
|
||||
}
|
||||
device.status = status;
|
||||
device.updateTime = QDateTime::currentDateTime();
|
||||
deviceInfos.append(device);
|
||||
if (status == "device") {
|
||||
if (!DeviceDAO::upsertDevice(device)) {
|
||||
qDebug() << "Cant update device";
|
||||
@ -378,6 +362,22 @@ void DeviceScreener::start() {
|
||||
if (!BankProfileDAO::checkInstalledApps(device.id, apps)) {
|
||||
qWarning() << "Cant check installed apps";
|
||||
}
|
||||
|
||||
// Обновляем версии установленных банков (не чаще раза в 6 часов на профиль)
|
||||
constexpr qint64 kAppVersionRefreshSecs = 6 * 60 * 60;
|
||||
const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
|
||||
for (const BankProfileInfo &profile : BankProfileDAO::getApplicationsByDeviceId(device.id)) {
|
||||
if (profile.package.isEmpty()) continue;
|
||||
const bool stale = !profile.appVersionCheckedAt.isValid()
|
||||
|| profile.appVersionCheckedAt.secsTo(nowUtc) >= kAppVersionRefreshSecs;
|
||||
if (!stale) continue;
|
||||
|
||||
const QString ver = AdbUtils::getAppVersion(adbSerial, profile.package);
|
||||
if (ver.isEmpty()) continue; // не затираем известную версию пустотой
|
||||
if (!BankProfileDAO::updateAppVersion(profile.id, ver)) {
|
||||
qWarning() << "Cant update app_version for" << profile.code << profile.package;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Синхронизация с API
|
||||
@ -405,26 +405,57 @@ void DeviceScreener::start() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!DeviceDAO::markDevicesOfflineExcept(onlineIds)) {
|
||||
qDebug() << "Cant mark devices offline";
|
||||
// Detect transitions:
|
||||
// 1) Online: устройство есть в liveSet, но ещё не в m_onlineDevices — мгновенно
|
||||
for (const QString &id : liveSet) {
|
||||
if (!m_onlineDevices.contains(id)) {
|
||||
m_onlineDevices.insert(id);
|
||||
m_offlineSince.remove(id);
|
||||
qDebug() << "[DeviceScreener] device went ONLINE:" << id;
|
||||
emit deviceWentOnline(id);
|
||||
} else {
|
||||
// Устройство всё ещё видно — сбрасываем pending offline-дебаунс (флап)
|
||||
m_offlineSince.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Было онлайн в БД, сейчас офлайн — выключаем профили и обновляем статус на сервере
|
||||
for (const QString &id : prevOnline) {
|
||||
if (!onlineIds.contains(id)) {
|
||||
toggleBankProfilesOnApi(id, false);
|
||||
// Отправляем OFFLINE статус на сервер
|
||||
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id);
|
||||
if (!offlineDevice.apiId.isEmpty()) {
|
||||
DeviceInfo offlineCopy = offlineDevice;
|
||||
offlineCopy.status = "offline";
|
||||
updateDeviceOnApi(offlineCopy);
|
||||
}
|
||||
// 2) Offline: устройство было в m_onlineDevices, но больше не видно
|
||||
// Засекаем m_offlineSince; срабатывает после 30s непрерывного отсутствия.
|
||||
QSet<QString> goneOffline;
|
||||
for (const QString &id : m_onlineDevices) {
|
||||
if (liveSet.contains(id)) continue;
|
||||
if (!m_offlineSince.contains(id)) {
|
||||
m_offlineSince[id] = nowSec;
|
||||
qDebug() << "[DeviceScreener] device missing, offline debounce started:" << id;
|
||||
} else if (nowSec - m_offlineSince[id] >= kOfflineDebounceSecs) {
|
||||
goneOffline.insert(id);
|
||||
}
|
||||
}
|
||||
for (const QString &id : goneOffline) {
|
||||
m_onlineDevices.remove(id);
|
||||
m_offlineSince.remove(id);
|
||||
qDebug() << "[DeviceScreener] device went OFFLINE (debounced):" << id;
|
||||
// Сообщаем серверу что устройство офлайн (отдельно от bank_profile-синка)
|
||||
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id);
|
||||
if (!offlineDevice.apiId.isEmpty()) {
|
||||
DeviceInfo offlineCopy = offlineDevice;
|
||||
offlineCopy.status = "offline";
|
||||
updateDeviceOnApi(offlineCopy);
|
||||
}
|
||||
emit deviceWentOffline(id);
|
||||
}
|
||||
|
||||
// БД status: считаем онлайном только debounced-онлайн.
|
||||
QStringList debouncedOnlineList;
|
||||
for (const QString &id : m_onlineDevices) debouncedOnlineList << id;
|
||||
if (!DeviceDAO::markDevicesOfflineExcept(debouncedOnlineList)) {
|
||||
qDebug() << "Cant mark devices offline";
|
||||
}
|
||||
|
||||
if (m_firstScan) {
|
||||
m_firstScan = false;
|
||||
qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices;
|
||||
emit firstPassDone();
|
||||
}
|
||||
QThread::msleep(1000);
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QSet>
|
||||
#include <QMap>
|
||||
#include <dao/DeviceDAO.h>
|
||||
#include <db/DeviceInfo.h>
|
||||
|
||||
@ -12,6 +14,8 @@ public:
|
||||
|
||||
~DeviceScreener() override;
|
||||
|
||||
static constexpr qint64 kOfflineDebounceSecs = 30;
|
||||
|
||||
public slots:
|
||||
void start();
|
||||
|
||||
@ -19,6 +23,9 @@ public slots:
|
||||
|
||||
signals:
|
||||
void finished();
|
||||
void firstPassDone();
|
||||
void deviceWentOnline(const QString &deviceId);
|
||||
void deviceWentOffline(const QString &deviceId);
|
||||
|
||||
private:
|
||||
bool m_running = true;
|
||||
@ -28,6 +35,8 @@ private:
|
||||
QString m_apiBase;
|
||||
QNetworkAccessManager *m_manager = nullptr;
|
||||
QMap<QString, qint64> m_lastApiUpdate; // deviceId → unix timestamp последнего PATCH
|
||||
QSet<QString> m_onlineDevices; // дебаунс-онлайн (true online после dedup)
|
||||
QMap<QString, qint64> m_offlineSince; // deviceId → когда пропал из ADB (для дебаунса)
|
||||
|
||||
static QList<QPair<QString, QString> > readAdbDevices();
|
||||
|
||||
@ -44,5 +53,4 @@ private:
|
||||
QString createDeviceOnApi(const DeviceInfo &device);
|
||||
void updateDeviceOnApi(const DeviceInfo &device);
|
||||
void postScreenshotToApi(const QString &apiId, const QByteArray &image);
|
||||
void toggleBankProfilesOnApi(const QString &deviceId, bool enable);
|
||||
};
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
#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"
|
||||
@ -32,8 +33,8 @@
|
||||
#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);
|
||||
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}, 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) {}
|
||||
|
||||
@ -73,7 +74,7 @@ void EventHandler::start() {
|
||||
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");
|
||||
sendTaskError(eventTypeToString(e.type), e.externalId, e.bankName, "App restarted, event was pending", {}, -1, e.transactionId);
|
||||
qDebug() << "[EventHandler] Closed stale event id:" << e.id;
|
||||
}
|
||||
}
|
||||
@ -107,11 +108,14 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
|
||||
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);
|
||||
sendTaskError(typeStr, materialId, bankName, "Account not found for material_id: " + materialId, {}, -1, incomingTransactionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -119,7 +123,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
|
||||
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");
|
||||
sendTaskError(eventTypeToString(c.type), c.externalId, c.bankName, "Cancelled: replaced by new task", {}, -1, c.transactionId);
|
||||
EventDAO::markSentToServer(c.id);
|
||||
}
|
||||
}
|
||||
@ -141,6 +145,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
|
||||
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();
|
||||
@ -159,7 +164,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
|
||||
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");
|
||||
sendTaskError(typeStr, materialId, bankName, "Device is offline", {}, -1, incomingTransactionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -202,29 +207,42 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
|
||||
}
|
||||
|
||||
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
|
||||
const QString &description, const QByteArray &screenshot, int eventId) {
|
||||
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 message = QStringLiteral("[%1] bank=%2 material=%3: %4")
|
||||
.arg(type, bankName, materialId, description);
|
||||
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) {
|
||||
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]() {
|
||||
networkService->postMonitoringLog(type, event, message, image);
|
||||
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);
|
||||
@ -278,7 +296,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
.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);
|
||||
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);
|
||||
@ -307,6 +325,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
}
|
||||
|
||||
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;
|
||||
@ -349,7 +369,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
if (parsedTx.isEmpty()) {
|
||||
qWarning() << "[EventHandler] FETCH_TRANSACTIONS: script did not emit parsed tx";
|
||||
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName,
|
||||
"Transaction not parsed from screen");
|
||||
"Transaction not parsed from screen", {}, -1, event.transactionId);
|
||||
EventDAO::updateEvent(event.id, EventStatus::Error,
|
||||
"Transaction not parsed from screen");
|
||||
EventDAO::markSentToServer(event.id);
|
||||
@ -365,6 +385,8 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
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;
|
||||
@ -419,21 +441,63 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
|
||||
}
|
||||
f.remove();
|
||||
}
|
||||
sendMonitoringLog("info", "task_success", msg, screenshot, event.id);
|
||||
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);
|
||||
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);
|
||||
sendMonitoringLog("info", "task_success", msg, scr, event.id, /*toApiBase=*/true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -485,15 +549,16 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
// для поиска 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; },
|
||||
Qt::DirectConnection);
|
||||
[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; },
|
||||
Qt::DirectConnection);
|
||||
[this, key](const QByteArray &s) { m_screenshots[key] = s; });
|
||||
};
|
||||
|
||||
if (event.type == EventType::FetchProfile) {
|
||||
@ -514,14 +579,24 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object();
|
||||
const QJsonObject body = taskJson["body"].toObject();
|
||||
const double searchAmount = body["amount"].toDouble() / 100.0; // копейки → рубли (с учётом знака)
|
||||
const QDateTime searchTime = QDateTime::fromString(body["created_at"].toString(), Qt::ISODate);
|
||||
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; },
|
||||
Qt::DirectConnection);
|
||||
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
|
||||
}
|
||||
else if (appCode == "black")
|
||||
setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
|
||||
@ -529,8 +604,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
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; },
|
||||
Qt::DirectConnection);
|
||||
[this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; });
|
||||
}
|
||||
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
|
||||
}
|
||||
@ -590,7 +664,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
|
||||
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)");
|
||||
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Script timeout (5 min)", {}, -1, event.transactionId);
|
||||
EventDAO::markSentToServer(event.id);
|
||||
|
||||
// Убиваем банковское приложение на устройстве после таймаута
|
||||
@ -627,7 +701,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
|
||||
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");
|
||||
sendTaskError(eventTypeToString(event.type), event.externalId, event.bankName, "Task expired: not started within 5 minutes", {}, -1, event.transactionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -637,7 +711,7 @@ void EventHandler::processNextTask(const QString &deviceId) {
|
||||
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);
|
||||
"Bank profile is disabled: " + account.appCode, {}, -1, event.transactionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -652,15 +726,22 @@ void EventHandler::processNextTask(const QString &deviceId) {
|
||||
}
|
||||
|
||||
void EventHandler::clearAll() {
|
||||
// Параллельная остановка: сначала всем requestInterruption + quit,
|
||||
// потом ждём каждый. terminate() намеренно НЕ используем — он опасен
|
||||
// (heap corruption, утечки DB-локов); если поток не успел остановиться,
|
||||
// abandon'им — ОС зачистит ресурсы при выходе процесса. Этот же подход
|
||||
// применяет watchdog в startThreadForTask.
|
||||
for (QThread *thread : m_threads) {
|
||||
if (thread->isRunning()) {
|
||||
thread->requestInterruption();
|
||||
thread->quit();
|
||||
if (!thread->wait(3000)) {
|
||||
qWarning() << "[EventHandler] Thread did not stop in 3s, terminating";
|
||||
thread->terminate();
|
||||
thread->wait(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
137
services/LogArchiveSender.cpp
Normal file
@ -0,0 +1,137 @@
|
||||
#include "LogArchiveSender.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QSettings>
|
||||
#include <QStringList>
|
||||
#include <QThread>
|
||||
|
||||
#include "dao/SettingsDAO.h"
|
||||
#include "net/NetworkService.h"
|
||||
|
||||
namespace {
|
||||
|
||||
QString resolveDbPath() {
|
||||
const QSettings settings("config.ini", QSettings::IniFormat);
|
||||
QString dbPath = settings.value("db/dbPath").toString();
|
||||
if (dbPath.startsWith("~"))
|
||||
dbPath.replace(0, 1, QDir::homePath());
|
||||
if (!dbPath.isEmpty() && QFileInfo(dbPath).isRelative())
|
||||
dbPath = QDir(QCoreApplication::applicationDirPath()).absoluteFilePath(dbPath);
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
// Запускается в worker-потоке. Возвращает байты zip или пустой массив.
|
||||
QByteArray buildArchive() {
|
||||
const QString logPath = QCoreApplication::applicationDirPath() + "/application.log";
|
||||
const QString dbPath = resolveDbPath();
|
||||
|
||||
const bool hasLog = QFile::exists(logPath);
|
||||
const bool hasDb = !dbPath.isEmpty() && QFile::exists(dbPath);
|
||||
if (!hasLog && !hasDb) {
|
||||
qWarning() << "[LogArchiveSender] neither log nor db exists — nothing to archive";
|
||||
return {};
|
||||
}
|
||||
|
||||
const QString tmpDir = QDir::tempPath() + QStringLiteral("/arc_logs_auto_") +
|
||||
QString::number(QDateTime::currentMSecsSinceEpoch());
|
||||
QDir().mkpath(tmpDir);
|
||||
|
||||
if (hasLog) {
|
||||
QFile::copy(logPath, tmpDir + "/application.log");
|
||||
}
|
||||
if (hasDb) {
|
||||
const QString dbBaseName = QFileInfo(dbPath).fileName();
|
||||
QFile::copy(dbPath, tmpDir + "/" + dbBaseName);
|
||||
for (const QString &suffix : {QStringLiteral("-wal"), QStringLiteral("-shm")}) {
|
||||
const QString sidecar = dbPath + suffix;
|
||||
if (QFile::exists(sidecar))
|
||||
QFile::copy(sidecar, tmpDir + "/" + dbBaseName + suffix);
|
||||
}
|
||||
}
|
||||
|
||||
const QString archivePath = tmpDir + ".zip";
|
||||
QFile::remove(archivePath);
|
||||
QProcess archiver;
|
||||
archiver.setWorkingDirectory(tmpDir);
|
||||
#ifdef Q_OS_WIN
|
||||
archiver.start("powershell", QStringList()
|
||||
<< "-NoProfile" << "-Command"
|
||||
<< QString("Compress-Archive -Path '%1/*' -DestinationPath '%2' -Force").arg(tmpDir, archivePath));
|
||||
#else
|
||||
archiver.start("zip", QStringList() << "-r" << archivePath << ".");
|
||||
#endif
|
||||
archiver.waitForFinished(30000);
|
||||
|
||||
QDir(tmpDir).removeRecursively();
|
||||
|
||||
if (!QFile::exists(archivePath)) {
|
||||
qWarning() << "[LogArchiveSender] archive not created at" << archivePath;
|
||||
return {};
|
||||
}
|
||||
QFile zip(archivePath);
|
||||
if (!zip.open(QIODevice::ReadOnly)) {
|
||||
qWarning() << "[LogArchiveSender] cannot open archive" << archivePath;
|
||||
QFile::remove(archivePath);
|
||||
return {};
|
||||
}
|
||||
const QByteArray bytes = zip.readAll();
|
||||
zip.close();
|
||||
QFile::remove(archivePath);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool LogArchiveSender::dispatch(const QString &label, int eventId) {
|
||||
const QString apiToken = SettingsDAO::get("api_key");
|
||||
const QDateTime now = QDateTime::currentDateTime();
|
||||
const QString timestamp = now.toString(Qt::ISODate);
|
||||
|
||||
QStringList head{QStringLiteral("[LOGS]")};
|
||||
if (!apiToken.isEmpty()) head << QStringLiteral("[%1]").arg(apiToken.toHtmlEscaped());
|
||||
if (!label.isEmpty()) head << QStringLiteral("[%1]").arg(label.toHtmlEscaped());
|
||||
const QString text = QStringLiteral("<b>%1</b>\n%2")
|
||||
.arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped());
|
||||
|
||||
QString tokenSafe = apiToken;
|
||||
tokenSafe.replace(QRegularExpression(R"([^\w\-.])"), "_");
|
||||
const QString stamp = now.toString("yyyy-MM-dd_HH-mm-ss");
|
||||
QStringList parts{QStringLiteral("LOGS")};
|
||||
if (!tokenSafe.isEmpty()) parts << tokenSafe;
|
||||
parts << stamp;
|
||||
if (!label.isEmpty()) parts << label;
|
||||
if (eventId > 0) parts << QStringLiteral("ev%1").arg(eventId);
|
||||
const QString archiveFilename = parts.join(QStringLiteral("_")) + QStringLiteral(".zip");
|
||||
|
||||
auto *worker = QThread::create([text, archiveFilename]() {
|
||||
const QByteArray archive = buildArchive();
|
||||
if (archive.isEmpty()) {
|
||||
qWarning() << "[LogArchiveSender] empty archive, skipping dispatch";
|
||||
return;
|
||||
}
|
||||
|
||||
auto *thread = new QThread;
|
||||
auto *svc = new NetworkService;
|
||||
svc->moveToThread(thread);
|
||||
QObject::connect(thread, &QThread::started, svc,
|
||||
[svc, text, archive, archiveFilename]() {
|
||||
svc->postMonitoringDump(text, archive, QByteArray(), archiveFilename);
|
||||
emit svc->finished();
|
||||
});
|
||||
QObject::connect(svc, &NetworkService::finished, thread, &QThread::quit);
|
||||
QObject::connect(svc, &NetworkService::finished, svc, &QObject::deleteLater);
|
||||
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||
thread->start();
|
||||
});
|
||||
QObject::connect(worker, &QThread::finished, worker, &QObject::deleteLater);
|
||||
worker->start();
|
||||
return true;
|
||||
}
|
||||
16
services/LogArchiveSender.h
Normal file
@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
// Headless-варианты сборки и отправки в Telegram-relay архива из application.log
|
||||
// + БД (+ WAL/SHM). Используется EventHandler-ом для авто-отправки при ошибках задач.
|
||||
// FileLogWindow по-прежнему имеет свою UI-обвязку для ручной отправки.
|
||||
namespace LogArchiveSender {
|
||||
// Собирает архив и постит через свежий NetworkService::postMonitoringDump
|
||||
// в собственных background-потоках (worker для zip + thread для HTTP).
|
||||
// `label` — короткий тег ("TASK_ERROR" и т.п.), попадает в caption и в имя файла.
|
||||
// `eventId` (если > 0) тоже уходит в имя файла для трассировки.
|
||||
// Возвращает true если задача поставлена в очередь; реальный результат отправки
|
||||
// не отслеживается (fire-and-forget, как у sendMonitoringLog).
|
||||
bool dispatch(const QString &label, int eventId = -1);
|
||||
}
|
||||
80
services/net/NetworkErrorUtils.h
Normal file
@ -0,0 +1,80 @@
|
||||
#pragma once
|
||||
#include <QNetworkReply>
|
||||
#include <QString>
|
||||
|
||||
// Хелперы для информативного логирования сетевых ошибок. Используются
|
||||
// и в NetworkService, и в DeviceScreener — категория сразу показывает
|
||||
// характер сбоя: timeout / refused / DNS / SSL / HTTP-уровень.
|
||||
|
||||
namespace NetworkErrorUtils {
|
||||
|
||||
// Удаляет схему+хост из URL, оставляет только путь (для логов/UI без секретов).
|
||||
inline QString sanitizePath(const QString &fullUrl) {
|
||||
const int schemeEnd = fullUrl.indexOf(QStringLiteral("://"));
|
||||
if (schemeEnd < 0) return fullUrl;
|
||||
const int pathStart = fullUrl.indexOf(QLatin1Char('/'), schemeEnd + 3);
|
||||
return pathStart >= 0 ? fullUrl.mid(pathStart) : QStringLiteral("/");
|
||||
}
|
||||
|
||||
// Категория ошибки в виде короткого ярлыка. См. QNetworkReply::NetworkError.
|
||||
inline QString networkErrorCategory(QNetworkReply::NetworkError err) {
|
||||
switch (err) {
|
||||
case QNetworkReply::NoError: return QStringLiteral("NO_ERROR");
|
||||
case QNetworkReply::ConnectionRefusedError: return QStringLiteral("CONNECTION_REFUSED");
|
||||
case QNetworkReply::RemoteHostClosedError: return QStringLiteral("REMOTE_CLOSED");
|
||||
case QNetworkReply::HostNotFoundError: return QStringLiteral("DNS_FAIL");
|
||||
case QNetworkReply::TimeoutError: return QStringLiteral("QT_TIMEOUT");
|
||||
case QNetworkReply::OperationCanceledError: return QStringLiteral("CANCELLED");
|
||||
case QNetworkReply::SslHandshakeFailedError: return QStringLiteral("SSL_HANDSHAKE");
|
||||
case QNetworkReply::TemporaryNetworkFailureError: return QStringLiteral("TEMP_NET_FAIL");
|
||||
case QNetworkReply::NetworkSessionFailedError: return QStringLiteral("NO_NETWORK_SESSION");
|
||||
case QNetworkReply::BackgroundRequestNotAllowedError: return QStringLiteral("BG_NOT_ALLOWED");
|
||||
case QNetworkReply::TooManyRedirectsError: return QStringLiteral("TOO_MANY_REDIRECTS");
|
||||
case QNetworkReply::InsecureRedirectError: return QStringLiteral("INSECURE_REDIRECT");
|
||||
case QNetworkReply::UnknownNetworkError: return QStringLiteral("UNKNOWN_NETWORK");
|
||||
case QNetworkReply::ProxyConnectionRefusedError: return QStringLiteral("PROXY_REFUSED");
|
||||
case QNetworkReply::ProxyConnectionClosedError: return QStringLiteral("PROXY_CLOSED");
|
||||
case QNetworkReply::ProxyNotFoundError: return QStringLiteral("PROXY_NOT_FOUND");
|
||||
case QNetworkReply::ProxyTimeoutError: return QStringLiteral("PROXY_TIMEOUT");
|
||||
case QNetworkReply::ProxyAuthenticationRequiredError: return QStringLiteral("PROXY_AUTH_REQ");
|
||||
case QNetworkReply::ContentAccessDenied: return QStringLiteral("HTTP_403");
|
||||
case QNetworkReply::ContentOperationNotPermittedError:return QStringLiteral("OP_NOT_PERMITTED");
|
||||
case QNetworkReply::ContentNotFoundError: return QStringLiteral("HTTP_404");
|
||||
case QNetworkReply::AuthenticationRequiredError: return QStringLiteral("HTTP_401");
|
||||
case QNetworkReply::ContentReSendError: return QStringLiteral("RESEND_REQUIRED");
|
||||
case QNetworkReply::ContentConflictError: return QStringLiteral("HTTP_409");
|
||||
case QNetworkReply::ContentGoneError: return QStringLiteral("HTTP_410");
|
||||
case QNetworkReply::InternalServerError: return QStringLiteral("HTTP_500");
|
||||
case QNetworkReply::OperationNotImplementedError: return QStringLiteral("HTTP_501");
|
||||
case QNetworkReply::ServiceUnavailableError: return QStringLiteral("HTTP_503");
|
||||
case QNetworkReply::UnknownServerError: return QStringLiteral("UNKNOWN_SERVER");
|
||||
case QNetworkReply::ProtocolUnknownError: return QStringLiteral("PROTO_UNKNOWN");
|
||||
case QNetworkReply::ProtocolInvalidOperationError: return QStringLiteral("PROTO_INVALID_OP");
|
||||
case QNetworkReply::UnknownContentError: return QStringLiteral("UNKNOWN_CONTENT");
|
||||
case QNetworkReply::ProtocolFailure: return QStringLiteral("PROTO_FAILURE");
|
||||
default: return QStringLiteral("err_%1").arg(static_cast<int>(err));
|
||||
}
|
||||
}
|
||||
|
||||
// Развёрнутое описание сетевой ошибки: HTTP-код + категория или просто
|
||||
// категория + Qt errorString. Эндпоинт — без хоста.
|
||||
inline QString describeNetError(QNetworkReply *reply, const QString &path) {
|
||||
const int httpCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QString endpoint = sanitizePath(path);
|
||||
const QString category = networkErrorCategory(reply->error());
|
||||
if (httpCode > 0) {
|
||||
return QStringLiteral("HTTP %1 [%2] %3").arg(httpCode).arg(category, endpoint);
|
||||
}
|
||||
return QStringLiteral("[%1] %2 (qt: %3)").arg(category, endpoint, reply->errorString());
|
||||
}
|
||||
|
||||
// Случай нашего QTimer-таймаута (reply ещё не завершился). reply->error()
|
||||
// обычно ещё NoError — помечаем категорию явно как CLIENT_TIMEOUT_30s.
|
||||
inline QString describeClientTimeout(QNetworkReply *reply, const QString &path) {
|
||||
const QString endpoint = sanitizePath(path);
|
||||
const QString qtCategory = networkErrorCategory(reply->error());
|
||||
return QStringLiteral("[CLIENT_TIMEOUT_30s] %1 (qt-state=%2 errorString=\"%3\")")
|
||||
.arg(endpoint, qtCategory, reply->errorString());
|
||||
}
|
||||
|
||||
} // namespace NetworkErrorUtils
|
||||
@ -4,6 +4,11 @@
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QMap>
|
||||
#include <QMutex>
|
||||
#include <QSharedPointer>
|
||||
#include <atomic>
|
||||
|
||||
class QWidget;
|
||||
|
||||
class NetworkService final : public QObject {
|
||||
Q_OBJECT
|
||||
@ -21,6 +26,11 @@ public:
|
||||
|
||||
void addExtra(const QString &key, const QVariant &value);
|
||||
|
||||
// Подвешивает QMessageBox с текстом ошибки от backend (HTTP 409 — "материал
|
||||
// заблокирован" и подобные) к ближайшему окну. Вызывать после `new NetworkService`
|
||||
// в UI-коде, до запуска треда.
|
||||
static void connectErrorDialog(QWidget *parent, NetworkService *net);
|
||||
|
||||
public slots:
|
||||
void postAccountsData();
|
||||
|
||||
@ -49,7 +59,28 @@ public slots:
|
||||
void updateTransactionData();
|
||||
|
||||
void loginAndSetup();
|
||||
void syncCurrentProfile();
|
||||
// true — GET current_profile прошёл; false — таймаут/сеть. Используется
|
||||
// health-счётчиком в runPeriodicSyncTick (правило #9).
|
||||
bool syncCurrentProfile();
|
||||
|
||||
// POST /api/v1/profile/ping — heartbeat: даёт серверу понять, что десктоп
|
||||
// онлайн. В теле уходят desktop_id и версия клиента. Вызывать периодически.
|
||||
void pingProfile();
|
||||
|
||||
// Включает периодический ping (kPingProfileMs). Идемпотентен.
|
||||
void startPeriodicPing();
|
||||
|
||||
// Включает периодическую синхронизацию статусов (60s). Должно быть вызвано
|
||||
// ОДИН раз для долгоживущего инстанса после firstPassDone от DeviceScreener.
|
||||
void startPeriodicSync();
|
||||
|
||||
// Обрабатывает переход устройства в офлайн (дебаунс-сигнал от DeviceScreener):
|
||||
// выключает bank_profile локально и на сервере.
|
||||
void onDeviceWentOffline(const QString &deviceId);
|
||||
|
||||
// Обрабатывает переход устройства в онлайн: пушит текущее локальное
|
||||
// состояние профиля и материалов на сервер (локаль не меняем).
|
||||
void onDeviceWentOnline(const QString &deviceId);
|
||||
|
||||
void createDevice();
|
||||
|
||||
@ -59,11 +90,16 @@ public slots:
|
||||
|
||||
void postTaskResult();
|
||||
|
||||
// toApiBase=true — POST на m_apiBase + /monitoring/log с Bearer-токеном
|
||||
// (старый эндпоинт, используется для task_success). По умолчанию false —
|
||||
// monitoringBase + X-Monitoring-Token (Telegram-релей, для ошибок).
|
||||
void postMonitoringLog(const QString &type, const QString &event,
|
||||
const QString &message, const QByteArray &image);
|
||||
const QString &message, const QByteArray &image,
|
||||
bool toApiBase = false);
|
||||
|
||||
void postMonitoringDump(const QString &text, const QByteArray &archive,
|
||||
const QByteArray &image);
|
||||
const QByteArray &image,
|
||||
const QString &archiveFilename = QStringLiteral("task_dump.zip"));
|
||||
|
||||
void start();
|
||||
void stop();
|
||||
@ -75,6 +111,12 @@ signals:
|
||||
void finishedWithResult(int value);
|
||||
void tokenRefreshFailed();
|
||||
void tasksReceived(QJsonArray tasks);
|
||||
// Эмитится при HTTP-ошибке PATCH-запроса (например, 409 'материал заблокирован').
|
||||
// UI подписывается и показывает QMessageBox.
|
||||
void apiPatchError(int httpStatus, QString message);
|
||||
// healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true
|
||||
// при первом успехе после такой серии. consecutiveFailures даёт текущий счёт.
|
||||
void syncHealthChanged(bool healthy, int consecutiveFailures);
|
||||
|
||||
private:
|
||||
QByteArray m_json;
|
||||
@ -103,10 +145,37 @@ private:
|
||||
QString m_pathTaskResult;
|
||||
QString m_pathMonitoringLog;
|
||||
QString m_pathMonitoringDump;
|
||||
QString m_monitoringBase;
|
||||
QString m_monitoringToken;
|
||||
QString m_pathCurrentProfile;
|
||||
QString m_pathProfileGetInfo;
|
||||
QString m_pathProfilePing;
|
||||
bool m_fetchingTasks = false;
|
||||
|
||||
// Последняя ошибка от postJson/patchJson/getJson — чтобы вызывающий код
|
||||
// мог логировать тело ответа сервера, не дублируя парсинг ошибки.
|
||||
QString m_lastErrorSummary;
|
||||
QString m_lastResponseBody;
|
||||
int m_lastHttpStatus = 0; // 0 если ответа не было (timeout); иначе HTTP-код
|
||||
|
||||
// Периодическая синхронизация
|
||||
bool m_periodicSyncEnabled = false; // включается только после firstPassDone
|
||||
std::atomic<bool> m_syncInProgress{false}; // защита от наложения тиков
|
||||
int m_consecutiveSyncFailures = 0; // счётчик подряд неуспешных тиков
|
||||
bool m_unhealthyEmitted = false; // чтобы не спамить syncHealthChanged
|
||||
static constexpr int kSyncFailureThreshold = 3;
|
||||
static constexpr int kPeriodicSyncMs = 60 * 1000;
|
||||
static constexpr int kPingProfileMs = 5 * 1000;
|
||||
|
||||
// Heartbeat (ping)
|
||||
bool m_pingEnabled = false;
|
||||
std::atomic<bool> m_pingInProgress{false};
|
||||
|
||||
// Реестр мьютексов на пару (deviceId, bankProfileId) — общий для всех
|
||||
// инстансов NetworkService. Используется для сериализации PATCH-операций.
|
||||
static QMutex s_keyRegistryMutex;
|
||||
static QMap<QString, QSharedPointer<QMutex>> s_keyMutexes;
|
||||
|
||||
QNetworkAccessManager *m_manager;
|
||||
|
||||
QJsonValue post(QHttpMultiPart *multiPart, const QString &path);
|
||||
@ -119,7 +188,16 @@ private:
|
||||
bool refreshToken();
|
||||
bool ensureValidToken();
|
||||
void scheduleNextRefresh();
|
||||
void scheduleNextStatusSync();
|
||||
bool runPeriodicSyncTick(); // делает один тик, возвращает true при успехе
|
||||
bool doPingProfile(); // тело ping без emit finished — для периодических тиков
|
||||
void scheduleNextPing();
|
||||
void syncDevicesFromServer(const QString &desktopId);
|
||||
void syncBankProfilesFromServer();
|
||||
void syncBanksIfEmpty();
|
||||
|
||||
public:
|
||||
// Возвращает (лениво создавая) shared mutex для пары (deviceId, bankProfileId).
|
||||
// Используется через QMutexLocker для сериализации PATCH-ов по одному профилю.
|
||||
static QSharedPointer<QMutex> keyMutex(const QString &deviceId, const QString &bankProfileId);
|
||||
};
|
||||
|
||||
@ -1,27 +1,7 @@
|
||||
{
|
||||
"bank_profiles": [
|
||||
{
|
||||
"id": "047fd3d6-f2a2-40e0-9a39-1d11422e8832",
|
||||
"bank_name": "dushanbe_city_bank",
|
||||
"state": "enabled",
|
||||
"phone_number": "+992185555519",
|
||||
"first_name": "Мехрубон",
|
||||
"middle_name": null,
|
||||
"last_name": "Имомкулзода",
|
||||
"currency": 972,
|
||||
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
|
||||
"device_label": "Pixel 7",
|
||||
"materials": [
|
||||
{
|
||||
"id": "83606831-7688-42cf-b2d7-2034e04f68f2",
|
||||
"state": "enabled",
|
||||
"card_number": "9762000207113258",
|
||||
"currency": 972
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "21aa26c8-ea89-45ea-a283-ee6d55ead82b",
|
||||
"id": "9b2f1a38-190c-4cb0-b026-eef721db36ed",
|
||||
"bank_name": "ozon",
|
||||
"state": "enabled",
|
||||
"phone_number": "+79538038318",
|
||||
@ -29,33 +9,13 @@
|
||||
"middle_name": null,
|
||||
"last_name": "Терехина",
|
||||
"currency": 643,
|
||||
"device_id": "986cb259-dfec-4421-8735-4fb126cd1917",
|
||||
"device_id": "cca4ec91-26e4-42e0-81ce-5226382b6cbc",
|
||||
"device_label": "Pixel 7",
|
||||
"materials": [
|
||||
{
|
||||
"id": "3a2ca76d-f735-4006-b521-66bccaf821a4",
|
||||
"id": "e1234f11-b99d-4930-9f86-75148601f683",
|
||||
"state": "enabled",
|
||||
"card_number": "2204321086109865",
|
||||
"currency": 643
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "524c4cfe-9522-4434-90d1-9ee80aae92b2",
|
||||
"bank_name": "ozon",
|
||||
"state": "enabled",
|
||||
"phone_number": "+79855532896",
|
||||
"first_name": "Владислав",
|
||||
"middle_name": null,
|
||||
"last_name": "Касаткин",
|
||||
"currency": 643,
|
||||
"device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f",
|
||||
"device_label": "TECNO KM4",
|
||||
"materials": [
|
||||
{
|
||||
"id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e",
|
||||
"state": "enabled",
|
||||
"card_number": "2204320439130982",
|
||||
"card_number": "2204321035207919",
|
||||
"currency": 643
|
||||
}
|
||||
]
|
||||
|
||||
@ -13,19 +13,4 @@ foreach ($_c in $_configCandidates) {
|
||||
if (-not $_configPath) { Write-Host "ERROR: config.ini not found" -ForegroundColor Red; exit 1 }
|
||||
$_configArg = @("--config", $_configPath)
|
||||
Write-Host "Config: $_configPath" -ForegroundColor Gray
|
||||
$_pythonExe = $null
|
||||
foreach ($_cmd in (Get-Command python, python3, py -ErrorAction SilentlyContinue)) {
|
||||
if ($_cmd.Source -notlike "*WindowsApps*") { $_pythonExe = $_cmd.Source; break }
|
||||
}
|
||||
if (-not $_pythonExe) {
|
||||
$_pythonExe = (Get-ChildItem "$env:LOCALAPPDATA\Programs\Python\*\python.exe",
|
||||
"C:\Users\*\AppData\Local\Programs\Python\*\python.exe",
|
||||
"C:\Program Files\Python*\python.exe",
|
||||
"C:\Python*\python.exe" -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1).FullName
|
||||
}
|
||||
if (-not $_pythonExe) {
|
||||
Write-Host "ERROR: python not found. Install Python or add it to PATH." -ForegroundColor Red; exit 1
|
||||
}
|
||||
Write-Host "Python: $_pythonExe" -ForegroundColor Gray
|
||||
& $_pythonExe "$PSScriptRoot\gen_fetch_transactions.py" @_configArg @args
|
||||
& "C:\Users\User\AppData\Local\Programs\Python\Python312-arm64\python.exe" "$PSScriptRoot\gen_fetch_transactions.py" @_configArg @args
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "3a2ca76d-f735-4006-b521-66bccaf821a4",
|
||||
"type": "FETCH_PROFILE",
|
||||
"body": {}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e",
|
||||
"type": "FETCH_PROFILE",
|
||||
"body": {}
|
||||
}
|
||||
@ -9,12 +9,12 @@
|
||||
#include <QStackedWidget>
|
||||
|
||||
#include <QMessageBox>
|
||||
#include <QStatusBar>
|
||||
#include <QThread>
|
||||
|
||||
#include "TutorialWindow.h"
|
||||
#include "bank/BankListWindow.h"
|
||||
#include "event/EventWindow.h"
|
||||
#include "log/LogWindow.h"
|
||||
#include "log/FileLogWindow.h"
|
||||
#include "dao/GeneralLogDAO.h"
|
||||
#include "net/NetworkService.h"
|
||||
@ -119,6 +119,27 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
|
||||
SettingsDAO::set("tutorial_shown", "1");
|
||||
});
|
||||
}
|
||||
|
||||
// Sync-health индикатор в статус-баре (правило #9). По умолчанию скрыт —
|
||||
// показывается только когда NetworkService сообщил unhealthy.
|
||||
m_syncHealthLabel = new QLabel(this);
|
||||
m_syncHealthLabel->setVisible(false);
|
||||
m_syncHealthLabel->setStyleSheet("color: #d9534f; padding: 0 8px;");
|
||||
statusBar()->addPermanentWidget(m_syncHealthLabel);
|
||||
}
|
||||
|
||||
void MainWindow::setSyncHealth(bool healthy, int consecutiveFailures) {
|
||||
if (!m_syncHealthLabel) return;
|
||||
if (healthy) {
|
||||
m_syncHealthLabel->setVisible(false);
|
||||
m_syncHealthLabel->setToolTip(QString());
|
||||
return;
|
||||
}
|
||||
m_syncHealthLabel->setText(QStringLiteral("⚠ Нет связи с сервером"));
|
||||
m_syncHealthLabel->setToolTip(
|
||||
QStringLiteral("Подряд %1 неудачных синхронизаций. Проверьте подключение.")
|
||||
.arg(consecutiveFailures));
|
||||
m_syncHealthLabel->setVisible(true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -21,6 +21,11 @@ public:
|
||||
|
||||
void loadDevices();
|
||||
|
||||
public slots:
|
||||
// Подключается к NetworkService::syncHealthChanged. healthy=false показывает
|
||||
// индикатор «нет связи» в status bar, healthy=true прячет (правило #9).
|
||||
void setSyncHealth(bool healthy, int consecutiveFailures);
|
||||
|
||||
private:
|
||||
QWidget *central;
|
||||
FlowLayout *flowLayout;
|
||||
@ -38,6 +43,7 @@ private:
|
||||
QWidget *m_eventWindow = nullptr;
|
||||
|
||||
QListWidget *m_logList = nullptr;
|
||||
QLabel *m_syncHealthLabel = nullptr;
|
||||
|
||||
void createDevicePage();
|
||||
void deleteDevicePage();
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
#include <QInputDialog>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QGuiApplication>
|
||||
#include <QClipboard>
|
||||
#include <QToolTip>
|
||||
#include <QIcon>
|
||||
|
||||
#include "ozon/LoginAndCheckAccountsScript.h"
|
||||
#include "ozon/GetProfileInfoScript.h"
|
||||
@ -81,10 +85,28 @@ AccountSettingsWindow::AccountSettingsWindow(
|
||||
layout->addLayout(pinRow);
|
||||
|
||||
// ── Профиль ────────────────────────────────────────────────────────
|
||||
auto *profileRow = new QHBoxLayout;
|
||||
m_profileLabel = new QLabel;
|
||||
m_profileLabel->setTextFormat(Qt::RichText);
|
||||
m_profileLabel->setStyleSheet("margin: 4px 0;");
|
||||
layout->addWidget(m_profileLabel);
|
||||
profileRow->addWidget(m_profileLabel);
|
||||
|
||||
m_copyPhoneBtn = new QPushButton;
|
||||
m_copyPhoneBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/copy.png"));
|
||||
m_copyPhoneBtn->setIconSize(QSize(16, 16));
|
||||
m_copyPhoneBtn->setToolTip("Копировать телефон");
|
||||
m_copyPhoneBtn->setFixedSize(28, 24);
|
||||
m_copyPhoneBtn->setStyleSheet("padding: 2px;");
|
||||
m_copyPhoneBtn->hide();
|
||||
connect(m_copyPhoneBtn, &QPushButton::clicked, this, [this]() {
|
||||
if (m_app.phone.isEmpty()) return;
|
||||
QGuiApplication::clipboard()->setText(m_app.phone);
|
||||
QToolTip::showText(m_copyPhoneBtn->mapToGlobal(QPoint(0, m_copyPhoneBtn->height())),
|
||||
"Скопировано", m_copyPhoneBtn, {}, 1500);
|
||||
});
|
||||
profileRow->addWidget(m_copyPhoneBtn);
|
||||
profileRow->addStretch();
|
||||
layout->addLayout(profileRow);
|
||||
|
||||
// ── Кнопки обновления ──────────────────────────────────────────────
|
||||
auto *actionsRow = new QHBoxLayout;
|
||||
@ -197,6 +219,7 @@ void AccountSettingsWindow::updateStatusUI() {
|
||||
: " <span style='color:green;'>● Синхронизирован</span>";
|
||||
m_profileLabel->setText(text + syncText);
|
||||
}
|
||||
m_copyPhoneBtn->setVisible(!m_app.phone.isEmpty());
|
||||
}
|
||||
|
||||
// ── Toggle On/Off ──────────────────────────────────────────────────────────
|
||||
@ -224,27 +247,36 @@ void AccountSettingsWindow::toggleProfileStatus() {
|
||||
|
||||
m_app.status = newStatus;
|
||||
showOverlay(isActive ? "Выключение профиля..." : "Включение профиля...");
|
||||
sendAppStatus(newStatus);
|
||||
|
||||
// При включении профиля — включаем на сервере все локально активные материалы
|
||||
// При включении профиля — материалы включаем ТОЛЬКО после успешного
|
||||
// включения bank_profile на сервере; иначе пользователь получит подряд
|
||||
// несколько одинаковых ошибок ("Invalid or disabled connection") от
|
||||
// независимых PATCH'ей.
|
||||
std::function<void()> onSuccess;
|
||||
if (!isActive) {
|
||||
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(m_deviceId, m_app.code);
|
||||
for (const auto &mat : materials) {
|
||||
if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) {
|
||||
auto *thread = new QThread;
|
||||
auto *net = new NetworkService;
|
||||
net->moveToThread(thread);
|
||||
net->addExtra("material_id", mat.materialId);
|
||||
net->addExtra("enable", true);
|
||||
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial);
|
||||
connect(net, &NetworkService::finished, thread, &QThread::quit);
|
||||
connect(net, &NetworkService::finished, net, &QObject::deleteLater);
|
||||
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||
thread->start();
|
||||
qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId;
|
||||
const QString deviceId = m_deviceId;
|
||||
const QString appCode = m_app.code;
|
||||
onSuccess = [this, deviceId, appCode]() {
|
||||
const QList<MaterialInfo> materials = MaterialDAO::getAccounts(deviceId, appCode);
|
||||
for (const auto &mat : materials) {
|
||||
if (mat.status == MaterialStatus::Active && !mat.materialId.isEmpty()) {
|
||||
auto *thread = new QThread;
|
||||
auto *net = new NetworkService;
|
||||
net->moveToThread(thread);
|
||||
net->addExtra("material_id", mat.materialId);
|
||||
net->addExtra("enable", true);
|
||||
NetworkService::connectErrorDialog(this, net);
|
||||
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial);
|
||||
connect(net, &NetworkService::finished, thread, &QThread::quit);
|
||||
connect(net, &NetworkService::finished, net, &QObject::deleteLater);
|
||||
connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||
thread->start();
|
||||
qDebug() << "[AccountSettings] Enabling material on server:" << mat.materialId;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
sendAppStatus(newStatus, std::move(onSuccess));
|
||||
}
|
||||
|
||||
// ── Edit PIN ───────────────────────────────────────────────────────────────
|
||||
@ -439,7 +471,7 @@ void AccountSettingsWindow::startGetCardInfo() {
|
||||
|
||||
// ── Send status to server ──────────────────────────────────────────────────
|
||||
|
||||
void AccountSettingsWindow::sendAppStatus(const QString &status) {
|
||||
void AccountSettingsWindow::sendAppStatus(const QString &status, std::function<void()> onSuccess) {
|
||||
if (m_app.bankProfileId.isEmpty()) {
|
||||
qWarning() << "[AccountSettings] sendAppStatus: no bankProfileId for" << m_app.code;
|
||||
hideOverlay(true, "Сохранено локально", [this]() { updateStatusUI(); });
|
||||
@ -452,12 +484,25 @@ void AccountSettingsWindow::sendAppStatus(const QString &status) {
|
||||
net->moveToThread(thread);
|
||||
net->addExtra("bank_profile_id", m_app.bankProfileId);
|
||||
net->addExtra("enable", enable);
|
||||
NetworkService::connectErrorDialog(this, net);
|
||||
connect(thread, &QThread::started, net, &NetworkService::toggleBankProfile);
|
||||
connect(net, &NetworkService::finishedWithResult, this, [this](int result) {
|
||||
connect(net, &NetworkService::finishedWithResult, this, [this, enable, onSuccess = std::move(onSuccess)](int result) {
|
||||
if (result == 0) {
|
||||
hideOverlay(true, "Успешно", [this]() { updateStatusUI(); });
|
||||
if (onSuccess) onSuccess();
|
||||
return;
|
||||
}
|
||||
// Правило #3: при неудачном включении — откатываем локаль в off и
|
||||
// сообщаем юзеру. При неудачном выключении — локаль остаётся off,
|
||||
// сервер дотянется периодическим sync-ом (правило #2).
|
||||
if (enable) {
|
||||
BankProfileDAO::update(m_app.id, m_app.pinCode, m_app.comment, "off");
|
||||
m_app.status = "off";
|
||||
hideOverlay(false, "Не удалось включить — возвращено в выключено",
|
||||
[this]() { updateStatusUI(); });
|
||||
} else {
|
||||
hideOverlay(false, "Ошибка отправки на сервер", [this]() { updateStatusUI(); });
|
||||
hideOverlay(false, "Локально выключено, сервер дотянется автоматически",
|
||||
[this]() { updateStatusUI(); });
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
connect(net, &NetworkService::finished, thread, &QThread::quit);
|
||||
@ -524,6 +569,16 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
|
||||
reloadCards();
|
||||
};
|
||||
|
||||
// Сервер не даёт включать материалы при выключенном профиле — возвращает их
|
||||
// в `disabled`, и client-side sync затирает локальный active. Поэтому пока
|
||||
// профиль off, синхронизация только локальная; материалы зальются на сервер
|
||||
// когда юзер включит профиль (см. toggleProfileStatus).
|
||||
const bool profileActive = m_app.status == "active";
|
||||
if (!profileActive) {
|
||||
hideOverlay(true, "Сохранено локально", finishToggle);
|
||||
return;
|
||||
}
|
||||
|
||||
// Выключаем на сервере остальные (fire-and-forget) — только при включении этой карты
|
||||
if (!isActive) {
|
||||
for (const MaterialInfo &m : deactivated) {
|
||||
@ -533,6 +588,7 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
|
||||
n->moveToThread(t);
|
||||
n->addExtra("material_id", m.materialId);
|
||||
n->addExtra("enable", false);
|
||||
NetworkService::connectErrorDialog(this, n);
|
||||
connect(t, &QThread::started, n, &NetworkService::toggleMaterial);
|
||||
connect(n, &NetworkService::finished, t, &QThread::quit);
|
||||
connect(n, &NetworkService::finished, n, &QObject::deleteLater);
|
||||
@ -547,12 +603,22 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
|
||||
net->moveToThread(thread);
|
||||
net->addExtra("material_id", account.materialId);
|
||||
net->addExtra("enable", !isActive);
|
||||
NetworkService::connectErrorDialog(this, net);
|
||||
connect(thread, &QThread::started, net, &NetworkService::toggleMaterial);
|
||||
connect(net, &NetworkService::finishedWithResult, this, [this, finishToggle](int result) {
|
||||
const bool willEnable = !isActive;
|
||||
const int materialDbId = account.id;
|
||||
connect(net, &NetworkService::finishedWithResult, this,
|
||||
[this, finishToggle, willEnable, materialDbId](int result) {
|
||||
if (result == 0) {
|
||||
hideOverlay(true, "Успешно", finishToggle);
|
||||
return;
|
||||
}
|
||||
// Правило #3: при неудачном включении карты — откат локально в off.
|
||||
if (willEnable) {
|
||||
MaterialDAO::updateStatus(materialDbId, "off");
|
||||
hideOverlay(false, "Не удалось включить — возвращено в выключено", finishToggle);
|
||||
} else {
|
||||
hideOverlay(false, "Ошибка отправки на сервер", finishToggle);
|
||||
hideOverlay(false, "Локально выключено, сервер дотянется автоматически", finishToggle);
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
connect(net, &NetworkService::finished, thread, &QThread::quit);
|
||||
@ -569,28 +635,50 @@ void AccountSettingsWindow::toggleMaterialStatus(const MaterialInfo &account) {
|
||||
void AccountSettingsWindow::reloadCards() {
|
||||
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(m_deviceId, m_app.code);
|
||||
|
||||
m_cardsTable->clearContents();
|
||||
// setRowCount(0) перед re-fill удаляет и items, и cell widgets.
|
||||
// clearContents() оставляет setCellWidget'ы висеть — старые кнопки с
|
||||
// замкнутой устаревшей `acc` остаются в строках, ставших active.
|
||||
m_cardsTable->setRowCount(0);
|
||||
m_cardsTable->setRowCount(accounts.size());
|
||||
m_cardsTable->setColumnCount(7);
|
||||
m_cardsTable->setColumnCount(8);
|
||||
m_cardsTable->setHorizontalHeaderLabels({
|
||||
"Сервер", "Статус", "Время обновления", "Номер карты", "Сумма", "Валюта", ""
|
||||
"Сервер", "Статус", "Время обновления", "Номер карты", "Сумма", "Валюта", "", ""
|
||||
});
|
||||
m_cardsTable->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
|
||||
// Правило 4-A: при выключенном профиле материалы фактически недоступны —
|
||||
// отображаем приглушённо (gray) и добавляем подсказку, локальный status в БД не трогаем.
|
||||
const bool profileOff = (m_app.status != "active");
|
||||
const QBrush dimmedBrush = QBrush(QColor(128, 128, 128));
|
||||
const QString dimmedTooltip = profileOff
|
||||
? QStringLiteral("Профиль банка выключен — карта фактически недоступна. "
|
||||
"Включите профиль, чтобы карта заработала.")
|
||||
: QString();
|
||||
|
||||
for (int k = 0; k < accounts.size(); ++k) {
|
||||
const MaterialInfo &acc = accounts[k];
|
||||
|
||||
// Сервер
|
||||
auto *syncItem = new QTableWidgetItem(acc.materialId.isEmpty() ? "Не синхр." : "Синхр.");
|
||||
syncItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
syncItem->setForeground(acc.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen));
|
||||
if (profileOff) {
|
||||
syncItem->setForeground(dimmedBrush);
|
||||
syncItem->setToolTip(dimmedTooltip);
|
||||
} else {
|
||||
syncItem->setForeground(acc.materialId.isEmpty() ? QBrush(Qt::red) : QBrush(Qt::darkGreen));
|
||||
}
|
||||
m_cardsTable->setItem(k, 0, syncItem);
|
||||
|
||||
// Статус
|
||||
const bool isActive = acc.status == MaterialStatus::Active;
|
||||
auto *statusItem = new QTableWidgetItem(materialStatusToUiString(acc.status));
|
||||
statusItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
statusItem->setForeground(isActive ? QBrush(Qt::darkGreen) : QBrush(Qt::red));
|
||||
if (profileOff) {
|
||||
statusItem->setForeground(dimmedBrush);
|
||||
statusItem->setToolTip(dimmedTooltip);
|
||||
} else {
|
||||
statusItem->setForeground(isActive ? QBrush(Qt::darkGreen) : QBrush(Qt::red));
|
||||
}
|
||||
m_cardsTable->setItem(k, 1, statusItem);
|
||||
|
||||
// Время обновления
|
||||
@ -598,11 +686,32 @@ void AccountSettingsWindow::reloadCards() {
|
||||
timeItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
m_cardsTable->setItem(k, 2, timeItem);
|
||||
|
||||
// Номер карты
|
||||
// Номер карты (текст + иконка копирования)
|
||||
const QString displayNumber = acc.cardNumber.isEmpty() ? acc.lastNumbers : acc.cardNumber;
|
||||
auto *numItem = new QTableWidgetItem(displayNumber);
|
||||
numItem->setTextAlignment(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
m_cardsTable->setItem(k, 3, numItem);
|
||||
const QString fullCardNumber = acc.cardNumber.isEmpty() ? acc.lastNumbers : acc.cardNumber;
|
||||
auto *cardCell = new QWidget(m_cardsTable);
|
||||
auto *cardLayout = new QHBoxLayout(cardCell);
|
||||
cardLayout->setContentsMargins(4, 0, 4, 0);
|
||||
cardLayout->setSpacing(6);
|
||||
auto *cardLabel = new QLabel(displayNumber, cardCell);
|
||||
cardLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
cardLayout->addWidget(cardLabel);
|
||||
if (!fullCardNumber.isEmpty()) {
|
||||
auto *copyCardBtn = new QPushButton(cardCell);
|
||||
copyCardBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/copy.png"));
|
||||
copyCardBtn->setIconSize(QSize(14, 14));
|
||||
copyCardBtn->setToolTip("Копировать номер карты");
|
||||
copyCardBtn->setFixedSize(22, 20);
|
||||
copyCardBtn->setStyleSheet("padding: 1px; border: none;");
|
||||
connect(copyCardBtn, &QPushButton::clicked, this, [copyCardBtn, fullCardNumber]() {
|
||||
QGuiApplication::clipboard()->setText(fullCardNumber);
|
||||
QToolTip::showText(copyCardBtn->mapToGlobal(QPoint(0, copyCardBtn->height())),
|
||||
"Скопировано", copyCardBtn, {}, 1500);
|
||||
});
|
||||
cardLayout->addWidget(copyCardBtn);
|
||||
}
|
||||
cardLayout->addStretch();
|
||||
m_cardsTable->setCellWidget(k, 3, cardCell);
|
||||
|
||||
// Сумма
|
||||
auto *amountItem = new QTableWidgetItem(QString::number(acc.amount));
|
||||
@ -624,6 +733,23 @@ void AccountSettingsWindow::reloadCards() {
|
||||
toggleMaterialStatus(acc);
|
||||
});
|
||||
}
|
||||
|
||||
// Кнопка копирования номера счёта
|
||||
if (!acc.accountNumber.isEmpty()) {
|
||||
auto *copyAcctBtn = new QPushButton(m_cardsTable);
|
||||
copyAcctBtn->setIcon(QIcon(QCoreApplication::applicationDirPath() + "/images/copy.png"));
|
||||
copyAcctBtn->setIconSize(QSize(16, 16));
|
||||
copyAcctBtn->setToolTip("Копировать номер счёта");
|
||||
copyAcctBtn->setFixedSize(28, 24);
|
||||
copyAcctBtn->setStyleSheet("padding: 2px;");
|
||||
m_cardsTable->setCellWidget(k, 7, copyAcctBtn);
|
||||
|
||||
connect(copyAcctBtn, &QPushButton::clicked, this, [this, copyAcctBtn, acc]() {
|
||||
QGuiApplication::clipboard()->setText(acc.accountNumber);
|
||||
QToolTip::showText(copyAcctBtn->mapToGlobal(QPoint(0, copyAcctBtn->height())),
|
||||
"Скопировано", copyAcctBtn, {}, 1500);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
m_cardsTable->resizeColumnsToContents();
|
||||
|
||||
@ -24,6 +24,7 @@ private:
|
||||
QLabel *m_statusLabel;
|
||||
QLabel *m_pinLabel;
|
||||
QLabel *m_profileLabel;
|
||||
QPushButton *m_copyPhoneBtn = nullptr;
|
||||
QPushButton *m_toggleBtn;
|
||||
QPushButton *m_profileBtn;
|
||||
QPushButton *m_cardsBtn;
|
||||
@ -43,7 +44,7 @@ private:
|
||||
void startCheckPinCode();
|
||||
void startGetProfileInfo();
|
||||
void startGetCardInfo();
|
||||
void sendAppStatus(const QString &status);
|
||||
void sendAppStatus(const QString &status, std::function<void()> onSuccess = {});
|
||||
void toggleMaterialStatus(const MaterialInfo &account);
|
||||
void reloadCards();
|
||||
|
||||
|
||||