From d5d945beab80be661b33f2265d8fd4e644e601f2 Mon Sep 17 00:00:00 2001 From: kantacky <51151242+kantacky@users.noreply.github.com> Date: Tue, 28 Apr 2026 10:06:28 +0000 Subject: [PATCH 01/19] Update OpenAPI schema from dotto-typespec --- openapi/openapi.yaml | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f35fc9a..4033567 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -406,8 +406,7 @@ components: - body - notifyAfter - notifyBefore - - isNotified - - targetUserIds + - targetUsers properties: id: type: string @@ -482,14 +481,11 @@ components: type: string format: date-time description: 通知送信期限日時(この時刻を過ぎた場合は送信しない) - isNotified: - type: boolean - description: 通知が送信されたかどうか - targetUserIds: + targetUsers: type: array items: - type: string - description: 対象ユーザーIDのリスト + $ref: '#/components/schemas/NotificationTargetUser' + description: 対象ユーザーのリスト NotificationRequest: type: object required: @@ -556,6 +552,21 @@ components: items: type: string description: 対象ユーザーIDのリスト + NotificationTargetUser: + type: object + required: + - userId + properties: + userId: + type: string + description: ユーザーID + notifiedAt: + type: string + format: date-time + description: |- + 通知送信日時 + + 通知が送信された日時。送信前は null User: type: object required: From 4b62a779c2f75c40488327f288048d8a54754068 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 19:07:46 +0900 Subject: [PATCH 02/19] =?UTF-8?q?=E7=94=9F=E6=88=90=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=82=92=E6=9C=80=E6=96=B0=E3=81=AE=20OpenAPI=20?= =?UTF-8?q?=E3=82=B9=E3=82=AD=E3=83=BC=E3=83=9E=E3=81=AB=E8=BF=BD=E5=BE=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notification の targetUserIds/isNotified を targetUsers (NotificationTargetUser) に置き換えた openapi.yaml に合わせて、oapi-codegen で生成した API 型を再生成する。 --- generated/api.gen.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/generated/api.gen.go b/generated/api.gen.go index 916e0b5..848c732 100644 --- a/generated/api.gen.go +++ b/generated/api.gen.go @@ -208,17 +208,14 @@ type Notification struct { // ImageUrl 通知に表示する画像のURL ImageUrl *string `json:"imageUrl,omitempty"` - // IsNotified 通知が送信されたかどうか - IsNotified bool `json:"isNotified"` - // NotifyAfter 通知送信可能になる日時(この時刻以降に送信対象となる) NotifyAfter time.Time `json:"notifyAfter"` // NotifyBefore 通知送信期限日時(この時刻を過ぎた場合は送信しない) NotifyBefore time.Time `json:"notifyBefore"` - // TargetUserIds 対象ユーザーIDのリスト - TargetUserIds []string `json:"targetUserIds"` + // TargetUsers 対象ユーザーのリスト + TargetUsers []NotificationTargetUser `json:"targetUsers"` // Title 通知タイトル Title string `json:"title"` @@ -285,6 +282,17 @@ type NotificationRequest struct { WebpushLink *string `json:"webpushLink,omitempty"` } +// NotificationTargetUser defines model for NotificationTargetUser. +type NotificationTargetUser struct { + // NotifiedAt 通知送信日時 + // + // 通知が送信された日時。送信前は null + NotifiedAt *time.Time `json:"notifiedAt,omitempty"` + + // UserId ユーザーID + UserId string `json:"userId"` +} + // User defines model for User. type User struct { // Class クラス From e976572ef07cc1c328a0940c87af056293450303 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 19:07:46 +0900 Subject: [PATCH 03/19] =?UTF-8?q?Notification=20API=20=E5=A4=89=E6=8F=9B?= =?UTF-8?q?=E3=82=92=20targetUsers=20=E5=BD=A2=E5=BC=8F=E3=81=AB=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API スキーマ変更に追従し、ドメインの TargetUserIDs を NotificationTargetUser のスライスへ変換するようにする。IsNotified フィールドは API から削除されたため出力しない。 --- internal/handler/converter.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/handler/converter.go b/internal/handler/converter.go index e3c10a7..82f2298 100644 --- a/internal/handler/converter.go +++ b/internal/handler/converter.go @@ -82,6 +82,10 @@ func toDomainFCMToken(req api.FCMTokenRequest) domain.FCMToken { } func toAPINotification(n domain.Notification) api.Notification { + targetUsers := make([]api.NotificationTargetUser, 0, len(n.TargetUserIDs)) + for _, uid := range n.TargetUserIDs { + targetUsers = append(targetUsers, api.NotificationTargetUser{UserId: uid}) + } return api.Notification{ Id: n.ID, Title: n.Title, @@ -98,8 +102,7 @@ func toAPINotification(n domain.Notification) api.Notification { Url: n.URL, NotifyAfter: n.NotifyAfter, NotifyBefore: n.NotifyBefore, - IsNotified: n.IsNotified, - TargetUserIds: n.TargetUserIDs, + TargetUsers: targetUsers, } } From d047f40cb525726906da00b0e13ca40f7952a505 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 19:14:49 +0900 Subject: [PATCH 04/19] =?UTF-8?q?Notification=20=E3=81=AE=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E6=B8=88=E3=81=BF=E3=83=95=E3=83=A9=E3=82=B0=E3=82=92?= =?UTF-8?q?=E5=AF=BE=E8=B1=A1=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E5=8D=98?= =?UTF-8?q?=E4=BD=8D=E3=81=AE=20NotifiedAt=20=E3=81=AB=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI スキーマで Notification.isNotified を廃止し targetUsers[].notifiedAt で通知日時を管理する形へ変更されたため、 GORM モデル・ドメイン・リポジトリ・サービス・コンバータを追従させた。 - database.Notification の IsNotified カラムを削除し、 database.NotificationTargetUser に NotifiedAt を追加 - domain.Notification の IsNotified / TargetUserIDs を廃止し、 UserID と NotifiedAt を保持する domain.NotificationTargetUser を導入 - DispatchNotifications を通知ID→送信成功UserID のマップ受け取りに変更し、 notification_target_users.notified_at を該当ユーザーに対して更新 - サービス層は未通知ユーザー(NotifiedAt が nil)のみを送信対象にし、 送信成功したユーザーだけを per-notification で通知済みに記録 - ListNotifications の isNotified フィルタは notification_target_users の notified_at に対する EXISTS 句で表現 - UpdateNotification では既存行の notified_at を維持したまま 対象ユーザー集合を入れ替え - handler.toAPINotification / toDomainNotification を新スキーマに合わせて変換 --- internal/database/notification.go | 7 +--- internal/database/notification_target_user.go | 3 ++ internal/domain/notification.go | 8 +++- internal/handler/converter.go | 15 +++++-- internal/repository/notification_create.go | 14 ++++--- internal/repository/notification_dispatch.go | 41 +++++++++++-------- internal/repository/notification_list.go | 15 +++++-- internal/repository/notification_update.go | 32 +++++++++++---- internal/repository/util.go | 15 +++++++ internal/service/notification.go | 2 +- internal/service/notification_dispatch.go | 30 ++++++++++---- 11 files changed, 129 insertions(+), 53 deletions(-) diff --git a/internal/database/notification.go b/internal/database/notification.go index af738d0..9f5a425 100644 --- a/internal/database/notification.go +++ b/internal/database/notification.go @@ -25,10 +25,9 @@ type Notification struct { NotifyAfter time.Time `gorm:"type:timestamptz;not null;index"` NotifyBefore time.Time `gorm:"type:timestamptz;not null;index"` - IsNotified bool `gorm:"type:boolean;not null;default:false;index"` } -func (n *Notification) ToDomain(targetUserIDs []string) domain.Notification { +func (n *Notification) ToDomain(targetUsers []domain.NotificationTargetUser) domain.Notification { return domain.Notification{ ID: n.ID, Title: n.Title, @@ -45,8 +44,7 @@ func (n *Notification) ToDomain(targetUserIDs []string) domain.Notification { URL: n.URL, NotifyAfter: n.NotifyAfter, NotifyBefore: n.NotifyBefore, - IsNotified: n.IsNotified, - TargetUserIDs: targetUserIDs, + TargetUsers: targetUsers, } } @@ -67,6 +65,5 @@ func NotificationFromDomain(n domain.Notification) Notification { URL: n.URL, NotifyAfter: n.NotifyAfter, NotifyBefore: n.NotifyBefore, - IsNotified: n.IsNotified, } } diff --git a/internal/database/notification_target_user.go b/internal/database/notification_target_user.go index 1d4018c..1bdcdb2 100644 --- a/internal/database/notification_target_user.go +++ b/internal/database/notification_target_user.go @@ -1,8 +1,11 @@ package database +import "time" + type NotificationTargetUser struct { NotificationID string `gorm:"type:text;primaryKey"` UserID string `gorm:"type:text;primaryKey"` + NotifiedAt *time.Time `gorm:"type:timestamptz;index"` Notification Notification `gorm:"constraint:OnDelete:CASCADE"` User User `gorm:"constraint:OnDelete:CASCADE"` } diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 9d2373c..d559deb 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -21,7 +21,11 @@ type Notification struct { NotifyAfter time.Time NotifyBefore time.Time - IsNotified bool - TargetUserIDs []string + TargetUsers []NotificationTargetUser +} + +type NotificationTargetUser struct { + UserID string + NotifiedAt *time.Time } diff --git a/internal/handler/converter.go b/internal/handler/converter.go index 82f2298..2e23ef8 100644 --- a/internal/handler/converter.go +++ b/internal/handler/converter.go @@ -82,9 +82,12 @@ func toDomainFCMToken(req api.FCMTokenRequest) domain.FCMToken { } func toAPINotification(n domain.Notification) api.Notification { - targetUsers := make([]api.NotificationTargetUser, 0, len(n.TargetUserIDs)) - for _, uid := range n.TargetUserIDs { - targetUsers = append(targetUsers, api.NotificationTargetUser{UserId: uid}) + targetUsers := make([]api.NotificationTargetUser, 0, len(n.TargetUsers)) + for _, t := range n.TargetUsers { + targetUsers = append(targetUsers, api.NotificationTargetUser{ + UserId: t.UserID, + NotifiedAt: t.NotifiedAt, + }) } return api.Notification{ Id: n.ID, @@ -115,6 +118,10 @@ func toAPINotifications(notifications []domain.Notification) []api.Notification } func toDomainNotification(id string, req api.NotificationRequest) domain.Notification { + targetUsers := make([]domain.NotificationTargetUser, 0, len(req.TargetUserIds)) + for _, uid := range req.TargetUserIds { + targetUsers = append(targetUsers, domain.NotificationTargetUser{UserID: uid}) + } return domain.Notification{ ID: id, Title: req.Title, @@ -131,7 +138,7 @@ func toDomainNotification(id string, req api.NotificationRequest) domain.Notific URL: req.Url, NotifyAfter: req.NotifyAfter, NotifyBefore: req.NotifyBefore, - TargetUserIDs: req.TargetUserIds, + TargetUsers: targetUsers, } } diff --git a/internal/repository/notification_create.go b/internal/repository/notification_create.go index 3d3c474..7d662ce 100644 --- a/internal/repository/notification_create.go +++ b/internal/repository/notification_create.go @@ -14,18 +14,20 @@ func (r *NotificationRepository) CreateNotification(ctx context.Context, notific dbNotification := database.NotificationFromDomain(notification) + uniqueTargets := uniqueTargetUsers(notification.TargetUsers) + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Create(&dbNotification).Error; err != nil { return err } - uniqueIDs := uniqueStrings(notification.TargetUserIDs) - if len(uniqueIDs) > 0 { - targets := make([]database.NotificationTargetUser, 0, len(uniqueIDs)) - for _, userID := range uniqueIDs { + if len(uniqueTargets) > 0 { + targets := make([]database.NotificationTargetUser, 0, len(uniqueTargets)) + for _, t := range uniqueTargets { targets = append(targets, database.NotificationTargetUser{ NotificationID: notification.ID, - UserID: userID, + UserID: t.UserID, + NotifiedAt: t.NotifiedAt, }) } if err := tx.Create(&targets).Error; err != nil { @@ -39,5 +41,5 @@ func (r *NotificationRepository) CreateNotification(ctx context.Context, notific return domain.Notification{}, err } - return dbNotification.ToDomain(uniqueStrings(notification.TargetUserIDs)), nil + return dbNotification.ToDomain(uniqueTargets), nil } diff --git a/internal/repository/notification_dispatch.go b/internal/repository/notification_dispatch.go index ab77593..3418ac7 100644 --- a/internal/repository/notification_dispatch.go +++ b/internal/repository/notification_dispatch.go @@ -2,6 +2,7 @@ package repository import ( "context" + "time" "github.com/fun-dotto/user-api/internal/database" "github.com/fun-dotto/user-api/internal/domain" @@ -31,9 +32,12 @@ func (r *NotificationRepository) GetNotificationsByIDs(ctx context.Context, ids return nil, err } - targetMap := make(map[string][]string) + targetMap := make(map[string][]domain.NotificationTargetUser) for _, t := range allTargets { - targetMap[t.NotificationID] = append(targetMap[t.NotificationID], t.UserID) + targetMap[t.NotificationID] = append(targetMap[t.NotificationID], domain.NotificationTargetUser{ + UserID: t.UserID, + NotifiedAt: t.NotifiedAt, + }) } notifications := make([]domain.Notification, 0, len(dbNotifications)) @@ -44,26 +48,29 @@ func (r *NotificationRepository) GetNotificationsByIDs(ctx context.Context, ids return notifications, nil } -func (r *NotificationRepository) DispatchNotifications(ctx context.Context, ids []string) ([]domain.Notification, error) { - uniqueIDs := uniqueStrings(ids) - if len(uniqueIDs) == 0 { +func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deliveries map[string][]string) ([]domain.Notification, error) { + if len(deliveries) == 0 { return []domain.Notification{}, nil } - if err := r.db.WithContext(ctx).Model(&database.Notification{}). - Where("id IN ?", uniqueIDs). - Update("is_notified", true).Error; err != nil { - return nil, err - } - - notifications, err := r.GetNotificationsByIDs(ctx, uniqueIDs) - if err != nil { - return nil, err + now := time.Now() + notificationIDs := make([]string, 0, len(deliveries)) + for nid, userIDs := range deliveries { + uniqueUsers := uniqueStrings(userIDs) + if len(uniqueUsers) == 0 { + continue + } + if err := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}). + Where("notification_id = ? AND user_id IN ?", nid, uniqueUsers). + Update("notified_at", now).Error; err != nil { + return nil, err + } + notificationIDs = append(notificationIDs, nid) } - for i := range notifications { - notifications[i].IsNotified = true + if len(notificationIDs) == 0 { + return []domain.Notification{}, nil } - return notifications, nil + return r.GetNotificationsByIDs(ctx, notificationIDs) } diff --git a/internal/repository/notification_list.go b/internal/repository/notification_list.go index 737c83c..fa25c13 100644 --- a/internal/repository/notification_list.go +++ b/internal/repository/notification_list.go @@ -17,7 +17,13 @@ func (r *NotificationRepository) ListNotifications(ctx context.Context, filter d query = query.Where("notify_after <= ?", *filter.NotifyAtTo) } if filter.IsNotified != nil { - query = query.Where("is_notified = ?", *filter.IsNotified) + if *filter.IsNotified { + query = query.Where(`NOT EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id AND tu.notified_at IS NULL) + AND EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id)`) + } else { + query = query.Where(`EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id AND tu.notified_at IS NULL) + OR NOT EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id)`) + } } var dbNotifications []database.Notification @@ -39,9 +45,12 @@ func (r *NotificationRepository) ListNotifications(ctx context.Context, filter d return nil, err } - targetMap := make(map[string][]string) + targetMap := make(map[string][]domain.NotificationTargetUser) for _, t := range allTargets { - targetMap[t.NotificationID] = append(targetMap[t.NotificationID], t.UserID) + targetMap[t.NotificationID] = append(targetMap[t.NotificationID], domain.NotificationTargetUser{ + UserID: t.UserID, + NotifiedAt: t.NotifiedAt, + }) } notifications := make([]domain.Notification, 0, len(dbNotifications)) diff --git a/internal/repository/notification_update.go b/internal/repository/notification_update.go index 2f0435c..2180ede 100644 --- a/internal/repository/notification_update.go +++ b/internal/repository/notification_update.go @@ -3,6 +3,7 @@ package repository import ( "context" "errors" + "time" "github.com/fun-dotto/user-api/internal/database" "github.com/fun-dotto/user-api/internal/domain" @@ -11,6 +12,7 @@ import ( func (r *NotificationRepository) UpdateNotification(ctx context.Context, notification domain.Notification) (domain.Notification, error) { var dbNotification database.Notification + uniqueTargets := uniqueTargetUsers(notification.TargetUsers) err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing database.Notification @@ -23,21 +25,37 @@ func (r *NotificationRepository) UpdateNotification(ctx context.Context, notific dbNotification = database.NotificationFromDomain(notification) - if err := tx.Omit("IsNotified").Save(&dbNotification).Error; err != nil { + if err := tx.Save(&dbNotification).Error; err != nil { return err } + var existingTargets []database.NotificationTargetUser + if err := tx.Where("notification_id = ?", notification.ID).Find(&existingTargets).Error; err != nil { + return err + } + existingNotifiedAt := make(map[string]*time.Time, len(existingTargets)) + for _, t := range existingTargets { + existingNotifiedAt[t.UserID] = t.NotifiedAt + } + if err := tx.Where("notification_id = ?", notification.ID).Delete(&database.NotificationTargetUser{}).Error; err != nil { return err } - uniqueIDs := uniqueStrings(notification.TargetUserIDs) - if len(uniqueIDs) > 0 { - targets := make([]database.NotificationTargetUser, 0, len(uniqueIDs)) - for _, userID := range uniqueIDs { + if len(uniqueTargets) > 0 { + targets := make([]database.NotificationTargetUser, 0, len(uniqueTargets)) + for i, t := range uniqueTargets { + notifiedAt := t.NotifiedAt + if notifiedAt == nil { + if prev, ok := existingNotifiedAt[t.UserID]; ok { + notifiedAt = prev + uniqueTargets[i].NotifiedAt = prev + } + } targets = append(targets, database.NotificationTargetUser{ NotificationID: notification.ID, - UserID: userID, + UserID: t.UserID, + NotifiedAt: notifiedAt, }) } if err := tx.Create(&targets).Error; err != nil { @@ -54,5 +72,5 @@ func (r *NotificationRepository) UpdateNotification(ctx context.Context, notific return domain.Notification{}, err } - return dbNotification.ToDomain(uniqueStrings(notification.TargetUserIDs)), nil + return dbNotification.ToDomain(uniqueTargets), nil } diff --git a/internal/repository/util.go b/internal/repository/util.go index 1bf7a8d..13cd60d 100644 --- a/internal/repository/util.go +++ b/internal/repository/util.go @@ -1,5 +1,7 @@ package repository +import "github.com/fun-dotto/user-api/internal/domain" + func uniqueStrings(s []string) []string { seen := make(map[string]struct{}, len(s)) result := make([]string, 0, len(s)) @@ -12,3 +14,16 @@ func uniqueStrings(s []string) []string { } return result } + +func uniqueTargetUsers(targets []domain.NotificationTargetUser) []domain.NotificationTargetUser { + seen := make(map[string]struct{}, len(targets)) + result := make([]domain.NotificationTargetUser, 0, len(targets)) + for _, t := range targets { + if _, ok := seen[t.UserID]; ok { + continue + } + seen[t.UserID] = struct{}{} + result = append(result, t) + } + return result +} diff --git a/internal/service/notification.go b/internal/service/notification.go index 73b6b40..a5e5a8f 100644 --- a/internal/service/notification.go +++ b/internal/service/notification.go @@ -13,7 +13,7 @@ type NotificationRepository interface { UpdateNotification(ctx context.Context, notification domain.Notification) (domain.Notification, error) DeleteNotification(ctx context.Context, id string) error GetNotificationsByIDs(ctx context.Context, ids []string) ([]domain.Notification, error) - DispatchNotifications(ctx context.Context, ids []string) ([]domain.Notification, error) + DispatchNotifications(ctx context.Context, deliveries map[string][]string) ([]domain.Notification, error) } type FCMTokenRepositoryForNotification interface { diff --git a/internal/service/notification_dispatch.go b/internal/service/notification_dispatch.go index 78f8a68..9f07e2a 100644 --- a/internal/service/notification_dispatch.go +++ b/internal/service/notification_dispatch.go @@ -20,8 +20,11 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s userIDSet := make(map[string]struct{}) for _, n := range notifications { - for _, uid := range n.TargetUserIDs { - userIDSet[uid] = struct{}{} + for _, t := range n.TargetUsers { + if t.NotifiedAt != nil { + continue + } + userIDSet[t.UserID] = struct{}{} } } @@ -40,11 +43,22 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s } } - successIDs := make([]string, 0, len(notifications)) + deliveries := make(map[string][]string, len(notifications)) for _, n := range notifications { - tokens := collectTokens(n.TargetUserIDs, tokensByUser) + pendingUserIDs := make([]string, 0, len(n.TargetUsers)) + for _, t := range n.TargetUsers { + if t.NotifiedAt != nil { + continue + } + pendingUserIDs = append(pendingUserIDs, t.UserID) + } + if len(pendingUserIDs) == 0 { + continue + } + + tokens := collectTokens(pendingUserIDs, tokensByUser) if len(tokens) == 0 { - successIDs = append(successIDs, n.ID) + deliveries[n.ID] = pendingUserIDs continue } @@ -54,15 +68,15 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s continue } if sent > 0 { - successIDs = append(successIDs, n.ID) + deliveries[n.ID] = pendingUserIDs } } - if len(successIDs) == 0 { + if len(deliveries) == 0 { return []domain.Notification{}, nil } - return s.repo.DispatchNotifications(ctx, successIDs) + return s.repo.DispatchNotifications(ctx, deliveries) } func collectTokens(userIDs []string, tokensByUser map[string][]string) []string { From 90d67c47cc12b45e7cad68714756cac1561a3cfb Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 19:53:49 +0900 Subject: [PATCH 05/19] =?UTF-8?q?Notification=20=E9=85=8D=E4=BF=A1?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AE?= =?UTF-8?q?=E3=81=BF=E3=82=92=20NotifiedAt=20=E8=A8=98=E9=8C=B2=E5=AF=BE?= =?UTF-8?q?=E8=B1=A1=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 従来は対象ユーザーのいずれか宛にトークンが存在すれば、FCM 送信結果に関わらず対象ユーザー全員を配信済みとして記録していた。 本コミットでは、トークンとユーザー ID の対応を保持し、SendEachForMulticast の応答からユーザー単位の成功可否を判定して、実際に配信に成功したユーザーのみを deliveries に積むよう変更した。 併せて FCM 呼び出し自体がエラーになった場合でも、その時点までに成功したユーザー分は確定保存する挙動に揃えている。 --- internal/service/notification_dispatch.go | 55 +++++++++++++++-------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/internal/service/notification_dispatch.go b/internal/service/notification_dispatch.go index 9f07e2a..e13f65d 100644 --- a/internal/service/notification_dispatch.go +++ b/internal/service/notification_dispatch.go @@ -56,19 +56,17 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s continue } - tokens := collectTokens(pendingUserIDs, tokensByUser) + tokens, tokenUserIDs := collectTokens(pendingUserIDs, tokensByUser) if len(tokens) == 0 { - deliveries[n.ID] = pendingUserIDs continue } - sent, err := s.sendToTokens(ctx, n, tokens) + successUserIDs, err := s.sendToTokens(ctx, n, tokens, tokenUserIDs) if err != nil { log.Printf("FCM send failed for notification %s: %v", n.ID, err) - continue } - if sent > 0 { - deliveries[n.ID] = pendingUserIDs + if len(successUserIDs) > 0 { + deliveries[n.ID] = successUserIDs } } @@ -79,9 +77,10 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s return s.repo.DispatchNotifications(ctx, deliveries) } -func collectTokens(userIDs []string, tokensByUser map[string][]string) []string { +func collectTokens(userIDs []string, tokensByUser map[string][]string) ([]string, []string) { seen := make(map[string]struct{}) tokens := make([]string, 0) + tokenUserIDs := make([]string, 0) for _, uid := range userIDs { for _, tk := range tokensByUser[uid] { if _, ok := seen[tk]; ok { @@ -89,14 +88,15 @@ func collectTokens(userIDs []string, tokensByUser map[string][]string) []string } seen[tk] = struct{}{} tokens = append(tokens, tk) + tokenUserIDs = append(tokenUserIDs, uid) } } - return tokens + return tokens, tokenUserIDs } const fcmMulticastBatchSize = 500 -func (s *NotificationService) sendToTokens(ctx context.Context, n domain.Notification, tokens []string) (int, error) { +func (s *NotificationService) sendToTokens(ctx context.Context, n domain.Notification, tokens []string, tokenUserIDs []string) ([]string, error) { data := map[string]string{"notification_id": n.ID} if n.URL != nil { data["url"] = *n.URL @@ -119,7 +119,7 @@ func (s *NotificationService) sendToTokens(ctx context.Context, n domain.Notific apnsConfig := buildAPNSConfig(n) webpushConfig := buildWebpushConfig(n) - totalSuccess := 0 + successUserSet := make(map[string]struct{}) for start := 0; start < len(tokens); start += fcmMulticastBatchSize { end := min(start+fcmMulticastBatchSize, len(tokens)) msg := &messaging.MulticastMessage{ @@ -133,18 +133,37 @@ func (s *NotificationService) sendToTokens(ctx context.Context, n domain.Notific } resp, err := s.messagingClient.SendEachForMulticast(ctx, msg) if err != nil { - return totalSuccess, err + return collectSuccessUserIDs(tokenUserIDs, successUserSet), err } - totalSuccess += resp.SuccessCount - if resp.FailureCount > 0 { - for i, r := range resp.Responses { - if r.Error != nil { - log.Printf("FCM delivery failed for notification %s token=%s: %v", n.ID, tokens[start+i], r.Error) - } + for i, r := range resp.Responses { + uid := tokenUserIDs[start+i] + if r.Error != nil { + log.Printf("FCM delivery failed for notification %s token=%s: %v", n.ID, tokens[start+i], r.Error) + continue } + successUserSet[uid] = struct{}{} + } + } + return collectSuccessUserIDs(tokenUserIDs, successUserSet), nil +} + +func collectSuccessUserIDs(tokenUserIDs []string, successUserSet map[string]struct{}) []string { + if len(successUserSet) == 0 { + return nil + } + seen := make(map[string]struct{}, len(successUserSet)) + result := make([]string, 0, len(successUserSet)) + for _, uid := range tokenUserIDs { + if _, ok := successUserSet[uid]; !ok { + continue + } + if _, dup := seen[uid]; dup { + continue } + seen[uid] = struct{}{} + result = append(result, uid) } - return totalSuccess, nil + return result } func buildAndroidConfig(n domain.Notification) *messaging.AndroidConfig { From 5dbcbbb6846b04f24d5feac6e99a19e426f127d3 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 20:32:29 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix(repository):=20isNotified=20=E3=83=95?= =?UTF-8?q?=E3=82=A3=E3=83=AB=E3=82=BF=E3=81=AE=20OR=20=E5=8F=A5=E3=82=92?= =?UTF-8?q?=E6=8B=AC=E5=BC=A7=E3=81=A7=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/notification_list.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/repository/notification_list.go b/internal/repository/notification_list.go index fa25c13..495767f 100644 --- a/internal/repository/notification_list.go +++ b/internal/repository/notification_list.go @@ -21,8 +21,8 @@ func (r *NotificationRepository) ListNotifications(ctx context.Context, filter d query = query.Where(`NOT EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id AND tu.notified_at IS NULL) AND EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id)`) } else { - query = query.Where(`EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id AND tu.notified_at IS NULL) - OR NOT EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id)`) + query = query.Where(`(EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id AND tu.notified_at IS NULL) + OR NOT EXISTS (SELECT 1 FROM notification_target_users tu WHERE tu.notification_id = notifications.id))`) } } From 3fbdef871236c0b6ff71b9efaea8dfb81aa7a70a Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 20:33:04 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix(repository):=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=B8=88=E3=81=BF=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AE?= =?UTF-8?q?=20notified=5Fat=20=E3=82=92=E4=B8=8A=E6=9B=B8=E3=81=8D?= =?UTF-8?q?=E3=81=97=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/notification_dispatch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/repository/notification_dispatch.go b/internal/repository/notification_dispatch.go index 3418ac7..99d9aad 100644 --- a/internal/repository/notification_dispatch.go +++ b/internal/repository/notification_dispatch.go @@ -61,7 +61,7 @@ func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deli continue } if err := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}). - Where("notification_id = ? AND user_id IN ?", nid, uniqueUsers). + Where("notification_id = ? AND user_id IN ? AND notified_at IS NULL", nid, uniqueUsers). Update("notified_at", now).Error; err != nil { return nil, err } From e840b9d4905df5c0b49a09a5e248cd06e30491b6 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 20:56:31 +0900 Subject: [PATCH 08/19] =?UTF-8?q?fix(repository):=200=E4=BB=B6=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E3=81=AE=E9=80=9A=E7=9F=A5ID=E3=82=92=20Dispatch=20?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AB=E5=90=AB=E3=82=81=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/notification_dispatch.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/repository/notification_dispatch.go b/internal/repository/notification_dispatch.go index 99d9aad..81e867b 100644 --- a/internal/repository/notification_dispatch.go +++ b/internal/repository/notification_dispatch.go @@ -60,12 +60,15 @@ func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deli if len(uniqueUsers) == 0 { continue } - if err := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}). + db := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}). Where("notification_id = ? AND user_id IN ? AND notified_at IS NULL", nid, uniqueUsers). - Update("notified_at", now).Error; err != nil { - return nil, err + Update("notified_at", now) + if db.Error != nil { + return nil, db.Error + } + if db.RowsAffected > 0 { + notificationIDs = append(notificationIDs, nid) } - notificationIDs = append(notificationIDs, nid) } if len(notificationIDs) == 0 { From e8129b09a6353df416980b9229f656ae550c2063 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 20:57:25 +0900 Subject: [PATCH 09/19] =?UTF-8?q?fix(repository):=20UpdateNotification=20?= =?UTF-8?q?=E3=81=A7=E5=AF=BE=E8=B1=A1=E8=A1=8C=E3=82=92=20SELECT=20...=20?= =?UTF-8?q?FOR=20UPDATE=20=E3=81=97=E3=81=A6=E3=83=AC=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/notification_update.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/repository/notification_update.go b/internal/repository/notification_update.go index 2180ede..6273f17 100644 --- a/internal/repository/notification_update.go +++ b/internal/repository/notification_update.go @@ -8,6 +8,7 @@ import ( "github.com/fun-dotto/user-api/internal/database" "github.com/fun-dotto/user-api/internal/domain" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func (r *NotificationRepository) UpdateNotification(ctx context.Context, notification domain.Notification) (domain.Notification, error) { @@ -30,7 +31,9 @@ func (r *NotificationRepository) UpdateNotification(ctx context.Context, notific } var existingTargets []database.NotificationTargetUser - if err := tx.Where("notification_id = ?", notification.ID).Find(&existingTargets).Error; err != nil { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("notification_id = ?", notification.ID). + Find(&existingTargets).Error; err != nil { return err } existingNotifiedAt := make(map[string]*time.Time, len(existingTargets)) From 302d1589f541e8f6ae7be47d0b827d33d94c3233 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 21:01:02 +0900 Subject: [PATCH 10/19] =?UTF-8?q?fix(service):=20FCM=20=E3=83=88=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=B3=E6=9C=AA=E7=99=BB=E9=8C=B2=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=82=82=20notifiedAt=20=E3=82=92=E5=9F=8B?= =?UTF-8?q?=E3=82=81=E3=81=A6=E5=86=8D=E3=83=87=E3=82=A3=E3=82=B9=E3=83=91?= =?UTF-8?q?=E3=83=83=E3=83=81=E3=82=92=E6=AD=A2=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/notification_dispatch.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/service/notification_dispatch.go b/internal/service/notification_dispatch.go index e13f65d..cd9dee4 100644 --- a/internal/service/notification_dispatch.go +++ b/internal/service/notification_dispatch.go @@ -58,6 +58,7 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s tokens, tokenUserIDs := collectTokens(pendingUserIDs, tokensByUser) if len(tokens) == 0 { + deliveries[n.ID] = pendingUserIDs continue } From 3af49724e63abe175441cbf5fd03c2f4a2a02d95 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 21:16:36 +0900 Subject: [PATCH 11/19] =?UTF-8?q?fix(service):=20=E3=83=88=E3=83=BC?= =?UTF-8?q?=E3=82=AF=E3=83=B3=E6=B7=B7=E5=9C=A8=E6=99=82=E3=82=82FCM?= =?UTF-8?q?=E6=9C=AA=E7=99=BB=E9=8C=B2=E3=83=A6=E3=83=BC=E3=82=B6=E3=83=BC?= =?UTF-8?q?=E3=81=AE=20notifiedAt=20=E3=82=92=E6=9B=B4=E6=96=B0=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dispatch 対象に FCM トークン保持ユーザーと未登録ユーザーが混在する場合、 従来は送信成功ユーザーのみ deliveries に積んでいたため、未登録ユーザーの notified_at が更新されず再ディスパッチされ続けていた。送信成功ユーザー に加えてトークン未登録ユーザーも delivered に含めるよう修正した。 --- internal/service/notification_dispatch.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/service/notification_dispatch.go b/internal/service/notification_dispatch.go index cd9dee4..db0adbf 100644 --- a/internal/service/notification_dispatch.go +++ b/internal/service/notification_dispatch.go @@ -66,8 +66,23 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s if err != nil { log.Printf("FCM send failed for notification %s: %v", n.ID, err) } - if len(successUserIDs) > 0 { - deliveries[n.ID] = successUserIDs + + successSet := make(map[string]struct{}, len(successUserIDs)) + for _, uid := range successUserIDs { + successSet[uid] = struct{}{} + } + delivered := make([]string, 0, len(pendingUserIDs)) + for _, uid := range pendingUserIDs { + if _, ok := successSet[uid]; ok { + delivered = append(delivered, uid) + continue + } + if _, hasToken := tokensByUser[uid]; !hasToken { + delivered = append(delivered, uid) + } + } + if len(delivered) > 0 { + deliveries[n.ID] = delivered } } From 0011fad995c424b20747c36ca447e7072360699f Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 21:22:42 +0900 Subject: [PATCH 12/19] =?UTF-8?q?perf(repository):=20DispatchNotifications?= =?UTF-8?q?=20=E3=82=92=201=20=E5=9B=9E=E3=81=AE=20UPDATE=20...=20RETURNIN?= =?UTF-8?q?G=20=E3=81=AB=E3=81=BE=E3=81=A8=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/notification_dispatch.go | 43 ++++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/internal/repository/notification_dispatch.go b/internal/repository/notification_dispatch.go index 81e867b..ecf0dc7 100644 --- a/internal/repository/notification_dispatch.go +++ b/internal/repository/notification_dispatch.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" "time" "github.com/fun-dotto/user-api/internal/database" @@ -54,26 +55,42 @@ func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deli } now := time.Now() - notificationIDs := make([]string, 0, len(deliveries)) + placeholders := make([]string, 0) + args := make([]any, 0) + args = append(args, now) for nid, userIDs := range deliveries { uniqueUsers := uniqueStrings(userIDs) - if len(uniqueUsers) == 0 { - continue - } - db := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}). - Where("notification_id = ? AND user_id IN ? AND notified_at IS NULL", nid, uniqueUsers). - Update("notified_at", now) - if db.Error != nil { - return nil, db.Error - } - if db.RowsAffected > 0 { - notificationIDs = append(notificationIDs, nid) + for _, uid := range uniqueUsers { + placeholders = append(placeholders, "(?, ?)") + args = append(args, nid, uid) } } + if len(placeholders) == 0 { + return []domain.Notification{}, nil + } + + sql := "UPDATE notification_target_users SET notified_at = ? " + + "WHERE (notification_id, user_id) IN (" + strings.Join(placeholders, ", ") + ") " + + "AND notified_at IS NULL RETURNING notification_id" - if len(notificationIDs) == 0 { + var updated []database.NotificationTargetUser + if err := r.db.WithContext(ctx).Raw(sql, args...).Scan(&updated).Error; err != nil { + return nil, err + } + + if len(updated) == 0 { return []domain.Notification{}, nil } + notificationIDs := make([]string, 0, len(updated)) + seen := make(map[string]struct{}, len(updated)) + for _, row := range updated { + if _, ok := seen[row.NotificationID]; ok { + continue + } + seen[row.NotificationID] = struct{}{} + notificationIDs = append(notificationIDs, row.NotificationID) + } + return r.GetNotificationsByIDs(ctx, notificationIDs) } From 5b18f824fe1c0a2a9f9692a452d66f00849053fb Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 21:23:45 +0900 Subject: [PATCH 13/19] =?UTF-8?q?fix(service):=20FCM=20=E9=80=81=E4=BF=A1?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=83=AD=E3=82=B0=E3=81=AB=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=88=90=E5=8A=9F=E3=81=AE=E4=BB=B6=E6=95=B0=E3=82=92=E6=98=8E?= =?UTF-8?q?=E7=A4=BA=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/notification_dispatch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/service/notification_dispatch.go b/internal/service/notification_dispatch.go index db0adbf..91c2185 100644 --- a/internal/service/notification_dispatch.go +++ b/internal/service/notification_dispatch.go @@ -64,7 +64,7 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s successUserIDs, err := s.sendToTokens(ctx, n, tokens, tokenUserIDs) if err != nil { - log.Printf("FCM send failed for notification %s: %v", n.ID, err) + log.Printf("FCM send partially failed for notification %s (success=%d/%d users): %v", n.ID, len(successUserIDs), len(pendingUserIDs), err) } successSet := make(map[string]struct{}, len(successUserIDs)) From ff3b98134dd8557a2f7243e86da5a063407b43a7 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 21:50:03 +0900 Subject: [PATCH 14/19] =?UTF-8?q?perf(repository):=20DispatchNotifications?= =?UTF-8?q?=20=E3=82=92=20UNNEST=20+=20JOIN=20=E3=81=A7=E3=83=91=E3=83=A9?= =?UTF-8?q?=E3=83=A1=E3=83=BC=E3=82=BF=E6=95=B0=E3=82=92=E4=B8=80=E5=AE=9A?= =?UTF-8?q?=E3=81=AB=E4=BF=9D=E3=81=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/notification_dispatch.go | 24 +++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/internal/repository/notification_dispatch.go b/internal/repository/notification_dispatch.go index ecf0dc7..63a4634 100644 --- a/internal/repository/notification_dispatch.go +++ b/internal/repository/notification_dispatch.go @@ -2,7 +2,6 @@ package repository import ( "context" - "strings" "time" "github.com/fun-dotto/user-api/internal/database" @@ -55,26 +54,29 @@ func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deli } now := time.Now() - placeholders := make([]string, 0) - args := make([]any, 0) - args = append(args, now) + notificationIDArgs := make([]string, 0) + userIDArgs := make([]string, 0) for nid, userIDs := range deliveries { uniqueUsers := uniqueStrings(userIDs) for _, uid := range uniqueUsers { - placeholders = append(placeholders, "(?, ?)") - args = append(args, nid, uid) + notificationIDArgs = append(notificationIDArgs, nid) + userIDArgs = append(userIDArgs, uid) } } - if len(placeholders) == 0 { + if len(notificationIDArgs) == 0 { return []domain.Notification{}, nil } - sql := "UPDATE notification_target_users SET notified_at = ? " + - "WHERE (notification_id, user_id) IN (" + strings.Join(placeholders, ", ") + ") " + - "AND notified_at IS NULL RETURNING notification_id" + sql := "UPDATE notification_target_users AS ntu " + + "SET notified_at = ? " + + "FROM unnest(?::text[], ?::text[]) AS t(notification_id, user_id) " + + "WHERE ntu.notification_id = t.notification_id " + + "AND ntu.user_id = t.user_id " + + "AND ntu.notified_at IS NULL " + + "RETURNING ntu.notification_id" var updated []database.NotificationTargetUser - if err := r.db.WithContext(ctx).Raw(sql, args...).Scan(&updated).Error; err != nil { + if err := r.db.WithContext(ctx).Raw(sql, now, notificationIDArgs, userIDArgs).Scan(&updated).Error; err != nil { return nil, err } From 1a4eab0b5c1d5988f086149bdd729a798fd695f8 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 22:09:46 +0900 Subject: [PATCH 15/19] =?UTF-8?q?revert(repository):=20DispatchNotificatio?= =?UTF-8?q?ns=20=E3=82=92=E9=80=9A=E7=9F=A5ID=E5=8D=98=E4=BD=8D=E3=81=AE?= =?UTF-8?q?=20UPDATE=20=E5=AE=9F=E8=A3=85=E3=81=B8=E6=88=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UNNEST + JOIN 化した実装を取りやめ、通知IDごとに UPDATE notification_target_users ... WHERE notification_id = ? AND user_id IN ? を発行する素直な実装に差し戻した。RowsAffected を見て 更新が発生した通知IDのみ後続の取得対象に含める挙動は維持する。 --- internal/repository/notification_dispatch.go | 45 ++++++-------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/internal/repository/notification_dispatch.go b/internal/repository/notification_dispatch.go index 63a4634..81e867b 100644 --- a/internal/repository/notification_dispatch.go +++ b/internal/repository/notification_dispatch.go @@ -54,45 +54,26 @@ func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deli } now := time.Now() - notificationIDArgs := make([]string, 0) - userIDArgs := make([]string, 0) + notificationIDs := make([]string, 0, len(deliveries)) for nid, userIDs := range deliveries { uniqueUsers := uniqueStrings(userIDs) - for _, uid := range uniqueUsers { - notificationIDArgs = append(notificationIDArgs, nid) - userIDArgs = append(userIDArgs, uid) + if len(uniqueUsers) == 0 { + continue + } + db := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}). + Where("notification_id = ? AND user_id IN ? AND notified_at IS NULL", nid, uniqueUsers). + Update("notified_at", now) + if db.Error != nil { + return nil, db.Error + } + if db.RowsAffected > 0 { + notificationIDs = append(notificationIDs, nid) } - } - if len(notificationIDArgs) == 0 { - return []domain.Notification{}, nil - } - - sql := "UPDATE notification_target_users AS ntu " + - "SET notified_at = ? " + - "FROM unnest(?::text[], ?::text[]) AS t(notification_id, user_id) " + - "WHERE ntu.notification_id = t.notification_id " + - "AND ntu.user_id = t.user_id " + - "AND ntu.notified_at IS NULL " + - "RETURNING ntu.notification_id" - - var updated []database.NotificationTargetUser - if err := r.db.WithContext(ctx).Raw(sql, now, notificationIDArgs, userIDArgs).Scan(&updated).Error; err != nil { - return nil, err } - if len(updated) == 0 { + if len(notificationIDs) == 0 { return []domain.Notification{}, nil } - notificationIDs := make([]string, 0, len(updated)) - seen := make(map[string]struct{}, len(updated)) - for _, row := range updated { - if _, ok := seen[row.NotificationID]; ok { - continue - } - seen[row.NotificationID] = struct{}{} - notificationIDs = append(notificationIDs, row.NotificationID) - } - return r.GetNotificationsByIDs(ctx, notificationIDs) } From 7712e636ad7cd47b9a915181e3c28feb9c69bcf8 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 22:10:51 +0900 Subject: [PATCH 16/19] =?UTF-8?q?fix(repository):=20DispatchNotifications?= =?UTF-8?q?=20=E3=81=A7=E9=80=9A=E7=9F=A5=E6=B8=88=E3=81=BF=E3=83=A6?= =?UTF-8?q?=E3=83=BC=E3=82=B6=E3=83=BC=E3=81=AE=20notified=5Fat=20?= =?UTF-8?q?=E3=82=82=E4=B8=8A=E6=9B=B8=E3=81=8D=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI 上 /v1/notifications/dispatch は「送信状態に関わらずまとめて送信する」 仕様であり、再ディスパッチ時にも notified_at を最新の送信時刻で更新する必要がある。 そのため UPDATE 条件から notified_at IS NULL を外し、対象ユーザーの行を 無条件に now で更新するようにした。 --- internal/repository/notification_dispatch.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/repository/notification_dispatch.go b/internal/repository/notification_dispatch.go index 81e867b..3fd0fba 100644 --- a/internal/repository/notification_dispatch.go +++ b/internal/repository/notification_dispatch.go @@ -61,7 +61,7 @@ func (r *NotificationRepository) DispatchNotifications(ctx context.Context, deli continue } db := r.db.WithContext(ctx).Model(&database.NotificationTargetUser{}). - Where("notification_id = ? AND user_id IN ? AND notified_at IS NULL", nid, uniqueUsers). + Where("notification_id = ? AND user_id IN ?", nid, uniqueUsers). Update("notified_at", now) if db.Error != nil { return nil, db.Error From 904118249b2126ce6c10acfffe6f9a077444c688 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 22:11:06 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix(service):=20DispatchNotifications=20?= =?UTF-8?q?=E3=81=A7=E9=80=9A=E7=9F=A5=E6=B8=88=E3=81=BF=E3=83=A6=E3=83=BC?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=82=82=E5=86=8D=E9=80=81=E5=AF=BE=E8=B1=A1?= =?UTF-8?q?=E3=81=AB=E5=90=AB=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI の /v1/notifications/dispatch は送信状態に関わらず指定 ID の通知を まとめて再送する仕様であるため、サービス層で NotifiedAt != nil の TargetUser を スキップしている分岐を取り除き、全ターゲットユーザーを FCM 送信対象にする。 notified_at の更新はリポジトリ側で全ユーザーを上書きする実装に揃える。 --- internal/service/notification_dispatch.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/service/notification_dispatch.go b/internal/service/notification_dispatch.go index 91c2185..cc6536a 100644 --- a/internal/service/notification_dispatch.go +++ b/internal/service/notification_dispatch.go @@ -21,9 +21,6 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s userIDSet := make(map[string]struct{}) for _, n := range notifications { for _, t := range n.TargetUsers { - if t.NotifiedAt != nil { - continue - } userIDSet[t.UserID] = struct{}{} } } @@ -47,9 +44,6 @@ func (s *NotificationService) DispatchNotifications(ctx context.Context, ids []s for _, n := range notifications { pendingUserIDs := make([]string, 0, len(n.TargetUsers)) for _, t := range n.TargetUsers { - if t.NotifiedAt != nil { - continue - } pendingUserIDs = append(pendingUserIDs, t.UserID) } if len(pendingUserIDs) == 0 { From 36cdec1f97b9657468ba38525dd8aad35e2dd229 Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 22:25:57 +0900 Subject: [PATCH 18/19] =?UTF-8?q?refactor(service):=20NotificationService?= =?UTF-8?q?=20=E3=81=AE=20messagingClient=20=E3=82=92=20interface=20?= =?UTF-8?q?=E5=8C=96=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DispatchNotifications をユニットテストで検証できるようにするため、 *messaging.Client への直接依存を MessagingClient interface (SendEachForMulticast のみ) に差し替える。 *messaging.Client は引き続きこの interface を満たすため呼び出し側に変更はない。 --- internal/service/notification.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/service/notification.go b/internal/service/notification.go index a5e5a8f..4a63210 100644 --- a/internal/service/notification.go +++ b/internal/service/notification.go @@ -20,16 +20,20 @@ type FCMTokenRepositoryForNotification interface { ListFCMTokens(ctx context.Context, filter domain.FCMTokenListFilter) ([]domain.FCMToken, error) } +type MessagingClient interface { + SendEachForMulticast(ctx context.Context, message *messaging.MulticastMessage) (*messaging.BatchResponse, error) +} + type NotificationService struct { - repo NotificationRepository - fcmTokenRepo FCMTokenRepositoryForNotification - messagingClient *messaging.Client + repo NotificationRepository + fcmTokenRepo FCMTokenRepositoryForNotification + messagingClient MessagingClient } func NewNotificationService( repo NotificationRepository, fcmTokenRepo FCMTokenRepositoryForNotification, - messagingClient *messaging.Client, + messagingClient MessagingClient, ) *NotificationService { return &NotificationService{ repo: repo, From 53411f457b22ba38e3f2796c923ce6d05a6c9b0f Mon Sep 17 00:00:00 2001 From: Kanta Oikawa Date: Tue, 28 Apr 2026 22:26:03 +0900 Subject: [PATCH 19/19] =?UTF-8?q?test(service):=20DispatchNotifications=20?= =?UTF-8?q?=E3=81=AE=E3=83=A6=E3=83=8B=E3=83=83=E3=83=88=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通知ID指定からの送信フロー全体をスタブで検証する。 - 対象通知が存在しない / TargetUsers が空のときは FCM もリポジトリも呼ばない - FCM トークン未登録ユーザーや既通知ユーザーも deliveries に含めて再送扱いにする - FCM 部分成功時は成功ユーザーのみ delivered として記録する - バッチ全体エラー時は dispatch を呼ばずエラーを握り潰してログのみ出す - リポジトリ/FCMトークン取得のエラーはそのまま伝搬する --- .../service/notification_dispatch_test.go | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 internal/service/notification_dispatch_test.go diff --git a/internal/service/notification_dispatch_test.go b/internal/service/notification_dispatch_test.go new file mode 100644 index 0000000..00bfda7 --- /dev/null +++ b/internal/service/notification_dispatch_test.go @@ -0,0 +1,340 @@ +package service + +import ( + "context" + "errors" + "sort" + "testing" + "time" + + "firebase.google.com/go/v4/messaging" + "github.com/fun-dotto/user-api/internal/domain" +) + +type stubNotificationRepo struct { + getByIDs func(ctx context.Context, ids []string) ([]domain.Notification, error) + dispatch func(ctx context.Context, deliveries map[string][]string) ([]domain.Notification, error) + dispatchCalled bool + lastDeliveriesArg map[string][]string + getByIDsCalledWith []string +} + +func (s *stubNotificationRepo) ListNotifications(context.Context, domain.NotificationListFilter) ([]domain.Notification, error) { + return nil, errors.New("not implemented") +} +func (s *stubNotificationRepo) CreateNotification(context.Context, domain.Notification) (domain.Notification, error) { + return domain.Notification{}, errors.New("not implemented") +} +func (s *stubNotificationRepo) UpdateNotification(context.Context, domain.Notification) (domain.Notification, error) { + return domain.Notification{}, errors.New("not implemented") +} +func (s *stubNotificationRepo) DeleteNotification(context.Context, string) error { + return errors.New("not implemented") +} +func (s *stubNotificationRepo) GetNotificationsByIDs(ctx context.Context, ids []string) ([]domain.Notification, error) { + s.getByIDsCalledWith = ids + if s.getByIDs == nil { + return nil, nil + } + return s.getByIDs(ctx, ids) +} +func (s *stubNotificationRepo) DispatchNotifications(ctx context.Context, deliveries map[string][]string) ([]domain.Notification, error) { + s.dispatchCalled = true + s.lastDeliveriesArg = deliveries + if s.dispatch == nil { + return nil, nil + } + return s.dispatch(ctx, deliveries) +} + +type stubFCMTokenRepo struct { + list func(ctx context.Context, filter domain.FCMTokenListFilter) ([]domain.FCMToken, error) + called bool + lastFilter domain.FCMTokenListFilter +} + +func (s *stubFCMTokenRepo) ListFCMTokens(ctx context.Context, filter domain.FCMTokenListFilter) ([]domain.FCMToken, error) { + s.called = true + s.lastFilter = filter + if s.list == nil { + return nil, nil + } + return s.list(ctx, filter) +} + +type stubMessagingClient struct { + send func(ctx context.Context, msg *messaging.MulticastMessage) (*messaging.BatchResponse, error) + calls int + tokenCalls [][]string +} + +func (s *stubMessagingClient) SendEachForMulticast(ctx context.Context, msg *messaging.MulticastMessage) (*messaging.BatchResponse, error) { + s.calls++ + tokens := append([]string{}, msg.Tokens...) + s.tokenCalls = append(s.tokenCalls, tokens) + if s.send == nil { + responses := make([]*messaging.SendResponse, len(msg.Tokens)) + for i := range responses { + responses[i] = &messaging.SendResponse{Success: true, MessageID: "msg"} + } + return &messaging.BatchResponse{SuccessCount: len(responses), Responses: responses}, nil + } + return s.send(ctx, msg) +} + +func newServiceWithStubs(repo *stubNotificationRepo, tokenRepo *stubFCMTokenRepo, msg *stubMessagingClient) *NotificationService { + return &NotificationService{repo: repo, fcmTokenRepo: tokenRepo, messagingClient: msg} +} + +func notifiedAt(t time.Time) *time.Time { return &t } + +func TestDispatchNotifications_NoNotifications(t *testing.T) { + repo := &stubNotificationRepo{getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return []domain.Notification{}, nil + }} + tokenRepo := &stubFCMTokenRepo{} + msg := &stubMessagingClient{} + svc := newServiceWithStubs(repo, tokenRepo, msg) + + got, err := svc.DispatchNotifications(context.Background(), []string{"n1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty result, got %d", len(got)) + } + if tokenRepo.called { + t.Errorf("expected ListFCMTokens not called") + } + if msg.calls != 0 { + t.Errorf("expected no FCM calls, got %d", msg.calls) + } + if repo.dispatchCalled { + t.Errorf("expected DispatchNotifications not called") + } +} + +func TestDispatchNotifications_GetByIDsError(t *testing.T) { + wantErr := errors.New("db down") + repo := &stubNotificationRepo{getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return nil, wantErr + }} + svc := newServiceWithStubs(repo, &stubFCMTokenRepo{}, &stubMessagingClient{}) + + if _, err := svc.DispatchNotifications(context.Background(), []string{"n1"}); !errors.Is(err, wantErr) { + t.Errorf("expected error %v, got %v", wantErr, err) + } +} + +func TestDispatchNotifications_NoTargetUsers(t *testing.T) { + repo := &stubNotificationRepo{getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return []domain.Notification{{ID: "n1", Title: "t", Body: "b"}}, nil + }} + tokenRepo := &stubFCMTokenRepo{} + msg := &stubMessagingClient{} + svc := newServiceWithStubs(repo, tokenRepo, msg) + + got, err := svc.DispatchNotifications(context.Background(), []string{"n1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty result, got %d", len(got)) + } + if tokenRepo.called { + t.Errorf("expected ListFCMTokens not called when no target users") + } + if repo.dispatchCalled { + t.Errorf("expected DispatchNotifications not called") + } +} + +func TestDispatchNotifications_NoTokens_StillRecordsDelivery(t *testing.T) { + notification := domain.Notification{ + ID: "n1", + Title: "t", + Body: "b", + TargetUsers: []domain.NotificationTargetUser{ + {UserID: "u1"}, + {UserID: "u2", NotifiedAt: notifiedAt(time.Now())}, + }, + } + repo := &stubNotificationRepo{ + getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + dispatch: func(_ context.Context, _ map[string][]string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + } + tokenRepo := &stubFCMTokenRepo{list: func(context.Context, domain.FCMTokenListFilter) ([]domain.FCMToken, error) { + return []domain.FCMToken{}, nil + }} + msg := &stubMessagingClient{} + svc := newServiceWithStubs(repo, tokenRepo, msg) + + got, err := svc.DispatchNotifications(context.Background(), []string{"n1"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 notification, got %d", len(got)) + } + if msg.calls != 0 { + t.Errorf("expected no FCM calls when no tokens, got %d", msg.calls) + } + gotUsers := repo.lastDeliveriesArg["n1"] + sort.Strings(gotUsers) + if len(gotUsers) != 2 || gotUsers[0] != "u1" || gotUsers[1] != "u2" { + t.Errorf("expected both u1 and u2 in deliveries (force re-send), got %v", gotUsers) + } +} + +func TestDispatchNotifications_AllTokensSucceed(t *testing.T) { + notification := domain.Notification{ + ID: "n1", + Title: "t", + Body: "b", + TargetUsers: []domain.NotificationTargetUser{ + {UserID: "u1"}, + {UserID: "u2", NotifiedAt: notifiedAt(time.Now())}, + }, + } + repo := &stubNotificationRepo{ + getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + dispatch: func(_ context.Context, _ map[string][]string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + } + tokenRepo := &stubFCMTokenRepo{list: func(context.Context, domain.FCMTokenListFilter) ([]domain.FCMToken, error) { + return []domain.FCMToken{ + {UserID: "u1", Token: "tok-u1"}, + {UserID: "u2", Token: "tok-u2"}, + }, nil + }} + msg := &stubMessagingClient{} + svc := newServiceWithStubs(repo, tokenRepo, msg) + + if _, err := svc.DispatchNotifications(context.Background(), []string{"n1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if msg.calls != 1 { + t.Errorf("expected 1 FCM batch call, got %d", msg.calls) + } + gotUsers := repo.lastDeliveriesArg["n1"] + sort.Strings(gotUsers) + if len(gotUsers) != 2 || gotUsers[0] != "u1" || gotUsers[1] != "u2" { + t.Errorf("expected both users delivered, got %v", gotUsers) + } +} + +func TestDispatchNotifications_PartialFCMFailure(t *testing.T) { + notification := domain.Notification{ + ID: "n1", + Title: "t", + Body: "b", + TargetUsers: []domain.NotificationTargetUser{ + {UserID: "u1"}, + {UserID: "u2"}, + {UserID: "u3"}, + }, + } + repo := &stubNotificationRepo{ + getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + dispatch: func(_ context.Context, _ map[string][]string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + } + tokenRepo := &stubFCMTokenRepo{list: func(context.Context, domain.FCMTokenListFilter) ([]domain.FCMToken, error) { + return []domain.FCMToken{ + {UserID: "u1", Token: "tok-u1"}, + {UserID: "u2", Token: "tok-u2"}, + }, nil + }} + msg := &stubMessagingClient{send: func(_ context.Context, m *messaging.MulticastMessage) (*messaging.BatchResponse, error) { + responses := make([]*messaging.SendResponse, len(m.Tokens)) + for i, tk := range m.Tokens { + if tk == "tok-u2" { + responses[i] = &messaging.SendResponse{Error: errors.New("invalid token")} + } else { + responses[i] = &messaging.SendResponse{Success: true, MessageID: "msg"} + } + } + return &messaging.BatchResponse{Responses: responses}, nil + }} + svc := newServiceWithStubs(repo, tokenRepo, msg) + + if _, err := svc.DispatchNotifications(context.Background(), []string{"n1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotUsers := repo.lastDeliveriesArg["n1"] + sort.Strings(gotUsers) + want := []string{"u1", "u3"} + if len(gotUsers) != len(want) || gotUsers[0] != want[0] || gotUsers[1] != want[1] { + t.Errorf("expected delivered=%v (u1 succeeded; u3 had no token), got %v", want, gotUsers) + } +} + +func TestDispatchNotifications_FCMBatchError_KeepsPartialSuccess(t *testing.T) { + notification := domain.Notification{ + ID: "n1", + Title: "t", + Body: "b", + TargetUsers: []domain.NotificationTargetUser{ + {UserID: "u1"}, + }, + } + repo := &stubNotificationRepo{ + getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + dispatch: func(_ context.Context, _ map[string][]string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }, + } + tokenRepo := &stubFCMTokenRepo{list: func(context.Context, domain.FCMTokenListFilter) ([]domain.FCMToken, error) { + return []domain.FCMToken{{UserID: "u1", Token: "tok-u1"}}, nil + }} + msg := &stubMessagingClient{send: func(context.Context, *messaging.MulticastMessage) (*messaging.BatchResponse, error) { + return nil, errors.New("fcm down") + }} + svc := newServiceWithStubs(repo, tokenRepo, msg) + + got, err := svc.DispatchNotifications(context.Background(), []string{"n1"}) + if err != nil { + t.Fatalf("expected nil error (FCM error is logged, not returned), got %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty result when no users delivered, got %d", len(got)) + } + if repo.dispatchCalled { + t.Errorf("expected DispatchNotifications not called when no successful deliveries") + } +} + +func TestDispatchNotifications_FCMTokenListError(t *testing.T) { + notification := domain.Notification{ + ID: "n1", + Title: "t", + Body: "b", + TargetUsers: []domain.NotificationTargetUser{ + {UserID: "u1"}, + }, + } + repo := &stubNotificationRepo{getByIDs: func(context.Context, []string) ([]domain.Notification, error) { + return []domain.Notification{notification}, nil + }} + wantErr := errors.New("token db down") + tokenRepo := &stubFCMTokenRepo{list: func(context.Context, domain.FCMTokenListFilter) ([]domain.FCMToken, error) { + return nil, wantErr + }} + svc := newServiceWithStubs(repo, tokenRepo, &stubMessagingClient{}) + + if _, err := svc.DispatchNotifications(context.Background(), []string{"n1"}); !errors.Is(err, wantErr) { + t.Errorf("expected error %v, got %v", wantErr, err) + } +}