修复日程提醒功能
This commit is contained in:
+168
-109
@@ -1,109 +1,168 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
type ApiReminderController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// AckReminder GET /api/schedule/reminder/ack
|
||||
// 邮件/Bark 客户端访问此接口进行提醒确认
|
||||
func (c *ApiReminderController) AckReminder() {
|
||||
token := c.GetString("token")
|
||||
if token == "" {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
_ = c.Ctx.Output.Body([]byte("Invalid request: missing token"))
|
||||
return
|
||||
}
|
||||
|
||||
var reminder models.PlatformScheduleReminder
|
||||
err := models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
Filter("ack_token", token).
|
||||
Filter("is_deleted", 0).
|
||||
One(&reminder)
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(404)
|
||||
_ = c.Ctx.Output.Body([]byte("Error: reminder task not found or token has expired"))
|
||||
return
|
||||
}
|
||||
|
||||
if reminder.AckStatus == 1 {
|
||||
// 已经确认过了,直接显示已确认成功的 HTML
|
||||
c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
|
||||
_ = c.Ctx.Output.Body([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>确认收到提醒</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f5f7fa; color: #303133; }
|
||||
.card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); display: inline-block; max-width: 400px; }
|
||||
h2 { color: #67C23A; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>提示</h2>
|
||||
<p>该日程提醒在此之前已确认过了。</p>
|
||||
<p style="color: #909399; font-size: 14px;">无需重复点击,感谢您的使用!</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新确认状态为已确认,置 remind_status 为已结束(2)
|
||||
now := time.Now()
|
||||
reminder.AckStatus = 1
|
||||
reminder.AckTime = &now
|
||||
reminder.RemindStatus = 2
|
||||
reminder.UpdateTime = now
|
||||
|
||||
_, err = models.Orm.Update(&reminder, "AckStatus", "AckTime", "RemindStatus", "UpdateTime")
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(500)
|
||||
_ = c.Ctx.Output.Body([]byte("Database error, please try again later"))
|
||||
return
|
||||
}
|
||||
|
||||
// 统一关闭该日程下的所有其他待提醒/提醒中渠道,防止重复打扰
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
Filter("ScheduleID", reminder.ScheduleID).
|
||||
Filter("RemindStatus__in", 0, 1).
|
||||
Update(map[string]interface{}{
|
||||
"RemindStatus": int8(2),
|
||||
"UpdateTime": now,
|
||||
})
|
||||
|
||||
// 成功确认
|
||||
c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
|
||||
_ = c.Ctx.Output.Body([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>确认成功</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f5f7fa; color: #303133; }
|
||||
.card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); display: inline-block; max-width: 400px; }
|
||||
h2 { color: #67C23A; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>确认成功</h2>
|
||||
<p>您已成功确认收到该日程提醒!</p>
|
||||
<p style="color: #909399; font-size: 14px;">系统已停止向您重复推送,感谢您的配合。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
type ApiReminderController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// AckReminder GET /api/schedule/reminder/ack
|
||||
// 邮件/Bark 客户端访问此接口进行提醒确认。
|
||||
// backend 与 platform 使用各自的提醒表,这里按 token 自动识别来源。
|
||||
func (c *ApiReminderController) AckReminder() {
|
||||
token := c.GetString("token")
|
||||
if token == "" {
|
||||
c.Ctx.Output.SetStatus(400)
|
||||
_ = c.Ctx.Output.Body([]byte("Invalid request: missing token"))
|
||||
return
|
||||
}
|
||||
|
||||
type reminderState struct {
|
||||
AckStatus int8
|
||||
ScheduleID uint64
|
||||
RemindStatus int8
|
||||
}
|
||||
|
||||
var state reminderState
|
||||
table := ""
|
||||
|
||||
var backendReminder models.BackendScheduleReminder
|
||||
if err := models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("ack_token", token).
|
||||
Filter("is_deleted", 0).
|
||||
One(&backendReminder); err == nil {
|
||||
state = reminderState{
|
||||
AckStatus: backendReminder.AckStatus,
|
||||
ScheduleID: backendReminder.ScheduleID,
|
||||
RemindStatus: backendReminder.RemindStatus,
|
||||
}
|
||||
table = "backend"
|
||||
} else {
|
||||
var platformReminder models.PlatformScheduleReminder
|
||||
if err := models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
Filter("ack_token", token).
|
||||
Filter("is_deleted", 0).
|
||||
One(&platformReminder); err != nil {
|
||||
c.Ctx.Output.SetStatus(404)
|
||||
_ = c.Ctx.Output.Body([]byte("Error: reminder task not found or token has expired"))
|
||||
return
|
||||
}
|
||||
state = reminderState{
|
||||
AckStatus: platformReminder.AckStatus,
|
||||
ScheduleID: platformReminder.ScheduleID,
|
||||
RemindStatus: platformReminder.RemindStatus,
|
||||
}
|
||||
table = "platform"
|
||||
}
|
||||
|
||||
if state.AckStatus == 1 {
|
||||
writeReminderAckAlreadyConfirmed(c)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var err error
|
||||
if table == "backend" {
|
||||
_, err = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("ack_token", token).
|
||||
Filter("is_deleted", 0).
|
||||
Update(map[string]interface{}{
|
||||
"AckStatus": int8(1),
|
||||
"AckTime": now,
|
||||
"RemindStatus": int8(2),
|
||||
"UpdateTime": now,
|
||||
})
|
||||
} else {
|
||||
_, err = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
Filter("ack_token", token).
|
||||
Filter("is_deleted", 0).
|
||||
Update(map[string]interface{}{
|
||||
"AckStatus": int8(1),
|
||||
"AckTime": now,
|
||||
"RemindStatus": int8(2),
|
||||
"UpdateTime": now,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
c.Ctx.Output.SetStatus(500)
|
||||
_ = c.Ctx.Output.Body([]byte("Database error, please try again later"))
|
||||
return
|
||||
}
|
||||
|
||||
// 只关闭命中来源表中、同一日程的其他待提醒记录,避免两套表互相影响。
|
||||
if table == "backend" {
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", state.ScheduleID).
|
||||
Filter("remind_status__in", 0, 1).
|
||||
Update(map[string]interface{}{
|
||||
"RemindStatus": int8(2),
|
||||
"UpdateTime": now,
|
||||
})
|
||||
} else {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
Filter("schedule_id", state.ScheduleID).
|
||||
Filter("remind_status__in", 0, 1).
|
||||
Update(map[string]interface{}{
|
||||
"RemindStatus": int8(2),
|
||||
"UpdateTime": now,
|
||||
})
|
||||
}
|
||||
|
||||
writeReminderAckSuccess(c)
|
||||
}
|
||||
|
||||
func writeReminderAckAlreadyConfirmed(c *ApiReminderController) {
|
||||
c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
|
||||
_ = c.Ctx.Output.Body([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>确认收到提醒</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f5f7fa; color: #303133; }
|
||||
.card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); display: inline-block; max-width: 400px; }
|
||||
h2 { color: #67C23A; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>提示</h2>
|
||||
<p>该日程提醒在此之前已确认过了。</p>
|
||||
<p style="color: #909399; font-size: 14px;">无需重复点击,感谢您的使用!</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}
|
||||
|
||||
func writeReminderAckSuccess(c *ApiReminderController) {
|
||||
c.Ctx.Output.Header("Content-Type", "text/html; charset=utf-8")
|
||||
_ = c.Ctx.Output.Body([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>确认成功</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f5f7fa; color: #303133; }
|
||||
.card { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1); display: inline-block; max-width: 400px; }
|
||||
h2 { color: #67C23A; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>确认成功</h2>
|
||||
<p>您已成功确认收到该日程提醒!</p>
|
||||
<p style="color: #909399; font-size: 14px;">系统已停止向您重复推送,感谢您的配合。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
@@ -23,18 +24,18 @@ type AppReminderController struct {
|
||||
func (c *AppReminderController) appClaims() (*jwtutil.Claims, error) {
|
||||
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
return nil, fmt.Errorf("未登录")
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
parts := strings.SplitN(auth, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
return nil, fmt.Errorf("认证信息格式错误")
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
claims, err := jwtutil.ParseToken(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的token")
|
||||
return nil, err
|
||||
}
|
||||
if claims.UserType != "backend" && claims.UserType != "app" && claims.UserType != "platform" {
|
||||
return nil, fmt.Errorf("无权访问")
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -71,7 +72,7 @@ type appSchedulePayload struct {
|
||||
func (c *AppReminderController) GetList() {
|
||||
claims, err := c.appClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
c.jsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,8 +88,9 @@ func (c *AppReminderController) GetList() {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.PlatformSchedule)).
|
||||
Filter("user_id", claims.UserID)
|
||||
qs := models.Orm.QueryTable(new(models.BackendSchedule)).
|
||||
Filter("user_id", claims.UserID).
|
||||
Filter("tid", claims.TenantId)
|
||||
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("content__contains", keyword)
|
||||
@@ -96,17 +98,20 @@ func (c *AppReminderController) GetList() {
|
||||
|
||||
total, _ := qs.Count()
|
||||
|
||||
var schedules []models.PlatformSchedule
|
||||
var schedules []models.BackendSchedule
|
||||
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&schedules)
|
||||
if err != nil {
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.jsonErr(500, 500, "查询失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if schedules == nil {
|
||||
schedules = []models.BackendSchedule{}
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(schedules))
|
||||
for _, s := range schedules {
|
||||
var reminders []models.PlatformScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
var reminders []models.BackendScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", s.ID).
|
||||
Filter("is_deleted", 0).
|
||||
All(&reminders)
|
||||
@@ -166,7 +171,7 @@ func (c *AppReminderController) GetList() {
|
||||
func (c *AppReminderController) GetDetail() {
|
||||
claims, err := c.appClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
c.jsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -177,24 +182,25 @@ func (c *AppReminderController) GetDetail() {
|
||||
return
|
||||
}
|
||||
|
||||
var schedule models.PlatformSchedule
|
||||
err = models.Orm.QueryTable(new(models.PlatformSchedule)).
|
||||
var schedule models.BackendSchedule
|
||||
err = models.Orm.QueryTable(new(models.BackendSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("user_id", claims.UserID).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&schedule)
|
||||
if err != nil {
|
||||
c.jsonErr(404, 404, "日程未找到")
|
||||
return
|
||||
}
|
||||
|
||||
var reminders []models.PlatformScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
var reminders []models.BackendScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", schedule.ID).
|
||||
Filter("is_deleted", 0).
|
||||
All(&reminders)
|
||||
|
||||
channels := make([]string, 0, len(reminders))
|
||||
var first models.PlatformScheduleReminder
|
||||
var first models.BackendScheduleReminder
|
||||
for _, r := range reminders {
|
||||
channels = append(channels, r.RemindChannel)
|
||||
first = r
|
||||
@@ -236,7 +242,7 @@ func (c *AppReminderController) GetDetail() {
|
||||
func (c *AppReminderController) Create() {
|
||||
claims, err := c.appClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
c.jsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -275,7 +281,8 @@ func (c *AppReminderController) Create() {
|
||||
title = "日程提醒"
|
||||
}
|
||||
|
||||
schedule := models.PlatformSchedule{
|
||||
schedule := models.BackendSchedule{
|
||||
Tid: claims.TenantId,
|
||||
Title: title,
|
||||
Content: content,
|
||||
ScheduleTime: schedTime,
|
||||
@@ -298,7 +305,8 @@ func (c *AppReminderController) Create() {
|
||||
|
||||
firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute)
|
||||
|
||||
reminder := models.PlatformScheduleReminder{
|
||||
reminder := models.BackendScheduleReminder{
|
||||
Tid: claims.TenantId,
|
||||
ScheduleID: uint64(schedID),
|
||||
RemindChannel: ch,
|
||||
AdvanceMinutes: p.AdvanceMinutes,
|
||||
@@ -336,7 +344,7 @@ func (c *AppReminderController) Create() {
|
||||
func (c *AppReminderController) Update() {
|
||||
claims, err := c.appClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
c.jsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -364,18 +372,19 @@ func (c *AppReminderController) Update() {
|
||||
return
|
||||
}
|
||||
|
||||
var schedule models.PlatformSchedule
|
||||
err = models.Orm.QueryTable(new(models.PlatformSchedule)).
|
||||
var schedule models.BackendSchedule
|
||||
err = models.Orm.QueryTable(new(models.BackendSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("user_id", claims.UserID).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&schedule)
|
||||
if err != nil {
|
||||
c.jsonErr(404, 404, "日程未找到")
|
||||
return
|
||||
}
|
||||
|
||||
var reminders []models.PlatformScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
var reminders []models.BackendScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", id).
|
||||
Filter("is_deleted", 0).
|
||||
All(&reminders)
|
||||
@@ -414,7 +423,7 @@ func (c *AppReminderController) Update() {
|
||||
}
|
||||
|
||||
// 软删除旧提醒,重建
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", id).
|
||||
Update(map[string]interface{}{
|
||||
"IsDeleted": 1,
|
||||
@@ -432,7 +441,8 @@ func (c *AppReminderController) Update() {
|
||||
|
||||
firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute)
|
||||
|
||||
reminder := models.PlatformScheduleReminder{
|
||||
reminder := models.BackendScheduleReminder{
|
||||
Tid: claims.TenantId,
|
||||
ScheduleID: id,
|
||||
RemindChannel: ch,
|
||||
AdvanceMinutes: p.AdvanceMinutes,
|
||||
@@ -470,7 +480,7 @@ func (c *AppReminderController) Update() {
|
||||
func (c *AppReminderController) Delete() {
|
||||
claims, err := c.appClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
c.jsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -481,18 +491,19 @@ func (c *AppReminderController) Delete() {
|
||||
return
|
||||
}
|
||||
|
||||
var schedule models.PlatformSchedule
|
||||
err = models.Orm.QueryTable(new(models.PlatformSchedule)).
|
||||
var schedule models.BackendSchedule
|
||||
err = models.Orm.QueryTable(new(models.BackendSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("user_id", claims.UserID).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&schedule)
|
||||
if err != nil {
|
||||
c.jsonErr(404, 404, "日程未找到")
|
||||
return
|
||||
}
|
||||
|
||||
var reminders []models.PlatformScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
var reminders []models.BackendScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", id).
|
||||
Filter("is_deleted", 0).
|
||||
All(&reminders)
|
||||
@@ -512,9 +523,9 @@ func (c *AppReminderController) Delete() {
|
||||
return
|
||||
}
|
||||
|
||||
_, err = models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).Delete()
|
||||
_, err = models.Orm.QueryTable(new(models.BackendSchedule)).Filter("id", id).Delete()
|
||||
if err == nil {
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", id).
|
||||
Update(map[string]interface{}{
|
||||
"IsDeleted": 1,
|
||||
@@ -530,7 +541,7 @@ func (c *AppReminderController) Delete() {
|
||||
func (c *AppReminderController) ToggleComplete() {
|
||||
claims, err := c.appClaims()
|
||||
if err != nil {
|
||||
c.jsonErr(401, 401, err.Error())
|
||||
c.jsonErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -541,18 +552,19 @@ func (c *AppReminderController) ToggleComplete() {
|
||||
return
|
||||
}
|
||||
|
||||
var schedule models.PlatformSchedule
|
||||
err = models.Orm.QueryTable(new(models.PlatformSchedule)).
|
||||
var schedule models.BackendSchedule
|
||||
err = models.Orm.QueryTable(new(models.BackendSchedule)).
|
||||
Filter("id", id).
|
||||
Filter("user_id", claims.UserID).
|
||||
Filter("tid", claims.TenantId).
|
||||
One(&schedule)
|
||||
if err != nil {
|
||||
c.jsonErr(404, 404, "日程未找到")
|
||||
return
|
||||
}
|
||||
|
||||
var reminders []models.PlatformScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
var reminders []models.BackendScheduleReminder
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendScheduleReminder)).
|
||||
Filter("schedule_id", id).
|
||||
Filter("is_deleted", 0).
|
||||
All(&reminders)
|
||||
|
||||
@@ -210,13 +210,13 @@ func (c *BackendReminderController) GetReminderDetail() {
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": schedule.ID,
|
||||
"title": schedule.Title,
|
||||
"content": schedule.Content,
|
||||
"schedule_time": schedule.ScheduleTime.Format("2006-01-02 15:04:05"),
|
||||
"remind_channels": channels,
|
||||
"id": schedule.ID,
|
||||
"title": schedule.Title,
|
||||
"content": schedule.Content,
|
||||
"schedule_time": schedule.ScheduleTime.Format("2006-01-02 15:04:05"),
|
||||
"remind_channels": channels,
|
||||
"receiver_targets": targets,
|
||||
"is_finished": isFinished,
|
||||
"is_finished": isFinished,
|
||||
}
|
||||
if first.ID > 0 {
|
||||
data["advance_minutes"] = first.AdvanceMinutes
|
||||
@@ -295,16 +295,16 @@ func (c *BackendReminderController) CreateReminder() {
|
||||
firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute)
|
||||
|
||||
reminder := models.BackendScheduleReminder{
|
||||
Tid: claims.TenantId,
|
||||
ScheduleID: uint64(schedID),
|
||||
RemindChannel: ch,
|
||||
AdvanceMinutes: p.AdvanceMinutes,
|
||||
NextRemindTime: firstSendTime,
|
||||
ReceiverUserID: uint64(claims.UserID),
|
||||
ReceiverTarget: target,
|
||||
RemindStatus: 0,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
Tid: claims.TenantId,
|
||||
ScheduleID: uint64(schedID),
|
||||
RemindChannel: ch,
|
||||
AdvanceMinutes: p.AdvanceMinutes,
|
||||
NextRemindTime: firstSendTime,
|
||||
ReceiverUserID: uint64(claims.UserID),
|
||||
ReceiverTarget: target,
|
||||
RemindStatus: 0,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
|
||||
if ch == "EMAIL" || ch == "BARK" {
|
||||
@@ -433,16 +433,16 @@ func (c *BackendReminderController) UpdateReminder() {
|
||||
firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute)
|
||||
|
||||
reminder := models.BackendScheduleReminder{
|
||||
Tid: claims.TenantId,
|
||||
ScheduleID: id,
|
||||
RemindChannel: ch,
|
||||
AdvanceMinutes: p.AdvanceMinutes,
|
||||
NextRemindTime: firstSendTime,
|
||||
ReceiverUserID: schedule.UserID,
|
||||
ReceiverTarget: target,
|
||||
RemindStatus: 0,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
Tid: claims.TenantId,
|
||||
ScheduleID: id,
|
||||
RemindChannel: ch,
|
||||
AdvanceMinutes: p.AdvanceMinutes,
|
||||
NextRemindTime: firstSendTime,
|
||||
ReceiverUserID: schedule.UserID,
|
||||
ReceiverTarget: target,
|
||||
RemindStatus: 0,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
|
||||
if ch == "EMAIL" || ch == "BARK" {
|
||||
@@ -703,13 +703,20 @@ func (c *BackendReminderController) TestReminder() {
|
||||
}
|
||||
|
||||
dummyToken := "test-token-for-verification"
|
||||
reminder := &models.PlatformScheduleReminder{
|
||||
reminder := &models.BackendScheduleReminder{
|
||||
Tid: claims.TenantId,
|
||||
RemindChannel: ch,
|
||||
ReceiverUserID: uint64(claims.UserID),
|
||||
AckToken: &dummyToken,
|
||||
}
|
||||
|
||||
success, sendErr := sender.Send(context.Background(), reminder, "[测试]"+p.Title, p.Content)
|
||||
success, sendErr := sender.Send(context.Background(), services.ReminderData{
|
||||
RemindChannel: reminder.RemindChannel,
|
||||
ReceiverUserID: reminder.ReceiverUserID,
|
||||
ReceiverTarget: reminder.ReceiverTarget,
|
||||
AckToken: reminder.AckToken,
|
||||
Tid: reminder.Tid,
|
||||
}, "[测试]"+p.Title, p.Content)
|
||||
msg := "发送成功"
|
||||
if !success {
|
||||
msg = "发送失败"
|
||||
|
||||
+636
-631
File diff suppressed because it is too large
Load Diff
+388
-371
@@ -1,371 +1,388 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// ReminderSender 提醒发送接口
|
||||
type ReminderSender interface {
|
||||
Send(ctx context.Context, reminder *models.PlatformScheduleReminder, title, content string) (success bool, err error)
|
||||
}
|
||||
|
||||
// SMSSender 短信发送实现
|
||||
type SMSSender struct{}
|
||||
|
||||
func (s *SMSSender) Send(ctx context.Context, reminder *models.PlatformScheduleReminder, title, content string) (bool, error) {
|
||||
backendURL, apiKey, err := getDefaultSystemSMSConfig()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
phone := ""
|
||||
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
|
||||
phone = *reminder.ReceiverTarget
|
||||
} else {
|
||||
var user models.AdminUser
|
||||
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", reminder.ReceiverUserID).One(&user); err == nil && user.Phone != nil {
|
||||
phone = *user.Phone
|
||||
}
|
||||
}
|
||||
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 *models.PlatformScheduleReminder, 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 := ""
|
||||
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
|
||||
toEmail = *reminder.ReceiverTarget
|
||||
} else {
|
||||
var user models.AdminUser
|
||||
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", reminder.ReceiverUserID).One(&user); err == nil && user.Email != nil {
|
||||
toEmail = *user.Email
|
||||
}
|
||||
}
|
||||
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 *models.PlatformScheduleReminder, title, content string) (bool, error) {
|
||||
deviceKey := ""
|
||||
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
|
||||
deviceKey = *reminder.ReceiverTarget
|
||||
} else {
|
||||
deviceKey = models.GetPlatformSettingValue("bark_device_key", "")
|
||||
}
|
||||
if deviceKey == "" {
|
||||
return false, fmt.Errorf("Bark 设备 Key 未配置")
|
||||
}
|
||||
|
||||
serverURL := models.GetPlatformSettingValue("bark_server_url", "https://api.day.app")
|
||||
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 *models.PlatformScheduleReminder, title, content string) (bool, error) {
|
||||
now := time.Now()
|
||||
msg := &models.SystemReminderList{
|
||||
Title: title,
|
||||
Content: content,
|
||||
SenderID: 0,
|
||||
SenderType: "system",
|
||||
ReceiverID: reminder.ReceiverUserID,
|
||||
ReceiverType: "platform", // 平台端用户
|
||||
IsRead: 0,
|
||||
CreateTime: &now,
|
||||
}
|
||||
_, 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_platform_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.PlatformScheduleReminder
|
||||
_, err = models.Orm.QueryTable(new(models.PlatformScheduleReminder)).
|
||||
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.PlatformSchedule
|
||||
err := models.Orm.QueryTable(new(models.PlatformSchedule)).
|
||||
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.PlatformScheduleReminder)).
|
||||
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, reminder, 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.PlatformScheduleReminderSendLog{
|
||||
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.PlatformScheduleReminder)).
|
||||
Filter("id", reminder.ID).
|
||||
Update(map[string]interface{}{
|
||||
"SendCount": newSendCount,
|
||||
"NextRemindTime": reminder.NextRemindTime,
|
||||
"RemindStatus": newStatus,
|
||||
"ScanLock": "", // 释放扫描锁
|
||||
"UpdateTime": time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 := ""
|
||||
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
|
||||
phone = *reminder.ReceiverTarget
|
||||
} else {
|
||||
var user models.AdminUser
|
||||
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", reminder.ReceiverUserID).One(&user); err == nil && user.Phone != nil {
|
||||
phone = *user.Phone
|
||||
}
|
||||
}
|
||||
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 := ""
|
||||
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
|
||||
toEmail = *reminder.ReceiverTarget
|
||||
} else {
|
||||
var user models.AdminUser
|
||||
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", reminder.ReceiverUserID).One(&user); err == nil && user.Email != nil {
|
||||
toEmail = *user.Email
|
||||
}
|
||||
}
|
||||
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 := ""
|
||||
if reminder.ReceiverTarget != nil && *reminder.ReceiverTarget != "" {
|
||||
deviceKey = *reminder.ReceiverTarget
|
||||
} else {
|
||||
deviceKey = models.GetPlatformSettingValue("bark_device_key", "")
|
||||
}
|
||||
if deviceKey == "" {
|
||||
return false, fmt.Errorf("Bark 设备 Key 未配置")
|
||||
}
|
||||
|
||||
serverURL := models.GetPlatformSettingValue("bark_server_url", "https://api.day.app")
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user