404 Not Found

404 Not Found


nginx

تطوير المشروع — التنفيذ البرمجي لمنصة ShopMetrics SaaS

تحولت مخططات التصميم أخيراً إلى كود—لكن لا تتسرع. نفذها وحدة تلو الأخرى، واختبر كل وحدة عند الانتهاء منها.

1. ما ستتعلمه


2. قصة حقيقية عن التطوير التعاوني

(1) المشكلة: ثلاثة أشخاص يكتبون كلاً كوداً خاصاً به، مما يؤدي إلى تعارضات عند الدمج

وزع Bob المهام: تكتب Alice كود إدارة المستأجرين، ويكتب Charlie كود تحليل البيانات. بعد ثلاثة أيام، عند دمج الكود، لم تتوافق علاقات نموذج Tenant لدى Alice مع نموذج Order لدى Charlie، ومنطق الوسيط لديهما تعارض مع بعضه البعض، مما أدى إلى أخطاء 500 عبر الموقع بأكمله بعد الدمج. قال Bob: "نحتاج عملية تطوير تضمن أن كود الجميع يمكن أن يعمل بشكل مستقل."

(2) حلول التطوير النمطي

طوّر بالترتيب الأولوي التالي: المصادقة → المستأجر → المتجر → الطلبات → التحليلات → الفوترة. بعد إتمام كل وحدة، شغّل الاختبارات وادمج الكود لضمان أن الفرع الرئيسي قابل للتشغيل دائماً.

TEXT
الأسبوع 1: المصادقة + المستأجر + الوسيط (الإطار الأساسي)
الأسبوع 2: المتجر + الطلب + المنتج (الأعمال الأساسية)
الأسبوع 3: لوحة المعلومات + التحليلات (تحليل البيانات)
الأسبوع 4: الاشتراك + Stripe (نظام الفوترة)

(3) العائد

بعد تبني التطوير النمطي، دمج المطورون الثلاثة كودهم مرة واحدة يومياً، مما خفض معدل التعارض من 40% إلى 5%، وظل الفرع الرئيسي قابلاً للتشغيل باستمرار.


3. وسيط التعدد والنطاق

(1) سير معالجة الطلب

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: تعيين المستأجر الحالي
    Middleware->>Controller: طلب بسياق المستأجر
    Controller->>DB: SELECT * FROM shops WHERE tenant_id=1
    DB-->>Controller: متاجر Alice فقط
    Controller-->>Client: 200 OK {data: [...]}

(2) تنفيذ الوسيط

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, 'حساب المستأجر غير نشط أو معلق.');
        }

        // التحقق من حدود الخطة
        if ($tenant->isOverLimit()) {
            abort(402, 'تم تجاوز حد الخطة. يرجى ترقية اشتراكك.');
        }

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

        return $next($request);
    }
}

// app/Http/Kernel.php - تسجيل الوسيط
protected $middlewareAliases = [
    'tenant' => SetTenantContext::class,
];

(1) ▶ مثال: التنفيذ الكامل لسمة 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(
                        'لا يمكن تعديل بيانات تنتمي إلى مستأجر آخر.'
                    );
                }
            }
        });
    }

    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');
    }
}

الناتج:

TEXT
// تم التنفيذ بنجاح

4. تكامل فوترة اشتراك Stripe

(1) دورة حياة الاشتراك

100%
stateDiagram-v2
    [*] --> Trial: تسجيل جديد
    Trial --> Active: تجريبي→مدفوع
    Trial --> Cancelled: إلغاء أثناء التجربة
    Active --> PastDue: فشل الدفع
    PastDue --> Active: نجاح إعادة المحاولة
    PastDue --> Cancelled: نفدت المحاولات
    Active --> Cancelled: المستخدم يلغي
    Cancelled --> Active: إعادة الاشتراك

(2) نماذج Plan و 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() ? 'مجاني' : '$' . number_format($this->price_cents / 100, 2) . '/شهر';
    }
}

// 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) ▶ مثال: Stripe Checkout ومعالجة 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' => 'تم الاشتراك في الخطة المجانية']);
        }

        // إنشاء جلسة 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' => 'سيتم إلغاء الاشتراك في نهاية الفترة']);
    }
}

// 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())
            );
        }
    }
}

الناتج:

TEXT
// تم التنفيذ بنجاح

5. لوحة معلومات تحليل البيانات

(1) تصميم API للوحة المعلومات

نقطة النهاية البيانات المعادة TTL التخزين المؤقت
GET /dashboard/overview إجمالي الإيرادات/عدد الطلبات/عدد العملاء/مقارنة سنوية 15 دقيقة
GET /dashboard/revenue بيانات مخطط اتجاه الإيرادات 30 دقيقة
GET /dashboard/top-products أفضل 10 منتجات ساعة
GET /dashboard/shop-comparison مقارنة المتاجر ساعة

(1) ▶ مثال: خدمة لوحة معلومات 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);
    }
}

الناتج:

TEXT
// تم التنفيذ بنجاح

6. إنشاء التقارير غير المتزامنة

(1) عملية إنشاء التقرير

100%
sequenceDiagram
    participant User as Alice
    participant API as خادم API
    participant Queue as طابور Redis
    participant Worker as عامل الطابور
    participant S3 as تخزين S3
    participant Mail as خدمة البريد

    User->>API: POST /reports {type: monthly, format: xlsx}
    API->>Queue: إرسال GenerateReportJob
    API-->>User: 202 {report_id, status: pending}

    Queue->>Worker: التقاط المهمة
    Worker->>Worker: استعلام وتجميع البيانات
    Worker->>S3: رفع ملف التقرير
    Worker->>Worker: تحديث حالة التقرير=completed
    Worker->>Mail: إرسال بريد إشعار
    Mail-->>User: بريد التقرير جاهز

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

(1) ▶ مثال: مهمة إنشاء تقرير 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("نوع تقرير غير معروف: {$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("صيغة غير مدعومة: {$format}"),
        };
    }

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

        fputcsv($file, ['معرف الطلب', 'التاريخ', 'العميل', 'الإجمالي', 'الحالة']);

        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;
    }
}

الناتج:

TEXT
// تم التنفيذ بنجاح

7. التنفيذ الكامل لطبقة API

(1) مخرجات مورد 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) التصفية والتقسيم

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);

        // الفلاتر
        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}%");
        }

        // الترتيب
        $sortField = $request->query('sort_by', 'ordered_at');
        $sortDir = $request->query('sort_dir', 'desc');
        $query->orderBy($sortField, $sortDir);

        // التقسيم
        $orders = $query->paginate($request->query('per_page', 25));

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

(1) ▶ مثال: مسارات ومتحكمات API الكاملة لـ 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']);
});

الناتج:

TEXT
// تم التنفيذ بنجاح

8. مثال شامل: تكامل ميزات ShopMetrics الكاملة

PHP
// ============================================
// شامل: سير الطلب من البداية للنهاية
// من تسجيل الدخول → لوحة المعلومات → التقرير → الإشعار
// ============================================

// الخطوة 1: Alice تسجل الدخول
// 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' => 'بيانات اعتماد غير صالحة'], 401);
        }

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

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

// الخطوة 2: Alice تعرض لوحة المعلومات (الوسيط يضبط سياق المستأجر تلقائياً)
// 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)
        );
    }
}

// الخطوة 3: Alice تنشئ تقريراً شهرياً
// 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');

        // التحقق من حدود الخطة
        $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' => 'تم بلوغ الحد الشهري للتقارير. قم بترقية خطتك.',
            ], 402);
        }

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

        return response()->json([
            'message' => 'بدأ إنشاء التقرير',
            'estimated_time' => '2-5 دقائق',
        ], 202);
    }
}

// الخطوة 4: العامل يعالج المهمة، يرفع إلى S3، يرسل بريداً إلكترونياً
// (انظر GenerateReportJob في القسم 6)

// الخطوة 5: Alice تنزل التقرير
// 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' => 'التقرير غير جاهز', 'status' => $report->status], 202);
        }

        // إنشاء رابط S3 مؤقت (5 دقائق)
        $url = Storage::disk('s3')->temporaryUrl(
            $report->storage_path,
            now()->addMinutes(5)
        );

        return redirect($url);
    }
}

❓ أسئلة شائعة

س هل يمكن تجاوز Global Scope؟
ج نعم، يمكن تجاوزه باستخدام SQL الخام وwithoutGlobalScope(). تدابير التخفيف: تحقق من ملكية tenant_id في طبقة API، نفذ سياسات Row-Level Security (RLS)، وسجل محاولات التجاوز في سجلات التدقيق.
س كيف يضمن Stripe Webhook الأمان؟
ج تحقق من رأس Stripe-Signature (HMAC-SHA256)؛ لا تثمّن أبداً في الحمولة الخام. نقطة نهاية webhook لا تتطلب وسيط مصادقة، لكن يجب أن تتحقق من التوقيع.
س ماذا أفعل إذا انتهت مهلة مهمة إنشاء التقرير؟
ج اضبط مهلة المهمة على 600 (10 دقائق)، tries على 3، وbackoff على 60. بعد انتهاء المهلة، تفشل المهمة وتعيد المحاولة؛ بعد 3 محاولات، تُعلَّم كـ "فاشلة"، ويُشعَر المستخدم بإعادة المحاولة. يجب معالجة مجموعات البيانات الكبيرة في shards أولاً.
س كيف يجب تصميم مفاتيح التخزين المؤقت في بيئة متعددة المستأجرين؟
ج أضف بادئة tenant_id لجميع مفاتيح التخزين المؤقت: dashboard:{tenant_id}:overview. عند التحقق من الانتهاء، استخدم البادئة لمسح التخزين المؤقت (Redis SCAN)؛ لا تستخدم KEYS (الذي سيؤدي إلى حظر).
س متى يجب استخدام whenLoaded لمورد API؟
ج يُخرج فقط عندما يكون الارتباط قد حُمِّل مسبقاً؛ وإلا سيؤدي إلى استعلام N+1. يطلب العميل الارتباط عبر ?include=items.product، ويحدد المتحكم with() بناءً على معلمة include.
س كيف ينسق المطورون الثلاثة كودهم أثناء التطوير؟
ج ندمج التغييرات في الفرع الرئيسي يومياً ونستخدم أعلام الميزات للتحكم في الميزات غير المكتملة بعد. تُختبر كل وحدة بشكل مستقل (Feature Test)، وبعد الدمج نشغل مجموعة كاملة من الاختبارات. CI يمنع طلبات الدمج الفاشلة.

📖 ملخص


📝 تمارين

  1. تمرين أساسي (⭐): نفذ ShopResource وOrderResource، مع البيانات المرتبطة (whenLoaded)، واكتب اختبارات الميزات المقابلة للتحقق من بنية JSON.

  2. تمرين متقدم (⭐⭐): نفذ سير عمل اشتراك Stripe بالكامل—إنشاء جلسة Checkout + معالجة webhooks (3 أحداث: created/updated/deleted) + مزامنة حالة الاشتراك + إرسال إشعارات بريد إلكتروني عند فشل الدفع.

  3. تحدي (⭐⭐⭐): نفذ نظام إنشاء تقارير كامل—GenerateReportJob—يدعم صيغتي CSV وJSON، تتبع التقدم (تخزين نسب التقدم في Redis)، روابط تنزيل S3 مؤقتة (صالحة لـ 5 دقائق)، فحص حدود المهام المجدولة، وإشعارات بريد إلكتروني عند الانتهاء. اكتب اختبارات ميزات تغطي سير العمل بالكامل: إنشاء → قيد التنفيذ → مكتمل → تنزيل.

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%