Merge pull request 'fix(user-center): complete certification and order flows' (#320) from worktree-fix-user-center-order-certification-flow into main

Reviewed-on: CYL/wordpress-theme-aksrt#320
This commit is contained in:
2026-07-29 10:46:20 +08:00
26 changed files with 613 additions and 148 deletions
@@ -22,9 +22,9 @@
<n-descriptions-item label="身份简介">{{ detail.request.application?.desc || '-' }}</n-descriptions-item>
<n-descriptions-item label="申请说明">{{ detail.request.application?.msg || '-' }}</n-descriptions-item>
</n-descriptions>
<div v-if="detail.request.application?.img?.length" class="aksrt-verify-drawer__proofs">
<a v-for="image in detail.request.application.img" :key="image" :href="image" target="_blank" rel="noopener noreferrer">
<img :src="image" alt="认证证明材料" />
<div v-if="detail.request.application?.evidence?.length" class="aksrt-verify-drawer__proofs">
<a v-for="item in detail.request.application.evidence" :key="item.id" :href="item.url" target="_blank" rel="noopener noreferrer">
<img :src="item.thumbnail_url || item.url" alt="认证证明材料" />
</a>
</div>
</div>
+15
View File
@@ -522,8 +522,15 @@
}
/* Order detail modal content */
html.aksrt-modal-open {
overflow: hidden;
}
.aksrt-order-detail-dialog {
display: flex;
flex-direction: column;
width: min(520px, 100%);
max-height: min(85vh, 760px);
border-radius: 20px;
overflow: hidden;
background: var(--surface-1, #fff);
@@ -557,8 +564,12 @@
}
.aksrt-order-detail-dialog .aksrt-modal__body {
min-height: 0;
padding: 8px 18px 16px;
gap: 14px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
/* Hero: status + amount */
@@ -1413,6 +1424,10 @@ html.dark .aksrt-order-detail-dialog .aksrt-modal__footer {
border: 1px solid color-mix(in srgb, var(--color-line, #e5e7eb) 70%, transparent);
}
.aksrt-modal__dialog.aksrt-order-detail-dialog {
overflow: hidden;
}
.aksrt-modal__header {
display: flex;
align-items: center;
+2 -2
View File
@@ -399,7 +399,7 @@
.uc-auth-card--verified .uc-auth-card__art { background: linear-gradient(150deg, #20a77d, #3287df); }
.uc-auth-modal { z-index: 620; }
.uc-auth-modal .aksrt-modal__dialog { width: min(620px, calc(100vw - 28px)); max-height: min(760px, calc(100vh - 40px)); overflow: auto; }
.uc-auth-modal__dialog { width: min(620px, calc(100vw - 28px)); max-width: 620px; max-height: min(760px, calc(100vh - 40px)); overflow: auto; background: var(--surface-1, #fff); }
.uc-auth-modal__header > div { display: flex; align-items: center; gap: 10px; }
.uc-auth-modal__header > div > span { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 11px; background: var(--color-primary-soft); color: var(--color-primary); }
.uc-auth-modal__body { padding: 22px; }
@@ -433,5 +433,5 @@
.uc-auth-card__art { font-size: 34px; }
.uc-auth-card__content { padding: 20px 17px; }
.uc-auth-card__content h2 { font-size: 20px; }
.uc-auth-modal .aksrt-modal__dialog { position: fixed; inset: auto 0 0; width: 100%; max-height: 88vh; margin: 0; border-radius: 18px 18px 0 0; }
.uc-auth-modal__dialog { position: fixed; inset: auto 0 0; width: 100%; max-width: none; max-height: 88vh; margin: 0; border-radius: 18px 18px 0 0; }
}
+9 -3
View File
@@ -262,10 +262,16 @@
}
if (message) message.textContent = '';
fetch(ajaxUrl, { method: 'POST', body: new FormData(form), credentials: 'same-origin' })
.then(function(response) { return response.json(); })
.then(function(response) {
if (!response || response.error) throw new Error((response && response.msg) || '提交失败');
showToast(response.msg || '提交成功');
return response.json().then(function(payload) {
if (!response.ok || !payload || !payload.success) {
throw new Error((payload && payload.data && payload.data.message) || '提交失败');
}
return payload.data || {};
});
})
.then(function(data) {
showToast(data.message || '提交成功');
window.setTimeout(function() { window.location.assign(tabUrls.auth || '?tab=auth'); }, 500);
})
.catch(function(error) {
@@ -17,6 +17,7 @@ $settings = class_exists(AuthApply::class) ? AuthApply::settings() : ['enabled'
$status = $user_id > 0 && class_exists(AuthApply::class) ? AuthApply::get_status($user_id) : '';
$info = $user_id > 0 && class_exists(AuthApply::class) ? AuthApply::get_auth_info($user_id) : [];
$apply = $user_id > 0 && class_exists(AuthApply::class) ? AuthApply::get_apply_payload($user_id) : [];
$evidence = class_exists(AuthApply::class) ? AuthApply::get_evidence_items($apply) : [];
$type_labels = [];
foreach ((array) ($settings['types'] ?? []) as $type) {
$type_labels[(string) ($type['slug'] ?? '')] = (string) ($type['label'] ?? '');
@@ -64,8 +65,8 @@ $can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) &&
<?php if ($can_apply) : ?>
<div class="aksrt-modal uc-auth-modal" id="uc-auth-modal" aria-hidden="true">
<div class="aksrt-modal__backdrop" data-auth-modal-close></div>
<div class="aksrt-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="uc-auth-modal-title">
<div class="aksrt-modal__overlay" data-auth-modal-close></div>
<div class="aksrt-modal__box uc-auth-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="uc-auth-modal-title">
<div class="aksrt-modal__header uc-auth-modal__header">
<div><span><i class="fas fa-id-badge" aria-hidden="true"></i></span><strong id="uc-auth-modal-title"><?php esc_html_e('申请身份认证', 'aksrt'); ?></strong></div>
<button type="button" class="aksrt-modal__close" data-auth-modal-close aria-label="<?php esc_attr_e('关闭', 'aksrt'); ?>"><i class="fas fa-times" aria-hidden="true"></i></button>
@@ -84,8 +85,8 @@ $can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) &&
<?php if (!empty($apply['msg'])) : ?><dl><dt><?php esc_html_e('申请说明', 'aksrt'); ?></dt><dd><?php echo esc_html((string) $apply['msg']); ?></dd></dl><?php endif; ?>
<?php if (!empty($apply['time'])) : ?><dl><dt><?php esc_html_e('申请时间', 'aksrt'); ?></dt><dd><?php echo esc_html((string) $apply['time']); ?></dd></dl><?php endif; ?>
</div>
<?php if (!empty($apply['img'])) : ?>
<div class="aksrt-user-auth-panel__imgs uc-auth-proof-list"><?php foreach ((array) $apply['img'] as $img_url) : ?><div class="preview-item"><img src="<?php echo esc_attr(AuthApply::evidence_src((string) $img_url)); ?>" alt="" loading="lazy"></div><?php endforeach; ?></div>
<?php if ($evidence !== []) : ?>
<div class="aksrt-user-auth-panel__imgs uc-auth-proof-list"><?php foreach ($evidence as $item) : ?><div class="preview-item"><img src="<?php echo esc_url((string) $item['thumbnail_url']); ?>" alt="" loading="lazy"></div><?php endforeach; ?></div>
<?php endif; ?>
<button type="button" class="btn-primary uc-auth-modal__confirm" data-auth-modal-close><?php esc_html_e('确定', 'aksrt'); ?></button>
<?php else : ?>
@@ -12,9 +12,19 @@ if ($orders_base_url === '') {
return;
}
$order_user_id = (int) ($user_id ?? 0);
$order_stats = class_exists('\AKSRT\Payment\Order')
? \AKSRT\Payment\Order::get_instance()->get_stats((int) ($user_id ?? 0))
? \AKSRT\Payment\Order::get_instance()->get_stats($order_user_id)
: [];
$order_repo = class_exists('\AKSRT\Payment\OrderRepository')
? \AKSRT\Payment\OrderRepository::get_instance()
: null;
$shop_type = class_exists('\AKSRT\Core\DomainModel')
? \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP
: 'shop_product';
$shop_paid = $order_repo ? $order_repo->count_user_orders($order_user_id, ['status' => \AKSRT\Payment\Order::STATUS_PAID, 'product_type' => $shop_type]) : 0;
$shop_delivered = $order_repo ? $order_repo->count_user_orders($order_user_id, ['status' => \AKSRT\Payment\Order::STATUS_DELIVERED, 'product_type' => $shop_type]) : 0;
$shop_review = $order_repo ? $order_repo->count_user_orders($order_user_id, ['status' => \AKSRT\Payment\Order::STATUS_PENDING_REVIEW, 'product_type' => $shop_type]) : 0;
$order_items = [
[
'label' => __('待支付', 'aksrt'),
@@ -26,19 +36,19 @@ $order_items = [
'label' => __('待发货', 'aksrt'),
'icon' => 'gift',
'tab' => 'to-ship',
'count' => (int) ($order_stats['paid'] ?? 0),
'count' => $shop_paid,
],
[
'label' => __('待收货', 'aksrt'),
'icon' => 'truck',
'tab' => 'shipped',
'count' => (int) ($order_stats['delivered'] ?? 0),
'count' => $shop_delivered,
],
[
'label' => __('待评价', 'aksrt'),
'icon' => 'comment-dots',
'tab' => 'review',
'count' => (int) ($order_stats['pending_review'] ?? 0),
'count' => $shop_review,
],
[
'label' => __('售后', 'aksrt'),
+5 -3
View File
@@ -546,9 +546,11 @@ class UsersMenus {
if (!empty($apply['msg'])) {
echo '<div class="description">' . esc_html((string) $apply['msg']) . '</div>';
}
foreach ((array) ($apply['img'] ?? []) as $image_url) {
$evidence_src = class_exists('\AKSRT\User\AuthApply') ? \AKSRT\User\AuthApply::evidence_src((string) $image_url) : '';
echo '<a href="' . esc_attr($evidence_src) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_attr($evidence_src) . '" alt="" style="width:56px;height:56px;object-fit:cover;margin:6px 6px 0 0;border-radius:4px;" /></a>';
$evidence_items = class_exists('\AKSRT\User\AuthApply')
? \AKSRT\User\AuthApply::get_evidence_items($apply)
: [];
foreach ($evidence_items as $item) {
echo '<a href="' . esc_url((string) $item['url']) . '" target="_blank" rel="noopener noreferrer"><img src="' . esc_url((string) $item['thumbnail_url']) . '" alt="" style="width:56px;height:56px;object-fit:cover;margin:6px 6px 0 0;border-radius:4px;" /></a>';
}
}
if (!empty($row['verify_reason'])) {
@@ -246,6 +246,9 @@ trait UserControllerVerifyIntegrations {
$email_verified = (bool) get_user_meta($user_id, 'aksrt_email_verified', true);
$application = get_user_meta($user_id, 'aksrt_user_verify_apply', true);
$application = is_array($application) ? $application : [];
$application_view = class_exists('\AKSRT\User\AuthApply')
? \AKSRT\User\AuthApply::application_view($application)
: [];
return [
'user_id' => $user_id,
@@ -256,7 +259,7 @@ trait UserControllerVerifyIntegrations {
'requested_at' => (string) ($application['time'] ?? $row['user_registered'] ?? ''),
'reviewed_at' => (string) ($row['reviewed_at'] ?? ''),
'email_verified' => $email_verified,
'application' => $application,
'application' => $application_view,
'reviewer' => $this->build_admin_user_summary($reviewer_id, $reviewer),
'history_count' => count($this->get_verify_history_for_user($user_id)),
];
+7
View File
@@ -54,6 +54,13 @@ trait PayOrders {
// Consume coupon + write affiliate commission (cash content only; idempotent).
$this->finalize_content_order_side_effects($order_number, $paid_order);
if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('内容权益已发放。', 'aksrt')]
);
}
}
+3
View File
@@ -165,6 +165,9 @@ trait PayRest {
if (!$order) {
return new \WP_Error('aksrt_invalid', __('订单不存在', 'aksrt'), ['status' => 404]);
}
if (class_exists('\AKSRT\Payment\Gateway')) {
$order = \AKSRT\Payment\Gateway::get_instance()->reconcile_order($order_number, $order) ?: $order;
}
$status = (string) ($order['status'] ?? '');
$paid = class_exists('\AKSRT\Core\DomainModel') && \AKSRT\Core\DomainModel::is_paid_order_status($status);
if ($paid && $uid <= 0 && function_exists('aksrt_set_guest_pay_cookies')) {
+24
View File
@@ -445,14 +445,38 @@ trait VipServicePurchase {
$level = 2;
}
$previous_level = (int) get_user_meta($user_id, 'aksrt_vip_level', true);
$previous_expiry = (int) get_user_meta($user_id, 'aksrt_vip_expires', true);
$fulfilled = false;
// unit upgrade: raise level only, keep current expiry (zibll).
if ($upgrade_mode === 'unit') {
$this->activate_vip($user_id, $level, 0, true);
$fulfilled = (int) get_user_meta($user_id, 'aksrt_vip_level', true) >= $level;
} elseif ($time > 0 || (isset($meta['time']) && (int) $meta['time'] === 0 && $days === 0)) {
// Prefer calendar time+unit when present (including permanent time=0).
$this->activate_vip_duration($user_id, $level, $time, $unit);
$new_expiry = (int) get_user_meta($user_id, 'aksrt_vip_expires', true);
$expected_base = max($previous_expiry, time());
$expected_expiry = $time <= 0
? 0
: strtotime('+' . $time . ' ' . ($unit === 'month' ? 'months' : 'days'), $expected_base);
if ($expected_expiry === false) {
$expected_expiry = $expected_base + ($this->duration_to_days($time, $unit) * DAY_IN_SECONDS);
}
$fulfilled = (int) get_user_meta($user_id, 'aksrt_vip_level', true) >= max($previous_level, $level)
&& ($new_expiry === 0 || ($time <= 0 ? $new_expiry === 0 : $new_expiry >= $expected_expiry));
} else {
$this->activate_vip($user_id, $level, $days, false);
$new_expiry = (int) get_user_meta($user_id, 'aksrt_vip_expires', true);
$fulfilled = (int) get_user_meta($user_id, 'aksrt_vip_level', true) >= max($previous_level, $level)
&& ($new_expiry === 0 || $days <= 0 || $new_expiry >= max($previous_expiry, time()) + ($days * DAY_IN_SECONDS));
}
if ($fulfilled && class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('会员权益已发放。', 'aksrt')]
);
}
}
+1
View File
@@ -449,6 +449,7 @@ class DomainModel {
self::ORDER_STATUS_PAID,
self::ORDER_STATUS_DELIVERED,
self::ORDER_STATUS_COMPLETED,
self::ORDER_STATUS_PENDING_REVIEW,
];
}
+4 -2
View File
@@ -32,7 +32,7 @@ class Balance {
* @param array<string,mixed> $ref
* @return float|\WP_Error New balance.
*/
public static function add(int $user_id, float $delta, string $reason, array $ref = [], string $remark = '') {
public static function add(int $user_id, float $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
if ($user_id <= 0) {
return new \WP_Error('aksrt_balance_user', __('用户无效', 'aksrt'));
}
@@ -46,7 +46,9 @@ class Balance {
return new \WP_Error('aksrt_balance_db', __('余额写入失败', 'aksrt'));
}
self::log($user_id, $delta, $new, $reason, $ref, $remark);
do_action('aksrt_balance_added', $user_id, $delta, $reason, $new);
if ($dispatch_hooks) {
do_action('aksrt_balance_added', $user_id, $delta, $reason, $new);
}
return $new;
}
+52 -6
View File
@@ -52,26 +52,49 @@ trait BalanceServiceCharge {
if ($points <= 0) {
return;
}
global $wpdb;
if ($wpdb->query('START TRANSACTION') === false) {
return;
}
$add = Credits::add(
$user_id,
$points,
'points_buy',
['type' => 'order', 'order' => $order_number],
sprintf(__('购买积分订单 %s', 'aksrt'), $order_number)
sprintf(__('购买积分订单 %s', 'aksrt'), $order_number),
false
);
if (is_wp_error($add)) {
$wpdb->query('ROLLBACK');
return;
}
$note['points_credited'] = 1;
$note['credited_points'] = $points;
global $wpdb;
$wpdb->update(
$updated = $wpdb->update(
$wpdb->prefix . 'aksrt_order',
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
['order_number' => $order_number],
['%s'],
['%s']
);
if ($updated === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
do_action('aksrt_credits_added', $user_id, $points, 'points_buy', (int) $add);
if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('购买积分已到账。', 'aksrt')]
);
}
return;
}
@@ -101,26 +124,49 @@ trait BalanceServiceCharge {
if ($credit_amount <= 0) {
return;
}
global $wpdb;
if ($wpdb->query('START TRANSACTION') === false) {
return;
}
$result = Balance::add(
$user_id,
$credit_amount,
'recharge',
['type' => 'order', 'order' => $order_number],
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number)
sprintf(__('余额充值订单 %s', 'aksrt'), $order_number),
false
);
if (is_wp_error($result)) {
$wpdb->query('ROLLBACK');
return;
}
$note['balance_credited'] = 1;
$note['credited_amount'] = $credit_amount;
global $wpdb;
$wpdb->update(
$updated = $wpdb->update(
$wpdb->prefix . 'aksrt_order',
['note' => wp_json_encode($note, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)],
['order_number' => $order_number],
['%s'],
['%s']
);
if ($updated === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
if ($wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
wp_cache_delete($user_id, 'user_meta');
return;
}
do_action('aksrt_balance_added', $user_id, $credit_amount, 'recharge', (float) $result);
if (class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('充值金额已到账。', 'aksrt')]
);
}
}
+4 -2
View File
@@ -37,7 +37,7 @@ class Credits {
* @param array $ref ['type' => 'post|comment|user|invite', 'id' => int]
* @return int|\WP_Error 新余额或错误
*/
public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '') {
public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '', bool $dispatch_hooks = true) {
if ($user_id <= 0) return new \WP_Error('aksrt_credits_user', __('用户无效', 'aksrt'));
if ($delta <= 0) return new \WP_Error('aksrt_credits_delta', __('增加积分必须为正数', 'aksrt'));
@@ -47,7 +47,9 @@ class Credits {
}
self::log($user_id, $delta, $new_balance, $reason, $ref, $remark);
do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance);
if ($dispatch_hooks) {
do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance);
}
return $new_balance;
}
+11
View File
@@ -510,8 +510,12 @@ class PlateFollowPay {
}
}
$fulfilled = $user_id <= 0;
if ($user_id > 0) {
$this->mark_followed($user_id, $plate_id);
$fulfilled = class_exists('AKSRT\\Forum\\Forum')
? Forum::get_instance()->is_user_following_plate($user_id, $plate_id)
: in_array($plate_id, array_map('intval', (array) get_user_meta($user_id, 'aksrt_follow_plate', true)), true);
} elseif (function_exists('aksrt_set_guest_pay_cookies')) {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$full = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
@@ -522,6 +526,13 @@ class PlateFollowPay {
aksrt_set_guest_pay_cookies($paid_order);
}
$this->finalize_plate_order_side_effects($order_number, $paid_order);
if ($fulfilled && class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('付费关注权益已发放。', 'aksrt')]
);
}
}
/**
+11
View File
@@ -514,8 +514,12 @@ class TopicPay {
}
}
$fulfilled = $user_id <= 0;
if ($user_id > 0) {
$this->mark_unlocked($user_id, $topic_id);
$fulfilled = class_exists('AKSRT\\Forum\\Forum')
? Forum::get_instance()->has_user_unlocked_topic($user_id, $topic_id)
: in_array($topic_id, array_map('intval', (array) get_user_meta($user_id, 'aksrt_unlocked_topics', true)), true);
} elseif (function_exists('aksrt_set_guest_pay_cookies')) {
if (class_exists('\AKSRT\Payment\OrderRepository')) {
$full = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
@@ -526,6 +530,13 @@ class TopicPay {
aksrt_set_guest_pay_cookies($paid_order);
}
$this->finalize_topic_order_side_effects($order_number, $paid_order);
if ($fulfilled && class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('付费主题权益已发放。', 'aksrt')]
);
}
}
/**
+62 -1
View File
@@ -243,6 +243,60 @@ class Gateway {
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;
}
/**
* 幂等、带金额校验的订单完成。仅由验签通过的回调调用。
*
@@ -273,20 +327,27 @@ class Gateway {
}
// 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 paid=%.2f transaction=%s gateway=%s',
'[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
));
@@ -119,6 +119,7 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
'success' => true,
'order_number' => $order_number,
'amount' => (float) ($data['total_amount'] ?? 0),
'currency' => 'CNY',
'transaction_id' => $trade_no,
'status' => $paid ? 'paid' : 'pending',
];
@@ -171,6 +172,7 @@ class AlipayGateway implements \AKSRT\Payment\GatewayInterface {
// 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,
];
@@ -165,6 +165,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
'success' => true,
'order_number' => $order_number,
'amount' => $amount,
'currency' => 'CNY',
'transaction_id' => $transaction_id,
'status' => $paid ? 'paid' : 'pending',
];
@@ -215,6 +216,7 @@ class EpayGateway implements \AKSRT\Payment\GatewayInterface {
'success' => true,
'order_number' => (string) ($body['out_trade_no'] ?? $out_trade_no),
'amount' => $amount,
'currency' => 'CNY',
'transaction_id' => $tx,
'status' => $paid ? 'paid' : 'pending',
];
+54 -15
View File
@@ -42,7 +42,11 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
}
$amount = (float) ($order['amount'] ?? 0);
$subject = $order['subject'] ?? __('订单支付', 'aksrt');
$currency = $config['currency'] ?? 'USD';
$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', [
@@ -73,6 +77,15 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
}
}
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,
@@ -102,11 +115,31 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
}
public function query_status(string $order_number): array {
return [
'success' => true,
'status' => 'pending',
'message' => __('PayPal 状态通过 Webhook 更新', 'aksrt'),
];
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 {
@@ -146,15 +179,7 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
$body = $response['body'];
if (in_array($response['status'], [200, 201], true) && ($body['status'] ?? '') === 'COMPLETED') {
$unit = $body['purchase_units'][0] ?? [];
$capture = $unit['payments']['captures'][0] ?? [];
return [
'success' => true,
'order_number' => $unit['reference_id'] ?? '',
'amount' => (float) ($capture['amount']['value'] ?? 0),
'transaction_id' => $capture['id'] ?? ($body['id'] ?? ''),
'status' => 'paid',
];
return $this->completed_order_evidence($body);
}
return ['success' => false, 'message' => $body['message'] ?? __('PayPal 捕获失败', 'aksrt')];
@@ -163,6 +188,20 @@ class PayPalGateway implements \AKSRT\Payment\GatewayInterface {
}
}
/** @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 必需)。
*/
+9 -1
View File
@@ -23,7 +23,9 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
return false;
}
$config = $this->get_config();
return !empty($config['secret_key']) && !empty($config['publishable_key']);
return !empty($config['secret_key'])
&& !empty($config['publishable_key'])
&& !empty($config['webhook_secret']);
}
public function create_payment(array $order): array {
@@ -42,7 +44,11 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
}
$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);
@@ -145,6 +151,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
'success' => true,
'order_number' => $orderNumber,
'amount' => $amount,
'currency' => strtoupper($currency),
'transaction_id' => $session->payment_intent ?? $session->id,
'status' => $session->payment_status === 'paid' ? 'paid' : 'pending',
];
@@ -185,6 +192,7 @@ class StripeGateway implements \AKSRT\Payment\GatewayInterface {
(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,
];
@@ -179,6 +179,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
'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',
];
@@ -242,6 +243,7 @@ class WechatPayGateway implements \AKSRT\Payment\GatewayInterface {
// 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,
];
+6 -1
View File
@@ -52,7 +52,9 @@ class UserOrders {
if ($status !== '') {
$repo_args['status'] = $status;
}
if ($product_type !== '' && $product_type !== 'all') {
if (in_array($status, [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_PENDING_REVIEW], true)) {
$repo_args['product_type'] = \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP;
} elseif ($product_type !== '' && $product_type !== 'all') {
$repo_args['product_type'] = $product_type;
}
@@ -83,6 +85,9 @@ class UserOrders {
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);
}
+64 -22
View File
@@ -65,7 +65,7 @@ trait Part3 {
];
}
public function execute_auto_delivery(int $product_id, string $options_key, int $qty, int $user_id, string $order_number): string {
public function execute_auto_delivery(int $product_id, string $options_key, int $qty, int $user_id, string $order_number, int $order_item_id = 0): string {
$config = $this->get_auto_delivery_config($product_id);
$type = $config['type'];
@@ -77,7 +77,7 @@ trait Part3 {
return (string) ($config['opts'][$key] ?? $config['content']);
}
if ($type === 'card_pass') {
return $this->consume_card_keys($product_id, $options_key, $qty, $user_id, $order_number);
return $this->consume_card_keys($product_id, $options_key, $qty, $user_id, $order_number, $order_item_id);
}
if ($type === 'invit_code') {
return $this->create_invite_codes($qty, $user_id, $order_number);
@@ -85,10 +85,20 @@ trait Part3 {
return '';
}
private function consume_card_keys(int $product_id, string $options_key, int $qty, int $user_id, string $order_number): string {
private function auto_delivery_succeeded(int $product_id, string $content, int $qty): bool {
$config = $this->get_auto_delivery_config($product_id);
if (in_array($config['type'], ['card_pass', 'invit_code'], true)) {
$lines = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $content) ?: [])));
return count($lines) === max(1, $qty)
&& !in_array($content, [__('卡密已售罄', 'aksrt'), __('邀请系统不可用', 'aksrt')], true);
}
return trim($content) !== '';
}
private function consume_card_keys(int $product_id, string $options_key, int $qty, int $user_id, string $order_number, int $order_item_id): string {
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('card_key');
if ($table === '') return '';
if ($table === '' || $order_item_id <= 0 || $wpdb->query('START TRANSACTION') === false) return '';
$where_opt = '';
$params = [$product_id];
@@ -101,28 +111,39 @@ trait Part3 {
$params[] = $qty;
$keys = $wpdb->get_results($wpdb->prepare(
"SELECT id, card_code FROM {$table} WHERE product_id = %d AND status = 'available'{$where_opt} ORDER BY id ASC LIMIT %d",
"SELECT id, card_code FROM {$table} WHERE product_id = %d AND status = 'available'{$where_opt} ORDER BY id ASC LIMIT %d FOR UPDATE",
...$params
), ARRAY_A);
if (empty($keys)) {
if (count((array) $keys) !== $qty) {
$wpdb->query('ROLLBACK');
return __('卡密已售罄', 'aksrt');
}
$codes = [];
$ids = [];
foreach ($keys as $k) {
$codes[] = $k['card_code'];
$ids[] = (int) $k['id'];
foreach ($keys as $key) {
$codes[] = (string) $key['card_code'];
$ids[] = (int) $key['id'];
}
if (!empty($ids)) {
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$wpdb->query($wpdb->prepare(
"UPDATE {$table} SET status = 'used', order_number = %s, used_by = %d, used_at = %s WHERE id IN ({$placeholders})",
array_merge([$order_number, $user_id, current_time('mysql')], $ids)
));
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
$updated = $wpdb->query($wpdb->prepare(
"UPDATE {$table} SET status = 'used', order_number = %s, used_by = %d, used_at = %s WHERE status = 'available' AND id IN ({$placeholders})",
array_merge([$order_number, $user_id, current_time('mysql')], $ids)
));
$content = implode("\n", $codes);
$item_table = $this->order_item_table();
$persisted = $item_table !== '' ? $wpdb->update(
$item_table,
['auto_delivery_content' => $content],
['id' => $order_item_id, 'order_number' => $order_number],
['%s'],
['%d', '%s']
) : false;
if ($updated !== $qty || $persisted === false || $wpdb->query('COMMIT') === false) {
$wpdb->query('ROLLBACK');
return '';
}
return implode("\n", $codes);
return $content;
}
private function create_invite_codes(int $qty, int $user_id, string $order_number): string {
@@ -221,6 +242,7 @@ trait Part3 {
$user_id = (int) ($order['user_id'] ?? 0);
$auto_delivery_parts = [];
$auto_delivery_successes = 0;
foreach ($items as $item) {
$pid = (int) $item['product_id'];
@@ -235,10 +257,16 @@ trait Part3 {
$shipping_type = (string) get_post_meta($pid, '_aksrt_shipping_type', true);
if ($shipping_type === 'auto') {
$content = $this->execute_auto_delivery($pid, $opt_key, $qty, $user_id, $order_number);
$content = $this->execute_auto_delivery($pid, $opt_key, $qty, $user_id, $order_number, (int) $item['id']);
if ($content !== '') {
$auto_delivery_parts[] = $content;
$this->update_order_item_delivery($item['id'], $content);
$config = $this->get_auto_delivery_config($pid);
$persisted = $config['type'] === 'card_pass'
? true
: $this->update_order_item_delivery($item['id'], $content);
if ($persisted && $this->auto_delivery_succeeded($pid, $content, $qty)) {
$auto_delivery_successes++;
}
}
}
}
@@ -250,14 +278,28 @@ trait Part3 {
$this->send_auto_delivery_notification($user_id, $order_number, $auto_delivery_parts);
}
if ($auto_delivery_successes === count($items) && class_exists('\AKSRT\Payment\OrderRepository')) {
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
$order_number,
\AKSRT\Payment\Order::STATUS_COMPLETED,
['note' => __('自动发货订单已完成。', 'aksrt')]
);
}
do_action('aksrt_shop_fulfillment_done', $order_number, $order, $items);
}
private function update_order_item_delivery(int $item_id, string $content): void {
private function update_order_item_delivery(int $item_id, string $content): bool {
global $wpdb;
$table = $this->order_item_table();
if ($table === '') return;
$wpdb->update($table, ['auto_delivery_content' => $content], ['id' => $item_id], ['%s'], ['%d']);
if ($table === '') return false;
$updated = $wpdb->update($table, ['auto_delivery_content' => $content], ['id' => $item_id], ['%s'], ['%d']);
if ($updated === false) return false;
if ($updated === 0) {
$stored = $wpdb->get_var($wpdb->prepare("SELECT auto_delivery_content FROM {$table} WHERE id = %d", $item_id));
return is_string($stored) && hash_equals($stored, $content);
}
return true;
}
private function send_auto_delivery_notification(int $user_id, string $order_number, array $parts): void {
+238 -78
View File
@@ -1,6 +1,6 @@
<?php
/**
* Frontend identity verification apply (zibll user-auth page parity on aksrt_user_verify_* meta).
* Frontend identity verification application.
*
* @package AKSRT
*/
@@ -85,59 +85,89 @@ class AuthApply {
return self::get_status($user_id) === 'verified';
}
/**
* @return array<string,mixed>
*/
/** @return array<string,mixed> */
public static function get_auth_info(int $user_id): array {
$info = get_user_meta($user_id, self::INFO_META, true);
return is_array($info) ? $info : [];
}
/**
* @return array<string,mixed>
*/
/** @return array<string,mixed> */
public static function get_apply_payload(int $user_id): array {
$apply = get_user_meta($user_id, self::APPLY_META, true);
return is_array($apply) ? $apply : [];
}
public static function evidence_src(string $value): string {
if (preg_match('#^data:image/(?:jpeg|png|gif|webp);base64,[A-Za-z0-9+/=]+$#', $value)) {
return $value;
/**
* Build the only public representation of certification evidence.
*
* @param array<string,mixed> $application
* @return array<int,array{id:int,url:string,thumbnail_url:string}>
*/
public static function get_evidence_items(array $application): array {
$items = [];
foreach ((array) ($application['evidence_attachment_ids'] ?? []) as $attachment_id) {
$attachment_id = (int) $attachment_id;
if ($attachment_id <= 0 || get_post_type($attachment_id) !== 'attachment') {
continue;
}
$url = wp_get_attachment_url($attachment_id);
if (!is_string($url) || $url === '') {
continue;
}
$thumb = wp_get_attachment_image_url($attachment_id, 'thumbnail');
$items[] = [
'id' => $attachment_id,
'url' => esc_url_raw($url),
'thumbnail_url' => esc_url_raw(is_string($thumb) && $thumb !== '' ? $thumb : $url),
];
}
return esc_url_raw($value);
return $items;
}
/** @param array<string,mixed> $application */
public static function application_view(array $application): array {
return [
'name' => (string) ($application['name'] ?? ''),
'desc' => (string) ($application['desc'] ?? ''),
'msg' => (string) ($application['msg'] ?? ''),
'type' => sanitize_key((string) ($application['type'] ?? '')),
'time' => (string) ($application['time'] ?? ''),
'evidence' => self::get_evidence_items($application),
];
}
public function ajax_apply(): void {
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? '')) !== 'POST') {
wp_send_json_error(['message' => __('请求方式无效', 'aksrt')], 405);
}
if (!is_user_logged_in()) {
wp_send_json(['error' => 1, 'msg' => __('请先登录', 'aksrt')]);
wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401);
}
if (!check_ajax_referer('aksrt_user_auth_apply', '_wpnonce', false)) {
wp_send_json(['error' => 1, 'msg' => __('安全校验失败', 'aksrt')]);
wp_send_json_error(['message' => __('安全校验失败', 'aksrt')], 403);
}
$user_id = get_current_user_id();
$s = self::settings();
if (!$s['enabled'] || !$s['allow_apply']) {
wp_send_json(['error' => 1, 'msg' => __('暂不能申请身份认证', 'aksrt')]);
wp_send_json_error(['message' => __('暂不能申请身份认证', 'aksrt')], 403);
}
if (self::is_verified($user_id)) {
wp_send_json(['error' => 1, 'msg' => __('您已是认证用户', 'aksrt')]);
wp_send_json_error(['message' => __('您已是认证用户', 'aksrt')], 409);
}
if (self::get_status($user_id) === 'pending') {
wp_send_json(['error' => 1, 'msg' => __('您的申请正在审核中', 'aksrt')]);
wp_send_json_error(['message' => __('您的申请正在审核中', 'aksrt')], 409);
}
if ($s['limit_level'] > 0 && class_exists('\AKSRT\User\Level')) {
$lv = (int) Level::get_level($user_id);
if ($lv < $s['limit_level']) {
wp_send_json([
'error' => 1,
'msg' => sprintf(
wp_send_json_error([
'message' => sprintf(
/* translators: %d: required level */
__('等级不足,需达到 LV.%d 后申请', 'aksrt'),
$s['limit_level']
),
]);
], 403);
}
}
@@ -146,77 +176,207 @@ class AuthApply {
$msg = isset($_POST['msg']) ? sanitize_textarea_field(wp_unslash((string) $_POST['msg'])) : '';
$type = isset($_POST['type']) ? sanitize_key((string) wp_unslash($_POST['type'])) : '';
$allowed_types = array_column((array) $s['types'], 'slug');
$name_length = function_exists('mb_strlen') ? mb_strlen($name) : strlen($name);
if ($name_length < 4 || $name_length > 16) {
wp_send_json(['error' => 1, 'msg' => __('认证身份长度需在 4-16 个字符之间', 'aksrt')]);
if ($this->text_length($name) < 4 || $this->text_length($name) > 16) {
wp_send_json_error(['message' => __('认证身份长度需在 4-16 个字符之间', 'aksrt')], 422);
}
if ($this->text_length($desc) > 300 || $this->text_length($msg) > 500) {
wp_send_json_error(['message' => __('认证说明内容过长', 'aksrt')], 422);
}
if ($type === '' || !in_array($type, $allowed_types, true)) {
wp_send_json(['error' => 1, 'msg' => __('请选择有效的认证类型', 'aksrt')]);
wp_send_json_error(['message' => __('请选择有效的认证类型', 'aksrt')], 422);
}
$images = [];
if (!empty($_FILES['image']['name'])) {
$files = $_FILES['image'];
$names = is_array($files['name']) ? $files['name'] : [$files['name']];
if (!is_array($files['name'])) {
$files = [
'name' => $names,
'type' => [$files['type']],
'tmp_name' => [$files['tmp_name']],
'error' => [$files['error']],
'size' => [$files['size']],
];
$lock_key = 'aksrt_auth_apply_lock_' . $user_id;
$lock_token = wp_generate_uuid4();
$lock_payload = ['token' => $lock_token, 'expires' => time() + 300];
if (!add_option($lock_key, $lock_payload, '', false)) {
$current_lock = get_option($lock_key, []);
if (is_array($current_lock) && (int) ($current_lock['expires'] ?? 0) < time()) {
delete_option($lock_key);
}
if (count($files['name']) > 3) {
wp_send_json(['error' => 1, 'msg' => __('证明材料最多上传 3 张', 'aksrt')]);
}
foreach ($files['name'] as $index => $filename) {
if ($filename === '') {
continue;
}
if ((int) ($files['error'][$index] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
wp_send_json(['error' => 1, 'msg' => __('证明材料上传失败,请重新选择', 'aksrt')]);
}
$size = (int) ($files['size'][$index] ?? 0);
$tmp_name = (string) ($files['tmp_name'][$index] ?? '');
if ($size <= 0 || $size > 2 * MB_IN_BYTES || !is_uploaded_file($tmp_name)) {
wp_send_json(['error' => 1, 'msg' => __('单张证明图片不能超过 2MB', 'aksrt')]);
}
$file_check = wp_check_filetype_and_ext($tmp_name, (string) $filename);
$mime = (string) ($file_check['type'] ?? '');
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
wp_send_json(['error' => 1, 'msg' => __('证明材料仅支持 JPG、PNG、GIF 或 WebP 图片', 'aksrt')]);
}
$bytes = file_get_contents($tmp_name);
if (!is_string($bytes) || $bytes === '') {
wp_send_json(['error' => 1, 'msg' => __('证明材料读取失败', 'aksrt')]);
}
$images[] = 'data:' . $mime . ';base64,' . base64_encode($bytes);
if (!add_option($lock_key, $lock_payload, '', false)) {
wp_send_json_error(['message' => __('认证申请正在提交,请勿重复操作', 'aksrt')], 409);
}
}
$uploaded = [];
if (self::is_verified($user_id)) {
$this->release_submission_lock($lock_key, $lock_token);
wp_send_json_error(['message' => __('您已是认证用户', 'aksrt')], 409);
}
if (self::get_status($user_id) === 'pending') {
$this->release_submission_lock($lock_key, $lock_token);
wp_send_json_error(['message' => __('您的申请正在审核中', 'aksrt')], 409);
}
$uploaded = $this->upload_evidence($user_id);
if (is_wp_error($uploaded)) {
$this->release_submission_lock($lock_key, $lock_token);
wp_send_json_error(['message' => $uploaded->get_error_message()], 422);
}
if (!$this->owns_submission_lock($lock_key, $lock_token)) {
$this->delete_attachments($uploaded);
wp_send_json_error(['message' => __('认证申请提交超时,请重新提交', 'aksrt')], 409);
}
$this->renew_submission_lock($lock_key, $lock_token);
$previous_payload = self::get_apply_payload($user_id);
$payload = [
'name' => $name,
'desc' => $desc,
'msg' => $msg,
'type' => $type,
'img' => $images,
'time' => current_time('Y-m-d H:i'),
'name' => $name,
'desc' => $desc,
'msg' => $msg,
'type' => $type,
'evidence_attachment_ids' => $uploaded,
'time' => current_time('Y-m-d H:i'),
];
update_user_meta($user_id, self::APPLY_META, $payload);
update_user_meta($user_id, self::STATUS_META, 'pending');
$payload_saved = update_user_meta($user_id, self::APPLY_META, $payload);
if ($payload_saved === false && get_user_meta($user_id, self::APPLY_META, true) !== $payload) {
$this->delete_attachments($uploaded);
$this->release_submission_lock($lock_key, $lock_token);
wp_send_json_error(['message' => __('认证申请保存失败,请重试', 'aksrt')], 500);
}
$status_saved = update_user_meta($user_id, self::STATUS_META, 'pending');
if ($status_saved === false && self::get_status($user_id) !== 'pending') {
$this->delete_attachments($uploaded);
$previous_payload === []
? delete_user_meta($user_id, self::APPLY_META)
: update_user_meta($user_id, self::APPLY_META, $previous_payload);
$this->release_submission_lock($lock_key, $lock_token);
wp_send_json_error(['message' => __('认证申请保存失败,请重试', 'aksrt')], 500);
}
if (!$this->owns_submission_lock($lock_key, $lock_token)) {
$this->release_submission_lock($lock_key, $lock_token);
wp_send_json_error(['message' => __('认证申请已提交,请刷新查看最新状态', 'aksrt')], 409);
}
$this->renew_submission_lock($lock_key, $lock_token);
delete_user_meta($user_id, self::REASON_META);
$this->delete_attachments(array_column(self::get_evidence_items($previous_payload), 'id'));
/**
* @param int $user_id
* @param array $payload
*/
/** @param int $user_id @param array $payload */
do_action('aksrt_user_auth_apply_submitted', $user_id, $payload);
$this->release_submission_lock($lock_key, $lock_token);
wp_send_json([
'error' => 0,
'msg' => __('提交成功,请等待管理员审核', 'aksrt'),
'reload' => true,
]);
wp_send_json_success([
'message' => __('提交成功,请等待管理员审核', 'aksrt'),
'status' => 'pending',
], 201);
}
/** @return array<int,int>|\WP_Error */
private function upload_evidence(int $user_id) {
$files = $this->normalize_uploads($_FILES['image'] ?? null);
if (is_wp_error($files)) {
return $files;
}
if ($files === []) {
return [];
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$attachment_ids = [];
foreach ($files as $file) {
$filename = sanitize_file_name((string) ($file['name'] ?? ''));
$tmp_name = (string) ($file['tmp_name'] ?? '');
$size = (int) ($file['size'] ?? 0);
$error = (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE);
if ($error !== UPLOAD_ERR_OK || $filename === '' || $size <= 0 || $size > 2 * MB_IN_BYTES || !is_uploaded_file($tmp_name)) {
$this->delete_attachments($attachment_ids);
return new \WP_Error('aksrt_auth_upload_failed', __('证明材料上传失败,单张图片不能超过 2MB', 'aksrt'));
}
$file_check = wp_check_filetype_and_ext($tmp_name, $filename);
$mime = (string) ($file_check['type'] ?? '');
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
$this->delete_attachments($attachment_ids);
return new \WP_Error('aksrt_auth_upload_type', __('证明材料仅支持 JPG、PNG、GIF 或 WebP 图片', 'aksrt'));
}
$attachment_id = media_handle_sideload([
'name' => $filename,
'tmp_name' => $tmp_name,
'type' => $mime,
'error' => UPLOAD_ERR_OK,
'size' => $size,
], 0, __('官方认证证明材料', 'aksrt'));
if (is_wp_error($attachment_id)) {
$this->delete_attachments($attachment_ids);
return new \WP_Error('aksrt_auth_upload_failed', __('证明材料写入媒体库失败', 'aksrt'));
}
$attachment_id = (int) $attachment_id;
$stored_mime = (string) get_post_mime_type($attachment_id);
if (!in_array($stored_mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
wp_delete_attachment($attachment_id, true);
$this->delete_attachments($attachment_ids);
return new \WP_Error('aksrt_auth_upload_type', __('证明材料类型校验失败', 'aksrt'));
}
wp_update_post(['ID' => $attachment_id, 'post_author' => $user_id]);
update_post_meta($attachment_id, '_aksrt_verify_evidence_owner', $user_id);
$attachment_ids[] = $attachment_id;
}
return $attachment_ids;
}
/** @return array<int,array<string,mixed>>|\WP_Error */
private function normalize_uploads($input) {
if (!is_array($input) || empty($input['name'])) {
return [];
}
$names = is_array($input['name']) ? $input['name'] : [$input['name']];
if (count($names) > 3) {
return new \WP_Error('aksrt_auth_upload_limit', __('证明材料最多上传 3 张', 'aksrt'));
}
$files = [];
foreach ($names as $index => $name) {
if ((string) $name === '') {
continue;
}
$files[] = [
'name' => $name,
'type' => is_array($input['type'] ?? null) ? ($input['type'][$index] ?? '') : ($input['type'] ?? ''),
'tmp_name' => is_array($input['tmp_name'] ?? null) ? ($input['tmp_name'][$index] ?? '') : ($input['tmp_name'] ?? ''),
'error' => is_array($input['error'] ?? null) ? ($input['error'][$index] ?? UPLOAD_ERR_NO_FILE) : ($input['error'] ?? UPLOAD_ERR_NO_FILE),
'size' => is_array($input['size'] ?? null) ? ($input['size'][$index] ?? 0) : ($input['size'] ?? 0),
];
}
return $files;
}
/** @param array<int,int|string> $attachment_ids */
private function delete_attachments(array $attachment_ids): void {
foreach ($attachment_ids as $attachment_id) {
$attachment_id = (int) $attachment_id;
if ($attachment_id > 0 && get_post_type($attachment_id) === 'attachment') {
wp_delete_attachment($attachment_id, true);
}
}
}
private function owns_submission_lock(string $lock_key, string $lock_token): bool {
$current_lock = get_option($lock_key, []);
return is_array($current_lock)
&& (int) ($current_lock['expires'] ?? 0) >= time()
&& hash_equals((string) ($current_lock['token'] ?? ''), $lock_token);
}
private function renew_submission_lock(string $lock_key, string $lock_token): void {
if ($this->owns_submission_lock($lock_key, $lock_token)) {
update_option($lock_key, ['token' => $lock_token, 'expires' => time() + 300], false);
}
}
private function release_submission_lock(string $lock_key, string $lock_token): void {
$current_lock = get_option($lock_key, []);
if (is_array($current_lock) && hash_equals((string) ($current_lock['token'] ?? ''), $lock_token)) {
delete_option($lock_key);
}
}
private function text_length(string $value): int {
return function_exists('mb_strlen') ? mb_strlen($value) : strlen($value);
}
}