fix: [subscription] 내보내기 stuck 문제 해결 - 동기 처리로 전환
- pending 상태로 영원히 남던 DataExport 문제 수정
- 미구현 비동기 Job 대신 ExportService::store() 동기 처리
- 5분 이상 stuck된 export 자동 만료 처리
- 파일 다운로드 엔드포인트 추가 (GET /export/{id}/download)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user