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