diff --git a/backend/components.d.ts b/backend/components.d.ts index fb754dd..3e73949 100644 --- a/backend/components.d.ts +++ b/backend/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElBacktop: typeof import('element-plus/es')['ElBacktop'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup'] ElCard: typeof import('element-plus/es')['ElCard'] @@ -66,6 +67,7 @@ declare module 'vue' { ElTree: typeof import('element-plus/es')['ElTree'] ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect'] ElUpload: typeof import('element-plus/es')['ElUpload'] + MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] } diff --git a/backend/src/api/sitereminder.js b/backend/src/api/sitereminder.js new file mode 100644 index 0000000..76029e2 --- /dev/null +++ b/backend/src/api/sitereminder.js @@ -0,0 +1,27 @@ +import request from "@/utils/request"; + +/** 获取我的消息列表 */ +export function getMySiteReminders(params) { + return request({ + url: "/backend/sitereminder/myList", + method: "get", + params, + }); +} + +/** 标记消息为已读 */ +export function readSiteReminder(id) { + return request({ + url: "/backend/sitereminder/read", + method: "post", + data: { id }, + }); +} + +/** 一键全部已读 */ +export function readAllSiteReminders() { + return request({ + url: "/backend/sitereminder/readall", + method: "post", + }); +} diff --git a/backend/src/components/CommonHeader.vue b/backend/src/components/CommonHeader.vue index 32983af..798be93 100644 --- a/backend/src/components/CommonHeader.vue +++ b/backend/src/components/CommonHeader.vue @@ -24,18 +24,48 @@ :title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" /> - + - - - - - + + + + + + + @@ -65,6 +95,12 @@
+ +
@@ -74,8 +110,10 @@ import { useRouter, useRoute } from "vue-router"; import { useAllDataStore, useMenuStore, useTabsStore } from "@/stores"; import { useAuthStore } from "@/stores/auth"; import { logout } from "@/api/login"; -import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled } from '@element-plus/icons-vue'; +import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled, Loading, Message } from '@element-plus/icons-vue'; import { ElMessage } from 'element-plus'; +import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder"; +import MessageDetailDialog from "./MessageDetailDialog.vue"; const router = useRouter(); const route = useRoute(); @@ -130,7 +168,86 @@ async function refreshCache() { } } -onMounted(loadMenu); +// 站内信消息中心逻辑 +const unreadCount = ref(0); +const messages = ref([]); +const loadingMessages = ref(false); +const detailVisible = ref(false); +const currentReminder = ref({}); + +const formatTime = (timeStr: any) => { + if (!timeStr) return "-"; + const date = new Date(timeStr); + return date.toLocaleString(); +}; + +const fetchUnreadCount = async () => { + if (!authStore.token) return; + try { + const res = await getMySiteReminders({ page: 1, pageSize: 1, isRead: 0 }); + if (res.code === 200) { + unreadCount.value = res.data.total || 0; + } + } catch (err) { + console.error("fetchUnreadCount failed", err); + } +}; + +const fetchMessages = async () => { + if (!authStore.token) return; + loadingMessages.value = true; + try { + const res = await getMySiteReminders({ page: 1, pageSize: 5 }); + if (res.code === 200) { + messages.value = res.data.list || []; + } + } catch (err) { + console.error("fetchMessages failed", err); + } finally { + loadingMessages.value = false; + } +}; + +const handleDropdownVisibleChange = (visible: boolean) => { + if (visible) { + fetchMessages(); + fetchUnreadCount(); + } +}; + +const handleMessageClick = (item: any) => { + currentReminder.value = item; + detailVisible.value = true; +}; + +const handleReadSuccess = (id: number) => { + const msg = messages.value.find(m => m.id === id); + if (msg && msg.is_read === 0) { + msg.is_read = 1; + unreadCount.value = Math.max(0, unreadCount.value - 1); + } + fetchUnreadCount(); +}; + +const handleMarkAllRead = async () => { + try { + const res = await readAllSiteReminders(); + if (res.code === 200) { + ElMessage.success("全部已读标记成功"); + unreadCount.value = 0; + messages.value.forEach(m => m.is_read = 1); + } + } catch (err) { + console.error(err); + } +}; + +const handleMessagesChanged = () => { + fetchUnreadCount(); + fetchMessages(); +}; + +let timer: any = null; // 根据菜单列表和当前路径计算出的面包屑导航 const breadcrumbs = computed(() => { @@ -300,7 +417,8 @@ const themeIcon = computed(() => isDark.value ? Sunny : Moon); let mediaQuery: MediaQueryList | null = null; let handleChange: ((e: MediaQueryListEvent) => void) | null = null; -onMounted(() => { +onMounted(async () => { + await loadMenu(); initTheme(); // 监听系统主题变化 @@ -313,6 +431,12 @@ onMounted(() => { } }; mediaQuery.addEventListener('change', handleChange); + + if (authStore.token) { + fetchUnreadCount(); + timer = setInterval(fetchUnreadCount, 60000); + window.addEventListener('site-messages-changed', handleMessagesChanged); + } }); // 组件卸载时清理 @@ -320,6 +444,10 @@ onUnmounted(() => { if (mediaQuery && handleChange) { mediaQuery.removeEventListener('change', handleChange); } + if (timer) { + clearInterval(timer); + } + window.removeEventListener('site-messages-changed', handleMessagesChanged); }); @@ -487,4 +615,133 @@ onUnmounted(() => { :deep(.el-button) { margin-left: 0 !important; } + +/* 消息中心下拉列表样式 */ +.message-badge { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.message-dropdown-container { + width: 320px; + background-color: var(--el-bg-color-overlay); + border: 1px solid var(--el-border-color-light); + border-radius: 8px; + box-shadow: var(--el-box-shadow-light); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.message-dropdown-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--el-border-color-lighter); + background-color: var(--el-fill-color-blank); + + .title { + font-size: 14px; + font-weight: 600; + color: var(--el-text-color-primary); + } + + .mark-all-btn { + font-size: 12px; + } +} + +.loading-state, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 0; + color: var(--el-text-color-secondary); + font-size: 13px; + gap: 8px; + + .el-icon { + font-size: 20px; + } +} + +.message-list { + display: flex; + flex-direction: column; +} + +.message-item { + padding: 12px 16px; + cursor: pointer; + transition: background-color 0.2s; + border-bottom: 1px solid var(--el-border-color-extra-light); + text-align: left; /* Ensure it is left-aligned */ + + &:hover { + background-color: var(--el-fill-color-light); + } + + &.unread { + background-color: var(--el-color-primary-light-9); + + &:hover { + background-color: var(--el-color-primary-light-8); + } + + .message-title { + font-weight: 600; + color: var(--el-text-color-primary); + position: relative; + padding-left: 10px; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--el-color-danger); + } + } + } +} + +.message-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 4px; + gap: 8px; + + .message-title { + font-size: 13px; + color: var(--el-text-color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + text-align: left; + } + + .message-time { + font-size: 11px; + color: var(--el-text-color-secondary); + white-space: nowrap; + } +} + +.message-item-brief { + font-size: 12px; + color: var(--el-text-color-regular); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} diff --git a/backend/src/components/MessageDetailDialog.vue b/backend/src/components/MessageDetailDialog.vue new file mode 100644 index 0000000..9cf4878 --- /dev/null +++ b/backend/src/components/MessageDetailDialog.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/docs/改造.md b/docs/改造.md index 8354da5..2068a04 100644 --- a/docs/改造.md +++ b/docs/改造.md @@ -1,10 +1,3 @@ -我完全懂了: -**我只帮你把「你的需求」优化润色成一段标准、清晰、可直接喂给AI的指令**,不替它写代码、不改结构,让AI根据你现有项目自己去改。 - -下面这段你**直接复制发给AI**即可: - ---- - # 【可直接投喂AI·优化版需求说明】 你好,我现在需要对我的项目进行**多租户二级域名绑定官网系统**的整体改造,请根据我的现有项目结构和需求,帮我完成所有代码修改。 diff --git a/go/controllers/api_getcard.go b/go/controllers/api_getcard.go index f0a87a7..2abb825 100644 --- a/go/controllers/api_getcard.go +++ b/go/controllers/api_getcard.go @@ -66,6 +66,15 @@ func (c *ApiGetCardController) GetCard() { return } + // 读取机器码/MAC + machineCode := strings.TrimSpace(c.GetString("machine_code")) + if machineCode == "" { + machineCode = strings.TrimSpace(c.GetString("machineCode")) + } + if machineCode == "" { + machineCode = strings.TrimSpace(c.GetString("mac")) + } + // 参数校验 if platform == "" { c.cardErr(400, 400, "缺少参数 type(来源平台)") @@ -92,7 +101,7 @@ func (c *ApiGetCardController) GetCard() { switch module { case "cursor": - c.extractCursor(platform, dataType, startID, now) + c.extractCursor(platform, dataType, startID, now, machineCode) case "windsurf": c.extractWindsurf(platform, dataType, startID, now) case "krio": @@ -121,8 +130,25 @@ func (c *ApiGetCardController) readOptionalStartID() (uint64, error) { return id, nil } -func (c *ApiGetCardController) extractCursor(platform, dataType string, startID uint64, now time.Time) { - c.extractWithProbe("cursor", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) { +func (c *ApiGetCardController) extractCursor(platform, dataType string, startID uint64, now time.Time, machineCode string) { + // 优先查询该机器码是否已经绑定过未删除的卡密 + if machineCode != "" { + var existing models.PlatformAccountPoolCursor + err := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). + Filter("machine_code", machineCode). + Filter("delete_time__isnull", true). + Exclude("is_used", 0). + OrderBy("-id"). + Limit(1). + One(&existing) + if err == nil { + // 直接返回已绑定的卡密信息 + c.cardOK(buildCardResult(&existing.Account, &existing.Password, existing.Token, existing.DataType)) + return + } + } + + c.extractWithProbe("cursor", platform, dataType, now, machineCode, func() (uint64, *string, *string, string, string, *int8, error) { var row models.PlatformAccountPoolCursor qs := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). Filter("is_extracted", 0). @@ -141,7 +167,7 @@ func (c *ApiGetCardController) extractCursor(platform, dataType string, startID } func (c *ApiGetCardController) extractWindsurf(platform, dataType string, startID uint64, now time.Time) { - c.extractWithProbe("windsurf", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) { + c.extractWithProbe("windsurf", platform, dataType, now, "", func() (uint64, *string, *string, string, string, *int8, error) { var row models.PlatformAccountPoolWindsurf qs := models.Orm.QueryTable(new(models.PlatformAccountPoolWindsurf)). Filter("is_extracted", 0). @@ -160,7 +186,7 @@ func (c *ApiGetCardController) extractWindsurf(platform, dataType string, startI } func (c *ApiGetCardController) extractKrio(platform, dataType string, startID uint64, now time.Time) { - c.extractWithProbe("krio", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) { + c.extractWithProbe("krio", platform, dataType, now, "", func() (uint64, *string, *string, string, string, *int8, error) { var row models.PlatformAccountPoolKiro qs := models.Orm.QueryTable(new(models.PlatformAccountPoolKiro)). Filter("is_extracted", 0). @@ -179,7 +205,7 @@ func (c *ApiGetCardController) extractKrio(platform, dataType string, startID ui } func (c *ApiGetCardController) extractCodex(platform, dataType string, startID uint64, now time.Time) { - c.extractWithProbe("codex", platform, dataType, now, func() (uint64, *string, *string, string, string, *int8, error) { + c.extractWithProbe("codex", platform, dataType, now, "", func() (uint64, *string, *string, string, string, *int8, error) { var row models.PlatformAccountPoolCodex qs := models.Orm.QueryTable(new(models.PlatformAccountPoolCodex)). Filter("is_extracted", 0). @@ -203,6 +229,7 @@ type poolRowFetcher func() (id uint64, account, password *string, token, rowData func (c *ApiGetCardController) extractWithProbe( module, platform, dataType string, now time.Time, + machineCode string, fetch poolRowFetcher, ) { for { @@ -221,14 +248,20 @@ func (c *ApiGetCardController) extractWithProbe( c.cardErr(500, 500, "无效模块") return } + + params := map[string]interface{}{ + "is_extracted": 1, + "extracted_time": now, + "extracted_platform": platform, + "update_time": now, + } + if module == "cursor" && machineCode != "" { + params["machine_code"] = machineCode + } + _, err = models.Orm.QueryTable(tableName). Filter("id", id). - Update(map[string]interface{}{ - "is_extracted": 1, - "extracted_time": now, - "extracted_platform": platform, - "update_time": now, - }) + Update(params) if err != nil { c.cardErr(500, 500, "提取失败") return @@ -240,10 +273,16 @@ func (c *ApiGetCardController) extractWithProbe( c.cardOK(buildCardResult(account, password, token, rowDataType)) return } + if module == "cursor" && machineCode != "" { + _, _ = models.Orm.QueryTable(tableName).Filter("id", id).Update(map[string]interface{}{"machine_code": "", "update_time": time.Now()}) + } continue } if !poolProbeToken(module, rowDataType, token, id) { + if module == "cursor" && machineCode != "" { + _, _ = models.Orm.QueryTable(tableName).Filter("id", id).Update(map[string]interface{}{"machine_code": "", "update_time": time.Now()}) + } continue } 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/backend_login_verify.go b/go/controllers/backend_login_verify.go index 9a8e0a9..ed9684c 100644 --- a/go/controllers/backend_login_verify.go +++ b/go/controllers/backend_login_verify.go @@ -4,7 +4,6 @@ import ( "encoding/json" "io" "strings" - "time" "server/models" "server/pkg/jwtutil" @@ -209,39 +208,17 @@ func (c *BackendLoginVerifyController) SaveLoginVerifyInfos() { } } - now := time.Now() - var existed models.PlatformLoginVerify - err = models.Orm.QueryTable(new(models.PlatformLoginVerify)).OrderBy("-id").One(&existed) - if err == nil { - _, err = models.Orm.QueryTable(new(models.PlatformLoginVerify)). - Filter("id", existed.ID). - Update(map[string]interface{}{ - "open_verify_enabled": openVerifyEnabled, - "verify_type": verifyType, - "geetest3_id": geetest3ID, - "geetest3_key": geetest3Key, - "geetest4_id": geetest4ID, - "geetest4_key": geetest4Key, - "update_time": now, - }) - if err != nil { - c.jsonErr(500, 500, "保存失败") - return - } - } else { - row := &models.PlatformLoginVerify{ - OpenVerifyEnabled: openVerifyEnabled, - VerifyType: verifyType, - Geetest3ID: geetest3ID, - Geetest3Key: geetest3Key, - Geetest4ID: geetest4ID, - Geetest4Key: geetest4Key, - UpdateTime: &now, - } - if _, err := models.Orm.Insert(row); err != nil { - c.jsonErr(500, 500, "保存失败") - return - } + err = models.SavePlatformLoginVerify(&models.PlatformLoginVerify{ + OpenVerifyEnabled: openVerifyEnabled, + VerifyType: verifyType, + Geetest3ID: geetest3ID, + Geetest3Key: geetest3Key, + Geetest4ID: geetest4ID, + Geetest4Key: geetest4Key, + }) + if err != nil { + c.jsonErr(500, 500, "保存失败") + return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} diff --git a/go/controllers/backend_sitereminder.go b/go/controllers/backend_sitereminder.go new file mode 100644 index 0000000..d33ac3f --- /dev/null +++ b/go/controllers/backend_sitereminder.go @@ -0,0 +1,151 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +type BackendSiteReminderController struct { + beego.Controller +} + +func (c *BackendSiteReminderController) backendClaims() (*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 != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +func (c *BackendSiteReminderController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// GetMyList GET /backend/sitereminder/myList +func (c *BackendSiteReminderController) GetMyList() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + var isRead *int8 + if isReadStr := c.GetString("isRead"); isReadStr != "" { + if val, err := strconv.Atoi(isReadStr); err == nil { + v := int8(val) + isRead = &v + } + } + + list, total, err := services.ListReminders(uint64(claims.UserID), "tenant", page, pageSize, isRead) + if err != nil { + c.jsonErr(500, 500, "获取消息列表失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// MarkRead POST /backend/sitereminder/read +func (c *BackendSiteReminderController) MarkRead() { + claims, err := c.backendClaims() + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.MarkReminderRead(p.ID, uint64(claims.UserID), "tenant") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// MarkAllRead POST /backend/sitereminder/readall +func (c *BackendSiteReminderController) MarkAllRead() { + claims, err := c.backendClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + err = services.MarkAllRemindersRead(uint64(claims.UserID), "tenant") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// Delete POST /backend/sitereminder/delete +func (c *BackendSiteReminderController) Delete() { + claims, err := c.backendClaims() + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.DeleteReminder(p.ID, uint64(claims.UserID), "tenant") + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_bark.go b/go/controllers/platform_bark.go new file mode 100644 index 0000000..bd3231b --- /dev/null +++ b/go/controllers/platform_bark.go @@ -0,0 +1,215 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + beego "github.com/beego/beego/v2/server/web" +) + +type PlatformBarkController struct { + beego.Controller +} + +func (c *PlatformBarkController) 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 *PlatformBarkController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// GetBarkInfo GET /platform/bark/info +func (c *PlatformBarkController) GetBarkInfo() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + enabledStr := models.GetPlatformSettingValue("bark_enabled", "0") + serverURL := models.GetPlatformSettingValue("bark_server_url", "https://api.day.app") + deviceKey := models.GetPlatformSettingValue("bark_device_key", "") + + enabled := false + if enabledStr == "1" { + enabled = true + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "enabled": enabled, + "server_url": serverURL, + "device_key": deviceKey, + }, + } + _ = c.ServeJSON() +} + +type barkEditPayload struct { + Enabled bool `json:"enabled"` + ServerUrl string `json:"server_url"` + DeviceKey string `json:"device_key"` +} + +// EditBarkInfo POST /platform/bark/editinfo +func (c *PlatformBarkController) EditBarkInfo() { + 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 barkEditPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + enabledStr := "0" + if p.Enabled { + enabledStr = "1" + } + + serverURL := strings.TrimSpace(p.ServerUrl) + if serverURL == "" { + serverURL = "https://api.day.app" + } + deviceKey := strings.TrimSpace(p.DeviceKey) + + settings := []struct { + code string + name string + value string + remark string + }{ + {"bark_enabled", "Bark推送启用状态", enabledStr, "0为关闭,1为开启"}, + {"bark_server_url", "Bark推送服务器地址", serverURL, ""}, + {"bark_device_key", "Bark设备Key", deviceKey, ""}, + } + + for _, item := range settings { + var setting models.PlatformNormalSetting + err := models.Orm.QueryTable(new(models.PlatformNormalSetting)). + Filter("code", item.code). + Filter("delete_time__isnull", true). + One(&setting) + if err == nil { + setting.Value = item.value + setting.Name = item.name + setting.Remark = item.remark + now := time.Now() + setting.UpdateTime = &now + _, err = models.Orm.Update(&setting, "Value", "Name", "Remark", "UpdateTime") + if err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + } else { + newSetting := models.PlatformNormalSetting{ + Name: item.name, + Code: item.code, + Value: item.value, + Remark: item.remark, + CreateTime: time.Now(), + } + _, err = models.Orm.Insert(&newSetting) + if err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + } + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} + +type barkTestPayload struct { + ServerUrl string `json:"server_url"` + DeviceKey string `json:"device_key"` +} + +// SendTestBark POST /platform/bark/sendtest +func (c *PlatformBarkController) SendTestBark() { + 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 barkTestPayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + serverURL := strings.TrimSpace(p.ServerUrl) + if serverURL == "" { + serverURL = models.GetPlatformSettingValue("bark_server_url", "https://api.day.app") + } + deviceKey := strings.TrimSpace(p.DeviceKey) + if deviceKey == "" { + deviceKey = models.GetPlatformSettingValue("bark_device_key", "") + } + + if deviceKey == "" { + c.jsonErr(400, 400, "设备 Key 不能为空") + return + } + + // 拼接发送 URL,注意去除多余斜杠 + baseURL := strings.TrimRight(serverURL, "/") + // Bark 的格式是: base_url/device_key/title/body + testURL := fmt.Sprintf("%s/%s/测试通知/您配置的 Bark 推送服务已连接成功!", baseURL, deviceKey) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(testURL) + if err != nil { + c.jsonErr(500, 500, "发送失败: "+err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + c.jsonErr(500, 500, fmt.Sprintf("发送失败,HTTP 状态码: %d, 返回内容: %s", resp.StatusCode, string(bodyBytes))) + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "测试推送已发出,请注意查收"} + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_cursor_equipment.go b/go/controllers/platform_cursor_equipment.go index 9b1fff7..764dd92 100644 --- a/go/controllers/platform_cursor_equipment.go +++ b/go/controllers/platform_cursor_equipment.go @@ -105,10 +105,11 @@ func (c *PlatformCursorEquipmentController) cursorActivationSummary(row *models. return count, &latest } -func (c *PlatformCursorEquipmentController) cursorExtractSummary() (int64, *models.PlatformAccountPoolCursor) { +func (c *PlatformCursorEquipmentController) cursorExtractSummary(machineCode string) (int64, *models.PlatformAccountPoolCursor) { qs := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). Filter("delete_time__isnull", true). - Filter("is_extracted__gt", 0) + Filter("is_extracted__gt", 0). + Filter("machine_code", machineCode) count, _ := qs.Count() @@ -122,7 +123,7 @@ func (c *PlatformCursorEquipmentController) cursorExtractSummary() (int64, *mode func (c *PlatformCursorEquipmentController) rowToMap(row *models.PlatformCursorEquipment) map[string]interface{} { activationCount, latestActivation := c.cursorActivationSummary(row) - extractCount, latestExtract := c.cursorExtractSummary() + extractCount, latestExtract := c.cursorExtractSummary(row.MachineCode) var bindActivationCode interface{} var activationCodeId interface{} @@ -676,7 +677,8 @@ func (c *PlatformCursorEquipmentController) ExtractRecords() { qs := models.Orm.QueryTable(new(models.PlatformAccountPoolCursor)). Filter("delete_time__isnull", true). - Filter("is_extracted__gt", 0) + Filter("is_extracted__gt", 0). + Filter("machine_code", equipment.MachineCode) total, _ := qs.Count() diff --git a/go/controllers/platform_email.go b/go/controllers/platform_email.go index b678870..96ea816 100644 --- a/go/controllers/platform_email.go +++ b/go/controllers/platform_email.go @@ -97,12 +97,44 @@ type emailFormPayload struct { Password string `json:"password"` Encryption string `json:"encryption"` Timeout interface{} `json:"timeout"` + Status interface{} `json:"status"` } type testEmailPayload struct { emailFormPayload TestEmail string `json:"testEmail"` } +func parseInt8Flexible(v interface{}) int8 { + if v == nil { + return 1 + } + switch x := v.(type) { + case bool: + if x { + return 1 + } + return 0 + case float64: + return int8(x) + case int: + return int8(x) + case int8: + return x + case string: + s := strings.TrimSpace(x) + if s == "" { + return 1 + } + n, err := strconv.ParseInt(s, 10, 8) + if err != nil { + return 1 + } + return int8(n) + default: + return 1 + } +} + func parseUintFlexible(v interface{}) uint { if v == nil { return 0 @@ -191,7 +223,8 @@ func (c *PlatformEmailController) EditInfo() { s := strings.TrimSpace(p.FromName) fn = &s } - err = services.UpsertFirstSystemEmail(from, fn, host, port, strings.TrimSpace(p.Password), enc, timeout, 1, nil) + status := parseInt8Flexible(p.Status) + err = services.UpsertFirstSystemEmail(from, fn, host, port, strings.TrimSpace(p.Password), enc, timeout, status, nil) if err != nil { c.jsonErr(500, 500, "保存邮箱配置失败: "+err.Error()) return diff --git a/go/controllers/platform_login_verify.go b/go/controllers/platform_login_verify.go index 79ad429..f016080 100644 --- a/go/controllers/platform_login_verify.go +++ b/go/controllers/platform_login_verify.go @@ -85,37 +85,18 @@ func (c *PlatformLoginVerifyController) SaveLoginVerifyInfos() { } } - var existed models.PlatformLoginVerify - err := models.Orm.QueryTable(new(models.PlatformLoginVerify)).OrderBy("-id").One(&existed) - if err == nil { - update := map[string]interface{}{ - "open_verify_enabled": openVerifyEnabled, - "verify_type": verifyType, - "geetest3_id": p.Geetest3ID, - "geetest3_key": p.Geetest3Key, - "geetest4_id": p.Geetest4ID, - "geetest4_key": p.Geetest4Key, - } - _, err = models.Orm.QueryTable(new(models.PlatformLoginVerify)).Filter("id", existed.ID).Update(update) - if err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "保存失败"} - _ = c.ServeJSON() - return - } - } else { - row := &models.PlatformLoginVerify{ - OpenVerifyEnabled: openVerifyEnabled, - VerifyType: verifyType, - Geetest3ID: p.Geetest3ID, - Geetest3Key: p.Geetest3Key, - Geetest4ID: p.Geetest4ID, - Geetest4Key: p.Geetest4Key, - } - if _, err := models.Orm.Insert(row); err != nil { - c.Data["json"] = map[string]interface{}{"code": 500, "msg": "保存失败"} - _ = c.ServeJSON() - return - } + err := models.SavePlatformLoginVerify(&models.PlatformLoginVerify{ + OpenVerifyEnabled: openVerifyEnabled, + VerifyType: verifyType, + Geetest3ID: p.Geetest3ID, + Geetest3Key: p.Geetest3Key, + Geetest4ID: p.Geetest4ID, + Geetest4Key: p.Geetest4Key, + }) + if err != nil { + c.Data["json"] = map[string]interface{}{"code": 500, "msg": "保存失败"} + _ = c.ServeJSON() + return } c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} 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/controllers/platform_sitereminder.go b/go/controllers/platform_sitereminder.go new file mode 100644 index 0000000..3be3ba8 --- /dev/null +++ b/go/controllers/platform_sitereminder.go @@ -0,0 +1,339 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + + "server/pkg/jwtutil" + "server/services" + + beego "github.com/beego/beego/v2/server/web" +) + +type PlatformSiteReminderController struct { + beego.Controller +} + +func (c *PlatformSiteReminderController) 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 *PlatformSiteReminderController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +// GetConfig GET /platform/sitereminder/config +func (c *PlatformSiteReminderController) GetConfig() { + if _, err := c.platformClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + cfg, err := services.GetSiteReminderConfig() + if err != nil { + c.jsonErr(500, 500, "获取配置失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": cfg} + _ = c.ServeJSON() +} + +// SaveConfig POST /platform/sitereminder/config +func (c *PlatformSiteReminderController) SaveConfig() { + 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 struct { + RetentionDays int `json:"retention_days"` + AutoRead int8 `json:"auto_read"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if err := services.SaveSiteReminderConfig(p.RetentionDays, p.AutoRead); err != nil { + c.jsonErr(500, 500, "保存配置失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"} + _ = c.ServeJSON() +} + +// Send POST /platform/sitereminder/send +func (c *PlatformSiteReminderController) Send() { + 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 struct { + Title string `json:"title"` + Content string `json:"content"` + TargetType string `json:"target_type"` // platform, tenant_all, role, tenant + TargetRoleID uint64 `json:"target_role_id"` + TargetTenantID uint64 `json:"target_tenant_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + p.Title = strings.TrimSpace(p.Title) + p.Content = strings.TrimSpace(p.Content) + if p.Title == "" || p.Content == "" { + c.jsonErr(400, 400, "标题与内容不能为空") + return + } + if p.TargetType == "" { + c.jsonErr(400, 400, "发送目标类型不能为空") + return + } + + err = services.SendSiteReminder(p.Title, p.Content, uint64(claims.UserID), "platform", p.TargetType, p.TargetRoleID, p.TargetTenantID) + if err != nil { + c.jsonErr(500, 500, "发送失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "发送成功"} + _ = c.ServeJSON() +} + +// GetMyList GET /platform/sitereminder/myList +func (c *PlatformSiteReminderController) GetMyList() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + var isRead *int8 + if isReadStr := c.GetString("isRead"); isReadStr != "" { + if val, err := strconv.Atoi(isReadStr); err == nil { + v := int8(val) + isRead = &v + } + } + + list, total, err := services.ListReminders(uint64(claims.UserID), "platform", page, pageSize, isRead) + if err != nil { + c.jsonErr(500, 500, "获取消息列表失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// MarkRead POST /platform/sitereminder/read +func (c *PlatformSiteReminderController) MarkRead() { + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.MarkReminderRead(p.ID, uint64(claims.UserID), "platform") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// MarkAllRead POST /platform/sitereminder/readall +func (c *PlatformSiteReminderController) MarkAllRead() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + err = services.MarkAllRemindersRead(uint64(claims.UserID), "platform") + if err != nil { + c.jsonErr(500, 500, "操作失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// Delete POST /platform/sitereminder/delete +func (c *PlatformSiteReminderController) Delete() { + 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 struct { + ID uint64 `json:"id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + + err = services.DeleteReminder(p.ID, uint64(claims.UserID), "platform") + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success"} + _ = c.ServeJSON() +} + +// GetSentList GET /platform/sitereminder/sentList +func (c *PlatformSiteReminderController) GetSentList() { + claims, err := c.platformClaims() + if err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 10) + + list, total, err := services.ListSentReminders(uint64(claims.UserID), page, pageSize) + if err != nil { + c.jsonErr(500, 500, "获取发送列表失败: "+err.Error()) + return + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// UpdateSent POST /platform/sitereminder/updateSent +func (c *PlatformSiteReminderController) UpdateSent() { + 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 struct { + BatchID string `json:"batch_id"` + Title string `json:"title"` + Content string `json:"content"` + TargetType string `json:"target_type"` + TargetRoleID uint64 `json:"target_role_id"` + TargetTenantID uint64 `json:"target_tenant_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + p.Title = strings.TrimSpace(p.Title) + p.Content = strings.TrimSpace(p.Content) + if p.BatchID == "" || p.Title == "" || p.Content == "" { + c.jsonErr(400, 400, "批次号、标题与内容不能为空") + return + } + if p.TargetType == "" { + c.jsonErr(400, 400, "发送目标类型不能为空") + return + } + + err = services.UpdateSentReminder(p.BatchID, p.Title, p.Content, p.TargetType, p.TargetRoleID, p.TargetTenantID) + if err != nil { + c.jsonErr(500, 500, "修改失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "修改成功"} + _ = c.ServeJSON() +} + +// DeleteSentBatch POST /platform/sitereminder/deleteSent +func (c *PlatformSiteReminderController) DeleteSentBatch() { + 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 struct { + BatchID string `json:"batch_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if p.BatchID == "" { + c.jsonErr(400, 400, "批次号不能为空") + return + } + + err = services.DeleteSentReminderBatch(p.BatchID) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} diff --git a/go/controllers/platform_sms.go b/go/controllers/platform_sms.go index b667403..3c4c5d1 100644 --- a/go/controllers/platform_sms.go +++ b/go/controllers/platform_sms.go @@ -55,24 +55,8 @@ func (c *PlatformSMSController) GetSmsInfo() { return } - var row models.SystemSMS - // 优先默认通道,其次 custom - err := models.Orm.QueryTable(new(models.SystemSMS)). - Filter("is_default", 1). - Filter("status", 1). - OrderBy("-weight", "-id"). - Limit(1). - One(&row) - if err != nil { - _ = models.Orm.QueryTable(new(models.SystemSMS)). - Filter("config_code", "custom"). - OrderBy("-id"). - Limit(1). - One(&row) - } - - backendURL := strings.TrimSpace(row.ApiURL) - apiKey := strings.TrimSpace(row.ApiKey) + backendURL := models.GetPlatformSettingValue("sms_custom_url", "") + apiKey := models.GetPlatformSettingValue("sms_custom_key", "") data := []map[string]interface{}{{ "backend_url": backendURL, @@ -93,7 +77,7 @@ type smsEditPayload struct { } // EditSmsInfo POST /platform/sms/editinfo -// 将旧前端的 backendUrl/apiKey 落到 yz_system_sms 的 api_url/api_key(写入 config_code=custom) +// 将旧前端的 backendUrl/apiKey 落到 yz_platform_normal_setting 表中 func (c *PlatformSMSController) EditSmsInfo() { if _, err := c.platformClaims(); err != nil { c.jsonErr(401, 401, err.Error()) @@ -128,44 +112,46 @@ func (c *PlatformSMSController) EditSmsInfo() { return } - // 确保只有一个默认:先清空默认,再 upsert custom 为默认 - _, _ = models.Orm.QueryTable(new(models.SystemSMS)).Update(map[string]interface{}{"is_default": 0}) + settings := []struct { + code string + name string + value string + remark string + }{ + {"sms_custom_url", "自定义短信网关地址", backendURL, ""}, + {"sms_custom_key", "自定义短信API KEY", apiKey, ""}, + } - var existed models.SystemSMS - e := models.Orm.QueryTable(new(models.SystemSMS)).Filter("config_code", "custom").Limit(1).One(&existed) - if e == nil && existed.ID > 0 { - _, err = models.Orm.QueryTable(new(models.SystemSMS)).Filter("id", existed.ID).Update(map[string]interface{}{ - "config_name": "自定义网关", - "channel_type": 2, - "api_url": backendURL, - "api_key": apiKey, - "weight": 10, - "is_default": 1, - "status": 1, - }) - if err != nil { - c.jsonErr(500, 500, "更新失败: "+err.Error()) - return - } - } else { - row := &models.SystemSMS{ - ConfigCode: "custom", - ConfigName: "自定义网关", - ChannelType: 2, - ApiURL: backendURL, - ApiKey: apiKey, - ApiSecret: "", - SignName: "", - TemplateID: "", - TestPhone: "", - Weight: 10, - IsDefault: 1, - Status: 1, - Remark: "", - } - if _, err := models.Orm.Insert(row); err != nil { - c.jsonErr(500, 500, "更新失败: "+err.Error()) - return + for _, item := range settings { + var setting models.PlatformNormalSetting + err := models.Orm.QueryTable(new(models.PlatformNormalSetting)). + Filter("code", item.code). + Filter("delete_time__isnull", true). + One(&setting) + if err == nil { + setting.Value = item.value + setting.Name = item.name + setting.Remark = item.remark + now := time.Now() + setting.UpdateTime = &now + _, err = models.Orm.Update(&setting, "Value", "Name", "Remark", "UpdateTime") + if err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } + } else { + newSetting := models.PlatformNormalSetting{ + Name: item.name, + Code: item.code, + Value: item.value, + Remark: item.remark, + CreateTime: time.Now(), + } + _, err = models.Orm.Insert(&newSetting) + if err != nil { + c.jsonErr(500, 500, "保存失败: "+err.Error()) + return + } } } @@ -232,18 +218,11 @@ func (c *PlatformSMSController) SendTestSms() { // 兜底:body 未带时从默认配置取 if backendURL == "" || apiKey == "" { - var row models.SystemSMS - _ = models.Orm.QueryTable(new(models.SystemSMS)). - Filter("is_default", 1). - Filter("status", 1). - OrderBy("-weight", "-id"). - Limit(1). - One(&row) if backendURL == "" { - backendURL = strings.TrimSpace(row.ApiURL) + backendURL = models.GetPlatformSettingValue("sms_custom_url", "") } if apiKey == "" { - apiKey = strings.TrimSpace(row.ApiKey) + apiKey = models.GetPlatformSettingValue("sms_custom_key", "") } } if backendURL == "" { 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 13796ca..43771bf 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -43,14 +43,11 @@ func Init(_ string) { new(AdminRole), new(SystemFile), new(SystemFilesCategory), - new(SystemEmail), - new(SystemSMS), new(SystemSMSTask), new(SystemOperationLog), new(SystemDomainPool), new(SystemTenantDomain), new(SystemModules), - new(PlatformLoginVerify), new(StorageConfig), new(TenantSiteSetting), new(ComplaintCategory), @@ -67,6 +64,16 @@ func Init(_ string) { new(CmsArticleCategory), new(CmsArticle), + + new(SystemReminderList), + + new(SystemNormalSetting), + new(PlatformNormalSetting), + new(BackendNormalSetting), + + new(PlatformSchedule), + new(PlatformScheduleReminder), + new(PlatformScheduleReminderSendLog), ) // 创建全局 Ormer diff --git a/go/models/platform_account_pool.go b/go/models/platform_account_pool.go index 3dc8b62..7c6b759 100644 --- a/go/models/platform_account_pool.go +++ b/go/models/platform_account_pool.go @@ -75,6 +75,7 @@ type PlatformAccountPoolCursor struct { IsUsed *int8 `orm:"column(is_used);null" json:"is_used"` // 0=用完/不可用 1=可用 NULL=未探测 ExtractedTime *time.Time `orm:"column(extracted_time);type(datetime);null" json:"extracted_time"` ExtractedPlatform *string `orm:"column(extracted_platform);size(32);null" json:"extracted_platform"` + MachineCode string `orm:"column(machine_code);size(128);default('')" json:"machine_code"` CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` diff --git a/go/models/platform_login_verify.go b/go/models/platform_login_verify.go index 69ec5d2..2ed40cb 100644 --- a/go/models/platform_login_verify.go +++ b/go/models/platform_login_verify.go @@ -15,20 +15,118 @@ type PlatformLoginVerify struct { UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"` } -func (m *PlatformLoginVerify) TableName() string { - return "yz_system_login_verify" -} - func GetPlatformLoginVerify() (*PlatformLoginVerify, error) { - var cfg PlatformLoginVerify - err := Orm.QueryTable(new(PlatformLoginVerify)).OrderBy("-id").One(&cfg) - if err != nil { - // 默认配置:验证码 - return &PlatformLoginVerify{OpenVerifyEnabled: 1, VerifyType: "captcha"}, nil + // 从 yz_platform_normal_setting 表中按 code 获取各个配置 + enabledStr := GetPlatformSettingValue("login_verify_enabled", "1") + verifyType := GetPlatformSettingValue("login_verify_type", "captcha") + geetest3ID := GetPlatformSettingValue("login_verify_geetest3_id", "") + geetest3Key := GetPlatformSettingValue("login_verify_geetest3_key", "") + geetest4ID := GetPlatformSettingValue("login_verify_geetest4_id", "") + geetest4Key := GetPlatformSettingValue("login_verify_geetest4_key", "") + + openVerifyEnabled := int8(1) + if enabledStr == "0" { + openVerifyEnabled = 0 } - if cfg.VerifyType == "" { - cfg.VerifyType = "captcha" + + cfg := &PlatformLoginVerify{ + OpenVerifyEnabled: openVerifyEnabled, + VerifyType: verifyType, } - return &cfg, nil + if geetest3ID != "" { + cfg.Geetest3ID = &geetest3ID + } + if geetest3Key != "" { + cfg.Geetest3Key = &geetest3Key + } + if geetest4ID != "" { + cfg.Geetest4ID = &geetest4ID + } + if geetest4Key != "" { + cfg.Geetest4Key = &geetest4Key + } + + return cfg, nil +} + +func GetPlatformSettingValue(code string, defaultVal string) string { + var setting PlatformNormalSetting + err := Orm.QueryTable(new(PlatformNormalSetting)). + Filter("code", code). + Filter("delete_time__isnull", true). + One(&setting) + if err != nil { + return defaultVal + } + return setting.Value +} + +func SavePlatformLoginVerify(cfg *PlatformLoginVerify) error { + openVerifyEnabledStr := "1" + if cfg.OpenVerifyEnabled == 0 { + openVerifyEnabledStr = "0" + } + geetest3ID := "" + if cfg.Geetest3ID != nil { + geetest3ID = *cfg.Geetest3ID + } + geetest3Key := "" + if cfg.Geetest3Key != nil { + geetest3Key = *cfg.Geetest3Key + } + geetest4ID := "" + if cfg.Geetest4ID != nil { + geetest4ID = *cfg.Geetest4ID + } + geetest4Key := "" + if cfg.Geetest4Key != nil { + geetest4Key = *cfg.Geetest4Key + } + + settings := []struct { + code string + name string + value string + remark string + }{ + {"login_verify_enabled", "登录验证开启状态", openVerifyEnabledStr, "0为关闭,1为开启"}, + {"login_verify_type", "登录验证类型", cfg.VerifyType, "支持 captcha/sms/geetest/email"}, + {"login_verify_geetest3_id", "极验3 ID", geetest3ID, ""}, + {"login_verify_geetest3_key", "极验3 Key", geetest3Key, ""}, + {"login_verify_geetest4_id", "极验4 ID", geetest4ID, ""}, + {"login_verify_geetest4_key", "极验4 Key", geetest4Key, ""}, + } + + for _, item := range settings { + var setting PlatformNormalSetting + err := Orm.QueryTable(new(PlatformNormalSetting)). + Filter("code", item.code). + Filter("delete_time__isnull", true). + One(&setting) + if err == nil { + setting.Value = item.value + setting.Name = item.name + setting.Remark = item.remark + now := time.Now() + setting.UpdateTime = &now + _, err = Orm.Update(&setting, "Value", "Name", "Remark", "UpdateTime") + if err != nil { + return err + } + } else { + newSetting := PlatformNormalSetting{ + Name: item.name, + Code: item.code, + Value: item.value, + Remark: item.remark, + CreateTime: time.Now(), + } + _, err = Orm.Insert(&newSetting) + if err != nil { + return err + } + } + } + return nil } 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/models/system_email.go b/go/models/system_email.go index 289dbfa..397a15c 100644 --- a/go/models/system_email.go +++ b/go/models/system_email.go @@ -18,6 +18,4 @@ type SystemEmail struct { UpdateTime time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"` } -func (m *SystemEmail) TableName() string { - return "yz_system_email" -} + diff --git a/go/models/system_normal_setting.go b/go/models/system_normal_setting.go new file mode 100644 index 0000000..61037cc --- /dev/null +++ b/go/models/system_normal_setting.go @@ -0,0 +1,51 @@ +package models + +import "time" + +// SystemNormalSetting 系统通用配置表: yz_system_normal_setting +type SystemNormalSetting struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Name string `orm:"column(name);size(128);default('')" json:"name"` + Value string `orm:"column(value);type(text);null" json:"value"` + Code string `orm:"column(code);size(64);default('')" json:"code"` + Remark string `orm:"column(remark);size(255);default('')" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemNormalSetting) TableName() string { + return "yz_system_normal_setting" +} + +// PlatformNormalSetting 平台通用配置表: yz_platform_normal_setting +type PlatformNormalSetting struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Name string `orm:"column(name);size(128);default('')" json:"name"` + Value string `orm:"column(value);type(text);null" json:"value"` + Code string `orm:"column(code);size(64);default('')" json:"code"` + Remark string `orm:"column(remark);size(255);default('')" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *PlatformNormalSetting) TableName() string { + return "yz_platform_normal_setting" +} + +// BackendNormalSetting 管理端通用配置表: yz_backend_normal_setting +type BackendNormalSetting struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Name string `orm:"column(name);size(128);default('')" json:"name"` + Value string `orm:"column(value);type(text);null" json:"value"` + Code string `orm:"column(code);size(64);default('')" json:"code"` + Remark string `orm:"column(remark);size(255);default('')" json:"remark"` + CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *BackendNormalSetting) TableName() string { + return "yz_backend_normal_setting" +} diff --git a/go/models/system_reminderlist.go b/go/models/system_reminderlist.go new file mode 100644 index 0000000..622b87f --- /dev/null +++ b/go/models/system_reminderlist.go @@ -0,0 +1,26 @@ +package models + +import "time" + +// SystemReminderList 站内信消息列表表 yz_system_reminderlist +type SystemReminderList 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"` + SenderID uint64 `orm:"column(sender_id);default(0)" json:"sender_id"` + SenderType string `orm:"column(sender_type);size(32);default('system')" json:"sender_type"` // system, platform, tenant + ReceiverID uint64 `orm:"column(receiver_id)" json:"receiver_id"` + ReceiverType string `orm:"column(receiver_type);size(32)" json:"receiver_type"` // platform, tenant + IsRead int8 `orm:"column(is_read);default(0)" json:"is_read"` // 0-未读, 1-已读 + ReadTime *time.Time `orm:"column(read_time);type(datetime);null" json:"read_time"` + CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` + BatchID string `orm:"column(batch_id);size(64);default('')" json:"batch_id"` + TargetType string `orm:"column(target_type);size(32);default('')" json:"target_type"` + TargetRoleID uint64 `orm:"column(target_role_id);default(0)" json:"target_role_id"` + TargetTenantID uint64 `orm:"column(target_tenant_id);default(0)" json:"target_tenant_id"` +} + +func (m *SystemReminderList) TableName() string { + return "yz_system_reminderlist" +} diff --git a/go/models/system_sitereminder.go b/go/models/system_sitereminder.go new file mode 100644 index 0000000..ba45555 --- /dev/null +++ b/go/models/system_sitereminder.go @@ -0,0 +1,14 @@ +package models + +import "time" + +// SystemSiteReminder 站内信配置表 yz_system_sitereminder +type SystemSiteReminder struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + RetentionDays int `orm:"column(retention_days);default(30)" json:"retention_days"` + AutoRead int8 `orm:"column(auto_read);default(0)" json:"auto_read"` + CreateTime *time.Time `orm:"column(create_time);type(datetime);null" json:"create_time"` + UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"` +} + + diff --git a/go/models/system_sms.go b/go/models/system_sms.go index 93d7418..094dcb1 100644 --- a/go/models/system_sms.go +++ b/go/models/system_sms.go @@ -27,6 +27,4 @@ type SystemSMS struct { UpdateTime time.Time `orm:"column(update_time);type(datetime);auto_now" json:"update_time"` } -func (m *SystemSMS) TableName() string { - return "yz_system_sms" -} + 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/backend/backend.go b/go/routers/backend/backend.go index 1cc327a..1c5eecc 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -51,6 +51,12 @@ func RegisterAuthRoutes() { beego.Router("/backend/loginVerifyInfos", &controllers.BackendLoginVerifyController{}, "get:GetLoginVerifyInfos") beego.Router("/backend/saveloginVerifyInfos", &controllers.BackendLoginVerifyController{}, "post:SaveLoginVerifyInfos") + // 站内信(yz_system_reminderlist) + beego.Router("/backend/sitereminder/myList", &controllers.BackendSiteReminderController{}, "get:GetMyList") + beego.Router("/backend/sitereminder/read", &controllers.BackendSiteReminderController{}, "post:MarkRead") + beego.Router("/backend/sitereminder/readall", &controllers.BackendSiteReminderController{}, "post:MarkAllRead") + beego.Router("/backend/sitereminder/delete", &controllers.BackendSiteReminderController{}, "post:Delete") + // 文件管理(yz_system_files / yz_system_files_category) beego.Router("/backend/usercate", &controllers.BackendFileController{}, "get:GetUserCate") beego.Router("/backend/allfiles", &controllers.BackendFileController{}, "get:GetAllFiles") diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go index 3345aee..9aac00d 100644 --- a/go/routers/platform/platform.go +++ b/go/routers/platform/platform.go @@ -126,6 +126,17 @@ func Register() { beego.Router("/platform/email/editinfo", &controllers.PlatformEmailController{}, "post:EditInfo") beego.Router("/platform/email/sendtestemail", &controllers.PlatformEmailController{}, "post:SendTestEmail") + // 站内信配置与发送(yz_system_sitereminder / yz_system_reminderlist) + beego.Router("/platform/sitereminder/config", &controllers.PlatformSiteReminderController{}, "get:GetConfig;post:SaveConfig") + beego.Router("/platform/sitereminder/send", &controllers.PlatformSiteReminderController{}, "post:Send") + beego.Router("/platform/sitereminder/myList", &controllers.PlatformSiteReminderController{}, "get:GetMyList") + beego.Router("/platform/sitereminder/read", &controllers.PlatformSiteReminderController{}, "post:MarkRead") + beego.Router("/platform/sitereminder/readall", &controllers.PlatformSiteReminderController{}, "post:MarkAllRead") + beego.Router("/platform/sitereminder/delete", &controllers.PlatformSiteReminderController{}, "post:Delete") + beego.Router("/platform/sitereminder/sentList", &controllers.PlatformSiteReminderController{}, "get:GetSentList") + beego.Router("/platform/sitereminder/updateSent", &controllers.PlatformSiteReminderController{}, "post:UpdateSent") + beego.Router("/platform/sitereminder/deleteSent", &controllers.PlatformSiteReminderController{}, "post:DeleteSentBatch") + // 短信配置(yz_system_sms) beego.Router("/platform/sms/info", &controllers.PlatformSMSController{}, "get:GetSmsInfo") beego.Router("/platform/sms/editinfo", &controllers.PlatformSMSController{}, "post:EditSmsInfo") @@ -133,6 +144,11 @@ func Register() { beego.Router("/platform/sms/taskList", &controllers.PlatformSMSController{}, "get:GetSmsTaskList") beego.Router("/platform/sms/taskEdit/:id", &controllers.PlatformSMSController{}, "post:EditSmsTask") + // Bark 推送配置 + beego.Router("/platform/bark/info", &controllers.PlatformBarkController{}, "get:GetBarkInfo") + beego.Router("/platform/bark/editinfo", &controllers.PlatformBarkController{}, "post:EditBarkInfo") + beego.Router("/platform/bark/sendtest", &controllers.PlatformBarkController{}, "post:SendTestBark") + // 文件管理(yz_system_files / yz_system_files_category) beego.Router("/platform/usercate", &controllers.PlatformFileController{}, "get:GetUserCate") beego.Router("/platform/allfiles", &controllers.PlatformFileController{}, "get:GetAllFiles") @@ -240,4 +256,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/login_verify_code.go b/go/services/login_verify_code.go index bef2db4..963027f 100644 --- a/go/services/login_verify_code.go +++ b/go/services/login_verify_code.go @@ -157,25 +157,11 @@ func VerifyBackendLoginCode(tenantName, account, channel, code string) error { } func getDefaultSystemSMSConfig() (backendURL string, apiKey string, err error) { - var row models.SystemSMS - err = models.Orm.QueryTable(new(models.SystemSMS)). - Filter("is_default", 1). - Filter("status", 1). - OrderBy("-weight", "-id"). - Limit(1). - One(&row) - if err != nil { - err2 := models.Orm.QueryTable(new(models.SystemSMS)). - Filter("config_code", "custom"). - OrderBy("-id"). - Limit(1). - One(&row) - if err2 != nil { - return "", "", err2 - } + backendURL = models.GetPlatformSettingValue("sms_custom_url", "") + apiKey = models.GetPlatformSettingValue("sms_custom_key", "") + if backendURL == "" || apiKey == "" { + return "", "", fmt.Errorf("短信网关未配置") } - backendURL = strings.TrimSpace(row.ApiURL) - apiKey = strings.TrimSpace(row.ApiKey) return backendURL, apiKey, nil } diff --git a/go/services/reminder_scheduler.go b/go/services/reminder_scheduler.go new file mode 100644 index 0000000..059ddf6 --- /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", "https://api.yunzer.cn") + 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", "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(), + }) + } +} 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/go/services/system_email_store.go b/go/services/system_email_store.go index 7defd3d..24f80f4 100644 --- a/go/services/system_email_store.go +++ b/go/services/system_email_store.go @@ -2,19 +2,51 @@ package services import ( "fmt" + "strconv" "strings" + "time" "server/models" ) -// ListSystemEmails 返回全部邮箱配置(按 id 升序,通常仅一条) +// ListSystemEmails 返回从 yz_platform_normal_setting 组装的邮箱配置(切片,通常仅一条) func ListSystemEmails() ([]models.SystemEmail, error) { - var rows []models.SystemEmail - _, err := models.Orm.QueryTable(new(models.SystemEmail)).OrderBy("id").All(&rows) - return rows, err + enabledStr := models.GetPlatformSettingValue("email_enabled", "0") + fromAddress := models.GetPlatformSettingValue("email_from_address", "") + fromName := models.GetPlatformSettingValue("email_from_name", "") + host := models.GetPlatformSettingValue("email_host", "") + portStr := models.GetPlatformSettingValue("email_port", "465") + password := models.GetPlatformSettingValue("email_password", "") + encryption := models.GetPlatformSettingValue("email_encryption", "ssl") + timeoutStr := models.GetPlatformSettingValue("email_timeout", "30") + + status := int8(0) + if enabledStr == "1" { + status = 1 + } + portVal, _ := strconv.ParseUint(portStr, 10, 32) + timeoutVal, _ := strconv.ParseUint(timeoutStr, 10, 32) + + row := models.SystemEmail{ + ID: 1, + FromAddress: fromAddress, + Host: host, + Port: uint(portVal), + Password: password, + Encryption: encryption, + Timeout: uint(timeoutVal), + Status: status, + CreateTime: time.Now(), + UpdateTime: time.Now(), + } + if fromName != "" { + row.FromName = &fromName + } + + return []models.SystemEmail{row}, nil } -// UpsertFirstSystemEmail 若已有记录则更新第一条,否则插入 +// UpsertFirstSystemEmail 将邮箱配置保存到 yz_platform_normal_setting 表中 func UpsertFirstSystemEmail(fromAddress string, fromName *string, host string, port uint, password string, encryption string, timeout uint, status int8, remark *string) error { if encryption == "" { encryption = "ssl" @@ -25,53 +57,79 @@ func UpsertFirstSystemEmail(fromAddress string, fromName *string, host string, p if timeout == 0 { timeout = 30 } - if status == 0 { - status = 1 - } fromAddress = strings.TrimSpace(fromAddress) host = strings.TrimSpace(host) - cnt, err := models.Orm.QueryTable(new(models.SystemEmail)).Count() - if err != nil { - return err + fn := "" + if fromName != nil { + fn = *fromName } - if cnt == 0 { - if strings.TrimSpace(password) == "" { + + statusStr := "0" + if status == 1 { + statusStr = "1" + } + + settings := []struct { + code string + name string + value string + remark string + }{ + {"email_enabled", "邮件服务启用状态", statusStr, "0为关闭,1为开启"}, + {"email_from_address", "发件人邮箱", fromAddress, ""}, + {"email_from_name", "发件人名称", fn, ""}, + {"email_host", "SMTP 服务器地址", host, ""}, + {"email_port", "SMTP 端口", strconv.FormatUint(uint64(port), 10), ""}, + {"email_encryption", "邮件加密方式", encryption, "支持 ssl/tls/none"}, + {"email_timeout", "邮件发送超时时间", strconv.FormatUint(uint64(timeout), 10), ""}, + } + + // 如果传入了新密码,或者目前还没有保存过密码,才更新密码 + if strings.TrimSpace(password) != "" { + settings = append(settings, struct { + code string + name string + value string + remark string + }{"email_password", "邮件授权码/密码", strings.TrimSpace(password), ""}) + } else { + // 校验:如果完全没有配置过密码,必须填写密码 + existingPass := models.GetPlatformSettingValue("email_password", "") + if existingPass == "" { return fmt.Errorf("首次保存必须填写授权码/密码") } - row := &models.SystemEmail{ - FromAddress: fromAddress, - FromName: fromName, - Host: host, - Port: port, - Password: strings.TrimSpace(password), - Encryption: encryption, - Timeout: timeout, - Status: status, - Remark: remark, + } + + for _, item := range settings { + var setting models.PlatformNormalSetting + err := models.Orm.QueryTable(new(models.PlatformNormalSetting)). + Filter("code", item.code). + Filter("delete_time__isnull", true). + One(&setting) + if err == nil { + setting.Value = item.value + setting.Name = item.name + setting.Remark = item.remark + now := time.Now() + setting.UpdateTime = &now + _, err = models.Orm.Update(&setting, "Value", "Name", "Remark", "UpdateTime") + if err != nil { + return err + } + } else { + newSetting := models.PlatformNormalSetting{ + Name: item.name, + Code: item.code, + Value: item.value, + Remark: item.remark, + CreateTime: time.Now(), + } + _, err = models.Orm.Insert(&newSetting) + if err != nil { + return err + } } - _, err = models.Orm.Insert(row) - return err } - - var first models.SystemEmail - if err := models.Orm.QueryTable(new(models.SystemEmail)).OrderBy("id").Limit(1).One(&first); err != nil { - return err - } - - up := map[string]interface{}{ - "from_address": fromAddress, - "from_name": fromName, - "host": host, - "port": port, - "encryption": encryption, - "timeout": timeout, - "status": status, - "remark": remark, - } - if strings.TrimSpace(password) != "" { - up["password"] = strings.TrimSpace(password) - } - _, err = models.Orm.QueryTable(new(models.SystemEmail)).Filter("id", first.ID).Update(up) - return err + return nil } diff --git a/go/services/system_sitereminder.go b/go/services/system_sitereminder.go new file mode 100644 index 0000000..4705f6f --- /dev/null +++ b/go/services/system_sitereminder.go @@ -0,0 +1,439 @@ +package services + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/beego/beego/v2/client/orm" + "server/models" +) + +// GetSiteReminderConfig 获取站内信配置(从 yz_platform_normal_setting 读取) +func GetSiteReminderConfig() (models.SystemSiteReminder, error) { + retentionDaysStr := models.GetPlatformSettingValue("sitemsg_retention_days", "30") + autoReadStr := models.GetPlatformSettingValue("sitemsg_auto_read", "0") + + retentionDays, _ := strconv.Atoi(retentionDaysStr) + if retentionDays <= 0 { + retentionDays = 30 + } + autoRead := int8(0) + if autoReadStr == "1" { + autoRead = 1 + } + + now := time.Now() + row := models.SystemSiteReminder{ + ID: 1, + RetentionDays: retentionDays, + AutoRead: autoRead, + CreateTime: &now, + UpdateTime: &now, + } + return row, nil +} + +// SaveSiteReminderConfig 保存/更新配置 +func SaveSiteReminderConfig(retentionDays int, autoRead int8) error { + if retentionDays <= 0 { + retentionDays = 30 + } + + autoReadStr := "0" + if autoRead == 1 { + autoReadStr = "1" + } + + settings := []struct { + code string + name string + value string + remark string + }{ + {"sitemsg_retention_days", "站内信消息保留天数", strconv.Itoa(retentionDays), ""}, + {"sitemsg_auto_read", "自动标记已读状态", autoReadStr, "0为关闭,1为开启"}, + } + + for _, item := range settings { + var setting models.PlatformNormalSetting + err := models.Orm.QueryTable(new(models.PlatformNormalSetting)). + Filter("code", item.code). + Filter("delete_time__isnull", true). + One(&setting) + if err == nil { + setting.Value = item.value + setting.Name = item.name + setting.Remark = item.remark + now := time.Now() + setting.UpdateTime = &now + _, err = models.Orm.Update(&setting, "Value", "Name", "Remark", "UpdateTime") + if err != nil { + return err + } + } else { + newSetting := models.PlatformNormalSetting{ + Name: item.name, + Code: item.code, + Value: item.value, + Remark: item.remark, + CreateTime: time.Now(), + } + _, err = models.Orm.Insert(&newSetting) + if err != nil { + return err + } + } + } + return nil +} + +// SendSiteReminder 发送站内信 +// targetType: platform (平台端), tenant_all (管理端所有用户), role (平台角色), tenant (特定租户) +func SendSiteReminder(title, content string, senderID uint64, senderType string, targetType string, targetRoleID uint64, targetTenantID uint64) error { + var receiverIDs []uint64 + var receiverType string + + switch targetType { + case "platform": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询平台用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant_all": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询租户用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "role": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("role_id", targetRoleID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据角色查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("tid", targetTenantID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据租户查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + default: + return fmt.Errorf("未知的发送目标类型: %s", targetType) + } + + if len(receiverIDs) == 0 { + return nil + } + + now := time.Now() + batchID := fmt.Sprintf("%d_%d", now.UnixNano(), senderID) + var reminders []models.SystemReminderList + for _, rid := range receiverIDs { + reminders = append(reminders, models.SystemReminderList{ + Title: title, + Content: content, + SenderID: senderID, + SenderType: senderType, + ReceiverID: rid, + ReceiverType: receiverType, + IsRead: 0, + CreateTime: &now, + BatchID: batchID, + TargetType: targetType, + TargetRoleID: targetRoleID, + TargetTenantID: targetTenantID, + }) + } + + // 批量插入 + _, err := models.Orm.InsertMulti(100, reminders) + return err +} + +// ListReminders 列表查询 +func ListReminders(receiverID uint64, receiverType string, page, pageSize int, isRead *int8) ([]models.SystemReminderList, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + var list []models.SystemReminderList + qs := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Filter("delete_time__isnull", true) + + if isRead != nil { + qs = qs.Filter("is_read", *isRead) + } + + total, err := qs.Count() + if err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + _, err = qs.OrderBy("-create_time", "-id").Limit(pageSize, offset).All(&list) + return list, total, err +} + +// MarkReminderRead 标记单条已读 +func MarkReminderRead(id uint64, receiverID uint64, receiverType string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("id", id). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Update(map[string]interface{}{ + "is_read": 1, + "read_time": &now, + }) + return err +} + +// MarkAllRemindersRead 一键全部已读 +func MarkAllRemindersRead(receiverID uint64, receiverType string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Filter("is_read", 0). + Update(map[string]interface{}{ + "is_read": 1, + "read_time": &now, + }) + return err +} + +// DeleteReminder 删除消息 +func DeleteReminder(id uint64, receiverID uint64, receiverType string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("id", id). + Filter("receiver_id", receiverID). + Filter("receiver_type", receiverType). + Update(map[string]interface{}{ + "delete_time": &now, + }) + return err +} + +// AutoCleanExpiredReminders 自动清理过期站内信 +func AutoCleanExpiredReminders() error { + cfg, err := GetSiteReminderConfig() + if err != nil { + return err + } + if cfg.RetentionDays <= 0 { + return nil + } + expireTime := time.Now().AddDate(0, 0, -cfg.RetentionDays) + _, err = models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("create_time__lt", expireTime). + Delete() + return err +} + +// ListSentReminders 获取已发送的消息列表(按 batch_id 分组) +func ListSentReminders(senderID uint64, page, pageSize int) ([]models.SystemReminderList, int64, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 10 + } + offset := (page - 1) * pageSize + + var total int64 + err := models.Orm.Raw("SELECT COUNT(DISTINCT batch_id) FROM yz_system_reminderlist WHERE sender_id = ? AND delete_time IS NULL", senderID).QueryRow(&total) + if err != nil { + return nil, 0, err + } + + var list []models.SystemReminderList + _, err = models.Orm.Raw("SELECT * FROM yz_system_reminderlist WHERE id IN (SELECT MIN(id) FROM yz_system_reminderlist WHERE sender_id = ? AND delete_time IS NULL GROUP BY batch_id) ORDER BY id DESC LIMIT ? OFFSET ?", senderID, pageSize, offset).QueryRows(&list) + if err != nil { + return nil, 0, err + } + + return list, total, nil +} + +// UpdateSentReminder 更新已发出的消息(更新该批次下所有接收者的消息,支持修改目标接收群体) +func UpdateSentReminder(batchID string, title, content, targetType string, targetRoleID, targetTenantID uint64) error { + // 1. 获取当前发送者ID (从该批次中任意一条记录中获取) + var firstRecord models.SystemReminderList + err := models.Orm.QueryTable(new(models.SystemReminderList)).Filter("batch_id", batchID).Limit(1).One(&firstRecord) + if err != nil { + return fmt.Errorf("找不到该批次的站内信记录: %w", err) + } + senderID := firstRecord.SenderID + senderType := firstRecord.SenderType + + // 2. 根据新的目标接收群体获取接收人列表 + var receiverIDs []uint64 + var receiverType string + + switch targetType { + case "platform": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询平台用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant_all": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("查询租户用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "role": + receiverType = "platform" + var list []models.AdminUser + _, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("status", 1).Filter("role_id", targetRoleID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据角色查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + case "tenant": + receiverType = "tenant" + var list []models.SystemTenantUser + _, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("status", 1).Filter("tid", targetTenantID).Filter("delete_time__isnull", true).All(&list, "id") + if err != nil { + return fmt.Errorf("根据租户查询用户失败: %w", err) + } + for _, u := range list { + receiverIDs = append(receiverIDs, u.ID) + } + default: + return fmt.Errorf("未知的发送目标类型: %s", targetType) + } + + // 3. 获取该批次中现有的所有记录 (包括已删除的) + var existingRecords []models.SystemReminderList + _, err = models.Orm.QueryTable(new(models.SystemReminderList)).Filter("batch_id", batchID).All(&existingRecords) + if err != nil { + return fmt.Errorf("获取现有记录失败: %w", err) + } + + // 建立 map 快速查找,Key 为 "receiverType_receiverID" + existingMap := make(map[string]*models.SystemReminderList) + for i := range existingRecords { + key := fmt.Sprintf("%s_%d", existingRecords[i].ReceiverType, existingRecords[i].ReceiverID) + existingMap[key] = &existingRecords[i] + } + + newReceiverMap := make(map[string]bool) + for _, rid := range receiverIDs { + key := fmt.Sprintf("%s_%d", receiverType, rid) + newReceiverMap[key] = true + } + + now := time.Now() + + // 事务处理 + err = models.Orm.DoTx(func(c context.Context, txOrm orm.TxOrmer) error { + // A. 对于已经不在新接收者列表中的用户,软删除 + for key, rec := range existingMap { + if !newReceiverMap[key] { + if rec.DeleteTime == nil { + rec.DeleteTime = &now + if _, e := txOrm.Update(rec, "DeleteTime"); e != nil { + return e + } + } + } + } + + // B. 对于仍然在新接收者列表中的用户,更新标题、内容、以及 target 信息;如果原来被删除了,清除 delete_time + var newInserts []models.SystemReminderList + for _, rid := range receiverIDs { + key := fmt.Sprintf("%s_%d", receiverType, rid) + if rec, exists := existingMap[key]; exists { + rec.Title = title + rec.Content = content + rec.TargetType = targetType + rec.TargetRoleID = targetRoleID + rec.TargetTenantID = targetTenantID + + cols := []string{"Title", "Content", "TargetType", "TargetRoleID", "TargetTenantID"} + if rec.DeleteTime != nil { + rec.DeleteTime = nil + rec.IsRead = 0 + rec.ReadTime = nil + cols = append(cols, "DeleteTime", "IsRead", "ReadTime") + } + if _, e := txOrm.Update(rec, cols...); e != nil { + return e + } + } else { + // C. 对于新增加的接收者,插入新记录 + newInserts = append(newInserts, models.SystemReminderList{ + Title: title, + Content: content, + SenderID: senderID, + SenderType: senderType, + ReceiverID: rid, + ReceiverType: receiverType, + IsRead: 0, + CreateTime: &now, + BatchID: batchID, + TargetType: targetType, + TargetRoleID: targetRoleID, + TargetTenantID: targetTenantID, + }) + } + } + + if len(newInserts) > 0 { + if _, e := txOrm.InsertMulti(100, newInserts); e != nil { + return e + } + } + + return nil + }) + + return err +} + +// DeleteSentReminderBatch 删除已发送消息(删除该批次下所有记录) +func DeleteSentReminderBatch(batchID string) error { + now := time.Now() + _, err := models.Orm.QueryTable(new(models.SystemReminderList)). + Filter("batch_id", batchID). + Update(map[string]interface{}{ + "delete_time": &now, + }) + return err +} diff --git a/platform/components.d.ts b/platform/components.d.ts index ffd2440..2e1dc36 100644 --- a/platform/components.d.ts +++ b/platform/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { ElAside: typeof import('element-plus/es')['ElAside'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElBacktop: typeof import('element-plus/es')['ElBacktop'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] ElCascader: typeof import('element-plus/es')['ElCascader'] @@ -24,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'] @@ -54,6 +56,7 @@ declare module 'vue' { ElRow: typeof import('element-plus/es')['ElRow'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] 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/basicSettings/sitereminder/components/detail.vue b/platform/src/views/basicSettings/sitereminder/components/detail.vue new file mode 100644 index 0000000..9cf4878 --- /dev/null +++ b/platform/src/views/basicSettings/sitereminder/components/detail.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/platform/src/views/basicSettings/sitereminder/components/edit.vue b/platform/src/views/basicSettings/sitereminder/components/edit.vue new file mode 100644 index 0000000..b14fd81 --- /dev/null +++ b/platform/src/views/basicSettings/sitereminder/components/edit.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/platform/src/views/basicSettings/sitereminder/index.vue b/platform/src/views/basicSettings/sitereminder/index.vue new file mode 100644 index 0000000..88a178e --- /dev/null +++ b/platform/src/views/basicSettings/sitereminder/index.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/platform/src/views/system/email/index.vue b/platform/src/views/system/email/index.vue index 0a97105..a280a71 100644 --- a/platform/src/views/system/email/index.vue +++ b/platform/src/views/system/email/index.vue @@ -75,6 +75,16 @@ /> + + + + { emailForm.password = item.password || ""; emailForm.encryption = item.encryption || "ssl"; emailForm.timeout = item.timeout != null ? item.timeout : 30; + emailForm.status = item.status != null ? item.status : 1; } else { hasSavedConfig.value = false; } @@ -211,6 +223,7 @@ const handleReset = () => { emailForm.password = ""; emailForm.encryption = "ssl"; emailForm.timeout = 30; + emailForm.status = 1; testEmail.value = ""; emailFormRef.value?.clearValidate(); }; diff --git a/platform/src/views/system/platformsettings/components/notificationSettings.vue b/platform/src/views/system/platformsettings/components/notificationSettings.vue new file mode 100644 index 0000000..d7379ca --- /dev/null +++ b/platform/src/views/system/platformsettings/components/notificationSettings.vue @@ -0,0 +1,849 @@ + + + + + diff --git a/platform/src/views/system/platformsettings/index.vue b/platform/src/views/system/platformsettings/index.vue index dc4be92..b525496 100644 --- a/platform/src/views/system/platformsettings/index.vue +++ b/platform/src/views/system/platformsettings/index.vue @@ -23,6 +23,14 @@ v-if="activeTab === 'storage'" /> + + + + + @@ -32,6 +40,7 @@ import { ref } from "vue"; import platformSettings from "./components/platformSettings.vue"; import storageSettings from "./components/storageSettings.vue"; +import notificationSettings from "./components/notificationSettings.vue"; const activeTab = ref("platform"); diff --git a/platform/src/views/tools/notebook/test-api.js b/platform/src/views/tools/notebook/test-api.js deleted file mode 100644 index 6f601bf..0000000 --- a/platform/src/views/tools/notebook/test-api.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * 记事本模块 API 测试脚本 - * 在浏览器控制台中运行此脚本测试 API - */ - -// 测试配置 -const API_BASE = '/platform/notebook'; - -// 辅助函数:发送请求 -async function request(url, options = {}) { - const token = localStorage.getItem('token'); - const headers = { - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - ...options.headers, - }; - - try { - const response = await fetch(url, { ...options, headers }); - const data = await response.json(); - console.log(`✅ ${options.method || 'GET'} ${url}`, data); - return data; - } catch (error) { - console.error(`❌ ${options.method || 'GET'} ${url}`, error); - throw error; - } -} - -// 测试函数 -const NotebookTest = { - // 1. 创建笔记 - async create() { - return await request(`${API_BASE}/create`, { - method: 'POST', - body: JSON.stringify({ - title: '测试笔记 - ' + new Date().toLocaleString(), - content: '

这是一条测试笔记

内容包含富文本格式

', - }), - }); - }, - - // 2. 获取笔记列表 - async list(params = {}) { - const query = new URLSearchParams({ - page: params.page || 1, - pageSize: params.pageSize || 20, - keyword: params.keyword || '', - }).toString(); - return await request(`${API_BASE}/list?${query}`); - }, - - // 3. 获取笔记详情 - async detail(id) { - return await request(`${API_BASE}/detail/${id}`); - }, - - // 4. 更新笔记 - async update(id, data) { - return await request(`${API_BASE}/update/${id}`, { - method: 'POST', - body: JSON.stringify(data), - }); - }, - - // 5. 删除笔记 - async delete(id) { - return await request(`${API_BASE}/delete/${id}`, { - method: 'DELETE', - }); - }, - - // 完整测试流程 - async runFullTest() { - console.log('🚀 开始测试记事本模块 API...\n'); - - try { - // 1. 创建笔记 - console.log('📝 测试1: 创建笔记'); - const createResult = await this.create(); - if (createResult.code !== 200) { - throw new Error('创建笔记失败'); - } - const noteId = createResult.data.id; - console.log(`✅ 创建成功,笔记ID: ${noteId}\n`); - - // 2. 获取笔记列表 - console.log('📋 测试2: 获取笔记列表'); - const listResult = await this.list(); - if (listResult.code !== 200) { - throw new Error('获取列表失败'); - } - console.log(`✅ 获取成功,共 ${listResult.data.total} 条笔记\n`); - - // 3. 获取笔记详情 - console.log('🔍 测试3: 获取笔记详情'); - const detailResult = await this.detail(noteId); - if (detailResult.code !== 200) { - throw new Error('获取详情失败'); - } - console.log(`✅ 获取成功,标题: ${detailResult.data.title}\n`); - - // 4. 更新笔记 - console.log('✏️ 测试4: 更新笔记'); - const updateResult = await this.update(noteId, { - title: '更新后的标题 - ' + new Date().toLocaleString(), - content: '

这是更新后的内容

', - }); - if (updateResult.code !== 200) { - throw new Error('更新笔记失败'); - } - console.log(`✅ 更新成功\n`); - - // 5. 搜索笔记 - console.log('🔎 测试5: 搜索笔记'); - const searchResult = await this.list({ keyword: '更新' }); - if (searchResult.code !== 200) { - throw new Error('搜索失败'); - } - console.log(`✅ 搜索成功,找到 ${searchResult.data.total} 条匹配笔记\n`); - - // 6. 删除笔记 - console.log('🗑️ 测试6: 删除笔记'); - const deleteResult = await this.delete(noteId); - if (deleteResult.code !== 200) { - throw new Error('删除笔记失败'); - } - console.log(`✅ 删除成功\n`); - - console.log('🎉 所有测试通过!'); - return true; - } catch (error) { - console.error('❌ 测试失败:', error); - return false; - } - }, -}; - -// 导出到全局 -window.NotebookTest = NotebookTest; - -console.log('📚 记事本模块测试工具已加载'); -console.log('使用方法:'); -console.log(' NotebookTest.create() - 创建笔记'); -console.log(' NotebookTest.list() - 获取列表'); -console.log(' NotebookTest.detail(id) - 获取详情'); -console.log(' NotebookTest.update(id, data) - 更新笔记'); -console.log(' NotebookTest.delete(id) - 删除笔记'); -console.log(' NotebookTest.runFullTest() - 运行完整测试'); diff --git a/platform/src/views/tools/reminder/components/detail.vue b/platform/src/views/tools/reminder/components/detail.vue new file mode 100644 index 0000000..f1ba278 --- /dev/null +++ b/platform/src/views/tools/reminder/components/detail.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/platform/src/views/tools/reminder/components/edit.vue b/platform/src/views/tools/reminder/components/edit.vue new file mode 100644 index 0000000..d6f211b --- /dev/null +++ b/platform/src/views/tools/reminder/components/edit.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/platform/src/views/tools/reminder/index.vue b/platform/src/views/tools/reminder/index.vue new file mode 100644 index 0000000..9952027 --- /dev/null +++ b/platform/src/views/tools/reminder/index.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/sql/upgrade_yz_system_reminderlist.sql b/sql/upgrade_yz_system_reminderlist.sql new file mode 100644 index 0000000..792b6be --- /dev/null +++ b/sql/upgrade_yz_system_reminderlist.sql @@ -0,0 +1,12 @@ +-- 升级已有的 yz_system_reminderlist 表,添加 batch_id 以及发送目标相关字段 +ALTER TABLE `yz_system_reminderlist` + ADD COLUMN `batch_id` varchar(64) NOT NULL DEFAULT '' COMMENT '批次号/分组ID' AFTER `delete_time`, + ADD COLUMN `target_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发送目标类型: platform, tenant_all, role, tenant' AFTER `batch_id`, + ADD COLUMN `target_role_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标角色ID' AFTER `target_type`, + ADD COLUMN `target_tenant_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标租户ID' AFTER `target_role_id`, + ADD INDEX `idx_batch` (`batch_id`); + +-- 初始化已有记录的 batch_id (如果存在空值,使用 create_time 和 sender_id 进行分组初始化) +UPDATE `yz_system_reminderlist` +SET `batch_id` = CONCAT(UNIX_TIMESTAMP(COALESCE(`create_time`, NOW())), '_', `sender_id`) +WHERE `batch_id` = '' OR `batch_id` IS NULL; diff --git a/sql/yz_system_sitereminder.sql b/sql/yz_system_sitereminder.sql new file mode 100644 index 0000000..e609d58 --- /dev/null +++ b/sql/yz_system_sitereminder.sql @@ -0,0 +1,36 @@ +-- 站内信配置表 +CREATE TABLE IF NOT EXISTS `yz_system_sitereminder` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `retention_days` int(11) NOT NULL DEFAULT '30' COMMENT '消息保留天数', + `auto_read` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否自动标记已读: 0-否, 1-是', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内信配置表'; + +-- 默认插入一条配置数据 +INSERT INTO `yz_system_sitereminder` (`id`, `retention_days`, `auto_read`, `create_time`, `update_time`) +VALUES (1, 30, 0, NOW(), NOW()) +ON DUPLICATE KEY UPDATE `update_time` = NOW(); + +-- 站内信消息列表表 +CREATE TABLE IF NOT EXISTS `yz_system_reminderlist` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `title` varchar(255) NOT NULL COMMENT '标题', + `content` text NOT NULL COMMENT '内容', + `sender_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '发送者ID (0为系统)', + `sender_type` varchar(32) NOT NULL DEFAULT 'system' COMMENT '发送者类型: system, platform, tenant', + `receiver_id` bigint(20) unsigned NOT NULL COMMENT '接收者ID', + `receiver_type` varchar(32) NOT NULL DEFAULT 'receiver' COMMENT '接收者类型: platform, tenant', + `is_read` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否已读: 0-未读, 1-已读', + `read_time` datetime DEFAULT NULL COMMENT '已读时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间/发送时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间', + `batch_id` varchar(64) NOT NULL DEFAULT '' COMMENT '批次号/分组ID', + `target_type` varchar(32) NOT NULL DEFAULT '' COMMENT '发送目标类型: platform, tenant_all, role, tenant', + `target_role_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标角色ID', + `target_tenant_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标租户ID', + PRIMARY KEY (`id`), + KEY `idx_receiver` (`receiver_type`, `receiver_id`, `is_read`), + KEY `idx_batch` (`batch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内信消息列表表';