Files
rsspipe/internal/rules/engine.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

163 lines
4.4 KiB
Go

package rules
import (
"context"
"crypto/sha256"
"encoding/hex"
"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 WorkerDispatcher interface {
Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int)
}
type JobEnqueuer interface {
Enqueue(ctx context.Context, jobType string, payload map[string]interface{}, idempotencyKey string) (*models.Job, error)
}
type Engine struct {
db *db.DB
bus *events.EventBus
evaluator *Evaluator
dispatcher WorkerDispatcher
enqueuer JobEnqueuer
log *zap.Logger
}
func NewEngine(database *db.DB, bus *events.EventBus, dispatcher WorkerDispatcher) *Engine {
return &Engine{
db: database,
bus: bus,
evaluator: NewEvaluator(),
dispatcher: dispatcher,
log: logger.Named("rules-engine"),
}
}
func (e *Engine) SetEnqueuer(enqueuer JobEnqueuer) {
e.enqueuer = enqueuer
}
// Start registers event bus subscriptions to evaluate rules reactively.
func (e *Engine) Start(ctx context.Context) {
e.bus.Subscribe(events.EventEntryTransition, e.handleTransitionEvent)
e.bus.Subscribe(events.EventPostCreated, e.handleEvent)
e.bus.Subscribe(events.EventPostUpdated, e.handleEvent)
e.bus.Subscribe(events.EventVariantUpdated, e.handleEvent)
e.bus.Subscribe(events.EventManualTrigger, e.handleEvent)
e.log.Info("Rules engine started, listening for events")
}
func (e *Engine) handleTransitionEvent(ctx context.Context, evt events.Event) error {
rules, err := e.db.ListRules()
if err != nil {
e.log.Error("Failed to fetch rules for transition", zap.Error(err))
return err
}
newEntry := evt.NewEntry
if newEntry == nil {
newEntry = &evt.Entry
}
for _, r := range rules {
if !r.IsActive {
continue
}
// Check trigger type compatibility
trigger := r.Trigger
if trigger != "on_update" && trigger != "on_entry_transition" && trigger != "on_variant_updated" && trigger != "on_post_updated" && trigger != "on_post_created" && trigger != "variant_change" {
continue
}
if e.evaluator.EvaluateTransition(r, evt.OldEntry, newEntry, evt.Diff) {
e.log.Info("Transition rule matched",
zap.String("rule_id", r.ID),
zap.String("rule_name", r.Name),
zap.String("entry_id", newEntry.ID),
zap.Int("actions_count", len(r.Actions)),
)
e.executeRuleActions(ctx, r, *newEntry, evt.Depth)
}
}
return nil
}
func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
trigger := string(evt.Type)
rules, err := e.db.GetActiveRules(trigger)
if err != nil {
e.log.Error("Failed to fetch active rules", zap.String("trigger", trigger), zap.Error(err))
return err
}
if len(rules) == 0 {
return nil
}
entry := evt.Entry
if entry.ID != "" {
if full, err := e.db.GetEntry(entry.ID); err == nil && full != nil {
entry = *full
}
}
for _, r := range rules {
if e.evaluator.EvaluateTransition(r, evt.OldEntry, &entry, evt.Diff) {
e.log.Info("Rule matched",
zap.String("rule_id", r.ID),
zap.String("rule_name", r.Name),
zap.String("entry_id", entry.ID),
zap.Int("actions_count", len(r.Actions)),
)
e.executeRuleActions(ctx, r, entry, evt.Depth)
}
}
return nil
}
func (e *Engine) executeRuleActions(ctx context.Context, r models.Rule, entry models.FeedEntry, depth int) {
for _, action := range r.Actions {
// Prefer enqueuing async jobs to the persistent task queue if available
if e.enqueuer != nil {
payload := make(map[string]interface{})
if action.Params != nil {
for k, v := range action.Params {
payload[k] = v
}
}
payload["entry_id"] = entry.ID
payload["feed_id"] = entry.FeedID
payload["rule_id"] = r.ID
payload["rule_name"] = r.Name
// Generate deterministic idempotency key
rawKey := fmt.Sprintf("%s:%s:%s:%d:%d", r.ID, entry.ID, action.Type, entry.CreatedAt.Unix(), entry.PublishedAt.Unix())
h := sha256.Sum256([]byte(rawKey))
idempotencyKey := hex.EncodeToString(h[:16])
_, err := e.enqueuer.Enqueue(ctx, action.Type, payload, idempotencyKey)
if err != nil {
e.log.Error("Failed to enqueue job for rule action",
zap.String("rule_id", r.ID),
zap.String("action", action.Type),
zap.Error(err),
)
}
} else if e.dispatcher != nil {
e.dispatcher.Dispatch(ctx, action, entry, depth+1)
}
}
}