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,147 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* 站内余额支付网关(内置实现)。
|
||||
*
|
||||
* 仅扣现金钱包(\AKSRT\Credits\Balance / pay_balance_s),不再回退到积分。
|
||||
*/
|
||||
|
||||
class BalanceGateway implements GatewayInterface {
|
||||
|
||||
public function get_id(): string {
|
||||
return 'balance';
|
||||
}
|
||||
|
||||
public function get_name(): string {
|
||||
return __('站内余额', 'aksrt');
|
||||
}
|
||||
|
||||
public function is_configured(): bool {
|
||||
// Cash wallet only — do not treat points as 余额.
|
||||
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||
return false;
|
||||
}
|
||||
if (class_exists('\AKSRT\Settings\PaymentRuntime')) {
|
||||
return \AKSRT\Settings\PaymentRuntime::is_gateway_enabled('balance');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create_payment(array $order): array {
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
$amount = round((float) ($order['amount'] ?? 0), 2);
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
|
||||
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
|
||||
}
|
||||
if ($user_id <= 0) {
|
||||
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
|
||||
}
|
||||
$balance = \AKSRT\Credits\Balance::balance($user_id);
|
||||
if ($balance < $amount) {
|
||||
return ['success' => false, 'message' => __('余额不足', 'aksrt')];
|
||||
}
|
||||
$result = \AKSRT\Credits\Balance::deduct(
|
||||
$user_id,
|
||||
$amount,
|
||||
'order_payment',
|
||||
['type' => 'order', 'id' => 0, 'order' => $order_number],
|
||||
sprintf(__('订单 %s 支付', 'aksrt'), $order_number)
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
return ['success' => false, 'message' => $result->get_error_message() ?: __('扣款失败', 'aksrt')];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'transaction_id' => 'BAL_' . ($order_number !== '' ? $order_number : (string) time()),
|
||||
'message' => __('余额支付成功', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
public function verify_callback(\WP_REST_Request $request): array {
|
||||
// Balance payment is synchronous — no callback needed
|
||||
return ['success' => false, 'message' => __('余额支付无需回调验证', 'aksrt')];
|
||||
}
|
||||
|
||||
public function query_status(string $order_number): array {
|
||||
$order_repo = OrderRepository::get_instance();
|
||||
$order = $order_repo->get($order_number);
|
||||
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $order['status'],
|
||||
'transaction_id' => $order['payment_transaction_id'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
public function refund(string $order_number, float $amount, string $request_id = ''): array {
|
||||
$order_repo = OrderRepository::get_instance();
|
||||
$order = $order_repo->get($order_number);
|
||||
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
|
||||
}
|
||||
|
||||
$user_id = (int) $order['user_id'];
|
||||
$amount = round((float) $amount, 2);
|
||||
|
||||
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
|
||||
return ['success' => false, 'message' => __('余额功能未启用', 'aksrt')];
|
||||
}
|
||||
|
||||
$request_id = sanitize_text_field($request_id);
|
||||
$refund_key = $request_id !== '' ? $request_id : 'order:' . $order_number;
|
||||
$lock_key = 'aksrt_balance_refund_' . hash('sha256', $refund_key);
|
||||
$existing = get_option($lock_key, []);
|
||||
if (is_array($existing) && ($existing['status'] ?? '') === 'succeeded') {
|
||||
return [
|
||||
'success' => true,
|
||||
'refund_id' => 'BAL_REF_' . $order_number,
|
||||
'message' => __('退款已处理(幂等)', 'aksrt'),
|
||||
];
|
||||
}
|
||||
if (is_array($existing) && ($existing['status'] ?? '') === 'pending') {
|
||||
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
|
||||
}
|
||||
|
||||
$marker = ['status' => 'pending', 'token' => wp_generate_uuid4(), 'created_at' => time()];
|
||||
if (!add_option($lock_key, $marker, '', false)) {
|
||||
return ['success' => false, 'status' => 'pending', 'message' => __('退款正在处理中,请稍后查询', 'aksrt')];
|
||||
}
|
||||
|
||||
try {
|
||||
$result = \AKSRT\Credits\Balance::add(
|
||||
$user_id,
|
||||
$amount,
|
||||
'order_refund',
|
||||
['type' => 'order', 'order' => $order_number, 'request_id' => $request_id],
|
||||
sprintf(__('订单 %s 退款', 'aksrt'), $order_number)
|
||||
);
|
||||
if (is_wp_error($result)) {
|
||||
delete_option($lock_key);
|
||||
return ['success' => false, 'message' => $result->get_error_message() ?: __('退款失败', 'aksrt')];
|
||||
}
|
||||
update_option($lock_key, [
|
||||
'status' => 'succeeded',
|
||||
'refund_id' => 'BAL_REF_' . $order_number,
|
||||
'completed_at' => time(),
|
||||
], false);
|
||||
return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')];
|
||||
} catch (\Throwable $e) {
|
||||
delete_option($lock_key);
|
||||
return ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ChargeCard {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public const TYPE_BALANCE = 'balance';
|
||||
public const TYPE_VIP = 'vip';
|
||||
public const TYPE_CREDITS = 'credits';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_USED = 'used';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_DISABLED = 'disabled';
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_public_routes']);
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('charge_card');
|
||||
}
|
||||
|
||||
public function register_public_routes(): void {
|
||||
register_rest_route('aksrt/v1', '/pay/card-pass/options', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'rest_options'],
|
||||
'permission_callback' => [$this, 'check_rest_nonce'],
|
||||
]);
|
||||
register_rest_route('aksrt/v1', '/pay/card-pass/redeem', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rest_redeem'],
|
||||
'permission_callback' => [$this, 'check_logged_in_rest'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \WP_REST_Request $request
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public function check_rest_nonce(\WP_REST_Request $request) {
|
||||
$nonce = $request->get_header('x-wp-nonce');
|
||||
if (!$nonce) {
|
||||
return new \WP_Error('aksrt_rest_no_nonce', __('缺少安全令牌', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if (!wp_verify_nonce($nonce, 'wp_rest')) {
|
||||
return new \WP_Error('aksrt_rest_bad_nonce', __('安全令牌已过期,请刷新页面', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \WP_REST_Request $request
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public function check_logged_in_rest(\WP_REST_Request $request) {
|
||||
$nonce = $this->check_rest_nonce($request);
|
||||
if (is_wp_error($nonce)) {
|
||||
return $nonce;
|
||||
}
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rest_options(\WP_REST_Request $request) {
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $this->get_public_options(),
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function rest_redeem(\WP_REST_Request $request) {
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = $request->get_params();
|
||||
}
|
||||
$password = sanitize_text_field((string) ($params['password'] ?? $params['code'] ?? ''));
|
||||
$card = sanitize_text_field((string) ($params['card'] ?? ''));
|
||||
$type = sanitize_key((string) ($params['type'] ?? ''));
|
||||
if ($type !== '' && !in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
|
||||
$type = '';
|
||||
}
|
||||
|
||||
$result = $this->redeem_for_user(get_current_user_id(), $password, $card, $type);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
}
|
||||
return new \WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function get_public_options(): array {
|
||||
$labels = [
|
||||
'title' => __('卡密兑换', 'aksrt'),
|
||||
'password' => __('卡密', 'aksrt'),
|
||||
'card' => __('卡号', 'aksrt'),
|
||||
'submit_balance' => __('兑换余额', 'aksrt'),
|
||||
'submit_vip' => __('兑换会员', 'aksrt'),
|
||||
'submit_credits' => __('兑换积分', 'aksrt'),
|
||||
'placeholder_pw' => __('请输入卡密', 'aksrt'),
|
||||
'placeholder_card' => __('请输入卡号', 'aksrt'),
|
||||
];
|
||||
|
||||
// Master kill-switch on 收款接口 → 卡密充值.
|
||||
$channel_on = !class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
|| \AKSRT\Settings\PaymentRuntime::is_gateway_enabled('card_pass');
|
||||
if (!$channel_on) {
|
||||
$off = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
return [
|
||||
'balance' => $off,
|
||||
'vip' => $off,
|
||||
'credits' => $off,
|
||||
'labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
$balance = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
$vip = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_recharge_settings')) {
|
||||
$recharge = \AKSRT\Settings\Settings::get_payment_recharge_settings();
|
||||
$pc = is_array($recharge['pass_charge'] ?? null) ? $recharge['pass_charge'] : [];
|
||||
$balance = [
|
||||
'enabled' => !empty($pc['enabled']),
|
||||
'desc' => (string) ($pc['desc'] ?? ''),
|
||||
'only_password' => !isset($pc['only_password']) || !empty($pc['only_password']),
|
||||
];
|
||||
}
|
||||
if (method_exists('\AKSRT\Settings\Settings', 'get_payment_vip_settings')) {
|
||||
$vip_s = \AKSRT\Settings\Settings::get_payment_vip_settings();
|
||||
$benefit = is_array($vip_s['benefit'] ?? null) ? $vip_s['benefit'] : [];
|
||||
$vip = [
|
||||
'enabled' => !empty($benefit['pass_charge']),
|
||||
'desc' => (string) ($benefit['pass_charge_desc'] ?? ''),
|
||||
'only_password' => !isset($benefit['pass_charge_only_password']) || !empty($benefit['pass_charge_only_password']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Points card-pass: zibll points_pass_exchange_* (stored in aksrt_settings).
|
||||
$credits = ['enabled' => false, 'desc' => '', 'only_password' => true];
|
||||
$all = class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_all')
|
||||
? \AKSRT\Settings\Settings::get_all()
|
||||
: (array) get_option('aksrt_settings', []);
|
||||
if (!empty($all['points_pass_exchange_s'])) {
|
||||
// Require credits system on when available.
|
||||
$credits_on = !class_exists('\AKSRT\Settings\Settings') || !method_exists('\AKSRT\Settings\Settings', 'credits_enabled') || \AKSRT\Settings\Settings::credits_enabled();
|
||||
$credits = [
|
||||
'enabled' => $credits_on,
|
||||
'desc' => sanitize_textarea_field((string) ($all['points_pass_exchange_desc'] ?? '')),
|
||||
'only_password' => !isset($all['points_pass_exchange_only_password']) || !empty($all['points_pass_exchange_only_password']),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'balance' => $balance,
|
||||
'vip' => $vip,
|
||||
'credits' => $credits,
|
||||
'labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one card.
|
||||
*
|
||||
* Accepted $data keys:
|
||||
* - password|code (required unless auto)
|
||||
* - card (optional)
|
||||
* - type balance|vip|credits
|
||||
* - amount (balance)
|
||||
* - level, days (vip; days 0 = permanent)
|
||||
* - points (credits)
|
||||
* - meta (array override)
|
||||
* - expires_at, note
|
||||
*/
|
||||
public function create(array $data): int {
|
||||
global $wpdb;
|
||||
|
||||
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_BALANCE));
|
||||
if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
|
||||
$type = self::TYPE_BALANCE;
|
||||
}
|
||||
|
||||
$password = sanitize_text_field((string) ($data['password'] ?? $data['code'] ?? ''));
|
||||
if ($password === '') {
|
||||
return 0;
|
||||
}
|
||||
$card = sanitize_text_field((string) ($data['card'] ?? ''));
|
||||
|
||||
$meta = $this->normalize_meta($type, $data);
|
||||
if ($meta === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Uniqueness: password alone (only_password mode common).
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$this->table()} WHERE password = %s LIMIT 1",
|
||||
$password
|
||||
));
|
||||
if ($exists) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$expires_at = sanitize_text_field((string) ($data['expires_at'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
||||
$amount_col = (float) ($meta['amount'] ?? $meta['points'] ?? 0);
|
||||
|
||||
$result = $wpdb->insert($this->table(), [
|
||||
'card' => $card,
|
||||
'password' => $password,
|
||||
'type' => $type,
|
||||
'amount' => $amount_col,
|
||||
'balance' => $amount_col,
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'meta' => wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'order_number' => '',
|
||||
'used_by' => null,
|
||||
'used_at' => null,
|
||||
'expires_at' => $expires_at !== '' ? $expires_at : null,
|
||||
'created_at' => current_time('mysql'),
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
return $result ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta_fields
|
||||
* @return array<int,string> passwords/codes generated
|
||||
*/
|
||||
public function batch_create(string $type, array $meta_fields, int $count, int $length = 16, string $prefix = '', string $expires_at = '', string $note = ''): array {
|
||||
$type = sanitize_key($type);
|
||||
if (!in_array($type, [self::TYPE_BALANCE, self::TYPE_VIP, self::TYPE_CREDITS], true)) {
|
||||
$type = self::TYPE_BALANCE;
|
||||
}
|
||||
$count = max(1, min(500, $count));
|
||||
$length = max(8, min(32, $length));
|
||||
$prefix = sanitize_text_field($prefix);
|
||||
$codes = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$password = $prefix . strtoupper(wp_generate_password($length, false));
|
||||
$id = $this->create(array_merge($meta_fields, [
|
||||
'type' => $type,
|
||||
'password' => $password,
|
||||
'expires_at' => $expires_at,
|
||||
'note' => $note,
|
||||
]));
|
||||
if ($id > 0) {
|
||||
$codes[] = $password;
|
||||
}
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success:bool,message:string,type?:string,amount?:float,level?:int,days?:int,order_number?:string,balance?:int}|\WP_Error
|
||||
*/
|
||||
public function redeem_for_user(int $user_id, string $password, string $card = '', string $expected_type = '') {
|
||||
$user_id = max(0, $user_id);
|
||||
$password = sanitize_text_field($password);
|
||||
$card = sanitize_text_field($card);
|
||||
|
||||
if ($user_id <= 0) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
if ($password === '') {
|
||||
return new \WP_Error('aksrt_card_empty', __('请输入卡密', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
// Simple per-user throttle.
|
||||
$throttle_key = 'aksrt_card_pass_rl_' . $user_id;
|
||||
$hits = (int) get_transient($throttle_key);
|
||||
if ($hits >= 20) {
|
||||
return new \WP_Error('aksrt_card_rate', __('操作过于频繁,请稍后再试', 'aksrt'), ['status' => 429]);
|
||||
}
|
||||
set_transient($throttle_key, $hits + 1, MINUTE_IN_SECONDS);
|
||||
|
||||
$row = $this->find_active($password, $card, $expected_type);
|
||||
if (!$row) {
|
||||
return new \WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$type = sanitize_key((string) ($row['type'] ?? self::TYPE_BALANCE));
|
||||
if ($expected_type !== '' && $type !== $expected_type) {
|
||||
return new \WP_Error('aksrt_card_type', __('当前卡密不能用于该兑换类型', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$gate = $this->assert_type_enabled($type);
|
||||
if (is_wp_error($gate)) {
|
||||
return $gate;
|
||||
}
|
||||
|
||||
// only_password: if feature requires card number and empty, fail.
|
||||
$opts = $this->get_public_options();
|
||||
$only_password = true;
|
||||
if ($type === self::TYPE_BALANCE) {
|
||||
$only_password = !empty($opts['balance']['only_password']);
|
||||
} elseif ($type === self::TYPE_VIP) {
|
||||
$only_password = !empty($opts['vip']['only_password']);
|
||||
}
|
||||
if (!$only_password && $card === '' && (string) ($row['card'] ?? '') !== '') {
|
||||
return new \WP_Error('aksrt_card_need_no', __('请输入卡号', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!$only_password && $card !== '' && (string) ($row['card'] ?? '') !== '' && (string) $row['card'] !== $card) {
|
||||
return new \WP_Error('aksrt_card_invalid', __('卡号或卡密错误', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
if (!empty($row['expires_at']) && strtotime((string) $row['expires_at']) < time()) {
|
||||
global $wpdb;
|
||||
$wpdb->update($this->table(), ['status' => self::STATUS_EXPIRED], ['id' => (int) $row['id']], ['%s'], ['%d']);
|
||||
return new \WP_Error('aksrt_card_expired', __('卡密已过期', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$meta = $this->decode_meta($row);
|
||||
$card_id = (int) $row['id'];
|
||||
|
||||
// Create audit order first (pending), then CAS mark used, then fulfill + mark paid.
|
||||
$order_number = $this->create_redeem_order($user_id, $type, $meta, $card_id, $password);
|
||||
if ($order_number === '') {
|
||||
return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
if (!$this->mark_used_cas($card_id, $user_id, $order_number)) {
|
||||
// Race lost.
|
||||
if (class_exists('AKSRT\\Payment\\Order')) {
|
||||
Order::get_instance()->update_status($order_number, Order::STATUS_CANCELLED, [
|
||||
'note' => __('卡密已被使用,订单取消', 'aksrt'),
|
||||
]);
|
||||
}
|
||||
return new \WP_Error('aksrt_card_used', __('该卡密已使用', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$fulfill = $this->fulfill($user_id, $type, $meta, $order_number, $card_id);
|
||||
if (is_wp_error($fulfill)) {
|
||||
return $fulfill;
|
||||
}
|
||||
|
||||
if (class_exists('AKSRT\\Payment\\Order')) {
|
||||
Order::get_instance()->update_status($order_number, Order::STATUS_PAID, [
|
||||
'payment_method' => 'card_pass',
|
||||
'payment_transaction_id' => 'card_pass_' . $card_id,
|
||||
'note' => (string) ($fulfill['message'] ?? __('卡密兑换成功', 'aksrt')),
|
||||
]);
|
||||
}
|
||||
|
||||
$out = array_merge([
|
||||
'success' => true,
|
||||
'type' => $type,
|
||||
'order_number' => $order_number,
|
||||
'reload' => true,
|
||||
], $fulfill);
|
||||
|
||||
if (class_exists('\AKSRT\Credits\Credits')) {
|
||||
$out['balance'] = \AKSRT\Credits\Credits::balance($user_id);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function find_active(string $password, string $card = '', string $type = ''): ?array {
|
||||
global $wpdb;
|
||||
$password = sanitize_text_field($password);
|
||||
if ($password === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} WHERE password = %s";
|
||||
$params = [$password];
|
||||
if ($type !== '') {
|
||||
$sql .= ' AND type = %s';
|
||||
$params[] = sanitize_key($type);
|
||||
}
|
||||
$sql .= ' LIMIT 1';
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
if ((string) ($row['status'] ?? '') !== self::STATUS_ACTIVE) {
|
||||
return null;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function mark_used_cas(int $card_id, int $user_id, string $order_number): bool {
|
||||
global $wpdb;
|
||||
if ($card_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->table()} SET status = %s, used_by = %d, used_at = %s, order_number = %s, balance = 0
|
||||
WHERE id = %d AND status = %s",
|
||||
self::STATUS_USED,
|
||||
$user_id,
|
||||
current_time('mysql'),
|
||||
$order_number,
|
||||
$card_id,
|
||||
self::STATUS_ACTIVE
|
||||
));
|
||||
return $updated === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
* @return array{message:string,amount?:float,level?:int,days?:int}|\WP_Error
|
||||
*/
|
||||
private function fulfill(int $user_id, string $type, array $meta, string $order_number, int $card_id) {
|
||||
if ($type === self::TYPE_BALANCE) {
|
||||
$amount = round((float) ($meta['amount'] ?? 0), 2);
|
||||
if ($amount <= 0) {
|
||||
return new \WP_Error('aksrt_card_amount', __('当前卡密可充值金额为0', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
// Cash wallet first (Zibll pay_balance); fall back to points if wallet class missing.
|
||||
if (class_exists('\AKSRT\Credits\Balance')) {
|
||||
$new = \AKSRT\Credits\Balance::add($user_id, $amount, 'card_pass_balance', [
|
||||
'type' => 'charge_card',
|
||||
'id' => $card_id,
|
||||
'order' => $order_number,
|
||||
], sprintf(__('卡密充值 %s 元', 'aksrt'), (string) $amount));
|
||||
if (is_wp_error($new)) {
|
||||
return new \WP_Error('aksrt_balance_fail', $new->get_error_message() ?: __('余额入账失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return [
|
||||
'message' => sprintf(__('卡密充值成功,充值金额:%s 元', 'aksrt'), (string) $amount),
|
||||
'amount' => (float) $amount,
|
||||
];
|
||||
}
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return new \WP_Error('aksrt_card_amount', __('余额系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$pts = (int) round($amount);
|
||||
$new = \AKSRT\Credits\Credits::add($user_id, $pts, 'card_pass_balance', [
|
||||
'ref_type' => 'charge_card',
|
||||
'ref_id' => $card_id,
|
||||
'order' => $order_number,
|
||||
], sprintf(__('卡密充值 %d', 'aksrt'), $pts));
|
||||
if ($new === null || $new === false || is_wp_error($new)) {
|
||||
return new \WP_Error('aksrt_credit_fail', __('余额入账失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return [
|
||||
'message' => sprintf(__('卡密充值成功,充值金额:%s', 'aksrt'), (string) $pts),
|
||||
'amount' => (float) $pts,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_VIP) {
|
||||
$level = max(1, min(2, (int) ($meta['level'] ?? 1)));
|
||||
$days = max(0, (int) ($meta['days'] ?? 0));
|
||||
if (!class_exists('\AKSRT\Content\VipService')) {
|
||||
return new \WP_Error('aksrt_vip_unavailable', __('会员系统不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
\AKSRT\Content\VipService::get_instance()->activate_vip($user_id, $level, $days);
|
||||
$msg = $days > 0
|
||||
? sprintf(
|
||||
/* translators: 1: level 2: days */
|
||||
__('卡密兑换成功:VIP%s %d 天', 'aksrt'),
|
||||
(string) $level,
|
||||
$days
|
||||
)
|
||||
: sprintf(
|
||||
/* translators: %s: level */
|
||||
__('卡密兑换成功:VIP%s 永久', 'aksrt'),
|
||||
(string) $level
|
||||
);
|
||||
return [
|
||||
'message' => $msg,
|
||||
'level' => $level,
|
||||
'days' => $days,
|
||||
];
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_CREDITS) {
|
||||
$points = (int) ($meta['points'] ?? 0);
|
||||
if ($points <= 0 || !class_exists('\AKSRT\Credits\Credits')) {
|
||||
return new \WP_Error('aksrt_card_points', __('当前卡密可兑换积分为0', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
\AKSRT\Credits\Credits::add($user_id, $points, 'card_pass_credits', [
|
||||
'ref_type' => 'charge_card',
|
||||
'ref_id' => $card_id,
|
||||
'order' => $order_number,
|
||||
], sprintf(__('卡密兑换积分 %d', 'aksrt'), $points));
|
||||
return [
|
||||
'message' => sprintf(__('卡密兑换成功,积分:%d', 'aksrt'), $points),
|
||||
'amount' => (float) $points,
|
||||
];
|
||||
}
|
||||
|
||||
return new \WP_Error('aksrt_card_type', __('不支持的卡密类型', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
*/
|
||||
private function create_redeem_order(int $user_id, string $type, array $meta, int $card_id, string $password): string {
|
||||
if (!class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('\AKSRT\Core\DomainModel')) {
|
||||
return 'CARD' . $card_id . '_' . time();
|
||||
}
|
||||
|
||||
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE;
|
||||
$product_id = $card_id;
|
||||
$amount = 0.0;
|
||||
$currency = 'CNY';
|
||||
|
||||
if ($type === self::TYPE_VIP) {
|
||||
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP;
|
||||
$amount = 0.0;
|
||||
} elseif ($type === self::TYPE_BALANCE) {
|
||||
$amount = round((float) ($meta['amount'] ?? 0), 2);
|
||||
$currency = 'CNY';
|
||||
} elseif ($type === self::TYPE_CREDITS) {
|
||||
$amount = (float) ($meta['points'] ?? 0);
|
||||
$currency = 'points';
|
||||
}
|
||||
|
||||
$note = wp_json_encode([
|
||||
'card_pass' => 1,
|
||||
'card_id' => $card_id,
|
||||
'card_type' => $type,
|
||||
'meta' => $meta,
|
||||
'password' => substr(md5($password), 0, 8),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => $product_type,
|
||||
'product_id' => $product_id,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'status' => class_exists('AKSRT\\Payment\\Order') ? Order::STATUS_PENDING : 'pending',
|
||||
'payment_method' => 'card_pass',
|
||||
'note' => is_string($note) ? $note : '',
|
||||
'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
private function assert_type_enabled(string $type) {
|
||||
$opts = $this->get_public_options();
|
||||
if ($type === self::TYPE_BALANCE && empty($opts['balance']['enabled'])) {
|
||||
return new \WP_Error('aksrt_card_disabled', __('余额卡密兑换未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if ($type === self::TYPE_VIP && empty($opts['vip']['enabled'])) {
|
||||
return new \WP_Error('aksrt_card_disabled', __('会员卡密兑换未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
if ($type === self::TYPE_CREDITS && empty($opts['credits']['enabled'])) {
|
||||
return new \WP_Error('aksrt_card_disabled', __('积分卡密兑换未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function normalize_meta(string $type, array $data): ?array {
|
||||
if (is_array($data['meta'] ?? null)) {
|
||||
$raw = $data['meta'];
|
||||
} else {
|
||||
$raw = $data;
|
||||
}
|
||||
|
||||
if ($type === self::TYPE_BALANCE) {
|
||||
$amount = round((float) ($raw['amount'] ?? $data['amount'] ?? 0), 2);
|
||||
if ($amount <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ['amount' => $amount];
|
||||
}
|
||||
if ($type === self::TYPE_VIP) {
|
||||
$level = max(1, min(2, (int) ($raw['level'] ?? $data['level'] ?? 1)));
|
||||
$days = max(0, (int) ($raw['days'] ?? $data['days'] ?? $data['vip_days'] ?? 0));
|
||||
return ['level' => $level, 'days' => $days];
|
||||
}
|
||||
if ($type === self::TYPE_CREDITS) {
|
||||
$points = max(0, (int) ($raw['points'] ?? $data['points'] ?? $data['credits'] ?? 0));
|
||||
if ($points <= 0) {
|
||||
return null;
|
||||
}
|
||||
return ['points' => $points];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function decode_meta(array $row): array {
|
||||
$meta = [];
|
||||
if (!empty($row['meta'])) {
|
||||
if (is_array($row['meta'])) {
|
||||
$meta = $row['meta'];
|
||||
} else {
|
||||
$decoded = json_decode((string) $row['meta'], true);
|
||||
if (is_array($decoded)) {
|
||||
$meta = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Legacy amount-only rows.
|
||||
if ($meta === [] && isset($row['amount'])) {
|
||||
$meta = ['amount' => (float) $row['amount']];
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin list helper (supports new + legacy columns).
|
||||
*
|
||||
* @return array{items:array,total:int,page:int,max_pages:int}
|
||||
*/
|
||||
public function get_list(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$type = sanitize_key((string) ($args['type'] ?? ''));
|
||||
$search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? ''));
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($type !== '') {
|
||||
if ($type === 'points') {
|
||||
$type = self::TYPE_CREDITS;
|
||||
}
|
||||
$where[] = 'type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
if ($search !== '') {
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$where[] = '(password LIKE %s OR card LIKE %s OR note LIKE %s OR CAST(id AS CHAR) = %s)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $search;
|
||||
}
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
$total = $params
|
||||
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params))
|
||||
: (int) $wpdb->get_var($count_sql);
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return [
|
||||
'items' => $items ?: [],
|
||||
'total' => $total,
|
||||
'page' => (int) floor($offset / $limit) + 1,
|
||||
'max_pages' => (int) ceil(max(1, $total) / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
public function disable(int $id): bool {
|
||||
global $wpdb;
|
||||
return (bool) $wpdb->update($this->table(), [
|
||||
'status' => self::STATUS_DISABLED,
|
||||
], ['id' => $id], ['%s'], ['%d']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function get_stats(): array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
|
||||
SUM(CASE WHEN status = 'used' THEN 1 ELSE 0 END) as used,
|
||||
SUM(amount) as total_amount,
|
||||
SUM(CASE WHEN status = 'used' THEN amount ELSE 0 END) as used_amount
|
||||
FROM {$table}",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'active' => (int) ($row['active'] ?? 0),
|
||||
'used' => (int) ($row['used'] ?? 0),
|
||||
'total_amount' => (float) ($row['total_amount'] ?? 0),
|
||||
'used_amount' => (float) ($row['used_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy alias used by older admin code.
|
||||
*
|
||||
* @return array{success:bool,message:string,amount?:float}
|
||||
*/
|
||||
public function redeem(string $code, int $user_id): array {
|
||||
$result = $this->redeem_for_user($user_id, $code, '', self::TYPE_BALANCE);
|
||||
if (is_wp_error($result)) {
|
||||
return ['success' => false, 'message' => $result->get_error_message()];
|
||||
}
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => (string) ($result['message'] ?? __('兑换成功', 'aksrt')),
|
||||
'amount' => (float) ($result['amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Coupon {
|
||||
|
||||
const TYPE_PERCENT = 'percent';
|
||||
const TYPE_FIXED = 'fixed';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('coupon');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建优惠券
|
||||
*/
|
||||
public function create(array $data): int {
|
||||
global $wpdb;
|
||||
|
||||
$code = sanitize_text_field((string) ($data['code'] ?? ''));
|
||||
if ($code === '') return 0;
|
||||
|
||||
// Check uniqueness
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$this->table()} WHERE code = %s",
|
||||
$code
|
||||
));
|
||||
if ($exists) return 0;
|
||||
|
||||
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_FIXED));
|
||||
if (!in_array($type, [self::TYPE_PERCENT, self::TYPE_FIXED], true)) {
|
||||
$type = self::TYPE_FIXED;
|
||||
}
|
||||
|
||||
$wpdb->insert($this->table(), [
|
||||
'code' => $code,
|
||||
'type' => $type,
|
||||
'value' => (float) ($data['value'] ?? 0),
|
||||
'min_amount' => (float) ($data['min_amount'] ?? 0),
|
||||
'usage_limit' => (int) ($data['usage_limit'] ?? 0),
|
||||
'used_count' => 0,
|
||||
'expires_at' => sanitize_text_field((string) ($data['expires_at'] ?? '')),
|
||||
'per_user_limit' => (int) ($data['per_user_limit'] ?? 0),
|
||||
'product_scope' => wp_json_encode($data['product_scope'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'max_discount' => (float) ($data['max_discount'] ?? 0),
|
||||
'status' => 'active',
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%s', '%f', '%f', '%d', '%d', '%s', '%d', '%s', '%f', '%s', '%s']);
|
||||
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取优惠券列表
|
||||
*/
|
||||
public function get_list(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$search = sanitize_text_field((string) ($args['search'] ?? ''));
|
||||
if ($search !== '') {
|
||||
$where[] = 'code LIKE %s';
|
||||
$params[] = '%' . $wpdb->esc_like($search) . '%';
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
if (!empty($params)) {
|
||||
$total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params));
|
||||
} else {
|
||||
$total = (int) $wpdb->get_var($count_sql);
|
||||
}
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return [
|
||||
'items' => array_map([$this, 'normalize_row'], (array) $rows),
|
||||
'total' => $total,
|
||||
'page' => max(1, (int) ceil($offset / $limit) + 1),
|
||||
'total_pages' => max(1, (int) ceil($total / $limit)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证优惠券是否可用
|
||||
*
|
||||
* @return array{valid: bool, coupon?: array, message?: string}
|
||||
*/
|
||||
public function validate(string $code, float $order_amount = 0, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$code = sanitize_text_field($code);
|
||||
if ($code === '') {
|
||||
return ['valid' => false, 'message' => '请输入优惠码'];
|
||||
}
|
||||
|
||||
$coupon = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE code = %s",
|
||||
$code
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$coupon) {
|
||||
return ['valid' => false, 'message' => '优惠码不存在'];
|
||||
}
|
||||
|
||||
$coupon = $this->normalize_row($coupon);
|
||||
|
||||
if ($coupon['status'] !== 'active') {
|
||||
return ['valid' => false, 'message' => '优惠码已失效'];
|
||||
}
|
||||
|
||||
if (!empty($coupon['expires_at']) && strtotime($coupon['expires_at']) < time()) {
|
||||
return ['valid' => false, 'message' => '优惠码已过期'];
|
||||
}
|
||||
|
||||
if ($coupon['usage_limit'] > 0 && $coupon['used_count'] >= $coupon['usage_limit']) {
|
||||
return ['valid' => false, 'message' => '优惠码已用完'];
|
||||
}
|
||||
|
||||
if ($coupon['min_amount'] > 0 && $order_amount < $coupon['min_amount']) {
|
||||
return ['valid' => false, 'message' => '订单金额不满足最低消费要求'];
|
||||
}
|
||||
|
||||
// Per-user limit check
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
if ($coupon['per_user_limit'] > 0 && $user_id > 0) {
|
||||
$user_used = $this->get_user_usage_count($coupon['id'], $user_id);
|
||||
if ($user_used >= $coupon['per_user_limit']) {
|
||||
return ['valid' => false, 'message' => '您已使用该优惠码达到上限'];
|
||||
}
|
||||
}
|
||||
|
||||
// Product / content scope check.
|
||||
// Supports product and content post cats.
|
||||
if (!empty($coupon['product_scope']) && !empty($args['product_ids'])) {
|
||||
$scope = is_array($coupon['product_scope']) ? $coupon['product_scope'] : json_decode($coupon['product_scope'], true);
|
||||
if (is_array($scope) && !empty($scope)) {
|
||||
$scope_type = sanitize_key((string) ($scope['type'] ?? ''));
|
||||
$scope_ids = array_map('intval', (array) ($scope['ids'] ?? []));
|
||||
$product_ids = array_map('intval', (array) $args['product_ids']);
|
||||
$taxonomies = array_values(array_filter(array_map('sanitize_key', (array) ($scope['taxonomies'] ?? []))));
|
||||
if ($taxonomies === []) {
|
||||
$taxonomies = ['aksrt_product_cat', 'category'];
|
||||
}
|
||||
|
||||
if ($scope_type === 'include' && empty(array_intersect($product_ids, $scope_ids))) {
|
||||
return ['valid' => false, 'message' => __('该优惠码不适用于当前商品', 'aksrt')];
|
||||
}
|
||||
if ($scope_type === 'exclude' && !empty(array_intersect($product_ids, $scope_ids))) {
|
||||
return ['valid' => false, 'message' => __('该优惠码不适用于当前商品', 'aksrt')];
|
||||
}
|
||||
if (in_array($scope_type, ['category', 'content_category', 'post_category'], true)) {
|
||||
$matched = false;
|
||||
foreach ($product_ids as $pid) {
|
||||
foreach ($taxonomies as $tax) {
|
||||
$cats = wp_get_post_terms($pid, $tax, ['fields' => 'ids']);
|
||||
if (!is_wp_error($cats) && !empty(array_intersect(array_map('intval', (array) $cats), $scope_ids))) {
|
||||
$matched = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$matched) {
|
||||
return ['valid' => false, 'message' => __('该优惠码不适用于当前分类', 'aksrt')];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate discount
|
||||
$discount = 0;
|
||||
if ($coupon['type'] === self::TYPE_PERCENT) {
|
||||
$discount = $order_amount * ($coupon['value'] / 100);
|
||||
} else {
|
||||
$discount = min($coupon['value'], $order_amount);
|
||||
}
|
||||
|
||||
// Max discount cap
|
||||
if ($coupon['max_discount'] > 0 && $discount > $coupon['max_discount']) {
|
||||
$discount = $coupon['max_discount'];
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => true,
|
||||
'coupon' => $coupon,
|
||||
'discount' => round($discount, 2),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 核销优惠券
|
||||
*/
|
||||
public function use_coupon(string $code): bool {
|
||||
global $wpdb;
|
||||
|
||||
$code = sanitize_text_field($code);
|
||||
$result = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$this->table()} SET used_count = used_count + 1 WHERE code = %s",
|
||||
$code
|
||||
));
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用优惠券
|
||||
*/
|
||||
public function disable(int $id): bool {
|
||||
global $wpdb;
|
||||
return $wpdb->update($this->table(), ['status' => 'disabled'], ['id' => $id], ['%s'], ['%d']) !== false;
|
||||
}
|
||||
|
||||
public function enable(int $id): bool {
|
||||
global $wpdb;
|
||||
return $wpdb->update($this->table(), ['status' => 'active'], ['id' => $id], ['%s'], ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get one coupon by id.
|
||||
*/
|
||||
public function get(int $id): ?array {
|
||||
global $wpdb;
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE id = %d",
|
||||
$id
|
||||
), ARRAY_A);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $this->normalize_row($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update mutable coupon fields (not code / used_count / created_at).
|
||||
*/
|
||||
public function update(int $id, array $data): bool {
|
||||
global $wpdb;
|
||||
if ($id <= 0 || !$this->get($id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fields = [];
|
||||
$format = [];
|
||||
|
||||
if (array_key_exists('type', $data)) {
|
||||
$type = sanitize_key((string) $data['type']);
|
||||
if (!in_array($type, [self::TYPE_PERCENT, self::TYPE_FIXED], true)) {
|
||||
$type = self::TYPE_FIXED;
|
||||
}
|
||||
$fields['type'] = $type;
|
||||
$format[] = '%s';
|
||||
}
|
||||
if (array_key_exists('value', $data)) {
|
||||
$fields['value'] = (float) $data['value'];
|
||||
$format[] = '%f';
|
||||
}
|
||||
if (array_key_exists('min_amount', $data)) {
|
||||
$fields['min_amount'] = (float) $data['min_amount'];
|
||||
$format[] = '%f';
|
||||
}
|
||||
if (array_key_exists('max_discount', $data)) {
|
||||
$fields['max_discount'] = (float) $data['max_discount'];
|
||||
$format[] = '%f';
|
||||
}
|
||||
if (array_key_exists('usage_limit', $data)) {
|
||||
$fields['usage_limit'] = (int) $data['usage_limit'];
|
||||
$format[] = '%d';
|
||||
}
|
||||
if (array_key_exists('per_user_limit', $data)) {
|
||||
$fields['per_user_limit'] = (int) $data['per_user_limit'];
|
||||
$format[] = '%d';
|
||||
}
|
||||
if (array_key_exists('expires_at', $data)) {
|
||||
$fields['expires_at'] = sanitize_text_field((string) $data['expires_at']);
|
||||
$format[] = '%s';
|
||||
}
|
||||
if (array_key_exists('product_scope', $data)) {
|
||||
$scope = is_array($data['product_scope']) ? $data['product_scope'] : [];
|
||||
$fields['product_scope'] = wp_json_encode($scope, JSON_UNESCAPED_UNICODE);
|
||||
$format[] = '%s';
|
||||
}
|
||||
if (array_key_exists('status', $data)) {
|
||||
$status = sanitize_key((string) $data['status']);
|
||||
if (in_array($status, ['active', 'disabled'], true)) {
|
||||
$fields['status'] = $status;
|
||||
$format[] = '%s';
|
||||
}
|
||||
}
|
||||
|
||||
if (!$fields) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $wpdb->update($this->table(), $fields, ['id' => $id], $format, ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-create coupons with auto-generated codes.
|
||||
*
|
||||
* @return array{created:int,codes:array<int,string>}
|
||||
*/
|
||||
public function batch_create(array $data, int $count = 1, int $length = 10, string $prefix = ''): array {
|
||||
$count = max(1, min(100, $count));
|
||||
$length = max(6, min(24, $length));
|
||||
$prefix = sanitize_text_field($prefix);
|
||||
$codes = [];
|
||||
$created = 0;
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$code = $prefix !== ''
|
||||
? $prefix . strtoupper(wp_generate_password($length, false, false))
|
||||
: strtoupper(wp_generate_password($length, false, false));
|
||||
$payload = $data;
|
||||
$payload['code'] = $code;
|
||||
$id = $this->create($payload);
|
||||
if ($id > 0) {
|
||||
$created++;
|
||||
$codes[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'created' => $created,
|
||||
'codes' => $codes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many times a user has used a specific coupon.
|
||||
*/
|
||||
public function get_user_usage_count(int $coupon_id, int $user_id): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('coupon_usage');
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE coupon_id = %d AND user_id = %d",
|
||||
$coupon_id, $user_id
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a coupon usage record.
|
||||
*/
|
||||
public function log_usage(int $coupon_id, int $user_id, string $order_number, float $discount): void {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('coupon_usage');
|
||||
$wpdb->insert($table, [
|
||||
'coupon_id' => $coupon_id,
|
||||
'user_id' => $user_id,
|
||||
'order_number' => $order_number,
|
||||
'discount' => $discount,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%d', '%s', '%f', '%s']);
|
||||
}
|
||||
|
||||
private function normalize_row(array $row): array {
|
||||
$row['id'] = (int) $row['id'];
|
||||
$row['value'] = (float) $row['value'];
|
||||
$row['min_amount'] = (float) $row['min_amount'];
|
||||
$row['usage_limit'] = (int) $row['usage_limit'];
|
||||
$row['used_count'] = (int) $row['used_count'];
|
||||
$row['per_user_limit'] = (int) ($row['per_user_limit'] ?? 0);
|
||||
$row['max_discount'] = (float) ($row['max_discount'] ?? 0);
|
||||
if (!empty($row['product_scope']) && is_string($row['product_scope'])) {
|
||||
$row['product_scope'] = json_decode($row['product_scope'], true) ?: [];
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class CreatorRevenue {
|
||||
|
||||
private static ?CreatorRevenue $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('aksrt_order_paid', [$this, 'handle_order_paid'], 20, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether creator revenue settlement is enabled.
|
||||
*/
|
||||
private function is_enabled(): bool {
|
||||
if (!class_exists('AKSRT\\Payment\\Revenue') || !class_exists('\AKSRT\Settings\Settings')) {
|
||||
return false;
|
||||
}
|
||||
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
|
||||
return !empty($settings['enabled']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve share ratio (percentage 0-100) for a given product author.
|
||||
*/
|
||||
private function resolve_share_ratio(int $author_id): float {
|
||||
$settings = \AKSRT\Settings\Settings::get_payment_revenue_settings();
|
||||
$ratio = (float) ($settings['share_ratio'] ?? 0);
|
||||
|
||||
// VIP-tier author overrides, if configured (>0 wins).
|
||||
if ($author_id > 0 && function_exists('aksrt_get_user_vip_level')) {
|
||||
$level = (int) aksrt_get_user_vip_level($author_id);
|
||||
if ($level === 1 && (float) ($settings['ratio_vip1'] ?? 0) > 0) {
|
||||
$ratio = (float) $settings['ratio_vip1'];
|
||||
} elseif ($level >= 2 && (float) ($settings['ratio_vip2'] ?? 0) > 0) {
|
||||
$ratio = (float) $settings['ratio_vip2'];
|
||||
}
|
||||
}
|
||||
|
||||
return max(0.0, min(100.0, $ratio));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: has this order already been settled into revenue?
|
||||
*/
|
||||
private function already_settled(string $order_number): bool {
|
||||
if ($order_number === '' || !class_exists('\AKSRT\Core\DBMigrator')) {
|
||||
return false;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('revenue');
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$count = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE order_number = %s AND type = %s",
|
||||
$order_number,
|
||||
'order'
|
||||
));
|
||||
return $count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* aksrt_order_paid listener: credit creators their share.
|
||||
*
|
||||
* @param string $order_number Paid order number.
|
||||
* @param array $order Order row (status=paid).
|
||||
*/
|
||||
public function handle_order_paid(string $order_number, array $order): void {
|
||||
if (!$this->is_enabled() || $order_number === '') {
|
||||
return;
|
||||
}
|
||||
if ($this->already_settled($order_number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$buyer_id = (int) ($order['user_id'] ?? 0);
|
||||
|
||||
$this->settle_non_shop_cash_order($order_number, $order, $buyer_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Content / topic cash orders (no shop line items).
|
||||
*/
|
||||
private function settle_non_shop_cash_order(string $order_number, array $order, int $buyer_id): void {
|
||||
$meta = $this->decode_note($order);
|
||||
if (($meta['pay_modo'] ?? '') === 'points') {
|
||||
return;
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? ''));
|
||||
if ($payment_method === 'points' || $currency === 'points') {
|
||||
return;
|
||||
}
|
||||
|
||||
$base_amount = round((float) ($order['amount'] ?? $meta['payable'] ?? 0), 2);
|
||||
if ($base_amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
if ($product_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post = get_post($product_id);
|
||||
if (!($post instanceof \WP_Post)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$is_content = !empty($meta['content_pay'])
|
||||
&& $post->post_type === 'post';
|
||||
|
||||
// Resource without content_pay and not a known cash kind: skip.
|
||||
if (!$is_content) {
|
||||
return;
|
||||
}
|
||||
|
||||
$author_id = (int) $post->post_author;
|
||||
if ($author_id <= 0 || $author_id === $buyer_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ratio = $this->resolve_share_ratio($author_id);
|
||||
if ($ratio <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$amount = round($base_amount * $ratio / 100, 2);
|
||||
if ($amount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-check after shop-less path; shop path already returned.
|
||||
if ($this->already_settled($order_number)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Revenue::get_instance()->add($author_id, $amount, $order_number, 'order');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function decode_note(array $order): array {
|
||||
if (isset($order['meta_payload']) && is_array($order['meta_payload'])) {
|
||||
return $order['meta_payload'];
|
||||
}
|
||||
$note = (string) ($order['note'] ?? '');
|
||||
if ($note === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($note, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* 支付网关管理器(注册/回调路由/到账)。
|
||||
*/
|
||||
|
||||
class Gateway {
|
||||
|
||||
private static $instance = null;
|
||||
private $gateways = [];
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册网关
|
||||
*/
|
||||
public function register(string $id, GatewayInterface $gateway): void {
|
||||
$this->gateways[$id] = $gateway;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网关
|
||||
*/
|
||||
public function get_gateway(string $id): ?GatewayInterface {
|
||||
return $this->gateways[$id] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已注册网关
|
||||
*
|
||||
* @return GatewayInterface[]
|
||||
*/
|
||||
public function get_all(): array {
|
||||
return $this->gateways;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取可用网关列表(已配置的)
|
||||
*/
|
||||
public function get_available(): array {
|
||||
$available = [];
|
||||
foreach ($this->gateways as $id => $gateway) {
|
||||
if ($gateway->is_configured()) {
|
||||
$available[$id] = $gateway;
|
||||
}
|
||||
}
|
||||
return $available;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网关状态摘要(用于后台展示)
|
||||
*/
|
||||
public function get_status_summary(): array {
|
||||
$summary = [];
|
||||
foreach ($this->gateways as $id => $gateway) {
|
||||
$summary[] = [
|
||||
'id' => $id,
|
||||
'name' => $gateway->get_name(),
|
||||
'configured' => $gateway->is_configured(),
|
||||
'available' => $gateway->is_configured(),
|
||||
];
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定支付回调路由。必须在 functions.php 中初始化。
|
||||
*
|
||||
* 此前网关 create_payment() 把 notify_url/return_url 指向
|
||||
* /wp-json/aksrt/v1/payment/{gateway}/{notify|return|cancel},但这些路由
|
||||
* 从未注册,导致所有跳转型网关(支付宝/微信/PayPal/Stripe)无法完成支付。
|
||||
*/
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
$args = [
|
||||
'methods' => ['GET', 'POST'],
|
||||
'permission_callback' => '__return_true', // 真实性由各网关签名校验保证
|
||||
'args' => [
|
||||
'gateway' => ['sanitize_callback' => 'sanitize_key'],
|
||||
],
|
||||
];
|
||||
|
||||
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/notify', array_merge($args, [
|
||||
'callback' => [$this, 'handle_notify'],
|
||||
]));
|
||||
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/return', array_merge($args, [
|
||||
'callback' => [$this, 'handle_return'],
|
||||
]));
|
||||
register_rest_route('aksrt/v1', '/payment/(?P<gateway>[a-z0-9_]+)/cancel', array_merge($args, [
|
||||
'callback' => [$this, 'handle_cancel'],
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步通知(服务器到服务器)。验签 → 到账 → 输出网关期望的 ACK。
|
||||
*/
|
||||
public function handle_notify(\WP_REST_Request $request) {
|
||||
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
|
||||
$gateway = $this->get_gateway($gateway_id);
|
||||
if (!$gateway) {
|
||||
return new \WP_REST_Response('gateway not found', 404);
|
||||
}
|
||||
|
||||
$verify = $gateway->verify_callback($request);
|
||||
$verified = !empty($verify['success']);
|
||||
$paid = $verified && ($verify['status'] ?? '') === 'paid';
|
||||
$processed = $verified;
|
||||
|
||||
if ($paid) {
|
||||
// Only ACK paid notifications after the order was actually synchronized.
|
||||
$processed = $this->complete_order($gateway_id, $verify);
|
||||
}
|
||||
|
||||
// 网关各自要求的 ACK 格式
|
||||
if ($gateway_id === 'wechatpay' && method_exists($gateway, 'output_callback_response')) {
|
||||
$gateway->output_callback_response(['success' => $processed]); // exit
|
||||
}
|
||||
if ($gateway_id === 'alipay' || str_starts_with($gateway_id, 'epay')) {
|
||||
// 支付宝 / 易支付异步通知要求返回纯文本 success,否则会重复通知
|
||||
if (method_exists($gateway, 'output_callback_response')) {
|
||||
$gateway->output_callback_response(['success' => $processed]); // exit
|
||||
}
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
echo $processed ? 'success' : 'fail';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Stripe / PayPal webhook:200 表示已接收;处理失败返回 400 触发重试
|
||||
return new \WP_REST_Response(['received' => $processed], $processed ? 200 : 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户浏览器同步返回。尽力确认到账(兜底异步通知延迟),再跳回订单中心。
|
||||
*/
|
||||
public function handle_return(\WP_REST_Request $request) {
|
||||
$gateway_id = sanitize_key((string) $request->get_param('gateway'));
|
||||
// Providers echo back whatever number we submitted, which for a re-pay is an
|
||||
// attempt-suffixed out_trade_no. Map it to the local order for query + redirect.
|
||||
$provider_ref = sanitize_text_field((string) ($request->get_param('order')
|
||||
?: $request->get_param('out_trade_no')
|
||||
?: ''));
|
||||
$order_number = $this->local_order_number($provider_ref);
|
||||
$gateway = $this->get_gateway($gateway_id);
|
||||
$status = 'pending';
|
||||
|
||||
if ($gateway) {
|
||||
if ($gateway_id === 'alipay' || str_starts_with($gateway_id, 'epay')) {
|
||||
// 支付宝 / 易支付同步返回带签名参数,可直接验签作为到账兜底
|
||||
$verify = $gateway->verify_callback($request);
|
||||
if (!empty($verify['success'])) {
|
||||
if ($order_number === '') {
|
||||
$order_number = $this->local_order_number(
|
||||
sanitize_text_field((string) ($verify['order_number'] ?? ''))
|
||||
);
|
||||
}
|
||||
if (($verify['status'] ?? '') === 'paid' && $this->complete_order($gateway_id, $verify)) {
|
||||
$status = 'success';
|
||||
} elseif ($order_number !== '') {
|
||||
// Synchronous returns generally omit trade_status; query after signature verification.
|
||||
$query = $gateway->query_status($provider_ref !== '' ? $provider_ref : $order_number);
|
||||
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
|
||||
if (empty($query['order_number'])) {
|
||||
$query['order_number'] = $order_number;
|
||||
}
|
||||
if ($this->complete_order($gateway_id, $query)) {
|
||||
$status = 'success';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($gateway_id === 'paypal' && method_exists($gateway, 'capture_order')) {
|
||||
// PayPal v2:用户批准后在 return 时捕获(token = PayPal 订单号)
|
||||
$paypal_order_id = sanitize_text_field((string) ($request->get_param('token') ?: ''));
|
||||
if ($paypal_order_id !== '') {
|
||||
$cap = $gateway->capture_order($paypal_order_id);
|
||||
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
|
||||
$captured_ref = sanitize_text_field((string) ($cap['order_number'] ?? ''));
|
||||
$captured_order = $this->local_order_number($captured_ref);
|
||||
if ($captured_order === ''
|
||||
|| ($order_number !== '' && !hash_equals($order_number, $captured_order))) {
|
||||
$this->redirect_to_orders($order_number, 'pending');
|
||||
return null;
|
||||
}
|
||||
$order_number = $captured_order;
|
||||
if ($this->complete_order($gateway_id, $cap)) {
|
||||
$status = 'success';
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($order_number !== '') {
|
||||
// 其余网关:用返回时的 provider ref 查询该次尝试;local number 仅用于跳转。
|
||||
$q = $gateway->query_status($provider_ref !== '' ? $provider_ref : $order_number);
|
||||
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
|
||||
if (empty($q['order_number'])) {
|
||||
$q['order_number'] = $order_number;
|
||||
}
|
||||
if ($this->complete_order($gateway_id, $q)) {
|
||||
$status = 'success';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->redirect_to_orders($order_number, $status);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户取消支付,跳回订单中心。
|
||||
*/
|
||||
public function handle_cancel(\WP_REST_Request $request) {
|
||||
$provider_ref = sanitize_text_field((string) ($request->get_param('order')
|
||||
?: $request->get_param('out_trade_no')
|
||||
?: ''));
|
||||
$this->redirect_to_orders($this->local_order_number($provider_ref), 'cancelled');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a provider out_trade_no back to the local order number (identity when unknown).
|
||||
*/
|
||||
private function local_order_number(string $provider_ref): string {
|
||||
if ($provider_ref === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||
return $provider_ref;
|
||||
}
|
||||
$order = OrderRepository::get_instance()->resolve_by_out_trade_no($provider_ref);
|
||||
if (!$order) {
|
||||
return $provider_ref;
|
||||
}
|
||||
return sanitize_text_field((string) ($order['order_number'] ?? $provider_ref));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a pending external order against the configured provider.
|
||||
*
|
||||
* @param array<string,mixed>|null $order
|
||||
*/
|
||||
public function reconcile_order(string $order_number, ?array $order = null): ?array {
|
||||
if ($order_number === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||
return $order;
|
||||
}
|
||||
$repo = OrderRepository::get_instance();
|
||||
$order = is_array($order) ? $order : $repo->get($order_number);
|
||||
if (!$order || !in_array((string) ($order['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$gateway_id = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points', 'manual_review'], true)) {
|
||||
return $order;
|
||||
}
|
||||
$gateway = $this->get_gateway($gateway_id);
|
||||
if (!$gateway || !$gateway->is_configured()) {
|
||||
return $order;
|
||||
}
|
||||
|
||||
$throttle_key = 'aksrt_reconcile_' . hash('sha256', $order_number);
|
||||
if (get_transient($throttle_key)) {
|
||||
return $order;
|
||||
}
|
||||
set_transient($throttle_key, 1, 5);
|
||||
|
||||
$lock = PaymentAttemptLock::acquire('reconcile_' . $order_number);
|
||||
if ($lock === false) {
|
||||
return $repo->get($order_number) ?: $order;
|
||||
}
|
||||
try {
|
||||
$provider_ref = OrderRepository::provider_out_trade_no($order);
|
||||
$query = $gateway->query_status($provider_ref);
|
||||
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
|
||||
if (empty($query['order_number'])) {
|
||||
$query['order_number'] = $provider_ref;
|
||||
}
|
||||
$this->complete_order($gateway_id, $query);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log(sprintf('[AKSRT Payment] reconcile failed order=%s gateway=%s: %s', $order_number, $gateway_id, $e->getMessage()));
|
||||
}
|
||||
} finally {
|
||||
PaymentAttemptLock::release('reconcile_' . $order_number, $lock);
|
||||
}
|
||||
|
||||
return $repo->get($order_number) ?: $order;
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等、带金额校验的订单完成。仅由验签通过的回调调用。
|
||||
*
|
||||
* @param string $gateway_id 网关 ID
|
||||
* @param array $verify 网关 verify_callback / query 结果
|
||||
* @return bool
|
||||
*/
|
||||
public function complete_order(string $gateway_id, array $verify): bool {
|
||||
$provider_out_trade_no = sanitize_text_field((string) ($verify['order_number'] ?? ''));
|
||||
if ($provider_out_trade_no === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$repo = OrderRepository::get_instance();
|
||||
// Callbacks may carry attempt-suffixed out_trade_no (…P2); map back to local order.
|
||||
$order = $repo->resolve_by_out_trade_no($provider_out_trade_no);
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
if ($order_number === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored_gateway = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($stored_gateway === '' || !hash_equals($stored_gateway, sanitize_key($gateway_id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Provider-confirmed external payments must include an exact amount and transaction ID.
|
||||
$expected_currency = strtoupper((string) ($order['currency'] ?? ''));
|
||||
$paid_currency = strtoupper(sanitize_key((string) ($verify['currency'] ?? '')));
|
||||
$expected = (float) ($order['amount'] ?? 0);
|
||||
$paid_amount = (float) ($verify['amount'] ?? 0);
|
||||
$transaction_id = sanitize_text_field((string) ($verify['transaction_id'] ?? ''));
|
||||
if ($expected <= 0
|
||||
|| $paid_amount <= 0
|
||||
|| $transaction_id === ''
|
||||
|| $expected_currency === ''
|
||||
|| $paid_currency === ''
|
||||
|| !hash_equals($expected_currency, $paid_currency)
|
||||
|| abs($expected - $paid_amount) > 0.01) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log(sprintf(
|
||||
'[AKSRT Payment] invalid completion order=%s out_trade_no=%s expected=%.2f %s paid=%.2f %s transaction=%s gateway=%s',
|
||||
$order_number,
|
||||
$provider_out_trade_no,
|
||||
$expected,
|
||||
$expected_currency,
|
||||
$paid_amount,
|
||||
$paid_currency,
|
||||
$transaction_id !== '' ? 'present' : 'missing',
|
||||
$gateway_id
|
||||
));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$current = (string) ($order['status'] ?? '');
|
||||
|
||||
// 幂等:仅同一交易号的重复回调可视为成功。不同交易号意味着
|
||||
// 用户可能重复付款,不能静默 ACK 为已正确同步。
|
||||
if (in_array($current, ['paid', 'delivered', 'completed'], true)) {
|
||||
$stored_transaction = sanitize_text_field((string) ($order['payment_transaction_id'] ?? ''));
|
||||
return $stored_transaction !== '' && hash_equals($stored_transaction, $transaction_id);
|
||||
}
|
||||
// 只允许从未支付态完成
|
||||
if (!in_array($current, ['created', 'pending'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $repo->update_status($order_number, Order::STATUS_PAID, [
|
||||
'payment_method' => $gateway_id,
|
||||
'payment_transaction_id' => $transaction_id,
|
||||
'note' => __('支付网关回调确认到账。', 'aksrt'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function redirect_to_orders(string $order_number, string $status): void {
|
||||
$url = add_query_arg(array_filter([
|
||||
'payment' => sanitize_key($status),
|
||||
'order' => $order_number,
|
||||
]), $this->orders_page_url());
|
||||
|
||||
nocache_headers();
|
||||
wp_safe_redirect($url);
|
||||
exit;
|
||||
}
|
||||
|
||||
private function orders_page_url(): string {
|
||||
$pages = get_posts([
|
||||
'post_type' => 'page',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 1,
|
||||
'meta_key' => '_wp_page_template',
|
||||
'meta_value' => 'templates/pages/page-orders.php',
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
]);
|
||||
|
||||
if (!empty($pages)) {
|
||||
return (string) get_permalink((int) $pages[0]);
|
||||
}
|
||||
|
||||
return home_url('/orders/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* 支付网关统一接口
|
||||
*/
|
||||
interface GatewayInterface {
|
||||
/**
|
||||
* 创建支付单
|
||||
*
|
||||
* @param array $order 订单数据
|
||||
* @return array{success: bool, payment_url?: string, payment_data?: array, message?: string}
|
||||
*/
|
||||
public function create_payment(array $order): array;
|
||||
|
||||
/**
|
||||
* 验证回调
|
||||
*
|
||||
* @param \WP_REST_Request $request 回调请求
|
||||
* @return array{success: bool, order_number?: string, amount?: float, transaction_id?: string, message?: string}
|
||||
*/
|
||||
public function verify_callback(\WP_REST_Request $request): array;
|
||||
|
||||
/**
|
||||
* 查询支付状态
|
||||
*
|
||||
* @param string $order_number 订单号
|
||||
* @return array{success: bool, status?: string, transaction_id?: string, message?: string}
|
||||
*/
|
||||
public function query_status(string $order_number): array;
|
||||
|
||||
/**
|
||||
* 退款
|
||||
*
|
||||
* @param string $order_number 订单号
|
||||
* @param float $amount 退款金额
|
||||
* @param string $request_id 幂等退款请求号
|
||||
* @return array{success: bool, status?: string, refund_id?: string, request_id?: string, message?: string}
|
||||
*/
|
||||
public function refund(string $order_number, float $amount, string $request_id = ''): array;
|
||||
|
||||
/**
|
||||
* 获取网关显示名称
|
||||
*/
|
||||
public function get_name(): string;
|
||||
|
||||
/**
|
||||
* 获取网关标识
|
||||
*/
|
||||
public function get_id(): string;
|
||||
|
||||
/**
|
||||
* 网关是否已配置
|
||||
*/
|
||||
public function is_configured(): bool;
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Order {
|
||||
|
||||
public const STATUS_CREATED = \AKSRT\Core\DomainModel::ORDER_STATUS_CREATED;
|
||||
public const STATUS_PENDING = \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING;
|
||||
public const STATUS_PAID = \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||||
public const STATUS_DELIVERED = \AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED;
|
||||
public const STATUS_COMPLETED = \AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED;
|
||||
public const STATUS_CANCELLED = \AKSRT\Core\DomainModel::ORDER_STATUS_CANCELLED;
|
||||
public const STATUS_EXPIRED = \AKSRT\Core\DomainModel::ORDER_STATUS_EXPIRED;
|
||||
public const STATUS_REFUNDING = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING;
|
||||
public const STATUS_REFUNDED = \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
|
||||
public const STATUS_PENDING_REVIEW = \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public static function get_valid_statuses(): array {
|
||||
return \AKSRT\Core\DomainModel::order_status_labels();
|
||||
}
|
||||
|
||||
public static function can_transition(string $from, string $to): bool {
|
||||
return \AKSRT\Core\DomainModel::can_transition_order_status($from, $to);
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('wp_ajax_aksrt_order_list', [$this, 'ajax_list']);
|
||||
add_action('wp_ajax_aksrt_order_update_status', [$this, 'ajax_update_status']);
|
||||
add_action('wp_ajax_aksrt_order_stats', [$this, 'ajax_stats']);
|
||||
}
|
||||
|
||||
public function create(int $user_id, string $product_type, int $product_id, float $amount, string $currency = 'CNY'): string {
|
||||
// Guest content orders may use user_id=0 (Zibll pay_no_logged_in).
|
||||
if ($user_id < 0 || $product_id < 0 || $amount < 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->repo()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => sanitize_key($product_type),
|
||||
'product_id' => $product_id,
|
||||
'amount' => $amount,
|
||||
'currency' => sanitize_key($currency),
|
||||
'status' => self::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
|
||||
public function get(string $order_number): ?array {
|
||||
return $this->repo()->get($order_number);
|
||||
}
|
||||
|
||||
public function get_user_orders(int $user_id, array $args = []): array {
|
||||
return $this->repo()->get_user_orders($user_id, $args);
|
||||
}
|
||||
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
return $this->repo()->update_status($order_number, $new_status, $extra);
|
||||
}
|
||||
|
||||
public function get_stats(int $user_id = 0): array {
|
||||
return $this->repo()->get_stats($user_id);
|
||||
}
|
||||
|
||||
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
|
||||
return $this->repo()->has_paid_access($user_id, $product_type, $product_id);
|
||||
}
|
||||
|
||||
public function get_valid_vip_order(int $user_id): ?array {
|
||||
return $this->repo()->get_valid_vip_order($user_id);
|
||||
}
|
||||
|
||||
public function ajax_list(): void {
|
||||
check_ajax_referer('aksrt_public_nonce', 'nonce');
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
|
||||
}
|
||||
|
||||
$status = sanitize_key((string) ($_GET['status'] ?? ''));
|
||||
$search = sanitize_text_field((string) ($_GET['search'] ?? ''));
|
||||
$limit = max(1, min(100, (int) ($_GET['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($_GET['offset'] ?? 0));
|
||||
|
||||
$orders = $this->repo()->get_orders_admin([
|
||||
'status' => $status,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
], $search);
|
||||
|
||||
wp_send_json_success([
|
||||
'orders' => $orders,
|
||||
'stats' => $this->get_stats(),
|
||||
'total' => $this->repo()->get_orders_count(['status' => $status], $search),
|
||||
]);
|
||||
}
|
||||
|
||||
public function ajax_update_status(): void {
|
||||
check_ajax_referer('aksrt_public_nonce', 'nonce');
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
|
||||
}
|
||||
|
||||
$order_number = sanitize_text_field((string) ($_POST['order_number'] ?? ''));
|
||||
$new_status = sanitize_key((string) ($_POST['status'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($_POST['note'] ?? ''));
|
||||
|
||||
if ($order_number === '' || $new_status === '') {
|
||||
wp_send_json_error(['message' => __('参数无效', 'aksrt')]);
|
||||
}
|
||||
|
||||
if ($this->update_status($order_number, $new_status, ['note' => $note, 'force' => true])) {
|
||||
wp_send_json_success(['message' => __('订单状态已更新', 'aksrt')]);
|
||||
}
|
||||
|
||||
wp_send_json_error(['message' => __('更新失败', 'aksrt')]);
|
||||
}
|
||||
|
||||
public function ajax_stats(): void {
|
||||
check_ajax_referer('aksrt_public_nonce', 'nonce');
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
|
||||
}
|
||||
|
||||
wp_send_json_success($this->get_stats());
|
||||
}
|
||||
|
||||
private function repo(): OrderRepository {
|
||||
return OrderRepository::get_instance();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
/**
|
||||
* Public REST for order-center actions (pay / cancel / detail).
|
||||
*
|
||||
* Registered outside the admin SPA bootstrap so front-end order center
|
||||
* always has routes even if an admin controller fails to load.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class OrderCenterRest {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes'], 20);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
$ns = 'aksrt/v1';
|
||||
// Allow common order_number charset (AKS… + optional separators).
|
||||
$on = '(?P<order_number>[A-Za-z0-9_-]+)';
|
||||
|
||||
register_rest_route($ns, '/shop/frontend/orders/' . $on . '/pay', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'pay'],
|
||||
'permission_callback' => [$this, 'require_logged_in'],
|
||||
]);
|
||||
|
||||
register_rest_route($ns, '/shop/frontend/orders/' . $on . '/cancel', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'cancel'],
|
||||
'permission_callback' => [$this, 'require_logged_in'],
|
||||
]);
|
||||
|
||||
register_rest_route($ns, '/shop/frontend/orders/' . $on, [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_order'],
|
||||
'permission_callback' => [$this, 'require_logged_in'],
|
||||
],
|
||||
[
|
||||
// order-center.js historically POSTed for refresh — accept both.
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'get_order'],
|
||||
'permission_callback' => [$this, 'require_logged_in'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public function require_logged_in() {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error(
|
||||
'aksrt_rest_forbidden',
|
||||
__('请先登录', 'aksrt'),
|
||||
['status' => 401]
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function pay(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$params = $request->get_json_params();
|
||||
if (!is_array($params)) {
|
||||
$params = [];
|
||||
}
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? ''));
|
||||
|
||||
if (!class_exists(UserOrders::class)) {
|
||||
return $this->error(__('订单服务不可用', 'aksrt'), 500);
|
||||
}
|
||||
$result = UserOrders::get_instance()->pay_order(get_current_user_id(), $order_number, $payment_method);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error((string) ($result['message'] ?? __('支付失败', 'aksrt')), 400);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $result['data'] ?? []]);
|
||||
}
|
||||
|
||||
public function cancel(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
|
||||
if (!class_exists(UserOrders::class)) {
|
||||
return $this->error(__('订单服务不可用', 'aksrt'), 500);
|
||||
}
|
||||
$result = UserOrders::get_instance()->cancel_order(get_current_user_id(), $order_number);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error((string) ($result['message'] ?? __('取消订单失败', 'aksrt')), 400);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $result['data'] ?? []]);
|
||||
}
|
||||
|
||||
public function get_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$order = null;
|
||||
|
||||
if (!class_exists(UserOrders::class)) {
|
||||
return $this->error(__('订单服务不可用', 'aksrt'), 500);
|
||||
}
|
||||
$order = UserOrders::get_instance()->get_order_detail_view(get_current_user_id(), $order_number);
|
||||
|
||||
if (!$order) {
|
||||
return $this->error(__('订单不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $order]);
|
||||
}
|
||||
|
||||
private function success(array $data = [], int $status = 200): \WP_REST_Response {
|
||||
return new \WP_REST_Response(array_merge(['success' => true], $data), $status);
|
||||
}
|
||||
|
||||
private function error(string $message, int $status = 400): \WP_REST_Response {
|
||||
return new \WP_REST_Response([
|
||||
'success' => false,
|
||||
'message' => $message,
|
||||
], $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class OrderRepository {
|
||||
use OrderRepositoryCore;
|
||||
use OrderRepositoryAdmin;
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait OrderRepositoryAdmin {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build a stable admin-facing order context payload for workbench pages.
|
||||
*/
|
||||
public function get_admin_order_context(string $order_number): array {
|
||||
$order_number = sanitize_text_field($order_number);
|
||||
$contexts = $this->get_admin_order_context_map([$order_number]);
|
||||
|
||||
return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build admin-facing order context payloads in bulk to avoid per-row order queries.
|
||||
*
|
||||
* @param array<int, string> $order_numbers
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function get_admin_order_context_map(array $order_numbers): array {
|
||||
global $wpdb;
|
||||
|
||||
$order_numbers = array_values(array_unique(array_filter(array_map(
|
||||
static function ($order_number): string {
|
||||
return sanitize_text_field((string) $order_number);
|
||||
},
|
||||
$order_numbers
|
||||
))));
|
||||
|
||||
if (empty($order_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contexts = [];
|
||||
foreach ($order_numbers as $order_number) {
|
||||
$contexts[$order_number] = $this->get_empty_admin_order_context($order_number);
|
||||
}
|
||||
|
||||
$placeholders = implode(', ', array_fill(0, count($order_numbers), '%s'));
|
||||
$sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})";
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A);
|
||||
|
||||
if (empty($rows)) {
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
$orders = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$order = $this->normalize_row($row);
|
||||
$orders[(string) ($order['order_number'] ?? '')] = $order;
|
||||
}
|
||||
|
||||
$resolved_order_numbers = array_keys($orders);
|
||||
|
||||
foreach ($orders as $order_number => $order) {
|
||||
$contexts[$order_number] = $this->build_admin_order_context($order, []);
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 更新订单状态(带状态机验证)
|
||||
*/
|
||||
/**
|
||||
* Merge operator admin note into order note meta without destroying JSON payload.
|
||||
*/
|
||||
public function update_admin_note(string $order_number, string $admin_note): bool {
|
||||
global $wpdb;
|
||||
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||||
$decoded = $raw !== '' ? json_decode($raw, true) : null;
|
||||
$admin_note = sanitize_textarea_field($admin_note);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
$decoded['admin_note'] = $admin_note;
|
||||
$new_note = wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} elseif ($raw !== '') {
|
||||
$new_note = wp_json_encode([
|
||||
'customer_note' => $raw,
|
||||
'admin_note' => $admin_note,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
$new_note = wp_json_encode([
|
||||
'admin_note' => $admin_note,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table(),
|
||||
['note' => $new_note],
|
||||
['order_number' => $order_number],
|
||||
['%s'],
|
||||
['%s']
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配)
|
||||
*/
|
||||
|
||||
public function get_orders_admin(array $args = [], string $search = ''): array {
|
||||
global $wpdb;
|
||||
|
||||
[$where, $params] = $this->build_admin_order_filters($args, $search);
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
return array_map([$this, 'normalize_row'], (array) $rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 管理后台订单计数 — 与 get_orders_admin 共用过滤条件
|
||||
*/
|
||||
public function get_orders_count(array $args = [], string $search = ''): int {
|
||||
global $wpdb;
|
||||
|
||||
[$where, $params] = $this->build_admin_order_filters($args, $search);
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
|
||||
if (!empty($params)) {
|
||||
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
|
||||
}
|
||||
return (int) $wpdb->get_var($sql);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Aggregate paid content (resource) sales by product_id (post ID).
|
||||
*
|
||||
* @param array{
|
||||
* product_type?: string,
|
||||
* product_ids?: int[],
|
||||
* status_in?: string[],
|
||||
* limit?: int,
|
||||
* offset?: int,
|
||||
* orderby?: string,
|
||||
* order?: string
|
||||
* } $args
|
||||
* @return array{items: list<array{product_id:int,product_type:string,sales_count:int,avg_amount:float,revenue:float}>, total: int}
|
||||
*/
|
||||
public function get_content_sales_summary(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE));
|
||||
if ($product_type === '') {
|
||||
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE;
|
||||
}
|
||||
|
||||
$status_in = $args['status_in'] ?? \AKSRT\Core\DomainModel::paid_order_statuses();
|
||||
if (!is_array($status_in) || empty($status_in)) {
|
||||
$status_in = \AKSRT\Core\DomainModel::paid_order_statuses();
|
||||
}
|
||||
$status_in = array_values(array_filter(array_map('sanitize_key', $status_in)));
|
||||
if (empty($status_in)) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
|
||||
$where = ['product_type = %s', 'product_id > 0'];
|
||||
$params = [$product_type];
|
||||
|
||||
$status_placeholders = implode(', ', array_fill(0, count($status_in), '%s'));
|
||||
$where[] = "status IN ({$status_placeholders})";
|
||||
foreach ($status_in as $st) {
|
||||
$params[] = $st;
|
||||
}
|
||||
|
||||
$product_ids = $args['product_ids'] ?? [];
|
||||
if (is_array($product_ids) && !empty($product_ids)) {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
|
||||
return $id > 0;
|
||||
})));
|
||||
if (!empty($ids)) {
|
||||
$id_ph = implode(', ', array_fill(0, count($ids), '%d'));
|
||||
$where[] = "product_id IN ({$id_ph})";
|
||||
foreach ($ids as $id) {
|
||||
$params[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
$table = $this->table();
|
||||
|
||||
$total_sql = "SELECT COUNT(DISTINCT product_id) FROM {$table} {$where_sql}";
|
||||
$total = (int) $wpdb->get_var($wpdb->prepare($total_sql, $params));
|
||||
|
||||
$orderby_map = [
|
||||
'sales_count' => 'sales_count',
|
||||
'revenue' => 'revenue',
|
||||
'avg_amount' => 'avg_amount',
|
||||
'product_id' => 'product_id',
|
||||
];
|
||||
$orderby_key = sanitize_key((string) ($args['orderby'] ?? 'sales_count'));
|
||||
$orderby_col = $orderby_map[$orderby_key] ?? 'sales_count';
|
||||
$order_dir = strtoupper((string) ($args['order'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$list_sql = "SELECT product_id,
|
||||
product_type,
|
||||
COUNT(*) AS sales_count,
|
||||
AVG(amount) AS avg_amount,
|
||||
SUM(amount) AS revenue
|
||||
FROM {$table}
|
||||
{$where_sql}
|
||||
GROUP BY product_id, product_type
|
||||
ORDER BY {$orderby_col} {$order_dir}
|
||||
LIMIT %d OFFSET %d";
|
||||
$list_params = $params;
|
||||
$list_params[] = $limit;
|
||||
$list_params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($list_sql, $list_params), ARRAY_A);
|
||||
$items = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$items[] = [
|
||||
'product_id' => (int) ($row['product_id'] ?? 0),
|
||||
'product_type' => (string) ($row['product_type'] ?? $product_type),
|
||||
'sales_count' => (int) ($row['sales_count'] ?? 0),
|
||||
'avg_amount' => round((float) ($row['avg_amount'] ?? 0), 2),
|
||||
'revenue' => round((float) ($row['revenue'] ?? 0), 2),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Paid order totals for a set of product IDs (resource content).
|
||||
*
|
||||
* @param int[] $product_ids
|
||||
* @return array<int, array{sales_count:int,avg_amount:float,revenue:float}>
|
||||
*/
|
||||
public function get_content_sales_map(array $product_ids, string $product_type = ''): array {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $product_ids), static function ($id) {
|
||||
return $id > 0;
|
||||
})));
|
||||
if (empty($ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($product_type === '') {
|
||||
$product_type = \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE;
|
||||
}
|
||||
|
||||
$summary = $this->get_content_sales_summary([
|
||||
'product_type' => $product_type,
|
||||
'product_ids' => $ids,
|
||||
'limit' => max(count($ids), 1),
|
||||
'offset' => 0,
|
||||
'orderby' => 'product_id',
|
||||
'order' => 'ASC',
|
||||
]);
|
||||
|
||||
$map = [];
|
||||
foreach ($summary['items'] as $item) {
|
||||
$pid = (int) ($item['product_id'] ?? 0);
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$map[$pid] = [
|
||||
'sales_count' => (int) ($item['sales_count'] ?? 0),
|
||||
'avg_amount' => (float) ($item['avg_amount'] ?? 0),
|
||||
'revenue' => (float) ($item['revenue'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Shared WHERE builder for admin order list/count.
|
||||
*
|
||||
* @return array{0: string[], 1: array<int, mixed>}
|
||||
*/
|
||||
private function build_admin_order_filters(array $args, string $search = ''): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$product_id = (int) ($args['product_id'] ?? 0);
|
||||
if ($product_id > 0) {
|
||||
$where[] = 'product_id = %d';
|
||||
$params[] = $product_id;
|
||||
}
|
||||
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key((string) ($args['payment_method'] ?? ''));
|
||||
if ($payment_method !== '') {
|
||||
$where[] = 'payment_method = %s';
|
||||
$params[] = $payment_method;
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
// Numeric search may mean user_id or product_id; also match order number / type.
|
||||
if (ctype_digit($search)) {
|
||||
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR user_id = %d OR product_id = %d)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = (int) $search;
|
||||
$params[] = (int) $search;
|
||||
} else {
|
||||
$where[] = '(order_number LIKE %s OR product_type LIKE %s OR payment_method LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
}
|
||||
|
||||
return [$where, $params];
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function get_empty_admin_order_context(string $order_number): array {
|
||||
return [
|
||||
'exists' => false,
|
||||
'summary' => [
|
||||
'order_number' => $order_number,
|
||||
'status' => '',
|
||||
'status_label' => '',
|
||||
'amount' => 0.0,
|
||||
'currency_symbol' => '¥',
|
||||
'created_at' => '',
|
||||
'paid_at' => '',
|
||||
],
|
||||
'buyer' => [],
|
||||
'address' => [],
|
||||
'payment_method' => [],
|
||||
'shipping_method' => [],
|
||||
'shipping' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'after_sale' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'coupon' => [],
|
||||
'pricing' => [
|
||||
'subtotal' => 0.0,
|
||||
'shipping_fee' => 0.0,
|
||||
'discount' => 0.0,
|
||||
'amount' => 0.0,
|
||||
],
|
||||
'items' => [],
|
||||
'customer_note' => '',
|
||||
'raw_order' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $related
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_admin_order_context(array $order, array $related = []): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$user = get_userdata((int) ($order['user_id'] ?? 0));
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
$address = is_array($meta['address'] ?? null) ? $meta['address'] : [];
|
||||
$payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : [];
|
||||
$shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : [];
|
||||
$coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : [];
|
||||
$items = is_array($meta['items'] ?? null) ? $meta['items'] : [];
|
||||
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
|
||||
$discount = (float) ($meta['discount'] ?? 0);
|
||||
$amount = (float) ($order['amount'] ?? 0);
|
||||
$subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee));
|
||||
|
||||
$address['full_text'] = $this->build_address_text($address);
|
||||
|
||||
return [
|
||||
'exists' => true,
|
||||
'summary' => [
|
||||
'order_number' => (string) ($order['order_number'] ?? ''),
|
||||
'status' => $status,
|
||||
'status_label' => class_exists('AKSRT\\Payment\\Order') ? (Order::get_valid_statuses()[$status] ?? $status) : $status,
|
||||
'amount' => $amount,
|
||||
'currency_symbol' => '¥',
|
||||
'created_at' => (string) ($order['created_at'] ?? ''),
|
||||
'paid_at' => (string) ($order['paid_at'] ?? ''),
|
||||
'product_type' => (string) ($order['product_type'] ?? ''),
|
||||
'product_id' => (int) ($order['product_id'] ?? 0),
|
||||
],
|
||||
'buyer' => [
|
||||
'id' => (int) ($order['user_id'] ?? 0),
|
||||
'display_name' => $user ? (string) $user->display_name : '',
|
||||
'user_login' => $user ? (string) $user->user_login : '',
|
||||
'user_email' => $user ? (string) $user->user_email : '',
|
||||
],
|
||||
'address' => $address,
|
||||
'payment_method' => [
|
||||
'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''),
|
||||
'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''),
|
||||
],
|
||||
'shipping_method' => [
|
||||
'value' => (string) ($shipping_method['value'] ?? ''),
|
||||
'label' => (string) ($shipping_method['label'] ?? ''),
|
||||
'fee' => $shipping_fee,
|
||||
],
|
||||
'shipping' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'after_sale' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'coupon' => $coupon,
|
||||
'pricing' => [
|
||||
'subtotal' => $subtotal,
|
||||
'shipping_fee' => $shipping_fee,
|
||||
'discount' => $discount,
|
||||
'amount' => $amount,
|
||||
],
|
||||
'items' => $items,
|
||||
'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''),
|
||||
'raw_order' => $order,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
*/
|
||||
private function calculate_order_subtotal(array $items, float $fallback): float {
|
||||
if (empty($items)) {
|
||||
return round($fallback, 2);
|
||||
}
|
||||
|
||||
$subtotal = 0.0;
|
||||
foreach ($items as $item) {
|
||||
if (isset($item['line_total'])) {
|
||||
$subtotal += (float) $item['line_total'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$subtotal += (float) ($item['price'] ?? 0) * max(1, (int) ($item['quantity'] ?? 1));
|
||||
}
|
||||
|
||||
if ($subtotal <= 0) {
|
||||
return round($fallback, 2);
|
||||
}
|
||||
|
||||
return round($subtotal, 2);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function build_address_text(array $address): string {
|
||||
$segments = [];
|
||||
|
||||
foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) {
|
||||
$value = trim((string) ($address[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
$segments[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($address['full_text']) && is_string($address['full_text'])) {
|
||||
return (string) $address['full_text'];
|
||||
}
|
||||
|
||||
return implode(' / ', $segments);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait OrderRepositoryCore {
|
||||
|
||||
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('order');
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* CRUD
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 创建订单(写入表)
|
||||
*/
|
||||
public function create(array $data): string {
|
||||
global $wpdb;
|
||||
|
||||
$order_number = $data['order_number'] ?? $this->generate_order_number();
|
||||
|
||||
// Preserve JSON pay meta (VIP/content/coupon). Only strip tags for plain text notes.
|
||||
$note_raw = (string) ($data['note'] ?? '');
|
||||
$note_trim = trim($note_raw);
|
||||
if ($note_trim !== '' && ($note_trim[0] === '{' || $note_trim[0] === '[')) {
|
||||
$decoded = json_decode($note_trim, true);
|
||||
$note = is_array($decoded)
|
||||
? (string) wp_json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
: sanitize_textarea_field($note_raw);
|
||||
} else {
|
||||
$note = sanitize_textarea_field($note_raw);
|
||||
}
|
||||
|
||||
$row = [
|
||||
'order_number' => $order_number,
|
||||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||||
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
|
||||
'product_id' => (int) ($data['product_id'] ?? 0),
|
||||
'amount' => (float) ($data['amount'] ?? 0),
|
||||
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
|
||||
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
|
||||
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
|
||||
'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')),
|
||||
'created_at' => $data['created_at'] ?? current_time('mysql'),
|
||||
'paid_at' => $data['paid_at'] ?? null,
|
||||
'note' => $note,
|
||||
];
|
||||
$formats = ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s'];
|
||||
|
||||
// Honour configured expiry when callers pass expires_at (shop checkout etc.).
|
||||
if (array_key_exists('expires_at', $data) && $data['expires_at'] !== null && $data['expires_at'] !== '') {
|
||||
$row['expires_at'] = (string) $data['expires_at'];
|
||||
$formats[] = '%s';
|
||||
}
|
||||
|
||||
$wpdb->insert($this->table(), $row, $formats);
|
||||
|
||||
if ($wpdb->insert_id) {
|
||||
return $order_number;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取单个订单
|
||||
*/
|
||||
public function get(string $order_number): ?array {
|
||||
global $wpdb;
|
||||
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE order_number = %s",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
|
||||
if ($row) {
|
||||
return $this->normalize_row($row);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取用户订单列表
|
||||
*/
|
||||
public function get_user_orders(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return array_map([$this, 'normalize_row'], (array) $rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取单个用户订单。
|
||||
*/
|
||||
public function get_user_order(int $user_id, string $order_number): ?array {
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($user_id > 0 && (int) ($order['user_id'] ?? 0) !== $user_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取用户订单总数。
|
||||
*/
|
||||
public function count_user_orders(int $user_id, array $args = []): int {
|
||||
global $wpdb;
|
||||
|
||||
if ($user_id <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$where = ['user_id = %d'];
|
||||
$params = [$user_id];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
$where[] = 'product_type = %s';
|
||||
$params[] = $product_type;
|
||||
}
|
||||
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
$sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
|
||||
return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
global $wpdb;
|
||||
|
||||
$valid = \AKSRT\Core\DomainModel::order_status_labels();
|
||||
if (!isset($valid[$new_status])) return false;
|
||||
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) return false;
|
||||
|
||||
$current_status = $order['status'] ?? 'created';
|
||||
|
||||
if (empty($extra['force']) && !\AKSRT\Core\DomainModel::can_transition_order_status($current_status, $new_status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update_data = ['status' => $new_status];
|
||||
$format = ['%s'];
|
||||
|
||||
// Stamp lifecycle timestamps. Some statuses share a column (e.g. expired → cancelled_at;
|
||||
// pending_review still counts as a completion-like milestone for the timeline).
|
||||
$time_fields = [
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_PAID => 'paid_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED => 'delivered_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED => 'completed_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW => 'completed_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_CANCELLED => 'cancelled_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_EXPIRED => 'cancelled_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED => 'refunded_at',
|
||||
];
|
||||
|
||||
if (isset($time_fields[$new_status])) {
|
||||
$field = $time_fields[$new_status];
|
||||
// Do not overwrite an existing timestamp (idempotent callbacks / force updates).
|
||||
$existing = is_string($order[$field] ?? null) ? trim((string) $order[$field]) : '';
|
||||
if ($existing === '' || str_starts_with($existing, '0000-00-00')) {
|
||||
$update_data[$field] = current_time('mysql');
|
||||
$format[] = '%s';
|
||||
}
|
||||
}
|
||||
|
||||
// When jumping past paid (force admin transitions), backfill paid_at so the
|
||||
// progress chain is never stuck on "订单创建" alone.
|
||||
if (
|
||||
in_array($new_status, [
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
|
||||
], true)
|
||||
) {
|
||||
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
|
||||
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
|
||||
if (!isset($update_data['paid_at'])) {
|
||||
$update_data['paid_at'] = current_time('mysql');
|
||||
$format[] = '%s';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($extra['payment_method'])) {
|
||||
$update_data['payment_method'] = sanitize_key($extra['payment_method']);
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
if (!empty($extra['payment_transaction_id'])) {
|
||||
$update_data['payment_transaction_id'] = sanitize_text_field($extra['payment_transaction_id']);
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
if (!empty($extra['note'])) {
|
||||
// Preserve JSON pay meta (coupon/topic_pay/content_pay/…); stash human text as status_note.
|
||||
$status_note = sanitize_textarea_field((string) $extra['note']);
|
||||
$raw_note = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||||
$decoded_note = $raw_note !== '' ? json_decode($raw_note, true) : null;
|
||||
if (is_array($decoded_note)) {
|
||||
$decoded_note['status_note'] = $status_note;
|
||||
$merged = wp_json_encode($decoded_note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$update_data['note'] = is_string($merged) ? $merged : $status_note;
|
||||
} else {
|
||||
$update_data['note'] = $status_note;
|
||||
}
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
// Compare-and-swap the status so concurrent callbacks cannot both win the
|
||||
// first paid transition and trigger fulfillment twice.
|
||||
$result = $wpdb->update(
|
||||
$this->table(),
|
||||
$update_data,
|
||||
['order_number' => $order_number, 'status' => $current_status],
|
||||
$format,
|
||||
['%s', '%s']
|
||||
);
|
||||
|
||||
if ($result === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($result === 0) {
|
||||
if (!empty($extra['require_transition'])) {
|
||||
return false;
|
||||
}
|
||||
$latest = $this->get($order_number);
|
||||
return is_array($latest) && ($latest['status'] ?? '') === $new_status;
|
||||
}
|
||||
|
||||
if ($result > 0) {
|
||||
// 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。
|
||||
// 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID && $current_status !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
|
||||
$paid_order = array_merge($order, [
|
||||
'status' => $new_status,
|
||||
'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''),
|
||||
'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''),
|
||||
]);
|
||||
if (isset($update_data['note'])) {
|
||||
// Keep pre-update JSON payload for listeners (coupon burn etc.);
|
||||
// only attach status_note if callers mutated note.
|
||||
$pre_meta = [];
|
||||
$pre_raw = isset($order['note']) && is_string($order['note']) ? $order['note'] : '';
|
||||
if ($pre_raw !== '') {
|
||||
$pre_decoded = json_decode($pre_raw, true);
|
||||
if (is_array($pre_decoded)) {
|
||||
$pre_meta = $pre_decoded;
|
||||
}
|
||||
}
|
||||
if ($pre_meta !== []) {
|
||||
$paid_order['meta_payload'] = $pre_meta;
|
||||
// Prefer merged DB note (JSON + status_note) for downstream reads.
|
||||
$paid_order['note'] = $update_data['note'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单到账。
|
||||
*
|
||||
* @param string $order_number 订单号
|
||||
* @param array $paid_order 订单数据(status=paid)
|
||||
*/
|
||||
do_action('aksrt_order_paid', $order_number, $paid_order);
|
||||
}
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Delete closed (cancelled/expired) orders older than $days days.
|
||||
* Mirrors zibll ZibPay::clear_order(14).
|
||||
*
|
||||
* @return int Number of rows deleted.
|
||||
*/
|
||||
public function clear_closed_orders(int $days = 14): int {
|
||||
global $wpdb;
|
||||
|
||||
$days = max(1, min(3650, $days));
|
||||
$table = $this->table();
|
||||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
||||
// Prefer cancelled_at when set; fall back to created_at for older rows.
|
||||
$sql = "DELETE FROM {$table}
|
||||
WHERE status IN ('cancelled', 'expired')
|
||||
AND COALESCE(NULLIF(cancelled_at, ''), created_at) < %s";
|
||||
$deleted = $wpdb->query($wpdb->prepare($sql, $cutoff));
|
||||
return $deleted === false ? 0 : (int) $deleted;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Hard-delete a single order by order_number (admin hygiene).
|
||||
*/
|
||||
public function delete_order(string $order_number): bool {
|
||||
global $wpdb;
|
||||
$order_number = sanitize_text_field($order_number);
|
||||
if ($order_number === '') {
|
||||
return false;
|
||||
}
|
||||
$ok = $wpdb->delete($this->table(), ['order_number' => $order_number], ['%s']);
|
||||
return $ok !== false && $ok > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取订单统计
|
||||
*/
|
||||
public function get_stats(int $user_id = 0): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = '';
|
||||
$params = [];
|
||||
if ($user_id > 0) {
|
||||
$where = 'WHERE user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid,
|
||||
SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN status = 'pending_review' THEN 1 ELSE 0 END) as pending_review,
|
||||
SUM(CASE WHEN status = 'refunding' THEN 1 ELSE 0 END) as refunding,
|
||||
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
|
||||
SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded,
|
||||
SUM(CASE WHEN status IN ('paid','delivered','completed','pending_review') THEN amount ELSE 0 END) as revenue
|
||||
FROM {$this->table()} {$where}";
|
||||
|
||||
if (!empty($params)) {
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
} else {
|
||||
$row = $wpdb->get_row($sql, ARRAY_A);
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'paid' => (int) ($row['paid'] ?? 0),
|
||||
'delivered' => (int) ($row['delivered'] ?? 0),
|
||||
'completed' => (int) ($row['completed'] ?? 0),
|
||||
'pending_review' => (int) ($row['pending_review'] ?? 0),
|
||||
'refunding' => (int) ($row['refunding'] ?? 0),
|
||||
'cancelled' => (int) ($row['cancelled'] ?? 0),
|
||||
'refunded' => (int) ($row['refunded'] ?? 0),
|
||||
'revenue' => (float) ($row['revenue'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function has_paid_access(int $user_id, string $product_type, int $product_id): bool {
|
||||
if ($user_id <= 0 || $product_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null !== $this->find_latest_success_order($user_id, $product_type, $product_id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function get_valid_vip_order(int $user_id): ?array {
|
||||
return $this->find_latest_success_order($user_id, \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP, 0, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Migration bridge: option → table
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* 一次性迁移:把 option 中的订单搬到表中。
|
||||
* 幂等 — 已存在的 order_number 不会重复插入。
|
||||
*/
|
||||
public function migrate_from_option(): int {
|
||||
global $wpdb;
|
||||
|
||||
$orders = get_option('aksrt_orders', []);
|
||||
if (!is_array($orders) || empty($orders)) return 0;
|
||||
|
||||
$migrated = 0;
|
||||
foreach ($orders as $order_number => $data) {
|
||||
$existing = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$this->table()} WHERE order_number = %s",
|
||||
$order_number
|
||||
));
|
||||
if ($existing) continue;
|
||||
|
||||
$wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number,
|
||||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||||
'product_type' => sanitize_key((string) ($data['product_type'] ?? '')),
|
||||
'product_id' => (int) ($data['product_id'] ?? 0),
|
||||
'amount' => (float) ($data['amount'] ?? 0),
|
||||
'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')),
|
||||
'status' => \AKSRT\Core\DomainModel::normalize_order_status((string) ($data['status'] ?? \AKSRT\Core\DomainModel::ORDER_STATUS_PENDING)),
|
||||
'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')),
|
||||
'created_at' => $data['created_at'] ?? current_time('mysql'),
|
||||
'paid_at' => $data['paid_at'] ?? null,
|
||||
'delivered_at' => $data['delivered_at'] ?? null,
|
||||
'completed_at' => $data['completed_at'] ?? null,
|
||||
'cancelled_at' => $data['cancelled_at'] ?? null,
|
||||
'refunded_at' => $data['refunded_at'] ?? null,
|
||||
'expires_at' => $data['expires_at'] ?? null,
|
||||
'note' => '',
|
||||
], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']);
|
||||
|
||||
if ($wpdb->insert_id) $migrated++;
|
||||
}
|
||||
|
||||
update_option(\AKSRT\Core\DomainModel::ORDER_MIGRATED_OPTION, time());
|
||||
|
||||
return $migrated;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Internal helpers
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
private function normalize_row(array $row): array {
|
||||
$row['id'] = (int) $row['id'];
|
||||
$row['user_id'] = (int) $row['user_id'];
|
||||
$row['product_id'] = (int) $row['product_id'];
|
||||
$row['amount'] = (float) $row['amount'];
|
||||
$row['status'] = \AKSRT\Core\DomainModel::normalize_order_status((string) ($row['status'] ?? ''));
|
||||
|
||||
$row['meta_payload'] = [];
|
||||
if (!empty($row['note']) && is_string($row['note'])) {
|
||||
$decoded = json_decode($row['note'], true);
|
||||
if (is_array($decoded)) {
|
||||
$row['meta_payload'] = $decoded;
|
||||
if (isset($decoded['customer_note']) && is_string($decoded['customer_note'])) {
|
||||
$row['note_text'] = $decoded['customer_note'];
|
||||
}
|
||||
} else {
|
||||
$row['note_text'] = $row['note'];
|
||||
}
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array {
|
||||
global $wpdb;
|
||||
|
||||
if ($user_id <= 0 || $product_type === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$status_placeholders = implode(', ', array_fill(0, count(\AKSRT\Core\DomainModel::paid_order_statuses()), '%s'));
|
||||
$params = array_merge(
|
||||
[$user_id, sanitize_key($product_type)],
|
||||
$ignore_product_id ? [] : [$product_id],
|
||||
\AKSRT\Core\DomainModel::paid_order_statuses()
|
||||
);
|
||||
|
||||
$product_sql = $ignore_product_id ? '' : ' AND product_id = %d';
|
||||
$sql = "SELECT * FROM {$this->table()}
|
||||
WHERE user_id = %d
|
||||
AND product_type = %s{$product_sql}
|
||||
AND status IN ({$status_placeholders})
|
||||
ORDER BY COALESCE(paid_at, created_at) DESC, id DESC
|
||||
LIMIT 1";
|
||||
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return is_array($row) ? $this->normalize_row($row) : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Build a unique order number using the admin-configured prefix.
|
||||
*
|
||||
* Column + major gateways (WeChat out_trade_no) cap at 32 chars, so the
|
||||
* prefix is trimmed to leave room for {YmdHis}{4 digits} (=18).
|
||||
*/
|
||||
private function generate_order_number(): string {
|
||||
$prefix = 'AKSRT';
|
||||
if (class_exists('\AKSRT\Settings\Settings') && method_exists('\AKSRT\Settings\Settings', 'get_order_settings')) {
|
||||
$settings = \AKSRT\Settings\Settings::get_order_settings();
|
||||
$configured = sanitize_text_field((string) ($settings['prefix'] ?? ''));
|
||||
if ($configured !== '') {
|
||||
$prefix = $configured;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only gateway-safe chars (REST + WeChat/Alipay out_trade_no).
|
||||
$prefix = preg_replace('/[^A-Za-z0-9_-]/', '', $prefix);
|
||||
if (!is_string($prefix) || $prefix === '') {
|
||||
$prefix = 'AKSRT';
|
||||
}
|
||||
|
||||
$body = date('YmdHis') . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||
// Reserve 18 for body; leave ≥1 char of prefix so numbers stay human-readable.
|
||||
$max_prefix = max(1, 32 - strlen($body));
|
||||
if (strlen($prefix) > $max_prefix) {
|
||||
$prefix = substr($prefix, 0, $max_prefix);
|
||||
}
|
||||
|
||||
return $prefix . $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge keys into the order note JSON payload (meta_payload).
|
||||
*
|
||||
* @param array<string,mixed> $patch
|
||||
*/
|
||||
public function merge_meta(string $order_number, array $patch): bool {
|
||||
global $wpdb;
|
||||
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
foreach ($patch as $key => $value) {
|
||||
$meta[(string) $key] = $value;
|
||||
}
|
||||
|
||||
$encoded = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if (!is_string($encoded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$updated = $wpdb->update(
|
||||
$this->table(),
|
||||
['note' => $encoded],
|
||||
['order_number' => $order_number],
|
||||
['%s'],
|
||||
['%s']
|
||||
);
|
||||
|
||||
return $updated !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local order from a provider out_trade_no (base or attempt-suffixed).
|
||||
*
|
||||
* The authoritative mapping is the order's own meta (provider_out_trade_no /
|
||||
* provider_out_trade_nos); the prefix heuristic is only a fallback and must
|
||||
* never be the sole resolver, otherwise a truncated attempt number would
|
||||
* strand a real payment.
|
||||
*/
|
||||
public function resolve_by_out_trade_no(string $out_trade_no): ?array {
|
||||
global $wpdb;
|
||||
|
||||
$out_trade_no = sanitize_text_field($out_trade_no);
|
||||
if ($out_trade_no === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$direct = $this->get($out_trade_no);
|
||||
if ($direct) {
|
||||
return $direct;
|
||||
}
|
||||
|
||||
// Authoritative: the attempt number was recorded in the order note JSON.
|
||||
$needle = '"provider_out_trade_no":"' . $out_trade_no . '"';
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE note LIKE %s ORDER BY id DESC LIMIT 1",
|
||||
'%' . $wpdb->esc_like($needle) . '%'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
if (is_array($row)) {
|
||||
return $this->normalize_row($row);
|
||||
}
|
||||
|
||||
// Superseded attempts stay in the history array.
|
||||
$history_needle = '"' . $out_trade_no . '"';
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()}
|
||||
WHERE note LIKE %s AND note LIKE %s
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
'%' . $wpdb->esc_like('"provider_out_trade_nos"') . '%',
|
||||
'%' . $wpdb->esc_like($history_needle) . '%'
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
if (is_array($row)) {
|
||||
return $this->normalize_row($row);
|
||||
}
|
||||
|
||||
// Last resort: {order_number}P{n} prefix heuristic.
|
||||
if (preg_match('/^(.+)P([0-9]{1,3})$/', $out_trade_no, $m)) {
|
||||
$base = $this->get($m[1]);
|
||||
if ($base) {
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider out_trade_no currently bound to this order (falls back to order_number).
|
||||
*
|
||||
* @param array<string,mixed> $order
|
||||
*/
|
||||
public static function provider_out_trade_no(array $order): string {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$out = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
|
||||
if ($out !== '') {
|
||||
return $out;
|
||||
}
|
||||
return sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate a new provider out_trade_no for a payment attempt.
|
||||
* First attempt uses bare order_number; later attempts append Pn (WeChat limit 32).
|
||||
*
|
||||
* @return array{out_trade_no:string,attempt:int,previous:string}|null
|
||||
*/
|
||||
public function allocate_payment_attempt(string $order_number): ?array {
|
||||
$order = $this->get($order_number);
|
||||
if (!$order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$previous = sanitize_text_field((string) ($meta['provider_out_trade_no'] ?? ''));
|
||||
$history = is_array($meta['provider_out_trade_nos'] ?? null) ? $meta['provider_out_trade_nos'] : [];
|
||||
|
||||
// First provider submission keeps the bare order number.
|
||||
// Re-pay (provider_out_trade_no already set) always mints a new suffix so
|
||||
// WeChat/Alipay/Epay accept the order when amount/subject/notify changed.
|
||||
if ($previous === '') {
|
||||
$attempt = 1;
|
||||
$out = $order_number;
|
||||
} else {
|
||||
$attempt = max(1, (int) ($meta['pay_attempt'] ?? 1)) + 1;
|
||||
$suffix = 'P' . $attempt;
|
||||
$base = $order_number;
|
||||
if (strlen($base) + strlen($suffix) > 32) {
|
||||
$base = substr($base, 0, 32 - strlen($suffix));
|
||||
}
|
||||
$out = $base . $suffix;
|
||||
if ($previous !== '' && !in_array($previous, $history, true)) {
|
||||
$history[] = $previous;
|
||||
}
|
||||
}
|
||||
|
||||
if (!in_array($out, $history, true)) {
|
||||
$history[] = $out;
|
||||
}
|
||||
if (count($history) > 20) {
|
||||
$history = array_slice($history, -20);
|
||||
}
|
||||
|
||||
$ok = $this->merge_meta($order_number, [
|
||||
'pay_attempt' => $attempt,
|
||||
'provider_out_trade_no' => $out,
|
||||
'provider_out_trade_nos' => array_values($history),
|
||||
]);
|
||||
if (!$ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'out_trade_no' => $out,
|
||||
'attempt' => $attempt,
|
||||
'previous' => $previous,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Cross-request lock for creating a payment attempt for one order.
|
||||
*/
|
||||
class PaymentAttemptLock {
|
||||
|
||||
private const TTL = 120;
|
||||
|
||||
/**
|
||||
* @return string|false Lock token, or false when another request owns it.
|
||||
*/
|
||||
public static function acquire(string $order_number) {
|
||||
$key = self::key($order_number);
|
||||
$token = wp_generate_uuid4();
|
||||
$value = [
|
||||
'token' => $token,
|
||||
'expires' => time() + self::TTL,
|
||||
];
|
||||
|
||||
if (add_option($key, $value, '', false)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
$current = get_option($key, []);
|
||||
if (is_array($current) && (int) ($current['expires'] ?? 0) < time()) {
|
||||
global $wpdb;
|
||||
$serialized_current = maybe_serialize($current);
|
||||
$serialized_value = maybe_serialize($value);
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$wpdb->options}
|
||||
SET option_value = %s
|
||||
WHERE option_name = %s AND option_value = %s",
|
||||
$serialized_value,
|
||||
$key,
|
||||
$serialized_current
|
||||
));
|
||||
if ($updated === 1) {
|
||||
wp_cache_delete($key, 'options');
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function release(string $order_number, string $token): void {
|
||||
global $wpdb;
|
||||
|
||||
$key = self::key($order_number);
|
||||
$current = get_option($key, []);
|
||||
if (!is_array($current) || !hash_equals((string) ($current['token'] ?? ''), $token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = $wpdb->query($wpdb->prepare(
|
||||
"DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s",
|
||||
$key,
|
||||
maybe_serialize($current)
|
||||
));
|
||||
if ($deleted === 1) {
|
||||
wp_cache_delete($key, 'options');
|
||||
}
|
||||
}
|
||||
|
||||
private static function key(string $order_number): string {
|
||||
return 'aksrt_pay_lock_' . hash('sha256', $order_number);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
/**
|
||||
* Single execution path for "create a payment attempt for an existing order".
|
||||
*
|
||||
* Every cashier (shop / content / VIP / forum / recharge / points) must route
|
||||
* through here so that all of them get:
|
||||
* 1. a cross-request attempt lock (no double deduction on double-click)
|
||||
* 2. a fresh provider out_trade_no on re-pay (providers reject reused numbers)
|
||||
* 3. best-effort close of the previous provider trade
|
||||
* 4. strict CAS on the paid transition + rollback of synchronous deductions
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PaymentOrchestrator {
|
||||
|
||||
/** Methods settled synchronously in-process (no provider round trip). */
|
||||
private const SYNC_METHODS = ['balance', 'points'];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $order Order row plus subject/amount overrides.
|
||||
* @param array<string,mixed> $opts paid_note: string
|
||||
*
|
||||
* @return array{
|
||||
* success:bool,
|
||||
* code?:string,
|
||||
* message:string,
|
||||
* redirect?:bool,
|
||||
* payment_url?:string,
|
||||
* qr_content?:string,
|
||||
* transaction_id?:string,
|
||||
* out_trade_no?:string
|
||||
* }
|
||||
*/
|
||||
public static function pay(GatewayInterface $gateway, array $order, array $opts = []): array {
|
||||
$order_number = sanitize_text_field((string) ($order['order_number'] ?? ''));
|
||||
if ($order_number === '' || !class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||
return self::fail('aksrt_pay_failed', __('订单号无效。', 'aksrt'));
|
||||
}
|
||||
|
||||
$method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
if ($method === '') {
|
||||
return self::fail('aksrt_pay_failed', __('支付方式无效。', 'aksrt'));
|
||||
}
|
||||
|
||||
$lock = PaymentAttemptLock::acquire($order_number);
|
||||
if ($lock === false) {
|
||||
return self::fail('aksrt_pay_locked', __('该订单正在发起支付,请稍后重试。', 'aksrt'));
|
||||
}
|
||||
|
||||
try {
|
||||
$repo = OrderRepository::get_instance();
|
||||
|
||||
// A pending order remains bound to the gateway selected at creation.
|
||||
// Switching it would make a late callback from the previous gateway look
|
||||
// invalid after that provider may already have collected the money.
|
||||
$stored = $repo->get($order_number);
|
||||
$stored_status = sanitize_key((string) ($stored['status'] ?? ''));
|
||||
if (!in_array($stored_status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
return self::fail('aksrt_order_not_payable', __('当前订单状态不可支付。', 'aksrt'));
|
||||
}
|
||||
$stored_method = sanitize_key((string) ($stored['payment_method'] ?? ''));
|
||||
if ($stored_method !== '' && !hash_equals($stored_method, $method)) {
|
||||
return self::fail('aksrt_pay_method_mismatch', __('支付方式与订单不一致,请重新下单。', 'aksrt'));
|
||||
}
|
||||
|
||||
// External providers refuse a second submission that reuses the same
|
||||
// merchant order number with changed params — mint a new one per attempt.
|
||||
if (!in_array($method, self::SYNC_METHODS, true)) {
|
||||
$attempt = $repo->allocate_payment_attempt($order_number);
|
||||
if ($attempt === null) {
|
||||
return self::fail('aksrt_pay_failed', __('无法生成支付单号,请稍后重试。', 'aksrt'));
|
||||
}
|
||||
$order['out_trade_no'] = (string) $attempt['out_trade_no'];
|
||||
$previous = (string) ($attempt['previous'] ?? '');
|
||||
if ($previous !== '' && $previous !== $order['out_trade_no'] && method_exists($gateway, 'close_trade')) {
|
||||
try {
|
||||
$gateway->close_trade($previous);
|
||||
} catch (\Throwable $e) {
|
||||
// Best-effort: the new attempt already uses a fresh number.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = $gateway->create_payment($order);
|
||||
if (empty($result['success'])) {
|
||||
$message = (string) ($result['message'] ?? __('支付失败。', 'aksrt'));
|
||||
return self::fail(self::failure_code($method, $message), $message);
|
||||
}
|
||||
|
||||
$payment_url = (string) ($result['payment_url'] ?? '');
|
||||
$qr_content = (string) ($result['payment_data']['code_url'] ?? '');
|
||||
if ($payment_url !== '' || $qr_content !== '') {
|
||||
// Redirect/scan gateways: the order stays pending until the signed
|
||||
// notify/return callback confirms it. Never pre-mark as paid here.
|
||||
return [
|
||||
'success' => true,
|
||||
'redirect' => true,
|
||||
'payment_url' => $payment_url,
|
||||
'qr_content' => $qr_content,
|
||||
'out_trade_no' => (string) ($order['out_trade_no'] ?? $order_number),
|
||||
'message' => (string) ($result['message'] ?? __('请在新页面完成支付。', 'aksrt')),
|
||||
];
|
||||
}
|
||||
|
||||
// Synchronous gateway (balance): funds already moved inside create_payment().
|
||||
$transaction_id = sanitize_text_field((string) ($result['transaction_id'] ?? ''));
|
||||
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
|
||||
'payment_method' => $method,
|
||||
'payment_transaction_id' => $transaction_id,
|
||||
'note' => (string) ($opts['paid_note'] ?? __('用户完成支付。', 'aksrt')),
|
||||
// Require a real status transition: if a callback already moved the
|
||||
// order, this attempt must roll its deduction back instead of
|
||||
// silently reporting success.
|
||||
'require_transition' => true,
|
||||
]);
|
||||
if (!$paid) {
|
||||
$rolled_back = self::rollback($gateway, $order_number, (float) ($order['amount'] ?? 0), $method);
|
||||
return self::fail(
|
||||
'aksrt_order_confirm_failed',
|
||||
$rolled_back
|
||||
? __('订单状态已变化,扣款已退回。', 'aksrt')
|
||||
: __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'redirect' => false,
|
||||
'transaction_id' => $transaction_id,
|
||||
'message' => (string) ($result['message'] ?? __('支付成功。', 'aksrt')),
|
||||
];
|
||||
} finally {
|
||||
PaymentAttemptLock::release($order_number, $lock);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve + validate the gateway for a cashier request.
|
||||
*
|
||||
* @return array{gateway:GatewayInterface}|array{error_code:string,message:string}
|
||||
*/
|
||||
public static function resolve_gateway(string $payment_method): array {
|
||||
if (!class_exists('AKSRT\\Payment\\Gateway')) {
|
||||
return ['error_code' => 'aksrt_pay_unavailable', 'message' => __('支付网关不可用。', 'aksrt')];
|
||||
}
|
||||
$gateway = Gateway::get_instance()->get_gateway(sanitize_key($payment_method));
|
||||
if (!$gateway) {
|
||||
return ['error_code' => 'aksrt_gateway_unavailable', 'message' => __('当前支付方式不可用。', 'aksrt')];
|
||||
}
|
||||
if (!$gateway->is_configured()) {
|
||||
return ['error_code' => 'aksrt_gateway_unconfigured', 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
||||
}
|
||||
return ['gateway' => $gateway];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compensate a synchronous deduction that could not be committed.
|
||||
*/
|
||||
private static function rollback(GatewayInterface $gateway, string $order_number, float $amount, string $method): bool {
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
$request_id = 'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24);
|
||||
try {
|
||||
$compensation = $gateway->refund($order_number, $amount, $request_id);
|
||||
} catch (\Throwable $e) {
|
||||
$compensation = ['success' => false, 'message' => $e->getMessage()];
|
||||
}
|
||||
if (!empty($compensation['success'])) {
|
||||
return true;
|
||||
}
|
||||
error_log(sprintf(
|
||||
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
|
||||
$order_number,
|
||||
$method,
|
||||
(string) ($compensation['message'] ?? 'unknown error')
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
private static function failure_code(string $method, string $message): string {
|
||||
if ($method === 'balance' && (str_contains($message, '余额') || str_contains($message, '不足'))) {
|
||||
return 'aksrt_balance_insufficient';
|
||||
}
|
||||
return 'aksrt_pay_failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success:false,code:string,message:string}
|
||||
*/
|
||||
private static function fail(string $code, string $message): array {
|
||||
return ['success' => false, 'code' => $code, 'message' => $message];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Rebate {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
const TYPE_ORDER = 'order';
|
||||
const TYPE_invite = 'invite';
|
||||
|
||||
const STATUS_PENDING = 'pending';
|
||||
const STATUS_SETTLED = 'settled';
|
||||
const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('rebate_log');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建返佣记录
|
||||
*/
|
||||
public function create(array $data): int {
|
||||
global $wpdb;
|
||||
|
||||
$user_id = (int) ($data['user_id'] ?? 0);
|
||||
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
|
||||
$source_user_id = (int) ($data['source_user_id'] ?? 0);
|
||||
$amount = (float) ($data['amount'] ?? 0);
|
||||
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_ORDER));
|
||||
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
||||
|
||||
if ($user_id <= 0 || $amount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$valid_types = [self::TYPE_ORDER, self::TYPE_invite];
|
||||
if (!in_array($type, $valid_types, true)) {
|
||||
$type = self::TYPE_ORDER;
|
||||
}
|
||||
|
||||
$result = $wpdb->insert($this->table(), [
|
||||
'user_id' => $user_id,
|
||||
'order_number' => $order_number,
|
||||
'source_user_id' => $source_user_id > 0 ? $source_user_id : null,
|
||||
'amount' => $amount,
|
||||
'type' => $type,
|
||||
'status' => self::STATUS_PENDING,
|
||||
'created_at' => current_time('mysql'),
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$id = $result ? (int) $wpdb->insert_id : 0;
|
||||
if ($id > 0) {
|
||||
do_action('aksrt_pay_rebate_added', $user_id, $amount, [
|
||||
'rebate_id' => $id,
|
||||
'order_number' => $order_number,
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算返佣
|
||||
*/
|
||||
public function settle(int $id): bool {
|
||||
global $wpdb;
|
||||
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE id = %d AND status = %s",
|
||||
$id, self::STATUS_PENDING
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$user_id = (int) $row['user_id'];
|
||||
$amount = (float) $row['amount'];
|
||||
|
||||
$result = $wpdb->update($this->table(), [
|
||||
'status' => self::STATUS_SETTLED,
|
||||
'settled_at' => current_time('mysql'),
|
||||
], ['id' => $id]);
|
||||
|
||||
if ($result && class_exists('\AKSRT\Credits\Credits')) {
|
||||
\AKSRT\Credits\Credits::add($user_id, (int) $amount, 'rebate', [
|
||||
'ref_type' => 'rebate',
|
||||
'ref_id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消返佣
|
||||
*/
|
||||
public function cancel(int $id): bool {
|
||||
global $wpdb;
|
||||
return (bool) $wpdb->update($this->table(), [
|
||||
'status' => self::STATUS_CANCELLED,
|
||||
], ['id' => $id, 'status' => self::STATUS_PENDING]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取返佣列表
|
||||
*/
|
||||
public function get_list(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return [
|
||||
'items' => $items ?: [],
|
||||
'total' => $total,
|
||||
'page' => floor($offset / $limit) + 1,
|
||||
'max_pages' => ceil($total / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计
|
||||
*/
|
||||
public function get_stats(): array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled,
|
||||
SUM(amount) as total_amount,
|
||||
SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END) as settled_amount
|
||||
FROM {$table}",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'settled' => (int) ($row['settled'] ?? 0),
|
||||
'total_amount' => (float) ($row['total_amount'] ?? 0),
|
||||
'settled_amount' => (float) ($row['settled_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Revenue {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('revenue');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录收益
|
||||
*/
|
||||
public function add(int $user_id, float $amount, string $order_number = '', string $type = 'income'): int {
|
||||
global $wpdb;
|
||||
|
||||
if ($user_id <= 0 || $amount <= 0) return 0;
|
||||
|
||||
$wpdb->insert($this->table(), [
|
||||
'user_id' => $user_id,
|
||||
'order_number' => sanitize_text_field($order_number),
|
||||
'amount' => $amount,
|
||||
'type' => sanitize_key($type),
|
||||
'status' => 'pending',
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%s', '%f', '%s', '%s', '%s']);
|
||||
|
||||
$id = (int) $wpdb->insert_id;
|
||||
if ($id > 0) {
|
||||
do_action('aksrt_creator_income_added', $user_id, $amount, [
|
||||
'order_number' => $order_number,
|
||||
'type' => $type,
|
||||
'revenue_id' => $id,
|
||||
]);
|
||||
}
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算收益
|
||||
*/
|
||||
public function settle(int $revenue_id): bool {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->update(
|
||||
$this->table(),
|
||||
['status' => 'settled', 'settled_at' => current_time('mysql')],
|
||||
['id' => $revenue_id, 'status' => 'pending'],
|
||||
['%s', '%s'],
|
||||
['%d', '%s']
|
||||
) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户收益列表
|
||||
*/
|
||||
public function get_user_revenues(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = ['user_id = %d'];
|
||||
$params = [$user_id];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin list (all users) with optional status / user filter.
|
||||
*
|
||||
* @param array{status?:string,user_id?:int,limit?:int,offset?:int} $args
|
||||
* @return array{items:array<int,array>,total:int,page:int,max_pages:int}
|
||||
*/
|
||||
public function get_list(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
$total = $params
|
||||
? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params))
|
||||
: (int) $wpdb->get_var($count_sql);
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
|
||||
return [
|
||||
'items' => $items ?: [],
|
||||
'total' => $total,
|
||||
'page' => (int) floor($offset / $limit) + 1,
|
||||
'max_pages' => (int) ceil(max(1, $total) / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收益统计
|
||||
*/
|
||||
public function get_stats(int $user_id = 0): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = '';
|
||||
$params = [];
|
||||
if ($user_id > 0) {
|
||||
$where = 'WHERE user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
|
||||
$sql = "SELECT
|
||||
COALESCE(SUM(amount), 0) as total,
|
||||
COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) as pending,
|
||||
COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) as settled,
|
||||
COUNT(*) as count
|
||||
FROM {$this->table()} {$where}";
|
||||
|
||||
if (!empty($params)) {
|
||||
$row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
} else {
|
||||
$row = $wpdb->get_row($sql, ARRAY_A);
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => (float) ($row['total'] ?? 0),
|
||||
'pending' => (float) ($row['pending'] ?? 0),
|
||||
'settled' => (float) ($row['settled'] ?? 0),
|
||||
'count' => (int) ($row['count'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
<?php
|
||||
/**
|
||||
* Front-end "My Orders" view-model (all product_types).
|
||||
*
|
||||
* Shop logistics/review stay shop-only; content/VIP/recharge get type-aware cards.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class UserOrders {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated user order center payload.
|
||||
*
|
||||
* @param array{page?:int,per_page?:int,status?:string,product_type?:string} $args
|
||||
* @return array{items:array,total:int,total_pages:int,page:int,stats:array,product_types:array}
|
||||
*/
|
||||
public function get_order_center_view(int $user_id, array $args = []): array {
|
||||
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
|
||||
return [
|
||||
'items' => [],
|
||||
'total' => 0,
|
||||
'total_pages' => 1,
|
||||
'page' => 1,
|
||||
'stats' => $this->empty_stats(),
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
}
|
||||
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 20)));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
|
||||
$repo_args = [
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
];
|
||||
if ($status !== '') {
|
||||
$repo_args['status'] = $status;
|
||||
}
|
||||
if ($product_type !== '' && $product_type !== 'all') {
|
||||
$repo_args['product_type'] = $product_type;
|
||||
}
|
||||
|
||||
$repo = OrderRepository::get_instance();
|
||||
$orders = $repo->get_user_orders($user_id, $repo_args);
|
||||
$total = $repo->count_user_orders($user_id, $repo_args);
|
||||
|
||||
$items = [];
|
||||
foreach ($orders as $order) {
|
||||
$items[] = $this->decorate_order_view($order, true);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'total_pages' => max(1, (int) ceil(max(0, $total) / $per_page)),
|
||||
'page' => $page,
|
||||
'stats' => $this->get_user_order_stats($user_id),
|
||||
'product_types' => $this->product_type_filter_options(),
|
||||
];
|
||||
}
|
||||
|
||||
public function get_order_detail_view(int $user_id, string $order_number): ?array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
|
||||
return null;
|
||||
}
|
||||
$order = OrderRepository::get_instance()->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return null;
|
||||
}
|
||||
if (class_exists(Gateway::class)) {
|
||||
$order = Gateway::get_instance()->reconcile_order($order_number, $order) ?: $order;
|
||||
}
|
||||
return $this->decorate_order_view($order, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total:int,pending:int,paid:int}
|
||||
*/
|
||||
public function get_user_order_stats(int $user_id): array {
|
||||
if ($user_id <= 0 || !class_exists(OrderRepository::class)) {
|
||||
return $this->empty_stats();
|
||||
}
|
||||
$repo = OrderRepository::get_instance();
|
||||
return [
|
||||
'total' => $repo->count_user_orders($user_id, []),
|
||||
'pending' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PENDING]),
|
||||
'paid' => $repo->count_user_orders($user_id, ['status' => Order::STATUS_PAID]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function product_type_filter_options(): array {
|
||||
$labels = \AKSRT\Core\DomainModel::order_product_type_labels();
|
||||
return array_merge(['' => __('全部类型', 'aksrt')], $labels);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order Normalized order row.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function decorate_order_view(array $order, bool $with_detail = false): array {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$status = (string) ($order['status'] ?? Order::STATUS_PENDING);
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||||
$is_points = $currency === 'points'
|
||||
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points'
|
||||
|| (!empty($meta['pay_modo']) && (string) $meta['pay_modo'] === 'points');
|
||||
|
||||
$type_labels = \AKSRT\Core\DomainModel::order_product_type_labels();
|
||||
$type_label = $type_labels[$product_type] ?? ($product_type !== '' ? $product_type : __('订单', 'aksrt'));
|
||||
|
||||
$title = $this->resolve_order_title($order, $meta, $product_type, $product_id, $type_label);
|
||||
$image = $this->resolve_order_image($order, $meta, $product_type, $product_id);
|
||||
$permalink = $this->resolve_order_permalink($product_type, $product_id, $meta);
|
||||
|
||||
$order_items = [];
|
||||
$has_structured_items = !empty($order_items);
|
||||
|
||||
$product = [];
|
||||
// Shop product cards were removed with the shop feature; content/VIP
|
||||
// orders fall through to the shared meta-based fields below.
|
||||
|
||||
if (!empty($product['title'])) {
|
||||
$title = (string) $product['title'];
|
||||
}
|
||||
if (!empty($product['image'])) {
|
||||
$image = (string) $product['image'];
|
||||
}
|
||||
if (!empty($product['permalink'])) {
|
||||
$permalink = (string) $product['permalink'];
|
||||
}
|
||||
|
||||
$shipping_records = [];
|
||||
$after_sale_items = [];
|
||||
$subtotal = (float) ($meta['items'][0]['line_total'] ?? $order['amount'] ?? 0);
|
||||
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
|
||||
$discount = (float) ($meta['discount'] ?? 0);
|
||||
$expires_at = (string) ($order['expires_at'] ?? '');
|
||||
$payment_value = sanitize_key((string) ($meta['payment_method']['value'] ?? $order['payment_method'] ?? 'balance'));
|
||||
|
||||
$can_pay = in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)
|
||||
&& $payment_value !== 'manual_review';
|
||||
$can_cancel = in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true);
|
||||
$can_after_sale = false;
|
||||
$can_receipt = false;
|
||||
$can_review = false;
|
||||
$can_rebuy = false;
|
||||
// Order detail modal is always available; product permalink is optional.
|
||||
$can_view = true;
|
||||
$can_view_product = $permalink !== '' && in_array($status, [
|
||||
Order::STATUS_PAID,
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
], true);
|
||||
|
||||
$view = [
|
||||
'id' => (int) ($order['id'] ?? 0),
|
||||
'order_number' => $order_number,
|
||||
'amount' => (float) ($order['amount'] ?? 0),
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $is_points
|
||||
? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'))
|
||||
: '¥',
|
||||
'is_points' => $is_points,
|
||||
'status' => $status,
|
||||
'status_label' => Order::get_valid_statuses()[$status] ?? $status,
|
||||
'product_type' => $product_type,
|
||||
'product_type_label' => $type_label,
|
||||
'product_id' => $product_id,
|
||||
'product_name' => $title,
|
||||
'product' => $product ?: [
|
||||
'title' => $title,
|
||||
'image' => $image,
|
||||
'permalink' => $permalink,
|
||||
],
|
||||
'image' => $image,
|
||||
'permalink' => $permalink,
|
||||
'created_at' => (string) ($order['created_at'] ?? ''),
|
||||
'paid_at' => (string) ($order['paid_at'] ?? ''),
|
||||
'expires_at' => $expires_at,
|
||||
'subtotal' => $subtotal,
|
||||
'shipping_fee' => $shipping_fee,
|
||||
'discount' => $discount,
|
||||
'address' => $meta['address'] ?? [],
|
||||
'payment_method' => $meta['payment_method'] ?? [
|
||||
'value' => (string) ($order['payment_method'] ?? ''),
|
||||
'label' => $this->payment_method_label((string) ($order['payment_method'] ?? '')),
|
||||
],
|
||||
'coupon' => $meta['coupon'] ?? [],
|
||||
'items' => $has_structured_items ? $order_items : ($meta['items'] ?? []),
|
||||
'shipping_method' => $meta['shipping_method'] ?? [],
|
||||
'customer_note' => $meta['customer_note'] ?? ($order['note_text'] ?? ''),
|
||||
'shipping_records' => $shipping_records,
|
||||
'pay_url' => '#',
|
||||
'actions' => [
|
||||
'can_pay' => $can_pay,
|
||||
'can_cancel' => $can_cancel,
|
||||
'can_after_sale' => $can_after_sale,
|
||||
'can_receipt' => $can_receipt,
|
||||
'can_review' => $can_review,
|
||||
'can_rebuy' => $can_rebuy,
|
||||
'can_view' => $can_view,
|
||||
'can_view_product' => $can_view_product,
|
||||
],
|
||||
'quantity' => $this->resolve_order_quantity($order_items, $meta),
|
||||
'time_left' => $this->get_order_countdown($expires_at, $status),
|
||||
'secondary_url' => $this->resolve_secondary_url($product_type),
|
||||
'secondary_label' => $this->resolve_secondary_label($product_type),
|
||||
];
|
||||
|
||||
if ($with_detail) {
|
||||
$view['after_sale_records'] = $after_sale_items;
|
||||
$view['timeline'] = $this->build_order_timeline($order, $shipping_records, $after_sale_items);
|
||||
}
|
||||
|
||||
return $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending order (any product_type).
|
||||
*
|
||||
* @return array{success:bool,message?:string,data?:array}
|
||||
*/
|
||||
public function cancel_order(int $user_id, string $order_number): array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo = OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if (!in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true)) {
|
||||
return ['success' => false, 'message' => __('当前订单不可取消。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo->update_status($order_number, Order::STATUS_CANCELLED, [
|
||||
'force' => true,
|
||||
'note' => __('用户在前台取消订单。', 'aksrt'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume payment via configured gateway (any product_type).
|
||||
*
|
||||
* @return array{success:bool,message?:string,data?:array}
|
||||
*/
|
||||
public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
// Resume via shared gateway stack (any product_type; shop removed).
|
||||
$repo = OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if (!in_array($status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||
}
|
||||
|
||||
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
|
||||
if ($stored_method !== '' && $payment_method !== $stored_method) {
|
||||
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
|
||||
}
|
||||
|
||||
$gateway_manager = class_exists(Gateway::class) ? Gateway::get_instance() : null;
|
||||
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
||||
if (!$gateway) {
|
||||
return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')];
|
||||
}
|
||||
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
|
||||
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
||||
}
|
||||
|
||||
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
||||
$order['payment_method'] = $payment_method;
|
||||
|
||||
$payment_result = PaymentOrchestrator::pay($gateway, $order, [
|
||||
'paid_note' => __('用户在前台完成支付。', 'aksrt'),
|
||||
]);
|
||||
if (empty($payment_result['success'])) {
|
||||
return ['success' => false, 'message' => $payment_result['message']];
|
||||
}
|
||||
|
||||
if (!empty($payment_result['redirect'])) {
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'redirect' => true,
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'message' => $payment_result['message'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
'message' => $payment_result['message'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function empty_stats(): array {
|
||||
return ['total' => 0, 'pending' => 0, 'paid' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_title(array $order, array $meta, string $product_type, int $product_id, string $type_label): string {
|
||||
foreach (['product_title', 'title', 'package_title'] as $key) {
|
||||
if (!empty($meta[$key]) && is_string($meta[$key])) {
|
||||
return (string) $meta[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
$pkg = (string) ($meta['package_title'] ?? '');
|
||||
if ($pkg !== '') {
|
||||
return $pkg;
|
||||
}
|
||||
return __('VIP 会员', 'aksrt');
|
||||
}
|
||||
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return (string) ($meta['title'] ?? __('余额充值', 'aksrt'));
|
||||
}
|
||||
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
|
||||
return (string) ($meta['title'] ?? __('购买积分', 'aksrt'));
|
||||
}
|
||||
|
||||
if ($product_id > 0) {
|
||||
$post_title = get_the_title($product_id);
|
||||
if (is_string($post_title) && $post_title !== '') {
|
||||
return $post_title;
|
||||
}
|
||||
}
|
||||
|
||||
return $type_label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_image(array $order, array $meta, string $product_type, int $product_id): string {
|
||||
if (!empty($meta['thumb']) && is_string($meta['thumb'])) {
|
||||
return esc_url_raw((string) $meta['thumb']);
|
||||
}
|
||||
if ($product_id > 0 && has_post_thumbnail($product_id)) {
|
||||
$url = get_the_post_thumbnail_url($product_id, 'thumbnail');
|
||||
if (is_string($url) && $url !== '') {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_permalink(string $product_type, int $product_id, array $meta): string {
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return function_exists('aksrt_get_user_center_tab_url')
|
||||
? aksrt_get_user_center_tab_url('vip')
|
||||
: '';
|
||||
}
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE
|
||||
|| $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
|
||||
return function_exists('aksrt_get_user_center_tab_url')
|
||||
? aksrt_get_user_center_tab_url('credits')
|
||||
: '';
|
||||
}
|
||||
$post_id = $product_id > 0 ? $product_id : (int) ($meta['post_id'] ?? 0);
|
||||
if ($post_id > 0) {
|
||||
$link = get_permalink($post_id);
|
||||
return is_string($link) ? $link : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolve_secondary_url(string $product_type): string {
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : '';
|
||||
}
|
||||
if (in_array($product_type, [
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE,
|
||||
\AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY,
|
||||
], true)) {
|
||||
return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function resolve_secondary_label(string $product_type): string {
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP) {
|
||||
return __('我的会员', 'aksrt');
|
||||
}
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RECHARGE) {
|
||||
return __('我的余额', 'aksrt');
|
||||
}
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS_BUY) {
|
||||
return class_exists('\AKSRT\Settings\Settings')
|
||||
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
|
||||
: __('我的积分', 'aksrt');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function payment_method_label(string $method): string {
|
||||
$method = sanitize_key($method);
|
||||
$map = [
|
||||
'balance' => __('站内余额', 'aksrt'),
|
||||
'points' => __('积分支付', 'aksrt'),
|
||||
'alipay' => __('支付宝', 'aksrt'),
|
||||
'wechatpay' => __('微信支付', 'aksrt'),
|
||||
'wechat' => __('微信支付', 'aksrt'),
|
||||
'paypal' => 'PayPal',
|
||||
'stripe' => 'Stripe',
|
||||
'epay_alipay' => __('易支付·支付宝', 'aksrt'),
|
||||
'epay_wxpay' => __('易支付·微信', 'aksrt'),
|
||||
];
|
||||
return $map[$method] ?? ($method !== '' ? $method : __('—', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build chronological order progress for the detail modal.
|
||||
*
|
||||
* Driven by current status + available timestamps / related records so the
|
||||
* chain advances even when historical rows lack paid_at/delivered_at.
|
||||
*
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array> $shipping_records
|
||||
* @param array<int, array> $after_sale_records
|
||||
* @return array<int, array{time:string,label:string,key?:string}>
|
||||
*/
|
||||
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
|
||||
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
|
||||
$created_at = $this->timeline_time((string) ($order['created_at'] ?? ''));
|
||||
$paid_at = $this->timeline_time((string) ($order['paid_at'] ?? ''));
|
||||
$delivered_at = $this->timeline_time((string) ($order['delivered_at'] ?? ''));
|
||||
$completed_at = $this->timeline_time((string) ($order['completed_at'] ?? ''));
|
||||
$cancelled_at = $this->timeline_time((string) ($order['cancelled_at'] ?? ''));
|
||||
$refunded_at = $this->timeline_time((string) ($order['refunded_at'] ?? ''));
|
||||
|
||||
$shipping_created = '';
|
||||
foreach ($shipping_records as $record) {
|
||||
$t = $this->timeline_time((string) ($record['created_at'] ?? ''));
|
||||
if ($t !== '' && ($shipping_created === '' || strcmp($t, $shipping_created) < 0)) {
|
||||
$shipping_created = $t;
|
||||
}
|
||||
}
|
||||
if ($delivered_at === '' && $shipping_created !== '') {
|
||||
$delivered_at = $shipping_created;
|
||||
}
|
||||
|
||||
$paid_like = [
|
||||
Order::STATUS_PAID,
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
Order::STATUS_REFUNDING,
|
||||
Order::STATUS_REFUNDED,
|
||||
];
|
||||
$ship_like = [
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
];
|
||||
$done_like = [
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
];
|
||||
|
||||
$timeline = [];
|
||||
$push = static function (array &$out, string $key, string $label, string $time): void {
|
||||
if ($label === '') {
|
||||
return;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'time' => $time,
|
||||
];
|
||||
};
|
||||
|
||||
// 1) Always: order created.
|
||||
$push(
|
||||
$timeline,
|
||||
'created',
|
||||
__('订单创建', 'aksrt'),
|
||||
$created_at !== '' ? $created_at : ''
|
||||
);
|
||||
|
||||
// 2) Paid (status-driven; fall back to created_at when paid_at missing).
|
||||
if ($paid_at !== '' || in_array($status, $paid_like, true)) {
|
||||
$push(
|
||||
$timeline,
|
||||
'paid',
|
||||
__('完成支付', 'aksrt'),
|
||||
$paid_at !== '' ? $paid_at : $created_at
|
||||
);
|
||||
}
|
||||
|
||||
// 3) Shipped / delivered — only when logistics exists or status reached it.
|
||||
$has_shipping = $shipping_records !== [];
|
||||
if (
|
||||
$delivered_at !== ''
|
||||
|| $has_shipping
|
||||
|| in_array($status, $ship_like, true)
|
||||
) {
|
||||
// Virtual / content flows often jump paid → completed without logistics;
|
||||
// only show the ship step when we have evidence of shipping.
|
||||
if ($delivered_at !== '' || $has_shipping || $status === Order::STATUS_DELIVERED) {
|
||||
$push(
|
||||
$timeline,
|
||||
'delivered',
|
||||
__('订单发货', 'aksrt'),
|
||||
$delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Completed / pending review.
|
||||
if ($completed_at !== '' || in_array($status, $done_like, true)) {
|
||||
$done_label = $status === Order::STATUS_PENDING_REVIEW
|
||||
? __('待评价', 'aksrt')
|
||||
: __('交易完成', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'completed',
|
||||
$done_label,
|
||||
$completed_at !== ''
|
||||
? $completed_at
|
||||
: ($delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at))
|
||||
);
|
||||
}
|
||||
|
||||
// 5) Terminal cancel / expire / refund.
|
||||
if ($status === Order::STATUS_CANCELLED || $cancelled_at !== '') {
|
||||
$push(
|
||||
$timeline,
|
||||
'cancelled',
|
||||
__('订单取消', 'aksrt'),
|
||||
$cancelled_at !== '' ? $cancelled_at : $created_at
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_EXPIRED) {
|
||||
$push(
|
||||
$timeline,
|
||||
'expired',
|
||||
__('订单过期', 'aksrt'),
|
||||
$cancelled_at !== '' ? $cancelled_at : $created_at
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_REFUNDING) {
|
||||
$push(
|
||||
$timeline,
|
||||
'refunding',
|
||||
__('退款处理中', 'aksrt'),
|
||||
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_REFUNDED || $refunded_at !== '') {
|
||||
$push(
|
||||
$timeline,
|
||||
'refunded',
|
||||
__('完成退款', 'aksrt'),
|
||||
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
|
||||
// Extra logistics / after-sale events (detail lines).
|
||||
foreach ($shipping_records as $record) {
|
||||
$tracking = trim((string) ($record['tracking_no'] ?? ''));
|
||||
$carrier = trim((string) ($record['carrier_label'] ?? $record['carrier'] ?? ''));
|
||||
$status_l = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
|
||||
$parts = array_filter([$carrier, $tracking, $status_l], static fn($v) => $v !== '');
|
||||
$label = $parts !== []
|
||||
? sprintf(__('物流更新:%s', 'aksrt'), implode(' / ', $parts))
|
||||
: __('物流更新', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'shipping',
|
||||
$label,
|
||||
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
foreach ($after_sale_records as $record) {
|
||||
$reason = trim((string) ($record['reason'] ?? ''));
|
||||
$type_l = trim((string) ($record['type_label'] ?? $record['type'] ?? ''));
|
||||
$as_status = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
|
||||
$parts = array_filter([$type_l, $as_status, $reason], static fn($v) => $v !== '');
|
||||
$label = $parts !== []
|
||||
? sprintf(__('售后:%s', 'aksrt'), implode(' — ', $parts))
|
||||
: __('售后申请', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'after_sale',
|
||||
$label,
|
||||
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
usort($timeline, static function ($a, $b) {
|
||||
$ta = (string) ($a['time'] ?? '');
|
||||
$tb = (string) ($b['time'] ?? '');
|
||||
if ($ta === $tb) {
|
||||
// Keep canonical pipeline order when times collide / missing.
|
||||
$rank = [
|
||||
'created' => 10,
|
||||
'paid' => 20,
|
||||
'delivered' => 30,
|
||||
'shipping' => 35,
|
||||
'completed' => 40,
|
||||
'refunding' => 50,
|
||||
'refunded' => 60,
|
||||
'cancelled' => 70,
|
||||
'expired' => 80,
|
||||
'after_sale'=> 90,
|
||||
];
|
||||
$ra = $rank[(string) ($a['key'] ?? '')] ?? 99;
|
||||
$rb = $rank[(string) ($b['key'] ?? '')] ?? 99;
|
||||
return $ra <=> $rb;
|
||||
}
|
||||
if ($ta === '') {
|
||||
return 1;
|
||||
}
|
||||
if ($tb === '') {
|
||||
return -1;
|
||||
}
|
||||
return strcmp($ta, $tb);
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mysql datetime for timeline display (empty when invalid/zero).
|
||||
*/
|
||||
private function timeline_time(string $value): string {
|
||||
$value = trim($value);
|
||||
if ($value === '' || str_starts_with($value, '0000-00-00')) {
|
||||
return '';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function get_order_countdown(string $expires_at, string $status): int {
|
||||
if ($expires_at === '' || !in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)) {
|
||||
return 0;
|
||||
}
|
||||
$left = strtotime($expires_at) - time();
|
||||
return max(0, (int) $left);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum line quantities for display (shop multi-item or single meta item).
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $order_items
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function resolve_order_quantity(array $order_items, array $meta): int {
|
||||
$qty = 0;
|
||||
$source = !empty($order_items) ? $order_items : (is_array($meta['items'] ?? null) ? $meta['items'] : []);
|
||||
foreach ($source as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$qty += max(1, (int) ($row['quantity'] ?? $row['qty'] ?? 1));
|
||||
}
|
||||
if ($qty <= 0) {
|
||||
$qty = max(1, (int) ($meta['quantity'] ?? $meta['qty'] ?? 1));
|
||||
}
|
||||
return $qty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class WeChatNotify {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('aksrt_notification_sent', [$this, 'maybe_send_wechat'], 10, 3);
|
||||
add_action('wp_ajax_aksrt_wechat_status', [$this, 'ajax_status']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信通知配置
|
||||
*/
|
||||
public function get_config(): array {
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
return \AKSRT\Settings\Settings::get_wechat_settings();
|
||||
}
|
||||
return ['enabled' => false];
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送微信模板消息
|
||||
*
|
||||
* @param int $user_id 用户 ID
|
||||
* @param string $template 模板标识
|
||||
* @param array $data 模板数据
|
||||
* @return bool
|
||||
*/
|
||||
public function send(int $user_id, string $template, array $data = []): bool {
|
||||
$config = $this->get_config();
|
||||
|
||||
if (empty($config['enabled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取用户的微信 openid
|
||||
$openid = get_user_meta($user_id, 'aksrt_wechat_openid', true);
|
||||
if (empty($openid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取 access token
|
||||
$access_token = $this->get_access_token($config);
|
||||
if (empty($access_token)) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT WeChat] Failed to get access token');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 构建模板消息
|
||||
$template_id = $config['template_id'] ?? '';
|
||||
if (empty($template_id)) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT WeChat] No template ID configured');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = $this->build_template_message($template, $data, $openid, $template_id);
|
||||
|
||||
// 调用微信模板消息 API
|
||||
$response = wp_remote_post('https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=' . $access_token, [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => wp_json_encode($message),
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('[AKSRT WeChat] API error: ' . $response->get_error_message());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
$errcode = (int) ($body['errcode'] ?? 0);
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log(sprintf('[AKSRT WeChat] User: %d, Template: %s, Result: %s', $user_id, $template, wp_json_encode($body)));
|
||||
}
|
||||
|
||||
return $errcode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信 access token(带缓存)
|
||||
*/
|
||||
private function get_access_token(array $config): string {
|
||||
$cache_key = 'aksrt_wechat_token';
|
||||
$cached = get_transient($cache_key);
|
||||
if (!empty($cached)) return (string) $cached;
|
||||
|
||||
$app_id = $config['app_id'] ?? '';
|
||||
$app_secret = $config['app_secret'] ?? '';
|
||||
if (empty($app_id) || empty($app_secret)) return '';
|
||||
|
||||
$response = wp_remote_get(sprintf(
|
||||
'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s',
|
||||
urlencode($app_id),
|
||||
urlencode($app_secret)
|
||||
));
|
||||
|
||||
if (is_wp_error($response)) return '';
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
$token = $body['access_token'] ?? '';
|
||||
$expires = max(600, (int) ($body['expires_in'] ?? 7200) - 300);
|
||||
|
||||
if (!empty($token)) {
|
||||
set_transient($cache_key, $token, $expires);
|
||||
}
|
||||
|
||||
return (string) $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建模板消息体
|
||||
*/
|
||||
private function build_template_message(string $template, array $data, string $openid, string $template_id): array {
|
||||
$url = $data['url'] ?? home_url('/');
|
||||
$values = $data['values'] ?? [];
|
||||
|
||||
$template_data = [];
|
||||
foreach ($values as $key => $value) {
|
||||
$template_data[$key] = [
|
||||
'value' => (string) $value,
|
||||
'color' => '#333333',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'touser' => $openid,
|
||||
'template_id' => $template_id,
|
||||
'url' => esc_url($url),
|
||||
'data' => $template_data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定用户微信 openid
|
||||
*/
|
||||
public function bind_user(int $user_id, string $openid): bool {
|
||||
if ($user_id <= 0 || empty($openid)) return false;
|
||||
return (bool) update_user_meta($user_id, 'aksrt_wechat_openid', sanitize_text_field($openid));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解绑用户微信
|
||||
*/
|
||||
public function unbind_user(int $user_id): bool {
|
||||
return (bool) delete_user_meta($user_id, 'aksrt_wechat_openid');
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听站内通知,联动微信通知
|
||||
*/
|
||||
public function maybe_send_wechat(int $user_id, string $type, array $data): void {
|
||||
$config = $this->get_config();
|
||||
if (empty($config['enabled'])) return;
|
||||
|
||||
// 仅特定类型通知发送微信
|
||||
$allowed_types = ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply'];
|
||||
if (!in_array($type, $allowed_types, true)) return;
|
||||
|
||||
$this->send($user_id, $type, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX: Get WeChat notification status (admin only).
|
||||
*/
|
||||
public function ajax_status(): void {
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_send_json_error(['message' => __('权限不足', 'aksrt')]);
|
||||
}
|
||||
|
||||
wp_send_json_success($this->get_status());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WeChat service status (used by REST and AJAX).
|
||||
*/
|
||||
public function get_status(): array {
|
||||
$config = $this->get_config();
|
||||
$has_token = false;
|
||||
|
||||
if (!empty($config['app_id']) && !empty($config['app_secret'])) {
|
||||
$cached = get_transient('aksrt_wechat_token');
|
||||
$has_token = !empty($cached);
|
||||
}
|
||||
|
||||
// Count bound users
|
||||
global $wpdb;
|
||||
$bound_count = 0;
|
||||
if ($wpdb) {
|
||||
$bound_count = (int) $wpdb->get_var(
|
||||
"SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_wechat_openid' AND meta_value != ''"
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !empty($config['enabled']),
|
||||
'appid' => $config['app_id'] ?? '',
|
||||
'configured' => !empty($config['app_id']) && !empty($config['app_secret']),
|
||||
'has_token' => $has_token,
|
||||
'has_template' => !empty($config['template_id']),
|
||||
'bound_users' => $bound_count,
|
||||
'app_secret' => !empty($config['app_secret']) ? '***' : '',
|
||||
'allowed_types' => ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification subscriptions for a user.
|
||||
*/
|
||||
public function get_user_subscriptions(int $user_id): array {
|
||||
$subs = get_user_meta($user_id, 'aksrt_wechat_subscriptions', true);
|
||||
if (!is_array($subs)) {
|
||||
$subs = [
|
||||
'comment_reply' => true,
|
||||
'system_notice' => true,
|
||||
'order_paid' => true,
|
||||
'credits_change' => false,
|
||||
'forum_reply' => false,
|
||||
];
|
||||
}
|
||||
return $subs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification subscriptions for a user.
|
||||
*/
|
||||
public function update_user_subscriptions(int $user_id, array $subscriptions): bool {
|
||||
$allowed = ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply'];
|
||||
$clean = [];
|
||||
foreach ($allowed as $key) {
|
||||
$clean[$key] = !empty($subscriptions[$key]);
|
||||
}
|
||||
return (bool) update_user_meta($user_id, 'aksrt_wechat_subscriptions', $clean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user should receive a specific notification type via WeChat.
|
||||
*/
|
||||
public function should_notify(int $user_id, string $type): bool {
|
||||
$config = $this->get_config();
|
||||
if (empty($config['enabled'])) return false;
|
||||
|
||||
$openid = get_user_meta($user_id, 'aksrt_wechat_openid', true);
|
||||
if (empty($openid)) return false;
|
||||
|
||||
$subs = $this->get_user_subscriptions($user_id);
|
||||
return !empty($subs[$type]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Withdrawal {
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('withdrawal');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建提现申请
|
||||
*/
|
||||
public function create(int $user_id, float $amount, string $method = '', string $account_info = ''): int {
|
||||
global $wpdb;
|
||||
|
||||
if ($user_id <= 0 || $amount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$wpdb->insert($this->table(), [
|
||||
'user_id' => $user_id,
|
||||
'amount' => $amount,
|
||||
'method' => sanitize_key($method),
|
||||
'account_info' => sanitize_textarea_field($account_info),
|
||||
'status' => self::STATUS_PENDING,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%f', '%s', '%s', '%s', '%s']);
|
||||
|
||||
return (int) $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single row.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function get(int $withdrawal_id): ?array {
|
||||
global $wpdb;
|
||||
if ($withdrawal_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d LIMIT 1", $withdrawal_id),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新提现状态(无余额副作用)。优先使用 process_status_change()。
|
||||
*/
|
||||
public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool {
|
||||
global $wpdb;
|
||||
|
||||
$update = ['status' => sanitize_key($new_status)];
|
||||
$format = ['%s'];
|
||||
|
||||
if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
|
||||
$update['processed_at'] = current_time('mysql');
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
if ($note !== '') {
|
||||
$update['note'] = sanitize_textarea_field($note);
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status transition with balance side-effects.
|
||||
* Reject of balance-source holds refunds gross_amount once (idempotent).
|
||||
*
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public function process_status_change(int $withdrawal_id, string $new_status, string $note = '') {
|
||||
$new_status = sanitize_key($new_status);
|
||||
$allowed = [
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_PROCESSING,
|
||||
self::STATUS_COMPLETED,
|
||||
self::STATUS_REJECTED,
|
||||
];
|
||||
if ($withdrawal_id <= 0 || !in_array($new_status, $allowed, true)) {
|
||||
return new \WP_Error('aksrt_withdraw_status', __('提现状态无效', 'aksrt'));
|
||||
}
|
||||
|
||||
$row = $this->get($withdrawal_id);
|
||||
if (!$row) {
|
||||
return new \WP_Error('aksrt_withdraw_missing', __('提现记录不存在', 'aksrt'));
|
||||
}
|
||||
|
||||
$old_status = sanitize_key((string) ($row['status'] ?? ''));
|
||||
if ($old_status === $new_status && $note === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
|
||||
// Refund frozen balance when moving into rejected (once).
|
||||
if (
|
||||
$new_status === self::STATUS_REJECTED
|
||||
&& $old_status !== self::STATUS_REJECTED
|
||||
&& empty($meta['balance_refunded'])
|
||||
&& $this->is_balance_source($meta)
|
||||
&& class_exists('\AKSRT\Credits\Balance')
|
||||
&& $user_id > 0
|
||||
) {
|
||||
$gross = $this->resolve_gross_amount($row, $meta);
|
||||
if ($gross > 0) {
|
||||
$refund = \AKSRT\Credits\Balance::add(
|
||||
$user_id,
|
||||
$gross,
|
||||
'withdraw_refund',
|
||||
['type' => 'withdraw', 'id' => $withdrawal_id],
|
||||
sprintf(__('提现拒绝退回 #%d', 'aksrt'), $withdrawal_id)
|
||||
);
|
||||
if (is_wp_error($refund)) {
|
||||
return $refund;
|
||||
}
|
||||
$meta['balance_refunded'] = true;
|
||||
$meta['refunded_at'] = current_time('mysql');
|
||||
}
|
||||
}
|
||||
|
||||
$account_info = $this->encode_account_meta($meta, (string) ($row['account_info'] ?? ''));
|
||||
$merged_note = $this->merge_note((string) ($row['note'] ?? ''), $note);
|
||||
|
||||
global $wpdb;
|
||||
$update = [
|
||||
'status' => $new_status,
|
||||
'account_info' => $account_info,
|
||||
];
|
||||
$format = ['%s', '%s'];
|
||||
|
||||
if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) {
|
||||
$update['processed_at'] = current_time('mysql');
|
||||
$format[] = '%s';
|
||||
}
|
||||
if ($merged_note !== '') {
|
||||
$update['note'] = $merged_note;
|
||||
$format[] = '%s';
|
||||
}
|
||||
|
||||
$ok = $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false;
|
||||
if (!$ok) {
|
||||
return new \WP_Error('aksrt_withdraw_update', __('提现状态更新失败', 'aksrt'));
|
||||
}
|
||||
|
||||
// Notify user on terminal status (Zibll withdraw_reply parity).
|
||||
if (class_exists('\AKSRT\Messages\Message') && $user_id > 0 && in_array($new_status, [self::STATUS_COMPLETED, self::STATUS_REJECTED], true)) {
|
||||
$gross = $this->resolve_gross_amount($row, $meta);
|
||||
$amount_txt = class_exists('\AKSRT\Credits\Balance') ? \AKSRT\Credits\Balance::format($gross) : number_format($gross, 2);
|
||||
if ($new_status === self::STATUS_REJECTED) {
|
||||
$title = __('提现已拒绝', 'aksrt');
|
||||
$body = sprintf(__('您申请提现 %s 元已被拒绝,冻结金额已退回钱包。', 'aksrt'), $amount_txt);
|
||||
if ($note !== '') {
|
||||
$body .= ' ' . sprintf(__('备注:%s', 'aksrt'), $note);
|
||||
}
|
||||
} else {
|
||||
$title = __('提现已完成', 'aksrt');
|
||||
$body = sprintf(__('您申请提现 %s 元已处理完成。', 'aksrt'), $amount_txt);
|
||||
}
|
||||
\AKSRT\Messages\Message::get_instance()->send($user_id, 'withdraw', $body, [
|
||||
'title' => $title,
|
||||
'target_url' => function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url('credits') : '',
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether user has open (pending/processing/approved) withdrawals.
|
||||
*/
|
||||
public function has_open_withdrawal(int $user_id): bool {
|
||||
return $this->count_open($user_id) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count open withdrawals.
|
||||
*/
|
||||
public function count_open(int $user_id): int {
|
||||
global $wpdb;
|
||||
if ($user_id <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table()}
|
||||
WHERE user_id = %d AND status IN ('pending','approved','processing')",
|
||||
$user_id
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of frozen gross amounts still open (balance source).
|
||||
*/
|
||||
public function sum_pending_hold(int $user_id): float {
|
||||
global $wpdb;
|
||||
if ($user_id <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT amount, account_info FROM {$this->table()}
|
||||
WHERE user_id = %d AND status IN ('pending','approved','processing')",
|
||||
$user_id
|
||||
), ARRAY_A);
|
||||
$total = 0.0;
|
||||
foreach ((array) $rows as $row) {
|
||||
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
|
||||
if (!$this->is_balance_source($meta) || !empty($meta['balance_refunded'])) {
|
||||
continue;
|
||||
}
|
||||
$total += $this->resolve_gross_amount($row, $meta);
|
||||
}
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户提现列表
|
||||
*
|
||||
* @param array{status?:string,limit?:int,offset?:int,open_only?:bool} $args
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function get_user_withdrawals(int $user_id, array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = ['user_id = %d'];
|
||||
$params = [$user_id];
|
||||
|
||||
if (!empty($args['open_only'])) {
|
||||
$where[] = "status IN ('pending','approved','processing')";
|
||||
} else {
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . ' ORDER BY created_at DESC LIMIT %d OFFSET %d';
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
return array_map([$this, 'normalize_row'], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有提现列表(管理后台)
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function get_all(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
if (!empty($params)) {
|
||||
$rows = (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
} else {
|
||||
$rows = (array) $wpdb->get_results($sql, ARRAY_A);
|
||||
}
|
||||
return array_map([$this, 'normalize_row'], $rows);
|
||||
}
|
||||
|
||||
public function get_count(array $args = []): int {
|
||||
global $wpdb;
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
if ($status !== '') {
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table()} WHERE status = %s",
|
||||
$status
|
||||
));
|
||||
}
|
||||
return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}");
|
||||
}
|
||||
|
||||
public function get_stats(): array {
|
||||
global $wpdb;
|
||||
|
||||
$row = $wpdb->get_row(
|
||||
"SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid
|
||||
FROM {$this->table()}",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return [
|
||||
'total' => (int) ($row['total'] ?? 0),
|
||||
'pending' => (int) ($row['pending'] ?? 0),
|
||||
'completed' => (int) ($row['completed'] ?? 0),
|
||||
'total_paid' => (float) ($row['total_paid'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function normalize_row(array $row): array {
|
||||
$meta = $this->parse_account_meta((string) ($row['account_info'] ?? ''));
|
||||
$gross = $this->resolve_gross_amount($row, $meta);
|
||||
$row['meta'] = $meta;
|
||||
$row['gross_amount'] = $gross;
|
||||
$row['fee_rate'] = (int) ($meta['fee_rate'] ?? 0);
|
||||
$row['display_account'] = (string) ($meta['account'] ?? $row['account_info'] ?? '');
|
||||
$row['source'] = (string) ($meta['source'] ?? 'balance');
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function parse_account_meta(string $raw): array {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
return is_array($decoded) ? $decoded : ['account' => $raw];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
*/
|
||||
private function encode_account_meta(array $meta, string $fallback_raw): string {
|
||||
if ($meta === []) {
|
||||
return $fallback_raw;
|
||||
}
|
||||
$json = wp_json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return is_string($json) ? $json : $fallback_raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $meta
|
||||
*/
|
||||
private function is_balance_source(array $meta): bool {
|
||||
$source = sanitize_key((string) ($meta['source'] ?? 'balance'));
|
||||
// Empty / balance = cash wallet freeze path.
|
||||
return $source === '' || $source === 'balance';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @param array<string,mixed> $meta
|
||||
*/
|
||||
private function resolve_gross_amount(array $row, array $meta): float {
|
||||
if (isset($meta['gross_amount'])) {
|
||||
return max(0, round((float) $meta['gross_amount'], 2));
|
||||
}
|
||||
// Legacy rows: amount was net or gross without meta.
|
||||
return max(0, round((float) ($row['amount'] ?? 0), 2));
|
||||
}
|
||||
|
||||
private function merge_note(string $old, string $new): string {
|
||||
$new = sanitize_textarea_field($new);
|
||||
if ($new === '') {
|
||||
return sanitize_textarea_field($old);
|
||||
}
|
||||
$old = sanitize_textarea_field($old);
|
||||
if ($old === '') {
|
||||
return $new;
|
||||
}
|
||||
return $old . "\n" . $new;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user