Files
rsspipe/internal/services/feed_service.go
T
haopengzhan ebcaa5d72a
CI Quality Checks & Tests / Gitleaks Secret Detection (push) Failing after 1m59s
CI Quality Checks & Tests / Go Lint & Unit Tests (push) Failing after 2m26s
CI Quality Checks & Tests / Frontend Typecheck & Build (push) Successful in 2m37s
refactor(core): overhaul state transition architecture, persistent job queue, and API layer
- Implement atomic state transitions: add EntryService.PatchEntry() with EntryDiff computation and EventEntryTransition event emission.
- Add persistent SQLite task queue (JobEngine): add jobs table with crash recovery, exponential backoff retries, concurrency control, and idempotency key deduplication.
- Decompose monolithic HTTP handlers: split handlers.go into domain handlers (auth, feeds, entries, jobs, settings, rules, sse).
- Implement real-time Server-Sent Events (/api/events/stream) for multi-tab/device state synchronization.
- Fix SQLite concurrency: apply busy_timeout(10000) and journal_mode(WAL) via DSN query parameters to resolve SQLITE_BUSY (5).
- Fix AI settings & prompt loading: unmarshal JSON from ai_settings, apply configured prompt templates, and enforce strict enabled/API key validations.
- Fix undo share state: remove omitempty from ShareUUID to serialize null for state resets.
- Fix middle list counter: compute true unread/total stats from DB and clean switcher button labels.
- Adapt mobile UI for iOS: add viewport-fit=cover in index.html and calibrate safe-area insets for the bottom navigation and article reader toolbar.
- Add architectural documentation and worklog in docs/009_worklog.md.
2026-09-01 00:41:05 -07:00

187 lines
4.7 KiB
Go

package services
import (
"context"
"fmt"
"strings"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/crawler"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
"go.uber.org/zap"
)
type FeedService struct {
db *db.DB
entryService *EntryService
parser *parser.Parser
crawler *crawler.Crawler
log *zap.Logger
}
func NewFeedService(database *db.DB, entrySvc *EntryService, cr *crawler.Crawler) *FeedService {
return &FeedService{
db: database,
entryService: entrySvc,
parser: parser.NewParser(),
crawler: cr,
log: logger.Named("feed-service"),
}
}
func (s *FeedService) ListFeeds(ctx context.Context) ([]models.Feed, error) {
return s.db.ListFeeds()
}
func (s *FeedService) GetFeed(ctx context.Context, id string) (*models.Feed, error) {
return s.db.GetFeed(id)
}
func (s *FeedService) CreateFeed(ctx context.Context, req models.CreateFeedRequest) (*models.Feed, error) {
feedURL := strings.TrimSpace(req.FeedURL)
if feedURL == "" {
return nil, fmt.Errorf("feed_url is required")
}
if existing, _ := s.db.GetFeedByURL(feedURL); existing != nil {
return nil, fmt.Errorf("feed is already subscribed")
}
res, err := s.parser.FetchFeed(ctx, feedURL, "", "")
if err != nil {
return nil, fmt.Errorf("failed to fetch feed: %w", err)
}
title := res.Title
if req.Title != nil && strings.TrimSpace(*req.Title) != "" {
title = strings.TrimSpace(*req.Title)
}
iconURL := ""
if req.IconURL != nil && strings.TrimSpace(*req.IconURL) != "" {
iconURL = strings.TrimSpace(*req.IconURL)
}
feed, err := s.db.CreateFeed(title, feedURL, res.SiteURL, iconURL, req.CategoryID)
if err != nil {
return nil, err
}
if len(res.Entries) > 0 {
_, _ = s.entryService.IngestEntries(ctx, feed.ID, res.Entries, feed.Title, feed.SiteURL)
_ = s.db.UpdateFeedFetchStatus(feed.ID, res.ETag, res.LastModified, "")
}
return s.db.GetFeed(feed.ID)
}
func (s *FeedService) UpdateFeed(ctx context.Context, id string, req models.UpdateFeedRequest) (*models.Feed, error) {
if err := s.db.UpdateFeed(id, req); err != nil {
return nil, err
}
return s.db.GetFeed(id)
}
func (s *FeedService) DeleteFeed(ctx context.Context, id string) error {
return s.db.DeleteFeed(id)
}
func (s *FeedService) RefreshFeed(ctx context.Context, id string) (*models.Feed, error) {
if s.crawler != nil {
if err := s.crawler.RefreshSingle(ctx, id); err != nil {
return nil, err
}
}
return s.db.GetFeed(id)
}
func (s *FeedService) RefreshAllFeeds(ctx context.Context) {
if s.crawler != nil {
go s.crawler.RefreshAll(context.Background())
}
}
// Category methods
func (s *FeedService) ListCategories(ctx context.Context) ([]models.Category, error) {
return s.db.ListCategories()
}
func (s *FeedService) CreateCategory(ctx context.Context, title string, parentID *string) (*models.Category, error) {
return s.db.CreateCategory(strings.TrimSpace(title), parentID)
}
func (s *FeedService) DeleteCategory(ctx context.Context, id string) error {
return s.db.DeleteCategory(id)
}
func (s *FeedService) GetStats(ctx context.Context) (*models.Stats, error) {
return s.db.GetStats()
}
// OPML methods
func (s *FeedService) ImportOPML(ctx context.Context, data []byte) (int, int, error) {
importedFeeds, err := opml.ParseOPML(data)
if err != nil {
return 0, 0, fmt.Errorf("invalid OPML XML: %w", err)
}
categoryCache := make(map[string]string)
cats, _ := s.db.ListCategories()
for _, c := range cats {
categoryCache[c.Title] = c.ID
}
createdCount := 0
for _, item := range importedFeeds {
if item.FeedURL == "" {
continue
}
if existing, _ := s.db.GetFeedByURL(item.FeedURL); existing != nil {
continue
}
var catID *string
if item.CategoryTitle != "" {
if cid, ok := categoryCache[item.CategoryTitle]; ok {
catID = &cid
} else {
newCat, err := s.db.CreateCategory(item.CategoryTitle, nil)
if err == nil {
categoryCache[item.CategoryTitle] = newCat.ID
catID = &newCat.ID
}
}
}
title := item.Title
if title == "" {
title = item.FeedURL
}
_, err := s.db.CreateFeed(title, item.FeedURL, item.SiteURL, "", catID)
if err == nil {
createdCount++
}
}
if s.crawler != nil {
go s.crawler.RefreshAll(context.Background())
}
return createdCount, len(importedFeeds), nil
}
func (s *FeedService) ExportOPML(ctx context.Context) ([]byte, error) {
feeds, err := s.db.ListFeeds()
if err != nil {
return nil, err
}
cats, err := s.db.ListCategories()
if err != nil {
return nil, err
}
return opml.GenerateOPML(feeds, cats)
}