diff --git a/app/Http/Controllers/Pay/PaypalPayController.php b/app/Http/Controllers/Pay/PaypalPayController.php index 70451d5..1445321 100644 --- a/app/Http/Controllers/Pay/PaypalPayController.php +++ b/app/Http/Controllers/Pay/PaypalPayController.php @@ -2,123 +2,153 @@ namespace App\Http\Controllers\Pay; - use AmrShawky\LaravelCurrency\Facade\Currency; use App\Exceptions\RuleValidationException; use App\Http\Controllers\PayController; +use GuzzleHttp\Client; +use GuzzleHttp\Exception\RequestException; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; -use PayPal\Api\Amount; -use PayPal\Api\Details; -use PayPal\Api\Item; -use PayPal\Api\ItemList; -use PayPal\Api\Payer; -use PayPal\Api\Payment; -use PayPal\Api\PaymentExecution; -use PayPal\Api\RedirectUrls; -use PayPal\Api\Transaction; -use PayPal\Auth\OAuthTokenCredential; -use PayPal\Exception\PayPalConnectionException; -use PayPal\Rest\ApiContext; class PaypalPayController extends PayController { - const Currency = 'USD'; //货币单位 + /** + * PayPal API 地址 + * + * v2 Checkout Orders: + * - 创建订单: POST https://api-m.paypal.com/v2/checkout/orders + * - 捕获订单: POST https://api-m.paypal.com/v2/checkout/orders/{order_id}/capture + */ + const PAYPAL_API_BASE = 'https://api-m.paypal.com'; + + /** + * PayPal 沙盒 API 地址 + */ + const PAYPAL_SANDBOX_API_BASE = 'https://api-m.sandbox.paypal.com'; + public function gateway(string $payway, string $orderSN) { try { // 加载网关 $this->loadGateWay($orderSN, $payway); - $paypal = new ApiContext( - new OAuthTokenCredential( - $this->payGateway->merchant_key, - $this->payGateway->merchant_pem - ) - ); - $paypal->setConfig(['mode' => 'live']); - $product = $this->order->title; + // 得到汇率 $total = Currency::convert() ->from('CNY') - ->to('USD') + ->to(self::Currency) ->amount($this->order->actual_price) ->round(2) ->get(); - $shipping = 0; - $description = $this->order->title; - $payer = new Payer(); - $payer->setPaymentMethod('paypal'); - $item = new Item(); - $item->setName($product)->setCurrency(self::Currency)->setQuantity(1)->setPrice($total); - $itemList = new ItemList(); - $itemList->setItems([$item]); - $details = new Details(); - $details->setShipping($shipping)->setSubtotal($total); - $amount = new Amount(); - $amount->setCurrency(self::Currency)->setTotal($total)->setDetails($details); - $transaction = new Transaction(); - $transaction->setAmount($amount)->setItemList($itemList)->setDescription($description)->setInvoiceNumber($this->order->order_sn); - $redirectUrls = new RedirectUrls(); - $redirectUrls->setReturnUrl(route('paypal-return', ['success' => 'ok', 'orderSN' => $this->order->order_sn]))->setCancelUrl(route('paypal-return', ['success' => 'no', 'orderSN' => $this->order->order_sn])); - $payment = new Payment(); - $payment->setIntent('sale')->setPayer($payer)->setRedirectUrls($redirectUrls)->setTransactions([$transaction]); - $payment->create($paypal); - $approvalUrl = $payment->getApprovalLink(); - return redirect($approvalUrl); - } catch (PayPalConnectionException $payPalConnectionException) { - return $this->err($payPalConnectionException->getMessage()); + + $total = $this->formatPaypalAmount($total, $this->order->actual_price); + $paypalConfig = $this->resolvePaypalConfig($this->payGateway); + $accessToken = $this->getAccessToken($paypalConfig['client_id'], $paypalConfig['client_secret'], $paypalConfig['api_base']); + + $paypalOrder = $this->createPaypalOrder($accessToken, $paypalConfig['api_base'], [ + 'intent' => 'CAPTURE', + 'purchase_units' => [ + [ + 'reference_id' => $this->order->order_sn, + 'description' => $this->order->title, + 'invoice_id' => $this->order->order_sn, + 'custom_id' => $this->order->order_sn, + 'amount' => [ + 'currency_code' => self::Currency, + 'value' => $total, + 'breakdown' => [ + 'item_total' => [ + 'currency_code' => self::Currency, + 'value' => $total, + ], + ], + ], + 'items' => [ + [ + 'name' => mb_substr($this->order->title, 0, 127), + 'unit_amount' => [ + 'currency_code' => self::Currency, + 'value' => $total, + ], + 'quantity' => '1', + 'category' => 'DIGITAL_GOODS', + ], + ], + ], + ], + 'application_context' => [ + 'brand_name' => config('app.name', 'dujiaoka'), + 'shipping_preference' => 'NO_SHIPPING', + 'user_action' => 'PAY_NOW', + 'return_url' => route('paypal-return', ['success' => 'ok', 'orderSN' => $this->order->order_sn]), + 'cancel_url' => route('paypal-return', ['success' => 'no', 'orderSN' => $this->order->order_sn]), + ], + ]); + + foreach ($paypalOrder['links'] ?? [] as $link) { + if (($link['rel'] ?? '') === 'approve' && !empty($link['href'])) { + return redirect($link['href']); + } + } + + Log::error('paypal创建订单失败', ['response' => $paypalOrder]); + return $this->err('PayPal 创建订单失败:未获取到支付跳转链接'); } catch (RuleValidationException $exception) { return $this->err($exception->getMessage()); + } catch (\Exception $exception) { + Log::error('paypal创建订单异常', ['message' => $exception->getMessage()]); + return $this->err($exception->getMessage()); } } /** - *paypal 同步回调 + * paypal 同步回调 */ public function returnUrl(Request $request) { $success = $request->input('success'); - $paymentId = $request->input('paymentId'); - $payerID = $request->input('PayerID'); + $paypalOrderId = $request->input('token'); $orderSN = $request->input('orderSN'); - if ($success == 'no' || empty($paymentId) || empty($payerID)) { + + if ($success == 'no' || empty($paypalOrderId)) { // 取消支付 - redirect(url('detail-order-sn', ['orderSN' => $payerID])); + return redirect(url('detail-order-sn', ['orderSN' => $orderSN])); } + $order = $this->orderService->detailOrderSN($orderSN); if (!$order) { return 'error'; } + $payGateway = $this->payService->detail($order->pay_id); if (!$payGateway) { return 'error'; } - if($payGateway->pay_handleroute != '/pay/paypal'){ + + if ($payGateway->pay_handleroute != '/pay/paypal') { return 'error'; } - $paypal = new ApiContext( - new OAuthTokenCredential( - $payGateway->merchant_key, - $payGateway->merchant_pem - ) - ); - $paypal->setConfig(['mode' => 'live']); - $payment = Payment::get($paymentId, $paypal); - $execute = new PaymentExecution(); - $execute->setPayerId($payerID); + try { - $payment->execute($execute, $paypal); - $this->orderProcessService->completedOrder($orderSN, $order->actual_price, $paymentId); - Log::info("paypal支付成功", ['支付成功,支付ID【' . $paymentId . '】,支付人ID【' . $payerID . '】']); + $paypalConfig = $this->resolvePaypalConfig($payGateway); + $accessToken = $this->getAccessToken($paypalConfig['client_id'], $paypalConfig['client_secret'], $paypalConfig['api_base']); + $capture = $this->capturePaypalOrder($accessToken, $paypalConfig['api_base'], $paypalOrderId); + + if (($capture['status'] ?? '') === 'COMPLETED') { + $captureId = $capture['purchase_units'][0]['payments']['captures'][0]['id'] ?? $paypalOrderId; + $this->orderProcessService->completedOrder($orderSN, $order->actual_price, $captureId); + Log::info('paypal支付成功', ['订单号' => $orderSN, 'PayPal订单ID' => $paypalOrderId, '捕获ID' => $captureId]); + } else { + Log::error('paypal支付未完成', ['订单号' => $orderSN, 'PayPal订单ID' => $paypalOrderId, 'response' => $capture]); + } } catch (\Exception $e) { - Log::error("paypal支付失败", ['支付失败,支付ID【' . $paymentId . '】,支付人ID【' . $payerID . '】']); + Log::error('paypal支付失败', ['订单号' => $orderSN, 'PayPal订单ID' => $paypalOrderId, '错误' => $e->getMessage()]); } + return redirect(url('detail-order-sn', ['orderSN' => $orderSN])); } - /** * 异步通知 * TODO: 暂未实现,但是好像只实现同步回调即可。这个可以放在后面实现 @@ -127,12 +157,145 @@ class PaypalPayController extends PayController { //获取回调结果 $json_data = $this->get_JsonData(); - if(!empty($json_data)){ + if (!empty($json_data)) { Log::debug("paypal notify info:\r\n" . json_encode($json_data)); - }else{ + } else { Log::debug("paypal notify fail:参加为空"); } + } + /** + * 格式化 PayPal 金额 + * + * PayPal v2 Orders API 要求金额必须大于 0,且最多保留两位小数。 + * 如果 CNY 转 USD 后因为订单金额过小或汇率服务异常得到 0.00,则使用 PayPal 最小可支付金额 0.01。 + */ + private function formatPaypalAmount($convertedAmount, $originalAmount): string + { + $amount = round((float)$convertedAmount, 2); + + if ($amount <= 0) { + Log::warning('paypal订单金额转换后小于等于0,已使用最小金额0.01', [ + 'original_amount_cny' => $originalAmount, + 'converted_amount_usd' => $convertedAmount, + ]); + + $amount = 0.01; + } + + return number_format($amount, 2, '.', ''); + } + + /** + * 获取 PayPal OAuth2 Access Token + */ + private function getAccessToken(string $clientId, string $clientSecret, string $apiBase): string + { + $response = $this->paypalClient($apiBase)->post('/v1/oauth2/token', [ + 'auth' => [$clientId, $clientSecret], + 'form_params' => [ + 'grant_type' => 'client_credentials', + ], + 'headers' => [ + 'Accept' => 'application/json', + 'Accept-Language' => 'en_US', + ], + ]); + + $data = json_decode((string)$response->getBody(), true); + + if (empty($data['access_token'])) { + throw new \RuntimeException('PayPal access_token 获取失败'); + } + + return $data['access_token']; + } + + /** + * 创建 PayPal v2 Checkout Order + */ + private function createPaypalOrder(string $accessToken, string $apiBase, array $payload): array + { + return $this->paypalRequest('POST', '/v2/checkout/orders', $accessToken, $apiBase, $payload); + } + + /** + * 捕获 PayPal v2 Checkout Order + */ + private function capturePaypalOrder(string $accessToken, string $apiBase, string $paypalOrderId): array + { + return $this->paypalRequest('POST', '/v2/checkout/orders/' . urlencode($paypalOrderId) . '/capture', $accessToken, $apiBase); + } + + /** + * 请求 PayPal API + */ + private function paypalRequest(string $method, string $uri, string $accessToken, string $apiBase, array $payload = null): array + { + $options = [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $accessToken, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + ]; + + if ($payload !== null) { + $options['json'] = $payload; + } + + try { + $response = $this->paypalClient($apiBase)->request($method, $uri, $options); + return json_decode((string)$response->getBody(), true) ?: []; + } catch (RequestException $exception) { + $responseBody = $exception->hasResponse() ? (string)$exception->getResponse()->getBody() : ''; + Log::error('paypal api request error', [ + 'method' => $method, + 'uri' => $uri, + 'status' => $exception->hasResponse() ? $exception->getResponse()->getStatusCode() : null, + 'response' => $responseBody, + ]); + + throw new \RuntimeException($responseBody ?: $exception->getMessage(), $exception->getCode(), $exception); + } + } + + /** + * 解析 PayPal 配置 + * + * 后台支付配置建议: + * - 商户号 merchant_id:PayPal Client ID + * - 商户密钥 merchant_pem:PayPal Secret + * - 商户 KEY merchant_key:可选,填 sandbox/test 则使用沙盒;填 live/prod 或留空则使用生产 + * + * 兼容旧配置:如果 merchant_id 为空,则继续使用 merchant_key 作为 Client ID。 + */ + private function resolvePaypalConfig($payGateway): array + { + $merchantId = trim((string)$payGateway->merchant_id); + $merchantKey = trim((string)$payGateway->merchant_key); + $merchantPem = trim((string)$payGateway->merchant_pem); + + $mode = strtolower($merchantKey); + $apiBase = in_array($mode, ['sandbox', 'test'], true) ? self::PAYPAL_SANDBOX_API_BASE : self::PAYPAL_API_BASE; + + return [ + 'client_id' => $merchantId ?: $merchantKey, + 'client_secret' => $merchantPem, + 'api_base' => $apiBase, + ]; + } + + /** + * PayPal HTTP Client + */ + private function paypalClient(string $apiBase): Client + { + return new Client([ + 'base_uri' => $apiBase, + 'timeout' => 30, + 'http_errors' => true, + ]); } private function get_JsonData() @@ -140,9 +303,8 @@ class PaypalPayController extends PayController $json = file_get_contents('php://input'); if ($json) { $json = str_replace("'", '', $json); - $json = json_decode($json,true); + $json = json_decode($json, true); } return $json; } - } diff --git a/it b/it new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/hyper/js/hyper.js b/public/assets/hyper/js/hyper.js index 3b80b2a..c90b49f 100644 --- a/public/assets/hyper/js/hyper.js +++ b/public/assets/hyper/js/hyper.js @@ -63,7 +63,7 @@ $(function() { localStorage.setItem("announcement",setTime); } // 版权 - console.group("Faka");console.log("Name: 云泽数卡");console.log("Github: https://github.com/assimon/dujiaoka");console.groupEnd(); + console.group("Faka");console.log("Name: 独角数卡");console.log("Github: https://github.com/assimon/dujiaoka");console.groupEnd(); console.group("Theme");console.log("Name: Hyper Theme");console.log("Author: Bimoes");console.groupEnd(); }); // 图片懒加载 diff --git a/public/vendor/dujiaoka-admin/images/logo.jpg b/public/vendor/dujiaoka-admin/images/logo.jpg new file mode 100644 index 0000000..ada4fb6 Binary files /dev/null and b/public/vendor/dujiaoka-admin/images/logo.jpg differ diff --git a/resources/views/luna/README.md b/resources/views/luna/README.md index 5b48ee5..10652fc 100644 --- a/resources/views/luna/README.md +++ b/resources/views/luna/README.md @@ -1,6 +1,6 @@ -## 云泽数卡 - Luna模板 +## 独角数卡 - Luna模板 -一套简洁的云泽数卡模板 +一套简洁的独角数卡模板 ## 特殊用法 diff --git a/resources/views/luna/layouts/_footer.blade.php b/resources/views/luna/layouts/_footer.blade.php index 62d2cf4..0dc4991 100644 --- a/resources/views/luna/layouts/_footer.blade.php +++ b/resources/views/luna/layouts/_footer.blade.php @@ -6,3 +6,4 @@ + \ No newline at end of file