From 5375ad6429785a3bcdad77bb5e8159bfc7951c6d Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 5 Aug 2026 02:04:55 +0800 Subject: [PATCH] perf(modal): unify cancellable frontend modal flows Co-Authored-By: Claude Fable 5 --- .specs/plans/frontend-modal-runtime.md | 22 +- assets/css/routes/orders.css | 6 +- assets/css/routes/user-center/settings.css | 2 + assets/js/core/modal-runtime.js | 47 +- assets/js/credits.js | 186 +++--- assets/js/order-center.js | 240 +++++--- assets/js/pay-vip.js | 534 +++++++++++------ assets/js/routes/user-center-auth.js | 352 ++++++++--- assets/js/user-badges.js | 562 ++++++++---------- assets/js/vip-purchase-coordinator.js | 356 +++++++++++ functions.php | 3 - .../user-center-renderers/credits-vip.php | 39 +- .../user-center-renderers/profile-auth.php | 40 +- .../user-center-renderers/profile-header.php | 27 +- .../user-center-renderers/profile-orders.php | 3 +- src/Content/VipServiceModal.php | 386 ++++-------- src/Content/VipServicePackages.php | 5 +- src/Content/VipServicePurchase.php | 318 ++++++---- src/Content/VipServiceRest.php | 23 +- src/Core/DBMigrator.php | 2 +- src/Core/DBMigratorPart2.php | 1 + src/Core/ScriptsPart1b.php | 25 +- src/Identity/ApplicationRepository.php | 108 +++- src/Identity/ApplicationService.php | 1 - src/Identity/RestController.php | 63 +- src/Payment/Gateway.php | 39 +- src/Payment/OrderCenterRest.php | 14 + src/Payment/OrderRepositoryCore.php | 162 +++-- src/Payment/PaymentOrchestrator.php | 11 +- src/Payment/UserOrders.php | 209 +++++-- src/User/AuthApply.php | 168 +++++- src/User/Badges.php | 417 ++++--------- src/User/BadgesAdmin.php | 1 + src/User/Home.php | 38 +- src/Widgets/WidgetAuthorCard.php | 3 - templates/parts/user/author-hero.php | 16 +- 36 files changed, 2623 insertions(+), 1806 deletions(-) create mode 100644 assets/js/vip-purchase-coordinator.js diff --git a/.specs/plans/frontend-modal-runtime.md b/.specs/plans/frontend-modal-runtime.md index 5fc34207..518b689d 100644 --- a/.specs/plans/frontend-modal-runtime.md +++ b/.specs/plans/frontend-modal-runtime.md @@ -2,13 +2,13 @@ ## Architectural Decisions -- **Canonical runtime**: Refactor `assets/js/core/remote-modal.js` into the single `AksrtModalRuntime` seam for shell creation, lifecycle, focus, loading, error, cancellation, and refresh. Domain scripts provide loaders and view-model renderers; they do not create competing modal shells. -- **Loader contract**: A loader receives a domain context and abort signal and returns a minimal view model plus render/update callbacks. It may read only the active modal's data. +- **Canonical runtime**: `assets/js/core/modal-runtime.js` is the single `AksrtModalRuntime` seam for shell creation, lifecycle, focus, loading, error, cancellation, and refresh. `assets/js/core/modal-triggers.js` only binds the new declarative trigger contract; no legacy modal alias or compatibility bridge exists. +- **Loader contract**: A loader receives a domain context and abort signal and returns a minimal view model plus render/update callbacks. `AksrtModalRuntime.openRemote({responseType: 'json', renderData})` owns the request, cache, cancellation, and lifecycle while the domain renderer owns only view-model markup. It may read only the active modal's data. - **Cache contract**: Use in-memory, user-scoped cache entries with short TTLs, in-flight request deduplication, explicit invalidation tags, and complete clearing on logout/account switch. Do not persist private modal payloads in localStorage. - **State contract**: All domains use the same `idle`, `loading`, `ready`, `empty`, `submitting`, `success`, `error`, `unauthorized`, and `stale` states. Skeletons match the final layout and never represent business truth. - **API boundary**: Existing `aksrt/v1` domain routes remain the service boundary; HTML modal responses are replaced domain by domain with minimal JSON view models. Every changed REST route keeps a `permission_callback`; AJAX is not a new data path. - **Request adapter**: Add one thin `AksrtRequest` adapter for the injected REST root, nonce headers, JSON/error envelopes, 401 handling, and abort signals. Domain files stop maintaining their own REST-root and response-parsing variants. -- **Rehydrate and feedback**: Remote HTML that remains during migration must call `AksrtFrontend.rehydrate(body, 'modal')`; shared feedback must call the existing callable `AksrtToast` API (with `aksrtNotify`/alert fallback), not assume an incompatible `.show` method. Runtime binding must be idempotent so `pay-vip.js` does not register both registry and immediate fallback handlers. +- **Rehydrate and feedback**: Every runtime-rendered HTML response calls `AksrtFrontend.rehydrate(body, 'modal')`; shared feedback calls the existing callable `AksrtToast` API directly. There is one binding path and no compatibility fallback. - **Rollout**: Add a temporary per-domain rollout section under the existing Tools configuration surface, read through `AKSRT_Settings`. One canonical new implementation is enabled per domain; the switch is for staged release, not a compatibility data layer. - **Telemetry**: Use browser `performance.mark/measure` and server `Server-Timing`/existing deployment observability by default. Do not add a private payload or payment/identity-content event store in this foundation plan. - **Build boundary**: Frontend assets are plain theme JavaScript, not the Admin SPA bundle. Run `node --check` for changed JS and run the admin contract audit only when the rollout schema/controller changes. @@ -31,10 +31,10 @@ Primary integration points: ### Done when -- [ ] The Tools schema, REST response, and `AKSRT_Settings` reader expose independent VIP, order, identity, and badge rollout values; `php tools/audit-admin-contracts.php` exits 0. -- [ ] `assets/js/core/modal-telemetry.js` emits click, shell, first-content, ready, action-start, action-end, and error marks without including payment credentials, order contents, or identity evidence. +- [x] The Tools schema, REST response, and `AKSRT_Settings` reader expose independent VIP, order, identity, and badge rollout values; `php tools/audit-admin-contracts.php` exits 0. +- [x] `assets/js/core/modal-telemetry.js` emits click, shell, first-content, ready, action-start, action-end, and error marks without including payment credentials, order contents, or identity evidence. - [ ] Browser scenario `modal-baseline` records cold and warm measurements for all four current entry points, and the baseline artifact names the request/SQL counts used for later comparison. -- [ ] `node --check assets/js/core/modal-telemetry.js assets/js/pay-vip.js assets/js/order-center.js assets/js/routes/user-center-auth.js assets/js/user-badges.js` passes. +- [x] `node --check assets/js/core/modal-telemetry.js assets/js/pay-vip.js assets/js/order-center.js assets/js/routes/user-center-auth.js assets/js/user-badges.js` passes. --- @@ -44,14 +44,14 @@ Primary integration points: ### What to build -Refactor `AksrtRemoteModal` in `assets/js/core/remote-modal.js` into the canonical runtime while preserving its existing visual shell as the first implementation. Add focused runtime modules for loader registration, cache/state, telemetry integration, and the `AksrtRequest` adapter. The runtime must support a skeleton before data, JSON or HTML during the migration boundary, abort signals on close/replacement, request deduplication, explicit invalidation, focus return, keyboard/overlay close, mobile bottom-sheet behavior, and retryable errors. When HTML is still returned, invoke `AksrtFrontend.rehydrate` for modal-scoped handlers and route notifications through the callable Toast adapter. Wire the existing generic remote-modal trigger set through this runtime, make feature registration idempotent, remove the pay-vip registry/immediate-bind double path, and inventory every remaining caller before domain migrations begin. +Build the canonical `AksrtModalRuntime` in `assets/js/core/modal-runtime.js` with focused request, cache, state, and trigger modules. The runtime must show a skeleton before data, use the single new modal trigger contract, abort requests on close/replacement, deduplicate reads, invalidate explicit tags, restore focus, support keyboard/overlay close, mobile bottom-sheet behavior, retryable errors, and call `AksrtFrontend.rehydrate` after every HTML render. All domain callers use the canonical request/runtime/Toast APIs directly; no legacy alias, second host, or migration compatibility path is retained. ### Done when -- [ ] `assets/js/core/remote-modal.js` exposes one canonical `AksrtModalRuntime.open/close/refresh` lifecycle, `AksrtRequest` owns REST root/nonce/envelope handling, and no domain script creates a second global modal host. -- [ ] `node --check assets/js/core/remote-modal.js assets/js/core/request.js assets/js/core/modal-runtime.js assets/js/core/modal-cache.js assets/js/core/modal-state.js assets/js/pay-vip.js` passes, using the actual new filenames if the implementation keeps modules consolidated. -- [ ] Browser scenario `modal-shell-lifecycle` verifies shell visibility within the 100ms initial budget, skeleton/error/retry states, ESC and overlay close, focus return, request cancellation, mobile bottom-sheet behavior, callable Toast fallback, and `AksrtFrontend.rehydrate(body, 'modal')` after remote HTML. -- [ ] Browser scenario `modal-cache-dedupe` verifies one read for concurrent/repeated opens, invalidation after a successful write, no reuse after logout/account switch, and one pay-vip binding; `git grep -n "AksrtRemoteModal\|data-toggle=\"AksrtRemoteModal\"\|data-aksrt-remote-modal"` produces an explicit migration inventory. +- [x] `assets/js/core/modal-runtime.js` exposes one canonical `AksrtModalRuntime.open/close/refresh` lifecycle, `AksrtRequest` owns REST root/nonce/envelope handling, and no domain script creates a second global modal host. +- [x] `node --check assets/js/core/modal-triggers.js assets/js/core/request.js assets/js/core/modal-runtime.js assets/js/core/modal-cache.js assets/js/core/modal-state.js assets/js/pay-vip.js` passes. +- [ ] Browser scenario `modal-shell-lifecycle` verifies shell visibility within the 100ms initial budget, skeleton/error/retry states, ESC and overlay close, focus return, request cancellation, mobile bottom-sheet behavior, callable Toast, and `AksrtFrontend.rehydrate(body, 'modal')` after runtime HTML. +- [ ] Browser scenario `modal-cache-dedupe` verifies one read for concurrent/repeated opens, invalidation after a successful write, no reuse after logout/account switch, and one pay-vip binding; `git grep -n "AksrtRemoteModal\|RefreshModal\|data-aksrt-remote-modal" -- '*.js' '*.php' '*.css'` returns no matches. --- diff --git a/assets/css/routes/orders.css b/assets/css/routes/orders.css index 5d6fed71..96b8e09c 100644 --- a/assets/css/routes/orders.css +++ b/assets/css/routes/orders.css @@ -94,9 +94,9 @@ .aksrt-orders-page__empty i { display: block; margin-bottom: 12px; color: var(--color-primary, #3b82f6); font-size: 30px; } .aksrt-orders-page__empty h3 { margin: 0 0 7px; font-size: 15px; }.aksrt-orders-page__empty p { margin: 0 0 16px; color: var(--orders-muted); font-size: 13px; } -.aksrt-modal[hidden] { display: none !important; }.aksrt-modal { position: fixed; inset: 0; z-index: var(--z-modal, 10050); display: grid; place-items: center; padding: 16px; }.aksrt-modal__overlay { position: absolute; inset: 0; background: rgba(18, 31, 50, .5); backdrop-filter: blur(4px); }.aksrt-modal__dialog { position: relative; width: min(470px, 100%); overflow: hidden; border: 1px solid var(--orders-line); border-radius: 16px; background: var(--surface-1, #fff); box-shadow: 0 24px 62px rgba(16, 25, 42, .25); }.aksrt-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 15px 17px; border-bottom: 1px solid var(--orders-line); }.aksrt-modal__header h3 { margin: 0; font-size: 15px; }.aksrt-modal__close { width: 28px; height: 28px; border: 0; border-radius: 50%; background: var(--surface-2, #f4f6f9); color: var(--orders-muted); font-size: 19px; cursor: pointer; }.aksrt-modal__body { display: grid; gap: 14px; padding: 16px; }.aksrt-modal__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; padding: 12px 16px; border-top: 1px solid var(--orders-line); background: var(--surface-2, #f7f8fb); } +.aksrt-modal__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; padding: 12px 16px; border-top: 1px solid var(--orders-line); background: var(--surface-2, #f7f8fb); } .aksrt-order-detail__hero { display: flex; align-items: center; justify-content: space-between; padding: 15px; border-radius: 11px; background: color-mix(in srgb, var(--color-primary, #3b82f6) 9%, var(--surface-2, #f7f8fb)); }.aksrt-order-detail__hero.is-pending { background: color-mix(in srgb, var(--color-warning, #c87819) 11%, var(--surface-2, #f7f8fb)); }.aksrt-order-detail__hero.is-completed { background: color-mix(in srgb, var(--color-success, #168560) 10%, var(--surface-2, #f7f8fb)); }.aksrt-order-detail__status-pill { font-size: 12px; font-weight: 700; }.aksrt-order-detail__hero-amount { font-size: 23px; font-variant-numeric: tabular-nums; }.aksrt-order-detail__product { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 11px; align-items: center; }.aksrt-order-detail__thumb { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; }.aksrt-order-detail__thumb--empty { display: grid; place-items: center; background: var(--surface-2, #f4f6f9); color: var(--color-primary, #3b82f6); }.aksrt-order-detail__product-title { display: block; line-height: 1.4; }.aksrt-order-detail__product-meta { display: block; margin-top: 5px; color: var(--orders-muted); font-size: 12px; }.aksrt-order-detail__list { margin: 0; border: 1px solid var(--orders-line); border-radius: 10px; overflow: hidden; }.aksrt-order-detail__list > div { display: grid; grid-template-columns: 86px minmax(0, 1fr); padding: 10px 12px; border-bottom: 1px solid var(--orders-line); font-size: 12px; }.aksrt-order-detail__list > div:last-child { border-bottom: 0; }.aksrt-order-detail__list dt { color: var(--orders-muted); }.aksrt-order-detail__list dd { margin: 0; overflow-wrap: anywhere; text-align: right; font-variant-numeric: tabular-nums; } -@media (max-width: 767px) { .uc-orders-panel__head { grid-template-columns: 1fr; gap: 17px; }.aksrt-orders-page__stats { max-width: none; }.aksrt-orders-page__search { flex-wrap: wrap; }.aksrt-orders-page__search input { min-width: 150px; }.aksrt-orders-page__search .btn-primary { margin-left: auto; }.aksrt-orders-page__filter-toggle { margin-left: auto; }.aksrt-orders-page__card-rail time { width: 100%; margin-left: 0; order: 3; }.aksrt-orders-page__card-main { grid-template-columns: 48px minmax(0, 1fr); }.aksrt-orders-page__card-img, .aksrt-orders-page__card-art { width: 48px; height: 48px; }.aksrt-orders-page__card-value { grid-column: 2; text-align: left; }.aksrt-orders-page__card-actions > :not(.aksrt-orders-page__countdown) { flex: 1 1 auto; }.aksrt-orders-page__countdown { width: 100%; }.aksrt-modal { align-items: end; padding: 0; }.aksrt-modal__dialog { width: 100%; border-radius: 16px 16px 0 0; }.aksrt-modal__footer > * { flex: 1; } } +@media (max-width: 767px) { .uc-orders-panel__head { grid-template-columns: 1fr; gap: 17px; }.aksrt-orders-page__stats { max-width: none; }.aksrt-orders-page__search { flex-wrap: wrap; }.aksrt-orders-page__search input { min-width: 150px; }.aksrt-orders-page__search .btn-primary { margin-left: auto; }.aksrt-orders-page__filter-toggle { margin-left: auto; }.aksrt-orders-page__card-rail time { width: 100%; margin-left: 0; order: 3; }.aksrt-orders-page__card-main { grid-template-columns: 48px minmax(0, 1fr); }.aksrt-orders-page__card-img, .aksrt-orders-page__card-art { width: 48px; height: 48px; }.aksrt-orders-page__card-value { grid-column: 2; text-align: left; }.aksrt-orders-page__card-actions > :not(.aksrt-orders-page__countdown) { flex: 1 1 auto; }.aksrt-orders-page__countdown { width: 100%; }.aksrt-modal__footer > * { flex: 1; } } @media (prefers-reduced-motion: reduce) { .aksrt-orders-page__card { transition: none; } } -html.dark .aksrt-orders-page__stat, html.dark .aksrt-orders-page__card, html.dark .aksrt-modal__dialog { background: var(--surface-1, #1a1d24); } html.dark .aksrt-orders-page__card-rail, html.dark .aksrt-orders-page__filter-panel, html.dark .aksrt-modal__footer { background: var(--surface-2, #12151b); } +html.dark .aksrt-orders-page__stat, html.dark .aksrt-orders-page__card { background: var(--surface-1, #1a1d24); } html.dark .aksrt-orders-page__card-rail, html.dark .aksrt-orders-page__filter-panel, html.dark .aksrt-modal__footer { background: var(--surface-2, #12151b); } diff --git a/assets/css/routes/user-center/settings.css b/assets/css/routes/user-center/settings.css index 27f461c3..cb2959b7 100644 --- a/assets/css/routes/user-center/settings.css +++ b/assets/css/routes/user-center/settings.css @@ -400,6 +400,8 @@ .uc-auth-modal { z-index: 620; } .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); } +.aksrt-identity-modal-shell .aksrt-modal-runtime__dialog { width: min(620px, calc(100vw - 28px)); max-width: 620px; } +.aksrt-identity-modal-shell .uc-auth-modal__dialog { width: auto; max-width: none; max-height: none; overflow: visible; background: transparent; } .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; } diff --git a/assets/js/core/modal-runtime.js b/assets/js/core/modal-runtime.js index 7440dbbe..331ca376 100644 --- a/assets/js/core/modal-runtime.js +++ b/assets/js/core/modal-runtime.js @@ -230,14 +230,18 @@ }, 220); } + function responseData(result) { + if (!result) return null; + if (result.data !== undefined && result.data !== null) return result.data; + if (result.json && result.json.data !== undefined) return result.json.data; + return result.json; + } + function responseHtml(result) { - if (!result) return ""; - var json = result.json; - if (json) { - if (typeof json.html === "string") return json.html; - if (json.data && typeof json.data.html === "string") return json.data.html; - } - return typeof result.text === "string" ? result.text : ""; + var data = responseData(result); + if (data && typeof data.html === "string") return data.html; + if (result && result.json && typeof result.json.html === "string") return result.json.html; + return result && typeof result.text === "string" ? result.text : ""; } function errorMessage(error) { @@ -262,6 +266,27 @@ function renderResult(session, result) { if (!session || session.closed || active !== session) return; + + if (session.options.responseType === "json" || typeof session.options.renderData === "function") { + var data = responseData(result); + if (data === null || data === undefined) { + setState(session, "empty"); + telemetryMark(session, "first-content"); + telemetryMark(session, "ready"); + session.content.innerHTML = '
暂无内容
'; + return; + } + if (typeof session.options.renderData === "function") { + session.options.renderData(data, session, result); + } + setState(session, "ready"); + telemetryMark(session, "first-content"); + rehydrate(session); + telemetryMark(session, "ready"); + if (typeof session.options.onData === "function") session.options.onData(data, session, result); + return; + } + var html = responseHtml(result); if (!html) { setState(session, "empty"); @@ -379,6 +404,13 @@ return window.AksrtModalCache.invalidate(tags); } + function rolloutEnabled(kind) { + var config = window.aksrtData || window.aksrt || {}; + var rollout = config.frontendModalRollout; + if (!rollout || typeof rollout[kind] === "undefined") return true; + return rollout[kind] === true; + } + window.AksrtModalRuntime = { open: open, openRemote: openRemote, @@ -387,6 +419,7 @@ registerLoader: registerLoader, openLoader: openLoader, invalidate: invalidate, + isRolloutEnabled: rolloutEnabled, getActive: function () { return active ? active.shell : null; }, getState: function () { return active ? active.state : null; }, }; diff --git a/assets/js/credits.js b/assets/js/credits.js index 5357cf7d..4eb8c216 100644 --- a/assets/js/credits.js +++ b/assets/js/credits.js @@ -14,49 +14,23 @@ if (!aksrt) { return; } - - // Prefer restUrl/apiUrl from wp_localize (handles plain permalinks: - // /?rest_route=/aksrt/v1/...). Never hardcode /wp-json/ under siteUrl. - var REST = ''; - if (aksrt.restUrl) { - REST = String(aksrt.restUrl).replace(/\/?$/, ''); - } else if (aksrt.apiUrl) { - REST = String(aksrt.apiUrl).replace(/\/?$/, ''); - } else if (aksrt.siteUrl) { - var siteUrl = String(aksrt.siteUrl); - if (window.location.protocol === 'https:' && siteUrl.substring(0, 5) === 'http:') { - siteUrl = 'https:' + siteUrl.substring(5); - } - REST = siteUrl.replace(/\/$/, '') + '/wp-json/aksrt/v1'; - } else { - return; - } - // Force HTTPS if page is HTTPS (mixed content). - if (window.location.protocol === 'https:' && REST.substring(0, 5) === 'http:') { - REST = 'https:' + REST.substring(5); - } - var NONCE_HEADER = aksrt.restNonce ? { 'X-WP-Nonce': aksrt.restNonce } : (aksrt.nonce ? { 'X-WP-Nonce': aksrt.nonce } : {}); + var activePoll = null; function api(path, options) { - var merged = Object.assign({ - credentials: 'same-origin', - headers: Object.assign({ 'Content-Type': 'application/json' }, NONCE_HEADER), - }, options || {}); - return fetch(REST + path, merged).then(function (r) { - // 401 时去掉 nonce 重试(页面缓存可能导致 nonce 过期,GET 请求可用 cookie 认证) - if (r.status === 401 && (!options || !options.method || options.method === 'GET')) { - var retry = Object.assign({}, merged, { - headers: { 'Content-Type': 'application/json' } - }); - return fetch(REST + path, retry).then(function (r2) { - return r2.json().catch(function () { - return { message: '服务器返回了非 JSON 响应 (HTTP ' + r2.status + ')' }; - }).then(function (j) { return { ok: r2.ok, status: r2.status, json: j }; }); - }); - } - return r.json().catch(function () { - return { message: '服务器返回了非 JSON 响应 (HTTP ' + r.status + ')' }; - }).then(function (j) { return { ok: r.ok, status: r.status, json: j }; }); + return window.AksrtRequest.request(path, options || {}).then(function (response) { + return { + ok: response.ok, + status: response.status, + json: response.json || {}, + data: response.data, + }; + }).catch(function (error) { + var response = error && error.response ? error.response : {}; + return { + ok: false, + status: error && error.status ? error.status : (response.status || 0), + json: error && error.json ? error.json : (response.json || { message: error && error.message }), + }; }); } @@ -411,12 +385,13 @@ - // ===== VIP 积分兑换 ===== + // ===== VIP 积分兑换 ===== function initVipPointsExchange() { var root = document.querySelector('[data-aksrt-vip-points]'); - if (!root) return; + if (!root || !window.AksrtModalRuntime.isRolloutEnabled('vip')) return; var tip = root.querySelector('[data-aksrt-vip-points-tip]'); var busy = false; + var coordinator = window.AksrtVipPurchaseCoordinator; function setTip(text, isError) { if (!tip) return; tip.textContent = text || ''; @@ -431,9 +406,9 @@ busy = true; btn.classList.add('is-loading'); setTip('正在兑换…'); - api('/pay/vip/points-exchange', { - method: 'POST', - body: JSON.stringify({ package_id: packageId }) + coordinator.exchange({ + packageId: packageId, + tags: ['vip', 'orders'] }).then(function (r) { busy = false; btn.classList.remove('is-loading'); @@ -455,9 +430,10 @@ // ===== VIP 开通 ===== function initVipShop() { var root = document.querySelector('[data-aksrt-vip-shop]'); - if (!root) return; + if (!root || !window.AksrtModalRuntime.isRolloutEnabled('vip')) return; var tip = root.querySelector('[data-aksrt-vip-tip]'); var busy = false; + var coordinator = window.AksrtVipPurchaseCoordinator; function setTip(text, isError) { if (!tip) return; @@ -474,23 +450,6 @@ return first ? first.value : 'balance'; } - function pollOrder(orderNumber) { - if (!orderNumber) return; - var tries = 0; - var maxTries = 40; - var timer = setInterval(function () { - tries += 1; - api('/pay/content/order/' + encodeURIComponent(orderNumber)).then(function (r) { - if (r.ok && r.json && r.json.paid) { - clearInterval(timer); - setTip('支付成功,正在刷新…'); - window.location.reload(); - } - }).catch(function () { /* ignore */ }); - if (tries >= maxTries) clearInterval(timer); - }, 3000); - } - root.addEventListener('click', function (e) { var btn = e.target.closest('[data-aksrt-vip-buy]'); if (!btn || busy) return; @@ -498,38 +457,46 @@ var packageId = parseInt(btn.getAttribute('data-package-id') || '0', 10); if (!packageId) return; busy = true; + btn.disabled = true; btn.classList.add('is-loading'); setTip('正在创建订单…'); - api('/pay/vip', { - method: 'POST', - body: JSON.stringify({ - package_id: packageId, - payment_method: selectedMethod() - }) - }).then(function (r) { - busy = false; - btn.classList.remove('is-loading'); - if (!r.ok || (r.json && r.json.code && !r.json.success)) { - var msg = (r.json && r.json.message) || '开通失败'; - setTip(msg, true); - return; - } - var data = (r.json && r.json.data) ? r.json.data : r.json; - if (data && data.redirect && (data.payment_url || data.qr_content)) { - if (data.payment_url) { - window.open(data.payment_url, '_blank'); - } + coordinator.start({ + packageId: packageId, + paymentMethod: selectedMethod(), + tags: ['vip', 'orders'], + onPending: function (data) { + busy = false; + btn.disabled = false; + btn.classList.remove('is-loading'); + if (data.payment_url) window.open(data.payment_url, '_blank'); setTip(data.message || '请完成支付,支付成功后将自动刷新。'); - if (data.order_number) pollOrder(data.order_number); - return; + }, + onSuccess: function (result) { + busy = false; + btn.disabled = false; + btn.classList.remove('is-loading'); + setTip((result.data && result.data.message) || '开通成功,正在刷新…'); + window.location.reload(); + }, + onFailure: function () { + busy = false; + btn.disabled = false; + btn.classList.remove('is-loading'); + setTip('支付未完成,请重试。', true); + }, + onTimeout: function () { + busy = false; + btn.disabled = false; + btn.classList.remove('is-loading'); + setTip('支付状态暂未确认,请稍后在订单中心查看。', true); + }, + onError: function (response) { + busy = false; + btn.disabled = false; + btn.classList.remove('is-loading'); + setTip((response.json && response.json.message) || '开通失败', true); } - setTip((data && data.message) || '开通成功,正在刷新…'); - window.location.reload(); - }).catch(function () { - busy = false; - btn.classList.remove('is-loading'); - setTip('网络错误,请重试', true); }); }); } @@ -588,6 +555,9 @@ return; } var data = (r.json && r.json.data) ? r.json.data : r.json; + if (type === 'vip' && window.AksrtModalRuntime && typeof window.AksrtModalRuntime.invalidate === 'function') { + window.AksrtModalRuntime.invalidate(['vip', 'orders']); + } setTip((data && data.message) || '兑换成功,正在刷新…'); window.location.reload(); }).catch(function () { @@ -608,26 +578,28 @@ } function closeModal(el) { if (!el) return; + if (activePoll && typeof activePoll.abort === 'function') activePoll.abort(); + activePoll = null; el.hidden = true; el.classList.remove('is-open'); if (!document.querySelector('.uc-modal.is-open')) { document.body.classList.remove('uc-modal-open'); } } - function pollPaidOrder(orderNumber) { + function pollPaidOrder(orderNumber, setTip, tags) { + var coordinator = window.AksrtVipPurchaseCoordinator; if (!orderNumber) return; - var tries = 0; - var maxTries = 40; - var timer = setInterval(function () { - tries += 1; - api('/pay/content/order/' + encodeURIComponent(orderNumber)).then(function (r) { - if (r.ok && r.json && r.json.paid) { - clearInterval(timer); + var handle = coordinator.poll(orderNumber, { + tags: tags || ['credits', 'orders'], + onTerminal: function (result) { + activePoll = null; + if (result.success) { + if (typeof setTip === 'function') setTip('支付成功,正在刷新…'); window.location.reload(); } - }).catch(function () {}); - if (tries >= maxTries) clearInterval(timer); - }, 3000); + } + }); + return handle; } function initBalanceCharge() { @@ -645,7 +617,11 @@ btn.addEventListener('click', function () { openModal(modal); }); }); modal.querySelectorAll('[data-aksrt-charge-close]').forEach(function (btn) { - btn.addEventListener('click', function () { closeModal(modal); }); + btn.addEventListener('click', function () { + if (activePoll && typeof activePoll.abort === 'function') activePoll.abort(); + activePoll = null; + closeModal(modal); + }); }); modal.querySelectorAll('.uc-charge-package input').forEach(function (inp) { inp.addEventListener('change', function () { @@ -694,7 +670,7 @@ if (data && data.redirect && (data.payment_url || data.qr_content)) { if (data.payment_url) window.open(data.payment_url, '_blank'); setTip(data.message || '请完成支付,支付成功后将自动刷新。'); - if (data.order_number) pollPaidOrder(data.order_number); + if (data.order_number) activePoll = pollPaidOrder(data.order_number, setTip, ['credits', 'orders']); return; } setTip((data && data.message) || '充值成功,正在刷新…'); @@ -917,7 +893,7 @@ if (data && data.redirect && (data.payment_url || data.qr_content)) { if (data.payment_url) window.open(data.payment_url, '_blank'); setTip(data.message || '请完成支付,支付成功后将自动刷新。'); - if (data.order_number) pollPaidOrder(data.order_number); + if (data.order_number) activePoll = pollPaidOrder(data.order_number, setTip, ['credits', 'orders']); return; } setTip((data && data.message) || '购买成功,正在刷新…'); diff --git a/assets/js/order-center.js b/assets/js/order-center.js index 9927f199..18bfc00c 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -8,12 +8,11 @@ "use strict"; var page = document.querySelector("[data-aksrt-order-center]"); - if (!page || !window.aksrtData) return; + if (!page) return; + if (!window.AksrtModalRuntime.isRolloutEnabled("order")) return; - var detailModal = page.querySelector("[data-aksrt-order-detail-modal]"); - var detailBody = page.querySelector("[data-aksrt-order-detail-body]"); - var detailFooter = page.querySelector("[data-aksrt-order-detail-footer]"); var detailOrderNumber = ""; + var detailAutoRefreshStarted = false; function toast(message, type) { window.AksrtToast(message, type || "info"); @@ -22,21 +21,18 @@ function request(path, body, method) { var options = { method: method || "POST" }; if (body !== undefined) options.body = JSON.stringify(body || {}); - return window.AksrtRequest.request(path, options).then(function (response) { - return response.json || {}; - }).catch(function (error) { - var response = error && error.response ? error.response : {}; - return error && error.json ? error.json : (response.json || { - success: false, - message: error && error.message ? error.message : "请求失败", - }); - }); + return window.AksrtRequest.request(path, options); } function orderPath(orderNumber, suffix) { return "/orders/" + encodeURIComponent(orderNumber || "") + (suffix || ""); } + function errorMessage(error, fallback) { + if (error && error.message) return String(error.message); + return fallback || "请求失败"; + } + function escapeHtml(value) { return String(value == null ? "" : value) .replace(/&/g, "&").replace(/' + + '' + + '' + + '' + + ''; + } + + function renderDetail(order, session) { + var allowedActions = order.allowed_actions || {}; + var reconciliation = order.reconciliation || {}; var image = order.image || ""; var title = order.product_name || "订单"; var status = order.status_group || "closed"; @@ -67,7 +81,9 @@ html += '' + escapeHtml(order.status_label || "") + ""; html += '' + formatMoney(order) + ""; html += '
'; - html += image ? '' : ''; + html += image + ? '' + : ''; html += '
' + escapeHtml(title) + ""; html += '' + escapeHtml(order.product_type_label || "") + "
"; html += '
'; @@ -75,42 +91,60 @@ html += "
" + escapeHtml(row[0]) + "
" + escapeHtml(row[1] || "—") + "
"; }); html += "
"; - detailBody.innerHTML = html; + if (reconciliation.state === "refresh_available") { + html += '
支付状态可能仍在同步,请刷新后查看。'; + html += '
'; + } + html += '"; + session.content.innerHTML = html; + } - var footer = ''; - if (actions.can_cancel) footer += ''; - if (actions.can_pay) footer += ''; - if (actions.can_open && order.permalink) footer += '' + escapeHtml(order.entitlement_label || "打开已购内容") + ""; - detailFooter.innerHTML = footer; + function detailOptions(orderNumber, refresh) { + return { + url: orderPath(orderNumber, refresh ? "/refresh" : ""), + method: refresh ? "POST" : "GET", + kind: "orders", + title: "订单详情", + responseType: "json", + cache: !refresh, + cacheKey: "orders:detail:" + orderNumber, + cacheTags: ["orders", orderTag(orderNumber)], + skeleton: detailSkeleton(), + renderData: function (data, session) { + if (orderNumber !== detailOrderNumber) return false; + if (!data || typeof data !== "object") { + session.content.innerHTML = '

订单不存在

'; + return; + } + renderDetail(data, session); + if (!refresh && !detailAutoRefreshStarted && data.reconciliation && data.reconciliation.state === "refresh_available") { + detailAutoRefreshStarted = true; + window.setTimeout(function () { + if (activeDetail(orderNumber)) refreshDetail(orderNumber); + }, 0); + } + }, + onData: function (data) { + if (refresh) invalidateOrder(orderNumber, data); + }, + }; } function openDetail(orderNumber) { - if (!detailModal || !detailBody) return; + if (!orderNumber) return; detailOrderNumber = orderNumber; - var telemetry = window.AksrtModalTelemetry; - var telemetryId = telemetry && typeof telemetry.start === "function" ? telemetry.start("orders") : ""; - function telemetryMark(event) { - if (telemetry && telemetryId && typeof telemetry.mark === "function") telemetry.mark(telemetryId, event); - } - detailBody.innerHTML = '

加载中…

'; - detailFooter.innerHTML = ""; - detailModal.hidden = false; - document.documentElement.classList.add("aksrt-modal-open"); - telemetryMark("shell"); - request(orderPath(orderNumber), undefined, "GET").then(function (result) { - if (orderNumber !== detailOrderNumber) return; - if (!result.success || !result.data) { - telemetryMark("error"); - detailBody.innerHTML = '

' + escapeHtml(result.message || "订单不存在") + "

"; - return; - } - telemetryMark("first-content"); - renderDetail(result.data); - telemetryMark("ready"); - }).catch(function () { - telemetryMark("error"); - detailBody.innerHTML = '

网络错误,请稍后重试

'; - }); + detailAutoRefreshStarted = false; + window.AksrtModalRuntime.openRemote(detailOptions(orderNumber, false)); + } + + function refreshDetail(orderNumber) { + if (!orderNumber || !activeDetail(orderNumber)) return; + window.AksrtModalRuntime.openRemote(detailOptions(orderNumber, true)); } function currentViewPath() { @@ -122,11 +156,11 @@ } function actionHtml(order) { - var actions = order.actions || {}; + var allowedActions = order.allowed_actions || {}; var html = ''; - if (actions.can_cancel) html += ''; - if (actions.can_pay) html += ''; - if (actions.can_open && order.permalink) html += '' + escapeHtml(order.entitlement_label || '打开已购内容') + ''; + if (allowedActions.can_cancel) html += ''; + if (allowedActions.can_pay) html += ''; + if (allowedActions.can_open && order.permalink) html += '' + escapeHtml(order.entitlement_label || "打开已购内容") + ""; return html; } @@ -136,15 +170,15 @@ : ''; var countdown = Number(order.time_left || 0) > 0 ? '' - : ''; - return '
' + - '
' + escapeHtml(order.product_type_label || '') + '' + escapeHtml(order.status_label || '') + '
' + - '
' + image + '

' + escapeHtml(order.product_name || '订单') + '

订单号 ' + escapeHtml(order.order_number) + '

' + formatMoney(order) + '实付
' + + : ""; + return '
' + + '
' + escapeHtml(order.product_type_label || "") + '' + escapeHtml(order.status_label || "") + '
' + + '
' + image + '

' + escapeHtml(order.product_name || "订单") + '

订单号 ' + escapeHtml(order.order_number) + '

' + formatMoney(order) + '实付
' + '
' + countdown + actionHtml(order) + '
'; } function renderStats(stats) { - var root = page.querySelector('[data-aksrt-order-stats]'); + var root = page.querySelector("[data-aksrt-order-stats]"); if (!root) return; root.innerHTML = '
全部' + Number(stats.total || 0) + '
' + '
待支付' + Number(stats.pending || 0) + '
' + @@ -152,95 +186,93 @@ } function renderPagination(view) { - var existing = page.querySelector('.aksrt-orders-page__pagination'); + var existing = page.querySelector(".aksrt-orders-page__pagination"); if (existing) existing.remove(); if (Number(view.total_pages || 1) <= 1) return; - var root = document.createElement('nav'); - root.className = 'aksrt-orders-page__pagination'; - root.setAttribute('aria-label', '订单分页'); + var root = document.createElement("nav"); + root.className = "aksrt-orders-page__pagination"; + root.setAttribute("aria-label", "订单分页"); var base = new URL(window.location.href); for (var i = 1; i <= Number(view.total_pages); i++) { - base.searchParams.set('order_page', String(i)); - var link = document.createElement('a'); - link.className = 'aksrt-orders-page__page-chip' + (i === Number(view.page) ? ' is-active' : ''); + base.searchParams.set("order_page", String(i)); + var link = document.createElement("a"); + link.className = "aksrt-orders-page__page-chip" + (i === Number(view.page) ? " is-active" : ""); link.href = base.toString(); link.textContent = String(i); root.appendChild(link); } - var list = page.querySelector('[data-aksrt-order-list]'); + var list = page.querySelector("[data-aksrt-order-list]"); if (list) list.after(root); } function renderView(view) { renderStats(view.stats || {}); - var list = page.querySelector('[data-aksrt-order-list]'); + var list = page.querySelector("[data-aksrt-order-list]"); if (!list) return; var items = Array.isArray(view.items) ? view.items : []; if (!items.length) { list.innerHTML = '

没有匹配的订单

换个条件试试,或清除当前筛选。

'; - list.classList.add('is-empty'); + list.classList.add("is-empty"); } else { - list.classList.remove('is-empty'); - list.innerHTML = items.map(cardHtml).join(''); + list.classList.remove("is-empty"); + list.innerHTML = items.map(cardHtml).join(""); } renderPagination(view); - if (detailOrderNumber) { - var detail = items.filter(function (item) { return item.order_number === detailOrderNumber; })[0]; - if (detail) renderDetail(detail); else closeDetail(); - } updateCountdowns(); } function refreshView() { return request(currentViewPath(), undefined, "GET").then(function (result) { - if (!result.success || !result.data) throw new Error(result.message || "刷新失败"); + if (!result || !result.data) throw new Error("刷新失败"); renderView(result.data); return result.data; }).catch(function (error) { - toast(error.message || "订单状态已变更,请刷新后查看"); + toast(errorMessage(error, "订单状态已变更,请刷新后查看")); + return null; }); } function startPayment(button) { var number = button.getAttribute("data-aksrt-order-pay") || ""; var method = button.getAttribute("data-aksrt-order-method") || ""; - if (!number) return; + if (!number || button.disabled) return; button.disabled = true; - request(orderPath(number, "/pay"), method ? { payment_method: method } : {}).then(function (result) { - if (!result.success) throw new Error(result.message || "支付失败"); - window.AksrtModalRuntime.invalidate(["orders", "vip"]); + request(orderPath(number, "/pay"), method ? { payment_method: method } : {}, "POST").then(function (result) { var data = result.data || {}; + invalidateOrder(number, data.order); if (data.redirect && data.payment_url) { window.location.assign(data.payment_url); return; } - toast(data.message || "支付成功"); + toast(data.message || "支付成功", "success"); refreshView(); + if (activeDetail(number)) openDetail(number); }).catch(function (error) { button.disabled = false; - toast(error.message || "网络错误,请稍后重试"); + toast(errorMessage(error, "网络错误,请稍后重试"), "error"); }); } function closeOrder(button) { var number = button.getAttribute("data-aksrt-order-cancel") || ""; - if (!number) return; + if (!number || button.disabled) return; if (button.getAttribute("data-aksrt-confirm") !== "true") { button.setAttribute("data-aksrt-confirm", "true"); button.textContent = "确认关闭"; return; } button.disabled = true; - request(orderPath(number, "/cancel")).then(function (result) { - if (!result.success) throw new Error(result.message || "关闭订单失败"); - window.AksrtModalRuntime.invalidate(["orders", "vip"]); - toast("订单已关闭"); + request(orderPath(number, "/cancel"), {}, "POST").then(function (result) { + var data = result.data || {}; + invalidateOrder(number, data.order); + toast("订单已关闭", "success"); refreshView(); + if (activeDetail(number)) openDetail(number); }).catch(function (error) { button.disabled = false; button.removeAttribute("data-aksrt-confirm"); button.textContent = "关闭订单"; - toast(error.message || "网络错误,请稍后重试"); + toast(errorMessage(error, "网络错误,请稍后重试"), "error"); }); } @@ -263,15 +295,31 @@ } var detail = event.target.closest("[data-aksrt-order-detail]"); if (detail) { openDetail(detail.getAttribute("data-aksrt-order-detail")); return; } - if (event.target.closest("[data-aksrt-order-detail-close]")) { closeDetail(); return; } var pay = event.target.closest("[data-aksrt-order-pay]"); - if (pay) { startPayment(pay); return; } + if (pay) { event.preventDefault(); startPayment(pay); return; } var cancel = event.target.closest("[data-aksrt-order-cancel]"); - if (cancel) closeOrder(cancel); + if (cancel) { event.preventDefault(); closeOrder(cancel); } }); - document.addEventListener("keydown", function (event) { - if (event.key === "Escape" && detailModal && !detailModal.hidden) closeDetail(); + document.addEventListener("click", function (event) { + var refresh = event.target.closest("[data-aksrt-order-refresh]"); + if (refresh) { + event.preventDefault(); + refreshDetail(refresh.getAttribute("data-aksrt-order-refresh") || ""); + return; + } + var pay = event.target.closest("[data-aksrt-order-pay]"); + if (pay && !page.contains(pay)) { + event.preventDefault(); + startPayment(pay); + return; + } + var cancel = event.target.closest("[data-aksrt-order-cancel]"); + if (cancel && !page.contains(cancel)) { + event.preventDefault(); + closeOrder(cancel); + } }); + window.setInterval(updateCountdowns, 1000); }()); diff --git a/assets/js/pay-vip.js b/assets/js/pay-vip.js index ad0ef7b2..8340c329 100644 --- a/assets/js/pay-vip.js +++ b/assets/js/pay-vip.js @@ -1,72 +1,246 @@ /** - * AKSRT VIP pay modal — zibll .pay-vip parity. - * Loads modal HTML from GET /aksrt/v1/pay/vip/modal then purchases via POST /pay/vip. + * AKSRT VIP pay modal — JSON view model + canonical purchase coordinator. + * + * @package AKSRT */ (function () { "use strict"; - function api(path, options) { - return window.AksrtRequest.request(path, Object.assign({}, options || {})).then(function (response) { - return { - ok: response.ok, - status: response.status, - json: response.json || {}, - }; - }).catch(function (error) { - var response = error && error.response ? error.response : {}; - return { - ok: false, - status: error && error.status ? error.status : (response.status || 0), - json: error && error.json ? error.json : (response.json || { message: error && error.message }), - }; + function text(value) { + return String(value == null ? "" : value); + } + + function node(tag, className, value) { + var el = document.createElement(tag); + if (className) el.className = className; + if (value !== undefined && value !== null) el.textContent = text(value); + return el; + } + + function append(parent, child) { + if (child) parent.appendChild(child); + return child; + } + + function levelMap(levels) { + var map = Object.create(null); + (Array.isArray(levels) ? levels : []).forEach(function (level) { + if (level && level.level) map[String(level.level)] = level; }); + return map; } - var activeVipShell = null; - - function openModal() { - activeVipShell = window.AksrtModalRuntime.open({ - className: "aksrt-vip-modal-shell", - mobileBottom: false, - skeleton: '
加载中…
', - telemetry: false, - kind: "vip", - }); - return activeVipShell; + function actionLabel(action) { + if (action === "renew") return "续费"; + if (action === "upgrade") return "升级"; + return "开通"; } - function closeModal() { - window.AksrtModalRuntime.close("vip"); - activeVipShell = null; + function actionKey(action) { + return action === "pay" ? "purchase" : action; } - function bindModalUi(root) { - if (!root || root.dataset.aksrtVipBound === "1") return; - root.dataset.aksrtVipBound = "1"; + function actionReason(reason) { + if (reason === "no_payment_method") return "暂无可用支付方式"; + if (reason === "no_package") return "暂无可用套餐"; + if (reason === "not_available") return "当前不可用"; + return ""; + } - root.addEventListener("click", function (e) { - var tab = e.target.closest("[data-vip-tab]"); - if (tab) { - e.preventDefault(); - var id = tab.getAttribute("data-vip-tab"); - root.querySelectorAll(".aksrt-vip-tab").forEach(function (a) { - a.parentElement && a.parentElement.classList.remove("active"); - }); - tab.parentElement && tab.parentElement.classList.add("active"); - root.querySelectorAll(".aksrt-vip-pane").forEach(function (p) { - var on = p.id === id; - p.hidden = !on; - p.classList.toggle("active", on); - }); - return; - } + function renderEquity(parent, value) { + if (!value) return; + var equity = node("div"); + // The server sanitizes and wraps this admin-authored fragment before JSON. + equity.innerHTML = text(value); + while (equity.firstChild) parent.appendChild(equity.firstChild); + } - var submit = e.target.closest("[data-vip-submit]"); - if (submit) { - e.preventDefault(); - purchaseFromPane(submit.closest(".aksrt-vip-pane") || root, submit); + function renderProducts(parent, items) { + if (!Array.isArray(items) || !items.length) { + append(parent, node("div", "muted-2-color text-center padding-10", "暂无可用套餐")); + return; + } + var wrap = node("div", "mb10 label-box charge-box aksrt-vip-products"); + items.forEach(function (item, index) { + if (!item || !item.package_id) return; + var label = node("label", "aksrt-vip-product-label"); + var input = node("input"); + input.className = "hide vip-product-input"; + input.type = "radio"; + input.name = "vip_product_id_" + String(item.level || "group"); + input.setAttribute("data-aksrt-vip-package", ""); + input.value = String(item.package_id); + input.checked = index === 0; + label.appendChild(input); + + var product = node("div", "zib-widget vip-product relative text-center product-box"); + if (item.discount_tag) append(product, node("div", "abs-right vip-tag badg jb-yellow", item.discount_tag)); + if (item.title) append(product, node("div", "em09 mb3", item.title)); + var priceRow = node("div", "flex abl gap6 jc"); + append(priceRow, node("div", "product-price c-red", item.price_label || "")); + if (item.show_price_label) { + var original = node("div", "original-price relative muted-2-color"); + var strike = node("s", "", item.show_price_label); + append(original, strike); + append(priceRow, original); } + append(product, priceRow); + if (item.days_label) append(product, node("div", "muted-2-color c-blue em09 p2-10 badg radius", item.days_label)); + append(label, product); + append(wrap, label); }); + append(parent, wrap); + } + + function renderMethods(parent, methods) { + methods = Array.isArray(methods) ? methods : []; + if (!methods.length) { + append(parent, node("div", "muted-2-color text-center padding-10", "暂无可用支付方式")); + return; + } + if (methods.length === 1) { + var one = node("input"); + one.type = "hidden"; + one.name = "aksrt_vip_pay_method"; + one.value = text(methods[0].id || "balance"); + one.setAttribute("data-aksrt-vip-method-fixed", ""); + append(parent, one); + return; + } + var wrap = node("div", "uc-vip-shop__methods mb10"); + wrap.setAttribute("data-aksrt-vip-methods", ""); + append(wrap, node("span", "uc-vip-shop__methods-label", "支付方式")); + methods.forEach(function (method, index) { + var label = node("label", "uc-vip-shop__method"); + var input = node("input"); + input.type = "radio"; + input.name = "aksrt_vip_pay_method"; + input.value = text(method.id || "balance"); + input.checked = index === 0; + append(label, input); + append(label, node("span", "", method.name || method.id || "")); + append(wrap, label); + }); + append(parent, wrap); + } + + function renderModalView(body, view) { + view = view || {}; + if (body.replaceChildren) body.replaceChildren(); + else body.innerHTML = ""; + + if (!view.enabled) { + append(body, node("div", "box-body payvip-modal text-center padding-20", "会员功能未启用。")); + return; + } + + var catalog = view.catalog || {}; + var member = view.member || {}; + var levels = levelMap(view.levels); + var groups = Array.isArray(catalog.level_packages) ? catalog.level_packages : []; + var methods = Array.isArray(view.payment_methods) ? view.payment_methods : []; + var allowed = view.allowed_actions || {}; + var reasons = view.action_reasons || {}; + var modal = node("div", "box-body payvip-modal"); + modal.setAttribute("data-aksrt-vip-modal", ""); + if (view.version && view.version.catalog) modal.setAttribute("data-aksrt-vip-version", view.version.catalog); + + var close = node("button", "aksrt-modal-close", "×"); + close.type = "button"; + close.setAttribute("data-aksrt-modal-close", ""); + close.setAttribute("aria-label", "关闭"); + append(modal, close); + + var userBox = node("ul", "list-inline user-box flex ac mb10"); + var avatarItem = node("li", "avatar-img"); + if (member.avatar_url) { + var avatar = node("img"); + avatar.src = text(member.avatar_url); + avatar.alt = ""; + avatar.width = 42; + avatar.height = 42; + append(avatarItem, avatar); + } + append(userBox, avatarItem); + var userInfo = node("li"); + append(userInfo, node("b", "em12", member.name || "")); + if (member.level > 0) { + var expiry = node("div", "muted-2-color em09"); + var badge = node("span", "badg c-yellow vip-expdate-tag", member.expires_label || ""); + append(expiry, badge); + append(userInfo, expiry); + } else if (member.state === "expired") { + append(userInfo, node("div", "muted-2-color em09", "已过期")); + } else if (catalog.desc) { + append(userInfo, node("div", "muted-2-color em09", catalog.desc)); + } + append(userBox, userInfo); + append(modal, userBox); + + var tabs = node("ul", "flex jsb mt10 mb20 vip-cardminis gutters-5 nav"); + var content = node("div", "tab-content mt10"); + var activeIndex = -1; + groups.forEach(function (group, index) { + if (activeIndex >= 0 || !group) return; + if (Number(group.level) === Number(view.prefer_level)) activeIndex = index; + }); + if (activeIndex < 0 && groups.length) activeIndex = 0; + + groups.forEach(function (group, index) { + if (!group || !Array.isArray(group.items) || !group.items.length) return; + var level = Number(group.level || 0); + var action = text(group.action || "pay"); + var tabId = "tab-payvip-" + level + "-" + action; + var active = index === activeIndex; + var tabItem = node("li", "relative" + (active ? " active" : "")); + var tab = node("a", "aksrt-vip-tab", actionLabel(action) + (group.name || "VIP" + level)); + tab.href = "javascript:;"; + tab.setAttribute("data-vip-tab", tabId); + append(tabItem, tab); + append(tabs, tabItem); + + var pane = node("div", "tab-pane aksrt-vip-pane" + (active ? " active" : "")); + pane.id = tabId; + pane.hidden = !active; + var row = node("div", "pay-vip-form-row"); + var info = node("div", "pay-vip-info level-" + level); + var levelInfo = levels[String(level)] || {}; + append(info, node("div", "v-name font-bold", group.name || levelInfo.name || "VIP" + level)); + if (levelInfo.desc) append(info, node("div", "muted-2-color em09 mb6", levelInfo.desc)); + renderEquity(info, levelInfo.equity); + append(row, info); + + var products = node("div", "pay-vip-products"); + renderProducts(products, group.items); + renderMethods(products, methods); + var actionName = actionKey(action); + var actionAllowed = allowed[actionName] === true; + var reason = actionReason(reasons[actionName]); + var canPurchase = actionAllowed && methods.length > 0; + var submit = node("button", "but jb-red btn-block padding-lg radius aksrt-vip-submit", canPurchase ? (group.items[0].cta_label || actionLabel(action)) : (reason || "暂无可用支付方式")); + submit.type = "button"; + submit.disabled = !canPurchase; + submit.setAttribute("data-vip-submit", ""); + append(products, submit); + append(products, node("p", "aksrt-vip-modal-tip muted-2-color em09 mt10")); + append(row, products); + append(pane, row); + append(content, pane); + }); + append(modal, tabs); + append(modal, content); + + if (catalog.help_text) append(modal, node("div", "muted-2-color em09 mb6", catalog.help_text)); + if (catalog.more) append(modal, node("div", "muted-2-color mb10 em09 muted-box padding-10 mini-radius", catalog.more)); + var points = catalog.points_exchange || {}; + if (allowed.points_exchange !== false && points.enabled && Array.isArray(points.packages) && points.packages.length && catalog.vip_url) { + var pointsWrap = node("div", "mt10"); + var pointsLink = node("a", "but c-green btn-block", "积分兑换会员"); + pointsLink.href = text(catalog.vip_url); + append(pointsWrap, pointsLink); + append(modal, pointsWrap); + } + append(body, modal); } function selectedMethod(root) { @@ -79,165 +253,155 @@ } function selectedPackage(root) { - var checked = root.querySelector('input[name="vip_product_id"]:checked'); + var checked = root.querySelector('[data-aksrt-vip-package]:checked'); return checked ? parseInt(checked.value, 10) : 0; } - function setTip(root, text, isError) { - var tip = root.querySelector("[data-aksrt-vip-tip]"); + function setTip(root, message, isError) { + var tip = root.querySelector("[data-aksrt-vip-tip]") || root.querySelector(".aksrt-vip-modal-tip"); if (!tip) return; - tip.textContent = text || ""; + tip.textContent = message || ""; tip.classList.toggle("is-error", !!isError); } - function pollOrder(orderNumber) { - if (!orderNumber) return; - var tries = 0; - var timer = setInterval(function () { - tries += 1; - api("/pay/content/order/" + encodeURIComponent(orderNumber)) - .then(function (r) { - if (r.ok && r.json && r.json.paid) { - clearInterval(timer); - window.location.reload(); - } - }) - .catch(function () {}); - if (tries >= 40) clearInterval(timer); - }, 3000); + var activePurchase = null; + + function restoreButton(button) { + if (!button) return; + button.disabled = false; + button.classList.remove("is-loading"); } - function purchaseFromPane(pane, btn) { + function purchaseFromPane(pane, button) { var packageId = selectedPackage(pane); if (!packageId) { setTip(pane, "请选择套餐", true); return; } - if (btn.disabled) return; - btn.disabled = true; - btn.classList.add("is-loading"); + if (!button || button.disabled) return; + var coordinator = window.AksrtVipPurchaseCoordinator; + + button.disabled = true; + button.classList.add("is-loading"); setTip(pane, "正在创建订单…"); - - api("/pay/vip", { - method: "POST", - body: JSON.stringify({ - package_id: packageId, - payment_method: selectedMethod(pane), - }), - }) - .then(function (r) { - btn.disabled = false; - btn.classList.remove("is-loading"); - if (!r.ok || (r.json && r.json.code && !r.json.success && r.json.success !== true)) { - var msg = (r.json && r.json.message) || "开通失败"; - if (r.status === 401) { - msg = "请先登录"; - document.querySelector(".signin-loader") && document.querySelector(".signin-loader").click(); - } - setTip(pane, msg, true); - return; - } - var data = r.json && r.json.data ? r.json.data : r.json; - if (data && data.redirect && (data.payment_url || data.qr_content)) { - window.AksrtModalRuntime.invalidate(["vip", "orders"]); - if (data.payment_url) window.open(data.payment_url, "_blank"); - setTip(pane, data.message || "请完成支付,支付成功后将自动刷新。"); - if (data.order_number) pollOrder(data.order_number); - return; - } - window.AksrtModalRuntime.invalidate(["vip", "orders"]); - setTip(pane, (data && data.message) || "开通成功,正在刷新…"); - setTimeout(function () { - window.location.reload(); - }, 400); - }) - .catch(function () { - btn.disabled = false; - btn.classList.remove("is-loading"); - setTip(pane, "网络错误,请重试", true); - }); - } - - var loading = false; - - function vipPay(trigger) { - var level = 1; - if (trigger) { - level = parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1; - } - if (loading) return; - loading = true; - var telemetry = window.AksrtModalTelemetry; - var telemetryId = telemetry && typeof telemetry.start === "function" ? telemetry.start("vip") : ""; - function telemetryMark(event) { - if (telemetry && telemetryId && typeof telemetry.mark === "function") telemetry.mark(telemetryId, event); - } - var shell = openModal(); - var body = shell.querySelector("[data-aksrt-vip-modal-body]"); - telemetryMark("shell"); - - api("/pay/vip/modal", { query: { vip_level: level } }) - .then(function (r) { - loading = false; - if (r.status === 401) { - telemetryMark("unauthorized"); - closeModal(); + activePurchase = coordinator.start({ + packageId: packageId, + paymentMethod: selectedMethod(pane), + tags: ["vip", "orders"], + onPending: function (data) { + restoreButton(button); + if (data.payment_url) window.open(data.payment_url, "_blank"); + setTip(pane, data.message || "请完成支付,支付成功后将自动刷新。"); + }, + onSuccess: function (result) { + activePurchase = null; + restoreButton(button); + setTip(pane, (result.data && result.data.message) || "开通成功,正在刷新…"); + window.setTimeout(function () { window.location.reload(); }, 400); + }, + onFailure: function (result) { + activePurchase = null; + restoreButton(button); + setTip(pane, result.data && result.data.message ? result.data.message : "支付未完成,请重试。", true); + }, + onTimeout: function () { + activePurchase = null; + restoreButton(button); + setTip(pane, "支付状态暂未确认,请稍后在订单中心查看。", true); + }, + onError: function (response) { + activePurchase = null; + restoreButton(button); + var message = response && response.json && response.json.message ? response.json.message : "开通失败"; + if (response && response.status === 401) { + message = "请先登录"; var login = document.querySelector(".signin-loader"); if (login) login.click(); - return; } - if (!r.ok || !r.json || !r.json.html) { - telemetryMark("error"); - if (body) { - body.innerHTML = - '
' + - ((r.json && r.json.message) || "加载失败") + - "
"; - } - return; - } - if (body) { - body.innerHTML = r.json.html; - telemetryMark("first-content"); - window.AksrtFrontend.rehydrate(body, "modal"); - bindModalUi(body); - telemetryMark("ready"); - } - }) - .catch(function () { - loading = false; - telemetryMark("error"); - if (body) { - body.innerHTML = '
网络错误
'; - } - }); + setTip(pane, message, true); + }, + }); } - function onDocClick(e) { - // Header CTA, float rail, UC buttons, VIP hover cards. - var t = e.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]"); - if (!t) return; - // Real external href without modal intent: leave alone. - var href = (t.getAttribute("href") || "").trim(); - if (href && href !== "#" && href.indexOf("javascript:") !== 0 && !t.classList.contains("pay-vip") && !t.hasAttribute("data-aksrt-pay-vip") && !t.hasAttribute("vip-level") && !t.hasAttribute("data-vip-level")) { + function bindModalUi(root) { + if (!root || root.dataset.aksrtVipBound === "1") return; + root.dataset.aksrtVipBound = "1"; + root.addEventListener("click", function (event) { + var tab = event.target.closest("[data-vip-tab]"); + if (tab) { + event.preventDefault(); + var id = tab.getAttribute("data-vip-tab"); + root.querySelectorAll(".aksrt-vip-tab").forEach(function (item) { + if (item.parentElement) item.parentElement.classList.toggle("active", item === tab); + }); + root.querySelectorAll(".aksrt-vip-pane").forEach(function (pane) { + var active = pane.id === id; + pane.hidden = !active; + pane.classList.toggle("active", active); + }); + return; + } + var submit = event.target.closest("[data-vip-submit]"); + if (submit) { + event.preventDefault(); + purchaseFromPane(submit.closest(".aksrt-vip-pane") || root, submit); + } + }); + } + + function vipPay(trigger) { + if (!window.AksrtModalRuntime.isRolloutEnabled("vip")) return; + var level = trigger + ? parseInt(trigger.getAttribute("vip-level") || trigger.getAttribute("data-vip-level") || "1", 10) || 1 + : 1; + window.AksrtModalRuntime.openRemote({ + url: "/pay/vip/modal", + query: { vip_level: level }, + className: "aksrt-vip-modal-shell", + mobileBottom: false, + skeleton: '
加载中…
', + telemetry: "vip", + kind: "vip", + responseType: "json", + cacheTtl: 15000, + cacheTags: ["vip"], + renderData: function (data, session) { + renderModalView(session.content, data); + bindModalUi(session.content); + }, + onError: function (error) { + if (!error || error.code !== "unauthorized") return; + window.AksrtModalRuntime.close("unauthorized"); + var login = document.querySelector(".signin-loader"); + if (login) login.click(); + }, + }); + } + + function onDocClick(event) { + var trigger = event.target.closest(".pay-vip, a.pay-vip, .float-btn.pay-vip, [data-aksrt-pay-vip]"); + if (!trigger) return; + var href = (trigger.getAttribute("href") || "").trim(); + if (href && href !== "#" && href.indexOf("javascript:") !== 0 && !trigger.classList.contains("pay-vip") && !trigger.hasAttribute("data-aksrt-pay-vip") && !trigger.hasAttribute("vip-level") && !trigger.hasAttribute("data-vip-level")) { return; } - e.preventDefault(); - e.stopPropagation(); - vipPay(t); + event.preventDefault(); + event.stopPropagation(); + vipPay(trigger); } - function init() { - // Capture phase so float/header anchors with href never navigate first. - document.addEventListener("click", onDocClick, true); - } + document.addEventListener("aksrt:modal:close", function (event) { + var detail = event && event.detail ? event.detail : {}; + var options = detail.session && detail.session.options ? detail.session.options : {}; + if (options.kind !== "vip" || !activePurchase) return; + if (typeof activePurchase.abort === "function") activePurchase.abort(); + activePurchase = null; + }); - // This handler is delegated from document, so registry rehydration is not - // needed. Bind exactly once and keep dynamically injected triggers working. function bindOnce() { if (window.__aksrtPayVipBound) return; window.__aksrtPayVipBound = true; - init(); + document.addEventListener("click", onDocClick, true); } if (document.readyState === "loading") { @@ -245,6 +409,4 @@ } else { bindOnce(); } - - window.aksrtVipPay = vipPay; })(); diff --git a/assets/js/routes/user-center-auth.js b/assets/js/routes/user-center-auth.js index 2aa655ed..c8edce11 100644 --- a/assets/js/routes/user-center-auth.js +++ b/assets/js/routes/user-center-auth.js @@ -1,4 +1,4 @@ -(function () { +(function (window, document) { 'use strict'; function parseConfig(root) { @@ -13,20 +13,22 @@ }); } - function request(url, config, options) { - options = options || {}; + function request(path, config, options) { + options = Object.assign({}, options || {}); options.credentials = 'same-origin'; options.headers = Object.assign({ 'X-WP-Nonce': config.nonce || '' }, options.headers || {}); - return fetch(url, options).then(function (response) { - return response.json().then(function (payload) { - if (!response.ok || !payload || payload.success === false) { - throw new Error((payload && payload.message) || '请求失败'); - } - return payload.data; - }); + return window.AksrtRequest.request(path, options).then(function (response) { + return response.data !== undefined ? response.data : response.json; }); } + function errorMessage(error, fallback) { + if (error && error.json) { + return error.json.message || (error.json.data && error.json.data.message) || fallback; + } + return error && error.message ? error.message : fallback; + } + function loadScript(src) { return new Promise(function (resolve, reject) { var existing = document.querySelector('script[src="' + src + '"]'); @@ -57,81 +59,194 @@ function init(root) { if (!root || root.dataset.identityBound === '1') return; root.dataset.identityBound = '1'; + var config = parseConfig(root); - var modal = root.querySelector('#uc-auth-modal'); - if (!modal) return; + var template = root.querySelector('[data-auth-modal-template]'); + var runtime = window.AksrtModalRuntime; + if (!template) return; + + var activeShell = null; var currentUuid = config.currentUuid || ''; + var currentStage = config.currentStage || ''; var pollTimer = null; - var shouldPoll = false; - var telemetry = window.AksrtModalTelemetry; - var telemetryId = ''; + var pollBackoff = 0; + var currentController = null; + var refreshController = null; + var refreshTimeout = null; + var refreshInFlight = false; + var openSequence = 0; - function telemetryMark(event) { - if (telemetry && telemetryId && typeof telemetry.mark === 'function') telemetry.mark(telemetryId, event); + function isOpen(shell) { + return !!shell && shell.classList.contains('is-open') && document.contains(shell); } - function open() { - telemetryId = telemetry && typeof telemetry.start === 'function' ? telemetry.start('identity') : ''; - modal.classList.add('is-open'); - modal.setAttribute('aria-hidden', 'false'); - document.body.style.overflow = 'hidden'; - telemetryMark('shell'); + function stopPolling() { + if (pollTimer) window.clearTimeout(pollTimer); + pollTimer = null; } - function close() { modal.classList.remove('is-open'); modal.setAttribute('aria-hidden', 'true'); document.body.style.overflow = ''; } - root.querySelectorAll('[data-auth-modal-open]').forEach(function (button) { button.addEventListener('click', open); }); - modal.querySelectorAll('[data-auth-modal-close]').forEach(function (button) { button.addEventListener('click', close); }); - var statusBox = modal.querySelector('[data-auth-status]'); - var createForm = modal.querySelector('[data-auth-create]'); - var manualForm = modal.querySelector('[data-auth-manual]'); - var stageNode = modal.querySelector('[data-auth-stage]'); - var messageNode = modal.querySelector('[data-auth-status-message]'); - var continueLink = modal.querySelector('[data-auth-continue]'); - var refreshButton = modal.querySelector('[data-auth-refresh]'); - var manualButton = modal.querySelector('[data-auth-manual-open]'); + function abortCurrent() { + if (currentController) { + try { currentController.abort(); } catch (_) {} + } + currentController = null; + } - function render(data) { - if (!data) return; - telemetryMark('first-content'); + function abortRefresh() { + if (refreshTimeout) window.clearTimeout(refreshTimeout); + refreshTimeout = null; + if (refreshController) { + try { refreshController.abort(); } catch (_) {} + } + refreshController = null; + refreshInFlight = false; + } + + function setMessage(shell, message) { + var node = shell && shell.querySelector('[data-auth-status-message]'); + if (node) node.textContent = message || ''; + } + + function updateSummary(data) { + if (!data || data.status !== 'verified') return; + var trigger = root.querySelector('[data-auth-modal-open]'); + if (trigger) { + trigger.disabled = true; + trigger.textContent = '已认证'; + } + runtime.invalidate(['identity']); + document.dispatchEvent(new CustomEvent('aksrt:identity:changed', { detail: { status: data.status } })); + } + + function schedulePoll(delay) { + stopPolling(); + if (!currentUuid || !isOpen(activeShell) || document.visibilityState !== 'visible') return; + var base = Math.max(5, Number(delay || config.pollInterval || 5)); + var seconds = Math.min(60, base * Math.pow(2, Math.min(4, pollBackoff))); + pollTimer = window.setTimeout(function () { + pollTimer = null; + refresh(false); + }, seconds * 1000); + } + + function render(data, shell) { + if (!shell || shell !== activeShell || !isOpen(shell)) return; + data = data || {}; + var previousStage = currentStage; currentUuid = data.uuid || currentUuid; - if (statusBox) statusBox.hidden = false; - if (createForm) createForm.hidden = true; - if (stageNode) stageNode.textContent = data.stage || data.status || '认证处理中'; + currentStage = data.stage || currentStage; + if (data.stage && data.stage !== previousStage) runtime.invalidate(['identity']); + var lock = ['created', 'provider_pending', 'provider_processing', 'manual_pending'].indexOf(currentStage) !== -1; + var retryable = data.next_action === 'manual_or_retry'; + var statusBox = shell.querySelector('[data-auth-status]'); + var createForm = shell.querySelector('[data-auth-create]'); + var stageNode = shell.querySelector('[data-auth-stage]'); + var messageNode = shell.querySelector('[data-auth-status-message]'); + var continueLink = shell.querySelector('[data-auth-continue]'); + var manualButton = shell.querySelector('[data-auth-manual-open]'); + + if (statusBox) statusBox.hidden = !data.uuid && !retryable; + if (createForm) createForm.hidden = lock || data.status === 'verified'; + if (stageNode) stageNode.textContent = data.stage || data.status || '准备认证'; if (messageNode) messageNode.textContent = data.message || '认证结果以站点服务端主动查询为准。'; if (continueLink) { var canContinue = data.next_action === 'poll' && data.redirect_url; continueLink.hidden = !canContinue; if (canContinue) continueLink.href = data.redirect_url; } - if (manualButton) manualButton.hidden = data.next_action !== 'manual_or_retry'; - if (data.next_action === 'manual_or_retry' && manualForm) manualForm.hidden = true; - shouldPoll = data.next_action === 'poll'; - if (data.status === 'verified') { - shouldPoll = false; - stopPolling(); - window.setTimeout(function () { window.location.reload(); }, 500); - } else if (shouldPoll) { - schedulePoll(data.poll_interval || 5); - } else { + if (manualButton) manualButton.hidden = !retryable; + + if (data.status === 'verified' || data.next_action !== 'poll') { + pollBackoff = 0; stopPolling(); + } else if (isOpen(shell) && document.visibilityState === 'visible') { + schedulePoll(data.poll_interval || config.pollInterval); } - telemetryMark('ready'); + updateSummary(data); } - function refresh() { - if (!currentUuid) return Promise.resolve(); - return request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/refresh', config, { method: 'POST' }).then(render); + function refresh(explicit) { + if (!currentUuid || !isOpen(activeShell) || document.visibilityState !== 'visible' || refreshInFlight) { + return Promise.resolve(null); + } + stopPolling(); + abortRefresh(); + refreshInFlight = true; + refreshController = window.AbortController ? new window.AbortController() : null; + var timedOut = false; + var shell = activeShell; + var timeoutMs = Math.max(5000, Number(config.refreshTimeout || 15000)); + refreshTimeout = window.setTimeout(function () { + timedOut = true; + if (refreshController) { + try { refreshController.abort(); } catch (_) {} + } + }, timeoutMs); + + return request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/refresh', config, { + method: 'POST', + signal: refreshController ? refreshController.signal : undefined, + }).then(function (data) { + if (shell !== activeShell || !isOpen(shell)) return data; + pollBackoff = explicit ? 0 : Math.min(4, pollBackoff + 1); + render(data, shell); + return data; + }).catch(function (error) { + var aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR'); + if (shell !== activeShell || !isOpen(shell) || (aborted && !timedOut)) return null; + setMessage(shell, timedOut ? '云端刷新超时,请稍后重试。' : errorMessage(error, '刷新失败,请稍后重试。')); + pollBackoff = Math.min(4, pollBackoff + 1); + schedulePoll(config.pollInterval || 5); + return null; + }).finally(function () { + if (refreshTimeout) window.clearTimeout(refreshTimeout); + refreshTimeout = null; + refreshController = null; + refreshInFlight = false; + }); } - function schedulePoll(seconds) { stopPolling(); shouldPoll = true; pollTimer = window.setTimeout(function () { refresh().catch(function () {}).finally(function () { if (shouldPoll && currentUuid) schedulePoll(seconds); }); }, Math.max(3, seconds) * 1000); } - function stopPolling() { shouldPoll = false; if (pollTimer) window.clearTimeout(pollTimer); pollTimer = null; } - if (refreshButton) refreshButton.addEventListener('click', function () { refresh().catch(function (error) { if (messageNode) messageNode.textContent = error.message; }); }); - document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && currentUuid) refresh().catch(function () {}); }); + + function loadCurrent(shell, sequence) { + abortCurrent(); + currentController = window.AbortController ? new window.AbortController() : null; + var controller = currentController; + return request(config.restBase + '/current', config, { + method: 'GET', + signal: controller ? controller.signal : undefined, + }).then(function (snapshot) { + if (sequence !== openSequence || shell !== activeShell || !isOpen(shell)) return; + var data = snapshot && (snapshot.current || snapshot.active || snapshot.latest); + if (!data) { + currentUuid = ''; + currentStage = ''; + var statusBox = shell.querySelector('[data-auth-status]'); + var createForm = shell.querySelector('[data-auth-create]'); + if (statusBox) statusBox.hidden = true; + if (createForm) createForm.hidden = false; + stopPolling(); + return; + } + render(data, shell); + }).catch(function (error) { + var aborted = error && (error.name === 'AbortError' || error.code === 'ABORT_ERR'); + if (aborted || sequence !== openSequence || shell !== activeShell || !isOpen(shell)) return; + setMessage(shell, errorMessage(error, '状态加载失败,请稍后重试。')); + }).finally(function () { + if (currentController === controller) currentController = null; + }); + } + + function bindForm(shell) { + var createForm = shell.querySelector('[data-auth-create]'); + var manualForm = shell.querySelector('[data-auth-manual]'); + var statusBox = shell.querySelector('[data-auth-status]'); + var refreshButton = shell.querySelector('[data-auth-refresh]'); + var manualButton = shell.querySelector('[data-auth-manual-open]'); + var typeSelect = createForm && createForm.querySelector('[name="type"]'); + var enterprise = createForm && createForm.querySelector('[data-enterprise-fields]'); + var personLabel = createForm && createForm.querySelector('[data-auth-person-label]'); + var idLabel = createForm && createForm.querySelector('[data-auth-id-label]'); - if (createForm) { - var typeSelect = createForm.querySelector('[name="type"]'); - var enterprise = createForm.querySelector('[data-enterprise-fields]'); - var personLabel = createForm.querySelector('[data-auth-person-label]'); - var idLabel = createForm.querySelector('[data-auth-id-label]'); function syncEnterprise() { var enabled = typeSelect && typeSelect.value === 'enterprise'; if (enterprise) enterprise.hidden = !enabled; @@ -139,45 +254,118 @@ if (personLabel) personLabel.textContent = enabled ? '法定代表人姓名' : '真实姓名'; if (idLabel) idLabel.textContent = enabled ? '法定代表人身份证号' : '身份证号码'; } - if (typeSelect) typeSelect.addEventListener('change', syncEnterprise); - syncEnterprise(); - createForm.addEventListener('submit', function (event) { + + if (typeSelect) { + typeSelect.addEventListener('change', syncEnterprise); + syncEnterprise(); + } + if (refreshButton) refreshButton.addEventListener('click', function () { refresh(true); }); + if (manualButton) manualButton.addEventListener('click', function () { if (manualForm) manualForm.hidden = false; }); + + if (createForm) createForm.addEventListener('submit', function (event) { event.preventDefault(); + var button = createForm.querySelector('[type="submit"]'); + var message = createForm.querySelector('[data-auth-message]'); var form = new FormData(createForm); var payload = {}; form.forEach(function (value, key) { payload[key] = value; }); payload.consent = payload.consent === '1'; payload.client_request_id = uuid(); - var button = createForm.querySelector('[type="submit"]'); if (button) button.disabled = true; + if (message) message.textContent = ''; aliyunMetaInfo(config).then(function (metaInfo) { if (metaInfo) payload.meta_info = metaInfo; return request(config.restBase, config, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), }); - }).then(render).catch(function (error) { - var node = createForm.querySelector('[data-auth-message]'); - if (node) node.textContent = error.message; + }).then(function (data) { + render(data, shell); + }).catch(function (error) { + if (message) message.textContent = errorMessage(error, '创建认证会话失败,请重试。'); + }).finally(function () { if (button) button.disabled = false; }); }); + + if (manualForm) manualForm.addEventListener('submit', function (event) { + event.preventDefault(); + if (!currentUuid) return; + var files = manualForm.querySelector('[name="evidence[]"]'); + if (files && files.files.length > 3) { + setMessage(shell, '证明材料最多上传 3 张'); + return; + } + var button = manualForm.querySelector('[type="submit"]'); + var body = new FormData(); + Array.prototype.forEach.call((files && files.files) || [], function (file) { body.append('evidence[]', file); }); + if (button) button.disabled = true; + request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/manual', config, { + method: 'POST', + body: body, + }).then(function (data) { + manualForm.hidden = true; + render(data, shell); + }).catch(function (error) { + setMessage(shell, errorMessage(error, '人工审核提交失败,请重试。')); + }).finally(function () { + if (button) button.disabled = false; + }); + }); + + if (statusBox && statusBox.hidden && createForm) createForm.hidden = false; } - if (manualButton) manualButton.addEventListener('click', function () { if (manualForm) manualForm.hidden = false; }); - if (manualForm) manualForm.addEventListener('submit', function (event) { - event.preventDefault(); - if (!currentUuid) return; - var files = manualForm.querySelector('[name="evidence[]"]'); - if (files && files.files.length > 3) return window.alert('证明材料最多上传 3 张'); - var body = new FormData(); - Array.prototype.forEach.call((files && files.files) || [], function (file) { body.append('evidence[]', file); }); - request(config.restBase + '/' + encodeURIComponent(currentUuid) + '/manual', config, { method: 'POST', body: body }).then(render).catch(function (error) { window.alert(error.message); }); + function open() { + if (!runtime.isRolloutEnabled('identity')) return; + stopPolling(); + abortCurrent(); + abortRefresh(); + openSequence += 1; + var sequence = openSequence; + var shell = runtime.open({ + html: template.innerHTML, + kind: 'identity', + className: 'aksrt-identity-modal-shell', + mobileBottom: true, + onContent: function (_, session) { + activeShell = session.shell; + bindForm(session.shell); + }, + }); + activeShell = shell; + loadCurrent(shell, sequence); + } + + root.querySelectorAll('[data-auth-modal-open]').forEach(function (button) { + button.addEventListener('click', function (event) { + event.preventDefault(); + open(); + }); }); - if (currentUuid && ['provider_pending', 'provider_processing'].indexOf(config.currentStage) !== -1) schedulePoll(5); + document.addEventListener('visibilitychange', function () { + if (!isOpen(activeShell)) return; + if (document.visibilityState !== 'visible') { + stopPolling(); + abortCurrent(); + abortRefresh(); + return; + } + if (currentUuid && ['provider_pending', 'provider_processing'].indexOf(currentStage) !== -1) { + schedulePoll(config.pollInterval || 5); + } + }); + + document.addEventListener('aksrt:modal:close', function (event) { + if (!activeShell || !event.detail || event.detail.shell !== activeShell) return; + stopPolling(); + abortCurrent(); + abortRefresh(); + activeShell = null; + }); } window.AKSRTUserCenterAuth = { init: init }; -})(); +})(window, document); diff --git a/assets/js/user-badges.js b/assets/js/user-badges.js index 04ed5e8d..98f4d4e0 100644 --- a/assets/js/user-badges.js +++ b/assets/js/user-badges.js @@ -1,16 +1,36 @@ /** - * AKSRT user badges — zibll medal UX parity. + * AKSRT user badges — canonical JSON/runtime path. * - * User-center modal: category-grouped medal card grid + in-modal detail - * view (medal-background card, obtain time, wear action). Also provides the - * global wear handler used inside runtime-loaded badge modals. + * User-center and public-author triggers share one privacy-aware REST DTO, + * short user-scoped runtime cache, and one in-modal list/detail renderer. + * + * @package AKSRT */ -(function () { +(function (window, document) { 'use strict'; + var activeState = null; + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function toast(message, type) { + window.AksrtToast(message, type || 'info'); + } + function api(path, options) { return window.AksrtRequest.request(path, options || {}).then(function (response) { - return { ok: response.ok, status: response.status, json: response.json || {} }; + return { + ok: response.ok, + status: response.status, + json: response.json || {}, + }; }).catch(function (error) { var response = error && error.response ? error.response : {}; return { @@ -21,357 +41,255 @@ }); } - function toast(msg, type) { - window.AksrtToast(msg, type || 'info'); - } - - function escapeHtml(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - /* ---------- shared partials ---------- */ - - function iconHtml(item, cls) { + function iconHtml(item, className) { var icon = item && item.icon_url ? String(item.icon_url) : ''; var label = item && item.label ? String(item.label) : ''; if (icon) { - return '' + escapeHtml(label) + ''; + return '' + escapeHtml(label) + ''; } - return ''; + return ''; } - function emptyHtml(text, desc) { + function emptyHtml() { return '
' + '
' + - '
' + escapeHtml(text) + '
' + - (desc ? '
' + escapeHtml(desc) + '
' : '') + + '
暂无徽章
' + + '
积极参与社区互动即可获得徽章
' + '
'; } + function findItem(state, slug) { + var categories = state.data && Array.isArray(state.data.categories) ? state.data.categories : []; + for (var i = 0; i < categories.length; i += 1) { + var items = Array.isArray(categories[i].items) ? categories[i].items : []; + for (var j = 0; j < items.length; j += 1) { + if (String(items[j].slug || '') === String(slug || '')) return items[j]; + } + } + return null; + } + + function wornItem(state) { + var wear = state.data && state.data.wear ? state.data.wear : ''; + return wear ? findItem(state, wear) : null; + } + function cardHtml(item) { - var cls = 'medal-card' + (item.owned ? ' is-has' : ' no-has') + (item.wear ? ' is-wear' : ''); - return '
' + + var classes = 'medal-card' + (item.owned ? ' is-has' : ' no-has') + (item.wear ? ' is-wear' : ''); + return '
' + iconHtml(item) + '
' + escapeHtml(item.label) + '
' + - (item.wear ? '
' + escapeHtml('佩戴中') + '
' : '') + + (item.wear ? '
佩戴中
' : '') + '
'; } - /* ---------- user-center modal ---------- */ + function listHtml(state) { + var data = state.data || {}; + var categories = Array.isArray(data.categories) ? data.categories : []; + var worn = wornItem(state); + var title = data.is_self ? '我的徽章' : '徽章明细'; + var wearHtml = worn + ? '' + iconHtml(worn) + '' + escapeHtml(worn.label) + '' + : ''; + var html = '
' + + '
' + + '' + + '
' + escapeHtml(title) + '' + (Number(data.count || 0) > 0 ? '共 ' + Number(data.count) + ' 枚' : '') + '
' + + '
' + wearHtml + '
' + + '
'; - function init() { - var openers = document.querySelectorAll('[data-aksrt-badges-open]'); - var modal = document.querySelector('[data-aksrt-badges-modal]'); - if (!modal || !openers.length) return; + if (!categories.length) return html + '
' + emptyHtml() + '
'; - var userId = parseInt(modal.getAttribute('data-user-id') || '0', 10); - var canWear = modal.getAttribute('data-can-wear') === '1'; - var medalBg = modal.getAttribute('data-medal-bg') || ''; - var body = modal.querySelector('[data-aksrt-badges-body]'); - var detailBox = modal.querySelector('[data-aksrt-badges-detail]'); - var countSlot = modal.querySelector('[data-aksrt-badges-count]'); - var wearSlot = modal.querySelector('[data-aksrt-badges-wear-slot]'); - - var state = { data: null, detailSlug: '' }; - var telemetry = window.AksrtModalTelemetry; - var telemetryId = ''; - - function telemetryMark(event) { - if (telemetry && telemetryId && typeof telemetry.mark === 'function') telemetry.mark(telemetryId, event); - } - - function findItem(slug) { - var cats = (state.data && state.data.categories) || []; - for (var i = 0; i < cats.length; i++) { - var items = cats[i].items || []; - for (var j = 0; j < items.length; j++) { - if (items[j].slug === slug) return items[j]; - } - } - return null; - } - - function wornItem() { - var wear = state.data && state.data.wear; - return wear ? findItem(wear) : null; - } - - /* ----- view switching ----- */ - function showView(name) { - modal.querySelectorAll('[data-aksrt-badges-view]').forEach(function (view) { - view.hidden = view.getAttribute('data-aksrt-badges-view') !== name; - }); - if (name === 'grid') state.detailSlug = ''; - } - - function openModal() { - modal.hidden = false; - document.body.classList.add('uc-modal-open'); - } - function closeModal() { - modal.hidden = true; - document.body.classList.remove('uc-modal-open'); - showView('grid'); - } - - /* ----- render: head ----- */ - function renderHead() { - var count = state.data && typeof state.data.count === 'number' ? state.data.count : 0; - if (countSlot) { - countSlot.textContent = count > 0 ? '共 ' + count + ' 枚' : ''; - } - if (!wearSlot) return; - var worn = wornItem(); - wearSlot.innerHTML = worn - ? '' + iconHtml(worn) + '' + escapeHtml(worn.label) + '' - : ''; - } - - /* ----- render: grid ----- */ - function renderGrid() { - if (!body) return; - var cats = (state.data && state.data.categories) || []; - if (!cats.length) { - body.innerHTML = emptyHtml('暂无徽章', '积极参与社区互动即可获得徽章'); - return; - } - body.innerHTML = cats.map(function (cat) { - var cards = (cat.items || []).map(function (item) { - return ''; - }).join(''); - return '
' + - '
' + escapeHtml(cat.cat_name || '') + '
' + - '
' + cards + '
' + - '
'; + html += '
'; + categories.forEach(function (category) { + var items = Array.isArray(category.items) ? category.items : []; + var cards = items.map(function (item) { + return ''; }).join(''); - } - - /* ----- render: detail ----- */ - function renderDetail(slug) { - var item = findItem(slug); - if (!item || !detailBox) return; - state.detailSlug = slug; - - var stateLine = ''; - if (item.owned && item.time) { - stateLine = '
' + escapeHtml(item.time + ' 获得') + '
'; - } else if (!item.owned) { - stateLine = '
' + escapeHtml('暂未获得') + '
'; - } - - var action = ''; - if (canWear && item.owned && !item.wear) { - action = ''; - } else if (item.wear) { - action = '' + escapeHtml('当前佩戴此徽章') + ''; - } - - detailBox.innerHTML = - '
' + - '' + - '
' + - '
' + - (medalBg ? '
' : '') + - '
' + - iconHtml(item) + - '
' + escapeHtml(item.label) + '
' + - (item.desc ? '
' + escapeHtml(item.desc) + '
' : '') + - stateLine + - '
' + - '
' + - (action ? '
' + action + '
' : ''); - - showView('detail'); - } - - /* ----- header worn icon sync (profile header, next to the name) ----- */ - function syncHeaderIcon() { - var worn = wornItem(); - if (!worn || !worn.icon_url) return; - document.querySelectorAll('.aksrt-identity-medal.is-wear').forEach(function (node) { - if (node.tagName === 'IMG') { - node.src = worn.icon_url; - node.alt = worn.label; - node.title = worn.label; - } else { - var img = document.createElement('img'); - img.className = 'medal-icon img-icon ml3 aksrt-identity-medal is-wear'; - img.src = worn.icon_url; - img.alt = worn.label; - img.title = worn.label; - img.setAttribute('loading', 'lazy'); - img.setAttribute('decoding', 'async'); - node.parentNode.replaceChild(img, node); - } - }); - } - - /* ----- data ----- */ - function load() { - if (!userId) return Promise.resolve(); - if (body) { - body.innerHTML = '
' + escapeHtml('加载中…') + '
'; - } - return api('/users/' + userId + '/badges').then(function (r) { - if (!r.ok || !r.json || !r.json.data) { - telemetryMark('error'); - if (body) { - body.innerHTML = '
' + - '' + escapeHtml('加载失败') + '' + - '' + - '
'; - } - return; - } - state.data = r.json.data; - telemetryMark('first-content'); - renderHead(); - renderGrid(); - telemetryMark('ready'); - }); - } - - function wear(slug, btn) { - btn.disabled = true; - api('/users/me/badges/wear', { - method: 'POST', - body: JSON.stringify({ slug: slug }), - }).then(function (r) { - if (!r.ok) { - toast((r.json && (r.json.message || (r.json.data && r.json.data.message))) || '佩戴失败', 'error'); - btn.disabled = false; - return; - } - toast((r.json && r.json.message) || '已佩戴徽章', 'success'); - window.AksrtModalRuntime.invalidate(['badges', 'identity']); - state.data = (r.json && r.json.data) || state.data; - renderHead(); - renderGrid(); - renderDetail(slug); - syncHeaderIcon(); - }).catch(function () { - toast('网络错误', 'error'); - btn.disabled = false; - }); - } - - /* ----- events ----- */ - openers.forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.preventDefault(); - telemetryId = telemetry && typeof telemetry.start === 'function' ? telemetry.start('badges') : ''; - openModal(); - telemetryMark('shell'); - showView('grid'); - load(); - }); + html += '
' + escapeHtml(category.cat_name || '') + '
' + cards + '
'; }); + return html + '
'; + } - modal.querySelectorAll('[data-aksrt-badges-close]').forEach(function (btn) { - btn.addEventListener('click', function (e) { - e.preventDefault(); - closeModal(); - }); - }); + function detailHtml(state) { + var item = findItem(state, state.detailSlug); + if (!item) return listHtml(state); - document.addEventListener('keydown', function (e) { - if (e.key !== 'Escape' || modal.hidden) return; - var detailView = modal.querySelector('[data-aksrt-badges-view="detail"]'); - // Detail view open: ESC steps back to the grid first. - if (detailView && !detailView.hidden) { - showView('grid'); - return; - } - closeModal(); - }); + var stateLine = ''; + if (item.owned && item.time) { + stateLine = '
' + escapeHtml(item.time + ' 获得') + '
'; + } else if (!item.owned) { + stateLine = '
暂未获得
'; + } - modal.addEventListener('click', function (e) { - var back = e.target.closest('[data-aksrt-badges-back]'); - if (back) { - e.preventDefault(); - showView('grid'); - return; - } - var retry = e.target.closest('[data-aksrt-badges-retry]'); - if (retry) { - e.preventDefault(); - load(); - return; - } - var wearBtn = e.target.closest('[data-aksrt-badge-wear]'); - if (wearBtn && canWear) { - e.preventDefault(); - wear(wearBtn.getAttribute('data-aksrt-badge-wear') || '', wearBtn); - return; - } - var card = e.target.closest('[data-aksrt-badge-detail]'); - if (card) { - e.preventDefault(); - renderDetail(card.getAttribute('data-aksrt-badge-detail') || ''); + var action = ''; + if (state.canWear && item.owned && !item.wear) { + action = ''; + } else if (item.wear) { + action = '当前佩戴此徽章'; + } + + return '
' + + '
' + + '
' + + iconHtml(item) + '
' + escapeHtml(item.label) + '
' + + (item.desc ? '
' + escapeHtml(item.desc) + '
' : '') + stateLine + + '
' + (action ? '
' + action + '
' : '') + + '
'; + } + + function renderState(state) { + return state.detailSlug ? detailHtml(state) : listHtml(state); + } + + function rehydrate(content) { + window.AksrtFrontend.rehydrate(content, 'modal'); + } + + function renderInto(state) { + if (!state.session || state.session.closed || !state.session.content) return; + state.session.content.innerHTML = renderState(state); + rehydrate(state.session.content); + bindContent(state); + } + + function syncHeaderIcon(state) { + var worn = wornItem(state); + if (!worn || !worn.icon_url) return; + document.querySelectorAll('.aksrt-identity-medal.is-wear').forEach(function (node) { + if (node.tagName === 'IMG') { + node.src = worn.icon_url; + node.alt = worn.label; + node.title = worn.label; } }); } - /* ---------- remote modal: global wear handler ---------- */ - document.addEventListener('click', function (e) { - var wearBtn = e.target.closest('[data-aksrt-badge-wear]'); - if (!wearBtn) return; - // UC modal has its own handler when canWear on modal root. - var modalRoot = wearBtn.closest('[data-aksrt-badges-modal]'); - if (modalRoot) return; - e.preventDefault(); - var slug = wearBtn.getAttribute('data-aksrt-badge-wear') || ''; - if (!slug) return; - wearBtn.disabled = true; + function wear(state, slug, button) { + if (!state.canWear || state.wearing || !slug) return; + state.wearing = true; + state.wearController = window.AbortController ? new window.AbortController() : null; + if (button) button.disabled = true; api('/users/me/badges/wear', { method: 'POST', body: JSON.stringify({ slug: slug }), - }).then(function (r) { - if (!r.ok) { - toast((r.json && (r.json.message || (r.json.data && r.json.data.message))) || '佩戴失败', 'error'); - wearBtn.disabled = false; + signal: state.wearController ? state.wearController.signal : undefined, + }).then(function (result) { + if (state.session && state.session.closed) return; + if (!result.ok) { + toast((result.json && (result.json.message || (result.json.data && result.json.data.message))) || '佩戴失败', 'error'); + if (button) button.disabled = false; return; } - toast((r.json && r.json.message) || '已佩戴徽章', 'success'); - window.AksrtModalRuntime.invalidate(['badges', 'identity']); - // Re-open the refreshed single-badge modal so state stays in sync. - var refreshed = null; - try { - var uid = parseInt((r.json && r.json.data && r.json.data.user_id) || '0', 10); - var data = r.json && r.json.data; - var isSelf = data && data.is_self; - if (isSelf && data.wear) { - refreshed = uid > 0 && data.wear - ? window.AksrtRequest.url('/users/' + uid + '/badges/' + encodeURIComponent(data.wear) + '/modal') - : null; - } - } catch (_) {} - if (refreshed) { - window.AksrtModalRuntime.openRemote({ - url: refreshed, - className: 'modal-mini', - mobileBottom: true, - kind: 'badges', - cache: false, - }); - } else { - window.AksrtModalRuntime.close('badge-wear'); - } - }).catch(function () { + if (result.json && result.json.data) state.data = result.json.data; + state.detailSlug = slug; + window.AksrtModalRuntime.invalidate(['badges', 'identity', 'badges:user:' + state.userId]); + toast((result.json && result.json.message) || '已佩戴徽章', 'success'); + renderInto(state); + syncHeaderIcon(state); + }).catch(function (error) { + if (error && error.name === 'AbortError') return; toast('网络错误', 'error'); - wearBtn.disabled = false; + if (button) button.disabled = false; + }).finally(function () { + state.wearing = false; + state.wearController = null; }); + } + + function bindContent(state) { + if (!state.session || !state.session.content || state.session.content.dataset.aksrtBadgesBound === '1') return; + state.session.content.dataset.aksrtBadgesBound = '1'; + state.session.content.addEventListener('click', function (event) { + var back = event.target.closest('[data-aksrt-badges-back]'); + if (back) { + event.preventDefault(); + state.detailSlug = ''; + renderInto(state); + return; + } + var detail = event.target.closest('[data-aksrt-badge-detail]'); + if (detail) { + event.preventDefault(); + state.detailSlug = detail.getAttribute('data-aksrt-badge-detail') || ''; + renderInto(state); + return; + } + var wearButton = event.target.closest('[data-aksrt-badge-wear]'); + if (wearButton) { + event.preventDefault(); + wear(state, wearButton.getAttribute('data-aksrt-badge-wear') || '', wearButton); + } + }); + } + + function openBadges(trigger) { + var runtime = window.AksrtModalRuntime; + if (!runtime.isRolloutEnabled("badge")) return; + var userId = parseInt(trigger.getAttribute('data-aksrt-badges-user-id') || '0', 10); + if (!userId) return; + if (activeState && activeState.wearController) { + try { activeState.wearController.abort(); } catch (_) {} + } + + var state = { + userId: userId, + canWear: trigger.getAttribute('data-aksrt-badges-can-wear') === '1', + data: null, + detailSlug: '', + session: null, + wearing: false, + wearController: null, + }; + activeState = state; + runtime.openRemote({ + url: '/users/' + userId + '/badges', + kind: 'badges', + title: '徽章', + className: 'modal-mini full-sm aksrt-badges-modal-shell', + mobileBottom: true, + skeleton: '
', + cacheTtl: 12000, + cacheTags: ['badges', 'identity', 'badges:user:' + userId], + responseType: 'json', + renderData: function (data, session) { + state.session = session; + state.data = data || {}; + state.canWear = state.canWear && state.data.can_wear === true; + session.content.innerHTML = renderState(state); + }, + onData: function (data, session) { + state.session = session; + state.data = data || state.data; + state.canWear = state.canWear && state.data.can_wear === true; + bindContent(state); + syncHeaderIcon(state); + }, + }); + } + + document.addEventListener('aksrt:modal:close', function (event) { + if (!activeState || !event.detail || event.detail.session !== activeState.session) return; + if (activeState.wearController) { + try { activeState.wearController.abort(); } catch (_) {} + } + activeState = null; }); - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); + function bindOnce() { + if (window.__aksrtBadgesBound) return; + window.__aksrtBadgesBound = true; + document.addEventListener('click', function (event) { + var trigger = event.target.closest('[data-aksrt-badges-open]'); + if (!trigger) return; + event.preventDefault(); + openBadges(trigger); + }); } -})(); + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bindOnce, { once: true }); + } else { + bindOnce(); + } +})(window, document); diff --git a/assets/js/vip-purchase-coordinator.js b/assets/js/vip-purchase-coordinator.js new file mode 100644 index 00000000..ebf59992 --- /dev/null +++ b/assets/js/vip-purchase-coordinator.js @@ -0,0 +1,356 @@ +/** + * Canonical VIP purchase and order polling coordinator. + * + * Purchase requests are deduplicated by package/payment method. External + * orders use bounded, visibility-aware polling with AbortController support; + * terminal states invalidate the user-scoped modal cache exactly once. + * + * @package AKSRT + */ +(function (window, document) { + "use strict"; + + var ORDER_PATH = "/pay/content/order/"; + var DEFAULT_MAX_ATTEMPTS = 12; + var DEFAULT_MAX_ELAPSED = 120000; + var MAX_DELAY = 15000; + var createFlights = Object.create(null); + var polls = Object.create(null); + var SUCCESS_STATUSES = { + paid: true, + delivered: true, + completed: true, + pending_review: true, + }; + var FAILURE_STATUSES = { + cancelled: true, + expired: true, + refunded: true, + failed: true, + }; + + function userScope() { + var config = window.aksrtData || window.aksrt || {}; + var userId = parseInt(config.userId || config.user_id || 0, 10) || 0; + if (userId > 0) return "user:" + userId; + return config.isLoggedIn ? "auth" : "guest"; + } + + function clearFlights() { + createFlights = Object.create(null); + Object.keys(polls).forEach(function (orderNumber) { + var active = polls[orderNumber]; + if (active && active.handle && typeof active.handle.abort === "function") active.handle.abort(); + }); + polls = Object.create(null); + } + + ["aksrt:auth:logout", "aksrt:account-switch", "aksrt:logout", "aksrt:user:logout", "aksrt:auth:changed", "wp_logout"].forEach(function (eventName) { + document.addEventListener(eventName, clearFlights); + }); + + function responseData(result) { + if (!result) return {}; + if (result.data !== undefined && result.data !== null) return result.data || {}; + if (result.json && result.json.data !== undefined && result.json.data !== null) return result.json.data || {}; + return result.json || {}; + } + + function normalizeError(error) { + var response = error && error.response ? error.response : {}; + var json = error && error.json ? error.json : (response.json || {}); + return { + ok: false, + status: Number((error && error.status) || response.status || 0), + json: json || { message: error && error.message ? error.message : "请求失败" }, + }; + } + + function request(path, options) { + return window.AksrtRequest.request(path, options || {}).then(function (result) { + return { + ok: true, + status: Number(result.status || 200), + json: result.json || {}, + data: responseData(result), + }; + }).catch(normalizeError); + } + + function invalidate(tags) { + window.AksrtModalRuntime.invalidate(tags || ["vip", "orders"]); + } + + function callback(options, name) { + var args = Array.prototype.slice.call(arguments, 2); + if (!options || typeof options[name] !== "function") return; + try { + options[name].apply(null, args); + } catch (error) { + if (window.console && typeof window.console.error === "function") { + window.console.error("[AKSRT VIP] callback failed", error); + } + } + } + + function purchase(options) { + options = options || {}; + var packageId = parseInt(options.packageId || options.package_id || 0, 10); + var method = String(options.paymentMethod || options.payment_method || "balance"); + if (!packageId) { + return Promise.resolve({ ok: false, status: 400, json: { message: "套餐无效" } }); + } + + var key = userScope() + ":" + String(packageId) + ":" + method; + if (createFlights[key]) return createFlights[key]; + + var promise = request("/pay/vip", { + method: "POST", + body: JSON.stringify({ + package_id: packageId, + payment_method: method, + }), + signal: options.signal, + }); + createFlights[key] = promise; + promise.then(function () { + if (createFlights[key] === promise) delete createFlights[key]; + }); + return promise; + } + + function exchange(options) { + options = options || {}; + var packageId = parseInt(options.packageId || options.package_id || 0, 10); + if (!packageId) { + return Promise.resolve({ ok: false, status: 400, json: { message: "兑换套餐无效" } }); + } + return request("/pay/vip/points-exchange", { + method: "POST", + body: JSON.stringify({ package_id: packageId }), + signal: options.signal, + }).then(function (response) { + if (response.ok) invalidate(options.tags || ["vip", "orders"]); + return response; + }); + } + + function isTerminal(status, data) { + if (data && data.paid) return true; + return !!(SUCCESS_STATUSES[status] || FAILURE_STATUSES[status]); + } + + function backoff(attempt) { + return Math.min(MAX_DELAY, 1000 * Math.pow(2, Math.max(0, attempt - 1))); + } + + function poll(orderNumber, options) { + options = options || {}; + orderNumber = String(orderNumber || ""); + if (!orderNumber) { + return { + promise: Promise.resolve({ terminal: true, success: false, status: "invalid" }), + abort: function () {}, + }; + } + if (polls[orderNumber]) return polls[orderNumber].handle; + + var maxAttempts = Math.max(1, Number(options.maxAttempts || DEFAULT_MAX_ATTEMPTS)); + var maxElapsed = Math.max(1000, Number(options.maxElapsed || DEFAULT_MAX_ELAPSED)); + var state = { + attempt: 0, + startedAt: Date.now(), + timer: null, + controller: null, + requestTimer: null, + waitingForVisible: false, + done: false, + aborted: false, + resolve: null, + onVisibility: null, + }; + + state.promise = new Promise(function (resolve) { + state.resolve = resolve; + }); + + function finish(result) { + if (state.done) return; + state.done = true; + if (state.timer) { + window.clearTimeout(state.timer); + state.timer = null; + } + if (state.requestTimer) { + window.clearTimeout(state.requestTimer); + state.requestTimer = null; + } + if (state.controller) { + try { state.controller.abort(); } catch (error) {} + state.controller = null; + } + if (state.onVisibility) document.removeEventListener("visibilitychange", state.onVisibility); + if (polls[orderNumber] && polls[orderNumber].handle === state.handle) delete polls[orderNumber]; + state.resolve(result); + } + + function terminal(data, response, status) { + var success = !!(data && data.paid) || !!SUCCESS_STATUSES[status]; + invalidate(options.tags || ["vip", "orders"]); + callback(options, "onTerminal", { + terminal: true, + success: success, + status: status, + data: data || {}, + response: response || null, + }); + finish({ + terminal: true, + success: success, + status: status, + data: data || {}, + response: response || null, + }); + } + + function schedule(delay) { + if (state.done || state.aborted) return; + if (document.hidden) { + state.waitingForVisible = true; + return; + } + state.waitingForVisible = false; + state.timer = window.setTimeout(run, Math.max(0, delay || 0)); + } + + function run() { + state.timer = null; + if (state.done || state.aborted) return; + if (document.hidden) { + state.waitingForVisible = true; + return; + } + if (state.attempt >= maxAttempts || Date.now() - state.startedAt >= maxElapsed) { + callback(options, "onTimeout", { terminal: false, timedOut: true, orderNumber: orderNumber }); + finish({ terminal: false, timedOut: true, status: "timeout" }); + return; + } + + state.attempt += 1; + state.controller = window.AbortController ? new window.AbortController() : null; + var requestOptions = {}; + if (state.controller) requestOptions.signal = state.controller.signal; + var remaining = Math.max(1000, maxElapsed - (Date.now() - state.startedAt)); + state.requestTimer = window.setTimeout(function () { + if (state.controller) { + try { state.controller.abort(); } catch (error) {} + } + }, Math.min(20000, remaining)); + request(ORDER_PATH + encodeURIComponent(orderNumber), requestOptions).then(function (response) { + if (state.requestTimer) { + window.clearTimeout(state.requestTimer); + state.requestTimer = null; + } + state.controller = null; + if (state.done || state.aborted) return; + var data = response.data || response.json || {}; + var status = String(data.status || (data.paid ? "paid" : "")).toLowerCase(); + callback(options, "onStatus", { status: status, data: data, response: response }); + if (response.ok && isTerminal(status, data)) { + terminal(data, response, status || "paid"); + return; + } + if (!response.ok && [401, 403, 404].indexOf(response.status) >= 0) { + terminal(data, response, "error"); + return; + } + if (state.attempt >= maxAttempts || Date.now() - state.startedAt >= maxElapsed) { + callback(options, "onTimeout", { terminal: false, timedOut: true, orderNumber: orderNumber }); + finish({ terminal: false, timedOut: true, status: "timeout", response: response }); + return; + } + schedule(backoff(state.attempt)); + }); + } + + state.onVisibility = function () { + if (document.hidden || state.done || state.aborted) return; + if (state.waitingForVisible) { + schedule(0); + } + }; + document.addEventListener("visibilitychange", state.onVisibility); + + state.handle = { + promise: state.promise, + abort: function () { + if (state.done) return; + state.aborted = true; + finish({ terminal: false, aborted: true, status: "aborted" }); + }, + }; + polls[orderNumber] = state; + schedule(0); + return state.handle; + } + + function start(options) { + options = Object.assign({}, options || {}); + var activePoll = null; + var stopped = false; + var controller = window.AbortController ? new window.AbortController() : null; + if (controller) options.signal = controller.signal; + var promise = purchase(options).then(function (response) { + if (stopped) return { ok: false, aborted: true, status: "aborted" }; + var data = response.data || response.json || {}; + if (!response.ok) { + callback(options, "onError", response); + return response; + } + if (data.redirect && (data.payment_url || data.qr_content)) { + callback(options, "onPending", data); + if (!data.order_number) { + var missing = { ok: false, status: 500, json: { message: "支付订单号缺失" } }; + callback(options, "onError", missing); + return missing; + } + activePoll = poll(data.order_number, options); + activePoll.promise.then(function (result) { + if (result.success) callback(options, "onSuccess", result); + else if (result.terminal) callback(options, "onFailure", result); + }); + return { + ok: true, + pending: true, + data: data, + poll: activePoll, + }; + } + invalidate(options.tags || ["vip", "orders"]); + callback(options, "onSuccess", { terminal: true, success: true, data: data, response: response }); + return { ok: true, pending: false, data: data, response: response }; + }); + + return { + promise: promise, + abort: function () { + stopped = true; + if (controller) { + try { controller.abort(); } catch (error) {} + } + if (activePoll && typeof activePoll.abort === "function") activePoll.abort(); + }, + }; + } + + window.AksrtVipPurchaseCoordinator = { + purchase: purchase, + exchange: exchange, + poll: poll, + start: start, + abort: function (orderNumber) { + var active = polls[String(orderNumber || "")]; + if (active && active.handle) active.handle.abort(); + }, + }; +})(window, document); diff --git a/functions.php b/functions.php index f596669f..d5b0c8df 100644 --- a/functions.php +++ b/functions.php @@ -177,9 +177,6 @@ if (class_exists('\AKSRT\User\Restriction')) { if (class_exists('\AKSRT\User\Center')) { \AKSRT\User\Center::get_instance()->init(); } -if (class_exists('\AKSRT\User\Badges')) { - \AKSRT\User\Badges::get_instance()->init(); -} if (class_exists('\AKSRT\User\BadgeAutoAward')) { \AKSRT\User\BadgeAutoAward::get_instance()->init(); } diff --git a/inc/helpers/user-center-renderers/credits-vip.php b/inc/helpers/user-center-renderers/credits-vip.php index 9787bfa8..374d555e 100644 --- a/inc/helpers/user-center-renderers/credits-vip.php +++ b/inc/helpers/user-center-renderers/credits-vip.php @@ -15,39 +15,27 @@ if (empty($vip_settings['enabled'])) { } $user_id = (int) ($user_id ?? get_current_user_id()); -$is_vip_user = function_exists('aksrt_is_vip') && aksrt_is_vip($user_id); -$vip_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; -$expires = (int) get_user_meta($user_id, 'aksrt_vip_expires', true); +$vip_level = 0; +$expires = 0; $expires_label = __('未开通', 'aksrt'); -if ($vip_level > 0) { - if ($expires <= 0) { - $expires_label = __('永久有效', 'aksrt'); - } elseif ($expires < time()) { - $expires_label = __('已过期', 'aksrt'); - } else { - $expires_label = sprintf( - /* translators: %s: date */ - __('到期时间:%s', 'aksrt'), - wp_date('Y-m-d', $expires) - ); - } -} - +$is_vip_user = false; $level_name = (string) ($vip_settings['name'] ?? __('VIP会员', 'aksrt')); -if ($vip_level >= 1 && !empty($vip_settings['levels'][$vip_level]['name'])) { - $level_name = (string) $vip_settings['levels'][$vip_level]['name']; -} $packages = []; $renew_desc = ''; $upgrade_desc = ''; $points_exchange = ['enabled' => false, 'packages' => [], 'desc' => '']; $user_points = 0; -$currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'); +$currency_name = __('积分', 'aksrt'); +$methods = [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]]; if (class_exists('\AKSRT\Content\VipService')) { $catalog = \AKSRT\Content\VipService::get_instance()->get_catalog($user_id); + $vip_level = (int) ($catalog['user_level'] ?? 0); + $expires = (int) ($catalog['user_expires'] ?? 0); + $expires_label = (string) ($catalog['user_expires_label'] ?? $expires_label); + $is_vip_user = $vip_level > 0; $packages = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : []; - $methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : []; + $methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : $methods; $renew_desc = (string) ($catalog['renew_desc'] ?? ''); $upgrade_desc = (string) ($catalog['upgrade_desc'] ?? ''); $points_exchange = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : $points_exchange; @@ -55,8 +43,11 @@ if (class_exists('\AKSRT\Content\VipService')) { if (!empty($catalog['currency_name'])) { $currency_name = (string) $catalog['currency_name']; } -} else { - $methods = [['id' => 'balance', 'name' => __('站内余额', 'aksrt')]]; +} elseif (class_exists('\\AKSRT\\Settings\\Settings')) { + $currency_name = \AKSRT\Settings\Settings::get_currency_name(); +} +if ($vip_level >= 1 && !empty($vip_settings['levels'][$vip_level]['name'])) { + $level_name = (string) $vip_settings['levels'][$vip_level]['name']; } $cash_symbol = '¥'; diff --git a/inc/helpers/user-center-renderers/profile-auth.php b/inc/helpers/user-center-renderers/profile-auth.php index 2e99edb4..7ad17a49 100644 --- a/inc/helpers/user-center-renderers/profile-auth.php +++ b/inc/helpers/user-center-renderers/profile-auth.php @@ -11,22 +11,35 @@ use AKSRT\User\AuthApply; $context = is_array($args['context'] ?? null) ? $args['context'] : []; $user_id = (int) ($context['user_id'] ?? get_current_user_id()); +$snapshot = class_exists(AuthApply::class) ? AuthApply::snapshot($user_id) : [ + 'status' => '', + 'stage' => '', + 'can_apply' => false, + 'verified' => false, + 'info' => [], + 'apply' => [], + 'active' => null, + 'latest' => null, + 'poll_interval' => 5, +]; $settings = class_exists(AuthApply::class) ? AuthApply::settings() : ['enabled' => false, 'allow_apply' => false]; -$status = class_exists(AuthApply::class) ? AuthApply::get_status($user_id) : ''; -$info = class_exists(AuthApply::class) ? AuthApply::get_auth_info($user_id) : []; -$apply = class_exists(AuthApply::class) ? AuthApply::get_apply_payload($user_id) : []; -$record = is_array($apply['_application'] ?? null) ? $apply['_application'] : []; -$stage = sanitize_key((string) ($record['status'] ?? '')); -$locks_application = in_array($stage, ['created', 'provider_pending', 'provider_processing', 'manual_pending'], true); -$reason = (string) ($record['review_note'] ?? $record['provider_result_summary'] ?? ''); -$can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) && $status !== 'verified'; +$status = (string) ($snapshot['status'] ?? ''); +$info = is_array($snapshot['info'] ?? null) ? $snapshot['info'] : []; +$active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : []; +$latest = is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : []; +$stage = sanitize_key((string) ($active['status'] ?? '')); +$locks_application = $stage !== ''; +$reason = (string) (($latest['review_note'] ?? '') ?: ($latest['provider_result_summary'] ?? '')); +$can_apply = !empty($snapshot['can_apply']); $config = [ 'restBase' => untrailingslashit(rest_url('aksrt/v1/identity/applications')), 'nonce' => wp_create_nonce('wp_rest'), - 'currentUuid' => $locks_application ? (string) ($record['application_uuid'] ?? '') : '', + 'currentUuid' => $locks_application ? (string) ($active['application_uuid'] ?? '') : '', 'currentStage' => $locks_application ? $stage : '', 'provider' => sanitize_key((string) ($settings['provider'] ?? 'aliyun')), 'allowManual' => !empty($settings['allow_manual_fallback']), + 'pollInterval' => max(5, (int) ($snapshot['poll_interval'] ?? 5)), + 'snapshot' => class_exists(AuthApply::class) ? AuthApply::snapshot_view($user_id) : [], ]; ?>
@@ -58,12 +71,11 @@ $config = [ -
- @@ -106,28 +106,3 @@ extract($context, EXTR_SKIP); // phpcs:ignore WordPress.PHP.DontExtract.extract_
- - - - diff --git a/inc/helpers/user-center-renderers/profile-orders.php b/inc/helpers/user-center-renderers/profile-orders.php index 7e90e058..38b26eb9 100644 --- a/inc/helpers/user-center-renderers/profile-orders.php +++ b/inc/helpers/user-center-renderers/profile-orders.php @@ -110,7 +110,7 @@ $tabs = [

- diff --git a/src/Content/VipServiceModal.php b/src/Content/VipServiceModal.php index b1433d68..decbc881 100644 --- a/src/Content/VipServiceModal.php +++ b/src/Content/VipServiceModal.php @@ -1,6 +1,6 @@ */ - public function render_modal_html(int $user_id, int $prefer_level = 1): string { - if ($user_id <= 0) { - return '
' - . esc_html__('请先登录后再开通会员。', 'aksrt') - . '
'; - } - - $vip = class_exists('\AKSRT\Settings\Settings') - ? \AKSRT\Settings\Settings::get_payment_vip_settings() - : ['enabled' => false]; - if (empty($vip['enabled'])) { - return '
' - . esc_html__('会员功能未启用。', 'aksrt') - . '
'; - } - + public function get_modal_view_model(int $user_id, int $prefer_level = 1): array { $catalog = $this->get_catalog($user_id); - $user_level = (int) ($catalog['user_level'] ?? 0); + $vip = $this->get_vip_settings_context(); + $member = $this->get_vip_member_context($user_id); + $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; $prefer_level = max(1, min(2, $prefer_level)); - if ($user_level > 0) { - return $this->render_modal_viped($user_id, $catalog, $vip, $prefer_level); + $level_view = []; + foreach ([1, 2] as $level) { + if (empty($levels[$level]['enabled'])) { + continue; + } + $equity = (string) ($levels[$level]['equity'] ?? ''); + $equity = function_exists('aksrt_format_vip_equity_html') + ? (string) aksrt_format_vip_equity_html($equity) + : wp_kses_post($equity); + $level_view[] = [ + 'level' => $level, + 'name' => (string) ($levels[$level]['name'] ?? ('VIP' . $level)), + 'desc' => (string) ($levels[$level]['desc'] ?? ''), + 'equity' => $equity, + ]; } - return $this->render_modal_pay($user_id, $catalog, $vip, $prefer_level); + + $package_ids = []; + $has_renew = false; + $has_upgrade = false; + $groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : []; + foreach ($groups as $group) { + if (!is_array($group)) { + continue; + } + $action = sanitize_key((string) ($group['action'] ?? 'pay')); + $has_renew = $has_renew || $action === 'renew'; + $has_upgrade = $has_upgrade || $action === 'upgrade'; + foreach ((array) ($group['items'] ?? []) as $item) { + if (is_array($item) && (int) ($item['package_id'] ?? 0) > 0) { + $package_ids[] = (int) $item['package_id']; + } + } + } + $points_exchange = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : []; + $methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : []; + $no_payment_method = $methods === []; + $action_reasons = [ + 'purchase' => $no_payment_method + ? 'no_payment_method' + : ($package_ids === [] ? 'no_package' : ''), + 'renew' => $no_payment_method + ? 'no_payment_method' + : ($has_renew ? '' : 'not_available'), + 'upgrade' => $no_payment_method + ? 'no_payment_method' + : ($has_upgrade ? '' : 'not_available'), + 'points_exchange' => !empty($points_exchange['enabled']) && !empty($points_exchange['packages']) + ? '' + : 'not_available', + ]; + + $member_state = (string) ($member['state'] ?? 'unopened'); + $member_version = substr(hash('sha256', (string) wp_json_encode([ + 'level' => (int) ($member['level'] ?? 0), + 'expires' => (int) ($member['expires'] ?? 0), + 'state' => $member_state, + ])), 0, 16); + $catalog_version = substr(hash('sha256', (string) wp_json_encode([ + 'packages' => $groups, + 'points' => $points_exchange, + 'methods' => $methods, + 'levels' => $level_view, + ])), 0, 16); + + $user = get_userdata($user_id); + $avatar_url = function_exists('get_avatar_url') + ? (string) get_avatar_url($user_id, ['size' => 42]) + : ''; + + return [ + 'enabled' => !empty($catalog['enabled']), + 'prefer_level' => $prefer_level, + 'catalog' => [ + 'desc' => (string) ($catalog['desc'] ?? ''), + 'help_text' => (string) ($catalog['help_text'] ?? ''), + 'more' => (string) ($vip['more'] ?? ''), + 'currency_name' => (string) ($catalog['currency_name'] ?? ''), + 'level_packages' => $groups, + 'points_exchange' => $points_exchange, + 'vip_url' => (string) ($catalog['vip_url'] ?? ''), + ], + 'member' => [ + 'id' => $user_id, + 'name' => $user ? (string) $user->display_name : '', + 'avatar_url' => $avatar_url, + 'level' => (int) ($member['level'] ?? 0), + 'expires' => (int) ($member['expires'] ?? 0), + 'expires_label' => (string) ($member['expires_label'] ?? ''), + 'state' => (string) ($member['state'] ?? 'unopened'), + 'is_permanent' => !empty($member['is_permanent']), + 'points' => (int) ($catalog['user_points'] ?? 0), + 'logged_in' => $user_id > 0, + ], + 'levels' => $level_view, + 'payment_methods' => $methods, + 'allowed_actions' => [ + 'purchase' => $package_ids !== [] && $methods !== [], + 'renew' => $has_renew && $methods !== [], + 'upgrade' => $has_upgrade && $methods !== [], + 'points_exchange' => !empty($points_exchange['enabled']) && !empty($points_exchange['packages']), + ], + 'action_reasons' => $action_reasons, + 'version' => [ + 'schema' => 1, + 'member' => $member_version, + 'catalog' => $catalog_version, + // Stable revision token; do not use request time and bust short cache. + 'updated_at' => $member_version . ':' . $catalog_version, + 'tags' => ['vip', 'orders'], + ], + ]; } /** @@ -45,9 +146,7 @@ trait VipServiceModal { */ public function render_vip_card(int $level, int $user_id = 0): string { $level = max(1, min(2, $level)); - $vip = class_exists('\AKSRT\Settings\Settings') - ? \AKSRT\Settings\Settings::get_payment_vip_settings() - : []; + $vip = $this->get_vip_settings_context(); $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; if (empty($levels[$level]['enabled'])) { return ''; @@ -78,241 +177,4 @@ trait VipServiceModal { $html .= ''; return $html; } - - private function render_modal_pay(int $user_id, array $catalog, array $vip, int $prefer_level): string { - $user = get_userdata($user_id); - $name = $user ? $user->display_name : ''; - $avatar = get_avatar($user_id, 42); - $desc = (string) ($vip['desc'] ?? $catalog['desc'] ?? ''); - $more = (string) ($vip['more'] ?? ''); - $groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : []; - $methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : []; - $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; - - $active_lv = !empty($levels[$prefer_level]['enabled']) ? $prefer_level : 0; - if ($active_lv <= 0) { - foreach ([1, 2] as $lv) { - if (!empty($levels[$lv]['enabled'])) { - $active_lv = $lv; - break; - } - } - } - - $tabs = ''; - $panes = ''; - foreach ([1, 2] as $lv) { - if (empty($levels[$lv]['enabled'])) { - continue; - } - $is_active = ($lv === $active_lv); - $lv_name = (string) ($levels[$lv]['name'] ?? ('VIP' . $lv)); - $tabs .= '
  • ' - . '' - . esc_html($lv_name) - . '
  • '; - - $group_items = []; - foreach ($groups as $g) { - if ((int) ($g['level'] ?? 0) === $lv) { - $group_items = is_array($g['items'] ?? null) ? $g['items'] : []; - break; - } - } - $products = $this->render_product_radios($group_items); - $equity = (string) ($levels[$lv]['equity'] ?? ''); - $lv_desc = (string) ($levels[$lv]['desc'] ?? ''); - - $panes .= '
    '; - $panes .= '
    '; - $panes .= '
    '; - $panes .= '
    ' . esc_html($lv_name) . '
    '; - if ($lv_desc !== '') { - $panes .= '
    ' . esc_html($lv_desc) . '
    '; - } - if ($equity !== '') { - $panes .= function_exists('aksrt_format_vip_equity_html') - ? aksrt_format_vip_equity_html($equity) - : '
    ' . wp_kses_post($equity) . '
    '; - } - $panes .= '
    '; - $panes .= $products; - $panes .= $this->render_pay_methods($methods); - $panes .= ''; - $panes .= '

    '; - $panes .= '
    '; - } - - $points = ''; - if (!empty($catalog['points_exchange']['enabled']) && !empty($catalog['points_exchange']['packages'])) { - $points = ''; - } - - $html = '
    '; - $html .= ''; - $html .= '
    • ' . $avatar . '
    • '; - $html .= '
    • ' . esc_html($name) . ''; - if ($desc !== '') { - $html .= '
      ' . esc_html($desc) . '
      '; - } - $html .= '
    '; - $html .= ''; - $html .= '
    ' . $panes . '
    '; - if ($more !== '') { - $html .= '
    ' . $more . '
    '; - } - $html .= $points; - $html .= '
    '; - return $html; - } - - /** - * @param array $catalog - * @param array $vip - */ - private function render_modal_viped(int $user_id, array $catalog, array $vip, int $prefer_level): string { - $user = get_userdata($user_id); - $name = $user ? $user->display_name : ''; - $avatar = get_avatar($user_id, 42); - $exp_label = (string) ($catalog['user_expires_label'] ?? ''); - $groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : []; - $methods = is_array($catalog['payment_methods'] ?? null) ? $catalog['payment_methods'] : []; - $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; - $more = (string) ($vip['more'] ?? ''); - - $tabs = ''; - $panes = ''; - $active_set = false; - foreach ($groups as $g) { - $lv = (int) ($g['level'] ?? 0); - $action = (string) ($g['action'] ?? 'renew'); - $items = is_array($g['items'] ?? null) ? $g['items'] : []; - if ($lv <= 0 || $items === []) { - continue; - } - $is_active = !$active_set; - if ($is_active) { - $active_set = true; - } - $lv_name = (string) ($g['name'] ?? ($levels[$lv]['name'] ?? ('VIP' . $lv))); - $badge = $action === 'upgrade' ? __('升级', 'aksrt') : __('续费', 'aksrt'); - $tab_id = 'tab-payvip-' . $lv . '-' . $action; - $tabs .= '
  • ' - . '' - . esc_html($badge . $lv_name) - . '
  • '; - $products = $this->render_product_radios($items); - $equity = (string) ($levels[$lv]['equity'] ?? ''); - $panes .= '
    '; - $panes .= '
    '; - $panes .= '
    '; - $panes .= '
    ' . esc_html($lv_name) . '
    '; - if ($equity !== '') { - $panes .= function_exists('aksrt_format_vip_equity_html') - ? aksrt_format_vip_equity_html($equity) - : '
    ' . wp_kses_post($equity) . '
    '; - } - $panes .= '
    '; - $panes .= $products; - $panes .= $this->render_pay_methods($methods); - $cta = $action === 'upgrade' ? __('立即升级', 'aksrt') : __('立即续费', 'aksrt'); - $panes .= ''; - $panes .= '

    '; - $panes .= '
    '; - } - - if ($tabs === '') { - return '
    ' - . esc_html__('当前没有可续费或升级的套餐。', 'aksrt') - . '
    '; - } - - $html = '
    '; - $html .= ''; - $html .= '
    • ' . $avatar . '
    • '; - $html .= '
    • ' . esc_html($name) . ''; - $html .= '
      ' - . esc_html($exp_label) . '
    '; - $html .= ''; - $html .= '
    ' . $panes . '
    '; - if ($more !== '') { - $html .= '
    ' . $more . '
    '; - } - $html .= '
    '; - return $html; - } - - /** - * @param array> $items - */ - private function render_product_radios(array $items): string { - if ($items === []) { - return '
    ' - . esc_html__('暂无可用套餐', 'aksrt') . '
    '; - } - $html = '
    '; - $i = 0; - foreach ($items as $item) { - $pid = (int) ($item['package_id'] ?? 0); - if ($pid <= 0) { - continue; - } - $i++; - $price = (string) ($item['price_label'] ?? ''); - $show = (string) ($item['show_price_label'] ?? ''); - $tag = (string) ($item['discount_tag'] ?? ''); - $time = (string) ($item['days_label'] ?? ''); - $title = (string) ($item['title'] ?? ''); - $checked = $i === 1 ? ' checked' : ''; - $html .= ''; - } - $html .= '
    '; - return $html; - } - - /** - * @param array $methods - */ - private function render_pay_methods(array $methods): string { - if ($methods === []) { - return ''; - } - if (count($methods) === 1) { - $id = (string) ($methods[0]['id'] ?? 'balance'); - return ''; - } - $html = '
    '; - $html .= '' . esc_html__('支付方式', 'aksrt') . ''; - foreach ($methods as $i => $m) { - $mid = (string) ($m['id'] ?? ''); - $mname = (string) ($m['name'] ?? $mid); - $html .= ''; - } - $html .= '
    '; - return $html; - } } diff --git a/src/Content/VipServicePackages.php b/src/Content/VipServicePackages.php index 3894ed55..1bbbdd6f 100644 --- a/src/Content/VipServicePackages.php +++ b/src/Content/VipServicePackages.php @@ -534,7 +534,10 @@ trait VipServicePackages { private function format_cash_label(float $amount): string { $symbol = '¥'; - if (class_exists('\AKSRT\Settings\Settings')) { + if (method_exists($this, 'get_paid_display_context')) { + $paid = $this->get_paid_display_context(); + $symbol = (string) ($paid['display']['currency_symbol'] ?? '¥'); + } elseif (class_exists('\AKSRT\Settings\Settings')) { $paid = \AKSRT\Settings\Settings::get_paid_content_settings(); $symbol = (string) ($paid['display']['currency_symbol'] ?? '¥'); } diff --git a/src/Content/VipServicePurchase.php b/src/Content/VipServicePurchase.php index c0e32572..a3da7642 100644 --- a/src/Content/VipServicePurchase.php +++ b/src/Content/VipServicePurchase.php @@ -9,27 +9,114 @@ defined('ABSPATH') || exit; trait VipServicePurchase { + /** @var array|null */ + private $vip_settings_cache = null; + /** @var array> */ + private $vip_member_cache = []; + + /** @var array> */ + private $vip_catalog_cache = []; + + /** @var array|null */ + private $vip_paid_display_cache = null; + + /** + * Read payment settings once per request. The cache is kept on the + * singleton instance and is never shared between users or requests. + * + * @return array + */ + private function get_vip_settings_context(): array { + if (is_array($this->vip_settings_cache)) { + return $this->vip_settings_cache; + } + + $settings = class_exists('\AKSRT\Settings\Settings') + ? \AKSRT\Settings\Settings::get_payment_vip_settings() + : []; + $this->vip_settings_cache = is_array($settings) + ? $settings + : ['enabled' => false, 'levels' => [], 'renew' => [], 'upgrade' => [], 'points_exchange' => []]; + return $this->vip_settings_cache; + } + + /** + * Read the current member state once per request and user. + * + * @return array{level:int,expires:int,is_permanent:bool,state:string,expires_label:string} + */ + private function get_vip_member_context(int $user_id): array { + $user_id = max(0, $user_id); + if (array_key_exists($user_id, $this->vip_member_cache)) { + return $this->vip_member_cache[$user_id]; + } + + $level = $user_id > 0 && function_exists('aksrt_get_user_vip_level') + ? (int) aksrt_get_user_vip_level($user_id) + : 0; + $expires = $user_id > 0 ? (int) get_user_meta($user_id, 'aksrt_vip_expires', true) : 0; + $member_state = 'unopened'; + if ($level > 0) { + $member_state = $expires <= 0 + ? 'permanent' + : ($expires < time() ? 'expired' : 'active'); + } elseif ($expires > 0 && $expires < time()) { + // aksrt_get_user_vip_level() lazily clears expired levels; retain + // the observable expired state for this response without reviving it. + $member_state = 'expired'; + } + $state = [ + 'level' => max(0, $level), + 'expires' => $expires, + 'is_permanent' => $level > 0 && $expires <= 0, + 'state' => $member_state, + 'expires_label' => $member_state === 'expired' + ? __('已过期', 'aksrt') + : $this->format_expires_label($expires, $level), + ]; + $this->vip_member_cache[$user_id] = $state; + return $state; + } + + /** + * Cache paid-content display settings alongside the VIP request context. + * + * @return array + */ + private function get_paid_display_context(): array { + if (is_array($this->vip_paid_display_cache)) { + return $this->vip_paid_display_cache; + } + $settings = class_exists('\AKSRT\Settings\Settings') + ? \AKSRT\Settings\Settings::get_paid_content_settings() + : []; + $this->vip_paid_display_cache = is_array($settings) ? $settings : []; + return $this->vip_paid_display_cache; + } /** * @return array */ public function get_catalog(int $user_id = 0): array { - $vip = class_exists('\AKSRT\Settings\Settings') - ? \AKSRT\Settings\Settings::get_payment_vip_settings() - : ['enabled' => false, 'levels' => [], 'renew' => [], 'upgrade' => [], 'points_exchange' => []]; + $user_id = max(0, $user_id); + if (array_key_exists($user_id, $this->vip_catalog_cache)) { + return $this->vip_catalog_cache[$user_id]; + } - $level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; - $expires = $user_id > 0 ? (int) get_user_meta($user_id, 'aksrt_vip_expires', true) : 0; - $is_permanent = ($level > 0 && $expires <= 0); + $vip = $this->get_vip_settings_context(); + $member = $this->get_vip_member_context($user_id); + $level = (int) $member['level']; + $expires = (int) $member['expires']; + $is_permanent = !empty($member['is_permanent']); $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; $renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : []; $upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : []; $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; $renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']); $upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']); - $points_enabled = !empty($points_ex['enabled']) - && (!class_exists('\AKSRT\Settings\Settings') || \AKSRT\Settings\Settings::credits_enabled()); + $credits_enabled = !class_exists('\AKSRT\Settings\Settings') || \AKSRT\Settings\Settings::credits_enabled(); + $points_enabled = !empty($points_ex['enabled']) && $credits_enabled; $level_packages = []; foreach ([1, 2] as $lv) { @@ -141,19 +228,20 @@ trait VipServicePurchase { $methods = $this->available_payment_methods(); $currency_name = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt'); - return [ + $catalog = [ 'enabled' => !empty($vip['enabled']), 'desc' => (string) ($vip['desc'] ?? ''), 'help_text' => (string) ($vip['help_text'] ?? ''), 'user_level' => $level, 'user_expires' => $expires, 'user_is_permanent' => $is_permanent, - 'user_expires_label' => $this->format_expires_label($expires, $level), + 'user_expires_label' => (string) ($member['expires_label'] ?? ''), 'user_points' => $points_balance, 'wallet_balance' => $wallet_balance, 'currency_name' => $currency_name, 'renew_enabled' => $renew_enabled, 'upgrade_enabled' => $upgrade_enabled, + 'credits_enabled' => $credits_enabled, 'renew_desc' => (string) ($renew['desc'] ?? ''), 'upgrade_desc' => (string) ($upgrade['desc'] ?? ''), 'level_packages' => $level_packages, @@ -168,6 +256,36 @@ trait VipServicePurchase { : (function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/')), 'logged_in' => $user_id > 0, ]; + $this->vip_catalog_cache[$user_id] = $catalog; + return $catalog; + } + + /** + * Find a package in the server-derived catalog. + * + * The returned group name is request-local metadata used only for the + * order subject; price and entitlement data remain server-derived. + * + * @param array $catalog + * @return array|null + */ + private function find_catalog_package(array $catalog, int $package_id): ?array { + $groups = is_array($catalog['level_packages'] ?? null) ? $catalog['level_packages'] : []; + foreach ($groups as $group) { + if (!is_array($group)) { + continue; + } + $group_name = (string) ($group['name'] ?? ''); + $items = is_array($group['items'] ?? null) ? $group['items'] : []; + foreach ($items as $item) { + if (!is_array($item) || (int) ($item['package_id'] ?? 0) !== $package_id) { + continue; + } + $item['_level_name'] = $group_name !== '' ? $group_name : ('VIP' . (int) ($item['level'] ?? 1)); + return $item; + } + } + return null; } @@ -180,106 +298,36 @@ trait VipServicePurchase { return new \WP_Error('aksrt_need_login', __('请先登录后再开通会员。', 'aksrt'), ['status' => 401]); } - $decoded = $this->decode_package_id($package_id); - if (!$decoded) { - return new \WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]); - } - $level = (int) $decoded['level']; - $index = (int) $decoded['index']; - - $vip = class_exists('\AKSRT\Settings\Settings') - ? \AKSRT\Settings\Settings::get_payment_vip_settings() - : ['enabled' => false, 'levels' => [], 'renew' => [], 'upgrade' => []]; - if (empty($vip['enabled'])) { + $catalog = $this->get_catalog($user_id); + if (empty($catalog['enabled'])) { return new \WP_Error('aksrt_vip_disabled', __('会员功能未启用', 'aksrt'), ['status' => 400]); } - $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; - if (empty($levels[$level]['enabled'])) { - return new \WP_Error('aksrt_vip_level_disabled', __('该会员等级未启用', 'aksrt'), ['status' => 400]); + + $pkg = $this->find_catalog_package($catalog, $package_id); + if (!$pkg) { + return new \WP_Error('aksrt_invalid_package', __('套餐不存在或当前不可购买', 'aksrt'), ['status' => 400]); } - $user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; - $expires = (int) get_user_meta($user_id, 'aksrt_vip_expires', true); - $is_permanent = ($user_level > 0 && $expires <= 0); - $renew = is_array($vip['renew'] ?? null) ? $vip['renew'] : []; - $upgrade = is_array($vip['upgrade'] ?? null) ? $vip['upgrade'] : []; - $renew_enabled = !isset($renew['enabled']) || !empty($renew['enabled']); - $upgrade_enabled = !isset($upgrade['enabled']) || !empty($upgrade['enabled']); - - if ($is_permanent && $level <= $user_level) { - return new \WP_Error('aksrt_vip_permanent', __('您已是永久会员,无需续费。', 'aksrt'), ['status' => 400]); - } - if ($user_level > 0 && $level === $user_level && !$renew_enabled) { - return new \WP_Error('aksrt_vip_renew_disabled', __('续费功能未开启。', 'aksrt'), ['status' => 400]); - } - if ($user_level > 0 && $level > $user_level && !$upgrade_enabled) { - return new \WP_Error('aksrt_vip_upgrade_disabled', __('升级功能未开启。', 'aksrt'), ['status' => 400]); - } - if ($user_level > 0 && $level < $user_level) { - return new \WP_Error('aksrt_vip_downgrade', __('不支持降级购买。', 'aksrt'), ['status' => 400]); - } - - $action = 'pay'; - if ($user_level > 0 && $level === $user_level) { - $action = 'renew'; - } elseif ($user_level > 0 && $level > $user_level) { - $action = 'upgrade'; - } - - // Dedicated upgrade product: - // 901 permanent/fixed, 902 unit remaining days, 903 jump permanent. - $upgrade_mode = ''; - $pkg_time = 0; - $pkg_unit = 'day'; - if (in_array($index, [901, 902, 903], true)) { - $upgrade_items = $this->build_upgrade_items($user_id, $expires, $is_permanent, (string) ($upgrade['product'] ?? '')); - $match = null; - foreach ($upgrade_items as $ui) { - if ((int) ($ui['package_id'] ?? 0) === $package_id) { - $match = $ui; - break; - } - } - if (!$match || $level !== 2) { - return new \WP_Error('aksrt_invalid_package', __('升级套餐不存在', 'aksrt'), ['status' => 400]); - } - $pkg = [ - 'price' => (float) $match['price'], - 'days' => (int) $match['days'], - 'title' => (string) $match['title'], - ]; - $amount = round((float) $match['price'], 2); - $days = (int) $match['days']; - $pkg_time = $days; // unit upgrade keeps days as remaining; jump/permanent use 0 + $level = (int) ($pkg['level'] ?? 0); + $index = (int) ($pkg['index'] ?? 0); + $action = sanitize_key((string) ($pkg['action'] ?? 'pay')); + $amount = round((float) ($pkg['price'] ?? 0), 2); + $base_price = (float) ($pkg['base_price'] ?? $amount); + $days = (int) ($pkg['days'] ?? 0); + $pkg_time = array_key_exists('time', $pkg) ? (int) $pkg['time'] : $days; + $pkg_unit = sanitize_key((string) ($pkg['unit'] ?? 'day')); + if (!in_array($pkg_unit, ['day', 'month'], true)) { $pkg_unit = 'day'; - $base_price = (float) ($match['base_price'] ?? $amount); - $discount_tag = (string) ($match['discount_tag'] ?? __('升级专享', 'aksrt')); - $upgrade_mode = sanitize_key((string) ($match['upgrade_mode'] ?? '')); - $action = 'upgrade'; - if ($upgrade_mode === 'jump' || $upgrade_mode === 'permanent') { - $pkg_time = 0; - $days = 0; - } - } else { - $level_cfg = is_array($levels[$level] ?? null) ? $levels[$level] : []; - if (isset($level_cfg['product_s']) && empty($level_cfg['product_s'])) { - return new \WP_Error('aksrt_vip_product_disabled', __('该等级暂不支持购买', 'aksrt'), ['status' => 400]); - } - $packages = $this->parse_level_products((string) ($level_cfg['products_raw'] ?? '')); - $pkg = $packages[$index - 1] ?? null; - if (!$pkg) { - return new \WP_Error('aksrt_invalid_package', __('套餐不存在', 'aksrt'), ['status' => 400]); - } - $base_price = (float) $pkg['price']; - $days = (int) $pkg['days']; - $pkg_time = (int) ($pkg['time'] ?? 0); - $pkg_unit = (string) ($pkg['unit'] ?? 'day'); - $priced = $this->apply_action_price($base_price, $action, $renew, $upgrade, $level, $user_level, $expires, $is_permanent, $days); - if ($priced === null) { - return new \WP_Error('aksrt_invalid_package', __('当前无法购买该套餐', 'aksrt'), ['status' => 400]); - } - $amount = round((float) $priced['price'], 2); - $discount_tag = (string) ($priced['tag'] ?? ''); + } + $discount_tag = (string) ($pkg['discount_tag'] ?? ''); + $upgrade_mode = sanitize_key((string) ($pkg['upgrade_mode'] ?? '')); + if (in_array($upgrade_mode, ['jump', 'permanent'], true)) { + $pkg_time = 0; + $days = 0; + } + + if ($level < 1 || $level > 2 || $index < 1 || $action === '') { + return new \WP_Error('aksrt_invalid_package', __('套餐无效', 'aksrt'), ['status' => 400]); } if ($amount <= 0) { @@ -310,6 +358,7 @@ trait VipServicePurchase { 'discount_tag' => $discount_tag, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $expires_at = gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS); $order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([ 'user_id' => $user_id, 'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP, @@ -319,27 +368,36 @@ trait VipServicePurchase { 'status' => \AKSRT\Payment\Order::STATUS_PENDING, 'payment_method' => $payment_method, 'note' => is_string($note) ? $note : '', - 'expires_at' => gmdate('Y-m-d H:i:s', time() + 30 * MINUTE_IN_SECONDS), + 'expires_at' => $expires_at, ]); if ($order_number === '') { return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); } - $order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number); - if (!$order) { - return new \WP_Error('aksrt_order_create_failed', __('创建订单失败', 'aksrt'), ['status' => 500]); - } - - $level_name = (string) ($levels[$level]['name'] ?? ('VIP' . $level)); + // Build the request-local order context from the same server-derived + // package used for validation. PaymentOrchestrator still reloads the + // persisted row under its attempt lock before touching the gateway. + $level_name = (string) ($pkg['_level_name'] ?? ('VIP' . $level)); $action_label = $action === 'renew' ? __('续费', 'aksrt') : ($action === 'upgrade' ? __('升级', 'aksrt') : __('开通', 'aksrt')); - $order['subject'] = sprintf( - /* translators: 1: action 2: vip name 3: package title */ - __('%1$s%2$s · %3$s', 'aksrt'), - $action_label, - $level_name, - (string) ($pkg['title'] ?? '') - ); - $order['payment_method'] = $payment_method; + $order = [ + 'order_number' => $order_number, + 'user_id' => $user_id, + 'product_type' => \AKSRT\Core\DomainModel::ORDER_PRODUCT_VIP, + 'product_id' => $package_id, + 'amount' => $amount, + 'currency' => 'CNY', + 'status' => \AKSRT\Payment\Order::STATUS_PENDING, + 'payment_method' => $payment_method, + 'note' => is_string($note) ? $note : '', + 'expires_at' => $expires_at, + 'subject' => sprintf( + /* translators: 1: action 2: vip name 3: package title */ + __('%1$s%2$s · %3$s', 'aksrt'), + $action_label, + $level_name, + (string) ($pkg['title'] ?? '') + ), + ]; if (!class_exists('\AKSRT\Payment\Gateway')) { return new \WP_Error('aksrt_pay_unavailable', __('支付网关不可用', 'aksrt'), ['status' => 500]); @@ -355,6 +413,7 @@ trait VipServicePurchase { $payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($resolved['gateway'], $order, [ 'paid_note' => __('用户完成会员开通支付。', 'aksrt'), + 'order_context' => $order, ]); if (empty($payment_result['success'])) { return new \WP_Error( @@ -568,28 +627,23 @@ trait VipServicePurchase { if (!class_exists('\AKSRT\Credits\Credits')) { return new \WP_Error('aksrt_credits_unavailable', __('积分系统不可用', 'aksrt'), ['status' => 500]); } - if (class_exists('\AKSRT\Settings\Settings') && !\AKSRT\Settings\Settings::credits_enabled()) { + $catalog = $this->get_catalog($user_id); + if (array_key_exists('credits_enabled', $catalog) && empty($catalog['credits_enabled'])) { return new \WP_Error('aksrt_credits_disabled', __('积分功能未启用', 'aksrt'), ['status' => 400]); } - - $vip = class_exists('\AKSRT\Settings\Settings') - ? \AKSRT\Settings\Settings::get_payment_vip_settings() - : []; - $points_ex = is_array($vip['points_exchange'] ?? null) ? $vip['points_exchange'] : []; + $points_ex = is_array($catalog['points_exchange'] ?? null) ? $catalog['points_exchange'] : []; if (empty($points_ex['enabled'])) { return new \WP_Error('aksrt_points_exchange_disabled', __('积分兑换会员未启用', 'aksrt'), ['status' => 400]); } - $user_level = function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($user_id) : 0; - if ($user_level > 0) { + if ((int) ($catalog['user_level'] ?? 0) > 0) { return new \WP_Error('aksrt_already_vip', __('您已是会员,无法使用积分兑换。', 'aksrt'), ['status' => 400]); } - $levels = is_array($vip['levels'] ?? null) ? $vip['levels'] : []; - $packages = $this->get_points_exchange_packages($vip, $levels); $match = null; + $packages = is_array($points_ex['packages'] ?? null) ? $points_ex['packages'] : []; foreach ($packages as $pkg) { - if ((int) ($pkg['package_id'] ?? 0) === $package_id) { + if (is_array($pkg) && (int) ($pkg['package_id'] ?? 0) === $package_id) { $match = $pkg; break; } @@ -605,7 +659,7 @@ trait VipServicePurchase { return new \WP_Error('aksrt_invalid_price', __('兑换积分无效', 'aksrt'), ['status' => 400]); } - $balance = \AKSRT\Credits\Credits::balance($user_id); + $balance = (int) ($catalog['user_points'] ?? 0); if ($balance < $cost) { return new \WP_Error('aksrt_credits_insufficient', __('积分不足,暂时无法兑换', 'aksrt'), [ 'status' => 402, diff --git a/src/Content/VipServiceRest.php b/src/Content/VipServiceRest.php index 3997580d..fe0d6037 100644 --- a/src/Content/VipServiceRest.php +++ b/src/Content/VipServiceRest.php @@ -59,11 +59,10 @@ trait VipServiceRest { if ($level < 1 || $level > 2) { $level = 1; } - $html = $this->render_modal_html($uid, $level); - return new \WP_REST_Response([ - 'html' => $html, - 'user_level' => function_exists('aksrt_get_user_vip_level') ? aksrt_get_user_vip_level($uid) : 0, - ], 200); + return $this->no_store(new \WP_REST_Response([ + 'success' => true, + 'data' => $this->get_modal_view_model($uid, $level), + ], 200)); } @@ -90,7 +89,7 @@ trait VipServiceRest { */ public function rest_catalog(\WP_REST_Request $request) { $uid = get_current_user_id(); - return new \WP_REST_Response($this->get_catalog($uid), 200); + return $this->no_store(new \WP_REST_Response($this->get_catalog($uid), 200)); } @@ -129,7 +128,7 @@ trait VipServiceRest { ['status' => (int) ($result['status'] ?? 400)] ); } - return new \WP_REST_Response($result, 200); + return $this->no_store(new \WP_REST_Response($result, 200)); } @@ -152,7 +151,15 @@ trait VipServiceRest { if (is_wp_error($result)) { return $result; } - return new \WP_REST_Response($result, 200); + return $this->no_store(new \WP_REST_Response($result, 200)); + } + + /** + * Private member/catalog payloads must not be stored by intermediaries. + */ + private function no_store(\WP_REST_Response $response): \WP_REST_Response { + $response->header('Cache-Control', 'private, no-store, max-age=0'); + return $response; } } diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index b0e4642e..6226d0a8 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -13,7 +13,7 @@ class DBMigrator { use DBMigratorIdentity; /** Current schema version; changing it re-runs dbDelta. */ - const SCHEMA_VERSION = '1.17.0'; + const SCHEMA_VERSION = '1.18.0'; const OPT_KEY = 'aksrt_db_schema_version'; diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php index 7e00f6db..949c8973 100644 --- a/src/Core/DBMigratorPart2.php +++ b/src/Core/DBMigratorPart2.php @@ -180,6 +180,7 @@ trait DBMigratorPart2 { PRIMARY KEY (id), UNIQUE KEY uniq_order_number (order_number), KEY idx_user (user_id), + KEY idx_user_status_created (user_id, status, created_at), KEY idx_status (status), KEY idx_created (created_at), KEY idx_product (product_type, product_id) diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php index bbf76059..641ea97c 100644 --- a/src/Core/ScriptsPart1b.php +++ b/src/Core/ScriptsPart1b.php @@ -209,9 +209,24 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; ); } - // VIP modal (zibll .pay-vip) + // Shared VIP purchase/poll coordinator for modal and credits surfaces. $vip_on = class_exists('\AKSRT\Settings\Settings') && !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']); + $credits_on = is_user_logged_in() + && class_exists('\AKSRT\Settings\Settings') + && \AKSRT\Settings\Settings::credits_enabled(); + $vip_coordinator = get_template_directory() . '/assets/js/vip-purchase-coordinator.js'; + if (($vip_on || $credits_on) && file_exists($vip_coordinator)) { + wp_enqueue_script( + 'aksrt-vip-coordinator', + $base_uri . '/assets/js/vip-purchase-coordinator.js', + ['aksrt-core-request'], + (string) filemtime($vip_coordinator), + true + ); + } + + // VIP modal (zibll .pay-vip) if ($vip_on) { $vip_css = get_template_directory() . '/assets/css/frontend/vip-modal.css'; if (file_exists($vip_css)) { @@ -227,7 +242,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; wp_enqueue_script( 'aksrt-pay-vip', $base_uri . '/assets/js/pay-vip.js', - ['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime'], + ['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator'], (string) filemtime($vip_js), true ); @@ -449,7 +464,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; wp_enqueue_script( 'aksrt-route-user-center-auth', $base_uri . '/assets/js/routes/user-center-auth.js', - ['aksrt-script'], + ['aksrt-script', 'aksrt-core-request', 'aksrt-core-modal-runtime'], (string) filemtime($identity_js), true ); @@ -875,7 +890,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; } // 积分相关:已登录 + 启用积分功能时全站加载 - if (is_user_logged_in() && class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled()) { + if ($credits_on) { $enqueue_module('credits'); // profile 页:加载 credits.js(签到 + 邀请交互) @@ -884,7 +899,7 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js'; wp_enqueue_script( 'aksrt-credits', $base_uri . '/assets/js/credits.js', - ['aksrt-script'], + ['aksrt-script', 'aksrt-core-modal-runtime', 'aksrt-vip-coordinator'], file_exists($credits_js_path) ? (string) filemtime($credits_js_path) : $version, true ); diff --git a/src/Identity/ApplicationRepository.php b/src/Identity/ApplicationRepository.php index 7b828e08..6a33365e 100644 --- a/src/Identity/ApplicationRepository.php +++ b/src/Identity/ApplicationRepository.php @@ -17,10 +17,27 @@ final class ApplicationRepository { private string $table; + /** @var array|null,active:array|null,verified:array|null}> */ + private static array $snapshot_cache = []; + public function __construct() { $this->table = \AKSRT\Core\DBMigrator::table('identity_application'); } + /** + * Clear the request-local application snapshot cache. + * + * Writes can change the active/latest/verified selection, so callers must + * invalidate before reading the next view in the same request. + */ + public static function clear_snapshot_cache(int $user_id = 0): void { + if ($user_id > 0) { + unset(self::$snapshot_cache[$user_id]); + return; + } + self::$snapshot_cache = []; + } + /** @param array $data */ public function create(array $data) { global $wpdb; @@ -47,6 +64,7 @@ final class ApplicationRepository { if ($inserted !== 1) { return new \WP_Error('aksrt_identity_create', __('认证申请创建失败', 'aksrt')); } + self::clear_snapshot_cache((int) $row['user_id']); return $this->find_by_id((int) $wpdb->insert_id); } @@ -75,36 +93,63 @@ final class ApplicationRepository { return is_array($row) ? $this->normalize($row) : null; } - /** @return array|null */ - public function latest_for_user(int $user_id): ?array { + /** + * Load the latest, active, and verified applications in one request-local + * context. The three views are selected from one ordered result set so + * facade callers cannot repeat the same application SELECT. + * + * @return array{latest:array|null,active:array|null,verified:array|null} + */ + public function snapshot_for_user(int $user_id): array { + if ($user_id <= 0) { + return ['latest' => null, 'active' => null, 'verified' => null]; + } + if (array_key_exists($user_id, self::$snapshot_cache)) { + return self::$snapshot_cache[$user_id]; + } + global $wpdb; - $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$this->table} WHERE user_id = %d ORDER BY id DESC LIMIT 1", + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table} WHERE user_id = %d ORDER BY id DESC", $user_id ), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; + $snapshot = ['latest' => null, 'active' => null, 'verified' => null]; + foreach ((array) $rows as $raw) { + if (!is_array($raw)) { + continue; + } + $record = $this->normalize($raw); + if ($snapshot['latest'] === null) { + $snapshot['latest'] = $record; + } + if ($snapshot['active'] === null && in_array((string) $record['status'], self::ACTIVE_STATUSES, true)) { + $snapshot['active'] = $record; + } + if ((string) $record['status'] === 'verified') { + $current = $snapshot['verified']; + if ($current === null || $this->verified_is_newer($record, $current)) { + $snapshot['verified'] = $record; + } + } + } + + self::$snapshot_cache[$user_id] = $snapshot; + return $snapshot; + } + + /** @return array|null */ + public function latest_for_user(int $user_id): ?array { + return $this->snapshot_for_user($user_id)['latest']; } /** @return array|null */ public function active_for_user(int $user_id): ?array { - global $wpdb; - $placeholders = implode(',', array_fill(0, count(self::ACTIVE_STATUSES), '%s')); - $params = array_merge([$user_id], self::ACTIVE_STATUSES); - $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$this->table} WHERE user_id = %d AND status IN ({$placeholders}) ORDER BY id DESC LIMIT 1", - $params - ), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; + return $this->snapshot_for_user($user_id)['active']; } /** @return array|null */ public function verified_for_user(int $user_id): ?array { - global $wpdb; - $row = $wpdb->get_row($wpdb->prepare( - "SELECT * FROM {$this->table} WHERE user_id = %d AND status = 'verified' ORDER BY completed_at DESC, id DESC LIMIT 1", - $user_id - ), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; + return $this->snapshot_for_user($user_id)['verified']; } /** @param array $fields */ @@ -125,7 +170,11 @@ final class ApplicationRepository { $formats[$index] = '%f'; } } - return $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1; + $updated = $wpdb->update($this->table, $fields, ['id' => $id, 'status' => $expected], $formats, ['%d', '%s']) === 1; + if ($updated) { + self::clear_snapshot_cache(); + } + return $updated; } /** @param array $fields */ @@ -136,6 +185,9 @@ final class ApplicationRepository { } $fields['updated_at'] = current_time('mysql', true); $updated = $wpdb->update($this->table, $fields, ['id' => $id]); + if ($updated !== false) { + self::clear_snapshot_cache(); + } return $updated !== false; } @@ -150,7 +202,21 @@ final class ApplicationRepository { public function delete(int $id): bool { global $wpdb; - return $wpdb->delete($this->table, ['id' => $id], ['%d']) === 1; + $deleted = $wpdb->delete($this->table, ['id' => $id], ['%d']) === 1; + if ($deleted) { + self::clear_snapshot_cache(); + } + return $deleted; + } + + /** @param array $record @param array $current */ + private function verified_is_newer(array $record, array $current): bool { + $record_time = strtotime((string) ($record['completed_at'] ?? '')) ?: 0; + $current_time = strtotime((string) ($current['completed_at'] ?? '')) ?: 0; + if ($record_time !== $current_time) { + return $record_time > $current_time; + } + return (int) ($record['id'] ?? 0) > (int) ($current['id'] ?? 0); } /** @return array */ diff --git a/src/Identity/ApplicationService.php b/src/Identity/ApplicationService.php index f995bd59..7b72b8cb 100644 --- a/src/Identity/ApplicationService.php +++ b/src/Identity/ApplicationService.php @@ -189,7 +189,6 @@ final class ApplicationService { 'result_code' => (string) $application['provider_result_code'], 'message' => (string) $application['provider_result_summary'], 'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')), - 'qr_payload' => sanitize_text_field((string) ($application['qr_payload'] ?? '')), 'expires_at' => (string) ($application['expires_at'] ?? ''), 'created_at' => (string) $application['created_at'], 'poll_interval' => (int) SettingsRuntime::get()['poll_interval_seconds'], diff --git a/src/Identity/RestController.php b/src/Identity/RestController.php index 0254c230..7f557168 100644 --- a/src/Identity/RestController.php +++ b/src/Identity/RestController.php @@ -13,6 +13,9 @@ final class RestController { private const NS = 'aksrt/v1'; + private ?array $request_application = null; + private string $request_uuid = ''; + public function __construct( private ?ApplicationRepository $applications = null, private ?ApplicationService $service = null @@ -57,7 +60,7 @@ final class RestController { if (!is_user_logged_in()) { return false; } - $application = $this->applications->find_by_uuid((string) $request['uuid']); + $application = $this->application_for_request($request); return is_array($application) && (int) $application['user_id'] === get_current_user_id(); } @@ -69,34 +72,74 @@ final class RestController { } public function current(): \WP_REST_Response { - $application = $this->applications->latest_for_user(get_current_user_id()); - return new \WP_REST_Response(['success' => true, 'data' => $application ? $this->service->view($application) : null]); + $user_id = get_current_user_id(); + $data = class_exists('\\AKSRT\\User\\AuthApply') + ? \AKSRT\User\AuthApply::snapshot_view($user_id) + : ['user_id' => $user_id, 'current' => null, 'active' => null, 'latest' => null]; + return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $data])); } public function get(\WP_REST_Request $request): \WP_REST_Response { - $application = $this->applications->find_by_uuid((string) $request['uuid']); - return new \WP_REST_Response(['success' => true, 'data' => $this->service->view($application)]); + $application = $this->application_for_request($request); + if (!$application) { + return $this->no_store(new \WP_REST_Response([ + 'success' => false, + 'code' => 'aksrt_identity_missing', + 'message' => __('认证申请不存在', 'aksrt'), + ], 404)); + } + return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $this->service->view($application)])); } public function refresh(\WP_REST_Request $request): \WP_REST_Response { - $application = $this->applications->find_by_uuid((string) $request['uuid']); + $application = $this->application_for_request($request); + if (!$application) { + return $this->no_store(new \WP_REST_Response([ + 'success' => false, + 'code' => 'aksrt_identity_missing', + 'message' => __('认证申请不存在', 'aksrt'), + ], 404)); + } return $this->respond($this->service->refresh($application)); } public function manual(\WP_REST_Request $request): \WP_REST_Response { - $application = $this->applications->find_by_uuid((string) $request['uuid']); + $application = $this->application_for_request($request); + if (!$application) { + return $this->no_store(new \WP_REST_Response([ + 'success' => false, + 'code' => 'aksrt_identity_missing', + 'message' => __('认证申请不存在', 'aksrt'), + ], 404)); + } return $this->respond($this->service->move_to_manual($application, $_FILES['evidence'] ?? null)); } + /** @return array|null */ + private function application_for_request(\WP_REST_Request $request): ?array { + $uuid = (string) $request['uuid']; + if ($uuid !== $this->request_uuid) { + $this->request_uuid = $uuid; + $this->request_application = $this->applications->find_by_uuid($uuid); + } + return is_array($this->request_application) ? $this->request_application : null; + } + private function respond($result, int $success_status = 200): \WP_REST_Response { if (is_wp_error($result)) { $status = in_array($result->get_error_code(), ['aksrt_identity_active', 'aksrt_identity_verified', 'aksrt_identity_conflict'], true) ? 409 : 422; - return new \WP_REST_Response([ + return $this->no_store(new \WP_REST_Response([ 'success' => false, 'code' => $result->get_error_code(), 'message' => $result->get_error_message(), - ], $status); + ], $status)); } - return new \WP_REST_Response(['success' => true, 'data' => $result], $success_status); + return $this->no_store(new \WP_REST_Response(['success' => true, 'data' => $result], $success_status)); + } + + private function no_store(\WP_REST_Response $response): \WP_REST_Response { + $response->header('Cache-Control', 'no-store, private'); + $response->header('Pragma', 'no-cache'); + return $response; } } diff --git a/src/Payment/Gateway.php b/src/Payment/Gateway.php index d2e28e9e..497d158f 100644 --- a/src/Payment/Gateway.php +++ b/src/Payment/Gateway.php @@ -243,6 +243,18 @@ class Gateway { return sanitize_text_field((string) ($order['order_number'] ?? $provider_ref)); } + /** + * Whether a pending order can be reconciled against an external provider. + * + * This is the single gate used by the order view and the actual reconcile + * operation, so ordinary detail reads never trigger provider I/O. + * + * @param array|null $order + */ + public function needs_reconciliation(?array $order): bool { + return null !== $this->reconciliation_gateway($order); + } + /** * Reconcile a pending external order against the configured provider. * @@ -254,18 +266,11 @@ class Gateway { } $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)) { + $gateway = $this->reconciliation_gateway($order); + if (!$gateway) { 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)) { @@ -297,6 +302,22 @@ class Gateway { return $repo->get($order_number) ?: $order; } + /** + * @param array|null $order + */ + private function reconciliation_gateway(?array $order): ?GatewayInterface { + if (!is_array($order) || !in_array((string) ($order['status'] ?? ''), [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) { + return null; + } + + $gateway_id = sanitize_key((string) ($order['payment_method'] ?? '')); + if ($gateway_id === '' || in_array($gateway_id, ['balance', 'points', 'manual_review'], true)) { + return null; + } + $gateway = $this->get_gateway($gateway_id); + return $gateway && $gateway->is_configured() ? $gateway : null; + } + /** * 幂等、带金额校验的订单完成。仅由验签通过的回调调用。 * diff --git a/src/Payment/OrderCenterRest.php b/src/Payment/OrderCenterRest.php index b3e83b15..ffa6702a 100644 --- a/src/Payment/OrderCenterRest.php +++ b/src/Payment/OrderCenterRest.php @@ -61,6 +61,12 @@ class OrderCenterRest { 'permission_callback' => [$this, 'require_logged_in'], 'args' => $route_args, ]); + register_rest_route($namespace, '/orders/' . $order_number . '/refresh', [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [$this, 'refresh'], + 'permission_callback' => [$this, 'require_logged_in'], + 'args' => $route_args, + ]); register_rest_route($namespace, '/orders/' . $order_number, [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'get_order'], @@ -108,6 +114,14 @@ class OrderCenterRest { : $this->error((string) ($result['message'] ?? __('关闭订单失败', 'aksrt')), 400); } + public function refresh(\WP_REST_Request $request): \WP_REST_Response { + $order = UserOrders::get_instance()->refresh_order( + get_current_user_id(), + sanitize_text_field((string) $request->get_param('order_number')) + ); + return $order ? $this->success(['data' => $order]) : $this->error(__('订单不存在', 'aksrt'), 404); + } + public function get_order(\WP_REST_Request $request): \WP_REST_Response { $order = UserOrders::get_instance()->get_order_detail_view( get_current_user_id(), diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php index 709faf6a..bf7ef938 100644 --- a/src/Payment/OrderRepositoryCore.php +++ b/src/Payment/OrderRepositoryCore.php @@ -100,46 +100,14 @@ trait OrderRepositoryCore { public function get_user_orders(int $user_id, array $args = []): array { global $wpdb; - $where = []; - $params = []; - - if ($user_id > 0) { - $where[] = 'user_id = %d'; - $params[] = $user_id; - } - - $statuses = array_values(array_filter(array_map('sanitize_key', (array) ($args['status_in'] ?? [])))); - if ($statuses !== []) { - $where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')'; - array_push($params, ...$statuses); - } else { - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '') { - $where[] = 'status = %s'; - $params[] = $status; - } - } - - $product_type = sanitize_key((string) ($args['product_type'] ?? '')); - if ($product_type !== '') { - $where[] = 'product_type = %s'; - $params[] = $product_type; - } - - $order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? ''); - if ($order_number_prefix !== '') { - $where[] = 'order_number LIKE %s'; - $params[] = $wpdb->esc_like($order_number_prefix) . '%'; - } - - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $parts = $this->get_user_order_query_parts($user_id, $args); + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); $offset = max(0, (int) ($args['offset'] ?? 0)); - - $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; - $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params = $parts['params']; $params[] = $limit; $params[] = $offset; + $sql = "SELECT * FROM {$this->table()} {$parts['where']} ORDER BY created_at DESC LIMIT %d OFFSET %d"; $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); return array_map([$this, 'normalize_row'], (array) $rows); @@ -175,37 +143,60 @@ trait OrderRepositoryCore { return 0; } - $where = ['user_id = %d']; - $params = [$user_id]; + $parts = $this->get_user_order_query_parts($user_id, $args); + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table()} {$parts['where']}", + $parts['params'] + )); + } - $statuses = array_values(array_filter(array_map('sanitize_key', (array) ($args['status_in'] ?? [])))); - if ($statuses !== []) { - $where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')'; - array_push($params, ...$statuses); - } else { - $status = sanitize_key((string) ($args['status'] ?? '')); - if ($status !== '') { - $where[] = 'status = %s'; - $params[] = $status; - } + /** + * Get the filtered list total and the user's order-center counters in one query. + * + * The filtered total mirrors the list query while the counters intentionally keep + * their existing user-wide semantics, so changing a filter does not change the + * summary cards. The subquery avoids four independent COUNT statements. + * + * @return array{total:int,stats:array{total:int,pending:int,completed:int}} + */ + public function get_user_order_center_summary(int $user_id, array $args = []): array { + global $wpdb; + + if ($user_id <= 0) { + return [ + 'total' => 0, + 'stats' => ['total' => 0, 'pending' => 0, 'completed' => 0], + ]; } - $product_type = sanitize_key((string) ($args['product_type'] ?? '')); - if ($product_type !== '') { - $where[] = 'product_type = %s'; - $params[] = $product_type; - } + $parts = $this->get_user_order_query_parts($user_id, $args); + $pending_statuses = [Order::STATUS_CREATED, Order::STATUS_PENDING]; + $paid_status = Order::STATUS_PAID; + $known_statuses = OrderCenterQuery::from_array([])->database_statuses(); + $known_placeholders = implode(', ', array_fill(0, count($known_statuses), '%s')); + $sql = "SELECT + (SELECT COUNT(*) FROM {$this->table()} {$parts['where']}) AS filtered_total, + COUNT(*) AS known_total, + SUM(CASE WHEN status IN (%s, %s) THEN 1 ELSE 0 END) AS pending, + SUM(CASE WHEN status = %s THEN 1 ELSE 0 END) AS completed + FROM {$this->table()} + WHERE user_id = %d AND status IN ({$known_placeholders})"; + $params = array_merge( + $parts['params'], + $pending_statuses, + [$paid_status, $user_id], + $known_statuses + ); + $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); - $order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? ''); - if ($order_number_prefix !== '') { - $where[] = 'order_number LIKE %s'; - $params[] = $wpdb->esc_like($order_number_prefix) . '%'; - } - - $where_sql = 'WHERE ' . implode(' AND ', $where); - $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; - - return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); + return [ + 'total' => (int) ($row['filtered_total'] ?? 0), + 'stats' => [ + 'total' => (int) ($row['known_total'] ?? 0), + 'pending' => (int) ($row['pending'] ?? 0), + 'completed' => (int) ($row['completed'] ?? 0), + ], + ]; } @@ -515,6 +506,51 @@ trait OrderRepositoryCore { * Internal helpers * ----------------------------------------------------------------*/ + /** + * Build the shared list/count predicate without changing caller semantics. + * + * @return array{where:string,params:array} + */ + private function get_user_order_query_parts(int $user_id, array $args = []): array { + global $wpdb; + + $where = []; + $params = []; + if ($user_id > 0) { + $where[] = 'user_id = %d'; + $params[] = $user_id; + } + + $statuses = array_values(array_filter(array_map('sanitize_key', (array) ($args['status_in'] ?? [])))); + if ($statuses !== []) { + $where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')'; + array_push($params, ...$statuses); + } else { + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + } + + $product_type = sanitize_key((string) ($args['product_type'] ?? '')); + if ($product_type !== '') { + $where[] = 'product_type = %s'; + $params[] = $product_type; + } + + $order_number_prefix = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($args['order_number_prefix'] ?? '')) ?? ''); + if ($order_number_prefix !== '') { + $where[] = 'order_number LIKE %s'; + $params[] = $wpdb->esc_like($order_number_prefix) . '%'; + } + + return [ + 'where' => $where ? 'WHERE ' . implode(' AND ', $where) : '', + 'params' => $params, + ]; + } + private function normalize_row(array $row): array { $row['id'] = (int) $row['id']; $row['user_id'] = (int) $row['user_id']; diff --git a/src/Payment/PaymentOrchestrator.php b/src/Payment/PaymentOrchestrator.php index f5eb9bb4..9f0a4f8f 100644 --- a/src/Payment/PaymentOrchestrator.php +++ b/src/Payment/PaymentOrchestrator.php @@ -23,7 +23,7 @@ class PaymentOrchestrator { /** * @param array $order Order row plus subject/amount overrides. - * @param array $opts paid_note: string + * @param array $opts paid_note: string, order_context: array * * @return array{ * success:bool, @@ -58,7 +58,16 @@ class PaymentOrchestrator { // A pending order remains bound to the gateway selected at creation. // Switching it would make a late callback from the previous gateway look // invalid after that provider may already have collected the money. + // The caller may pass request-local create context so metadata such as + // subject is not reconstructed or read a second time. The persisted row + // remains authoritative for status, ownership, and payment method. $stored = $repo->get($order_number); + if (is_array($opts['order_context'] ?? null)) { + $context = $opts['order_context']; + if ((string) ($context['order_number'] ?? '') === $order_number && is_array($stored)) { + $stored = array_merge($context, $stored); + } + } $stored_status = sanitize_key((string) ($stored['status'] ?? '')); if (!in_array($stored_status, [Order::STATUS_CREATED, Order::STATUS_PENDING], true)) { return self::fail('aksrt_order_not_payable', __('当前订单状态不可支付。', 'aksrt')); diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php index 19e75a46..d80517fc 100644 --- a/src/Payment/UserOrders.php +++ b/src/Payment/UserOrders.php @@ -15,6 +15,18 @@ class UserOrders { private static $instance = null; + /** @var array */ + private array $post_title_cache = []; + + /** @var array */ + private array $post_image_cache = []; + + /** @var array */ + private array $post_permalink_cache = []; + + /** @var array */ + private array $type_labels_cache = []; + public static function get_instance(): self { if (null === self::$instance) { self::$instance = new self(); @@ -36,7 +48,8 @@ class UserOrders { $repo_args = $query->repository_args(); $repo = OrderRepository::get_instance(); $orders = $repo->get_user_orders($user_id, $repo_args); - $total = $repo->count_user_orders($user_id, $repo_args); + $summary = $repo->get_user_order_center_summary($user_id, $repo_args); + $total = (int) ($summary['total'] ?? 0); $total_pages = max(1, (int) ceil($total / $query->per_page())); $page = min($query->page(), $total_pages); @@ -46,18 +59,19 @@ class UserOrders { } return [ - 'items' => array_map([$this, 'decorate_order_view'], $orders), + 'items' => $this->decorate_order_views($orders), 'total' => $total, 'total_pages' => $total_pages, 'page' => $page, 'query' => array_merge($query->url_values(), ['order_page' => $page]), - 'stats' => $this->get_user_order_stats($user_id), + 'stats' => is_array($summary['stats'] ?? null) ? $summary['stats'] : ['total' => 0, 'pending' => 0, 'completed' => 0], + 'invalidation_tags' => ['orders'], 'product_types' => $this->product_type_filter_options(), ]; } /** @return array|null */ - public function get_order_detail_view(int $user_id, string $order_number): ?array { + public function get_order_detail_view(int $user_id, string $order_number, bool $reconcile = false): ?array { if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { return null; } @@ -67,11 +81,29 @@ class UserOrders { return null; } + $reconciliation = [ + 'state' => 'not_needed', + 'required' => false, + ]; if (class_exists(Gateway::class)) { - $order = Gateway::get_instance()->reconcile_order($order_number, $order) ?: $order; + $gateway = Gateway::get_instance(); + $required = $gateway->needs_reconciliation($order); + if ($required && $reconcile) { + $order = $gateway->reconcile_order($order_number, $order) ?: $order; + $required = $gateway->needs_reconciliation($order); + $reconciliation['state'] = $required ? 'refresh_available' : 'checked'; + } elseif ($required) { + $reconciliation['state'] = 'refresh_available'; + } + $reconciliation['required'] = $required; } - return $this->decorate_order_view($order); + return $this->decorate_order_view($order, true, $reconciliation); + } + + /** @return array|null */ + public function refresh_order(int $user_id, string $order_number): ?array { + return $this->get_order_detail_view($user_id, $order_number, true); } /** @return array */ @@ -80,30 +112,67 @@ class UserOrders { return ['total' => 0, 'pending' => 0, 'completed' => 0]; } - $repo = OrderRepository::get_instance(); - return [ - 'total' => $repo->count_user_orders($user_id, [ - 'status_in' => OrderCenterQuery::from_array([])->database_statuses(), - ]), - 'pending' => $repo->count_user_orders($user_id, [ - 'status_in' => [Order::STATUS_CREATED, Order::STATUS_PENDING], - ]), - 'completed' => $repo->count_user_orders($user_id, [ - 'status_in' => [Order::STATUS_PAID], - ]), - ]; + $summary = OrderRepository::get_instance()->get_user_order_center_summary($user_id); + return is_array($summary['stats'] ?? null) + ? $summary['stats'] + : ['total' => 0, 'pending' => 0, 'completed' => 0]; } /** @return array */ public function product_type_filter_options(): array { - return array_merge(['' => __('全部类型', 'aksrt')], DomainModel::order_product_type_labels()); + if ($this->type_labels_cache === []) { + $this->type_labels_cache = DomainModel::order_product_type_labels(); + } + return array_merge(['' => __('全部类型', 'aksrt')], $this->type_labels_cache); + } + + /** + * Prime post and post-meta caches for all resource rows before decoration. + * + * @param array> $orders + */ + private function prime_resource_caches(array $orders): void { + $post_ids = []; + foreach ($orders as $order) { + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + foreach ([(int) ($order['product_id'] ?? 0), (int) ($meta['post_id'] ?? 0)] as $post_id) { + if ($post_id > 0) { + $post_ids[$post_id] = $post_id; + } + } + } + if ($post_ids === []) { + return; + } + + _prime_post_caches(array_values($post_ids), false, true); + $thumbnail_ids = []; + foreach ($post_ids as $post_id) { + $thumbnail_id = (int) get_post_thumbnail_id($post_id); + if ($thumbnail_id > 0) { + $thumbnail_ids[$thumbnail_id] = $thumbnail_id; + } + } + if ($thumbnail_ids !== []) { + _prime_post_caches(array_values($thumbnail_ids), false, true); + } + } + + /** + * @param array> $orders + * @return array> + */ + private function decorate_order_views(array $orders): array { + $this->prime_resource_caches($orders); + return array_map(fn(array $order): array => $this->decorate_order_view($order, false), $orders); } /** * @param array $order + * @param array|null $reconciliation * @return array */ - public function decorate_order_view(array $order): array { + public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array { $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; $product_type = sanitize_key((string) ($order['product_type'] ?? '')); $product_id = (int) ($order['product_id'] ?? 0); @@ -112,8 +181,10 @@ class UserOrders { $is_points = $currency === 'points' || sanitize_key((string) ($order['payment_method'] ?? '')) === 'points' || sanitize_key((string) ($meta['pay_modo'] ?? '')) === 'points'; - $type_labels = DomainModel::order_product_type_labels(); - $type_label = $type_labels[$product_type] ?? __('订单', 'aksrt'); + if ($this->type_labels_cache === []) { + $this->type_labels_cache = DomainModel::order_product_type_labels(); + } + $type_label = $this->type_labels_cache[$product_type] ?? __('订单', 'aksrt'); $title = $this->resolve_title($meta, $product_type, $product_id, $type_label); $image = $this->resolve_image($meta, $product_id); $permalink = $this->resolve_entitlement_url($product_type, $product_id, $meta); @@ -122,10 +193,12 @@ class UserOrders { $can_pay = $group === OrderCenterQuery::STATUS_PENDING && $payment_method !== 'manual_review'; $can_cancel = $group === OrderCenterQuery::STATUS_PENDING; $can_open = $group === OrderCenterQuery::STATUS_COMPLETED && $permalink !== ''; + $order_number = (string) ($order['order_number'] ?? ''); + $updated_at = $this->resolve_updated_at($order); - return [ + $view = [ 'id' => (int) ($order['id'] ?? 0), - 'order_number' => (string) ($order['order_number'] ?? ''), + 'order_number' => $order_number, 'amount' => (float) ($order['amount'] ?? 0), 'currency' => $currency, 'currency_symbol' => $is_points @@ -142,20 +215,30 @@ class UserOrders { 'image' => $image, 'permalink' => $permalink, 'created_at' => (string) ($order['created_at'] ?? ''), - 'paid_at' => (string) ($order['paid_at'] ?? ''), - 'expires_at' => (string) ($order['expires_at'] ?? ''), 'payment_method' => [ 'value' => $payment_method, 'label' => $this->payment_method_label($payment_method), ], - 'actions' => [ + 'allowed_actions' => [ 'can_pay' => $can_pay, 'can_cancel' => $can_cancel, 'can_open' => $can_open, ], 'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status), 'entitlement_label' => $this->entitlement_label($product_type), + 'updated_at' => $updated_at, + 'status_version' => $this->resolve_status_version($order, $updated_at), + 'invalidation_tags' => array_values(array_filter([ + 'orders', + $order_number !== '' ? 'order:' . sanitize_key($order_number) : '', + ])), ]; + if ($detail) { + $view['paid_at'] = (string) ($order['paid_at'] ?? ''); + $view['expires_at'] = (string) ($order['expires_at'] ?? ''); + $view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false]; + } + return $view; } /** @return array */ @@ -167,10 +250,39 @@ class UserOrders { 'page' => 1, 'query' => $query->url_values(), 'stats' => ['total' => 0, 'pending' => 0, 'completed' => 0], + 'invalidation_tags' => ['orders'], 'product_types' => $this->product_type_filter_options(), ]; } + /** @param array $order */ + private function resolve_updated_at(array $order): string { + $latest = ''; + $latest_timestamp = 0; + foreach (['created_at', 'paid_at', 'delivered_at', 'completed_at', 'cancelled_at', 'refunded_at'] as $field) { + $value = trim((string) ($order[$field] ?? '')); + if ($value === '') { + continue; + } + $timestamp = strtotime($value); + if ($timestamp !== false && $timestamp >= $latest_timestamp) { + $latest_timestamp = $timestamp; + $latest = $value; + } + } + return $latest; + } + + /** @param array $order */ + private function resolve_status_version(array $order, string $updated_at): string { + return substr(hash('sha256', implode('|', [ + (int) ($order['id'] ?? 0), + (string) ($order['order_number'] ?? ''), + sanitize_key((string) ($order['status'] ?? '')), + $updated_at, + ])), 0, 16); + } + private function status_group(string $status): string { return match ($status) { Order::STATUS_CREATED, Order::STATUS_PENDING => OrderCenterQuery::STATUS_PENDING, @@ -205,9 +317,13 @@ class UserOrders { return __('购买积分', 'aksrt'); } if ($product_id > 0) { - $title = get_the_title($product_id); - if (is_string($title) && $title !== '') { - return $title; + $cache_key = (string) $product_id; + if (!array_key_exists($cache_key, $this->post_title_cache)) { + $title = get_the_title($product_id); + $this->post_title_cache[$cache_key] = is_string($title) ? $title : ''; + } + if ($this->post_title_cache[$cache_key] !== '') { + return $this->post_title_cache[$cache_key]; } } return $fallback; @@ -218,22 +334,37 @@ class UserOrders { if (!empty($meta['thumb']) && is_string($meta['thumb'])) { return esc_url_raw($meta['thumb']); } - if ($product_id > 0 && has_post_thumbnail($product_id)) { - return (string) get_the_post_thumbnail_url($product_id, 'thumbnail'); + if ($product_id > 0) { + $cache_key = (string) $product_id; + if (!array_key_exists($cache_key, $this->post_image_cache)) { + $image = has_post_thumbnail($product_id) + ? get_the_post_thumbnail_url($product_id, 'thumbnail') + : ''; + $this->post_image_cache[$cache_key] = is_string($image) ? $image : ''; + } + return $this->post_image_cache[$cache_key]; } return ''; } /** @param array $meta */ private function resolve_entitlement_url(string $type, int $product_id, array $meta): string { - if ($type === DomainModel::ORDER_PRODUCT_VIP) { - return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : ''; - } - if (in_array($type, [DomainModel::ORDER_PRODUCT_RECHARGE, DomainModel::ORDER_PRODUCT_POINTS_BUY], true)) { - return function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : ''; - } $post_id = $product_id ?: (int) ($meta['post_id'] ?? 0); - return $post_id > 0 ? (string) get_permalink($post_id) : ''; + $cache_key = $type . ':' . $post_id; + if (array_key_exists($cache_key, $this->post_permalink_cache)) { + return $this->post_permalink_cache[$cache_key]; + } + + if ($type === DomainModel::ORDER_PRODUCT_VIP) { + $url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('vip') : ''; + } elseif (in_array($type, [DomainModel::ORDER_PRODUCT_RECHARGE, DomainModel::ORDER_PRODUCT_POINTS_BUY], true)) { + $url = function_exists('aksrt_get_user_center_tab_url') ? aksrt_get_user_center_tab_url('credits') : ''; + } else { + $url = $post_id > 0 ? get_permalink($post_id) : ''; + } + + $this->post_permalink_cache[$cache_key] = is_string($url) ? $url : ''; + return $this->post_permalink_cache[$cache_key]; } private function entitlement_label(string $type): string { diff --git a/src/User/AuthApply.php b/src/User/AuthApply.php index 782a5c70..5ee7bfad 100644 --- a/src/User/AuthApply.php +++ b/src/User/AuthApply.php @@ -11,6 +11,7 @@ use AKSRT\Identity\ApplicationRepository; use AKSRT\Identity\EvidenceService; use AKSRT\Identity\EventRepository; use AKSRT\Identity\TransitionService; +use AKSRT\Identity\SettingsRuntime; defined('ABSPATH') || exit; @@ -44,49 +45,162 @@ class AuthApply { return is_array($types[$type] ?? null) ? $types[$type] : []; } + /** + * Read the current user's identity state from one repository context. + * + * The repository memoizes the latest/active/verified records for the + * request, while this typed facade keeps all presentation callers on the + * same application selection and next-action contract. + * + * @return array + */ + public static function snapshot(int $user_id): array { + $user_id = absint($user_id); + $settings = self::settings(); + $records = (new ApplicationRepository())->snapshot_for_user($user_id); + $verified = is_array($records['verified'] ?? null) ? $records['verified'] : null; + $latest = is_array($records['latest'] ?? null) ? $records['latest'] : null; + $active = is_array($records['active'] ?? null) ? $records['active'] : null; + $status_record = $verified ?: $latest; + $status = $status_record + ? TransitionService::public_status((string) ($status_record['status'] ?? '')) + : ''; + $current = $active ?: $latest; + $stage = $active + ? (string) ($active['status'] ?? '') + : (string) ($latest['status'] ?? ''); + $next_action = self::next_action($stage); + $runtime_settings = class_exists('\\AKSRT\\Settings\\Settings') ? SettingsRuntime::get() : []; + $poll_interval = max(5, (int) ($runtime_settings['poll_interval_seconds'] ?? 5)); + $can_apply = !empty($settings['enabled']) && !empty($settings['allow_apply']) && $status !== 'verified'; + + return [ + 'user_id' => $user_id, + 'verified' => $verified !== null, + 'status' => $status, + 'stage' => $stage, + 'next_action' => $next_action, + 'poll_interval' => $poll_interval, + 'can_apply' => $can_apply, + 'latest' => $latest, + 'active' => $active, + 'current' => $current, + 'info' => $verified ? self::info_from_application($verified) : [], + 'apply' => self::payload_from_application($latest), + ]; + } + + /** + * Safe browser-facing snapshot; raw application rows never cross this + * boundary. + * + * @return array + */ + public static function snapshot_view(int $user_id): array { + $snapshot = self::snapshot($user_id); + $latest = is_array($snapshot['latest'] ?? null) ? $snapshot['latest'] : null; + $active = is_array($snapshot['active'] ?? null) ? $snapshot['active'] : null; + $current = is_array($snapshot['current'] ?? null) ? $snapshot['current'] : null; + + return [ + 'user_id' => (int) ($snapshot['user_id'] ?? 0), + 'verified' => !empty($snapshot['verified']), + 'status' => (string) ($snapshot['status'] ?? ''), + 'stage' => (string) ($snapshot['stage'] ?? ''), + 'next_action' => (string) ($snapshot['next_action'] ?? 'wait'), + 'poll_interval' => (int) ($snapshot['poll_interval'] ?? 5), + 'can_apply' => !empty($snapshot['can_apply']), + 'info' => is_array($snapshot['info'] ?? null) ? $snapshot['info'] : [], + 'current' => $current ? self::application_view_model($current, (int) ($snapshot['poll_interval'] ?? 5)) : null, + 'active' => $active ? self::application_view_model($active, (int) ($snapshot['poll_interval'] ?? 5)) : null, + 'latest' => $latest ? self::application_view_model($latest, (int) ($snapshot['poll_interval'] ?? 5)) : null, + ]; + } + + private static function next_action(string $stage): string { + if (in_array($stage, ['provider_pending', 'provider_processing'], true)) { + return 'poll'; + } + if (in_array($stage, ['provider_failed', 'provider_unsupported', 'provider_expired'], true)) { + return 'manual_or_retry'; + } + if ($stage === 'verified') { + return 'complete'; + } + return 'wait'; + } + + /** @return array */ + private static function info_from_application(array $application): array { + return [ + 'name' => (string) ($application['public_name'] ?? ''), + 'desc' => (string) ($application['public_desc'] ?? ''), + 'type' => (string) ($application['type'] ?? ''), + 'time' => (string) (($application['completed_at'] ?? '') ?: ($application['updated_at'] ?? '')), + ]; + } + + /** @return array */ + private static function payload_from_application(?array $application): array { + if (!$application) { + return []; + } + return [ + 'application_uuid' => (string) ($application['application_uuid'] ?? ''), + 'user_id' => (int) ($application['user_id'] ?? 0), + 'name' => (string) ($application['public_name'] ?? ''), + 'real_name' => (string) ($application['person_name_masked'] ?? ''), + 'desc' => (string) ($application['public_desc'] ?? ''), + 'msg' => (string) ($application['manual_reason'] ?? ''), + 'type' => (string) ($application['type'] ?? ''), + 'time' => (string) ($application['created_at'] ?? ''), + 'fee' => (float) ($application['fee_amount'] ?? 0), + '_application' => $application, + ]; + } + + /** @return array */ + private static function application_view_model(array $application, int $poll_interval): array { + $stage = (string) ($application['status'] ?? ''); + return [ + 'uuid' => (string) ($application['application_uuid'] ?? ''), + 'type' => (string) ($application['type'] ?? ''), + 'status' => TransitionService::public_status($stage), + 'stage' => $stage, + 'next_action' => self::next_action($stage), + 'public_name' => (string) ($application['public_name'] ?? ''), + 'public_desc' => (string) ($application['public_desc'] ?? ''), + 'result_code' => (string) ($application['provider_result_code'] ?? ''), + 'message' => (string) ($application['provider_result_summary'] ?? ''), + 'redirect_url' => esc_url_raw((string) ($application['redirect_url'] ?? '')), + 'expires_at' => (string) ($application['expires_at'] ?? ''), + 'created_at' => (string) ($application['created_at'] ?? ''), + 'poll_interval' => $poll_interval, + ]; + } + public static function application_count(int $user_id, string $type): int { return (new ApplicationRepository())->count_user_type($user_id, sanitize_key($type)); } public static function get_status(int $user_id): string { - $repo = new ApplicationRepository(); - $application = $repo->verified_for_user($user_id) ?: $repo->latest_for_user($user_id); - return $application ? TransitionService::public_status((string) $application['status']) : ''; + return (string) (self::snapshot($user_id)['status'] ?? ''); } public static function is_verified(int $user_id): bool { - return (new ApplicationRepository())->verified_for_user($user_id) !== null; + return !empty(self::snapshot($user_id)['verified']); } /** @return array */ public static function get_auth_info(int $user_id): array { - $application = (new ApplicationRepository())->verified_for_user($user_id); - return $application ? [ - 'name' => (string) $application['public_name'], - 'desc' => (string) $application['public_desc'], - 'type' => (string) $application['type'], - 'time' => (string) ($application['completed_at'] ?: $application['updated_at']), - ] : []; + $info = self::snapshot($user_id)['info'] ?? []; + return is_array($info) ? $info : []; } /** @return array */ public static function get_apply_payload(int $user_id): array { - $application = (new ApplicationRepository())->latest_for_user($user_id); - if (!$application) { - return []; - } - return [ - 'application_uuid' => (string) $application['application_uuid'], - 'user_id' => (int) $application['user_id'], - 'name' => (string) $application['public_name'], - 'real_name' => (string) $application['person_name_masked'], - 'desc' => (string) $application['public_desc'], - 'msg' => (string) ($application['manual_reason'] ?? ''), - 'type' => (string) $application['type'], - 'time' => (string) $application['created_at'], - 'fee' => (float) $application['fee_amount'], - '_application' => $application, - ]; + $payload = self::snapshot($user_id)['apply'] ?? []; + return is_array($payload) ? $payload : []; } /** @return array */ diff --git a/src/User/Badges.php b/src/User/Badges.php index ff69551e..7a46df70 100644 --- a/src/User/Badges.php +++ b/src/User/Badges.php @@ -17,6 +17,11 @@ class Badges { public const META_WEAR = 'aksrt_wear_badge'; private static $instance = null; + private static ?array $dictionary_cache = null; + /** @var array,owned:list,wear:string}> */ + private static array $user_context_cache = []; + /** @var array> */ + private static array $list_cache = []; private function __construct() {} @@ -32,6 +37,25 @@ class Badges { add_action('aksrt_badge_granted', [$this, 'on_badge_granted_notify'], 10, 3); } + private static function clear_context(int $user_id = 0): void { + if ($user_id > 0) { + unset(self::$user_context_cache[$user_id]); + foreach (array_keys(self::$list_cache) as $key) { + if (strpos($key, $user_id . ':') === 0) { + unset(self::$list_cache[$key]); + } + } + do_action('aksrt_badges_changed', $user_id); + return; + } + self::$user_context_cache = []; + self::$list_cache = []; + } + + public static function invalidate_cache(int $user_id = 0): void { + self::clear_context($user_id); + } + /** * Notify user when a badge is granted (zibll user_add_medal message). */ @@ -66,220 +90,8 @@ class Badges { } /** - * REST response for the badge list modal. + * Register the canonical JSON badge routes. */ - public function rest_modal_list(\WP_REST_Request $request) { - $user_id = absint($request->get_param('id')); - if ($user_id <= 0 || !get_userdata($user_id)) { - return new \WP_Error('aksrt_badge_user_missing', __('用户不存在', 'aksrt'), ['status' => 404]); - } - return new \WP_REST_Response(['html' => self::render_info_modal_html($user_id)], 200); - } - - /** - * REST response for a single badge modal. - */ - public function rest_modal_single(\WP_REST_Request $request) { - $slug = sanitize_key((string) $request->get_param('slug')); - $user_id = absint($request->get_param('id')); - if ($slug === '') { - return new \WP_Error('aksrt_badge_missing', __('徽章不存在', 'aksrt'), ['status' => 404]); - } - return new \WP_REST_Response(['html' => self::render_single_modal_html($slug, $user_id)], 200); - } - - /** - * Badge list modal HTML. - * - * Head (avatar + owner + worn medal) and category-grouped medal card grid. - */ - public static function render_info_modal_html(int $user_id): string { - $user = get_userdata($user_id); - if (!$user) { - return '

    ' . esc_html__('用户不存在', 'aksrt') . '

    '; - } - $current = get_current_user_id(); - $is_self = $current > 0 && $current === $user_id; - $display = $is_self ? __('我', 'aksrt') : $user->display_name; - $avatar = function_exists('aksrt_get_avatar_url') - ? aksrt_get_avatar_url($user_id, ['size' => 80]) - : get_avatar_url($user_id, ['size' => 80]); - $data = self::list_for_user($user_id, $is_self || self::can_manually_set($current)); - $categories = is_array($data['categories'] ?? null) ? $data['categories'] : []; - $wear = (string) ($data['wear'] ?? ''); - $map = self::get_dictionary_map(); - - // Head slot: worn medal icon with tooltip, or an empty-state tag (zibll). - $wear_slot = ''; - if ($wear !== '' && isset($map[$wear])) { - $wear_label = (string) ($map[$wear]['label'] ?? $wear); - $wear_icon = (string) ($map[$wear]['icon_url'] ?? ''); - if ($wear_icon !== '') { - $wear_slot = '' . esc_attr($wear_label) . ''; - } - } elseif ((int) ($data['count'] ?? 0) === 0) { - $wear_slot = '' . esc_html__('暂无徽章', 'aksrt') . ''; - } - - $html = '
    '; - $html .= ''; - $html .= '
    '; - $html .= ''; - $html .= '
    ' . esc_html(sprintf(__('%s的徽章', 'aksrt'), $display)) . '' . $wear_slot . '
    '; - $html .= '
    '; - - $html .= '
    '; - if (!$categories) { - $html .= '
    ' - . '
    ' - . '
    ' . esc_html__('暂无徽章', 'aksrt') . '
    ' - . '
    ' . esc_html__('积极参与社区互动即可获得徽章', 'aksrt') . '
    ' - . '
    '; - } else { - foreach ($categories as $cat) { - $html .= '
    '; - $html .= '
    ' . esc_html((string) ($cat['cat_name'] ?? '')) . '
    '; - $html .= '
    '; - foreach ((array) ($cat['items'] ?? []) as $item) { - $html .= self::render_card_link($user_id, is_array($item) ? $item : []); - } - $html .= '
    '; - } - } - $html .= '
    '; - - // Footer: quick jump to my own badges while browsing someone else (zibll). - if (!$is_self && $current > 0) { - $my_url = rest_url('aksrt/v1/users/' . $current . '/badges/modal'); - $html .= ''; - } - - $html .= '
    '; - return $html; - } - - /** - * Single medal card inside the list grid; deep-links to the detail modal. - * - * @param array{slug?:string,label?:string,icon_url?:string,owned?:bool,wear?:bool} $item - */ - protected static function render_card_link(int $user_id, array $item): string { - $slug = (string) ($item['slug'] ?? ''); - $label = (string) ($item['label'] ?? $slug); - $icon = (string) ($item['icon_url'] ?? ''); - $owned = !empty($item['owned']); - $wear = !empty($item['wear']); - if ($slug === '') { - return ''; - } - - $icon_html = $icon !== '' - ? '' . esc_attr($label) . '' - : ''; - - $card = '
    '; - $card .= $icon_html; - $card .= '
    ' . esc_html($label) . '
    '; - if ($wear) { - $card .= '
    ' . esc_html__('佩戴中', 'aksrt') . '
    '; - } - $card .= '
    '; - - $url = rest_url('aksrt/v1/users/' . $user_id . '/badges/' . rawurlencode($slug) . '/modal'); - - return '' . $card . ''; - } - - /** - * Single badge detail modal (zibll zib_get_single_medal_info_modal parity). - * - * medal-background.svg backdrop + large icon + name + desc + obtain time, - * with wear action for the owner and a back link into the list modal. - */ - public static function render_single_modal_html(string $slug, int $user_id = 0): string { - $map = self::get_dictionary_map(); - $slug = sanitize_key($slug); - if ($slug === '' || !isset($map[$slug])) { - return '

    ' . esc_html__('徽章不存在', 'aksrt') . '

    '; - } - $item = $map[$slug]; - $current = get_current_user_id(); - $is_self = $user_id > 0 && $current === $user_id; - $owned_map = $user_id > 0 ? self::get_owned_map($user_id) : []; - $has = isset($owned_map[$slug]); - $is_wear = $has && self::get_wear($user_id) === $slug; - - $label = (string) ($item['label'] ?? $slug); - $desc = (string) ($item['desc'] ?? ''); - $icon = (string) ($item['icon_url'] ?? ''); - $bg = get_template_directory_uri() . '/assets/img/medal/medal-background.svg'; - - $icon_html = $icon !== '' - ? '' . esc_attr($label) . '' - : ''; - - // Obtain state line (self only, zibll). - $state = ''; - if ($is_self) { - $state = $has - ? '
    ' . esc_html(sprintf(__('%s 获得', 'aksrt'), (string) $owned_map[$slug])) . '
    ' - : '
    ' . esc_html__('暂未获得', 'aksrt') . '
    '; - } - - $html = '
    '; - $html .= ''; - $html .= '
    '; - $html .= '
    '; - $html .= '
    ' . $icon_html; - $html .= '
    ' . esc_html($label) . '
    '; - if ($desc !== '') { - $html .= '
    ' . esc_html($desc) . '
    '; - } - $html .= $state . '
    '; - - if ($is_self && $has && !$is_wear) { - $html .= '
    '; - } elseif ($is_self && $is_wear) { - $html .= '
    ' . esc_html__('当前佩戴此徽章', 'aksrt') . '
    '; - } - - // Back to the full badge list modal. - if ($user_id > 0) { - $back_url = rest_url('aksrt/v1/users/' . $user_id . '/badges/modal'); - $html .= ' ' . esc_html__('返回徽章列表', 'aksrt') . ''; - } - - $html .= '
    '; - return $html; - } - - /** - * Declarative modal runtime trigger for a user's badge list. - */ - public static function get_info_link(int $user_id, string $class = '', ?string $text = null): string { - if ($user_id <= 0 || !self::enabled()) { - return ''; - } - if ($text === null) { - $text = __('徽章明细', 'aksrt'); - } - if (!function_exists('aksrt_get_modal_link')) { - return ''; - } - $url = rest_url('aksrt/v1/users/' . $user_id . '/badges/modal'); - return aksrt_get_modal_link([ - 'tag' => 'a', - 'class' => trim('user-medal-info ' . $class), - 'text' => $text, - 'url' => $url, - 'data_class' => 'full-sm', - 'height' => 240, - 'mobile_bottom' => true, - ]); - } - public function register_routes(): void { register_rest_route('aksrt/v1', '/users/(?P\d+)/badges', [ 'methods' => 'GET', @@ -294,29 +106,14 @@ class Badges { ], ]); - register_rest_route('aksrt/v1', '/users/(?P\d+)/badges/modal', [ - 'methods' => 'GET', - 'callback' => [$this, 'rest_modal_list'], - 'permission_callback' => '__return_true', - 'args' => [ - 'id' => [ - 'type' => 'integer', - 'required' => true, - 'sanitize_callback' => 'absint', - ], - ], - ]); - register_rest_route('aksrt/v1', '/users/(?P\d+)/badges/(?P[a-z0-9_-]+)/modal', [ - 'methods' => 'GET', - 'callback' => [$this, 'rest_modal_single'], - 'permission_callback' => '__return_true', + register_rest_route('aksrt/v1', '/users/me/badges/wear', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_wear'], + 'permission_callback' => static function () { + return is_user_logged_in(); + }, 'args' => [ - 'id' => [ - 'type' => 'integer', - 'required' => true, - 'sanitize_callback' => 'absint', - ], 'slug' => [ 'type' => 'string', 'required' => true, @@ -325,19 +122,16 @@ class Badges { ], ]); - register_rest_route('aksrt/v1', '/users/me/badges/wear', [ - 'methods' => 'POST', - 'callback' => [$this, 'rest_wear'], - 'permission_callback' => static function () { - return is_user_logged_in(); - }, - ]); - register_rest_route('aksrt/v1', '/users/(?P\d+)/badges/grant', [ 'methods' => 'POST', 'callback' => [$this, 'rest_grant'], 'permission_callback' => [$this, 'rest_can_manually_set'], 'args' => [ + 'slug' => [ + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ], 'id' => [ 'type' => 'integer', 'required' => true, @@ -351,6 +145,11 @@ class Badges { 'callback' => [$this, 'rest_revoke'], 'permission_callback' => [$this, 'rest_can_manually_set'], 'args' => [ + 'slug' => [ + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + ], 'id' => [ 'type' => 'integer', 'required' => true, @@ -384,8 +183,11 @@ class Badges { * @return list */ public static function get_dictionary(): array { + if (self::$dictionary_cache !== null) { + return self::$dictionary_cache; + } if (!class_exists('\AKSRT\Settings\Settings')) { - return []; + return self::$dictionary_cache = []; } $settings = \AKSRT\Settings\Settings::get_user_identity_settings(); $dict = is_array($settings['badge']['dictionary'] ?? null) ? $settings['badge']['dictionary'] : []; @@ -403,13 +205,17 @@ class Badges { 'icon_url' => (string) ($item['icon_url'] ?? ''), ]; } - return $out; + return self::$dictionary_cache = $out; } /** * @return array */ public static function get_dictionary_map(): array { + static $map = null; + if ($map !== null) { + return $map; + } $map = []; foreach (self::get_dictionary() as $item) { $map[$item['slug']] = $item; @@ -417,29 +223,48 @@ class Badges { return $map; } + /** @return array{owned_map:array,owned:list,wear:string} */ + private static function user_context(int $user_id): array { + if ($user_id <= 0) { + return ['owned_map' => [], 'owned' => [], 'wear' => '']; + } + if (isset(self::$user_context_cache[$user_id])) { + return self::$user_context_cache[$user_id]; + } + + $raw = get_user_meta($user_id, self::META_OWNED, true); + $raw = is_array($raw) ? $raw : []; + $map = self::get_dictionary_map(); + $owned_map = []; + foreach ($raw as $slug => $time) { + $slug = sanitize_key((string) $slug); + if ($slug === '' || !isset($map[$slug])) { + continue; + } + $owned_map[$slug] = is_string($time) ? $time : ''; + } + $owned = array_keys($owned_map); + $wear = sanitize_key((string) get_user_meta($user_id, self::META_WEAR, true)); + if ($wear === '') { + $wear = $owned[0] ?? ''; + } elseif (!in_array($wear, $owned, true)) { + $wear = ''; + } + + return self::$user_context_cache[$user_id] = [ + 'owned_map' => $owned_map, + 'owned' => $owned, + 'wear' => $wear, + ]; + } + /** * Owned badge map: slug => grant time (`Y-m-d H:i:s`), newest first. * * @return array */ public static function get_owned_map(int $user_id): array { - if ($user_id <= 0) { - return []; - } - $raw = get_user_meta($user_id, self::META_OWNED, true); - if (!is_array($raw)) { - return []; - } - $map = self::get_dictionary_map(); - $out = []; - foreach ($raw as $slug => $time) { - $slug = sanitize_key((string) $slug); - if ($slug === '' || !isset($map[$slug])) { - continue; - } - $out[$slug] = is_string($time) ? $time : ''; - } - return $out; + return self::user_context($user_id)['owned_map']; } /** @@ -448,7 +273,7 @@ class Badges { * @return list */ public static function get_owned(int $user_id): array { - return array_keys(self::get_owned_map($user_id)); + return self::user_context($user_id)['owned']; } /** @@ -478,23 +303,11 @@ class Badges { if ($wear !== '' && !isset($clean[$wear])) { delete_user_meta($user_id, self::META_WEAR); } + self::clear_context($user_id); } public static function get_wear(int $user_id): string { - if ($user_id <= 0) { - return ''; - } - $wear = sanitize_key((string) get_user_meta($user_id, self::META_WEAR, true)); - if ($wear === '') { - // zibll alias. - $wear = sanitize_key((string) get_user_meta($user_id, 'wear_medal', true)); - } - $owned = self::get_owned($user_id); - if ($wear === '') { - // Default to newest/first owned badge when unset (zibll behavior). - return $owned[0] ?? ''; - } - return in_array($wear, $owned, true) ? $wear : ''; + return self::user_context($user_id)['wear']; } /** @@ -517,6 +330,7 @@ class Badges { return new \WP_Error('aksrt_badge_not_owned', __('尚未获得该徽章', 'aksrt'), ['status' => 403]); } update_user_meta($user_id, self::META_WEAR, $slug); + self::clear_context($user_id); return true; } @@ -600,13 +414,15 @@ class Badges { $public = self::public_display(); $viewer = get_current_user_id(); $is_self = $viewer > 0 && $viewer === $user_id; - $can_see = $include_private - || $public - || $is_self - || self::can_manually_set($viewer); + $is_manager = self::can_manually_set($viewer); + $cache_key = $user_id . ':' . ($include_private ? 'private' : 'public') . ':' . $viewer . ':' . (int) $enabled . ':' . (int) $public; + if (array_key_exists($cache_key, self::$list_cache)) { + return self::$list_cache[$cache_key]; + } + $can_see = $include_private || $public || $is_self || $is_manager; if (!$enabled || !$can_see || $user_id <= 0) { - return [ + return self::$list_cache[$cache_key] = [ 'enabled' => $enabled, 'public_display' => $public, 'is_self' => $is_self, @@ -623,9 +439,11 @@ class Badges { $medal = is_array($settings['medal'] ?? null) ? $settings['medal'] : []; $catalog = is_array($medal['categories'] ?? null) ? $medal['categories'] : []; - $owned_map = self::get_owned_map($user_id); - $wear = self::get_wear($user_id); - $show_unearned = $include_private || $is_self || self::can_manually_set($viewer); + $context = self::user_context($user_id); + $owned_map = $context['owned_map']; + $wear = $context['wear']; + $show_private = $include_private || $is_self || $is_manager; + $show_unearned = $show_private; $categories = []; foreach ($catalog as $cat) { @@ -652,7 +470,7 @@ class Badges { 'icon_url' => (string) ($row['icon'] ?? ''), 'owned' => $has, 'wear' => $has && $slug === $wear, - 'time' => $has ? (string) $owned_map[$slug] : '', + 'time' => $has && $show_private ? (string) $owned_map[$slug] : '', ]; // Owned first within each category (zibll is-has / no-has order). if ($has) { @@ -671,7 +489,7 @@ class Badges { ]; } - return [ + return self::$list_cache[$cache_key] = [ 'enabled' => true, 'public_display' => $public, 'is_self' => $is_self, @@ -690,7 +508,8 @@ class Badges { return new \WP_Error('aksrt_badge_user', __('用户不存在', 'aksrt'), ['status' => 404]); } $data = self::list_for_user($user_id); - return rest_ensure_response(['success' => true, 'data' => $data]); + $response = rest_ensure_response(['success' => true, 'data' => $data]); + return self::rest_no_store($response); } public function rest_wear(\WP_REST_Request $request) { @@ -698,16 +517,16 @@ class Badges { if (!is_array($params)) { $params = $request->get_params(); } - $slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? '')); + $slug = sanitize_key((string) ($params['slug'] ?? '')); $result = self::wear(get_current_user_id(), $slug); if (is_wp_error($result)) { return $result; } - return rest_ensure_response([ + return self::rest_no_store(rest_ensure_response([ 'success' => true, 'data' => self::list_for_user(get_current_user_id(), true), 'message' => __('已佩戴徽章', 'aksrt'), - ]); + ])); } public function rest_grant(\WP_REST_Request $request) { @@ -719,16 +538,16 @@ class Badges { if (!is_array($params)) { $params = $request->get_params(); } - $slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? '')); + $slug = sanitize_key((string) ($params['slug'] ?? '')); $result = self::grant($user_id, $slug, get_current_user_id()); if (is_wp_error($result)) { return $result; } - return rest_ensure_response([ + return self::rest_no_store(rest_ensure_response([ 'success' => true, 'data' => self::list_for_user($user_id, true), 'message' => __('已授予徽章', 'aksrt'), - ]); + ])); } public function rest_revoke(\WP_REST_Request $request) { @@ -740,16 +559,22 @@ class Badges { if (!is_array($params)) { $params = $request->get_params(); } - $slug = sanitize_key((string) ($params['slug'] ?? $params['name'] ?? '')); + $slug = sanitize_key((string) ($params['slug'] ?? '')); $result = self::revoke($user_id, $slug, get_current_user_id()); if (is_wp_error($result)) { return $result; } - return rest_ensure_response([ + return self::rest_no_store(rest_ensure_response([ 'success' => true, 'data' => self::list_for_user($user_id, true), 'message' => __('已收回徽章', 'aksrt'), - ]); + ])); + } + + private static function rest_no_store(\WP_REST_Response $response): \WP_REST_Response { + $response->header('Cache-Control', 'no-store, private'); + $response->header('Pragma', 'no-cache'); + return $response; } diff --git a/src/User/BadgesAdmin.php b/src/User/BadgesAdmin.php index 3f6e97ca..a3eb7c7e 100644 --- a/src/User/BadgesAdmin.php +++ b/src/User/BadgesAdmin.php @@ -119,5 +119,6 @@ class BadgesAdmin { delete_user_meta($user_id, Badges::META_WEAR); } } + Badges::invalidate_cache($user_id); } } diff --git a/src/User/Home.php b/src/User/Home.php index db7bec43..891e5601 100644 --- a/src/User/Home.php +++ b/src/User/Home.php @@ -164,7 +164,7 @@ class Home { if (!empty($settings['badge']['enabled']) && !empty($settings['badge']['public_display'])) { $limit = max(1, (int) ($settings['badge']['limit'] ?? 3)); if (class_exists('AKSRT\\User\\Badges')) { - $list = Badges::list_for_user($user_id, true); + $list = Badges::list_for_user($user_id); $badge_count = 0; $done = false; foreach ((array) ($list['categories'] ?? []) as $cat) { @@ -191,42 +191,6 @@ class Home { break; } } - } else { - $badge_map = []; - foreach ((array) ($settings['badge']['dictionary'] ?? []) as $item) { - if (!empty($item['slug'])) { - $badge_map[(string) $item['slug']] = $item; - } - } - $assigned = get_user_meta($user_id, 'aksrt_user_badges', true); - // Storage shape: associative map slug => grant time. - $assigned = is_array($assigned) ? array_keys($assigned) : []; - $wear = sanitize_key((string) get_user_meta($user_id, 'aksrt_wear_badge', true)); - $assigned = array_values(array_filter(array_map(static function ($slug) { - return sanitize_key((string) $slug); - }, (array) $assigned))); - if ($wear !== '' && in_array($wear, $assigned, true) && isset($badge_map[$wear])) { - $assigned = array_values(array_unique(array_merge([$wear], $assigned))); - } - $badge_count = 0; - foreach ($assigned as $slug) { - if ($slug === '' || !isset($badge_map[$slug])) { - continue; - } - $signals[] = [ - 'type' => 'badge', - 'slug' => $slug, - 'label' => (string) $badge_map[$slug]['label'], - 'icon' => 'award', - 'tone' => (string) ($badge_map[$slug]['tone'] ?? 'neutral'), - 'icon_url' => (string) ($badge_map[$slug]['icon_url'] ?? ''), - 'wear' => ($wear !== '' && $slug === $wear), - ]; - $badge_count++; - if ($badge_count >= $limit) { - break; - } - } } } diff --git a/src/Widgets/WidgetAuthorCard.php b/src/Widgets/WidgetAuthorCard.php index c8fd8a20..61db9bba 100644 --- a/src/Widgets/WidgetAuthorCard.php +++ b/src/Widgets/WidgetAuthorCard.php @@ -115,9 +115,6 @@ class WidgetAuthorCard extends \WP_Widget { $show_badge = !isset($instance['show_badge']) || !empty($instance['show_badge']); if ($show_badge && class_exists('\AKSRT\User\Badges') && \AKSRT\User\Badges::public_display()) { $wear = \AKSRT\User\Badges::get_wear($author_id); - if ($wear === '') { - $wear = sanitize_key((string) get_user_meta($author_id, 'wear_medal', true)); - } if ($wear !== '') { $map = \AKSRT\User\Badges::get_dictionary_map(); if (isset($map[$wear])) { diff --git a/templates/parts/user/author-hero.php b/templates/parts/user/author-hero.php index fcd8f509..b0df01a5 100644 --- a/templates/parts/user/author-hero.php +++ b/templates/parts/user/author-hero.php @@ -108,15 +108,13 @@ $pm_url = ($pm_enabled && $messages_url !== '') - ' . $badge_link . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } - } - ?> + +
    + +
    +