تطوير المشروع — التنفيذ البرمجي لمنصة ShopMetrics SaaS
تحولت مخططات التصميم أخيراً إلى كود—لكن لا تتسرع. نفذها وحدة تلو الأخرى، واختبر كل وحدة عند الانتهاء منها.
1. ما ستتعلمه
- وسيط التعدد وتنفيذ عزل النطاق
- تكامل فوترة اشتراك Stripe: Plan/Subscription/معالجة Webhook
- لوحة معلومات تحليل البيانات: تجميعات Eloquent + تصور Chart.js
- إنشاء التقارير غير المتزامنة: Queue Job + تخزين S3 + إشعارات بريد إلكتروني
- التنفيذ الكامل لطبقة API: المصادقة، الموارد، التصفية، التقسيم، والتحكم في الإصدارات
2. قصة حقيقية عن التطوير التعاوني
(1) المشكلة: ثلاثة أشخاص يكتبون كلاً كوداً خاصاً به، مما يؤدي إلى تعارضات عند الدمج
وزع Bob المهام: تكتب Alice كود إدارة المستأجرين، ويكتب Charlie كود تحليل البيانات. بعد ثلاثة أيام، عند دمج الكود، لم تتوافق علاقات نموذج Tenant لدى Alice مع نموذج Order لدى Charlie، ومنطق الوسيط لديهما تعارض مع بعضه البعض، مما أدى إلى أخطاء 500 عبر الموقع بأكمله بعد الدمج. قال Bob: "نحتاج عملية تطوير تضمن أن كود الجميع يمكن أن يعمل بشكل مستقل."
(2) حلول التطوير النمطي
طوّر بالترتيب الأولوي التالي: المصادقة → المستأجر → المتجر → الطلبات → التحليلات → الفوترة. بعد إتمام كل وحدة، شغّل الاختبارات وادمج الكود لضمان أن الفرع الرئيسي قابل للتشغيل دائماً.
الأسبوع 1: المصادقة + المستأجر + الوسيط (الإطار الأساسي)
الأسبوع 2: المتجر + الطلب + المنتج (الأعمال الأساسية)
الأسبوع 3: لوحة المعلومات + التحليلات (تحليل البيانات)
الأسبوع 4: الاشتراك + Stripe (نظام الفوترة)
(3) العائد
بعد تبني التطوير النمطي، دمج المطورون الثلاثة كودهم مرة واحدة يومياً، مما خفض معدل التعارض من 40% إلى 5%، وظل الفرع الرئيسي قابلاً للتشغيل باستمرار.
3. وسيط التعدد والنطاق
(1) سير معالجة الطلب
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) تنفيذ الوسيط
// 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
// 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');
}
}
الناتج:
// تم التنفيذ بنجاح
4. تكامل فوترة اشتراك Stripe
(1) دورة حياة الاشتراك
stateDiagram-v2
[*] --> Trial: تسجيل جديد
Trial --> Active: تجريبي→مدفوع
Trial --> Cancelled: إلغاء أثناء التجربة
Active --> PastDue: فشل الدفع
PastDue --> Active: نجاح إعادة المحاولة
PastDue --> Cancelled: نفدت المحاولات
Active --> Cancelled: المستخدم يلغي
Cancelled --> Active: إعادة الاشتراك
(2) نماذج Plan و Subscription
// 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
// 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())
);
}
}
}
الناتج:
// تم التنفيذ بنجاح
5. لوحة معلومات تحليل البيانات
(1) تصميم API للوحة المعلومات
| نقطة النهاية | البيانات المعادة | TTL التخزين المؤقت |
|---|---|---|
GET /dashboard/overview |
إجمالي الإيرادات/عدد الطلبات/عدد العملاء/مقارنة سنوية | 15 دقيقة |
GET /dashboard/revenue |
بيانات مخطط اتجاه الإيرادات | 30 دقيقة |
GET /dashboard/top-products |
أفضل 10 منتجات | ساعة |
GET /dashboard/shop-comparison |
مقارنة المتاجر | ساعة |
(1) ▶ مثال: خدمة لوحة معلومات ShopMetrics
// 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);
}
}
الناتج:
// تم التنفيذ بنجاح
6. إنشاء التقارير غير المتزامنة
(1) عملية إنشاء التقرير
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
// 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;
}
}
الناتج:
// تم التنفيذ بنجاح
7. التنفيذ الكامل لطبقة API
(1) مخرجات مورد API
// 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) التصفية والتقسيم
// 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
// 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']);
});
الناتج:
// تم التنفيذ بنجاح
8. مثال شامل: تكامل ميزات ShopMetrics الكاملة
// ============================================
// شامل: سير الطلب من البداية للنهاية
// من تسجيل الدخول → لوحة المعلومات → التقرير → الإشعار
// ============================================
// الخطوة 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);
}
}
❓ أسئلة شائعة
withoutGlobalScope(). تدابير التخفيف: تحقق من ملكية tenant_id في طبقة API، نفذ سياسات Row-Level Security (RLS)، وسجل محاولات التجاوز في سجلات التدقيق.dashboard:{tenant_id}:overview. عند التحقق من الانتهاء، استخدم البادئة لمسح التخزين المؤقت (Redis SCAN)؛ لا تستخدم KEYS (الذي سيؤدي إلى حظر).whenLoaded لمورد API؟?include=items.product، ويحدد المتحكم with() بناءً على معلمة include.📖 ملخص
- استخدم Global Scope + وسيط لحقن
tenant_idتلقائياً لعزل التعدد - Stripe Checkout يعالج عملية الدفع، وWebhooks تزامن حالة الاشتراك
- طبقة Service للوحة المعلومات تغلف منطق التجميع، والتخزين المؤقت Redis يقلل الحمل على قاعدة البيانات
- تُنتج التقارير بشكل غير متزامن باستخدام Queue Job، تُخزن في S3، ويُشعَر المستخدمون عبر البريد الإلكتروني
- تنسيق مخرجات موحد لموارد API؛ التصفية والتقسيم والترتيب مغلفة في المتحكم
- التطوير النمطي: المصادقة → المستأجر → المتجر → الطلب → التحليلات → الفوترة؛ تقدم بهذا الترتيب
📝 تمارين
-
تمرين أساسي (⭐): نفذ ShopResource وOrderResource، مع البيانات المرتبطة (whenLoaded)، واكتب اختبارات الميزات المقابلة للتحقق من بنية JSON.
-
تمرين متقدم (⭐⭐): نفذ سير عمل اشتراك Stripe بالكامل—إنشاء جلسة Checkout + معالجة webhooks (3 أحداث: created/updated/deleted) + مزامنة حالة الاشتراك + إرسال إشعارات بريد إلكتروني عند فشل الدفع.
-
تحدي (⭐⭐⭐): نفذ نظام إنشاء تقارير كامل—GenerateReportJob—يدعم صيغتي CSV وJSON، تتبع التقدم (تخزين نسب التقدم في Redis)، روابط تنزيل S3 مؤقتة (صالحة لـ 5 دقائق)، فحص حدود المهام المجدولة، وإشعارات بريد إلكتروني عند الانتهاء. اكتب اختبارات ميزات تغطي سير العمل بالكامل: إنشاء → قيد التنفيذ → مكتمل → تنزيل.



