文章、草稿和比文章花更长时间的评论树

2026年6月23日 devlogpostgres

只知道文章的服务

和auth的分割方式一样:blog是它自己的Go服务,不知道密码是什么,不知道会话是什么。它接收一个已经验证过的JWT请求(blog自己对比auth发布的密钥检查签名,不需要回调auth的网络请求),然后处理文章、评论、点赞和举报。这篇文章里的所有东西都存在一个叫blog_db的Postgres数据库里,完全独立于账户所在的auth_db

Slugs,不信任标题来生成

每篇文章都需要一个slug作为URL,我最初的想法就是把标题变成小写,把空格换成连字符,完成了:

// 第一个想法:完全没有碰撞处理
func slugFromTitle(title string) string {
	return strings.ReplaceAll(strings.ToLower(title), " ", "-")
}

除了标题会碰撞。两篇名字相似的文章都想要同一个slug,第二篇要么直接失败,要么默默地覆盖了什么东西。所以slug生成会检查数据库,不断添加数字直到找到一个空闲的:

func (s *Server) uniqueSlugFromTitle(ctx context.Context, title string) (string, error) {
	base := slug.Generate(title)
	if base == "" {
		base = "post"
	}

	candidate := base
	for n := 2; ; n++ {
		exists, err := s.Queries.SlugExists(ctx, candidate)
		if err != nil {
			return "", err
		}
		if !exists {
			return candidate, nil
		}
		candidate = fmt.Sprintf("%s-%d", base, n)
	}
}

你也可以在创建时直接给它你自己的slug,我有时候会这样做,当生成的slug出来比较丑的时候。

草稿、发布、存档

一篇文章在数据库里有三种状态:draftpublishedarchived。我没想到的是有多少逻辑都挂在这一列上。你只能存档已发布的东西(存档一篇草稿会产生一篇可以通过自己的URL访问但没有发布日期的文章,这没有意义),回到草稿模式会清除任何待发布的计划表,这样它就不会在我不看的时候偷偷发出去。

调度的部分是我最骄傲的。你可以把文章设置为draft并有一个未来的publish_at时间戳,然后一个后台扫描在时间真正过去时把它翻转到已发布:

// D2扫描声明1:一旦文章的计划时间过去,就把草稿翻转到已发布,
// 用计划时间戳记录published_at(不是now()),清除publish_at这样它就永远不会再触发。
func (q *Queries) PublishScheduledPosts(ctx context.Context) error {
	_, err := q.db.Exec(ctx, publishScheduledPosts)
	return err
}
UPDATE posts SET status = 'published', published_at = publish_at, publish_at = NULL
WHERE status = 'draft' AND publish_at <= now()

这个声明每30秒从一个goroutine里运行一次,这个goroutine就持续不断地运行直到进程关闭。它完全是幂等的,一行不再匹配WHERE子句的话就不会被触碰,所以可以随意调用,从任何地方,甚至从测试里。实际上这整个开发日志系列就是通过这个机制发出去的:提前写好并回溯日期,让扫描在它有时间的时候来处理。

评论是一棵假装是表的树

评论需要支持回复,所以一条评论可以指向一条父评论。Postgres对此不需要什么奇特的东西,只是一个可空的自引用:

CREATE TABLE comments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
    author_id UUID NOT NULL,
    author_username TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    edited_at TIMESTAMPTZ,
    deleted_at TIMESTAMPTZ
);

不过API本身不返回嵌套树,它返回一个按创建时间排序的扁平列表,每一行都带着自己的parent_comment_id

comments, err := s.Queries.GetCommentsForPost(ctx, post.ID)

前端是把那个扁平列表变成你看到的嵌套线程的。最初感觉有点不对,像我跳过了什么步骤,但实际上这是更灵活的形状:排序、分页和审核都保持了简单查询反对一个扁平表,而且从带父指针的扁平列表建立树这件事,一旦你已经有了数据在手,就完全是一个机械的事情。

删除评论也不删除行。它设置deleted_at,API清除内容,但保持行活着,因为否则删除一条有三个回复的评论要么会级联整个对话,要么留下孤立的指向无物的回复。一个"[已删除]"占位符在线程里保持位置比这两种情况都要少混淆得多。

点赞和举报,故意保持简单

点赞是一个直接的连接表,每个用户每条评论一行,数数是一个group-by。举报是我唯一需要稍微想想滥用的地方:没什么能阻止有人五次报告同一条评论,除了一个作用域只在报告还是开放时的唯一索引:

CREATE UNIQUE INDEX reports_open_unique_idx ON reports (comment_id, reporter_id) WHERE resolved_at IS NULL;

一个部分唯一索引。每个人对每条评论一个开放报告,但一旦主持人解决了它,这个人如果它后来真的发生了,可以再报告一次。Postgres用一行做到这件事的感觉还是像一个小魔术。

迁移,或者说变得不那么害怕改表

这些表中的每一个都来自它自己的编号迁移,通过goose运行:

-- +goose Up
CREATE TABLE posts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title TEXT NOT NULL,
    slug TEXT NOT NULL,
    content TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'draft',
    published_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || content)) STORED
);

-- +goose Down
DROP TABLE posts;

在这个项目之前,我最接近真实迁移的是Django的manage.py migrate,它为你做了大量的思考。自己为每一个改变手写up和down,让我实际读了我在对模式做什么,而不是相信一个工具在后台安静地想清楚它。那个search_vector列是一个生成列,Postgres在每次一行改变时自动从标题和内容建立它,这就是博客上的全文搜索实际运行的东西。在我需要它之前不知道这个特性存在。

对blog_db运行一个迁移

0 条评论

登录 后即可评论。

登录

忘记密码?

还没有账号?