Files
yunzerwebsiteallinone/go/services/reminder_scheduler.go
T
2026-11-09 11:42:23 +08:00

535 lines
16 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"
"server/services/wechatmini"
"server/services/wechatmp"
"github.com/beego/beego/v2/core/logs"
)
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
}
// 同时推送到小程序订阅消息(用户授权过才有一次发送机会,失败不影响站内信)
receiverID := reminder.ReceiverUserID
go func(uid uint64, t, c string) {
res := wechatmini.PushTenantNotice(uid, t, c)
if res.Sent > 0 {
logs.Info("[wechat-mini] 日程提醒已推送订阅消息: user=%d sent=%d", uid, res.Sent)
}
for _, e := range res.Errors {
logs.Info("[wechat-mini] 订阅消息推送失败: user=%d err=%v", uid, e)
}
}(receiverID, title, content)
return true, nil
}
// WechatMpSender 微信服务号模板消息发送实现
// 要求:服务号已认证且在平台设置中启用;用户已在服务号绑定账号并关注。
type WechatMpSender struct{}
func (s *WechatMpSender) Send(ctx context.Context, reminder ReminderData, title, content string) (bool, error) {
cfg, err := wechatmp.LoadEnabledConfig()
if err != nil {
return false, fmt.Errorf("服务号未配置或未启用: %w", err)
}
// 接收目标优先级:手动指定的 openid → 服务号粉丝绑定表反查
openid := ""
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
openid = *reminder.ReceiverTarget
}
if openid == "" && reminder.Tid > 0 {
if f, ferr := wechatmp.GetFollowerByUser("tenant_user", reminder.ReceiverUserID); ferr == nil && f != nil {
if f.Subscribe != 1 {
return false, fmt.Errorf("用户已取消关注服务号,无法推送模板消息")
}
openid = f.OpenID
}
}
if openid == "" {
return false, fmt.Errorf("用户未绑定服务号,无法推送(请在服务号内完成账号绑定)")
}
perr := wechatmp.PushToOpenID(cfg, openid, wechatmp.PushMessage{
Title: title,
Content: content,
Remark: "来自云泽管理平台日程提醒",
})
if perr != nil {
return false, perr
}
return true, nil
}
// WechatMiniSender 微信小程序订阅消息发送实现
// 要求:用户在小程序内授权过订阅消息(一次性授权,发一条少一条)。
type WechatMiniSender struct{}
func (s *WechatMiniSender) Send(ctx context.Context, reminder ReminderData, title, content string) (bool, error) {
if reminder.ReceiverUserID == 0 {
return false, fmt.Errorf("无效的接收用户")
}
res := wechatmini.PushTenantNotice(reminder.ReceiverUserID, title, content)
if res.Sent > 0 {
return true, nil
}
if len(res.Errors) > 0 {
return false, res.Errors[0]
}
return false, fmt.Errorf("用户未授权小程序订阅消息(授权一次仅可发送一条)")
}
// 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{},
"WECHAT_MP": &WechatMpSender{},
"WECHAT_MINI": &WechatMiniSender{},
}
for i := range list {
reminder := &list[i]
// 3.1 获取日程信息;消息内容统一拼接为【标题】内容,内容为空时用兜底文案,保证永远有提示内容
var schedule models.BackendSchedule
err := models.Orm.QueryTable(new(models.BackendSchedule)).
Filter("id", reminder.ScheduleID).
One(&schedule)
title := "日程提醒"
body := ""
if err == nil {
if t := strings.TrimSpace(schedule.Title); t != "" {
title = t
}
body = strings.TrimSpace(schedule.Content)
}
if body == "" {
body = "您有一个待处理的日程时间已到,请注意查收。"
}
content := fmt.Sprintf("【%s】%s", title, body)
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" ||
reminder.RemindChannel == "WECHAT_MP" || reminder.RemindChannel == "WECHAT_MINI" {
// 一次性发送:发送后直接置为结束
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(),
})
}
}