From f701feebe80f5f2edace3c7f7a1605a2e7030f36 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Thu, 16 Apr 2026 13:37:06 +0800 Subject: [PATCH] feat(supervisor): add reminder settings and rate-limited logs --- cmd/api/main.go | 2 + .../quitcheckin/handler/reminder_handler.go | 73 +++++ .../model/supervisor_reminder_log.go | 37 +++ .../model/supervisor_reminder_setting.go | 31 +++ internal/quitcheckin/service/reminder.go | 253 ++++++++++++++++++ internal/quitcheckin/service/reminder_test.go | 153 +++++++++++ internal/routes/quitcheckin_routes.go | 3 + 7 files changed, 552 insertions(+) create mode 100644 internal/quitcheckin/handler/reminder_handler.go create mode 100644 internal/quitcheckin/model/supervisor_reminder_log.go create mode 100644 internal/quitcheckin/model/supervisor_reminder_setting.go create mode 100644 internal/quitcheckin/service/reminder.go create mode 100644 internal/quitcheckin/service/reminder_test.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 1e00f61..d799bd9 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -91,6 +91,8 @@ func main() { &quitcheckinmodel.HPChangeLog{}, &quitcheckinmodel.SupervisorInvite{}, &quitcheckinmodel.SupervisorBinding{}, + &quitcheckinmodel.SupervisorReminderSetting{}, + &quitcheckinmodel.SupervisorReminderLog{}, &quitcheckinmodel.RewardGoal{}, &quitcheckinmodel.DreamPreset{}, &achievement.Theme{}, diff --git a/internal/quitcheckin/handler/reminder_handler.go b/internal/quitcheckin/handler/reminder_handler.go new file mode 100644 index 0000000..8727d77 --- /dev/null +++ b/internal/quitcheckin/handler/reminder_handler.go @@ -0,0 +1,73 @@ +package handler + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "wx_service/internal/middleware" + "wx_service/internal/model" + service "wx_service/internal/quitcheckin/service" +) + +// GetReminderSettings GET /api/v2/supervisor/reminders/settings +// owner 读取自己的提醒设置。 +func (h *Handler) GetReminderSettings(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + res, err := h.service.GetReminderSettings(c.Request.Context(), int(user.ID)) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取提醒设置失败,请稍后重试")) + return + } + c.JSON(http.StatusOK, model.Success(res)) +} + +type updateReminderSettingsRequest struct { + Enabled *bool `json:"enabled"` + NotifyTime *string `json:"notify_time"` + MaxPerDay *int `json:"max_per_day"` +} + +// UpdateReminderSettings PUT /api/v2/supervisor/reminders/settings +func (h *Handler) UpdateReminderSettings(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var req updateReminderSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + // basic trim + if req.NotifyTime != nil { + v := strings.TrimSpace(*req.NotifyTime) + req.NotifyTime = &v + } + + res, err := h.service.UpdateReminderSettings(c.Request.Context(), int(user.ID), service.UpdateReminderSettingsRequest{ + Enabled: req.Enabled, + NotifyTime: req.NotifyTime, + MaxPerDay: req.MaxPerDay, + }, time.Now()) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "保存提醒设置失败")) + return + } + c.JSON(http.StatusOK, model.Success(res)) +} + +// RunReminders POST /api/v2/supervisor/reminders/run +// supervisor 手动触发提醒(开发/测试用):只会给“当前用户作为监督人”的绑定关系写 reminder_log。 +func (h *Handler) RunReminders(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + res, err := h.service.RunMissedCheckinRemindersForSupervisor(c.Request.Context(), int(user.ID), time.Now()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "触发提醒失败,请稍后重试")) + return + } + c.JSON(http.StatusOK, model.Success(res)) +} diff --git a/internal/quitcheckin/model/supervisor_reminder_log.go b/internal/quitcheckin/model/supervisor_reminder_log.go new file mode 100644 index 0000000..4457c54 --- /dev/null +++ b/internal/quitcheckin/model/supervisor_reminder_log.go @@ -0,0 +1,37 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// SupervisorReminderLog 记录一次提醒尝试,用于: +// - 频控:按天限制每个 (owner, supervisor) 的提醒次数 +// - 追踪:后续接入真实通道(订阅消息)时可落库 status/result +type SupervisorReminderLog struct { + ID uint `gorm:"primaryKey;comment:主键" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` + + OwnerUID int `gorm:"column:owner_uid;index:idx_owner_supervisor_date,priority:1;comment:被监督用户ID" json:"owner_uid"` + SupervisorUID int `gorm:"column:supervisor_uid;index:idx_owner_supervisor_date,priority:2;comment:监督人用户ID" json:"supervisor_uid"` + + ReminderDate time.Time `gorm:"column:reminder_date;type:date;index:idx_owner_supervisor_date,priority:3;comment:所属自然日" json:"reminder_date"` + ReminderAt time.Time `gorm:"column:reminder_at;comment:提醒时间" json:"reminder_at"` + + Type string `gorm:"column:type;size:32;index;comment:提醒类型(missed_checkin...)" json:"type"` + Status string `gorm:"column:status;size:16;index;comment:状态(stubbed|sent|failed)" json:"status"` + Channel string `gorm:"column:channel;size:32;comment:通道(stub|subscribe_msg...)" json:"channel"` + + Message string `gorm:"column:message;type:text;comment:提醒内容(可选)" json:"message,omitempty"` +} + +func (SupervisorReminderLog) TableName() string { + return "fa_quit_checkin_supervisor_reminder_log" +} + +func (SupervisorReminderLog) TableComment() string { + return "V2-无烟打卡-监督提醒记录" +} diff --git a/internal/quitcheckin/model/supervisor_reminder_setting.go b/internal/quitcheckin/model/supervisor_reminder_setting.go new file mode 100644 index 0000000..542a0f5 --- /dev/null +++ b/internal/quitcheckin/model/supervisor_reminder_setting.go @@ -0,0 +1,31 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// SupervisorReminderSetting 表示“被监督用户(owner)”对提醒机制的配置。 +// 注意:提醒本质是隐私相关能力,默认应为关闭,需 owner 显式开启。 +type SupervisorReminderSetting struct { + ID uint `gorm:"primaryKey;comment:主键" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` + + OwnerUID int `gorm:"column:owner_uid;uniqueIndex;comment:被监督用户ID" json:"owner_uid"` + + Enabled bool `gorm:"column:enabled;comment:是否启用提醒" json:"enabled"` + NotifyTime string `gorm:"column:notify_time;size:5;comment:提醒时间(HH:MM)" json:"notify_time"` + MaxPerDay int `gorm:"column:max_per_day;comment:每日最多提醒次数(每个监督人)" json:"max_per_day"` + ChannelHint string `gorm:"column:channel_hint;size:32;comment:提醒通道提示(stub|subscribe_msg...)" json:"channel_hint,omitempty"` +} + +func (SupervisorReminderSetting) TableName() string { + return "fa_quit_checkin_supervisor_reminder_setting" +} + +func (SupervisorReminderSetting) TableComment() string { + return "V2-无烟打卡-监督提醒设置" +} diff --git a/internal/quitcheckin/service/reminder.go b/internal/quitcheckin/service/reminder.go new file mode 100644 index 0000000..99321e3 --- /dev/null +++ b/internal/quitcheckin/service/reminder.go @@ -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_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) +} diff --git a/internal/quitcheckin/service/reminder_test.go b/internal/quitcheckin/service/reminder_test.go new file mode 100644 index 0000000..3f8d232 --- /dev/null +++ b/internal/quitcheckin/service/reminder_test.go @@ -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) + } +} diff --git a/internal/routes/quitcheckin_routes.go b/internal/routes/quitcheckin_routes.go index 0accc09..06f8e60 100644 --- a/internal/routes/quitcheckin_routes.go +++ b/internal/routes/quitcheckin_routes.go @@ -22,6 +22,9 @@ func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinh v2.POST("/supervisor/revoke", handler.RevokeSupervisorBinding) v2.GET("/supervisor/overview", handler.GetSupervisorOverview) v2.GET("/supervisor/status", handler.GetSupervisorStatus) + v2.GET("/supervisor/reminders/settings", handler.GetReminderSettings) + v2.PUT("/supervisor/reminders/settings", handler.UpdateReminderSettings) + v2.POST("/supervisor/reminders/run", handler.RunReminders) v2.GET("/stats/overview", handler.StatsOverview) v2.GET("/badges", handler.ListBadges)