fix: [subscription] 내보내기 stuck 문제 해결 - 동기 처리로 전환

- pending 상태로 영원히 남던 DataExport 문제 수정
- 미구현 비동기 Job 대신 ExportService::store() 동기 처리
- 5분 이상 stuck된 export 자동 만료 처리
- 파일 다운로드 엔드포인트 추가 (GET /export/{id}/download)
This commit is contained in:
김보곤
2026-03-18 14:10:43 +09:00
parent 8f215b235b
commit abf5b6896e
3 changed files with 97 additions and 8 deletions

View File

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

View File

@@ -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];
}
/**

View File

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