diff --git a/app/Http/Controllers/Web/Auth/LoginController.php b/app/Http/Controllers/Web/Auth/LoginController.php
index a031844..47826cd 100644
--- a/app/Http/Controllers/Web/Auth/LoginController.php
+++ b/app/Http/Controllers/Web/Auth/LoginController.php
@@ -27,8 +27,6 @@ final class LoginController extends Controller
if (app()->environment('production')) {
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')];
- var_dump('google');
- exit;
}
$v = Validator::make($request->all(), $rules, [
diff --git a/app/Http/Controllers/Web/Cs/CsQnaController.php b/app/Http/Controllers/Web/Cs/CsQnaController.php
new file mode 100644
index 0000000..6adf89b
--- /dev/null
+++ b/app/Http/Controllers/Web/Cs/CsQnaController.php
@@ -0,0 +1,165 @@
+qnaService->getEnquiryCodes();
+
+ return view('web.cs.qna.index', [
+ 'enquiryCodes' => $codes, // select 옵션
+ ]);
+ }
+
+ // 등록
+ public function store(Request $request)
+ {
+ // legacy.auth가 세션에 _sess 넣어준다 했으니 그대로 사용
+ $sess = (array) $request->session()->get('_sess', []);
+ $memNo = (int)($sess['_mno'] ?? 0);
+
+ if ($memNo <= 0) {
+ abort(403);
+ }
+
+ /**
+ * 0) 레이트리밋(봇/대량 등록 방어)
+ * - 회원번호 + IP 기준으로 1분당 5회, 10분당 20회 같은 식으로 강하게 제한
+ */
+ $ip = (string) $request->ip();
+ $key = 'qna:create:' . $memNo . ':' . $ip;
+
+ if (RateLimiter::tooManyAttempts($key, 5)) {
+ abort(429, '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.');
+ }
+ RateLimiter::hit($key, 60); // 60초
+
+
+ $rules = [
+ 'enquiry_code' => ['required', 'string', 'max:20'],
+ 'enquiry_title' => ['required', 'string', 'max:60'],
+ 'enquiry_content' => ['required', 'string', 'max:5000'],
+ 'return_type' => ['nullable', Rule::in(['sms', 'email'])],
+ 'hp' => ['nullable', 'size:0'], //봇공격 대비
+ ];
+
+ if (app()->environment('production')) {
+ $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('cs_qna_create')];
+ }
+
+ $validated = $request->validate($rules);
+
+ /**
+ * 2) 정규화/무해화 (XSS/스크립트/이상 문자 방어)
+ * - strip_tags는 기본, 추가로 제어문자 제거 + 공백정리
+ * - HTML은 아예 저장하지 않는 정책이면 가장 안전
+ */
+ $validated['enquiry_title'] = $this->sanitizePlainText(
+ (string) $validated['enquiry_title'],
+ 60
+ );
+
+ $validated['enquiry_content'] = $this->sanitizePlainText(
+ (string) $validated['enquiry_content'],
+ 5000
+ );
+
+ /**
+ * 3) “의심 입력” 즉시 차단(선택)
+ * - 운영 경험상 스크립트가 박힌 적이 있다 했으니,
+ * script 태그/iframe/onerror/javascript: 같은 패턴은 바로 차단하는게 낫다.
+ */
+ $badField = null;
+ if ($this->looksMalicious($validated['enquiry_title'])) $badField = 'enquiry_title';
+ if ($this->looksMalicious($validated['enquiry_content'])) $badField = $badField ?: 'enquiry_content';
+
+
+ if ($badField) {
+ return redirect()->route('web.cs.qna.index')
+ ->with('ui_dialog', [
+ 'type' => 'alert',
+ 'title' => '안내',
+ 'message' => '특수 스크립트/태그가 포함되어 등록할 수 없습니다. HTML/스크립트 없이 내용만 작성해 주세요.',
+ ]);
+ }
+
+ $sess = (array) $request->session()->get('_sess', []);
+
+ $validated['_user_email'] = (string)($sess['_mid'] ?? '');
+ $validated['_user_name'] = (string)($sess['_mname'] ?? '');
+ $validated['_user_cell_enc'] = (string)($sess['_mcell'] ?? '');
+ $validated['_ip'] = (string)($sess['_ip'] ?? $request->ip());
+
+
+ // 등록 (현재 연도 테이블에 저장)
+ $this->qnaService->createQna($memNo, $validated);
+
+ // 등록 후 내 문의내역으로 이동
+ return redirect()->route('web.mypage.qna.index')
+ ->with('ui_dialog', [
+ 'type' => 'alert',
+ 'title' => '안내',
+ 'message' => '문의가 등록되었습니다.',
+ ]);
+ }
+
+ private function sanitizePlainText(string $s, int $maxLen): string
+ {
+ // 1) 유니코드 정규화(가능하면) - 없으면 패스
+ if (class_exists(\Normalizer::class)) {
+ $s = \Normalizer::normalize($s, \Normalizer::FORM_C) ?: $s;
+ }
+
+ // 2) 태그 제거(HTML 저장 자체를 금지하는 정책)
+ $s = strip_tags($s);
+
+ // 3) 제어문자 제거(줄바꿈/탭은 허용)
+ // \p{C}는 Control chars 포함. \r\n\t는 남김.
+ $s = preg_replace('/[\p{C}&&[^\r\n\t]]/u', '', $s) ?? $s;
+
+ // 4) 공백 정리(연속 스페이스/줄바꿈 폭발 방지)
+ $s = preg_replace("/[ \t]+/u", ' ', $s) ?? $s;
+ $s = preg_replace("/\n{4,}/u", "\n\n\n", $s) ?? $s;
+
+ $s = trim($s);
+
+ // 5) 길이 자르기(멀티바이트 안전)
+ return Str::limit($s, $maxLen, '');
+ }
+
+ private function looksMalicious(string $s): bool
+ {
+ $lower = Str::lower($s);
+
+ // 너무 공격적으로 잡으면 정상 텍스트도 막을 수 있으니, “확실한 것만”
+ $patterns = [
+ '
+
+
+@endpush
+@push('styles')
+
+@endpush
+
@section('subcontent')
@include('web.partials.content-head', [
'title' => '1:1 문의 접수',
@@ -49,8 +58,9 @@
@@ -62,17 +72,24 @@
답변 속도를 높이려면 “주문시각/결제수단/금액/오류문구”를 같이 적어주세요.
-
@@ -153,35 +153,81 @@
@endpush
+
+
+
diff --git a/resources/views/web/mypage/qna/index.blade.php b/resources/views/web/mypage/qna/index.blade.php
index 41f0528..1884ae9 100644
--- a/resources/views/web/mypage/qna/index.blade.php
+++ b/resources/views/web/mypage/qna/index.blade.php
@@ -9,6 +9,8 @@
];
$mypageActive = 'qna';
+
+ $stateMap = config('qna_state.state_map', []);
@endphp
@extends('web.layouts.subpage')
@@ -17,14 +19,172 @@
@section('meta_description', 'PIN FOR YOU 마이페이지 1:1문의내역 입니다. 문의 및 답변 내역을 확인하세요.')
@section('canonical', url('/mypage/qna'))
+@push('styles')
+
+@endpush
+
@section('subcontent')
+
@include('web.partials.content-head', [
'title' => '1:1문의내역',
'desc' => '문의 처리 상태를 확인할 수 있습니다.'
])
- {{-- TODO: 내용 추후 구현 --}}
+
+
+
+
문의작성하기
+
+ {{-- 상세 영역(선택된 글이 있을 때만) --}}
+ @if($detail)
+ @php
+ $st = (string)($detail->state ?? 'a');
+ [$stLabel, $stBadge] = $stateMap[$st] ?? ['접수', 'bg-primary'];
+ @endphp
+
+
+ 문의내용
+
+
+
+
+
+
+ 제목 : {{ $detail->enquiry_title ?? '-' }}
+
+
+ {!! nl2br(e($detail->enquiry_content ?? '')) !!}
+
+
+
+
+
관리자 답변
+
+ @if(trim((string)($detail->answer_content ?? '')) !== '')
+ {!! nl2br(e($detail->answer_content)) !!}
+ @else
+ 아직 답변이 등록되지 않았습니다.
+ @endif
+
+
+
+
+ @endif
+
+ {{-- 리스트 --}}
+
+ {{-- ✅ 데스크톱 테이블 --}}
+
+
+
+
+
+ | NO. |
+ 상태 |
+ 접수분류 |
+ 제목 |
+ 접수시간 |
+
+
+
+
+ @forelse($items as $row)
+ @php
+ $st = (string)($row->state ?? 'a');
+ [$stLabel, $stBadge] = $stateMap[$st] ?? ['접수', 'bg-primary'];
+ $no = ($items->total() - ($items->firstItem() + $loop->index) + 1);
+ @endphp
+
+ | {{ $no }} |
+ {{ $stLabel }} |
+ {{ $row->enquiry_code_name ?? $row->enquiry_code ?? '-' }} |
+
+
+ {{ $row->enquiry_title ?? '-' }}
+
+ |
+ {{ $row->regdate ? substr($row->regdate, 0, 16) : '-' }} |
+
+ @empty
+
+ | 등록된 문의가 없습니다. |
+
+ @endforelse
+
+
+
+
+
+ {{-- ✅ 모바일 카드 리스트 --}}
+
+
+ @if($items->hasPages())
+
+ @endif
+
+
@include('web.partials.mypage-quick-actions')
@endsection
diff --git a/routes/console.php b/routes/console.php
index 3c9adf1..a5a8998 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -2,7 +2,184 @@
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schedule;
+use Illuminate\Console\Scheduling\Event;
+/**
+ * -----------------------------------------------------------------------------
+ * Gifticon Platform - Console Routes / Scheduler Registry
+ * -----------------------------------------------------------------------------
+ *
+ * [운영/개발 공통] 스케줄/워커/큐 동작 확인 치트시트
+ *
+ * ✅ 스케줄 등록 목록 확인
+ * docker exec -it gifticon-app php artisan schedule:list
+ *
+ * ✅ 스케줄러 컨테이너 프로세스 확인
+ * docker exec -it gifticon-scheduler ps aux
+ *
+ * ✅ 워커 컨테이너 프로세스 확인
+ * docker exec -it gifticon-worker ps aux
+ *
+ * ✅ 워커 하트비트 로그 확인 (워커가 큐를 소비하고 있음을 증명)
+ * docker exec -it gifticon-app sh -lc "tail -n 200 storage/logs/laravel.log | grep '\[worker-heartbeat\]' | tail -n 20"
+ *
+ * ✅ 큐 길이 확인 (default 큐)
+ * docker exec -it gifticon-redis redis-cli LLEN queues:default
+ *
+ * ✅ 실패 잡 확인
+ * docker exec -it gifticon-app php artisan queue:failed
+ *
+ * -----------------------------------------------------------------------------
+ * [장애 대응 가이드]
+ * -----------------------------------------------------------------------------
+ * 1) "메일은 되는데 워커가 이상" / "큐가 쌓이기만 함"
+ * - queue:failed 확인 → 실패가 쌓이면 코드/설정 오류 가능
+ * - LLEN queues:default 확인 → 계속 증가하면 워커가 소비 못함
+ * - 워커를 일시적으로 디버그 모드로 실행(컨테이너에서):
+ * docker exec -it gifticon-worker php artisan queue:work -vvv --tries=1 --timeout=90
+ *
+ * 2) "스케줄이 안 돈다"
+ * - schedule:list로 등록 여부 확인
+ * - gifticon-scheduler 컨테이너가 schedule:work로 떠 있는지 확인
+ * - 스케줄은 등록됐는데 실행 흔적이 없다면: 로그/권한/환경(APP_ENV) 분기 확인
+ *
+ * 3) "Redis 연결 이상"
+ * - 컨테이너 내부에서 Redis 연결 테스트:
+ * docker exec -it gifticon-worker php -r '$r=new Redis(); var_dump($r->connect("gifticon-redis", 6379, 1)); echo $r->ping().PHP_EOL;'
+ *
+ * -----------------------------------------------------------------------------
+ * 운영 안전장치(추천)
+ * -----------------------------------------------------------------------------
+ * - 테스트용 스케줄은 APP_ENV=local|development에서만 등록되도록 제한
+ * - 운영에서 불필요한 스케줄 로그가 쌓이는 사고 예방
+ */
+
+// -----------------------------------------------------------------------------
+// 공통: 스케줄 등록 헬퍼 (cron 문자열 기반)
+// -----------------------------------------------------------------------------
+function registerScheduleCron(string $name, string $cron, $job, array $opt = []): Event
+{
+ $tz = $opt['timezone'] ?? config('app.timezone', 'Asia/Seoul');
+ $lock = $opt['without_overlapping'] ?? true;
+
+ $event = is_callable($job)
+ ? Schedule::call(function () use ($name, $job) {
+ try {
+ Log::info("[schedule:$name] start");
+ $job();
+ Log::info("[schedule:$name] done");
+ } catch (\Throwable $e) {
+ Log::error("[schedule:$name] failed", [
+ 'err' => $e->getMessage(),
+ ]);
+ throw $e;
+ }
+ })
+ : Schedule::command((string) $job);
+
+ // 중요: withoutOverlapping 전에 name 필수
+ $event->name($name);
+
+ $event->cron($cron)->timezone($tz);
+
+ // 운영 옵션
+ if ($lock) $event->withoutOverlapping();
+ if (!empty($opt['on_one_server'])) $event->onOneServer();
+ if (!empty($opt['run_in_background'])) $event->runInBackground();
+
+ // 실패 시 로그 남기기 (Laravel 12에서도 지원)
+ // NOTE: 잡 실패를 즉시 알기 좋음
+ $event->onFailure(function (\Throwable $e) use ($name) {
+ Log::error("[schedule:$name] onFailure", [
+ 'err' => $e->getMessage(),
+ ]);
+ });
+
+ return $event;
+}
+
+// -----------------------------------------------------------------------------
+// 운영용(Production 포함): 워커/스케줄러 생존 확인용 Heartbeat
+// - 스케줄러가 매 분 실행
+// - 큐로 잡을 던지고
+// - 워커가 처리하면 로그가 찍힘
+// => scheduler + redis + worker 전체 체인이 정상임을 증명
+// -----------------------------------------------------------------------------
+
+//Schedule::call(function () {
+// dispatch(new \App\Jobs\WorkerHeartbeatJob());
+//})
+// ->everyMinute()
+// ->name('worker_heartbeat_dispatch')
+// ->withoutOverlapping();
+
+
+
+
+
+
+// -----------------------------------------------------------------------------
+// Artisan 커맨드 (개발/운영 공통) - 단발성 테스트 도구 호출용
+// docker exec -it gifticon-app php artisan inspire
+// docker exec -it gifticon-app php artisan tick
+// -----------------------------------------------------------------------------
Artisan::command('inspire', function () {
$this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
+
+Artisan::command('tick', function () {
+ Log::info('[tick] artisan command ok '.now());
+ $this->info('tick ok: '.now());
+})->purpose('Quick artisan health check');
+
+
+
+
+
+
+// -----------------------------------------------------------------------------
+// 개발 전용 스케줄/테스트 스케줄 (운영에서 로그 쌓이는 사고 방지)
+// -----------------------------------------------------------------------------
+$env = (string) config('app.env', 'production');
+$isDev = in_array($env, ['local', 'development', 'dev', 'testing'], true);
+
+if ($isDev) {
+ // "스케줄러가 실제로 돌아가는지" 확인용 - 운영에서는 불필요
+ Schedule::call(function () {
+ Log::info('[schedule-dev] alive '.now());
+ })
+ ->everyMinute()
+ ->name('dev_scheduler_alive')
+ ->withoutOverlapping();
+
+ // 예시: 5분마다 실행
+ registerScheduleCron('every_5m_log', '*/5 * * * *', function () {
+ Log::info('[job-dev] every 5 minutes '.now());
+ });
+
+ // 예시: 매일 새벽 2시 10분
+ registerScheduleCron('every_day_0210_log', '10 2 * * *', function () {
+ Log::info('[job-dev] daily 02:10 '.now());
+ });
+
+ // 예시: 매월 1일 03:30
+ registerScheduleCron('monthly_1st_0330', '30 3 1 * *', function () {
+ Log::info('[job-dev] monthly 1st 03:30 '.now());
+ });
+}
+
+
+// -----------------------------------------------------------------------------
+// 운영용 스케줄은 여기 아래에 "명시적으로" 추가해라.
+// - 개발 예시와 섞으면 운영에서 실수할 가능성이 커짐.
+// - registerScheduleCron()으로 이름 통일해서 관리.
+// -----------------------------------------------------------------------------
+
+// 예시(운영): 정산 배치
+// registerScheduleCron('settlement_daily', '0 4 * * *', 'app:settlement:daily', [
+// 'without_overlapping' => true,
+// 'on_one_server' => true,
+// ]);
+
diff --git a/routes/web.php b/routes/web.php
index 4e5fe4b..4ab0488 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -9,6 +9,8 @@ use App\Http\Controllers\Web\Auth\LoginController;
use App\Http\Controllers\Web\Mypage\InfoGateController;
use App\Http\Controllers\Web\Cs\NoticeController;
use App\Http\Controllers\Web\Auth\EmailVerificationController;
+use App\Http\Controllers\Web\Cs\CsQnaController;
+use App\Http\Controllers\Web\Mypage\MypageQnaController;
Route::view('/', 'web.home')->name('web.home');
@@ -23,7 +25,10 @@ Route::prefix('cs')->name('web.cs.')->group(function () {
Route::get('/notice/{seq}', [NoticeController::class, 'show'])->whereNumber('seq')->name('notice.show');
Route::view('faq', 'web.cs.faq.index')->name('faq.index');
Route::view('kakao', 'web.cs.kakao.index')->name('kakao.index');
- Route::view('qna', 'web.cs.qna.index')->name('qna.index')->middleware('legacy.auth');
+ Route::middleware('legacy.auth')->group(function () {
+ Route::get('qna', [CsQnaController::class, 'create'])->name('qna.index');
+ Route::post('qna', [CsQnaController::class, 'store'])->name('qna.store');
+ });
Route::view('guide', 'web.cs.guide.index')->name('guide.index');
});
@@ -52,7 +57,8 @@ Route::prefix('mypage')->name('web.mypage.')
Route::view('usage', 'web.mypage.usage.index')->name('usage.index');
Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index');
- Route::view('qna', 'web.mypage.qna.index')->name('qna.index');
+ Route::get('qna', [MypageQnaController::class, 'index'])->name('qna.index');
+ Route::get('qna/{seq}', [MypageQnaController::class, 'show'])->whereNumber('seq')->name('qna.show');
});
/*