fix(payment): harden payment and refund chains

This commit is contained in:
2026-07-26 02:29:46 +08:00
parent c70fbe9835
commit 53ff99f343
24 changed files with 993 additions and 251 deletions
+1 -7
View File
@@ -41,20 +41,14 @@ class PayAfterSale {
$ok = false;
if ($order_number !== '' && class_exists('\AKSRT\Payment\AfterSale')) {
$user_id = isset($_POST['user_id']) ? (int) $_POST['user_id'] : 0;
if ($user_id <= 0 && class_exists('\AKSRT\Payment\OrderRepository')) {
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
$user_id = (int) ($order['user_id'] ?? 0);
}
$id = \AKSRT\Payment\AfterSale::get_instance()->create([
'order_number' => $order_number,
'user_id' => $user_id,
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'refund')),
'reason' => sanitize_textarea_field((string) wp_unslash($_POST['reason'] ?? '')),
'amount' => (float) ($_POST['amount'] ?? 0),
'note' => sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')),
]);
$ok = $id > 0;
$ok = !is_wp_error($id) && $id > 0;
}
$msg = $ok ? 'after_sale_created' : 'after_sale_create_failed';
+1 -1
View File
@@ -258,7 +258,7 @@ class SchemaPayment {
]],
['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'expandWhen' => 'payment_alipay_enabled', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true], 'fields' => [
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => '', 'dependsOn' => ['key' => 'payment_alipay_enabled', 'value' => true]],
['key' => 'alipay_payment_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'page', 'options' => [
['value' => 'page', 'label' => '电脑网站支付(web)'],
['value' => 'wap', 'label' => '手机网站支付(wap)'],
+1 -1
View File
@@ -318,7 +318,7 @@ class SchemaUsers {
]],
['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [
['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''],
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'input', 'default' => ''],
['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'textarea', 'sanitize_type' => 'code', 'default' => ''],
['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'input', 'default' => ''],
]],
],
@@ -82,13 +82,12 @@ trait ShopControllerFulfillmentOperations {
return $this->error(__('订单不存在', 'aksrt'), 404);
}
$params['user_id'] = (int) ($params['user_id'] ?? ($order['user_id'] ?? 0));
if ($params['user_id'] <= 0) {
return $this->error(__('订单未绑定有效用户', 'aksrt'));
}
unset($params['user_id'], $params['applicant_user_id']);
$id = \AKSRT\Payment\AfterSale::get_instance()->create($params);
if (is_wp_error($id)) {
return $this->error($id->get_error_message(), 422);
}
if ($id > 0) {
return $this->success([
'message' => __('售后工单已创建', 'aksrt'),
+1 -1
View File
@@ -12,7 +12,7 @@ class DBMigrator {
use DBMigratorPart3;
/** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '1.15.0';
const SCHEMA_VERSION = '1.16.0';
const OPT_KEY = 'aksrt_db_schema_version';
+21 -7
View File
@@ -337,18 +337,32 @@ trait DBMigratorPart2 {
type VARCHAR(20) NOT NULL DEFAULT 'refund',
reason TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
note TEXT DEFAULT NULL,
operator_id BIGINT UNSIGNED DEFAULT NULL,
operator_note TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
updated_at DATETIME DEFAULT NULL,
resolved_at DATETIME DEFAULT NULL,
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
note TEXT DEFAULT NULL,
operator_id BIGINT UNSIGNED DEFAULT NULL,
operator_note TEXT DEFAULT NULL,
refund_request_id VARCHAR(64) DEFAULT NULL,
provider VARCHAR(32) NOT NULL DEFAULT '',
provider_refund_id VARCHAR(128) NOT NULL DEFAULT '',
provider_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
refund_effect_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
refund_error TEXT DEFAULT NULL,
refund_attempts INT UNSIGNED NOT NULL DEFAULT 0,
refund_requested_at DATETIME DEFAULT NULL,
provider_updated_at DATETIME DEFAULT NULL,
refund_succeeded_at DATETIME DEFAULT NULL,
refund_effected_at DATETIME DEFAULT NULL,
order_status_before VARCHAR(20) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
updated_at DATETIME DEFAULT NULL,
resolved_at DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uniq_refund_request (refund_request_id),
KEY idx_order (order_number),
KEY idx_user (user_id),
KEY idx_status (status),
KEY idx_type (type),
KEY idx_provider_status (provider_status),
KEY idx_created (created_at)
) {$charset_collate};";
dbDelta($sql_after_sale);
+462 -82
View File
@@ -31,6 +31,17 @@ class AfterSale {
const PROGRESS_WAITING_USER_RECEIVE = 3;
const PROGRESS_DONE = 4;
public function init(): void {
add_action('aksrt_reconcile_refunds', [$this, 'reconcile_pending_refunds']);
add_action('aksrt_after_sale_auto_cancel', [$this, 'run_auto_cancel_cron']);
if (!wp_next_scheduled('aksrt_reconcile_refunds')) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'hourly', 'aksrt_reconcile_refunds');
}
if (!wp_next_scheduled('aksrt_after_sale_auto_cancel')) {
wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', 'aksrt_after_sale_auto_cancel');
}
}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
@@ -44,24 +55,89 @@ class AfterSale {
/**
* 创建售后工单
*
* @return int|\WP_Error
*/
public function create(array $data): int {
public function create(array $data) {
global $wpdb;
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
$user_id = (int) ($data['user_id'] ?? 0);
$applicant_id = (int) ($data['applicant_user_id'] ?? $data['user_id'] ?? 0);
$type = sanitize_key((string) ($data['type'] ?? self::TYPE_REFUND));
$reason = sanitize_textarea_field((string) ($data['reason'] ?? ''));
$amount = (float) ($data['amount'] ?? 0);
$amount = round((float) ($data['amount'] ?? 0), 2);
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
if ($order_number === '' || $user_id <= 0 || $reason === '') {
return 0;
if ($order_number === '' || $reason === '') {
return new \WP_Error('aksrt_after_sale_invalid', __('售后申请参数不完整。', 'aksrt'));
}
if (!array_key_exists($type, self::get_valid_types())) {
return new \WP_Error('aksrt_after_sale_type', __('售后类型无效。', 'aksrt'));
}
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
return new \WP_Error('aksrt_after_sale_order_service', __('订单服务不可用。', 'aksrt'));
}
$valid_types = [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_EXCHANGE, self::TYPE_REPAIR, self::TYPE_WARRANTY, self::TYPE_INSURED_PRICE, self::TYPE_REPLACEMENT];
if (!in_array($type, $valid_types, true)) {
$type = self::TYPE_REFUND;
$order = OrderRepository::get_instance()->get($order_number);
if (!$order) {
return new \WP_Error('aksrt_after_sale_order', __('订单不存在。', 'aksrt'));
}
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0 || ($applicant_id > 0 && $applicant_id !== $user_id)) {
return new \WP_Error('aksrt_after_sale_owner', __('无权为该订单申请售后。', 'aksrt'));
}
if (!$this->can_apply_after_sale($order, $type)) {
return new \WP_Error('aksrt_after_sale_state', __('当前订单状态不支持该售后类型。', 'aksrt'));
}
if (!$this->within_application_window($order, $type)) {
return new \WP_Error('aksrt_after_sale_expired', __('该订单已超过售后申请期限。', 'aksrt'));
}
$refund_type = $this->is_refund_type($type);
if ($refund_type && $amount <= 0) {
return new \WP_Error('aksrt_after_sale_amount', __('退款金额必须大于零。', 'aksrt'));
}
if (!$refund_type) {
$amount = 0.0;
}
$creation_lock = 'aksrt_as_' . substr(hash('sha256', $order_number), 0, 48);
$lock_acquired = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $creation_lock));
if ($lock_acquired !== 1) {
return new \WP_Error('aksrt_after_sale_busy', __('该订单正在处理售后申请,请稍后重试。', 'aksrt'));
}
$active_statuses = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED];
$placeholders = implode(', ', array_fill(0, count($active_statuses), '%s'));
$active_sql = "SELECT COUNT(*) FROM {$this->table()} WHERE order_number = %s AND status IN ({$placeholders})";
$active = (int) $wpdb->get_var($wpdb->prepare($active_sql, array_merge([$order_number], $active_statuses)));
if ($active > 0) {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
return new \WP_Error('aksrt_after_sale_active', __('该订单已有处理中的售后申请。', 'aksrt'));
}
if ($refund_type) {
$reserved_sql = "SELECT COALESCE(SUM(amount), 0) FROM {$this->table()}
WHERE order_number = %s
AND type IN (%s, %s, %s)
AND status IN (%s, %s, %s, %s)";
$reserved = (float) $wpdb->get_var($wpdb->prepare(
$reserved_sql,
$order_number,
self::TYPE_REFUND,
self::TYPE_REFUND_RETURN,
self::TYPE_INSURED_PRICE,
self::STATUS_PENDING,
self::STATUS_PROCESSING,
self::STATUS_APPROVED,
self::STATUS_COMPLETED
));
$remaining = max(0.0, round((float) ($order['amount'] ?? 0) - $reserved, 2));
if ($amount > $remaining) {
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
return new \WP_Error('aksrt_after_sale_excess', __('退款金额超过订单剩余可退金额。', 'aksrt'));
}
}
$result = $wpdb->insert($this->table(), [
@@ -70,13 +146,15 @@ class AfterSale {
'type' => $type,
'reason' => $reason,
'status' => self::STATUS_PENDING,
'amount' => $amount,
'note' => $note,
'created_at' => current_time('mysql'),
'amount' => $amount,
'note' => $note,
'order_status_before' => sanitize_key((string) ($order['status'] ?? '')),
'created_at' => current_time('mysql'),
]);
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $creation_lock));
if (!$result) {
return 0;
return new \WP_Error('aksrt_after_sale_db', __('售后申请创建失败。', 'aksrt'));
}
$ticket_id = (int) $wpdb->insert_id;
$this->append_event($ticket_id, 'created', __('用户提交售后申请', 'aksrt'), $user_id);
@@ -506,126 +584,241 @@ class AfterSale {
}
/**
* R69: Execute actual refund — credit back user balance / points.
* R69: Execute local refund effects and sales rollback once.
*
* @return true|\WP_Error
*/
private function execute_refund(?array $order, float $refund_amount): void {
private function execute_refund(?array $order, float $refund_amount) {
if (!$order || $refund_amount <= 0) {
return;
return new \WP_Error('aksrt_refund_order', __('退款订单或金额无效。', 'aksrt'));
}
$user_id = (int) ($order['user_id'] ?? 0);
if ($user_id <= 0) {
return;
return new \WP_Error('aksrt_refund_user', __('退款用户无效。', 'aksrt'));
}
$payment_method = (string) ($order['payment_method'] ?? 'balance');
$order_number = (string) ($order['order_number'] ?? '');
// Balance refund: prefer cash wallet, fall back to points.
if ($payment_method === 'balance') {
if (class_exists('\AKSRT\Credits\Balance') && \AKSRT\Credits\Balance::enabled()) {
\AKSRT\Credits\Balance::add(
$user_id,
(float) $refund_amount,
'order_refund',
['type' => 'order', 'order' => $order_number],
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
);
} elseif (class_exists('\AKSRT\Credits\Credits')) {
\AKSRT\Credits\Credits::add(
$user_id,
(int) round($refund_amount),
'order_refund',
['order_number' => $order_number, 'type' => 'refund'],
sprintf(__('售后退款金额 ¥%s', 'aksrt'), number_format($refund_amount, 2))
);
if (!class_exists('\AKSRT\Credits\Balance') || !\AKSRT\Credits\Balance::enabled()) {
return new \WP_Error('aksrt_refund_balance', __('余额退款服务不可用。', 'aksrt'));
}
$credited = \AKSRT\Credits\Balance::add(
$user_id,
$refund_amount,
'order_refund',
['type' => 'order', 'order' => $order_number],
sprintf(__('售后退款:订单 %s', 'aksrt'), $order_number)
);
if (is_wp_error($credited)) {
return $credited;
}
} elseif ($payment_method === 'points') {
if (!class_exists('\AKSRT\Credits\Credits') || floor($refund_amount) !== $refund_amount) {
return new \WP_Error('aksrt_refund_points', __('积分退款金额无效或积分服务不可用。', 'aksrt'));
}
$credited = \AKSRT\Credits\Credits::add(
$user_id,
(int) $refund_amount,
'order_refund',
['order_number' => $order_number, 'type' => 'refund'],
sprintf(__('售后退回积分:订单 %s', 'aksrt'), $order_number)
);
if (is_wp_error($credited)) {
return $credited;
}
} elseif (!in_array($payment_method, ['wechatpay', 'alipay', 'stripe'], true)) {
return new \WP_Error('aksrt_refund_gateway', __('当前支付方式不支持自动退款。', 'aksrt'));
}
// Full-order refunds roll back sales once after the monetary refund succeeds.
if (abs((float) ($order['amount'] ?? 0) - $refund_amount) <= 0.01) {
$meta = json_decode((string) ($order['note'] ?? ''), true);
$items = is_array($meta) ? ($meta['items'] ?? []) : [];
if (empty($items) && !empty($order['product_id'])) {
$items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]];
}
foreach ($items as $item) {
$pid = (int) ($item['product_id'] ?? 0);
$qty = max(1, (int) ($item['quantity'] ?? 1));
if ($pid <= 0) {
continue;
}
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty));
}
}
// Deduct sales_volume for refunded items
$meta = json_decode((string) ($order['note'] ?? ''), true);
$items = is_array($meta) ? ($meta['items'] ?? []) : [];
if (empty($items) && !empty($order['product_id'])) {
$items = [['product_id' => (int) $order['product_id'], 'quantity' => 1]];
}
foreach ($items as $item) {
$pid = (int) ($item['product_id'] ?? 0);
$qty = max(1, (int) ($item['quantity'] ?? 1));
if ($pid <= 0) {
continue;
}
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
update_post_meta($pid, '_aksrt_sales_volume', max(0, $sales - $qty));
}
return true;
}
/**
* Fires after refund execution.
*
* @param string $order_number
* @param int $user_id
* @param float $refund_amount
* @param string $payment_method
*/
private function publish_refund_event(array $order, float $refund_amount): void {
$order_number = (string) ($order['order_number'] ?? '');
$user_id = (int) ($order['user_id'] ?? 0);
$payment_method = (string) ($order['payment_method'] ?? '');
do_action('aksrt_shop_refund_executed', $order_number, $user_id, $refund_amount, $payment_method);
}
/**
* Execute an online-channel refund before completing the after-sale ticket.
* Execute and persist a refund before completing the after-sale ticket.
*/
private function complete_refund(array $record): bool {
global $wpdb;
$type = (string) ($record['type'] ?? '');
if (!in_array($type, [self::TYPE_REFUND, self::TYPE_REFUND_RETURN, self::TYPE_INSURED_PRICE], true)) {
if (!$this->is_refund_type($type)) {
return true;
}
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
return false;
}
$ticket_id = (int) ($record['id'] ?? 0);
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
$order = OrderRepository::get_instance()->get($order_number);
if (!$order) {
$refund_amount = (float) ($record['amount'] ?? 0);
if ($ticket_id <= 0 || !$order || $refund_amount <= 0) {
return false;
}
$payment_method = (string) ($order['payment_method'] ?? 'balance');
if (!in_array($payment_method, ['wechatpay', 'alipay'], true)) {
$latest = $this->get_by_id($ticket_id) ?: $record;
if (($latest['refund_effect_status'] ?? '') === 'succeeded') {
return true;
}
$refund_amount = (float) ($record['amount'] ?? 0);
if ($refund_amount <= 0) {
if (($latest['provider_status'] ?? '') === 'succeeded'
&& ($latest['refund_effect_status'] ?? '') === 'processing') {
return false;
}
$request_id = sprintf(
'AS_%d_%s',
(int) ($record['id'] ?? 0),
substr(hash('sha256', $order_number), 0, 16)
);
return $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id);
$payment_method = sanitize_key((string) ($order['payment_method'] ?? 'balance'));
$request_id = (string) ($latest['refund_request_id'] ?? '');
if ($request_id === '') {
$request_id = sprintf('as_%d_%s', $ticket_id, substr(hash('sha256', $order_number), 0, 16));
}
$provider_status = (string) ($latest['provider_status'] ?? 'not_started');
if ($provider_status !== 'succeeded') {
$claimed = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()}
SET refund_request_id = %s, provider = %s, provider_status = 'submitting',
refund_attempts = refund_attempts + 1, refund_requested_at = COALESCE(refund_requested_at, %s),
provider_updated_at = %s, refund_error = NULL, updated_at = %s
WHERE id = %d AND provider_status IN ('not_started', 'failed', 'pending', 'unknown')",
$request_id,
$payment_method,
current_time('mysql'),
current_time('mysql'),
current_time('mysql'),
$ticket_id
));
if ($claimed !== 1) {
return false;
}
if (in_array($payment_method, ['wechatpay', 'alipay', 'stripe'], true)) {
$result = $this->refund_via_gateway($payment_method, $order_number, $refund_amount, $request_id);
$status = sanitize_key((string) ($result['status'] ?? (!empty($result['success']) ? 'unknown' : 'failed')));
if (!in_array($status, ['pending', 'succeeded', 'failed', 'unknown'], true)) {
$status = 'unknown';
}
$wpdb->update($this->table(), [
'refund_request_id' => sanitize_text_field((string) ($result['request_id'] ?? $request_id)),
'provider_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? '')),
'provider_status' => $status,
'refund_error' => empty($result['success']) ? sanitize_textarea_field((string) ($result['message'] ?? '')) : null,
'provider_updated_at' => current_time('mysql'),
'refund_succeeded_at' => $status === 'succeeded' ? current_time('mysql') : null,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
if ($status !== 'succeeded') {
return false;
}
} elseif (in_array($payment_method, ['balance', 'points'], true)) {
$wpdb->update($this->table(), [
'provider_status' => 'succeeded',
'provider_updated_at' => current_time('mysql'),
'refund_succeeded_at' => current_time('mysql'),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
} else {
$wpdb->update($this->table(), [
'provider_status' => 'failed',
'refund_error' => __('当前支付方式不支持自动退款。', 'aksrt'),
'provider_updated_at' => current_time('mysql'),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
return false;
}
}
$wpdb->query('START TRANSACTION');
$effect_claimed = $wpdb->query($wpdb->prepare(
"UPDATE {$this->table()}
SET refund_effect_status = 'processing', updated_at = %s
WHERE id = %d AND refund_effect_status IN ('not_started', 'failed')",
current_time('mysql'),
$ticket_id
));
if ($effect_claimed !== 1) {
$wpdb->query('ROLLBACK');
return false;
}
$effect = $this->execute_refund($order, $refund_amount);
if (is_wp_error($effect)) {
$wpdb->query('ROLLBACK');
$wpdb->update($this->table(), [
'refund_effect_status' => 'failed',
'refund_error' => $effect->get_error_message(),
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
$this->log_refund_error($order_number, $payment_method, $effect->get_error_message());
return false;
}
$effect_saved = $wpdb->update($this->table(), [
'refund_effect_status' => 'succeeded',
'refund_effected_at' => current_time('mysql'),
'refund_error' => null,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
if ($effect_saved === false) {
$wpdb->query('ROLLBACK');
return false;
}
$committed = $wpdb->query('COMMIT');
if ($committed === false) {
return false;
}
$this->publish_refund_event($order, $refund_amount);
return true;
}
private function refund_via_gateway(string $payment_method, string $order_number, float $refund_amount, string $request_id): bool {
/**
* @return array{success:bool,status:string,refund_id?:string,message?:string}
*/
private function refund_via_gateway(string $payment_method, string $order_number, float $refund_amount, string $request_id): array {
$gateway = Gateway::get_instance()->get_gateway($payment_method);
if (!$gateway || !method_exists($gateway, 'refund')) {
if (!$gateway) {
$this->log_refund_error($order_number, $payment_method, 'gateway unavailable');
return false;
return ['success' => false, 'status' => 'failed', 'message' => 'gateway unavailable'];
}
try {
$result = $gateway->refund($order_number, $refund_amount, $request_id);
} catch (\Throwable $e) {
$this->log_refund_error($order_number, $payment_method, $e->getMessage());
return false;
return ['success' => false, 'status' => 'unknown', 'message' => $e->getMessage()];
}
if (empty($result['success'])) {
$this->log_refund_error($order_number, $payment_method, (string) ($result['message'] ?? 'unknown error'));
return false;
$result['status'] = $result['status'] ?? 'unknown';
}
return true;
return $result;
}
private function log_refund_error(string $order_number, string $payment_method, string $message): void {
@@ -656,6 +849,15 @@ class AfterSale {
$repository = OrderRepository::get_instance();
if (in_array($new_status, [self::STATUS_PROCESSING, self::STATUS_APPROVED], true)) {
$order = $repository->get($order_number);
$current_status = sanitize_key((string) ($order['status'] ?? ''));
if ($current_status !== '' && $current_status !== Order::STATUS_REFUNDING) {
global $wpdb;
$wpdb->update($this->table(), [
'order_status_before' => $current_status,
'updated_at' => current_time('mysql'),
], ['id' => (int) ($record['id'] ?? 0)]);
}
$repository->update_status($order_number, Order::STATUS_REFUNDING, [
'note' => $note,
]);
@@ -664,17 +866,49 @@ class AfterSale {
if ($new_status === self::STATUS_COMPLETED) {
$order = $repository->get($order_number);
if (!$order) {
return;
}
if (($order['status'] ?? '') !== Order::STATUS_REFUNDING) {
$repository->update_status($order_number, Order::STATUS_REFUNDING, [
'note' => $note,
]);
$order = $repository->get($order_number) ?: $order;
}
$this->execute_refund($order, (float) ($record['amount'] ?? 0));
$repository->update_status($order_number, Order::STATUS_REFUNDED, [
'note' => $note,
]);
$refund_amount = (float) ($record['amount'] ?? 0);
global $wpdb;
$completed_other = (float) $wpdb->get_var($wpdb->prepare(
"SELECT COALESCE(SUM(amount), 0) FROM {$this->table()}
WHERE order_number = %s AND id <> %d
AND type IN (%s, %s, %s) AND status = %s",
$order_number,
(int) ($record['id'] ?? 0),
self::TYPE_REFUND,
self::TYPE_REFUND_RETURN,
self::TYPE_INSURED_PRICE,
self::STATUS_COMPLETED
));
$total_refunded = round($completed_other + $refund_amount, 2);
if ($total_refunded + 0.01 >= (float) ($order['amount'] ?? 0)) {
$repository->update_status($order_number, Order::STATUS_REFUNDED, [
'note' => $note,
]);
} else {
$previous_status = sanitize_key((string) ($record['order_status_before'] ?? ''));
if (!in_array($previous_status, [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
], true)) {
$previous_status = Order::STATUS_PAID;
}
$repository->update_status($order_number, $previous_status, [
'force' => true,
'note' => __('部分退款已完成。', 'aksrt'),
]);
}
}
}
@@ -782,6 +1016,7 @@ class AfterSale {
'status' => self::STATUS_CANCELLED,
'updated_at' => current_time('mysql'),
], ['id' => $ticket_id]);
$this->restore_order_after_cancel($record);
$msg = $reason !== ''
? sprintf(__('用户取消售后:%s', 'aksrt'), sanitize_text_field($reason))
@@ -903,6 +1138,33 @@ class AfterSale {
* Shipping status window + type constraints (§6.8)
* ============================================================ */
private function is_refund_type(string $type): bool {
return in_array($type, [
self::TYPE_REFUND,
self::TYPE_REFUND_RETURN,
self::TYPE_INSURED_PRICE,
], true);
}
private function within_application_window(array $order, string $type): bool {
$days = (int) ($this->get_type_timeouts()[$type] ?? 0);
if ($days <= 0) {
return true;
}
$base = (string) ($order['completed_at']
?? $order['delivered_at']
?? $order['paid_at']
?? $order['created_at']
?? '');
$timestamp = $base !== '' ? strtotime($base) : false;
if ($timestamp === false) {
return true;
}
return $timestamp >= current_time('timestamp') - ($days * DAY_IN_SECONDS);
}
public function can_apply_after_sale(array $order, string $type): bool {
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [
@@ -944,6 +1206,123 @@ class AfterSale {
];
}
private function restore_order_after_cancel(array $record): void {
if (!$this->is_refund_type((string) ($record['type'] ?? ''))
|| !class_exists('AKSRT\\Payment\\OrderRepository')) {
return;
}
$order_number = sanitize_text_field((string) ($record['order_number'] ?? ''));
$order = OrderRepository::get_instance()->get($order_number);
if (!$order || ($order['status'] ?? '') !== Order::STATUS_REFUNDING) {
return;
}
$previous = sanitize_key((string) ($record['order_status_before'] ?? ''));
if (!in_array($previous, [
Order::STATUS_PAID,
Order::STATUS_DELIVERED,
Order::STATUS_COMPLETED,
Order::STATUS_PENDING_REVIEW,
], true)) {
$previous = Order::STATUS_PAID;
}
OrderRepository::get_instance()->update_status($order_number, $previous, [
'force' => true,
'note' => __('售后申请已取消,恢复订单状态。', 'aksrt'),
]);
}
public function reconcile_pending_refunds(): void {
global $wpdb;
$submitting_cutoff = date('Y-m-d H:i:s', current_time('timestamp') - (15 * MINUTE_IN_SECONDS));
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$this->table()}
WHERE (
provider_status IN ('pending', 'unknown')
OR (provider_status = 'submitting' AND provider_updated_at < %s)
OR (provider_status = 'succeeded' AND refund_effect_status = 'processing' AND updated_at < %s)
)
AND status IN ('processing', 'approved')
ORDER BY provider_updated_at ASC
LIMIT 50",
$submitting_cutoff,
$submitting_cutoff
),
ARRAY_A
);
foreach ((array) $rows as $record) {
$provider_status = (string) ($record['provider_status'] ?? '');
$effect_status = (string) ($record['refund_effect_status'] ?? '');
if ($provider_status === 'succeeded' && $effect_status === 'processing') {
$wpdb->update($this->table(), [
'refund_effect_status' => 'failed',
'refund_error' => __('本地退款效果状态不确定,请人工核对余额或积分流水后重试。', 'aksrt'),
'updated_at' => current_time('mysql'),
], ['id' => (int) $record['id']]);
continue;
}
$provider = sanitize_key((string) ($record['provider'] ?? ''));
$request_id = (string) ($record['refund_request_id'] ?? '');
$gateway = Gateway::get_instance()->get_gateway($provider);
if (!$gateway || $request_id === '' || !method_exists($gateway, 'query_refund')) {
continue;
}
try {
$result = $gateway->query_refund(
$request_id,
(string) ($record['order_number'] ?? '')
);
} catch (\Throwable $e) {
continue;
}
$status = sanitize_key((string) ($result['status'] ?? 'unknown'));
if (!in_array($status, ['pending', 'succeeded', 'failed', 'unknown'], true)) {
$status = 'unknown';
}
$wpdb->update($this->table(), [
'provider_status' => $status,
'provider_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $record['provider_refund_id'] ?? '')),
'refund_error' => $status === 'failed' ? sanitize_textarea_field((string) ($result['message'] ?? '')) : null,
'provider_updated_at' => current_time('mysql'),
'refund_succeeded_at' => $status === 'succeeded' ? current_time('mysql') : null,
'updated_at' => current_time('mysql'),
], ['id' => (int) $record['id']]);
if ($status === 'unknown'
&& (int) ($record['refund_attempts'] ?? 0) < 3
&& in_array($provider_status, ['submitting', 'unknown'], true)) {
$wpdb->update($this->table(), [
'provider_status' => 'failed',
'provider_updated_at' => current_time('mysql'),
'refund_error' => __('未查询到退款记录,将使用原请求号幂等重试。', 'aksrt'),
'updated_at' => current_time('mysql'),
], ['id' => (int) $record['id']]);
$fresh = $this->get_by_id((int) $record['id']);
if ($fresh) {
$this->complete_refund($fresh);
}
continue;
}
if ($status === 'succeeded') {
$fresh = $this->get_by_id((int) $record['id']);
if ($fresh && $this->complete_refund($fresh)) {
$this->update_status(
(int) $record['id'],
self::STATUS_COMPLETED,
0,
__('系统对账确认退款成功。', 'aksrt')
);
}
}
}
}
/* ============================================================
* Auto-cancel Cron (§6.8)
* ============================================================ */
@@ -988,6 +1367,7 @@ class AfterSale {
self::TYPE_REFUND => __('仅退款', 'aksrt'),
self::TYPE_REFUND_RETURN => __('退货退款', 'aksrt'),
self::TYPE_EXCHANGE => __('换货', 'aksrt'),
self::TYPE_REPAIR => __('维修', 'aksrt'),
self::TYPE_REPLACEMENT => __('补发', 'aksrt'),
self::TYPE_WARRANTY => __('保修', 'aksrt'),
self::TYPE_INSURED_PRICE => __('保价', 'aksrt'),
+1 -1
View File
@@ -86,7 +86,7 @@ class BalanceGateway implements GatewayInterface {
];
}
public function refund(string $order_number, float $amount): array {
public function refund(string $order_number, float $amount, string $request_id = ''): array {
$order_repo = OrderRepository::get_instance();
$order = $order_repo->get($order_number);
+40 -27
View File
@@ -162,13 +162,8 @@ class Gateway {
// Alipay synchronous returns generally omit trade_status; query after signature verification.
$query = $gateway->query_status($order_number);
if (!empty($query['success']) && ($query['status'] ?? '') === 'paid') {
$done = $this->complete_order($gateway_id, [
'order_number' => $order_number,
'amount' => 0,
'transaction_id' => $query['transaction_id'] ?? '',
'status' => 'paid',
]);
if ($done) {
$query['order_number'] = $order_number;
if ($this->complete_order($gateway_id, $query)) {
$status = 'success';
}
}
@@ -180,8 +175,13 @@ class Gateway {
if ($paypal_order_id !== '') {
$cap = $gateway->capture_order($paypal_order_id);
if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') {
$order_number = $order_number ?: sanitize_text_field((string) ($cap['order_number'] ?? ''));
$cap['order_number'] = $order_number;
$captured_order = sanitize_text_field((string) ($cap['order_number'] ?? ''));
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';
}
@@ -191,13 +191,10 @@ class Gateway {
// 其余网关:主动查询订单状态兜底
$q = $gateway->query_status($order_number);
if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') {
$done = $this->complete_order($gateway_id, [
'order_number' => $order_number,
'amount' => 0, // 由网关 API 已确认到账,金额校验交给 notify 路径
'transaction_id' => $q['transaction_id'] ?? '',
'status' => 'paid',
]);
if ($done) {
if (empty($q['order_number'])) {
$q['order_number'] = $order_number;
}
if ($this->complete_order($gateway_id, $q)) {
$status = 'success';
}
}
@@ -238,6 +235,32 @@ class Gateway {
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 = (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 === ''
|| abs($expected - $paid_amount) > 0.01) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf(
'[AKSRT Payment] invalid completion order=%s expected=%.2f paid=%.2f transaction=%s gateway=%s',
$order_number,
$expected,
$paid_amount,
$transaction_id !== '' ? 'present' : 'missing',
$gateway_id
));
}
return false;
}
$current = (string) ($order['status'] ?? '');
// 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达)
@@ -249,19 +272,9 @@ class Gateway {
return false;
}
// 金额校验,防止前端/回调金额被篡改
$expected = (float) ($order['amount'] ?? 0);
$paid_amount = (float) ($verify['amount'] ?? 0);
if ($expected > 0 && $paid_amount > 0 && abs($expected - $paid_amount) > 0.01) {
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[AKSRT Payment] amount mismatch order=%s expected=%.2f paid=%.2f gateway=%s', $order_number, $expected, $paid_amount, $gateway_id));
}
return false;
}
return $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $gateway_id,
'payment_transaction_id' => sanitize_text_field((string) ($verify['transaction_id'] ?? '')),
'payment_transaction_id' => $transaction_id,
'note' => __('支付网关回调确认到账。', 'aksrt'),
]);
}
+3 -2
View File
@@ -40,9 +40,10 @@ interface GatewayInterface {
*
* @param string $order_number 订单号
* @param float $amount 退款金额
* @return array{success: bool, refund_id?: string, message?: string}
* @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): array;
public function refund(string $order_number, float $amount, string $request_id = ''): array;
/**
* 获取网关显示名称
+64 -20
View File
@@ -52,34 +52,25 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
YansongdaPayFactory::boot_alipay();
$payload = [
'_method' => 'GET',
'out_trade_no' => $order_number,
'total_amount' => number_format($amount, 2, '.', ''),
'subject' => mb_substr($subject, 0, 256),
];
$result = $payment_type === 'wap'
? Pay::alipay()->wap($payload)
? Pay::alipay()->h5($payload)
: Pay::alipay()->web($payload);
$payment_url = YansongdaPayFactory::extract_payment_url($result);
$html = YansongdaPayFactory::extract_html_body($result);
// Prefer Location / action URL; if only auto-submit HTML, expose as payment_data for frontend.
if ($payment_url === '' && $html !== '' && preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $html, $m)) {
$payment_url = html_entity_decode($m[1], ENT_QUOTES);
}
if ($payment_url === '' && $html === '') {
if ($payment_url === '') {
return ['success' => false, 'message' => __('支付宝支付链接生成失败', 'aksrt')];
}
return [
'success' => true,
'payment_url' => $payment_url,
'payment_data' => array_filter([
'order_number' => $order_number,
'html' => $html !== '' ? $html : null,
]),
'payment_data' => ['order_number' => $order_number],
'message' => __('支付宝支付链接已生成', 'aksrt'),
];
} catch (\Throwable $e) {
@@ -103,14 +94,23 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
$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' => (string) ($data['out_trade_no'] ?? ''),
'order_number' => $order_number,
'amount' => (float) ($data['total_amount'] ?? 0),
'transaction_id' => (string) ($data['trade_no'] ?? ''),
'transaction_id' => $trade_no,
'status' => $paid ? 'paid' : 'pending',
];
} catch (\Throwable $e) {
@@ -157,8 +157,10 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
return [
'success' => true,
'status' => $status,
'order_number' => (string) ($data['out_trade_no'] ?? $order_number),
'amount' => (float) ($data['total_amount'] ?? 0),
'transaction_id' => (string) ($data['trade_no'] ?? ''),
'status' => $status,
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
@@ -166,13 +168,20 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
}
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')];
}
try {
YansongdaPayFactory::boot_alipay();
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : $order_number . '_refund');
$refund_request_id = (string) preg_replace(
'/[^A-Za-z0-9_-]/',
'',
$request_id !== '' ? $request_id : $order_number . '_refund'
);
$result = Pay::alipay()->refund([
'out_trade_no' => $order_number,
'refund_amount' => number_format($amount, 2, '.', ''),
@@ -181,16 +190,51 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
]);
$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,
'refund_id' => (string) ($data['trade_no'] ?? $data['out_trade_no'] ?? ''),
'message' => __('退款成功', 'aksrt'),
'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'] = $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}
*/
+8 -29
View File
@@ -84,34 +84,13 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
}
public function verify_callback(\WP_REST_Request $request): array {
try {
$body = json_decode($request->get_body(), true);
$eventType = $body['event_type'] ?? '';
if ($eventType !== 'CHECKOUT.ORDER.APPROVED') {
return ['success' => false, 'message' => __('非支付完成事件', 'aksrt')];
}
$resource = $body['resource'] ?? [];
$orderNumber = '';
$amount = 0;
foreach ($resource['purchase_units'] ?? [] as $unit) {
$orderNumber = $unit['reference_id'] ?? '';
$amount = (float) ($unit['amount']['value'] ?? 0);
break;
}
return [
'success' => true,
'order_number' => $orderNumber,
'amount' => $amount,
'transaction_id' => $resource['id'] ?? '',
'status' => 'paid',
];
} catch (\Exception $e) {
return ['success' => false, 'message' => $e->getMessage()];
}
// 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 {
@@ -122,7 +101,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
];
}
public function refund(string $order_number, float $amount): array {
public function refund(string $order_number, float $amount, string $request_id = ''): array {
return [
'success' => false,
'message' => __('PayPal 退款需要通过后台手动处理', 'aksrt'),
+64 -7
View File
@@ -110,7 +110,8 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$session = $event->data->object;
$orderNumber = $session->metadata->order_number ?? '';
$amount = ($session->amount_total ?? 0) / 100;
$currency = strtolower((string) ($session->currency ?? $config['currency'] ?? 'usd'));
$amount = $this->from_smallest_unit((int) ($session->amount_total ?? 0), $currency);
return [
'success' => true,
@@ -160,10 +161,19 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$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'))
),
'status' => $status,
'transaction_id' => $found->payment_intent ?? '',
'transaction_id' => $transaction_id,
];
}
@@ -173,7 +183,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
}
}
public function refund(string $order_number, float $amount): array {
public function refund(string $order_number, float $amount, string $request_id = ''): array {
if (!$this->is_configured()) {
return ['success' => false, 'message' => __('Stripe 未配置', 'aksrt')];
}
@@ -208,16 +218,26 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
$currency = strtolower($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',
], ['api_key' => $config['secret_key']]);
'metadata' => [
'aksrt_request_id' => $request_id,
'order_number' => $order_number,
],
], $options);
return [
'success' => true,
'refund_id' => $refund->id,
'message' => __('退款成功', 'aksrt'),
'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()];
@@ -226,6 +246,34 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
}
}
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(前端使用)。
*/
@@ -271,6 +319,15 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
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)。
*/
@@ -142,6 +142,29 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
$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,
@@ -204,8 +227,10 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
return [
'success' => true,
'status' => $status,
'order_number' => (string) ($data['out_trade_no'] ?? $order_number),
'amount' => ((int) YansongdaPayFactory::dig($data, 'amount.total', 0)) / 100,
'transaction_id' => (string) ($data['transaction_id'] ?? ''),
'status' => $status,
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => $e->getMessage()];
@@ -229,13 +254,17 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
$total_fen = (int) YansongdaPayFactory::dig($q, 'amount.total', 0);
$refund_fen = (int) round($amount * 100);
if ($total_fen <= 0) {
$total_fen = $refund_fen;
return ['success' => false, 'status' => 'failed', 'message' => __('无法确认原订单金额', 'aksrt')];
}
if ($refund_fen <= 0) {
return ['success' => false, 'message' => __('退款金额无效', 'aksrt')];
if ($refund_fen <= 0 || $refund_fen > $total_fen) {
return ['success' => false, 'status' => 'failed', 'message' => __('退款金额无效', 'aksrt')];
}
$refund_request_id = sanitize_key($request_id !== '' ? $request_id : 'REF_' . $order_number);
$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' => $order_number,
@@ -249,16 +278,49 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
]);
$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,
'refund_id' => (string) ($data['refund_id'] ?? $data['out_refund_no'] ?? ''),
'message' => __('退款成功', 'aksrt'),
'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}
*/
@@ -52,7 +52,7 @@ class YansongdaPayFactory {
*/
public static function wechat_from_settings(): array {
$all = self::settings();
$notify = home_url('/wp-json/aksrt/v1/payment/wechatpay/notify');
$notify = rest_url('aksrt/v1/payment/wechatpay/notify');
return [
'mch_id' => (string) ($all['wechat_merchantid'] ?? ''),
@@ -75,8 +75,8 @@ class YansongdaPayFactory {
*/
public static function alipay_from_settings(): array {
$all = self::settings();
$notify = home_url('/wp-json/aksrt/v1/payment/alipay/notify');
$return = home_url('/wp-json/aksrt/v1/payment/alipay/return');
$notify = rest_url('aksrt/v1/payment/alipay/notify');
$return = rest_url('aksrt/v1/payment/alipay/return');
return [
'app_id' => (string) ($all['alipay_appid'] ?? ''),
@@ -159,13 +159,9 @@ class YansongdaPayFactory {
return $loc;
}
$body = (string) $result->getBody();
if (preg_match('#action=[\'"]([^\'"]+)[\'"]#i', $body, $m)) {
return html_entity_decode($m[1], ENT_QUOTES);
}
if (preg_match('#https?://[^\s"\'<>]+#', $body, $m)) {
if (preg_match('#^https?://[^\s"\'<>]+$#', trim($body), $m)) {
return $m[0];
}
// Auto-submit HTML form: callers may open as document; store empty payment_url and put HTML in payment_data.
return '';
}
if (is_object($result)) {
+22 -2
View File
@@ -236,9 +236,29 @@ trait OrderRepositoryCore {
$format[] = '%s';
}
$result = $wpdb->update($this->table(), $update_data, ['order_number' => $order_number], $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) {
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) {
+76
View File
@@ -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);
}
}
+65 -26
View File
@@ -308,7 +308,17 @@ class UserOrders {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
$payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance'));
$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) {
@@ -318,39 +328,68 @@ class UserOrders {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$payment_result = $gateway->create_payment($order);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
$lock = class_exists(PaymentAttemptLock::class)
? PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
}
$code_url = $payment_result['payment_data']['code_url'] ?? '';
if (!empty($payment_result['payment_url']) || $code_url !== '') {
try {
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$payment_result = $gateway->create_payment($order);
if (empty($payment_result['success'])) {
return ['success' => false, 'message' => $payment_result['message'] ?? __('支付失败。', 'aksrt')];
}
$code_url = $payment_result['payment_data']['code_url'] ?? '';
if (!empty($payment_result['payment_url']) || $code_url !== '') {
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) $code_url,
'order_number' => $order_number,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
],
];
}
$paid = $repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
'require_transition' => true,
]);
if (!$paid) {
$compensation = $gateway->refund(
$order_number,
(float) ($order['amount'] ?? 0),
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
);
if (empty($compensation['success'])) {
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
(string) ($compensation['message'] ?? 'unknown error')
));
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
}
return [
'success' => true,
'data' => [
'redirect' => true,
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
'qr_content' => (string) $code_url,
'order_number' => $order_number,
'message' => $payment_result['message'] ?? __('请在新页面完成支付。', 'aksrt'),
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
],
];
} finally {
PaymentAttemptLock::release($order_number, $lock);
}
$repo->update_status($order_number, Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
]);
return [
'success' => true,
'data' => [
'order' => $this->get_order_detail_view($user_id, $order_number),
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
],
];
}
private function empty_stats(): array {
+67 -6
View File
@@ -184,8 +184,21 @@ class CheckoutService {
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
}
// 未显式指定时,使用订单创建时选定的支付方式。
$payment_method = sanitize_key($payment_method ?: (string) ($order['payment_method'] ?? 'balance'));
$status = (string) ($order['status'] ?? '');
if (!in_array($status, [
\AKSRT\Payment\Order::STATUS_CREATED,
\AKSRT\Payment\Order::STATUS_PENDING,
], true)) {
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
}
// Payment attempts are bound to the method selected when the order was created.
$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('\AKSRT\Payment\Gateway') ? \AKSRT\Payment\Gateway::get_instance() : null;
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
@@ -197,8 +210,16 @@ class CheckoutService {
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
}
// 携带订单号与摘要,供网关生成支付单。
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
: false;
if ($lock === false) {
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
}
try {
// 携带订单号与摘要,供网关生成支付单。
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
// Points payment: synchronous deduction via \AKSRT\Credits\Credits
if ($payment_method === 'points') {
@@ -227,11 +248,29 @@ class CheckoutService {
}
// 同步型网关(站内余额):create_payment 已实际扣款,标记已支付(触发 aksrt_order_paid 履约)。
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => $payment_method,
'payment_transaction_id' => sanitize_text_field((string) ($payment_result['transaction_id'] ?? '')),
'note' => __('用户在前台完成支付。', 'aksrt'),
'require_transition' => true,
]);
if (!$paid) {
$compensation = $gateway->refund(
$order_number,
(float) ($order['amount'] ?? 0),
'PAY_ROLLBACK_' . substr(hash('sha256', $order_number), 0, 24)
);
if (empty($compensation['success'])) {
error_log(sprintf(
'[AKSRT Payment] rollback failed order=%s gateway=%s: %s',
$order_number,
$payment_method,
(string) ($compensation['message'] ?? 'unknown error')
));
return ['success' => false, 'message' => __('订单状态已变化,扣款补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,扣款已退回。', 'aksrt')];
}
return [
'success' => true,
@@ -240,6 +279,9 @@ class CheckoutService {
'message' => $payment_result['message'] ?? __('支付成功。', 'aksrt'),
],
];
} finally {
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
}
}
private function pay_with_points(int $user_id, string $order_number, array $order): array {
@@ -269,11 +311,30 @@ class CheckoutService {
}
$repo = \AKSRT\Payment\OrderRepository::get_instance();
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
'payment_method' => 'points',
'payment_transaction_id' => 'points_' . $order_number,
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
'require_transition' => true,
]);
if (!$paid) {
$compensation = \AKSRT\Credits\Credits::add(
$user_id,
$amount,
'shop_points_pay_rollback',
['order' => $order_number],
sprintf(__('订单 %s 支付失败退回积分', 'aksrt'), $order_number)
);
if (is_wp_error($compensation)) {
error_log(sprintf(
'[AKSRT Payment] points rollback failed order=%s: %s',
$order_number,
$compensation->get_error_message()
));
return ['success' => false, 'message' => __('订单状态已变化,积分补偿失败,请联系管理员处理。', 'aksrt')];
}
return ['success' => false, 'message' => __('订单状态已变化,积分已退回。', 'aksrt')];
}
return [
'success' => true,
+9 -6
View File
@@ -28,14 +28,17 @@ trait Part1 {
}
$ticket_id = \AKSRT\Payment\AfterSale::get_instance()->create([
'order_number' => $order_number,
'user_id' => $user_id,
'type' => $type,
'reason' => $reason,
'amount' => (float) ($order['amount'] ?? 0),
'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')),
'order_number' => $order_number,
'applicant_user_id' => $user_id,
'type' => $type,
'reason' => $reason,
'amount' => (float) ($payload['amount'] ?? $order['amount'] ?? 0),
'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')),
]);
if (is_wp_error($ticket_id)) {
return ['success' => false, 'message' => $ticket_id->get_error_message()];
}
if ($ticket_id <= 0) {
return ['success' => false, 'message' => __('售后申请提交失败。', 'aksrt')];
}