一次报价请求失败,页面应该显示“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辅助起草,并经本地离线测试核验。