refactor(payment): migrate SDK to yansongda pay

This commit is contained in:
2026-07-26 00:09:38 +08:00
parent 8fbde0785e
commit a8d99b200b
10 changed files with 1555 additions and 405 deletions
+16 -7
View File
@@ -247,17 +247,26 @@ class SchemaPayment {
// Field-level dependsOn kept for contract audit / non-SPA consumers.
['key' => 'wechat', 'title' => '微信支付配置', 'collapsible' => true, 'expandWhen' => 'payment_wechat_enabled', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true], 'fields' => [
['key' => 'wechat_merchantid', 'label' => '商户号', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_jsapi', 'label' => 'JSAPI密钥', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_cert_path', 'label' => '证书路径', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_key_path', 'label' => '密钥路径', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_appid', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'native', 'options' => [
['value' => 'native', 'label' => '扫码支付(Native)'],
['value' => 'h5', 'label' => 'H5支付'],
], 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_api_v3_key', 'label' => 'APIv3密钥', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_private_key_path', 'label' => '商户私钥路径', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
['key' => 'wechat_cert_path', 'label' => '商户API证书路径', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_wechat_enabled', 'value' => true]],
]],
['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'expandWhen' => 'payment_alipay_enabled', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true], 'fields' => [
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_return_url', 'label' => '同步回调地址', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_notify_url', 'label' => '异步回调地址', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [
['value' => 'page', 'label' => '电脑网站支付(web)'],
['value' => 'wap', 'label' => '手机网站支付(wap)'],
], 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_app_public_cert_path', 'label' => '应用公钥证书路径', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_public_cert_path', 'label' => '支付宝公钥证书路径', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_root_cert_path', 'label' => '支付宝根证书路径', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_notify_help', 'label' => '回调地址说明', 'type' => 'notice', 'alertType' => 'info', 'alertContent' => '异步通知 / 同步返回已固定为 /wp-json/aksrt/v1/payment/alipay/{notify|return},无需在此填写。', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
]],
['key' => 'paypal', 'title' => 'PayPal配置', 'collapsible' => true, 'expandWhen' => 'payment_paypal_enabled', 'dependsOn' => ['key' => 'payment_paypal_enabled', 'value' => true], 'fields' => [
['key' => 'paypal_client_id', 'label' => 'Client ID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_paypal_enabled', 'value' => true]],
+80 -1
View File
@@ -328,6 +328,10 @@ class AfterSale {
$update['resolved_at'] = current_time('mysql');
}
if ($new_status === self::STATUS_COMPLETED && !$this->complete_refund($record)) {
return false;
}
$updated = (bool) $wpdb->update($this->table(), $update, ['id' => $id]);
if ($updated) {
$note = $operator_note !== '' ? $operator_note : sprintf(__('售后状态更新为:%s', 'aksrt'), $this->get_status_label($new_status));
@@ -565,6 +569,74 @@ class AfterSale {
do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method);
}
/**
* Execute an online-channel refund before completing the after-sale ticket.
*/
private function complete_refund(array $record): bool {
$type = (string) ($record['type'] ?? '');
if (!in_array($type, [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_INSURED_PRICE], true)) {
return true;
}
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
return false;
}
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
$order = OrderRepository::get_instance()->get($order_number);
if (!$order) {
return false;
}
$payment_method = (string) ($order['payment_method'] ?? 'balance');
if (!in_array($payment_method, ['wechatpay', 'alipay'], true)) {
return true;
}
$refund_amount = (float) ($record['amount'] ?? 0);
if ($refund_amount <= 0) {
return false;
}
$request_id = sprintf(
'AS_%d_%s',
(int) ($record['id'] ?? 0),
substr(hash('sha256', $order_number), 0, 16)
);
return $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id);
}
private function refund_via_gateway(string $payment_method, string $order_number, float $refund_amount, string $request_id): bool {
$gateway = Gateway::get_instance()->get_gateway($payment_method);
if (!$gateway || !method_exists($gateway, 'refund')) {
$this->log_refund_error($order_number, $payment_method, 'gateway unavailable');
return false;
}
try {
$result = $gateway->refund($order_number, $refund_amount, $request_id);
} catch (\Throwable $e) {
$this->log_refund_error($order_number, $payment_method, $e->getMessage());
return false;
}
if (empty($result['success'])) {
$this->log_refund_error($order_number, $payment_method, (string) ($result['message'] ?? 'unknown error'));
return false;
}
return true;
}
private function log_refund_error(string $order_number, string $payment_method, string $message): void {
error_log(sprintf(
'[AKSRT AfterSale] gateway refund failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
$message
));
}
private function sync_order_status(array $record, string $new_status, string $note = ''): void {
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('AKSRT\\Payment\\Order')) {
return;
@@ -807,13 +879,20 @@ class AfterSale {
return true;
}
$wpdb->update($this->table(), [
if (!$this->complete_refund($record)) {
return false;
}
$updated = (bool) $wpdb->update($this->table(), [
'status' => self::STATUS_COMPLETED,
'operator_id' => $operator_id,
'operator_note' => sanitize_textarea_field($note),
'updated_at' => current_time('mysql'),
'resolved_at' => current_time('mysql'),
], ['id' => $ticket_id]);
if (!$updated) {
return false;
}
update_post_meta($ticket_id, '_aksrt_as_progress', self::PROGRESS_DONE);
$this->sync_order_status($record, self::STATUS_COMPLETED, $note ?: __('售后已完成', 'aksrt'));
+24 -8
View File
@@ -114,27 +114,29 @@ class Gateway {
return new \WP_REST_Response('gateway not found', 404);
}
$verify = $gateway->verify_callback($request);
$verified = !empty($verify['success']);
$paid = $verified && ($verify['status'] ?? '') === 'paid';
$verify = $gateway->verify_callback($request);
$verified = !empty($verify['success']);
$paid = $verified && ($verify['status'] ?? '') === 'paid';
$processed = $verified;
if ($paid) {
$this->complete_order($gateway_id, $verify);
// Only ACK paid notifications after the order was actually synchronized.
$processed = $this->complete_order($gateway_id, $verify);
}
// 网关各自要求的 ACK 格式
if ($gateway_id === 'wechatpay' && method_exists($gateway, 'output_callback_response')) {
$gateway->output_callback_response(['success' => $verified]); // exit
$gateway->output_callback_response(['success' => $processed]); // exit
}
if ($gateway_id === 'alipay') {
// 支付宝异步通知要求返回纯文本 success,否则会重复通知
header('Content-Type: text/plain; charset=utf-8');
echo $verified ? 'success' : 'fail';
echo $processed ? 'success' : 'fail';
exit;
}
// Stripe / PayPal webhook:200 表示已接收;验签失败返回 400 触发重试
return new \WP_REST_Response(['received' => $verified], $verified ? 200 : 400);
// Stripe / PayPal webhook:200 表示已接收;处理失败返回 400 触发重试
return new \WP_REST_Response(['received' => $processed], $processed ? 200 : 400);
}
/**
@@ -156,6 +158,20 @@ class Gateway {
$order_number = $order_number ?: sanitize_text_field((string) ($verify['order_number'] ?? ''));
if (($verify['status'] ?? '') === 'paid' && $this->complete_order($gateway_id, $verify)) {
$status = 'success';
} elseif ($order_number !== '') {
// Alipay synchronous returns generally omit trade_status; query after signature verification.
$query = $gateway->query_status($order_number);
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
$done = $this->complete_order($gateway_id, [
'order_number' => $order_number,
'amount' => 0,
'transaction_id' => $query['transaction_id'] ?? '',
'status' => 'paid',
]);
if ($done) {
$status = 'success';
}
}
}
}
} elseif ($gateway_id === 'paypal' && method_exists($gateway, 'capture_order')) {
+176 -156
View File
@@ -1,5 +1,7 @@
<?php
/**
* Alipay gateway via yansongda/pay (cert mode).
*
* @package AKSRT
*/
@@ -7,188 +9,206 @@ namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
use Yansongda\Pay\Pay;
class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
public function get_id(): string {
return 'alipay';
}
public function get_id(): string {
return 'alipay';
}
public function get_name(): string {
return __('支付宝', 'aksrt');
}
public function get_name(): string {
return __('支付宝', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('alipay')) {
return false;
}
$config = $this->get_config();
return !empty($config['app_id']) && !empty($config['private_key']) && !empty($config['alipay_public_key']);
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('alipay')) {
return false;
}
$c = $this->get_config();
return $c['app_id'] !== ''
&& $c['private_key'] !== ''
&& $c['app_public_cert'] !== ''
&& $c['alipay_public_cert'] !== ''
&& $c['alipay_root_cert'] !== '';
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$payment_type = $config['payment_type'] ?? 'page'; // page / wap
$order_number = (string) ($order['order_number'] ?? '');
$amount = (float) ($order['amount'] ?? 0);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
$payment_type = $this->get_config()['payment_type'];
try {
$sdk = $this->create_sdk($config);
if ($order_number === '' || $amount <= 0) {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
}
if ($payment_type === 'wap') {
// 手机网站支付
$request = new \Yurun\PaySDK\AlipayApp\Wap\Params\Pay\Request;
$request->notify_url = home_url('/wp-json/aksrt/v1/payment/alipay/notify');
$request->return_url = home_url('/wp-json/aksrt/v1/payment/alipay/return');
$request->businessParams->out_trade_no = $order_number;
$request->businessParams->total_amount = number_format($amount, 2, '.', '');
$request->businessParams->subject = $subject;
} else {
// 电脑网站支付
$request = new \Yurun\PaySDK\AlipayApp\Page\Params\Pay\Request;
$request->notify_url = home_url('/wp-json/aksrt/v1/payment/alipay/notify');
$request->return_url = home_url('/wp-json/aksrt/v1/payment/alipay/return');
$request->businessParams->out_trade_no = $order_number;
$request->businessParams->total_amount = number_format($amount, 2, '.', '');
$request->businessParams->subject = $subject;
}
try {
YansongdaPayFactory::boot_alipay();
// 构建支付跳转 URL(prepareExecute 已将查询参数追加到 $url)
$sdk->prepareExecute($request, $url, $data);
$payment_url = $url;
$payload = [
'out_trade_no' => $order_number,
'total_amount' => number_format($amount, 2, '.', ''),
'subject' => mb_substr($subject, 0, 256),
];
return [
'success' => true,
'payment_url' => $payment_url,
'payment_data' => ['order_number' => $order_number],
'message' => __('支付宝支付链接已生成', 'aksrt'),
];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
$result = $payment_type === 'wap'
? Pay::alipay()->wap($payload)
: Pay::alipay()->web($payload);
public function verify_callback(\WP_REST_Request $request): array {
$config = $this->get_config();
$payment_url = YansongdaPayFactory::extract_payment_url($result);
$html = YansongdaPayFactory::extract_html_body($result);
try {
$sdk = $this->create_sdk($config);
$params = $request->get_params();
// Prefer Location / action URL; if only auto-submit HTML, expose as payment_data for frontend.
if ($payment_url === '' && $html !== '' && preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $html, $m)) {
$payment_url = html_entity_decode($m[1], ENT_QUOTES);
}
if ($sdk->verifyCallback($params)) {
$trade_status = $params['trade_status'] ?? '';
return [
'success' => true,
'order_number' => $params['out_trade_no'] ?? '',
'amount' => (float) ($params['total_amount'] ?? 0),
'transaction_id' => $params['trade_no'] ?? '',
'status' => in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED']) ? 'paid' : 'pending',
];
}
if ($payment_url === '' && $html === '') {
return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')];
}
return ['success' => false, 'message' => __('支付宝回调验证失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
return [
'success' => true,
'payment_url' => $payment_url,
'payment_data' => array_filter([
'order_number' => $order_number,
'html' => $html !== '' ? $html : null,
]),
'message' => __('支付宝支付链接已生成', 'aksrt'),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
public function verify_callback(\WP_REST_Request $request): array {
try {
YansongdaPayFactory::boot_alipay();
$config = $this->get_config();
// Use the unsanitized form/query payload; route params must not enter the signature base.
$callback_input = strtoupper($request->get_method()) === 'POST'
? $request->get_body_params()
: $request->get_query_params();
if (!is_array($callback_input)) {
$callback_input = [];
}
unset($callback_input['gateway']);
try {
$sdk = $this->create_sdk($config);
$data_obj = Pay::alipay()->callback($callback_input);
$data = YansongdaPayFactory::to_array($data_obj);
$request = new \Yurun\PaySDK\AlipayApp\Params\Query\Request;
$request->businessParams->out_trade_no = $order_number;
$trade_status = (string) ($data['trade_status'] ?? '');
$paid = in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true);
$result = $sdk->execute($request);
return [
'success' => true,
'order_number' => (string) ($data['out_trade_no'] ?? ''),
'amount' => (float) ($data['total_amount'] ?? 0),
'transaction_id' => (string) ($data['trade_no'] ?? ''),
'status' => $paid ? 'paid' : 'pending',
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
if ($sdk->checkResult()) {
$trade_status = $result['trade_status'] ?? '';
$status = 'pending';
if (in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'])) {
$status = 'paid';
} elseif ($trade_status === 'TRADE_CLOSED') {
$status = 'cancelled';
}
/**
* Alipay async notify expects plain text success/fail (Gateway also handles this).
*/
public function output_callback_response(array $result): void {
try {
if (!empty($result['success'])) {
YansongdaPayFactory::boot_alipay();
YansongdaPayFactory::emit_response(Pay::alipay()->success(), 'success');
}
} catch (\Throwable $e) {
// fall through
}
header('Content-Type: text/plain; charset=utf-8');
echo 'fail';
exit;
}
return [
'success' => true,
'status' => $status,
'transaction_id' => $result['trade_no'] ?? '',
];
}
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('查询失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
try {
YansongdaPayFactory::boot_alipay();
$result = Pay::alipay()->query([
'out_trade_no' => $order_number,
]);
$data = YansongdaPayFactory::to_array($result);
public function refund(string $order_number, float $amount): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$trade_status = (string) ($data['trade_status'] ?? '');
$status = 'pending';
if (in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true)) {
$status = 'paid';
} elseif ($trade_status === 'TRADE_CLOSED') {
$status = 'cancelled';
}
$config = $this->get_config();
return [
'success' => true,
'status' => $status,
'transaction_id' => (string) ($data['trade_no'] ?? ''),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
try {
$sdk = $this->create_sdk($config);
public function refund(string $order_number, float $amount, string $request_id = ''): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
}
$request = new \Yurun\PaySDK\AlipayApp\Params\Refund\Request;
$request->businessParams->out_trade_no = $order_number;
$request->businessParams->refund_amount = number_format($amount, 2, '.', '');
$request->businessParams->refund_reason = __('用户申请退款', 'aksrt');
$request->businessParams->out_request_no = $order_number . '_refund_' . time();
try {
YansongdaPayFactory::boot_alipay();
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : $order_number . '_refund');
$result = Pay::alipay()->refund([
'out_trade_no' => $order_number,
'refund_amount' => number_format($amount, 2, '.', ''),
'refund_reason' => __('用户申请退款', 'aksrt'),
'out_request_no' => $refund_request_id,
]);
$data = YansongdaPayFactory::to_array($result);
$result = $sdk->execute($request);
return [
'success' => true,
'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''),
'message' => __('退款成功', 'aksrt'),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
if ($sdk->checkResult()) {
return [
'success' => true,
'refund_id' => $result['trade_no'] ?? '',
'message' => __('退款成功', 'aksrt'),
];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('退款失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
private function get_config(): array {
if (!class_exists('\AKSRT\Settings\Settings')) {
return [];
}
$all = \AKSRT\Settings\Settings::get_all();
return [
'app_id' => (string) ($all['alipay_appid'] ?? ''),
'private_key' => (string) ($all['alipay_privatekey'] ?? ''),
'alipay_public_key' => (string) ($all['alipay_publickey'] ?? ''),
'return_url' => (string) ($all['alipay_return_url'] ?? ''),
'notify_url' => (string) ($all['alipay_notify_url'] ?? ''),
'payment_type' => (string) ($all['alipay_payment_type'] ?? 'page'),
'sign_type' => (string) ($all['alipay_sign_type'] ?? 'RSA2'),
];
}
private function create_sdk(array $config): \Yurun\PaySDK\AlipayApp\SDK {
$params = new \Yurun\PaySDK\AlipayApp\Params\PublicParams;
$params->appID = $config['app_id'] ?? '';
$params->appPrivateKey = $config['private_key'] ?? '';
$params->appPublicKey = $config['alipay_public_key'] ?? '';
$params->sign_type = $config['sign_type'] ?? 'RSA2';
return new \Yurun\PaySDK\AlipayApp\SDK($params);
}
/**
* @return array{app_id:string,private_key:string,app_public_cert:string,alipay_public_cert:string,alipay_root_cert:string,payment_type:string}
*/
private function get_config(): array {
$all = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_all()
: [];
$type = sanitize_key((string) ($all['alipay_payment_type'] ?? 'page'));
if (!in_array($type, ['page', 'wap'], true)) {
$type = 'page';
}
return [
'app_id' => (string) ($all['alipay_appid'] ?? ''),
'private_key' => (string) ($all['alipay_privatekey'] ?? ''),
'app_public_cert' => (string) ($all['alipay_app_public_cert_path'] ?? ''),
'alipay_public_cert' => (string) ($all['alipay_public_cert_path'] ?? ''),
'alipay_root_cert' => (string) ($all['alipay_root_cert_path'] ?? ''),
'payment_type' => $type,
];
}
}
+246 -197
View File
@@ -1,5 +1,7 @@
<?php
/**
* WeChat Pay gateway via yansongda/pay (API v3).
*
* @package AKSRT
*/
@@ -7,236 +9,283 @@ namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
use Yansongda\Pay\Pay;
class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
public function get_id(): string {
return 'wechatpay';
}
public function get_id(): string {
return 'wechatpay';
}
public function get_name(): string {
return __('微信支付', 'aksrt');
}
public function get_name(): string {
return __('微信支付', 'aksrt');
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('wechatpay')) {
return false;
}
$config = $this->get_config();
return !empty($config['mch_id']) && !empty($config['key']);
}
public function is_configured(): bool {
if (class_exists('\AKSRT\Settings\PaymentRuntime')
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('wechatpay')) {
return false;
}
$c = $this->get_config();
return $c['mch_id'] !== ''
&& $c['appid'] !== ''
&& $c['api_v3_key'] !== ''
&& $c['private_key'] !== ''
&& $c['cert_path'] !== '';
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
public function create_payment(array $order): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$config = $this->get_config();
$order_number = $order['order_number'] ?? '';
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$payment_type = $config['payment_type'] ?? 'native'; // native / jsapi / h5
$order_number = (string) ($order['order_number'] ?? '');
$amount = (float) ($order['amount'] ?? 0);
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
$payment_type = $this->get_config()['payment_type'];
$total_fen = (int) round($amount * 100);
try {
$sdk = $this->create_sdk($config);
$total_fee = (int) ($amount * 100); // 转为分
if ($order_number === '' || $total_fen <= 0) {
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
}
switch ($payment_type) {
case 'jsapi':
$request = new \Yurun\PaySDK\Weixin\JSAPI\Params\Pay\Request;
$request->openid = $order['openid'] ?? '';
break;
case 'h5':
$request = new \Yurun\PaySDK\Weixin\H5\Params\Pay\Request;
break;
case 'native':
default:
$request = new \Yurun\PaySDK\Weixin\Native\Params\Pay\Request;
break;
}
try {
YansongdaPayFactory::boot_wechat();
$request->body = $subject;
$request->out_trade_no = $order_number;
$request->total_fee = $total_fee;
$request->fee_type = 'CNY';
$request->notify_url = home_url('/wp-json/aksrt/v1/payment/wechatpay/notify');
$request->spbill_create_ip = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$payload = [
'out_trade_no' => $order_number,
'description' => mb_substr($subject, 0, 127),
'amount' => [
'total' => $total_fen,
'currency' => 'CNY',
],
];
$result = $sdk->execute($request);
switch ($payment_type) {
case 'h5':
$payload['scene_info'] = [
'payer_client_ip' => (string) ($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'),
'h5_info' => ['type' => 'Wap'],
];
$result = Pay::wechat()->h5($payload);
break;
case 'native':
default:
$result = Pay::wechat()->scan($payload);
break;
}
if ($sdk->checkResult()) {
$result_data = [
'success' => true,
'payment_data' => array_merge($result, ['order_number' => $order_number]),
'message' => __('微信支付订单已创建', 'aksrt'),
];
$data = YansongdaPayFactory::to_array($result);
$payment_data = array_merge($data, ['order_number' => $order_number]);
if ($payment_type === 'native' && !empty($result['code_url'])) {
$result_data['payment_url'] = $result['code_url'];
} elseif ($payment_type === 'h5' && !empty($result['h5_url'])) {
$result_data['payment_url'] = $result['h5_url'];
}
$result_data = [
'success' => true,
'payment_data' => $payment_data,
'message' => __('微信支付订单已创建', 'aksrt'),
];
return $result_data;
}
if ($payment_type === 'native' && !empty($data['code_url'])) {
$result_data['payment_url'] = (string) $data['code_url'];
} elseif ($payment_type === 'h5' && !empty($data['h5_url'])) {
$result_data['payment_url'] = (string) $data['h5_url'];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('微信支付创建失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
return $result_data;
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function verify_callback(\WP_REST_Request $request): array {
$config = $this->get_config();
public function verify_callback(\WP_REST_Request $request): array {
try {
YansongdaPayFactory::boot_wechat();
try {
$sdk = $this->create_sdk($config);
// Pass raw body + headers so callback works under WP REST (php://input may be drained).
$raw = $request->get_body();
$headers = [
'Content-Type' => (string) ($request->get_header('content-type') ?: 'application/json'),
'Wechatpay-Serial' => (string) $request->get_header('wechatpay-serial'),
'Wechatpay-Timestamp' => (string) $request->get_header('wechatpay-timestamp'),
'Wechatpay-Nonce' => (string) $request->get_header('wechatpay-nonce'),
'Wechatpay-Signature' => (string) $request->get_header('wechatpay-signature'),
];
$callback = Pay::wechat()->callback([
'body' => $raw !== '' ? $raw : file_get_contents('php://input'),
'headers' => $headers,
]);
// 读取原始请求体
$xml = file_get_contents('php://input');
$data = \Yurun\PaySDK\Lib\XML::fromString($xml);
$data = YansongdaPayFactory::to_array($callback);
// Decrypted resource is nested under resource (array) after CallbackPlugin.
$resource = [];
if (isset($data['resource']) && is_array($data['resource'])) {
$resource = $data['resource'];
// ciphertext decrypt may leave plaintext fields at resource root or under ciphertext key.
if (isset($resource['ciphertext']) && is_array($resource['ciphertext'])) {
$resource = $resource['ciphertext'];
}
}
if ($sdk->verifyCallback($data)) {
return [
'success' => true,
'order_number' => $data['out_trade_no'] ?? '',
'amount' => (($data['total_fee'] ?? 0) / 100),
'transaction_id' => $data['transaction_id'] ?? '',
'status' => ($data['result_code'] ?? '') === 'SUCCESS' ? 'paid' : 'pending',
];
}
$order_number = (string) ($resource['out_trade_no'] ?? $data['out_trade_no'] ?? '');
$transaction_id = (string) ($resource['transaction_id'] ?? $data['transaction_id'] ?? '');
$trade_state = (string) ($resource['trade_state'] ?? $data['trade_state'] ?? '');
$event_type = (string) ($data['event_type'] ?? '');
return ['success' => false, 'message' => __('微信支付回调签名验证失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
$total_fen = (int) YansongdaPayFactory::dig($resource, 'amount.total', 0);
if ($total_fen <= 0) {
$total_fen = (int) YansongdaPayFactory::dig($data, 'amount.total', 0);
}
/**
* 输出微信支付回调响应
*/
public function output_callback_response(array $result): void {
if ($result['success'] ?? false) {
echo '<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>';
} else {
echo '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[' . ($result['message'] ?? 'FAIL') . ']]></return_msg></xml>';
}
exit;
}
$paid = ($trade_state === 'SUCCESS') || ($event_type === 'TRANSACTION.SUCCESS');
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
// When event is pay success, resource has trade_state SUCCESS.
if ($event_type !== '' && $event_type !== 'TRANSACTION.SUCCESS' && $trade_state !== 'SUCCESS') {
$paid = false;
}
$config = $this->get_config();
return [
'success' => true,
'order_number' => $order_number,
'amount' => $total_fen > 0 ? ($total_fen / 100) : 0.0,
'transaction_id' => $transaction_id,
'status' => $paid ? 'paid' : 'pending',
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
try {
$sdk = $this->create_sdk($config);
/**
* Output WeChat v3 notify ACK (JSON).
*/
public function output_callback_response(array $result): void {
try {
YansongdaPayFactory::boot_wechat();
if (!empty($result['success'])) {
YansongdaPayFactory::emit_response(Pay::wechat()->success(), '{"code":"SUCCESS","message":"成功"}');
}
} catch (\Throwable $e) {
// fall through to fail body
}
$request = new \Yurun\PaySDK\Weixin\OrderQuery\Request;
$request->out_trade_no = $order_number;
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
}
echo wp_json_encode([
'code' => 'FAIL',
'message' => (string) ($result['message'] ?? 'FAIL'),
]);
exit;
}
$result = $sdk->execute($request);
public function query_status(string $order_number): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
if ($sdk->checkResult()) {
$trade_state = $result['trade_state'] ?? '';
$status = 'pending';
if ($trade_state === 'SUCCESS') {
$status = 'paid';
} elseif (in_array($trade_state, ['CLOSED', 'REVOKED'])) {
$status = 'cancelled';
} elseif ($trade_state === 'REFUND') {
$status = 'refunded';
}
try {
YansongdaPayFactory::boot_wechat();
$action = $this->query_action();
$result = Pay::wechat()->query([
'out_trade_no' => $order_number,
'_action' => $action,
]);
$data = YansongdaPayFactory::to_array($result);
return [
'success' => true,
'status' => $status,
'transaction_id' => $result['transaction_id'] ?? '',
];
}
$trade_state = (string) ($data['trade_state'] ?? '');
$status = 'pending';
if ($trade_state === 'SUCCESS') {
$status = 'paid';
} elseif (in_array($trade_state, ['CLOSED', 'REVOKED'], true)) {
$status = 'cancelled';
} elseif ($trade_state === 'REFUND') {
$status = 'refunded';
}
return ['success' => false, 'message' => $sdk->getError() ?: __('查询失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
return [
'success' => true,
'status' => $status,
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function refund(string $order_number, float $amount): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
public function refund(string $order_number, float $amount, string $request_id = ''): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
}
$config = $this->get_config();
try {
YansongdaPayFactory::boot_wechat();
$action = $this->query_action();
try {
$sdk = $this->create_sdk($config);
$query = Pay::wechat()->query([
'out_trade_no' => $order_number,
'_action' => $action,
]);
$q = YansongdaPayFactory::to_array($query);
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
$refund_fen = (int) round($amount * 100);
if ($total_fen <= 0) {
$total_fen = $refund_fen;
}
if ($refund_fen <= 0) {
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
}
// 先查询订单获取 transaction_id
$query = new \Yurun\PaySDK\Weixin\OrderQuery\Request;
$query->out_trade_no = $order_number;
$query_result = $sdk->execute($query);
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : 'REF_' . $order_number);
$result = Pay::wechat()->refund([
'_action' => 'refund_' . $action,
'out_trade_no' => $order_number,
'out_refund_no' => $refund_request_id,
'reason' => __('用户申请退款', 'aksrt'),
'amount' => [
'refund' => $refund_fen,
'total' => $total_fen,
'currency' => 'CNY',
],
]);
$data = YansongdaPayFactory::to_array($result);
if (!$sdk->checkResult() || empty($query_result['transaction_id'])) {
return ['success' => false, 'message' => __('未找到支付记录', 'aksrt')];
}
return [
'success' => true,
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
'message' => __('退款成功', 'aksrt'),
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
$refund_fee = (int) ($amount * 100);
/**
* @return array{mch_id:string,appid:string,api_v3_key:string,private_key:string,cert_path:string,payment_type:string}
*/
private function get_config(): array {
$all = class_exists('\AKSRT\Settings\Settings')
? \AKSRT\Settings\Settings::get_all()
: [];
$type = sanitize_key((string) ($all['wechat_payment_type'] ?? 'native'));
if (!in_array($type, ['native', 'h5'], true)) {
$type = 'native';
}
return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
'appid' => (string) ($all['wechat_appid'] ?? ''),
'api_v3_key' => (string) ($all['wechat_api_v3_key'] ?? ''),
'private_key' => (string) ($all['wechat_private_key_path'] ?? ''),
'cert_path' => (string) ($all['wechat_cert_path'] ?? ''),
'payment_type' => $type,
];
}
$request = new \Yurun\PaySDK\Weixin\Refund\Request;
$request->out_trade_no = $order_number;
$request->out_refund_no = 'REF_' . $order_number . '_' . time();
$request->total_fee = (int) ($query_result['total_fee'] ?? $refund_fee);
$request->refund_fee = $refund_fee;
$request->refund_desc = __('用户申请退款', 'aksrt');
$result = $sdk->execute($request);
if ($sdk->checkResult()) {
return [
'success' => true,
'refund_id' => $result['refund_id'] ?? '',
'message' => __('退款成功', 'aksrt'),
];
}
return ['success' => false, 'message' => $sdk->getError() ?: __('退款失败', 'aksrt')];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
}
private function get_config(): array {
if (!class_exists('\AKSRT\Settings\Settings')) {
return [];
}
$all = \AKSRT\Settings\Settings::get_all();
return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
'key' => (string) ($all['wechat_jsapi'] ?? ''),
'appid' => (string) ($all['wechat_appid'] ?? ''),
'appsecret' => (string) ($all['wechat_appsecret'] ?? ''),
'cert_path' => (string) ($all['wechat_cert_path'] ?? ''),
'key_path' => (string) ($all['wechat_key_path'] ?? ''),
'payment_type' => (string) ($all['wechat_payment_type'] ?? 'native'),
];
}
private function create_sdk(array $config): \Yurun\PaySDK\Weixin\SDK {
$params = new \Yurun\PaySDK\Weixin\Params\PublicParams;
$params->appID = $config['appid'] ?? '';
$params->mch_id = $config['mch_id'] ?? '';
$params->key = $config['key'] ?? '';
$params->sign_type = $config['sign_type'] ?? 'HMAC-SHA256';
if (!empty($config['cert_path'])) {
$params->certPath = $config['cert_path'];
}
if (!empty($config['key_path'])) {
$params->keyPath = $config['key_path'];
}
return new \Yurun\PaySDK\Weixin\SDK($params);
}
private function query_action(): string {
$map = [
'native' => 'native',
'h5' => 'h5',
];
$type = $this->get_config()['payment_type'];
return $map[$type] ?? 'native';
}
}
@@ -0,0 +1,275 @@
<?php
/**
* Shared yansongda/pay config bootstrap for WeChat / Alipay gateways.
*
* @package AKSRT
*/
namespace AKSRT\Payment\Gateways;
defined('ABSPATH') || exit;
use Yansongda\Pay\Pay;
/**
* Builds Pay::config() payloads from aksrt_settings and boots the SDK.
*/
class YansongdaPayFactory {
/**
* Boot Pay with a partial channel config (wechat and/or alipay).
*
* @param array<string,mixed> $channels Keys: wechat?, alipay?
*/
public static function boot(array $channels): void {
$config = [
'logger' => self::logger_config(),
'http' => [
'timeout' => 10.0,
'connect_timeout' => 5.0,
],
];
if (!empty($channels['wechat']) && is_array($channels['wechat'])) {
$config['wechat'] = [
'default' => $channels['wechat'],
];
}
if (!empty($channels['alipay']) && is_array($channels['alipay'])) {
$config['alipay'] = [
'default' => $channels['alipay'],
];
}
// Re-config is safe; Pay caches tenant config per process.
Pay::config($config);
}
/**
* WeChat Pay API v3 merchant config.
*
* @return array<string,mixed>
*/
public static function wechat_from_settings(): array {
$all = self::settings();
$notify = home_url('/wp-json/aksrt/v1/payment/wechatpay/notify');
return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
'mch_secret_key' => (string) ($all['wechat_api_v3_key'] ?? ''),
'mch_secret_cert' => (string) ($all['wechat_private_key_path'] ?? ''),
'mch_public_cert_path' => (string) ($all['wechat_cert_path'] ?? ''),
'mp_app_id' => (string) ($all['wechat_appid'] ?? ''),
'notify_url' => $notify,
'mode' => Pay::MODE_NORMAL,
];
}
/**
* Alipay cert-mode config (yansongda/pay 3.x requires cert paths for SN + verify).
*
* app_secret_cert may be PEM string or file path.
* Cert path fields accept absolute file paths (or PEM content via get_public_cert).
*
* @return array<string,mixed>
*/
public static function alipay_from_settings(): array {
$all = self::settings();
$notify = home_url('/wp-json/aksrt/v1/payment/alipay/notify');
$return = home_url('/wp-json/aksrt/v1/payment/alipay/return');
return [
'app_id' => (string) ($all['alipay_appid'] ?? ''),
'app_secret_cert' => (string) ($all['alipay_privatekey'] ?? ''),
'app_public_cert_path' => (string) ($all['alipay_app_public_cert_path'] ?? ''),
'alipay_public_cert_path' => (string) ($all['alipay_public_cert_path'] ?? ''),
'alipay_root_cert_path' => (string) ($all['alipay_root_cert_path'] ?? ''),
'return_url' => $return,
'notify_url' => $notify,
'mode' => Pay::MODE_NORMAL,
];
}
public static function boot_wechat(): void {
self::boot(['wechat' => self::wechat_from_settings()]);
}
public static function boot_alipay(): void {
self::boot(['alipay' => self::alipay_from_settings()]);
}
/**
* Convert Collection / ArrayAccess / array to plain array.
*
* @param mixed $data
* @return array<string,mixed>
*/
public static function to_array($data): array {
if (is_array($data)) {
return $data;
}
if ($data instanceof \Yansongda\Supports\Collection) {
return $data->all();
}
if ($data instanceof \ArrayAccess) {
$out = [];
foreach ($data as $k => $v) {
$out[$k] = is_array($v) || $v instanceof \ArrayAccess || $v instanceof \Yansongda\Supports\Collection
? self::to_array($v)
: $v;
}
return $out;
}
if (is_object($data) && method_exists($data, 'toArray')) {
$arr = $data->toArray();
return is_array($arr) ? $arr : [];
}
return [];
}
/**
* Nested value from array using dot path.
*
* @param array<string,mixed> $data
* @return mixed
*/
public static function dig(array $data, string $path, $default = null) {
$cur = $data;
foreach (explode('.', $path) as $seg) {
if (!is_array($cur) || !array_key_exists($seg, $cur)) {
return $default;
}
$cur = $cur[$seg];
}
return $cur;
}
/**
* Extract a redirect / payment URL from alipay web/wap response.
*
* @param mixed $result
*/
public static function extract_payment_url($result): string {
if (is_string($result) && $result !== '') {
return $result;
}
if ($result instanceof \Psr\Http\Message\ResponseInterface) {
$loc = $result->getHeaderLine('Location');
if ($loc !== '') {
return $loc;
}
$body = (string) $result->getBody();
if (preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $body, $m)) {
return html_entity_decode($m[1], ENT_QUOTES);
}
if (preg_match('#https?://[^\s"\'<>]+#', $body, $m)) {
return $m[0];
}
// Auto-submit HTML form: callers may open as document; store empty payment_url and put HTML in payment_data.
return '';
}
if (is_object($result)) {
if (method_exists($result, 'getTargetUrl')) {
$url = (string) $result->getTargetUrl();
if ($url !== '') {
return $url;
}
}
if (method_exists($result, '__toString')) {
$s = (string) $result;
if (strpos($s, 'http') === 0) {
return $s;
}
}
}
$arr = self::to_array($result);
foreach (['payment_url', 'h5_url', 'code_url', 'mweb_url'] as $k) {
if (!empty($arr[$k]) && is_string($arr[$k])) {
return $arr[$k];
}
}
return '';
}
/**
* HTML body from alipay Response (form auto-submit) when no Location header.
*
* @param mixed $result
*/
public static function extract_html_body($result): string {
if ($result instanceof \Psr\Http\Message\ResponseInterface) {
if ($result->getHeaderLine('Location') !== '') {
return '';
}
return (string) $result->getBody();
}
return '';
}
/**
* Emit SDK success()/fail() ResponseInterface (or string) and exit.
*
* @param mixed $response
*/
public static function emit_response($response, string $fallback_body = 'success'): void {
if ($response instanceof \Psr\Http\Message\ResponseInterface) {
$status = $response->getStatusCode();
if ($status > 0 && !headers_sent()) {
http_response_code($status);
}
foreach ($response->getHeaders() as $name => $values) {
if (strtolower((string) $name) === 'transfer-encoding') {
continue;
}
foreach ($values as $value) {
header($name . ': ' . $value, false);
}
}
$body = (string) $response->getBody();
echo $body !== '' ? $body : $fallback_body;
exit;
}
if (is_string($response) && $response !== '') {
echo $response;
exit;
}
echo $fallback_body;
exit;
}
/**
* @return array<string,mixed>
*/
private static function settings(): array {
if (class_exists('\AKSRT\Settings\Settings')) {
return \AKSRT\Settings\Settings::get_all();
}
return [];
}
/**
* @return array<string,mixed>
*/
private static function logger_config(): array {
$enabled = defined('WP_DEBUG') && WP_DEBUG;
$file = '';
if ($enabled && function_exists('wp_upload_dir')) {
$uploads = wp_upload_dir();
if (empty($uploads['error']) && !empty($uploads['basedir'])) {
$dir = trailingslashit((string) $uploads['basedir']) . 'aksrt-pay';
if (!is_dir($dir)) {
wp_mkdir_p($dir);
}
$file = trailingslashit($dir) . 'pay.log';
}
}
return [
'enable' => $enabled && $file !== '',
'file' => $file,
'level' => 'info',
'type' => 'single',
'max_file' => 30,
];
}
}
-4
View File
@@ -336,10 +336,6 @@ class PaymentRuntime {
'pid' => sanitize_text_field((string) ($settings['epay_pid'] ?? '')),
'key' => sanitize_text_field((string) ($settings['epay_key'] ?? '')),
],
'alipay_extra' => [
'return_url' => esc_url_raw((string) ($settings['alipay_return_url'] ?? '')),
'notify_url' => esc_url_raw((string) ($settings['alipay_notify_url'] ?? '')),
],
];
}