作者复盘 BidPage 竞拍产品开发中「归零时并发关闭」「最后秒出价」「付款失败处理」等边界场景的决策与实现思路。
倒计时结束不是终点:拍卖产品中卡片竞价、自动关闭与防狙击的几个技术决策
构建一个小型拍卖产品时,我在卡片-backed 竞价、自动关闭和防狙击机制上学到的一些经验。
我一直在做 BidPage,一个用于在线交付物品的拍卖小工具。
我想让这个概念保持简单:卖家创建一个拍卖页面,把链接分享给感兴趣的人,让他们出价。中标者成交时扣款,其他人无需付费。
在屏幕上,这并不是一个复杂的产品——一张图片、一段描述、几条规则、一个出价列表和一个倒计时。
然后倒计时归零了。
谁有权限关闭拍卖?如果两个任务同时尝试关闭会怎样?如果在最后一秒有人出价怎么办?如果最高出价者的卡片扣款失败呢?以及,当中标者并不一定支付最高出价时,“最高出价者”究竟是什么意思?
这些问题比拍卖页面本身需要更多思考。
这不是一个完整的拍卖教程,下面的代码片段也是简化过的而非直接从生产代码复制。这些只是我在重新做另一个拍卖系统前会先记录下来的决策。
倒计时对查看页面的人有用,但它不能作为权威依据。浏览器可能会休眠、断网或本地时间错误。有人也可以修改自己设备上运行的任何东西。
服务器时钟和数据库必须判断一笔出价是否迟到。
一个更安全的模型是把“关闭”当作 live 和 awaiting_payment 之间的一个真实状态。一个 worker 首先尝试用原子更新来认领拍卖:
UPDATE auctions
SET status = 'closing'
WHERE id = :auction_id
AND status = 'live'
AND closes_at <= CURRENT_TIMESTAMP;
如果一行被修改,那这个 worker 就拥有了关闭权。如果没有行被修改,它就无需做任何事。另一个 worker 可能已经认领了它,或者关闭时间可能已经被推迟了。
第二种情况很重要,因为 BidPage 有防狙击机制。在最后两分钟内的有效出价会延长拍卖,让其他竞拍者有时间回应。
这个小功能毁掉了我最初那个“为一个关闭时间调度一个任务”的简洁心智模型。当那个任务运行时,原始的关闭时间可能已经不正确了。
因此一个关闭 worker 每次都必须读取当前状态。如果拍卖被延长了,旧任务就退出。由更晚的任务,或者定期扫描实际已到期的拍卖的程序来处理。
出价接受和时间延长也必须放在同一个事务中。否则一个请求可能判定拍卖是开放的,而另一个请求同时在关闭它。
BidPage 有三种拍卖形式:
公开竞价(Open Bidding):无底价,价高者得。
一口价(Buy Now):设定一个固定价格,立即成交。
公平价(Fair Price):需要解释的那种。如果最高两个出价分别是 $100 和 $75,出 $100 的人中标,但实际支付 $75,但要不低于底价。
结果函数本身应该是平淡无奇的。给它一个拍卖和一组固定的有效出价,它应该总是返回相同的结果。
function determineOutcome(auction, bids) {
const ranked = bids
.filter((bid) => bid.status === "valid")
.sort((a, b) =>
b.amountCents - a.amountCents ||
a.acceptedSequence - b.acceptedSequence
);
const winner = ranked[0];
if (!winner || winner.amountCents < auction.reserveCents) {
return { sold: false };
}
const priceCents = auction.format === "fair_price"
? Math.max(
auction.reserveCents,
ranked[1]?.amountCents ?? auction.reserveCents
)
: winner.amountCents;
return { sold: true, winner, priceCents };
}
这个例子用更早被接受的出价作为平局决胜规则。也可以用不同的规则,但必须在两个竞拍者提交相同金额之前就决定好。
金额也应该以“分”为单位存储,而不是用浮点数。拍卖是发现前端和后端对同一个数字舍入方式不一致的特别糟糕的地方。
我发现值得记录下来的情况都是比较棘手的:无出价、只有一个竞拍者参与公平价拍卖、两个相同的私密出价、一笔恰好等于底价的出价,以及一口价操作和普通出价竞速。
这些规则的公开解释是规格说明的一部分。如果界面说一套,结算函数做的是另一套,代码可能运行完美但仍然产生错误的结果。
我不想向每个竞拍者收费然后再退还所有输家。我也不希望一个持续数天的拍卖依赖一个长期的卡片授权。
流程因此被分成两步。竞拍者在出价时授予保存支付方式的权限。拍卖关闭并得出最终价格后,中标者进行离会话扣款。
Stripe 的 Setup Intents API 就是为了在不创建扣款的情况下保存支付方式而设计的。Stripe 也有文档说明了后续的离会话支付流程。
令人不安的细节是,保存的卡片仍然可能扣款失败。发卡行可能拒绝支付,或者要求客户重新认证。
所以 winner_selected 和 paid 不能是同一个状态。两者之间,数据模型需要 pending、requires_action 和 failed 这样的支付状态。
支付尝试也必须能够安全地重试。每次结算尝试使用一个稳定的幂等键可以防止重复的任务创建另一笔扣款。浏览器重定向不应算作支付证明。最终状态应来自已验证的 webhook;Stripe 推荐用 webhook 监控 PaymentIntent 状态。
对于中标者扣款失败的情况,我仍然很感兴趣了解其他人是如何处理的。自动将物品提供给第二高出价者听起来合理,直到你问自己:那个竞拍者应该付多少钱?原中标者有多长时间来完成认证?公开的规则是否真的允许这样做?
数字交付不是一件事。
一个域名需要转移。一场咨询需要开展。一条横幅可能需要在线保留七天。一篇赞助帖子可能有约定的发布日期和持续时间。
这就是为什么拍卖在任何人都出价之前就需要明确的交付条款。当合理时,我还要求卖家提供一个证明链接,让买家可以检查卖家是否控制了所拍卖的东西。
支付之后,拍卖还要经历交付和确认环节才算完成。在卡片扣款后就立即标记为完成,会让支付系统高兴,但不一定让买家高兴。
这部分是代码问题,部分是政策问题。买家有多长时间来确认?卖家能提供什么证据?双方意见不一致时怎么办?即使是一个小产品,最终也必须回答这些问题。
同一个拍卖引擎可以处理域名、newsletter 时段、咨询、数字产品或首页横幅。
这种灵活性在代码里很有用。我不太确定它在落地页的第一句话里是否同样有用。
“拍卖任何数字产品”是准确的,但可能太宽泛了。从一个群体开始——比如创作者出售赞助位置或创始人出售小型数字资产——然后再扩展,可能会得到更清晰的产品。
这是我正在测试的问题。拍卖逻辑可以告诉我谁赢了。但它无法告诉我,人们最初会关心哪种使用场景。
如果你做过拍卖或其他延迟扣款系统,我真的很想听听你是如何处理关闭竞态和中标者扣款失败的。
当前版本在 bidpage.app。
声明:本文使用了 AI 辅助来帮助组织和编辑。我审查了最终版本,并对技术内容负责。