Files
crm.clientright.ru/modules/Vtiger/services/Base.php
Fedor d3ba054027 Исправление архивации проектов: поддержка S3 файлов и документов из связанных сущностей
- Добавлен метод getRelatedDocs() для получения документов из связанных сущностей (контакты, контрагенты)
- Добавлен метод downloadS3File() для скачивания файлов из S3 во временную папку
- Добавлен метод cleanupTempFiles() для очистки временных файлов
- Исправлен getPaths() для корректной обработки S3 файлов (всегда запрашивает s3_bucket/s3_key из БД)
- Исправлен getArchive() для проектов: собирает документы из основной записи и связанных сущностей
- Исправлен путь к vendor/autoload.php (поиск по нескольким путям)
- Исправлено имя временного файла (короткое имя вместо полного пути для избежания 'File name too long')

Результат: архив успешно создается с документами из проекта и связанных сущностей (25 документов для проекта 396447)
2025-11-21 10:23:52 +03:00

655 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
class Vtiger_Base_Service
{
private static $s3Client = null;
private static $tempFiles = []; // Для очистки временных файлов после архивации
/**
* Инициализация S3 клиента
*/
private static function initS3Client()
{
if (self::$s3Client === null) {
$configPath = __DIR__ . '/../../crm_extensions/file_storage/config.php';
if (file_exists($configPath)) {
$config = require $configPath;
require_once __DIR__ . '/../../crm_extensions/file_storage/S3Client.php';
self::$s3Client = new S3Client($config['s3']);
}
}
return self::$s3Client;
}
/**
* Скачивание файла из S3 во временную папку
*/
private static function downloadS3File($s3Bucket, $s3Key, $fileName)
{
$debugLog = '/tmp/s3_download_debug.log';
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - downloadS3File: START - bucket={$s3Bucket}, key={$s3Key}\n", FILE_APPEND);
try {
error_log("downloadS3File: Starting download - bucket={$s3Bucket}, key={$s3Key}");
// Используем нативный AWS SDK для скачивания
// Пробуем несколько возможных путей к vendor/autoload.php
$possibleVendorPaths = [
__DIR__ . '/../../vendor/autoload.php', // От modules/Vtiger/services/
__DIR__ . '/../../../vendor/autoload.php', // Альтернативный путь
'/var/www/fastuser/data/www/crm.clientright.ru/vendor/autoload.php', // Абсолютный путь
];
$vendorPath = null;
foreach ($possibleVendorPaths as $path) {
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Checking vendor path: {$path}\n", FILE_APPEND);
if (file_exists($path)) {
$vendorPath = $path;
break;
}
}
if (!$vendorPath) {
$errorMsg = "downloadS3File: vendor/autoload.php not found. Tried: " . implode(', ', $possibleVendorPaths);
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
require_once $vendorPath;
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - vendor/autoload.php loaded from: {$vendorPath}\n", FILE_APPEND);
// Пробуем несколько путей к конфигурации
$possiblePaths = [
__DIR__ . '/../../crm_extensions/file_storage/config.php',
dirname(__DIR__) . '/../../crm_extensions/file_storage/config.php',
'/var/www/fastuser/data/www/crm.clientright.ru/crm_extensions/file_storage/config.php'
];
$configPath = null;
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$configPath = $path;
break;
}
}
if (!$configPath) {
$errorMsg = "downloadS3File: Config file not found. Tried: " . implode(', ', $possiblePaths);
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Config found at: {$configPath}\n", FILE_APPEND);
try {
$config = require $configPath;
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Config loaded successfully\n", FILE_APPEND);
} catch (Exception $e) {
$errorMsg = "downloadS3File: Error loading config: " . $e->getMessage();
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
if (!isset($config['s3'])) {
$errorMsg = "downloadS3File: S3 config not found in config file";
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
$s3Config = $config['s3'];
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - S3 config loaded, endpoint: " . ($s3Config['endpoint'] ?? 'NULL') . "\n", FILE_APPEND);
// Проверяем наличие обязательных полей
if (empty($s3Config['key']) || empty($s3Config['secret']) || empty($s3Config['endpoint'])) {
$errorMsg = "downloadS3File: Missing required S3 config fields";
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - ERROR: {$errorMsg}\n", FILE_APPEND);
return false;
}
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Creating S3Client...\n", FILE_APPEND);
$awsClient = new \Aws\S3\S3Client([
'version' => $s3Config['version'],
'region' => $s3Config['region'],
'endpoint' => $s3Config['endpoint'],
'use_path_style_endpoint' => $s3Config['use_path_style_endpoint'],
'credentials' => [
'key' => $s3Config['key'],
'secret' => $s3Config['secret'],
],
]);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - S3Client created\n", FILE_APPEND);
// Используем bucket из параметра, а не из конфига
// Используем только расширение файла для имени временного файла, чтобы избежать "File name too long"
$extension = '';
if (!empty($fileName)) {
// Декодируем URL-encoded имя файла, если это URL
$decodedFileName = urldecode($fileName);
// Извлекаем расширение из оригинального s3_key, если filename - это URL
if (strpos($decodedFileName, '/') !== false) {
// Если filename содержит путь, используем s3_key для расширения
$extension = pathinfo($s3Key, PATHINFO_EXTENSION);
} else {
$extension = pathinfo($decodedFileName, PATHINFO_EXTENSION);
}
}
if (empty($extension) && !empty($s3Key)) {
$extension = pathinfo($s3Key, PATHINFO_EXTENSION);
}
// Создаем короткое имя файла с расширением
$tempFileName = uniqid('s3_') . (!empty($extension) ? '.' . $extension : '');
$tempFile = sys_get_temp_dir() . '/' . $tempFileName;
error_log("downloadS3File: Temp file path: {$tempFile}");
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Temp file path: {$tempFile}\n", FILE_APPEND);
// Скачиваем файл
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Calling getObject() - Bucket: {$s3Bucket}, Key: {$s3Key}\n", FILE_APPEND);
$result = $awsClient->getObject([
'Bucket' => $s3Bucket,
'Key' => $s3Key,
'SaveAs' => $tempFile
]);
error_log("downloadS3File: getObject() completed successfully");
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - getObject() completed successfully\n", FILE_APPEND);
if (!file_exists($tempFile)) {
error_log("downloadS3File: File was not created: {$tempFile}");
return false;
}
$fileSize = filesize($tempFile);
if ($fileSize == 0) {
error_log("downloadS3File: WARNING - File size is 0 bytes: {$tempFile}");
// Не возвращаем false для пустого файла - возможно, это нормально
}
error_log("downloadS3File: Success - file size: {$fileSize} bytes");
// Сохраняем путь для последующей очистки
self::$tempFiles[] = $tempFile;
return $tempFile;
} catch (\Aws\Exception\AwsException $e) {
$errorMsg = "downloadS3File: AWS Exception - " . $e->getMessage();
$errorMsg .= " | Error Code: " . $e->getAwsErrorCode();
$errorMsg .= " | Request ID: " . $e->getAwsRequestId();
$errorMsg .= " | Bucket: {$s3Bucket} | Key: {$s3Key}";
error_log($errorMsg);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - AWS EXCEPTION: {$errorMsg}\n", FILE_APPEND);
@file_put_contents('/tmp/s3_download_errors.log', date('Y-m-d H:i:s') . ' - ' . $errorMsg . "\n", FILE_APPEND);
return false;
} catch (Exception $e) {
$errorMsg = "downloadS3File: Exception - " . $e->getMessage();
$errorMsg .= " | Bucket: {$s3Bucket} | Key: {$s3Key}";
error_log($errorMsg);
error_log("downloadS3File: Stack trace - " . $e->getTraceAsString());
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - EXCEPTION: {$errorMsg}\n", FILE_APPEND);
@file_put_contents($debugLog, date('Y-m-d H:i:s') . " - Stack trace: " . $e->getTraceAsString() . "\n", FILE_APPEND);
@file_put_contents('/tmp/s3_download_errors.log', date('Y-m-d H:i:s') . ' - ' . $errorMsg . "\n", FILE_APPEND);
return false;
}
}
/**
* Очистка временных файлов
*/
private static function cleanupTempFiles()
{
foreach (self::$tempFiles as $tempFile) {
if (file_exists($tempFile)) {
@unlink($tempFile);
}
}
self::$tempFiles = [];
}
public static function getDocs($record)
{
$module = 'Documents';
$relation = Vtiger_RelationListView_Model::getInstance(
$record,
$module
);
$pager = new Vtiger_Paging_Model();
$pager->set('limit', 1000);
return $relation->getEntries($pager);
}
/**
* Получение документов из связанных сущностей (для проектов)
*/
public static function getRelatedDocs($projectId)
{
$adb = PearDatabase::getInstance();
$docs = [];
// Получаем информацию о проекте и связанных контрагентах
$query = 'SELECT
p.linktoaccountscontacts as contactid,
pcf.cf_1994 as accountid,
pcf.cf_2274 as acc1,
pcf.cf_2276 as acc2
FROM vtiger_project p
LEFT JOIN vtiger_projectcf pcf ON pcf.projectid = p.projectid
LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0 AND p.projectid = ?';
$result = $adb->pquery($query, array($projectId));
if ($adb->num_rows($result) == 0) {
return $docs;
}
$row = $adb->query_result_rowdata($result, 0);
$contactId = $row['contactid'];
$accountId = $row['accountid'];
$acc1 = $row['acc1'];
$acc2 = $row['acc2'];
// Собираем ID всех связанных сущностей
$relatedIds = array_filter([$projectId, $contactId, $accountId, $acc1, $acc2]);
if (empty($relatedIds)) {
return $docs;
}
// Получаем все документы из связанных сущностей
$placeholders = str_repeat('?,', count($relatedIds) - 1) . '?';
$query = "SELECT
n.notesid,
n.title,
n.filename,
n.filelocationtype,
n.s3_bucket,
n.s3_key,
r.crmid as related_to_id,
CASE
WHEN r.crmid = ? THEN 'Project'
WHEN r.crmid = ? THEN 'Contact'
WHEN r.crmid IN (?, ?, ?) THEN 'Account'
ELSE 'Unknown'
END as source_type
FROM vtiger_senotesrel r
LEFT JOIN vtiger_notes n ON n.notesid = r.notesid
LEFT JOIN vtiger_crmentity e ON e.crmid = r.notesid
WHERE r.crmid IN ($placeholders)
AND e.deleted = 0
AND n.filename IS NOT NULL
ORDER BY r.crmid, n.title";
$params = array_merge([$projectId, $contactId, $accountId, $acc1, $acc2], $relatedIds);
$result = $adb->pquery($query, $params);
while ($row = $adb->fetchByAssoc($result)) {
$docs[] = $row;
}
return $docs;
}
public static function getPaths($docs = [])
{
$archived = 0;
$errors = [];
$files = [];
// Отладочное логирование
error_log("========================================");
error_log("getPaths: Processing " . count($docs) . " documents");
foreach ($docs as $x) {
// Поддержка как Record Model, так и массива (для связанных документов)
if (is_object($x)) {
$filename = $x->get('filename');
$filelocationtype = $x->get('filelocationtype');
$title = $x->get('title');
$notesid = $x->getId();
// ВСЕГДА получаем s3_bucket и s3_key напрямую из БД для Record Models,
// так как эти поля могут отсутствовать в Record Model
$adb = PearDatabase::getInstance();
$dbResult = $adb->pquery(
"SELECT s3_bucket, s3_key, filelocationtype FROM vtiger_notes WHERE notesid = ?",
array($notesid)
);
if ($adb->num_rows($dbResult) > 0) {
$dbRow = $adb->fetchByAssoc($dbResult);
$s3Bucket = $dbRow['s3_bucket'] ?? null;
$s3Key = $dbRow['s3_key'] ?? null;
// Используем filelocationtype из БД, если он есть
if (!empty($dbRow['filelocationtype'])) {
$filelocationtype = $dbRow['filelocationtype'];
}
} else {
$s3Bucket = null;
$s3Key = null;
}
} else {
// Массив из getRelatedDocs
$filename = $x['filename'] ?? null;
$filelocationtype = $x['filelocationtype'] ?? null;
$s3Bucket = $x['s3_bucket'] ?? null;
$s3Key = $x['s3_key'] ?? null;
$title = $x['title'] ?? '';
$notesid = $x['notesid'] ?? null;
}
$logMsg = "getPaths: Processing doc notesid={$notesid}, filename=" . ($filename ?? 'NULL') . ", filelocationtype=" . ($filelocationtype ?? 'NULL') . ", s3_bucket=" . ($s3Bucket ?? 'NULL') . ", s3_key=" . ($s3Key ?? 'NULL');
error_log($logMsg);
// Для S3 файлов filename может быть URL, это нормально
// Проверяем только что filename не пустой ИЛИ есть s3_key
if (empty($filename) && empty($s3Key)) {
$errors[] = 'skip non-file docs (notesid=' . ($notesid ?? 'unknown') . ')';
error_log("getPaths: SKIP - empty filename and s3_key for notesid=" . ($notesid ?? 'unknown'));
continue;
}
// Проверяем условия для S3
$isS3File = ($filelocationtype == 'E' && !empty($s3Bucket) && !empty($s3Key));
error_log("getPaths: CHECK S3 - filelocationtype='{$filelocationtype}' == 'E': " . (($filelocationtype == 'E') ? 'YES' : 'NO') . ", s3Bucket empty: " . (empty($s3Bucket) ? 'YES' : 'NO') . ", s3Key empty: " . (empty($s3Key) ? 'YES' : 'NO') . ", isS3File: " . ($isS3File ? 'YES' : 'NO'));
// Проверяем, файл ли это в S3
if ($isS3File) {
// Файл в S3 - скачиваем во временную папку
// Определяем расширение файла
$extension = '';
if (!empty($filename)) {
$extension = pathinfo($filename, PATHINFO_EXTENSION);
}
if (empty($extension) && !empty($s3Key)) {
$extension = pathinfo($s3Key, PATHINFO_EXTENSION);
}
$displayName = !empty($title)
? $title . (!empty($extension) ? '.' . $extension : '')
: basename($s3Key);
$tempPath = self::downloadS3File($s3Bucket, $s3Key, $displayName);
if ($tempPath && file_exists($tempPath)) {
$archived++;
$files[] = [
'name' => $displayName,
'path' => $tempPath,
'is_temp' => true
];
} else {
$errors[] = "S3 file download failed: {$s3Key}";
}
} else {
// Локальный файл - используем старую логику
// НО: если это массив из getRelatedDocs и у него filelocationtype != 'E',
// значит это не S3 файл, но и не локальный (возможно, внешняя ссылка)
// Пропускаем такие файлы или пытаемся обработать как локальные
if (is_object($x)) {
// Record Model - получаем детали файла
$details = $x->getFileDetails();
if (empty($details) || empty($details['path'])) {
$errors[] = "Cannot get file details for Record Model: {$notesid}";
error_log("getPaths: Cannot get file details for notesid={$notesid}");
continue;
}
$name = $details['attachmentsid'] . '_' . $details['storedname'];
$fullPath = $details['path'] . $name;
} else {
// Массив из getRelatedDocs - если это не S3, значит локальный файл
// Пытаемся создать Record Model для получения пути
if (!empty($x['notesid'])) {
try {
$docRecord = Vtiger_Record_Model::getInstanceById($x['notesid'], 'Documents');
if ($docRecord) {
$details = $docRecord->getFileDetails();
if (empty($details) || empty($details['path'])) {
$errors[] = "Cannot get file details for document: {$x['notesid']}";
error_log("getPaths: Cannot get file details for notesid={$x['notesid']}");
continue;
}
$name = $details['attachmentsid'] . '_' . $details['storedname'];
$fullPath = $details['path'] . $name;
} else {
$errors[] = "Cannot create Record Model for document: {$x['notesid']}";
error_log("getPaths: Cannot create Record Model for notesid={$x['notesid']}");
continue;
}
} catch (Exception $e) {
$errors[] = "Error creating Record Model: {$e->getMessage()}";
error_log("getPaths: Exception creating Record Model: " . $e->getMessage());
continue;
}
} else {
$errors[] = "Local file without Record Model and notesid: {$filename}";
error_log("getPaths: Local file without notesid: {$filename}");
continue;
}
}
if (empty($fullPath)) {
$errors[] = "Empty file path for notesid: {$notesid}";
error_log("getPaths: Empty file path for notesid={$notesid}");
continue;
}
if (!file_exists($fullPath)) {
$errors[] = "{$fullPath} is missing!";
error_log("getPaths: File not found: {$fullPath}");
continue;
}
$archived++;
$files[] = [
'name' => $name,
'path' => $fullPath,
'is_temp' => false
];
error_log("getPaths: Added local file: {$name}");
}
}
$resultMsg = "getPaths: Result - archived={$archived}, files=" . count($files) . ", errors=" . count($errors);
error_log($resultMsg);
if (count($errors) > 0) {
$errorsMsg = "getPaths: Errors: " . implode('; ', array_slice($errors, 0, 10));
error_log($errorsMsg);
}
return compact(
'files',
'errors',
'archived'
);
}
public static function createArchive($id)
{
$record = Vtiger_Record_Model::getInstanceById($id);
if (! $record) {
return false;
}
$docs = self::getDocs($record);
if (count($docs) == 0) {
return false;
}
$files = self::getPaths($docs);
if ($files['archived'] == 0) {
self::cleanupTempFiles();
return false;
}
$ts = date('Ymd_His_') . array_pop(explode('.', microtime(1)));
$zipFile = "cache/{$id}_documents_{$ts}.zip";
$zip = new ZipArchive();
$result = $zip->open($zipFile, ZipArchive::CREATE);
if (!$result) {
self::cleanupTempFiles();
return false;
}
foreach ($files['files'] as $x) {
$zip->addFile($x['path'], $x['name']);
}
$zip->close();
$size = filesize($zipFile);
if ($size == 0) {
self::cleanupTempFiles();
return false;
}
// Очищаем временные файлы после успешного создания архива
self::cleanupTempFiles();
return [
'total' => count($docs),
'archived' => $files['archived'],
'path' => $zipFile,
'size' => $size,
'errors' => $files['errors'],
];
}
public static function getArchive($id)
{
// Логирование через error_log (более надежно)
error_log("========================================");
error_log("getArchive: START for project ID={$id}");
try {
$record = Vtiger_Record_Model::getInstanceById($id);
if (! $record) {
error_log("getArchive: Record not found for ID={$id}");
return self::response('Record not found');
}
$moduleName = $record->getModuleName();
error_log("getArchive: Module name={$moduleName}");
$allDocs = [];
// Получаем документы из самой записи
$docs = self::getDocs($record);
$docsCount = count($docs);
error_log("getArchive: Found {$docsCount} docs from getDocs()");
foreach ($docs as $doc) {
$allDocs[] = $doc;
}
// Для проектов - добавляем документы из связанных сущностей
if ($moduleName == 'Project') {
error_log("getArchive: Getting related docs for Project");
$relatedDocs = self::getRelatedDocs($id);
$relatedCount = count($relatedDocs);
error_log("getArchive: Found {$relatedCount} related docs");
// Собираем notesid уже добавленных документов, чтобы избежать дубликатов
$addedNotesIds = [];
foreach ($allDocs as $doc) {
if (is_object($doc)) {
$addedNotesIds[] = $doc->getId();
}
}
// Добавляем только те документы, которых еще нет
foreach ($relatedDocs as $relatedDoc) {
if (!in_array($relatedDoc['notesid'], $addedNotesIds)) {
$allDocs[] = $relatedDoc;
$addedNotesIds[] = $relatedDoc['notesid'];
}
}
}
$totalDocs = count($allDocs);
error_log("getArchive: Total docs to process: {$totalDocs}");
if ($totalDocs == 0) {
error_log("getArchive: No documents found, returning error");
return self::response('Record has no documents');
}
error_log("getArchive: Calling getPaths() with {$totalDocs} docs");
$files = self::getPaths($allDocs);
$archivedCount = $files['archived'];
$errorsCount = count($files['errors']);
error_log("getArchive: getPaths returned archived={$archivedCount}, errors={$errorsCount}");
// Выводим первые несколько ошибок
if ($errorsCount > 0) {
$firstErrors = array_slice($files['errors'], 0, 5);
error_log("getArchive: First errors: " . implode('; ', $firstErrors));
}
if ($files['archived'] == 0) {
// Очищаем временные файлы перед выходом
self::cleanupTempFiles();
$errorDetails = implode('; ', array_slice($files['errors'], 0, 10));
error_log("getArchive: Nothing to archive - errors: " . $errorDetails);
error_log("getArchive: Total docs processed: {$totalDocs}, archived: {$archivedCount}, errors: {$errorsCount}");
// Возвращаем детальную информацию об ошибках для отладки
return self::response([
'message' => 'Nothing to archive',
'total_docs' => $totalDocs,
'archived' => $archivedCount,
'errors_count' => $errorsCount,
'errors' => array_slice($files['errors'], 0, 10)
]);
}
$ts = date('Ymd_His_') . array_pop(explode('.', microtime(1)));
$archive = "{$id}_documents_{$ts}.zip";
$zipFile = "cache/{$archive}";
$zip = new ZipArchive();
$result = $zip->open($zipFile, ZipArchive::CREATE|ZipArchive::OVERWRITE);
if (! $result) {
self::cleanupTempFiles();
return self::response('Unable to create file');
}
foreach ($files['files'] as $x) {
$zip->addFile($x['path'], $x['name']);
}
$result = $zip->close();
if (! $result) {
self::cleanupTempFiles();
return self::response('Unable to write file');
}
$size = filesize($zipFile);
if ($size == 0) {
self::cleanupTempFiles();
return self::response('Error creating archive');
}
// Очищаем временные файлы после успешного создания архива
self::cleanupTempFiles();
header('Content-disposition: attachment; filename='.$archive);
header('Content-type: application/zip');
readfile($zipFile);
//unlink($zipFile); // Можно оставить для отладки или удалить сразу
exit();
} catch (Exception $e) {
error_log("getArchive: Exception - " . $e->getMessage());
error_log("getArchive: Stack trace - " . $e->getTraceAsString());
self::cleanupTempFiles();
return self::response('Error: ' . $e->getMessage());
}
}
public static function response($data)
{
$response = new Vtiger_Response();
$response->setResult($data);
return $response->emit();
}
}