438 lines
15 KiB
PHP
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));
|
|
}
|
|
}
|