#include "TaskDumpRecorder.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "adb/AdbUtils.h" #include "dao/SettingsDAO.h" #include "net/NetworkService.h" #include "LogArchiveSender.h" namespace { QThreadStorage &tlsRecorder() { static QThreadStorage tls; return tls; } QPointer &networkServiceRef() { static QPointer ref; return ref; } QString dumpsRoot() { // Пишем в пользовательский temp — UAC/Program Files не мешают. QString base = QStandardPaths::writableLocation(QStandardPaths::TempLocation); if (base.isEmpty()) base = QDir::tempPath(); return base + "/arc_dumps"; } QString sanitize(const QString &raw, int maxLen = 40) { static const QRegularExpression re(QStringLiteral("[^\\p{L}\\p{N}_-]+")); QString s = raw; s.replace(re, "_"); if (s.size() > maxLen) s = s.left(maxLen); while (s.endsWith('_')) s.chop(1); while (s.startsWith('_')) s = s.mid(1); return s; } QString extractScreenTag(const QString &xml) { static const QRegularExpression re(QStringLiteral("text=\"([^\"]{4,40})\"")); static const QRegularExpression noiseRe(QStringLiteral("^[0-9\\s,\\.\\-+₽$€%]+$")); auto it = re.globalMatch(xml); while (it.hasNext()) { QString text = it.next().captured(1).trimmed(); if (text.isEmpty() || noiseRe.match(text).hasMatch()) continue; QString tag = sanitize(text); if (!tag.isEmpty()) return tag; } return QStringLiteral("screen"); } bool zipFolder(const QString &folder, const QString &archivePath) { #ifdef Q_OS_WIN const QString cmd = QString( "Compress-Archive -Path '%1\\*' -DestinationPath '%2' -Force" ).arg(QDir::toNativeSeparators(folder), QDir::toNativeSeparators(archivePath)); QProcess p; p.start("powershell", {"-NoProfile", "-Command", cmd}); if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) { qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString(); return false; } if (p.exitCode() != 0) { qWarning() << "[TaskDumpRecorder] zip exit=" << p.exitCode() << "stderr:" << QString::fromUtf8(p.readAllStandardError()).trimmed(); return false; } return true; #else const QFileInfo fi(folder); QProcess p; p.setWorkingDirectory(fi.absolutePath()); p.start("zip", {"-rq", archivePath, fi.fileName()}); if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) { qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString(); return false; } return p.exitCode() == 0; #endif } QByteArray readFileBytes(const QString &path) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) return {}; QByteArray bytes = f.readAll(); f.close(); return bytes; } } // namespace void TaskDumpRecorder::setNetworkService(NetworkService *service) { networkServiceRef() = service; } NetworkService *TaskDumpRecorder::networkService() { return networkServiceRef().data(); } TaskDumpRecorder *TaskDumpRecorder::current() { return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr; } void TaskDumpRecorder::begin(const QString &bank, const QString &taskKind, const QString &adbDeviceId, const TaskDumpMeta &meta) { if (auto *prev = current()) { prev->finishInternal(QStringLiteral("interrupted (new task started before previous finished)")); // Не вызываем `delete prev` — QThreadStorage сам удалит предыдущий // указатель при замене через setLocalData. } auto *rec = new TaskDumpRecorder(bank, taskKind, adbDeviceId, meta); tlsRecorder().setLocalData(rec); } void TaskDumpRecorder::finish(const QString &errorMessage) { auto *rec = current(); if (!rec) return; rec->finishInternal(errorMessage); // setLocalData(nullptr) сам вызовет delete на текущем указателе. tlsRecorder().setLocalData(nullptr); } TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind, const QString &adbDeviceId, const TaskDumpMeta &meta) : m_bank(sanitize(bank)), m_taskKind(sanitize(taskKind)), m_adbDeviceIdRaw(adbDeviceId), m_adbDeviceIdSafe(sanitize(adbDeviceId)), m_meta(meta) { // Короткий суффикс для имени папки — чтобы визуально отличать задачи, // но не раздувать путь. Берём materialId (первые 8 символов — UUID segment) // + сумму, если есть. QString folderTail; if (!m_meta.materialId.isEmpty()) { folderTail = sanitize(m_meta.materialId.left(8)); } if (m_meta.amount != 0) { const QString amt = QString::number(static_cast(m_meta.amount)); folderTail = folderTail.isEmpty() ? amt : folderTail + "_" + amt; } const QString ts = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); const QString sub = folderTail.isEmpty() ? QString("%1_%2").arg(ts, m_adbDeviceIdSafe) : QString("%1_%2_%3").arg(ts, m_adbDeviceIdSafe, folderTail); m_folder = QString("%1/%2_%3/%4") .arg(dumpsRoot(), m_bank, m_taskKind, sub); if (!QDir().mkpath(m_folder)) { qWarning() << "[TaskDumpRecorder] failed to mkpath" << m_folder; m_folder.clear(); return; } qDebug() << "[TaskDumpRecorder] begin:" << m_folder; } QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const { const QString timestamp = QDateTime::currentDateTime().toString(Qt::ISODate); // api-токен в caption больше не пишем: он общий на нескольких операторов и // не позволяет отличить, чей профиль упал. Идентифицируем по UUID профиля, // материалу и телефону профиля (см. m_meta). QStringList head{QStringLiteral("[TASK_ERROR]")}; head << QStringLiteral("[%1]").arg(m_bank.toHtmlEscaped()); head << QStringLiteral("[%1]").arg(m_taskKind.toHtmlEscaped()); const QString desktopId = SettingsDAO::get("desktop_id"); // Версия самого ARC-десктопа (клиента), не банковского приложения. const QString clientVersion = QSettings("config.ini", QSettings::IniFormat).value("common/version").toString(); QString body; if (!desktopId.isEmpty()) body += QStringLiteral("Desktop: %1\n").arg(desktopId.toHtmlEscaped()); if (!clientVersion.isEmpty()) body += QStringLiteral("DesktopVersion: %1\n").arg(clientVersion.toHtmlEscaped()); body += QStringLiteral("Device: %1").arg(m_adbDeviceIdRaw.toHtmlEscaped()); if (!m_meta.androidId.isEmpty()) body += QStringLiteral("\nAndroidId: %1").arg(m_meta.androidId.toHtmlEscaped()); if (!m_meta.bankProfileId.isEmpty()) body += QStringLiteral("\nProfile: %1").arg(m_meta.bankProfileId.toHtmlEscaped()); if (!m_meta.materialId.isEmpty()) body += QStringLiteral("\nMaterial: %1").arg(m_meta.materialId.toHtmlEscaped()); if (!m_meta.profilePhone.isEmpty()) body += QStringLiteral("\nPhone: %1").arg(m_meta.profilePhone.toHtmlEscaped()); if (!m_meta.appVersion.isEmpty()) body += QStringLiteral("\nAppVersion: %1").arg(m_meta.appVersion.toHtmlEscaped()); if (m_meta.amount != 0) body += QStringLiteral("\nAmount: %1").arg(static_cast(m_meta.amount)); if (!m_meta.phone.isEmpty()) body += QStringLiteral("\nRecipient: %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("%1\n%2\n%3") .arg(head.join(QStringLiteral(" ")), timestamp.toHtmlEscaped(), body); } void TaskDumpRecorder::saveXml(int counter, const QString &xml) { if (m_folder.isEmpty() || xml.isEmpty()) return; const QString tag = extractScreenTag(xml); const QString fname = QString("%1/%2_%3.xml") .arg(m_folder, QString("%1").arg(counter, 4, 10, QChar('0')), tag); QFile f(fname); if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { f.write(xml.toUtf8()); f.close(); } else { qWarning() << "[TaskDumpRecorder] failed to write" << fname << f.errorString(); } } void TaskDumpRecorder::finishInternal(const QString &errorMessage) { if (m_finished) return; m_finished = true; if (m_folder.isEmpty()) return; if (errorMessage.isEmpty()) { QDir(m_folder).removeRecursively(); qDebug() << "[TaskDumpRecorder] success — removed" << m_folder; return; } qWarning() << "[TaskDumpRecorder] task failed:" << errorMessage << "folder:" << m_folder; // Финальный скриншот текущего экрана if (!m_adbDeviceIdRaw.isEmpty()) { const QByteArray png = AdbUtils::captureScreenshotBytes(m_adbDeviceIdRaw); if (!png.isEmpty()) { QFile f(m_folder + "/_final_screen.png"); if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { f.write(png); f.close(); } } } // Метаданные { QFile ef(m_folder + "/_error.txt"); if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) { QTextStream ts(&ef); ts.setEncoding(QStringConverter::Utf8); const QString desktopId = SettingsDAO::get("desktop_id"); if (!desktopId.isEmpty()) ts << "desktop_id: " << desktopId << "\n"; const QString clientVersion = QSettings("config.ini", QSettings::IniFormat).value("common/version").toString(); if (!clientVersion.isEmpty()) ts << "desktop_version: " << clientVersion << "\n"; ts << "bank: " << m_bank << "\n" << "task: " << m_taskKind << "\n" << "device: " << m_adbDeviceIdRaw << "\n"; if (!m_meta.androidId.isEmpty()) ts << "android_id: " << m_meta.androidId << "\n"; if (!m_meta.bankProfileId.isEmpty()) ts << "profile_id: " << m_meta.bankProfileId << "\n"; if (!m_meta.profilePhone.isEmpty()) ts << "profile_phone: " << m_meta.profilePhone << "\n"; if (!m_meta.appVersion.isEmpty()) ts << "app_version: " << m_meta.appVersion << "\n"; if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n"; if (m_meta.amount != 0) ts << "amount: " << static_cast(m_meta.amount) << "\n"; if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n"; if (!m_meta.card.isEmpty()) ts << "card: " << m_meta.card << "\n"; ts << "time: " << QDateTime::currentDateTime().toString(Qt::ISODate) << "\n" << "error: " << errorMessage << "\n"; ef.close(); } } const QString folder = m_folder; const QString archivePath = m_folder + ".zip"; const QString text = buildCaption(errorMessage); // Имя файла архива на relay: __.zip — чтобы дамп // сразу читался по банку и типу задачи без открытия caption'а. const QString stamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); const QString archiveFilename = QStringLiteral("%1_%2_%3.zip").arg(m_bank, m_taskKind, stamp); // Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем // его в память, пока папка ещё цела. QByteArray finalScreenshot; { const QString screenPath = folder + "/_final_screen.png"; if (QFile::exists(screenPath)) { finalScreenshot = readFileBytes(screenPath); } } QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot, archiveFilename]() { // Кладём application.log + БД (+WAL/SHM) прямо в папку задачи (подкаталог // logs/), чтобы скрины задачи и логи ушли одним общим архивом, а не двумя // отдельными сообщениями на relay. LogArchiveSender::collectInto(folder + "/logs"); QByteArray archiveBytes; if (zipFolder(folder, archivePath)) { archiveBytes = readFileBytes(archivePath); if (archiveBytes.isEmpty()) { qWarning() << "[TaskDumpRecorder] zip is empty, sending text-only dump for" << folder; } } else { qWarning() << "[TaskDumpRecorder] zip failed, sending text-only dump for" << folder; } QDir(folder).removeRecursively(); QFile::remove(archivePath); NetworkService *svc = networkServiceRef().data(); 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), Q_ARG(QString, archiveFilename)); 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?)"; } }); QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater); bg->start(); } TaskDumpRecorder::Scope::Scope(const QString &bank, const QString &taskKind, const QString &adbDeviceId, const TaskDumpMeta &meta, const QString *errorPtr) : m_errorPtr(errorPtr), m_owned(false) { // Вложенные скоупы не перетирают рекордер родителя — иначе внутренний // скрипт прервал бы запись внешней задачи и сгенерировал ложный дамп // с пометкой "interrupted". if (TaskDumpRecorder::current() == nullptr) { TaskDumpRecorder::begin(bank, taskKind, adbDeviceId, meta); m_owned = true; } } TaskDumpRecorder::Scope::~Scope() { if (m_owned) { TaskDumpRecorder::finish(m_errorPtr ? *m_errorPtr : QString()); } }