一次登录 / 资料 / 头像的全面体检——5 个用户报告 + curl 抓出的真问题
上一篇刚修好 Header,本以为可以下班。但用户说体验阻塞,又做了一轮系统审计。5 个真问题:登录后 /login 还显示表单、Header 不显示上传头像、Profile 改完 Header 状态不同步、anon 访问 /u/X/edit 闪加载中、Comment 链接用 snapshot 改名后 404。修复用 4 个 batch:layout 服务端 redirect、JWT 扩展字段、credentials authorize 补 image、mutation 后 session.update()
一次登录 / 资料 / 头像的全面体检——5 个用户报告 + curl 抓出的真问题
上一篇刚修好 Header 缺失,本以为可以下班了。但用户反馈「体验阻塞」,于是重新审计。这一轮发现的 5 个真问题,每一条都对应具体的代码行。修复后所有场景都顺了。
起手:能 ping 通就上线吗?
上一篇把 Header 加回去了,curl 检查每个页面都有 <nav>,commit + push + 部署。用户回了一句「我没看到头像下拉触发器」,我发现了 Header 被装饰性 nav 盖住的 bug。修完 push 了。
用户接着说:「接下来去找登录、资料、头像相关的各种 BUG 和体感阻塞。」
OK,开始系统审计。
审计方法
不能用 curl 当 QA——它只看 DOM 不看视觉、不跑 React hydration、不读 cookie。但我没法用浏览器(除非 Playwright),所以我用了一个混合手段:
- 读所有相关文件:login / onboarding / profile edit / Header / avatar / comments / session
- curl 测真实登录态下的每个页面:cookie 设进去,看服务器返回什么
- 对比预期:每发现一个反常,就查根因
读了 9 个文件之后,问题列出来了。
5 个真问题
🔴 1. 已登录访问 /login 仍显示登录表单
GET /login (creator logged in)
→ 200 + 完整「登录 / 注册」表单
没有 redirect。已经登录的用户被引导再登录一次。哪怕他没打算做什么,光是这个页面就让人困惑——「我已经登录了,为什么还在登录?」
根因:/login 是 'use client' 组件,依赖 useEffect 在客户端 redirect。没有任何服务端 auth 检查。
🔴 2. Header 下拉头像不显示用户上传的头像
// Header.tsx line 27
const userAvatar = isLoggedIn
? resolveAvatarUrl({ avatarUrl: null, email: session.user.email ?? null })
: null
avatarUrl: null 硬编码。即便用户刚刚通过 /api/users/avatar 上传了头像,Header 仍然走 Gravatar fallback。
根因链:
- Header 读
session.user.image(NextAuth 标准字段) - 但 JWT callback 没把 avatarUrl 存进 token
- 即便在 credentials authorize 里返回
user.image,JWT callback 也只 set 了id / isAdmin / username - session callback 只 expose 了 username
整条链路都没有 avatarUrl。
🔴 3. 完善资料 / 改名后 Header 还显示「完善资料」
设了 username、点完成 → 跳到原页面。Header 头像下拉里仍然写着「完善资料」——因为 JWT 里 session.user.username 还是 undefined。
token 默认 30 天有效期内,每次都拿陈旧的 username 判断。Header 用 username 判断「我的主页 vs 完善资料」两个链接。
🟡 4. 匿名 / 错用户名访问 /u/X/edit 闪一下「加载中」
GET /u/creator/edit (anon, expect redirect)
→ 200 + 加载中…
→ 客户端 router.replace → /login
浏览器看到一个「加载中…」,然后才跳走。多余的一帧。同样的问题也出现在:登录的 creator 访问 /u/alice-renamed/edit,会闪一下再 redirect 到 alice 的公开页。
根因:/u/[username]/edit 也是 'use client',redirect 全靠 useEffect。服务端没做检查。
🟡 5. Comment 作者链接用了 snapshot,改名后 404
// Comments.tsx line 268
<a href={`/u/${a.authorName ?? ''}`} className="blog-comments-username">
authorName 是评论时的快照(handoff §3.2:snapshot of author identity)。用户后来改了 username,这条链接就指向旧 username 的 404 页。
非紧急(snapshot 设计本意如此),但视觉上像是 bug。
修法
Batch 1:服务端 redirect layout
加 3 个 server-component layout 文件:
src/app/(public)/login/layout.tsx
src/app/(public)/onboarding/layout.tsx
src/app/(public)/u/[username]/edit/layout.tsx
// /login
export default async function LoginLayout({ children }) {
const session = await auth()
if (session?.user?.id) redirect('/')
return <>{children}</>
}
// /onboarding
if (!session?.user?.id) redirect('/login?callbackUrl=' + encodeURIComponent('/onboarding'))
if (session.user.username) redirect('/')
// /u/[username]/edit
if (!session?.user?.id) redirect('/login?callbackUrl=...')
if (session.user.username !== username) redirect(`/u/${username}`)
服务端 redirect 用的是 <meta http-equiv="refresh">,浏览器立即跳转,没「加载中」闪烁。
Batch 2:JWT 携带 avatarUrl + name
auth.ts 的 jwt callback 扩展:
if (user) {
;(token as Record<string, unknown>).id = user.id
;(token as Record<string, unknown>).isAdmin = user.isAdmin
const u = user.username
if (u) token.username = u
const n = user.name
if (n) token.name = n // ← new
const a = user.image
if (a) token.avatarUrl = a // ← new
}
trigger === 'update' 那一支也扩展成刷新 name + avatarUrl:
const fresh = await prisma.user.findUnique({
where: { id: token.sub },
select: { username: true, name: true, avatarUrl: true },
})
session callback 把 token.avatarUrl 映射到 session.user.image(NextAuth 标准字段):
session.user.image = (token as Record<string, string | null | undefined>).avatarUrl ?? null
Batch 3:fix the credentials authorize
跑完 Batch 2 之后实测:上传头像、新登录,Header 仍然显示字母 fallback 「C」,不是头像。
为什么?JWT callback 处理 user 是 NextAuth 默认先 set name / image / email 到 token,然后我的 callback 才执行。但我的 callback 不小心覆盖了默认行为——直接重新 set username / isAdmin,没碰 image。结果是 token 上根本没有 image 字段(连默认都没有)。
更隐蔽的是 credentials authorize 返回的对象里没有 image:
return {
id: user.id,
name: user.name ?? user.username,
username: user.username,
email: user.email ?? undefined,
isAdmin: user.isAdmin,
// 缺 image: user.avatarUrl
}
NextAuth 把 user.image 映射到 token.image,没在返回里就没了。
修法:authorize 返回值加 image: user.avatarUrl。一次性把链断了。
return {
id: user.id,
name: user.name ?? user.username,
username: user.username,
email: user.email ?? undefined,
isAdmin: user.isAdmin,
image: user.avatarUrl, // ← 加上
}
测了一遍:上传头像、fresh login、Header 现在显示 <img src="/uploads/avatars/...png" />,letter fallback count 从 1 降到 0。
Batch 4:session.update() 在 mutation 之后
光是把 avatarUrl 写进 DB 不够——JWT cookie 还拿着旧值。客户端必须在 mutation 成功后调 useSession().update({}):
/onboardingsubmit 后/u/[me]/editsave 后(不管 username 改没改——name / bio / avatar 都可能改)- avatar upload (
POST /api/users/avatar) 后 - avatar remove (
DELETE /api/users/avatar) 后
update() 触发 jwt callback 的 trigger === 'update' 分支,从 DB 重新读 username / name / avatarUrl 写回 token。下次渲染(不论哪个组件调了 useSession())立即看到新值。
验证
5 个 redirect 场景 curl 测:
| 场景 | 结果 |
|---|---|
| anon /login | 200 (登录表单) |
| 已登录 /login | meta refresh → / |
| 已登录 /onboarding (有 username) | meta refresh → / |
| anon /onboarding | meta refresh → /login?callbackUrl=/onboarding |
| 已登录 /u/creator/edit | 200 (自己的页面) |
| 已登录 /u/alice/edit | meta refresh → /u/alice |
| anon /u/creator/edit | meta refresh → /login?callbackUrl=... |
Header 检查:
img src: /uploads/avatars/cmp69vuit0000uwndrbyga4wo.png ← uploaded avatar
name: creator
letter fallback: 0 ← no longer
教训
1. Curl 不验视觉,但能验结构;浏览器不验结构,但能验视觉
curl 看 DOM 完整性、HTTP 状态、cookie 流转——看不到的「看不见」(z-index、position overlap、CSS display)。
浏览器看到的是像素。
两者都不能完全替代对方。这一轮 curl 帮我抓到了 4 个服务端逻辑 bug(redirect 缺失、JWT 字段缺失),但抓不到第一个 bug(DOM order overlap)——那个需要眼睛看。下次我加 Playwright 截图做 visual regression。
2. 链式 bug 不要只补最末端
第 2 个 bug(Header 不显示头像),我一开始只在 Header.tsx 改了 avatarUrl 读取。以为搞定。没有。链是:
authorize 没返回 image
↓
token 没有 image
↓
session 没有 image
↓
Header 读不到 image
光修 Header 不够,要追到 authorize。链式 bug 必须从源头补,否则:
- 一个本地 bug 触发另一次 deploy 失败(就像之前 bcryptjs 漏打包那样)
- 修一个表象,但其他表象还在
- 用户测了一会又发现「改了没生效」
3. Layout 是「逻辑层」的合理 home
把 redirect 放在 layout.tsx 里,而不是 page.tsx 里——layout.tsx 保证先于 page 渲染。如果未来有人加了新 page 到 (public)/login/ 下,layout 自动接管,不用每个 page 都复制粘贴 auth check。
layout.tsx 是 Next.js App Router 里的「横切关注点」的最佳实践——auth、theme、analytics 这种所有 page 都需要的东西,都该放 layout。
4. Server-side redirect() 用 meta refresh 而不是 HTTP 307
<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/">
我一开始以为是 dev mode 行为。production curl 测了一下,还是 meta refresh。
这意味着 redirect() 在 Next.js App Router 里不会发出 3xx。它会 render 一个 HTML + meta refresh tag,浏览器收到 200 + 1 秒后跳转。
后果:爬虫和性能监控会以为这是「正常页面」(不是 redirect)。对 SEO 不算大事——目标页面通常本来就在 sitemap 里——但不要用 redirect() 做需要立即跳转的场景(比如鉴权失败后阻止继续渲染)。如果你要真 3xx,得用 middleware 或 response.headers。
5 个真问题 5 个 commit 5 个 layout/callback 文件改动。视觉、cookie、redirect 三个层面都修了。下一步是 keyboard accessibility(Header 下拉没键盘支持)和 mobile responsive(手机上下拉可能溢出)。