giftcon_dev/app/Services/Payments/OwnPinIssueService.php
2026-03-06 15:48:44 +09:00

438 lines
15 KiB
PHP

<?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));
}
}