Laravel: 项目开发 — ShopMetrics SaaS平台编码实现

最后更新:2026-08-26

设计图终于变成代码——但别急,按模块逐个实现,每完成一个模块就测试一个模块。

1. 你将学到


2. 一个团队协作开发的真实故事

(1) 痛点:三个人各写各的,代码合并就冲突

Bob 分配任务:Alice 写租户管理、Charlie 写数据分析。三天后合并代码——Alice 的 Tenant 模型和 Charlie 的 Order 模型关联关系对不上,中间件逻辑互相冲突,合并后全站 500 错误。Bob 说:"我们需要一个开发顺序,让每个人的代码都能独立运行。"

(2) 模块化开发的解法

按依赖顺序开发:认证→租户→店铺→订单→分析→计费,每完成一个模块就跑测试、合并代码,保证主分支随时可运行。

TEXT 📖 仅展示
Week 1: Auth + Tenant + Middleware (基础骨架)
Week 2: Shop + Order + Product (核心业务)
Week 3: Dashboard + Analytics (数据分析)
Week 4: Subscription + Stripe (计费系统)

(3) 收益

模块化开发后,三人代码每天合并一次,冲突率从 40% 降到 5%,主分支始终可运行。


3. 多租户中间件与 Scope

(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: Set current tenant
    Middleware->>Controller: Request with tenant context
    Controller->>DB: SELECT * FROM shops WHERE tenant_id=1
    DB-->>Controller: Alice's shops only
    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, 'Tenant account is inactive or suspended.');
        }

        // Check plan limits
        if ($tenant->isOverLimit()) {
            abort(402, 'Plan limit exceeded. Please upgrade your subscription.');
        }

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

        return $next($request);
    }
}

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

▶ 示例:BelongsToTenant Trait 完整实现

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(
                        'Cannot modify data belonging to another 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');
    }
}

输出:

TEXT 📖 仅展示
// 执行成功

4. Stripe 订阅计费集成

(1) 订阅生命周期

100%
stateDiagram-v2
    [*] --> Trial: New Signup
    Trial --> Active: Trial→Paid
    Trial --> Cancelled: Cancel during trial
    Active --> PastDue: Payment failed
    PastDue --> Active: Payment retry success
    PastDue --> Cancelled: Retry exhausted
    Active --> Cancelled: User cancels
    Cancelled --> Active: Resubscribe

(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() ? 'Free' : '$' . number_format($this->price_cents / 100, 2) . '/mo';
    }
}

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

▶ 示例: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' => 'Subscribed to free plan']);
        }

        // Create Stripe Checkout session
        $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' => 'Subscription will cancel at period end']);
    }
}

// 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 TOP 10 商品 1 小时
GET /dashboard/shop-comparison 店铺对比 1 小时

▶ 示例:ShopMetrics 仪表盘 Service

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 Server
    participant Queue as Redis Queue
    participant Worker as Queue Worker
    participant S3 as S3 Storage
    participant Mail as Email Service

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

    Queue->>Worker: Pick up job
    Worker->>Worker: Query & aggregate data
    Worker->>S3: Upload report file
    Worker->>Worker: Update report status=completed
    Worker->>Mail: Send notification email
    Mail-->>User: Report ready email

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

▶ 示例:ShopMetrics 报表生成 Job

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("Unknown report type: {$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("Unsupported format: {$format}"),
        };
    }

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

        fputcsv($file, ['Order ID', 'Date', 'Customer', '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;
    }
}

输出:

TEXT 📖 仅展示
// 执行成功

7. API 层完整实现

(1) API Resource 输出

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

        // Filters
        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}%");
        }

        // Sort
        $sortField = $request->query('sort_by', 'ordered_at');
        $sortDir = $request->query('sort_dir', 'desc');
        $query->orderBy($sortField, $sortDir);

        // Paginate
        $orders = $query->paginate($request->query('per_page', 25));

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

▶ 示例:ShopMetrics 完整 API 路由与控制器

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
// ============================================
// Comprehensive: End-to-end request flow
// From login → dashboard → report → notification
// ============================================

// Step 1: Alice logs in
// 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' => 'Invalid credentials'], 401);
        }

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

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

// Step 2: Alice views dashboard (tenant middleware auto-sets context)
// 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)
        );
    }
}

// Step 3: Alice generates a monthly report
// 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');

        // Check plan limits
        $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' => 'Monthly report limit reached. Upgrade your plan.',
            ], 402);
        }

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

        return response()->json([
            'message' => 'Report generation started',
            'estimated_time' => '2-5 minutes',
        ], 202);
    }
}

// Step 4: Worker processes the job, uploads to S3, sends email
// (See GenerateReportJob in Section 6)

// Step 5: Alice downloads the report
// 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' => 'Report not ready', 'status' => $report->status], 202);
        }

        // Generate temporary S3 URL (5 minutes)
        $url = Storage::disk('s3')->temporaryUrl(
            $report->storage_path,
            now()->addMinutes(5)
        );

        return redirect($url);
    }
}

❓ 常见问题

Q Global Scope 会不会被绕过?
A 会,raw SQL 和 withoutGlobalScope() 可以绕过。防护措施:API 层校验 tenant_id 归属、数据库行级安全策略(RLS)、审计日志记录绕过操作。
Q Stripe Webhook 怎么保证安全?
A 验证 Stripe-Signature 头(HMAC-SHA256),绝不信任裸 payload。Webhook 端点不需要 auth 中间件,但必须验证签名。
Q 报表生成超时怎么办?
A Job 设 timeout=600(10 分钟),tries=3 + backoff=60。超时后 Job 失败重试,3 次后标记为 failed,通知用户重试。大数据集先分片处理。
Q 多租户下缓存 key 怎么设计?
A 所有缓存 key 加 tenant_id 前缀:dashboard:{tenant_id}:overview。失效时用前缀扫描(Redis SCAN),不要用 KEYS(会阻塞)。
Q API Resource 什么时候用 whenLoaded?
A 只在关联已 eager load 时才输出,否则 N+1。客户端通过 ?include=items.product 请求关联,控制器按 include 参数决定 with()
Q 开发时三个人的代码怎么协调?
A 每日合并到 main 分支,Feature Flag 控制未完成功能。每个模块独立测试(Feature Test),合并后跑全量测试。CI 阻断失败的合并请求。

📖 小节


📝 作业

  1. 基础题(⭐):实现 ShopResource 和 OrderResource,包含关联数据(whenLoaded),写出对应的 Feature Test 验证 JSON 结构。

  2. 进阶题(⭐⭐):完整实现 Stripe 订阅流程——Checkout Session 创建 + Webhook 处理(3 个事件:created/updated/deleted)+ 订阅状态同步 + 付费失败邮件通知。

  3. 挑战题(⭐⭐⭐):实现完整的报表生成系统——GenerateReportJob 支持 CSV/JSON 两种格式、进度跟踪(Redis 存进度百分比)、S3 临时下载 URL(5 分钟有效期)、计划限制检查、生成完成邮件通知。编写 Feature Test 覆盖:创建→处理中→完成→下载 全流程。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏