Files
yunzerwebsiteallinone/go/services/reminder_scheduler.go
T

453 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"server/models"
)
type ReminderData struct {
RemindChannel string
ReceiverUserID uint64
ReceiverTarget *string
AckToken *string
Tid int
}
// ReminderSender 提醒发送接口
type ReminderSender interface {
Send(ctx context.Context, reminder ReminderData, title, content string) (success bool, err error)
}
// resolveReceiverTarget 统一解析接收目标
// 优先级(从高到低):
// 1. 单条提醒指定的 ReceiverTarget(创建提醒时手动填写)
// 2. backend 用户通知配置表(yz_backend_user_notify_config)
// 3. 用户基础信息表(backend 查 SystemTenantUser,platform 查 AdminUser)
//
// channel: SMS / EMAIL / BARK
// data.Tid > 0 表示 backend 租户用户,Tid == 0 表示 platform 管理员
func resolveReceiverTarget(channel string, data ReminderData) string {
// 1. 单条提醒指定的目标最高优先
if data.ReceiverTarget != nil && *data.ReceiverTarget != "" {
return *data.ReceiverTarget
}
// 2. backend 用户查用户通知配置表
if data.Tid > 0 {
cfg := models.GetBackendUserNotifyConfig(uint64(data.Tid), data.ReceiverUserID)
if cfg != nil {
switch channel {
case "SMS":
if cfg.Phone != nil && *cfg.Phone != "" {
return *cfg.Phone
}
case "EMAIL":
if cfg.Email != nil && *cfg.Email != "" {
return *cfg.Email
}
case "BARK":
if cfg.BarkDeviceKey != nil && *cfg.BarkDeviceKey != "" {
return *cfg.BarkDeviceKey
}
}
}
}
// 3. 最后 fallback 到用户基础信息表
if data.Tid > 0 {
// backend 租户用户
var user models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("id", data.ReceiverUserID).One(&user); err == nil {
switch channel {
case "SMS":
if user.Phone != nil && *user.Phone != "" {
return *user.Phone
}
case "EMAIL":
if user.Email != nil && *user.Email != "" {
return *user.Email
}
}
}
} else {
// platform 管理员
var user models.AdminUser
if err := models.Orm.QueryTable(new(models.AdminUser)).
Filter("id", data.ReceiverUserID).One(&user); err == nil {
switch channel {
case "SMS":
if user.Phone != nil && *user.Phone != "" {
return *user.Phone
}
case "EMAIL":
if user.Email != nil && *user.Email != "" {
return *user.Email
}
}
}
}
return ""
}
// resolveBarkServerURL 解析 Bark 服务端地址
// backend 用户优先用自己配置的 server URL,否则用平台默认配置
func resolveBarkServerURL(data ReminderData) string {
if data.Tid > 0 {
cfg := models.GetBackendUserNotifyConfig(uint64(data.Tid), data.ReceiverUserID)
if cfg != nil && cfg.BarkServerURL != nil && *cfg.BarkServerURL != "" {
return *cfg.BarkServerURL
}
}
return models.GetPlatformSettingValue("bark_server_url", "https://api.day.app")
}
// SMSSender 短信发送实现
type SMSSender struct{}
func (s *SMSSender) Send(ctx context.Context, reminder ReminderData, title, content string) (bool, error) {
backendURL, apiKey, err := getDefaultSystemSMSConfig()
if err != nil {
return false, err
}
phone := resolveReceiverTarget("SMS", reminder)
if phone == "" {
return false, fmt.Errorf("未配置手机号(请在通知设置中配置,或在创建提醒时指定)")
}
enqueueURL := strings.TrimRight(backendURL, "/") + "/api/v1/business/outbound-tasks"
payload := map[string]interface{}{
"phone": phone,
"content": title + ": " + content,
}
bs, _ := json.Marshal(payload)
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", enqueueURL, bytes.NewReader(bs))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", apiKey)
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("网关返回HTTP状态码: %d, 返回内容: %s", resp.StatusCode, string(bodyBytes))
}
return true, nil
}
// EmailSender 邮件发送实现
type EmailSender struct{}
func (s *EmailSender) Send(ctx context.Context, reminder ReminderData, title, content string) (bool, error) {
emails, err := ListSystemEmails()
if err != nil || len(emails) == 0 {
return false, fmt.Errorf("未配置系统邮箱")
}
emailCfg := emails[0]
if emailCfg.FromAddress == "" || emailCfg.Host == "" {
return false, fmt.Errorf("未配置系统邮箱")
}
toEmail := resolveReceiverTarget("EMAIL", reminder)
if toEmail == "" {
return false, fmt.Errorf("未配置收件邮箱(请在通知设置中配置,或在创建提醒时指定)")
}
sysDomain := models.GetPlatformSettingValue("system_domain", "https://api.yunzer.cn")
ackToken := ""
if reminder.AckToken != nil {
ackToken = *reminder.AckToken
}
// 构造 HTML 邮件
htmlBody := fmt.Sprintf(`
<div style="font-family: Arial, sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 5px; max-width: 600px; margin: 0 auto;">
<h2 style="color: #409EFF; margin-bottom: 20px;">日程提醒:%s</h2>
<p style="font-size: 16px; line-height: 1.6; color: #333;">%s</p>
<hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
`, title, content)
if ackToken != "" {
ackURL := fmt.Sprintf("%s/api/schedule/reminder/ack?token=%s", strings.TrimRight(sysDomain, "/"), ackToken)
htmlBody += fmt.Sprintf(`
<div style="text-align: center; margin-top: 30px;">
<a href="%s" target="_blank" style="background-color: #409EFF; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold; display: inline-block;">
收到,确认此提醒
</a>
</div>
<p style="font-size: 12px; color: #999; text-align: center; margin-top: 15px;">确认收到后,系统将不再向您发送该日程的重复提醒。</p>
`, ackURL)
}
htmlBody += "</div>"
cfg := SMTPConfig{
FromAddress: emailCfg.FromAddress,
Host: emailCfg.Host,
Port: emailCfg.Port,
Password: emailCfg.Password,
Encryption: emailCfg.Encryption,
Timeout: emailCfg.Timeout,
}
if emailCfg.FromName != nil {
cfg.FromName = *emailCfg.FromName
}
err = SendHTMLEmailSMTP(cfg, toEmail, title, htmlBody)
if err != nil {
return false, err
}
return true, nil
}
// BarkSender Bark 推送实现
type BarkSender struct{}
func (s *BarkSender) Send(ctx context.Context, reminder ReminderData, title, content string) (bool, error) {
deviceKey := resolveReceiverTarget("BARK", reminder)
if deviceKey == "" {
return false, fmt.Errorf("Bark 设备 Key 未配置(请在通知设置中配置,或在创建提醒时指定)")
}
serverURL := resolveBarkServerURL(reminder)
sysDomain := models.GetPlatformSettingValue("system_domain", "https://api.yunzer.cn")
ackToken := ""
if reminder.AckToken != nil {
ackToken = *reminder.AckToken
}
baseURL := strings.TrimRight(serverURL, "/")
escapedTitle := url.PathEscape(title)
pushContent := content
if ackToken != "" {
pushContent += "\n确认收到请点击→"
}
escapedContent := url.PathEscape(pushContent)
barkURL := fmt.Sprintf("%s/%s/%s/%s", baseURL, deviceKey, escapedTitle, escapedContent)
if ackToken != "" {
ackURL := fmt.Sprintf("%s/api/schedule/reminder/ack?token=%s", strings.TrimRight(sysDomain, "/"), ackToken)
// Bark 官方推送支持 url 参数
barkURL += "?url=" + url.QueryEscape(ackURL)
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", barkURL, nil)
if err != nil {
return false, err
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return false, fmt.Errorf("Bark返回HTTP状态码: %d, 返回内容: %s", resp.StatusCode, string(bodyBytes))
}
return true, nil
}
// SiteMsgSender 站内信发送实现
type SiteMsgSender struct{}
func (s *SiteMsgSender) Send(ctx context.Context, reminder ReminderData, title, content string) (bool, error) {
now := time.Now()
msg := &models.SystemReminderList{
Title: title,
Content: content,
SenderID: 0,
SenderType: "system",
ReceiverID: reminder.ReceiverUserID,
ReceiverType: "tenant", // backend/租户用户
IsRead: 0,
CreateTime: &now,
TargetType: "tenant",
TargetTenantID: uint64(reminder.Tid),
}
_, err := models.Orm.Insert(msg)
if err != nil {
return false, err
}
return true, nil
}
// generateUUID 生成一个安全的随机 UUID 字符
func generateUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
// StartReminderScheduler 启动定时提醒调度器 (1分钟一次的 Ticker)
func StartReminderScheduler(stopChan chan struct{}) {
ticker := time.NewTicker(1 * time.Minute)
go func() {
for {
select {
case <-ticker.C:
scanAndSendReminders()
case <-stopChan:
ticker.Stop()
return
}
}
}()
}
func scanAndSendReminders() {
// 1. 生成唯一扫描批次号用于抢占锁定
scanBatch := generateUUID()
now := time.Now()
// 2. 抢占待处理的数据(乐观锁防并发重复发送)
_, err := models.Orm.Raw(`
UPDATE yz_backend_schedule_reminder
SET scan_lock = ?, update_time = NOW()
WHERE next_remind_time <= ?
AND remind_status IN (0, 1)
AND is_deleted = 0
AND (scan_lock = '' OR scan_lock IS NULL)
`, scanBatch, now).Exec()
if err != nil {
return
}
// 3. 查询自己锁定成功的数据
var list []models.BackendScheduleReminder
_, err = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
Filter("scan_lock", scanBatch).
Filter("remind_status__in", 0, 1).
Filter("is_deleted", 0).
All(&list)
if err != nil || len(list) == 0 {
return
}
// 实例分发发送
senders := map[string]ReminderSender{
"SMS": &SMSSender{},
"EMAIL": &EmailSender{},
"BARK": &BarkSender{},
"SITE_MSG": &SiteMsgSender{},
}
for i := range list {
reminder := &list[i]
// 3.1 获取日程信息(主要拿 Content,Title 统一为 "日程提醒")
var schedule models.BackendSchedule
err := models.Orm.QueryTable(new(models.BackendSchedule)).
Filter("id", reminder.ScheduleID).
One(&schedule)
title := "日程提醒"
content := "您有一个待处理的日程时间已到,请注意查收。"
if err == nil {
content = schedule.Content
}
sender, ok := senders[reminder.RemindChannel]
if !ok {
// 未知渠道,直接强制置为结束
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
Filter("id", reminder.ID).
Update(map[string]interface{}{
"remind_status": 2,
"scan_lock": "",
"update_time": time.Now(),
})
continue
}
// 执行发送
ctx := context.Background()
success, sendErr := sender.Send(ctx, ReminderData{
RemindChannel: reminder.RemindChannel,
ReceiverUserID: reminder.ReceiverUserID,
ReceiverTarget: reminder.ReceiverTarget,
AckToken: reminder.AckToken,
Tid: reminder.Tid,
}, title, content)
// 3.2 记录发送流水日志
sendResult := int8(0)
var failReason *string
if success {
sendResult = 1
} else if sendErr != nil {
errStr := sendErr.Error()
if len(errStr) > 255 {
errStr = errStr[:255]
}
failReason = &errStr
}
logRow := &models.BackendScheduleReminderSendLog{
Tid: reminder.Tid,
ReminderID: reminder.ID,
SendTime: time.Now(),
SendResult: sendResult,
FailReason: failReason,
}
_, _ = models.Orm.Insert(logRow)
// 3.3 根据发送渠道分类更新提醒状态和下一次发送时间
newSendCount := reminder.SendCount + 1
newStatus := reminder.RemindStatus
if reminder.RemindChannel == "SMS" || reminder.RemindChannel == "SITE_MSG" {
// 一次性发送:发送后直接置为结束
newStatus = 2
} else {
// 重复发送渠道 EMAIL / BARK
// 如果还没被 Ack,且没有达到 max_send_count,继续提醒
if reminder.AckStatus == 0 && newSendCount < reminder.MaxSendCount {
newStatus = 1 // 提醒中
// 更新下次发送时间
reminder.NextRemindTime = time.Now().Add(time.Duration(reminder.RepeatIntervalMinutes) * time.Minute)
} else {
// 达到最大上限或者已 Ack
newStatus = 2
}
}
// 3.4 回写主表记录
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
Filter("id", reminder.ID).
Update(map[string]interface{}{
"SendCount": newSendCount,
"NextRemindTime": reminder.NextRemindTime,
"RemindStatus": newStatus,
"ScanLock": "", // 释放扫描锁
"UpdateTime": time.Now(),
})
}
}