Files
cunkebao_v3/Server/application/common/controller/PaymentService.php
2025-09-23 17:42:23 +08:00

334 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace app\common\controller;
use think\Db;
use app\common\util\PaymentUtil;
use think\facade\Config;
use think\facade\Env;
use think\facade\Log;
use think\facade\Request;
use app\common\model\Order;
/**
* 支付服务(内部调用)
*/
class PaymentService
{
/**
* 下单
*
* @param array $order
* - out_trade_no: string 商户订单号(必填)
* - total_fee: int 金额(分,必填)
* - body: string 商品描述(必填)
* - notify_url: string 异步通知地址(可覆盖配置)
* - attach: string 附加数据(可选)
* - time_expire: string 订单失效时间(可选)
* - client_ip: string 终端IP可选
* - sign_type: string MD5/RSA_1_256/RSA_1_1可选默认MD5
* - pay_type: string 支付场景,如 JSAPI/APP/H5可选
* @return array
* @throws \Exception
*/
public function createOrder(array $order)
{
$params = [
'service' => 'unified.trade.native',
'sign_type' => PaymentUtil::SIGN_TYPE_MD5,
'mch_id' => Env::get('payment.mchId'),
'out_trade_no' => $order['orderNo'],
'body' => $order['goodsName'] ?? '',
'total_fee' => $order['money'] ?? 0,
'mch_create_ip' => Request::ip(),
'notify_url' => Env::get('payment.notify_url','127.0.0.1'),
'nonce_str' => PaymentUtil::generateNonceStr(),
];
exit_data($params);
Db::startTrans();
try {
// 过滤空值签名
$secret = Env::get('payment.key');
$params['sign_type'] = 'MD5';
$params['sign'] = PaymentUtil::generateSign($params, $secret, 'MD5');
$url = Env::get('payment.url');
if (empty($url)) {
throw new \Exception('支付网关地址未配置');
}
//创建订单
Order::create([
'mchId' => $params['mch_id'],
'companyId' => $order['companyId'],
'userId' => $order['userId'],
'orderType' => $order['orderType'] ?? 1,
'status' => 0,
'goodsId' => $order['goodsId'],
'goodsName' => $order['goodsName'],
'money' => $order['money'],
'orderNo' => $order['orderNo'],
'ip' => Request::ip(),
'nonceStr' => $params['nonce_str'],
'createTime' => time(),
]);
// XML POST 请求
$xmlBody = $this->arrayToXml($params);
$response = $this->postXml($url, $xmlBody);
$parsed = $this->parseXmlOrRaw($response);
if ($parsed['status'] == 0 && $parsed['result_code'] == 0) {
Db::commit();
return json(['code' => 200, 'msg' => '订单创建成功','data' => $parsed['code_url']]);
}else{
Db::rollback();
return json(['code' => 500, 'msg' => '订单创建失败:' . $parsed['err_msg']]);
}
} catch (\Exception $e) {
Db::rollback();
return json(['code' => 500, 'msg' => '订单创建失败:' . $e->getMessage()]);
}
}
/**
* POST 请求x-www-form-urlencoded
*/
protected function httpPost(string $url, array $params, array $headers = [])
{
if (!function_exists('requestCurl')) {
throw new \RuntimeException('requestCurl 未定义');
}
return requestCurl($url, $params, 'POST', $headers, 'dataBuild');
}
/**
* 解析响应
*/
protected function parseResponse($response)
{
if ($response === '' || $response === null) {
return '';
}
$decoded = json_decode($response, true);
if (json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
if (strpos($response, '=') !== false && strpos($response, '&') !== false) {
$arr = [];
foreach (explode('&', $response) as $pair) {
if ($pair === '') continue;
$kv = explode('=', $pair, 2);
$arr[$kv[0]] = $kv[1] ?? '';
}
return $arr;
}
return $response;
}
/**
* 以 XML 方式 POSTtext/xml
*/
protected function postXml(string $url, string $xml)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: text/xml; charset=UTF-8'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$res = curl_exec($ch);
curl_close($ch);
return $res;
}
/**
* 数组转 XML按 ASCII 升序,字符串走 CDATA
*/
protected function arrayToXml(array $data): string
{
// 过滤空值
$filtered = [];
foreach ($data as $k => $v) {
if ($v === '' || $v === null) continue;
$filtered[$k] = $v;
}
ksort($filtered, SORT_STRING);
$xml = '<xml>';
foreach ($filtered as $key => $value) {
if (is_numeric($value)) {
$xml .= "<{$key}>{$value}</{$key}>";
} else {
$xml .= "<{$key}><![CDATA[{$value}]]></{$key}>";
}
}
$xml .= '</xml>';
return $xml;
}
/**
* 解析 XML 响应
*/
protected function parseXmlOrRaw($response)
{
if (!is_string($response) || $response === '') {
return $response;
}
libxml_use_internal_errors(true);
$xml = simplexml_load_string($response, 'SimpleXMLElement', LIBXML_NOCDATA);
if ($xml !== false) {
$json = json_encode($xml, JSON_UNESCAPED_UNICODE);
return json_decode($json, true);
}
return $response;
}
/**
* 支付结果异步通知
* - 威富通回调为 XML需校验签名与业务字段并更新订单
* - 回应:成功回"success",失败回"fail"
* @return void
*/
public function notify()
{
Log::info('支付结果异步通知');
$rawBody = file_get_contents('php://input');
Log::info('[SwiftPass][notify] raw: ' . $rawBody);
$payload = $this->parseXmlOrRaw($rawBody);
if (!is_array($payload) || empty($payload)) {
Log::error('[SwiftPass][notify] parse fail');
echo 'fail';
return;
}
// 基础字段
$status = (string)($payload['status'] ?? '');
$resultCode = (string)($payload['result_code'] ?? '');
$outTradeNo = (string)($payload['out_trade_no'] ?? '');
$totalFee = (int)($payload['total_fee'] ?? 0);
$mchIdNotify = (string)($payload['mch_id'] ?? '');
$signInNotify = (string)($payload['sign'] ?? '');
$signType = (string)($payload['sign_type'] ?? 'MD5');
if ($status !== '0' || $resultCode !== '0') {
Log::warning('[SwiftPass][notify] business not success', $payload);
echo 'fail';
return;
}
// 验签
$secret = Env::get('payment.key');
if (empty($secret)) {
Log::error('[SwiftPass][notify] payment.key not configured');
echo 'fail';
return;
}
$verifyData = $payload;
unset($verifyData['sign']);
$calcSign = PaymentUtil::generateSign($verifyData, $secret, $signType ?: 'MD5');
if (strcasecmp($calcSign, $signInNotify) !== 0) {
Log::error('[SwiftPass][notify] sign mismatch', [
'calc' => $calcSign,
'recv' => $signInNotify,
]);
echo 'fail';
return;
}
// 校验商户号
$mchIdConfig = Env::get('payment.mchId');
if (!empty($mchIdConfig) && $mchIdConfig !== $mchIdNotify) {
Log::error('[SwiftPass][notify] mch_id mismatch', [
'config' => $mchIdConfig,
'notify' => $mchIdNotify,
]);
echo 'fail';
return;
}
// 业务处理:更新订单
Db::startTrans();
try {
/** @var Order|null $order */
$order = Order::where('orderNo', $outTradeNo)->lock(true)->find();
if (!$order) {
Db::rollback();
Log::error('[SwiftPass][notify] order not found', ['out_trade_no' => $outTradeNo]);
echo 'fail';
return;
}
// 金额校验(单位:分)
if ((int)$order['money'] !== $totalFee) {
Db::rollback();
Log::error('[SwiftPass][notify] amount mismatch', [
'order_money' => (int)$order['money'],
'notify_fee' => $totalFee,
]);
echo 'fail';
return;
}
// 幂等:已支付直接返回成功
if ((int)$order['status'] === 1) {
Db::commit();
echo 'success';
return;
}
$transactionId = (string)($payload['transaction_id'] ?? '');
$timeEndStr = (string)($payload['time_end'] ?? '');
$paidAt = $this->parsePayTime($timeEndStr) ?: time();
$order->save([
'status' => 1,
'transactionId' => $transactionId,
'payTime' => $paidAt,
'updateTime' => time(),
]);
Db::commit();
echo 'success';
return;
} catch (\Throwable $e) {
Db::rollback();
Log::error('[SwiftPass][notify] exception: ' . $e->getMessage());
echo 'fail';
return;
}
}
/**
* 解析威富通时间yyyyMMddHHmmss为时间戳
*/
protected function parsePayTime(string $timeEnd)
{
if ($timeEnd === '') {
return 0;
}
// 期望格式20250102153045
if (preg_match('/^\\d{14}$/', $timeEnd) !== 1) {
return 0;
}
$dt = \DateTime::createFromFormat('YmdHis', $timeEnd, new \DateTimeZone('Asia/Shanghai'));
return $dt ? $dt->getTimestamp() : 0;
}
}