2021 年的一个星期四下午,一位客户打电话给我,是一个来自巴斯的古董商,想把他的月度线下拍卖搬到网上。我以为很简单。然后他说"竞价需要实时更新所有观看者,无需刷新页面"。好吧。就这样,一个"简单的 WordPress 工作"变成了为期两周的架构讨论。
关键要点:Next.js加Supabase的直播竞价取决于实时通道、行级安全和服务端竞价验证;在做UI之前要先把状态机搞对。
我在 Seahawk Media 构建了超过 12,000 个网站,实时功能是那些如果你一开始计划不周就会咬你的功能。每五秒轮询看起来不错,直到你有 200 个竞价者同时猛烈冲击一个端点,你的托管账单在一夜间翻倍。所以让我向你展示我今天会如何建设一个适当的实时拍卖平台,使用 Next.js 和 Supabase,基于我实际交付过的。
---
为什么为此选择 Next.js 和 Supabase
听着,实时有很多种做法。Socket.io 搭配 Node 服务器、Ably、Pusher、Firebase,我在不同阶段都用过。但 Next.js + Supabase 的组合因为一个具体的原因值得放在这里:Supabase Realtime 是建立在 PostgreSQL 的逻辑复制基础上的,这意味着你的实时竞价更新和你的持久化数据层是同一个系统。没有两个真实来源的同步问题。没有不确定通过 WebSocket 的竞价是否也进入了数据库的疑虑。
Supabase 还为你提供开箱即用的 Auth、行级安全 (RLS) 和存储。对于拍卖网站,"只有拍卖所有者可以结束一个拍品" 和 "用户不能对自己的物品出价" 这些都是真实的业务规则,Postgres 中的 RLS 策略确实是正确的工具。
之所以选 Next.js,说实话,是因为 App Router 和 Server Components 意味着你可以静态渲染拍卖目录,让 SEO 满意,只在客户端水合实时竞价小组件。这个分离很重要。你不想为一个 90% 是静态内容的页面支付动态渲染的代价。
---
先设计数据库模式(不要跳过这一步)
大多数人在这里匆匆忙忙,后来都后悔了。我曾为浴室古董客户花了令人尴尬的三天时间重构模式,原因是我没有充分考虑出价历史模型。
这是我现在使用的核心结构:
- `profiles`,扩展 Supabase 的 auth.users,存储显示名称、认证竞价者标志和 credit_balance(如果你做保证金竞价的话)
- `auctions`,拍卖活动本身;starts_at、ends_at、status(draft | live | closed)和 created_by
- `lots`,一场拍卖内的单个物品;reserve_price、current_bid、current_bidder_id、lot_number、ends_at(各批物品可以有各自的倒计时)
- `bids`,不可变的仅追加日志;lot_id、bidder_id、amount、placed_at。永远不要更新这个表。永远不要。
- `auction_participants`,一个联接表,跟踪谁注册了哪个拍卖(对保证金冻结和通知定位很有用)
lots 表上的 current_bid 和 current_bidder_id 列是有意反范式化的。是的,你可以在每次读取时从 bids 表派生它们,但在并发负载下那个查询会变得很昂贵。反范式化它,保持 bids 表作为你的审计日志,然后用一个 Postgres 函数在出价被接受时原子性地更新 lots。
原子出价函数
大多数教程都会跳过这一点。拍卖中的竞态条件是真实存在的。两个用户在同一毫秒内提交520英镑出价,会发生什么?
答案是一个带有对 lot 行 FOR UPDATE 锁定的 Postgres 函数:
``` create or replace function place_bid(p_lot_id uuid, p_bidder_id uuid, p_amount numeric) returns json as $$ declare v_lot lots%rowtype; begin select * into v_lot from lots where id = p_lot_id for update;
if v_lot.status!= 'live' then return json_build_object('success', false, 'error', 'Lot is not live'); end if;
if p_amount <= v_lot.current_bid then return json_build_object('success', false, 'error', 'Bid too low'); end if;
if p_bidder_id = v_lot.current_bidder_id then return json_build_object('success', false, 'error', 'You are already the highest bidder'); end if;
insert into bids (lot_id, bidder_id, amount) values (p_lot_id, p_bidder_id, p_amount);
update lots set current_bid = p_amount, current_bidder_id = p_bidder_id where id = p_lot_id;
return json_build_object('success', true, 'new_bid', p_amount); end; $$ language plpgsql security definer; ```
通过 supabase.rpc('place_bid', {...}) 从你的 Next.js API 路由调用这个。FOR UPDATE 锁意味着在任何给定时刻只有一个事务能赢得该 lot。另一个会得到序列化错误,你在客户端返回一条友好的"有人刚刚出价更高"的消息。
---
行级安全性,拍卖规则层
RLS 是那些开发者要么立刻喜欢,要么因为感觉不透明而回避的东西之一。在我参与的 Seahawk 金融科技项目之前,我属于回避派,直到那个项目用惨痛的教训告诉我,只在应用代码中强制执行访问控制距离灾难只有一个配置错误的 API 路由之遥。
对于拍卖网站,这些策略很重要:
- 任何人都可以查看活跃拍品,SELECT on lots where auctions.status = 'live'
- 只有经过身份验证的已认证出价人才能插入出价,检查策略中的 profiles.verified_bidder = true
- 只有拍卖创建者可以更新拍品状态,UPDATE on lots where auctions.created_by = auth.uid()
- 出价历史可由拍品所属拍卖的创建者和出价人自己查看,其他人无需实时查看完整出价历史
Supabase 的 RLS 文档在这方面确实很不错,值得阅读关于安全定义者函数的部分,因为它与 place_bid 这样的 RPC 调用的交互方式有关。
一个陷阱:如果你在 Postgres 函数中使用安全定义者(如上所示),它将以函数所有者的权限运行,绕过 RLS。这是有意的,你希望出价提交能绕过出价人的 RLS,这样它就可以锁定和更新拍品行。但这意味着你必须在函数内部强制执行自己的业务逻辑检查,上面的代码就是这样做的。
---
在 Next.js 中设置 Supabase Realtime
这就是真正令人满意的地方。Supabase Realtime 让你可以通过 WebSockets 订阅 Postgres 表上的更改,客户端 SDK 让这变得简单得不可思议。
在你的拍卖拍品页面中,Next.js App Router 中的客户端组件,你可以这样做:
``` 'use client'
import { useEffect, useState } from 'react' import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
export default function LotBidDisplay({ lotId, initialBid }) { const [currentBid, setCurrentBid] = useState(initialBid) const supabase = createClientComponentClient()
useEffect(() => { const channel = supabase.channel(lot-${lotId}).on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'lots', filter:id=eq.${lotId}}, (payload) => { setCurrentBid(payload.new.current_bid) } ).subscribe()
return () => { supabase.removeChannel(channel) } }, [lotId])
return <div>Current bid: £{currentBid.toLocaleString()}</div> } ```
从在服务器组件中传递 initialBid,该组件在请求时获取新鲜数据。客户端接管后,监听该特定拍卖品行的 UPDATE 事件。每次 place_bid 成功运行时,Supabase 广播更改,每个连接的竞价者的 UI 通常在 100-300ms 内更新。
处理倒计时计时器
拍品通常有一个倒计时,"3:42 后关闭"。不要相信客户端时钟。从 lots.ends_at(存储在 Postgres 中的 UTC 时间)推导出结束时间,并在客户端使用 Date.now() 计算剩余秒数。每 60 秒重新同步一次,进行新的获取以防止时钟漂移。并添加"软关闭"逻辑:如果出价在最后 60 秒内到达,将 ends_at 延长两分钟。这是标准的拍卖行为,出价人也期望如此。
---
Next.js App Router 竞拍 UI 架构
我使用的页面结构:
``app/ auctions/ page.tsx ← 服务器组件,列出实时拍卖(ISR,revalidate: 60) [auctionId]/ page.tsx ← 服务器组件,服务器端获取lots列表 LotGrid.tsx ← 客户端组件,订阅lot状态变化 [lotId]/ page.tsx ← 服务器组件,初始lot数据 + SEO元数据 BidPanel.tsx ← 客户端组件,实时竞价显示 + 竞价表单``
目录(/auctions)使用增量静态再生成,重新验证时间为60秒。单个拍品页面在首次加载时进行服务器端渲染(用于分享、预览、og:image生成),然后交由客户端组件处理实时内容。
我总是这样做:把 BidPanel 组件用 dynamic(() => import('./BidPanel'), { ssr: false }) 进行延迟加载。这本来就只在客户端有意义,而且能让你的初始 HTML 体积对于慢速网络用户保持精简,这一点如果你的拍卖受众偏年长(古董拍卖通常如此),比你想象的更重要。
---
身份验证和"已验证竞拍者"流程
标准的Supabase身份验证,使用邮箱/密码或魔法链接,适用于注册。但拍卖通常需要额外一步:竞拍者验证。你可能需要信用卡预授权、身份验证,或者只是管理员批准,才能让某人实际出价。
我使用的模式:在 profiles 表上有一个 verified_bidder 布尔值,默认为 false。注册后,用户会看到一个"完成你的注册"屏幕。一旦获得批准(由管理员手动操作,或在 Stripe 支付授权后自动),你就翻转这个标志。bids 表上的 RLS 策略会检查它。他们可以浏览和关注,但在验证前无法出价。
对于 Stripe 支付授权冻结,Stripe 的支付意向(payment intents)配合 capture_method: manual 是正确的方式,你冻结 £50,如果他们赢了就扣款,没赢就释放。这能大幅减少不付款的情况,相信我,这是每个在线拍卖运营者都头疼的问题。
---
部署、性能和会让你吃亏的细节
部署到 Vercel,这对 Next.js 是显而易见的选择,边缘网络与 Supabase 全球基础设施配合得很好。确保你的 Supabase 项目在离 Vercel 部署区域最近的 AWS 区域。我见过有人把 Vercel 部署在 us-east-1,Supabase 放在 eu-west-2,结果多了 40-60ms 完全不必要的延迟。选一个区域,两个都放那里。
如果你不提前处理这些问题,会给你带来痛苦:
- WebSocket连接限制。Supabase的免费层允许大约200个并发Realtime连接。如果你的拍卖走红,那个上限很重要。检查你的计划。
- 竞价的乐观 UI。在服务器确认之前立即在竞价者的屏幕上显示竞价。如果失败(被超价、竞态条件),用错误恢复。200-300ms 的服务器往返时间除非 UI 等待它,否则是察觉不到的。
- 拍卖品结束宽限期。永远不要在 ends_at 时刻关闭拍卖品。给它一个 2-3 秒的服务器端缓冲区,以允许在截止时间前刚刚提交的飞行中竞价处理。在你的 close_lot 定时函数中处理这个问题。
- 电子邮件通知。用 Supabase Edge Functions 配合 Resend 或 Postmark 来发送"你被超价了"和"你赢了!"的邮件。别试图从 Next.js API 路由做这件事,它们会超时,拍卖参与者如果通知不可靠会真的很生气。
---
常见问题
Supabase Realtime 能处理多少个并发竞价人?
Supabase 的 Pro 计划默认支持最多 500 个并发 Realtime 连接,更高的限制也有。对于大多数拍卖网站,除非你在运营苏富比在线那样的规模,这已经足够了。如果你预期有数千个同时观看者,考虑通过单个服务端频道而不是按用户订阅来广播拍品更新,还要看看 Supabase 的 Realtime Broadcast 功能,它在高扇出场景下效率更高。
我应该使用 Supabase Realtime 还是 Ably 这样的专用服务?
对于大多数项目,Supabase Realtime 完全足够用,而且集成简单得多,因为你的数据已经在 Supabase 了。只有在你需要全球低于 50ms 的延迟,或者在构建有数百万并发连接的东西时,我才会考虑 Ably 或 Pusher。一个古董拍卖、慈善筹款、小美术馆的在线销售,Supabase 都处理得很好。
如果用户的 WebSocket 连接在拍卖中途断开会怎样?
Supabase 的客户端 SDK 将自动尝试重新连接。但在重新连接时,您应始终重新获取当前拍品状态(current_bid、ends_at),而不是信任连接中断前本地状态中的内容。在客户端组件中添加在线/离线事件监听器,并在连接恢复时触发新的服务器获取。
我可以用 Next.js Server Actions 代替 API 路由来出价吗?
有的,我做过。Next.js 14 的 Server Actions 很方便,它们省去了独立 /api/bid 路由的模板代码。代价是 Server Actions 稍微难一点单独进行速率限制(你得在中间件级别而不是按操作来应用速率限制)。对于生产拍卖网站,我会在中间件中加入 Upstash Redis 速率限制,防止单个用户不管你用的是 Actions 还是 API 路由都能频繁发送出价请求。
我怎么处理平手,两个一样的出价同时发生?
place_bid Postgres 函数中的 FOR UPDATE 锁序列化并发出价,因此从技术上讲,数据库级别无法出现平局。一个会成功,另一个会因"出价过低"响应失败(因为两者都等于 current_bid,且检查是 p_amount <= v_lot.current_bid)。先到先得。这是标准拍卖实践,大多数竞拍者都能理解。
---
那位巴斯古董商,顺便说一句,已经在线运营他的月度拍卖两年多了。某个周六晚上的最高并发竞拍者人数达到 84,他整个村子似乎都在看一批争议的乔治亚风格银器以三倍底价成交。Supabase 没有问题。Next.js 没有问题。唯一坏的是他的 Wi-Fi,因为他从店铺地板上运营的。
实时思考起来很难提前考虑,但一旦模式确定了、原子出价函数就位了,剩下的大多就是管道工作。打好基础,你会把时间花在有趣的部分,倒计时动画、"第一次、第二次"的 UX,而不是在午夜调试竞态条件。
