Cairn
← 返回博客

评论区不是一个表单:用户身份、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 的初始缩写规范,不写成 UserIdUsername 是一个完整单词,不需要拆成 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
  • 请求体长度限制

限流是单机内存实现,适合当前单实例部署。未来多实例时再迁移到共享存储。

九、验证场景

手工检查:

  1. 未登录提交评论,应跳转登录。
  2. 正常用户能发表评论。
  3. 回复显示在正确父评论下。
  4. 回复其他文章的评论 ID,应被拒绝。
  5. 回复一条回复,应被拒绝。
  6. 用户不能删除他人评论。
  7. 管理员可以删除任意评论。
  8. 输入 <script>,页面只显示文本,不执行脚本。

评论区最后只占页面一小块,但它把身份、权限、数据库关系、HTML 安全和局部交互连在了一起。

小功能不等于简单功能。区别只是它出问题时,占的屏幕比较小。

评论

暂无评论,来说点什么吧。

请先登录后再发表评论

去登录