- 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.
163 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|