From 7cc59c81a2df135e059a837c3c901e91a838b916 Mon Sep 17 00:00:00 2001 From: taminororo <169162271+taminororo@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:38:40 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E3=83=87=E3=83=A2=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=A7Start()=E9=96=A2=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=82=92=E7=90=86=E8=A7=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 +++- api/lib/externals/scheduler/scheduler.go | 53 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 api/lib/externals/scheduler/scheduler.go 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/externals/scheduler/scheduler.go b/api/lib/externals/scheduler/scheduler.go new file mode 100644 index 00000000..344c3b7e --- /dev/null +++ b/api/lib/externals/scheduler/scheduler.go @@ -0,0 +1,53 @@ +package scheduler + +import ( + "context" + "log" + "time" +) + +// Job は定期実行する処理の型。 +// +// Go: type Job func(ctx context.Context) error +// Python: Callable[[Context], Awaitable[None]] (typing の型エイリアス) +// TS: type Job = (ctx: Context) => Promise +// +// 関数を「型」として名付けることで、scheduler は usecase を import せずに済む +// (依存方向が di → scheduler / di → usecase の二股になる)。 +type Job func(ctx context.Context) error + +// Scheduler は「名前・間隔・実行する処理」を保持するだけの箱。 +// Python なら @dataclass、TS なら class { constructor(...) } に相当。 +type Scheduler struct { + name string + interval time.Duration + job Job +} + +// New はコンストラクタ。Go には __init__ / constructor が無いので、 +// 慣習として New〜 関数でポインタを返す。 +func New(name string, interval time.Duration, job Job) *Scheduler { + return &Scheduler{name: name, interval: interval, job: job} +} + +// Start は ticker ループを goroutine で起動し、即座に return する。 +// +// ★ここの中身は上林さんが書く。 +// - go func() { ... }() で別の実行単位(goroutine)を起動して呼び出し元をブロックしない +// - time.NewTicker(s.interval) で s.interval ごとに発火するチャネルを作る +// - for ループ内で <-ticker.C を待ち、発火のたびに s.job(ctx) を呼ぶ +// - job がエラーを返したら log.Printf でログ出力のみ(パニックさせない) +// +// 実装したら "log" を import に追加すること(Go は未使用 import をコンパイルエラーにする)。 +func (s *Scheduler) Start(ctx context.Context) { + // TODO(上林): goroutine + time.NewTicker ループを実装する + 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) + } + } + }() +} From 191b35bcbf54b8e27060853a5a9d9f972b03d175 Mon Sep 17 00:00:00 2001 From: taminororo <169162271+taminororo@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:39:55 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20di.go=E3=81=AB5=E5=88=86=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=AB=E6=9C=AA=E9=80=81=E4=BF=A1=E3=81=AE=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E3=82=92push=E3=81=99=E3=82=8Bgoroutine=E7=99=BA?= =?UTF-8?q?=E7=81=AB=E3=81=AE=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=E6=9B=B8?= =?UTF-8?q?=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/lib/di/di.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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) From de5b9540466ca2b448d6cc4e941758d4d8806fb7 Mon Sep 17 00:00:00 2001 From: taminororo <169162271+taminororo@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:40:10 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E3=83=87=E3=83=A2=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=81=A6?= =?UTF-8?q?=E7=90=86=E8=A7=A3=E3=82=92=E6=B7=B1=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/cmd/scheduler-demo/main.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 api/cmd/scheduler-demo/main.go diff --git a/api/cmd/scheduler-demo/main.go b/api/cmd/scheduler-demo/main.go new file mode 100644 index 00000000..fd831e68 --- /dev/null +++ b/api/cmd/scheduler-demo/main.go @@ -0,0 +1,31 @@ +// scheduler.Start の挙動を目で見るための使い捨てデモ。確認後に削除する。 +package main + +import ( + "context" + "fmt" + "time" + + "github.com/NUTFes/SeeFT/api/lib/externals/scheduler" +) + +func main() { + fmt.Println("[1] Start を呼ぶ前") + + // 本番では NotificationUseCase.ProcessUnsentNotifications が入る。 + // デモでは Slack も DB も使わず、print だけするダミー job。 + job := func(ctx context.Context) error { + fmt.Println(" → job 実行!(本番ではここで Slack DM を送る)") + return nil + } + + // 間隔は 2 秒(本番は 5*time.Minute)。これが s.interval になる。 + scheduler.New("demo", 2*time.Second, job).Start(context.Background()) + + fmt.Println("[2] Start を呼んだ直後(もうここに来た=ブロックしてない)") + + // 本番では server.RunServer がここでブロックしてプロセスを生かし続ける。 + // デモでは 7 秒だけ待って、裏で job が何回鳴るか観察する。 + time.Sleep(7 * time.Second) + fmt.Println("[3] デモ終了(main が終わると裏方の goroutine も道連れに消える)") +} From 18ca2a11bb0b38beef370758dd2ab8c1174f4e67 Mon Sep 17 00:00:00 2001 From: taminororo <169162271+taminororo@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:46:34 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E9=80=9A=E7=9F=A5=E3=81=AE?= =?UTF-8?q?=E6=9C=AA=E9=80=81=E4=BF=A1=E3=83=AD=E3=82=B0=E3=82=925?= =?UTF-8?q?=E5=88=86=E9=96=93=E9=9A=94=E3=81=A7=E8=87=AA=E5=8B=95=E9=80=81?= =?UTF-8?q?=E4=BF=A1=E3=81=99=E3=82=8Bscheduler=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?(=E3=83=87=E3=83=A2=E3=81=AE=E5=89=8A=E9=99=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/cmd/scheduler-demo/main.go | 31 ------------------------ api/lib/externals/scheduler/scheduler.go | 11 +-------- 2 files changed, 1 insertion(+), 41 deletions(-) delete mode 100644 api/cmd/scheduler-demo/main.go diff --git a/api/cmd/scheduler-demo/main.go b/api/cmd/scheduler-demo/main.go deleted file mode 100644 index fd831e68..00000000 --- a/api/cmd/scheduler-demo/main.go +++ /dev/null @@ -1,31 +0,0 @@ -// scheduler.Start の挙動を目で見るための使い捨てデモ。確認後に削除する。 -package main - -import ( - "context" - "fmt" - "time" - - "github.com/NUTFes/SeeFT/api/lib/externals/scheduler" -) - -func main() { - fmt.Println("[1] Start を呼ぶ前") - - // 本番では NotificationUseCase.ProcessUnsentNotifications が入る。 - // デモでは Slack も DB も使わず、print だけするダミー job。 - job := func(ctx context.Context) error { - fmt.Println(" → job 実行!(本番ではここで Slack DM を送る)") - return nil - } - - // 間隔は 2 秒(本番は 5*time.Minute)。これが s.interval になる。 - scheduler.New("demo", 2*time.Second, job).Start(context.Background()) - - fmt.Println("[2] Start を呼んだ直後(もうここに来た=ブロックしてない)") - - // 本番では server.RunServer がここでブロックしてプロセスを生かし続ける。 - // デモでは 7 秒だけ待って、裏で job が何回鳴るか観察する。 - time.Sleep(7 * time.Second) - fmt.Println("[3] デモ終了(main が終わると裏方の goroutine も道連れに消える)") -} diff --git a/api/lib/externals/scheduler/scheduler.go b/api/lib/externals/scheduler/scheduler.go index 344c3b7e..45973545 100644 --- a/api/lib/externals/scheduler/scheduler.go +++ b/api/lib/externals/scheduler/scheduler.go @@ -17,7 +17,6 @@ import ( type Job func(ctx context.Context) error // Scheduler は「名前・間隔・実行する処理」を保持するだけの箱。 -// Python なら @dataclass、TS なら class { constructor(...) } に相当。 type Scheduler struct { name string interval time.Duration @@ -31,16 +30,8 @@ func New(name string, interval time.Duration, job Job) *Scheduler { } // Start は ticker ループを goroutine で起動し、即座に return する。 -// -// ★ここの中身は上林さんが書く。 -// - go func() { ... }() で別の実行単位(goroutine)を起動して呼び出し元をブロックしない -// - time.NewTicker(s.interval) で s.interval ごとに発火するチャネルを作る -// - for ループ内で <-ticker.C を待ち、発火のたびに s.job(ctx) を呼ぶ -// - job がエラーを返したら log.Printf でログ出力のみ(パニックさせない) -// -// 実装したら "log" を import に追加すること(Go は未使用 import をコンパイルエラーにする)。 +// interval ごとに job を実行し、job が返したエラーはログ出力のみ(ループは止めない)。 func (s *Scheduler) Start(ctx context.Context) { - // TODO(上林): goroutine + time.NewTicker ループを実装する go func() { ticker := time.NewTicker(s.interval) defer ticker.Stop() From b2dc29661e959b19b51cc9f358d7aa619cff28d4 Mon Sep 17 00:00:00 2001 From: taminororo <169162271+taminororo@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:52:06 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=AE=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/lib/externals/scheduler/scheduler.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/api/lib/externals/scheduler/scheduler.go b/api/lib/externals/scheduler/scheduler.go index 45973545..f8fdb2da 100644 --- a/api/lib/externals/scheduler/scheduler.go +++ b/api/lib/externals/scheduler/scheduler.go @@ -6,17 +6,11 @@ import ( "time" ) -// Job は定期実行する処理の型。 -// -// Go: type Job func(ctx context.Context) error -// Python: Callable[[Context], Awaitable[None]] (typing の型エイリアス) -// TS: type Job = (ctx: Context) => Promise -// -// 関数を「型」として名付けることで、scheduler は usecase を import せずに済む -// (依存方向が di → scheduler / di → usecase の二股になる)。 +// Job は scheduler が定期実行する処理。usecase を import せず関数型で受け取り、 +// scheduler と業務ロジックを疎結合に保つ。 type Job func(ctx context.Context) error -// Scheduler は「名前・間隔・実行する処理」を保持するだけの箱。 +// Scheduler は「名前・間隔・実行する処理」を保持する。 type Scheduler struct { name string interval time.Duration From c680c761157d908deabbdb079e2fd5b87abf5be1 Mon Sep 17 00:00:00 2001 From: taminororo <169162271+taminororo@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:54:39 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E4=B8=8D=E8=A6=81=E3=81=AANew?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E3=81=AE=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/lib/externals/scheduler/scheduler.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/lib/externals/scheduler/scheduler.go b/api/lib/externals/scheduler/scheduler.go index f8fdb2da..2c4eeee7 100644 --- a/api/lib/externals/scheduler/scheduler.go +++ b/api/lib/externals/scheduler/scheduler.go @@ -17,8 +17,7 @@ type Scheduler struct { job Job } -// New はコンストラクタ。Go には __init__ / constructor が無いので、 -// 慣習として New〜 関数でポインタを返す。 +// New はコンストラクタでSchedulerを生成する。 func New(name string, interval time.Duration, job Job) *Scheduler { return &Scheduler{name: name, interval: interval, job: job} }