feat(supervisor): add reminder settings and rate-limited logs

This commit is contained in:
nepiedg
2026-04-16 13:37:06 +08:00
parent 50352d67ca
commit f701feebe8
7 changed files with 552 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
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_logstubbed),不接真实通道
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)
}
@@ -0,0 +1,153 @@
package service
import (
"context"
"testing"
"time"
quitmodel "wx_service/internal/quitcheckin/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func setupReminderTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&quitmodel.Profile{},
&quitmodel.DailyStatus{},
&quitmodel.RelapseEvent{},
&quitmodel.RewardGoal{},
&quitmodel.DreamPreset{},
&quitmodel.SupervisorBinding{},
&quitmodel.SupervisorInvite{},
&quitmodel.SupervisorReminderSetting{},
&quitmodel.SupervisorReminderLog{},
&quitmodel.HPChangeLog{},
); err != nil {
t.Fatalf("auto migrate: %v", err)
}
return db
}
func TestReminderRunCreatesLogAfterNotifyTime(t *testing.T) {
t.Parallel()
db := setupReminderTestDB(t)
svc := NewService(db)
ctx := context.Background()
ownerUID := 4001
supervisorUID := 4002
startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local)
now := time.Date(2026, 4, 16, 21, 30, 0, 0, time.Local)
// seed quit profile
if err := db.Create(&quitmodel.Profile{
UID: ownerUID,
QuitStartDate: startDate,
PackPriceCent: 2500,
BaselineCigsPerDay: 10,
NotifyTime: "21:00",
ResetRule: "relapse_clears_streak",
}).Error; err != nil {
t.Fatalf("seed profile: %v", err)
}
// seed binding
if err := db.Create(&quitmodel.SupervisorBinding{
OwnerUID: ownerUID,
SupervisorUID: supervisorUID,
Status: "active",
}).Error; err != nil {
t.Fatalf("seed binding: %v", err)
}
// enable reminder
if err := db.Create(&quitmodel.SupervisorReminderSetting{
OwnerUID: ownerUID,
Enabled: true,
NotifyTime: "21:00",
MaxPerDay: 1,
ChannelHint: "stub",
}).Error; err != nil {
t.Fatalf("seed setting: %v", err)
}
res, err := svc.RunMissedCheckinRemindersForSupervisor(ctx, supervisorUID, now)
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Created != 1 {
t.Fatalf("created=%d want=1", res.Created)
}
// run again should be rate-limited
res2, err := svc.RunMissedCheckinRemindersForSupervisor(ctx, supervisorUID, now.Add(10*time.Minute))
if err != nil {
t.Fatalf("run2: %v", err)
}
if res2.Created != 0 {
t.Fatalf("created2=%d want=0", res2.Created)
}
}
func TestReminderRunSkipsBeforeNotifyTime(t *testing.T) {
t.Parallel()
db := setupReminderTestDB(t)
svc := NewService(db)
ctx := context.Background()
ownerUID := 4011
supervisorUID := 4012
startDate := time.Date(2026, 4, 10, 0, 0, 0, 0, time.Local)
now := time.Date(2026, 4, 16, 20, 50, 0, 0, time.Local)
if err := db.Create(&quitmodel.Profile{
UID: ownerUID,
QuitStartDate: startDate,
PackPriceCent: 2500,
BaselineCigsPerDay: 10,
NotifyTime: "21:00",
ResetRule: "relapse_clears_streak",
}).Error; err != nil {
t.Fatalf("seed profile: %v", err)
}
if err := db.Create(&quitmodel.SupervisorBinding{
OwnerUID: ownerUID,
SupervisorUID: supervisorUID,
Status: "active",
}).Error; err != nil {
t.Fatalf("seed binding: %v", err)
}
if err := db.Create(&quitmodel.SupervisorReminderSetting{
OwnerUID: ownerUID,
Enabled: true,
NotifyTime: "21:00",
MaxPerDay: 1,
ChannelHint: "stub",
}).Error; err != nil {
t.Fatalf("seed setting: %v", err)
}
res, err := svc.RunMissedCheckinRemindersForSupervisor(ctx, supervisorUID, now)
if err != nil {
t.Fatalf("run: %v", err)
}
if res.Created != 0 {
t.Fatalf("created=%d want=0", res.Created)
}
}