cfg->phone($mode); $binPath = rtrim($c['bin_path'], '/'); $itemCode = (string)$c['item_code']; // ITEMSEND2 $trans = [ 'Command' => 'ITEMSEND2', 'SERVICE' => 'TELEDIT', 'ItemCount' => '1', 'OUTPUTOPTION' => 'DEFAULT', 'ID' => $c['cpid'], 'PWD' => $c['pwd'], 'SUBCP' => '', 'USERID' => (string)$order->mem_no, 'ORDERID' => (string)$order->oid, 'IsPreOtbill' => 'N', 'IsSubscript' => 'N', 'ItemInfo' => $this->makeItemInfo((int)$order->pay_money, $itemCode, $this->orderTitle($order)), ]; $res = $this->runSClient($binPath, $trans); if (($res['Result'] ?? '') !== '0') { $msg = (string)($res['ErrMsg'] ?? '휴대폰 결제 모듈 실패'); return [ 'req' => $trans, 'res' => $res, 'start' => null, 'error' => ['code' => (string)($res['Result'] ?? 'E_TELEDIT'), 'msg' => $msg], ]; } // RETURN 시 검증용(oid/amount/mem_no/token/mode) $orderPayload = Crypt::encryptString($order->oid . '/' . $order->pay_money . '/' . $order->mem_no . '/' . $attemptToken . '/' . $mode); $byPass = [ 'BgColor' => '00', 'TargetURL' => route('web.payments.danal.phone.return', [], true), 'BackURL' => route('web.payments.danal.phone.cancel', [], true), 'IsUseCI' => 'N', 'CIURL' => $extra['ci_url'] ?? url('/img/main/top_logo.png'), 'Email' => $extra['email'] ?? '', 'IsCharSet' => '', 'Order' => $orderPayload, ]; $actionUrl = $isMobile ? $c['start_url_mobile'] : $c['start_url_web']; $params = []; foreach ($res as $k => $v) { if ($k === 'Result' || $k === 'ErrMsg') continue; $params[$k] = $v; } foreach ($byPass as $k => $v) $params[$k] = $v; // 폼 히든 추가(필요시) $params['CPName'] = $extra['cp_name'] ?? '핀포유'; $params['ItemName'] = $this->orderTitle($order); $params['ItemAmt'] = (string)$order->pay_money; $params['IsCarrier'] = (string)$extra['member_cell_corp']; $params['IsDstAddr'] = (string)$extra['member_phone_enc']; logger()->info('danal_phone_mode_config', [ 'params' => $extra, 'default_mode' => config('danal.phone.default_mode'), ]); return [ 'req' => $trans, 'res' => $res, 'start' => [ 'actionUrl' => $actionUrl, 'params' => $params, 'acceptCharset' => 'EUC-KR', ], ]; } public function confirmAndBill(array $post, callable $onPaid): array { $serverInfo = (string)($post['ServerInfo'] ?? ''); $encOrder = (string)($post['Order'] ?? ''); if ($serverInfo === '' || $encOrder === '') { return ['ok' => false, 'code' => 'E_PHONE_PARAM', 'msg' => 'ServerInfo/Order 누락', 'payload' => ['post'=>$post]]; } [$oid, $amount, $memNo, $token, $mode] = $this->decryptOrder($encOrder); $c = $this->cfg->phone($mode); $binPath = rtrim($c['bin_path'], '/'); // NCONFIRM $nConfirm = [ 'Command' => 'NCONFIRM', 'OUTPUTOPTION' => 'DEFAULT', 'ServerInfo' => $serverInfo, 'IFVERSION' => 'V1.1.2', 'ConfirmOption' => '0', ]; $res1 = $this->runSClient($binPath, $nConfirm); if (($res1['Result'] ?? '') !== '0') { return ['ok' => false, 'code' => (string)($res1['Result'] ?? 'E_NCONFIRM'), 'msg' => (string)($res1['ErrMsg'] ?? 'NCONFIRM 실패'), 'payload'=>['post'=>$post,'res1'=>$res1]]; } // NBILL $nBill = [ 'Command' => 'NBILL', 'OUTPUTOPTION' => 'DEFAULT', 'ServerInfo' => $serverInfo, 'IFVERSION' => 'V1.1.2', 'BillOption' => '0', ]; $res2 = $this->runSClient($binPath, $nBill); if (($res2['Result'] ?? '') !== '0') { return ['ok' => false, 'code' => (string)($res2['Result'] ?? 'E_NBILL'), 'msg' => (string)($res2['ErrMsg'] ?? 'NBILL 실패'), 'payload'=>['post'=>$post,'res1'=>$res1,'res2'=>$res2]]; } $tid = (string)($res1['TID'] ?? ''); $paidAmount = (int)($res1['AMOUNT'] ?? $amount); $payload = ['post'=>$post,'nconfirm'=>$res1,'nbill'=>$res2,'meta'=>['oid'=>$oid,'mem_no'=>$memNo,'token'=>$token,'mode'=>$mode]]; $onPaid($oid, $token, $tid, $paidAmount, $payload); return ['ok'=>true, 'oid'=>$oid, 'token'=>$token, 'tid'=>$tid, 'amount'=>$paidAmount, 'payload'=>$payload]; } public function decryptOrder(string $encOrder): array { $plain = Crypt::decryptString($encOrder); $parts = explode('/', $plain); $oid = (string)($parts[0] ?? ''); $amount = (int)($parts[1] ?? 0); $memNo = (string)($parts[2] ?? ''); $token = (string)($parts[3] ?? ''); $mode = (string)($parts[4] ?? 'prod'); if ($oid === '' || $amount <= 0 || $memNo === '' || $token === '') { throw new \RuntimeException('Invalid phone Order payload'); } if ($mode !== 'prod' && $mode !== 'dev') $mode = 'prod'; return [$oid, $amount, $memNo, $token, $mode]; } public function cancel(array $post, callable $onCancel): array { $encOrder = (string)($post['Order'] ?? ''); if ($encOrder === '') return ['ok'=>false, 'code'=>'E_CANCEL', 'msg'=>'Order 누락', 'payload'=>['post'=>$post]]; [$oid, $amount, $memNo, $token, $mode] = $this->decryptOrder($encOrder); $payload = ['post'=>$post,'meta'=>compact('oid','amount','memNo','token','mode')]; $onCancel($oid, $token, $payload); return ['ok'=>true, 'oid'=>$oid, 'token'=>$token, 'payload'=>$payload]; } private function runSClient(string $binPath, array $params): array { $arg = $this->makeParam($params); $proc = new Process([$binPath . '/SClient', $arg]); $proc->setTimeout((int)config('danal.http.timeout', 30)); $proc->run(); $out = $proc->getOutput(); if ($out === '' && !$proc->isSuccessful()) { $err = $proc->getErrorOutput(); throw new \RuntimeException("Teledit SClient failed: {$err}"); } return $this->parseOutput($out); } private function parseOutput(string $out): array { $out = str_replace("\r", '', $out); $lines = array_filter(array_map('trim', explode("\n", $out)), fn($v) => $v !== ''); $in = implode('&', $lines); $map = []; foreach (explode('&', $in) as $tok) { $tok = trim($tok); if ($tok === '') continue; $tmp = explode('=', $tok, 2); $name = trim($tmp[0] ?? ''); if ($name === '') continue; $value = trim($tmp[1] ?? ''); $value = urldecode($value); // 이미 UTF-8이면 그대로 두고, 아니면 EUC-KR -> UTF-8 변환 if (!function_exists('mb_check_encoding') || !mb_check_encoding($value, 'UTF-8')) { $value = @iconv('EUC-KR', 'UTF-8//IGNORE', $value) ?: $value; // 마지막 안전망(깨진 UTF-8 제거) if (function_exists('mb_check_encoding') && !mb_check_encoding($value, 'UTF-8')) { $value = @iconv('UTF-8', 'UTF-8//IGNORE', $value) ?: ''; } } $map[$name] = $value; } return $map; } private function makeParam(array $arr): string { $parts = []; foreach ($arr as $k => $v) { $sv = (string)$v; // SClient 전송용만 EUC-KR $sv = @iconv('UTF-8', 'EUC-KR//IGNORE', $sv) ?: $sv; $parts[] = $k . '=' . $sv; } return implode(';', $parts); } private function makeItemInfo(int $amt, string $code, string $name): string { $name = $this->safeItemName($name); return substr($code, 0, 1) . '|' . $amt . '|1|' . $code . '|' . $name; } private function orderTitle(GcPinOrder $order): string { $items = $order->items()->limit(2)->get(); if ($items->count() === 0) return '상품권'; if ($items->count() === 1) return (string)$items[0]->item_name; return (string)$items[0]->item_name . ' 외'; } private function safeItemName(string $s): string { $s = str_replace([";","=","'","|","\r","\n"], " ", $s); // teledit 금칙 최소 $s = str_replace(["&","\"","\\","<",">","," , "+"], " ", $s); return trim(preg_replace('/\s+/', ' ', $s)) ?: '상품권'; } public function billCancel(string $mode, string $tid): array { $c = $this->cfg->phone($mode); $binPath = rtrim($c['bin_path'], '/'); $req = [ 'Command' => 'BILL_CANCEL', 'OUTPUTOPTION' => '3', 'ID' => $c['cpid'], 'PWD' => $c['pwd'], 'TID' => $tid, ]; $res = $this->runSClient($binPath, $req); return ['req' => $req, 'res' => $res]; } }