88c6ce8ccc
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
155 lines
5.5 KiB
PHP
155 lines
5.5 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Soon\Api\Services;
|
|
|
|
use Soon\Api\Core\Config;
|
|
|
|
/**
|
|
* 支付宝 PC 网站支付(页面跳转)。
|
|
*/
|
|
final class AlipayClient
|
|
{
|
|
/** @return array{gateway: string, params: array<string, string>} */
|
|
public static function pagePayForm(string $orderNo, int $amountCents, string $subject): array
|
|
{
|
|
$cfg = Config::get('alipay', []);
|
|
$params = [
|
|
'app_id' => (string)($cfg['app_id'] ?? ''),
|
|
'method' => 'alipay.trade.page.pay',
|
|
'charset' => 'utf-8',
|
|
'sign_type' => 'RSA2',
|
|
'timestamp' => date('Y-m-d H:i:s'),
|
|
'version' => '1.0',
|
|
'notify_url' => (string)Config::get('site.base_url', '') . '/api/v1/pay/alipay/notify',
|
|
'return_url' => (string)Config::get('site.front_base_url', Config::get('site.base_url', ''))
|
|
. '/pages/member.web.html?paid=' . rawurlencode($orderNo),
|
|
'biz_content' => json_encode([
|
|
'out_trade_no' => $orderNo,
|
|
'product_code' => 'FAST_INSTANT_TRADE_PAY',
|
|
'total_amount' => number_format($amountCents / 100, 2, '.', ''),
|
|
'subject' => $subject,
|
|
], JSON_UNESCAPED_UNICODE),
|
|
];
|
|
$params['sign'] = self::sign($params, (string)($cfg['private_key'] ?? ''));
|
|
$gateway = ($cfg['sandbox'] ?? false)
|
|
? 'https://openapi.alipaydev.com/gateway.do'
|
|
: 'https://openapi.alipay.com/gateway.do';
|
|
return ['gateway' => $gateway, 'params' => $params];
|
|
}
|
|
|
|
public static function pagePay(string $orderNo, int $amountCents, string $subject): string
|
|
{
|
|
$form = self::pagePayForm($orderNo, $amountCents, $subject);
|
|
return $form['gateway'] . '?' . http_build_query($form['params']);
|
|
}
|
|
|
|
public static function verifyNotify(array $params): bool
|
|
{
|
|
$cfg = Config::get('alipay', []);
|
|
$sign = (string)($params['sign'] ?? '');
|
|
if ($sign === '') {
|
|
return false;
|
|
}
|
|
unset($params['sign'], $params['sign_type']);
|
|
ksort($params);
|
|
$query = '';
|
|
foreach ($params as $k => $v) {
|
|
if ($v === '' || $v === null) {
|
|
continue;
|
|
}
|
|
$query .= $k . '=' . $v . '&';
|
|
}
|
|
$query = rtrim($query, '&');
|
|
return self::verifyNotifySign($query, $sign, (string)($cfg['public_key'] ?? ''));
|
|
}
|
|
|
|
public static function verifyNotifySign(string $content, string $signBase64, string $alipayPublicKey): bool
|
|
{
|
|
$pub = openssl_get_publickey(self::normalizePem($alipayPublicKey, 'PUBLIC KEY'));
|
|
if ($pub === false) {
|
|
return false;
|
|
}
|
|
$sig = base64_decode($signBase64, true);
|
|
if ($sig === false) {
|
|
openssl_free_key($pub);
|
|
return false;
|
|
}
|
|
$ok = openssl_verify($content, $sig, $pub, OPENSSL_ALGO_SHA256);
|
|
openssl_free_key($pub);
|
|
return $ok === 1;
|
|
}
|
|
|
|
private static function sign(array $params, string $privateKey): string
|
|
{
|
|
ksort($params);
|
|
$query = '';
|
|
foreach ($params as $k => $v) {
|
|
if ($v === '' || $v === null) {
|
|
continue;
|
|
}
|
|
$query .= $k . '=' . $v . '&';
|
|
}
|
|
$query = rtrim($query, '&');
|
|
$res = openssl_get_privatekey(self::normalizePem($privateKey, 'PRIVATE KEY'));
|
|
if ($res === false) {
|
|
return '';
|
|
}
|
|
$signature = '';
|
|
openssl_sign($query, $signature, $res, OPENSSL_ALGO_SHA256);
|
|
openssl_free_key($res);
|
|
return base64_encode($signature);
|
|
}
|
|
|
|
public static function refund(string $orderNo, int $amountCents): bool
|
|
{
|
|
$cfg = Config::get('alipay', []);
|
|
$appId = (string)($cfg['app_id'] ?? '');
|
|
$privateKey = (string)($cfg['private_key'] ?? '');
|
|
if ($appId === '' || $privateKey === '') {
|
|
return false;
|
|
}
|
|
$params = [
|
|
'app_id' => $appId,
|
|
'method' => 'alipay.trade.refund',
|
|
'charset' => 'utf-8',
|
|
'sign_type' => 'RSA2',
|
|
'timestamp' => date('Y-m-d H:i:s'),
|
|
'version' => '1.0',
|
|
'biz_content' => json_encode([
|
|
'out_trade_no' => $orderNo,
|
|
'refund_amount' => number_format($amountCents / 100, 2, '.', ''),
|
|
], JSON_UNESCAPED_UNICODE),
|
|
];
|
|
$params['sign'] = self::sign($params, $privateKey);
|
|
$gateway = ($cfg['sandbox'] ?? false)
|
|
? 'https://openapi.alipaydev.com/gateway.do'
|
|
: 'https://openapi.alipay.com/gateway.do';
|
|
$ch = curl_init($gateway);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => http_build_query($params),
|
|
CURLOPT_TIMEOUT => 20,
|
|
]);
|
|
$resp = curl_exec($ch);
|
|
curl_close($ch);
|
|
if (!is_string($resp) || $resp === '') {
|
|
return false;
|
|
}
|
|
$data = json_decode($resp, true);
|
|
$key = 'alipay_trade_refund_response';
|
|
return is_array($data) && ($data[$key]['code'] ?? '') === '10000';
|
|
}
|
|
|
|
private static function normalizePem(string $key, string $type): string
|
|
{
|
|
$key = trim($key);
|
|
if (str_contains($key, 'BEGIN')) {
|
|
return $key;
|
|
}
|
|
$body = chunk_split(preg_replace('/\s+/', '', $key), 64, "\n");
|
|
return "-----BEGIN {$type}-----\n{$body}-----END {$type}-----\n";
|
|
}
|
|
}
|