修复镜像订单重试与级联配置可见性一致性
This commit is contained in:
1 parent
4bc84717ff
commit
695bc9192f
3 files changed
+497
-47
No files matched your search
@@ -59,6 +59,33 @@ var ce = {
|
||||
"24:month": `biennially`,
|
||||
"36:month": `triennially`,
|
||||
};
|
||||
// 默认值、购物车回填和界面共用可见叶子集合;隐藏祖先会隐藏整个子树。
|
||||
// 保留后端遍历顺序供默认值使用,显示排序仅由界面处理。
|
||||
function cascadeItems(option) {
|
||||
const items = [];
|
||||
function visit(nodes, path) {
|
||||
for (const node of nodes) {
|
||||
if (!node || typeof node !== `object` || Array.isArray(node)) continue;
|
||||
if (Number(node.hidden) === 1) continue;
|
||||
const name = String(node.item_name ?? ``).trim();
|
||||
const labels = name ? [...path, name] : path;
|
||||
const children = Array.isArray(node.children) ? node.children : [];
|
||||
const id = Number(node.item_id);
|
||||
if (
|
||||
(Number(node.is_leaf) === 1 || children.length === 0) &&
|
||||
Number.isInteger(id) &&
|
||||
id > 0
|
||||
)
|
||||
items.push({ item_id: id, label: labels.join(` / `) || `选项 ${id}` });
|
||||
else if (children.length) visit(children, labels);
|
||||
}
|
||||
}
|
||||
visit(option.tree ?? [], []);
|
||||
return items.filter(
|
||||
(item, index) =>
|
||||
items.findIndex((other) => other.item_id === item.item_id) === index,
|
||||
);
|
||||
}
|
||||
function R(e, t = {}) {
|
||||
// 业务状态按普通配置 u、级联 d、重复数量 p 分开存储,再按现有后端契约组装。
|
||||
let n = useApi(),
|
||||
@@ -218,7 +245,7 @@ function R(e, t = {}) {
|
||||
(p.value[String(n.id)] = [Number(e ?? n.qty_min ?? 1)]),
|
||||
n.option_type === `cascade`)
|
||||
) {
|
||||
let e = fe(n)[0];
|
||||
let e = cascadeItems(n)[0];
|
||||
e &&
|
||||
(d.value[String(n.id)] = {
|
||||
item_id: e.item_id,
|
||||
@@ -268,15 +295,11 @@ function R(e, t = {}) {
|
||||
e.id
|
||||
);
|
||||
});
|
||||
function fe(e) {
|
||||
let t = (e) => e.flatMap((e) => (e.children?.length ? t(e.children) : [e]));
|
||||
return t(e.tree ?? []).filter((e) => Number(e.hidden) !== 1);
|
||||
}
|
||||
function pe(e, t, n = 1) {
|
||||
let r = l.value?.configoptions.find((t) => t.id === e);
|
||||
r &&
|
||||
r.option_type === `cascade` &&
|
||||
fe(r).some((e) => e.item_id === t) &&
|
||||
cascadeItems(r).some((e) => e.item_id === t) &&
|
||||
((d.value[String(e)] = {
|
||||
item_id: t,
|
||||
quantity: Math.max(1, Number(n) || 1),
|
||||
@@ -373,17 +396,25 @@ function R(e, t = {}) {
|
||||
let n = t,
|
||||
r = Number(n.item_id),
|
||||
i = Number(n.quantity ?? 1);
|
||||
return Number.isFinite(r)
|
||||
? [
|
||||
[
|
||||
e,
|
||||
{
|
||||
item_id: r,
|
||||
quantity: Math.max(1, Number.isFinite(i) ? i : 1),
|
||||
},
|
||||
],
|
||||
]
|
||||
: [];
|
||||
const option = l.value?.configoptions.find(
|
||||
(option) => String(option.id) === e,
|
||||
);
|
||||
// 已保存的级联项不可静默替换成另一资源,也不能继续提交已隐藏的节点。
|
||||
if (
|
||||
!option ||
|
||||
option.option_type !== `cascade` ||
|
||||
!cascadeItems(option).some((item) => item.item_id === r)
|
||||
)
|
||||
throw Error(`商品级联配置已调整,请返回购物车删除该项后重新配置`);
|
||||
return [
|
||||
[
|
||||
e,
|
||||
{
|
||||
item_id: r,
|
||||
quantity: Math.max(1, Number.isFinite(i) ? i : 1),
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
));
|
||||
let i = t.repeat_configoption;
|
||||
@@ -773,34 +804,8 @@ var le = {
|
||||
? configuration_display_r(t)
|
||||
: t;
|
||||
function Z(e) {
|
||||
let t = [],
|
||||
n = (e, r) => {
|
||||
for (let i of e) {
|
||||
if (!i || typeof i != `object` || Array.isArray(i)) continue;
|
||||
let e = i;
|
||||
if (Number(e.hidden) === 1) continue;
|
||||
let a = String(e.item_name ?? ``).trim(),
|
||||
o = a ? [...r, a] : r,
|
||||
s = Array.isArray(e.children) ? e.children : [],
|
||||
c = Number(e.item_id);
|
||||
(Number(e.is_leaf) === 1 || s.length === 0) &&
|
||||
Number.isInteger(c) &&
|
||||
c > 0
|
||||
? t.push({
|
||||
item_id: c,
|
||||
label: o.join(` / `) || `选项 ${c}`,
|
||||
})
|
||||
: s.length && n(s, o);
|
||||
}
|
||||
};
|
||||
return (
|
||||
n(e.tree ?? [], []),
|
||||
t
|
||||
.sort((e, t) => e.label.localeCompare(t.label, `zh-Hans-CN`))
|
||||
.filter(
|
||||
(e, t, n) =>
|
||||
n.findIndex((t) => t.item_id === e.item_id) === t,
|
||||
)
|
||||
return cascadeItems(e).sort((e, t) =>
|
||||
e.label.localeCompare(t.label, `zh-Hans-CN`),
|
||||
);
|
||||
}
|
||||
function Re(e) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
// 付费镜像授权弹窗:复查实时价格、复用本组件已建订单,并在支付后重新检查授权。
|
||||
// 付费镜像授权弹窗:复查实时价格和已建订单状态,并在支付后重新检查授权。
|
||||
import "~/assets/styles/PaymentDialog.BNFKQF1U.css";
|
||||
import { toDisplayString } from "vue";
|
||||
import { onBeforeUnmount } from "vue";
|
||||
@@ -73,7 +73,7 @@ var C = {
|
||||
if (!n()) return;
|
||||
let r = F(t.price);
|
||||
if (Number(r) === 0) {
|
||||
((w.value = null), T(`authorized`));
|
||||
(j.delete(P(e)), (w.value = null), T(`authorized`));
|
||||
return;
|
||||
}
|
||||
if (r !== F(e.price)) {
|
||||
@@ -85,6 +85,21 @@ var C = {
|
||||
return;
|
||||
}
|
||||
let i = j.get(P(e));
|
||||
if (i) {
|
||||
// 订单可在其他标签页取消;缓存只负责去重,是否可复用必须以服务端状态为准。
|
||||
let { order } = await E.get(`/order/${i}`);
|
||||
if (!n()) return;
|
||||
if (!order || String(order.id) !== String(i))
|
||||
throw Error(`镜像订单信息无效,请稍后重试`);
|
||||
if (order.status === `Cancelled`) {
|
||||
j.delete(P(e));
|
||||
i = null;
|
||||
} else if (order.status === `Paid`)
|
||||
// 已付款不等于授权已同步,不能因授权延迟再创建一张收费订单。
|
||||
throw Error(`镜像订单已支付,授权尚未生效,请稍后重新检查`);
|
||||
else if (order.status !== `Unpaid`)
|
||||
throw Error(`当前镜像订单状态不支持支付,请稍后重试`);
|
||||
}
|
||||
if (!i) {
|
||||
let t = await E.post(`/${e.module}/${e.hostId}/image/order`, {
|
||||
image_id: e.imageId,
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/** 购买回归走真实配置页、实例动作和支付界面;只替换已配置后端的 HTTP 响应。 */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
fixtureRequest,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
async function purchaseBoundary(page, { savedCascade } = {}) {
|
||||
const state = {
|
||||
authorized: false,
|
||||
orders: new Map(),
|
||||
created: [],
|
||||
quotes: [],
|
||||
carts: [],
|
||||
settlements: [],
|
||||
requests: [],
|
||||
};
|
||||
const tree = [
|
||||
{
|
||||
item_name: "隐藏区域",
|
||||
hidden: 1,
|
||||
children: [{ item_id: 10, item_name: "不应售出的节点", is_leaf: 1 }],
|
||||
},
|
||||
{
|
||||
item_name: "常规区域",
|
||||
children: [
|
||||
{ item_id: 20, item_name: "可选节点", is_leaf: 1 },
|
||||
{ item_id: 21, item_name: "隐藏节点", is_leaf: 1, hidden: 1 },
|
||||
],
|
||||
},
|
||||
{ item_id: 30, item_name: "备用区域", is_leaf: 1 },
|
||||
];
|
||||
// 拦截点与生产 apiBase 一致;非本场景接口继续交给现有独立 HTTP 假后端。
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const method = request.method();
|
||||
const body = request.postDataJSON();
|
||||
const reply = (data, extra = {}) =>
|
||||
route.fulfill({
|
||||
json: { status: 200, msg: "", data, ...extra },
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
});
|
||||
const record = () =>
|
||||
state.requests.push({
|
||||
method,
|
||||
path,
|
||||
authenticated: !!request.headers().authorization,
|
||||
});
|
||||
const respond = (data, extra) => {
|
||||
record();
|
||||
return reply(data, extra);
|
||||
};
|
||||
if (path === "/host/5")
|
||||
return respond({
|
||||
host: {
|
||||
id: 5,
|
||||
product_id: 123,
|
||||
module: "mf_cloud",
|
||||
name: "本地镜像购买实例",
|
||||
status: "Active",
|
||||
billing_cycle: "monthly",
|
||||
upgrade_allowed: false,
|
||||
},
|
||||
});
|
||||
if (path === "/host/5/view") return respond({ module: "mf_cloud" });
|
||||
if (path === "/mf_cloud/5")
|
||||
return respond({
|
||||
image: { id: 6, name: "Ubuntu 24.04" },
|
||||
port: 22,
|
||||
username: "root",
|
||||
cpu: 2,
|
||||
memory: 4,
|
||||
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
|
||||
});
|
||||
if (path === "/mf_cloud/5/status")
|
||||
return respond({ status: "on", rescue: false });
|
||||
if (path === "/mf_cloud/5/disk") return respond({ list: [] });
|
||||
if (path === "/mf_cloud/5/flow")
|
||||
return respond({ total: 100, used: 0, leave: 100 });
|
||||
if (path === "/mf_cloud/5/real_data")
|
||||
return respond({ cpu: 0, memory: 0, disk: 0 });
|
||||
if (path === "/mf_cloud/5/chart") return respond({ list: [] });
|
||||
if (path === "/product/123/mf_cloud/image")
|
||||
return respond({
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Ubuntu",
|
||||
image: [
|
||||
{
|
||||
id: 6,
|
||||
name: "Ubuntu 24.04",
|
||||
image_group_id: 1,
|
||||
charge: 1,
|
||||
price: "10.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/mf_cloud/5/image/check")
|
||||
return respond({ price: state.authorized ? "0.00" : "10.00" });
|
||||
if (path === "/mf_cloud/5/image/order") {
|
||||
const id = 42 + state.created.length;
|
||||
state.created.push(id);
|
||||
state.orders.set(id, "Unpaid");
|
||||
return respond({ id });
|
||||
}
|
||||
if (path === "/mf_cloud/5/reinstall") return respond({});
|
||||
const order = /^\/order\/(\d+)$/.exec(path);
|
||||
if (order) {
|
||||
const id = Number(order[1]);
|
||||
if (method === "DELETE") {
|
||||
state.orders.set(id, "Cancelled");
|
||||
return respond({});
|
||||
}
|
||||
const status = state.orders.get(id) ?? "Unpaid";
|
||||
return respond({
|
||||
order: {
|
||||
id,
|
||||
amount: "10.00",
|
||||
amount_unpaid: status === "Paid" ? "0.00" : "10.00",
|
||||
credit: "0.00",
|
||||
type: "new",
|
||||
status,
|
||||
items: [{ id: 1, product_name: "系统镜像", amount: "10.00" }],
|
||||
},
|
||||
customfields: { is_pre_invoice: 0 },
|
||||
});
|
||||
}
|
||||
if (/^\/order\/\d+\/transaction_record$/.test(path))
|
||||
return respond({ list: [] });
|
||||
if (path === "/pay") {
|
||||
state.orders.set(Number(body.id), "Paid");
|
||||
return respond({}, { code: "Paid" });
|
||||
}
|
||||
if (path === "/product/123")
|
||||
return respond({
|
||||
product: {
|
||||
id: 123,
|
||||
name: "本地级联配置",
|
||||
module: "idcsmart_common",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
if (path === "/idcsmart_common/product/123/configoption")
|
||||
return respond({
|
||||
common_product: { pay_type: "free" },
|
||||
configoptions: [
|
||||
{
|
||||
id: 5,
|
||||
option_name: "区域",
|
||||
option_type: "cascade",
|
||||
is_show: 1,
|
||||
tree,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/product/123/self_defined_field/order_page")
|
||||
return respond({ data: [] });
|
||||
if (path === "/purchase_limit/check")
|
||||
return respond({ allowed: true, message: "" });
|
||||
if (path === "/product/123/config_option") {
|
||||
state.quotes.push(body);
|
||||
return respond({ price_total: "0.00" });
|
||||
}
|
||||
if (path === "/cart" && method === "POST") {
|
||||
state.carts.push(body);
|
||||
return respond({});
|
||||
}
|
||||
if (path === "/cart" && method === "GET")
|
||||
return respond({
|
||||
list:
|
||||
savedCascade === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
config_options: {
|
||||
cycle: "free",
|
||||
cascade_configoption: {
|
||||
5: { item_id: savedCascade, quantity: 1 },
|
||||
},
|
||||
},
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/product/settle") {
|
||||
state.settlements.push(body);
|
||||
return respond({ order_id: 42, amount: "0.00" });
|
||||
}
|
||||
return route.fallback();
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
test(
|
||||
"image retry reuses unpaid orders and replaces only a cancelled order",
|
||||
{ timeout: 60000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page);
|
||||
await login(page, "/servers/server?id=5");
|
||||
await page.getByRole("tab", { name: "访问控制", exact: true }).click();
|
||||
await page.getByRole("button", { name: "重装系统", exact: true }).click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "重装系统", exact: true })
|
||||
.getByRole("button", { name: "确认", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("dialog", { name: "确认重装系统", exact: true })
|
||||
.getByRole("button", { name: "确认重装", exact: true })
|
||||
.click();
|
||||
const purchase = page.getByRole("dialog", {
|
||||
name: "购买系统镜像",
|
||||
exact: true,
|
||||
});
|
||||
const continuePurchase = () =>
|
||||
purchase
|
||||
.getByRole("button", { name: "确认购买 / 继续支付", exact: true })
|
||||
.click();
|
||||
const payment = page.getByRole("dialog", {
|
||||
name: "订单支付",
|
||||
exact: true,
|
||||
});
|
||||
const closePayment = async () => {
|
||||
await payment.getByRole("button", { name: /^(关闭|Close)$/ }).click();
|
||||
await payment.waitFor({ state: "hidden" });
|
||||
};
|
||||
await continuePurchase();
|
||||
await payment.waitFor();
|
||||
await closePayment();
|
||||
await continuePurchase();
|
||||
await payment.waitFor();
|
||||
assert.deepEqual(
|
||||
state.created,
|
||||
[42],
|
||||
"Retrying an unpaid order must not create a second order",
|
||||
);
|
||||
await closePayment();
|
||||
|
||||
// 另一标签使用真实订单取消入口,原实例页保持挂载,不用刷新掩盖缓存生命周期。
|
||||
const orderPage = await page.context().newPage();
|
||||
await orderPage.goto(`${appOrigin}/orders/42`);
|
||||
await orderPage
|
||||
.getByRole("button", { name: "取消订单", exact: true })
|
||||
.click();
|
||||
await orderPage
|
||||
.getByRole("dialog", { name: "取消订单", exact: true })
|
||||
.getByRole("button", { name: "确认取消", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.orders.get(42),
|
||||
(status) => status === "Cancelled",
|
||||
"Second tab must cancel the first order",
|
||||
);
|
||||
await orderPage.close();
|
||||
await continuePurchase();
|
||||
await eventually(
|
||||
() => state.created.length,
|
||||
(count) => count === 2,
|
||||
"Cancelled cached order must be replaced without reloading the instance",
|
||||
);
|
||||
await payment.waitFor();
|
||||
assert.deepEqual(state.created, [42, 43]);
|
||||
await payment
|
||||
.getByRole("button", { name: "确认支付", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.orders.get(43),
|
||||
(status) => status === "Paid",
|
||||
"Payment must complete through the real payment panel",
|
||||
);
|
||||
await purchase.waitFor();
|
||||
await purchase
|
||||
.getByText("镜像授权尚未生效,请稍后重新检查", { exact: true })
|
||||
.waitFor();
|
||||
const paidRetryMessage = purchase.getByText(
|
||||
"镜像订单已支付,授权尚未生效,请稍后重新检查",
|
||||
{ exact: true },
|
||||
);
|
||||
assert.equal(await paidRetryMessage.count(), 0);
|
||||
const readsBeforePaidRetry = state.requests.filter(
|
||||
(request) => request.method === "GET" && request.path === "/order/43",
|
||||
).length;
|
||||
await continuePurchase();
|
||||
// 本次 GET 增量和新文案共同证明重试已完成,不能沿用支付回调的旧提示或按钮状态。
|
||||
await eventually(
|
||||
() =>
|
||||
state.requests.filter(
|
||||
(request) =>
|
||||
request.method === "GET" && request.path === "/order/43",
|
||||
).length,
|
||||
(count) => count > readsBeforePaidRetry,
|
||||
"Paid order retry must fetch fresh server state",
|
||||
);
|
||||
await paidRetryMessage.waitFor();
|
||||
assert.deepEqual(
|
||||
state.created,
|
||||
[42, 43],
|
||||
"Paid but not-yet-authorized orders must not create another order",
|
||||
);
|
||||
assert.equal(await payment.isVisible(), false);
|
||||
state.authorized = true;
|
||||
await continuePurchase();
|
||||
await purchase.waitFor({ state: "hidden" });
|
||||
assert.deepEqual(state.created, [42, 43]);
|
||||
assert.equal(
|
||||
state.requests.filter((request) => request.path.endsWith("/reinstall"))
|
||||
.length,
|
||||
0,
|
||||
"Paying for an image must not automatically reinstall the instance",
|
||||
);
|
||||
assert.ok(state.requests.every((request) => request.authenticated));
|
||||
assert.ok(
|
||||
(await fixtureRequest("requests")).some(
|
||||
(request) => request.path === "/login",
|
||||
),
|
||||
);
|
||||
return {
|
||||
unpaidOrderReused: true,
|
||||
cancelledOrderReplaced: true,
|
||||
paidOrderRetainedUntilAuthorized: true,
|
||||
createdOrders: state.created.length,
|
||||
automaticReinstalls: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"cascade defaults, visible choices and submitted values agree",
|
||||
{ timeout: 40000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page);
|
||||
await login(page, "/products/123");
|
||||
await eventually(
|
||||
() => state.quotes.length,
|
||||
(count) => count > 0,
|
||||
"Configuration must receive a real quote",
|
||||
);
|
||||
assert.equal(
|
||||
state.quotes.at(-1).config_options.cascade_configoption[5].item_id,
|
||||
20,
|
||||
"Default must skip descendants of hidden ancestors",
|
||||
);
|
||||
const region = page.getByRole("combobox", { name: "区域", exact: true });
|
||||
assert.match(await region.innerText(), /常规区域.*可选节点/);
|
||||
await region.click();
|
||||
const labels = await page.getByRole("option").allTextContents();
|
||||
assert.equal(labels.length, 2);
|
||||
assert.ok(
|
||||
labels.every(
|
||||
(label) => !label.includes("隐藏") && !label.includes("不应售出"),
|
||||
),
|
||||
);
|
||||
await page
|
||||
.getByRole("option", { name: "常规区域 / 可选节点", exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("button", { name: "加入购物车", exact: true })
|
||||
.click();
|
||||
await eventually(
|
||||
() => state.carts.length,
|
||||
(count) => count === 1,
|
||||
"Cart must receive the selected visible candidate",
|
||||
);
|
||||
assert.equal(
|
||||
state.carts[0].config_options.cascade_configoption[5].item_id,
|
||||
20,
|
||||
);
|
||||
await page.getByRole("button", { name: "立即购买", exact: true }).click();
|
||||
await eventually(
|
||||
() => state.settlements.length,
|
||||
(count) => count === 1,
|
||||
"Checkout must submit the same visible candidate",
|
||||
);
|
||||
assert.equal(
|
||||
state.settlements[0].config_options.cascade_configoption[5].item_id,
|
||||
20,
|
||||
);
|
||||
return {
|
||||
visibleChoices: labels.length,
|
||||
defaultVisibleItem: 20,
|
||||
cartVisibleItem: 20,
|
||||
checkoutVisibleItem: 20,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"cart editing does not restore a hidden cascade descendant",
|
||||
{ timeout: 40000 },
|
||||
async (t) => {
|
||||
await scenario(t, {}, async (page) => {
|
||||
const state = await purchaseBoundary(page, { savedCascade: 10 });
|
||||
await login(page, "/products/123?edit_position=0");
|
||||
await page
|
||||
.getByRole("button", { name: "重新加载配置", exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
state.quotes.length,
|
||||
0,
|
||||
"An unavailable saved candidate must not receive a valid quote",
|
||||
);
|
||||
assert.equal(state.settlements.length, 0);
|
||||
assert.equal(state.carts.length, 0);
|
||||
return {
|
||||
hiddenSavedCandidateRejected: true,
|
||||
quotesForHiddenCandidate: 0,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user