评论区不是一个表单:用户身份、HTMX 与回复树
评论区不是一个表单:用户身份、HTMX 与回复树
评论区刚开始看起来很简单:
一个 textarea
一个提交按钮
一张 comments 表
真正写下去后,它会迅速追问:
- 评论属于哪个用户?
- 用户改名后怎么办?
- 回复能不能跨文章?
- 谁可以删除?
- HTMX 请求失败时返回什么?
- 为什么数据库里有回复,页面却不显示?
评论功能不大,但身份、权限、数据关系和前端交互全都碰了一遍。很适合拿来练手,也很适合拿来踩坑。
一、先做用户和 Session
用户表:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(256) NOT NULL,
role VARCHAR(16) NOT NULL DEFAULT 'user',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Session 表:
CREATE TABLE sessions (
token VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
密码使用 bcrypt:
hash, err := bcrypt.GenerateFromPassword(
[]byte(password),
bcrypt.DefaultCost,
)
登录时生成 32 字节随机 token:
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
token := hex.EncodeToString(tokenBytes)
Cookie 配置:
http.Cookie{
Name: "cairn_session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: production,
SameSite: http.SameSiteLaxMode,
}
每次请求由中间件读取 token,查出用户并放入 Context。
二、评论表只存 user_id
第一版评论表存了:
author
email
后来用户系统加入后,又增加了 user_id。这会产生两份身份数据:
comments.author
users.username
一旦用户改名,两边就不一致。
最终结构:
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
parent_id BIGINT REFERENCES comments(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
content TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'approved',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
查询评论时关联用户名:
SELECT
c.id,
c.post_id,
c.parent_id,
c.user_id,
u.username,
c.content,
c.status,
c.created_at
FROM comments AS c
JOIN users AS u ON u.id = c.user_id
WHERE c.post_id = $1
AND c.status = 'approved'
ORDER BY c.created_at ASC;
Go 模型中 Username 是查询字段,不存 comments 表:
type Comment struct {
ID int64
PostID int64
ParentID *int64
UserID int64
Content string
Status string
CreatedAt time.Time
Username string
Replies []Comment
}
UserID 使用 Go 的初始缩写规范,不写成 UserId。Username 是一个完整单词,不需要拆成 UserName。
三、评论内容只允许纯文本
文章需要 Markdown,评论不需要。
policy := bluemonday.StrictPolicy()
content = policy.Sanitize(strings.TrimSpace(content))
校验:
if content == "" {
return nil, errors.New("内容不能为空")
}
if len(content) > 4000 {
return nil, errors.New("评论最长 4000 个字符")
}
需要注意,len(content) 计算的是字节数,不是 Unicode 字符数。如果产品要求严格的“4000 个字符”,应该使用:
utf8.RuneCountInString(content)
当前限制更接近请求体保护,因此按字节计算也能接受,但要清楚自己限制的到底是什么。
四、回复关系必须在 Service 校验
表单提交 parent_id:
<input type="hidden" name="parent_id">
不能拿到 ID 后直接写数据库。至少需要检查:
parent, err := repo.GetByID(ctx, *parentID)
if err != nil {
return nil, errors.New("回复的评论不存在")
}
if parent.PostID != postID {
return nil, errors.New("不能回复其他文章的评论")
}
if parent.ParentID != nil {
return nil, errors.New("评论只支持一级回复")
}
否则用户可以构造请求,让一篇文章的评论回复另一篇文章;或者不断套娃,最终模板渲染出一棵考验浏览器耐心的评论树。
五、HTMX 请求怎么处理
表单:
<form
hx-post="/posts/{{.Slug}}/comments"
hx-target="#comment-section"
hx-swap="outerHTML">
</form>
Handler 根据请求头判断:
isHTMX := r.Header.Get("HX-Request") == "true"
成功时重新查询评论并返回片段:
renderer.Render(w, "comment-section", data)
普通表单请求则重定向:
http.Redirect(
w,
r,
"/posts/"+slug+"#comments",
http.StatusSeeOther,
)
这样即使 HTMX 加载失败,基本功能仍然能工作。
HTMX 本身放在本地静态目录:
web/static/js/vendor/htmx-2.0.4.min.js
避免评论功能依赖外部 CDN。
六、回复为什么写进数据库却没显示
最难找的 Bug 出现在 buildTree。
错误实现一边构建父子关系,一边把根评论复制到结果切片:
for i := range comments {
if comments[i].ParentID == nil {
roots = append(roots, comments[i])
}
// 后面才给 map 中的父评论追加 Replies
}
问题是 roots 保存的是值副本。后面修改 map 中的父评论,早先复制进 roots 的对象不会跟着更新。
修复为两趟:
byID := make(map[int64]*Comment, len(comments))
for i := range comments {
byID[comments[i].ID] = &comments[i]
}
// 第一趟只建关系
for i := range comments {
comment := &comments[i]
if comment.ParentID != nil {
if parent, ok := byID[*comment.ParentID]; ok {
parent.Replies = append(parent.Replies, *comment)
}
}
}
// 第二趟收集根节点
for i := range comments {
if comments[i].ParentID == nil {
roots = append(roots, comments[i])
}
}
这个问题不是 SQL、HTMX 或模板导致的,而是 Go 切片复制语义。数据库里数据完全正确,反而让排查方向更容易跑偏。
七、删除权限
删除规则:
if user.Role != "admin" && comment.UserID != user.ID {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
- 未登录:401
- 非作者:403
- 作者本人:允许
- 管理员:允许
权限判断必须使用 user_id,不能使用用户名,也不能只看前端是否显示删除按钮。前端按钮是体验,服务端判断才是权限。
八、写接口的安全边界
评论接口还增加了:
- SameSite Session Cookie
- 同源
Origin/Referer检查 - 每个 IP 的提交频率限制
- 参数化 SQL
- 请求体长度限制
限流是单机内存实现,适合当前单实例部署。未来多实例时再迁移到共享存储。
九、验证场景
手工检查:
- 未登录提交评论,应跳转登录。
- 正常用户能发表评论。
- 回复显示在正确父评论下。
- 回复其他文章的评论 ID,应被拒绝。
- 回复一条回复,应被拒绝。
- 用户不能删除他人评论。
- 管理员可以删除任意评论。
- 输入
<script>,页面只显示文本,不执行脚本。
评论区最后只占页面一小块,但它把身份、权限、数据库关系、HTML 安全和局部交互连在了一起。
小功能不等于简单功能。区别只是它出问题时,占的屏幕比较小。
评论
暂无评论,来说点什么吧。
请先登录后再发表评论
去登录