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
}