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:
@@ -0,0 +1,183 @@
|
||||
// Package retention обслуживает чистку данных инстанса (AGENT.md 6.4, Фаза 5):
|
||||
// истёкшие сессии, старые записи аудита и файлы без ссылок. Запускается
|
||||
// периодически вместе с обслуживанием базы и доступен командой `glchat cleanup`.
|
||||
package retention
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"glchat/internal/store"
|
||||
)
|
||||
|
||||
// Options — настройки чистки.
|
||||
type Options struct {
|
||||
// DataDir — каталог данных: файлы лежат в его подкаталоге `files`.
|
||||
DataDir string
|
||||
// AuditDays — сколько дней хранить журнал аудита (0 — хранить всегда).
|
||||
AuditDays int
|
||||
// OrphanHours — сколько часов файл может жить без ссылок.
|
||||
OrphanHours int
|
||||
// BatchLimit — сколько файлов удалять за один проход.
|
||||
BatchLimit int
|
||||
// Logger — журнал: чистка пишет итоги и предупреждения.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Stats — что сделала одна чистка.
|
||||
type Stats struct {
|
||||
Sessions int64
|
||||
Audit int64
|
||||
Files int64
|
||||
// Bytes — сколько освободили на диске.
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
// Runner выполняет чистку по расписанию.
|
||||
type Runner struct {
|
||||
store *store.Store
|
||||
options Options
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New создаёт обслуживание чистки. Логгер по умолчанию — тихий.
|
||||
func New(st *store.Store, options Options) *Runner {
|
||||
if options.Logger == nil {
|
||||
options.Logger = slog.New(slog.DiscardHandler)
|
||||
}
|
||||
if options.BatchLimit <= 0 {
|
||||
options.BatchLimit = 200
|
||||
}
|
||||
return &Runner{store: st, options: options, now: func() time.Time { return time.Now().UTC() }}
|
||||
}
|
||||
|
||||
// RunOnce выполняет один проход чистки и возвращает статистику.
|
||||
func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
var stats Stats
|
||||
var errs []error
|
||||
|
||||
// 1. Истёкшие сессии: вход по ним всё равно невозможен.
|
||||
deleted, err := r.store.DeleteSessionsBefore(ctx, r.now())
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("sessions: %w", err))
|
||||
}
|
||||
stats.Sessions = deleted
|
||||
|
||||
// 2. Журнал аудита: хранится ограниченное время (настраивается).
|
||||
if r.options.AuditDays > 0 {
|
||||
cutoff := r.now().AddDate(0, 0, -r.options.AuditDays)
|
||||
deleted, err := r.store.DeleteAuditBefore(ctx, cutoff)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("audit: %w", err))
|
||||
}
|
||||
stats.Audit = deleted
|
||||
}
|
||||
|
||||
// 3. Файлы без ссылок: сначала с диска, потом из базы — иначе при сбое
|
||||
// между шагами файл остался бы без записи и не чистился бы никогда.
|
||||
if r.options.OrphanHours > 0 {
|
||||
cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour)
|
||||
orphans, err := r.store.ListOrphanFiles(ctx, cutoff, r.options.BatchLimit)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("orphan files: %w", err))
|
||||
}
|
||||
for i := range orphans {
|
||||
file := orphans[i]
|
||||
size, err := r.removeFile(file)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove file %d: %w", file.ID, err))
|
||||
continue
|
||||
}
|
||||
if err := r.store.DeleteFileRecord(ctx, file.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("delete file record %d: %w", file.ID, err))
|
||||
continue
|
||||
}
|
||||
stats.Files++
|
||||
stats.Bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
stats2 := stats
|
||||
if len(errs) > 0 {
|
||||
return stats2, errors.Join(errs...)
|
||||
}
|
||||
return stats2, nil
|
||||
}
|
||||
|
||||
// Run запускает чистку по расписанию; возвращает функцию остановки.
|
||||
func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
interval = time.Hour
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
stats, err := r.RunOnce(ctx)
|
||||
if err != nil {
|
||||
r.options.Logger.WarnContext(ctx, "retention failed", slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
if stats != (Stats{}) {
|
||||
r.options.Logger.InfoContext(ctx, "retention done",
|
||||
slog.Int64("sessions", stats.Sessions),
|
||||
slog.Int64("audit", stats.Audit),
|
||||
slog.Int64("files", stats.Files),
|
||||
slog.Int64("bytes", stats.Bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// removeFile удаляет содержимое файла и возвращает его размер. Отсутствие файла
|
||||
// на диске не считается ошибкой: запись всё равно нужно убрать.
|
||||
func (r *Runner) removeFile(file store.File) (int64, error) {
|
||||
path, err := r.storagePath(file.StoragePath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// storagePath проверяет, что путь лежит внутри каталога данных (AGENT.md 9.2).
|
||||
func (r *Runner) storagePath(stored string) (string, error) {
|
||||
root := filepath.Join(r.options.DataDir, "files")
|
||||
clean := filepath.Clean(stored)
|
||||
relative, err := filepath.Rel(root, clean)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasPrefix(relative, "..") || filepath.IsAbs(relative) {
|
||||
return "", fmt.Errorf("path %q is outside %q", clean, root)
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
Reference in New Issue
Block a user