QNA 등록 이메일 발송 db 개발/운영 정리 기타 수정 스케줄러 테스트
This commit is contained in:
parent
2905c22f26
commit
494d95327a
@ -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, [
|
||||
|
||||
165
app/Http/Controllers/Web/Cs/CsQnaController.php
Normal file
165
app/Http/Controllers/Web/Cs/CsQnaController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
64
app/Http/Controllers/Web/Mypage/MypageQnaController.php
Normal file
64
app/Http/Controllers/Web/Mypage/MypageQnaController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
68
app/Jobs/SendAdminQnaCreatedMailJob.php
Normal file
68
app/Jobs/SendAdminQnaCreatedMailJob.php
Normal 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(),
|
||||
]);
|
||||
// 한 명 실패해도 나머지 계속 시도
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/Jobs/WorkerHeartbeatJob.php
Normal file
24
app/Jobs/WorkerHeartbeatJob.php
Normal 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());
|
||||
}
|
||||
}
|
||||
37
app/Models/CounselingOneOnOne.php
Normal file
37
app/Models/CounselingOneOnOne.php
Normal 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
149
app/Services/QnaService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
],
|
||||
|
||||
|
||||
|
||||
20
config/qna_state.php
Normal file
20
config/qna_state.php
Normal 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
120
docs/ops/docker-runbook.md
Normal 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}}"
|
||||
401
public/assets/css/cs_qna.css
Normal file
401
public/assets/css/cs_qna.css
Normal 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; }
|
||||
}
|
||||
385
public/assets/css/mypage_qna.css
Normal file
385
public/assets/css/mypage_qna.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
58
resources/views/mail/admin/qna_created.blade.php
Normal file
58
resources/views/mail/admin/qna_created.blade.php
Normal 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
|
||||
@ -350,7 +350,7 @@
|
||||
setBtnBusy(targetBtn, true);
|
||||
|
||||
try {
|
||||
// ✅ 요청 직전에 토큰 생성해서 body에 포함
|
||||
// 요청 직전에 토큰 생성해서 body에 포함
|
||||
const token = await getRecaptchaToken('find_pass');
|
||||
|
||||
if (!token) {
|
||||
|
||||
@ -33,6 +33,15 @@
|
||||
@section('meta_description', '핀포유 1:1 문의 페이지입니다. 결제/발송/코드 확인 등 상세 문의를 접수하면 순차적으로 답변드립니다.')
|
||||
@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')
|
||||
@include('web.partials.content-head', [
|
||||
'title' => '1:1 문의 접수',
|
||||
@ -49,8 +58,9 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -62,17 +72,24 @@
|
||||
<p class="qna-card__desc">답변 속도를 높이려면 “주문시각/결제수단/금액/오류문구”를 같이 적어주세요.</p>
|
||||
</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
|
||||
<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 --}}
|
||||
<div class="qna-grid">
|
||||
|
||||
<div class="qna-field">
|
||||
<label class="qna-label" for="enquiry_code">문의분류 <span class="qna-req">*</span></label>
|
||||
<select class="qna-input" id="enquiry_code" name="enquiry_code" required>
|
||||
<option value="">문의분류선택</option>
|
||||
@foreach($enquiryCategories as $c)
|
||||
<option value="{{ $c['key'] }}">{{ $c['label'] }}</option>
|
||||
@foreach(($enquiryCodes ?? []) as $c)
|
||||
<option value="{{ $c['code'] }}" @selected(old('enquiry_code') == $c['code'])>
|
||||
{{ $c['title'] }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<div class="qna-help">가장 가까운 분류를 선택해 주세요.</div>
|
||||
@ -81,33 +98,24 @@
|
||||
<div class="qna-field">
|
||||
<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"
|
||||
maxlength="60" placeholder="예) 결제는 완료됐는데 코드가 안 보여요" required>
|
||||
<div class="qna-help">제목은 60자 이내로 작성해 주세요.</div>
|
||||
maxlength="30" placeholder="문의 제목을 넣어주세요."
|
||||
value="{{ old('enquiry_title') }}"
|
||||
required>
|
||||
<div class="qna-help">제목은 30자 이내로 작성해 주세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{-- Content --}}
|
||||
<div class="qna-field qna-field--mt">
|
||||
<label class="qna-label" for="enquiry_content">문의 내용 <span class="qna-req">*</span></label>
|
||||
<textarea class="qna-textarea" id="enquiry_content" name="enquiry_content"
|
||||
placeholder="문제 상황을 자세히 적어주세요. 예) 주문시각/결제수단/금액/오류메시지/상품명"
|
||||
required></textarea>
|
||||
required>{{ old('enquiry_content') }}</textarea>
|
||||
<div class="qna-help">
|
||||
정확한 안내를 위해 개인정보(주민번호/전체 카드번호 등)는 작성하지 마세요.
|
||||
</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 --}}
|
||||
<div class="qna-field qna-field--mt">
|
||||
<div class="qna-label">추가회신</div>
|
||||
@ -130,18 +138,10 @@
|
||||
<div class="qna-help">현재는 UI만 제공되며, 실제 알림 연동은 추후 적용됩니다.</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 --}}
|
||||
<div class="qna-actions">
|
||||
<button class="qna-btn qna-btn--primary" type="submit">문의등록</button>
|
||||
<div class="qna-actions__note">현재는 저장 기능이 준비 중입니다. (UI 확인용)</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -153,35 +153,81 @@
|
||||
<script>
|
||||
(() => {
|
||||
const form = document.getElementById('qnaForm');
|
||||
const fileInput = document.getElementById('screenshots');
|
||||
const fileList = document.getElementById('fileList');
|
||||
if (!form) return;
|
||||
|
||||
// 파일 리스트 표시(최대 4장)
|
||||
const renderFiles = () => {
|
||||
if (!fileList || !fileInput) return;
|
||||
const files = Array.from(fileInput.files || []);
|
||||
fileList.innerHTML = '';
|
||||
const btn = form.querySelector('button[type="submit"]');
|
||||
|
||||
const shown = files.slice(0, 4);
|
||||
if (files.length > 4) {
|
||||
fileList.innerHTML = '<div class="qna-filelist__warn">최대 4장까지만 업로드할 수 있어요. 처음 4장만 표시됩니다.</div>';
|
||||
function ensureHiddenRecaptcha(){
|
||||
let el = document.getElementById('g-recaptcha-response');
|
||||
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 => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'qna-filelist__item';
|
||||
el.textContent = `• ${f.name}`;
|
||||
fileList.appendChild(el);
|
||||
});
|
||||
};
|
||||
async function getRecaptchaToken(action){
|
||||
if (typeof window.recaptchaV3Exec === 'function') {
|
||||
try { return (await window.recaptchaV3Exec(action)) || ''; } catch(e){ return ''; }
|
||||
}
|
||||
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);
|
||||
|
||||
// 임시 submit(저장 기능 추후)
|
||||
form?.addEventListener('submit', (e) => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
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>
|
||||
@endpush
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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')
|
||||
<link rel="stylesheet" href="{{ asset('assets/css/mypage_qna.css') }}?v={{ config('app.version', time()) }}">
|
||||
@endpush
|
||||
|
||||
@section('subcontent')
|
||||
<div class="mypage-qna-page">
|
||||
|
||||
@include('web.partials.content-head', [
|
||||
'title' => '1:1문의내역',
|
||||
'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')
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@ -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,
|
||||
// ]);
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user