From 494d95327a8fe3447651e4a37274fd97f6e6eb54 Mon Sep 17 00:00:00 2001 From: sungro815 Date: Tue, 3 Feb 2026 17:43:42 +0900 Subject: [PATCH] =?UTF-8?q?QNA=20=EB=93=B1=EB=A1=9D=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20db=20=EA=B0=9C=EB=B0=9C/?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=A0=95=EB=A6=AC=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Web/Auth/LoginController.php | 2 - .../Controllers/Web/Cs/CsQnaController.php | 165 +++++++ .../Web/Mypage/MypageQnaController.php | 64 +++ app/Jobs/SendAdminQnaCreatedMailJob.php | 68 +++ app/Jobs/WorkerHeartbeatJob.php | 24 ++ app/Models/CounselingOneOnOne.php | 37 ++ app/Services/QnaService.php | 149 +++++++ config/database.php | 41 +- config/qna_state.php | 20 + docs/ops/docker-runbook.md | 120 ++++++ public/assets/css/cs_qna.css | 401 ++++++++++++++++++ public/assets/css/mypage_qna.css | 385 +++++++++++++++++ resources/css/web.css | 298 ------------- .../views/mail/admin/qna_created.blade.php | 58 +++ .../views/web/auth/find_password.blade.php | 2 +- resources/views/web/cs/qna/index.blade.php | 146 ++++--- .../views/web/mypage/qna/index.blade.php | 162 ++++++- routes/console.php | 177 ++++++++ routes/web.php | 10 +- 19 files changed, 1953 insertions(+), 376 deletions(-) create mode 100644 app/Http/Controllers/Web/Cs/CsQnaController.php create mode 100644 app/Http/Controllers/Web/Mypage/MypageQnaController.php create mode 100644 app/Jobs/SendAdminQnaCreatedMailJob.php create mode 100644 app/Jobs/WorkerHeartbeatJob.php create mode 100644 app/Models/CounselingOneOnOne.php create mode 100644 app/Services/QnaService.php create mode 100644 config/qna_state.php create mode 100644 docs/ops/docker-runbook.md create mode 100644 public/assets/css/cs_qna.css create mode 100644 public/assets/css/mypage_qna.css create mode 100644 resources/views/mail/admin/qna_created.blade.php 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 = [ + 'session()->all(), '_sess._mno', 0); + if ($memNo <= 0) { + return redirect()->route('web.mypage.info.index'); + } + + $year = $this->normalizeYear((int)$request->query('year', (int)date('Y'))); + + $items = $this->qnaService->paginateMyQna($memNo, 10, $year); + + return view('web.mypage.qna.index', [ + 'items' => $items, + 'detail' => null, + 'seq' => null, + 'year' => $year, + 'years' => range(2018, (int)date('Y')), + ]); + } + + public function show(Request $request, int $seq) + { + $memNo = (int) data_get($request->session()->all(), '_sess._mno', 0); + if ($memNo <= 0) { + return redirect()->route('web.mypage.info.index'); + } + + $year = $this->normalizeYear((int)$request->query('year', (int)date('Y'))); + + $items = $this->qnaService->paginateMyQna($memNo, 10, $year); + $detail = $this->qnaService->findMyQna($memNo, $seq, $year); + + return view('web.mypage.qna.index', [ + 'items' => $items, + 'detail' => $detail, + 'seq' => $seq, + 'year' => $year, + 'years' => range(2018, (int)date('Y')), + ]); + } + + private function normalizeYear(int $year): int + { + $min = 2018; + $max = (int)date('Y'); + if ($year < $min) return $min; + if ($year > $max) return $max; + return $year; + } +} diff --git a/app/Jobs/SendAdminQnaCreatedMailJob.php b/app/Jobs/SendAdminQnaCreatedMailJob.php new file mode 100644 index 0000000..29c99f7 --- /dev/null +++ b/app/Jobs/SendAdminQnaCreatedMailJob.php @@ -0,0 +1,68 @@ +sendTemplate( + $to, + '[PIN FOR YOU] 1:1 문의 등록 알림', + 'mail.admin.qna_created', + [ + 'name' => $this->data['name'], + 'email' => $this->data['email'], + 'cell' => $this->data['cell'], + 'title' => '[PIN FOR YOU] 1:1 문의 등록 알림', + 'mem_no' => $this->memNo, + 'qna_id' => $this->qnaId, + 'year' => $this->data['year'] ?? null, + 'enquiry_code' => $this->data['enquiry_code'] ?? '', + 'enquiry_title' => $this->data['enquiry_title'] ?? '', + 'enquiry_content' => $this->data['enquiry_content'] ?? '', + 'return_type' => $this->data['return_type'] ?? null, + 'ip' => $this->data['ip'] ?? '', + 'created_at' => $this->data['created_at'] ?? now()->toDateTimeString(), + 'accent' => '#E4574B', + 'brand' => 'PIN FOR YOU', + 'siteUrl' => config('app.url'), + ], + queue: false // 여기서도 실제 발송은 즉시, 하지만 "요청 처리"와 분리됨(비동기) + ); + } catch (\Throwable $e) { + Log::error('[QNA] admin notify mail failed', [ + 'mem_no' => $this->memNo, + 'qna_id' => $this->qnaId, + 'email' => $to, + 'err' => $e->getMessage(), + ]); + // 한 명 실패해도 나머지 계속 시도 + } + } + } +} diff --git a/app/Jobs/WorkerHeartbeatJob.php b/app/Jobs/WorkerHeartbeatJob.php new file mode 100644 index 0000000..a01179c --- /dev/null +++ b/app/Jobs/WorkerHeartbeatJob.php @@ -0,0 +1,24 @@ +setTable('counseling_one_on_one_'.$year); + return $m->newQuery(); + } + + public function regdateCarbon(): ?Carbon + { + $v = $this->regdate ?? null; + if (!$v) return null; + try { + return Carbon::parse($v); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Services/QnaService.php b/app/Services/QnaService.php new file mode 100644 index 0000000..7b00d83 --- /dev/null +++ b/app/Services/QnaService.php @@ -0,0 +1,149 @@ + 0 ? $year : (int) date('Y'); + $table = "counseling_one_on_one_{$year}"; + + if (!Schema::hasTable($table)) { + return new Paginator([], 0, $perPage, 1, [ + 'path' => request()->url(), + 'query' => request()->query(), + ]); + } + + return CounselingOneOnOne::queryForYear($year) + ->from("{$table} as q") + ->leftJoin('counseling_code as cc', function ($join) { + $join->on('cc.enquiry_code', '=', 'q.enquiry_code') + ->where('cc.enquiry_type', '=', 'e'); // ✅ 접수분류 타입 + }) + ->where('q.member_num', $memNo) + ->select([ + 'q.seq', + 'q.state', + 'q.enquiry_code', + 'q.enquiry_title', + 'q.regdate', + // ✅ 코드 대신 보여줄 분류명 + 'cc.enquiry_title as enquiry_code_name', + ]) + ->orderByDesc('q.seq') + ->paginate($perPage) + ->withQueryString(); + } + + public function findMyQna(int $memNo, int $seq, int $year = 0) + { + $year = $year > 0 ? $year : (int) date('Y'); + $table = "counseling_one_on_one_{$year}"; + + if (!Schema::hasTable($table)) { + abort(404); + } + + return CounselingOneOnOne::queryForYear($year) + ->from("{$table} as q") + ->leftJoin('counseling_code as cc', function ($join) { + $join->on('cc.enquiry_code', '=', 'q.enquiry_code') + ->where('cc.enquiry_type', '=', 'e'); + }) + ->where('q.member_num', $memNo) + ->where('q.seq', $seq) + ->select([ + 'q.seq', + 'q.state', + 'q.enquiry_code', + 'q.enquiry_title', + 'q.enquiry_content', + 'q.answer_content', + 'q.regdate', + 'cc.enquiry_title as enquiry_code_name', + ]) + ->firstOrFail(); + } + + public function getEnquiryCodes(): array + { + return DB::table('counseling_code') + ->where('enquiry_type', 'e') + ->orderBy('enquiry_code', 'asc') + ->get(['enquiry_code', 'enquiry_title']) + ->map(fn($r) => ['code' => (string)$r->enquiry_code, 'title' => (string)$r->enquiry_title]) + ->all(); + } + + /** + * 1:1 문의 등록 (연도 테이블: counseling_one_on_one_YYYY) + * 반환: insert seq(id) + */ + public function createQna(int $memNo, array $payload, ?int $year = null): int + { + $year = $year ?: (int)date('Y'); + $table = "counseling_one_on_one_{$year}"; + + // 테이블이 없으면(혹시 모를 경우) 생성은 다음 단계로. 지금은 실패 처리 + if (!Schema::hasTable($table)) { + throw new \RuntimeException("qna_table_not_found: {$table}"); + } + + $enquiryCode = (string)($payload['enquiry_code'] ?? ''); + $enquiryTitle = strip_tags(trim((string)($payload['enquiry_title'] ?? ''))); + $enquiryContent = strip_tags(trim((string)($payload['enquiry_content'] ?? ''))); + $returnType = (string)($payload['return_type'] ?? 'web'); + + + + $id = (int) DB::transaction(function () use ($table, $memNo, $enquiryCode, $enquiryTitle, $enquiryContent, $returnType) { + return DB::table($table)->insertGetId([ + 'state' => 'a', + 'member_num' => $memNo, + 'enquiry_code' => $enquiryCode, + 'enquiry_title' => $enquiryTitle, + 'enquiry_content' => $enquiryContent, + 'return_type' => $returnType, + 'user_upload' => null, + 'regdate' => now()->format('Y-m-d H:i:s'), + 'receipt_date' => "1900-01-01 00:00:00", + 'defer_date' => "1900-01-01 00:00:00", + 'completion_date' => "1900-01-01 00:00:00", + ]); + }); + + + $userEmail = (string)($payload['_user_email'] ?? ''); + $userName = (string)($payload['_user_name'] ?? ''); + $ip = (string)($payload['_ip'] ?? ''); + $cellEnc = (string)($payload['_user_cell_enc'] ?? ''); + $seed = app(CiSeedCrypto::class); + $cellPlain = (string) $seed->decrypt($cellEnc); + + SendAdminQnaCreatedMailJob::dispatch($memNo, $id, [ + 'name' => $userName, + 'email' => $userEmail, + 'cell' => $cellPlain, + 'ip' => $ip, + 'year' => $year, + 'enquiry_code' => $enquiryCode, + 'enquiry_title' => $enquiryTitle, + 'enquiry_content' => $enquiryContent, + 'return_type' => $returnType, + 'created_at' => now()->toDateTimeString(), + ])->onConnection('redis'); // 네 QUEUE_CONNECTION=redis일 때 명시해도 좋음 + + return $id; + } +} diff --git a/config/database.php b/config/database.php index 3bf3c31..29e06c3 100644 --- a/config/database.php +++ b/config/database.php @@ -16,8 +16,7 @@ return [ | */ - 'default' => env('DB_CONNECTION', 'sqlite'), - + 'default' => env('DB_CONNECTION', 'default'), /* |-------------------------------------------------------------------------- | Database Connections @@ -31,42 +30,40 @@ return [ 'connections' => [ - 'mariadb' => [ - 'driver' => 'mariadb', - 'url' => env('DB_URL'), + 'default' => [ + 'driver' => env('DB_CONNECTION', 'mysql'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'laravel'), 'username' => env('DB_USERNAME', 'root'), 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'strict' => true, + ], + + 'admin' => [ + 'driver' => env('DB_ADMIN_CONNECTION', env('DB_CONNECTION', 'mysql')), + 'host' => env('DB_ADMIN_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('DB_ADMIN_PORT', env('DB_PORT', '3306')), + 'database' => env('DB_ADMIN_DATABASE', ''), + 'username' => env('DB_ADMIN_USERNAME', ''), + 'password' => env('DB_ADMIN_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), - ]) : [], ], 'sms_server' => [ - 'driver' => 'mysql', + 'driver' => env('SMS_DB_CONNECTION', 'mysql'), 'host' => env('SMS_DB_HOST', '127.0.0.1'), 'port' => env('SMS_DB_PORT', '3306'), 'database' => env('SMS_DB_DATABASE', 'lguplus'), 'username' => env('SMS_DB_USERNAME', 'lguplus'), 'password' => env('SMS_DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => '', - 'strict' => false, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], + 'strict' => false, // 외부 DB면 strict 끄는거 OK ], diff --git a/config/qna_state.php b/config/qna_state.php new file mode 100644 index 0000000..09b7ac5 --- /dev/null +++ b/config/qna_state.php @@ -0,0 +1,20 @@ + [label, badge_class] + */ + + 'state_map' => [ + 'a' => ['접수', 'bg-primary'], + 'b' => ['접수완료', 'bg-success'], + 'c' => ['처리중', 'bg-info'], + 'd' => ['처리중', 'bg-warning text-dark'], + 'e' => ['완료', 'bg-dark'], + ], + +]; diff --git a/docs/ops/docker-runbook.md b/docs/ops/docker-runbook.md new file mode 100644 index 0000000..1a822e8 --- /dev/null +++ b/docs/ops/docker-runbook.md @@ -0,0 +1,120 @@ +# Gifticon Platform Docker Runbook (Dev/Prod 통합) + +이 문서는 Gifticon Platform의 Docker 기반 실행 환경을 운영/배포 관점에서 정리한 통합 런북이다. + +포함 범위: +- 컨테이너 역할/연동 구조 +- 프록시(HTTPS terminate) 구조 +- 정상 동작 테스트(필수 체크리스트) +- 스케줄/큐/메일/캐시 관련 대표 장애 사례 및 해결 +- 배포 절차(운영 권장 루틴) +- 운영 하드닝(보안/안정성) 권장사항 +- 데이터 영속성/백업 + +--- + +## 0. 아키텍처 개요 + +### 0.1 컨테이너 구성(대표) +- `gifticon-web` : Nginx (Web 도메인, 정적/라우팅 + PHP-FPM upstream) +- `gifticon-admin` : Nginx (Admin 도메인, 인증/허용 IP 적용 가능) +- `gifticon-app` : PHP-FPM + Laravel (비즈니스 로직) +- `gifticon-worker` : Laravel queue worker (Redis 큐 처리) +- `gifticon-scheduler` : Laravel scheduler (schedule:work 상시 실행) +- `gifticon-db` : MariaDB (개발/임시용. 리얼에서는 RDS 사용 가능) +- `gifticon-redis` : Redis (cache/session/queue) +- (mailpit: 개발용 SMTP 테스트 — 현재 제거 완료) + +### 0.2 프록시(NAS/nginx-proxy/CloudFront) 연결 +외부에서 HTTPS로 들어오면 프록시가 HTTPS를 terminate 하고 내부 도커는 HTTP로 통신한다. + +예시(NAS nginx-proxy): +- `https://four.syye.net` → `http://:8091` (gifticon-web) +- `https://myworld.syye.net` → `http://:8092` (gifticon-admin) + +운영에서도: +- CloudFront(HTTPS) → EC2(HTTP) → Docker(gifticon-web/admin) 동일 패턴으로 적용 가능. + +프록시에서 전달 헤더: +- `Host` +- `X-Forwarded-Proto=https` +- `X-Forwarded-For` +- `X-Forwarded-Host` + +--- + +## 1. docker-compose 주요 역할/의미 + +### 1.1 gifticon-web (nginx) +- 역할: Web 도메인용 Nginx +- 포트: 8091:80 (호스트 노출) +- 볼륨: + - `./src:/var/www/html:ro` (소스 read-only) + - `./nginx/web/default.conf:/etc/nginx/conf.d/default.conf:ro` + +### 1.2 gifticon-admin (nginx) +- 역할: Admin 도메인용 Nginx +- 포트: 8092:80 +- 볼륨: + - `./src:/var/www/html:ro` + - `./nginx/admin/default.conf:/etc/nginx/conf.d/default.conf:ro` + - `./nginx/auth:/etc/nginx/auth:ro` (허용 IP, basic auth 등 정책 적용) + +### 1.3 gifticon-app (php-fpm + laravel) +- 역할: Laravel 실행 +- depends_on: db, redis +- UID/GID 매핑으로 호스트 파일 권한 문제 방지 +- 주의: `artisan tinker` 실행 시 PsySH가 HOME 디렉토리에 쓰기를 시도할 수 있음 + → `docker exec -it -e HOME=/tmp gifticon-app php artisan tinker` 권장 + +### 1.4 gifticon-worker (queue) +- 역할: Redis 큐 처리 +- command: `php artisan queue:work ...` +- 운영 포인트: + - **Closure Job(익명 함수 dispatch) 금지**: 직렬화/경로 오류 발생 가능 + - 반드시 Job 클래스로 구현 + +### 1.5 gifticon-scheduler (schedule) +- 역할: `php artisan schedule:work` 상시 실행 +- Laravel 12 기준 스케줄 정의는 `routes/console.php`에서 가능 +- `withoutOverlapping()` 사용 시 `->name()`을 먼저 지정해야 함 + (name 없는 상태에서 withoutOverlapping 호출하면 LogicException) + +### 1.6 gifticon-db (mariadb) +- 역할: 개발/임시 DB +- 영속성: `db_data` 볼륨 사용 (재시작해도 유지) +- **주의:** `docker compose down -v`는 볼륨 삭제(데이터 삭제) + +### 1.7 gifticon-redis +- 역할: cache/session/queue +- 영속성: `redis_data` 볼륨(appendonly) 사용 + +--- + +## 2. 운영(리얼) 서버 적용 원칙 + +### 2.1 APP_ENV / APP_DEBUG +운영 권장: +- `APP_ENV=production` +- `APP_DEBUG=false` (디버그/스택노출 위험) + +### 2.2 캐시 전략(운영 배포 후 권장) +운영 배포 직후: +- `php artisan optimize:clear` +- `php artisan config:cache` +- `php artisan route:cache` +- `php artisan view:cache` + +> 템플릿 변경 후 메일/뷰가 반영 안 되는 문제는 대부분 view cache 또는 worker 재시작 누락. + +### 2.3 secrets 관리 +- .env 민감정보(키/비번/SMTP/토큰)는 저장소 커밋 금지 +- 운영 서버에서는 `.env` 권한 600 + 배포 파이프라인에서 주입 권장 + +--- + +## 3. “정상 동작” 점검 체크리스트 (필수) + +### 3.1 컨테이너 상태 확인 +```bash +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" diff --git a/public/assets/css/cs_qna.css b/public/assets/css/cs_qna.css new file mode 100644 index 0000000..023b101 --- /dev/null +++ b/public/assets/css/cs_qna.css @@ -0,0 +1,401 @@ +/* ========================================================= + CS QnA (UI only) — refined color + modern +========================================================= */ +:root{ + --q-brand: #0b63ff; + --q-ink: rgba(0,0,0,.88); + --q-muted: rgba(0,0,0,.62); + --q-line: rgba(0,0,0,.10); + --q-soft: rgba(11,99,255,.08); + --q-soft2: rgba(255,199,0,.10); + --q-shadow: 0 10px 28px rgba(0,0,0,.07); + --q-shadow2: 0 14px 34px rgba(0,0,0,.10); +} + +.qna{ margin-top: 12px; } + +/* Top */ +.qna-top{ + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + margin-bottom: 12px; +} + +.qna-top__hint{ + flex:1; + border:1px solid rgba(11,99,255,.14); + background: + radial-gradient(900px 240px at 0% 0%, rgba(11,99,255,.14), transparent 55%), + radial-gradient(900px 240px at 100% 0%, rgba(255,199,0,.10), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); + border-radius: 18px; + padding:12px 12px; + font-weight:850; + color: rgba(0,0,0,.76); + line-height:1.5; + box-shadow: 0 8px 22px rgba(0,0,0,.05); +} + +.qna-pill{ + display:inline-flex; + align-items:center; + height:26px; + padding:0 12px; + border-radius:999px; + background: linear-gradient(135deg, var(--q-brand), #2aa6ff); + color:#fff; + font-weight:950; + margin-right:8px; + letter-spacing:-0.02em; + box-shadow: 0 10px 22px rgba(11,99,255,.22); +} + +/* Card */ +.qna-card{ + border:1px solid var(--q-line); + background:#fff; + border-radius: 20px; + overflow:hidden; + box-shadow: var(--q-shadow); +} + +.qna-card__head{ + padding:15px 15px 13px; + border-bottom:1px solid rgba(0,0,0,.08); + background: + radial-gradient(900px 260px at 0% 0%, rgba(11,99,255,.10), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); +} + +.qna-card__title{ + margin:0; + font-size: 16px; + font-weight: 950; + letter-spacing:-0.03em; + color: var(--q-ink); +} + +.qna-card__desc{ + margin:7px 0 0; + color: var(--q-muted); + line-height: 1.65; + font-weight: 650; +} + +/* Form */ +.qna-form{ padding:15px; } + +.qna-grid{ + display:grid; + grid-template-columns: 1fr 1fr; + gap:12px; +} + +.qna-field--mt{ margin-top: 12px; } + +.qna-label{ + display:flex; + align-items:center; + gap:6px; + font-weight: 950; + letter-spacing:-0.02em; + color: var(--q-ink); +} + +.qna-req{ color:#d14b4b; font-weight: 950; } +.qna-sub{ color: rgba(0,0,0,.55); font-weight: 850; } + +/* Inputs */ +.qna-input{ + margin-top:8px; + width:100%; + height:46px; + border-radius:16px; + border:1px solid rgba(0,0,0,.10); + background:#fff; + padding:0 12px; + font-weight:850; + outline:none; + transition: box-shadow .15s ease, border-color .15s ease, background .15s ease; +} + +.qna-input:focus{ + border-color: rgba(11,99,255,.30); + box-shadow: 0 0 0 4px rgba(11,99,255,.12); +} + +.qna-textarea{ + margin-top:8px; + width:100%; + min-height: 180px; + border-radius:16px; + border:1px solid rgba(0,0,0,.10); + background:#fff; + padding:12px; + font-weight:750; + line-height:1.75; + outline:none; + resize: vertical; + transition: box-shadow .15s ease, border-color .15s ease; +} + +.qna-textarea:focus{ + border-color: rgba(11,99,255,.30); + box-shadow: 0 0 0 4px rgba(11,99,255,.12); +} + +.qna-help{ + margin-top:8px; + color: rgba(0,0,0,.64); + font-weight:650; + line-height:1.55; +} + +/* File */ +.qna-file{ + margin-top:8px; + width:100%; + height:46px; + padding: 9px 10px; + border-radius:16px; + border:1px solid rgba(0,0,0,.10); + background: rgba(0,0,0,.02); + font-weight:850; +} + +.qna-filelist{ + margin-top:10px; + display:flex; + flex-direction:column; + gap:7px; +} + +.qna-filelist__item{ + padding:9px 11px; + border-radius:16px; + border:1px solid rgba(11,99,255,.12); + background: rgba(11,99,255,.05); + font-weight:850; + color: rgba(0,0,0,.76); +} + +.qna-filelist__warn{ + padding:10px 12px; + border-radius:16px; + border:1px dashed rgba(0,0,0,.20); + background: rgba(0,0,0,.02); + color: rgba(0,0,0,.68); + font-weight:950; +} + +/* Choice */ +.qna-choice{ + margin-top:10px; + display:flex; + flex-wrap:wrap; + gap:10px 14px; + align-items:center; +} + +.qna-check, .qna-radio{ + display:flex; + align-items:center; + gap:8px; + font-weight:850; + color: rgba(0,0,0,.78); +} + +.qna-check input, .qna-radio input{ transform: translateY(1px); } + +/* Recap placeholder */ +.qna-recap{ + display:flex; + align-items:center; + justify-content:space-between; + gap:10px; + border:1px solid rgba(11,99,255,.14); + background: + radial-gradient(900px 240px at 0% 0%, rgba(11,99,255,.12), transparent 55%), + linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); + border-radius:18px; + padding:12px; +} + +.qna-recap__badge{ + display:inline-flex; + align-items:center; + height:28px; + padding:0 12px; + border-radius:999px; + background: rgba(0,0,0,.86); + color:#fff; + font-weight:950; +} + +.qna-recap__text{ + color: rgba(0,0,0,.70); + font-weight:750; + line-height:1.55; +} + +/* Actions */ +.qna-actions{ + margin-top: 14px; + display:flex; + flex-direction:column; + align-items:center; + gap:10px; +} + +.qna-btn{ + display:inline-flex; + align-items:center; + justify-content:center; + height:46px; + padding:0 14px; + border-radius:16px; + text-decoration:none; + font-weight:950; + border:1px solid rgba(0,0,0,.10); + background:#fff; + color: rgba(0,0,0,.86); + cursor:pointer; + transition: transform .12s ease, box-shadow .12s ease, background .12s ease, border-color .12s ease; +} + +.qna-btn:hover{ + transform: translateY(-1px); + box-shadow: 0 12px 26px rgba(0,0,0,.10); +} + +.qna-btn--primary{ + width:min(360px, 100%); + background: linear-gradient(135deg, var(--q-brand), #2aa6ff); + color:#fff; + border-color: rgba(11,99,255,.18); +} + +.qna-btn--ghost{ + background: rgba(0,0,0,.02); +} + +.qna-top__actions .qna-btn{ height:42px; } + +.qna-actions__note{ + color: rgba(0,0,0,.58); + font-weight:750; +} + +/* Responsive */ +@media (max-width: 960px){ + .qna-top{ flex-direction:column; align-items:stretch; } + .qna-grid{ grid-template-columns: 1fr; } +} +/* ===== QNA typography tune-down (override) ===== */ +.qna-top__hint{ + font-weight: 650; + font-size: 14px; + padding: 10px 12px; +} + +.qna-pill{ + height: 24px; + padding: 0 10px; + font-weight: 750; + font-size: 12px; +} + +.qna-card__title{ + font-size: 15px; + font-weight: 800; +} + +.qna-card__desc{ + font-weight: 550; + font-size: 13px; + line-height: 1.6; +} + +.qna-form{ + padding: 14px; +} + +.qna-label{ + font-weight: 750; + font-size: 13px; +} + +.qna-req{ + font-weight: 800; +} + +.qna-input{ + height: 44px; + border-radius: 14px; + font-weight: 600; + font-size: 14px; +} + +.qna-textarea{ + border-radius: 14px; + font-weight: 550; + font-size: 14px; + line-height: 1.65; + min-height: 170px; +} + +.qna-help{ + font-weight: 450; + font-size: 12.5px; + color: rgba(0,0,0,.58); +} + +.qna-choice{ + gap: 8px 12px; +} + +.qna-check, .qna-radio{ + font-weight: 600; + font-size: 13px; + color: rgba(0,0,0,.72); +} + +.qna-recap__badge{ + font-weight: 750; + font-size: 12px; + height: 26px; +} + +.qna-recap__text{ + font-weight: 500; + font-size: 12.5px; + color: rgba(0,0,0,.62); +} + +.qna-btn{ + height: 44px; + border-radius: 14px; + font-weight: 750; + font-size: 14px; + padding: 0 12px; +} + +.qna-top__actions .qna-btn{ + height: 40px; + font-size: 13px; +} + +.qna-actions__note{ + font-weight: 450; + font-size: 12.5px; + color: rgba(0,0,0,.56); +} + +/* 모바일에서 더 정돈 */ +@media (max-width: 960px){ + .qna-top__hint{ font-size: 13px; } + .qna-card__title{ font-size: 14px; } + .qna-input, .qna-textarea{ font-size: 13.5px; } +} diff --git a/public/assets/css/mypage_qna.css b/public/assets/css/mypage_qna.css new file mode 100644 index 0000000..bf17320 --- /dev/null +++ b/public/assets/css/mypage_qna.css @@ -0,0 +1,385 @@ +.mypage-qna-page .mq-topbar{ + display:flex; + justify-content:space-between; /* ✅ 좌/우 끝으로 */ + align-items:center; + margin: 0 0 14px 0; +} + +.mypage-qna-page .mq-btn{ + display:inline-flex; + align-items:center; + justify-content:center; + + padding: 8px 12px; + border-radius: 8px; + background:#0b7b77; + color:#fff; + font-weight:600; + font-size:14px; + line-height:1.2; + + text-decoration:none; + border:1px solid rgba(0,0,0,.05); +} + +.mypage-qna-page .mq-detail{ + border:1px solid #e6e6e6; + border-radius:10px; + padding: 16px; + margin-bottom: 18px; + background:#fff; +} + +.mq-detail-title{ + font-weight:700; + margin-bottom: 12px; +} + +.mq-detail-meta{ + border:1px solid #e6e6e6; + border-radius:8px; + overflow:hidden; + margin-bottom: 14px; +} + +.mq-meta-row{ + display:grid; + grid-template-columns: 1fr 1fr 1fr; +} + +.mq-meta-th{ + background:#f6f7f8; + padding: 10px 12px; + font-weight:600; + text-align:center; + border-right:1px solid #e6e6e6; +} +.mq-meta-th:last-child{border-right:none;} + +.mq-meta-row--val .mq-meta-td{ + padding: 12px; + text-align:center; + border-right:1px solid #e6e6e6; + background:#fff; +} +.mq-meta-row--val .mq-meta-td:last-child{border-right:none;} + +.mq-cards{ + display:grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} + +.mq-card{ + border:1px solid #e6e6e6; + border-radius:8px; + overflow:hidden; + background:#fff; +} + +.mq-card-head{ + padding: 12px 14px; + font-weight:700; + background:#f6f7f8; + border-bottom:1px solid #e6e6e6; +} + +.mq-card-body{ + padding: 14px; + min-height: 96px; + line-height:1.6; +} + +.mq-muted{ color:#888; } + +.mq-link{ + color:#0b7b77; + text-decoration:none; +} +.mq-link:hover{ text-decoration:underline; } + +.mq-row-active td{ + background:#f2fbfa !important; +} + +.mq-pager{ + display:flex; + justify-content:center; + margin-top: 14px; +} + +.mq-yearform{ margin:0; } +.mq-yearselect{ + height: 34px; + border:1px solid #e6e6e6; + border-radius:8px; + padding:0 10px; + background:#fff; +} +/* ===== table (bootstrap 없이도 보이게) ===== */ +.mypage-qna-page .table-responsive{ + overflow-x: auto; +} + +.mypage-qna-page table.table{ + width: 100%; + border-collapse: collapse; + background: #fff; +} + +.mypage-qna-page .table thead th{ + background: #f6f7f8; + font-weight: 700; + padding: 12px 10px; + border: 1px solid #e6e6e6; +} + +.mypage-qna-page .table tbody td{ + padding: 12px 10px; + border: 1px solid #e6e6e6; + vertical-align: middle; +} + +.mypage-qna-page .table tbody tr:nth-child(even) td{ + background: #fafafa; +} + +/* text helpers (bootstrap 대체) */ +.mypage-qna-page .text-start{ text-align:left; } +.mypage-qna-page .text-center{ text-align:center; } +.mypage-qna-page .text-nowrap{ white-space:nowrap; } + +/* ===== badge (bootstrap 없이도 보이게) ===== */ +.mypage-qna-page .badge{ + display: inline-block; + padding: 5px 9px; + border-radius: 6px; + font-size: 12px; + line-height: 1; + font-weight: 700; + color: #fff; +} + +/* config/qna_state.php에서 쓰는 클래스들을 직접 스타일링 */ +.mypage-qna-page .bg-primary{ background:#0d6efd; } +.mypage-qna-page .bg-success{ background:#198754; } +.mypage-qna-page .bg-info{ background:#0dcaf0; } +.mypage-qna-page .bg-warning{ background:#ffc107; } +.mypage-qna-page .bg-dark{ background:#212529; } + +.mypage-qna-page .text-dark{ color:#212529 !important; } + +/* ====== Responsive: 모바일 카드 리스트 ====== */ +.mq-cards-mobile{ display:none; } + +.mq-mcard{ + display:block; + border:1px solid #e6e6e6; + border-radius:12px; + padding:12px 12px; + background:#fff; + text-decoration:none; + color:inherit; + margin-bottom:10px; +} + +.mq-mcard.is-active{ + border-color:#0b7b77; + box-shadow: 0 0 0 2px rgba(11,123,119,.08); +} + +.mq-mcard-top{ + display:flex; + justify-content:space-between; + align-items:center; + margin-bottom:8px; +} + +.mq-mno{ + font-weight:700; + font-size:13px; + color:#333; +} + +.mq-mtitle{ + font-weight:700; + font-size:15px; + line-height:1.35; + margin-bottom:10px; + color:#0b7b77; +} + +.mq-mmeta{ + display:flex; + flex-direction:column; + gap:6px; +} + +.mq-mmeta-row{ + display:flex; + justify-content:space-between; + gap:10px; + font-size:13px; +} + +.mq-mmeta-k{ + color:#666; + white-space:nowrap; +} + +.mq-mmeta-v{ + color:#111; + text-align:right; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + max-width: 70%; +} + +.mq-empty-mobile{ + border:1px dashed #e6e6e6; + border-radius:12px; + padding:18px 14px; + text-align:center; + color:#777; + background:#fff; +} + +/* ====== breakpoint ====== */ +@media (max-width: 768px){ + .mypage-qna-page .mq-topbar{ + gap:10px; + } + + /* 모바일: 테이블 숨김, 카드 표시 */ + .mq-table-wrap{ display:none; } + .mq-cards-mobile{ display:block; } + + /* 연도 셀렉트/버튼도 모바일에서 자연스럽게 */ + .mypage-qna-page .mq-topbar{ + justify-content:space-between; + } + + .mq-yearselect{ height:34px; } + .mypage-qna-page .mq-btn{ + padding:8px 12px; + font-size:14px; + } +} + + + +/* ===== PC 테이블 글자/간격 축소 ===== */ +.mypage-qna-page .mq-list table.table{ + font-size: 13px; /* 기본보다 살짝 작게 */ +} + +.mypage-qna-page .mq-list .table thead th{ + font-size: 13px; + padding: 10px 8px; /* 헤더 높이 줄이기 */ +} + +.mypage-qna-page .mq-list .table tbody td{ + font-size: 13px; + padding: 10px 8px; /* 행 높이 줄이기 */ +} + +/* 상태 배지도 같이 살짝 줄이기 */ +.mypage-qna-page .mq-list .badge{ + font-size: 11px; + padding: 4px 8px; + border-radius: 6px; +} +/* ===== show(상세) 모바일 가독성 개선 ===== */ +@media (max-width: 768px){ + + /* 상세 박스 패딩 살짝 축소 */ + .mypage-qna-page .mq-detail{ + padding: 12px; + } + + /* ✅ 메타(문의분류/처리상태/등록일) : 3컬럼 -> 세로 카드형 */ + .mq-detail-meta{ + border-radius: 10px; + } + + /* 헤더 row 숨기고(라벨은 각 항목에 붙임) */ + .mq-detail-meta .mq-meta-row:first-child{ + display:none; + } + + /* 값 row를 1컬럼으로 */ + .mq-detail-meta .mq-meta-row.mq-meta-row--val{ + display: grid; + grid-template-columns: 1fr; + } + + .mq-detail-meta .mq-meta-row--val .mq-meta-td{ + border-right: none !important; + border-bottom: 1px solid #e6e6e6; + text-align: left; + padding: 12px; + } + .mq-detail-meta .mq-meta-row--val .mq-meta-td:last-child{ + border-bottom: none; + } + + /* 각 값 앞에 라벨을 붙여서 “한줄 요약” 형태로 */ + .mq-detail-meta .mq-meta-row--val .mq-meta-td:nth-child(1)::before{ + content:"문의분류"; + display:block; + font-size:12px; + color:#666; + margin-bottom:6px; + font-weight:600; + } + .mq-detail-meta .mq-meta-row--val .mq-meta-td:nth-child(2)::before{ + content:"처리상태"; + display:block; + font-size:12px; + color:#666; + margin-bottom:6px; + font-weight:600; + } + .mq-detail-meta .mq-meta-row--val .mq-meta-td:nth-child(3)::before{ + content:"등록일"; + display:block; + font-size:12px; + color:#666; + margin-bottom:6px; + font-weight:600; + } + + /* 긴 문자열 줄바꿈 개선(문의분류/등록일 등) */ + .mq-detail-meta .mq-meta-td{ + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; + } + + /* ✅ 본문 카드: 2컬럼 -> 1컬럼 */ + .mq-cards{ + grid-template-columns: 1fr !important; + gap: 10px; + } + + .mq-card-head{ + padding: 10px 12px; + font-size: 14px; + } + + .mq-card-body{ + padding: 12px; + font-size: 13px; + min-height: auto; + word-break: break-word; + overflow-wrap: anywhere; + } + + /* 배지 크기/줄바꿈 안전 */ + .mypage-qna-page .badge{ + font-size: 11px; + padding: 4px 8px; + white-space: nowrap; + } +} diff --git a/resources/css/web.css b/resources/css/web.css index 5629e4f..cff8f14 100644 --- a/resources/css/web.css +++ b/resources/css/web.css @@ -3310,304 +3310,6 @@ body.is-drawer-open{ .guide-card__icon{ color: rgba(11,99,255,.92); } - -/* ========================================================= - CS QnA (UI only) — refined color + modern -========================================================= */ -:root{ - --q-brand: #0b63ff; - --q-ink: rgba(0,0,0,.88); - --q-muted: rgba(0,0,0,.62); - --q-line: rgba(0,0,0,.10); - --q-soft: rgba(11,99,255,.08); - --q-soft2: rgba(255,199,0,.10); - --q-shadow: 0 10px 28px rgba(0,0,0,.07); - --q-shadow2: 0 14px 34px rgba(0,0,0,.10); -} - -.qna{ margin-top: 12px; } - -/* Top */ -.qna-top{ - display:flex; - align-items:center; - justify-content:space-between; - gap:12px; - margin-bottom: 12px; -} - -.qna-top__hint{ - flex:1; - border:1px solid rgba(11,99,255,.14); - background: - radial-gradient(900px 240px at 0% 0%, rgba(11,99,255,.14), transparent 55%), - radial-gradient(900px 240px at 100% 0%, rgba(255,199,0,.10), transparent 55%), - linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); - border-radius: 18px; - padding:12px 12px; - font-weight:850; - color: rgba(0,0,0,.76); - line-height:1.5; - box-shadow: 0 8px 22px rgba(0,0,0,.05); -} - -.qna-pill{ - display:inline-flex; - align-items:center; - height:26px; - padding:0 12px; - border-radius:999px; - background: linear-gradient(135deg, var(--q-brand), #2aa6ff); - color:#fff; - font-weight:950; - margin-right:8px; - letter-spacing:-0.02em; - box-shadow: 0 10px 22px rgba(11,99,255,.22); -} - -/* Card */ -.qna-card{ - border:1px solid var(--q-line); - background:#fff; - border-radius: 20px; - overflow:hidden; - box-shadow: var(--q-shadow); -} - -.qna-card__head{ - padding:15px 15px 13px; - border-bottom:1px solid rgba(0,0,0,.08); - background: - radial-gradient(900px 260px at 0% 0%, rgba(11,99,255,.10), transparent 55%), - linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); -} - -.qna-card__title{ - margin:0; - font-size: 16px; - font-weight: 950; - letter-spacing:-0.03em; - color: var(--q-ink); -} - -.qna-card__desc{ - margin:7px 0 0; - color: var(--q-muted); - line-height: 1.65; - font-weight: 650; -} - -/* Form */ -.qna-form{ padding:15px; } - -.qna-grid{ - display:grid; - grid-template-columns: 1fr 1fr; - gap:12px; -} - -.qna-field--mt{ margin-top: 12px; } - -.qna-label{ - display:flex; - align-items:center; - gap:6px; - font-weight: 950; - letter-spacing:-0.02em; - color: var(--q-ink); -} - -.qna-req{ color:#d14b4b; font-weight: 950; } -.qna-sub{ color: rgba(0,0,0,.55); font-weight: 850; } - -/* Inputs */ -.qna-input{ - margin-top:8px; - width:100%; - height:46px; - border-radius:16px; - border:1px solid rgba(0,0,0,.10); - background:#fff; - padding:0 12px; - font-weight:850; - outline:none; - transition: box-shadow .15s ease, border-color .15s ease, background .15s ease; -} - -.qna-input:focus{ - border-color: rgba(11,99,255,.30); - box-shadow: 0 0 0 4px rgba(11,99,255,.12); -} - -.qna-textarea{ - margin-top:8px; - width:100%; - min-height: 180px; - border-radius:16px; - border:1px solid rgba(0,0,0,.10); - background:#fff; - padding:12px; - font-weight:750; - line-height:1.75; - outline:none; - resize: vertical; - transition: box-shadow .15s ease, border-color .15s ease; -} - -.qna-textarea:focus{ - border-color: rgba(11,99,255,.30); - box-shadow: 0 0 0 4px rgba(11,99,255,.12); -} - -.qna-help{ - margin-top:8px; - color: rgba(0,0,0,.64); - font-weight:650; - line-height:1.55; -} - -/* File */ -.qna-file{ - margin-top:8px; - width:100%; - height:46px; - padding: 9px 10px; - border-radius:16px; - border:1px solid rgba(0,0,0,.10); - background: rgba(0,0,0,.02); - font-weight:850; -} - -.qna-filelist{ - margin-top:10px; - display:flex; - flex-direction:column; - gap:7px; -} - -.qna-filelist__item{ - padding:9px 11px; - border-radius:16px; - border:1px solid rgba(11,99,255,.12); - background: rgba(11,99,255,.05); - font-weight:850; - color: rgba(0,0,0,.76); -} - -.qna-filelist__warn{ - padding:10px 12px; - border-radius:16px; - border:1px dashed rgba(0,0,0,.20); - background: rgba(0,0,0,.02); - color: rgba(0,0,0,.68); - font-weight:950; -} - -/* Choice */ -.qna-choice{ - margin-top:10px; - display:flex; - flex-wrap:wrap; - gap:10px 14px; - align-items:center; -} - -.qna-check, .qna-radio{ - display:flex; - align-items:center; - gap:8px; - font-weight:850; - color: rgba(0,0,0,.78); -} - -.qna-check input, .qna-radio input{ transform: translateY(1px); } - -/* Recap placeholder */ -.qna-recap{ - display:flex; - align-items:center; - justify-content:space-between; - gap:10px; - border:1px solid rgba(11,99,255,.14); - background: - radial-gradient(900px 240px at 0% 0%, rgba(11,99,255,.12), transparent 55%), - linear-gradient(135deg, rgba(255,255,255,1), rgba(0,0,0,.01)); - border-radius:18px; - padding:12px; -} - -.qna-recap__badge{ - display:inline-flex; - align-items:center; - height:28px; - padding:0 12px; - border-radius:999px; - background: rgba(0,0,0,.86); - color:#fff; - font-weight:950; -} - -.qna-recap__text{ - color: rgba(0,0,0,.70); - font-weight:750; - line-height:1.55; -} - -/* Actions */ -.qna-actions{ - margin-top: 14px; - display:flex; - flex-direction:column; - align-items:center; - gap:10px; -} - -.qna-btn{ - display:inline-flex; - align-items:center; - justify-content:center; - height:46px; - padding:0 14px; - border-radius:16px; - text-decoration:none; - font-weight:950; - border:1px solid rgba(0,0,0,.10); - background:#fff; - color: rgba(0,0,0,.86); - cursor:pointer; - transition: transform .12s ease, box-shadow .12s ease, background .12s ease, border-color .12s ease; -} - -.qna-btn:hover{ - transform: translateY(-1px); - box-shadow: 0 12px 26px rgba(0,0,0,.10); -} - -.qna-btn--primary{ - width:min(360px, 100%); - background: linear-gradient(135deg, var(--q-brand), #2aa6ff); - color:#fff; - border-color: rgba(11,99,255,.18); -} - -.qna-btn--ghost{ - background: rgba(0,0,0,.02); -} - -.qna-top__actions .qna-btn{ height:42px; } - -.qna-actions__note{ - color: rgba(0,0,0,.58); - font-weight:750; -} - -/* Responsive */ -@media (max-width: 960px){ - .qna-top{ flex-direction:column; align-items:stretch; } - .qna-grid{ grid-template-columns: 1fr; } -} - - /* ========================================================= AUTH (Clean & Centered) - delete your old Auth Layout/Auth Forms block and paste this diff --git a/resources/views/mail/admin/qna_created.blade.php b/resources/views/mail/admin/qna_created.blade.php new file mode 100644 index 0000000..7efc8c4 --- /dev/null +++ b/resources/views/mail/admin/qna_created.blade.php @@ -0,0 +1,58 @@ +@extends('mail.layouts.base') + +@section('content') + +
+
+ {{ $title ?? '[PIN FOR YOU] 1:1 문의 등록 알림' }} +
+
+ +
+ 1:1 문의가 새로 등록되었습니다.
+ 아래 내용을 확인해 주세요. +
+ +
+
+
회원번호 : {{ $mem_no ?? '-' }}
+
요청자 : {{ $name ?: '-' }}
+
이메일 : {{ $email ?: '-' }}
+
휴대폰 : {{ $cell ?: '-' }}
+
등록 IP : {{ $ip ?: '-' }}
+
분류코드 : {{ $enquiry_code ?? '-' }}
+
제목 : {{ $enquiry_title ?? '-' }}
+
회신요청 : {{ $return_type ?? '-' }}
+
등록시간 : {{ $created_at ?? '-' }}
+
+
+ +
+ +
+ 문의 내용 +
+
+ +
+
+ {{ $enquiry_content ?? '-' }} +
+
+ + @if(!empty($adminUrl)) +
+ + 관리자에서 확인하기 + + @endif + +
+ +
+ 이 메일은 시스템 알림입니다. +
+
+@endsection diff --git a/resources/views/web/auth/find_password.blade.php b/resources/views/web/auth/find_password.blade.php index 2be4aba..9f42287 100644 --- a/resources/views/web/auth/find_password.blade.php +++ b/resources/views/web/auth/find_password.blade.php @@ -350,7 +350,7 @@ setBtnBusy(targetBtn, true); try { - // ✅ 요청 직전에 토큰 생성해서 body에 포함 + // 요청 직전에 토큰 생성해서 body에 포함 const token = await getRecaptchaToken('find_pass'); if (!token) { diff --git a/resources/views/web/cs/qna/index.blade.php b/resources/views/web/cs/qna/index.blade.php index 4fca4c0..facbfd1 100644 --- a/resources/views/web/cs/qna/index.blade.php +++ b/resources/views/web/cs/qna/index.blade.php @@ -33,6 +33,15 @@ @section('meta_description', '핀포유 1:1 문의 페이지입니다. 결제/발송/코드 확인 등 상세 문의를 접수하면 순차적으로 답변드립니다.') @section('canonical', url('/cs/qna')) +@push('recaptcha') + + + +@endpush +@push('styles') + +@endpush + @section('subcontent') @include('web.partials.content-head', [ 'title' => '1:1 문의 접수', @@ -49,8 +58,9 @@ @@ -62,17 +72,24 @@

답변 속도를 높이려면 “주문시각/결제수단/금액/오류문구”를 같이 적어주세요.

-
+ @csrf + + {{-- Subject row --}}
+
가장 가까운 분류를 선택해 주세요.
@@ -81,33 +98,24 @@
-
제목은 60자 이내로 작성해 주세요.
+ maxlength="30" placeholder="문의 제목을 넣어주세요." + value="{{ old('enquiry_title') }}" + required> +
제목은 30자 이내로 작성해 주세요.
-
+
{{-- Content --}}
+ required>{{ old('enquiry_content') }}
정확한 안내를 위해 개인정보(주민번호/전체 카드번호 등)는 작성하지 마세요.
- {{-- Upload --}} -
- - -
- 업로드 가능 확장자: .png, .jpeg, .jpg, .gif · 용량이 큰 파일은 업로드에 시간이 걸릴 수 있어요. -
-
-
- {{-- Reply options --}}
추가회신
@@ -130,18 +138,10 @@
현재는 UI만 제공되며, 실제 알림 연동은 추후 적용됩니다.
- {{-- Recaptcha placeholder --}} -
-
-
reCAPTCHA
-
스팸 방지 기능은 추후 적용 예정입니다.
-
-
{{-- Submit --}}
-
현재는 저장 기능이 준비 중입니다. (UI 확인용)
@@ -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_code_name ?? $detail->enquiry_code ?? '-' }}
+
+ {{ $stLabel }} +
+
{{ $detail->regdate ? substr($detail->regdate, 0, 16) : '-' }}
+
+
+ +
+
+
+ 제목 : {{ $detail->enquiry_title ?? '-' }} +
+
+ {!! nl2br(e($detail->enquiry_content ?? '')) !!} +
+
+ +
+
관리자 답변
+
+ @if(trim((string)($detail->answer_content ?? '')) !== '') + {!! nl2br(e($detail->answer_content)) !!} + @else + 아직 답변이 등록되지 않았습니다. + @endif +
+
+
+
+ @endif + + {{-- 리스트 --}} +
+ {{-- ✅ 데스크톱 테이블 --}} +
+
+ + + + + + + + + + + + + @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 + + + + + + + + @empty + + + + @endforelse + +
NO.상태접수분류제목접수시간
{{ $no }}{{ $stLabel }}{{ $row->enquiry_code_name ?? $row->enquiry_code ?? '-' }} + + {{ $row->enquiry_title ?? '-' }} + + {{ $row->regdate ? substr($row->regdate, 0, 16) : '-' }}
등록된 문의가 없습니다.
+
+
+ + {{-- ✅ 모바일 카드 리스트 --}} +
+ @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. {{ $no }} + {{ $stLabel }} +
+ +
+ {{ $row->enquiry_title ?? '-' }} +
+ +
+
+ 접수분류 + {{ $row->enquiry_code_name ?? $row->enquiry_code ?? '-' }} +
+
+ 접수시간 + {{ $row->regdate ? substr($row->regdate, 0, 16) : '-' }} +
+
+
+ + @empty +
+ 등록된 문의가 없습니다. +
+ @endforelse +
+ + @if($items->hasPages()) +
+ {{ $items->links('web.partials.pagination') }} +
+ @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'); }); /*