Laravel: 项目开发 — ShopMetrics SaaS平台编码实现
最后更新:2026-08-26
设计图终于变成代码——但别急,按模块逐个实现,每完成一个模块就测试一个模块。
1. 你将学到
- 多租户中间件与 Scope 隔离实现
- Stripe 订阅计费集成:Plan/Subscription/Webhook 处理
- 数据分析仪表盘:Eloquent 聚合 + Chart.js 可视化
- 异步报表生成:Queue Job + S3 存储 + 邮件通知
- API 层完整实现:认证/资源/过滤/分页/版本控制
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) 请求处理流程
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) 订阅生命周期
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) 报表生成流程
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 阻断失败的合并请求。
📖 小节
- 多租户隔离用 Global Scope + Middleware 自动注入 tenant_id
- Stripe Checkout 处理支付流程,Webhook 同步订阅状态
- 仪表盘 Service 层封装聚合逻辑,Redis 缓存减轻数据库压力
- 报表生成用 Queue Job 异步处理,S3 存储结果,邮件通知用户
- API Resource 统一输出格式,过滤/分页/排序封装在控制器
- 模块化开发:认证→租户→店铺→订单→分析→计费,按序推进
📝 作业
-
基础题(⭐):实现 ShopResource 和 OrderResource,包含关联数据(whenLoaded),写出对应的 Feature Test 验证 JSON 结构。
-
进阶题(⭐⭐):完整实现 Stripe 订阅流程——Checkout Session 创建 + Webhook 处理(3 个事件:created/updated/deleted)+ 订阅状态同步 + 付费失败邮件通知。
-
挑战题(⭐⭐⭐):实现完整的报表生成系统——GenerateReportJob 支持 CSV/JSON 两种格式、进度跟踪(Redis 存进度百分比)、S3 临时下载 URL(5 分钟有效期)、计划限制检查、生成完成邮件通知。编写 Feature Test 覆盖:创建→处理中→完成→下载 全流程。