feat(ops): чистка данных, reindex и PWA с офлайн-режимом

- internal/retention: истёкшие сессии, аудит старше RETENTION_AUDIT_DAYS и
  файлы без ссылок старше RETENTION_ORPHAN_HOURS; сиротой файл считается
  только если на него не ссылается ничего (вложения, аватары, оформление
  сервера, комнат, ролей, эмодзи, звуки, вебхуки, приглашения)
- обслуживание запускается в приложении по RETENTION_INTERVAL_SECONDS;
  команды glchat cleanup и glchat reindex делают то же вручную
- PWA: манифест с иконками и shortcuts, офлайн-страница, сервис-воркер
  (кэш только /assets/*, приватные запросы не кэшируются)
- тесты: Go на чистку (сирота удаляется, используемые файлы и свежие сироты
  остаются, истёкшая сессия уходит), Vitest на манифест и регистрацию воркера
This commit is contained in:
2026-09-21 19:42:12 +03:00
parent 72537e9ff9
commit 5afcfee744
19 changed files with 813 additions and 12 deletions
+26 -6
View File
@@ -96,16 +96,36 @@ func (s *Store) DeleteFile(ctx context.Context, id uint64) error {
return nil
}
// ListOrphanFiles возвращает файлы без сообщения старше указанного времени:
// их удаляет обслуживание (AGENT.md 7.7).
// orphanFilesQuery — файлы, на которые никто не ссылается: вложения сообщений,
// аватары и баннеры пользователей, оформление сервера, роли и комнаты, эмодзи,
// звуки, вебхуки и приглашения (AGENT.md 6.4, 7.7).
const orphanFilesQuery = `
SELECT ` + fileColumns + `
FROM files f
WHERE f.created_at <= ?
AND f.message_id IS NULL
AND NOT EXISTS (SELECT 1 FROM users u WHERE u.avatar_file_id = f.id OR u.banner_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guilds g WHERE g.icon_file_id = f.id OR g.banner_file_id = f.id OR g.splash_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM channels c WHERE c.background_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM invites i WHERE i.background_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guild_cosmetics gc WHERE gc.file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM webhooks w WHERE w.avatar_file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guild_emojis ge WHERE ge.file_id = f.id)
AND NOT EXISTS (SELECT 1 FROM guild_sounds gs WHERE gs.file_id = f.id)
AND NOT EXISTS (
SELECT 1 FROM messages m, json_each(m.attachments_json) a
WHERE json_extract(a.value, '$.file_id') = f.id
)
ORDER BY f.id
LIMIT ?`
// ListOrphanFiles возвращает файлы без ссылок старше указанного времени: их
// удаляет обслуживание (AGENT.md 6.4, 7.7).
func (s *Store) ListOrphanFiles(ctx context.Context, olderThan time.Time, limit int) ([]File, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
rows, err := s.reader.QueryContext(ctx, `
SELECT `+fileColumns+` FROM files
WHERE message_id IS NULL AND created_at < ? ORDER BY id LIMIT ?`,
s.Timestamp(olderThan), limit)
rows, err := s.reader.QueryContext(ctx, orphanFilesQuery, s.Timestamp(olderThan), limit)
if err != nil {
return nil, err
}
+55
View File
@@ -0,0 +1,55 @@
package store
import (
"context"
"time"
)
// Retention-запросы (AGENT.md 6.4, Фаза 5): чистка сессий, аудита и файлов без
// ссылок. Все методы идемпотентны и работают порциями, чтобы не держать
// блокировку писателя надолго.
// DeleteSessionsBefore удаляет сессии, истёкшие раньше указанного времени.
func (s *Store) DeleteSessionsBefore(ctx context.Context, before time.Time) (int64, error) {
result, err := s.writer.ExecContext(ctx,
`DELETE FROM sessions WHERE expires_at <= ?`, s.Timestamp(before))
if err != nil {
return 0, mapError(err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return affected, nil
}
// DeleteAuditBefore удаляет записи аудита старше указанного времени.
func (s *Store) DeleteAuditBefore(ctx context.Context, before time.Time) (int64, error) {
result, err := s.writer.ExecContext(ctx,
`DELETE FROM audit_log WHERE created_at <= ?`, s.Timestamp(before))
if err != nil {
return 0, mapError(err)
}
affected, err := result.RowsAffected()
if err != nil {
return 0, err
}
return affected, nil
}
// DeleteFileRecord удаляет запись о файле (содержимое удаляет вызывающий).
func (s *Store) DeleteFileRecord(ctx context.Context, id uint64) error {
if _, err := s.writer.ExecContext(ctx, `DELETE FROM files WHERE id = ?`, int64(id)); err != nil {
return mapError(err)
}
return nil
}
// RebuildSearchIndex пересобирает индекс полнотекстового поиска (FTS5).
func (s *Store) RebuildSearchIndex(ctx context.Context) error {
if _, err := s.writer.ExecContext(ctx,
`INSERT INTO messages_fts (messages_fts) VALUES ('rebuild')`); err != nil {
return mapError(err)
}
return nil
}