Files
wordpress-theme-aksrt/src/Payment/Gateways/WechatPayGateway.php
T

425 lines
14 KiB
PHP

<?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;
}
$subject = (string) ($order['subject'] ?? __('订单支付', 'aksrt'));
$payment_type = $this->get_config()['payment_type'];
try {
$total_fen = \AKSRT\Ledger\AssetAmount::balance(is_string($order['amount'] ?? null) ? trim($order['amount']) : '')->minor();
} catch (\InvalidArgumentException $e) {
$total_fen = 0;
}
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' => $this->fen_decimal($total_fen),
'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' => $this->fen_decimal((int) YansongdaPayFactory::dig($data, 'amount.total', 0)),
'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, string $amount, string $request_id = ''): array {
try {
$refund_amount = \AKSRT\Ledger\AssetAmount::balance($amount);
} catch (\InvalidArgumentException $e) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
if ($refund_amount->minor() <= 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_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 = $refund_amount->minor();
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';
}
private function fen_decimal(int $amount): string {
return \AKSRT\Ledger\AssetAmount::from_minor(
\AKSRT\Ledger\AssetType::BALANCE,
max(0, $amount)
)->decimal();
}
/**
* 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;
}
}