Feat: Creating Migration Models And Adapters for file Service
This commit is contained in:
@@ -43,6 +43,7 @@
|
|||||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CartServiceProvider",
|
"Modules\\Core\\Providers\\CartServiceProvider",
|
||||||
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\FileServiceProvider",
|
||||||
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
||||||
"Modules\\Core\\Providers\\OrderServiceProvider",
|
"Modules\\Core\\Providers\\OrderServiceProvider",
|
||||||
"Modules\\Core\\Providers\\PrivacyServiceProvider"
|
"Modules\\Core\\Providers\\PrivacyServiceProvider"
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic, storage-backend-agnostic file registry — Modules\Core\Files\
|
||||||
|
* Services\FileService's own backing table. `disk`/`path` are whatever
|
||||||
|
* Laravel's Storage facade already understands (local, s3, ...); this
|
||||||
|
* table adds what Flysystem itself has no concept of: who a file
|
||||||
|
* belongs to, why it was uploaded, and whether anything still needs it.
|
||||||
|
*
|
||||||
|
* `owner_type`/`owner_id` are nullable — a file can (and, for a product
|
||||||
|
* custom-field photo, always does) exist before anything owns it yet: a
|
||||||
|
* shopper picks a photo on the product page and it's uploaded immediately
|
||||||
|
* (see 3dealer's CustomFieldUploadController), well before add-to-cart
|
||||||
|
* gives it a CartLine to belong to. FileService::attachOwner() re-points
|
||||||
|
* these columns once an owner exists, rather than creating a second row
|
||||||
|
* for the same physical file.
|
||||||
|
*
|
||||||
|
* `purpose` (e.g. 'custom-field-upload') lets one table serve unrelated
|
||||||
|
* future features without collision — FileService itself has no
|
||||||
|
* knowledge of what a purpose means, callers scope their own queries by
|
||||||
|
* it.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('file_uploads', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('disk');
|
||||||
|
$table->string('path');
|
||||||
|
$table->string('original_name')->nullable();
|
||||||
|
$table->string('mime')->nullable();
|
||||||
|
$table->unsignedBigInteger('size')->nullable();
|
||||||
|
$table->string('purpose');
|
||||||
|
$table->nullableMorphs('owner');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['purpose', 'owner_type', 'owner_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('file_uploads');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\File\Adapters;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Modules\Core\File\Contracts\FileAdapterInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps Laravel's own 'local' Storage disk — see FileAdapterInterface's
|
||||||
|
* own docblock for why this exists as a named adapter rather than every
|
||||||
|
* caller reaching for Storage::disk('local') directly: swapping to a
|
||||||
|
* different backend later (S3FileAdapter, say) means adding one class and
|
||||||
|
* one contextual-binding entry, touching nothing that already uses
|
||||||
|
* FileService.
|
||||||
|
*/
|
||||||
|
class LocalFileAdapter implements FileAdapterInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Filesystem $disk,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(UploadedFile $file, string $directory): string
|
||||||
|
{
|
||||||
|
return $this->disk->putFile($directory, $file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool
|
||||||
|
{
|
||||||
|
return $this->disk->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void
|
||||||
|
{
|
||||||
|
$this->disk->delete($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retrieve(string $path, ?string $name = null): StreamedResponse
|
||||||
|
{
|
||||||
|
return $this->disk->response($path, $name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\File\Contracts;
|
||||||
|
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One storage backend's actual byte-level operations — a disk name (see
|
||||||
|
* Modules\Core\File\Models\File::$disk) resolves to exactly one
|
||||||
|
* implementation of this via Modules\Core\File\Services\FileService's own
|
||||||
|
* contextual binding (see Providers\FileServiceProvider), the same
|
||||||
|
* pattern Shipping\Contracts\CarrierFulfillmentInterface uses to pick an
|
||||||
|
* AcsFulfillmentService/BoxNowFulfillmentService per carrier. FileService
|
||||||
|
* itself never touches a disk directly — every backend-specific detail
|
||||||
|
* (a local path, an S3 bucket/region, ...) lives entirely inside one
|
||||||
|
* adapter, so adding a new backend never touches FileService or any of
|
||||||
|
* its callers.
|
||||||
|
*/
|
||||||
|
interface FileAdapterInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Stores the file under $directory, returning the path to record on
|
||||||
|
* the File row (Models\File::$path) — backend-specific (a relative
|
||||||
|
* local path, an S3 object key, ...), meaningful only to this same
|
||||||
|
* adapter.
|
||||||
|
*/
|
||||||
|
public function store(UploadedFile $file, string $directory): string;
|
||||||
|
|
||||||
|
public function exists(string $path): bool;
|
||||||
|
|
||||||
|
public function delete(string $path): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams the file at $path straight to the browser.
|
||||||
|
*/
|
||||||
|
public function retrieve(string $path, ?string $name = null): StreamedResponse;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\File\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row per physical file Modules\Core\File\Services\FileService has
|
||||||
|
* stored — see that migration's own docblock for why `owner_type`/
|
||||||
|
* `owner_id` are nullable and what `purpose` is for.
|
||||||
|
*/
|
||||||
|
class File extends Model
|
||||||
|
{
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'size' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function owner(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\File\Services;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Container\Container;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Modules\Core\File\Contracts\FileAdapterInterface;
|
||||||
|
use Modules\Core\File\Models\File;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrates the `files` table (ownership, purpose, cleanup — see that
|
||||||
|
* migration's own docblock) on top of whichever FileAdapterInterface a
|
||||||
|
* disk resolves to (see Providers\FileServiceProvider's contextual
|
||||||
|
* binding) — never touches a disk or a raw path itself. Knows nothing
|
||||||
|
* about custom fields, carts, or orders specifically; every caller
|
||||||
|
* (3dealer's custom-field upload flow today, some other future
|
||||||
|
* file-upload need tomorrow) supplies its own `purpose` string and owner
|
||||||
|
* model, and scopes its own queries by them.
|
||||||
|
*/
|
||||||
|
class FileService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Container $container,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function store(UploadedFile $file, string $purpose, string $directory, string $disk = 'local'): File
|
||||||
|
{
|
||||||
|
$path = $this->adapter($disk)->store($file, $directory);
|
||||||
|
|
||||||
|
return File::create([
|
||||||
|
'disk' => $disk,
|
||||||
|
'path' => $path,
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime' => $file->getMimeType(),
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
'purpose' => $purpose,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-points an existing File at its owner — called once an owner
|
||||||
|
* actually exists (e.g. a shopper's picked-but-not-yet-added photo
|
||||||
|
* gets a CartLine the moment it's added to the cart). Never creates a
|
||||||
|
* second row for the same physical file.
|
||||||
|
*/
|
||||||
|
public function attachOwner(File $file, Model $owner): File
|
||||||
|
{
|
||||||
|
$file->update([
|
||||||
|
'owner_type' => $owner->getMorphClass(),
|
||||||
|
'owner_id' => $owner->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function retrieve(File $file): StreamedResponse
|
||||||
|
{
|
||||||
|
return $this->adapter($file->disk)->retrieve($file->path, $file->original_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(File $file): bool
|
||||||
|
{
|
||||||
|
return $this->adapter($file->disk)->exists($file->path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(File $file): void
|
||||||
|
{
|
||||||
|
$this->adapter($file->disk)->delete($file->path);
|
||||||
|
|
||||||
|
$file->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, File>
|
||||||
|
*/
|
||||||
|
public function list(string $purpose, ?Model $owner = null): Collection
|
||||||
|
{
|
||||||
|
return File::query()
|
||||||
|
->where('purpose', $purpose)
|
||||||
|
->when($owner, fn ($query) => $query
|
||||||
|
->where('owner_type', $owner->getMorphClass())
|
||||||
|
->where('owner_id', $owner->getKey()))
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes every File of the given purpose that has no owner yet and
|
||||||
|
* is older than $olderThan — the grace period covers a shopper still
|
||||||
|
* on the page with a picked-but-not-yet-added file. An owned File
|
||||||
|
* (whatever the owner type) is never touched here; callers that want
|
||||||
|
* owned files gone too should delete() them explicitly wherever that
|
||||||
|
* ownership itself ends (e.g. a CartLine being removed).
|
||||||
|
*
|
||||||
|
* @return int number of files deleted
|
||||||
|
*/
|
||||||
|
public function pruneUnowned(string $purpose, Carbon $olderThan): int
|
||||||
|
{
|
||||||
|
$files = File::query()
|
||||||
|
->where('purpose', $purpose)
|
||||||
|
->whereNull('owner_type')
|
||||||
|
->where('created_at', '<', $olderThan)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$this->delete($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function adapter(string $disk): FileAdapterInterface
|
||||||
|
{
|
||||||
|
return $this->container->make(FileAdapterInterface::class, ['disk' => $disk]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Core\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Modules\Core\File\Adapters\LocalFileAdapter;
|
||||||
|
use Modules\Core\File\Contracts\FileAdapterInterface;
|
||||||
|
|
||||||
|
class FileServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
// Same pattern as ShippingServiceProvider's CarrierFulfillmentInterface
|
||||||
|
// binding — a plain param ('disk' here, 'carrier' there) picks which
|
||||||
|
// concrete adapter Modules\Core\File\Services\FileService actually
|
||||||
|
// talks to. Adding a real S3FileAdapter later is one class plus one
|
||||||
|
// more match arm here; nothing that already calls FileService changes.
|
||||||
|
$this->app->bind(FileAdapterInterface::class, function ($app, array $params) {
|
||||||
|
$disk = $params['disk'] ?? 'local';
|
||||||
|
|
||||||
|
return match ($disk) {
|
||||||
|
'local' => new LocalFileAdapter(Storage::disk($disk)),
|
||||||
|
default => throw new InvalidArgumentException("No FileAdapterInterface available for disk \"{$disk}\"."),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->loadMigrationsFrom(__DIR__.'/../../database/migrations');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user