посты, черновики и дерево комментариев что заняло дольше чем посты

23 июня 2026 г. devlogpostgres

Сервис, знающий только о постах

Как и в случае с auth: blog это отдельный Go сервис, не знает что такое пароль, не знает что такое сессия. Он получает запрос с уже проверенным JWT (blog сам проверяет подпись против опубликованных auth ключей, без обратного вызова в auth), и работает с постами, комментариями, лайками и репортами. Всё в этом посте живёт в Postgres базе blog_db, полностью отдельной от auth_db где хранятся аккаунты.

Слаги и недоверие заголовкам

Каждый пост нужен слаг для URL, и моя первая идея была просто сделать заголовок в нижний регистр, заменить пробелы на дефисы, готово:

// первая идея: никакой обработки коллизий
func slugFromTitle(title string) string {
	return strings.ReplaceAll(strings.ToLower(title), " ", "-")
}

Кроме того что заголовки совпадают. Два поста с похожим названием захотели бы одного слага, и второй либо упал бы с ошибкой, либо молча перезаписал что-то. Поэтому генерация слага проверяет базу и приписывает номер пока не найдёт свободный:

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)
	}
}

Можешь и просто передать свой слаг при создании если хочешь, я иногда это делаю когда сгенерированный выглядит хуже чем хотелось бы.

Черновик, опубликовано, архив

Пост в базе в одном из трёх состояний: draft, published или archived. То что я не ожидал это сколько логики зависит от этого одного столбца. Можно архивировать только то что действительно было опубликовано (архивирование черновика даст пост по его URL но без даты публикации, что не имеет смысла), и возврат в черновик очищает любое расписание публикации что бы оно не выскочило потом без моего ведома.

Расписание это часть которой я больше всего горжусь. Можно установить пост в draft с меткой времени publish_at в будущее, и фоновый процесс переключит его в published как только это время наступит:

// 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 секунд из горутины которая просто тикает пока процесс не завершится. Оно полностью идемпотентно, строка которая больше не соответствует 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 очищает содержимое, но держит строку, потому что иначе удаление комментария с тремя ответами внизу либо каскадом снесёт весь разговор, либо оставит сирот указывающих в никуда. Заполнитель «[удалено]» держащий место в треде намного менее запутан чем оба этих варианта.

Лайки и репорты, намеренно просто

Лайки это простая таблица связей, одна строка на пользователя на комментарий, count это 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 комментариев

Войдите , чтобы оставить комментарий.

Войти

Забыли пароль?

Нет аккаунта?