新建 MkSaaS 项目之后,建议先只做一件事:把登录跑通,并确认登录态到底写进了哪里。等这一步能被日志和浏览器面板复现,再把订阅状态的读取点固定到请求入口的中间件里,让业务页面只消费一个已经解析好的状态对象。顺序反了,排查时很容易把“登录没成功”和“订阅查不到”混成同一个问题。
先跑通登录、再接入订阅状态,判断依据是登录态能落在 cookie 或会话记录里、订阅状态在中间件阶段被读入并透传给后续处理。适用场景是刚生成项目、只想验证请求链路是否串起来的阶段。操作顺序为:确认默认页面可访问、定位登录写入点、把订阅读取前置到业务处理之前、用新账号走一遍受保护页面。风险边界是各脚手架会话存储方式不同,cookie 名称、字段名、中间件文件名都需要以项目实际配置为准,不能照抄示例。
用脚手架命令生成项目并确认默认页面可访问
先固定一个能复现的起点,后面才不会把端口占用、依赖没装这类环境问题误判成代码问题。生成项目后不要急着改业务代码,先用项目 package.json 里定义的脚本启动,常见是 npm run dev 或 pnpm dev。
- 监听端口:默认通常是 3000,被占用时脚手理会自动顺延到 3001 之类,以终端输出为准。
- 默认访问路径:一般是根路径
/,未登录状态下可能直接重定向到/login或/sign-in。 - 控制台输出:通常会出现类似
ready/Local: http://localhost:3000的启动行,以及编译完成提示;具体文案随脚手架版本不同,看到可访问地址即可。
用 curl -i http://localhost:3000/ 看一次响应头和状态码,能确认是 200 还是重定向。这一步只是建立一个基线,不做任何登录之外的改动。
在会话记录与响应头里确认登录态写到了哪里
登录成功后,登录态一般通过两种方式落地:会话存储(数据库或内存 session 表)加上一个指向它的 cookie。先找到登录处理入口,它通常在认证路由文件、server action 或认证配置文件中,具体路径取决于脚手架选择。
- 返回字段:登录响应里常见的字段是
user.id、user.email、expires一类,实际字段名看项目实现。 - cookie 名称:可能是
next-auth.session-token、__session或带项目前缀的sb-*-auth-token,需要以浏览器面板显示的为准。 - 查看方式:浏览器开发者工具的 Application → Cookies 看写入项和过期时间;或在命令行用
curl -i观察Set-Cookie响应头。
确认清楚“哪个 cookie 对应哪个用户 id”之后,后面读取订阅状态时才有稳定的关联键。如果这一步字段名没对上,订阅查询会一直查到空值。
把订阅状态的读取点放在业务处理之前
订阅状态建议在中间件或请求前置钩子里读一次,而不是在仪表盘、设置页、计费页各查一遍。注册位置通常是项目根目录或 src/ 下的 middleware.ts,注册顺序为:会话解析 → 订阅状态读取 → 路由与业务处理,订阅读取必须排在会话解析之后,因为要用登录态里的用户 id 去查。
下面是一段可替换的骨架,函数名和返回值按项目实际实现改,不要当成某个官方接口:
export async function middleware(req) {
const session = await getSession(req); // 第一段:登录态
if (!session?.user?.id) return redirectToLogin(req);
const sub = await getSubscription(session.user.id); // 第二段:订阅状态
const headers = new Headers(req.headers);
headers.set('x-user-id', session.user.id);
headers.set('x-plan', sub?.plan ?? 'free');
headers.set('x-sub-status', sub?.status ?? 'none');
return next({ request: { headers } }); // 第三段:透传给路由判断
}
export const config = { matcher: ['/dashboard/:path*', '/settings/:path*'] };透传方式可以选请求头、请求上下文或者直接挂到 session 对象上,关键是业务代码只读一次解析结果,不再自己发起订阅查询。matcher 决定哪些路径会经过这段逻辑,受保护页面要写进去,否则状态读不到。
用一个新账号走完登录到受保护页面的完整路径
测试账号建议新建一个,或使用明确处于免费状态的账号,避免老账号的历史状态干扰判断。走一遍:注册或登录 → 打开受保护路径(例如 /dashboard)→ 观察跳转结果。
- 预期跳转:如果状态读取正确,免费账号访问收费功能通常会被引导到升级页或计费页,而不是直接进入页面。
- 日志中应出现的状态值:订阅查询那一段应打印出
plan=free、status=none或trialing之类的值,以及对应的用户 id。 - 如果日志显示查询到了状态,但页面仍然放行,问题多半出在路由判断那一步没有读到透传值。
这一步不是验证“有没有读”,而是验证“读出来的状态有没有参与判断”。只读到不判断,等于没接。
用登录、状态查询、路由判断三段日志定位不一致
状态对不上时,不要从页面往前猜,按固定顺序看三段日志:
- 登录段:打印
userId、会话标识、cookie 名、过期时间。 - 状态查询段:打印查询用的
userId、返回的plan、status、currentPeriodEnd、是否命中缓存。 - 路由判断段:打印请求路径、要求的权限级别、最终允许或拒绝的原因。
对比方法是用同一个 requestId 把三段串起来,先确认登录段的 id 与状态查询段使用的 id 是否一致,再确认路由判断段读到的 plan 是否等于状态查询段的返回值。
常见的不一致有三种表现:一是状态查询段的用户 id 为空或与登录段不同,通常是 cookie 名写错或读取时机早于会话写入;二是状态查询命中了旧缓存,导致升级或到期后判断结果没有跟着变;三是部分路径没被 matcher 覆盖,压根没经过状态读取,页面只能拿到默认值。三种情况都能从这三段日志里直接看出来,不需要改代码去试。