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,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"))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user