Files
wordpress-theme-aksrt/assets/js/vip-purchase-coordinator.js
T
kate522 aad83072a8 refactor(pay): 统一流水号,移除订单/流水/提现三类号码前缀
订单号、资金流水号、提现单号此前各自带前缀配置,同一笔业务在不同界面会
呈现多个号码口径,对账与客诉排查都要先做一次前缀换算。现收敛为单一流水号
分配器,不再按业务类型区分号码形态。

- 删除 payment_order_prefix / payment_flow_prefix /
  payment_withdrawal_prefix 三项配置项及其生成、校验链路
- BusinessNumber::allocate() 成为唯一入口,输出 {YmdHis}{6 位随机},
  共 20 位无前缀,满足 VARCHAR(32) 与网关 out_trade_no ≤ 32 的限制
- 订单、资金流水、提现、卡密渠道交易号四处生成点全部改走该分配器
- 后台支付中心表头「订单号 / 提现单号」统一为「流水号」
- 前台用户中心与订单卡片文案同步统一;前台订单详情合并为单行流水号,
  账本流水号不再下发到前台,避免同一单据出现两个对外号码
- 同步更新 financial-flow / pay-card / card-pass-order-numbers 契约测试
  与 BusinessNumberTest 断言

按用户要求不做双路逻辑与数据迁移,直接替换为统一方案,历史行不做转换。

验证:node --test tests/**/*.test.cjs 362/364 通过,新增 37 项契约;失败的
2 项在改动前的干净 HEAD 基线上同样失败(基线 327 项 / 失败 2 项),分别命中
遗留的 fa fa- 标记与 FA 版本号断言,与本次改动无关。phpunit 44 项 / 103
断言全部通过;php -l 全部改动的 PHP 文件通过。
2026-09-18 22:14:27 +08:00

429 lines
15 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 operationKeys = Object.create(null);
var polls = Object.create(null);
var lastScope = "";
var scopeGeneration = 0;
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;
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);
operationKeys = Object.create(null);
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);
}
["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;
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) {
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].promise;
if (!operationKeys[key]) operationKeys[key] = window.AksrtRequest.createIdempotencyKey();
var requestController = !options.signal && window.AbortController
? new window.AbortController()
: null;
var flight = {
promise: request("/pay/vip", {
method: "POST",
headers: { "Idempotency-Key": operationKeys[key] },
body: JSON.stringify({
package_id: packageId,
payment_method: method,
}),
signal: options.signal || (requestController ? requestController.signal : undefined),
}),
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];
});
flight.promise.then(function (response) {
if (response.ok) delete operationKeys[key];
});
return flight.promise;
}
function exchange(options) {
options = options || {};
var scope = userScope();
var packageId = parseInt(options.packageId || options.package_id || 0, 10);
if (!packageId) {
return Promise.resolve({ ok: false, status: 400, json: { message: "兑换套餐无效" } });
}
var key = scope + ":points-exchange:" + String(packageId);
if (!operationKeys[key]) operationKeys[key] = window.AksrtRequest.createIdempotencyKey();
return request("/pay/vip/points-exchange", {
method: "POST",
headers: { "Idempotency-Key": operationKeys[key] },
body: JSON.stringify({ package_id: packageId }),
signal: options.signal,
}).then(function (response) {
if (response.ok) {
delete operationKeys[key];
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 = Object.assign({}, options || {});
orderNumber = String(orderNumber || "");
if (!orderNumber) {
return {
promise: Promise.resolve({ terminal: true, success: false, status: "invalid" }),
abort: function () {},
};
}
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));
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[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", {
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;
// 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;
}
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[pollKey] = 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;
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 (!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);
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;
}
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);
else if (result.terminal) callback(options, "onFailure", result);
});
return {
ok: true,
pending: true,
data: data,
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 };
});
return {
promise: promise,
abort: function () {
stopped = true;
options.cancel();
if (activePoll && typeof activePoll.abort === "function") activePoll.abort();
},
};
}
window.AksrtVipPurchaseCoordinator = {
purchase: purchase,
exchange: exchange,
poll: poll,
start: start,
abort: function (orderNumber) {
var key = userScope() + ":" + String(orderNumber || "");
var active = polls[key];
if (active && active.handle) active.handle.abort();
},
};
})(window, document);