diff --git a/assets/js/auth.js b/assets/js/auth.js index b366ec51..0e6e19db 100644 --- a/assets/js/auth.js +++ b/assets/js/auth.js @@ -36,7 +36,7 @@ }); } - function showToast(text, type) { + function notify(text, type) { if (!text) return; window.notyf(escapeHtml(text), type || 'info'); } @@ -69,7 +69,7 @@ box.dataset.loading = '0'; box.dataset.loaded = '0'; box.innerHTML = ''; - showToast(error.message, 'danger'); + notify(error.message, 'danger'); }); } @@ -78,14 +78,14 @@ window.setTimeout(function () { request('check-qrcode-login', { token: token }).then(function (result) { if (result.logged_in) { - showToast('登录成功', 'success'); + notify('登录成功', 'success'); window.location.assign(result.redirect || window.location.href); return; } if (result.status === 'expired') { box.dataset.loaded = '0'; box.innerHTML = ''; - showToast('二维码已过期,请刷新后重试', 'warning'); + notify('二维码已过期,请刷新后重试', 'warning'); return; } pollQr(box, token, count + 1); @@ -98,11 +98,11 @@ var button = form.querySelector('button[type="submit"]'); if (button) button.disabled = true; request('weixingzh-code', data).then(function (result) { - showToast('登录成功', 'success'); + notify('登录成功', 'success'); window.location.assign(result.redirect || window.location.href); }).catch(function (error) { if (button) button.disabled = false; - showToast(error.message, 'danger'); + notify(error.message, 'danger'); }); } @@ -156,7 +156,7 @@ var form = button.closest('form'); var target = form && form.querySelector('[name="target"]'); if (!target || !target.value.trim()) { - showToast('请先填写邮箱或手机号', 'warning'); + notify('请先填写邮箱或手机号', 'warning'); return; } button.disabled = true; @@ -167,7 +167,7 @@ verifyCaptcha(payload.scene, function (verified) { if (!verified) { button.disabled = false; - showToast('请先完成人机验证', 'warning'); + notify('请先完成人机验证', 'warning'); return; } payload = formData(form); @@ -175,11 +175,11 @@ payload.target = target.value.trim(); payload.channel = (form.querySelector('[name="channel"]') || {}).value || ''; request('code', payload).then(function (result) { - showToast(result.message || '验证码已发送', 'success'); + notify(result.message || '验证码已发送', 'success'); startCountdown(button, result.countdown || 60); }).catch(function (error) { button.disabled = false; - showToast(error.message, 'danger'); + notify(error.message, 'danger'); }).finally(function () { resetCaptcha(payload.scene); }); @@ -194,10 +194,10 @@ } setBusy(form, true); request(action, payload).then(function (result) { - showToast(result.message || '操作成功', 'success'); + notify(result.message || '操作成功', 'success'); if (result.redirect) window.setTimeout(function () { window.location.assign(result.redirect); }, 350); }).catch(function (error) { - showToast(error.message, 'danger'); + notify(error.message, 'danger'); setBusy(form, false); }); } @@ -255,7 +255,7 @@ function validateForm(form) { if (!form.checkValidity || form.checkValidity()) return true; var field = form.querySelector(':invalid'); - showToast((field && field.validationMessage) || '请完整填写表单', 'warning'); + notify((field && field.validationMessage) || '请完整填写表单', 'warning'); if (field && typeof field.focus === 'function') field.focus(); return false; } diff --git a/assets/widgets/frontend.js b/assets/widgets/frontend.js index 60f1ddb6..5d348ba9 100644 --- a/assets/widgets/frontend.js +++ b/assets/widgets/frontend.js @@ -27,6 +27,79 @@ tabs[next].click(); } + function initSwiperA11y(el) { + if (el.dataset.autoplay !== '1' || !once(el, 'SwiperA11y')) return; + var i18n = config.i18n || {}; + var userPaused = reducedMotion; + var focusPaused = false; + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'aksrt-widget-swiper__pause'; + + function setControlState() { + button.setAttribute('aria-pressed', userPaused ? 'true' : 'false'); + button.setAttribute('aria-label', userPaused ? (i18n.resume || '继续自动播放') : (i18n.pause || '暂停自动播放')); + button.textContent = userPaused ? '▶' : 'Ⅱ'; + } + + function autoplay() { + return el.swiper && el.swiper.autoplay ? el.swiper.autoplay : null; + } + + function stopAutoplay() { + var runtime = autoplay(); + if (runtime && typeof runtime.stop === 'function') runtime.stop(); + setControlState(); + } + + function pause() { + focusPaused = true; + stopAutoplay(); + } + + function resume() { + if (reducedMotion || userPaused || focusPaused) { + stopAutoplay(); + return; + } + var runtime = autoplay(); + if (runtime && typeof runtime.start === 'function') runtime.start(); + setControlState(); + } + + button.addEventListener('click', function () { + if (userPaused && !reducedMotion) { + userPaused = false; + resume(); + } else { + userPaused = true; + stopAutoplay(); + } + }); + el.addEventListener('focusin', pause); + el.addEventListener('focusout', function () { + window.setTimeout(function () { + if (el.contains(document.activeElement)) return; + focusPaused = false; + resume(); + }, 0); + }); + + setControlState(); + el.appendChild(button); + + var attempts = 0; + function connectToSwiper() { + if (autoplay()) { + if (reducedMotion || userPaused || focusPaused) stopAutoplay(); + return; + } + attempts += 1; + if (attempts < 120) window.requestAnimationFrame(connectToSwiper); + } + connectToSwiper(); + } + function videoType(url) { if (/\.m3u8(?:\?|$)/i.test(url)) return 'hls'; if (/\.flv(?:\?|$)/i.test(url)) return 'flv'; @@ -257,6 +330,8 @@ function scan(root) { var scope = root || document; + if (scope.matches && scope.matches('.aksrt-widget-swiper')) initSwiperA11y(scope); + scope.querySelectorAll('.aksrt-widget-swiper').forEach(initSwiperA11y); scope.querySelectorAll('.aksrt-widget-video[data-video-url]').forEach(initVideo); scope.querySelectorAll('.aksrt-widget-toc').forEach(initToc); scope.querySelectorAll('.aksrt-widget-quote').forEach(initQuote); diff --git a/src/Credits/BalanceServiceRest.php b/src/Credits/BalanceServiceRest.php index dc356b6f..f6524d69 100644 --- a/src/Credits/BalanceServiceRest.php +++ b/src/Credits/BalanceServiceRest.php @@ -373,17 +373,42 @@ trait BalanceServiceRest { } return true; }; - $order_number = 'PX' . strtoupper(substr(hash('sha256', $operation_key), 0, 30)); - $business_mutation = static function (int $transaction_id) use ($user_id, $amount, $points, $operation_key, $order_number) { - if (!class_exists('\\AKSRT\\Payment\\OrderRepository')) { - return new \WP_Error('aksrt_order_unavailable', __('订单服务不可用', 'aksrt'), ['status' => 500]); + if (!class_exists('\\AKSRT\\Payment\\OrderRepository')) { + return new \WP_Error('aksrt_order_unavailable', __('订单服务不可用', 'aksrt'), ['status' => 500]); + } + $note = wp_json_encode([ + 'points_exchange' => 1, + 'points' => $points, + 'product_title' => sprintf(__('购买 %d 积分', 'aksrt'), $points), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $repo = \AKSRT\Payment\OrderRepository::get_instance(); + $existing_order = $repo->get_by_request_key($operation_key); + $is_new_order = !is_array($existing_order); + if (is_array($existing_order)) { + $existing_meta = json_decode((string) ($existing_order['note'] ?? ''), true); + $existing_meta = is_array($existing_meta) ? $existing_meta : []; + if ((int) ($existing_order['user_id'] ?? 0) !== $user_id + || sanitize_key((string) ($existing_order['product_type'] ?? '')) !== \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS + || (int) ($existing_order['product_id'] ?? 0) !== 0 + || (string) ($existing_order['amount'] ?? '') !== $amount->decimal() + || sanitize_key((string) ($existing_order['currency'] ?? '')) !== 'cny' + || sanitize_key((string) ($existing_order['payment_method'] ?? '')) !== 'balance' + || (int) ($existing_meta['points'] ?? 0) !== $points + ) { + return new \WP_Error('aksrt_ledger_idempotency_conflict', __('相同操作标识已用于其他积分购买请求', 'aksrt'), ['status' => 409]); } - $note = wp_json_encode([ - 'points_exchange' => 1, - 'points' => $points, - 'product_title' => sprintf(__('购买 %d 积分', 'aksrt'), $points), - ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $created = \AKSRT\Payment\OrderRepository::get_instance()->create([ + $order_number = (string) ($existing_order['order_number'] ?? ''); + } else { + $order_number = $repo->allocate_order_number(); + } + if ($order_number === '') { + return new \WP_Error('aksrt_order_create_failed', __('积分订单创建失败', 'aksrt'), ['status' => 500]); + } + $business_mutation = static function (int $transaction_id) use ($is_new_order, $repo, $user_id, $amount, $points, $operation_key, $order_number, $note) { + if (!$is_new_order) { + return true; + } + $created = $repo->create([ 'order_number' => $order_number, 'user_id' => $user_id, 'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_POINTS, diff --git a/tests/widget-system/asset-ledger-contract.test.cjs b/tests/widget-system/asset-ledger-contract.test.cjs index f420ff72..2ad6ddd6 100644 --- a/tests/widget-system/asset-ledger-contract.test.cjs +++ b/tests/widget-system/asset-ledger-contract.test.cjs @@ -234,6 +234,9 @@ test('asset purchases share the order center and refresh every wallet surface', assert.match(balanceRest, /register_rest_route\('aksrt\/v1', '\/pay\/assets'/) assert.match(balanceRest, /'product_type'\s*=>\s*\\AKSRT\\Core\\DomainModel::ORDER_PRODUCT_POINTS/) + assert.match(balanceRest, /\$existing_order\s*=\s*\$repo->get_by_request_key\(\$operation_key\)/) + assert.match(balanceRest, /\$order_number\s*=\s*\$repo->allocate_order_number\(\)/) + assert.doesNotMatch(balanceRest, /['"]PX['"]\s*\.|hash\('sha256',\s*\$operation_key\)/) assert.match(balanceRest, /\['type'\s*=>\s*'order',\s*'key'\s*=>\s*\$order_number\]/) assert.match(balanceRest, /'order_number'\s*=>\s*\$order_number/) assert.match(chargeCard, /self::TYPE_CREDITS[\s\S]*?ORDER_PRODUCT_POINTS/) diff --git a/tests/widget-system/auth-source-contract.test.cjs b/tests/widget-system/auth-source-contract.test.cjs index ceeb244d..12e31ba7 100644 --- a/tests/widget-system/auth-source-contract.test.cjs +++ b/tests/widget-system/auth-source-contract.test.cjs @@ -174,7 +174,7 @@ test('shared renderer contracts every configured authentication state', () => { assert.match(client, /data-password-show/); assert.match(client, /pollQr/); assert.match(client, /window\.notyf\(escapeHtml\(text\), type \|\| 'info'\)/); - assert.match(client, /showToast\('请先完成人机验证', 'warning'\)/); + assert.match(client, /notify\('请先完成人机验证', 'warning'\)/); assert.match(client, /form\.noValidate = true/); assert.match(client, /form\.checkValidity\(\)[\s\S]*?field\.validationMessage/); assert.match(client, /window\.location\.assign\(authPageUrl/);