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:
kate522 committed 2026-08-02 22:06:06 +08:00
1 parent 56f1a8175f
commit 8c5b1555c9
285 files changed
+26 -3600

No files matched your search

+311
View File
@@ -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;
}
}
+344
View File
@@ -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;
}
}
+275
View File
@@ -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 ?: [],
];
}
}
+415
View File
@@ -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);
}
}
+406
View File
@@ -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,
];
}
}