diff --git a/app/Http/Controllers/Api/V1/SubscriptionController.php b/app/Http/Controllers/Api/V1/SubscriptionController.php index 595c777c..371c4d26 100644 --- a/app/Http/Controllers/Api/V1/SubscriptionController.php +++ b/app/Http/Controllers/Api/V1/SubscriptionController.php @@ -8,13 +8,17 @@ use App\Http\Requests\V1\Subscription\SubscriptionCancelRequest; use App\Http\Requests\V1\Subscription\SubscriptionIndexRequest; use App\Http\Requests\V1\Subscription\SubscriptionStoreRequest; +use App\Services\ExportService; use App\Services\SubscriptionService; use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class SubscriptionController extends Controller { public function __construct( - private readonly SubscriptionService $subscriptionService + private readonly SubscriptionService $subscriptionService, + private readonly ExportService $exportService ) {} /** @@ -117,12 +121,12 @@ public function usage(): JsonResponse } /** - * 내보내기 요청 + * 내보내기 요청 (동기 처리) */ public function export(ExportStoreRequest $request): JsonResponse { return ApiResponse::handle( - fn () => $this->subscriptionService->createExport($request->validated()), + fn () => $this->subscriptionService->createExport($request->validated(), $this->exportService), __('message.export.requested') ); } @@ -137,4 +141,24 @@ public function exportStatus(int $id): JsonResponse __('message.fetched') ); } + + /** + * 내보내기 파일 다운로드 + */ + public function exportDownload(int $id): BinaryFileResponse + { + $export = $this->subscriptionService->getExport($id); + + if (! $export->is_downloadable) { + throw new NotFoundHttpException(__('error.export.not_found')); + } + + $filePath = storage_path('app/'.$export->file_path); + + if (! file_exists($filePath)) { + throw new NotFoundHttpException(__('error.export.not_found')); + } + + return response()->download($filePath, $export->file_name); + } } diff --git a/app/Services/SubscriptionService.php b/app/Services/SubscriptionService.php index c4741c1b..21bb73a2 100644 --- a/app/Services/SubscriptionService.php +++ b/app/Services/SubscriptionService.php @@ -10,6 +10,7 @@ use App\Models\Tenants\Tenant; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -408,13 +409,19 @@ public function usage(): array // ========================================================================= /** - * 내보내기 요청 생성 + * 내보내기 요청 생성 (동기 처리) */ - public function createExport(array $data): DataExport + public function createExport(array $data, ExportService $exportService): DataExport { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); + // 5분 이상 stuck된 pending/processing 내보내기 자동 만료 처리 + DataExport::where('tenant_id', $tenantId) + ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) + ->where('created_at', '<', now()->subMinutes(5)) + ->each(fn (DataExport $e) => $e->markAsFailed('시간 초과로 자동 만료')); + // 진행 중인 내보내기가 있는지 확인 $pendingExport = DataExport::where('tenant_id', $tenantId) ->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING]) @@ -432,10 +439,67 @@ public function createExport(array $data): DataExport 'created_by' => $userId, ]); - // TODO: 비동기 Job 디스패치 - // dispatch(new ProcessDataExport($export)); + // 동기 처리: 즉시 파일 생성 + try { + $export->markAsProcessing(); - return $export; + $exportData = $this->getSubscriptionExportData($data['export_type'] ?? DataExport::TYPE_ALL); + $filename = 'exports/subscriptions_'.$tenantId.'_'.date('Ymd_His').'.xlsx'; + + $exportService->store( + $exportData['data'], + $exportData['headings'], + $filename, + '구독관리' + ); + + $filePath = storage_path('app/'.$filename); + $fileSize = file_exists($filePath) ? filesize($filePath) : 0; + + $export->markAsCompleted( + $filename, + basename($filename), + $fileSize + ); + } catch (\Throwable $e) { + Log::error('구독 내보내기 실패', ['error' => $e->getMessage()]); + $export->markAsFailed($e->getMessage()); + } + + return $export->fresh(); + } + + /** + * 구독 내보내기 데이터 준비 + */ + private function getSubscriptionExportData(string $exportType): array + { + $tenantId = $this->tenantId(); + + $query = Subscription::query() + ->where('tenant_id', $tenantId) + ->with(['plan:id,name,code,price,billing_cycle']); + + $subscriptions = $query->orderBy('started_at', 'desc')->get(); + + $headings = ['No', '요금제', '요금제 코드', '월 요금', '결제주기', '시작일', '종료일', '상태', '취소일', '취소 사유']; + + $data = $subscriptions->map(function ($sub, $index) { + return [ + $index + 1, + $sub->plan?->name ?? '-', + $sub->plan?->code ?? '-', + $sub->plan?->price ? number_format($sub->plan->price) : '0', + $sub->plan?->billing_cycle === 'yearly' ? '연간' : '월간', + $sub->started_at?->format('Y-m-d') ?? '-', + $sub->ended_at?->format('Y-m-d') ?? '-', + $sub->status_label, + $sub->cancelled_at?->format('Y-m-d') ?? '-', + $sub->cancel_reason ?? '-', + ]; + })->toArray(); + + return ['data' => $data, 'headings' => $headings]; } /** diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index 3311abc6..f7e4305b 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -256,6 +256,7 @@ Route::get('/usage', [SubscriptionController::class, 'usage'])->name('v1.subscriptions.usage'); Route::post('/export', [SubscriptionController::class, 'export'])->name('v1.subscriptions.export'); Route::get('/export/{id}', [SubscriptionController::class, 'exportStatus'])->whereNumber('id')->name('v1.subscriptions.export.status'); + Route::get('/export/{id}/download', [SubscriptionController::class, 'exportDownload'])->whereNumber('id')->name('v1.subscriptions.export.download'); Route::get('/{id}', [SubscriptionController::class, 'show'])->whereNumber('id')->name('v1.subscriptions.show'); Route::post('/{id}/cancel', [SubscriptionController::class, 'cancel'])->whereNumber('id')->name('v1.subscriptions.cancel'); Route::post('/{id}/renew', [SubscriptionController::class, 'renew'])->whereNumber('id')->name('v1.subscriptions.renew');