成为全栈·Next.js 网站前台篇·会员中心:资料、密码与个人数据为什么不能进公共缓存
会员中心不是在公开页面外面套一个登录判断。它还要等待会话恢复、隔离每个账号的查询缓存、禁止搜索索引,并让修改资料与修改密码产生不同的会话后果。

前言
公开文章允许短暂旧值,会员资料却不能“缓存 60 秒再说”。一旦 A 用户的响应被公共缓存复用给 B,问题就从内容新鲜度升级成隐私事故。
当前项目把会员中心放在 /member/*,页面外壳由 App Router 提供,但数据仍在浏览器会话恢复后通过私有 API 获取。这个结构并不是因为 CSR 天然安全,而是为了让内存 access token、当前账号和交互查询处在同一客户端生命周期里。
会员区有三层边界
| MemberShell 客户端守卫 | 等待恢复、未登录跳转、避免内容闪现 | 后端鉴权 |
| 私有请求与查询键 | 不走公开缓存、按用户隔离状态 | 资源归属校验 |
| 后端权限检查 | 验证 token、角色和数据所有者 | 前端体验状态 |
客户端守卫可以被绕过,所以“看不到页面”不等于“拿不到数据”。后端仍必须对 /me/* 和稿件接口做最终裁决。

先等启动恢复完成,再决定是否跳登录
页面刷新后,内存 access token 必然丢失,但 HttpOnly Cookie 可能还有效。如果一看到 user === null 就跳登录,合法会员会先被踢走,刷新请求随后才成功。
export function MemberShell({ children }: { children: React.ReactNode }) {
const { user, bootStatus } = useAuthStore()
const pathname = usePathname()
const router = useRouter()
useEffect(() => {
if (bootStatus === 'ready' && !user) {
router.replace(`/login?redirect=${encodeURIComponent(pathname)}`)
}
}, [user, bootStatus, pathname, router])
if (bootStatus !== 'ready' || !user) return <Skeleton />
return <section className="membercontent">{children}</section>
}
这里有三个状态:尚未恢复、已恢复会员、已确认游客。骨架只覆盖第一种过渡与跳转前的瞬间,不会把“游客”误当成长期加载中。
私有页面同时拒绝搜索索引
export const metadata = {
title: '会员中心',
robots: { index: false, follow: false },
}
会员页面不进入 sitemap,layout 还输出 noindex。它们减少误抓取,却不是安全控制。搜索引擎可以遵守指令,恶意请求不需要遵守;真正的数据保护仍是 API 鉴权与 private, no-store。
查询键必须包含用户身份
const query = useQuery({
queryKey: ['profile', user?.id],
queryFn: getMeProfile,
enabled: !!user,
})
如果 key 只有 ['profile'],A 账号退出后 B 账号登录,组件可能先读到 A 的缓存。把 user id 放进 key,再在身份变化时移除私有查询,才能表达数据所有权。
列表也遵循同一原则:
useQuery({
queryKey: ['member', user?.id, kind, page, status],
queryFn: () => fetchCollection(kind, page, status),
enabled: !!user,
})
用户、列表类型、页码和筛选状态共同决定一份结果。少一个维度,都可能出现错误复用。
资料更新要同步服务端状态与会话显示
昵称既出现在资料页,也出现在会员侧栏。更新成功后只刷新表单缓存,侧栏仍会显示旧名称:
const update = useMutation({
mutationFn: updateMeProfile,
onSuccess: (data) => {
useAuthStore.getState().setUser(data)
queryClient.setQueryData(['profile', user?.id], data)
setMessage('资料已更新')
},
onError: (error) => setMessage(error.message),
})
这里同时更新轻量会话状态和 React Query 中的资料实体。两者职责不同,却需要在一次成功结果后保持一致。
表单只提交契约允许修改的字段:
update.mutate({
nickname: String(data.get('nickname')).trim(),
…(String(data.get('email')).trim()
? { email: String(data.get('email')).trim() }
: {}),
})
用户名在界面中禁用,但不能因此省略后端字段白名单;攻击者可以绕过表单直接构造请求。
修改密码成功后必须结束旧会话
await changePassword({
oldPassword: String(data.get('oldPassword')),
newPassword: String(data.get('newPassword')),
})
form.reset()
useAuthStore.getState().clear()
queryClient.clear()
window.location.assign('/login?expired=1')
修改昵称可以保留登录,修改密码则需要清空内存令牌和所有私有查询,重新登录。这里使用完整页面导航,让会话恢复与页面状态从干净环境重启。
客户端会先检查两次新密码一致:
if (data.get('newPassword') !== data.get('confirm')) {
setMessage('两次输入的新密码不一致')
return
}
但密码强度、旧密码正确性和会话撤销仍由后端决定。前端校验只提供即时反馈。
私有请求需要双向 no-store
客户端请求层统一使用:
await fetch('/api/v1/me/profile', {
headers: { Authorization: `Bearer ${accessToken}` },
cache: 'no-store',
})
同源 BFF 对浏览器响应再写入:
headers.set('cache-control', 'private, no-store')
第一处阻止 Next.js 请求缓存复用上游数据,第二处约束浏览器与中间缓存。它们仍不是鉴权,只是避免合法响应被错误保存和共享。
删除最后一项后要处理空页
会员收藏、历史和点赞共用列表控制器。若用户在第 3 页删除唯一一项,当前页将不存在:
useEffect(() => {
if (query.data && !query.data.items.length && page > 1) {
setPage(page – 1)
}
}, [query.data, page])
操作成功还要失效相关状态:
await queryClient.invalidateQueries({ queryKey: ['member'] })
await queryClient.invalidateQueries({ queryKey: ['favorite-ids'] })
await queryClient.invalidateQueries({ queryKey: ['like'] })
这样详情页按钮和会员列表不会长期互相矛盾。
错误、空数据和未登录是三种界面
| 会话仍在恢复 | 骨架 | 等待一次启动恢复 |
| 已确认未登录 | 跳转登录并保留 redirect | 登录后返回 |
| 私有请求失败 | 错误提示 | 重试当前请求 |
| 请求成功但列表为空 | 空状态 | 浏览文章或开始投稿 |
| 密码修改成功 | 清会话 | 使用新密码登录 |

验收要使用两个账号
1. A 登录后打开资料、收藏和历史
2. 退出 A,登录 B,不刷新页面继续访问会员中心
3. 检查 B 看不到 A 的昵称、列表和请求缓存
4. 刷新页面,确认 HttpOnly Cookie 可恢复 B 会话
5. 修改密码,确认旧会话清空并回到登录页
6. 直接请求 A 的资源编号,确认后端拒绝越权
7. 检查所有 /me 响应含 private, no-store
8. 检查会员页面不在 sitemap 且带 noindex
只用一个账号无法证明隔离,只点页面也无法证明 API 权限。会员中心必须把界面、缓存、响应头和后端授权一起验收。
适用边界
当前会员区采用客户端取数,适合交互密集且依赖内存令牌的页面。如果需要服务端直接渲染私有内容,可以让 BFF 代持会话,但缓存键、Cookie 和每请求用户隔离会更复杂。
对于金融、医疗等高敏感信息,还需要更严格的重新认证、操作审计、设备管理与内容遮罩,本文的普通内容会员模型不够。
小结
会员中心的关键不是做一套侧栏导航,而是确保每份数据都属于当前账号:会话恢复完成后再请求,查询键带用户 id,身份切换清私有缓存,响应始终 no-store,后端继续检查资源归属。
公开内容可以用缓存换速度,个人数据必须先保证身份和边界正确。这是两类页面最根本的工程差异。
延伸阅读
- C 端认证:内存令牌、HttpOnly Cookie 与会话代次
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

网硕互联帮助中心





评论前必须登录!
注册