From bd30b687663e507c81fcd27750279a19d6009045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Thu, 18 Jun 2026 00:55:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=97=A5=E7=A8=8B=E6=8F=90?= =?UTF-8?q?=E9=86=92=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go/controllers/api_reminder.go | 109 +++ go/controllers/platform_reminder.go | 631 ++++++++++++++++++ go/main.go | 4 + go/models/init.go | 4 + go/models/platform_schedule_reminder.go | 55 ++ go/routers/api/api.go | 3 + go/routers/platform/platform.go | 7 + go/services/reminder_scheduler.go | 371 ++++++++++ go/services/system_email_smtp.go | 94 +++ platform/components.d.ts | 1 + platform/src/App.vue | 5 +- platform/src/api/reminder.js | 19 +- .../components/notificationSettings.vue | 2 +- .../tools/reminder/components/detail.vue | 116 ++-- .../views/tools/reminder/components/edit.vue | 285 ++++---- platform/src/views/tools/reminder/index.vue | 212 +++--- 16 files changed, 1621 insertions(+), 297 deletions(-) create mode 100644 go/controllers/api_reminder.go create mode 100644 go/controllers/platform_reminder.go create mode 100644 go/models/platform_schedule_reminder.go create mode 100644 go/services/reminder_scheduler.go diff --git a/go/controllers/api_reminder.go b/go/controllers/api_reminder.go new file mode 100644 index 0000000..3230026 --- /dev/null +++ b/go/controllers/api_reminder.go @@ -0,0 +1,109 @@ +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(` + + + + + 确认收到提醒 + + + +
+

提示

+

该日程提醒在此之前已确认过了。

+

无需重复点击,感谢您的使用!

+
+ + + `)) + 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(` + + + + + 确认成功 + + + +
+

确认成功

+

您已成功确认收到该日程提醒!

+

系统已停止向您重复推送,感谢您的配合。

+
+ + + `)) +} diff --git a/go/controllers/platform_reminder.go b/go/controllers/platform_reminder.go new file mode 100644 index 0000000..97ed833 --- /dev/null +++ b/go/controllers/platform_reminder.go @@ -0,0 +1,631 @@ +package controllers + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +type PlatformReminderController struct { + beego.Controller +} + +func (c *PlatformReminderController) platformClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "platform" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *PlatformReminderController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *PlatformReminderController) ok(data interface{}) { + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data} + _ = c.ServeJSON() +} + +// generateToken 生成一个随机的 ack_token +func generateToken() 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:]) +} + +type reminderFormPayload struct { + Title string `json:"title"` + Content string `json:"content"` + ScheduleTime string `json:"schedule_time"` + RemindChannels []string `json:"remind_channels"` // EMAIL, BARK, SMS, SITE_MSG + AdvanceMinutes int `json:"advance_minutes"` + RepeatIntervalMinutes int `json:"repeat_interval_minutes"` + MaxSendCount int `json:"max_send_count"` + ReceiverUserID uint64 `json:"receiver_user_id"` + ReceiverTargets map[string]string `json:"receiver_targets"` // "SMS": "1380...", "EMAIL": "...", "BARK": "..." +} + +// GetReminderList GET /platform/reminder/list +func (c *PlatformReminderController) GetReminderList() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 20) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + + // 联表获取日程及提醒信息 + var schedules []models.PlatformSchedule + qs := models.Orm.QueryTable(new(models.PlatformSchedule)) + total, _ := qs.Count() + + _, err := qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&schedules) + if err != nil { + c.jsonErr(500, 500, "查询失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(schedules)) + for _, s := range schedules { + // 查询该日程关联的所有提醒记录 + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", s.ID). + Filter("is_deleted", 0). + All(&reminders) + + channels := make([]string, 0, len(reminders)) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + channels = append(channels, r.RemindChannel) + if r.RemindStatus != 2 { + isFinished = false + } + } + } + + item := map[string]interface{}{ + "id": s.ID, + "title": s.Title, + "content": s.Content, + "schedule_time": s.ScheduleTime.Format("2006-01-02 15:04:05"), + "remind_channels": channels, + "user_id": s.UserID, + "is_finished": isFinished, + } + if len(reminders) > 0 { + first := reminders[0] + item["advance_minutes"] = first.AdvanceMinutes + item["repeat_interval_minutes"] = first.RepeatIntervalMinutes + item["max_send_count"] = first.MaxSendCount + item["receiver_user_id"] = first.ReceiverUserID + } + list = append(list, item) + } + + c.ok(map[string]interface{}{ + "list": list, + "total": total, + "page": page, + "pageSize": pageSize, + }) +} + +// GetReminderDetail GET /platform/reminder/:id +func (c *PlatformReminderController) GetReminderDetail() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.jsonErr(400, 400, "无效的ID") + return + } + + var schedule models.PlatformSchedule + err := models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).One(&schedule) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", schedule.ID). + Filter("is_deleted", 0). + All(&reminders) + + channels := make([]string, 0, len(reminders)) + targets := make(map[string]string) + var first models.PlatformScheduleReminder + + for _, r := range reminders { + channels = append(channels, r.RemindChannel) + if r.ReceiverTarget != nil { + targets[r.RemindChannel] = *r.ReceiverTarget + } + first = r + } + + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + + 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, + "receiver_targets": targets, + "is_finished": isFinished, + } + if first.ID > 0 { + data["advance_minutes"] = first.AdvanceMinutes + data["repeat_interval_minutes"] = first.RepeatIntervalMinutes + data["max_send_count"] = first.MaxSendCount + data["receiver_user_id"] = first.ReceiverUserID + } + + c.ok(data) +} + +// CreateReminder POST /platform/reminder +func (c *PlatformReminderController) CreateReminder() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p reminderFormPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + if strings.TrimSpace(p.ScheduleTime) == "" { + c.jsonErr(400, 400, "日程发生时间不能为空") + return + } + + schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local) + if err != nil { + c.jsonErr(400, 400, "日程时间格式不合法,支持 YYYY-MM-DD HH:mm:ss") + return + } + + // 1. 插入日程主表 + schedule := models.PlatformSchedule{ + Title: "日程提醒", + Content: p.Content, + ScheduleTime: schedTime, + UserID: uint64(claims.UserID), + } + schedID, err := models.Orm.Insert(&schedule) + if err != nil { + c.jsonErr(500, 500, "保存日程失败: "+err.Error()) + return + } + + // 2. 根据选中的渠道循环创建提醒 + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" { + continue + } + + targetVal := p.ReceiverTargets[ch] + var target *string + if targetVal != "" { + target = &targetVal + } + + // 计算首次发送时间 + firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) + + reminder := models.PlatformScheduleReminder{ + 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" { + token := generateToken() + reminder.AckToken = &token + reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes + reminder.MaxSendCount = p.MaxSendCount + if reminder.MaxSendCount <= 0 { + reminder.MaxSendCount = 1 + } + } else { + // SMS 或 SITE_MSG + reminder.RepeatIntervalMinutes = 0 + reminder.MaxSendCount = 1 + } + + _, err = models.Orm.Insert(&reminder) + if err != nil { + c.jsonErr(500, 500, "创建提醒失败: "+err.Error()) + return + } + } + + c.ok(map[string]interface{}{"schedule_id": schedID}) +} + +// UpdateReminder PUT /platform/reminder/:id +func (c *PlatformReminderController) UpdateReminder() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.jsonErr(400, 400, "无效的ID") + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p reminderFormPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + schedTime, err := time.ParseInLocation("2006-01-02 15:04:05", p.ScheduleTime, time.Local) + if err != nil { + c.jsonErr(400, 400, "日程时间格式不合法") + return + } + + // 1. 更新日程详情 + var schedule models.PlatformSchedule + err = models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).One(&schedule) + if err != nil { + c.jsonErr(404, 404, "日程未找到") + return + } + + // 检查是否所有关联的提醒都已结束 + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.jsonErr(400, 400, "该日程提醒已全部结束,无法编辑") + return + } + schedule.Title = "日程提醒" + schedule.Content = p.Content + schedule.ScheduleTime = schedTime + _, err = models.Orm.Update(&schedule, "Title", "Content", "ScheduleTime") + if err != nil { + c.jsonErr(500, 500, "更新失败") + return + } + + // 2. 软删除原本的所有提醒 + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + + // 3. 重新建立提醒 + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + if ch != "SMS" && ch != "EMAIL" && ch != "BARK" && ch != "SITE_MSG" { + continue + } + + targetVal := p.ReceiverTargets[ch] + var target *string + if targetVal != "" { + target = &targetVal + } + + firstSendTime := schedTime.Add(-time.Duration(p.AdvanceMinutes) * time.Minute) + + reminder := models.PlatformScheduleReminder{ + 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" { + token := generateToken() + reminder.AckToken = &token + reminder.RepeatIntervalMinutes = p.RepeatIntervalMinutes + reminder.MaxSendCount = p.MaxSendCount + if reminder.MaxSendCount <= 0 { + reminder.MaxSendCount = 1 + } + } else { + reminder.RepeatIntervalMinutes = 0 + reminder.MaxSendCount = 1 + } + + _, err = models.Orm.Insert(&reminder) + if err != nil { + c.jsonErr(500, 500, "重新创建提醒失败") + return + } + } + + c.ok(nil) +} + +// DeleteReminder DELETE /platform/reminder/:id +func (c *PlatformReminderController) DeleteReminder() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + idStr := c.Ctx.Input.Param(":id") + id, _ := strconv.ParseUint(idStr, 10, 64) + if id == 0 { + c.jsonErr(400, 400, "无效的ID") + return + } + + // 检查是否所有关联的提醒都已结束 + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.jsonErr(400, 400, "该日程提醒已全部结束,无法删除") + return + } + + // 软删除日程 + // 这里的 id 既可以是主表的 id,也可以是日程的 id + // 我们如果是管理页面,都是基于日程维度的,所以这里 id 指代 schedule_id + _, err := models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id", id).Delete() + if err == nil { + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", id). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + } + + c.ok(nil) +} + +type reminderBatchDeletePayload struct { + Ids []uint64 `json:"ids"` +} + +// BatchDeleteReminder POST /platform/reminder/batchDelete +func (c *PlatformReminderController) BatchDeleteReminder() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p reminderBatchDeletePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + if len(p.Ids) == 0 { + c.ok(nil) + return + } + + // 检查选中的日程是否有任何一个是全部结束的,防误操作 + for _, scheduleID := range p.Ids { + var reminders []models.PlatformScheduleReminder + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id", scheduleID). + Filter("is_deleted", 0). + All(&reminders) + isFinished := true + if len(reminders) == 0 { + isFinished = false + } else { + for _, r := range reminders { + if r.RemindStatus != 2 { + isFinished = false + break + } + } + } + if isFinished { + c.jsonErr(400, 400, fmt.Sprintf("选中的日程ID %d 的提醒已全部结束,无法删除", scheduleID)) + return + } + } + + // 批量软删除 + _, _ = models.Orm.QueryTable(new(models.PlatformSchedule)).Filter("id__in", p.Ids).Delete() + _, _ = models.Orm.QueryTable(new(models.PlatformScheduleReminder)). + Filter("schedule_id__in", p.Ids). + Update(map[string]interface{}{ + "IsDeleted": 1, + "UpdateTime": time.Now(), + }) + + c.ok(nil) +} + +type reminderTestPayload struct { + Title string `json:"title"` + Content string `json:"content"` + RemindChannels []string `json:"remind_channels"` +} + +// TestReminder POST /platform/reminder/test +func (c *PlatformReminderController) TestReminder() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p reminderTestPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + if strings.TrimSpace(p.Title) == "" { + p.Title = "测试提醒" + } + if strings.TrimSpace(p.Content) == "" { + p.Content = "这是一条验证日程提醒配置的测试通知。" + } + + senders := map[string]services.ReminderSender{ + "SMS": &services.SMSSender{}, + "EMAIL": &services.EmailSender{}, + "BARK": &services.BarkSender{}, + "SITE_MSG": &services.SiteMsgSender{}, + } + + type TestResult struct { + Channel string `json:"channel"` + Success bool `json:"success"` + Msg string `json:"msg"` + } + results := make([]TestResult, 0) + + for _, ch := range p.RemindChannels { + ch = strings.ToUpper(strings.TrimSpace(ch)) + sender, ok := senders[ch] + if !ok { + results = append(results, TestResult{Channel: ch, Success: false, Msg: "不支持的提醒渠道"}) + continue + } + + dummyToken := "test-token-for-verification" + reminder := &models.PlatformScheduleReminder{ + RemindChannel: ch, + ReceiverUserID: uint64(claims.UserID), + AckToken: &dummyToken, + } + + success, sendErr := sender.Send(context.Background(), reminder, "[测试]"+p.Title, p.Content) + msg := "发送成功" + if !success { + msg = "发送失败" + if sendErr != nil { + msg = sendErr.Error() + } + } + results = append(results, TestResult{Channel: ch, Success: success, Msg: msg}) + } + + c.ok(results) +} diff --git a/go/main.go b/go/main.go index 525a065..6ada88c 100644 --- a/go/main.go +++ b/go/main.go @@ -2,6 +2,7 @@ package main import ( "server/models" + "server/services" _ "server/routers" "server/version" @@ -21,5 +22,8 @@ func main() { // 静态资源:映射 /uploads 到本地 uploads 目录,供前端访问上传文件 beego.SetStaticPath("/uploads", "uploads") + // 启动日程提醒定时任务 + services.StartReminderScheduler(make(chan struct{})) + beego.Run() } diff --git a/go/models/init.go b/go/models/init.go index fa05bbb..c02f7ee 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -69,6 +69,10 @@ func Init(_ string) { new(SystemNormalSetting), new(PlatformNormalSetting), new(BackendNormalSetting), + + new(PlatformSchedule), + new(PlatformScheduleReminder), + new(PlatformScheduleReminderSendLog), ) // 创建全局 Ormer diff --git a/go/models/platform_schedule_reminder.go b/go/models/platform_schedule_reminder.go new file mode 100644 index 0000000..8e96596 --- /dev/null +++ b/go/models/platform_schedule_reminder.go @@ -0,0 +1,55 @@ +package models + +import "time" + +// PlatformSchedule 日程主表: yz_platform_schedule +type PlatformSchedule struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Title string `orm:"column(title);size(255)" json:"title"` + Content string `orm:"column(content);type(text)" json:"content"` + ScheduleTime time.Time `orm:"column(schedule_time);type(datetime)" json:"schedule_time"` + UserID uint64 `orm:"column(user_id)" json:"user_id"` +} + +func (m *PlatformSchedule) TableName() string { + return "yz_platform_schedule" +} + +// PlatformScheduleReminder 日程提醒主表: yz_platform_schedule_reminder +type PlatformScheduleReminder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ScheduleID uint64 `orm:"column(schedule_id)" json:"schedule_id"` + RemindChannel string `orm:"column(remind_channel);size(20)" json:"remind_channel"` // SMS/EMAIL/BARK/SITE_MSG + AdvanceMinutes int `orm:"column(advance_minutes);default(0)" json:"advance_minutes"` + RepeatIntervalMinutes int `orm:"column(repeat_interval_minutes);default(0)" json:"repeat_interval_minutes"` + NextRemindTime time.Time `orm:"column(next_remind_time);type(datetime)" json:"next_remind_time"` + SendCount int `orm:"column(send_count);default(0)" json:"send_count"` + MaxSendCount int `orm:"column(max_send_count);default(1)" json:"max_send_count"` + AckToken *string `orm:"column(ack_token);size(64);null" json:"ack_token"` + AckStatus int8 `orm:"column(ack_status);default(0)" json:"ack_status"` // 0-未确认 1-已确认 + AckTime *time.Time `orm:"column(ack_time);type(datetime);null" json:"ack_time"` + ReceiverUserID uint64 `orm:"column(receiver_user_id)" json:"receiver_user_id"` + ReceiverTarget *string `orm:"column(receiver_target);size(255);null" json:"receiver_target"` + RemindStatus int8 `orm:"column(remind_status);default(0)" json:"remind_status"` // 0-待提醒 1-提醒中 2-已结束 + ScanLock string `orm:"column(scan_lock);size(64);default('')" json:"scan_lock"` + IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"` +} + +func (m *PlatformScheduleReminder) TableName() string { + return "yz_platform_schedule_reminder" +} + +// PlatformScheduleReminderSendLog 提醒实际发送流水: yz_platform_schedule_reminder_send_log +type PlatformScheduleReminderSendLog struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + ReminderID uint64 `orm:"column(reminder_id)" json:"reminder_id"` + SendTime time.Time `orm:"column(send_time);type(datetime)" json:"send_time"` + SendResult int8 `orm:"column(send_result)" json:"send_result"` // 0-失败 1-成功 + FailReason *string `orm:"column(fail_reason);size(255);null" json:"fail_reason"` +} + +func (m *PlatformScheduleReminderSendLog) TableName() string { + return "yz_platform_schedule_reminder_send_log" +} diff --git a/go/routers/api/api.go b/go/routers/api/api.go index 6e8af17..1281df9 100644 --- a/go/routers/api/api.go +++ b/go/routers/api/api.go @@ -30,4 +30,7 @@ func Register() { // 对外提卡接口(无需登录) // GET /api/getcard?type=xianyu&module=cursor&data_type=tk beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard") + + // 日程提醒确认接口(无需登录) + beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder") } diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 61842bc..80fd3b0 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -255,4 +255,11 @@ func Register() { beego.Router("/platform/notebook/create", &controllers.PlatformNotebookController{}, "post:Create") beego.Router("/platform/notebook/update/:id", &controllers.PlatformNotebookController{}, "post:Update") beego.Router("/platform/notebook/delete/:id", &controllers.PlatformNotebookController{}, "delete:Delete") + + // 日程提醒管理 + beego.Router("/platform/reminder/list", &controllers.PlatformReminderController{}, "get:GetReminderList") + beego.Router("/platform/reminder/test", &controllers.PlatformReminderController{}, "post:TestReminder") + beego.Router("/platform/reminder/:id", &controllers.PlatformReminderController{}, "get:GetReminderDetail;put:UpdateReminder;delete:DeleteReminder") + beego.Router("/platform/reminder", &controllers.PlatformReminderController{}, "post:CreateReminder") + beego.Router("/platform/reminder/batchDelete", &controllers.PlatformReminderController{}, "post:BatchDeleteReminder") } diff --git a/go/services/reminder_scheduler.go b/go/services/reminder_scheduler.go new file mode 100644 index 0000000..fc4df65 --- /dev/null +++ b/go/services/reminder_scheduler.go @@ -0,0 +1,371 @@ +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", "http://127.0.0.1:8080") + ackToken := "" + if reminder.AckToken != nil { + ackToken = *reminder.AckToken + } + + // 构造 HTML 邮件 + htmlBody := fmt.Sprintf(` +
+

日程提醒:%s

+

%s

+
+ `, title, content) + + if ackToken != "" { + ackURL := fmt.Sprintf("%s/api/schedule/reminder/ack?token=%s", strings.TrimRight(sysDomain, "/"), ackToken) + htmlBody += fmt.Sprintf(` +
+ + 收到,确认此提醒 + +
+

确认收到后,系统将不再向您发送该日程的重复提醒。

+ `, ackURL) + } + + htmlBody += "
" + + 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", "http://127.0.0.1:8080") + 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(), + }) + } +} diff --git a/go/services/system_email_smtp.go b/go/services/system_email_smtp.go index 9d4e902..4eb3abc 100644 --- a/go/services/system_email_smtp.go +++ b/go/services/system_email_smtp.go @@ -117,6 +117,100 @@ func SendTestEmailSMTP(cfg SMTPConfig, to string) error { return client.Quit() } +// SendHTMLEmailSMTP 发送一封 HTML 格式邮件 +func SendHTMLEmailSMTP(cfg SMTPConfig, to string, subject string, htmlBody string) error { + to = strings.TrimSpace(to) + if to == "" { + return fmt.Errorf("收件人不能为空") + } + if cfg.Host == "" || cfg.FromAddress == "" { + return fmt.Errorf("SMTP 主机或发件人不能为空") + } + if cfg.Port == 0 { + cfg.Port = 465 + } + timeout := cfg.Timeout + if timeout == 0 { + timeout = 30 + } + d := net.Dialer{Timeout: time.Duration(timeout) * time.Second} + addr := net.JoinHostPort(cfg.Host, strconv.FormatUint(uint64(cfg.Port), 10)) + enc := strings.ToLower(strings.TrimSpace(cfg.Encryption)) + if enc == "" { + enc = "ssl" + } + + var client *smtp.Client + var err error + + switch enc { + case "ssl": + conn, derr := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}) + if derr != nil { + return fmt.Errorf("连接 SMTP 失败: %w", derr) + } + defer conn.Close() + client, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("SMTP 握手失败: %w", err) + } + case "tls": + conn, derr := d.Dial("tcp", addr) + if derr != nil { + return fmt.Errorf("连接 SMTP 失败: %w", derr) + } + defer conn.Close() + client, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("SMTP 握手失败: %w", err) + } + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil { + _ = client.Close() + return fmt.Errorf("STARTTLS 失败: %w", err) + } + } + case "none": + conn, derr := d.Dial("tcp", addr) + if derr != nil { + return fmt.Errorf("连接 SMTP 失败: %w", derr) + } + defer conn.Close() + client, err = smtp.NewClient(conn, cfg.Host) + if err != nil { + return fmt.Errorf("SMTP 握手失败: %w", err) + } + default: + return fmt.Errorf("不支持的加密方式: %s", cfg.Encryption) + } + defer func() { _ = client.Close() }() + + auth := smtp.PlainAuth("", cfg.FromAddress, cfg.Password, cfg.Host) + if err = client.Auth(auth); err != nil { + return fmt.Errorf("SMTP 认证失败: %w", err) + } + if err = client.Mail(cfg.FromAddress); err != nil { + return fmt.Errorf("MAIL FROM 失败: %w", err) + } + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("RCPT TO 失败: %w", err) + } + wc, err := client.Data() + if err != nil { + return fmt.Errorf("DATA 失败: %w", err) + } + fromName := strings.TrimSpace(cfg.FromName) + headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n", + formatFromHeader(fromName, cfg.FromAddress), to, subject) + if _, err = wc.Write([]byte(headers + htmlBody)); err != nil { + return fmt.Errorf("写入邮件内容失败: %w", err) + } + if err = wc.Close(); err != nil { + return fmt.Errorf("结束 DATA 失败: %w", err) + } + return client.Quit() +} + func formatFromHeader(name, addr string) string { name = strings.TrimSpace(name) if name == "" { diff --git a/platform/components.d.ts b/platform/components.d.ts index 7ffac2a..2e1dc36 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -25,6 +25,7 @@ declare module 'vue' { ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCol: typeof import('element-plus/es')['ElCol'] ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] + ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] diff --git a/platform/src/App.vue b/platform/src/App.vue index bf9547c..8e6b25f 100644 --- a/platform/src/App.vue +++ b/platform/src/App.vue @@ -1,8 +1,11 @@ diff --git a/platform/src/views/tools/reminder/components/edit.vue b/platform/src/views/tools/reminder/components/edit.vue index 4569f29..d6f211b 100644 --- a/platform/src/views/tools/reminder/components/edit.vue +++ b/platform/src/views/tools/reminder/components/edit.vue @@ -1,8 +1,8 @@ diff --git a/platform/src/views/tools/reminder/index.vue b/platform/src/views/tools/reminder/index.vue index 076f9fd..9952027 100644 --- a/platform/src/views/tools/reminder/index.vue +++ b/platform/src/views/tools/reminder/index.vue @@ -1,7 +1,7 @@