262 lines
9.2 KiB
PHP

<?php
namespace App\Providers\Danal\Gateways;
use App\Models\Payments\GcPinOrder;
use App\Providers\Danal\DanalConfig;
use Illuminate\Support\Facades\Crypt;
use Symfony\Component\Process\Process;
final class PhoneGateway
{
public function __construct(
private readonly DanalConfig $cfg,
) {}
public function ready(GcPinOrder $order, string $attemptToken, string $mode, bool $isMobile, array $extra = []): array
{
$c = $this->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)) ?: '상품권';
}
}