feat: 18
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Photo;
|
||||
use App\Services\PhotoPreview;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PrunePhotosBeforeCommand extends Command
|
||||
{
|
||||
protected $signature = 'coruna:prune-photos-before
|
||||
{date : Delete photos created before this date (e.g. 2026-09-01 or "2026-09-01 00:00:00")}
|
||||
{--execute : Actually delete files and rows (default is dry-run)}
|
||||
{--device= : Only prune photos for this device_id or numeric id}';
|
||||
|
||||
protected $description = 'Delete photo DB rows, file blobs, read markers, and preview caches created before a given date';
|
||||
|
||||
public function handle(PhotoPreview $previews): int
|
||||
{
|
||||
$execute = (bool) $this->option('execute');
|
||||
$dateArg = trim((string) $this->argument('date'));
|
||||
$date = date('Y-m-d H:i:s', strtotime($dateArg));
|
||||
if ($date === false || $date === '1970-01-01 00:00:00') {
|
||||
$this->error("invalid date: {$dateArg}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$q = Photo::query()->where('photos.created_at', '<', $date);
|
||||
$deviceFilter = trim((string) $this->option('device'));
|
||||
if ($deviceFilter !== '') {
|
||||
if (ctype_digit($deviceFilter)) {
|
||||
$q->where('photos.device_id', (int) $deviceFilter);
|
||||
} else {
|
||||
$q->join('devices', 'devices.id', '=', 'photos.device_id')
|
||||
->where('devices.device_id', $deviceFilter)
|
||||
->select('photos.*');
|
||||
}
|
||||
}
|
||||
|
||||
$count = (clone $q)->count();
|
||||
$this->info(sprintf(
|
||||
'%s cutoff=%s photos=%d',
|
||||
$execute ? 'EXECUTE' : 'DRY-RUN',
|
||||
$date,
|
||||
$count,
|
||||
));
|
||||
|
||||
if ($count === 0) {
|
||||
$this->info('nothing to prune');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $execute) {
|
||||
$this->warn('dry-run only; pass --execute to delete');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$disk = Storage::disk('local');
|
||||
$deletedRows = 0;
|
||||
$deletedFiles = 0;
|
||||
$deviceIds = [];
|
||||
|
||||
// Process in chunks to avoid loading everything into memory.
|
||||
$q->select(['photos.id', 'photos.device_id', 'photos.path'])
|
||||
->orderBy('photos.id')
|
||||
->chunk(500, function ($photos) use ($disk, $previews, &$deletedRows, &$deletedFiles, &$deviceIds) {
|
||||
$ids = [];
|
||||
foreach ($photos as $photo) {
|
||||
$ids[] = $photo->id;
|
||||
$path = trim((string) ($photo->path ?? ''));
|
||||
if ($path !== '' && $disk->exists($path)) {
|
||||
$disk->delete($path);
|
||||
$deletedFiles++;
|
||||
}
|
||||
$deviceIds[$photo->device_id] = true;
|
||||
}
|
||||
// Delete PhotoRead markers first (foreign key), then photos.
|
||||
DB::table('photo_reads')->whereIn('photo_id', $ids)->delete();
|
||||
Photo::query()->whereIn('id', $ids)->delete();
|
||||
$deletedRows += count($ids);
|
||||
});
|
||||
|
||||
// Clear preview caches for affected devices.
|
||||
foreach (array_keys($deviceIds) as $deviceId) {
|
||||
$device = \App\Models\Device::find($deviceId);
|
||||
if ($device) {
|
||||
$previews->forgetForDevice((string) $device->device_id);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('deleted_rows=%d deleted_files=%d devices=%d', $deletedRows, $deletedFiles, count($deviceIds)));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,29 @@ use Illuminate\Support\Facades\DB;
|
||||
*
|
||||
* Only 32-char hex values are real UUIDs — shorter IDs (e.g. old xxbb
|
||||
* 16-hex device IDs) are left untouched.
|
||||
*
|
||||
* If a plain-hex device and a dashed device coexist (because the dashed
|
||||
* one was created by DarkSwordIngestAdapter after normalizeDarkswordKey was
|
||||
* introduced), the plain-hex duplicate is merged into the dashed one: all
|
||||
* child rows (photos, events, apps, …) are reparented to the dashed device,
|
||||
* then the plain-hex row is deleted.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/** Tables that reference devices.id via device_id FK. */
|
||||
private const CHILD_TABLES = [
|
||||
'device_apps',
|
||||
'device_events',
|
||||
'ds_beacon_tasks',
|
||||
'notes',
|
||||
'photos',
|
||||
'plugin_sessions',
|
||||
'sms_reports',
|
||||
'wallet_addresses',
|
||||
'wallet_keystores',
|
||||
'wallet_mnemonics',
|
||||
];
|
||||
|
||||
private function dashedKey(string $plain): ?string
|
||||
{
|
||||
$hex = strtoupper(preg_replace('/[^0-9A-Fa-f]/', '', $plain) ?? '');
|
||||
@@ -23,20 +43,74 @@ return new class extends Migration
|
||||
return substr($hex, 0, 8).'-'.substr($hex, 8, 4).'-'.substr($hex, 12, 4).'-'.substr($hex, 16, 4).'-'.substr($hex, 20, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given string is a 32-char plain hex value
|
||||
* (no dashes) — i.e. the "before" state that needs conversion.
|
||||
*/
|
||||
private function isPlainHex(string $value): bool
|
||||
{
|
||||
$trimmed = strtoupper(trim($value));
|
||||
|
||||
return strlen($trimmed) === 32 && ctype_xdigit($trimmed);
|
||||
}
|
||||
|
||||
private function convertColumn(string $table, string $column): void
|
||||
{
|
||||
$rows = DB::table($table)->get(['id', $column]);
|
||||
foreach ($rows as $row) {
|
||||
$dashed = $this->dashedKey((string) $row->{$column});
|
||||
if ($dashed !== null) {
|
||||
DB::table($table)->where('id', $row->id)->update([$column => $dashed]);
|
||||
$value = (string) $row->{$column};
|
||||
if (! $this->isPlainHex($value)) {
|
||||
continue;
|
||||
}
|
||||
$dashed = $this->dashedKey($value);
|
||||
if ($dashed === null) {
|
||||
continue;
|
||||
}
|
||||
DB::table($table)->where('id', $row->id)->update([$column => $dashed]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the plain-hex device (loser) into the dashed device (winner):
|
||||
* reparent all child rows, then delete the loser.
|
||||
*/
|
||||
private function mergeDevice(int $loserId, int $winnerId): void
|
||||
{
|
||||
foreach (self::CHILD_TABLES as $table) {
|
||||
DB::table($table)->where('device_id', $loserId)->update(['device_id' => $winnerId]);
|
||||
}
|
||||
DB::table('devices')->where('id', $loserId)->delete();
|
||||
}
|
||||
|
||||
private function convertDevices(): void
|
||||
{
|
||||
// Find all plain-hex device_ids that need conversion.
|
||||
$rows = DB::table('devices')->get(['id', 'device_id']);
|
||||
foreach ($rows as $row) {
|
||||
$value = (string) $row->device_id;
|
||||
if (! $this->isPlainHex($value)) {
|
||||
continue;
|
||||
}
|
||||
$dashed = $this->dashedKey($value);
|
||||
if ($dashed === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if a device with the dashed format already exists.
|
||||
$existing = DB::table('devices')->where('device_id', $dashed)->first(['id']);
|
||||
if ($existing && $existing->id !== $row->id) {
|
||||
// Merge loser (plain-hex) into winner (dashed).
|
||||
$this->mergeDevice($row->id, $existing->id);
|
||||
} else {
|
||||
// No conflict — safe to update.
|
||||
DB::table('devices')->where('id', $row->id)->update(['device_id' => $dashed]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$this->convertColumn('devices', 'device_id');
|
||||
$this->convertDevices();
|
||||
$this->convertColumn('page_visits', 'client_uid');
|
||||
$this->convertColumn('ds_chain_logs', 'client_uid');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user