setName('attachment:scan')
->addOption('delete', null, Option::VALUE_NONE, '删除孤儿附件(记录+文件+COS对象)')
->setDescription('扫描无业务引用的孤儿附件');
}
/**
* 命令执行
*/
protected function execute(Input $input, Output $output)
{
$config = Config::get('database');
$database = $config['database'];
$prefix = $config['prefix'];
// 1. 收集业务表所有文本类列(排除附件表自身)
$columns = Db::query(
"SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS "
. "WHERE TABLE_SCHEMA = ? AND DATA_TYPE IN ('varchar','char','text','tinytext','mediumtext','longtext') "
. "AND TABLE_NAME NOT IN (?, CONCAT(?, 'attachment'))",
[$database, $database . '.' . $prefix . 'attachment', $prefix]
);
if (!$columns) {
$output->writeln('未找到业务表列');
return;
}
// 2. 遍历附件记录,逐列检查 url 引用(每列一次查询,OR 组合全部 url)
$attachments = Db::name('attachment')->field('id,url,storage,filename,uploadtime')->select();
$urls = array_column($attachments, 'url');
$referenced = []; // 有引用的 url 集合
foreach ($columns as $col) {
$table = $col['TABLE_NAME'];
$field = $col['COLUMN_NAME'];
$wheres = [];
$bind = [];
foreach ($urls as $i => $url) {
$wheres[] = "`{$field}` LIKE :u{$i}";
$bind["u{$i}"] = '%' . addcslashes($url, '%_') . '%';
}
$sql = "SELECT `{$field}` AS v FROM `{$table}` WHERE " . implode(' OR ', $wheres) . " LIMIT 500";
try {
$rows = Db::query($sql, $bind);
} catch (\Exception $e) {
continue; // 个别列类型不兼容时跳过
}
foreach ($rows as $row) {
$v = (string)$row['v'];
foreach ($urls as $url) {
if ($url !== '' && strpos($v, $url) !== false) {
$referenced[$url] = true;
}
}
}
}
// 3. 输出孤儿清单
$orphans = array_values(array_filter($attachments, function ($a) use ($referenced) {
return empty($referenced[$a['url']]);
}));
if (!$orphans) {
$output->writeln('扫描完成:所有附件均有业务引用,无孤儿文件');
return;
}
$output->writeln(sprintf('扫描完成:共 %d 条附件,孤儿 %d 条:', count($attachments), count($orphans)));
foreach ($orphans as $o) {
$output->writeln(sprintf(' [%d] %s (%s) %s', $o['id'], $o['url'], $o['storage'], date('Y-m-d', $o['uploadtime'])));
}
// 4. 可选删除:手动清理本地文件 / COS 对象 / 记录
if ($input->getOption('delete')) {
Loader::addNamespace('Qcloud\Cos', ROOT_PATH . 'addons/cos/library/Qcloud/Cos/');
$cosConfig = get_addon_config('cos');
$client = null;
if ($cosConfig && !empty($cosConfig['cdnurl'])) {
$client = new \Qcloud\Cos\Client([
'region' => $cosConfig['region'],
'schema' => 'https',
'credentials' => ['secretId' => $cosConfig['secretId'], 'secretKey' => $cosConfig['secretKey']],
]);
}
foreach ($orphans as $o) {
$id = $o['id'];
// 同 URL 成对记录一起删
$pairs = Db::name('attachment')->where('url', $o['url'])->select();
foreach ($pairs as $pair) {
if ($pair['storage'] === 'local') {
$file = ROOT_PATH . 'public' . $pair['url'];
if (is_file($file)) {
@unlink($file);
}
} elseif ($pair['storage'] === 'cos' && $client) {
try {
$client->deleteObject(['Bucket' => $cosConfig['bucket'], 'Key' => ltrim($pair['url'], '/')]);
} catch (\Exception $e) {
$output->writeln('COS删除失败: ' . $pair['url'] . ' ' . $e->getMessage() . '');
}
}
Db::name('attachment')->where('id', $pair['id'])->delete();
}
$output->writeln('已删除: ' . $o['url'] . '');
}
}
}
}