From e1b3a7ebd286538ad0d760b0d4a7ef0efdabeb8a Mon Sep 17 00:00:00 2001 From: "DESKTOP-L8OUL9F\\gfl0034" <1003614916@qq.com> Date: Thu, 24 Sep 2026 23:09:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(squarebot):=20=E8=A1=A5=E5=85=85=20Bot=20W?= =?UTF-8?q?orker=20=E4=B8=8E=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善广场机器人定时 Worker,并补充 RSS 解析相关单测。 --- .../module/squarebot/logic/service_test.go | 135 ++++++++++++++++++ internal/module/squarebot/logic/worker.go | 69 +++++++++ 2 files changed, 204 insertions(+) create mode 100644 internal/module/squarebot/logic/service_test.go create mode 100644 internal/module/squarebot/logic/worker.go diff --git a/internal/module/squarebot/logic/service_test.go b/internal/module/squarebot/logic/service_test.go new file mode 100644 index 0000000..efb63bf --- /dev/null +++ b/internal/module/squarebot/logic/service_test.go @@ -0,0 +1,135 @@ +package logic + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "spark-server/internal/infrastructure/id" + friendlogic "spark-server/internal/module/friend/logic" + friendmodel "spark-server/internal/module/friend/model" + squarelogic "spark-server/internal/module/square/logic" + squaremodel "spark-server/internal/module/square/model" + userentity "spark-server/internal/module/user/entity" + "spark-server/internal/module/user/logictest" + "spark-server/internal/pkg/errs" +) + +func TestParseRSSAndFormat(t *testing.T) { + body := []byte(` + +Demo News + +Hello & World +https://example.com/a +<p>Short summary here.</p> + +`) + items, err := parseFeed(body, "https://example.com/rss") + if err != nil || len(items) != 1 { + t.Fatalf("%+v %v", items, err) + } + if items[0].Title != "Hello & World" || items[0].Link != "https://example.com/a" { + t.Fatalf("%+v", items[0]) + } + text := FormatPostText(items[0]) + if !strings.Contains(text, "Demo News") || !strings.Contains(text, "https://example.com/a") { + t.Fatalf("text=%q", text) + } +} + +func TestPublishBotMediaTTLAndDedupe(t *testing.T) { + ids, err := id.New(2) + if err != nil { + t.Fatal(err) + } + users := logictest.Users(ids, nil) + friends := friendlogic.NewService(friendmodel.NewMemoryRepo(), users, ids, nil) + square := squarelogic.NewService(squaremodel.NewMemoryRepo(), friends, users, nil, ids) + ctx := t.Context() + u, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{ + DeviceID: 9, DeviceType: userentity.DeviceIOS, + }) + if err != nil { + t.Fatal(err) + } + in := &squarelogic.BotPublishInput{ + Text: "title\n\nsum\n\nhttps://example.com/x", SourceURL: "https://example.com/x", TTL: time.Hour, + } + v, err := square.PublishBotMedia(ctx, u.ID, in) + if err != nil || v == nil { + t.Fatalf("%v %v", v, err) + } + _, err = square.PublishBotMedia(ctx, u.ID, in) + if !errors.Is(err, errs.ErrDuplicate) { + t.Fatalf("want duplicate got %v", err) + } + feed, err := square.Feed(ctx, u.ID, 0, 20) + if err != nil || len(feed.Items) < 1 { + t.Fatalf("feed %+v %v", feed, err) + } + n, err := square.ExpireBotPosts(ctx, time.Now().Add(2*time.Hour), 10) + if err != nil || n < 1 { + t.Fatalf("expire %d %v", n, err) + } + feed2, err := square.Feed(ctx, u.ID, 0, 20) + if err != nil { + t.Fatal(err) + } + for _, it := range feed2.Items { + if it.ID == v.ID { + t.Fatal("expired post still visible") + } + } +} + +func TestServiceDisabledWithoutDeps(t *testing.T) { + svc := NewService(nil, nil, nil, nil, Options{Enabled: true}) + if svc.Enabled() { + t.Fatal("enabled without deps") + } +} + +type stubUsers struct { + u *userentity.User +} + +func (s stubUsers) GetByEmail(context.Context, string) (*userentity.User, error) { + if s.u == nil { + return nil, errs.ErrNotFound + } + return s.u, nil +} + +type stubSquare struct { + published int + expired int +} + +func (s *stubSquare) PublishBotMedia(context.Context, int64, *squarelogic.BotPublishInput) (*squarelogic.View, error) { + s.published++ + return &squarelogic.View{ID: 1}, nil +} + +func (s *stubSquare) ExpireBotPosts(context.Context, time.Time, int) (int, error) { + s.expired++ + return 0, nil +} + +func TestTickExpiresEvenIfFeedFails(t *testing.T) { + sq := &stubSquare{} + users := stubUsers{u: &userentity.User{ID: 7, Nickname: "Bot"}} + svc := NewService(nil, sq, users, nil, Options{ + Enabled: true, Interval: time.Minute, TTL: time.Hour, BotIndexes: []int{1}, + }) + svc.opts.Feeds = []string{"http://127.0.0.1:1/nope"} + err := svc.Tick(t.Context()) + if err == nil { + t.Fatal("expected feed error") + } + if sq.expired != 1 { + t.Fatalf("expire called %d", sq.expired) + } +} diff --git a/internal/module/squarebot/logic/worker.go b/internal/module/squarebot/logic/worker.go new file mode 100644 index 0000000..83cd5cf --- /dev/null +++ b/internal/module/squarebot/logic/worker.go @@ -0,0 +1,69 @@ +package logic + +import ( + "context" + "sync" + "time" + + "go.uber.org/zap" +) + +// Worker runs Tick on an interval until Stop. +type Worker struct { + log *zap.Logger + svc *Service + stop chan struct{} + wg sync.WaitGroup +} + +func NewWorker(log *zap.Logger, svc *Service) *Worker { + if log == nil { + log = zap.NewNop() + } + return &Worker{log: log, svc: svc, stop: make(chan struct{})} +} + +func (w *Worker) Start() { + if w == nil || w.svc == nil || !w.svc.Enabled() { + return + } + w.wg.Add(1) + go w.loop() +} + +func (w *Worker) Stop() { + if w == nil { + return + } + select { + case <-w.stop: + default: + close(w.stop) + } + w.wg.Wait() +} + +func (w *Worker) loop() { + defer w.wg.Done() + interval := w.svc.Interval() + // Run once shortly after boot so ops can verify without waiting a full interval. + timer := time.NewTimer(15 * time.Second) + defer timer.Stop() + for { + select { + case <-w.stop: + return + case <-timer.C: + w.runOnce() + timer.Reset(interval) + } + } +} + +func (w *Worker) runOnce() { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if err := w.svc.Tick(ctx); err != nil { + w.log.Warn("squarebot tick", zap.Error(err)) + } +}