diff --git a/AGENTS.md b/AGENTS.md index 0e18b5f8..6f179043 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ api/lib/ ├── internals/ │ ├── controller/ # HTTP I/O(Echo)。DB アクセス禁止 │ └── repository/ # SQL 実行のみ。*sql.Rows を返す -└── externals/{db,server,slack} +└── externals/{db,server,slack,scheduler} # scheduler: 通知の定期実行 ticker ループ mobile/lib/ ├── pages/ # 画面(StatefulWidget) @@ -49,6 +49,12 @@ mobile/lib/ gas/{shift,task,user,rescue}/ # ドメイン別。コード.js / onChange.js 等 ``` +## 通知の定期実行 + +未送信の通知ログ(`action_logs` の `is_sent = false`)は、`externals/scheduler` の ticker ループが API プロセス内で5分間隔に `NotificationUseCase.ProcessUnsentNotifications` を呼び、Slack DM へ flush します(`di.go` で配線。`cmd/send-notifications` は手動 flush 用として併存)。 + +**API は単一インスタンス前提**です。複数レプリカで動かすと各プロセスの ticker が同じ未送信ログを拾って二重送信します。本番(`docker-compose.prod.yml`)は API を1レプリカで運用しているため現状は問題ありません。複数レプリカ化する場合はリーダー選出や排他制御が必要です。 + ## Code Style ### Go (`api/`) diff --git a/api/lib/di/di.go b/api/lib/di/di.go index b1955654..b0680349 100755 --- a/api/lib/di/di.go +++ b/api/lib/di/di.go @@ -1,9 +1,14 @@ package di import ( + "context" "log" + "time" + "github.com/NUTFes/SeeFT/api/lib/externals/db" + "github.com/NUTFes/SeeFT/api/lib/externals/scheduler" "github.com/NUTFes/SeeFT/api/lib/externals/server" + "github.com/NUTFes/SeeFT/api/lib/externals/slack" "github.com/NUTFes/SeeFT/api/lib/internals/controller" "github.com/NUTFes/SeeFT/api/lib/internals/repository" "github.com/NUTFes/SeeFT/api/lib/internals/repository/abstract" @@ -93,6 +98,18 @@ func InitializeServer() db.Client { reviewController, ) + // Scheduler: 5分間隔で未送信通知を flush する(goroutine で起動し即 return) + slackService, err := slack.NewSlackService() + if err != nil { + log.Fatalf("slack init: %v", err) + } + notificationUseCase := usecase.NewNotificationUseCase( + actionLogRepository, slackService, + userRepository, dateRepository, timeRepository, + taskRepository, shiftRepository, weatherRepository, + ) + scheduler.New("notification", 5*time.Minute, notificationUseCase.ProcessUnsentNotifications).Start(context.Background()) + // Server server.RunServer(router) diff --git a/api/lib/externals/scheduler/scheduler.go b/api/lib/externals/scheduler/scheduler.go new file mode 100644 index 00000000..2c4eeee7 --- /dev/null +++ b/api/lib/externals/scheduler/scheduler.go @@ -0,0 +1,37 @@ +package scheduler + +import ( + "context" + "log" + "time" +) + +// Job は scheduler が定期実行する処理。usecase を import せず関数型で受け取り、 +// scheduler と業務ロジックを疎結合に保つ。 +type Job func(ctx context.Context) error + +// Scheduler は「名前・間隔・実行する処理」を保持する。 +type Scheduler struct { + name string + interval time.Duration + job Job +} + +// New はコンストラクタでSchedulerを生成する。 +func New(name string, interval time.Duration, job Job) *Scheduler { + return &Scheduler{name: name, interval: interval, job: job} +} + +// Start は ticker ループを goroutine で起動し、即座に return する。 +// interval ごとに job を実行し、job が返したエラーはログ出力のみ(ループは止めない)。 +func (s *Scheduler) Start(ctx context.Context) { + go func() { + ticker := time.NewTicker(s.interval) + defer ticker.Stop() + for range ticker.C { + if err := s.job(ctx); err != nil { + log.Printf("[scheduler:%s] job error: %v", s.name, err) + } + } + }() +}