realtime-auction-nextjs-supabase.html
< BACK 空荡荡的拍卖厅,编号拍品牌在温暖的聚光灯下,天鹅绒椅子和木制竞价锤,电影编辑风格

使用 Next.js 和 Supabase 构建实时拍卖网站

2021 年的一个周四下午,一位客户打电话给我——一位来自巴斯的古董商,想把他每月的线下拍卖转到网上。我觉得足够简单。然后他说"竞价需要实时更新给所有观看者,无需刷新页面"。好吧。那是一个"简单的 WordPress 工作"变成了为期两周的架构讨论的时刻。

在 Seahawk Media,我构建了超过 5000 个网站,实时功能是那些如果你从一开始没有妥善规划就会咬你的东西。每五秒轮询听起来不错,直到你有 200 个竞价者同时猛击一个端点,你的托管账单在一夜间翻倍。所以让我向你展示我今天会如何构建一个适当的在线拍卖平台——使用 Next.js 和 Supabase——基于我实际交付的经验。Seahawk Media, and real-time features are the ones that bite you if you don't plan them properly from the start. Polling every five seconds sounds fine until you have 200 bidders hammering a single endpoint simultaneously and your hosting bill doubles overnight. So let me show you exactly how I'd build a proper live auction platform today — usingNext.jsandSupabase— based on what I've actually shipped.

---

为什么为此选择 Next.js 和 Supabase

看,有十几种方式来实现实时。Node 服务器上的 Socket.io、Ably、Pusher、Firebase——我在不同的时间点都用过所有的。但 Next.js + Supabase 的组合在这里赢得了一席之地是有具体原因的:Supabase Realtime 构建在 PostgreSQL 的逻辑复制之上,这意味着你的实时竞价更新和你的持久化数据层是同一个系统。无需同步两个信息源。无需担心进入 WebSocket 的竞价是否也进入了数据库。same system. No syncing two sources of truth. No wondering if a bid that went into the WebSocket also made it into the database.

Supabase 还为你提供开箱即用的 Auth、行级安全 (RLS) 和存储。对于拍卖网站,"只有拍卖所有者可以结束一个拍品" 和 "用户不能对自己的物品出价" 这些都是真实的业务规则,Postgres 中的 RLS 策略确实是正确的工具。

还有 Next.js,因为老实说,App Router 配合 Server Components 意味着你可以静态渲染拍卖目录,让 SEO 满意,只在客户端注水实时竞价小部件。这种分离很重要。你不想为一个 90% 是静态内容的页面付出动态渲染的代价。

---

先设计数据库模式(不要跳过这一步)

大多数人在这里匆匆忙忙,后来都后悔了。我曾为浴室古董客户花了令人尴尬的三天时间重构模式,原因是我没有充分考虑出价历史模型。

这是我现在使用的核心结构:

  • `profiles` — 扩展 Supabase 的 auth.users,存储显示名称、认证竞拍者标志和 credit_balance(如果你使用基于押金的竞拍)— extends Supabase'sauth.users, stores display name, verified bidder flag, and acredit_balanceif you're doing deposit-based bidding
  • `auctions` — 拍卖事件本身;starts_at、ends_at、status(draft | live | closed)和 created_by— the event itself;starts_at,ends_at,status(draft | live | closed), andcreated_by
  • `lots` — 拍卖中的单个物品;reserve_price、current_bid、current_bidder_id、lot_number、ends_at(拍品可以有单独的倒计时)— individual items within an auction;reserve_price,current_bid,current_bidder_id,lot_number,ends_at(lots can have individual countdowns)
  • `bids` — 不可变的仅追加日志;lot_id、bidder_id、amount、placed_at。永远不要更新这个表。绝对不要。— immutable append-only log;lot_id,bidder_id,amount,placed_at. Never update this table. Ever.
  • `auction_participants` — 一个连接表,用于跟踪谁已注册参与哪场拍卖(对保证金冻结和通知目标定位很有用)— a join table tracking who has registered for which auction (useful for deposit holds and notification targeting)

lots 表上的 current_bid 和 current_bidder_id 列是故意反范式化的。是的,你可以在每次读取时从 bids 表派生它们,但在并发负载下那个查询很快就会变得昂贵。反范式化它,保持 bids 表作为你的审计日志,并使用 Postgres 函数在接受出价时原子地更新 lots。current_bidandcurrent_bidder_idcolumns onlotsare denormalised intentionally. Yes, you could derive them from thebidstable on every read, but under concurrent load that query gets expensive fast. Denormalise it, keep thebidstable as your audit log, and use a Postgres function to updatelotsatomically when a bid is accepted.

原子出价函数

这是大多数教程跳过的部分。拍卖中的竞态条件是真实存在的。两个用户在同一毫秒内提交 £520 — 会发生什么?real. Two users submitting £520 at the same millisecond — what happens?

答案是一个在 lot 行上使用 FOR UPDATE 锁定的 Postgres 函数:FOR UPDATElocking on the lot row:

``` 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 在任何给定时刻只有一个交易能赢。另一个会收到序列化错误,你在客户端返回一个友好的"有人刚刚出价更高"的消息。supabase.rpc('place_bid', {...}). TheFOR UPDATElock means only one transaction wins per lot at any given moment. The other one gets a serialisation error and you return a friendly "someone just outbid you" message on the client.

---

行级安全性 — 拍卖规则层

RLS 是那些开发者要么立刻喜欢,要么因为感觉不透明而回避的东西之一。在我参与的 Seahawk 金融科技项目之前,我属于回避派,直到那个项目用惨痛的教训告诉我,只在应用代码中强制执行访问控制距离灾难只有一个配置错误的 API 路由之遥。

对于拍卖网站,这些策略很重要:

  1. 任何人都可以读取活跃的 lot — 在 auctions.status = 'live' 的 lots 上进行 SELECTSELECTonlotswhereauctions.status = 'live'
  2. 只有经过身份验证的认证竞标者才能插入出价 — 在策略中检查 profiles.verified_bidder = true— checkprofiles.verified_bidder = truein the policy
  3. 只有拍卖创建者可以更新 lot 状态 — 在 auctions.created_by = auth.uid() 的 lots 上进行 UPDATEUPDATEonlotswhereauctions.created_by = auth.uid()
  4. 竞价历史只有拍卖品创建者和竞价者本人可以查看——其他人无需在实时看到完整的竞价历史— no one else needs to see full bid history in real time

Supabase RLS 文档在这里确实很不错——值得阅读关于 security definer 函数的部分,因为它会影响 place_bid 这样的 RPC 调用如何工作。Supabase RLS documentationis genuinely good here — worth reading the section on security definer functions, because it interacts with how RPC calls likeplace_bidwork.

一个需要注意的地方:如果你在 Postgres 函数上使用 security definer(如上所示),它会以函数所有者的权限运行,绕过 RLS。这是有意设计的——你希望竞价放置绕过竞价者的 RLS,这样它就能锁定并更新拍卖品行。但这意味着你必须在函数内部强制执行自己的业务逻辑检查,上面的代码就是这样做的。security defineron your Postgres function (as above), it runs with the function owner's privileges, bypassing RLS. That's intentional — youwantthe bid placement to bypass the bidder's RLS so it can lock and update the lot row. But it means you must enforce your own business logic checks inside the function, which the code above does.

---

在 Next.js 中设置 Supabase Realtime

这就是真正令人满意的地方。Supabase Realtime 让你能够使用 WebSockets 订阅 Postgres 表的更改,客户端 SDK 让这变得几乎不费力。WebSockets underneath, and the client SDK makes it almost embarrassingly simple.

在你的拍卖品页面中——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()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–300 毫秒内更新。initialBidfrom a Server Component that fetches fresh data at request time. The client then takes over, listening forUPDATEevents on that specific lot row. Every timeplace_bidruns successfully, Supabase broadcasts the change and every connected bidder's UI updates within about 100–300ms typically.

处理倒计时计时器

竞拍通常有倒计时——"3:42 后结束"。不要依赖客户端时钟。从 lots.ends_at(存储在 Postgres 中的 UTC 时间)获取结束时间,并在客户端使用 Date.now() 计算剩余秒数。每 60 秒重新同步一次(通过新的获取请求)以防时间偏差。并添加"软关闭"逻辑:如果竞价在最后 60 秒内到达,将 ends_at 延长两分钟。这是标准的竞拍行为,竞拍者期望如此。nottrust the client clock for this. Derive the end time fromlots.ends_at(stored in UTC in Postgres) and calculate remaining seconds on the client usingDate.now(). Re-sync it every 60 seconds with a fresh fetch in case of drift. And add "soft close" logic: if a bid arrives within the last 60 seconds, extendends_atby two minutes. That's standard auction behaviour and bidders expect it.

---

Next.js App Router 竞拍 UI 架构

我使用的页面结构:

`` app/ auctions/ page.tsx ← 服务器组件,列出现场竞拍(ISR,revalidate: 60) [auctionId]/ page.tsx ← 服务器组件,服务器端获取竞拍物品列表 LotGrid.tsx ← 客户端组件,订阅竞拍物品状态变化 [lotId]/ page.tsx ← 服务器组件,初始竞拍物品数据 + SEO 元数据 BidPanel.tsx ← 客户端组件,实时竞价显示 + 竞价表单 ``app/ auctions/ page.tsx ← Server Component, lists live auctions (ISR, revalidate: 60) [auctionId]/ page.tsx ← Server Component, fetches lots list server-side LotGrid.tsx ← Client Component, subscribes to lot status changes [lotId]/ page.tsx ← Server Component, initial lot data + metadata for SEO BidPanel.tsx ← Client Component, real-time bid display + bid form``

目录(/auctions)使用增量静态再生成,重新验证时间为60秒。单个拍品页面在首次加载时进行服务器端渲染(用于分享、预览、og:image生成),然后交由客户端组件处理实时内容。/auctions) uses Incremental Static Regeneration with a 60-second revalidation. Individual lot pages render server-side on first load (for sharing, previewing, og:image generation), then hand off to client components for the live stuff.

我总是做的一件事:将BidPanel组件懒加载到dynamic(() => import('./BidPanel'), { ssr: false })后面。它本来就只在客户端有意义,而且能让初始HTML有效载荷保持精简——特别是对于网络连接较慢的用户,这一点很重要——如果你的拍卖受众偏向年长(古董拍卖往往如此),这比你想象的更有意义。BidPanelcomponent lazy-loaded behinddynamic(() => import('./BidPanel'), { ssr: false }). It only makes sense client-side anyway, and it keeps your initial HTML payload lean for users on slow connections — which, if your auction audience skews older (as antique auctions tend to), matters more than you'd expect.

---

身份验证和"已验证竞拍者"流程

标准的Supabase身份验证,使用邮箱/密码或魔法链接,适用于注册。但拍卖通常需要额外一步:竞拍者验证。你可能需要信用卡预授权、身份验证,或者只是管理员批准,才能让某人实际出价。

我使用的模式:在profiles表上设一个verified_bidder布尔值,默认为false。注册后,用户看到"完成你的注册"屏幕。一旦获批(由管理员手动批准,或Stripe支付授权自动完成后),你翻转该标志。bids上的RLS策略会检查它。他们可以浏览、关注,但在验证前无法出价。verified_bidderboolean on theprofilestable, defaulting tofalse. After sign-up, the user sees a "Complete your registration" screen. Once approved (manually by admin, or automatically after a Stripe payment authorisation), you flip the flag. The RLS policy on bids checks it. They can browse, watch, but not bid until verified.

对于Stripe支付授权预授权,Stripe的payment intents配合capture_method: manual是正确的方式——你授权£50预授权,如果他们赢了就捕获,没赢就释放。这大大减少了不付款的情况,相信我,这是每个在线拍卖运营商的噩梦。Stripe's payment intents with capture_method: manualis the right approach — you authorise a £50 hold, capture it if they win, release it if they don't. This dramatically reduces no-pay situations which, trust me, are the bane of every online auction operator's existence.

---

部署、性能和会让你吃亏的细节

部署到Vercel——这是Next.js的显而易见的选择,边缘网络与Supabase的全球基础设施配合良好。确保你的Supabase项目位于离Vercel部署区域最近的AWS区域。我见过有人在us-east-1部署Vercel,在eu-west-2部署Supabase,结果产生了40–60ms完全不必要的延迟。选择一个区域,把两个都放在那里。Vercel— it's the obvious choice for Next.js and the edge network plays well with Supabase's global infrastructure. Make sure your Supabase project is in the AWS region closest to your Vercel deployment region. I've seen 40–60ms of completely unnecessary latency because someone deployed Vercel inus-east-1and Supabase ineu-west-2. Pick one region, put both there.

如果你不提前处理这些问题,会给你带来痛苦:

  • WebSocket 连接限制。Supabase 免费计划允许大约 200 个并发 Realtime 连接。如果你的拍卖走红,这个上限很重要。检查你的计划。Supabase's free tier allows around 200 concurrent Realtime connections. If your auction goes viral, that cap matters. Check your plan.
  • 竞价的乐观 UI。在服务器确认之前立即在竞价人的屏幕上显示竞价。如果失败(被超价、竞态条件),以错误形式回退。200–300ms 的服务器往返时间是无感知的,除非 UI 等待它。Show the bid immediately on the bidder's screen before the server confirms. If it fails (outbid, race condition), revert with an error. The 200–300ms server round-trip is imperceptibleunlessthe UI waits for it.
  • 拍品结束宽限期。不要在 ends_at 时刻精确关闭拍品。在服务器端给予 2–3 秒的缓冲时间,以允许在截止时间之前提交的在途竞价得以处理。在你的 close_lot 定时函数中处理这个问题。Never close a lot exactly atends_at. Give it a 2–3 second server-side buffer to allow in-flight bids that were submitted just before the deadline to process. Handle this in yourclose_lotscheduled function.
  • 邮件通知。使用 Supabase Edge Functions 配合 Resend 或 Postmark 发送"你被超价了"和"你赢了!"电子邮件。不要试图从你的 Next.js API 路由做这件事——它们会超时,拍卖参与者会为不可靠的通知感到真正恼火。Use Supabase Edge Functions with Resend or Postmark to send "You've been outbid" and "You won!" emails. Don't try to do this from your Next.js API routes — they can time out, and auction participants get genuinely annoyed if notifications are unreliable.

---

常见问题

Supabase Realtime 能处理多少个并发竞价人?

Supabase 的 Pro 计划默认支持最多 500 个并发 Realtime 连接,更高的限制也可用。对于大多数拍卖网站——除非你运行的是苏富比在线那样规模的东西——那已经绰绰有余了。如果你期望有数千个同时观众,考虑通过单个服务器端频道广播拍品更新,而不是每用户订阅,并查看 Supabase 的 Realtime Broadcast 功能,它对高扇出场景更高效。Realtime Broadcastfeature which is more efficient for high fan-out scenarios.

我应该使用 Supabase Realtime 还是 Ably 这样的专用服务?

对于大多数项目,Supabase Realtime 完全足够,而且集成简单得多,因为你的数据已经在 Supabase 中。只有当你需要全球范围内低于 50ms 的延迟,或者你正在构建有数百万并发连接的东西时,我才会考虑使用 Ably 或 Pusher。古董拍卖、慈善募捐、小画廊的在线销售——Supabase 都能很好地处理。

如果用户的 WebSocket 连接在拍卖中途断开会怎样?

Supabase 的客户端 SDK 会尝试自动重新连接。但你应该始终在重新连接时重新获取当前拍品的状态(current_bid、ends_at),而不是信任连接断开前本地状态中的任何内容。在你的 Client Component 中添加一个在线/离线事件监听器,当连接恢复时触发一次新的服务器获取。current_bid,ends_at) on reconnection rather than trusting whatever was in local state before the drop. Add anonline/offlineevent listener in your Client Component and trigger a fresh server fetch when the connection restores.

我可以用 Next.js Server Actions 代替 API 路由来出价吗?

可以,我这样做过。Next.js 14 中的 Server Actions 很方便——它们消除了专用 /api/bid 路由的样板代码。权衡是 Server Actions 稍微难以单独进行速率限制(你需要在中间件级别应用速率限制,而不是针对每个 Action)。对于生产拍卖网站,我会在中间件中添加 Upstash Redis 速率限制,以防止单个用户无论你使用 Actions 还是 API 路由都会疯狂发送出价请求。/api/bidroute. The tradeoff is that Server Actions are a bit harder to rate-limit individually (you'd apply rate limiting at the middleware level rather than per-action). For a production auction site, I'd addUpstash Redisrate limiting in middleware to prevent a single user from spamming bid requests regardless of whether you use Actions or API routes.

我如何处理平局——两个相同的出价在同一时间进行?

place_bid Postgres 函数中的 FOR UPDATE 锁将并发出价序列化,所以从技术上讲,平局不可能在数据库级别发生。一个会成功,另一个会因"出价过低"而失败(因为两者都等于 current_bid,检查是 p_amount <= v_lot.current_bid)。先到先得。这是标准的拍卖实践,大多数竞拍者都理解这一点。FOR UPDATElock in theplace_bidPostgres function serialises concurrent bids, so technically ties can't happen at the database level. One will succeed, the other will fail with a "bid too low" response (since both are equal tocurrent_bidand the check isp_amount <= v_lot.current_bid). First in, first served. That's standard auction practice and most bidders understand it.

---

那位巴斯古董商人,顺便说一下,已经在线运营他的月度拍卖两年多了。某个周六晚上的峰值并发竞拍者达到 84 人——他的整个村庄似乎都在看一件争议的格鲁吉亚银器以其底价的三倍成交。Supabase 没有动摇。Next.js 没有动摇。唯一坏掉的是他的 Wi-Fi,因为他是从店铺地板上运行的。

实时处理起初很难想清楚,但一旦架构确定,原子出价函数就位,其余的大多就是管道工作。把基础做对,你就能把时间花在有趣的部分——倒计时动画、"第一次,第二次"的用户体验——而不是在午夜调试竞态条件。

< BACK