Bài 28 — Mail, Notification và Upload: TaskFlow cho owner đính kèm PDF vào task và nhận thông báo email qua queue. File không nằm trong thư mục public; cả upload lẫn download đều phải qua kiểm tra quyền.
1. Tách dữ liệu file khỏi thông báo
Luồng thực hành là authorize → validate → lưu file riêng tư → lưu metadata → enqueue notification. Thành công upload không phụ thuộc email đã đến hộp thư. Nếu enqueue lỗi, ứng dụng report lỗi nhưng không hoàn tác file đã lưu, tránh khiến người dùng hiểu nhầm phải upload lại. Đây là notification best-effort, chưa có outbox bảo đảm không mất thông báo.
Migration task_attachments lưu task_id, path, size và timestamps. Task có quan hệ attachments; model TaskAttachment chỉ cho fill path/size, task_id được gán qua relationship. Foreign key cascade xóa metadata khi task bị xóa, không tự xóa file vật lý. Ứng dụng hiện chưa có endpoint xóa task; khi bổ sung phải thiết kế dọn file riêng.
2. Upload và download riêng tư
app/Http/Controllers/TaskAttachmentController.php
<?php
namespace App\Http\Controllers;
use App\Models\Project;
use App\Models\Task;
use App\Models\TaskAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File;
class TaskAttachmentController extends Controller
{
public function store(Request $request, Project $project, Task $task)
{
Gate::authorize('update', $task);
$request->validate(['attachment' => ['required', File::types(['pdf'])->max(2048)]]);
$file = $request->file('attachment');
$path = $file->store('task-attachments', 'local');
if ($path === false) {
throw new \RuntimeException('Attachment storage failed.');
}
try {
$task->attachments()->create(['path' => $path, 'size' => $file->getSize()]);
} catch (\Throwable $exception) {
Storage::disk('local')->delete($path);
throw $exception;
}
// Storage already succeeded. Delivery failure must not undo or duplicate the upload.
try {
$project->owner->notify(new \App\Notifications\AttachmentUploaded($project->id));
} catch (\Throwable $exception) {
report($exception);
}
return to_route('projects.show', $project);
}
public function download(Project $project, Task $task, TaskAttachment $attachment)
{
Gate::authorize('update', $task);
abort_unless(Storage::disk('local')->exists($attachment->path), 404);
return Storage::disk('local')->download($attachment->path, 'attachment-'.$attachment->id.'.pdf', [
'Content-Type' => 'application/pdf', 'X-Content-Type-Options' => 'nosniff',
'Cache-Control' => 'no-store, private',
]);
}
}Route POST /projects/{project}/tasks/{task}/attachments và GET cùng đường dẫn thêm /{attachment} nằm trong nhóm auth, scopeBindings. Nested binding xác nhận attachment thuộc task và task thuộc project; Gate kiểm tra owner. File được lưu bằng tên do storage sinh trên disk local trỏ storage/app/private. Không dùng tên gốc hoặc đường dẫn client gửi để chọn nơi ghi.
Form dùng multipart/form-data và @csrf. Validation chấp nhận PDF tối đa 2.048 KB theo rule server; thuộc tính accept trong trình duyệt chỉ là gợi ý. Giới hạn PHP upload_max_filesize/post_max_size và reverse proxy phải phù hợp. MIME validation không phải antivirus và không chứng minh PDF không chứa nội dung nguy hiểm.
Download trả attachment với tên cố định, Content-Type PDF, nosniff và no-store/private. Không tạo symlink public cho file này. Trang project eager-load attachments để liệt kê link; email không chứa link storage trực tiếp hoặc bản sao PDF.
Nếu insert metadata lỗi, controller thử xóa file vừa lưu rồi ném lại exception. Database và filesystem không có transaction chung: process crash giữa hai bước hoặc cleanup thất bại có thể để lại file mồ côi. Cần cơ chế đối soát/quarantine cho triển khai nghiêm túc, không tuyên bố đoạn catch đã giải quyết mọi tình huống.
3. Notification chọn mail channel
app/Notifications/AttachmentUploaded.php
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AttachmentUploaded extends Notification implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $timeout = 20;
public function __construct(public int $projectId)
{
$this->onQueue('notifications')->afterCommit();
}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)->subject('TaskFlow: attachment uploaded')
->line('A PDF attachment was added to a task in your project.')
->action('Open project', route('projects.show', $this->projectId))
->line('Sign in to review it. This email does not include the private file.');
}
public function backoff(): array
{
return [10, 30];
}
}Notification triển khai ShouldQueue, dùng queue notifications và afterCommit. Nội dung chỉ có thông báo chung và URL trang project cần đăng nhập. MailMessage phù hợp thông báo ngắn; khi cần template email chuyên biệt có thể dùng Mailable, không cần tạo thêm một lớp chỉ để gửi cùng nội dung hai lần.
Job có tries=3, timeout=20 và backoff, nhưng retry email không tự exactly-once: provider có thể đã nhận thư trước khi worker mất kết nối. Nếu nghiệp vụ không chấp nhận trùng cần tracking/idempotency theo khả năng provider. Một kết quả queue thành công cũng không chứng minh email vào inbox thay vì spam.
4. Chạy local mà không gửi thư ra ngoài
# .env phát triển
MAIL_MAILER=log
QUEUE_CONNECTION=database
php artisan migrate
php artisan queue:work database --queue=notifications,maintenance --tries=3 --timeout=20
Xác nhận database phát triển trước migrate. Đăng nhập, tạo project/task rồi upload PDF nhỏ từ trang chi tiết. Worker nghe notifications mới xử lý thư; worker chỉ nghe maintenance ở bài 26 sẽ không lấy job này. Mailer log ghi email vào log local, vì vậy log vẫn cần quyền truy cập và retention phù hợp.
Production cần transport và sender thật, cấu hình DNS/xác thực domain theo provider, theo dõi bounce và giới hạn gửi. Bài học không cấu hình SMTP production hoặc gửi email thật. Không commit mật khẩu mail vào source hay cấu hình ví dụ.
5. Kiểm thử từng ranh giới
tests/Feature/TaskAttachmentTest.php
<?php
namespace Tests\Feature;
use App\Models\Task;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class TaskAttachmentTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => ':memory:',
'database.connections.sqlite.url' => null]);
DB::purge('sqlite');
$this->artisan('migrate', ['--force' => true])->assertSuccessful();
Storage::fake('local');
\Illuminate\Support\Facades\Notification::fake();
}
public function test_owner_can_upload_and_download_but_another_user_cannot(): void
{
$task = Task::factory()->create();
$this->actingAs($task->project->owner)->post(route('attachments.store', [$task->project, $task]), [
'attachment' => UploadedFile::fake()->createWithContent('report.pdf', "%PDF-1.4\n1 0 obj\n<<>>\nendobj\n%%EOF"),
])->assertRedirect();
$attachment = $task->attachments()->sole();
\Illuminate\Support\Facades\Notification::assertSentTo($task->project->owner,
\App\Notifications\AttachmentUploaded::class);
Storage::disk('local')->assertExists($attachment->path);
$url = route('attachments.download', [$task->project, $task, $attachment]);
$this->get($url)->assertOk()->assertDownload('attachment-'.$attachment->id.'.pdf');
$this->actingAs(User::factory()->create())->get($url)->assertForbidden();
}
public function test_invalid_file_and_unauthorized_upload_create_no_record(): void
{
$task = Task::factory()->create();
$url = route('attachments.store', [$task->project, $task]);
$this->actingAs($task->project->owner)->postJson($url, [
'attachment' => UploadedFile::fake()->create('fake.pdf', 1, 'text/plain'),
])->assertUnprocessable();
$this->actingAs(User::factory()->create())->postJson($url, [])->assertForbidden();
$this->assertDatabaseCount('task_attachments', 0);
}
}tests/Feature/AttachmentNotificationTest.php
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Notifications\AttachmentUploaded;
use Illuminate\Notifications\SendQueuedNotifications;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class AttachmentNotificationTest extends TestCase
{
public function test_notification_is_queued_and_mail_contains_only_a_project_link(): void
{
Queue::fake();
$user = new User(['name' => 'Local recipient', 'email' => 'recipient@example.test']);
$notification = new AttachmentUploaded(123);
Notification::send($user, $notification);
Queue::assertPushedOn('notifications', SendQueuedNotifications::class);
$mail = $notification->toMail($user);
$this->assertSame(['mail'], $notification->via($user));
$this->assertSame(route('projects.show', 123), $mail->actionUrl);
$this->assertSame([], $mail->attachments);
$this->assertStringContainsString('Open project', (string) $mail->render());
}
}php artisan test --filter=TaskAttachmentTest
php artisan test --filter=AttachmentNotificationTest
php artisan test
Suite đạt 84 test, 394 assertions. Upload test dùng disk giả và SQLite bộ nhớ, xác nhận owner tải được, user khác bị chặn, MIME không hợp lệ bị từ chối và notification được yêu cầu. Test notification kiểm tra job vào đúng queue, mail render có link và không có file đính kèm. Không dùng transport bên ngoài.
Fake upload chỉ kiểm tra hành vi validation với MIME được cung cấp, không thay kiểm thử file thật. Chưa có quota tổng dung lượng, antivirus, màn hình xóa file hoặc test crash storage. Bài tập: thêm test PDF quá 2 MB, attachment thuộc task khác và file mất khỏi storage; mô tả cách dọn orphan mà không xóa file còn được tham chiếu.
Điều hướng: Bài 27 · Lộ trình. Tiếp theo là realtime với Reverb.




Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.