Files
glchat/internal/store/link_previews.go
T

120 lines
4.4 KiB
Go
Raw Normal View History

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
}