diff --git a/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js b/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js index a3f9ca43..5bd22200 100644 --- a/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js +++ b/assets/admin/src/schema/sections/tools/frontend-modal-rollout.js @@ -4,10 +4,10 @@ export const frontendModalRolloutSchema = { icon: 'fas fa-bolt', description: '按领域控制前台弹窗新流程的分阶段发布。', defaults: { - frontend_modal_rollout_identity: false, - frontend_modal_rollout_badge: false, - frontend_modal_rollout_order: false, - frontend_modal_rollout_vip: false, + frontend_modal_rollout_identity: true, + frontend_modal_rollout_badge: true, + frontend_modal_rollout_order: true, + frontend_modal_rollout_vip: true, }, groups: [ { diff --git a/assets/js/core/modal-cache.js b/assets/js/core/modal-cache.js index c7fe8eaf..e5003d41 100644 --- a/assets/js/core/modal-cache.js +++ b/assets/js/core/modal-cache.js @@ -10,11 +10,15 @@ var DEFAULT_TTL = 15000; var entries = Object.create(null); var flights = Object.create(null); + var lastScope = ""; function scope() { var config = window.aksrtData || window.aksrt || {}; var userId = parseInt(config.userId || 0, 10) || 0; - return (config.isLoggedIn && userId > 0 ? "user:" + userId : "guest") + ":v1"; + var current = (config.isLoggedIn && userId > 0 ? "user:" + userId : "guest") + ":v1"; + if (lastScope && lastScope !== current) clear(); + lastScope = current; + return current; } function token(key) { diff --git a/assets/js/core/modal-runtime.js b/assets/js/core/modal-runtime.js index 331ca376..8258e1fa 100644 --- a/assets/js/core/modal-runtime.js +++ b/assets/js/core/modal-runtime.js @@ -342,11 +342,12 @@ }, }; - session.flight = cacheable + var flight = cacheable ? cache.acquire(cacheKey, request, acquireOptions) : { promise: request(), release: function () {} }; + session.flight = flight; - return session.flight.promise.then(function (result) { + return flight.promise.then(function (result) { if (!session.closed && active === session) renderResult(session, result); return session.shell; }).catch(function (error) { @@ -354,8 +355,12 @@ if (!aborted && !session.closed && active === session) renderError(session, error); return session.shell; }).then(function (shell) { - if (session.flight && typeof session.flight.release === "function") session.flight.release(); - session.flight = null; + // A refresh/replacement may have installed a newer flight on this session. + // Only the owner of this promise may release and clear the slot. + if (session.flight === flight) { + if (flight && typeof flight.release === "function") flight.release(); + session.flight = null; + } return shell; }); } diff --git a/assets/js/order-center.js b/assets/js/order-center.js index 18bfc00c..ae5169e7 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -13,6 +13,9 @@ var detailOrderNumber = ""; var detailAutoRefreshStarted = false; + var detailSession = null; + var detailReplacement = false; + var detailReplacementSession = null; function toast(message, type) { window.AksrtToast(message, type || "info"); @@ -48,7 +51,12 @@ } function activeDetail(number) { - return detailOrderNumber === number && !!window.AksrtModalRuntime.getActive(); + return detailOrderNumber === number + && !!detailSession + && !detailSession.closed + && detailSession.options + && detailSession.options.kind === "orders" + && window.AksrtModalRuntime.getActive() === detailSession.shell; } function orderTag(orderNumber) { @@ -115,8 +123,12 @@ cacheKey: "orders:detail:" + orderNumber, cacheTags: ["orders", orderTag(orderNumber)], skeleton: detailSkeleton(), + onState: function (_, session) { + if (orderNumber === detailOrderNumber) detailSession = session; + }, renderData: function (data, session) { if (orderNumber !== detailOrderNumber) return false; + detailSession = session; if (!data || typeof data !== "object") { session.content.innerHTML = '
订单不存在
'; return; @@ -137,6 +149,9 @@ function openDetail(orderNumber) { if (!orderNumber) return; + detailSession = null; + detailReplacement = false; + detailReplacementSession = null; detailOrderNumber = orderNumber; detailAutoRefreshStarted = false; window.AksrtModalRuntime.openRemote(detailOptions(orderNumber, false)); @@ -144,7 +159,16 @@ function refreshDetail(orderNumber) { if (!orderNumber || !activeDetail(orderNumber)) return; - window.AksrtModalRuntime.openRemote(detailOptions(orderNumber, true)); + detailReplacement = true; + detailReplacementSession = detailSession; + var refreshPromise = window.AksrtModalRuntime.openRemote(detailOptions(orderNumber, true)); + refreshPromise.then(function () { + detailReplacement = false; + detailReplacementSession = null; + }, function () { + detailReplacement = false; + detailReplacementSession = null; + }); } function currentViewPath() { @@ -301,6 +325,17 @@ if (cancel) { event.preventDefault(); closeOrder(cancel); } }); + document.addEventListener("aksrt:modal:close", function (event) { + var detail = event && event.detail ? event.detail : {}; + if (!detailSession || detail.session !== detailSession) return; + if (detail.reason === "replace" && detailReplacement && detail.session === detailReplacementSession) return; + detailSession = null; + detailOrderNumber = ""; + detailAutoRefreshStarted = false; + detailReplacement = false; + detailReplacementSession = null; + }); + document.addEventListener("click", function (event) { var refresh = event.target.closest("[data-aksrt-order-refresh]"); if (refresh) { diff --git a/assets/js/vip-purchase-coordinator.js b/assets/js/vip-purchase-coordinator.js index ebf59992..90810066 100644 --- a/assets/js/vip-purchase-coordinator.js +++ b/assets/js/vip-purchase-coordinator.js @@ -16,6 +16,8 @@ var MAX_DELAY = 15000; var createFlights = Object.create(null); var polls = Object.create(null); + var lastScope = ""; + var scopeGeneration = 0; var SUCCESS_STATUSES = { paid: true, delivered: true, @@ -32,14 +34,21 @@ 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"; + var current = userId > 0 ? "user:" + userId : (config.isLoggedIn ? "auth" : "guest"); + if (lastScope && lastScope !== current) clearFlights(); + lastScope = current; + return current; } function clearFlights() { + scopeGeneration += 1; + Object.keys(createFlights).forEach(function (key) { + var flight = createFlights[key]; + if (flight && typeof flight.cancel === "function") flight.cancel(); + }); createFlights = Object.create(null); - Object.keys(polls).forEach(function (orderNumber) { - var active = polls[orderNumber]; + Object.keys(polls).forEach(function (pollKey) { + var active = polls[pollKey]; if (active && active.handle && typeof active.handle.abort === "function") active.handle.abort(); }); polls = Object.create(null); @@ -84,6 +93,9 @@ function callback(options, name) { var args = Array.prototype.slice.call(arguments, 2); if (!options || typeof options[name] !== "function") return; + if (options.scope && userScope() !== options.scope) return; + if (options.generation !== undefined && scopeGeneration !== options.generation) return; + if (typeof options.isActive === "function" && !options.isActive()) return; try { options[name].apply(null, args); } catch (error) { @@ -102,25 +114,41 @@ } var key = userScope() + ":" + String(packageId) + ":" + method; - if (createFlights[key]) return createFlights[key]; + if (createFlights[key]) return createFlights[key].promise; - var promise = request("/pay/vip", { - method: "POST", - body: JSON.stringify({ - package_id: packageId, - payment_method: method, + var requestController = !options.signal && window.AbortController + ? new window.AbortController() + : null; + var flight = { + promise: request("/pay/vip", { + method: "POST", + body: JSON.stringify({ + package_id: packageId, + payment_method: method, + }), + signal: options.signal || (requestController ? requestController.signal : undefined), }), - signal: options.signal, + cancel: function () { + if (typeof options.cancel === "function") { + try { options.cancel(); } catch (error) {} + } + if (requestController) { + try { requestController.abort(); } catch (error) {} + } + }, + }; + createFlights[key] = flight; + flight.promise.then(function () { + if (createFlights[key] === flight) delete createFlights[key]; + }, function () { + if (createFlights[key] === flight) delete createFlights[key]; }); - createFlights[key] = promise; - promise.then(function () { - if (createFlights[key] === promise) delete createFlights[key]; - }); - return promise; + return flight.promise; } function exchange(options) { options = options || {}; + userScope(); var packageId = parseInt(options.packageId || options.package_id || 0, 10); if (!packageId) { return Promise.resolve({ ok: false, status: 400, json: { message: "兑换套餐无效" } }); @@ -145,7 +173,7 @@ } function poll(orderNumber, options) { - options = options || {}; + options = Object.assign({}, options || {}); orderNumber = String(orderNumber || ""); if (!orderNumber) { return { @@ -153,7 +181,12 @@ abort: function () {}, }; } - if (polls[orderNumber]) return polls[orderNumber].handle; + var pollScope = userScope(); + var pollGeneration = scopeGeneration; + if (!options.scope) options.scope = pollScope; + if (options.generation === undefined) options.generation = pollGeneration; + var pollKey = pollScope + ":" + orderNumber; + if (polls[pollKey]) return polls[pollKey].handle; var maxAttempts = Math.max(1, Number(options.maxAttempts || DEFAULT_MAX_ATTEMPTS)); var maxElapsed = Math.max(1000, Number(options.maxElapsed || DEFAULT_MAX_ELAPSED)); @@ -190,11 +223,16 @@ state.controller = null; } if (state.onVisibility) document.removeEventListener("visibilitychange", state.onVisibility); - if (polls[orderNumber] && polls[orderNumber].handle === state.handle) delete polls[orderNumber]; + if (polls[pollKey] && polls[pollKey].handle === state.handle) delete polls[pollKey]; state.resolve(result); } function terminal(data, response, status) { + if ((options.scope && userScope() !== options.scope) + || (options.generation !== undefined && scopeGeneration !== options.generation)) { + finish({ terminal: false, aborted: true, status: "scope_changed" }); + return; + } var success = !!(data && data.paid) || !!SUCCESS_STATUSES[status]; invalidate(options.tags || ["vip", "orders"]); callback(options, "onTerminal", { @@ -226,6 +264,8 @@ function run() { state.timer = null; if (state.done || state.aborted) return; + // Detect account transitions and explicit auth resets even when no event reached this page. + if (userScope() !== pollScope || scopeGeneration !== pollGeneration || state.done || state.aborted) return; if (document.hidden) { state.waitingForVisible = true; return; @@ -289,7 +329,7 @@ finish({ terminal: false, aborted: true, status: "aborted" }); }, }; - polls[orderNumber] = state; + polls[pollKey] = state; schedule(0); return state.handle; } @@ -299,9 +339,23 @@ var activePoll = null; var stopped = false; var controller = window.AbortController ? new window.AbortController() : null; + var startScope = userScope(); + var startGeneration = scopeGeneration; if (controller) options.signal = controller.signal; + options.scope = startScope; + options.generation = startGeneration; + options.isActive = function () { + return !stopped && scopeGeneration === startGeneration && userScope() === startScope; + }; + options.cancel = function () { + if (controller) { + try { controller.abort(); } catch (error) {} + } + }; var promise = purchase(options).then(function (response) { - if (stopped) return { ok: false, aborted: true, status: "aborted" }; + if (!options.isActive()) { + return { ok: false, aborted: true, status: stopped ? "aborted" : "scope_changed" }; + } var data = response.data || response.json || {}; if (!response.ok) { callback(options, "onError", response); @@ -314,6 +368,9 @@ callback(options, "onError", missing); return missing; } + if (!options.isActive()) { + return { ok: false, aborted: true, status: "scope_changed" }; + } activePoll = poll(data.order_number, options); activePoll.promise.then(function (result) { if (result.success) callback(options, "onSuccess", result); @@ -326,6 +383,9 @@ poll: activePoll, }; } + if (!options.isActive()) { + return { ok: false, aborted: true, status: "scope_changed" }; + } 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 }; @@ -335,9 +395,7 @@ promise: promise, abort: function () { stopped = true; - if (controller) { - try { controller.abort(); } catch (error) {} - } + options.cancel(); if (activePoll && typeof activePoll.abort === "function") activePoll.abort(); }, }; @@ -349,7 +407,8 @@ poll: poll, start: start, abort: function (orderNumber) { - var active = polls[String(orderNumber || "")]; + var key = userScope() + ":" + String(orderNumber || ""); + var active = polls[key]; if (active && active.handle) active.handle.abort(); }, }; diff --git a/src/Admin/Schema/SchemaToolsFrontendModalRollout.php b/src/Admin/Schema/SchemaToolsFrontendModalRollout.php index acaa428a..6f7a4871 100644 --- a/src/Admin/Schema/SchemaToolsFrontendModalRollout.php +++ b/src/Admin/Schema/SchemaToolsFrontendModalRollout.php @@ -24,28 +24,28 @@ class SchemaToolsFrontendModalRollout { 'key' => 'frontend_modal_rollout_identity', 'label' => '官方认证新流程', 'type' => 'switch', - 'default' => false, + 'default' => true, 'help' => '启用统一弹窗运行时与认证快照流程。', ], [ 'key' => 'frontend_modal_rollout_badge', 'label' => '徽章新流程', 'type' => 'switch', - 'default' => false, + 'default' => true, 'help' => '启用统一 JSON 徽章流程和短缓存。', ], [ 'key' => 'frontend_modal_rollout_order', 'label' => '订单新流程', 'type' => 'switch', - 'default' => false, + 'default' => true, 'help' => '启用订单详情快速开壳与状态同步流程。', ], [ 'key' => 'frontend_modal_rollout_vip', 'label' => '会员支付新流程', 'type' => 'switch', - 'default' => false, + 'default' => true, 'help' => '启用统一会员购买、支付和结果同步流程。', ], ], diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php index bd5dbff9..51efb439 100644 --- a/src/Core/ScriptsPart1c.php +++ b/src/Core/ScriptsPart1c.php @@ -575,10 +575,10 @@ trait ScriptsPart1c { // Alias used by modal-auth / follow / report (same value as restUrl). 'apiUrl' => esc_url_raw(rest_url('aksrt/v1')), 'frontendModalRollout' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_frontend_modal_rollout() : [ - 'vip' => false, - 'order' => false, - 'identity' => false, - 'badge' => false, + 'vip' => true, + 'order' => true, + 'identity' => true, + 'badge' => true, ], 'stickyEnabled' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_enabled'] : true, 'stickyPages' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_pages'] : ['home', 'cat'], diff --git a/src/Settings/FrontendModalRuntime.php b/src/Settings/FrontendModalRuntime.php index 05a45cf2..79ddbeb4 100644 --- a/src/Settings/FrontendModalRuntime.php +++ b/src/Settings/FrontendModalRuntime.php @@ -20,10 +20,12 @@ class FrontendModalRuntime { $settings = Settings::get_all(); return [ - 'vip' => !empty($settings['frontend_modal_rollout_vip']), - 'order' => !empty($settings['frontend_modal_rollout_order']), - 'identity' => !empty($settings['frontend_modal_rollout_identity']), - 'badge' => !empty($settings['frontend_modal_rollout_badge']), + // Canonical-only flows must be live when the option has never been configured. + // An explicit false remains an intentional per-domain kill switch. + 'vip' => !array_key_exists('frontend_modal_rollout_vip', $settings) || !empty($settings['frontend_modal_rollout_vip']), + 'order' => !array_key_exists('frontend_modal_rollout_order', $settings) || !empty($settings['frontend_modal_rollout_order']), + 'identity' => !array_key_exists('frontend_modal_rollout_identity', $settings) || !empty($settings['frontend_modal_rollout_identity']), + 'badge' => !array_key_exists('frontend_modal_rollout_badge', $settings) || !empty($settings['frontend_modal_rollout_badge']), ]; } }