CleanupGeneratedDocuments.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  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. {--dry-run : Показать, что будет удалено, без удаления}';
  16. protected $description = 'Удаляет старые сгенерированные документы, не затрагивая пользовательские загрузки';
  17. public function handle(): int
  18. {
  19. $days = $this->retentionDays();
  20. if ($days < 1) {
  21. $this->error('Срок хранения должен быть больше 0 дней.');
  22. return self::FAILURE;
  23. }
  24. $cutoff = now()->subDays($days);
  25. $dryRun = (bool) $this->option('dry-run');
  26. $deletedRows = 0;
  27. $deletedFiles = 0;
  28. [$fileRows, $storedFiles] = $this->cleanupGeneratedFileRecords($cutoff, $dryRun, $days);
  29. $deletedRows += $fileRows;
  30. $deletedFiles += $storedFiles;
  31. $deletedFiles += $this->cleanupOrphanGeneratedArchives($cutoff, $dryRun);
  32. $deletedFiles += $this->cleanupImportFiles($dryRun);
  33. if ($dryRun) {
  34. $this->info('Проверка завершена. Данные не изменены.');
  35. return self::SUCCESS;
  36. }
  37. $this->info("Очистка завершена. Удалено записей: {$deletedRows}. Удалено файлов: {$deletedFiles}.");
  38. return self::SUCCESS;
  39. }
  40. /**
  41. * @return array{0:int,1:int}
  42. */
  43. private function cleanupGeneratedFileRecords($cutoff, bool $dryRun, int $days): array
  44. {
  45. $deletedRows = 0;
  46. $deletedFiles = 0;
  47. $query = File::query()
  48. ->where('is_generated', true)
  49. ->where('created_at', '<', $cutoff)
  50. ->orderBy('id');
  51. $total = (clone $query)->count();
  52. if ($total === 0) {
  53. $this->info("Сгенерированных документов старше {$days} дн. в БД нет.");
  54. return [0, 0];
  55. }
  56. $this->info(($dryRun ? 'Проверка' : 'Очистка') . ": найдено {$total} записей сгенерированных файлов старше " . $cutoff->toDateTimeString());
  57. $query->chunkById(100, function ($files) use ($dryRun, &$deletedFiles, &$deletedRows): void {
  58. foreach ($files as $file) {
  59. $paths = $this->storagePaths($file);
  60. $this->line(($dryRun ? 'Будет удалён' : 'Удаляется') . ": #{$file->id} {$file->original_name}");
  61. if ($dryRun) {
  62. foreach ($paths as $path) {
  63. $this->line(" {$path}");
  64. }
  65. continue;
  66. }
  67. DB::table('order_document')->where('file_id', $file->id)->delete();
  68. DB::table('reclamation_document')->where('file_id', $file->id)->delete();
  69. DB::table('chat_message_file')->where('file_id', $file->id)->delete();
  70. foreach ($paths as $path) {
  71. if (Storage::disk('public')->exists($path)) {
  72. Storage::disk('public')->delete($path);
  73. $deletedFiles++;
  74. }
  75. }
  76. $file->delete();
  77. $deletedRows++;
  78. }
  79. });
  80. return [$deletedRows, $deletedFiles];
  81. }
  82. private function retentionDays(): int
  83. {
  84. $option = $this->option('days');
  85. if (is_numeric($option)) {
  86. return (int) $option;
  87. }
  88. return (int) config('documents.generated_retention_days', 14);
  89. }
  90. private function importRetentionDays(): int
  91. {
  92. $option = $this->option('import-days');
  93. if (is_numeric($option)) {
  94. return (int) $option;
  95. }
  96. return (int) config('documents.import_retention_days', 14);
  97. }
  98. /**
  99. * @return list<string>
  100. */
  101. private function storagePaths(File $file): array
  102. {
  103. $paths = [];
  104. foreach ([$file->path, $this->pathFromLink((string) $file->link)] as $path) {
  105. if (!is_string($path) || $path === '') {
  106. continue;
  107. }
  108. $paths[] = $this->normalizePublicPath($path);
  109. if (str_contains($path, '/tmp/')) {
  110. $paths[] = $this->normalizePublicPath(Str::replace('/tmp/', '/', $path));
  111. }
  112. }
  113. return array_values(array_unique(array_filter($paths)));
  114. }
  115. private function pathFromLink(string $link): ?string
  116. {
  117. $path = parse_url($link, PHP_URL_PATH);
  118. if (!is_string($path) || $path === '') {
  119. return null;
  120. }
  121. $marker = '/storage/';
  122. $position = strpos($path, $marker);
  123. if ($position === false) {
  124. return null;
  125. }
  126. return substr($path, $position + strlen($marker));
  127. }
  128. private function normalizePublicPath(string $path): string
  129. {
  130. $path = str_replace('\\', '/', $path);
  131. $marker = 'app/public/';
  132. $position = strpos($path, $marker);
  133. if ($position !== false) {
  134. $path = substr($path, $position + strlen($marker));
  135. }
  136. return ltrim($path, '/');
  137. }
  138. private function cleanupOrphanGeneratedArchives($cutoff, bool $dryRun): int
  139. {
  140. $knownPaths = $this->knownGeneratedPublicPaths();
  141. $deleted = 0;
  142. $candidates = array_values(array_filter(
  143. Storage::disk('public')->allFiles(),
  144. fn (string $path): bool => $this->isGeneratedArchivePath($path)
  145. ));
  146. foreach ($candidates as $path) {
  147. if (isset($knownPaths[$path])) {
  148. continue;
  149. }
  150. if (Storage::disk('public')->lastModified($path) >= $cutoff->timestamp) {
  151. continue;
  152. }
  153. $this->line(($dryRun ? 'Будет удалён сиротский архив' : 'Удаляется сиротский архив') . ": {$path}");
  154. if (!$dryRun) {
  155. Storage::disk('public')->delete($path);
  156. }
  157. $deleted++;
  158. }
  159. if ($deleted === 0) {
  160. $this->info('Сиротских сгенерированных архивов старше срока хранения нет.');
  161. }
  162. return $deleted;
  163. }
  164. /**
  165. * @return array<string,true>
  166. */
  167. private function knownGeneratedPublicPaths(): array
  168. {
  169. $paths = [];
  170. File::query()
  171. ->where('is_generated', true)
  172. ->select(['id', 'path', 'link'])
  173. ->chunkById(500, function ($files) use (&$paths): void {
  174. foreach ($files as $file) {
  175. foreach ($this->storagePaths($file) as $path) {
  176. $paths[$path] = true;
  177. }
  178. }
  179. });
  180. return $paths;
  181. }
  182. private function isGeneratedArchivePath(string $path): bool
  183. {
  184. if (!Str::endsWith(Str::lower($path), '.zip')) {
  185. return false;
  186. }
  187. return (bool) preg_match('#^(orders/\d+/[^/]+|reclamations/\d+/[^/]+|files/[^/]+/[^/]+|export/(?:orders|order|schedule)/[^/]+)\.zip$#u', $path);
  188. }
  189. private function cleanupImportFiles(bool $dryRun): int
  190. {
  191. $days = $this->importRetentionDays();
  192. if ($days < 1) {
  193. $this->warn('Срок хранения импортов меньше 1 дня, очистка импортов пропущена.');
  194. return 0;
  195. }
  196. $cutoff = now()->subDays($days);
  197. $knownPaths = [];
  198. $deleted = 0;
  199. Import::query()
  200. ->whereNotNull('filename')
  201. ->select(['id', 'filename', 'created_at'])
  202. ->orderBy('id')
  203. ->chunkById(500, function ($imports) use (&$knownPaths, &$deleted, $cutoff, $dryRun): void {
  204. foreach ($imports as $import) {
  205. $path = trim((string) $import->filename, '/');
  206. if ($path === '') {
  207. continue;
  208. }
  209. $knownPaths[$path] = true;
  210. if ($import->created_at >= $cutoff) {
  211. continue;
  212. }
  213. if (!Storage::disk('upload')->exists($path)) {
  214. continue;
  215. }
  216. $this->line(($dryRun ? 'Будет удалён файл импорта' : 'Удаляется файл импорта') . ": {$path}");
  217. if (!$dryRun) {
  218. Storage::disk('upload')->delete($path);
  219. }
  220. $deleted++;
  221. }
  222. });
  223. foreach (Storage::disk('upload')->allFiles() as $path) {
  224. if (isset($knownPaths[$path])) {
  225. continue;
  226. }
  227. if (!$this->isImportUploadPath($path)) {
  228. continue;
  229. }
  230. if (Storage::disk('upload')->lastModified($path) >= $cutoff->timestamp) {
  231. continue;
  232. }
  233. $this->line(($dryRun ? 'Будет удалён сиротский файл импорта' : 'Удаляется сиротский файл импорта') . ": {$path}");
  234. if (!$dryRun) {
  235. Storage::disk('upload')->delete($path);
  236. }
  237. $deleted++;
  238. }
  239. if ($deleted === 0) {
  240. $this->info("Файлов импорта старше {$days} дн. нет.");
  241. }
  242. return $deleted;
  243. }
  244. private function isImportUploadPath(string $path): bool
  245. {
  246. return (bool) preg_match('#^(?:[A-Za-z0-9]{2}/[^/]+|import/(?:areas|districts|year_data)/[^/]+)$#', $path);
  247. }
  248. }