Lee's Blog
  • 首页
  • 博客
  • 作品
  • 成长
  • 学习
  • 打榜
  • 练习
  • 关于
登录 / 注册
✦

保持联系

关注我的最新动态

GitHubbilibili

© 2026 Lee's Blog

首页/博客/博客开发/一次登录 / 资料 / 头像的全面体检——5 个用户报告 + curl 抓出的真问题
一次登录 / 资料 / 头像的全面体检——5 个用户报告 + curl 抓出的真问题
博客开发2026年8月7日9 分钟阅读

一次登录 / 资料 / 头像的全面体检——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()

前端后端教程
X微博

一次登录 / 资料 / 头像的全面体检——5 个用户报告 + curl 抓出的真问题

上一篇刚修好 Header 缺失,本以为可以下班了。但用户反馈「体验阻塞」,于是重新审计。这一轮发现的 5 个真问题,每一条都对应具体的代码行。修复后所有场景都顺了。

起手:能 ping 通就上线吗?

上一篇把 Header 加回去了,curl 检查每个页面都有 <nav>,commit + push + 部署。用户回了一句「我没看到头像下拉触发器」,我发现了 Header 被装饰性 nav 盖住的 bug。修完 push 了。

用户接着说:「接下来去找登录、资料、头像相关的各种 BUG 和体感阻塞。」

OK,开始系统审计。

审计方法

不能用 curl 当 QA——它只看 DOM 不看视觉、不跑 React hydration、不读 cookie。但我没法用浏览器(除非 Playwright),所以我用了一个混合手段:

  1. 读所有相关文件:login / onboarding / profile edit / Header / avatar / comments / session
  2. curl 测真实登录态下的每个页面:cookie 设进去,看服务器返回什么
  3. 对比预期:每发现一个反常,就查根因

读了 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({}):

  • /onboarding submit 后
  • /u/[me]/edit save 后(不管 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(手机上下拉可能溢出)。

← 上一篇用户登录后看不到自己的头像——一次 DOM 顺序撞车下一篇 →用户明明默写了 20 多个单词,为什么任务栏还停在 4/20?

相关文章

  • 用户登录后看不到自己的头像——一次 DOM 顺序撞车约 5 分钟
  • TOC 悬浮折腾记:6 次 commit 才彻底搞定约 6 分钟
  • 把博客搞崩两次的那次部署:一次 OTP 重构复盘约 8 分钟

评论

需要登录账号才能发表评论。

  • 加载中…

本页内容

  • 一次登录 / 资料 / 头像的全面体检——5 个用户报告 + curl 抓出的真问题
  • 起手:能 ping 通就上线吗?
  • 审计方法
  • 5 个真问题
  • 🔴 1. 已登录访问 /login 仍显示登录表单
  • 🔴 2. Header 下拉头像不显示用户上传的头像
  • 🔴 3. 完善资料 / 改名后 Header 还显示「完善资料」
  • 🟡 4. 匿名 / 错用户名访问 /u/X/edit 闪一下「加载中」
  • 🟡 5. Comment 作者链接用了 snapshot,改名后 404
  • 修法
  • Batch 1:服务端 redirect layout
  • Batch 2:JWT 携带 avatarUrl + name
  • Batch 3:fix the credentials authorize
  • Batch 4:session.update() 在 mutation 之后
  • 验证
  • 教训
  • 1. Curl 不验视觉,但能验结构;浏览器不验结构,但能验视觉
  • 2. 链式 bug 不要只补最末端
  • 3. Layout 是「逻辑层」的合理 home
  • 4. Server-side `redirect()` 用 meta refresh 而不是 HTTP 307