404 Not Found

404 Not Found


nginx

Desenvolvimento do Projeto — Implementação em Código da Plataforma SaaS ShopMetrics

Os diagramas de design finalmente se tornaram código — mas não se apresse. Implemente módulo por módulo e teste cada módulo ao concluir.

1. O Que Você Vai Aprender


2. Uma História Real de Desenvolvimento Colaborativo

(1) Dor: Três pessoas estão cada uma escrevendo seu próprio código, o que leva a conflitos ao mesclar o código

Bob distribuiu as tarefas: Alice deveria escrever o código de gerenciamento de tenants e Charlie deveria escrever o código de análise de dados. Três dias depois, quando mesclaram o código, os relacionamentos entre o model Tenant de Alice e o model Order de Charlie não estavam alinhados, e a lógica de middleware deles conflitava entre si, resultando em erros 500 em todo o site após a mesclagem. Bob disse: "Precisamos de um processo de desenvolvimento que garanta que o código de todos possa rodar independentemente."

(2) Soluções para Desenvolvimento Modular

Desenvolva na seguinte ordem de prioridade: Autenticação → Tenant → Loja → Pedidos → Analytics → Cobrança. Após completar cada módulo, execute testes e mescle o código para garantir que a branch principal esteja sempre em estado executável.

TEXT
Semana 1: Auth + Tenant + Middleware (Framework Básico)
Semana 2: Shop + Order + Product (Negócio Principal)
Semana 3: Dashboard + Analytics (Análise de Dados)
Semana 4: Subscription + Stripe (Sistema de Cobrança)

(3) Resultado

Após adotar desenvolvimento modular, os três desenvolvedores mesclaram seu código uma vez por dia, reduzindo a taxa de conflitos de 40% para 5%, e a branch principal permaneceu consistentemente executável.


3. Middleware Multi-tenant e Scope

(1) Fluxo de Processamento de Requisição

100%
sequenceDiagram
    participant Client
    participant Nginx
    participant Middleware
    participant TenantScope
    participant Controller
    participant DB

    Client->>Nginx: GET alice.shopmetrics.io/api/v1/shops
    Nginx->>Middleware: X-Tenant: alice
    Middleware->>DB: SELECT * FROM tenants WHERE slug=alice
    DB-->>Middleware: Tenant{id:1, slug:alice}
    Middleware->>TenantScope: Definir tenant atual
    Middleware->>Controller: Request com contexto de tenant
    Controller->>DB: SELECT * FROM shops WHERE tenant_id=1
    DB-->>Controller: Apenas lojas de Alice
    Controller-->>Client: 200 OK {data: [...]}

(2) Implementação do Middleware

PHP
// app/Http/Middleware/SetTenantContext.php
class SetTenantContext
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!$user = $request->user()) {
            return $next($request);
        }

        $tenant = $user->tenant;

        if (!$tenant || !$tenant->isActive()) {
            abort(403, 'Conta do tenant está inativa ou suspensa.');
        }

        // Verificar limites do plano
        if ($tenant->isOverLimit()) {
            abort(402, 'Limite do plano excedido. Atualize sua assinatura.');
        }

        Tenant::setCurrent($tenant);
        $request->attributes->set('tenant', $tenant);

        return $next($request);
    }
}

// app/Http/Kernel.php - Registrar middleware
protected $middlewareAliases = [
    'tenant' => SetTenantContext::class,
];

(1) ▶ Exemplo: Implementação Completa da Trait BelongsToTenant

PHP
// app/Models/Traits/BelongsToTenant.php
namespace App\Models\Traits;

use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            $tenant = Tenant::current();
            if ($tenant) {
                $builder->where($builder->getModel()->getTable() . '.tenant_id', $tenant->id);
            }
        });

        static::creating(function (Model $model) {
            $tenant = Tenant::current();
            if ($tenant && !$model->isDirty('tenant_id')) {
                $model->tenant_id = $tenant->id;
            }
        });

        static::saving(function (Model $model) {
            $tenant = Tenant::current();
            if ($tenant) {
                $currentTenantId = $model->getOriginal('tenant_id') ?? $model->tenant_id;
                if ((int) $currentTenantId !== $tenant->id) {
                    throw new \Illuminate\Auth\Access\AuthorizationException(
                        'Não é possível modificar dados pertencentes a outro tenant.'
                    );
                }
            }
        });
    }

    public function tenant(): \Illuminate\Database\Eloquent\Relations\BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function scopeForTenant(Builder $query, Tenant $tenant): Builder
    {
        return $query->withoutGlobalScope('tenant')
            ->where('tenant_id', $tenant->id);
    }

    public function scopeAllTenants(Builder $query): Builder
    {
        return $query->withoutGlobalScope('tenant');
    }
}

Saída:

TEXT
// Execução bem-sucedida

4. Integração de Cobrança de Assinatura Stripe

(1) Ciclo de Vida da Assinatura

100%
stateDiagram-v2
    [*] --> Trial: Novo Cadastro
    Trial --> Active: Trial→Pago
    Trial --> Cancelled: Cancelar durante trial
    Active --> PastDue: Pagamento falhou
    PastDue --> Active: Retry de pagamento bem-sucedido
    PastDue --> Cancelled: Retry esgotado
    Active --> Cancelled: Usuário cancela
    Cancelled --> Active: Reassinar

(2) Os Models Plan e Subscription

PHP
// app/Models/Plan.php
class Plan extends Model
{
    protected $fillable = [
        'name', 'slug', 'shop_limit', 'user_limit',
        'price_cents', 'stripe_price_id', 'is_active',
    ];

    protected $casts = [
        'price_cents' => 'integer',
        'shop_limit' => 'integer',
        'user_limit' => 'integer',
        'is_active' => 'boolean',
    ];

    public function subscriptions(): HasMany
    {
        return $this->hasMany(Subscription::class);
    }

    public function isFree(): bool
    {
        return $this->price_cents === 0;
    }

    public function formattedPrice(): string
    {
        return $this->isFree() ? 'Grátis' : '$' . number_format($this->price_cents / 100, 2) . '/mês';
    }
}

// app/Models/Subscription.php
class Subscription extends Model
{
    protected $fillable = [
        'tenant_id', 'plan_id', 'stripe_id',
        'stripe_status', 'trial_ends_at', 'ends_at',
    ];

    protected $casts = [
        'trial_ends_at' => 'datetime',
        'ends_at' => 'datetime',
    ];

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }

    public function plan(): BelongsTo
    {
        return $this->belongsTo(Plan::class);
    }

    public function isActive(): bool
    {
        return in_array($this->stripe_status, ['active', 'trialing']);
    }

    public function onTrial(): bool
    {
        return $this->stripe_status === 'trialing' ||
               ($this->trial_ends_at && $this->trial_ends_at->isFuture());
    }

    public function onGracePeriod(): bool
    {
        return $this->ends_at && $this->ends_at->isFuture();
    }
}

(1) ▶ Exemplo: Stripe Checkout e Processamento Webhook

PHP
// app/Http/Controllers/SubscriptionController.php
class SubscriptionController extends Controller
{
    public function store(Request $request)
    {
        $request->validate(['plan_slug' => 'required|exists:plans,slug']);
        $plan = Plan::whereSlug($request->plan_slug)->firstOrFail();
        $tenant = $request->attributes->get('tenant');

        if ($plan->isFree()) {
            $tenant->updateToFreePlan($plan);
            return response()->json(['message' => 'Assinado no plano gratuito']);
        }

        // Criar sessão Stripe Checkout
        $checkout = Stripe::checkout()->sessions()->create([
            'customer' => $tenant->stripe_customer_id ?? $this->createStripeCustomer($tenant),
            'mode' => 'subscription',
            'line_items' => [[
                'price' => $plan->stripe_price_id,
                'quantity' => 1,
            ]],
            'subscription_data' => [
                'trial_period_days' => config('shopmetrics.tenant.trial_days'),
                'metadata' => ['tenant_id' => $tenant->id],
            ],
            'success_url' => config('app.url') . '/billing?success=1',
            'cancel_url' => config('app.url') . '/billing?cancel=1',
        ]);

        return response()->json(['checkout_url' => $checkout->url]);
    }

    public function cancel(Request $request)
    {
        $tenant = $request->attributes->get('tenant');
        $subscription = $tenant->subscription;

        Stripe::subscriptions()->update($subscription->stripe_id, [
            'cancel_at_period_end' => true,
        ]);

        $subscription->update(['ends_at' => $subscription->current_period_end]);

        return response()->json(['message' => 'Assinatura será cancelada ao final do período']);
    }
}

// app/Http/Controllers/WebhookController.php
class WebhookController extends Controller
{
    public function handleStripe(Request $request)
    {
        $payload = $request->all();
        $event = Stripe::webhooks()->constructEvent(
            $payload,
            $request->header('Stripe-Signature'),
            config('services.stripe.webhook_secret')
        );

        match ($event->type) {
            'customer.subscription.created' => $this->handleSubscriptionCreated($event),
            'customer.subscription.updated' => $this->handleSubscriptionUpdated($event),
            'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event),
            'invoice.payment_failed' => $this->handlePaymentFailed($event),
            default => null,
        };

        return response()->json(['received' => true]);
    }

    private function handleSubscriptionUpdated($event): void
    {
        $stripeSubscription = $event->data->object;
        $subscription = Subscription::whereStripeId($stripeSubscription->id)->firstOrFail();
        $subscription->update([
            'stripe_status' => $stripeSubscription->status,
            'trial_ends_at' => $stripeSubscription->trial_end
                ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->trial_end)
                : null,
            'ends_at' => $stripeSubscription->cancel_at_period_end
                ? \Carbon\Carbon::createFromTimestamp($stripeSubscription->current_period_end)
                : null,
        ]);
    }

    private function handlePaymentFailed($event): void
    {
        $stripeSubscription = $event->data->object->subscription;
        $subscription = Subscription::whereStripeId($stripeSubscription)->first();
        if ($subscription) {
            $subscription->tenant->users->each(
                fn ($user) => $user->notify(new PaymentFailedNotification())
            );
        }
    }
}

Saída:

TEXT
// Execução bem-sucedida

5. Dashboard de Análise de Dados

(1) Design da API do Dashboard

Endpoint Dados Retornados TTL do Cache
GET /dashboard/overview Receita Total/Número de Pedidos/Número de Clientes/Ano-a-Ano 15 minutos
GET /dashboard/revenue Dados do Gráfico de Tendência de Receita 30 minutos
GET /dashboard/top-products Top 10 Produtos 1 hora
GET /dashboard/shop-comparison Comparação de Lojas 1 hora

(1) ▶ Exemplo: Serviço de Dashboard do ShopMetrics

PHP
// app/Services/DashboardService.php
class DashboardService
{
    public function getOverview(Tenant $tenant): array
    {
        return Cache::remember(
            "dashboard:{$tenant->id}:overview",
            now()->addMinutes(15),
            fn () => $this->calculateOverview($tenant)
        );
    }

    private function calculateOverview(Tenant $tenant): array
    {
        $currentMonth = now()->startOfMonth();
        $lastMonth = now()->subMonth()->startOfMonth();

        $current = DB::table('orders')
            ->where('tenant_id', $tenant->id)
            ->where('ordered_at', '>=', $currentMonth)
            ->select([
                DB::raw('SUM(total_cents) as revenue_cents'),
                DB::raw('COUNT(*) as order_count'),
                DB::raw('COUNT(DISTINCT customer_email) as customer_count'),
            ])
            ->first();

        $previous = DB::table('orders')
            ->where('tenant_id', $tenant->id)
            ->whereBetween('ordered_at', [$lastMonth, $currentMonth])
            ->select([
                DB::raw('SUM(total_cents) as revenue_cents'),
                DB::raw('COUNT(*) as order_count'),
            ])
            ->first();

        return [
            'revenue' => [
                'current' => (float) ($current->revenue_cents / 100),
                'previous' => (float) ($previous->revenue_cents / 100),
                'change_percent' => $this->percentChange(
                    $previous->revenue_cents, $current->revenue_cents
                ),
            ],
            'orders' => [
                'current' => (int) $current->order_count,
                'previous' => (int) $previous->order_count,
                'change_percent' => $this->percentChange(
                    $previous->order_count, $current->order_count
                ),
            ],
            'customers' => (int) $current->customer_count,
        ];
    }

    public function getRevenueChart(Tenant $tenant, string $period = '30d'): array
    {
        $days = match ($period) {
            '7d' => 7, '30d' => 30, '90d' => 90, '1y' => 365,
            default => 30,
        };

        return Cache::remember(
            "dashboard:{$tenant->id}:revenue:{$period}",
            now()->addMinutes(30),
            fn () => $this->calculateRevenueChart($tenant, $days)
        );
    }

    private function calculateRevenueChart(Tenant $tenant, int $days): array
    {
        $data = DB::table('orders')
            ->where('tenant_id', $tenant->id)
            ->where('ordered_at', '>=', now()->subDays($days))
            ->groupBy('date')
            ->orderBy('date')
            ->select([
                DB::raw('DATE(ordered_at) as date'),
                DB::raw('SUM(total_cents) as revenue_cents'),
                DB::raw('COUNT(*) as order_count'),
            ])
            ->get();

        return [
            'labels' => $data->pluck('date')->map(fn ($d) => \Carbon\Carbon::parse($d)->format('M d')),
            'revenue' => $data->pluck('revenue_cents')->map(fn ($v) => $v / 100),
            'orders' => $data->pluck('order_count'),
        ];
    }

    private function percentChange(float|int $old, float|int $new): float
    {
        if ($old == 0) return $new > 0 ? 100.0 : 0.0;
        return round((($new - $old) / $old) * 100, 1);
    }
}

Saída:

TEXT
// Execução bem-sucedida

6. Geração Assíncrona de Relatórios

(1) Processo de Geração de Relatório

100%
sequenceDiagram
    participant User as Alice
    participant API as Servidor API
    participant Queue as Fila Redis
    participant Worker as Queue Worker
    participant S3 as S3 Storage
    participant Mail as Serviço de Email

    User->>API: POST /reports {type: monthly, format: xlsx}
    API->>Queue: Dispatch GenerateReportJob
    API-->>User: 202 {report_id, status: pending}

    Queue->>Worker: Pegar job
    Worker->>Worker: Consultar & agregar dados
    Worker->>S3: Upload arquivo de relatório
    Worker->>Worker: Atualizar status=completed
    Worker->>Mail: Enviar email de notificação
    Mail-->>User: Email de relatório pronto

    User->>API: GET /reports/{id}
    API-->>User: {status: completed, download_url}

(1) ▶ Exemplo: Job de Geração de Relatório do ShopMetrics

PHP
// app/Jobs/GenerateReportJob.php
class GenerateReportJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60;
    public int $timeout = 600;

    public function __construct(
        public Tenant $tenant,
        public string $type,
        public string $format,
        public array $filters = [],
    ) {}

    public function handle(
        ReportGenerator $generator,
        ReportStorage $storage,
    ): void {
        $report = Report::create([
            'tenant_id' => $this->tenant->id,
            'type' => $this->type,
            'format' => $this->format,
            'status' => 'processing',
            'filters' => $this->filters,
        ]);

        try {
            $data = $this->fetchData();
            $filePath = $generator->generate($data, $this->format, $report);
            $storagePath = $storage->store($filePath, $this->tenant, $report);

            $report->update([
                'status' => 'completed',
                'storage_path' => $storagePath,
                'generated_at' => now(),
            ]);

            $this->notifyUser($report);

        } catch (\Throwable $e) {
            $report->update(['status' => 'failed']);
            throw $e;
        } finally {
            Cache::forget("dashboard:{$this->tenant->id}:*");
        }
    }

    private function fetchData(): Collection
    {
        return match ($this->type) {
            'monthly' => $this->fetchMonthlyData(),
            'product_comparison' => $this->fetchProductComparison(),
            'shop_performance' => $this->fetchShopPerformance(),
            default => throw new \InvalidArgumentException("Tipo de relatório desconhecido: {$this->type}"),
        };
    }

    private function fetchMonthlyData(): Collection
    {
        return Order::with('items.product')
            ->where('tenant_id', $this->tenant->id)
            ->whereBetween('ordered_at', [
                $this->filters['date_from'] ?? now()->subMonth(),
                $this->filters['date_to'] ?? now(),
            ])
            ->orderBy('ordered_at')
            ->get();
    }

    private function notifyUser(Report $report): void
    {
        $user = User::find($this->filters['user_id'] ?? $this->tenant->users()->first()->id);
        $user?->notify(new ReportReadyNotification($report));
    }
}

// app/Services/ReportGenerator.php
class ReportGenerator
{
    public function generate(Collection $data, string $format, Report $report): string
    {
        return match ($format) {
            'csv' => $this->generateCsv($data, $report),
            'xlsx' => $this->generateExcel($data, $report),
            'json' => $this->generateJson($data, $report),
            default => throw new \InvalidArgumentException("Formato não suportado: {$format}"),
        };
    }

    private function generateCsv(Collection $data, Report $report): string
    {
        $path = tempnam(sys_get_temp_dir(), 'report_');
        $file = fopen($path, 'w');

        fputcsv($file, ['ID do Pedido', 'Data', 'Cliente', 'Total', 'Status']);

        foreach ($data as $order) {
            fputcsv($file, [
                $order->external_id,
                $order->ordered_at->format('Y-m-d'),
                $order->customer_email,
                number_format($order->total_cents / 100, 2),
                $order->status,
            ]);
        }

        fclose($file);
        return $path;
    }
}

Saída:

TEXT
// Execução bem-sucedida

7. Implementação Completa da Camada API

(1) Saída de Recursos API

PHP
// app/Http/Resources/ShopResource.php
class ShopResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'platform' => $this->platform,
            'status' => $this->status,
            'metadata' => $this->metadata,
            'order_count' => $this->whenCounted('orders'),
            'revenue_cents' => $this->whenAggregated('orders', 'total_cents', 'sum'),
            'created_at' => $this->created_at->toIso8601String(),
        ];
    }
}

// app/Http/Resources/OrderResource.php
class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'external_id' => $this->external_id,
            'customer_email' => $this->customer_email,
            'total' => [
                'cents' => $this->total_cents,
                'formatted' => '$' . number_format($this->total_cents / 100, 2),
            ],
            'status' => $this->status,
            'items' => OrderItemResource::collection($this->whenLoaded('items')),
            'ordered_at' => $this->ordered_at->toIso8601String(),
        ];
    }
}

(2) Filtragem e Paginação

PHP
// app/Http/Controllers/OrderController.php
class OrderController extends Controller
{
    public function index(Request $request)
    {
        $query = Order::with(['items.product'])
            ->where('shop_id', $request->route('shop')->id);

        // Filtros
        if ($status = $request->query('status')) {
            $query->where('status', $status);
        }

        if ($dateFrom = $request->query('date_from')) {
            $query->where('ordered_at', '>=', $dateFrom);
        }

        if ($dateTo = $request->query('date_to')) {
            $query->where('ordered_at', '<=', $dateTo);
        }

        if ($minTotal = $request->query('min_total')) {
            $query->where('total_cents', '>=', $minTotal * 100);
        }

        if ($search = $request->query('search')) {
            $query->where('external_id', 'like', "%{$search}%")
                ->orWhere('customer_email', 'like', "%{$search}%");
        }

        // Ordenação
        $sortField = $request->query('sort_by', 'ordered_at');
        $sortDir = $request->query('sort_dir', 'desc');
        $query->orderBy($sortField, $sortDir);

        // Paginação
        $orders = $query->paginate($request->query('per_page', 25));

        return OrderResource::collection($orders);
    }
}

(1) ▶ Exemplo: Rotas API e Controllers Completos do ShopMetrics

PHP
// routes/api.php
Route::prefix('v1')->group(function () {
    Route::post('auth/login', [AuthController::class, 'login']);
    Route::post('auth/register', [AuthController::class, 'register']);

    Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
        Route::post('auth/logout', [AuthController::class, 'logout']);
        Route::get('auth/me', [AuthController::class, 'me']);

        Route::apiResource('shops', ShopController::class);
        Route::prefix('shops/{shop}')->group(function () {
            Route::apiResource('orders', OrderController::class)->only(['index', 'show']);
            Route::apiResource('products', ProductController::class);
        });

        Route::prefix('dashboard')->group(function () {
            Route::get('overview', [DashboardController::class, 'overview']);
            Route::get('revenue', [DashboardController::class, 'revenue']);
            Route::get('top-products', [DashboardController::class, 'topProducts']);
        });

        Route::apiResource('reports', ReportController::class)->only(['index', 'store', 'show']);
        Route::get('reports/{report}/download', [ReportController::class, 'download']);

        Route::apiResource('alerts', AlertController::class);

        Route::get('plans', [PlanController::class, 'index']);
        Route::post('subscriptions', [SubscriptionController::class, 'store']);
        Route::get('subscription', [SubscriptionController::class, 'show']);
        Route::delete('subscription', [SubscriptionController::class, 'cancel']);

        Route::middleware('role:tenant_owner')->prefix('team')->group(function () {
            Route::apiResource('members', TeamController::class)->only(['index', 'destroy']);
            Route::post('invite', [TeamController::class, 'invite']);
        });
    });

    Route::post('webhooks/stripe', [WebhookController::class, 'handleStripe']);
});

Saída:

TEXT
// Execução bem-sucedida

8. Exemplo Compreensivo: Integração Completa de Funcionalidades do ShopMetrics

PHP
// ============================================
// Compreensivo: Fluxo de requisição end-to-end
// Do login → dashboard → relatório → notificação
// ============================================

// Passo 1: Alice faz login
// POST /api/v1/auth/login {email, password}
class AuthController extends Controller
{
    public function login(Request $request)
    {
        $credentials = $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        if (!Auth::attempt($credentials)) {
            return response()->json(['message' => 'Credenciais inválidas'], 401);
        }

        $user = Auth::user();
        $token = $user->createToken('auth-token')->plainTextToken;

        return response()->json([
            'user' => new UserResource($user),
            'token' => $token,
            'tenant' => new TenantResource($user->tenant),
        ]);
    }
}

// Passo 2: Alice visualiza dashboard (middleware de tenant auto-configura contexto)
// GET /api/v1/dashboard/overview
class DashboardController extends Controller
{
    public function overview(Request $request)
    {
        $tenant = $request->attributes->get('tenant');
        return response()->json(
            app(DashboardService::class)->getOverview($tenant)
        );
    }
}

// Passo 3: Alice gera um relatório mensal
// POST /api/v1/reports {type: monthly, format: xlsx, date_from, date_to}
class ReportController extends Controller
{
    public function store(Request $request)
    {
        $validated = $request->validate([
            'type' => 'required|in:monthly,product_comparison,shop_performance',
            'format' => 'required|in:csv,xlsx,json',
            'date_from' => 'nullable|date',
            'date_to' => 'nullable|date|after:date_from',
        ]);

        $tenant = $request->attributes->get('tenant');

        // Verificar limites do plano
        $monthlyReports = Report::where('tenant_id', $tenant->id)
            ->whereMonth('created_at', now()->month)
            ->count();
        $limit = $tenant->subscription->plan->limits['reports_per_month'] ?? 10;

        if ($monthlyReports >= $limit && $limit !== -1) {
            return response()->json([
                'message' => 'Limite mensal de relatórios atingido. Atualize seu plano.',
            ], 402);
        }

        GenerateReportJob::dispatch(
            $tenant,
            $validated['type'],
            $validated['format'],
            [...$validated, 'user_id' => $request->user()->id]
        );

        return response()->json([
            'message' => 'Geração de relatório iniciada',
            'estimated_time' => '2-5 minutos',
        ], 202);
    }
}

// Passo 4: Worker processa o job, faz upload para S3, envia email
// (Ver GenerateReportJob na Seção 6)

// Passo 5: Alice faz download do relatório
// GET /api/v1/reports/{report}/download
class ReportController extends Controller
{
    public function download(Report $report, Request $request)
    {
        $tenant = $request->attributes->get('tenant');
        if ($report->tenant_id !== $tenant->id) {
            abort(403);
        }

        if ($report->status !== 'completed') {
            return response()->json(['message' => 'Relatório não está pronto', 'status' => $report->status], 202);
        }

        // Gerar URL S3 temporária (5 minutos)
        $url = Storage::disk('s3')->temporaryUrl(
            $report->storage_path,
            now()->addMinutes(5)
        );

        return redirect($url);
    }
}

❓ Perguntas Frequentes

P O Global Scope pode ser bypassado?
R Sim, pode ser bypassado usando SQL bruto e withoutGlobalScope(). Medidas de mitigação: Verificar propriedade do tenant_id na camada API, implementar políticas de Row-Level Security (RLS) e registrar tentativas de bypass em logs de auditoria.
P Como o Stripe Webhook garante segurança?
R Verifique o header Stripe-Signature (HMAC-SHA256); nunca confie no payload bruto. O endpoint de webhook não requer middleware de auth, mas deve verificar a assinatura.
P O que devo fazer se um job de geração de relatório exceder o timeout?
R Defina o timeout do job para 600 (10 minutos), tries para 3 e backoff para 60. Após um timeout, o job falha e tenta novamente; após 3 tentativas, é marcado como "failed" e o usuário é notificado para tentar novamente. Grandes conjuntos de dados devem ser processados em shards primeiro.
P Como as chaves de cache devem ser projetadas em um ambiente multi-tenant?
R Prefixe todas as chaves de cache com o tenant_id: dashboard:{tenant_id}:overview. Ao verificar expiração, use o prefixo para escanear o cache (Redis SCAN); não use KEYS (que causará bloqueio).
P Quando devo usar whenLoaded para um recurso API?
R É exibido apenas quando o relacionamento já foi carregado com eager loading; caso contrário, resulta em uma consulta N+1. O cliente solicita o relacionamento via ?include=items.product, e o controller determina with() com base no parâmetro include.
P Como três desenvolvedores coordenam seu código durante o desenvolvimento?
R Mesclamos alterações na branch principal diariamente e usamos feature flags para controlar funcionalidades que ainda não estão completas. Cada módulo é testado independentemente (Feature Test), e após a mesclagem, executamos uma suíte completa de testes. O CI bloqueia merge requests que falham.

📖 Resumo


📝 Exercícios

  1. Exercício Básico (⭐): Implemente ShopResource e OrderResource, incluindo dados associados (whenLoaded), e escreva os testes funcionais correspondentes para verificar a estrutura JSON.

  2. Exercício Avançado (⭐⭐): Implemente completamente o fluxo de assinatura Stripe — criar uma sessão Checkout + processar webhooks (3 eventos: created/updated/deleted) + sincronizar status da assinatura + enviar notificações por email para falhas de pagamento.

  3. Desafio (⭐⭐⭐): Implemente um sistema completo de geração de relatórios — GenerateReportJob — que suporta formatos CSV e JSON, acompanhamento de progresso (armazenando porcentagens de progresso no Redis), URLs temporárias de download S3 (válidas por 5 minutos), verificação de limite de jobs agendados e notificações por email ao concluir. Escreva testes funcionais cobrindo todo o fluxo: Criar → Em Progresso → Concluído → Download.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%