diff --git a/app/api/postcards/route.ts b/app/api/postcards/route.ts index ea69b15..be965b1 100644 --- a/app/api/postcards/route.ts +++ b/app/api/postcards/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server"; import { pool } from "@/lib/db"; import { getCurrentUser } from "@/lib/auth"; -import { ensureImageHashColumn, normalizeImageHash } from "@/lib/postcard-image-hash"; -import { ensurePostcardMetadataColumns, ensurePostcardHiddenColumn } from "@/lib/schema"; +import { normalizeImageHash } from "@/lib/postcard-image-hash"; +import { ensureSchema } from "@/lib/schema"; import type { PostcardCounts } from "@/lib/types"; // GET /api/postcards -> plaza, all postcards (newest first) @@ -15,7 +15,8 @@ function positiveInt(value: string | null, fallback: number) { } export async function GET(request: Request) { - const user = await getCurrentUser(); + // getCurrentUser + one-time schema/index check are independent. + const [user, schema] = await Promise.all([getCurrentUser(), ensureSchema()]); if (!user) return NextResponse.json({ error: "未登录" }, { status: 401 }); const { searchParams } = new URL(request.url); @@ -82,8 +83,7 @@ export async function GET(request: Request) { // 广场视图:排除被认领人隐藏的明信片。 const baseConditions: string[] = []; const baseParams: unknown[] = []; - const hiddenReady = await ensurePostcardHiddenColumn(); - if (hiddenReady) baseConditions.push("p.hidden_by_claimer is not true"); + if (schema.hidden) baseConditions.push("p.hidden_by_claimer is not true"); const conditions = [...baseConditions]; const params = [...baseParams]; @@ -179,8 +179,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: "取件地点过长" }, { status: 400 }); } - const canStoreMetadata = await ensurePostcardMetadataColumns(); - const canStoreHash = imageHash ? await ensureImageHashColumn() : false; + const schema = await ensureSchema(); + const canStoreMetadata = schema.metadata; + const canStoreHash = imageHash ? schema.hash : false; let result; if (canStoreHash && canStoreMetadata) { diff --git a/app/home-client.tsx b/app/home-client.tsx index 043e0ea..b81ad7a 100644 --- a/app/home-client.tsx +++ b/app/home-client.tsx @@ -72,12 +72,16 @@ export default function HomeClient({ nextFilter = filter, nextPage = currentPage, nextPageSize = pageSize, + silent = false, }: { nextFilter?: PostcardFilter; nextPage?: number; nextPageSize?: number; + // silent: reconcile in the background after an optimistic action — no + // grid dimming, no scroll jump. Used so 认领/签收 feel instant. + silent?: boolean; } = {}) => { - setLoadingPage(true); + if (!silent) setLoadingPage(true); setError(null); try { const params = new URLSearchParams({ @@ -100,7 +104,7 @@ export default function HomeClient({ const nextPageCount = Math.max(1, Math.ceil(nextTotal / nextPageSize)); const safePage = Math.min(nextPage, nextPageCount); if (safePage !== nextPage) { - await loadPage({ nextFilter, nextPage: safePage, nextPageSize }); + await loadPage({ nextFilter, nextPage: safePage, nextPageSize, silent }); return; } @@ -109,18 +113,29 @@ export default function HomeClient({ setPage(safePage); setCounts(nextCounts); setPostcards(data.postcards || []); - // 换页/换板块后把列表顶部滚回视野,避免停在旧的滚动位置。 - requestAnimationFrame(() => - gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), - ); + // 换页/换板块后把列表顶部滚回视野;静默重同步(认领后)不打断滚动。 + if (!silent) { + requestAnimationFrame(() => + gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), + ); + } } catch { - setError("网络错误,请重试"); + if (!silent) setError("网络错误,请重试"); } finally { - setLoadingPage(false); + if (!silent) setLoadingPage(false); setPendingFilter(null); } }; + // 认领/签收/取消:先本地即时改状态(乐观),再后台静默与服务器对齐。 + // 让点击"秒响应",不再整格变灰、跳回顶部。 + const optimisticStatus: Record Partial> = { + "claim:POST": () => ({ status: "claimed", claimer_id: currentUserId }), + "receive:POST": () => ({ status: "received" }), + "claim:DELETE": () => ({ status: "available", claimer_id: null }), + "receive:DELETE": () => ({ status: "claimed" }), + }; + const act = async (id: string, path: string) => { await actWithMethod(id, path, "POST"); }; @@ -132,38 +147,44 @@ export default function HomeClient({ ) => { setBusyId(id); setError(null); + const patch = optimisticStatus[`${path}:${method}`]; + if (patch) { + setPostcards((prev) => + prev.map((p) => (p.id === id ? { ...p, ...patch(p) } : p)), + ); + } try { const res = await fetch(`/api/postcards/${id}/${path}`, { method }); const data = await res.json(); if (!res.ok) { setError(data?.error || "操作失败"); - return; + } else { + setDetailId(null); } - setDetailId(null); - await loadPage(); } catch { setError("网络错误,请重试"); } finally { setBusyId(null); + // 后台对齐:修正计数、分页归属,成功/失败都以服务器为准。 + loadPage({ silent: true }); } }; const remove = async (id: string) => { setBusyId(id); setError(null); + // 乐观移除,弹窗立即关闭。 + setPostcards((prev) => prev.filter((p) => p.id !== id)); + setDetailId(null); try { const res = await fetch(`/api/postcards/${id}`, { method: "DELETE" }); const data = await res.json().catch(() => ({})); - if (!res.ok) { - setError(data?.error || "删除失败"); - return; - } - setDetailId(null); - await loadPage(); + if (!res.ok) setError(data?.error || "删除失败"); } catch { setError("网络错误,请重试"); } finally { setBusyId(null); + loadPage({ silent: true }); } }; @@ -237,12 +258,13 @@ export default function HomeClient({ loadingPage ? "pointer-events-none opacity-40" : "" }`} > - {postcards.map((p) => ( + {postcards.map((p, index) => ( act(id, "claim")} onReceive={(id) => act(id, "receive")} onCancelClaim={(id) => actWithMethod(id, "claim", "DELETE")} diff --git a/app/mine/mine-client.tsx b/app/mine/mine-client.tsx index adc0681..007dcd5 100644 --- a/app/mine/mine-client.tsx +++ b/app/mine/mine-client.tsx @@ -68,12 +68,15 @@ export default function MineClient({ nextScope = scope, nextPage = currentPage, nextPageSize = pageSize, + silent = false, }: { nextScope?: Scope; nextPage?: number; nextPageSize?: number; + // silent: 乐观操作后的后台对齐,不变灰、不跳顶部。 + silent?: boolean; } = {}) => { - setLoadingPage(true); + if (!silent) setLoadingPage(true); setError(null); try { const params = new URLSearchParams({ @@ -96,7 +99,7 @@ export default function MineClient({ const nextPageCount = Math.max(1, Math.ceil(nextTotal / nextPageSize)); const safePage = Math.min(nextPage, nextPageCount); if (safePage !== nextPage) { - await loadPage({ nextScope, nextPage: safePage, nextPageSize }); + await loadPage({ nextScope, nextPage: safePage, nextPageSize, silent }); return; } @@ -105,14 +108,16 @@ export default function MineClient({ setPage(safePage); setScopeCounts(nextCounts); setPostcards(data.postcards || []); - // 换页/换 tab 后把列表顶部滚回视野,避免停在旧的滚动位置。 - requestAnimationFrame(() => - gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), - ); + // 换页/换 tab 后把列表顶部滚回视野;静默重同步不打断滚动。 + if (!silent) { + requestAnimationFrame(() => + gridRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), + ); + } } catch { - setError("网络错误,请重试"); + if (!silent) setError("网络错误,请重试"); } finally { - setLoadingPage(false); + if (!silent) setLoadingPage(false); setPendingScope(null); } }; @@ -124,38 +129,39 @@ export default function MineClient({ const receiveWithMethod = async (id: string, method: "POST" | "DELETE") => { setBusyId(id); setError(null); + // 乐观改状态:确认签收 -> received,取消签收 -> claimed。 + const nextStatus = method === "POST" ? "received" : "claimed"; + setPostcards((list) => + list.map((p) => (p.id === id ? { ...p, status: nextStatus } : p)), + ); try { const res = await fetch(`/api/postcards/${id}/receive`, { method }); const data = await res.json(); - if (!res.ok) { - setError(data?.error || "操作失败"); - return; - } - setDetailId(null); - await loadPage(); + if (!res.ok) setError(data?.error || "操作失败"); + else setDetailId(null); } catch { setError("网络错误,请重试"); } finally { setBusyId(null); + loadPage({ silent: true }); } }; const cancelClaim = async (id: string) => { setBusyId(id); setError(null); + // 取消认领后会离开"我认领的"列表,先本地移除。 + setPostcards((list) => list.filter((p) => p.id !== id)); + setDetailId(null); try { const res = await fetch(`/api/postcards/${id}/claim`, { method: "DELETE" }); const data = await res.json().catch(() => ({})); - if (!res.ok) { - setError(data?.error || "取消认领失败"); - return; - } - setDetailId(null); - await loadPage(); + if (!res.ok) setError(data?.error || "取消认领失败"); } catch { setError("网络错误,请重试"); } finally { setBusyId(null); + loadPage({ silent: true }); } }; @@ -184,19 +190,17 @@ export default function MineClient({ const remove = async (id: string) => { setBusyId(id); setError(null); + setPostcards((list) => list.filter((p) => p.id !== id)); + setDetailId(null); try { const res = await fetch(`/api/postcards/${id}`, { method: "DELETE" }); const data = await res.json().catch(() => ({})); - if (!res.ok) { - setError(data?.error || "删除失败"); - return; - } - setDetailId(null); - await loadPage(); + if (!res.ok) setError(data?.error || "删除失败"); } catch { setError("网络错误,请重试"); } finally { setBusyId(null); + loadPage({ silent: true }); } }; diff --git a/app/page.tsx b/app/page.tsx index 17093c4..d479cbb 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ import { getCurrentUser } from "@/lib/auth"; import { pool } from "@/lib/db"; -import { getTotalReceived, ensurePostcardHiddenColumn } from "@/lib/schema"; +import { getTotalReceived, ensureSchema } from "@/lib/schema"; import Nav from "@/components/nav"; import HomeClient from "./home-client"; import type { Postcard, PostcardCounts } from "@/lib/types"; @@ -9,18 +9,20 @@ export const dynamic = "force-dynamic"; const DEFAULT_PAGE_SIZE = 21; export default async function HomePage() { - const user = await getCurrentUser(); + // Session lookup and the one-time schema/index check are independent — run + // them together instead of serially. + const [user, schema] = await Promise.all([getCurrentUser(), ensureSchema()]); // Middleware guarantees a session, but guard anyway. if (!user) return null; - const totalReceived = await getTotalReceived(); - // 排除被认领人隐藏的明信片(广场对所有人不可见)。 - const hiddenReady = await ensurePostcardHiddenColumn(); - const listHidden = hiddenReady ? "where p.hidden_by_claimer is not true" : ""; - const countHidden = hiddenReady ? "where hidden_by_claimer is not true" : ""; + const listHidden = schema.hidden ? "where p.hidden_by_claimer is not true" : ""; + const countHidden = schema.hidden ? "where hidden_by_claimer is not true" : ""; - const [result, countResult] = await Promise.all([ + // Cumulative counter, plaza page, and status counts have no data dependency + // on each other — fetch all three in parallel. + const [totalReceived, result, countResult] = await Promise.all([ + getTotalReceived(), pool.query( ` select diff --git a/components/postcard-card.tsx b/components/postcard-card.tsx index 691e728..7f1fe74 100644 --- a/components/postcard-card.tsx +++ b/components/postcard-card.tsx @@ -1,18 +1,20 @@ "use client"; +import { memo, useState } from "react"; import Image from "next/image"; import { STATUS_LABEL, type Postcard } from "@/lib/types"; const STATUS_STYLE: Record = { - available: "bg-primary/10 text-primary", + available: "bg-primary text-primary-foreground", claimed: "bg-amber-100 text-amber-700", received: "bg-emerald-100 text-emerald-700", }; -export default function PostcardCard({ +function PostcardCard({ postcard, currentUserId, busy, + priority, onClaim, onReceive, onCancelClaim, @@ -22,6 +24,7 @@ export default function PostcardCard({ postcard: Postcard; currentUserId: string; busy?: boolean; + priority?: boolean; onClaim?: (id: string) => void; onReceive?: (id: string) => void; onCancelClaim?: (id: string) => void; @@ -29,6 +32,7 @@ export default function PostcardCard({ onOpen?: (id: string) => void; }) { const isClaimer = postcard.claimer_id === currentUserId; + const [imgLoaded, setImgLoaded] = useState(false); // Action buttons live inside the clickable card — stop them from also // opening the detail modal. const stop = @@ -44,16 +48,24 @@ export default function PostcardCard({ onOpen ? "cursor-pointer" : "" }`} > -
+
+ {/* Shimmer placeholder until the image paints — avoids a hard pop-in. */} + {!imgLoaded && ( + + )} {/* Blob images are on an allowed remote host; unoptimized keeps it simple */} {`寄给 setImgLoaded(true)} + className={`object-cover transition-opacity duration-500 ${ + imgLoaded ? "opacity-100" : "opacity-0" + }`} /> ); } + +// Lists re-render on every claim/receive/paging action; memo skips cards whose +// props are unchanged so only the affected card (and its image) touch the DOM. +export default memo(PostcardCard); diff --git a/components/postcard-detail.tsx b/components/postcard-detail.tsx index 91b5174..9e697f4 100644 --- a/components/postcard-detail.tsx +++ b/components/postcard-detail.tsx @@ -6,7 +6,7 @@ import { STATUS_LABEL, type Postcard, type PostcardUpdateInput } from "@/lib/typ import { COMMON_PICKUP_LOCATIONS } from "@/lib/pickup-locations"; const STATUS_STYLE: Record = { - available: "bg-primary/10 text-primary", + available: "bg-primary text-primary-foreground", claimed: "bg-amber-100 text-amber-700", received: "bg-emerald-100 text-emerald-700", }; diff --git a/db/migrations.sql b/db/migrations.sql new file mode 100644 index 0000000..3c97f1c --- /dev/null +++ b/db/migrations.sql @@ -0,0 +1,45 @@ +-- PostBack schema & performance indexes (idempotent). +-- +-- The app also applies all of this automatically on first request via +-- lib/schema.ts `ensureSchema()`, so pushing the code is enough to keep the site +-- working. Running this once by hand in the Supabase SQL editor is still +-- recommended right after deploy: it builds the indexes ahead of time so the +-- very first production request doesn't pay for the index creation, and lets you +-- optionally use CREATE INDEX CONCURRENTLY on a large, live table. +-- +-- Safe to run multiple times — every statement is `if not exists` / `on conflict`. + +-- Columns added incrementally as features shipped. +alter table public.postcards add column if not exists pickup_location text; +alter table public.postcards add column if not exists hidden_by_claimer boolean not null default false; +alter table public.postcards add column if not exists image_hash text; +alter table public.users add column if not exists recipient_names text[] not null default '{}'; + +-- Indexes for the hot list/count queries. +-- 广场 & 我的-上传: order by created_at desc +-- 状态筛选 & 计数: where/ group by status +-- 我的-认领: where claimer_id order by claimed_at desc +-- 我的-上传: where uploader_id order by created_at desc +-- 去重: where image_hash +-- 广场默认视图: created_at desc, 排除隐藏 (partial index) +create index if not exists postcards_created_at_idx on public.postcards (created_at desc); +create index if not exists postcards_status_idx on public.postcards (status); +create index if not exists postcards_claimer_idx on public.postcards (claimer_id, claimed_at desc); +create index if not exists postcards_uploader_idx on public.postcards (uploader_id, created_at desc); +create index if not exists postcards_image_hash_idx on public.postcards (image_hash); +create index if not exists postcards_plaza_idx + on public.postcards (created_at desc) + where hidden_by_claimer is not true; + +-- getCurrentUser() joins sessions -> users on every request. +create index if not exists sessions_user_id_idx on public.sessions (user_id); + +-- Cumulative "已签收" counter (single-row table). +create table if not exists public.site_stats ( + id smallint primary key default 1, + total_received bigint not null default 0, + constraint site_stats_singleton check (id = 1) +); +insert into public.site_stats (id, total_received) +select 1, (select count(*) from public.postcards where status = 'received') +on conflict (id) do nothing; diff --git a/lib/db.ts b/lib/db.ts index 108e769..899b920 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -8,13 +8,27 @@ types.setTypeParser(types.builtins.DATE, (value) => value); // Reuse a single pool across hot reloads / serverless invocations. const globalForPg = globalThis as unknown as { pgPool?: Pool }; +// On Vercel each serverless instance is short-lived and mostly handles one +// request at a time, so a large per-instance pool just wastes Postgres +// connection slots. Keep `max` small and let idle connections drop quickly. +// +// IMPORTANT (Supabase): point DATABASE_URL at the *transaction pooler* so many +// serverless instances share a small set of real Postgres connections instead +// of each opening its own: +// postgresql://postgres.:@aws-0-.pooler.supabase.com:6543/postgres +// node-postgres never uses server-side prepared statements here (we pass no +// query `name`), so it is compatible with the transaction (6543) pooler. export const pool = globalForPg.pgPool ?? new Pool({ connectionString: process.env.DATABASE_URL, - max: 5, - idleTimeoutMillis: 30000, + // 1 real connection per instance is plenty behind the shared pooler. + max: 3, + // Drop idle sockets fast so a frozen/idle function doesn't hold a slot. + idleTimeoutMillis: 10000, connectionTimeoutMillis: 5000, + // Let the Node process exit cleanly once all connections go idle. + allowExitOnIdle: true, ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false, }); diff --git a/lib/schema.ts b/lib/schema.ts index d28542c..3fbff7c 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -5,6 +5,54 @@ let userRecipientNamesReady = false; let postcardHiddenReady = false; let siteStatsReady = false; +export type SchemaFlags = { metadata: boolean; hidden: boolean; hash: boolean }; + +// Consolidated, once-per-process schema check for the hot read paths +// (广场 / 我的 / 首屏). Batches every `add column if not exists`, the +// performance indexes, and the site_stats singleton into ONE round trip so a +// cold start pays a single query instead of 3–4 serial ones. Idempotent, so a +// fresh Supabase database is brought fully up to date on first request; an +// existing database just no-ops. Failure degrades gracefully via the returned +// flags, exactly like the granular helpers below. +let schemaFlags: SchemaFlags | null = null; +export async function ensureSchema(): Promise { + if (schemaFlags) return schemaFlags; + try { + await pool.query(` + alter table public.postcards add column if not exists pickup_location text; + alter table public.postcards add column if not exists hidden_by_claimer boolean not null default false; + alter table public.postcards add column if not exists image_hash text; + + create index if not exists postcards_created_at_idx on public.postcards (created_at desc); + create index if not exists postcards_status_idx on public.postcards (status); + create index if not exists postcards_claimer_idx on public.postcards (claimer_id, claimed_at desc); + create index if not exists postcards_uploader_idx on public.postcards (uploader_id, created_at desc); + create index if not exists postcards_image_hash_idx on public.postcards (image_hash); + create index if not exists postcards_plaza_idx + on public.postcards (created_at desc) + where hidden_by_claimer is not true; + + create table if not exists public.site_stats ( + id smallint primary key default 1, + total_received bigint not null default 0, + constraint site_stats_singleton check (id = 1) + ); + insert into public.site_stats (id, total_received) + select 1, (select count(*) from public.postcards where status = 'received') + on conflict (id) do nothing; + `); + // Everything above is now guaranteed; short-circuit the granular helpers. + postcardMetadataReady = true; + postcardHiddenReady = true; + siteStatsReady = true; + schemaFlags = { metadata: true, hidden: true, hash: true }; + return schemaFlags; + } catch (err) { + console.error("Failed to ensure schema:", err); + return { metadata: false, hidden: false, hash: false }; + } +} + export async function ensurePostcardMetadataColumns() { if (postcardMetadataReady) return true; try { diff --git a/package.json b/package.json index 1ccc2ff..5d9a4a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postback", - "version": "0.1.2", + "version": "0.1.5", "private": true, "scripts": { "dev": "next dev",