woo-alipay/inc/class-wc-alipay-webhook-retry.php
feibisi 7d99b65289 Add Alipay FaceToPay and Huabei gateways
Introduces new WooCommerce payment gateways for Alipay FaceToPay (QR code) and Alipay Installment (Huabei), including frontend styles, JS, PHP gateway classes, Blocks support, and SDK helpers. Enables QR code payments, installment selection, order status polling, and admin configuration for both gateways.
2025-10-04 06:42:11 +08:00

563 lines
16 KiB
PHP

<?php
/**
* Alipay Webhook Retry Class
*
* Handles recording and retrying failed Alipay notifications
*
* @package Woo_Alipay
* @since 3.3.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
class WC_Alipay_Webhook_Retry {
/**
* Table name for webhook logs
*/
const TABLE_NAME = 'woo_alipay_webhook_logs';
/**
* Maximum retry attempts (default)
*/
const MAX_RETRIES = 5;
/**
* Gateway instance
*/
private $gateway;
/**
* Constructor
*/
public function __construct() {
$this->init_hooks();
}
/**
* Initialize hooks
*/
private function init_hooks() {
// Create table on plugin activation
register_activation_hook( WOO_ALIPAY_PLUGIN_FILE, array( $this, 'create_table' ) );
// Log webhook attempts
add_action( 'woocommerce_api_wc_alipay', array( $this, 'log_webhook_attempt' ), 5 );
// Schedule retry cron
add_action( 'wp', array( $this, 'schedule_retry_cron' ) );
add_action( 'woo_alipay_retry_failed_webhooks', array( $this, 'retry_failed_webhooks' ) );
// Add admin menu for webhook logs
add_action( 'admin_menu', array( $this, 'add_admin_menu' ), 60 );
// AJAX handler for manual retry
add_action( 'wp_ajax_woo_alipay_retry_webhook', array( $this, 'ajax_retry_webhook' ) );
}
/**
* Create webhook logs table
*/
public function create_table() {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_NAME;
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS $table_name (
id bigint(20) NOT NULL AUTO_INCREMENT,
order_id bigint(20) NOT NULL,
out_trade_no varchar(64) NOT NULL,
trade_no varchar(64) DEFAULT NULL,
request_data longtext NOT NULL,
response_data text DEFAULT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
retry_count int(11) NOT NULL DEFAULT 0,
last_error text DEFAULT NULL,
created_at datetime NOT NULL,
updated_at datetime NOT NULL,
PRIMARY KEY (id),
KEY order_id (order_id),
KEY out_trade_no (out_trade_no),
KEY status (status),
KEY created_at (created_at)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
/**
* Log webhook attempt
*/
public function log_webhook_attempt() {
global $wpdb;
// Check if feature is enabled
$gateway = $this->get_gateway();
if ( ! $gateway || 'yes' !== $gateway->get_option( 'enable_webhook_retry', 'yes' ) ) {
return;
}
// Only log POST requests with data
if ( 'POST' !== $_SERVER['REQUEST_METHOD'] || empty( $_POST ) ) {
return;
}
$request_data = $_POST;
$out_trade_no = isset( $request_data['out_trade_no'] ) ? sanitize_text_field( $request_data['out_trade_no'] ) : '';
$trade_no = isset( $request_data['trade_no'] ) ? sanitize_text_field( $request_data['trade_no'] ) : '';
if ( empty( $out_trade_no ) ) {
return;
}
// Extract order ID
$out_trade_no_parts = explode( '-', str_replace( 'WooA', '', $out_trade_no ) );
$order_id = absint( array_shift( $out_trade_no_parts ) );
// Check if this webhook was already logged recently
$table_name = $wpdb->prefix . self::TABLE_NAME;
$existing = $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM $table_name
WHERE out_trade_no = %s
AND created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)
ORDER BY id DESC LIMIT 1",
$out_trade_no
) );
$now = current_time( 'mysql' );
if ( $existing ) {
// Update existing log
$wpdb->update(
$table_name,
array(
'request_data' => wp_json_encode( $request_data ),
'trade_no' => $trade_no,
'updated_at' => $now,
),
array( 'id' => $existing ),
array( '%s', '%s', '%s' ),
array( '%d' )
);
} else {
// Insert new log
$wpdb->insert(
$table_name,
array(
'order_id' => $order_id,
'out_trade_no' => $out_trade_no,
'trade_no' => $trade_no,
'request_data' => wp_json_encode( $request_data ),
'status' => 'pending',
'retry_count' => 0,
'created_at' => $now,
'updated_at' => $now,
),
array( '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s' )
);
}
}
/**
* Update webhook log status
*
* @param string $out_trade_no
* @param string $status
* @param string $error
*/
public function update_webhook_status( $out_trade_no, $status, $error = '' ) {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_NAME;
$now = current_time( 'mysql' );
$data = array(
'status' => $status,
'updated_at' => $now,
);
$format = array( '%s', '%s' );
if ( ! empty( $error ) ) {
$data['last_error'] = $error;
$format[] = '%s';
}
if ( 'failed' === $status ) {
$wpdb->query( $wpdb->prepare(
"UPDATE $table_name SET retry_count = retry_count + 1 WHERE out_trade_no = %s",
$out_trade_no
) );
}
$wpdb->update(
$table_name,
$data,
array( 'out_trade_no' => $out_trade_no ),
$format,
array( '%s' )
);
}
/**
* Schedule retry cron
*/
public function schedule_retry_cron() {
$gateway = $this->get_gateway();
// Check if feature is enabled
if ( ! $gateway || 'yes' !== $gateway->get_option( 'enable_webhook_retry', 'yes' ) ) {
// Unschedule if disabled
$timestamp = wp_next_scheduled( 'woo_alipay_retry_failed_webhooks' );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, 'woo_alipay_retry_failed_webhooks' );
}
return;
}
if ( ! wp_next_scheduled( 'woo_alipay_retry_failed_webhooks' ) ) {
// Retry every hour
wp_schedule_event( time(), 'hourly', 'woo_alipay_retry_failed_webhooks' );
}
}
/**
* Retry failed webhooks
*/
public function retry_failed_webhooks() {
global $wpdb;
// Check if feature is enabled
$gateway = $this->get_gateway();
if ( ! $gateway || 'yes' !== $gateway->get_option( 'enable_webhook_retry', 'yes' ) ) {
return;
}
$table_name = $wpdb->prefix . self::TABLE_NAME;
// Get max retries from settings
$max_retries = absint( $gateway->get_option( 'webhook_max_retries', self::MAX_RETRIES ) );
$retry_interval = absint( $gateway->get_option( 'webhook_retry_interval', '10' ) );
// Get failed webhooks that haven't exceeded max retries
$failed_webhooks = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM $table_name
WHERE status = 'failed'
AND retry_count < %d
AND updated_at < DATE_SUB(NOW(), INTERVAL %d MINUTE)
ORDER BY created_at ASC
LIMIT 10",
$max_retries,
$retry_interval
) );
foreach ( $failed_webhooks as $webhook ) {
$this->process_webhook_retry( $webhook );
// Sleep to avoid rate limiting
sleep( 2 );
}
}
/**
* Process webhook retry
*
* @param object $webhook
* @return bool
*/
private function process_webhook_retry( $webhook ) {
$order = wc_get_order( $webhook->order_id );
if ( ! $order ) {
$this->update_webhook_status( $webhook->out_trade_no, 'failed', 'Order not found' );
return false;
}
// If order is already paid, mark as success
if ( $order->is_paid() ) {
$this->update_webhook_status( $webhook->out_trade_no, 'success', '' );
return true;
}
// Query order status from Alipay
if ( class_exists( 'WC_Alipay_Order_Query' ) ) {
$query = new WC_Alipay_Order_Query();
$result = $query->query_single_order_status( $order );
if ( true === $result ) {
$this->update_webhook_status( $webhook->out_trade_no, 'success', '' );
$this->log( 'Webhook retry successful for order #' . $webhook->order_id );
return true;
} elseif ( is_wp_error( $result ) ) {
$this->update_webhook_status( $webhook->out_trade_no, 'failed', $result->get_error_message() );
$this->log( 'Webhook retry failed for order #' . $webhook->order_id . ': ' . $result->get_error_message(), 'error' );
return false;
}
}
$this->update_webhook_status( $webhook->out_trade_no, 'failed', 'Unable to confirm payment status' );
return false;
}
/**
* Add admin menu
*/
public function add_admin_menu() {
// Check if feature is enabled
$gateway = $this->get_gateway();
if ( ! $gateway || 'yes' !== $gateway->get_option( 'enable_webhook_retry', 'yes' ) ) {
return;
}
add_submenu_page(
'woocommerce',
__( 'Alipay Webhook Logs', 'woo-alipay' ),
__( 'Alipay Webhooks', 'woo-alipay' ),
'manage_woocommerce',
'woo-alipay-webhooks',
array( $this, 'render_admin_page' )
);
}
/**
* Render admin page
*/
public function render_admin_page() {
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_NAME;
// Handle bulk actions
if ( isset( $_POST['action'] ) && 'retry_selected' === $_POST['action'] && ! empty( $_POST['webhook_ids'] ) ) {
check_admin_referer( 'bulk-webhooks' );
foreach ( $_POST['webhook_ids'] as $webhook_id ) {
$webhook = $wpdb->get_row( $wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
absint( $webhook_id )
) );
if ( $webhook ) {
$this->process_webhook_retry( $webhook );
}
}
echo '<div class="notice notice-success"><p>' . __( 'Selected webhooks have been retried.', 'woo-alipay' ) . '</p></div>';
}
// Get statistics
$total = $wpdb->get_var( "SELECT COUNT(*) FROM $table_name" );
$pending = $wpdb->get_var( "SELECT COUNT(*) FROM $table_name WHERE status = 'pending'" );
$success = $wpdb->get_var( "SELECT COUNT(*) FROM $table_name WHERE status = 'success'" );
$failed = $wpdb->get_var( "SELECT COUNT(*) FROM $table_name WHERE status = 'failed'" );
// Get recent webhooks
$current_status = isset( $_GET['status'] ) ? sanitize_text_field( $_GET['status'] ) : 'all';
$per_page = 20;
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$offset = ( $paged - 1 ) * $per_page;
$where = '';
if ( 'all' !== $current_status ) {
$where = $wpdb->prepare( "WHERE status = %s", $current_status );
}
$webhooks = $wpdb->get_results(
"SELECT * FROM $table_name $where ORDER BY created_at DESC LIMIT $per_page OFFSET $offset"
);
$total_items = $wpdb->get_var( "SELECT COUNT(*) FROM $table_name $where" );
$total_pages = ceil( $total_items / $per_page );
?>
<div class="wrap">
<h1><?php _e( 'Alipay Webhook Logs', 'woo-alipay' ); ?></h1>
<ul class="subsubsub">
<li><a href="?page=woo-alipay-webhooks&status=all" <?php echo 'all' === $current_status ? 'class="current"' : ''; ?>><?php printf( __( 'All (%d)', 'woo-alipay' ), $total ); ?></a> |</li>
<li><a href="?page=woo-alipay-webhooks&status=pending" <?php echo 'pending' === $current_status ? 'class="current"' : ''; ?>><?php printf( __( 'Pending (%d)', 'woo-alipay' ), $pending ); ?></a> |</li>
<li><a href="?page=woo-alipay-webhooks&status=success" <?php echo 'success' === $current_status ? 'class="current"' : ''; ?>><?php printf( __( 'Success (%d)', 'woo-alipay' ), $success ); ?></a> |</li>
<li><a href="?page=woo-alipay-webhooks&status=failed" <?php echo 'failed' === $current_status ? 'class="current"' : ''; ?>><?php printf( __( 'Failed (%d)', 'woo-alipay' ), $failed ); ?></a></li>
</ul>
<form method="post">
<?php wp_nonce_field( 'bulk-webhooks' ); ?>
<input type="hidden" name="action" value="retry_selected" />
<div class="tablenav top">
<div class="alignleft actions">
<input type="submit" class="button action" value="<?php _e( 'Retry Selected', 'woo-alipay' ); ?>" />
</div>
</div>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<td class="check-column"><input type="checkbox" /></td>
<th><?php _e( 'Order', 'woo-alipay' ); ?></th>
<th><?php _e( 'Trade No', 'woo-alipay' ); ?></th>
<th><?php _e( 'Status', 'woo-alipay' ); ?></th>
<th><?php _e( 'Retry Count', 'woo-alipay' ); ?></th>
<th><?php _e( 'Error', 'woo-alipay' ); ?></th>
<th><?php _e( 'Created', 'woo-alipay' ); ?></th>
<th><?php _e( 'Actions', 'woo-alipay' ); ?></th>
</tr>
</thead>
<tbody>
<?php if ( empty( $webhooks ) ) : ?>
<tr><td colspan="8"><?php _e( 'No webhook logs found.', 'woo-alipay' ); ?></td></tr>
<?php else : ?>
<?php foreach ( $webhooks as $webhook ) : ?>
<tr>
<th class="check-column">
<input type="checkbox" name="webhook_ids[]" value="<?php echo esc_attr( $webhook->id ); ?>" />
</th>
<td>
<a href="<?php echo esc_url( admin_url( 'post.php?post=' . $webhook->order_id . '&action=edit' ) ); ?>">
#<?php echo esc_html( $webhook->order_id ); ?>
</a>
</td>
<td><?php echo esc_html( $webhook->trade_no ? $webhook->trade_no : '-' ); ?></td>
<td>
<span class="status-<?php echo esc_attr( $webhook->status ); ?>">
<?php echo esc_html( ucfirst( $webhook->status ) ); ?>
</span>
</td>
<td><?php echo esc_html( $webhook->retry_count ); ?> / <?php echo self::MAX_RETRIES; ?></td>
<td><?php echo esc_html( $webhook->last_error ? substr( $webhook->last_error, 0, 50 ) . '...' : '-' ); ?></td>
<td><?php echo esc_html( $webhook->created_at ); ?></td>
<td>
<?php if ( 'failed' === $webhook->status && $webhook->retry_count < self::MAX_RETRIES ) : ?>
<button type="button" class="button button-small woo-alipay-retry-webhook" data-webhook-id="<?php echo esc_attr( $webhook->id ); ?>">
<?php _e( 'Retry', 'woo-alipay' ); ?>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<?php if ( $total_pages > 1 ) : ?>
<div class="tablenav bottom">
<div class="tablenav-pages">
<?php
echo paginate_links( array(
'base' => add_query_arg( 'paged', '%#%' ),
'format' => '',
'current' => $paged,
'total' => $total_pages,
) );
?>
</div>
</div>
<?php endif; ?>
</form>
</div>
<script>
jQuery(document).ready(function($) {
$('.woo-alipay-retry-webhook').on('click', function() {
var $btn = $(this);
var webhookId = $btn.data('webhook-id');
$btn.prop('disabled', true).text('<?php _e( 'Retrying...', 'woo-alipay' ); ?>');
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'woo_alipay_retry_webhook',
webhook_id: webhookId,
nonce: '<?php echo wp_create_nonce( 'woo_alipay_retry_webhook' ); ?>'
},
success: function(response) {
if (response.success) {
location.reload();
} else {
alert(response.data.message);
$btn.prop('disabled', false).text('<?php _e( 'Retry', 'woo-alipay' ); ?>');
}
}
});
});
});
</script>
<style>
.status-pending { color: #f0ad4e; }
.status-success { color: #5cb85c; }
.status-failed { color: #d9534f; }
</style>
<?php
}
/**
* AJAX handler for manual retry
*/
public function ajax_retry_webhook() {
check_ajax_referer( 'woo_alipay_retry_webhook', 'nonce' );
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_send_json_error( array( 'message' => __( 'Permission denied', 'woo-alipay' ) ) );
}
$webhook_id = isset( $_POST['webhook_id'] ) ? absint( $_POST['webhook_id'] ) : 0;
if ( ! $webhook_id ) {
wp_send_json_error( array( 'message' => __( 'Invalid webhook ID', 'woo-alipay' ) ) );
}
global $wpdb;
$table_name = $wpdb->prefix . self::TABLE_NAME;
$webhook = $wpdb->get_row( $wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d",
$webhook_id
) );
if ( ! $webhook ) {
wp_send_json_error( array( 'message' => __( 'Webhook not found', 'woo-alipay' ) ) );
}
$result = $this->process_webhook_retry( $webhook );
if ( $result ) {
wp_send_json_success( array( 'message' => __( 'Webhook retried successfully', 'woo-alipay' ) ) );
} else {
wp_send_json_error( array( 'message' => __( 'Webhook retry failed', 'woo-alipay' ) ) );
}
}
/**
* Get gateway instance
*
* @return WC_Alipay|null
*/
private function get_gateway() {
if ( ! $this->gateway ) {
$gateways = WC()->payment_gateways->payment_gateways();
$this->gateway = isset( $gateways['alipay'] ) ? $gateways['alipay'] : null;
}
return $this->gateway;
}
/**
* Log message
*
* @param string $message
* @param string $level
*/
private function log( $message, $level = 'info' ) {
$logger = wc_get_logger();
$logger->log( $level, $message, array( 'source' => 'alipay-webhook' ) );
}
}
// Initialize the class
new WC_Alipay_Webhook_Retry();