// +---------------------------------------------------------------------- declare(strict_types=1); namespace ywxapp\command; use think\console\Command; use think\console\Input; use think\console\Output; use think\console\input\Option; use think\facade\Db; use ywxapp\service\FileStorageService; /** * 附件孤儿清理 * * 「孤儿」= 磁盘上存在、但 common_attachment 里没有对应记录的文件: * 上传成功却落库失败、记录被硬删、手工拷进去的文件都会留下这类残留。 * * 默认只统计不删除(安全),加 --delete 才真正清理;删除同样走 * FileStorageService::delete(),OSS / FTP 等自定义存储由 FileDelete 事件接管。 * * 用法: * php think attachment:clean # 只看不删 * php think attachment:clean --delete # 真删 * php think attachment:clean --days=30 # 只处理 30 天前的文件 * * @author ywxapp */ class AttachmentCleanCommand extends Command { protected function configure() { $this->setName('attachment:clean') ->addOption('delete', null, Option::VALUE_NONE, '真正删除孤儿文件(默认只统计)') ->addOption('days', null, Option::VALUE_OPTIONAL, '只处理 N 天前的文件(按修改时间),0=不限') ->addOption('dir', null, Option::VALUE_OPTIONAL, '扫描目录,默认 public/storage') ->setDescription('清理磁盘上没有附件记录的孤儿文件(默认只统计不删除)'); } protected function execute(Input $input, Output $output) { $doDelete = (bool) $input->getOption('delete'); $days = max(0, (int) $input->getOption('days')); $root = rtrim((string) ($input->getOption('dir') ?: public_path() . 'storage'), '/\\'); if (!is_dir($root)) { $output->writeln('目录不存在:' . $root . ''); return 1; } // 库里所有在用的相对路径 $known = []; try { $rows = Db::name('common_attachment')->where('delete_at', 0)->field('path')->select()->toArray(); } catch (\Throwable $e) { $output->writeln('附件表不可用:' . $e->getMessage() . ''); return 1; } foreach ($rows as $r) { $known[ltrim(str_replace('\\', '/', (string) $r['path']), '/')] = true; } $deadline = $days > 0 ? (time() - $days * 86400) : 0; $orphans = []; $total = 0; $size = 0; $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($it as $file) { if (! $file->isFile()) { continue; } $total++; $rel = ltrim(str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1)), '/'); if (isset($known[$rel])) { continue; } if ($deadline && $file->getMTime() > $deadline) { continue; } $orphans[] = $rel; $size += $file->getSize(); } $output->writeln('扫描目录:' . $root); $output->writeln('磁盘文件:' . $total . ',已登记附件:' . count($known) . ',孤儿:' . count($orphans)); $output->writeln('孤儿占用:' . round($size / 1024 / 1024, 2) . ' MB'); foreach (array_slice($orphans, 0, 20) as $rel) { $output->writeln(' ' . $rel); } if (count($orphans) > 20) { $output->writeln(' … 另有 ' . (count($orphans) - 20) . ' 个'); } if (! $doDelete) { $output->writeln('未删除(如需清理请加 --delete)'); return 0; } if (empty($orphans)) { $output->writeln('没有需要清理的孤儿文件'); return 0; } $storage = new FileStorageService(); $ok = 0; foreach ($orphans as $rel) { try { if ($storage->delete($rel)) { $ok++; } } catch (\Throwable $e) { // 单个失败不中断 } } $output->writeln('已删除 ' . $ok . ' / ' . count($orphans) . ' 个孤儿文件'); return 0; } }