자사핀 발행 / 핀확인 완료

This commit is contained in:
sungro815 2026-03-06 15:48:44 +09:00
parent 466cb89307
commit 23136fc1da
16 changed files with 1363 additions and 216 deletions

View File

@ -4,14 +4,65 @@ namespace App\Http\Controllers\Web\Mypage;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Services\Mypage\UsageService; use App\Services\Mypage\UsageService;
use App\Services\Payments\OwnPinIssueService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
final class UsageController extends Controller final class UsageController extends Controller
{ {
public function __construct( public function __construct(
private readonly UsageService $service, private readonly UsageService $service,
private readonly OwnPinIssueService $ownPinIssueService,
) {} ) {}
public function issuePinInstant(Request $request, int $attemptId)
{
if ((bool)session('_sess._login_') !== true) {
return redirect()->route('web.auth.login');
}
$memNo = (int)session('_sess._mno', 0);
if ($memNo <= 0) abort(403);
$out = $this->ownPinIssueService->issuePinInstant($attemptId, $memNo);
if (!($out['ok'] ?? false)) {
return redirect()->back()->with('error', (string)($out['message'] ?? '발행 실패'));
}
return redirect()->route('web.mypage.usage.show', ['attemptId' => $attemptId])
->with('success', (string)($out['message'] ?? '발행 완료'));
}
public function revealPins(Request $request, int $attemptId)
{
if ((bool)session('_sess._login_') !== true) {
return redirect()->route('web.auth.login');
}
$memNo = (int)session('_sess._mno', 0);
if ($memNo <= 0) abort(403);
$data = $request->validate([
'pin2' => ['required', 'string', 'min:4', 'max:20'],
]);
$out = $this->service->revealPins(
$attemptId,
$memNo,
(string)$data['pin2']
);
if (!($out['ok'] ?? false)) {
return redirect()
->route('web.mypage.usage.show', ['attemptId' => $attemptId])
->with('error', (string)($out['message'] ?? '핀번호 확인에 실패했습니다.'));
}
return redirect()
->route('web.mypage.usage.show', ['attemptId' => $attemptId, 'revealed' => 1])
->with('success', '핀번호 확인이 완료되었습니다.');
}
/** /**
* GET /mypage/usage * GET /mypage/usage
* - 리스트 + 검색 + 페이징 * - 리스트 + 검색 + 페이징

View File

@ -37,13 +37,14 @@ final class UsageRepository
return $out; return $out;
} }
private function decodeJsonArray($v): array private function decodeJsonArray($json): array
{ {
if ($v === null) return []; if (is_array($json)) return $json;
if (is_array($v)) return $v;
$s = trim((string)$v); $json = trim((string)$json);
if ($s === '') return []; if ($json === '') return [];
$decoded = json_decode($s, true);
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : []; return is_array($decoded) ? $decoded : [];
} }
@ -119,17 +120,23 @@ final class UsageRepository
$from = trim((string)($filters['from'] ?? '')); $from = trim((string)($filters['from'] ?? ''));
$to = trim((string)($filters['to'] ?? '')); $to = trim((string)($filters['to'] ?? ''));
// order_items 집계 서브쿼리 (group by를 메인 쿼리에서 피해서 paginate 안정) // 주문아이템 집계
$oiAgg = DB::table('gc_pin_order_items') $oiAgg = DB::table('gc_pin_order_items')
->selectRaw('order_id, SUM(qty) as total_qty, MIN(item_name) as first_item_name') ->selectRaw('order_id, SUM(qty) as total_qty, MIN(item_name) as first_item_name')
->groupBy('order_id'); ->groupBy('order_id');
// 발행 집계
$issueAgg = DB::table('gc_pin_issues')
->selectRaw('order_id, COUNT(*) as issued_count')
->groupBy('order_id');
$qb = DB::table('gc_payment_attempts as a') $qb = DB::table('gc_payment_attempts as a')
->leftJoin('gc_pin_order as o', 'o.id', '=', 'a.order_id') ->leftJoin('gc_pin_order as o', 'o.id', '=', 'a.order_id')
->leftJoinSub($oiAgg, 'oi', 'oi.order_id', '=', 'o.id') ->leftJoinSub($oiAgg, 'oi', 'oi.order_id', '=', 'o.id')
->leftJoinSub($issueAgg, 'gi', 'gi.order_id', '=', 'o.id')
->where('a.mem_no', $memNo) ->where('a.mem_no', $memNo)
// ✅ 중요: OR 조건 전체를 반드시 하나의 where 그룹으로 묶어야 함 // 리스트 기본 노출 대상
->where(function ($s) { ->where(function ($s) {
// 1) 취소완료 // 1) 취소완료
$s->where(function ($x) { $s->where(function ($x) {
@ -152,6 +159,7 @@ final class UsageRepository
->select([ ->select([
'a.id as attempt_id', 'a.id as attempt_id',
'o.id as order_id',
'o.oid as order_oid', 'o.oid as order_oid',
DB::raw("COALESCE(o.products_name, oi.first_item_name) as product_name"), DB::raw("COALESCE(o.products_name, oi.first_item_name) as product_name"),
'oi.first_item_name as item_name', 'oi.first_item_name as item_name',
@ -162,11 +170,12 @@ final class UsageRepository
'o.stat_pay as order_stat_pay', 'o.stat_pay as order_stat_pay',
'a.cancel_status as attempt_cancel_status', 'a.cancel_status as attempt_cancel_status',
'o.cancel_status as order_cancel_status', 'o.cancel_status as order_cancel_status',
DB::raw("COALESCE(gi.issued_count, 0) as issued_count"),
'a.created_at as created_at', 'a.created_at as created_at',
]) ])
->orderByDesc('a.id'); ->orderByDesc('a.id');
// ✅ q 검색: 거래번호(o.oid / a.oid) 정확히 일치 // 주문번호 검색
if ($q !== '') { if ($q !== '') {
$qb->where(function ($w) use ($q) { $qb->where(function ($w) use ($q) {
$w->where('o.oid', $q) $w->where('o.oid', $q)
@ -179,31 +188,63 @@ final class UsageRepository
$qb->where('a.pay_method', $method); $qb->where('a.pay_method', $method);
} }
// ✅ 상태 필터도 화면 의미에 맞게 묶어서 처리 /**
// (현재 화면 기준: paid / issued / cancel를 의미 상태로 쓰는 경우) * 상태 필터는 화면 의미 기준
*
* pay : 입금/결제 진행 상태
* issue_wait : 결제완료 + 발행대기
* issue_done : 결제완료 + 발행완료
* cancelled : 결제취소
* failed : 결제실패
*
* 하위호환:
* paid : issue_wait + issue_done
* issued : pay
* cancel : cancelled
* canceled : cancelled
*/
if ($status !== '') { if ($status !== '') {
if ($status === 'paid') { if (in_array($status, ['cancel', 'cancelled', 'canceled'], true)) {
$qb->where(function ($x) {
$x->where('a.status', 'paid')
->orWhere('o.stat_pay', 'p');
});
} elseif ($status === 'issued') {
$qb->where(function ($x) {
$x->where('a.status', 'issued')
->orWhere('o.stat_pay', 'w');
});
} elseif (in_array($status, ['cancel', 'cancelled', 'canceled'], true)) {
$qb->where(function ($x) { $qb->where(function ($x) {
$x->where('a.cancel_status', 'success') $x->where('a.cancel_status', 'success')
->orWhere('o.cancel_status', 'success'); ->orWhere('o.cancel_status', 'success');
}); });
} elseif ($status === 'issue_done') {
$qb->where(function ($x) {
$x->where('a.status', 'paid')
->orWhere('o.stat_pay', 'p');
});
$qb->whereRaw('COALESCE(gi.issued_count, 0) > 0');
} elseif ($status === 'issue_wait') {
$qb->where(function ($x) {
$x->where('a.status', 'paid')
->orWhere('o.stat_pay', 'p');
});
$qb->whereRaw('COALESCE(gi.issued_count, 0) = 0');
} elseif ($status === 'pay' || $status === 'issued') {
$qb->where(function ($x) {
$x->where('a.status', 'issued')
->orWhere('o.stat_pay', 'w')
->orWhere('a.status', 'ready')
->orWhere('a.status', 'redirected');
});
} elseif ($status === 'paid') {
// 하위호환: 결제완료 전체(발행대기+발행완료)
$qb->where(function ($x) {
$x->where('a.status', 'paid')
->orWhere('o.stat_pay', 'p');
});
} elseif ($status === 'failed') {
$qb->where(function ($x) {
$x->where('a.status', 'failed')
->orWhere('o.stat_pay', 'f');
});
} else { } else {
// 기타 상태는 attempts 기준으로 그대로
$qb->where('a.status', $status); $qb->where('a.status', $status);
} }
} }
// 날짜 필터 (date 기준) // 날짜 필터
if ($from !== '') { if ($from !== '') {
$qb->whereDate('a.created_at', '>=', $from); $qb->whereDate('a.created_at', '>=', $from);
} }
@ -268,4 +309,81 @@ final class UsageRepository
return array_map(fn($r) => (array)$r, $rows->all()); return array_map(fn($r) => (array)$r, $rows->all());
} }
public function getIssuesForOrder(int $orderId): array
{
$rows = DB::table('gc_pin_issues')
->where('order_id', $orderId)
->orderBy('id', 'asc')
->get();
return array_map(function ($r) {
$arr = (array)$r;
$arr['pins_json_decoded'] = $this->decodeJsonArray($arr['pins_json'] ?? null);
$arr['issue_logs_json_decoded'] = $this->decodeJsonArray($arr['issue_logs_json'] ?? null);
return $arr;
}, $rows->all());
}
public function getIssuesForOrderForUpdate(int $orderId): array
{
$rows = DB::table('gc_pin_issues')
->where('order_id', $orderId)
->orderBy('id', 'asc')
->lockForUpdate()
->get();
return array_map(fn ($r) => (array)$r, $rows->all());
}
/**
* 핀번호 확인 / SMS / 출금완료 강한 잠금
*/
public function hasLockedOrOpenedIssues(int $orderId): bool
{
return DB::table('gc_pin_issues')
->where('order_id', $orderId)
->where(function ($q) {
$q->whereNotNull('opened_at')
->orWhereNotNull('sms_sent_at')
->orWhereNotNull('payout_done_at')
->orWhere('cancel_status', 'LOCKED');
})
->exists();
}
public function hasAnyIssuedIssues(int $orderId): bool
{
return DB::table('gc_pin_issues')
->where('order_id', $orderId)
->where(function ($q) {
$q->whereIn('issue_status', ['PROCESSING', 'ISSUED'])
->orWhere('pin_count', '>', 0)
->orWhereNotNull('issued_at');
})
->exists();
}
public function markIssueOpened(int $issueId, string $openedAt, array $logs, ?string $reason = null): void
{
DB::table('gc_pin_issues')
->where('id', $issueId)
->update([
'opened_at' => $openedAt,
'cancel_status' => 'LOCKED',
'cancel_locked_reason' => $reason ?? '핀번호 확인 완료',
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
'updated_at' => $openedAt,
]);
}
public function getSkuForOrderItem(int $productId, int $skuId): ?object
{
return DB::table('gc_product_skus')
->where('product_id', $productId)
->where('id', $skuId)
->where('is_active', 1)
->first();
}
} }

View File

@ -0,0 +1,43 @@
<?php
namespace App\Repositories\Payments;
use Illuminate\Support\Facades\DB;
final class GcPinIssueRepository
{
public function findByOrderItemId(int $orderItemId): ?object
{
return DB::table('gc_pin_issues')
->where('order_item_id', $orderItemId)
->first();
}
public function findByOrderId(int $orderId): array
{
$rows = DB::table('gc_pin_issues')
->where('order_id', $orderId)
->orderBy('id', 'asc')
->get();
return array_map(fn ($r) => (array)$r, $rows->all());
}
public function insert(array $data): int
{
return (int) DB::table('gc_pin_issues')->insertGetId($data);
}
public function markOpened(int $id, string $now, array $logs, ?string $reason = null): void
{
DB::table('gc_pin_issues')
->where('id', $id)
->update([
'opened_at' => $now,
'cancel_status' => 'LOCKED',
'cancel_locked_reason' => $reason ?? '핀 오픈 완료',
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Repositories\Payments;
use Illuminate\Support\Facades\DB;
final class GcPinsRepository
{
public function lockAvailablePins(int $productId, int $skuId, int $qty): array
{
$rows = DB::table('gc_pins')
->where('product_id', $productId)
->where('sku_id', $skuId)
->where('status', 'AVAILABLE')
->orderBy('id', 'asc')
->lockForUpdate()
->limit($qty)
->get();
return array_map(fn ($r) => (array)$r, $rows->all());
}
public function updateStatusByIds(array $ids, string $status, array $extra = []): void
{
if (empty($ids)) return;
$payload = array_merge([
'status' => $status,
'updated_at' => now(),
], $extra);
DB::table('gc_pins')
->whereIn('id', $ids)
->update($payload);
}
}

View File

@ -4,7 +4,9 @@ namespace App\Services\Mypage;
use App\Repositories\Mypage\UsageRepository; use App\Repositories\Mypage\UsageRepository;
use App\Repositories\Payments\GcPinOrderRepository; use App\Repositories\Payments\GcPinOrderRepository;
use App\Repositories\Member\MemberAuthRepository;
use App\Services\Payments\PaymentCancelService; use App\Services\Payments\PaymentCancelService;
use Illuminate\Support\Facades\DB;
final class UsageService final class UsageService
{ {
@ -12,6 +14,7 @@ final class UsageService
private readonly UsageRepository $repo, private readonly UsageRepository $repo,
private readonly GcPinOrderRepository $orders, private readonly GcPinOrderRepository $orders,
private readonly PaymentCancelService $cancelSvc, private readonly PaymentCancelService $cancelSvc,
private readonly MemberAuthRepository $memberAuthRepo,
) {} ) {}
/** /**
@ -19,7 +22,7 @@ final class UsageService
*/ */
public function buildListPageData(int $sessionMemNo, array $filters): array public function buildListPageData(int $sessionMemNo, array $filters): array
{ {
$rows = $this->repo->paginateAttemptsWithOrder($sessionMemNo, $filters, 5); $rows = $this->repo->paginateAttemptsWithOrder($sessionMemNo, $filters, 15);
return [ return [
'pageTitle' => '구매내역', 'pageTitle' => '구매내역',
@ -43,81 +46,215 @@ final class UsageService
$order = (array)($data['order'] ?? []); $order = (array)($data['order'] ?? []);
$orderId = (int)($order['id'] ?? 0); $orderId = (int)($order['id'] ?? 0);
$pins = $this->repo->getPinsForOrder($orderId); $issues = $this->repo->getIssuesForOrder($orderId);
$cancelLogs = $this->repo->getCancelLogsForAttempt($attemptId, 20); $cancelLogs = $this->repo->getCancelLogsForAttempt($attemptId, 20);
$retData = $order['ret_data'] ?? null; $pins = [];
$retArr = is_array($retData) ? $retData : []; foreach ($issues as $issue) {
$pinsOpened = !empty($retArr['pin_opened_at']); $issue = (array)$issue;
$orderItemId = (int)($issue['order_item_id'] ?? 0);
$pinsJson = $issue['pins_json_decoded'] ?? null;
if (!is_array($pinsJson)) {
$pinsJson = $this->decodeJsonArray($issue['pins_json'] ?? null);
}
foreach ($pinsJson as $pin) {
$pin = (array)$pin;
$pins[] = [
'id' => (int)($pin['gc_pin_id'] ?? 0),
'issue_id' => (int)($issue['id'] ?? 0),
'order_item_id' => $orderItemId,
'status' => (string)($issue['issue_status'] ?? ''),
'pin_mask' => (string)($pin['pin_mask'] ?? ''),
'pin' => $this->decryptIssuedPin(
(string)($pin['pin_enc'] ?? ''),
(int)($order['mem_no'] ?? 0),
(string)($order['oid'] ?? ''),
$orderItemId,
(int)($pin['seq'] ?? 0)
),
'face_value' => (int)($pin['face_value'] ?? 0),
'issued_at' => (string)($pin['issued_at'] ?? ''),
];
}
}
// 이번 요청에서만 실핀 표시
$pinsRevealed = (
(int)request()->query('revealed', 0) === 1
|| (int)session('pin_revealed_attempt_id', 0) === $attemptId
);
// 결제완료 조건 // 결제완료 조건
$attempt = (array)($data['attempt'] ?? []); $attempt = (array)($data['attempt'] ?? []);
$isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid'); $isPaid = (($order['stat_pay'] ?? '') === 'p') || (($attempt['status'] ?? '') === 'paid');
// cancel_status 기반 버튼 제어 // 핀 발행 완료되면 회원 취소 불가
$aCancel = (string)($attempt['cancel_status'] ?? 'none'); $hasIssuedIssues = !empty($issues);
$canCancel = $isPaid && !$pinsOpened && in_array($aCancel, ['none','failed'], true);
$aCancel = (string)($attempt['cancel_status'] ?? 'none');
$canCancel = $isPaid
&& !$hasIssuedIssues
&& in_array($aCancel, ['none', 'failed'], true);
$data['issues'] = $issues;
$data['pins'] = $pins; $data['pins'] = $pins;
$data['pinsOpened'] = $pinsOpened; $data['pinsRevealed'] = $pinsRevealed;
$data['hasIssuedIssues'] = $hasIssuedIssues;
$data['canCancel'] = $canCancel; $data['canCancel'] = $canCancel;
$data['cancelLogs'] = $cancelLogs; $data['cancelLogs'] = $cancelLogs;
return $data; return $data;
} }
private function decodeJsonArray($json): array
{
if (is_array($json)) {
return $json;
}
$json = trim((string)$json);
if ($json === '') {
return [];
}
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
private function decryptIssuedPin(string $enc, int $memNo, string $oid, int $orderItemId, int $seq): string
{
if (trim($enc) === '') {
return '';
}
$plain = \Illuminate\Support\Facades\Crypt::decryptString($enc);
$suffix = '|M:' . $memNo . '|O:' . $oid . '|I:' . $orderItemId . '|S:' . $seq;
if (str_ends_with($plain, $suffix)) {
return substr($plain, 0, -strlen($suffix));
}
return $plain;
}
/** /**
* 오픈(확인): ret_data에 pin_opened_at 기록 * 오픈(확인): ret_data에 pin_opened_at 기록
*/ */
public function openPins(int $attemptId, int $sessionMemNo): array public function openPins(int $attemptId, int $sessionMemNo): array
{ {
return \Illuminate\Support\Facades\DB::transaction(function () use ($attemptId, $sessionMemNo) {
$row = $this->repo->findAttemptWithOrder($attemptId); $row = $this->repo->findAttemptWithOrder($attemptId);
if (!$row) return ['ok'=>false, 'message'=>'결제내역을 찾을 수 없습니다.']; if (!$row) {
return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.'];
}
$attemptMem = (int)($row->attempt_mem_no ?? 0); $attemptMem = (int)($row->attempt_mem_no ?? 0);
$orderMem = (int)($row->order_mem_no ?? 0); $orderMem = (int)($row->order_mem_no ?? 0);
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) { if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
return ['ok'=>false, 'message'=>'권한이 없습니다.']; return ['ok' => false, 'message' => '권한이 없습니다.'];
} }
$attemptStatus = (string)($row->attempt_status ?? ''); $attemptStatus = (string)($row->attempt_status ?? '');
$orderStatPay = (string)($row->order_stat_pay ?? ''); $orderStatPay = (string)($row->order_stat_pay ?? '');
if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) { if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) {
return ['ok'=>false, 'message'=>'결제완료 상태에서만 핀 확인이 가능합니다.']; return ['ok' => false, 'message' => '결제완료 상태에서만 핀 확인이 가능합니다.'];
} }
$oid = (string)($row->order_oid ?? ''); $orderId = (int)($row->order_id ?? 0);
if ($oid === '') return ['ok'=>false, 'message'=>'주문정보가 올바르지 않습니다.']; if ($orderId <= 0) {
return ['ok' => false, 'message' => '주문정보가 올바르지 않습니다.'];
$order = $this->orders->findByOidForUpdate($oid);
if (!$order) return ['ok'=>false, 'message'=>'주문을 찾을 수 없습니다.'];
$ret = (array)($order->ret_data ?? []);
if (!empty($ret['pin_opened_at'])) {
return ['ok'=>true];
} }
$ret['pin_opened_at'] = now()->toDateTimeString(); // 발행건 잠금 조회
$order->ret_data = $ret; $issues = $this->repo->getIssuesForOrderForUpdate($orderId);
$order->save(); if (empty($issues)) {
return ['ok' => false, 'message' => '발행된 핀이 없습니다.'];
return ['ok'=>true];
} }
// 실제 발행된 건만 대상
$issuedIssues = array_values(array_filter($issues, function ($issue) {
$issue = (array)$issue;
$status = (string)($issue['issue_status'] ?? '');
$pinCnt = (int)($issue['pin_count'] ?? 0);
return in_array($status, ['ISSUED', 'PROCESSING'], true) || $pinCnt > 0;
}));
if (empty($issuedIssues)) {
return ['ok' => false, 'message' => '확인 가능한 핀이 없습니다.'];
}
// 이미 모두 오픈된 상태면 성공 처리
$allOpened = collect($issuedIssues)->every(function ($issue) {
$issue = (array)$issue;
return !empty($issue['opened_at']);
});
if ($allOpened) {
return ['ok' => true];
}
$now = now()->format('Y-m-d H:i:s');
foreach ($issuedIssues as $issue) {
$issue = (array)$issue;
if (!empty($issue['opened_at'])) {
continue;
}
$logs = $this->decodeJsonArray($issue['issue_logs_json'] ?? null);
$logs[] = [
'at' => $now,
'type' => 'OPEN',
'code' => 'PIN_OPENED',
'msg' => '회원 웹페이지에서 핀 오픈',
];
$this->repo->markIssueOpened(
(int)$issue['id'],
$now,
$logs,
'핀 오픈 완료'
);
}
return ['ok' => true];
});
}
/** /**
* 결제완료 취소( 오픈 전만) * 결제완료 취소( 오픈 전만)
*/ */
public function cancelPaidAttempt(int $attemptId, int $sessionMemNo, string $reason): array public function cancelPaidAttempt(int $attemptId, int $sessionMemNo, string $reason): array
{ {
$data = $this->buildDetailPageData($attemptId, $sessionMemNo); $data = $this->buildDetailPageData($attemptId, $sessionMemNo);
$order = (array)($data['order'] ?? []);
$orderId = (int)($order['id'] ?? 0);
$pinsOpened = (bool)($data['pinsOpened'] ?? false); // 핀 발행이 완료되면 회원 취소 금지
if ($orderId > 0 && $this->repo->hasAnyIssuedIssues($orderId)) {
return [
'ok' => false,
'message' => '핀 발행이 완료된 주문은 회원이 직접 취소할 수 없습니다. 관리자에게 문의해 주세요.',
];
}
return $this->cancelSvc->cancelByAttempt( return $this->cancelSvc->cancelByAttempt(
$attemptId, $attemptId,
['type' => 'user', 'mem_no' => $sessionMemNo, 'id' => $sessionMemNo], ['type' => 'user', 'mem_no' => $sessionMemNo, 'id' => $sessionMemNo],
$reason, $reason,
$pinsOpened false
); );
} }
@ -445,4 +582,47 @@ final class UsageService
return $s; return $s;
} }
public function revealPins(int $attemptId, int $sessionMemNo, string $pin2): array
{
$row = $this->repo->findAttemptWithOrder($attemptId);
if (!$row) {
return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.'];
}
$attemptMem = (int)($row->attempt_mem_no ?? 0);
$orderMem = (int)($row->order_mem_no ?? 0);
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
return ['ok' => false, 'message' => '권한이 없습니다.'];
}
$attemptStatus = (string)($row->attempt_status ?? '');
$orderStatPay = (string)($row->order_stat_pay ?? '');
if (!(($orderStatPay === 'p') || ($attemptStatus === 'paid'))) {
return ['ok' => false, 'message' => '결제완료 상태에서만 핀번호 확인이 가능합니다.'];
}
$pin2Ok = $this->memberAuthRepo->verifyPin2($sessionMemNo, $pin2);
if (!$pin2Ok) {
return ['ok' => false, 'message' => '2차 비밀번호가 올바르지 않습니다.'];
}
$orderId = (int)($row->order_id ?? 0);
if ($orderId <= 0) {
return ['ok' => false, 'message' => '주문정보가 올바르지 않습니다.'];
}
$issues = $this->repo->getIssuesForOrder($orderId);
if (empty($issues)) {
return ['ok' => false, 'message' => '발행된 핀이 없습니다.'];
}
// 상태값 변경 없이 이번 요청에서만 실핀 표시
session()->flash('pin_revealed_attempt_id', $attemptId);
return ['ok' => true];
}
} }

View File

@ -0,0 +1,437 @@
<?php
namespace App\Services\Payments;
use App\Repositories\Mypage\UsageRepository;
use App\Repositories\Payments\GcPinIssueRepository;
use App\Repositories\Payments\GcPinsRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class OwnPinIssueService
{
public function __construct(
private readonly UsageRepository $usageRepo,
private readonly GcPinIssueRepository $issueRepo,
private readonly GcPinsRepository $pinsRepo,
) {}
/**
* 웹페이지 직접발행 실행
* - 주문에는 OWN_PIN 또는 API_LINK 종류만 존재한다고 가정
* - OWN_PIN : 실제 자사핀 발행
* - API_LINK : 현재는 분기만 처리하고 미구현 메시지 반환
*/
public function issuePinInstant(int $attemptId, int $sessionMemNo): array
{
try {
return DB::transaction(function () use ($attemptId, $sessionMemNo) {
$row = $this->usageRepo->findAttemptWithOrder($attemptId);
if (!$row) {
return ['ok' => false, 'message' => '결제내역을 찾을 수 없습니다.'];
}
$attemptMem = (int)($row->attempt_mem_no ?? 0);
$orderMem = (int)($row->order_mem_no ?? 0);
if ($attemptMem !== $sessionMemNo || ($orderMem > 0 && $orderMem !== $sessionMemNo)) {
return ['ok' => false, 'message' => '권한이 없습니다.'];
}
$orderId = (int)($row->order_id ?? 0);
if ($orderId <= 0) {
return ['ok' => false, 'message' => '주문 정보가 없습니다.'];
}
$isPaid = (($row->order_stat_pay ?? '') === 'p') || (($row->attempt_status ?? '') === 'paid');
if (!$isPaid) {
return ['ok' => false, 'message' => '결제 완료 주문만 발행할 수 있습니다.'];
}
$items = $this->usageRepo->getOrderItems($orderId);
if ($items->isEmpty()) {
return ['ok' => false, 'message' => '주문 아이템이 없습니다.'];
}
$now = now()->format('Y-m-d H:i:s');
$created = 0;
foreach ($items as $item) {
$orderItemId = (int)($item->id ?? 0);
if ($orderItemId <= 0) {
return ['ok' => false, 'message' => '주문 아이템 정보가 올바르지 않습니다.'];
}
// 이미 발행된 주문아이템이면 중복 방지
$exists = $this->issueRepo->findByOrderItemId($orderItemId);
if ($exists) {
continue;
}
$meta = $this->decodeJson((string)($item->meta ?? ''));
$productId = (int)($meta['product_id'] ?? 0);
// 정책상 item_code = gc_product_skus.id
$skuId = (int)($item->item_code ?? ($meta['sku_id'] ?? 0));
if ($productId <= 0 || $skuId <= 0) {
return ['ok' => false, 'message' => '상품 정보(product_id/sku_id)가 올바르지 않습니다.'];
}
$qty = (int)($item->qty ?? 0);
if ($qty <= 0) {
return ['ok' => false, 'message' => '주문 수량이 올바르지 않습니다.'];
}
// 상품의 회원 발행 방식 허용 여부 확인
$issueOptions = $this->usageRepo->getProductsIssueOptions([$productId]);
$methods = $issueOptions[$productId]['pin_check_methods'] ?? [];
if (!in_array('PIN_INSTANT', $methods, true)) {
return ['ok' => false, 'message' => '웹페이지 직접발행이 허용되지 않은 상품입니다.'];
}
// SKU 조회 -> sales_method 분기
$sku = $this->usageRepo->getSkuForOrderItem($productId, $skuId);
if (!$sku) {
return ['ok' => false, 'message' => 'SKU 정보를 찾을 수 없습니다.'];
}
$salesMethod = (string)($sku->sales_method ?? 'OWN_PIN');
$apiProviderId = (int)($sku->api_provider_id ?? 0);
$apiProductCode = trim((string)($sku->api_product_code ?? ''));
if ($salesMethod === 'OWN_PIN') {
$this->issueOwnPinItem(
row: $row,
item: (array)$item,
attemptId: $attemptId,
orderId: $orderId,
orderItemId: $orderItemId,
productId: $productId,
skuId: $skuId,
qty: $qty,
now: $now
);
$created++;
continue;
}
if ($salesMethod === 'API_LINK') {
$this->issueApiLinkPlaceholder(
row: $row,
item: (array)$item,
attemptId: $attemptId,
orderId: $orderId,
orderItemId: $orderItemId,
productId: $productId,
skuId: $skuId,
qty: $qty,
apiProviderId: $apiProviderId,
apiProductCode: $apiProductCode,
now: $now
);
return [
'ok' => false,
'message' => '연동발행 상품입니다. 연동발행 구현 후 처리됩니다.',
];
}
return ['ok' => false, 'message' => '알 수 없는 발행 방식입니다.'];
}
if ($created <= 0) {
return ['ok' => true, 'message' => '이미 발행된 주문입니다.'];
}
return ['ok' => true, 'message' => '자사핀 발행이 완료되었습니다.'];
});
} catch (\Throwable $e) {
return [
'ok' => false,
'message' => $e->getMessage() !== '' ? $e->getMessage() : '핀 발행 중 오류가 발생했습니다.',
];
}
}
/**
* OWN_PIN 실제 발행
*/
private function issueOwnPinItem(
object|array $row,
array $item,
int $attemptId,
int $orderId,
int $orderItemId,
int $productId,
int $skuId,
int $qty,
string $now,
): void {
$row = (object)$row;
$pins = $this->pinsRepo->lockAvailablePins($productId, $skuId, $qty);
if (count($pins) < $qty) {
throw new \Exception('죄송합니다 재고가 부족합니다. 관리자에게 문의해 주시면 바로 처리해 드리겠습니다.');
}
$pinIds = array_map(fn ($p) => (int)$p['id'], $pins);
// 1차 선점
$this->pinsRepo->updateStatusByIds($pinIds, 'HOLD', [
'order_id' => $orderId,
]);
$memNo = (int)($row->order_mem_no ?? 0);
$oid = (string)($row->order_oid ?? '');
$pinsJson = [];
foreach ($pins as $idx => $pin) {
$rawPin = $this->decryptSourcePin((string)($pin['pin_code'] ?? ''));
$memberEnc = $this->encryptForIssue(
$rawPin,
$memNo,
$oid,
$orderItemId,
$idx + 1
);
$pinsJson[] = [
'seq' => $idx + 1,
'gc_pin_id' => (int)($pin['id'] ?? 0),
'pin_enc' => $memberEnc,
'pin_mask' => $this->maskPin($rawPin),
'pin_hash' => hash('sha256', $rawPin),
'face_value' => (int)($pin['face_value'] ?? 0),
'issued_at' => $now,
];
}
$logs = [[
'at' => $now,
'type' => 'ISSUE',
'code' => 'OWN_PIN_ISSUED',
'msg' => '자사핀 발행 완료',
'count' => count($pinsJson),
]];
$this->issueRepo->insert([
'issue_no' => $this->makeIssueNo(),
'order_id' => $orderId,
'order_item_id' => $orderItemId,
'oid' => $oid,
'mem_no' => $memNo,
'product_id' => $productId,
'sku_id' => $skuId,
'item_name' => (string)($item['item_name'] ?? ''),
'item_code' => (string)($item['item_code'] ?? ''),
'qty' => $qty,
'unit_price' => (int)($item['unit_price'] ?? 0),
'unit_pay_price' => (int)($item['unit_pay_price'] ?? 0),
'line_subtotal' => (int)($item['line_subtotal'] ?? 0),
'line_fee' => (int)($item['line_fee'] ?? 0),
'line_total' => (int)($item['line_total'] ?? 0),
'supply_type' => 'OWN_PIN',
'provider_code' => 'OWN',
'provider_id' => null,
'provider_product_code' => null,
'member_delivery_type' => 'PIN_INSTANT',
'issue_status' => 'ISSUED',
'pin_count' => count($pinsJson),
'pins_json' => json_encode($pinsJson, JSON_UNESCAPED_UNICODE),
'pin_key_ver' => 1,
'issued_at' => $now,
'opened_at' => null,
'sms_phone_enc' => null,
'sms_phone_hash' => null,
'sms_sent_at' => null,
'sms_result_code' => null,
'sms_result_msg' => null,
'payout_amount' => 0,
'payout_fee_amount' => 0,
'payout_status' => 'NONE',
'payout_done_at' => null,
'cancel_status' => 'AVAILABLE',
'cancel_locked_reason' => null,
'cancelled_at' => null,
'provider_payload_json' => null,
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
'meta' => json_encode([
'source' => 'OWN_PIN',
'attempt_id' => $attemptId,
'sales_method' => 'OWN_PIN',
], JSON_UNESCAPED_UNICODE),
'created_at' => $now,
'updated_at' => $now,
]);
// 최종 판매 처리
$this->pinsRepo->updateStatusByIds($pinIds, 'SOLD', [
'sold_at' => $now,
]);
}
/**
* API_LINK 분기만 먼저 저장
* 실제 연동 통신은 다음 단계에서 구현
*/
private function issueApiLinkPlaceholder(
object|array $row,
array $item,
int $attemptId,
int $orderId,
int $orderItemId,
int $productId,
int $skuId,
int $qty,
int $apiProviderId,
string $apiProductCode,
string $now,
): void {
$row = (object)$row;
$logs = [[
'at' => $now,
'type' => 'ISSUE',
'code' => 'API_LINK_PENDING',
'msg' => '연동발행 대상 상품 - 실제 연동 구현 전',
'api_provider_id' => $apiProviderId,
'api_product_code' => $apiProductCode,
]];
$this->issueRepo->insert([
'issue_no' => $this->makeIssueNo(),
'order_id' => $orderId,
'order_item_id' => $orderItemId,
'oid' => (string)($row->order_oid ?? ''),
'mem_no' => (int)($row->order_mem_no ?? 0),
'product_id' => $productId,
'sku_id' => $skuId,
'item_name' => (string)($item['item_name'] ?? ''),
'item_code' => (string)($item['item_code'] ?? ''),
'qty' => $qty,
'unit_price' => (int)($item['unit_price'] ?? 0),
'unit_pay_price' => (int)($item['unit_pay_price'] ?? 0),
'line_subtotal' => (int)($item['line_subtotal'] ?? 0),
'line_fee' => (int)($item['line_fee'] ?? 0),
'line_total' => (int)($item['line_total'] ?? 0),
'supply_type' => 'API_LINK',
'provider_code' => 'API_LINK',
'provider_id' => $apiProviderId > 0 ? $apiProviderId : null,
'provider_product_code' => $apiProductCode !== '' ? $apiProductCode : null,
'member_delivery_type' => 'PIN_INSTANT',
'issue_status' => 'PROCESSING',
'pin_count' => 0,
'pins_json' => null,
'pin_key_ver' => 1,
'issued_at' => null,
'opened_at' => null,
'sms_phone_enc' => null,
'sms_phone_hash' => null,
'sms_sent_at' => null,
'sms_result_code' => null,
'sms_result_msg' => null,
'payout_amount' => 0,
'payout_fee_amount' => 0,
'payout_status' => 'NONE',
'payout_done_at' => null,
'cancel_status' => 'AVAILABLE',
'cancel_locked_reason' => null,
'cancelled_at' => null,
'provider_payload_json' => json_encode([
'api_provider_id' => $apiProviderId,
'api_product_code' => $apiProductCode,
], JSON_UNESCAPED_UNICODE),
'issue_logs_json' => json_encode($logs, JSON_UNESCAPED_UNICODE),
'meta' => json_encode([
'source' => 'API_LINK',
'attempt_id' => $attemptId,
'sales_method' => 'API_LINK',
], JSON_UNESCAPED_UNICODE),
'created_at' => $now,
'updated_at' => $now,
]);
}
private function decodeJson(string $json): array
{
$json = trim($json);
if ($json === '') {
return [];
}
$arr = json_decode($json, true);
return is_array($arr) ? $arr : [];
}
private function decryptSourcePin(string $enc): string
{
$enc = trim($enc);
if ($enc === '') {
throw new \RuntimeException('원본 핀 데이터가 비어 있습니다.');
}
return Crypt::decryptString($enc);
}
private function maskPin(string $raw): string
{
$v = preg_replace('/\s+/', '', $raw);
$len = mb_strlen($v);
if ($len <= 4) {
return str_repeat('*', $len);
}
if ($len <= 8) {
return mb_substr($v, 0, 2)
. str_repeat('*', max(1, $len - 4))
. mb_substr($v, -2);
}
return mb_substr($v, 0, 4)
. str_repeat('*', max(4, $len - 6))
. mb_substr($v, -2);
}
/**
* 회원 발행용 암호화
* 현재는 Crypt 기반으로 suffix를 붙이는 방식
* 추후 HKDF 기반 파생키 구조로 교체 가능
*/
private function encryptForIssue(string $rawPin, int $memNo, string $oid, int $orderItemId, int $seq): string
{
return Crypt::encryptString(
$rawPin . '|M:' . $memNo . '|O:' . $oid . '|I:' . $orderItemId . '|S:' . $seq
);
}
private function makeIssueNo(): string
{
return 'GI' . now()->format('YmdHis') . strtoupper(substr(bin2hex(random_bytes(4)), 0, 8));
}
}

View File

@ -107,7 +107,7 @@ return [
[ [
'category' => 'code', 'category' => 'code',
'q' => '상품 코드는 어디에서 확인하나요?', 'q' => '상품 코드는 어디에서 확인하나요?',
'a' => "마이페이지 > 이용내역에서 확인 가능합니다.\n단, 일부 상품은 휴대폰으로 즉시 전송되어 이용내역에서 확인이 어려울 수 있습니다.\n미수신/확인 불가 시 1:1 문의로 접수해 주세요.", 'a' => "마이페이지 > 구매내역에서 확인 가능합니다.\n단, 일부 상품은 휴대폰으로 즉시 전송되어 구매내역에서 확인이 어려울 수 있습니다.\n미수신/확인 불가 시 1:1 문의로 접수해 주세요.",
], ],
[ [
'category' => 'code', 'category' => 'code',

View File

@ -86,7 +86,7 @@ return [
], ],
'mypage_tabs' => [ 'mypage_tabs' => [
['label' => '나의정보', 'route' => 'web.mypage.info.index', 'key' => 'info'], ['label' => '나의정보', 'route' => 'web.mypage.info.index', 'key' => 'info'],
['label' => '이용내역', 'route' => 'web.mypage.usage.index', 'key' => 'usage'], ['label' => '구매내역', 'route' => 'web.mypage.usage.index', 'key' => 'usage'],
['label' => '교환내역', 'route' => 'web.mypage.exchange.index', 'key' => 'exchange'], ['label' => '교환내역', 'route' => 'web.mypage.exchange.index', 'key' => 'exchange'],
['label' => '1:1문의내역', 'route' => 'web.mypage.qna.index', 'key' => 'qna'], ['label' => '1:1문의내역', 'route' => 'web.mypage.qna.index', 'key' => 'qna'],
], ],

View File

@ -39,7 +39,7 @@
<ul style="color: #fda4af; margin-top: 8px; margin-bottom: 0; padding-left: 20px; font-size: 13px;"> <ul style="color: #fda4af; margin-top: 8px; margin-bottom: 0; padding-left: 20px; font-size: 13px;">
@foreach ($errors->all() as $error) @foreach ($errors->all() as $error)
<li>{{ $error }}</li> <li>{{ $error }}</li>
@endforeachZ @endforeach
</ul> </ul>
</div> </div>
@endif @endif

View File

@ -69,7 +69,7 @@
<div class="border-t profile-sep"></div> <div class="border-t profile-sep"></div>
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.info.index') }}"> 정보</a> <a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.info.index') }}"> 정보</a>
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.usage.index') }}">이용내역</a> <a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.mypage.usage.index') }}">구매내역</a>
<a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.cs.qna.index') }}">1:1 문의</a> <a class="block px-4 py-2 hover:bg-gray-50" href="{{ route('web.cs.qna.index') }}">1:1 문의</a>
<div class="border-t profile-sep"></div> <div class="border-t profile-sep"></div>

View File

@ -115,7 +115,7 @@
<div class="guide-note"> <div class="guide-note">
<b>확인</b> <b>확인</b>
결제 완료 안내가 진행되며, 필요한 경우 마이페이지 이용내역에서 확인할 있어요. 결제 완료 안내가 진행되며, 필요한 경우 마이페이지 구매내역에서 확인할 있어요.
</div> </div>
</article> </article>

View File

@ -19,26 +19,34 @@
}; };
// 상태는 "결제완료/결제취소" 중심 + 화면 깨짐 방지 최소 처리 // 상태는 "결제완료/결제취소" 중심 + 화면 깨짐 방지 최소 처리
$statusLabel = function ($r) { $payStatusLabel = function ($r) {
$aCancel = (string)($r->attempt_cancel_status ?? 'none'); $aCancel = (string)($r->attempt_cancel_status ?? 'none');
$oCancel = (string)($r->order_cancel_status ?? 'none'); $oCancel = (string)($r->order_cancel_status ?? 'none');
// 결제 후 취소 인식: status/stat_pay 유지 + cancel_status=success
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소'; if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
$aStatus = (string)($r->attempt_status ?? ''); $aStatus = (string)($r->attempt_status ?? '');
$oPay = (string)($r->order_stat_pay ?? ''); $oPay = (string)($r->order_stat_pay ?? '');
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료'; if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
// 아래는 운영/테스트 중 섞여도 UI가 깨지지 않게 최소 표시
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패'; if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기'; if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
return '진행중'; return '진행중';
}; };
$statusClass = function ($label) { $issueStatusLabel = function ($r) use ($payStatusLabel) {
$pay = $payStatusLabel($r);
// 결제취소면 발행 상태는 의미 없음
if ($pay === '결제취소') return '-';
$issuedCount = (int)($r->issued_count ?? 0);
return $issuedCount > 0 ? '발행완료' : '발행대기';
};
$payStatusClass = function ($label) {
return match ($label) { return match ($label) {
'결제취소' => 'pill--danger', '결제취소' => 'pill--danger',
'결제완료' => 'pill--ok', '결제완료' => 'pill--ok',
@ -48,6 +56,14 @@
}; };
}; };
$issueStatusClass = function ($label) {
return match ($label) {
'발행완료' => 'pill--ok',
'발행대기' => 'pill--wait',
default => 'pill--muted',
};
};
$formatDate = function ($v) { $formatDate = function ($v) {
$s = (string)$v; $s = (string)$v;
if ($s === '') return '-'; if ($s === '') return '-';
@ -120,8 +136,10 @@
$qty = (int)($r->total_qty ?? 0); $qty = (int)($r->total_qty ?? 0);
$money = (int)($r->pay_money ?? 0); $money = (int)($r->pay_money ?? 0);
$method = (string)($r->pay_method ?? ''); $method = (string)($r->pay_method ?? '');
$st = $statusLabel($r); $paySt = $payStatusLabel($r);
$stCls = $statusClass($st); $payStCls = $payStatusClass($paySt);
$issueSt = $issueStatusLabel($r);
$issueStCls = $issueStatusClass($issueSt);
$dt = $formatDate($r->created_at ?? ''); $dt = $formatDate($r->created_at ?? '');
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery)); $href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
@endphp @endphp
@ -129,7 +147,10 @@
<a class="mcard" href="{{ $href }}"> <a class="mcard" href="{{ $href }}">
<div class="mcard__top"> <div class="mcard__top">
<div class="mcard__no">No. {{ $no }}</div> <div class="mcard__no">No. {{ $no }}</div>
<span class="pill {{ $stCls }}">{{ $st }}</span> <div class="mcard__badges">
<span class="pill {{ $payStCls }}">{{ $paySt }}</span>
<span class="pill {{ $issueStCls }}">{{ $issueSt }}</span>
</div>
</div> </div>
<div class="mcard__title"> <div class="mcard__title">
@ -170,6 +191,7 @@
<th style="width:110px;">결제수단</th> <th style="width:110px;">결제수단</th>
<th style="width:80px;">수량</th> <th style="width:80px;">수량</th>
<th style="width:130px;">금액</th> <th style="width:130px;">금액</th>
<th style="width:120px;">결제</th>
<th style="width:120px;">상태</th> <th style="width:120px;">상태</th>
<th style="width:160px;">일시</th> <th style="width:160px;">일시</th>
</tr> </tr>
@ -186,8 +208,10 @@
$qty = (int)($r->total_qty ?? 0); $qty = (int)($r->total_qty ?? 0);
$money = (int)($r->pay_money ?? 0); $money = (int)($r->pay_money ?? 0);
$method = (string)($r->pay_method ?? ''); $method = (string)($r->pay_method ?? '');
$st = $statusLabel($r); $paySt = $payStatusLabel($r);
$stCls = $statusClass($st); $payStCls = $payStatusClass($paySt);
$issueSt = $issueStatusLabel($r);
$issueStCls = $issueStatusClass($issueSt);
$dt = $formatDate($r->created_at ?? ''); $dt = $formatDate($r->created_at ?? '');
$href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery)); $href = route('web.mypage.usage.show', array_merge(['attemptId' => $r->attempt_id], $listQuery));
@endphp @endphp
@ -197,11 +221,12 @@
<td>{{ $methodLabel($method) }}</td> <td>{{ $methodLabel($method) }}</td>
<td>{{ $qty }}</td> <td>{{ $qty }}</td>
<td class="money">{{ number_format($money) }} </td> <td class="money">{{ number_format($money) }} </td>
<td><span class="pill {{ $stCls }}">{{ $st }}</span></td> <td><span class="pill {{ $payStCls }}">{{ $paySt }}</span></td>
<td><span class="pill {{ $issueStCls }}">{{ $issueSt }}</span></td>
<td>{{ $dt }}</td> <td>{{ $dt }}</td>
</tr> </tr>
@empty @empty
<tr><td colspan="7" class="empty">구매내역이 없습니다.</td></tr> <tr><td colspan="8" class="empty">구매내역이 없습니다.</td></tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
@ -305,5 +330,6 @@
.list-mobile{display:none;} .list-mobile{display:none;}
.list-desktop{display:block;} .list-desktop{display:block;}
} }
.mcard__badges{display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end;}
</style> </style>
@endsection @endsection

View File

@ -6,13 +6,14 @@
$order = $order ?? []; $order = $order ?? [];
$items = $items ?? []; $items = $items ?? [];
$pins = $pins ?? []; $pins = $pins ?? [];
$pinsOpened = (bool)($pinsOpened ?? false); $pinsOpened = (bool)($pinsOpened ?? false); // 기존 핀 오픈 상태
$pinsRevealed = (bool)($pinsRevealed ?? false); // 이번 요청에서 실핀 표시 여부
$pinsRevealLocked = (bool)($pinsRevealLocked ?? false); // 실핀 확인 이력(취소 잠금)
$canCancel = (bool)($canCancel ?? false); $canCancel = (bool)($canCancel ?? false);
$backToListQuery = request()->only(['q', 'method', 'status', 'from', 'to', 'page']); $backToListQuery = request()->only(['q', 'method', 'status', 'from', 'to', 'page']);
$backToListQuery = array_filter($backToListQuery, fn($v) => $v !== null && $v !== ''); $backToListQuery = array_filter($backToListQuery, fn($v) => $v !== null && $v !== '');
// 결제수단 한글 매핑
$methodLabel = function ($m) { $methodLabel = function ($m) {
$m = (string)$m; $m = (string)$m;
return match ($m) { return match ($m) {
@ -24,12 +25,10 @@
}; };
}; };
// 리스트와 동일한 상태 라벨
$statusLabel = function () use ($attempt, $order) { $statusLabel = function () use ($attempt, $order) {
$aCancel = (string)($attempt['cancel_status'] ?? 'none'); $aCancel = (string)($attempt['cancel_status'] ?? 'none');
$oCancel = (string)($order['cancel_status'] ?? 'none'); $oCancel = (string)($order['cancel_status'] ?? 'none');
// 결제 후 취소는 cancel_status=success로만 인식
if ($aCancel === 'success' || $oCancel === 'success') return '결제취소'; if ($aCancel === 'success' || $oCancel === 'success') return '결제취소';
$aStatus = (string)($attempt['status'] ?? ''); $aStatus = (string)($attempt['status'] ?? '');
@ -37,14 +36,12 @@
if ($aStatus === 'paid' || $oPay === 'p') return '결제완료'; if ($aStatus === 'paid' || $oPay === 'p') return '결제완료';
if ($aStatus === 'issued' || $oPay === 'w') return '입금대기'; if ($aStatus === 'issued' || $oPay === 'w') return '입금대기';
// 화면 깨짐 방지용(원하면 숨겨도 됨)
if ($aStatus === 'failed' || $oPay === 'f') return '결제실패'; if ($aStatus === 'failed' || $oPay === 'f') return '결제실패';
return '진행중'; return '진행중';
}; };
$st = $statusLabel(); $st = $statusLabel();
$isCancelledAfterPaid = ($st === '결제취소'); // 취소 완료면 전표만 남김 $isCancelledAfterPaid = ($st === '결제취소');
$statusClass = function ($label) { $statusClass = function ($label) {
return match ($label) { return match ($label) {
@ -56,7 +53,6 @@
}; };
}; };
// 전표용 값
$attemptId = (int)($attempt['id'] ?? 0); $attemptId = (int)($attempt['id'] ?? 0);
$oid = (string)($order['oid'] ?? ''); $oid = (string)($order['oid'] ?? '');
$method = (string)($order['pay_method'] ?? ($attempt['pay_method'] ?? '')); $method = (string)($order['pay_method'] ?? ($attempt['pay_method'] ?? ''));
@ -67,32 +63,22 @@
$fee = (int)($amounts['fee'] ?? 0); $fee = (int)($amounts['fee'] ?? 0);
$payMoney = (int)($amounts['pay_money'] ?? 0); $payMoney = (int)($amounts['pay_money'] ?? 0);
// 상품명: 현재 전달받는 변수 기준 유지 (사용자 확인 완료)
$productName = (string)($productname ?? ''); $productName = (string)($productname ?? '');
if ($productName === '') $productName = '-'; if ($productName === '') $productName = '-';
$itemName = (string)($items[0]['name'] ?? ''); $itemName = (string)($items[0]['name'] ?? '');
if ($itemName === '') $itemName = '-'; if ($itemName === '') $itemName = '-';
// 수량 합계
$totalQty = 0; $totalQty = 0;
foreach ($items as $it) $totalQty += (int)($it['qty'] ?? 0); foreach ($items as $it) $totalQty += (int)($it['qty'] ?? 0);
// 일시 (분까지)
$createdAt = (string)($order['created_at'] ?? ($attempt['created_at'] ?? '')); $createdAt = (string)($order['created_at'] ?? ($attempt['created_at'] ?? ''));
$dateStr = $createdAt ? \Carbon\Carbon::parse($createdAt)->format('Y-m-d H:i') : '-'; $dateStr = $createdAt ? \Carbon\Carbon::parse($createdAt)->format('Y-m-d H:i') : '-';
// 핀 목록 "추후 조건" 대비: 지금은 보여줌
$showPinsNow = true; $showPinsNow = true;
// 핀발행 완료 여부 (우선 pins 존재 기준)
// 추후 서버에서 bool($pinsIssuedCompleted) 내려주면 그 값 우선 사용 권장
$isPinIssuedCompleted = (bool)($pinsIssuedCompleted ?? !empty($pins)); $isPinIssuedCompleted = (bool)($pinsIssuedCompleted ?? !empty($pins));
// 오른쪽 영역 배너 모드 조건
$useRightBannerMode = $isCancelledAfterPaid || $isPinIssuedCompleted; $useRightBannerMode = $isCancelledAfterPaid || $isPinIssuedCompleted;
// 핀 발행(확인) 방식: gc_products.pin_check_methods 기반 (서비스에서 내려줌)
$issueMethods = $issueMethods ?? ['PIN_INSTANT','SMS','BUYBACK']; $issueMethods = $issueMethods ?? ['PIN_INSTANT','SMS','BUYBACK'];
if (is_string($issueMethods)) $issueMethods = json_decode($issueMethods, true) ?: []; if (is_string($issueMethods)) $issueMethods = json_decode($issueMethods, true) ?: [];
if (!is_array($issueMethods) || empty($issueMethods)) $issueMethods = ['PIN_INSTANT','SMS','BUYBACK']; if (!is_array($issueMethods) || empty($issueMethods)) $issueMethods = ['PIN_INSTANT','SMS','BUYBACK'];
@ -112,7 +98,6 @@
if (isset($issueMap[$k])) $issueMissingLabels[] = $issueMap[$k]; if (isset($issueMap[$k])) $issueMissingLabels[] = $issueMap[$k];
} }
// 1개만 가능하면 그 옵션을 기본으로 열어둠
$issueOpenKey = (count($issueMethods) === 1) ? ($issueMethods[0] ?? null) : null; $issueOpenKey = (count($issueMethods) === 1) ? ($issueMethods[0] ?? null) : null;
@endphp @endphp
@ -128,9 +113,7 @@
<a class="btn btn--back" href="{{ route('web.mypage.usage.index', $backToListQuery) }}"> 목록</a> <a class="btn btn--back" href="{{ route('web.mypage.usage.index', $backToListQuery) }}"> 목록</a>
</div> </div>
{{-- 상단: 전표 + 우측 영역(핀발행/배너) --}}
<div class="detail-hero-grid"> <div class="detail-hero-grid">
{{-- 좌측: 영수증형 전표 --}}
<div class="receipt-card receipt-card--paper"> <div class="receipt-card receipt-card--paper">
<div class="receipt-head"> <div class="receipt-head">
<div> <div>
@ -186,16 +169,8 @@
<span class="v">{{ number_format($payMoney) }}</span> <span class="v">{{ number_format($payMoney) }}</span>
</div> </div>
</div> </div>
@if(!empty($order['cancel_last_msg']))
<div class="notice-box notice-box--err">
<b>취소 처리 결과</b><br>
{{ $order['cancel_last_msg'] }}
</div>
@endif
</div> </div>
{{-- 우측: 핀발행 인터랙션 또는 안내 배너 --}}
<aside class="right-panel {{ $useRightBannerMode ? 'right-panel--banner right-panel--mobile-hide' : '' }}"> <aside class="right-panel {{ $useRightBannerMode ? 'right-panel--banner right-panel--mobile-hide' : '' }}">
@if($useRightBannerMode) @if($useRightBannerMode)
<div class="banner-stack"> <div class="banner-stack">
@ -240,11 +215,18 @@
</div> </div>
</div> </div>
<div class="info-banner info-banner--warn"> <div class="info-banner {{ $pinsRevealLocked ? 'info-banner--danger' : 'info-banner--warn' }}">
<div class="info-banner__title">취소 제한 안내</div> <div class="info-banner__title">
{{ $pinsRevealLocked ? '핀번호 확인 완료' : '핀번호 확인 안내' }}
</div>
<div class="info-banner__desc"> <div class="info-banner__desc">
확인/발행 이후에는 결제 취소가 제한될 있습니다. @if($pinsRevealLocked)
실제 취소 가능 여부는 하단 결제 취소 영역에서 확인됩니다. 핀번호를 확인한 주문은 회원이 직접 취소할 없습니다.
취소가 필요한 경우 관리자에게 문의해 주세요.
@else
오픈 후에도 기본은 마스킹 상태로 유지됩니다.
“핀번호 확인” 버튼에서 2 비밀번호 인증 전체 핀번호를 확인할 있습니다.
@endif
</div> </div>
</div> </div>
@endif @endif
@ -262,7 +244,6 @@
</div> </div>
<div class="issue-picker" id="issuePicker"> <div class="issue-picker" id="issuePicker">
{{-- 옵션 1 --}}
@if(isset($issueAllowed['PIN_INSTANT'])) @if(isset($issueAllowed['PIN_INSTANT']))
<div class="issue-option {{ $issueOpenKey==='PIN_INSTANT' ? 'is-active' : '' }}" data-issue-card="view"> <div class="issue-option {{ $issueOpenKey==='PIN_INSTANT' ? 'is-active' : '' }}" data-issue-card="view">
<button type="button" class="issue-option__toggle" data-issue-toggle> <button type="button" class="issue-option__toggle" data-issue-toggle>
@ -276,15 +257,17 @@
<p class="issue-option__detail-text"> <p class="issue-option__detail-text">
핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요. 핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.
</p> </p>
<button id="btnIssueView" type="button" class="issue-run issue-run--dark"> <form method="post" action="{{ route('web.mypage.usage.issue.pin_instant', ['attemptId' => $attemptId]) }}">
@csrf
<button id="btnIssuePinInstant" type="submit" class="issue-run issue-run--dark">
핀번호 바로 확인 실행 핀번호 바로 확인 실행
</button> </button>
</form>
</div> </div>
</div> </div>
</div> </div>
@endif @endif
{{-- 옵션 2 --}}
@if(isset($issueAllowed['SMS'])) @if(isset($issueAllowed['SMS']))
<div class="issue-option {{ $issueOpenKey==='SMS' ? 'is-active' : '' }}" data-issue-card="sms"> <div class="issue-option {{ $issueOpenKey==='SMS' ? 'is-active' : '' }}" data-issue-card="sms">
<button type="button" class="issue-option__toggle" data-issue-toggle> <button type="button" class="issue-option__toggle" data-issue-toggle>
@ -297,7 +280,6 @@
<div class="issue-option__detail-inner"> <div class="issue-option__detail-inner">
<p class="issue-option__detail-text"> <p class="issue-option__detail-text">
SMS 발송 핀번호는 저장되지 않습니다. 문자 수신 즉시 확인하세요. SMS 발송 핀번호는 저장되지 않습니다. 문자 수신 즉시 확인하세요.
</p> </p>
<button id="btnIssueSms" type="button" class="issue-run issue-run--sky"> <button id="btnIssueSms" type="button" class="issue-run issue-run--sky">
SMS 발송 실행 SMS 발송 실행
@ -307,7 +289,6 @@
</div> </div>
@endif @endif
{{-- 옵션 3 --}}
@if(isset($issueAllowed['BUYBACK'])) @if(isset($issueAllowed['BUYBACK']))
<div class="issue-option {{ $issueOpenKey==='BUYBACK' ? 'is-active' : '' }}" data-issue-card="sell"> <div class="issue-option {{ $issueOpenKey==='BUYBACK' ? 'is-active' : '' }}" data-issue-card="sell">
<button type="button" class="issue-option__toggle" data-issue-toggle> <button type="button" class="issue-option__toggle" data-issue-toggle>
@ -321,7 +302,6 @@
<p class="issue-option__detail-text"> <p class="issue-option__detail-text">
구매하신 상품권을 판매합니다. 계좌번호가 등록되어 있어야 하며, 구매하신 상품권을 판매합니다. 계좌번호가 등록되어 있어야 하며,
매입 처리 회원님 계좌로 입금됩니다. 매입 처리 회원님 계좌로 입금됩니다.
</p> </p>
<button id="btnIssueSell" type="button" class="issue-run issue-run--green"> <button id="btnIssueSell" type="button" class="issue-run issue-run--green">
구매상품권 판매 실행 구매상품권 판매 실행
@ -343,77 +323,154 @@
</div> </div>
@if(!$isCancelledAfterPaid) @if(!$isCancelledAfterPaid)
@if($showPinsNow && $hasIssuedIssues)
{{-- 목록 --}} <div class="gift-zone">
@if($showPinsNow) <div class="gift-zone__head">
<div class="usage-card"> <div>
<div class="section-head"> <h3 class="gift-zone__title">핀번호</h3>
<h3 class="card-title"> 목록</h3> <div class="gift-zone__sub">
<div class="sub muted"> 기본은 마스킹 상태로 표시됩니다.
발행이 완료되면 영역에서 정보를 확인할 있습니다. (현재는 UI 확인을 위해 표시 ) 핀번호 확인 버튼에서 2 비밀번호를 입력하면 이번 화면에서만 전체 핀번호를 확인할 있습니다.
</div> </div>
</div> </div>
@if($pinsRevealed)
<span class="gift-badge gift-badge--danger">핀번호 확인됨</span>
@else
<span class="gift-badge">마스킹 표시중</span>
@endif
</div>
@if(empty($pins)) @if(empty($pins))
<div class="usage-card">
<p class="muted">표시할 핀이 없습니다.</p> <p class="muted">표시할 핀이 없습니다.</p>
</div>
@else @else
<ul class="pins"> <div class="gift-list">
@foreach($pins as $p) @foreach($pins as $idx => $p)
@php @php
$id = (int)($p['id'] ?? 0); $raw = (string)($p['pin'] ?? '');
$status = (string)($p['status'] ?? ''); $masked = (string)($p['pin_mask'] ?? '****');
$raw = (string)($p['pin'] ?? $p['pin_code'] ?? $p['pin_no'] ?? ''); $display = $pinsRevealed ? ($raw ?: $masked) : $masked;
$masked = (string)($p['pin_masked'] ?? $p['pin_mask'] ?? ''); $amount = number_format((int)($p['face_value'] ?? 0));
if ($masked === '' && $raw !== '') {
$digits = preg_replace('/\s+/', '', $raw);
$masked = (mb_strlen($digits) >= 8)
? (mb_substr($digits,0,4).str_repeat('*',4).mb_substr($digits,-2))
: '****';
}
// 오픈 전에는 마스킹 우선
$display = $pinsOpened ? ($raw ?: $masked) : ($masked ?: '****');
@endphp @endphp
<li class="pin-row">
<span class="pill">#{{ $id }}</span>
@if($status !== '') <span class="pill pill--muted">{{ $status }}</span> @endif
<span class="mono pin-code">{{ $display }}</span>
</li>
@endforeach
</ul>
@endif
</div>
@endif
{{-- 취소 버튼은 아래, 작게 --}} <article class="gift-card">
<div class="usage-card cancel-box"> <div class="gift-card__top">
<h3 class="card-title">결제 취소</h3> <div class="gift-card__brand">MOBILE GIFT</div>
<div class="muted"> <div class="gift-card__chip">No. {{ $idx + 1 }}</div>
핀을 확인/발행한 이후에는 취소가 제한될 있습니다.<br>
결제 취소는 처리 시간이 소요될 있으며, 취소 결과는 페이지에 반영됩니다.
</div> </div>
<div class="gift-card__body">
<div class="gift-card__name">{{ $productName }}</div>
<div class="gift-card__amount">{{ $amount }}</div>
<div class="gift-card__pinbox">
<div class="gift-card__pinlabel">PIN NUMBER</div>
<div
class="gift-card__pincode mono"
data-pin-value="{{ $pinsRevealed ? ($raw ?: $masked) : '' }}"
>
{{ $display }}
</div>
</div>
<div class="gift-card__notice">
핀번호는 본인만 확인해 주세요.<br>
발행이 완료된 주문은 회원이 직접 취소할 없습니다.
</div>
</div>
</article>
@endforeach
</div>
@if(!$pinsRevealed)
<div class="gift-actions">
<button type="button" class="issue-run issue-run--dark gift-open-btn" onclick="openPinRevealModal()">
핀번호 확인
</button>
</div>
@else
<div class="gift-actions">
<button type="button" class="issue-run issue-run--dark gift-open-btn" id="btnCopyPins">
핀번호 복사
</button>
</div>
@endif
@endif
</div>
@endif
@if($canCancel) @if($canCancel)
<form method="post" action="{{ route('web.mypage.usage.cancel', ['attemptId' => $attemptId]) }}" class="cancel-form"> <div class="usage-card cancel-box">
<h3 class="card-title">결제 취소</h3>
<div class="muted">
취소 사유를 선택한 결제를 취소할 있습니다.
</div>
<form method="post"
action="{{ route('web.mypage.usage.cancel', ['attemptId' => $attemptId]) }}"
class="cancel-form cancel-form--inline"
id="cancelForm">
@csrf @csrf
<input type="hidden" name="q" value="{{ request('q', '') }}">
<input type="hidden" name="method" value="{{ request('method', '') }}"> <select class="inp sel" name="reason" id="cancelReason" required>
<input type="hidden" name="status" value="{{ request('status', '') }}"> <option value="">취소 사유를 선택해 주세요</option>
<input type="hidden" name="from" value="{{ request('from', '') }}"> <option value="단순 변심">단순 변심</option>
<input type="hidden" name="to" value="{{ request('to', '') }}"> <option value="상품을 잘못 선택함">상품을 잘못 선택함</option>
<input type="hidden" name="page" value="{{ request('page', '') }}"> <option value="구매 수량을 잘못 선택함">구매 수량을 잘못 선택함</option>
<input class="inp" name="reason" placeholder="취소 사유(선택)"> <option value="결제 수단을 잘못 선택함">결제 수단을 잘못 선택함</option>
<option value="중복 결제 시도">중복 결제 시도</option>
<option value="다른 상품으로 다시 구매 예정">다른 상품으로 다시 구매 예정</option>
<option value="가격을 다시 확인 후 구매 예정">가격을 다시 확인 구매 예정</option>
<option value="회원 정보 확인 후 다시 진행 예정">회원 정보 확인 다시 진행 예정</option>
<option value="프로모션/혜택 적용 후 재구매 예정">프로모션/혜택 적용 재구매 예정</option>
<option value="기타">기타</option>
</select>
<button id="btnCancel" class="btn btn--danger btn--sm" type="submit"> <button id="btnCancel" class="btn btn--danger btn--sm" type="submit">
결제 취소 결제 취소
</button> </button>
</form> </form>
@else
<div class="notice-box">
현재 상태에서는 결제 취소가 불가능합니다.
</div> </div>
@endif @endif
@endif
</div> </div>
@endif <div class="pin-auth-modal" id="pinRevealModal" hidden>
<div class="pin-auth-modal__backdrop" onclick="closePinRevealModal()"></div>
<div class="pin-auth-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="pinRevealTitle">
<div class="pin-auth-modal__head">
<h3 id="pinRevealTitle">핀번호 확인</h3>
<button type="button" class="pin-auth-modal__close" onclick="closePinRevealModal()">×</button>
</div>
<div class="pin-auth-modal__body">
<p class="pin-auth-modal__desc">
전체 핀번호 확인을 위해 2 비밀번호를 입력해 주세요.
</p>
<form id="pinRevealForm" method="post" action="{{ route('web.mypage.usage.reveal', ['attemptId' => $attemptId]) }}">
@csrf
<input
class="pin-auth-modal__input"
type="password"
name="pin2"
inputmode="numeric"
pattern="\d{4}"
maxlength="4"
autocomplete="off"
placeholder="2차 비밀번호 4자리"
>
<div class="pin-auth-modal__actions">
<button type="button" class="issue-run" onclick="closePinRevealModal()">닫기</button>
<button type="submit" class="issue-run issue-run--dark">핀번호 확인</button>
</div>
</form>
</div>
</div>
</div> </div>
<style> <style>
@ -425,7 +482,7 @@
.btn{ .btn{
display:inline-flex;align-items:center;justify-content:center; display:inline-flex;align-items:center;justify-content:center;
padding:10px 12px;border-radius:12px;border:1px solid rgba(0,0,0,.14); padding:10px 12px;border-radius:12px;border:1px solid rgba(0,0,0,.14);
cursor:pointer;text-decoration:none;font-size:13px; white-space:nowrap; background:#fff; cursor:pointer;text-decoration:none;font-size:13px; white-space:nowrap;
} }
.btn--sm{padding:8px 10px;border-radius:10px;font-size:12px;} .btn--sm{padding:8px 10px;border-radius:10px;font-size:12px;}
.btn--danger{border-color: rgba(220,0,0,.35); color:rgb(180,0,0); font-weight:800;} .btn--danger{border-color: rgba(220,0,0,.35); color:rgb(180,0,0); font-weight:800;}
@ -449,7 +506,6 @@
.notice-box { padding:12px; border-radius:12px; background:rgba(0,0,0,.04); margin-top:10px; } .notice-box { padding:12px; border-radius:12px; background:rgba(0,0,0,.04); margin-top:10px; }
.notice-box--err { background:rgba(220,0,0,.06); } .notice-box--err { background:rgba(220,0,0,.06); }
/* ===== Hero grid (전표 + 오른쪽 영역) ===== */
.detail-hero-grid{ .detail-hero-grid{
display:grid; display:grid;
grid-template-columns:1fr; grid-template-columns:1fr;
@ -457,8 +513,8 @@
} }
@media (min-width: 960px){ @media (min-width: 960px){
.detail-hero-grid{ .detail-hero-grid{
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); /* 5:5 */ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: stretch; /* 좌우 높이 자연스럽게 맞춤 */ align-items: stretch;
} }
.detail-hero-grid > * { .detail-hero-grid > * {
min-width: 0; min-width: 0;
@ -479,7 +535,6 @@
.right-panel--mobile-hide{display:none;} .right-panel--mobile-hide{display:none;}
} }
/* ===== Receipt (카드 영수증 느낌) ===== */
.receipt-card{ .receipt-card{
border:1px solid rgba(0,0,0,.08); border:1px solid rgba(0,0,0,.08);
border-radius:18px; border-radius:18px;
@ -538,7 +593,6 @@
font-size:15px; font-weight:900; font-size:15px; font-weight:900;
} }
/* ===== Right panel: banner mode ===== */
.banner-stack{ .banner-stack{
display:flex; display:flex;
flex-direction:column; flex-direction:column;
@ -573,7 +627,6 @@
border-color:rgba(255,190,0,.20); border-color:rgba(255,190,0,.20);
} }
/* ===== Cancel 상태용 세로 광고 배너 ===== */
.promo-vertical-banner{ .promo-vertical-banner{
border-radius:20px; border-radius:20px;
position:relative; position:relative;
@ -664,7 +717,7 @@
} }
.promo-vertical-banner__footer{ .promo-vertical-banner__footer{
margin-top:auto; /* 아래 정렬 */ margin-top:auto;
border-top:1px dashed rgba(0,0,0,.10); border-top:1px dashed rgba(0,0,0,.10);
padding-top:10px; padding-top:10px;
} }
@ -680,7 +733,6 @@
color:rgba(0,0,0,.55); color:rgba(0,0,0,.55);
} }
/* ===== Right panel: issue picker (색감 + 입체감 강화) ===== */
.issue-panel{ .issue-panel{
border-radius:20px; border-radius:20px;
padding:14px; padding:14px;
@ -702,9 +754,6 @@
.issue-panel__title{ .issue-panel__title{
margin:0; font-size:17px; font-weight:900; margin:0; font-size:17px; font-weight:900;
} }
.issue-panel__desc{
font-size:13px; color:rgba(0,0,0,.62); line-height:1.4;
}
.issue-picker{ .issue-picker{
display:flex; flex-direction:column; gap:10px; display:flex; flex-direction:column; gap:10px;
@ -753,7 +802,6 @@
transform:translateY(-1px); transform:translateY(-1px);
} }
/* 카드별 컬러 포인트 */
.issue-option[data-issue-card="view"]::before{ .issue-option[data-issue-card="view"]::before{
background:linear-gradient(180deg, rgba(70,70,70,.85), rgba(20,20,20,.85)); background:linear-gradient(180deg, rgba(70,70,70,.85), rgba(20,20,20,.85));
} }
@ -858,68 +906,254 @@
border-color:rgba(0,160,60,.18); border-color:rgba(0,160,60,.18);
} }
/* ===== Standard card / Pins / Cancel ===== */
.usage-card{border:1px solid rgba(0,0,0,.08); border-radius:16px; padding:16px; background:#fff;} .usage-card{border:1px solid rgba(0,0,0,.08); border-radius:16px; padding:16px; background:#fff;}
.section-head{display:flex; flex-direction:column; gap:6px; margin-bottom:10px;} .section-head{display:flex; flex-direction:column; gap:6px; margin-bottom:10px;}
.card-title{font-size:16px; margin:0;} .card-title{font-size:16px; margin:0;}
.sub{font-size:13px;} .sub{font-size:13px;}
.pins{margin:0; padding-left:0; list-style:none; display:flex; flex-direction:column; gap:8px;}
.pin-row{display:flex; align-items:center; gap:8px; flex-wrap:wrap;}
.pin-code{font-weight:900; letter-spacing:0.3px;}
.cancel-box{padding:14px;} .cancel-box{padding:14px;}
.cancel-form{margin-top:10px; display:flex; flex-direction:column; gap:8px; align-items:flex-start;} .cancel-form{margin-top:10px; display:flex; flex-direction:column; gap:8px; align-items:flex-start;}
.inp{ .inp{
padding:10px 10px; border-radius:12px; border:1px solid rgba(0,0,0,.14); padding:10px 10px; border-radius:12px; border:1px solid rgba(0,0,0,.14);
background:#fff; font-size:13px; width:100%; max-width:420px; background:#fff; font-size:13px; width:100%; max-width:420px;
} }
.gift-zone{display:flex;flex-direction:column;gap:14px;}
.gift-zone__head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;}
.gift-zone__title{margin:0;font-size:18px;font-weight:900;}
.gift-zone__sub{margin-top:6px;font-size:13px;color:rgba(0,0,0,.58);line-height:1.5;}
.gift-badge{
display:inline-flex;align-items:center;justify-content:center;
min-height:34px;padding:0 12px;border-radius:999px;
border:1px solid rgba(0,0,0,.08);background:#fff;font-size:12px;font-weight:800;
}
.gift-badge--danger{
color:#b42318;background:rgba(255,90,90,.08);border-color:rgba(180,35,24,.16);
}
.gift-badge--ok{
color:#067647;background:rgba(18,183,106,.10);border-color:rgba(6,118,71,.16);
}
.gift-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px;}
.gift-card{
position:relative;overflow:hidden;
border-radius:24px;
padding:18px;
background:
radial-gradient(circle at top right, rgba(255,255,255,.82), rgba(255,255,255,.08) 35%),
linear-gradient(135deg, #0f172a 0%, #1e293b 44%, #334155 100%);
color:#fff;
box-shadow:0 18px 38px rgba(15,23,42,.16);
}
.gift-card::after{
content:"";
position:absolute;right:-28px;bottom:-28px;width:120px;height:120px;border-radius:50%;
background:rgba(255,255,255,.08);
}
.gift-card__top{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:18px;}
.gift-card__brand{font-size:12px;font-weight:900;letter-spacing:.18em;opacity:.92;}
.gift-card__chip{
padding:6px 10px;border-radius:999px;
background:rgba(255,255,255,.14);font-size:11px;font-weight:800;
border:1px solid rgba(255,255,255,.18);
}
.gift-card__body{position:relative;z-index:1;display:flex;flex-direction:column;gap:12px;}
.gift-card__name{font-size:17px;font-weight:900;line-height:1.35;}
.gift-card__amount{font-size:28px;font-weight:900;letter-spacing:-.02em;}
.gift-card__pinbox{
border-radius:18px;padding:14px;
background:rgba(255,255,255,.1);
border:1px solid rgba(255,255,255,.18);
backdrop-filter: blur(8px);
}
.gift-card__pinlabel{font-size:11px;letter-spacing:.14em;opacity:.8;margin-bottom:6px;}
.gift-card__pincode{font-size:18px;font-weight:900;letter-spacing:.08em;word-break:break-all;}
.gift-card__notice{font-size:12px;line-height:1.55;color:rgba(255,255,255,.82);}
.gift-actions{display:flex;justify-content:center;margin-top:6px;}
.gift-open-btn{min-width:260px;}
.pin-auth-modal[hidden]{display:none !important;}
.pin-auth-modal{position:fixed;inset:0;z-index:1000;}
.pin-auth-modal__backdrop{position:absolute;inset:0;background:rgba(15,23,42,.52);}
.pin-auth-modal__dialog{
position:relative;
width:min(92vw, 420px);
margin:8vh auto 0;
background:#fff;border-radius:24px;
box-shadow:0 24px 60px rgba(15,23,42,.22);
overflow:hidden;
}
.pin-auth-modal__head{
display:flex;justify-content:space-between;align-items:center;
padding:18px 18px 0 18px;
}
.pin-auth-modal__head h3{margin:0;font-size:18px;}
.pin-auth-modal__close{
border:0;background:transparent;font-size:28px;line-height:1;cursor:pointer;color:#111827;
}
.pin-auth-modal__body{padding:16px 18px 18px;}
.pin-auth-modal__desc{margin:0 0 14px;font-size:13px;line-height:1.6;color:rgba(0,0,0,.65);}
.pin-auth-modal__input{
width:100%;height:48px;border-radius:14px;border:1px solid rgba(0,0,0,.14);
padding:0 14px;font-size:15px;background:#fff;
}
.pin-auth-modal__actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;flex-wrap:wrap;}
.cancel-form--inline{
margin-top:10px;
display:flex;
flex-direction:row;
align-items:center;
gap:8px;
flex-wrap:nowrap;
}
.cancel-form--inline .sel{
flex:1 1 auto;
min-width:0;
max-width:none;
height:40px;
}
.cancel-form--inline .btn{
flex:0 0 auto;
height:40px;
}
@media (max-width: 640px){
.cancel-form--inline{
flex-direction:column;
align-items:stretch;
}
.cancel-form--inline .btn{
width:100%;
}
}
</style> </style>
<script> <script>
// ---- 핀 오픈(오픈 후 취소 제한 안내) ---- async function copyAllPins() {
const nodes = Array.from(document.querySelectorAll('[data-pin-value]'));
const pins = nodes
.map(el => (el.getAttribute('data-pin-value') || '').trim())
.filter(v => v !== '');
if (!pins.length) {
await showMsg(
"복사할 핀번호가 없습니다.",
{ type: 'alert', title: '안내' }
);
return;
}
const text = pins.join('\n');
try {
await navigator.clipboard.writeText(text);
await showMsg(
"전체 핀번호가 복사되었습니다.",
{ type: 'alert', title: '복사 완료' }
);
} catch (e) {
// clipboard API 실패 시 fallback
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
let ok = false;
try {
ok = document.execCommand('copy');
} catch (_) {
ok = false;
}
document.body.removeChild(ta);
if (ok) {
await showMsg(
"전체 핀번호가 복사되었습니다.",
{ type: 'alert', title: '복사 완료' }
);
} else {
await showMsg(
"복사에 실패했습니다. 브라우저 권한을 확인해 주세요.",
{ type: 'alert', title: '복사 실패' }
);
}
}
}
async function onOpenPinsOnce(e) { async function onOpenPinsOnce(e) {
e.preventDefault(); e.preventDefault();
const btn = e.currentTarget;
const form = btn ? btn.closest('form') : null;
const ok = await showMsg( const ok = await showMsg(
"핀 확인(오픈) 후에는 취소가 불가능할 수 있습니다.\n\n진행할까요?", "핀 확인(오픈) 후에도 핀번호는 기본 마스킹 상태로 유지됩니다.\n\n진행할까요?",
{ type: 'confirm', title: '핀 확인' } { type: 'confirm', title: '핀 오픈' }
); );
if (!ok) return; if (!ok) return;
const form = e.currentTarget.closest('form');
if (!form) return; if (!form) return;
// requestSubmit이 있으면 native validation도 같이 탄다
if (form.requestSubmit) form.requestSubmit(); if (form.requestSubmit) form.requestSubmit();
else form.submit(); else form.submit();
} }
// ---- 결제 취소(confirm) ----
async function onCancelOnce(e) { async function onCancelOnce(e) {
e.preventDefault(); e.preventDefault();
const btn = e.currentTarget;
const form = btn ? btn.closest('form') : null;
if (!form) return;
const reasonEl = form.querySelector('select[name="reason"]');
const reason = (reasonEl?.value || '').trim();
if (!reason) {
await showMsg(
"취소 사유를 먼저 선택해 주세요.",
{ type: 'alert', title: '취소 사유 선택' }
);
reasonEl?.focus();
return;
}
const ok = await showMsg( const ok = await showMsg(
"핀 확인 전에만 취소할 수 있습니다.\n\n결제를 취소할까요?", "선택한 사유로 결제를 취소할까요?\n\n취소 사유: " + reason,
{ type: 'confirm', title: '결제 취소' } { type: 'confirm', title: '결제 취소' }
); );
if (!ok) return; if (!ok) return;
const form = e.currentTarget.closest('form');
if (!form) return;
if (form.requestSubmit) form.requestSubmit(); if (form.requestSubmit) form.requestSubmit();
else form.submit(); else form.submit();
} }
// ---- 준비중(3버튼 alert) ---- async function onIssuePinInstant(e) {
async function onIssueViewSoon() { e.preventDefault();
await showMsg(
"준비중입니다.\n\n핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.", const btn = e.currentTarget;
{ type: 'alert', title: '안내' } const form = btn ? btn.closest('form') : null;
const ok = await showMsg(
"핀번호를 개인 암호화하여 발행합니다. 핀번호 유출에 주의하세요.",
{ type: 'confirm', title: '핀발행' }
); );
if (!ok || !form) return;
if (form.requestSubmit) form.requestSubmit();
else form.submit();
} }
async function onIssueSmsSoon() { async function onIssueSmsSoon() {
@ -936,17 +1170,37 @@
); );
} }
// ---- 바인딩 (최소 1회) ---- function openPinRevealModal() {
const modal = document.getElementById('pinRevealModal');
if (!modal) return;
modal.hidden = false;
const input = modal.querySelector('input[name="pin2"]');
setTimeout(() => input && input.focus(), 30);
}
function closePinRevealModal() {
const modal = document.getElementById('pinRevealModal');
if (!modal) return;
modal.hidden = true;
const input = modal.querySelector('input[name="pin2"]');
if (input) input.value = '';
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePinRevealModal();
});
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// 기존 로직 유지 (현재 버튼이 없을 수 있어도 그대로)
const btnOpen = document.getElementById('btnOpenPins'); const btnOpen = document.getElementById('btnOpenPins');
if (btnOpen) btnOpen.addEventListener('click', onOpenPinsOnce); if (btnOpen) btnOpen.addEventListener('click', onOpenPinsOnce);
const btnCancel = document.getElementById('btnCancel'); const btnCancel = document.getElementById('btnCancel');
if (btnCancel) btnCancel.addEventListener('click', onCancelOnce); if (btnCancel) btnCancel.addEventListener('click', onCancelOnce);
const btn1 = document.getElementById('btnIssueView'); const btnPinInstant = document.getElementById('btnIssuePinInstant');
if (btn1) btn1.addEventListener('click', onIssueViewSoon); if (btnPinInstant) btnPinInstant.addEventListener('click', onIssuePinInstant);
const btn2 = document.getElementById('btnIssueSms'); const btn2 = document.getElementById('btnIssueSms');
if (btn2) btn2.addEventListener('click', onIssueSmsSoon); if (btn2) btn2.addEventListener('click', onIssueSmsSoon);
@ -954,7 +1208,11 @@
const btn3 = document.getElementById('btnIssueSell'); const btn3 = document.getElementById('btnIssueSell');
if (btn3) btn3.addEventListener('click', onIssueSellSoon); if (btn3) btn3.addEventListener('click', onIssueSellSoon);
// ---- 핀 발행 선택형 배너 (한 번에 1개만 확장) ---- const btnCopyPins = document.getElementById('btnCopyPins');
if (btnCopyPins) {
btnCopyPins.addEventListener('click', copyAllPins);
}
const issueCards = Array.from(document.querySelectorAll('[data-issue-card]')); const issueCards = Array.from(document.querySelectorAll('[data-issue-card]'));
issueCards.forEach((card) => { issueCards.forEach((card) => {
const toggle = card.querySelector('[data-issue-toggle]'); const toggle = card.querySelector('[data-issue-toggle]');
@ -962,11 +1220,7 @@
toggle.addEventListener('click', () => { toggle.addEventListener('click', () => {
const isActive = card.classList.contains('is-active'); const isActive = card.classList.contains('is-active');
// 모두 닫기
issueCards.forEach(c => c.classList.remove('is-active')); issueCards.forEach(c => c.classList.remove('is-active'));
// 방금 클릭한 카드가 닫힌 상태였으면 열기
if (!isActive) { if (!isActive) {
card.classList.add('is-active'); card.classList.add('is-active');
} }

View File

@ -68,7 +68,7 @@
<div class="m-usercard2__actions"> <div class="m-usercard2__actions">
@if($isLogin) @if($isLogin)
<a class="m-btn2" href="{{ route('web.mypage.info.index') }}"> 정보</a> <a class="m-btn2" href="{{ route('web.mypage.info.index') }}"> 정보</a>
<a class="m-btn2 m-btn2--ghost" href="{{ route('web.mypage.usage.index') }}">이용내역</a> <a class="m-btn2 m-btn2--ghost" href="{{ route('web.mypage.usage.index') }}">구매내역</a>
<form action="{{ route('web.auth.logout') }}" method="post" class="m-usercard2__logout"> <form action="{{ route('web.auth.logout') }}" method="post" class="m-usercard2__logout">
@csrf @csrf

View File

@ -10,7 +10,7 @@
</a> </a>
<a class="cs-quick__card" href="{{ route('web.mypage.usage.index') }}"> <a class="cs-quick__card" href="{{ route('web.mypage.usage.index') }}">
<div class="cs-quick__title">이용내역</div> <div class="cs-quick__title">구매내역</div>
<div class="cs-quick__desc">구매/결제/발송 진행 확인</div> <div class="cs-quick__desc">구매/결제/발송 진행 확인</div>
</a> </a>

View File

@ -66,8 +66,10 @@ Route::prefix('mypage')->name('web.mypage.')
Route::get('usage', [UsageController::class, 'index'])->name('usage.index'); Route::get('usage', [UsageController::class, 'index'])->name('usage.index');
Route::get('usage/{attemptId}', [UsageController::class, 'show'])->whereNumber('attemptId')->name('usage.show'); Route::get('usage/{attemptId}', [UsageController::class, 'show'])->whereNumber('attemptId')->name('usage.show');
Route::post('usage/{attemptId}/issue/pin-instant', [UsageController::class, 'issuePinInstant'])->whereNumber('attemptId')->name('usage.issue.pin_instant');
Route::post('usage/{attemptId}/open', [UsageController::class, 'openPins'])->whereNumber('attemptId')->name('usage.open'); Route::post('usage/{attemptId}/open', [UsageController::class, 'openPins'])->whereNumber('attemptId')->name('usage.open');
Route::post('usage/{attemptId}/cancel', [UsageController::class, 'cancel'])->whereNumber('attemptId')->name('usage.cancel'); Route::post('usage/{attemptId}/cancel', [UsageController::class, 'cancel'])->whereNumber('attemptId')->name('usage.cancel');
Route::post('usage/{attemptId}/reveal', [UsageController::class, 'revealPins'])->whereNumber('attemptId')->name('usage.reveal');
Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index'); Route::view('exchange', 'web.mypage.exchange.index')->name('exchange.index');
Route::get('qna', [MypageQnaController::class, 'index'])->name('qna.index'); Route::get('qna', [MypageQnaController::class, 'index'])->name('qna.index');