From 4550bea6cc05a993a5256c9d7600f88ab919c8c4 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 23:29:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(db):=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=A4=D0=B0=D0=B7=D1=8B=202=20=E2=80=94=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F,=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D0=B8,=20read=20states,=20=D1=84=D0=B0=D0=B9?= =?UTF-8?q?=D0=BB=D1=8B,=20=D0=B8=D0=BD=D0=B2=D0=B0=D0=B9=D1=82=D1=8B,=20F?= =?UTF-8?q?TS5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 00004: таблицы `messages`, `message_reactions`, `channel_read_states`, `files`, `invites` и виртуальная таблица `messages_fts` с триггерами синхронизации (AGENT.md 6.1, 7.6, 7.7, 7.9, 7.15, 7.16). Полнотекстовый поиск требует сборки с тегом `sqlite_fts5`: - `GO_TAGS ?= sqlite_fts5` в Makefile (build/test/cover) и в Dockerfile; - build-tags в golangci-lint; - страж `internal/database/fts5_guard.go`: сборка без тега падает с понятным текстом, а не ломается на миграции в рантайме. --- .golangci.yml | 2 +- Dockerfile | 2 +- Makefile | 9 +- internal/database/fts5_guard.go | 10 ++ .../migrations/00004_phase2_messages.sql | 114 ++++++++++++++++++ 5 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 internal/database/fts5_guard.go create mode 100644 internal/database/migrations/00004_phase2_messages.sql diff --git a/.golangci.yml b/.golangci.yml index b4ed47a..5023093 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -4,7 +4,7 @@ run: timeout: 5m tests: true build-tags: - - sqlite_cgo + - sqlite_fts5 linters: default: none diff --git a/Dockerfile b/Dockerfile index e4f6121..e1eb2cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ RUN go mod download COPY cmd/ ./cmd/ COPY internal/ ./internal/ RUN CC=musl-gcc CGO_ENABLED=1 go build \ - -tags "sqlite_omit_load_extension" \ + -tags "sqlite_omit_load_extension sqlite_fts5" \ -trimpath \ -ldflags "-s -w -linkmode external -extldflags -static -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${BUILD_DATE}" \ -o /out/glchat ./cmd/glchat diff --git a/Makefile b/Makefile index cfa896f..33afe91 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,10 @@ export GOPROXY ?= https://proxy.golang.org,direct export GOSUMDB ?= sum.golang.org export CGO_ENABLED ?= 1 -GOFLAGS_LOCAL := -trimpath +# sqlite_fts5 включает полнотекстовый поиск в mattn/go-sqlite3 (AGENT.md 7.15): +# без тега поиск по сообщениям не работает, поэтому он обязателен для всех сборок. +GO_TAGS ?= sqlite_fts5 +GOFLAGS_LOCAL := -trimpath -tags $(GO_TAGS) GO_LDFLAGS := -s -w \ -X main.version=$(VERSION) \ -X main.commit=$(COMMIT) \ @@ -102,12 +105,12 @@ build: ## Собрать бинарник сервера в ./build/glchat .PHONY: test test: ## Тесты Go с race-детектором - go test -race -count=1 $(GO_PACKAGES) + go test -tags $(GO_TAGS) -race -count=1 $(GO_PACKAGES) .PHONY: test-cover test-cover: ## Тесты с отчётом покрытия (./build/coverage.out) @mkdir -p $(BUILD_DIR) - go test -race -count=1 -coverprofile=$(BUILD_DIR)/coverage.out $(GO_PACKAGES) + go test -tags $(GO_TAGS) -race -count=1 -coverprofile=$(BUILD_DIR)/coverage.out $(GO_PACKAGES) go tool cover -func=$(BUILD_DIR)/coverage.out | tail -1 .PHONY: lint diff --git a/internal/database/fts5_guard.go b/internal/database/fts5_guard.go new file mode 100644 index 0000000..ed98bfc --- /dev/null +++ b/internal/database/fts5_guard.go @@ -0,0 +1,10 @@ +//go:build !sqlite_fts5 + +package database + +// Без тега sqlite_fts5 драйвер mattn/go-sqlite3 не умеет виртуальные таблицы +// FTS5, и миграция поиска падает на старте. Паникуем сразу и с понятным +// текстом: собрать инстанс без полнотекстового поиска нельзя (AGENT.md 7.15). +func init() { + panic("glchat: сборка без тега sqlite_fts5 — добавьте -tags sqlite_fts5 (см. GO_TAGS в Makefile)") +} diff --git a/internal/database/migrations/00004_phase2_messages.sql b/internal/database/migrations/00004_phase2_messages.sql new file mode 100644 index 0000000..d58c492 --- /dev/null +++ b/internal/database/migrations/00004_phase2_messages.sql @@ -0,0 +1,114 @@ +-- +goose Up +-- Фаза 2: сообщения, реакции, закрепления, read states и полнотекстовый поиск +-- (AGENT.md 6.1, 7.6, 7.15). + +CREATE TABLE messages ( + id INTEGER PRIMARY KEY, + channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + author_id INTEGER REFERENCES users (id) ON DELETE SET NULL, + content TEXT NOT NULL DEFAULT '', + reply_to_id INTEGER REFERENCES messages (id) ON DELETE SET NULL, + type TEXT NOT NULL DEFAULT 'default', + -- Вложения хранятся списком метаданных: сами файлы лежат на диске (7.7). + attachments_json TEXT NOT NULL DEFAULT '[]', + mentions_json TEXT NOT NULL DEFAULT '[]', + edited_at TEXT, + pinned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX messages_channel_idx ON messages (channel_id, id DESC); +CREATE INDEX messages_author_idx ON messages (author_id, id DESC); +CREATE INDEX messages_pinned_idx ON messages (channel_id, id) WHERE pinned = 1; + +CREATE TABLE message_reactions ( + message_id INTEGER NOT NULL REFERENCES messages (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (message_id, user_id, emoji) +); +CREATE INDEX message_reactions_emoji_idx ON message_reactions (message_id, emoji); + +-- Состояние прочтения на пользователя и комнату: синхронизируется между +-- устройствами через Gateway (AGENT.md 7.16). +CREATE TABLE channel_read_states ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + last_message_id INTEGER NOT NULL DEFAULT 0, + mention_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (user_id, channel_id) +); +CREATE INDEX channel_read_states_channel_idx ON channel_read_states (channel_id); + +-- Вложения: файл регистрируется отдельно от сообщения, чтобы работали +-- предпросмотры и очистка сирот (AGENT.md 7.7). +CREATE TABLE files ( + id INTEGER PRIMARY KEY, + uploader_id INTEGER REFERENCES users (id) ON DELETE SET NULL, + guild_id INTEGER REFERENCES guilds (id) ON DELETE CASCADE, + channel_id INTEGER REFERENCES channels (id) ON DELETE CASCADE, + message_id INTEGER REFERENCES messages (id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size_bytes INTEGER NOT NULL DEFAULT 0, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + storage_path TEXT NOT NULL DEFAULT '', + sha256 TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX files_message_idx ON files (message_id); + +-- Полнотекстовый поиск по сообщениям (AGENT.md 7.15): индексируем содержимое, +-- права проверяются при запросе. +CREATE VIRTUAL TABLE messages_fts USING fts5 ( + content, + content = 'messages', + content_rowid = 'id', + tokenize = 'unicode61 remove_diacritics 2' +); + +-- Триггеры содержат `;` внутри тела: goose требует явных границ оператора. +-- +goose StatementBegin +CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts (rowid, content) VALUES (new.id, new.content); +END; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN + INSERT INTO messages_fts (messages_fts, rowid, content) VALUES ('delete', old.id, old.content); +END; +-- +goose StatementEnd +-- +goose StatementBegin +CREATE TRIGGER messages_fts_update AFTER UPDATE OF content ON messages BEGIN + INSERT INTO messages_fts (messages_fts, rowid, content) VALUES ('delete', old.id, old.content); + INSERT INTO messages_fts (rowid, content) VALUES (new.id, new.content); +END; +-- +goose StatementEnd + +-- Приглашения (AGENT.md 7.9): код, лимит использований и срок жизни. +CREATE TABLE invites ( + code TEXT PRIMARY KEY, + guild_id INTEGER NOT NULL REFERENCES guilds (id) ON DELETE CASCADE, + channel_id INTEGER REFERENCES channels (id) ON DELETE SET NULL, + creator_id INTEGER REFERENCES users (id) ON DELETE SET NULL, + max_uses INTEGER NOT NULL DEFAULT 0, + uses INTEGER NOT NULL DEFAULT 0, + max_age_sec INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX invites_guild_idx ON invites (guild_id, created_at DESC); +CREATE INDEX invites_expires_idx ON invites (expires_at); + +-- +goose Down +DROP TABLE invites; +DROP TRIGGER messages_fts_update; +DROP TRIGGER messages_fts_delete; +DROP TRIGGER messages_fts_insert; +DROP TABLE messages_fts; +DROP TABLE files; +DROP TABLE channel_read_states; +DROP TABLE message_reactions; +DROP TABLE messages;