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;