生产环境中 Dashboard 支付数据未更新
结论
cache: "no-store" 只表示当前这次 fetch 不使用 Next.js Data Cache。它不能保证整条数据链路都没有缓存,也不会在 Webhook 更新数据库后主动刷新用户已经打开的 Dashboard。
这个问题一般来自两个方面:
/api/payments、Dashboard 页面或前置 CDN 在生产环境中仍然经过静态化或缓存。- Webhook 更新 MongoDB 后没有让缓存失效。即使服务端缓存已经失效,浏览器中已经渲染的 Dashboard 也不会自动发起新请求。
支付状态需要及时更新,建议采用以下做法:
- Server Component 直接查询数据库,不通过生产域名调用应用自己的 API。
- 明确将相关页面和 Route Handler 设为动态。
- 如果需要缓存,使用
revalidateTag()按需让缓存失效。 - Webhook 成功更新数据库后再触发重新验证。
- 客户端通过
router.refresh()、轮询或实时推送更新界面。
为什么 cache: "no-store" 仍可能读到旧数据
当前调用链如下:
Dashboard Server Component
↓
fetch(production-domain/api/payments)
↓
Route Handler
↓
MongoDB
这条链路不止一层缓存。
no-store 只影响发起请求的一侧
下面的配置:
fetch(url, {
cache: "no-store",
});
表示当前 fetch 不从 Next.js Data Cache 读取结果,也不把结果写入这个缓存。
它无法自动保证:
/api/payments没有被 Route Handler 缓存;- Dashboard 页面没有进入 Full Route Cache;
- 部署平台或 CDN 没有缓存
/api/payments; - 浏览器中已经打开的 Dashboard 会重新渲染;
- Webhook 写入数据库后,旧缓存会被主动删除。
手动执行 Purge Everything 后能看到新数据,说明读取链路中确实存在缓存,但不能据此判断 no-store 没有生效。
生产环境和开发环境的缓存行为不同
开发模式会频繁重新编译并执行服务端代码,部分缓存行为也会被弱化,因此这个问题不容易出现。
生产构建会启用页面静态化、路由缓存、Data Cache 和 CDN 优化。默认是否缓存还取决于 Next.js 版本、路由写法和部署平台。与其依赖默认行为,不如明确声明动态策略。
Webhook 不会自动刷新浏览器页面
Webhook 在服务器上更新 MongoDB,用户当前打开的 Dashboard 则来自另一个独立请求。
即使数据库和服务端缓存都已经更新,浏览器中已经渲染的 HTML 也不会自行变化。页面需要通过以下任一方式重新获取数据:
- 用户重新加载页面;
- 客户端调用
router.refresh(); - 客户端定时轮询;
- 使用 Server-Sent Events 或 WebSocket 推送更新。
方案一:直接查询数据库,不在 Server Component 中调用自己的 API
UsersInfo 本来就运行在服务端,没有必要通过生产域名访问 /api/payments。可以提取一个专门查询 MongoDB 的服务函数,供 Server Component 和 Route Handler 共用。
// lib/payments.js
import clientPromise from "@/utils/mongodb";
export async function getPayments() {
const client = await clientPromise;
const db = client.db(process.env.MONGODB_DB);
return db.collection("payments").find({}).toArray();
}
然后在页面中直接调用:
import { getPayments } from "@/lib/payments";
import { currentUser } from "@clerk/nextjs/server";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export default async function UsersInfo() {
try {
const user = await currentUser();
if (!user) {
return (
<div>
<p>Status: User not found</p>
<p>Exam: N/A</p>
<p>Full Name: N/A</p>
<p>Email: N/A</p>
</div>
);
}
const payments = await getPayments();
const userPayment = payments.find(
(payment) => payment.clerkUser?.userID === user.id
);
if (!userPayment) {
return (
<div>
<p>Status: FREE</p>
<p>Exam: No Specified</p>
<p>Full Name: {user.fullName}</p>
<p>Email: {user.primaryEmailAddress?.emailAddress}</p>
</div>
);
}
return (
<div>
<p>
Status: {userPayment.lemonsqueezyUser?.paymentsInfo?.status}
</p>
<p>Exam: {userPayment.examName}</p>
<p>Full Name: {userPayment.clerkUser?.userFullName}</p>
<p>Email: {userPayment.clerkUser?.userEmail}</p>
</div>
);
} catch (error) {
console.error("Error loading user payment info:", error);
return <p>Status: Error loading data</p>;
}
}
这里需要明确配置:
export const dynamic = "force-dynamic";
export const revalidate = 0;
这两项配置会告诉 Next.js,该路由需要在收到请求时执行,不应把它当作静态页面长期复用。
支付信息属于用户私有数据。与其读取全部支付记录后再调用 .find(),更合理的方式是直接按 user.id 查询:
export async function getPaymentByUserId(userId) {
const client = await clientPromise;
const db = client.db(process.env.MONGODB_DB);
return db.collection("payments").findOne({
"clerkUser.userID": userId,
});
}
这样既能减少读取的数据量,也不会把其他用户的支付记录加载到当前请求中。
方案二:保留 /api/payments,并明确设为动态
如果客户端或其他服务确实需要调用这个 API,可以继续保留 Route Handler,但要明确禁止静态缓存:
import clientPromise from "@/utils/mongodb";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET() {
try {
const client = await clientPromise;
const db = client.db(process.env.MONGODB_DB);
const payments = await db.collection("payments").find({}).toArray();
return NextResponse.json(
{ payments },
{
status: 200,
headers: {
"Cache-Control": "no-store, max-age=0",
},
}
);
} catch (error) {
console.error("Failed to load payments:", error);
return NextResponse.json(
{ message: "Failed to load payments" },
{
status: 500,
headers: {
"Cache-Control": "no-store, max-age=0",
},
}
);
}
}
调用端继续使用:
export async function getUsersPaymentsData() {
const res = await fetch(
`${config.domainNameProduction}/api/payments`,
{
cache: "no-store",
}
);
if (!res.ok) {
throw new Error(`Failed to fetch payments: ${res.status}`);
}
return res.json();
}
不要捕获异常后隐式返回 undefined,否则后续代码可能在这里再次报错:
data.payments.find(...)
如果需要捕获异常,应在记录错误后继续抛出:
export async function getUsersPaymentsData() {
try {
const res = await fetch(
`${config.domainNameProduction}/api/payments`,
{
cache: "no-store",
}
);
if (!res.ok) {
throw new Error(`Failed to fetch payments: ${res.status}`);
}
return await res.json();
} catch (error) {
console.error("Error loading payments:", error);
throw error;
}
}
如果应用前面还有 Cloudflare、反向代理或其他 CDN,也要检查 /api/payments 是否命中了单独配置的 Cache Rule。Next.js 的 cache: "no-store" 不能覆盖外部 CDN 的强制缓存规则。
方案三:使用缓存标签,由 Webhook 精确失效
如果支付数据访问量较大,不想让每个 Dashboard 请求都查询数据库,可以保留缓存,并在 Webhook 写入成功后调用 revalidateTag()。
具体 API 取决于 Next.js 版本。下面是标签重新验证的基本结构,使用前需要按照当前项目版本确认 unstable_cache 和 revalidateTag 的签名。
// lib/payments.js
import { unstable_cache } from "next/cache";
import clientPromise from "@/utils/mongodb";
export const getPayments = unstable_cache(
async () => {
const client = await clientPromise;
const db = client.db(process.env.MONGODB_DB);
return db.collection("payments").find({}).toArray();
},
["payments"],
{
tags: ["payments"],
}
);
Webhook 在数据库更新成功后让标签失效:
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import clientPromise from "@/utils/mongodb";
export async function POST(request) {
try {
const event = await request.json();
// 这里必须先验证 payment provider 的 Webhook 签名。
// 签名验证通过后再处理 event。
const client = await clientPromise;
const db = client.db(process.env.MONGODB_DB);
await db.collection("payments").updateOne(
{
"lemonsqueezyUser.orderId": event.data.id,
},
{
$set: {
// 根据实际 Webhook 数据结构填写字段
},
},
{
upsert: true,
}
);
revalidateTag("payments");
return NextResponse.json({ received: true });
} catch (error) {
console.error("Webhook processing failed:", error);
return NextResponse.json(
{ message: "Webhook processing failed" },
{ status: 500 }
);
}
}
调用顺序应当是:
验证 Webhook 签名
↓
更新 Database
↓
确认数据库写入成功
↓
revalidateTag("payments")
↓
返回成功响应
不要在数据库写入完成前清理缓存,否则并发请求可能再次把旧数据写进缓存。
如果缓存按用户划分,可以使用粒度更细的标签:
const tag = `payment:${userId}`;
更新某位用户的数据后调用:
revalidateTag(`payment:${userId}`);
这样,一个用户完成支付时,不会让所有用户的支付缓存一起失效。
更新已经打开的 Dashboard
revalidateTag() 能让下一次服务端读取拿到新数据,但不会把更新推送到已经打开的页面。
一种简单的处理方式是在支付完成后调用 router.refresh():
"use client";
import { useRouter } from "next/navigation";
export default function PaymentCompletedButton() {
const router = useRouter();
return (
<button onClick={() => router.refresh()}>
Refresh payment status
</button>
);
}
如果支付完成时间取决于异步 Webhook,可以在短时间内轮询:
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function PaymentStatusRefresher() {
const router = useRouter();
useEffect(() => {
const timer = setInterval(() => {
router.refresh();
}, 5000);
return () => clearInterval(timer);
}, [router]);
return null;
}
将它放入 Dashboard:
<>
<PaymentStatusRefresher />
<UsersInfo />
</>
轮询需要有停止条件。例如检测到状态变为 paid、active 或业务规定的最终状态后,就应停止轮询,避免页面持续发送请求。
如果用户量较大,而且需要接近实时的体验,可以考虑 SSE 或 WebSocket。不过,要解决当前的缓存问题,并不一定需要引入它们。
推荐配置
当前场景可以先采用下面这套配置:
- Server Component 通过共享函数直接查询 MongoDB,不再请求自己的生产域名。
- Dashboard 路由声明:
export const dynamic = "force-dynamic";
export const revalidate = 0;
- 如果保留
/api/payments,Route Handler 也设置为动态,并返回:
Cache-Control: no-store, max-age=0
- 支付完成后,Dashboard 在短时间内使用
router.refresh()或有限轮询。 - 数据量和访问量增加后,再改用带用户级标签的缓存,并由 Webhook 调用
revalidateTag()。
不要公开返回整个 payments 集合。支付状态、姓名和邮箱都是敏感数据,API 应验证当前用户身份,只查询并返回该用户有权访问的记录。缓存键和标签也要按用户隔离,避免不同用户之间复用响应。
备注:内容仅供参考。
