refactor: flatten src/AKSRT → src (drop theme-identifier dir)
Move all domain folders from src/AKSRT/{Domain} to src/{Domain}; remap
Composer PSR-4 "AKSRT\\": "src/"; update audit script + README + ADR
paths; remove dead tools/build-psr4-map.php (scanned deleted inc/classes).
This commit is contained in:
1 parent
56f1a8175f
commit
8c5b1555c9
285 files changed
+26
-3600
No files matched your search
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
/**
|
||||
* Alipay gateway via yansongda/pay (cert mode).
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
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_name(): string {
|
||||
return __('支付宝', 'aksrt');
|
||||
}
|
||||
|
||||
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')];
|
||||
}
|
||||
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
|
||||
if ($out_trade_no === '') {
|
||||
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
|
||||
: $order_number;
|
||||
}
|
||||
$amount = (float) ($order['amount'] ?? 0);
|
||||
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
|
||||
$payment_type = $this->get_config()['payment_type'];
|
||||
|
||||
if ($order_number === '' || $out_trade_no === '' || $amount <= 0) {
|
||||
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_alipay();
|
||||
|
||||
$payload = [
|
||||
'_method' => 'GET',
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'total_amount' => number_format($amount, 2, '.', ''),
|
||||
'subject' => mb_substr($subject, 0, 256),
|
||||
];
|
||||
|
||||
$result = $payment_type === 'wap'
|
||||
? Pay::alipay()->h5($payload)
|
||||
: Pay::alipay()->web($payload);
|
||||
|
||||
$payment_url = YansongdaPayFactory::extract_payment_url($result);
|
||||
if ($payment_url === '') {
|
||||
return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'payment_url' => $payment_url,
|
||||
'payment_data' => [
|
||||
'order_number' => $order_number,
|
||||
'out_trade_no' => $out_trade_no,
|
||||
],
|
||||
'message' => __('支付宝支付链接已生成', 'aksrt'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): array {
|
||||
try {
|
||||
YansongdaPayFactory::boot_alipay();
|
||||
|
||||
// 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']);
|
||||
|
||||
$data_obj = Pay::alipay()->callback($callback_input);
|
||||
$data = YansongdaPayFactory::to_array($data_obj);
|
||||
|
||||
$config = $this->get_config();
|
||||
$trade_status = (string) ($data['trade_status'] ?? '');
|
||||
$paid = in_array($trade_status, ['TRADE_SUCCESS', 'TRADE_FINISHED'], true);
|
||||
$order_number = (string) ($data['out_trade_no'] ?? '');
|
||||
$trade_no = (string) ($data['trade_no'] ?? '');
|
||||
|
||||
if (($data['app_id'] ?? '') !== $config['app_id']
|
||||
|| $order_number === ''
|
||||
|| ($paid && ($trade_no === '' || (float) ($data['total_amount'] ?? 0) <= 0))) {
|
||||
return ['success' => false, 'message' => __('支付宝回调身份或订单数据无效', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'order_number' => $order_number,
|
||||
'amount' => (float) ($data['total_amount'] ?? 0),
|
||||
'currency' => 'CNY',
|
||||
'transaction_id' => $trade_no,
|
||||
'status' => $paid ? 'paid' : 'pending',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
public function query_status(string $order_number): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$out_trade_no = $this->resolve_out_trade_no($order_number);
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_alipay();
|
||||
$result = Pay::alipay()->query([
|
||||
'out_trade_no' => $out_trade_no,
|
||||
]);
|
||||
$data = YansongdaPayFactory::to_array($result);
|
||||
|
||||
$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';
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
// Keep provider out_trade_no for Gateway::complete_order resolve.
|
||||
'order_number' => (string) ($data['out_trade_no'] ?? $out_trade_no),
|
||||
'amount' => (float) ($data['total_amount'] ?? 0),
|
||||
'currency' => 'CNY',
|
||||
'transaction_id' => (string) ($data['trade_no'] ?? ''),
|
||||
'status' => $status,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort close of a previous provider trade before re-pay.
|
||||
*/
|
||||
public function close_trade(string $out_trade_no): void {
|
||||
$out_trade_no = sanitize_text_field($out_trade_no);
|
||||
if ($out_trade_no === '' || !$this->is_configured()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
YansongdaPayFactory::boot_alipay();
|
||||
// alipay.trade.close — supported by yansongda as close().
|
||||
Pay::alipay()->close([
|
||||
'out_trade_no' => $out_trade_no,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||
if ($amount <= 0) {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
|
||||
}
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('支付宝未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$out_trade_no = $this->resolve_out_trade_no($order_number);
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_alipay();
|
||||
$refund_request_id = (string) preg_replace(
|
||||
'/[^A-Za-z0-9_-]/',
|
||||
'',
|
||||
$request_id !== '' ? $request_id : $order_number . '_refund'
|
||||
);
|
||||
$result = Pay::alipay()->refund([
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'refund_amount' => number_format($amount, 2, '.', ''),
|
||||
'refund_reason' => __('用户申请退款', 'aksrt'),
|
||||
'out_request_no' => $refund_request_id,
|
||||
]);
|
||||
$data = YansongdaPayFactory::to_array($result);
|
||||
|
||||
$fund_change = strtoupper((string) ($data['fund_change'] ?? ''));
|
||||
$status = ($fund_change === 'Y' || (float) ($data['refund_fee'] ?? 0) > 0)
|
||||
? 'succeeded'
|
||||
: 'unknown';
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $status,
|
||||
'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''),
|
||||
'request_id' => $refund_request_id,
|
||||
'message' => $status === 'succeeded' ? __('退款成功', 'aksrt') : __('退款结果待确认', 'aksrt'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('支付宝退款查询参数无效', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_alipay();
|
||||
$params = ['out_request_no' => $request_id, '_action' => 'refund'];
|
||||
if ($order_number !== '') {
|
||||
$params['out_trade_no'] = $this->resolve_out_trade_no($order_number);
|
||||
}
|
||||
$result = Pay::alipay()->query($params);
|
||||
$data = YansongdaPayFactory::to_array($result);
|
||||
$refund_status = strtoupper((string) ($data['refund_status'] ?? ''));
|
||||
$refund_amount = (float) ($data['refund_amount'] ?? 0);
|
||||
$status = ($refund_status === 'REFUND_SUCCESS' || $refund_amount > 0)
|
||||
? 'succeeded'
|
||||
: ($refund_status === 'REFUND_FAIL' ? 'failed' : 'pending');
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $status,
|
||||
'refund_id' => (string) ($data['trade_no'] ?? $data['out_request_no'] ?? ''),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map local order_number → provider out_trade_no (supports resume attempts).
|
||||
*/
|
||||
private function resolve_out_trade_no(string $order_number): string {
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
// A provider attempt id must be queried verbatim, including superseded Pn.
|
||||
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
|
||||
return $order_number;
|
||||
}
|
||||
$order = $repo->get($order_number);
|
||||
if ($order) {
|
||||
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
|
||||
}
|
||||
}
|
||||
return $order_number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
/**
|
||||
* Compatible Epay (彩虹易支付) gateway — alipay / wxpay channels.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment\Gateways;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class EpayGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
/** @var string alipay|wxpay */
|
||||
private string $channel;
|
||||
|
||||
public function __construct(string $channel = 'alipay') {
|
||||
$channel = sanitize_key($channel);
|
||||
$this->channel = in_array($channel, ['alipay', 'wxpay'], true) ? $channel : 'alipay';
|
||||
}
|
||||
|
||||
public function get_id(): string {
|
||||
return $this->channel === 'wxpay' ? 'epay_wxpay' : 'epay_alipay';
|
||||
}
|
||||
|
||||
public function get_name(): string {
|
||||
return $this->channel === 'wxpay'
|
||||
? __('易支付·微信', 'aksrt')
|
||||
: __('易支付·支付宝', 'aksrt');
|
||||
}
|
||||
|
||||
public function is_configured(): bool {
|
||||
if (class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled($this->get_id())) {
|
||||
return false;
|
||||
}
|
||||
$c = $this->get_config();
|
||||
return $c['apiurl'] !== '' && $c['pid'] !== '' && $c['key'] !== '';
|
||||
}
|
||||
|
||||
public function create_payment(array $order): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('易支付未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
|
||||
if ($out_trade_no === '') {
|
||||
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
|
||||
: $order_number;
|
||||
}
|
||||
$amount = (float) ($order['amount'] ?? 0);
|
||||
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
|
||||
if ($order_number === '' || $out_trade_no === '' || $amount <= 0) {
|
||||
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
$params = [
|
||||
'pid' => $config['pid'],
|
||||
'type' => $this->channel,
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'notify_url' => rest_url('aksrt/v1/payment/' . $this->get_id() . '/notify'),
|
||||
'return_url' => rest_url('aksrt/v1/payment/' . $this->get_id() . '/return'),
|
||||
'name' => mb_substr(wp_strip_all_tags($subject), 0, 127),
|
||||
'money' => number_format($amount, 2, '.', ''),
|
||||
];
|
||||
$params['sign'] = $this->sign($params, $config['key']);
|
||||
$params['sign_type'] = 'MD5';
|
||||
|
||||
$use_mobile = $this->prefer_mobile($config['device']);
|
||||
if ($use_mobile) {
|
||||
// mapi.php returns JSON with payurl / qrcode / urlscheme.
|
||||
$api = $this->endpoint($config['apiurl'], 'mapi.php');
|
||||
$response = wp_remote_post($api, [
|
||||
'timeout' => 30,
|
||||
'body' => $params,
|
||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'],
|
||||
]);
|
||||
if (is_wp_error($response)) {
|
||||
return ['success' => false, 'message' => $response->get_error_message()];
|
||||
}
|
||||
$body = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($body)) {
|
||||
// Fall back to browser submit if mapi is unavailable.
|
||||
return $this->browser_submit_result($config['apiurl'], $params, $order_number, $out_trade_no);
|
||||
}
|
||||
$code = (int) ($body['code'] ?? 0);
|
||||
if ($code !== 1) {
|
||||
$msg = (string) ($body['msg'] ?? $body['message'] ?? __('易支付下单失败', 'aksrt'));
|
||||
return ['success' => false, 'message' => $msg];
|
||||
}
|
||||
$pay_url = (string) ($body['payurl'] ?? $body['urlscheme'] ?? '');
|
||||
$qrcode = (string) ($body['qrcode'] ?? '');
|
||||
if ($pay_url === '' && $qrcode === '') {
|
||||
return ['success' => false, 'message' => __('易支付未返回支付链接', 'aksrt')];
|
||||
}
|
||||
$result = [
|
||||
'success' => true,
|
||||
'payment_data' => [
|
||||
'order_number' => $order_number,
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'code_url' => $qrcode,
|
||||
'channel' => $this->channel,
|
||||
],
|
||||
'message' => __('易支付订单已创建', 'aksrt'),
|
||||
];
|
||||
if ($pay_url !== '') {
|
||||
$result['payment_url'] = $pay_url;
|
||||
} elseif ($qrcode !== '') {
|
||||
$result['payment_url'] = $qrcode;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
return $this->browser_submit_result($config['apiurl'], $params, $order_number, $out_trade_no);
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('易支付未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$params = strtoupper($request->get_method()) === 'POST'
|
||||
? $request->get_body_params()
|
||||
: $request->get_query_params();
|
||||
if (!is_array($params) || $params === []) {
|
||||
// Some panels POST raw query string.
|
||||
$params = $request->get_params();
|
||||
}
|
||||
if (!is_array($params)) {
|
||||
$params = [];
|
||||
}
|
||||
unset($params['gateway'], $params['rest_route']);
|
||||
|
||||
$config = $this->get_config();
|
||||
$sign = (string) ($params['sign'] ?? '');
|
||||
if ($sign === '' || !$this->verify_sign($params, $config['key'], $sign)) {
|
||||
return ['success' => false, 'message' => __('易支付签名校验失败', 'aksrt')];
|
||||
}
|
||||
|
||||
$pid = (string) ($params['pid'] ?? '');
|
||||
if ($pid !== '' && !hash_equals($config['pid'], $pid)) {
|
||||
return ['success' => false, 'message' => __('易支付商户号不匹配', 'aksrt')];
|
||||
}
|
||||
|
||||
$order_number = sanitize_text_field((string) ($params['out_trade_no'] ?? ''));
|
||||
$transaction_id = sanitize_text_field((string) ($params['trade_no'] ?? ''));
|
||||
$amount = (float) ($params['money'] ?? 0);
|
||||
$trade_status = (string) ($params['trade_status'] ?? '');
|
||||
$type = sanitize_key((string) ($params['type'] ?? ''));
|
||||
|
||||
// When type is present, require channel match.
|
||||
if ($type !== '' && $type !== $this->channel) {
|
||||
return ['success' => false, 'message' => __('易支付通道类型不匹配', 'aksrt')];
|
||||
}
|
||||
|
||||
$paid = ($trade_status === '' || $trade_status === 'TRADE_SUCCESS');
|
||||
if ($order_number === '' || ($paid && ($transaction_id === '' || $amount <= 0))) {
|
||||
return ['success' => false, 'message' => __('易支付回调数据无效', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $amount,
|
||||
'currency' => 'CNY',
|
||||
'transaction_id' => $transaction_id,
|
||||
'status' => $paid ? 'paid' : 'pending',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Epay expects plain-text success/fail (handled also by Gateway for epay_* ids).
|
||||
*/
|
||||
public function output_callback_response(array $result): void {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo !empty($result['success']) ? 'success' : 'fail';
|
||||
exit;
|
||||
}
|
||||
|
||||
public function query_status(string $order_number): array {
|
||||
if (!$this->is_configured() || $order_number === '') {
|
||||
return ['success' => false, 'message' => __('易支付未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$out_trade_no = $this->resolve_out_trade_no($order_number);
|
||||
$config = $this->get_config();
|
||||
// Common compatible query: api.php?act=order&pid&key&out_trade_no
|
||||
$url = add_query_arg([
|
||||
'act' => 'order',
|
||||
'pid' => $config['pid'],
|
||||
'key' => $config['key'],
|
||||
'out_trade_no' => $out_trade_no,
|
||||
], $this->endpoint($config['apiurl'], 'api.php'));
|
||||
|
||||
$response = wp_remote_get($url, ['timeout' => 20]);
|
||||
if (is_wp_error($response)) {
|
||||
return ['success' => false, 'message' => $response->get_error_message()];
|
||||
}
|
||||
$body = json_decode((string) wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($body)) {
|
||||
return ['success' => false, 'message' => __('易支付查询响应无效', 'aksrt')];
|
||||
}
|
||||
|
||||
// code=1 success in most forks; status 1 = paid.
|
||||
$code = (int) ($body['code'] ?? 0);
|
||||
$status = (int) ($body['status'] ?? $body['trade_status'] ?? 0);
|
||||
$paid = $code === 1 && ((string) $status === '1' || (string) ($body['trade_status'] ?? '') === 'TRADE_SUCCESS');
|
||||
|
||||
$amount = (float) ($body['money'] ?? $body['amount'] ?? 0);
|
||||
$tx = (string) ($body['trade_no'] ?? $body['api_trade_no'] ?? '');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'order_number' => (string) ($body['out_trade_no'] ?? $out_trade_no),
|
||||
'amount' => $amount,
|
||||
'currency' => 'CNY',
|
||||
'transaction_id' => $tx,
|
||||
'status' => $paid ? 'paid' : 'pending',
|
||||
];
|
||||
}
|
||||
|
||||
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'failed',
|
||||
'message' => __('易支付退款请在商户后台人工处理', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{apiurl:string,pid:string,key:string,device:string}
|
||||
*/
|
||||
private function get_config(): array {
|
||||
$all = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_all()
|
||||
: [];
|
||||
$device = sanitize_key((string) ($all['epay_device'] ?? 'auto'));
|
||||
if (!in_array($device, ['auto', 'pc', 'mobile'], true)) {
|
||||
$device = 'auto';
|
||||
}
|
||||
$apiurl = (string) ($all['epay_apiurl'] ?? '');
|
||||
$apiurl = rtrim(trim($apiurl), '/') . '/';
|
||||
if ($apiurl === '/') {
|
||||
$apiurl = '';
|
||||
}
|
||||
return [
|
||||
'apiurl' => $apiurl,
|
||||
'pid' => (string) ($all['epay_pid'] ?? ''),
|
||||
'key' => (string) ($all['epay_key'] ?? ''),
|
||||
'device' => $device,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
*/
|
||||
private function sign(array $params, string $key): string {
|
||||
$filtered = [];
|
||||
foreach ($params as $k => $v) {
|
||||
if ($k === 'sign' || $k === 'sign_type') {
|
||||
continue;
|
||||
}
|
||||
if ($v === '' || $v === null) {
|
||||
continue;
|
||||
}
|
||||
$filtered[(string) $k] = is_scalar($v) ? (string) $v : '';
|
||||
}
|
||||
ksort($filtered);
|
||||
$parts = [];
|
||||
foreach ($filtered as $k => $v) {
|
||||
$parts[] = $k . '=' . $v;
|
||||
}
|
||||
return md5(implode('&', $parts) . $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $params
|
||||
*/
|
||||
private function verify_sign(array $params, string $key, string $sign): bool {
|
||||
$expected = $this->sign($params, $key);
|
||||
return hash_equals(strtolower($expected), strtolower($sign));
|
||||
}
|
||||
|
||||
private function endpoint(string $apiurl, string $file): string {
|
||||
return rtrim($apiurl, '/') . '/' . ltrim($file, '/');
|
||||
}
|
||||
|
||||
private function prefer_mobile(string $device): bool {
|
||||
if ($device === 'mobile') {
|
||||
return true;
|
||||
}
|
||||
if ($device === 'pc') {
|
||||
return false;
|
||||
}
|
||||
// auto
|
||||
if (!empty($_SERVER['HTTP_USER_AGENT'])) {
|
||||
$ua = (string) $_SERVER['HTTP_USER_AGENT'];
|
||||
return (bool) preg_match('/Mobile|Android|iPhone|iPad|iPod|MicroMessenger/i', $ua);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $params
|
||||
* @return array{success:bool,payment_url?:string,payment_data?:array,message?:string}
|
||||
*/
|
||||
/**
|
||||
* @param array<string,string> $params
|
||||
* @return array{success:bool,payment_url?:string,payment_data?:array,message?:string}
|
||||
*/
|
||||
private function browser_submit_result(string $apiurl, array $params, string $order_number, string $out_trade_no = ''): array {
|
||||
$url = add_query_arg($params, $this->endpoint($apiurl, 'submit.php'));
|
||||
return [
|
||||
'success' => true,
|
||||
'payment_url' => $url,
|
||||
'payment_data' => [
|
||||
'order_number' => $order_number,
|
||||
'out_trade_no' => $out_trade_no !== '' ? $out_trade_no : $order_number,
|
||||
'channel' => $this->channel,
|
||||
],
|
||||
'message' => __('易支付支付链接已生成', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map local order_number → provider out_trade_no (supports resume attempts).
|
||||
*/
|
||||
private function resolve_out_trade_no(string $order_number): string {
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
|
||||
return $order_number;
|
||||
}
|
||||
$order = $repo->get($order_number);
|
||||
if ($order) {
|
||||
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
|
||||
}
|
||||
}
|
||||
return $order_number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment\Gateways;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function get_id(): string {
|
||||
return 'paypal';
|
||||
}
|
||||
|
||||
public function get_name(): string {
|
||||
return __('PayPal', 'aksrt');
|
||||
}
|
||||
|
||||
public function is_configured(): bool {
|
||||
if (class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('paypal')) {
|
||||
return false;
|
||||
}
|
||||
$config = $this->get_config();
|
||||
return !empty($config['client_id']) && !empty($config['client_secret']);
|
||||
}
|
||||
|
||||
public function create_payment(array $order): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('PayPal 未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
// Re-pay attempts carry a fresh provider number; the callback resolves it back.
|
||||
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
|
||||
if ($out_trade_no === '') {
|
||||
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
|
||||
: $order_number;
|
||||
}
|
||||
$amount = (float) ($order['amount'] ?? 0);
|
||||
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
|
||||
$order_currency = strtoupper((string) ($order['currency'] ?? ''));
|
||||
$currency = strtoupper((string) ($config['currency'] ?? 'USD'));
|
||||
if ($order_currency === '' || !hash_equals($order_currency, $currency)) {
|
||||
return ['success' => false, 'message' => __('PayPal 币种与订单币种不一致', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->api_request('POST', '/v2/checkout/orders', [
|
||||
'intent' => 'CAPTURE',
|
||||
'purchase_units' => [[
|
||||
'reference_id' => $out_trade_no,
|
||||
'description' => $subject,
|
||||
'amount' => [
|
||||
'currency_code' => $currency,
|
||||
'value' => number_format($amount, 2, '.', ''),
|
||||
],
|
||||
]],
|
||||
'application_context' => [
|
||||
'return_url' => home_url('/wp-json/aksrt/v1/payment/paypal/return?order=' . rawurlencode($out_trade_no)),
|
||||
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/paypal/cancel?order=' . rawurlencode($out_trade_no)),
|
||||
'brand_name' => get_bloginfo('name'),
|
||||
'user_action' => 'PAY_NOW',
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response['status'] === 201) {
|
||||
$data = $response['body'];
|
||||
$approveUrl = '';
|
||||
foreach ($data['links'] ?? [] as $link) {
|
||||
if ($link['rel'] === 'approve') {
|
||||
$approveUrl = $link['href'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$persisted = \AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [
|
||||
'paypal_order_id' => (string) ($data['id'] ?? ''),
|
||||
]);
|
||||
if (!$persisted) {
|
||||
return ['success' => false, 'message' => __('PayPal 支付单保存失败,请重试', 'aksrt')];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'payment_url' => $approveUrl,
|
||||
'payment_data' => [
|
||||
'order_number' => $order_number,
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'paypal_id' => $data['id'] ?? '',
|
||||
],
|
||||
'message' => __('PayPal 订单已创建', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => $response['body']['message'] ?? __('PayPal 创建失败', 'aksrt')];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): array {
|
||||
// PayPal orders are finalized only by the authenticated capture call in handle_return().
|
||||
// Never treat an unsigned CHECKOUT.ORDER.APPROVED webhook as payment completion.
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'pending',
|
||||
'message' => __('PayPal 支付需在返回流程中完成捕获确认', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
public function query_status(string $order_number): array {
|
||||
if (!$this->is_configured() || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return ['success' => false, 'status' => 'pending', 'message' => __('PayPal 未配置', 'aksrt')];
|
||||
}
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->resolve_by_out_trade_no($order_number);
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$paypal_order_id = sanitize_text_field((string) ($meta['paypal_order_id'] ?? ''));
|
||||
if ($paypal_order_id === '') {
|
||||
return ['success' => true, 'status' => 'pending', 'message' => __('PayPal 支付尚未建立', 'aksrt')];
|
||||
}
|
||||
try {
|
||||
$response = $this->api_request('GET', '/v2/checkout/orders/' . rawurlencode($paypal_order_id));
|
||||
$body = $response['body'];
|
||||
if ($response['status'] !== 200 || !is_array($body)) {
|
||||
return ['success' => false, 'status' => 'pending', 'message' => __('PayPal 状态查询失败', 'aksrt')];
|
||||
}
|
||||
if (($body['status'] ?? '') === 'APPROVED') {
|
||||
return $this->capture_order($paypal_order_id);
|
||||
}
|
||||
if (($body['status'] ?? '') === 'COMPLETED') {
|
||||
return $this->completed_order_evidence($body);
|
||||
}
|
||||
return ['success' => true, 'status' => 'pending', 'message' => __('PayPal 支付处理中', 'aksrt')];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'status' => 'pending', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
private function get_config(): array {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return [];
|
||||
}
|
||||
$all = \AKSRT\Settings\Settings::get_all();
|
||||
return [
|
||||
'client_id' => (string) ($all['paypal_client_id'] ?? ''),
|
||||
'client_secret' => (string) ($all['paypal_client_secret'] ?? ''),
|
||||
'currency' => (string) ($all['paypal_currency'] ?? 'USD'),
|
||||
'mode' => !empty($all['paypal_sandbox']) ? 'sandbox' : 'live',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 捕获已批准的 PayPal 订单(v2 CAPTURE 必需步骤)。
|
||||
* 用户在 return_url 返回后调用,确认真正扣款。
|
||||
*
|
||||
* @param string $paypal_order_id PayPal 订单 ID(create 时返回的 id)
|
||||
* @return array{success:bool, order_number?:string, amount?:float, transaction_id?:string, status?:string, message?:string}
|
||||
*/
|
||||
public function capture_order(string $paypal_order_id): array {
|
||||
if ($paypal_order_id === '') {
|
||||
return ['success' => false, 'message' => __('缺少 PayPal 订单号', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->api_request('POST', '/v2/checkout/orders/' . rawurlencode($paypal_order_id) . '/capture');
|
||||
$body = $response['body'];
|
||||
|
||||
if (in_array($response['status'], [200, 201], true) && ($body['status'] ?? '') === 'COMPLETED') {
|
||||
return $this->completed_order_evidence($body);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => $body['message'] ?? __('PayPal 捕获失败', 'aksrt')];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
private function completed_order_evidence(array $body): array {
|
||||
$unit = $body['purchase_units'][0] ?? [];
|
||||
$capture = $unit['payments']['captures'][0] ?? [];
|
||||
return [
|
||||
'success' => true,
|
||||
'order_number' => (string) ($unit['reference_id'] ?? ''),
|
||||
'amount' => (float) ($capture['amount']['value'] ?? 0),
|
||||
'currency' => strtoupper((string) ($capture['amount']['currency_code'] ?? '')),
|
||||
'transaction_id' => (string) ($capture['id'] ?? ($body['id'] ?? '')),
|
||||
'status' => 'paid',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 OAuth2 access token(v2 REST 必需)。
|
||||
*/
|
||||
private function get_access_token(): string {
|
||||
$config = $this->get_config();
|
||||
$isSandbox = ($config['mode'] ?? 'live') === 'sandbox';
|
||||
$baseUrl = $isSandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
|
||||
|
||||
$response = wp_remote_post($baseUrl . '/v1/oauth2/token', [
|
||||
'headers' => [
|
||||
'Authorization' => 'Basic ' . base64_encode(($config['client_id'] ?? '') . ':' . ($config['client_secret'] ?? '')),
|
||||
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||
],
|
||||
'body' => 'grant_type=client_credentials',
|
||||
'timeout' => 30,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
throw new \Exception($response->get_error_message());
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
$token = $body['access_token'] ?? '';
|
||||
if ($token === '') {
|
||||
throw new \Exception($body['error_description'] ?? __('PayPal 鉴权失败', 'aksrt'));
|
||||
}
|
||||
|
||||
return (string) $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 PayPal REST API(带 OAuth2 Bearer token)
|
||||
*/
|
||||
private function api_request(string $method, string $endpoint, array $data = []): array {
|
||||
$config = $this->get_config();
|
||||
$isSandbox = ($config['mode'] ?? 'live') === 'sandbox';
|
||||
$baseUrl = $isSandbox
|
||||
? 'https://api-m.sandbox.paypal.com'
|
||||
: 'https://api-m.paypal.com';
|
||||
|
||||
$url = $baseUrl . $endpoint;
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->get_access_token(),
|
||||
];
|
||||
|
||||
$args = [
|
||||
'method' => $method,
|
||||
'headers' => $headers,
|
||||
'timeout' => 30,
|
||||
];
|
||||
if (!empty($data)) {
|
||||
$args['body'] = wp_json_encode($data);
|
||||
}
|
||||
|
||||
$response = wp_remote_request($url, $args);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
throw new \Exception($response->get_error_message());
|
||||
}
|
||||
|
||||
$status = wp_remote_retrieve_response_code($response);
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'body' => $body ?: [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment\Gateways;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class StripeGateway implements \AKSRT\Payment\GatewayInterface {
|
||||
|
||||
public function get_id(): string {
|
||||
return 'stripe';
|
||||
}
|
||||
|
||||
public function get_name(): string {
|
||||
return __('Stripe', 'aksrt');
|
||||
}
|
||||
|
||||
public function is_configured(): bool {
|
||||
if (class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
&& !\AKSRT\Settings\PaymentRuntime::is_gateway_enabled('stripe')) {
|
||||
return false;
|
||||
}
|
||||
$config = $this->get_config();
|
||||
return !empty($config['secret_key'])
|
||||
&& !empty($config['publishable_key'])
|
||||
&& !empty($config['webhook_secret']);
|
||||
}
|
||||
|
||||
public function create_payment(array $order): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
// Re-pay attempts carry a fresh provider number; the callback resolves it back.
|
||||
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
|
||||
if ($out_trade_no === '') {
|
||||
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($order)
|
||||
: $order_number;
|
||||
}
|
||||
$amount = (float) ($order['amount'] ?? 0);
|
||||
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
|
||||
$order_currency = strtoupper((string) ($order['currency'] ?? ''));
|
||||
$currency = strtolower($config['currency'] ?? 'usd');
|
||||
if ($order_currency === '' || !hash_equals($order_currency, strtoupper($currency))) {
|
||||
return ['success' => false, 'message' => __('Stripe 币种与订单币种不一致', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
$stripe = $this->create_client($config);
|
||||
|
||||
// 创建 Checkout Session
|
||||
$session_params = [
|
||||
'payment_method_types' => $this->get_payment_methods($config),
|
||||
'line_items' => [[
|
||||
'price_data' => [
|
||||
'currency' => $currency,
|
||||
'product_data' => [
|
||||
'name' => $subject,
|
||||
],
|
||||
'unit_amount' => $this->to_smallest_unit($amount, $currency),
|
||||
],
|
||||
'quantity' => 1,
|
||||
]],
|
||||
'mode' => 'payment',
|
||||
'client_reference_id' => $out_trade_no,
|
||||
'success_url' => home_url('/wp-json/aksrt/v1/payment/stripe/return?session_id={CHECKOUT_SESSION_ID}&order=' . rawurlencode($out_trade_no)),
|
||||
'cancel_url' => home_url('/wp-json/aksrt/v1/payment/stripe/cancel?order=' . rawurlencode($out_trade_no)),
|
||||
'metadata' => [
|
||||
'order_number' => $out_trade_no,
|
||||
],
|
||||
];
|
||||
|
||||
// 启用自动支付方式(Apple Pay, Google Pay 等)
|
||||
if (!empty($config['auto_payment_methods'])) {
|
||||
$session_params['automatic_payment_methods'] = [
|
||||
'enabled' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$session = \Stripe\Checkout\Session::create($session_params, [
|
||||
'api_key' => $config['secret_key'],
|
||||
]);
|
||||
|
||||
// Persist the session id before returning the redirect. Without this,
|
||||
// status/refund lookup may miss older sessions after traffic exceeds 100.
|
||||
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$persisted = \AKSRT\Payment\OrderRepository::get_instance()->merge_meta($order_number, [
|
||||
'stripe_session_id' => (string) $session->id,
|
||||
'stripe_sessions' => $this->append_session_mapping($order, $out_trade_no, (string) $session->id),
|
||||
]);
|
||||
if (!$persisted) {
|
||||
try {
|
||||
\Stripe\Checkout\Session::expire((string) $session->id, [
|
||||
'api_key' => $config['secret_key'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// Best-effort cleanup; do not return an untraceable session.
|
||||
}
|
||||
return ['success' => false, 'message' => __('Stripe 支付会话保存失败,请重试', 'aksrt')];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'payment_url' => $session->url,
|
||||
'payment_data' => [
|
||||
'order_number' => $order_number,
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'session_id' => $session->id,
|
||||
],
|
||||
'message' => __('Stripe 支付页面已创建', 'aksrt'),
|
||||
];
|
||||
} catch (\Stripe\Exception\ApiErrorException $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): array {
|
||||
$config = $this->get_config();
|
||||
|
||||
try {
|
||||
$stripe = $this->create_client($config);
|
||||
|
||||
$payload = $request->get_body();
|
||||
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
|
||||
|
||||
// 验证 Webhook 签名
|
||||
$event = \Stripe\Webhook::constructEvent(
|
||||
$payload,
|
||||
$sigHeader,
|
||||
$config['webhook_secret'] ?? ''
|
||||
);
|
||||
|
||||
if ($event->type !== 'checkout.session.completed') {
|
||||
return ['success' => false, 'message' => __('非支付完成事件', 'aksrt')];
|
||||
}
|
||||
|
||||
$session = $event->data->object;
|
||||
$orderNumber = $session->metadata->order_number ?? '';
|
||||
$currency = strtolower((string) ($session->currency ?? $config['currency'] ?? 'usd'));
|
||||
$amount = $this->from_smallest_unit((int) ($session->amount_total ?? 0), $currency);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'order_number' => $orderNumber,
|
||||
'amount' => $amount,
|
||||
'currency' => strtoupper($currency),
|
||||
'transaction_id' => $session->payment_intent ?? $session->id,
|
||||
'status' => $session->payment_status === 'paid' ? 'paid' : 'pending',
|
||||
];
|
||||
} catch (\Stripe\Exception\SignatureVerificationException $e) {
|
||||
return ['success' => false, 'message' => __('Webhook 签名验证失败', 'aksrt')];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function query_status(string $order_number): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
|
||||
try {
|
||||
$this->create_client($config);
|
||||
$found = $this->find_session($order_number, $config, true);
|
||||
|
||||
if ($found) {
|
||||
$status = 'pending';
|
||||
if ($found->payment_status === 'paid') {
|
||||
$status = 'paid';
|
||||
} elseif ($found->status === 'expired') {
|
||||
$status = 'cancelled';
|
||||
}
|
||||
|
||||
$transaction_id = is_string($found->payment_intent)
|
||||
? $found->payment_intent
|
||||
: (string) ($found->payment_intent->id ?? '');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'order_number' => (string) ($found->metadata->order_number ?? $order_number),
|
||||
'amount' => $this->from_smallest_unit(
|
||||
(int) ($found->amount_total ?? 0),
|
||||
strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd'))
|
||||
),
|
||||
'currency' => strtoupper((string) ($found->currency ?? $config['currency'] ?? 'usd')),
|
||||
'status' => $status,
|
||||
'transaction_id' => $transaction_id,
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'status' => 'pending', 'message' => __('未找到支付记录', 'aksrt')];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$config = $this->get_config();
|
||||
|
||||
try {
|
||||
$this->create_client($config);
|
||||
$found = $this->find_session($order_number, $config, true);
|
||||
|
||||
if (!$found) {
|
||||
return ['success' => false, 'message' => __('未找到支付记录', 'aksrt')];
|
||||
}
|
||||
|
||||
$paymentIntentId = is_string($found->payment_intent)
|
||||
? $found->payment_intent
|
||||
: (string) ($found->payment_intent->id ?? '');
|
||||
|
||||
if ($paymentIntentId === '') {
|
||||
return ['success' => false, 'message' => __('未找到支付意图', 'aksrt')];
|
||||
}
|
||||
|
||||
$currency = strtolower((string) ($found->currency ?? $config['currency'] ?? 'usd'));
|
||||
|
||||
$options = ['api_key' => $config['secret_key']];
|
||||
if ($request_id !== '') {
|
||||
$options['idempotency_key'] = $request_id;
|
||||
}
|
||||
$refund = \Stripe\Refund::create([
|
||||
'payment_intent' => $paymentIntentId,
|
||||
'amount' => $this->to_smallest_unit($amount, $currency),
|
||||
'reason' => 'requested_by_customer',
|
||||
'metadata' => [
|
||||
'aksrt_request_id' => $request_id,
|
||||
'order_number' => $order_number,
|
||||
],
|
||||
], $options);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => ($refund->status ?? '') === 'succeeded' ? 'succeeded' : 'pending',
|
||||
'refund_id' => $refund->id,
|
||||
'request_id' => $request_id,
|
||||
'message' => __('退款请求已提交', 'aksrt'),
|
||||
];
|
||||
} catch (\Stripe\Exception\ApiErrorException $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('Stripe 退款查询参数无效', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
$config = $this->get_config();
|
||||
$this->create_client($config);
|
||||
$refunds = \Stripe\Refund::all(['limit' => 100], ['api_key' => $config['secret_key']]);
|
||||
foreach ($refunds->data as $refund) {
|
||||
if (($refund->metadata->aksrt_request_id ?? '') !== $request_id) {
|
||||
continue;
|
||||
}
|
||||
$status = ($refund->status ?? '') === 'succeeded'
|
||||
? 'succeeded'
|
||||
: (($refund->status ?? '') === 'failed' ? 'failed' : 'pending');
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $status,
|
||||
'refund_id' => (string) ($refund->id ?? ''),
|
||||
];
|
||||
}
|
||||
return ['success' => true, 'status' => 'unknown', 'message' => __('未找到 Stripe 退款记录', 'aksrt')];
|
||||
} catch (\Exception $e) {
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Stripe Publishable Key(前端使用)。
|
||||
*/
|
||||
public function get_publishable_key(): string {
|
||||
$config = $this->get_config();
|
||||
return $config['publishable_key'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function append_session_mapping(array $order, string $out_trade_no, string $session_id): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$map = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : [];
|
||||
$map[$out_trade_no] = $session_id;
|
||||
if (count($map) > 20) {
|
||||
$map = array_slice($map, -20, null, true);
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the Checkout Session for an order. Prefers the session id persisted
|
||||
* at create time; falls back to scanning recent sessions for legacy orders.
|
||||
*
|
||||
* @return \Stripe\Checkout\Session|null
|
||||
*/
|
||||
private function find_session(string $order_number, array $config, bool $allow_legacy_scan = false) {
|
||||
$session_id = '';
|
||||
if ($order_number !== '' && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->resolve_by_out_trade_no($order_number) ?: $repo->get($order_number);
|
||||
if ($order) {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$sessions = is_array($meta['stripe_sessions'] ?? null) ? $meta['stripe_sessions'] : [];
|
||||
$session_id = sanitize_text_field((string) ($sessions[$order_number] ?? ''));
|
||||
if ($session_id === '' && $repo->get($order_number)) {
|
||||
$current_ref = \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
|
||||
$session_id = sanitize_text_field((string) ($sessions[$current_ref] ?? $meta['stripe_session_id'] ?? ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($session_id !== '') {
|
||||
try {
|
||||
return \Stripe\Checkout\Session::retrieve($session_id, ['api_key' => $config['secret_key']]);
|
||||
} catch (\Exception $e) {
|
||||
// Fall through to the scan below.
|
||||
}
|
||||
}
|
||||
|
||||
if (!$allow_legacy_scan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy orders created before session-id persistence can only be found
|
||||
// through Stripe's recent-session window.
|
||||
$sessions = \Stripe\Checkout\Session::all(['limit' => 100], ['api_key' => $config['secret_key']]);
|
||||
foreach ($sessions->data as $session) {
|
||||
if (($session->metadata->order_number ?? '') === $order_number) {
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function get_config(): array {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return [];
|
||||
}
|
||||
$all = \AKSRT\Settings\Settings::get_all();
|
||||
return [
|
||||
'secret_key' => (string) ($all['stripe_secret_key'] ?? ''),
|
||||
'publishable_key' => (string) ($all['stripe_publishable_key'] ?? ''),
|
||||
'webhook_secret' => (string) ($all['stripe_webhook_secret'] ?? ''),
|
||||
'currency' => (string) ($all['stripe_currency'] ?? 'usd'),
|
||||
'mode' => !empty($all['stripe_test_mode']) ? 'test' : 'live',
|
||||
];
|
||||
}
|
||||
|
||||
private function create_client(array $config): void {
|
||||
\Stripe\Stripe::setApiKey($config['secret_key']);
|
||||
|
||||
// 设置 API 版本
|
||||
\Stripe\Stripe::setApiVersion('2023-10-16');
|
||||
|
||||
// 启用遥测(可选)
|
||||
if (!empty($config['enable_telemetry'])) {
|
||||
\Stripe\Stripe::setEnableTelemetry(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的支付方式。
|
||||
*/
|
||||
private function get_payment_methods(array $config): array {
|
||||
$methods = $config['payment_methods'] ?? ['card'];
|
||||
|
||||
// 验证支付方式
|
||||
$valid = ['card', 'alipay', 'wechat_pay', 'sepa_debit', 'ideal', 'bancontact', 'giropay', 'sofort'];
|
||||
return array_values(array_intersect($methods, $valid)) ?: ['card'];
|
||||
}
|
||||
|
||||
private function from_smallest_unit(int $amount, string $currency): float {
|
||||
$zero_decimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'];
|
||||
if (in_array(strtolower($currency), $zero_decimal, true)) {
|
||||
return (float) $amount;
|
||||
}
|
||||
|
||||
return $amount / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将金额转换为最小单位(分/cent)。
|
||||
*/
|
||||
private function to_smallest_unit(float $amount, string $currency): int {
|
||||
// 零小数货币(日元、韩元等)
|
||||
$zeroDecimal = ['bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf'];
|
||||
|
||||
if (in_array(strtolower($currency), $zeroDecimal, true)) {
|
||||
return (int) $amount;
|
||||
}
|
||||
|
||||
return (int) round($amount * 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
/**
|
||||
* WeChat Pay gateway via yansongda/pay (API v3).
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
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_name(): string {
|
||||
return __('微信支付', 'aksrt');
|
||||
}
|
||||
|
||||
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')];
|
||||
}
|
||||
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
// Prefer attempt-specific out_trade_no so re-pay does not reuse a frozen provider order.
|
||||
$out_trade_no = (string) ($order['out_trade_no'] ?? '');
|
||||
if ($out_trade_no === '') {
|
||||
$out_trade_no = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::provider_out_trade_no($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);
|
||||
|
||||
if ($order_number === '' || $out_trade_no === '' || $total_fen <= 0) {
|
||||
return ['success' => false, 'message' => __('订单参数无效', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_wechat();
|
||||
|
||||
$payload = [
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'description' => mb_substr($subject, 0, 127),
|
||||
'amount' => [
|
||||
'total' => $total_fen,
|
||||
'currency' => 'CNY',
|
||||
],
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$data = YansongdaPayFactory::to_array($result);
|
||||
$payment_data = array_merge($data, [
|
||||
'order_number' => $order_number,
|
||||
'out_trade_no' => $out_trade_no,
|
||||
]);
|
||||
|
||||
$result_data = [
|
||||
'success' => true,
|
||||
'payment_data' => $payment_data,
|
||||
'message' => __('微信支付订单已创建', 'aksrt'),
|
||||
];
|
||||
|
||||
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 $result_data;
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): array {
|
||||
try {
|
||||
YansongdaPayFactory::boot_wechat();
|
||||
|
||||
// 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,
|
||||
]);
|
||||
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
|
||||
$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'] ?? '');
|
||||
|
||||
$total_fen = (int) YansongdaPayFactory::dig($resource, 'amount.total', 0);
|
||||
if ($total_fen <= 0) {
|
||||
$total_fen = (int) YansongdaPayFactory::dig($data, 'amount.total', 0);
|
||||
}
|
||||
|
||||
$paid = ($trade_state === 'SUCCESS') || ($event_type === 'TRANSACTION.SUCCESS');
|
||||
|
||||
// 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();
|
||||
$mch_id = (string) ($resource['mchid'] ?? $data['mchid'] ?? '');
|
||||
$app_id = (string) ($resource['appid'] ?? $data['appid'] ?? '');
|
||||
$currency = (string) YansongdaPayFactory::dig($resource, 'amount.currency', '');
|
||||
if ($order_number === ''
|
||||
|| ($paid && (
|
||||
$mch_id === ''
|
||||
|| $app_id === ''
|
||||
|| $currency === ''
|
||||
|| !hash_equals($config['mch_id'], $mch_id)
|
||||
|| !hash_equals($config['appid'], $app_id)
|
||||
|| $currency !== 'CNY'
|
||||
|| $transaction_id === ''
|
||||
|| $total_fen <= 0
|
||||
))
|
||||
|| (!$paid && (
|
||||
($mch_id !== '' && !hash_equals($config['mch_id'], $mch_id))
|
||||
|| ($app_id !== '' && !hash_equals($config['appid'], $app_id))
|
||||
|| ($currency !== '' && $currency !== 'CNY')
|
||||
))) {
|
||||
return ['success' => false, 'message' => __('微信支付回调身份或订单数据无效', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'order_number' => $order_number,
|
||||
'amount' => $total_fen > 0 ? ($total_fen / 100) : 0.0,
|
||||
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
|
||||
'transaction_id' => $transaction_id,
|
||||
'status' => $paid ? 'paid' : 'pending',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public function query_status(string $order_number): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$out_trade_no = $this->resolve_out_trade_no($order_number);
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_wechat();
|
||||
$action = $this->query_action();
|
||||
$result = Pay::wechat()->query([
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'_action' => $action,
|
||||
]);
|
||||
$data = YansongdaPayFactory::to_array($result);
|
||||
|
||||
$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' => true,
|
||||
// Keep provider out_trade_no so Gateway::complete_order can resolve the local order.
|
||||
'order_number' => (string) ($data['out_trade_no'] ?? $out_trade_no),
|
||||
'amount' => ((int) YansongdaPayFactory::dig($data, 'amount.total', 0)) / 100,
|
||||
'currency' => strtoupper((string) YansongdaPayFactory::dig($data, 'amount.currency', 'CNY')),
|
||||
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
|
||||
'status' => $status,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort close of a previous provider trade before re-pay.
|
||||
*/
|
||||
public function close_trade(string $out_trade_no): void {
|
||||
$out_trade_no = sanitize_text_field($out_trade_no);
|
||||
if ($out_trade_no === '' || !$this->is_configured()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
YansongdaPayFactory::boot_wechat();
|
||||
Pay::wechat()->close([
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'_action' => $this->query_action(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
// ignore — new attempt still uses a fresh out_trade_no
|
||||
}
|
||||
}
|
||||
|
||||
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||
if (!$this->is_configured()) {
|
||||
return ['success' => false, 'message' => __('微信支付未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$out_trade_no = $this->resolve_out_trade_no($order_number);
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_wechat();
|
||||
$action = $this->query_action();
|
||||
|
||||
$query = Pay::wechat()->query([
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'_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) {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')];
|
||||
}
|
||||
if ($refund_fen <= 0 || $refund_fen > $total_fen) {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
|
||||
}
|
||||
|
||||
$refund_request_id = (string) preg_replace(
|
||||
'/[^A-Za-z0-9_-]/',
|
||||
'',
|
||||
$request_id !== '' ? $request_id : 'REF_' . $order_number
|
||||
);
|
||||
$result = Pay::wechat()->refund([
|
||||
'_action' => 'refund_' . $action,
|
||||
'out_trade_no' => $out_trade_no,
|
||||
'out_refund_no' => $refund_request_id,
|
||||
'reason' => __('用户申请退款', 'aksrt'),
|
||||
'amount' => [
|
||||
'refund' => $refund_fen,
|
||||
'total' => $total_fen,
|
||||
'currency' => 'CNY',
|
||||
],
|
||||
]);
|
||||
$data = YansongdaPayFactory::to_array($result);
|
||||
|
||||
$status = strtoupper((string) ($data['status'] ?? ''));
|
||||
$normalized = $status === 'SUCCESS'
|
||||
? 'succeeded'
|
||||
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $normalized,
|
||||
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
|
||||
'request_id' => $refund_request_id,
|
||||
'message' => $normalized === 'succeeded' ? __('退款成功', 'aksrt') : __('退款请求已受理', 'aksrt'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function query_refund(string $request_id, string $order_number = ''): array {
|
||||
if (!$this->is_configured() || $request_id === '') {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('微信退款查询参数无效', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
YansongdaPayFactory::boot_wechat();
|
||||
$result = Pay::wechat()->query([
|
||||
'out_refund_no' => $request_id,
|
||||
'_action' => 'refund_' . $this->query_action(),
|
||||
]);
|
||||
$data = YansongdaPayFactory::to_array($result);
|
||||
$status = strtoupper((string) ($data['status'] ?? ''));
|
||||
$normalized = $status === 'SUCCESS'
|
||||
? 'succeeded'
|
||||
: (in_array($status, ['CLOSED', 'ABNORMAL'], true) ? 'failed' : 'pending');
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $normalized,
|
||||
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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,
|
||||
];
|
||||
}
|
||||
|
||||
private function query_action(): string {
|
||||
$map = [
|
||||
'native' => 'native',
|
||||
'h5' => 'h5',
|
||||
];
|
||||
$type = $this->get_config()['payment_type'];
|
||||
return $map[$type] ?? 'native';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map local order_number → provider out_trade_no (supports resume attempts).
|
||||
*/
|
||||
private function resolve_out_trade_no(string $order_number): string {
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
if ($repo->resolve_by_out_trade_no($order_number) && !$repo->get($order_number)) {
|
||||
return $order_number;
|
||||
}
|
||||
$order = $repo->get($order_number);
|
||||
if ($order) {
|
||||
return \AKSRT\Payment\OrderRepository::provider_out_trade_no($order);
|
||||
}
|
||||
}
|
||||
return $order_number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?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 = rest_url('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 = rest_url('aksrt/v1/payment/alipay/notify');
|
||||
$return = rest_url('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('#^https?://[^\s"\'<>]+$#', trim($body), $m)) {
|
||||
return $m[0];
|
||||
}
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user