CleanupGeneratedDocuments.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Console\Commands;
  4. use App\Models\File;
  5. use App\Models\Import;
  6. use Illuminate\Console\Command;
  7. use Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Storage;
  9. use Illuminate\Support\Str;
  10. class CleanupGeneratedDocuments extends Command
  11. {
  12. protected $signature = 'documents:cleanup-generated
  13. {--days= : Удалять сгенерированные документы старше N дней}
  14. {--import-days= : Удалять загруженные файлы импорта старше N дней}
  15. {--temp-hours= : Удалять временные ZIP-файлы из системного tmp старше N часов}
  16. {--dry-run : Показать, что будет удалено, без удаления}';
  17. protected $description = 'Удаляет старые сгенерированные документы, не затрагивая пользовательские загрузки';
  18. public function handle(): int
  19. {
  20. $days = $this->retentionDays();
  21. if ($days < 1) {
  22. $this->error('Срок хранения должен быть больше 0 дней.');
  23. return self::FAILURE;
  24. }
  25. $cutoff = now()->subDays($days);
  26. $dryRun = (bool) $this->option('dry-run');
  27. $deletedRows = 0;
  28. $deletedFiles = 0;
  29. [$fileRows, $storedFiles] = $this->cleanupGeneratedFileRecords($cutoff, $dryRun, $days);
  30. $deletedRows += $fileRows;
  31. $deletedFiles += $storedFiles;
  32. $deletedFiles += $this->cleanupOrphanGeneratedArchives($cutoff, $dryRun);
  33. $deletedFiles += $this->cleanupImportFiles($dryRun);
  34. $deletedFiles += $this->cleanupSystemTempZipFiles($dryRun);
  35. if ($dryRun) {
  36. $this->info('Проверка завершена. Данные не изменены.');
  37. return self::SUCCESS;
  38. }
  39. $this->info("Очистка завершена. Удалено записей: {$deletedRows}. Удалено файлов: {$deletedFiles}.");
  40. return self::SUCCESS;
  41. }
  42. /**
  43. * @return array{0:int,1:int}
  44. */
  45. private function cleanupGeneratedFileRecords($cutoff, bool $dryRun, int $days): array
  46. {
  47. $deletedRows = 0;
  48. $deletedFiles = 0;
  49. $query = File::query()
  50. ->where('is_generated', true)
  51. ->where('created_at', '<', $cutoff)
  52. ->orderBy('id');
  53. $total = (clone $query)->count();
  54. if ($total === 0) {
  55. $this->info("Сгенерированных документов старше {$days} дн. в БД нет.");
  56. return [0, 0];
  57. }
  58. $this->info(($dryRun ? 'Проверка' : 'Очистка') . ": найдено {$total} записей сгенерированных файлов старше " . $cutoff->toDateTimeString());
  59. $query->chunkById(100, function ($files) use ($dryRun, &$deletedFiles, &$deletedRows): void {
  60. foreach ($files as $file) {
  61. $paths = $this->storagePaths($file);
  62. $this->line(($dryRun ? 'Будет удалён' : 'Удаляется') . ": #{$file->id} {$file->original_name}");
  63. if ($dryRun) {
  64. foreach ($paths as $path) {
  65. $this->line(" {$path}");
  66. }
  67. continue;
  68. }
  69. DB::table('order_document')->where('file_id', $file->id)->delete();
  70. DB::table('reclamation_document')->where('file_id', $file->id)->delete();
  71. DB::table('chat_message_file')->where('file_id', $file->id)->delete();
  72. $disk = $this->generatedFileDisk($file);
  73. foreach ($paths as $path) {
  74. if (Storage::disk($disk)->exists($path)) {
  75. Storage::disk($disk)->delete($path);
  76. $deletedFiles++;
  77. }
  78. }
  79. $file->delete();
  80. $deletedRows++;
  81. }
  82. });
  83. return [$deletedRows, $deletedFiles];
  84. }
  85. private function retentionDays(): int
  86. {
  87. $option = $this->option('days');
  88. if (is_numeric($option)) {
  89. return (int) $option;
  90. }
  91. return (int) config('documents.generated_retention_days', 14);
  92. }
  93. private function importRetentionDays(): int
  94. {
  95. $option = $this->option('import-days');
  96. if (is_numeric($option)) {
  97. return (int) $option;
  98. }
  99. return (int) config('documents.import_retention_days', 14);
  100. }
  101. private function tempRetentionHours(): int
  102. {
  103. $option = $this->option('temp-hours');
  104. if (is_numeric($option)) {
  105. return (int) $option;
  106. }
  107. return (int) config('documents.temp_file_retention_hours', 24);
  108. }
  109. private function tempDirectory(): string
  110. {
  111. return (string) config('documents.temp_directory', sys_get_temp_dir());
  112. }
  113. /**
  114. * @return list<string>
  115. */
  116. private function storagePaths(File $file): array
  117. {
  118. $paths = [];
  119. foreach ([$file->path, $this->pathFromLink((string) $file->link)] as $path) {
  120. if (!is_string($path) || $path === '') {
  121. continue;
  122. }
  123. $paths[] = $this->normalizePublicPath($path);
  124. if (str_contains($path, '/tmp/')) {
  125. $paths[] = $this->normalizePublicPath(Str::replace('/tmp/', '/', $path));
  126. }
  127. }
  128. return array_values(array_unique(array_filter($paths)));
  129. }
  130. private function generatedFileDisk(File $file): string
  131. {
  132. return Str::startsWith((string) $file->path, 'generated/technical-descriptions/')
  133. ? 'local'
  134. : 'public';
  135. }
  136. private function pathFromLink(string $link): ?string
  137. {
  138. $path = parse_url($link, PHP_URL_PATH);
  139. if (!is_string($path) || $path === '') {
  140. return null;
  141. }
  142. $marker = '/storage/';
  143. $position = strpos($path, $marker);
  144. if ($position === false) {
  145. return null;
  146. }
  147. return substr($path, $position + strlen($marker));
  148. }
  149. private function normalizePublicPath(string $path): string
  150. {
  151. $path = str_replace('\\', '/', $path);
  152. $marker = 'app/public/';
  153. $position = strpos($path, $marker);
  154. if ($position !== false) {
  155. $path = substr($path, $position + strlen($marker));
  156. }
  157. return ltrim($path, '/');
  158. }
  159. private function cleanupOrphanGeneratedArchives($cutoff, bool $dryRun): int
  160. {
  161. $knownPaths = $this->knownGeneratedPublicPaths();
  162. $deleted = 0;
  163. $candidates = array_values(array_filter(
  164. Storage::disk('public')->allFiles(),
  165. fn (string $path): bool => $this->isGeneratedArchivePath($path)
  166. ));
  167. foreach ($candidates as $path) {
  168. if (isset($knownPaths[$path])) {
  169. continue;
  170. }
  171. if (Storage::disk('public')->lastModified($path) >= $cutoff->timestamp) {
  172. continue;
  173. }
  174. $this->line(($dryRun ? 'Будет удалён сиротский архив' : 'Удаляется сиротский архив') . ": {$path}");
  175. if (!$dryRun) {
  176. Storage::disk('public')->delete($path);
  177. }
  178. $deleted++;
  179. }
  180. if ($deleted === 0) {
  181. $this->info('Сиротских сгенерированных архивов старше срока хранения нет.');
  182. }
  183. return $deleted;
  184. }
  185. /**
  186. * @return array<string,true>
  187. */
  188. private function knownGeneratedPublicPaths(): array
  189. {
  190. $paths = [];
  191. File::query()
  192. ->where('is_generated', true)
  193. ->select(['id', 'path', 'link'])
  194. ->chunkById(500, function ($files) use (&$paths): void {
  195. foreach ($files as $file) {
  196. foreach ($this->storagePaths($file) as $path) {
  197. $paths[$path] = true;
  198. }
  199. }
  200. });
  201. return $paths;
  202. }
  203. private function isGeneratedArchivePath(string $path): bool
  204. {
  205. if (!Str::endsWith(Str::lower($path), '.zip')) {
  206. return false;
  207. }
  208. return (bool) preg_match('#^(orders/\d+/[^/]+|reclamations/\d+/[^/]+|files/[^/]+/[^/]+|export/(?:orders|order|schedule)/[^/]+)\.zip$#u', $path);
  209. }
  210. private function cleanupImportFiles(bool $dryRun): int
  211. {
  212. $days = $this->importRetentionDays();
  213. if ($days < 1) {
  214. $this->warn('Срок хранения импортов меньше 1 дня, очистка импортов пропущена.');
  215. return 0;
  216. }
  217. $cutoff = now()->subDays($days);
  218. $knownPaths = [];
  219. $deleted = 0;
  220. Import::query()
  221. ->whereNotNull('filename')
  222. ->select(['id', 'filename', 'created_at'])
  223. ->orderBy('id')
  224. ->chunkById(500, function ($imports) use (&$knownPaths, &$deleted, $cutoff, $dryRun): void {
  225. foreach ($imports as $import) {
  226. $path = trim((string) $import->filename, '/');
  227. if ($path === '') {
  228. continue;
  229. }
  230. $knownPaths[$path] = true;
  231. if ($import->created_at >= $cutoff) {
  232. continue;
  233. }
  234. if (!Storage::disk('upload')->exists($path)) {
  235. continue;
  236. }
  237. $this->line(($dryRun ? 'Будет удалён файл импорта' : 'Удаляется файл импорта') . ": {$path}");
  238. if (!$dryRun) {
  239. Storage::disk('upload')->delete($path);
  240. }
  241. $deleted++;
  242. }
  243. });
  244. foreach (Storage::disk('upload')->allFiles() as $path) {
  245. if (isset($knownPaths[$path])) {
  246. continue;
  247. }
  248. if (!$this->isImportUploadPath($path)) {
  249. continue;
  250. }
  251. if (Storage::disk('upload')->lastModified($path) >= $cutoff->timestamp) {
  252. continue;
  253. }
  254. $this->line(($dryRun ? 'Будет удалён сиротский файл импорта' : 'Удаляется сиротский файл импорта') . ": {$path}");
  255. if (!$dryRun) {
  256. Storage::disk('upload')->delete($path);
  257. }
  258. $deleted++;
  259. }
  260. if ($deleted === 0) {
  261. $this->info("Файлов импорта старше {$days} дн. нет.");
  262. }
  263. return $deleted;
  264. }
  265. private function isImportUploadPath(string $path): bool
  266. {
  267. return (bool) preg_match('#^(?:[A-Za-z0-9]{2}/[^/]+|import/(?:areas|districts|year_data)/[^/]+)$#', $path);
  268. }
  269. private function cleanupSystemTempZipFiles(bool $dryRun): int
  270. {
  271. $hours = $this->tempRetentionHours();
  272. if ($hours < 1) {
  273. $this->warn('Срок хранения системных временных файлов меньше 1 часа, очистка /tmp пропущена.');
  274. return 0;
  275. }
  276. $cutoffTimestamp = now()->subHours($hours)->timestamp;
  277. $deleted = 0;
  278. $paths = glob(rtrim($this->tempDirectory(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*') ?: [];
  279. foreach ($paths as $path) {
  280. if (!is_file($path) || filemtime($path) === false || filemtime($path) >= $cutoffTimestamp) {
  281. continue;
  282. }
  283. if (!$this->looksLikeZipFile($path)) {
  284. continue;
  285. }
  286. $this->line(($dryRun ? 'Будет удалён временный ZIP' : 'Удаляется временный ZIP') . ": {$path}");
  287. if (!$dryRun && @unlink($path)) {
  288. $deleted++;
  289. continue;
  290. }
  291. if ($dryRun) {
  292. $deleted++;
  293. }
  294. }
  295. if ($deleted === 0) {
  296. $this->info("Временных ZIP-файлов в /tmp старше {$hours} ч. нет.");
  297. }
  298. return $deleted;
  299. }
  300. private function looksLikeZipFile(string $path): bool
  301. {
  302. $handle = @fopen($path, 'rb');
  303. if ($handle === false) {
  304. return false;
  305. }
  306. try {
  307. $signature = fread($handle, 4);
  308. } finally {
  309. fclose($handle);
  310. }
  311. return in_array($signature, ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"], true);
  312. }
  313. }