package store import ( "context" "fmt" "time" ) // LinkPreview — кэшированное превью ссылки (AGENT.md, фаза 7: unfurl). // URLHash — sha256 (hex) от нормализованного URL, он же ключ таблицы. type LinkPreview struct { URLHash string URL string Title string Description string SiteName string ImageURL string Status string FetchedAt string ExpiresAt string } // Статусы превью. ok — метаданные найдены, empty — страница без них, // blocked — адрес запрещён, error — загрузка или разбор не удались. const ( LinkPreviewStatusOK = "ok" LinkPreviewStatusEmpty = "empty" LinkPreviewStatusBlocked = "blocked" LinkPreviewStatusError = "error" ) // defaultLinkPreviewTTL — запасной срок жизни записи, если вызывающий код // не задал ExpiresAt: нулевая метка выглядела бы как уже истёкшая. const defaultLinkPreviewTTL = 24 * time.Hour // SaveLinkPreviewParams — параметры сохранения превью. Метки времени // хранятся в том же формате, что и в остальных таблицах (s.Timestamp). type SaveLinkPreviewParams struct { URLHash string URL string Title string Description string SiteName string ImageURL string Status string FetchedAt time.Time ExpiresAt time.Time } const linkPreviewColumns = `url_hash, url, title, description, site_name, image_url, status, fetched_at, expires_at` // GetLinkPreview отдаёт превью по хэшу нормализованного URL. Истёкшие записи // считаются отсутствующими (ErrNotFound): их удаляет DeleteExpiredLinkPreviews. func (s *Store) GetLinkPreview(ctx context.Context, urlHash string) (*LinkPreview, error) { row := s.reader.QueryRowContext(ctx, ` SELECT `+linkPreviewColumns+` FROM link_previews WHERE url_hash = ? AND expires_at > ?`, urlHash, s.Now()) return scanLinkPreview(row) } // SaveLinkPreview сохраняет превью, перезаписывая запись с тем же url_hash // (upsert): повторная загрузка той же ссылки обновляет кэш. func (s *Store) SaveLinkPreview(ctx context.Context, params SaveLinkPreviewParams) error { if params.Status == "" { params.Status = LinkPreviewStatusError } fetchedAt := params.FetchedAt if fetchedAt.IsZero() { fetchedAt = s.now() } expiresAt := params.ExpiresAt if expiresAt.IsZero() { expiresAt = fetchedAt.Add(defaultLinkPreviewTTL) } _, err := s.writer.ExecContext(ctx, ` INSERT INTO link_previews (url_hash, url, title, description, site_name, image_url, status, fetched_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (url_hash) DO UPDATE SET url = excluded.url, title = excluded.title, description = excluded.description, site_name = excluded.site_name, image_url = excluded.image_url, status = excluded.status, fetched_at = excluded.fetched_at, expires_at = excluded.expires_at`, params.URLHash, params.URL, params.Title, params.Description, params.SiteName, params.ImageURL, params.Status, s.Timestamp(fetchedAt), s.Timestamp(expiresAt)) if err != nil { return mapError(err) } return nil } // DeleteExpiredLinkPreviews удаляет записи, срок жизни которых истёк до // before, и возвращает число удалённых строк (фоновая уборка кэша). func (s *Store) DeleteExpiredLinkPreviews(ctx context.Context, before time.Time) (int64, error) { result, err := s.writer.ExecContext(ctx, `DELETE FROM link_previews WHERE expires_at <= ?`, s.Timestamp(before)) if err != nil { return 0, mapError(err) } deleted, err := result.RowsAffected() if err != nil { return 0, fmt.Errorf("count deleted link previews: %w", err) } return deleted, nil } func scanLinkPreview(scanner interface{ Scan(...any) error }) (*LinkPreview, error) { var preview LinkPreview err := scanner.Scan(&preview.URLHash, &preview.URL, &preview.Title, &preview.Description, &preview.SiteName, &preview.ImageURL, &preview.Status, &preview.FetchedAt, &preview.ExpiresAt) if err != nil { return nil, mapError(err) } return &preview, nil }