refactor(core): overhaul state transition architecture, persistent job queue, and API layer
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

- 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:
2026-09-01 00:41:05 -07:00
parent 271a542196
commit ebcaa5d72a
38 changed files with 3748 additions and 1202 deletions
+218
View File
@@ -0,0 +1,218 @@
package services
import (
"context"
"fmt"
"reflect"
"strings"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"go.uber.org/zap"
)
type EntryService struct {
db *db.DB
bus *events.EventBus
log *zap.Logger
}
func NewEntryService(database *db.DB, bus *events.EventBus) *EntryService {
return &EntryService{
db: database,
bus: bus,
log: logger.Named("entry-service"),
}
}
// IngestEntries handles bulk ingestion of parsed feed entries (from crawler or OPML import)
func (s *EntryService) IngestEntries(ctx context.Context, feedID string, entries []models.FeedEntry, feedTitle, feedSiteURL string) ([]models.FeedEntry, error) {
newEntries, err := s.db.UpsertFeedEntries(feedID, entries)
if err != nil {
return nil, err
}
for _, entry := range newEntries {
entry.FeedTitle = feedTitle
entry.FeedSiteURL = feedSiteURL
diff := &models.EntryDiff{
FieldsChanged: []string{"id", "title", "content", "url"},
}
if s.bus != nil {
s.bus.Publish(ctx, events.Event{
Type: events.EventPostCreated,
Entry: entry,
NewEntry: &entry,
Diff: diff,
Depth: 0,
})
s.bus.Publish(ctx, events.Event{
Type: events.EventEntryTransition,
Entry: entry,
NewEntry: &entry,
Diff: diff,
Depth: 0,
})
}
}
return newEntries, nil
}
// PatchEntry applies atomic mutations to an entry, calculates the EntryDiff between Old and New states,
// and fires EventEntryTransition to trigger downstream transition-based automation rules.
func (s *EntryService) PatchEntry(ctx context.Context, entryID string, patch models.PatchEntryRequest) (*models.FeedEntry, *models.EntryDiff, error) {
oldEntry, err := s.db.GetEntry(entryID)
if err != nil {
return nil, nil, fmt.Errorf("entry not found: %w", err)
}
diff := &models.EntryDiff{
FieldsChanged: make([]string, 0),
VariantsAdded: make([]string, 0),
VariantsChanged: make([]string, 0),
VariantsRemoved: make([]string, 0),
}
// 1. Update IsRead
if patch.IsRead != nil && *patch.IsRead != oldEntry.IsRead {
if err := s.db.UpdateEntryRead(entryID, *patch.IsRead); err != nil {
return nil, nil, err
}
diff.FieldsChanged = append(diff.FieldsChanged, "is_read")
}
// 2. Update IsStarred
if patch.IsStarred != nil && *patch.IsStarred != oldEntry.IsStarred {
if err := s.db.UpdateEntryStarred(entryID, *patch.IsStarred); err != nil {
return nil, nil, err
}
diff.FieldsChanged = append(diff.FieldsChanged, "is_starred")
}
// 3. Update Metadata (e.g. tags)
if patch.Metadata != nil {
if oldEntry.Metadata == nil {
oldEntry.Metadata = make(map[string]interface{})
}
metadataChanged := false
for k, v := range patch.Metadata {
if !reflect.DeepEqual(oldEntry.Metadata[k], v) {
oldEntry.Metadata[k] = v
metadataChanged = true
diff.FieldsChanged = append(diff.FieldsChanged, "metadata."+k)
}
}
if metadataChanged {
if err := s.db.UpdateEntryMetadata(entryID, oldEntry.Metadata); err != nil {
return nil, nil, err
}
}
}
// 4. Update Variants
if patch.Variants != nil {
for vKey, vVal := range patch.Variants {
if vVal == nil {
// Variant removal
if oldEntry.Variants != nil {
if _, exists := oldEntry.Variants[vKey]; exists {
_ = s.db.DeleteVariant(entryID, vKey)
diff.VariantsRemoved = append(diff.VariantsRemoved, vKey)
}
}
continue
}
// Variant add or update
vVal.EntryID = entryID
vVal.VariantKey = vKey
oldVariant, exists := oldEntry.Variants[vKey]
if !exists {
diff.VariantsAdded = append(diff.VariantsAdded, vKey)
} else if oldVariant.TextValue != vVal.TextValue || oldVariant.BlobPath != vVal.BlobPath || !reflect.DeepEqual(oldVariant.Metadata, vVal.Metadata) {
diff.VariantsChanged = append(diff.VariantsChanged, vKey)
}
if err := s.db.SetVariant(*vVal); err != nil {
return nil, nil, fmt.Errorf("failed to save variant %s: %w", vKey, err)
}
}
}
// Fetch fresh NewState
newEntry, err := s.db.GetEntry(entryID)
if err != nil {
return nil, nil, fmt.Errorf("failed to retrieve updated entry: %w", err)
}
s.log.Debug("Entry patched with transition diff",
zap.String("entry_id", entryID),
zap.Strings("fields_changed", diff.FieldsChanged),
zap.Strings("variants_added", diff.VariantsAdded),
zap.Strings("variants_changed", diff.VariantsChanged),
)
// Publish transition event to trigger reactive rules
if s.bus != nil {
s.bus.Publish(ctx, events.Event{
Type: events.EventEntryTransition,
Entry: *newEntry,
OldEntry: oldEntry,
NewEntry: newEntry,
Diff: diff,
Depth: 0,
})
s.bus.Publish(ctx, events.Event{
Type: events.EventPostUpdated,
Entry: *newEntry,
OldEntry: oldEntry,
NewEntry: newEntry,
Diff: diff,
Depth: 0,
})
for _, vKey := range diff.VariantsAdded {
s.bus.Publish(ctx, events.Event{
Type: events.EventVariantUpdated,
Entry: *newEntry,
VariantKey: vKey,
Depth: 0,
})
}
for _, vKey := range diff.VariantsChanged {
s.bus.Publish(ctx, events.Event{
Type: events.EventVariantUpdated,
Entry: *newEntry,
VariantKey: vKey,
Depth: 0,
})
}
}
return newEntry, diff, nil
}
func (s *EntryService) GetEntry(ctx context.Context, id string) (*models.FeedEntry, error) {
return s.db.GetEntry(id)
}
func (s *EntryService) ListEntries(ctx context.Context, filter db.EntryFilter) ([]models.FeedEntry, int, error) {
return s.db.ListEntries(filter)
}
func (s *EntryService) MarkAllRead(ctx context.Context, feedID, categoryID *string) error {
return s.db.MarkAllRead(feedID, categoryID)
}
func (s *EntryService) ToggleShare(ctx context.Context, id string) (*string, error) {
return s.db.ToggleShareEntry(id)
}
func (s *EntryService) GetSharedEntry(ctx context.Context, shareUUID string) (*models.FeedEntry, error) {
return s.db.GetSharedEntry(strings.TrimSuffix(shareUUID, ".html"))
}
+85
View File
@@ -0,0 +1,85 @@
package services
import (
"context"
"path/filepath"
"testing"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
)
func TestEntryServicePatchAndTransition(t *testing.T) {
tempDBPath := filepath.Join(t.TempDir(), "test.db")
database, err := db.InitDB(tempDBPath)
if err != nil {
t.Fatalf("failed to init db: %v", err)
}
defer func() { _ = database.Close() }()
bus := events.NewEventBus()
svc := NewEntryService(database, bus)
// Create test category and feed
cat, _ := database.CreateCategory("Tech", nil)
feed, _ := database.CreateFeed("Test Feed", "https://example.com/rss", "https://example.com", "", &cat.ID)
// Ingest an entry
entry := models.FeedEntry{
ID: "e_test_1",
FeedID: feed.ID,
GUID: "guid_1",
URL: "https://example.com/p/1",
Title: "Test Post",
Content: "Hello world",
PublishedAt: time.Now().UTC(),
}
ingested, err := svc.IngestEntries(context.Background(), feed.ID, []models.FeedEntry{entry}, feed.Title, feed.SiteURL)
if err != nil || len(ingested) == 0 {
t.Fatalf("failed to ingest entry: %v", err)
}
// 1. Test patching tags
isStarred := true
updated, diff, err := svc.PatchEntry(context.Background(), ingested[0].ID, models.PatchEntryRequest{
IsStarred: &isStarred,
Metadata: map[string]interface{}{
"tags": []string{"AI", "Kubernetes"},
},
})
if err != nil {
t.Fatalf("failed to patch entry: %v", err)
}
if !updated.IsStarred {
t.Errorf("expected entry to be starred")
}
if !diff.HasFieldChange("is_starred") {
t.Errorf("expected diff to contain is_starred change")
}
// 2. Test patching variant addition (readability_full_text)
fullTextVariant := &models.EntryVariant{
ContentType: "html",
TextValue: "<p>Full article extracted content...</p>",
}
updated2, diff2, err := svc.PatchEntry(context.Background(), ingested[0].ID, models.PatchEntryRequest{
Variants: map[string]*models.EntryVariant{
"readability_full_text": fullTextVariant,
},
})
if err != nil {
t.Fatalf("failed to patch variant: %v", err)
}
if !diff2.HasVariantAddition("readability_full_text") {
t.Errorf("expected diff2 to report variant addition of readability_full_text")
}
if updated2.Variants["readability_full_text"].TextValue == "" {
t.Errorf("expected variant to be loaded in updated entry")
}
}
+186
View File
@@ -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)
}
+126
View File
@@ -0,0 +1,126 @@
package services
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"github.com/google/uuid"
"go.uber.org/zap"
)
type JobService struct {
db *db.DB
bus *events.EventBus
log *zap.Logger
}
func NewJobService(database *db.DB, bus *events.EventBus) *JobService {
return &JobService{
db: database,
bus: bus,
log: logger.Named("job-service"),
}
}
// GenerateIdempotencyKey creates a deterministic key to prevent duplicate jobs
func GenerateIdempotencyKey(jobType, entityID, action string, extra string) string {
raw := fmt.Sprintf("%s:%s:%s:%s", jobType, entityID, action, extra)
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:16])
}
// Enqueue submits a job to the persistent SQLite queue.
// If an idempotency key is provided and a job already exists with that key, returns the existing job.
func (s *JobService) Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error) {
if idempotencyKey != "" {
if existing, err := s.db.GetJobByIdempotencyKey(idempotencyKey); err == nil && existing != nil {
s.log.Debug("Job already exists with idempotency key, skipping duplicate enqueue",
zap.String("job_id", existing.ID),
zap.String("type", jobType),
zap.String("key", idempotencyKey),
)
return existing, nil
}
}
job := models.Job{
ID: uuid.New().String(),
Type: jobType,
Payload: payload,
Status: "pending",
Progress: 0,
Attempts: 0,
MaxAttempts: 3,
IdempotencyKey: idempotencyKey,
RunAt: time.Now().UTC(),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
created, err := s.db.CreateJob(job)
if err != nil {
return nil, fmt.Errorf("failed to enqueue job: %w", err)
}
s.log.Info("Job enqueued",
zap.String("job_id", created.ID),
zap.String("type", created.Type),
zap.String("idempotency_key", idempotencyKey),
)
if s.bus != nil {
s.bus.Publish(ctx, events.Event{
Type: events.EventJobProgress,
Job: created,
})
}
return created, nil
}
func (s *JobService) GetJob(ctx context.Context, id string) (*models.Job, error) {
return s.db.GetJob(id)
}
func (s *JobService) ListJobs(ctx context.Context, filter models.JobFilter) ([]models.Job, error) {
return s.db.ListJobs(filter)
}
func (s *JobService) CancelJob(ctx context.Context, id string) error {
err := s.db.CancelJob(id)
if err == nil && s.bus != nil {
if j, err := s.db.GetJob(id); err == nil {
s.bus.Publish(ctx, events.Event{
Type: events.EventJobFailed,
Job: j,
})
}
}
return err
}
func (s *JobService) RetryJob(ctx context.Context, id string) error {
j, err := s.db.GetJob(id)
if err != nil {
return err
}
j.Status = "pending"
j.ErrorMessage = ""
j.Attempts = 0
j.RunAt = time.Now().UTC()
err = s.db.UpdateJob(*j)
if err == nil && s.bus != nil {
s.bus.Publish(ctx, events.Event{
Type: events.EventJobProgress,
Job: j,
})
}
return err
}
+98
View File
@@ -0,0 +1,98 @@
package services
import (
"context"
"encoding/json"
"fmt"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"go.uber.org/zap"
)
type SettingsService struct {
db *db.DB
bus *events.EventBus
log *zap.Logger
}
func NewSettingsService(database *db.DB, bus *events.EventBus) *SettingsService {
return &SettingsService{
db: database,
bus: bus,
log: logger.Named("settings-service"),
}
}
func (s *SettingsService) GetPreferences(ctx context.Context, username string) (map[string]interface{}, error) {
user, err := s.db.GetUserByUsername(username)
if err != nil {
return nil, err
}
if user.Preferences == nil {
return make(map[string]interface{}), nil
}
return user.Preferences, nil
}
func (s *SettingsService) UpdatePreferences(ctx context.Context, username string, newPrefs map[string]interface{}) (map[string]interface{}, error) {
user, err := s.db.GetUserByUsername(username)
if err != nil {
return nil, err
}
if user.Preferences == nil {
user.Preferences = make(map[string]interface{})
}
for k, v := range newPrefs {
user.Preferences[k] = v
}
if err := s.db.UpdateUserPreferences(user.ID, user.Preferences); err != nil {
return nil, err
}
if s.bus != nil {
s.bus.Publish(ctx, events.Event{
Type: events.EventPreferencesSync,
DirectAttrs: user.Preferences,
})
}
return user.Preferences, nil
}
func (s *SettingsService) GetAISettings(ctx context.Context) (*models.AISettings, error) {
raw, err := s.db.GetSetting("ai_settings")
if err != nil || raw == "" {
return &models.AISettings{
Enabled: false,
Provider: "openai",
BaseURL: "https://api.openai.com/v1",
APIKey: "",
Model: "gpt-4o-mini",
Temperature: 0.3,
MaxTokens: 1000,
Prompts: []models.PromptTemplate{},
}, nil
}
var cfg models.AISettings
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, fmt.Errorf("failed to parse AI settings: %w", err)
}
return &cfg, nil
}
func (s *SettingsService) UpdateAISettings(ctx context.Context, cfg models.AISettings) (*models.AISettings, error) {
bytes, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("failed to marshal AI settings: %w", err)
}
if err := s.db.SetSetting("ai_settings", string(bytes)); err != nil {
return nil, err
}
return &cfg, nil
}