Files
wordpress-theme-aksrt/assets/js/vip-purchase-coordinator.js
T
2026-08-05 02:04:55 +08:00

357 lines
12 KiB
JavaScript

/**
* 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);