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.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user