package service import ( "context" "fmt" "html" "strings" "time" "go.uber.org/zap" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) // expiryNotifiedKeyPrefix 记录「某个用户在某个提醒桶已经提醒过」,防止重复打扰。 // 键格式:telegram.expiry_notified.{userID}.{bucket},bucket 为 "3d" 或 "1d"。 const expiryNotifiedKeyPrefix = "telegram.expiry_notified." // expiryWarnWindow 是「即将到期」的判定总窗口(所有提醒桶中最大的上限)。 const expiryWarnWindow = 72 * time.Hour // expiryBucket 描述一个提醒时间窗口。 type expiryBucket struct { name string from time.Duration // 剩余时间下限(不含) to time.Duration // 剩余时间上限(含) } // expiryBuckets 定义两次提醒:约 3 天前 / 约 1 天前,各提醒一次。 var expiryBuckets = []expiryBucket{ {"3d", 24 * time.Hour, 72 * time.Hour}, // 1–3 天 {"1d", 0, 24 * time.Hour}, // 0–1 天 } // TelegramExpiryWatcher 每日巡检即将到期的账号并提醒用户。 // // 只负责「发现 + 通知 + 去重」,发送本身交给注入的 notifier,因此没有配置 // Telegram 时整条链路静默跳过。 type TelegramExpiryWatcher struct { log *zap.Logger repo *repository.Container now func() time.Time userNotifier func(ctx context.Context, userID, text string) } // NewTelegramExpiryWatcher 构建巡检器。 func NewTelegramExpiryWatcher(log *zap.Logger, repo *repository.Container) *TelegramExpiryWatcher { return &TelegramExpiryWatcher{log: log, repo: repo, now: time.Now} } // SetUserNotifier 注入用户通知回调(通常是 TelegramService.SendToUser)。 func (w *TelegramExpiryWatcher) SetUserNotifier(fn func(ctx context.Context, userID, text string)) { if w == nil { return } w.userNotifier = fn } // RunOnce 执行一次巡检。每个用户每个提醒桶("3d" / "1d")最多触发一次, // 未绑定 Telegram 的用户跳过且不写标记键。 func (w *TelegramExpiryWatcher) RunOnce(ctx context.Context) error { if w == nil || w.repo == nil || w.repo.User == nil || w.userNotifier == nil { return nil } now := w.now() for _, bucket := range expiryBuckets { from := now.Add(bucket.from) // expired_at > from to := now.Add(bucket.to) // expired_at <= to users, err := w.dueUsers(ctx, from, to) if err != nil { return err } for _, u := range users { if u.ExpiredAt == nil { continue } // 跳过未绑定 Telegram 的用户,且不消耗标记键。 if strings.TrimSpace(u.TelegramChatID) == "" { continue } markKey := expiryNotifiedKeyPrefix + u.ID + "." + bucket.name if seen, err := w.repo.Setting.Get(ctx, markKey); err == nil && strings.TrimSpace(seen) != "" { continue } w.userNotifier(ctx, u.ID, fmt.Sprintf( "⏳ 账号 %s 将于 %s 到期,请及时续期,避免到期后无法登录。", html.EscapeString(u.Username), u.ExpiredAt.In(time.Local).Format("2006-01-02 15:04"), )) // 仅在发送(尝试)后才写标记键。 if err := w.repo.Setting.Set(ctx, markKey, "1"); err != nil && w.log != nil { w.log.Warn("telegram expiry mark failed", zap.String("user_id", u.ID), zap.Error(err)) } } } return nil } // dueUsers 返回 (now, deadline] 内到期且仍处于启用状态的账号。 func (w *TelegramExpiryWatcher) dueUsers(ctx context.Context, now, deadline time.Time) ([]*model.User, error) { var users []model.User err := w.repo.DB.WithContext(ctx). Where("expired_at IS NOT NULL AND expired_at > ? AND expired_at <= ?", now, deadline). Where("is_active = ?", true). Find(&users).Error if err != nil { return nil, err } out := make([]*model.User, 0, len(users)) for i := range users { out = append(out, &users[i]) } return out, nil }