|
|
<?php
|
|
|
// CLI only. Upload to the Laravel project root, beside artisan.
|
|
|
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
|
|
|
|
|
|
class ImportedSupplyCleanup
|
|
|
{
|
|
|
public static function run($execute, $backupDirectory)
|
|
|
{
|
|
|
$db = \Illuminate\Support\Facades\DB::connection();
|
|
|
foreach (['supply_demands'=>['id','is_proxy','import_key','deleted_at','updated_at'], 'dialogues'=>['id','supply_demand_id'], 'messages'=>['id','supply_demand_id','dialogue_id']] as $table=>$columns) {
|
|
|
if (!$db->getSchemaBuilder()->hasColumns($table,$columns)) throw new \RuntimeException('缺少必要表或字段:'.$table.',已停止');
|
|
|
}
|
|
|
if ($execute && $db->getDriverName() === 'mysql') {
|
|
|
foreach (['supply_demands','dialogues','messages'] as $table) {
|
|
|
$engine=$db->selectOne('SELECT ENGINE AS engine FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?',[$db->getDatabaseName(),$db->getTablePrefix().$table]);
|
|
|
if (!$engine || strcasecmp($engine->engine,'InnoDB') !== 0) throw new \RuntimeException('表不支持预期事务保护:'.$table.',已停止');
|
|
|
}
|
|
|
}
|
|
|
return $db->transaction(function () use ($db,$execute,$backupDirectory) {
|
|
|
// Raw queries deliberately include previously soft-deleted imported rows.
|
|
|
$demands=$db->table('supply_demands')->where('is_proxy',1)->whereNotNull('import_key')->where('import_key','!=','')->orderBy('id')->lockForUpdate()->get();
|
|
|
$ids=$demands->pluck('id')->all();
|
|
|
$dialogues=$db->table('dialogues')->whereIn('supply_demand_id',$ids)->orderBy('id')->lockForUpdate()->get();
|
|
|
$dialogueIds=$dialogues->pluck('id')->all();
|
|
|
$messages=$db->table('messages')->where(function($q)use($ids,$dialogueIds){$q->whereIn('supply_demand_id',$ids)->orWhereIn('dialogue_id',$dialogueIds);})->orderBy('id')->lockForUpdate()->get();
|
|
|
$report=['database'=>$db->getDatabaseName(),'host'=>$db->getConfig('host'),'mode'=>$execute?'备份并按条件清理':'只读检查', 'imported_demands'=>count($ids),'dialogues'=>$dialogues->count(),'messages'=>$messages->count(),'cleaned'=>0,'backup'=>null];
|
|
|
if (!$execute || !$ids) {
|
|
|
$report['result']=!$ids?'没有需要清理的旧导入记录':($messages->count()?'存在历史私信,不允许清理':'未发现历史私信,可使用 --execute 备份并清理');
|
|
|
return $report;
|
|
|
}
|
|
|
$snapshot=['format_version'=>1,'created_at'=>date(DATE_ATOM),'database'=>$db->getDatabaseName(),'host'=>$db->getConfig('host'),'scope'=>'is_proxy=1 AND import_key IS NOT NULL AND import_key <> empty','supply_demands'=>$demands->all(),'dialogues'=>$dialogues->all(),'messages'=>$messages->all()];
|
|
|
$report['backup']=self::backup($backupDirectory,$snapshot);
|
|
|
if ($messages->count()) {
|
|
|
$report['result']='发现历史私信:已备份,未清理任何业务数据,请先核对';
|
|
|
return $report;
|
|
|
}
|
|
|
$now=date('Y-m-d H:i:s');
|
|
|
$report['cleaned']=$db->table('supply_demands')->whereIn('id',$ids)->where('is_proxy',1)->whereNotNull('import_key')->update(['deleted_at'=>$db->raw('COALESCE(deleted_at, CURRENT_TIMESTAMP)'),'import_key'=>null,'updated_at'=>$now]);
|
|
|
if ($report['cleaned'] !== count($ids)) throw new \RuntimeException('清理数量不符,事务已回滚,请保留备份并核对');
|
|
|
$remaining=$db->table('supply_demands')->whereIn('id',$ids)->where(function($q){$q->whereNull('deleted_at')->orWhereNotNull('import_key');})->exists();
|
|
|
if ($remaining) throw new \RuntimeException('清理结果校验失败,事务已回滚');
|
|
|
$report['result']='已备份并清理旧导入记录及查重标记,可以自行导入新表';
|
|
|
return $report;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
private static function backup($directory, array $snapshot)
|
|
|
{
|
|
|
if (!is_dir($directory) && !mkdir($directory,0700,true) && !is_dir($directory)) throw new \RuntimeException('无法创建备份目录,停止清理');
|
|
|
if (!chmod($directory,0700)) throw new \RuntimeException('无法保护备份目录权限,停止清理');
|
|
|
$file=rtrim($directory,'/').'/imported-supply-'.date('Ymd-His').'-'.bin2hex(random_bytes(4)).'.json';
|
|
|
$json=json_encode($snapshot,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR);
|
|
|
$handle=fopen($file,'x');
|
|
|
if (!$handle) throw new \RuntimeException('无法创建备份文件,停止清理');
|
|
|
try {
|
|
|
if (!chmod($file,0600)) throw new \RuntimeException('无法保护备份文件权限,停止清理');
|
|
|
$offset=0;$size=strlen($json);
|
|
|
while ($offset<$size) { $written=fwrite($handle,substr($json,$offset)); if (!$written) throw new \RuntimeException('备份写入失败,停止清理'); $offset+=$written; }
|
|
|
if (!fflush($handle)) throw new \RuntimeException('备份刷新失败,停止清理');
|
|
|
if (function_exists('fsync') && !fsync($handle)) throw new \RuntimeException('备份落盘失败,停止清理');
|
|
|
} finally { fclose($handle); }
|
|
|
if (!hash_equals(hash('sha256',$json),hash_file('sha256',$file))) throw new \RuntimeException('备份校验失败,停止清理');
|
|
|
json_decode(file_get_contents($file),true,512,JSON_THROW_ON_ERROR);
|
|
|
return ['file'=>$file,'sha256'=>hash('sha256',$json)];
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
|
|
try {
|
|
|
if (!is_file(__DIR__.'/artisan') || !is_file(__DIR__.'/vendor/autoload.php')) throw new \RuntimeException('请把脚本放到包含 artisan 的 Laravel 项目根目录');
|
|
|
require __DIR__.'/vendor/autoload.php';
|
|
|
$app=require __DIR__.'/bootstrap/app.php';
|
|
|
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
|
$args=array_slice($argv,1);
|
|
|
if ($args && $args !== ['--check'] && $args !== ['--execute']) throw new \RuntimeException('用法:php cleanup-imported-supply.php [--check|--execute]');
|
|
|
$report=ImportedSupplyCleanup::run($args === ['--execute'],storage_path('app/private/supply-demand-cleanup'));
|
|
|
// Printed only after the transaction has committed successfully.
|
|
|
echo json_encode($report,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR).PHP_EOL;
|
|
|
} catch (\Throwable $e) { fwrite(STDERR,'执行停止:'.$e->getMessage().PHP_EOL); exit(1); }
|
|
|
}
|