package service import ( "context" "errors" "fmt" "strings" "time" quitmodel "wx_service/internal/quitcheckin/model" "gorm.io/gorm" ) const ( reminderTypeMissedCheckin = "missed_checkin" reminderStatusStubbed = "stubbed" reminderChannelStub = "stub" ) type ReminderSettingsResult struct { Enabled bool `json:"enabled"` NotifyTime string `json:"notify_time"` MaxPerDay int `json:"max_per_day"` } type UpdateReminderSettingsRequest struct { Enabled *bool NotifyTime *string MaxPerDay *int } type RunRemindersResult struct { Created int `json:"created"` Skipped int `json:"skipped"` } var ( ErrReminderSettingsInvalid = errors.New("提醒设置不合法") ) func (s *Service) GetReminderSettings(ctx context.Context, ownerUID int) (ReminderSettingsResult, error) { setting, err := s.loadOrInitReminderSetting(ctx, ownerUID, time.Now().In(time.Local)) if err != nil { return ReminderSettingsResult{}, err } return ReminderSettingsResult{ Enabled: setting.Enabled, NotifyTime: setting.NotifyTime, MaxPerDay: setting.MaxPerDay, }, nil } func (s *Service) UpdateReminderSettings(ctx context.Context, ownerUID int, req UpdateReminderSettingsRequest, now time.Time) (ReminderSettingsResult, error) { setting, err := s.loadOrInitReminderSetting(ctx, ownerUID, now) if err != nil { return ReminderSettingsResult{}, err } updates := map[string]interface{}{} if req.Enabled != nil { updates["enabled"] = *req.Enabled setting.Enabled = *req.Enabled } if req.NotifyTime != nil { v := strings.TrimSpace(*req.NotifyTime) if !ParseNotifyTime(v) { return ReminderSettingsResult{}, ErrReminderSettingsInvalid } updates["notify_time"] = v setting.NotifyTime = v } if req.MaxPerDay != nil { v := *req.MaxPerDay if v < 0 || v > 10 { return ReminderSettingsResult{}, ErrReminderSettingsInvalid } if v == 0 { // 0 表示关闭提醒(即使 enabled=true 也不会发送) updates["max_per_day"] = 0 setting.MaxPerDay = 0 } else { updates["max_per_day"] = v setting.MaxPerDay = v } } if len(updates) == 0 { return ReminderSettingsResult{ Enabled: setting.Enabled, NotifyTime: setting.NotifyTime, MaxPerDay: setting.MaxPerDay, }, nil } updates["updated_at"] = now if err := s.db.WithContext(ctx). Model(&quitmodel.SupervisorReminderSetting{}). Where("owner_uid = ?", ownerUID). Updates(updates).Error; err != nil { return ReminderSettingsResult{}, err } return ReminderSettingsResult{ Enabled: setting.Enabled, NotifyTime: setting.NotifyTime, MaxPerDay: setting.MaxPerDay, }, nil } // RunMissedCheckinRemindersForSupervisor 手动触发提醒(用于开发/测试): // - 只会针对“当前监督人”绑定的 owner // - 只会写入 reminder_log(stubbed),不接真实通道 func (s *Service) RunMissedCheckinRemindersForSupervisor(ctx context.Context, supervisorUID int, now time.Time) (RunRemindersResult, error) { today := normalizeDate(now) var bindings []quitmodel.SupervisorBinding if err := s.db.WithContext(ctx). Where("supervisor_uid = ? AND status = ?", supervisorUID, "active"). Find(&bindings).Error; err != nil { return RunRemindersResult{}, err } if len(bindings) == 0 { return RunRemindersResult{Created: 0, Skipped: 0}, nil } created := 0 skipped := 0 for _, b := range bindings { ownerUID := b.OwnerUID // owner 必须有 profile 才视为 quit-checkin 用户;没有就跳过。 _, err := s.loadProfile(ctx, ownerUID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { skipped++ continue } return RunRemindersResult{}, err } setting, err := s.loadOrInitReminderSetting(ctx, ownerUID, now) if err != nil { return RunRemindersResult{}, err } if !setting.Enabled || setting.MaxPerDay <= 0 { skipped++ continue } // 触发时间门槛:now >= notify_time if !isAfterNotifyTime(now, setting.NotifyTime) { skipped++ continue } // 今日状态:已打卡或已复吸,则无需提醒 var status quitmodel.DailyStatus err = s.db.WithContext(ctx). Where("uid = ? AND date = ?", ownerUID, today). First(&status).Error if err == nil { if status.Status == quitmodel.DailyStatusCheckedIn || status.Status == quitmodel.DailyStatusRelapsed { skipped++ continue } } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return RunRemindersResult{}, err } // 频控:每日最多 N 次(按 owner+supervisor+date 计数) var count int64 if err := s.db.WithContext(ctx). Model(&quitmodel.SupervisorReminderLog{}). Where("owner_uid = ? AND supervisor_uid = ? AND reminder_date = ?", ownerUID, supervisorUID, today). Count(&count).Error; err != nil { return RunRemindersResult{}, err } if int(count) >= setting.MaxPerDay { skipped++ continue } msg := fmt.Sprintf("提醒:%s 今天还没打卡", safeNickname(ownerUID)) logRow := quitmodel.SupervisorReminderLog{ OwnerUID: ownerUID, SupervisorUID: supervisorUID, ReminderDate: today, ReminderAt: now, Type: reminderTypeMissedCheckin, Status: reminderStatusStubbed, Channel: reminderChannelStub, Message: msg, } if err := s.db.WithContext(ctx).Create(&logRow).Error; err != nil { return RunRemindersResult{}, err } created++ } return RunRemindersResult{Created: created, Skipped: skipped}, nil } func (s *Service) loadOrInitReminderSetting(ctx context.Context, ownerUID int, now time.Time) (*quitmodel.SupervisorReminderSetting, error) { var row quitmodel.SupervisorReminderSetting err := s.db.WithContext(ctx).Where("owner_uid = ?", ownerUID).First(&row).Error if err == nil { // sanity defaults if row.NotifyTime == "" { row.NotifyTime = "21:00" } if row.MaxPerDay < 0 { row.MaxPerDay = 0 } return &row, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } // 默认关闭,避免隐私风险 row = quitmodel.SupervisorReminderSetting{ OwnerUID: ownerUID, Enabled: false, NotifyTime: "21:00", MaxPerDay: 1, ChannelHint: "stub", } if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { return nil, err } return &row, nil } func isAfterNotifyTime(now time.Time, notifyTime string) bool { notifyTime = strings.TrimSpace(notifyTime) if notifyTime == "" { notifyTime = "21:00" } t, err := time.ParseInLocation("15:04", notifyTime, time.Local) if err != nil { return true } target := time.Date(now.Year(), now.Month(), now.Day(), t.Hour(), t.Minute(), 0, 0, time.Local) return !now.Before(target) } func safeNickname(uid int) string { // 这里先不依赖 user.nickname,避免跨模块耦合和额外查询。 // motivation 字段也不适合作为昵称,仅作占位。 return fmt.Sprintf("用户%d", uid) }