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.created_at || '') + ' ' + escapeHtml(order.status_label || '') + '
' +
- '' + image + '
' + escapeHtml(order.product_name || '订单') + ' 订单号 ' + escapeHtml(order.order_number) + '
' + formatMoney(order) + ' 实付
' +
+ : "";
+ return '' +
+ '' + escapeHtml(order.product_type_label || "") + ' ' + escapeHtml(order.created_at || "") + ' ' + 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 ' ';
+ return ' ';
}
- 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 '
' +
- cardHtml(item) +
- ' ';
- }).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 '
' + cardHtml(item) + ' ';
}).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 = '
' + escapeHtml('佩戴此徽章') + ' ';
- } else if (item.wear) {
- action = '
' + escapeHtml('当前佩戴此徽章') + ' ';
- }
-
- detailBox.innerHTML =
- '
' +
- ' ' + escapeHtml('返回列表') + ' ' +
- '
' +
- '
' +
- (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('加载失败') + ' ' +
- '' + 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 = [
-
-
-
+
+
>
@@ -98,6 +110,6 @@ $config = [
-
+
diff --git a/inc/helpers/user-center-renderers/profile-header.php b/inc/helpers/user-center-renderers/profile-header.php
index f1a3be84..db9301de 100644
--- a/inc/helpers/user-center-renderers/profile-header.php
+++ b/inc/helpers/user-center-renderers/profile-header.php
@@ -87,7 +87,7 @@ 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 .= '';
- }
-
- $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 .= '';
- }
-
- if ($tabs === '') {
- return ''
- . esc_html__('当前没有可续费或升级的套餐。', 'aksrt')
- . '
';
- }
-
- $html = '';
- $html .= '
× ';
- $html .= '
';
- $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 .= ' ';
- $html .= '';
- if ($tag !== '') {
- $html .= '
' . esc_html($tag) . '
';
- }
- if ($title !== '') {
- $html .= '
' . esc_html($title) . '
';
- }
- $html .= '
' . esc_html($price) . '
';
- if ($show !== '') {
- $html .= '
' . esc_html($show) . '
';
- }
- $html .= '
';
- if ($time !== '') {
- $html .= '
' . esc_html($time) . '
';
- }
- $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 .= ' '
- . esc_html($mname) . ' ';
- }
- $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 = ' ';
- }
- } 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 .= '
';
-
- // 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 !== ''
- ? ' '
- : ' ';
-
- $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 !== ''
- ? ' '
- : ' ';
-
- // 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 .= '
' . esc_html__('佩戴此徽章', 'aksrt') . '
';
- } 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
- }
- }
- ?>
+
+
+
+
+
+
+