プロジェクト開発—ShopMetrics SaaSプラットフォームのコーディング実装
設計図がついにコードになりました—でも焦らないで。1モジュールずつ実装し, 各モジュールを完成させるたびにテストしましょう。
1. 学ぶこと
- マルチテナントミドルウェアとスコープ分離の実装
- Stripeサブスクリプション課金統合:Plan/Subscription/Webhook処理
- データ分析ダッシュボード:Eloquent集計 + Chart.js可視化
- 非同期レポート生成:Queue Job + S3ストレージ + メール通知
- APIレイヤーの完全実装:認証, リソース, フィルタリング, ページネーション, バージョニング
2. 協同開発の実話
(1) 痛み:3人がそれぞれコードを書いており, マージ時に競合が発生する
Bobはタスクを割り当てました:Aliceはテナント管理コードを書き, Charlieはデータ分析コードを書くことにしました。3日後, コードをマージすると, AliceのTenantモデルとCharlieのOrderモデルの関係が整合せず, ミドルウェアロジックも互いに競合し, マージ後はサイト全体で500エラーが発生しました。Bobは言いました。「全員のコードが独立して実行できることを保証する開発プロセスが必要だ。」
(2) モジュール開発による解決策
次の優先順位で開発します:認証 → テナント → 店舗 → 注文 → 分析 → 課金。各モジュールの完了後にテストを実行し, コードをマージして, メインブランチが常に実行可能な状態を保ちます。
週1:Auth + Tenant + Middleware (基本フレームワーク)
週2:Shop + Order + Product (コアビジネス)
週3:Dashboard + Analytics (データ分析)
週4:Subscription + Stripe (課金システム)
(3) 成果
モジュール開発を採用した後, 3人の開発者は毎日1回コードをマージし, 競合率は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, 'Tenant account is inactive or suspended.');
}
// プラン制限の確認
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 - ミドルウェアの登録
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(
'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');
}
}
出力:
// 実行成功
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() ? '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();
}
}
(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' => 'Subscribed to free plan']);
}
// 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' => '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())
);
}
}
}
出力:
// 実行成功
5. データ分析ダッシュボード
(1) ダッシュボードAPI設計
| エンドポイント | 返却データ | キャッシュTTL |
|---|---|---|
GET /dashboard/overview |
総売上/注文数/顧客数/前年比 | 15分 |
GET /dashboard/revenue |
売上推移グラフデータ | 30分 |
GET /dashboard/top-products |
トップ10商品 | 1時間 |
GET /dashboard/shop-comparison |
店舗比較 | 1時間 |
(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("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;
}
}
出力:
// 実行成功
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) ▶ サンプル:ShopMetrics完全APIルートとコントローラー
// 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' => '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),
]);
}
}
// ステップ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' => '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);
}
}
// ステップ4:ワーカーがジョブを処理し, S3にアップロードし, メールを送信
// (セクション6のGenerateReportJobを参照)
// ステップ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' => 'Report not ready', 'status' => $report->status], 202);
}
// 一時的なS3 URLを生成 (5分間有効)
$url = Storage::disk('s3')->temporaryUrl(
$report->storage_path,
now()->addMinutes(5)
);
return redirect($url);
}
}
❓ よくある質問
withoutGlobalScope()でバイパスできます。対策:API層でtenant_idの所有権を検証, Row-Level Security (RLS)ポリシーを実装, バイパス試行を監査ログに記録します。dashboard:{tenant_id}:overview。期限切れ確認時はプレフィックスでキャッシュをスキャン (Redis SCAN)。KEYSは使用しないでください (ブロックが発生します)。whenLoadedはいつ使うべきですか??include=items.productで関連付けをリクエストし, コントローラーはincludeパラメータに基づいてwith()を決定します。📖 まとめ
- Global Scope + ミドルウェアでマルチテナント分離の
tenant_idを自動注入します - Stripe Checkoutが支払いプロセスを処理し, Webhookがサブスクリプションステータスを同期します
- ダッシュボードのService層が集計ロジックをカプセル化し, Redisキャッシュがデータベース負荷を軽減します
- レポートはQueue Jobで非同期生成され, S3に保存され, メールでユーザーに通知します
- APIリソースの標準化された出力形式。フィルタリング, ページネーション, ソートはコントローラーにカプセル化します
- モジュール開発:認証 → テナント → 店舗 → 注文 → 分析 → 課金の順に進めます
📝 練習問題
-
基本問題 (⭐):ShopResourceとOrderResourceを実装し, 関連データ (whenLoaded)を含め, 対応する機能テストを記述してJSON構造を検証してください。
-
応用問題 (⭐⭐):Stripeサブスクリプションワークフローを完全に実装してください—Checkoutセッションの作成 + Webhook処理 (3イベント:created/updated/deleted) + サブスクリプションステータスの同期 + 支払い失敗時のメール通知。
-
チャレンジ (⭐⭐⭐):完全なレポート生成システムを実装してください—GenerateReportJob—CSVとJSONの両形式をサポート, 進捗追跡 (Redisに進捗率を保存), 一時S3ダウンロードURL (5分間有効), スケジュールジョブの制限チェック, 完了時のメール通知。全ワークフローをカバーする機能テストを記述してください:作成 → 処理中 → 完了 → ダウンロード。



