feat: 对接越南AlinPay支付网关(代收+代付)
- 新增 AlinPay 网关核心类:签名/验签/代收/代付/查询/账户接口 - 新增 alinPay trait:充值发起 + 代收回调结算(事务+行锁) - 修改 admin/agent Report:提现审批自动路由 ALIN_VN 发起代付 - 新增 Pcapi:银行卡提现申请 + 越南银行列表接口 - 修改 Crypto:代付回调/订单查询/账户查询入口 - 新增越南区 area_list(id=3) + alinPay pay_list 配置 - DDL: user_withdraw 表增加 pay_channel/ext 字段
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
namespace pay;
|
||||
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* AlinPay 越南支付网关
|
||||
* 代收(Collection): /api/order product_id=11
|
||||
* 代付(Disbursement): /api/payment product_id=14
|
||||
* 代收查询: /api/order/status
|
||||
* 代付查询: /api/payment/status
|
||||
* 账户查询: /api/account
|
||||
*/
|
||||
class AlinPay
|
||||
{
|
||||
private $config;
|
||||
private $domain;
|
||||
|
||||
// 越南银行编码映射
|
||||
private static $bankCodes = [
|
||||
'Liobank','VietcomBank','VietinBank','MB','BIDV',
|
||||
'Techcombank','Sacombank','ACB','Agribank','VPBank',
|
||||
'TPBank','VIB','BVBank','MSB','SHB',
|
||||
'Vikki Bank','Nam A Bank','HDBank','Eximbank','SeABank',
|
||||
'LPBank','PVcomBank','PVcomBank Pay','SCB','Shinhan VN',
|
||||
'ABBank','Kienlongbank','CAKE','VietBank','Wooribank',
|
||||
'NCB','PG_Bank','BacABank','SCBVN','HSBC VN',
|
||||
'BaoViet Bank','VietABank','Saigonbank','Co-op Bank',
|
||||
'Timo by Ban Viet Bank','MBV','IVB','VCBNeo',
|
||||
'Public Bank Viet Nam','Citibank HN','Citibank HCM',
|
||||
'VBSP','VRB','Hong Leong Bank','UOB','Umee',
|
||||
'GPBank','Ubank','KBank','Deutsche Bank','VNPT Money',
|
||||
'Viettel Money','KBNN','MUFG Bank','ANZ','IBKHN',
|
||||
'IBK HCM','Sinopac','CTBC','SMBC','Mizuho Bank HN',
|
||||
'Mizuho Bank HCM','DBS HCM','Bank Of China','CIMB',
|
||||
'BNP Paribas HCM','BNP Paribas HN','MEGA ICBC',
|
||||
'BANGKOK BANK H','BPCE IOM','Taipei Fubon Binh Duong',
|
||||
'Taipei Fubon HN','Taipei Fubon HCM','OCBC HCM',
|
||||
'Home Credit','CCB','HuaNan Bank','ICBC Hà Nội',
|
||||
'SIAM-SCB','JP Morgan Chase Bank','KBHN','KBHCM',
|
||||
'Maybank','BIDC','CILC','BoCom',
|
||||
'First Commercial Bank HN','First Commercial Bank HCM',
|
||||
'Agricultural Bank of China','Keb Hana HN','Keb Hana HCM',
|
||||
'NHNN','VDB','Napas','HAFIC','TNEX by MSB',
|
||||
'Vinaconex - Viettel','MoMo','ZaloPay',
|
||||
'Mobifone Tiendidong','PAYOO','E.SUN Commercial Bank',
|
||||
'Neopay','Hpay','CFC','PTF','VFC',
|
||||
'TechcomFinance','MAFC','JIVF','VFL','TFSVN',
|
||||
'Bank of India- BOI','Busan-BNK','Cathay United',
|
||||
'Nonghyup','Quý TDCS','SVFC','Vikki',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->getConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置信息
|
||||
*/
|
||||
private function getConfig()
|
||||
{
|
||||
$config = Db::name('pay_channel')->where('key', 'ALIN_VN')->value('value');
|
||||
$config = json_decode($config, true);
|
||||
$this->config = $config;
|
||||
|
||||
// 域名从配置读取,回退默认值
|
||||
$this->domain = $config['domain'] ?? 'https://alinintermon-api.fantianchuangshi.com';
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名
|
||||
* 规则:参数按key排序 → key=value&连接(空值不参与) → 拼接key={secret} → MD5 32位小写
|
||||
* ext字段不参与签名
|
||||
*
|
||||
* @param array $params
|
||||
* @return string
|
||||
*/
|
||||
public function generateSign(array $params)
|
||||
{
|
||||
// 移除不参与签名的字段
|
||||
unset($params['sign']);
|
||||
unset($params['ext']);
|
||||
|
||||
// 过滤空值
|
||||
$params = array_filter($params, function ($val) {
|
||||
return $val !== '' && $val !== null;
|
||||
});
|
||||
|
||||
// 按key排序
|
||||
ksort($params);
|
||||
|
||||
// 拼接
|
||||
$parts = [];
|
||||
foreach ($params as $key => $val) {
|
||||
$parts[] = $key . '=' . $val;
|
||||
}
|
||||
$str = implode('&', $parts);
|
||||
$str .= '&key=' . $this->config['secret'];
|
||||
|
||||
return md5($str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证回调签名(时间安全比较,防timing attack)
|
||||
*
|
||||
* @param array $params
|
||||
* @return bool
|
||||
*/
|
||||
public function verifySign(array $params)
|
||||
{
|
||||
$receivedSign = $params['sign'] ?? '';
|
||||
$calculatedSign = $this->generateSign($params);
|
||||
return hash_equals($calculatedSign, $receivedSign);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP请求
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $data
|
||||
* @return array|null
|
||||
*/
|
||||
private function request($url, $data)
|
||||
{
|
||||
$jsonData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
addLogToFile('请求地址:' . $url, 'alin_request', 'alin');
|
||||
addLogToFile('请求报文:' . $jsonData, 'alin_request', 'alin');
|
||||
|
||||
$header = ['Content-Type: application/json'];
|
||||
$result = httpPost($url, $jsonData, $header);
|
||||
|
||||
addLogToFile('返回结果:' . json_encode($result, JSON_UNESCAPED_UNICODE), 'alin_response', 'alin');
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 代收下单(用户充值)
|
||||
*
|
||||
* @param string $orderNo 商户订单号
|
||||
* @param string $amount 金额(元)
|
||||
* @param string $notifyUrl 回调地址
|
||||
* @param string $desc 订单描述
|
||||
* @return array|null
|
||||
*/
|
||||
public function createOrder($orderNo, $amount, $notifyUrl, $desc = '')
|
||||
{
|
||||
$params = [
|
||||
'app_id' => $this->config['app_id'],
|
||||
'product_id' => $this->config['collection_product_id'], // 代收 product_id=11
|
||||
'out_trade_no' => $orderNo,
|
||||
'notify_url' => $notifyUrl,
|
||||
'amount' => $amount,
|
||||
'time' => time(),
|
||||
'desc' => $desc,
|
||||
];
|
||||
|
||||
$params['sign'] = $this->generateSign($params);
|
||||
|
||||
$url = $this->domain . '/api/order';
|
||||
return $this->request($url, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 代收查询
|
||||
*
|
||||
* @param string $orderNo 商户订单号
|
||||
* @return array|null
|
||||
*/
|
||||
public function queryOrder($orderNo)
|
||||
{
|
||||
$params = [
|
||||
'app_id' => $this->config['app_id'],
|
||||
'out_trade_no' => $orderNo,
|
||||
'time' => time(),
|
||||
];
|
||||
|
||||
$params['sign'] = $this->generateSign($params);
|
||||
|
||||
$url = $this->domain . '/api/order/status';
|
||||
return $this->request($url, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 代付下单(用户提现)
|
||||
*
|
||||
* @param string $orderNo 商户订单号
|
||||
* @param string $amount 金额(元)
|
||||
* @param string $accountName 账户名称
|
||||
* @param string $accountNumber 卡号
|
||||
* @param string $bankCode 银行编码
|
||||
* @param string $bankName 银行名称
|
||||
* @param string $notifyUrl 回调地址
|
||||
* @param string $desc 订单描述
|
||||
* @return array|null
|
||||
*/
|
||||
public function createPayment($orderNo, $amount, $accountName, $accountNumber, $bankCode, $bankName, $notifyUrl, $desc = '')
|
||||
{
|
||||
$params = [
|
||||
'app_id' => $this->config['app_id'],
|
||||
'product_id' => $this->config['payment_product_id'], // 代付 product_id=14
|
||||
'out_trade_no' => $orderNo,
|
||||
'notify_url' => $notifyUrl,
|
||||
'amount' => $amount,
|
||||
'time' => time(),
|
||||
'desc' => $desc,
|
||||
];
|
||||
|
||||
$params['sign'] = $this->generateSign($params);
|
||||
|
||||
// ext不参与签名,单独附加
|
||||
$params['ext'] = [
|
||||
'accountName' => $accountName,
|
||||
'accountNumber' => $accountNumber,
|
||||
'bankCode' => $bankCode,
|
||||
'bankName' => $bankName,
|
||||
];
|
||||
|
||||
$url = $this->domain . '/api/payment';
|
||||
return $this->request($url, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 代付查询
|
||||
*
|
||||
* @param string $orderNo 商户订单号
|
||||
* @return array|null
|
||||
*/
|
||||
public function queryPayment($orderNo)
|
||||
{
|
||||
$params = [
|
||||
'app_id' => $this->config['app_id'],
|
||||
'out_trade_no' => $orderNo,
|
||||
'time' => time(),
|
||||
];
|
||||
|
||||
$params['sign'] = $this->generateSign($params);
|
||||
|
||||
$url = $this->domain . '/api/payment/status';
|
||||
return $this->request($url, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户查询
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function queryAccount()
|
||||
{
|
||||
$params = [
|
||||
'app_id' => $this->config['app_id'],
|
||||
'time' => time(),
|
||||
];
|
||||
|
||||
$params['sign'] = $this->generateSign($params);
|
||||
|
||||
$url = $this->domain . '/api/account';
|
||||
return $this->request($url, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理代收回调 — 仅验签+解析状态,不操作余额
|
||||
* 余额结算由 trait alin_recharge_settle() 统一处理
|
||||
*
|
||||
* @param array $data 回调数据
|
||||
* @param int $logId 日志ID
|
||||
* @return array ['verified' => bool, 'trade_status' => int, 'data' => array]
|
||||
*/
|
||||
public function verifyRechargeCallback($data, $logId = 0)
|
||||
{
|
||||
// 验签
|
||||
if (!$this->verifySign($data)) {
|
||||
addLogToFile('代收回调签名验证失败:' . json_encode($data, 320), 'alin_callback_error', 'alin');
|
||||
return ['verified' => false, 'trade_status' => 0, 'data' => $data];
|
||||
}
|
||||
|
||||
$orderNo = $data['out_trade_no'] ?? '';
|
||||
if ($logId && $orderNo) {
|
||||
Db::name('pay_callback_log')->where('id', $logId)->update(['unique_no' => $orderNo]);
|
||||
}
|
||||
|
||||
$tradeStatus = intval($data['trade_status']);
|
||||
addLogToFile('代收回调验签通过:order=' . $orderNo . ' status=' . $tradeStatus, 'alin_callback', 'alin');
|
||||
|
||||
return ['verified' => true, 'trade_status' => $tradeStatus, 'data' => $data];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理代付回调(提现回调)
|
||||
*
|
||||
* @param array $data 回调数据
|
||||
* @param int $logId 日志ID
|
||||
* @return string|void
|
||||
*/
|
||||
public function paymentCallback($data, $logId = 0)
|
||||
{
|
||||
// 验签
|
||||
if (!$this->verifySign($data)) {
|
||||
addLogToFile('代付回调签名验证失败:' . json_encode($data, 320), 'alin_callback_error', 'alin');
|
||||
return;
|
||||
}
|
||||
|
||||
$orderNo = $data['out_trade_no'] ?? '';
|
||||
if (!$orderNo) return;
|
||||
|
||||
if ($logId) {
|
||||
Db::name('pay_callback_log')->where('id', $logId)->update(['unique_no' => $orderNo]);
|
||||
}
|
||||
|
||||
$tradeStatus = intval($data['trade_status']);
|
||||
|
||||
$order = Db::name('user_withdraw')->where('order_no', $orderNo)->find();
|
||||
if (!$order) {
|
||||
addLogToFile('代付回调订单不存在:' . $orderNo, 'alin_callback', 'alin');
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// 只处理 AGREE 状态的订单
|
||||
if ($order['status'] !== 'AGREE') {
|
||||
addLogToFile('代付回调订单状态非AGREE:' . $orderNo . ' status=' . $order['status'], 'alin_callback', 'alin');
|
||||
return 'success';
|
||||
}
|
||||
|
||||
$payTime = strtotime($data['complete_time'] ?? '') ?: time();
|
||||
|
||||
if ($tradeStatus === 1) {
|
||||
// 成功
|
||||
$updateData = [
|
||||
'status' => 'SUCCESS',
|
||||
'pay_time' => $payTime,
|
||||
'out_trade_no' => $data['trade_no'] ?? '',
|
||||
];
|
||||
Db::name('user_withdraw')->where('id', $order['id'])->update($updateData);
|
||||
addLogToFile('代付回调成功:' . $orderNo, 'alin_callback', 'alin');
|
||||
return 'success';
|
||||
} elseif ($tradeStatus === 2) {
|
||||
// 失败 — 退回用户余额
|
||||
Db::startTrans();
|
||||
try {
|
||||
$user = Db::name('user')->where('id', $order['user_id'])->lock(true)->find();
|
||||
$newMoney = $user['money'] + $order['money'] + $order['service_fee_money'];
|
||||
|
||||
Db::name('user')->where('id', $order['user_id'])->update([
|
||||
'money' => $newMoney,
|
||||
]);
|
||||
|
||||
Db::name('user_withdraw')->where('id', $order['id'])->update([
|
||||
'status' => 'FAIL',
|
||||
'pay_time' => $payTime,
|
||||
]);
|
||||
|
||||
Db::commit();
|
||||
addLogToFile('代付回调失败已退款:' . $orderNo . ' refund=' . ($order['money'] + $order['service_fee_money']), 'alin_callback', 'alin');
|
||||
return 'success';
|
||||
} catch (\Exception $e) {
|
||||
Db::rollback();
|
||||
addLogToFile('代付回调退款异常:' . $e->getMessage(), 'alin_callback_error', 'alin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理中(-1),不返回success让网关重试
|
||||
addLogToFile('代付回调处理中:' . $orderNo . ' status=' . $tradeStatus, 'alin_callback', 'alin');
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取银行编码列表
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getBankCodes()
|
||||
{
|
||||
return self::$bankCodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置信息(外部使用)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getConfigInfo()
|
||||
{
|
||||
return $this->config;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user