254 lines
6.7 KiB
Go
254 lines
6.7 KiB
Go
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)
|
||
}
|