一次报价请求失败,页面应该显示“0元”,还是“价格待确认”?
这不是两个占位文案的区别。如果代码把初始金额设为0,再让下单函数直接读取这个变量,就可能把“没有取得价格”当成“已经确认免费”。
本文把报价状态和金额分开,用一个不连接真实商城的JavaScript示例,演示失败重试、乱序响应、报价确认与订单提交的边界。全部金额、购物车和订单标识都是合成数据,不涉及真实付款。
一、先区分未知报价与有效零价
下面这种初始化方式隐含了一个危险假设:
let price = 0;
try {
price = await fetchPrice();
} catch {
// price remains 0, but no valid quote has been obtained.
}
请求失败,只能说明这次没有拿到可用报价,不能说明商品免费。
反过来,也不能简单用price > 0作为所有业务的提交条件。全额优惠、赠品等业务可能确有合法零价。正确判断应是“有没有与当前商品、数量和优惠条件对应的有效报价”,金额为0是否允许由服务端业务规则决定。
本例因此允许服务端返回有效的0分报价,但没有报价时使用null,不用0代替。
二、状态应该先于金额
示例包含七个状态:
| 状态 | 含义 | 允许创建订单 |
|---|---|---|
| idle | 尚未报价或购物车已变更 | 否 |
| loading | 报价请求进行中 | 否 |
| quote-error | 未取得有效报价 | 否 |
| ready | 当前报价已取得,仍需用户确认 | 确认且未过期后允许 |
| submitting | 一次创建请求正在进行 | 禁止再次创建 |
| uncertain | 创建请求响应丢失或结果不完整 | 先由服务端核对结果 |
| complete | 已得到订单标识 | 不再重复创建 |
cartKey是购物车版本标识,不是用户身份。商品、数量、优惠条件改变时,应换一个版本;纯展示字段变化不应无端使报价失效。服务端仍需校验真实购物车,不能相信客户端随便传来的key。
三、最容易漏掉的三个时序
第一个是乱序响应。请求A先发后到,请求B后发先到,如果不判断版本,A可能覆盖B。示例给每次请求递增revision,只有当前版本能写回。旧成功和旧失败都不能覆盖新状态。
第二个是报价重试。用户在报价尚未准备好时点提交,本次最多重试报价;即使重试成功,也到此返回,不能顺带自动建单。用户应先看到并确认新报价,再进行下一次明确提交。
第三个是建单响应丢失。超时不一定代表服务端没有创建订单。示例进入uncertain后保留attemptId并阻止自动重试,真实系统应按这个幂等标识查询或核对结果,而不是立刻再建一单。
四、可运行的离线实现
以下代码是完整quote-gate.mjs,与本目录测试导入的文件一致。它只处理前端流程,不调用任何真实接口。
export function createQuoteGate({
loadQuote,
createOrder,
now = Date.now,
newAttemptId = () => globalThis.crypto.randomUUID(),
}) {
let revision = 0;
let state = Object.freeze({
phase: 'idle', cartKey: null, quote: null,
confirmedId: null, attemptId: null, orderId: null,
});
const locked = () => ['submitting', 'uncertain', 'complete'].includes(state.phase);
const update = (next) => { state = Object.freeze(next); };
const fresh = (quote, cartKey) => Boolean(
quote && quote.cartKey === cartKey && quote.expiresAt > now()
);
const valid = (quote, cartKey) => Boolean(
quote && typeof quote.id === 'string' && quote.id.trim() &&
quote.cartKey === cartKey && quote.currency === 'CNY' &&
Number.isSafeInteger(quote.amountMinor) && quote.amountMinor >= 0 &&
Number.isSafeInteger(quote.expiresAt) && quote.expiresAt > now()
);
function setCart(cartKey) {
if (typeof cartKey !== 'string' || !cartKey.trim()) {
throw new TypeError('A non-empty cart revision key is required');
}
if (locked()) return false;
if (state.cartKey === cartKey) return true;
revision += 1;
update({ ...state, phase: 'idle', cartKey, quote: null, confirmedId: null });
return true;
}
async function refreshQuote() {
if (locked() || state.cartKey === null) return false;
const request = ++revision;
const cartKey = state.cartKey;
update({ ...state, phase: 'loading', quote: null, confirmedId: null });
try {
const result = await loadQuote(cartKey);
if (request !== revision) return false;
if (!valid(result, cartKey)) throw new Error('Invalid quote');
const quote = Object.freeze({
id: result.id, cartKey, currency: result.currency,
amountMinor: result.amountMinor, expiresAt: result.expiresAt,
});
update({ ...state, phase: 'ready', quote, confirmedId: null });
return true;
} catch {
if (request !== revision) return false;
update({ ...state, phase: 'quote-error', quote: null, confirmedId: null });
return false;
}
}
function confirmQuote(id) {
if (state.phase !== 'ready' || state.quote.id !== id ||
!fresh(state.quote, state.cartKey)) return false;
update({ ...state, confirmedId: id });
return true;
}
async function submit() {
if (locked()) return { kind: 'blocked', phase: state.phase };
if (state.phase !== 'ready' || !fresh(state.quote, state.cartKey)) {
await refreshQuote();
return { kind: 'quote-only' };
}
if (state.confirmedId !== state.quote.id) {
return { kind: 'needs-confirmation' };
}
const quote = state.quote;
const attemptId = newAttemptId();
update({ ...state, phase: 'submitting', attemptId });
try {
const order = await createOrder({
quoteId: quote.id, cartKey: state.cartKey, attemptId,
});
if (!order || typeof order.id !== 'string' || !order.id.trim()) {
throw new Error('Missing order result');
}
update({ ...state, phase: 'complete', orderId: order.id });
return { kind: 'created', orderId: order.id };
} catch {
// A lost response does not prove the server failed to create the order.
update({ ...state, phase: 'uncertain' });
return { kind: 'uncertain', attemptId };
}
}
function priceLabel() {
if (state.phase !== 'ready' || !fresh(state.quote, state.cartKey)) {
return 'QUOTE_PENDING';
}
const minor = BigInt(state.quote.amountMinor);
return `${state.quote.currency} ${minor / 100n}.${String(minor % 100n).padStart(2, '0')}`;
}
return { snapshot: () => state, setCart, refreshQuote, confirmQuote, submit, priceLabel };
}
priceLabel()在未知、加载、失败和过期时返回QUOTE_PENDING,界面可映射为“价格待确认”;有效报价以整数分存储,展示时用BigInt拆分整数与两位小数,避免安全整数范围内的大额分值经浮点除法再toFixed产生一分钱误差。金额范围仍需服务端业务校验。
confirmQuote(id)要求确认的就是当前报价;换购物车、重新报价都会清除旧确认。实际界面应把它绑定到用户明确确认当前报价的动作,不能在接口成功回调里自动勾选。
五、怎样验证没有顺手建单
测试使用Node.js内置node:test、可控Promise和注入时钟,不连接网络,不生成真实订单。本次20项离线测试全部通过,包括有效零价、乱序成功与失败、报价到期边界、重复点击和建单结果不确定等情况。Node.js测试环境为v25.4.0,不代表完整浏览器或生产支付验收。
运行:
node --test quote-gate.test.mjs
重点检查的不只是按钮颜色,而是createOrder的实际调用次数:初始状态、报价失败、重试报价后未确认、旧响应返回、过期报价等情况下,都应为0;报价有效且用户确认后,连续点击也只能进入一次创建。
以下是完整quote-gate.test.mjs,与上面的模块放在同一目录即可运行:
import test from 'node:test';
import assert from 'node:assert/strict';
import { createQuoteGate } from './quote-gate.mjs';
function deferred() {
let resolve;
let reject;
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
return { promise, resolve, reject };
}
function quote(cartKey = 'cart-a', overrides = {}) {
return {
id: 'quote-a', cartKey, currency: 'CNY', amountMinor: 15380,
expiresAt: 5000, ...overrides,
};
}
function harness(options = {}) {
const clock = options.clock ?? { value: 1000 };
const calls = { loads: [], creates: [], attemptIds: 0 };
const gate = createQuoteGate({
now: () => clock.value,
loadQuote(cartKey) {
calls.loads.push(cartKey);
return options.loadQuote ? options.loadQuote(cartKey) : Promise.resolve(quote(cartKey));
},
createOrder(payload) {
calls.creates.push(payload);
return options.createOrder ? options.createOrder(payload) : Promise.resolve({ id: 'order-a' });
},
newAttemptId() {
calls.attemptIds += 1;
return options.newAttemptId ? options.newAttemptId() : `attempt-${calls.attemptIds}`;
},
});
return { gate, clock, calls };
}
async function ready(gate, cartKey = 'cart-a') {
assert.equal(gate.setCart(cartKey), true);
assert.equal(await gate.refreshQuote(), true);
return gate.snapshot().quote;
}
test('initial unknown price never appears as zero or creates an order', async () => {
const { gate, calls } = harness();
assert.equal(gate.snapshot().phase, 'idle');
assert.equal(gate.snapshot().quote, null);
assert.equal(gate.priceLabel(), 'QUOTE_PENDING');
assert.equal(gate.confirmQuote('quote-a'), false);
assert.equal(await gate.refreshQuote(), false);
assert.deepEqual(await gate.submit(), { kind: 'quote-only' });
assert.equal(calls.loads.length, 0);
assert.equal(calls.creates.length, 0);
gate.setCart('cart-a');
assert.equal(gate.priceLabel(), 'QUOTE_PENDING');
});
test('valid positive quote stays unconfirmed until explicitly accepted', async () => {
const { gate, calls } = harness();
await ready(gate);
assert.equal(gate.priceLabel(), 'CNY 153.80');
assert.equal(gate.snapshot().confirmedId, null);
assert.deepEqual(await gate.submit(), { kind: 'needs-confirmation' });
assert.equal(calls.loads.length, 1);
assert.equal(calls.creates.length, 0);
});
test('a valid zero quote is displayed and may create after confirmation', async () => {
const { gate, calls } = harness({
loadQuote: async (cartKey) => quote(cartKey, { amountMinor: 0 }),
newAttemptId: () => 'synthetic-zero-attempt',
});
await ready(gate);
assert.equal(gate.priceLabel(), 'CNY 0.00');
assert.equal(gate.confirmQuote('quote-a'), true);
assert.deepEqual(await gate.submit(), { kind: 'created', orderId: 'order-a' });
assert.deepEqual(calls.creates, [{
quoteId: 'quote-a', cartKey: 'cart-a', attemptId: 'synthetic-zero-attempt',
}]);
assert.equal(gate.snapshot().phase, 'complete');
});
test('older successful response cannot replace a newer successful quote', async () => {
const old = deferred();
const next = deferred();
let count = 0;
const { gate } = harness({ loadQuote: () => (++count === 1 ? old.promise : next.promise) });
gate.setCart('cart-a');
const first = gate.refreshQuote();
const second = gate.refreshQuote();
next.resolve(quote('cart-a', { id: 'quote-new', amountMinor: 14900 }));
assert.equal(await second, true);
old.resolve(quote('cart-a', { id: 'quote-old', amountMinor: 999 }));
assert.equal(await first, false);
assert.equal(gate.snapshot().quote.id, 'quote-new');
assert.equal(gate.priceLabel(), 'CNY 149.00');
});
test('older failed response cannot clear a newer successful quote', async () => {
const old = deferred();
const next = deferred();
let count = 0;
const { gate } = harness({ loadQuote: () => (++count === 1 ? old.promise : next.promise) });
gate.setCart('cart-a');
const first = gate.refreshQuote();
const second = gate.refreshQuote();
next.resolve(quote('cart-a', { id: 'quote-new' }));
assert.equal(await second, true);
assert.equal(gate.confirmQuote('quote-new'), true);
old.reject(new Error('synthetic old network failure'));
assert.equal(await first, false);
assert.equal(gate.snapshot().phase, 'ready');
assert.equal(gate.snapshot().confirmedId, 'quote-new');
});
test('older success cannot revive a quote after the newer request fails', async () => {
const old = deferred();
const next = deferred();
let count = 0;
const { gate } = harness({ loadQuote: () => (++count === 1 ? old.promise : next.promise) });
gate.setCart('cart-a');
const first = gate.refreshQuote();
const second = gate.refreshQuote();
next.reject(new Error('synthetic current network failure'));
assert.equal(await second, false);
old.resolve(quote('cart-a', { id: 'quote-old' }));
assert.equal(await first, false);
assert.equal(gate.snapshot().phase, 'quote-error');
assert.equal(gate.snapshot().quote, null);
assert.equal(gate.priceLabel(), 'QUOTE_PENDING');
});
test('cart change discards a successful in-flight response from the old cart', async () => {
const pending = deferred();
const { gate } = harness({ loadQuote: () => pending.promise });
gate.setCart('cart-a');
const loading = gate.refreshQuote();
assert.equal(gate.setCart('cart-b'), true);
pending.resolve(quote('cart-a'));
assert.equal(await loading, false);
assert.equal(gate.snapshot().cartKey, 'cart-b');
assert.equal(gate.snapshot().phase, 'idle');
assert.equal(gate.snapshot().quote, null);
});
test('cart change discards an old failure without marking the new cart failed', async () => {
const pending = deferred();
const { gate } = harness({ loadQuote: () => pending.promise });
gate.setCart('cart-a');
const loading = gate.refreshQuote();
gate.setCart('cart-b');
pending.reject(new Error('synthetic old cart failure'));
assert.equal(await loading, false);
assert.equal(gate.snapshot().phase, 'idle');
assert.equal(gate.snapshot().cartKey, 'cart-b');
assert.equal(gate.snapshot().confirmedId, null);
});
test('changing a ready cart clears both its quote and confirmation', async () => {
const { gate, calls } = harness();
await ready(gate);
assert.equal(gate.confirmQuote('quote-a'), true);
gate.setCart('cart-b');
assert.equal(gate.snapshot().quote, null);
assert.equal(gate.snapshot().confirmedId, null);
assert.equal(gate.priceLabel(), 'QUOTE_PENDING');
assert.deepEqual(await gate.submit(), { kind: 'quote-only' });
assert.equal(gate.snapshot().quote.cartKey, 'cart-b');
assert.equal(gate.snapshot().confirmedId, null);
assert.equal(calls.creates.length, 0);
});
test('setting the same cart preserves ready confirmation and in-flight revision', async () => {
const { gate } = harness();
await ready(gate);
gate.confirmQuote('quote-a');
const snapshot = gate.snapshot();
assert.equal(gate.setCart('cart-a'), true);
assert.equal(gate.snapshot(), snapshot);
const pending = deferred();
const other = harness({ loadQuote: () => pending.promise });
other.gate.setCart('cart-a');
const loading = other.gate.refreshQuote();
other.gate.setCart('cart-a');
pending.resolve(quote('cart-a'));
assert.equal(await loading, true);
assert.equal(other.gate.snapshot().phase, 'ready');
});
test('quote failure and subsequent submit only reload the quote, never create', async () => {
let count = 0;
const { gate, calls } = harness({ loadQuote: async (cartKey) => {
if (++count === 1) throw new Error('synthetic quote failure');
return quote(cartKey, { id: 'quote-recovered' });
} });
gate.setCart('cart-a');
assert.equal(await gate.refreshQuote(), false);
assert.equal(gate.snapshot().phase, 'quote-error');
assert.equal(gate.priceLabel(), 'QUOTE_PENDING');
assert.deepEqual(await gate.submit(), { kind: 'quote-only' });
assert.equal(gate.snapshot().phase, 'ready');
assert.equal(gate.snapshot().confirmedId, null);
assert.deepEqual(await gate.submit(), { kind: 'needs-confirmation' });
assert.equal(calls.loads.length, 2);
assert.equal(calls.creates.length, 0);
assert.equal(calls.attemptIds, 0);
});
test('only the current quote id may be confirmed and used to create', async () => {
let revision = 0;
const { gate, calls } = harness({
loadQuote: async (cartKey) => quote(cartKey, { id: `quote-${++revision}` }),
});
await ready(gate);
assert.equal(gate.confirmQuote('quote-1'), true);
assert.equal(await gate.refreshQuote(), true);
assert.equal(gate.confirmQuote('quote-1'), false);
assert.deepEqual(await gate.submit(), { kind: 'needs-confirmation' });
assert.equal(gate.confirmQuote('quote-2'), true);
assert.deepEqual(await gate.submit(), { kind: 'created', orderId: 'order-a' });
assert.equal(calls.creates[0].quoteId, 'quote-2');
});
test('expiration at the exact deadline blocks creation and requires a new confirmation', async () => {
const clock = { value: 1000 };
let revision = 0;
const { gate, calls } = harness({ clock, loadQuote: async (cartKey) =>
quote(cartKey, { id: `quote-${++revision}`, expiresAt: clock.value + 100 }) });
await ready(gate);
gate.confirmQuote('quote-1');
clock.value = 1100;
assert.equal(gate.priceLabel(), 'QUOTE_PENDING');
assert.equal(gate.confirmQuote('quote-1'), false);
assert.deepEqual(await gate.submit(), { kind: 'quote-only' });
assert.equal(calls.creates.length, 0);
assert.equal(gate.snapshot().confirmedId, null);
assert.equal(gate.snapshot().quote.id, 'quote-2');
assert.deepEqual(await gate.submit(), { kind: 'needs-confirmation' });
});
test('double submit creates once and locks cart changes and quote reload while submitting', async () => {
const pending = deferred();
const { gate, calls } = harness({ createOrder: () => pending.promise });
await ready(gate);
gate.confirmQuote('quote-a');
const first = gate.submit();
assert.equal(gate.snapshot().phase, 'submitting');
assert.deepEqual(await gate.submit(), { kind: 'blocked', phase: 'submitting' });
assert.equal(gate.setCart('cart-b'), false);
assert.equal(await gate.refreshQuote(), false);
assert.equal(gate.confirmQuote('quote-a'), false);
assert.equal(calls.creates.length, 1);
assert.equal(calls.attemptIds, 1);
pending.resolve({ id: 'order-once' });
assert.deepEqual(await first, { kind: 'created', orderId: 'order-once' });
assert.deepEqual(await gate.submit(), { kind: 'blocked', phase: 'complete' });
assert.equal(calls.creates.length, 1);
});
test('network failure after create enters uncertain and retains its attempt key without retry', async () => {
const { gate, calls } = harness({
createOrder: async () => { throw new Error('synthetic lost response'); },
newAttemptId: () => 'synthetic-stable-key',
});
await ready(gate);
gate.confirmQuote('quote-a');
assert.deepEqual(await gate.submit(), { kind: 'uncertain', attemptId: 'synthetic-stable-key' });
assert.equal(gate.snapshot().phase, 'uncertain');
assert.equal(gate.snapshot().attemptId, 'synthetic-stable-key');
assert.deepEqual(await gate.submit(), { kind: 'blocked', phase: 'uncertain' });
assert.equal(await gate.refreshQuote(), false);
assert.equal(gate.setCart('cart-b'), false);
assert.equal(calls.creates.length, 1);
assert.equal(calls.loads.length, 1);
assert.equal(calls.attemptIds, 1);
assert.equal(calls.creates[0].attemptId, gate.snapshot().attemptId);
});
test('malformed create responses are uncertain rather than falsely complete or retried', async () => {
for (const response of [null, {}, { id: '' }, { id: ' ' }, { id: 12 }]) {
const { gate, calls } = harness({ createOrder: async () => response });
await ready(gate);
gate.confirmQuote('quote-a');
assert.equal((await gate.submit()).kind, 'uncertain');
assert.equal(gate.snapshot().orderId, null);
assert.equal((await gate.submit()).kind, 'blocked');
assert.equal(calls.creates.length, 1);
}
});
test('malformed, mismatched and already expired quotes are rejected without a zero fallback', async () => {
const responses = [
null, {}, quote('cart-b'), quote('cart-a', { id: '' }),
quote('cart-a', { id: ' ' }), quote('cart-a', { id: 2 }),
quote('cart-a', { currency: 'USD' }), quote('cart-a', { amountMinor: -1 }),
quote('cart-a', { amountMinor: 1.5 }), quote('cart-a', { amountMinor: '15380' }),
quote('cart-a', { amountMinor: NaN }), quote('cart-a', { amountMinor: Infinity }),
quote('cart-a', { amountMinor: Number.MAX_SAFE_INTEGER + 1 }),
quote('cart-a', { expiresAt: 1000 }), quote('cart-a', { expiresAt: 999 }),
quote('cart-a', { expiresAt: Infinity }), quote('cart-a', { expiresAt: '5000' }),
quote('cart-a', { expiresAt: 5000.5 }),
];
for (const response of responses) {
const { gate, calls } = harness({ loadQuote: async () => response });
gate.setCart('cart-a');
assert.equal(await gate.refreshQuote(), false);
assert.equal(gate.snapshot().phase, 'quote-error');
assert.equal(gate.snapshot().quote, null);
assert.equal(gate.priceLabel(), 'QUOTE_PENDING');
assert.equal(gate.confirmQuote('quote-a'), false);
assert.equal(calls.creates.length, 0);
}
});
test('snapshots and copied quotes are immutable and do not alias the loader response', async () => {
const source = quote('cart-a');
const { gate } = harness({ loadQuote: async () => source });
const initial = gate.snapshot();
assert.equal(Object.isFrozen(initial), true);
await ready(gate);
const current = gate.snapshot();
assert.equal(Object.isFrozen(current), true);
assert.equal(Object.isFrozen(current.quote), true);
assert.notEqual(current.quote, source);
source.amountMinor = 1;
source.id = 'mutated-source';
assert.equal(gate.priceLabel(), 'CNY 153.80');
assert.equal(current.quote.id, 'quote-a');
assert.throws(() => { current.phase = 'complete'; }, TypeError);
assert.throws(() => { current.quote.amountMinor = 0; }, TypeError);
gate.confirmQuote('quote-a');
assert.equal(current.confirmedId, null);
assert.equal(initial.cartKey, null);
assert.equal(initial.phase, 'idle');
});
test('invalid cart keys fail without changing the existing quote or confirmation', async () => {
const { gate } = harness();
await ready(gate);
gate.confirmQuote('quote-a');
const snapshot = gate.snapshot();
for (const key of [null, undefined, '', ' ', 0, {}, []]) {
assert.throws(() => gate.setCart(key), TypeError);
assert.equal(gate.snapshot(), snapshot);
}
});
test('all accepted safe-integer minor amounts retain exact cents when formatted', async () => {
const { gate } = harness({
loadQuote: async (cartKey) => quote(cartKey, { amountMinor: 9007199254740990 }),
});
await ready(gate);
assert.equal(gate.priceLabel(), 'CNY 90071992547409.90');
});
六、前端门禁不是安全边界
禁用按钮可以减少误操作,但前端代码和请求都可被绕过。MDN说明了原生disabled的交互行为;它不是服务端交易授权机制。MDN:disabled
服务端必须独立核验用户权限、购物车、币种、报价版本和有效期,校验优惠条件与应付金额,并以幂等键和数据库约束处理重复创建。OWASP明确建议在服务端执行输入校验,因为客户端校验可以被绕过。OWASP:Input Validation
AbortController可以取消不再需要的fetch请求,减少无用工作;但取消请求不等于撤销服务端已经执行的交易,也不能取代上面的版本判断与幂等控制。MDN:AbortController
本例没有实现服务端报价签发、身份绑定、数据库事务、订单查询与幂等存储,也没有处理多标签页和多设备协调,不应直接复制后视为完整支付系统。
结语
报价失败不应变成一个看似有效的0;合法零价也不应被粗暴误伤。可靠的流程应该能回答:价格来自哪次有效报价、用户确认的是不是当前版本、这次点击是在重试报价还是创建订单,以及上一次创建的结果是否已经查明。
作者说明:本文由AIXiamo维护方结合前端工程问题整理,示例为独立教学实现,未展示生产接口、客户或订单数据;不涉及商品报价、购买引导或平台背书。内容与代码使用AI辅助起草,并经本地离线测试核验。