From 7ed368a13782df4d7049a8ece4317e670b8ec8a3 Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Tue, 23 Sep 2025 16:43:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E4=BB=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Server/application/common/config/route.php | 12 +- .../common/controller/PaymentService.php | 329 ++++++++++++++++++ Server/application/common/model/Order.php | 20 ++ .../application/common/util/PaymentUtil.php | 255 ++++++++++++++ Server/application/common/util/Signer.php | 135 +++++++ .../application/cunkebao/controller/Pay.php | 27 ++ ...PotentialListWithInCompanyV1Controller.php | 4 +- 7 files changed, 778 insertions(+), 4 deletions(-) create mode 100644 Server/application/common/controller/PaymentService.php create mode 100644 Server/application/common/model/Order.php create mode 100644 Server/application/common/util/PaymentUtil.php create mode 100644 Server/application/common/util/Signer.php create mode 100644 Server/application/cunkebao/controller/Pay.php diff --git a/Server/application/common/config/route.php b/Server/application/common/config/route.php index 8c6ccc6b..08c7d248 100644 --- a/Server/application/common/config/route.php +++ b/Server/application/common/config/route.php @@ -9,7 +9,6 @@ Route::group('v1/auth', function () { Route::post('login', 'app\common\controller\PasswordLoginController@index'); // 账号密码登录 Route::post('mobile-login', 'app\common\controller\Auth@mobileLogin'); // 手机号验证码登录 Route::post('code', 'app\common\controller\Auth@SendCodeController'); // 发送验证码 - // 需要JWT认证的接口 Route::get('info', 'app\common\controller\Auth@info')->middleware(['jwt']); // 获取用户信息 Route::post('refresh', 'app\common\controller\Auth@refresh')->middleware(['jwt']); // 刷新令牌 @@ -22,4 +21,13 @@ Route::group('v1/', function () { })->middleware(['jwt']); -Route::get('app/update', 'app\common\controller\Api@uploadApp'); \ No newline at end of file + +Route::group('v1/pay', function () { + Route::post('', 'app\cunkebao\controller\Pay@createOrder')->middleware(['jwt']); + Route::post('notify', 'app\common\controller\Attachment@notify'); +})->middleware(['jwt']); + + + + +Route::get('app/update', 'app\common\controller\PaymentService@createOrder'); \ No newline at end of file diff --git a/Server/application/common/controller/PaymentService.php b/Server/application/common/controller/PaymentService.php new file mode 100644 index 00000000..969731b0 --- /dev/null +++ b/Server/application/common/controller/PaymentService.php @@ -0,0 +1,329 @@ + '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',''), + 'nonce_str' => PaymentUtil::generateNonceStr(), + ]; + + 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 方式 POST(text/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 = ''; + foreach ($filtered as $key => $value) { + if (is_numeric($value)) { + $xml .= "<{$key}>{$value}"; + } else { + $xml .= "<{$key}>"; + } + } + $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() + { + $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; + } +} + diff --git a/Server/application/common/model/Order.php b/Server/application/common/model/Order.php new file mode 100644 index 00000000..c1ac3a3a --- /dev/null +++ b/Server/application/common/model/Order.php @@ -0,0 +1,20 @@ + $value) { + $pairs[] = $key . '=' . $value; + } + return implode('&', $pairs); + } + + /** + * 生成MD5签名 + * + * @param string $queryString 待签名字符串 + * @param string $secretKey 密钥 + * @return string MD5签名 + */ + private static function generateMd5Sign(string $queryString, string $secretKey): string + { + $signString = $queryString . '&key=' . $secretKey; + return strtoupper(md5($signString)); + } + + /** + * 生成RSA256签名 + * + * @param string $queryString 待签名字符串 + * @param string $privateKey 私钥 + * @return string RSA256签名 + */ + private static function generateRsa256Sign(string $queryString, string $privateKey): string + { + $privateKey = self::formatPrivateKey($privateKey); + $key = openssl_pkey_get_private($privateKey); + if (!$key) { + throw new \Exception('RSA私钥格式错误'); + } + + $signature = ''; + $result = openssl_sign($queryString, $signature, $key, OPENSSL_ALGO_SHA256); + openssl_pkey_free($key); + + if (!$result) { + throw new \Exception('RSA256签名失败'); + } + + return base64_encode($signature); + } + + /** + * 生成RSA1签名 + * + * @param string $queryString 待签名字符串 + * @param string $privateKey 私钥 + * @return string RSA1签名 + */ + private static function generateRsa1Sign(string $queryString, string $privateKey): string + { + $privateKey = self::formatPrivateKey($privateKey); + $key = openssl_pkey_get_private($privateKey); + if (!$key) { + throw new \Exception('RSA私钥格式错误'); + } + + $signature = ''; + $result = openssl_sign($queryString, $signature, $key, OPENSSL_ALGO_SHA1); + openssl_pkey_free($key); + + if (!$result) { + throw new \Exception('RSA1签名失败'); + } + + return base64_encode($signature); + } + + /** + * 格式化私钥 + * + * @param string $privateKey 原始私钥 + * @return string 格式化后的私钥 + */ + private static function formatPrivateKey(string $privateKey): string + { + $privateKey = str_replace(['-----BEGIN PRIVATE KEY-----', '-----END PRIVATE KEY-----', "\n", "\r"], '', $privateKey); + $privateKey = chunk_split($privateKey, 64, "\n"); + return "-----BEGIN PRIVATE KEY-----\n" . $privateKey . "-----END PRIVATE KEY-----"; + } + + /** + * 格式化公钥 + * + * @param string $publicKey 原始公钥 + * @return string 格式化后的公钥 + */ + private static function formatPublicKey(string $publicKey): string + { + $publicKey = str_replace(['-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----', "\n", "\r"], '', $publicKey); + $publicKey = chunk_split($publicKey, 64, "\n"); + return "-----BEGIN PUBLIC KEY-----\n" . $publicKey . "-----END PUBLIC KEY-----"; + } + + /** + * 验证RSA签名 + * + * @param string $queryString 原始字符串 + * @param string $signature 签名 + * @param string $publicKey 公钥 + * @param string $signType 签名类型 + * @return bool 验证结果 + */ + public static function verifyRsaSign(string $queryString, string $signature, string $publicKey, string $signType = self::SIGN_TYPE_RSA_1_256): bool + { + $publicKey = self::formatPublicKey($publicKey); + $key = openssl_pkey_get_public($publicKey); + if (!$key) { + return false; + } + + $algorithm = $signType === self::SIGN_TYPE_RSA_1_1 ? OPENSSL_ALGO_SHA1 : OPENSSL_ALGO_SHA256; + $result = openssl_verify($queryString, base64_decode($signature), $key, $algorithm); + openssl_pkey_free($key); + + return $result === 1; + } + + /** + * 生成随机字符串 + * + * @param int $length 长度 + * @return string 随机字符串 + */ + public static function generateNonceStr(int $length = 32): string + { + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + $str = ''; + for ($i = 0; $i < $length; $i++) { + $str .= $chars[mt_rand(0, strlen($chars) - 1)]; + } + return $str; + } + + /** + * 生成时间戳 + * + * @return int 时间戳 + */ + public static function generateTimestamp(): int + { + return time(); + } + + /** + * 格式化金额(分转元) + * + * @param int $amount 金额(分) + * @return string 格式化后的金额(元) + */ + public static function formatAmount(int $amount): string + { + return number_format($amount / 100, 2, '.', ''); + } + + /** + * 解析金额(元转分) + * + * @param string $amount 金额(元) + * @return int 金额(分) + */ + public static function parseAmount(string $amount): int + { + return (int) round(floatval($amount) * 100); + } +} diff --git a/Server/application/common/util/Signer.php b/Server/application/common/util/Signer.php new file mode 100644 index 00000000..9b68840d --- /dev/null +++ b/Server/application/common/util/Signer.php @@ -0,0 +1,135 @@ + $value) { + if ($key === 'sign') { + continue; + } + if ($value === '' || $value === null) { + continue; + } + $filtered[$key] = $value; + } + + ksort($filtered, SORT_STRING); + + $pairs = []; + foreach ($filtered as $key => $value) { + // 原始值拼接,不做 urlencode + $pairs[] = $key . '=' . (is_bool($value) ? ($value ? '1' : '0') : (string)$value); + } + + return implode('&', $pairs); + } + + /** + * MD5 签名 + * - 若提供 secret,则原串末尾追加 &key=SECRET + * - 返回 32 位小写 + * + * @param string $signString + * @param string|null $secret + * @return string + */ + protected static function signMd5($signString, $secret = null) + { + if ($secret !== null && $secret !== '') { + $signString .= '&key=' . $secret; + } + return strtolower(md5($signString)); + } + + /** + * RSA 签名 + * + * @param string $signString + * @param array $options 必填:private_key,可选:passphrase + * @param string $hashAlgo sha256|sha1 + * @return string base64 签名 + * @throws \InvalidArgumentException + */ + protected static function signRsa($signString, array $options, $hashAlgo = 'sha256') + { + if (empty($options['private_key'])) { + throw new \InvalidArgumentException('RSA signing requires private_key.'); + } + + $privateKey = $options['private_key']; + $passphrase = isset($options['passphrase']) ? (string)$options['passphrase'] : ''; + + // 兼容无头尾私钥,自动包裹为 PEM + if (strpos($privateKey, 'BEGIN') === false) { + $privateKey = "-----BEGIN PRIVATE KEY-----\n" . trim(chunk_split(str_replace(["\r", "\n"], '', $privateKey), 64, "\n")) . "\n-----END PRIVATE KEY-----"; + } + + $pkeyId = openssl_pkey_get_private($privateKey, $passphrase); + if ($pkeyId === false) { + throw new \InvalidArgumentException('Invalid RSA private key or passphrase.'); + } + + $signature = ''; + $algoConst = $hashAlgo === 'sha1' ? OPENSSL_ALGO_SHA1 : OPENSSL_ALGO_SHA256; + $ok = openssl_sign($signString, $signature, $pkeyId, $algoConst); + openssl_free_key($pkeyId); + + if (!$ok) { + throw new \InvalidArgumentException('OpenSSL sign failed.'); + } + + return base64_encode($signature); + } +} + + diff --git a/Server/application/cunkebao/controller/Pay.php b/Server/application/cunkebao/controller/Pay.php new file mode 100644 index 00000000..990a60de --- /dev/null +++ b/Server/application/cunkebao/controller/Pay.php @@ -0,0 +1,27 @@ + 111, + 'userId' => 111, + 'orderNo' => time() . rand(100000, 999999), + 'goodsId' => 34, + 'goodsName' => '测试测试', + 'orderType' => 1, + 'money' => 1 + ]; + + $paymentService = new PaymentService(); + $res = $paymentService->createOrder($order); + return $res; + } +} \ No newline at end of file diff --git a/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php b/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php index a6252698..84137be8 100644 --- a/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php +++ b/Server/application/cunkebao/controller/traffic/GetPotentialListWithInCompanyV1Controller.php @@ -56,11 +56,11 @@ class GetPotentialListWithInCompanyV1Controller extends BaseController } if (!empty($device)) { - $where[] = ['d.deviceId', '=', $device]; +// $where[] = ['d.deviceId', '=', $device]; } if (!empty($taskId)) { - $where[] = ['t.sceneId', '=', $taskId]; + //$where[] = ['t.sceneId', '=', $taskId]; } $where[] = ['s.companyId', '=', $this->getUserInfo('companyId')];