a5205cc054
- миграция 00002: users, sessions, totp_secrets, webauthn_credentials, security_events, guilds, guild_members, roles, member_roles, channels, channel_overrides, audit_log + дефолтные instance_settings (AGENT.md 6.1) - internal/store: Snowflake-идентификаторы, CRUD пользователей и сессий (ротация, step-up, logout-all), TOTP и события безопасности, серверы, участники, роли, комнаты и оверрайды, настройки инстанса и аудит - internal/permissions: 37 прав битмаской, вычисление по правилам §6.2 (баз role @user → оверрайды ролей → оверрайд пользователя → ADMINISTRATOR), иерархия ролей и участников, тайм-ауты, обход для инстанс-админа, LRU-кэш с инвалидацией - internal/source: адаптер permissions.Source поверх store - тесты: 18 unit-тестов прав + интеграционный набор на реальной SQLite (приватная комната, модератор, владелец, инстанс-админ, тайм-аут) - golangci: обоснованное исключение gosec для пакета store (конверсии Snowflake и сборка SQL из константных шаблонов)
113 lines
3.7 KiB
Go
113 lines
3.7 KiB
Go
// Package store содержит доступ к данным glchat: модели и запросы поверх SQLite.
|
|
// Вся работа с БД идёт только через этот пакет (AGENT.md §12).
|
|
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"glchat/internal/database"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("record not found")
|
|
ErrConflict = errors.New("record already exists")
|
|
ErrPermissionDenied = errors.New("permission denied")
|
|
ErrLastAdminRole = errors.New("guild must keep a role with MANAGE_ROLES")
|
|
ErrLastAdministrator = errors.New("guild must keep administrator permissions")
|
|
)
|
|
|
|
// Store — точка доступа к данным. Writer используется для мутаций,
|
|
// Reader — для чтения (один писатель и пул чтения, AGENT.md §9.1).
|
|
type Store struct {
|
|
writer *sql.DB
|
|
reader *sql.DB
|
|
ids *Snowflake
|
|
now func() time.Time
|
|
}
|
|
|
|
func New(db *database.DB) *Store {
|
|
return &Store{
|
|
writer: db.Writer,
|
|
reader: db.Reader,
|
|
ids: NewSnowflake(0),
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
// Reader возвращает соединение для чтения (доступно другим пакетам,
|
|
// которым нужны собственные запросы, например permissions).
|
|
func (s *Store) Reader() *sql.DB { return s.reader }
|
|
|
|
// Writer возвращает соединение для записи.
|
|
func (s *Store) Writer() *sql.DB { return s.writer }
|
|
|
|
// NextID выдаёт новый идентификатор (Snowflake, AGENT.md 6.4).
|
|
func (s *Store) NextID() uint64 { return s.ids.Next() }
|
|
|
|
// Timestamp форматирует время в ISO 8601 UTC с миллисекундами.
|
|
func (s *Store) Timestamp(t time.Time) string {
|
|
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
|
}
|
|
|
|
func (s *Store) Now() string { return s.Timestamp(s.now()) }
|
|
|
|
// InTx выполняет функцию в транзакции с откатом при ошибке.
|
|
func (s *Store) InTx(ctx context.Context, fn func(tx *sql.Tx) error) error {
|
|
tx, err := s.writer.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
if err := fn(tx); err != nil {
|
|
if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
|
return fmt.Errorf("%w (rollback: %w)", err, rollbackErr)
|
|
}
|
|
return err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("commit transaction: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func mapError(err error) error {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// idToInt переводит uint64-идентификатор в int64 для SQLite. Идентификаторы
|
|
// Snowflake формируются приложением, поэтому заведомо меньше MaxInt64;
|
|
// проверка диапазона сохраняет корректность при ручных данных.
|
|
func idToInt(id uint64) (int64, error) {
|
|
if id > math.MaxInt64 {
|
|
return 0, fmt.Errorf("identifier %d exceeds the supported range", id)
|
|
}
|
|
return int64(id), nil
|
|
}
|
|
|
|
// intToID переводит значение SQLite в uint64-идентификатор.
|
|
func intToID(value int64) uint64 {
|
|
if value < 0 {
|
|
return 0
|
|
}
|
|
return uint64(value)
|
|
}
|
|
|
|
// boolToInt превращает Go-флаг в значение SQLite (0/1).
|
|
func boolToInt(value bool) int {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// mustID конвертирует идентификатор, который уже проверен вызывающим кодом
|
|
// (значения Snowflake всегда в диапазоне int64).
|
|
func mustID(id uint64) int64 { return int64(id) }
|