359 lines
15 KiB
C++
359 lines
15 KiB
C++
#include "TaskDumpRecorder.h"
|
||
|
||
#include <QCoreApplication>
|
||
#include <QDateTime>
|
||
#include <QDir>
|
||
#include <QFile>
|
||
#include <QMetaObject>
|
||
#include <QPointer>
|
||
#include <QProcess>
|
||
#include <QRegularExpression>
|
||
#include <QStandardPaths>
|
||
#include <QTextStream>
|
||
#include <QThread>
|
||
#include <QThreadStorage>
|
||
#include <qDebug>
|
||
|
||
#include "adb/AdbUtils.h"
|
||
#include "dao/SettingsDAO.h"
|
||
#include "net/NetworkService.h"
|
||
#include "LogArchiveSender.h"
|
||
|
||
namespace {
|
||
|
||
QThreadStorage<TaskDumpRecorder *> &tlsRecorder() {
|
||
static QThreadStorage<TaskDumpRecorder *> tls;
|
||
return tls;
|
||
}
|
||
|
||
QPointer<NetworkService> &networkServiceRef() {
|
||
static QPointer<NetworkService> 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<T*> сам удалит предыдущий
|
||
// указатель при замене через 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<qint64>(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");
|
||
QString body;
|
||
if (!desktopId.isEmpty())
|
||
body += QStringLiteral("Desktop: %1\n").arg(desktopId.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<qint64>(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("<b>%1</b>\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;
|
||
|
||
// "Interrupted" трактуем как НЕ-ошибку для дампа: это следствие внешнего
|
||
// requestInterruption() (watchdog-таймаут или clearAll при остановке/выходе),
|
||
// а не самостоятельный сбой. Причину уже репортит инициатор прерывания
|
||
// (watchdog шлёт "Script timeout (N min)"), поэтому тяжёлый ZIP-дамп по
|
||
// Interrupted не шлём — иначе на один таймаут копится дубль log+dump.
|
||
if (errorMessage.isEmpty() || errorMessage == "Interrupted") {
|
||
QDir(m_folder).removeRecursively();
|
||
qDebug() << "[TaskDumpRecorder]" << (errorMessage.isEmpty() ? "success" : "interrupted")
|
||
<< "— 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";
|
||
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<qint64>(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: <bank>_<task>_<timestamp>.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());
|
||
}
|
||
}
|