CleanupGeneratedDocuments.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  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. foreach ($paths as $path) {
  73. if (Storage::disk('public')->exists($path)) {
  74. Storage::disk('public')->delete($path);
  75. $deletedFiles++;
  76. }
  77. }
  78. $file->delete();
  79. $deletedRows++;
  80. }
  81. });
  82. return [$deletedRows, $deletedFiles];
  83. }
  84. private function retentionDays(): int
  85. {
  86. $option = $this->option('days');
  87. if (is_numeric($option)) {
  88. return (int) $option;
  89. }
  90. return (int) config('documents.generated_retention_days', 14);
  91. }
  92. private function importRetentionDays(): int
  93. {
  94. $option = $this->option('import-days');
  95. if (is_numeric($option)) {
  96. return (int) $option;
  97. }
  98. return (int) config('documents.import_retention_days', 14);
  99. }
  100. private function tempRetentionHours(): int
  101. {
  102. $option = $this->option('temp-hours');
  103. if (is_numeric($option)) {
  104. return (int) $option;
  105. }
  106. return (int) config('documents.temp_file_retention_hours', 24);
  107. }
  108. private function tempDirectory(): string
  109. {
  110. return (string) config('documents.temp_directory', sys_get_temp_dir());
  111. }
  112. /**
  113. * @return list<string>
  114. */
  115. private function storagePaths(File $file): array
  116. {
  117. $paths = [];
  118. foreach ([$file->path, $this->pathFromLink((string) $file->link)] as $path) {
  119. if (!is_string($path) || $path === '') {
  120. continue;
  121. }
  122. $paths[] = $this->normalizePublicPath($path);
  123. if (str_contains($path, '/tmp/')) {
  124. $paths[] = $this->normalizePublicPath(Str::replace('/tmp/', '/', $path));
  125. }
  126. }
  127. return array_values(array_unique(array_filter($paths)));
  128. }
  129. private function pathFromLink(string $link): ?string
  130. {
  131. $path = parse_url($link, PHP_URL_PATH);
  132. if (!is_string($path) || $path === '') {
  133. return null;
  134. }
  135. $marker = '/storage/';
  136. $position = strpos($path, $marker);
  137. if ($position === false) {
  138. return null;
  139. }
  140. return substr($path, $position + strlen($marker));
  141. }
  142. private function normalizePublicPath(string $path): string
  143. {
  144. $path = str_replace('\\', '/', $path);
  145. $marker = 'app/public/';
  146. $position = strpos($path, $marker);
  147. if ($position !== false) {
  148. $path = substr($path, $position + strlen($marker));
  149. }
  150. return ltrim($path, '/');
  151. }
  152. private function cleanupOrphanGeneratedArchives($cutoff, bool $dryRun): int
  153. {
  154. $knownPaths = $this->knownGeneratedPublicPaths();
  155. $deleted = 0;
  156. $candidates = array_values(array_filter(
  157. Storage::disk('public')->allFiles(),
  158. fn (string $path): bool => $this->isGeneratedArchivePath($path)
  159. ));
  160. foreach ($candidates as $path) {
  161. if (isset($knownPaths[$path])) {
  162. continue;
  163. }
  164. if (Storage::disk('public')->lastModified($path) >= $cutoff->timestamp) {
  165. continue;
  166. }
  167. $this->line(($dryRun ? 'Будет удалён сиротский архив' : 'Удаляется сиротский архив') . ": {$path}");
  168. if (!$dryRun) {
  169. Storage::disk('public')->delete($path);
  170. }
  171. $deleted++;
  172. }
  173. if ($deleted === 0) {
  174. $this->info('Сиротских сгенерированных архивов старше срока хранения нет.');
  175. }
  176. return $deleted;
  177. }
  178. /**
  179. * @return array<string,true>
  180. */
  181. private function knownGeneratedPublicPaths(): array
  182. {
  183. $paths = [];
  184. File::query()
  185. ->where('is_generated', true)
  186. ->select(['id', 'path', 'link'])
  187. ->chunkById(500, function ($files) use (&$paths): void {
  188. foreach ($files as $file) {
  189. foreach ($this->storagePaths($file) as $path) {
  190. $paths[$path] = true;
  191. }
  192. }
  193. });
  194. return $paths;
  195. }
  196. private function isGeneratedArchivePath(string $path): bool
  197. {
  198. if (!Str::endsWith(Str::lower($path), '.zip')) {
  199. return false;
  200. }
  201. return (bool) preg_match('#^(orders/\d+/[^/]+|reclamations/\d+/[^/]+|files/[^/]+/[^/]+|export/(?:orders|order|schedule)/[^/]+)\.zip$#u', $path);
  202. }
  203. private function cleanupImportFiles(bool $dryRun): int
  204. {
  205. $days = $this->importRetentionDays();
  206. if ($days < 1) {
  207. $this->warn('Срок хранения импортов меньше 1 дня, очистка импортов пропущена.');
  208. return 0;
  209. }
  210. $cutoff = now()->subDays($days);
  211. $knownPaths = [];
  212. $deleted = 0;
  213. Import::query()
  214. ->whereNotNull('filename')
  215. ->select(['id', 'filename', 'created_at'])
  216. ->orderBy('id')
  217. ->chunkById(500, function ($imports) use (&$knownPaths, &$deleted, $cutoff, $dryRun): void {
  218. foreach ($imports as $import) {
  219. $path = trim((string) $import->filename, '/');
  220. if ($path === '') {
  221. continue;
  222. }
  223. $knownPaths[$path] = true;
  224. if ($import->created_at >= $cutoff) {
  225. continue;
  226. }
  227. if (!Storage::disk('upload')->exists($path)) {
  228. continue;
  229. }
  230. $this->line(($dryRun ? 'Будет удалён файл импорта' : 'Удаляется файл импорта') . ": {$path}");
  231. if (!$dryRun) {
  232. Storage::disk('upload')->delete($path);
  233. }
  234. $deleted++;
  235. }
  236. });
  237. foreach (Storage::disk('upload')->allFiles() as $path) {
  238. if (isset($knownPaths[$path])) {
  239. continue;
  240. }
  241. if (!$this->isImportUploadPath($path)) {
  242. continue;
  243. }
  244. if (Storage::disk('upload')->lastModified($path) >= $cutoff->timestamp) {
  245. continue;
  246. }
  247. $this->line(($dryRun ? 'Будет удалён сиротский файл импорта' : 'Удаляется сиротский файл импорта') . ": {$path}");
  248. if (!$dryRun) {
  249. Storage::disk('upload')->delete($path);
  250. }
  251. $deleted++;
  252. }
  253. if ($deleted === 0) {
  254. $this->info("Файлов импорта старше {$days} дн. нет.");
  255. }
  256. return $deleted;
  257. }
  258. private function isImportUploadPath(string $path): bool
  259. {
  260. return (bool) preg_match('#^(?:[A-Za-z0-9]{2}/[^/]+|import/(?:areas|districts|year_data)/[^/]+)$#', $path);
  261. }
  262. private function cleanupSystemTempZipFiles(bool $dryRun): int
  263. {
  264. $hours = $this->tempRetentionHours();
  265. if ($hours < 1) {
  266. $this->warn('Срок хранения системных временных файлов меньше 1 часа, очистка /tmp пропущена.');
  267. return 0;
  268. }
  269. $cutoffTimestamp = now()->subHours($hours)->timestamp;
  270. $deleted = 0;
  271. $paths = glob(rtrim($this->tempDirectory(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*') ?: [];
  272. foreach ($paths as $path) {
  273. if (!is_file($path) || filemtime($path) === false || filemtime($path) >= $cutoffTimestamp) {
  274. continue;
  275. }
  276. if (!$this->looksLikeZipFile($path)) {
  277. continue;
  278. }
  279. $this->line(($dryRun ? 'Будет удалён временный ZIP' : 'Удаляется временный ZIP') . ": {$path}");
  280. if (!$dryRun && @unlink($path)) {
  281. $deleted++;
  282. continue;
  283. }
  284. if ($dryRun) {
  285. $deleted++;
  286. }
  287. }
  288. if ($deleted === 0) {
  289. $this->info("Временных ZIP-файлов в /tmp старше {$hours} ч. нет.");
  290. }
  291. return $deleted;
  292. }
  293. private function looksLikeZipFile(string $path): bool
  294. {
  295. $handle = @fopen($path, 'rb');
  296. if ($handle === false) {
  297. return false;
  298. }
  299. try {
  300. $signature = fread($handle, 4);
  301. } finally {
  302. fclose($handle);
  303. }
  304. return in_array($signature, ["PK\x03\x04", "PK\x05\x06", "PK\x07\x08"], true);
  305. }
  306. }