QNA 등록 이메일 발송 db 개발/운영 정리 기타 수정 스케줄러 테스트

This commit is contained in:
sungro815 2026-02-03 17:43:42 +09:00
parent 2905c22f26
commit 494d95327a
19 changed files with 1953 additions and 376 deletions

View File

@ -27,8 +27,6 @@ final class LoginController extends Controller
if (app()->environment('production')) { if (app()->environment('production')) {
$rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')]; $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')];
var_dump('google');
exit;
} }
$v = Validator::make($request->all(), $rules, [ $v = Validator::make($request->all(), $rules, [

View File

@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers\Web\Cs;
use App\Http\Controllers\Controller;
use App\Rules\RecaptchaV3Rule;
use App\Services\QnaService;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
final class CsQnaController extends Controller
{
public function __construct(
private readonly QnaService $qnaService
) {}
// 글쓰기 폼
public function create(Request $request)
{
$codes = $this->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 = [
'<script', '</script', '<iframe', '<object', '<embed',
'javascript:', 'data:text/html', 'vbscript:',
'onerror=', 'onload=', 'onclick=', 'onmouseover=',
];
foreach ($patterns as $p) {
if (str_contains($lower, $p)) return true;
}
// 특수한 XSS 벡터 (html entity 우회)도 일부 방어
if (preg_match('/&#x?0*3c;.*script/i', $s)) return true; // "<script" 엔티티 우회
return false;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Web\Mypage;
use App\Http\Controllers\Controller;
use App\Services\QnaService;
use Illuminate\Http\Request;
final class MypageQnaController extends Controller
{
public function __construct(
private readonly QnaService $qnaService
) {}
public function index(Request $request)
{
$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);
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;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Jobs;
use App\Services\MailService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SendAdminQnaCreatedMailJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public function __construct(
public int $memNo,
public int $qnaId,
public array $data = []
) {}
// 문의 등록이 되면 db처리 후 관리자 메일 발송만 큐에 담아 순차적으로 발송한다.
public function handle(MailService $mail): void
{
//관리자 이메일
$adminEmails = ['sungro815@syye.net', 'rudals1540@plusmaker.co.kr'];
foreach ($adminEmails as $to) {
try {
$mail->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(),
]);
// 한 명 실패해도 나머지 계속 시도
}
}
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
final class WorkerHeartbeatJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public string $queue = 'default';
public int $tries = 1;
public int $timeout = 20;
public function handle(): void
{
Log::info('[worker-heartbeat] ok '.now());
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
final class CounselingOneOnOne extends Model
{
// ⚠️ 기본 table은 의미 없음. queryForYear()로 강제 지정해서 씀.
protected $table = 'counseling_one_on_one_'.'0000';
protected $primaryKey = 'seq';
public $incrementing = true;
public $timestamps = false;
protected $guarded = [];
public static function queryForYear(int $year): Builder
{
$m = new static();
$m->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;
}
}
}

149
app/Services/QnaService.php Normal file
View File

@ -0,0 +1,149 @@
<?php
namespace App\Services;
use App\Models\CounselingOneOnOne;
use App\Jobs\SendAdminQnaCreatedMailJob;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Str;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as Paginator;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
final class QnaService
{
public function paginateMyQna(int $memNo, int $perPage = 10, int $year = 0): LengthAwarePaginator
{
$year = $year > 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;
}
}

View File

@ -16,8 +16,7 @@ return [
| |
*/ */
'default' => env('DB_CONNECTION', 'sqlite'), 'default' => env('DB_CONNECTION', 'default'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Database Connections | Database Connections
@ -31,42 +30,40 @@ return [
'connections' => [ 'connections' => [
'mariadb' => [ 'default' => [
'driver' => 'mariadb', 'driver' => env('DB_CONNECTION', 'mysql'),
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'), 'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'), 'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'), 'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'), 'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''), 'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4',
'charset' => env('DB_CHARSET', 'utf8mb4'), 'collation' => 'utf8mb4_unicode_ci',
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 'strict' => true,
'prefix' => '', ],
'prefix_indexes' => 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, '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' => [ 'sms_server' => [
'driver' => 'mysql', 'driver' => env('SMS_DB_CONNECTION', 'mysql'),
'host' => env('SMS_DB_HOST', '127.0.0.1'), 'host' => env('SMS_DB_HOST', '127.0.0.1'),
'port' => env('SMS_DB_PORT', '3306'), 'port' => env('SMS_DB_PORT', '3306'),
'database' => env('SMS_DB_DATABASE', 'lguplus'), 'database' => env('SMS_DB_DATABASE', 'lguplus'),
'username' => env('SMS_DB_USERNAME', 'lguplus'), 'username' => env('SMS_DB_USERNAME', 'lguplus'),
'password' => env('SMS_DB_PASSWORD', ''), 'password' => env('SMS_DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4', 'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci', 'collation' => 'utf8mb4_unicode_ci',
'prefix' => '', 'strict' => false, // 외부 DB면 strict 끄는거 OK
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
], ],

20
config/qna_state.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| QnA state map
|--------------------------------------------------------------------------
| state => [label, badge_class]
*/
'state_map' => [
'a' => ['접수', 'bg-primary'],
'b' => ['접수완료', 'bg-success'],
'c' => ['처리중', 'bg-info'],
'd' => ['처리중', 'bg-warning text-dark'],
'e' => ['완료', 'bg-dark'],
],
];

120
docs/ops/docker-runbook.md Normal file
View File

@ -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://<docker-host>:8091` (gifticon-web)
- `https://myworld.syye.net``http://<docker-host>: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}}"

View File

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

View File

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

View File

@ -3310,304 +3310,6 @@ body.is-drawer-open{
.guide-card__icon{ color: rgba(11,99,255,.92); } .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) AUTH (Clean & Centered)
- delete your old Auth Layout/Auth Forms block and paste this - delete your old Auth Layout/Auth Forms block and paste this

View File

@ -0,0 +1,58 @@
@extends('mail.layouts.base')
@section('content')
<div style="font-family:Apple SD Gothic Neo,Malgun Gothic,Segoe UI,Arial,sans-serif;color:#101828;">
<div style="font-size:18px;font-weight:800;letter-spacing:-.2px;">
{{ $title ?? '[PIN FOR YOU] 1:1 문의 등록 알림' }}
</div>
<div style="height:10px"></div>
<div style="font-size:13px;line-height:1.8;color:#344054;">
1:1 문의가 새로 등록되었습니다.<br>
아래 내용을 확인해 주세요.
</div>
<div style="background:#F2F4F7;border:1px solid #EAECF0;border-radius:14px;padding:14px 14px;">
<div style="font-size:13px;line-height:1.9;color:#101828;">
<div><span style="color:#667085;">회원번호</span> : <strong>{{ $mem_no ?? '-' }}</strong></div>
<div><span style="color:#667085;">요청자</span> : <strong>{{ $name ?: '-' }}</strong></div>
<div><span style="color:#667085;">이메일</span> : {{ $email ?: '-' }}</div>
<div><span style="color:#667085;">휴대폰</span> : {{ $cell ?: '-' }}</div>
<div><span style="color:#667085;">등록 IP</span> : {{ $ip ?: '-' }}</div>
<div><span style="color:#667085;">분류코드</span> : {{ $enquiry_code ?? '-' }}</div>
<div><span style="color:#667085;">제목</span> : <strong>{{ $enquiry_title ?? '-' }}</strong></div>
<div><span style="color:#667085;">회신요청</span> : {{ $return_type ?? '-' }}</div>
<div><span style="color:#667085;">등록시간</span> : {{ $created_at ?? '-' }}</div>
</div>
</div>
<div style="height:14px"></div>
<div style="font-size:12px;line-height:1.7;color:#667085;">
문의 내용
</div>
<div style="height:8px"></div>
<div style="background:#FFFFFF;border:1px solid #EAECF0;border-radius:14px;padding:14px 14px;">
<div style="font-size:13px;line-height:1.8;color:#101828;white-space:pre-wrap;word-break:break-word;">
{{ $enquiry_content ?? '-' }}
</div>
</div>
@if(!empty($adminUrl))
<div style="height:16px"></div>
<a href="{{ $adminUrl }}"
style="display:inline-block;background:{{ $accent ?? '#E4574B' }};color:#fff;text-decoration:none;
font-size:13px;font-weight:700;padding:10px 14px;border-radius:12px;">
관리자에서 확인하기
</a>
@endif
<div style="height:14px"></div>
<div style="font-size:12px;line-height:1.7;color:#667085;">
메일은 시스템 알림입니다.
</div>
</div>
@endsection

View File

@ -350,7 +350,7 @@
setBtnBusy(targetBtn, true); setBtnBusy(targetBtn, true);
try { try {
// 요청 직전에 토큰 생성해서 body에 포함 // 요청 직전에 토큰 생성해서 body에 포함
const token = await getRecaptchaToken('find_pass'); const token = await getRecaptchaToken('find_pass');
if (!token) { if (!token) {

View File

@ -33,6 +33,15 @@
@section('meta_description', '핀포유 1:1 문의 페이지입니다. 결제/발송/코드 확인 등 상세 문의를 접수하면 순차적으로 답변드립니다.') @section('meta_description', '핀포유 1:1 문의 페이지입니다. 결제/발송/코드 확인 등 상세 문의를 접수하면 순차적으로 답변드립니다.')
@section('canonical', url('/cs/qna')) @section('canonical', url('/cs/qna'))
@push('recaptcha')
<script>window.__recaptchaSiteKey = @json(config('services.recaptcha.site_key'));</script>
<script src="https://www.google.com/recaptcha/api.js?render={{ config('services.recaptcha.site_key') }}"></script>
<script src="{{ asset('assets/js/recaptcha-v3.js') }}"></script>
@endpush
@push('styles')
<link rel="stylesheet" href="{{ asset('assets/css/cs_qna.css') }}?v={{ config('app.version', time()) }}">
@endpush
@section('subcontent') @section('subcontent')
@include('web.partials.content-head', [ @include('web.partials.content-head', [
'title' => '1:1 문의 접수', 'title' => '1:1 문의 접수',
@ -49,8 +58,9 @@
</div> </div>
<div class="qna-top__actions"> <div class="qna-top__actions">
<a class="qna-btn qna-btn--ghost" href="#" aria-disabled="true" onclick="return false;"> <a class="qna-btn qna-btn--ghost"
문의내용 확인 (준비중) href="{{ route('web.mypage.qna.index') }}">
문의내용 확인
</a> </a>
</div> </div>
</div> </div>
@ -62,17 +72,24 @@
<p class="qna-card__desc">답변 속도를 높이려면 “주문시각/결제수단/금액/오류문구”를 같이 적어주세요.</p> <p class="qna-card__desc">답변 속도를 높이려면 “주문시각/결제수단/금액/오류문구”를 같이 적어주세요.</p>
</div> </div>
<form class="qna-form" id="qnaForm" action="#" method="post" enctype="multipart/form-data" novalidate> <form class="qna-form" id="qnaForm" action="{{ route('web.cs.qna.store') }}" method="post" novalidate>
@csrf @csrf
<input type="hidden" name="g-recaptcha-response" id="g-recaptcha-response" value="">
<input type="text" name="hp" value="" autocomplete="off"
style="position:absolute; left:-9999px; width:1px; height:1px; opacity:0;"
tabindex="-1" aria-hidden="true">
{{-- Subject row --}} {{-- Subject row --}}
<div class="qna-grid"> <div class="qna-grid">
<div class="qna-field"> <div class="qna-field">
<label class="qna-label" for="enquiry_code">문의분류 <span class="qna-req">*</span></label> <label class="qna-label" for="enquiry_code">문의분류 <span class="qna-req">*</span></label>
<select class="qna-input" id="enquiry_code" name="enquiry_code" required> <select class="qna-input" id="enquiry_code" name="enquiry_code" required>
<option value="">문의분류선택</option> <option value="">문의분류선택</option>
@foreach($enquiryCategories as $c) @foreach(($enquiryCodes ?? []) as $c)
<option value="{{ $c['key'] }}">{{ $c['label'] }}</option> <option value="{{ $c['code'] }}" @selected(old('enquiry_code') == $c['code'])>
{{ $c['title'] }}
</option>
@endforeach @endforeach
</select> </select>
<div class="qna-help">가장 가까운 분류를 선택해 주세요.</div> <div class="qna-help">가장 가까운 분류를 선택해 주세요.</div>
@ -81,33 +98,24 @@
<div class="qna-field"> <div class="qna-field">
<label class="qna-label" for="enquiry_title">문의 제목 <span class="qna-req">*</span></label> <label class="qna-label" for="enquiry_title">문의 제목 <span class="qna-req">*</span></label>
<input class="qna-input" type="text" id="enquiry_title" name="enquiry_title" <input class="qna-input" type="text" id="enquiry_title" name="enquiry_title"
maxlength="60" placeholder="예) 결제는 완료됐는데 코드가 안 보여요" required> maxlength="30" placeholder="문의 제목을 넣어주세요."
<div class="qna-help">제목은 60 이내로 작성해 주세요.</div> value="{{ old('enquiry_title') }}"
</div> required>
<div class="qna-help">제목은 30 이내로 작성해 주세요.</div>
</div> </div>
</div>
{{-- Content --}} {{-- Content --}}
<div class="qna-field qna-field--mt"> <div class="qna-field qna-field--mt">
<label class="qna-label" for="enquiry_content">문의 내용 <span class="qna-req">*</span></label> <label class="qna-label" for="enquiry_content">문의 내용 <span class="qna-req">*</span></label>
<textarea class="qna-textarea" id="enquiry_content" name="enquiry_content" <textarea class="qna-textarea" id="enquiry_content" name="enquiry_content"
placeholder="문제 상황을 자세히 적어주세요.&#10;예) 주문시각/결제수단/금액/오류메시지/상품명" placeholder="문제 상황을 자세히 적어주세요.&#10;예) 주문시각/결제수단/금액/오류메시지/상품명"
required></textarea> required>{{ old('enquiry_content') }}</textarea>
<div class="qna-help"> <div class="qna-help">
정확한 안내를 위해 개인정보(주민번호/전체 카드번호 ) 작성하지 마세요. 정확한 안내를 위해 개인정보(주민번호/전체 카드번호 ) 작성하지 마세요.
</div> </div>
</div> </div>
{{-- Upload --}}
<div class="qna-field qna-field--mt">
<label class="qna-label" for="screenshots">스크린샷 <span class="qna-sub">(최대 4)</span></label>
<input class="qna-file" type="file" id="screenshots" name="screenshots[]"
accept=".png,.jpeg,.jpg,.gif" multiple>
<div class="qna-help">
업로드 가능 확장자: .png, .jpeg, .jpg, .gif · 용량이 파일은 업로드에 시간이 걸릴 있어요.
</div>
<div class="qna-filelist" id="fileList" aria-live="polite"></div>
</div>
{{-- Reply options --}} {{-- Reply options --}}
<div class="qna-field qna-field--mt"> <div class="qna-field qna-field--mt">
<div class="qna-label">추가회신</div> <div class="qna-label">추가회신</div>
@ -130,18 +138,10 @@
<div class="qna-help">현재는 UI만 제공되며, 실제 알림 연동은 추후 적용됩니다.</div> <div class="qna-help">현재는 UI만 제공되며, 실제 알림 연동은 추후 적용됩니다.</div>
</div> </div>
{{-- Recaptcha placeholder --}}
<div class="qna-field qna-field--mt">
<div class="qna-recap">
<div class="qna-recap__badge">reCAPTCHA</div>
<div class="qna-recap__text">스팸 방지 기능은 추후 적용 예정입니다.</div>
</div>
</div>
{{-- Submit --}} {{-- Submit --}}
<div class="qna-actions"> <div class="qna-actions">
<button class="qna-btn qna-btn--primary" type="submit">문의등록</button> <button class="qna-btn qna-btn--primary" type="submit">문의등록</button>
<div class="qna-actions__note">현재는 저장 기능이 준비 중입니다. (UI 확인용)</div>
</div> </div>
</form> </form>
</div> </div>
@ -153,35 +153,81 @@
<script> <script>
(() => { (() => {
const form = document.getElementById('qnaForm'); const form = document.getElementById('qnaForm');
const fileInput = document.getElementById('screenshots'); if (!form) return;
const fileList = document.getElementById('fileList');
// 파일 리스트 표시(최대 4장) const btn = form.querySelector('button[type="submit"]');
const renderFiles = () => {
if (!fileList || !fileInput) return;
const files = Array.from(fileInput.files || []);
fileList.innerHTML = '';
const shown = files.slice(0, 4); function ensureHiddenRecaptcha(){
if (files.length > 4) { let el = document.getElementById('g-recaptcha-response');
fileList.innerHTML = '<div class="qna-filelist__warn">최대 4장까지만 업로드할 수 있어요. 처음 4장만 표시됩니다.</div>'; if(!el){
el = document.createElement('input');
el.type = 'hidden';
el.id = 'g-recaptcha-response';
el.name = 'g-recaptcha-response';
form.appendChild(el);
}
return el;
} }
shown.forEach(f => { async function getRecaptchaToken(action){
const el = document.createElement('div'); if (typeof window.recaptchaV3Exec === 'function') {
el.className = 'qna-filelist__item'; try { return (await window.recaptchaV3Exec(action)) || ''; } catch(e){ return ''; }
el.textContent = `• ${f.name}`; }
fileList.appendChild(el); const siteKey = window.__recaptchaSiteKey || '';
}); if (!siteKey || typeof window.grecaptcha === 'undefined') return '';
}; try {
await new Promise(r => window.grecaptcha.ready(r));
return (await window.grecaptcha.execute(siteKey, { action })) || '';
} catch(e){
return '';
}
}
fileInput?.addEventListener('change', renderFiles); form.addEventListener('submit', async (e) => {
// 임시 submit(저장 기능 추후)
form?.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
alert('현재는 문의 저장 기능이 준비 중입니다.\nUI/디자인 확인용으로 폼만 제공됩니다.');
// 프론트 기본 폼체크(너가 이미 구현한 것 있으면 유지)
const code = (document.getElementById('enquiry_code')?.value || '').trim();
const title = (document.getElementById('enquiry_title')?.value || '').trim();
const content = (document.getElementById('enquiry_content')?.value || '').trim();
if (!code) { await showMsg("문의분류를 선택해 주세요.", { type:'alert', title:'폼체크' }); return; }
if (!title) { await showMsg("문의 제목을 입력해 주세요.", { type:'alert', title:'폼체크' }); return; }
if (!content) { await showMsg("문의 내용을 입력해 주세요.", { type:'alert', title:'폼체크' }); return; }
if (btn) btn.disabled = true;
try {
const isProd = @json(app()->environment('production'));
const hasKey = @json((bool) config('services.recaptcha.site_key'));
if (isProd && hasKey) {
const hidden = ensureHiddenRecaptcha();
hidden.value = '';
const token = await getRecaptchaToken('cs_qna_create');
hidden.value = token || '';
if (!hidden.value) {
if (btn) btn.disabled = false;
await showMsg('보안 검증(캡챠) 로딩에 실패했습니다. 새로고침 후 다시 시도해 주세요.', {
type:'alert', title:'보안검증 실패'
});
return;
}
}
form.submit();
} catch (err) {
if (btn) btn.disabled = false;
await showMsg('문의 등록 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.', {
type:'alert', title:'오류'
});
}
}); });
})(); })();
</script> </script>
@endpush @endpush

View File

@ -9,6 +9,8 @@
]; ];
$mypageActive = 'qna'; $mypageActive = 'qna';
$stateMap = config('qna_state.state_map', []);
@endphp @endphp
@extends('web.layouts.subpage') @extends('web.layouts.subpage')
@ -17,14 +19,172 @@
@section('meta_description', 'PIN FOR YOU 마이페이지 1:1문의내역 입니다. 문의 및 답변 내역을 확인하세요.') @section('meta_description', 'PIN FOR YOU 마이페이지 1:1문의내역 입니다. 문의 및 답변 내역을 확인하세요.')
@section('canonical', url('/mypage/qna')) @section('canonical', url('/mypage/qna'))
@push('styles')
<link rel="stylesheet" href="{{ asset('assets/css/mypage_qna.css') }}?v={{ config('app.version', time()) }}">
@endpush
@section('subcontent') @section('subcontent')
<div class="mypage-qna-page"> <div class="mypage-qna-page">
@include('web.partials.content-head', [ @include('web.partials.content-head', [
'title' => '1:1문의내역', 'title' => '1:1문의내역',
'desc' => '문의 처리 상태를 확인할 수 있습니다.' 'desc' => '문의 처리 상태를 확인할 수 있습니다.'
]) ])
{{-- TODO: 내용 추후 구현 --}} <div class="mq-topbar">
<form method="GET" action="{{ route('web.mypage.qna.index') }}" class="mq-yearform">
<select name="year" onchange="this.form.submit()" class="mq-yearselect" aria-label="연도 선택">
@foreach(($years ?? range(2018, (int)date('Y'))) as $y)
<option value="{{ $y }}" @selected((int)($year ?? date('Y')) === (int)$y)>
{{ $y }}
</option>
@endforeach
</select>
</form>
<a class="mq-btn" href="{{ route('web.cs.qna.index') }}">문의작성하기</a>
</div>
{{-- 상세 영역(선택된 글이 있을 때만) --}}
@if($detail)
@php
$st = (string)($detail->state ?? 'a');
[$stLabel, $stBadge] = $stateMap[$st] ?? ['접수', 'bg-primary'];
@endphp
<section class="mq-detail">
<div class="mq-detail-title">문의내용</div>
<div class="mq-detail-meta">
<div class="mq-meta-row">
<div class="mq-meta-th">문의분류</div>
<div class="mq-meta-th">처리상태</div>
<div class="mq-meta-th">등록일</div>
</div>
<div class="mq-meta-row mq-meta-row--val">
<div class="mq-meta-td">{{ $detail->enquiry_code_name ?? $detail->enquiry_code ?? '-' }}</div>
<div class="mq-meta-td">
<span class="badge {{ $stBadge }}">{{ $stLabel }}</span>
</div>
<div class="mq-meta-td">{{ $detail->regdate ? substr($detail->regdate, 0, 16) : '-' }} </div>
</div>
</div>
<div class="mq-cards">
<div class="mq-card">
<div class="mq-card-head">
제목 : {{ $detail->enquiry_title ?? '-' }}
</div>
<div class="mq-card-body">
{!! nl2br(e($detail->enquiry_content ?? '')) !!}
</div>
</div>
<div class="mq-card">
<div class="mq-card-head">관리자 답변</div>
<div class="mq-card-body">
@if(trim((string)($detail->answer_content ?? '')) !== '')
{!! nl2br(e($detail->answer_content)) !!}
@else
<span class="mq-muted">아직 답변이 등록되지 않았습니다.</span>
@endif
</div>
</div>
</div>
</section>
@endif
{{-- 리스트 --}}
<section class="mq-list">
{{-- 데스크톱 테이블 --}}
<div class="mq-table-wrap">
<div class="table-responsive">
<table class="table table-bordered align-middle text-center mb-0">
<thead class="table-light">
<tr>
<th style="width:90px;">NO.</th>
<th style="width:120px;">상태</th>
<th style="width:220px;">접수분류</th>
<th>제목</th>
<th style="width:180px;">접수시간</th>
</tr>
</thead>
<tbody>
@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
<tr class="{{ (int)($seq ?? 0) === (int)$row->seq ? 'mq-row-active' : '' }}">
<td>{{ $no }}</td>
<td><span class="badge {{ $stBadge }}">{{ $stLabel }}</span></td>
<td class="text-nowrap">{{ $row->enquiry_code_name ?? $row->enquiry_code ?? '-' }}</td>
<td class="text-start">
<a class="mq-link"
href="{{ route('web.mypage.qna.show', ['seq' => $row->seq]) }}?year={{ (int)($year ?? date('Y')) }}{{ request('page') ? '&page='.request('page') : '' }}">
{{ $row->enquiry_title ?? '-' }}
</a>
</td>
<td class="text-nowrap">{{ $row->regdate ? substr($row->regdate, 0, 16) : '-' }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-5 text-muted">등록된 문의가 없습니다.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
{{-- 모바일 카드 리스트 --}}
<div class="mq-cards-mobile">
@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
<a class="mq-mcard {{ (int)($seq ?? 0) === (int)$row->seq ? 'is-active' : '' }}"
href="{{ route('web.mypage.qna.show', ['seq' => $row->seq]) }}?year={{ (int)($year ?? date('Y')) }}{{ request('page') ? '&page='.request('page') : '' }}">
<div class="mq-mcard-top">
<span class="mq-mno">NO. {{ $no }}</span>
<span class="badge {{ $stBadge }}">{{ $stLabel }}</span>
</div>
<div class="mq-mtitle">
{{ $row->enquiry_title ?? '-' }}
</div>
<div class="mq-mmeta">
<div class="mq-mmeta-row">
<span class="mq-mmeta-k">접수분류</span>
<span class="mq-mmeta-v">{{ $row->enquiry_code_name ?? $row->enquiry_code ?? '-' }}</span>
</div>
<div class="mq-mmeta-row">
<span class="mq-mmeta-k">접수시간</span>
<span class="mq-mmeta-v">{{ $row->regdate ? substr($row->regdate, 0, 16) : '-' }}</span>
</div>
</div>
</a>
@empty
<div class="mq-empty-mobile">
등록된 문의가 없습니다.
</div>
@endforelse
</div>
@if($items->hasPages())
<div class="mq-pager">
{{ $items->links('web.partials.pagination') }}
</div>
@endif
</section>
@include('web.partials.mypage-quick-actions') @include('web.partials.mypage-quick-actions')
</div> </div>
@endsection @endsection

View File

@ -2,7 +2,184 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; 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 () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an 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,
// ]);

View File

@ -9,6 +9,8 @@ use App\Http\Controllers\Web\Auth\LoginController;
use App\Http\Controllers\Web\Mypage\InfoGateController; use App\Http\Controllers\Web\Mypage\InfoGateController;
use App\Http\Controllers\Web\Cs\NoticeController; use App\Http\Controllers\Web\Cs\NoticeController;
use App\Http\Controllers\Web\Auth\EmailVerificationController; 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'); 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::get('/notice/{seq}', [NoticeController::class, 'show'])->whereNumber('seq')->name('notice.show');
Route::view('faq', 'web.cs.faq.index')->name('faq.index'); Route::view('faq', 'web.cs.faq.index')->name('faq.index');
Route::view('kakao', 'web.cs.kakao.index')->name('kakao.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'); 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('usage', 'web.mypage.usage.index')->name('usage.index');
Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.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');
}); });
/* /*