成为全栈·Next.js 网站前台篇·会员公开主页与通知中心:公开身份和私有消息如何分界
同一个会员既有可被搜索和分享的作者身份,也有只能本人读取的通知。前端不能因为数据都属于 User,就让公开主页和私有消息共享同一接口与缓存。

前言
内容站里的会员可能只是读者,也可能发表文章。公开作者页需要展示昵称和已发布文章,通知中心则包含评论审核、文章发布和账号消息。两者都与会员有关,公开范围却完全相反。
这篇通过两个页面说明数据分界:/members/[id] 是公开内容入口,可服务端渲染和索引;/member/notifications 属于当前会话,只能走私有请求和 no-store。
公开身份不是完整用户实体
| 昵称、头像 | 可以 | 可以 |
| 已发布文章与数量 | 可以 | 可以按需引用 |
| 邮箱、账号状态 | 不展示 | 仅本人或管理权限 |
| 草稿、收藏、历史 | 不展示 | 仅本人 |
| 通知正文、未读状态 | 不展示 | 仅本人 |
最安全的契约是后端直接返回 PublicMember,而不是前端拿完整 User 再“记得删字段”。数据越早收窄,越不容易在日志、缓存或组件 props 中泄漏。

公开作者页先校验路由身份
const { id } = await params
if (!/^\\d+$/.test(id)) notFound()
const member = await getMemberProfile(id).catch((error) => {
if (error instanceof ApiError && error.status === 404) notFound()
throw error
})
只有明确不存在才进入 404,网络错误继续由错误边界处理。非法 id 在请求上游前就被拒绝。
页面只渲染公开字段:
<section className="authorhero">
<div className="avatar large">{member.nickname?.slice(0, 1) || '读'}</div>
<div>
<span>作者主页</span>
<h1>{member.nickname}</h1>
<p>{member.articleCount} 篇公开文章</p>
</div>
</section>
{member.articles?.length ? (
member.articles.map((article) => (
<ArticleCard key={article.id} article={article} categories={tree} />
))
) : (
<Empty title="作者还没有发布文章" />
)}
没有公开文章是有效空状态,不代表会员不存在。草稿和待审核文章永远不应因为作者本人能看到,就混进公开主页。
通知查询键必须带身份、筛选与页码
const query = useQuery({
queryKey: ['notifications', user?.id, unread, page],
queryFn: () => getMyNotifications({
page,
pageSize: 10,
…(unread ? { isRead: false } : {}),
}),
enabled: !!user,
})
const count = useQuery({
queryKey: ['unread', user?.id],
queryFn: getUnreadCount,
enabled: !!user,
})
列表和未读数是两份服务端状态。任何标记操作成功后都要同时失效,否则标签显示“未读 3”,列表却已经全部已读。
标记已读与跳转要按顺序执行
const mutation = useMutation({
mutationFn: async ({ id, link }: { id?: number; link?: string }) => {
if (id) await updateNotification(id, true)
else await readAllNotifications()
return link
},
onSuccess: (link) => {
void client.invalidateQueries({ queryKey: ['notifications'] })
void client.invalidateQueries({ queryKey: ['unread'] })
if (link) window.location.assign(link)
},
})
先由服务端确认已读,再跳转详情。若先跳走,请求可能被中断,用户返回后仍看到未读。
“全部已读”后,未读筛选页可能突然没有当前页:
if (unread && !mutation.variables?.id) setPage(1)
else if (page > 1 && query.data?.list.length === 1) setPage(page – 1)
这与收藏末页删除是同一种分页恢复问题。
通知链接必须经过安全过滤
通知 link 来自服务端配置或业务事件,不能直接赋给 location.href:
export const safeLink = (value?: string | null): string | undefined => {
if (!value || /[\\\\\\r\\n]/.test(value)) return undefined
if (value.startsWith('/') && !value.startsWith('//')) return value
try {
const url = new URL(value)
return ['https:', 'http:'].includes(url.protocol) ? url.href : undefined
} catch {
return undefined
}
}
它拒绝 javascript:、协议相对地址和换行注入。当前允许 http(s) 外链;若产品只需要站内深链,可以进一步限制同源。
const link = safeLink(notification.link)
{link && (
<button onClick={() => mutation.mutate({ id: notification.id, link })}>
查看详情 →
</button>
)}
不要编造通知类型
const icon = notification.type === 'article_published'
? '文'
: notification.type === 'comment_approved'
? '评'
: '信'
前端只对契约已知类型提供特定图标,未知类型使用通用样式并显示服务端 title/body。不要根据正文关键词猜类型,也不要为尚未存在的“关注、私信”预造入口。
公开页与通知页使用完全不同的缓存路径
公开作者页:Server Component → serverFetch → 公开缓存
通知中心:Client Component → 同源 BFF → private, no-store
公开作者文章允许短暂陈旧,通知未读状态要求当前用户的新鲜结果。即使两页都显示昵称,也不能因此共用完整响应。
验收矩阵
| 游客访问 | 可读 | 跳转登录 |
| 会员无公开文章 | 显示空状态 | 不受影响 |
| 会员有草稿 | 不展示 | 相关审核通知可见本人 |
| A 切换 B | 公共页不敏感 | 清除 A 的通知缓存 |
| 全部标为已读 | 无变化 | 未读数和列表同步 |
| 通知 link 非法 | 无变化 | 不显示跳转按钮 |
| 后端 500 | 错误页,不是 404 | 局部失败可重试 |

双账号测试时,应确认 B 看不到 A 的未读数、通知正文和分页缓存;公开作者页则在退出后仍可访问。这个对照能证明“公开身份”和“当前会话”没有被混为一谈。
适用边界
当前作者页只展示昵称与文章。如果以后增加简介、社交链接或关注关系,需要逐字段定义公开性与 URL 安全,不应直接复用个人资料表单模型。
通知量增大后可能需要游标分页、实时推送和批量操作。WebSocket 只负责告诉客户端“有变化”,最终未读状态仍应由服务端查询确认。
小结
会员是一个账号,公开作者和私有收件箱却是两个数据域。公开页只使用为公众设计的字段和文章,通知中心始终绑定当前用户、私有缓存和安全链接。
先确定信息是否公开,再选择渲染与缓存方式,比先复用一个 User 类型更可靠。
延伸阅读
- 搜索页:实时查询、URL 状态与错误边界
- 会员中心:资料、密码与个人数据
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
