PAYATHON 2026

生产环境中 Dashboard 支付数据未更新

支付阿杰

结论

cache: "no-store" 只表示当前这次 fetch 不使用 Next.js Data Cache。它不能保证整条数据链路都没有缓存,也不会在 Webhook 更新数据库后主动刷新用户已经打开的 Dashboard。

这个问题一般来自两个方面:

  1. /api/payments、Dashboard 页面或前置 CDN 在生产环境中仍然经过静态化或缓存。
  2. 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。不过,要解决当前的缓存问题,并不一定需要引入它们。

推荐配置

当前场景可以先采用下面这套配置:

  1. Server Component 通过共享函数直接查询 MongoDB,不再请求自己的生产域名。
  2. Dashboard 路由声明:
export const dynamic = "force-dynamic";
export const revalidate = 0;
  1. 如果保留 /api/payments,Route Handler 也设置为动态,并返回:
Cache-Control: no-store, max-age=0
  1. 支付完成后,Dashboard 在短时间内使用 router.refresh() 或有限轮询。
  2. 数据量和访问量增加后,再改用带用户级标签的缓存,并由 Webhook 调用 revalidateTag()。

不要公开返回整个 payments 集合。支付状态、姓名和邮箱都是敏感数据,API 应验证当前用户身份,只查询并返回该用户有权访问的记录。缓存键和标签也要按用户隔离,避免不同用户之间复用响应。

备注:内容仅供参考。