云计算百科
云计算领域专业知识百科平台

成为全栈·Next.js 网站前台篇·会员中心:资料、密码与个人数据为什么不能进公共缓存

成为全栈·Next.js 网站前台篇·会员中心:资料、密码与个人数据为什么不能进公共缓存

会员中心不是在公开页面外面套一个登录判断。它还要等待会话恢复、隔离每个账号的查询缓存、禁止搜索索引,并让修改资料与修改密码产生不同的会话后果。

成为全栈·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

成为全栈专栏订阅

赞(0)
未经允许不得转载:网硕互联帮助中心 » 成为全栈·Next.js 网站前台篇·会员中心:资料、密码与个人数据为什么不能进公共缓存
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!