├── README.md └── index.js /README.md: -------------------------------------------------------------------------------- 1 | # 事件通知系统 2 | 3 | > 一款基于 Cloudflare Workers 的轻量级事件通知系统。帮助您轻松跟踪各类订阅服务的到期时间,并通过多种渠道(微信、Telegram、邮件等)发送及时提醒。 4 | > 5 | > **本项目基于 [wangwangit/SubsTracker](https://github.com/wangwangit/SubsTracker) 项目进行二次开发,在原作者的优秀工作基础上,进行了功能增强和体验优化。在此向原作者表示诚挚的感谢!** 6 | 7 | Image 8 | 9 | Image 10 | 11 | Image 12 | 13 | ![Image](https://github.com/user-attachments/assets/8f33f9c3-85fa-48c5-a0dd-3872f16f292e) 14 | 15 | ![Image](https://github.com/user-attachments/assets/f4991bd0-a138-462a-9f6e-fd45cba508d0) 16 | 17 | ## ✨ 功能特色 18 | 19 | ### 🎯 核心功能 20 | - **事件管理**:轻松添加、编辑、删除各类事件服务。 21 | - **智能提醒**:支持自动计算下一个续订日期。 22 | - **状态管理**:自动识别并标记“已过期”状态,支持手动启用/停用订阅。 23 | - **手机浏览器优化**:方便在手机编辑管理。 24 | 25 | ### 📱 多渠道通知 26 | - **WXPusher**:WXPusher app推送。 27 | - **息知**:集成 实现微信消息推送 28 | - **NotifyX**:支持通过 NotifyX 发送通知。 29 | - **邮件**:支持域名邮箱发送。 30 | - **Telegram**:通过您的个人 Telegram Bot 发送通知。 31 | - **bark**:ios多个推送选择。 32 | 33 | ### 📱 加入黑夜模式 34 | 35 | Image 36 | 37 | ### 🎨 优秀的用户体验 38 | - **响应式设计**:完美适配桌面和移动设备,随时随地轻松管理。 39 | - **备注优化**:长备注内容自动截断,鼠标悬停即可查看全文。 40 | - **实时预览**:日期选择器会实时显示对应的农历日期。 41 | - **用户偏好**:系统会记住您的显示偏好 42 | 43 | --- 44 | ## ✨ 更新 45 | - **加入bark通知**:20250712 46 | - **到期时间加入时分**:默认到期日当天8点推送,方便除了订阅以外精确的事件推送 20250712(建议cf的执行时间改成每分钟调度,方便更多事件提醒) 47 | 48 | 49 | ## 🚀 快速部署 50 | 51 | ### 方式一:全新部署(推荐) 52 | 53 | 1. **Fork 本仓库**到您自己的 GitHub 账户。 54 | 2. 点击您仓库中的 "Deploy to Cloudflare Workers" 按钮进行一键部署。 55 | 56 | [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cometzhang/notify-worker) 3. 在 Cloudflare 的部署配置页面,**必须**设置 KV 命名空间绑定。 57 | > ⚠️ **重要提示**:在 "KV Namespace Bindings" 设置中,变量名称 (`Variable Name`) **必须**填写为 `SUBSCRIPTIONS_KV`,并选择或创建一个 KV 仓库作为值 (`KV Namespace`)。 58 | > 59 | mg width="1506" height="912" alt="Image" src="https://github.com/user-attachments/assets/0e0b4d0a-44aa-406f-a526-956a99a84557" /> 60 | > 61 | ### 方式二:更新现有部署 62 | 对于已部署过的用户,直接在 Cloudflare 后台的 Worker 编辑器中,将本项目最新的 JS 代码内容完整复制并替换旧代码即可。 63 | 64 | ## 部署完之后务必设置kv空间和设定执行时间,具体可以看下面的手动部署指南 65 | 66 | 67 | ## 🚀 手动部署指南 68 | 前提条件 69 | Cloudflare账户 70 | 可以直接将代码丢给AI,帮助查漏补缺 71 | 部署步骤 72 | 1.登陆cloudflare,创建worker,粘贴本项目中的js代码,点击部署 73 | 74 | Image 75 | 76 | Image 77 | 78 | Image 79 | 80 | 代码在index.js里面,直接复制替代原来的就行 81 | 82 | Image 83 | 84 | 2.创建KV键值 SUBSCRIPTIONS_KV 85 | 86 | Image 87 | 88 | Image 89 | 90 | 3、在worker项目里面绑定kv键值对 91 | 92 | Image 93 | 94 | Image 95 | 96 | Image 97 | 98 | Image 99 | 100 | 3.绑定自定义域名(才能在国内网络访问)最后设定设置定时执行时间!设定设置定时执行时间!设定设置定时执行时间! 才能正常推送。推荐设置* * * * *每分钟执行一次 101 | 102 | Image 103 | 104 | Image 105 | 106 | 4.打开worker提供的域名地址或者自定义域名,输入默认账号密码: admin password. 登录进入点右上角配置,修改账号密码,以及配置通知方式的信息 107 | 108 | Image 109 | 110 | 6.配置完成可以点击测试通知,查看是否能够正常通知,然后就可以正常添加订阅使用了! 111 | 112 | 113 | ## 🔧 通知渠道配置详解 114 | 115 | ### WXPusher 116 | - 前往 WXPusher 官网获取您的 `appToken` 和 `uid`。 117 | 118 | ### 邮件通知 119 | - **推送 URL**: 参考https://resend.com/login,绑定域名,申请api key填入 120 | 121 | ### Telegram Bot 122 | - **Bot Token**: 从 [@BotFather](https://t.me/BotFather) 获取。 123 | - **Chat ID**: 从 [@userinfobot](https://t.me/userinfobot) 获取您的个人 Chat ID。 124 | 125 | ### bark ios app 126 | - **复制填入即可 127 | - **Image 128 | 129 | 130 | 131 | 132 | ## 🙏 致谢 133 | 134 | 本项目是在 [wangwangit/SubsTracker](https://github.com/wangwangit/SubsTracker) 的基础上进行的二次开发。感谢原作者为社区带来的优秀项目,为本项目提供了坚实的基础和灵感。 135 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // 事件通知系统 - 基于CloudFlare Workers (V3.8) 2 | 3 | // 定义HTML模板 4 | const loginPage = ` 5 | 6 | 7 | 8 | 9 | 10 | 事件通知系统 11 | 12 | 13 | 40 | 41 | 42 |
43 |
44 |

事件通知系统

45 |
46 | 47 |
48 |
49 | 52 | 53 | 55 |
56 | 57 |
58 | 61 | 64 |
65 | 66 | 70 | 71 |
72 |
73 |
74 | 75 | 106 | 107 | 108 | `; 109 | const adminPage = ` 110 | 111 | 112 | 113 | 114 | 115 | 事件通知系统 116 | 117 | 118 | 247 | 248 | 249 |
250 | 251 | 277 | 278 |
279 |
280 |

事件列表

282 |
283 |
284 | 287 |
288 |
289 | 290 |
291 | 292 | 293 | 294 | 297 |
298 |
299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 |
名称类型 307 | 308 | 起止时间 309 | 状态操作
318 |
319 | 320 |
321 | 322 | 325 | 326 | 457 | 458 | 459 | 1084 | 1085 | 1086 | `; 1087 | 1088 | // ############################################################################# 1089 | // # Worker Logic 1090 | // ############################################################################# 1091 | 1092 | const configPage = ` 1093 | 1094 | 1095 | 1096 | 1097 | 1098 | 系统配置 - 事件通知系统 1099 | 1100 | 1101 | 1175 | 1176 | 1177 |
1178 | 1179 | 1205 | 1206 |
1207 |
1208 | 1209 |
1210 |
1211 |

管理员账户

1212 |

1213 | 1216 |
1217 | 1218 | 1219 |
1220 |

通知设置(可多选)

1221 |

1222 |
1223 | 1224 |
1225 | 1226 | WXPusher 1227 |
1228 | 1229 | 1230 | 1231 |
1232 |
1233 | 1234 |
1235 | 息知 1236 |
1237 | 1239 | 1240 |
1241 |
1242 | 1243 |
1244 | 1245 | NotifyX 1246 |
1247 | 1248 | 1249 | 1250 |
1251 |
1252 | 1253 |
1254 | 邮件 1255 |
1256 | 1258 | 1259 |
1260 |
1261 | 1262 |
1263 | Telegram 1264 | 1265 |
1266 | 1267 | 1268 |
1269 | 1270 |
1271 |
1272 | Bark 1273 |
1274 | 1275 | 1276 | 1277 |
1278 |
1279 |
1280 |
1281 | 1282 |
1283 | 1287 |
1288 |
1289 |
1290 |
1291 | 1292 |
1293 | 1319 | 1320 | 1353 | 1354 | 1377 | 1378 | 1400 | 1401 | 1437 | 1438 | 1472 | 1473 | 1474 | 1498 |
1499 | 1500 | 1745 | 1746 | 1747 | `; 1748 | 1749 | // ############################################################################# 1750 | // # Worker Logic 1751 | // ############################################################################# 1752 | 1753 | const admin = { 1754 | async handleRequest(request, env, ctx) { 1755 | const url = new URL(request.url); 1756 | const pathname = url.pathname; 1757 | 1758 | const token = getCookieValue(request.headers.get('Cookie'), 'token'); 1759 | const config = await getConfig(env); 1760 | const user = token ? 1761 | await verifyJWT(token, config.JWT_SECRET) : null; 1762 | 1763 | if (!user) { 1764 | return new Response('', { 1765 | status: 302, 1766 | headers: { 'Location': '/' } 1767 | }); 1768 | } 1769 | 1770 | if (pathname === '/admin/config') { 1771 | return new Response(configPage, { 1772 | headers: { 'Content-Type': 'text/html; charset=utf-8' } 1773 | }); 1774 | } 1775 | 1776 | return new Response(adminPage, { 1777 | headers: { 'Content-Type': 'text/html; charset=utf-8' } 1778 | }); 1779 | } 1780 | }; 1781 | 1782 | const api = { 1783 | async handleRequest(request, env, ctx) { 1784 | const url = new URL(request.url); 1785 | const path = url.pathname.slice(4); 1786 | const method = request.method; 1787 | 1788 | const config = await getConfig(env); 1789 | if (path === '/login' && method === 'POST') { 1790 | const body = await request.json(); 1791 | if (body.username === config.ADMIN_USERNAME && body.password === config.ADMIN_PASSWORD) { 1792 | const token = await generateJWT(body.username, config.JWT_SECRET); 1793 | return new Response( 1794 | JSON.stringify({ success: true }), 1795 | { 1796 | headers: { 1797 | 'Content-Type': 'application/json', 1798 | 'Set-Cookie': 'token=' + token + '; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400' 1799 | } 1800 | } 1801 | 1802 | ); 1803 | } else { 1804 | return new Response( 1805 | JSON.stringify({ success: false, message: '用户名或密码错误' }), 1806 | { headers: { 'Content-Type': 'application/json' } } 1807 | ); 1808 | } 1809 | } 1810 | 1811 | if (path === '/logout' && (method === 'GET' || method === 'POST')) { 1812 | return new Response('', { 1813 | status: 302, 1814 | headers: { 1815 | 'Location': '/', 1816 | 'Set-Cookie': 'token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0' 1817 | } 1818 | }); 1819 | } 1820 | 1821 | const token = getCookieValue(request.headers.get('Cookie'), 'token'); 1822 | const user = token ? 1823 | await verifyJWT(token, config.JWT_SECRET) : null; 1824 | 1825 | if (!user && path !== '/login') { 1826 | return new Response( 1827 | JSON.stringify({ success: false, message: '未授权访问' }), 1828 | { status: 401, headers: { 'Content-Type': 'application/json' } } 1829 | ); 1830 | } 1831 | 1832 | if (path === '/config') { 1833 | if (method === 'GET') { 1834 | const { JWT_SECRET, ADMIN_PASSWORD, ...safeConfig } = config; 1835 | return new Response( 1836 | JSON.stringify(safeConfig), 1837 | { headers: { 'Content-Type': 'application/json' } } 1838 | ); 1839 | } 1840 | 1841 | if (method === 'POST') { 1842 | try { 1843 | const newConfig = await request.json(); 1844 | const currentConfig = await getConfig(env); 1845 | 1846 | const updatedConfig = { 1847 | ...currentConfig, 1848 | ADMIN_USERNAME: newConfig.ADMIN_USERNAME, 1849 | NOTIFYX_API_KEY: newConfig.NOTIFYX_API_KEY, 1850 | WXPUSHER_APP_TOKEN: newConfig.WXPUSHER_APP_TOKEN, 1851 | WXPUSHER_UID: newConfig.WXPUSHER_UID, 1852 | WXPUSHER_TOPIC_ID: newConfig.WXPUSHER_TOPIC_ID, 1853 | TG_BOT_TOKEN: newConfig.TG_BOT_TOKEN, 1854 | 1855 | TG_CHAT_ID: newConfig.TG_CHAT_ID, 1856 | RESEND_API_KEY: newConfig.RESEND_API_KEY, 1857 | SENDER_EMAIL: newConfig.SENDER_EMAIL, 1858 | SENDER_NAME: newConfig.SENDER_NAME, 1859 | RECIPIENT_EMAIL: newConfig.RECIPIENT_EMAIL, 1860 | XIZHI_PUSH_URL: newConfig.XIZHI_PUSH_URL, 1861 | BARK_PUSH_URL: newConfig.BARK_PUSH_URL, 1862 | 1863 | ENABLED_NOTIFIERS: newConfig.ENABLED_NOTIFIERS 1864 | }; 1865 | if (newConfig.ADMIN_PASSWORD) { 1866 | updatedConfig.ADMIN_PASSWORD = newConfig.ADMIN_PASSWORD; 1867 | } 1868 | 1869 | await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig)); 1870 | return new Response( 1871 | JSON.stringify({ success: true }), 1872 | { headers: { 'Content-Type': 'application/json' } } 1873 | ); 1874 | } catch (error) { 1875 | console.error('保存配置失败:', error); 1876 | return new Response( 1877 | JSON.stringify({ success: false, message: '更新配置失败: ' + error.message }), 1878 | { status: 400, headers: { 'Content-Type': 'application/json' } } 1879 | ); 1880 | } 1881 | } 1882 | } 1883 | 1884 | if (path === '/test-notification' && method === 'POST') { 1885 | try { 1886 | const body = await request.json(); 1887 | let success = false; 1888 | 1889 | const title = '测试通知'; 1890 | const content = '这是一条来自事件通知系统的测试通知。\n\n发送时间: ' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); 1891 | if (body.type === 'notifyx') { 1892 | success = await sendNotifyXNotification(title, '## ' + title + '\n\n' + content, '测试通知', config); 1893 | } else if (body.type === 'wxpusher') { 1894 | success = await sendWXPusherNotification(title, content, config); 1895 | } else if (body.type === 'telegram') { 1896 | success = await sendTelegramNotification(title, `**${title}**\n\n${content}`, config); 1897 | } else if (body.type === 'email') { 1898 | success = await sendResendEmailNotification(title, content, config); 1899 | } else if (body.type === 'xizhi') { 1900 | success = await sendXiZhiNotification(title, content, config); 1901 | } else if (body.type === 'bark') { 1902 | success = await sendBarkNotification(title, content, config); 1903 | } 1904 | 1905 | const message = success ? 1906 | (body.type.toUpperCase() + ' 通知测试成功') : (body.type.toUpperCase() + ' 通知发送失败,请检查配置'); 1907 | 1908 | return new Response( 1909 | JSON.stringify({ success, message }), 1910 | { headers: { 'Content-Type': 'application/json' } } 1911 | ); 1912 | } catch (error) { 1913 | console.error('测试通知失败:', error); 1914 | return new Response( 1915 | JSON.stringify({ success: false, message: '测试通知失败: ' + error.message }), 1916 | { status: 500, headers: { 'Content-Type': 'application/json' } } 1917 | ); 1918 | } 1919 | } 1920 | 1921 | if (path === '/subscriptions') { 1922 | if (method === 'GET') { 1923 | const subscriptions = await getAllSubscriptions(env); 1924 | return new Response( 1925 | JSON.stringify(subscriptions), 1926 | { headers: { 'Content-Type': 'application/json' } } 1927 | ); 1928 | } 1929 | 1930 | if (method === 'POST') { 1931 | const subscription = await request.json(); 1932 | const result = await createSubscription(subscription, env); 1933 | return new Response( 1934 | JSON.stringify(result), 1935 | { status: result.success ? 201 : 400, headers: { 'Content-Type': 'application/json' } } 1936 | ); 1937 | } 1938 | } 1939 | 1940 | if (path.startsWith('/subscriptions/')) { 1941 | const parts = path.split('/'); 1942 | const id = parts[2]; 1943 | 1944 | if (parts[3] === 'toggle-status' && method === 'POST') { 1945 | const body = await request.json(); 1946 | const result = await toggleSubscriptionStatus(id, body.isActive, env); 1947 | return new Response( 1948 | JSON.stringify(result), 1949 | { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } } 1950 | ); 1951 | } 1952 | 1953 | if (parts[3] === 'test-notify' && method === 'POST') { 1954 | const result = await testSingleSubscriptionNotification(id, env); 1955 | return new Response(JSON.stringify(result), { status: result.success ? 200 : 500, headers: { 'Content-Type': 'application/json' } }); 1956 | } 1957 | 1958 | if (method === 'GET') { 1959 | const subscription = await getSubscription(id, env); 1960 | return new Response( 1961 | JSON.stringify(subscription), 1962 | { headers: { 'Content-Type': 'application/json' } } 1963 | ); 1964 | } 1965 | 1966 | if (method === 'PUT') { 1967 | const subscription = await request.json(); 1968 | const result = await updateSubscription(id, subscription, env); 1969 | return new Response( 1970 | JSON.stringify(result), 1971 | { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } } 1972 | ); 1973 | } 1974 | 1975 | if (method === 'DELETE') { 1976 | const result = await deleteSubscription(id, env); 1977 | return new Response( 1978 | JSON.stringify(result), 1979 | { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } } 1980 | ); 1981 | } 1982 | } 1983 | 1984 | return new Response( 1985 | JSON.stringify({ success: false, message: '未找到请求的资源' }), 1986 | { status: 404, headers: { 'Content-Type': 'application/json' } } 1987 | ); 1988 | } 1989 | }; 1990 | 1991 | async function getConfig(env) { 1992 | try { 1993 | const data = await env.SUBSCRIPTIONS_KV.get('config'); 1994 | const config = data ? JSON.parse(data) : {}; 1995 | 1996 | return { 1997 | ADMIN_USERNAME: config.ADMIN_USERNAME || 1998 | 'admin', 1999 | ADMIN_PASSWORD: config.ADMIN_PASSWORD || 'password', 2000 | JWT_SECRET: config.JWT_SECRET || 2001 | 'your-secret-key-change-me', 2002 | NOTIFYX_API_KEY: config.NOTIFYX_API_KEY || '', 2003 | WXPUSHER_APP_TOKEN: config.WXPUSHER_APP_TOKEN || 2004 | '', 2005 | WXPUSHER_UID: config.WXPUSHER_UID || '', 2006 | WXPUSHER_TOPIC_ID: config.WXPUSHER_TOPIC_ID || 2007 | '', 2008 | TG_BOT_TOKEN: config.TG_BOT_TOKEN || '', 2009 | TG_CHAT_ID: config.TG_CHAT_ID || 2010 | '', 2011 | RESEND_API_KEY: config.RESEND_API_KEY || '', 2012 | SENDER_EMAIL: config.SENDER_EMAIL || 2013 | '', 2014 | SENDER_NAME: config.SENDER_NAME || '事件通知系统', 2015 | RECIPIENT_EMAIL: config.RECIPIENT_EMAIL || 2016 | '', 2017 | XIZHI_PUSH_URL: config.XIZHI_PUSH_URL || '', 2018 | BARK_PUSH_URL: config.BARK_PUSH_URL || 2019 | '', 2020 | ENABLED_NOTIFIERS: config.ENABLED_NOTIFIERS || [] 2021 | }; 2022 | } catch (error) { 2023 | console.error("Failed to get or parse config from KV:", error); 2024 | return { 2025 | ADMIN_USERNAME: 'admin', 2026 | ADMIN_PASSWORD: 'password', 2027 | JWT_SECRET: 'your-secret-key-change-me', 2028 | NOTIFYX_API_KEY: '', 2029 | WXPUSHER_APP_TOKEN: '', 2030 | WXPUSHER_UID: '', 2031 | WXPUSHER_TOPIC_ID: '', 2032 | TG_BOT_TOKEN: '', 2033 | TG_CHAT_ID: '', 2034 | RESEND_API_KEY: '', 2035 | SENDER_EMAIL: '', 2036 | SENDER_NAME: '事件通知系统', 2037 | RECIPIENT_EMAIL: '', 2038 | XIZHI_PUSH_URL: '', 2039 | 2040 | BARK_PUSH_URL: '', 2041 | ENABLED_NOTIFIERS: [] 2042 | }; 2043 | } 2044 | } 2045 | 2046 | async function generateJWT(username, secret) { 2047 | const header = { alg: 'HS256', typ: 'JWT' }; 2048 | const payload = { username, iat: Math.floor(Date.now() / 1000) }; 2049 | 2050 | const headerBase64 = btoa(JSON.stringify(header)); 2051 | const payloadBase64 = btoa(JSON.stringify(payload)); 2052 | const signatureInput = headerBase64 + '.' + payloadBase64; 2053 | const signature = await CryptoJS.HmacSHA256(signatureInput, secret); 2054 | 2055 | return headerBase64 + '.' 2056 | + payloadBase64 + '.' + signature; 2057 | } 2058 | 2059 | async function verifyJWT(token, secret) { 2060 | try { 2061 | const parts = token.split('.'); 2062 | if (parts.length !== 3) return null; 2063 | 2064 | const [headerBase64, payloadBase64, signature] = parts; 2065 | const signatureInput = headerBase64 + '.' + payloadBase64; 2066 | const expectedSignature = await CryptoJS.HmacSHA256(signatureInput, secret); 2067 | 2068 | if (signature !== expectedSignature) return null; 2069 | 2070 | return JSON.parse(atob(payloadBase64)); 2071 | } catch (error) { 2072 | return null; 2073 | } 2074 | } 2075 | 2076 | async function getAllSubscriptions(env) { 2077 | try { 2078 | const data = await env.SUBSCRIPTIONS_KV.get('subscriptions'); 2079 | return data ? JSON.parse(data) : []; 2080 | } catch (error) { 2081 | console.error("Failed to get or parse subscriptions from KV:", error); 2082 | return []; 2083 | } 2084 | } 2085 | 2086 | async function getSubscription(id, env) { 2087 | const subscriptions = await getAllSubscriptions(env); 2088 | return subscriptions.find(s => s.id === id); 2089 | } 2090 | 2091 | async function createSubscription(subscription, env) { 2092 | try { 2093 | let subscriptions = await getAllSubscriptions(env); 2094 | if (!subscription.name || !subscription.expiryDate) { 2095 | return { success: false, message: '缺少必填字段' }; 2096 | } 2097 | 2098 | const newSubscription = { 2099 | id: Date.now().toString(), 2100 | name: subscription.name, 2101 | customType: subscription.customType || 2102 | '', 2103 | startDate: subscription.startDate || null, 2104 | expiryDate: subscription.expiryDate, 2105 | periodValue: parseInt(subscription.periodValue) || 2106 | 1, 2107 | periodUnit: subscription.periodUnit || 'month', 2108 | notes: subscription.notes || 2109 | '', 2110 | isActive: true, 2111 | autoRenew: subscription.autoRenew !== false, 2112 | customNotifyTimes: subscription.customNotifyTimes || 2113 | [], 2114 | sentCustomNotifications: [], 2115 | createdAt: new Date().toISOString() 2116 | }; 2117 | subscriptions.push(newSubscription); 2118 | 2119 | await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); 2120 | 2121 | return { success: true, subscription: newSubscription }; 2122 | } catch (error) { 2123 | console.error("创建事件失败:", error); 2124 | return { success: false, message: '创建事件失败: ' + error.message }; 2125 | } 2126 | } 2127 | 2128 | async function updateSubscription(id, subscription, env) { 2129 | try { 2130 | let subscriptions = await getAllSubscriptions(env); 2131 | const index = subscriptions.findIndex(s => s.id === id); 2132 | 2133 | if (index === -1) return { success: false, message: '事件不存在' }; 2134 | if (!subscription.name || !subscription.expiryDate) return { success: false, message: '缺少必填字段' }; 2135 | 2136 | const existingSub = subscriptions[index]; 2137 | const updatedSub = { 2138 | ...existingSub, 2139 | ...subscription, 2140 | autoRenew: subscription.autoRenew !== false, 2141 | customNotifyTimes: subscription.customNotifyTimes || 2142 | [], 2143 | updatedAt: new Date().toISOString() 2144 | }; 2145 | if (new Date(updatedSub.expiryDate) > new Date()) { 2146 | delete updatedSub.expiredNotificationSent; 2147 | delete updatedSub.sentCustomNotifications; 2148 | // 重置自定义通知发送记录 2149 | } 2150 | 2151 | subscriptions[index] = updatedSub; 2152 | 2153 | await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); 2154 | return { success: true, subscription: subscriptions[index] }; 2155 | } catch (error) { 2156 | console.error("更新事件失败:", error); 2157 | return { success: false, message: '更新事件失败: ' + error.message }; 2158 | } 2159 | } 2160 | 2161 | async function deleteSubscription(id, env) { 2162 | try { 2163 | let subscriptions = await getAllSubscriptions(env); 2164 | const filteredSubscriptions = subscriptions.filter(s => s.id !== id); 2165 | 2166 | if (filteredSubscriptions.length === subscriptions.length) { 2167 | return { success: false, message: '事件不存在' }; 2168 | } 2169 | 2170 | await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(filteredSubscriptions)); 2171 | return { success: true }; 2172 | } catch (error) { 2173 | console.error("删除事件失败:", error); 2174 | return { success: false, message: '删除事件失败: ' + error.message }; 2175 | } 2176 | } 2177 | 2178 | async function toggleSubscriptionStatus(id, isActive, env) { 2179 | try { 2180 | let subscriptions = await getAllSubscriptions(env); 2181 | const index = subscriptions.findIndex(s => s.id === id); 2182 | 2183 | if (index === -1) return { success: false, message: '事件不存在' }; 2184 | subscriptions[index].isActive = isActive; 2185 | subscriptions[index].updatedAt = new Date().toISOString(); 2186 | 2187 | await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); 2188 | return { success: true, subscription: subscriptions[index] }; 2189 | } catch (error) { 2190 | console.error("更新事件状态失败:", error); 2191 | return { success: false, message: '更新事件状态失败: ' + error.message }; 2192 | } 2193 | } 2194 | 2195 | async function sendNotifyXNotification(title, content, description, config) { 2196 | try { 2197 | if (!config.NOTIFYX_API_KEY) { 2198 | console.log('[NotifyX] 通知未配置'); 2199 | return false; 2200 | } 2201 | const url = 'https://www.notifyx.cn/api/v1/send/' + config.NOTIFYX_API_KEY; 2202 | const response = await fetch(url, { 2203 | method: 'POST', 2204 | headers: { 'Content-Type': 'application/json' }, 2205 | body: JSON.stringify({ title, content, description: description || '' }) 2206 | }); 2207 | const result = await response.json(); 2208 | if (result.status !== 'queued') console.error('[NotifyX] 发送结果失败:', result); 2209 | return result.status === 'queued'; 2210 | } catch (error) { 2211 | console.error('[NotifyX] 发送通知失败:', error); 2212 | return false; 2213 | } 2214 | } 2215 | 2216 | async function sendWXPusherNotification(title, content, config) { 2217 | try { 2218 | if (!config.WXPUSHER_APP_TOKEN || (!config.WXPUSHER_UID && !config.WXPUSHER_TOPIC_ID)) { 2219 | console.error('[WXPusher] 通知未配置 (缺少 AppToken, 或 UID/TopicID 都为空)'); 2220 | return false; 2221 | } 2222 | const url = 'https://wxpusher.zjiecode.com/api/send/message'; 2223 | const body = { 2224 | appToken: config.WXPUSHER_APP_TOKEN, 2225 | content: content, 2226 | summary: title, 2227 | contentType: 3, // Markdown 2228 | }; 2229 | if (config.WXPUSHER_UID) { 2230 | body.uids = config.WXPUSHER_UID.split(',').map(uid => uid.trim()).filter(uid => uid); 2231 | } 2232 | if (config.WXPUSHER_TOPIC_ID) { 2233 | body.topicIds = [config.WXPUSHER_TOPIC_ID]; 2234 | } 2235 | 2236 | const response = await fetch(url, { 2237 | method: 'POST', 2238 | headers: { 'Content-Type': 'application/json' }, 2239 | body: JSON.stringify(body) 2240 | }); 2241 | const result = await response.json(); 2242 | if (result.code !== 1000) console.error('[WXPusher] 发送结果失败:', result); 2243 | return result.code === 1000; 2244 | } catch (error) { 2245 | console.error('[WXPusher] 发送通知失败:', error); 2246 | return false; 2247 | } 2248 | } 2249 | 2250 | async function sendTelegramNotification(title, content, config) { 2251 | try { 2252 | if (!config.TG_BOT_TOKEN || !config.TG_CHAT_ID) { 2253 | console.log('[Telegram] 通知未配置'); 2254 | return false; 2255 | } 2256 | const url = `https://api.telegram.org/bot${config.TG_BOT_TOKEN}/sendMessage`; 2257 | const response = await fetch(url, { 2258 | method: 'POST', 2259 | headers: { 'Content-Type': 'application/json' }, 2260 | body: JSON.stringify({ 2261 | chat_id: config.TG_CHAT_ID, 2262 | text: content, 2263 | parse_mode: 'Markdown' 2264 | }) 2265 | }); 2266 | const result = await response.json(); 2267 | if (!result.ok) console.error('[Telegram] 发送结果失败:', result); 2268 | return result.ok; 2269 | } catch (error) { 2270 | console.error('[Telegram] 发送通知失败:', error); 2271 | return false; 2272 | } 2273 | } 2274 | 2275 | async function sendResendEmailNotification(title, content, config) { 2276 | if (!config.RESEND_API_KEY || !config.SENDER_EMAIL || !config.RECIPIENT_EMAIL) { 2277 | console.error('[Email] Resend 配置不完整'); 2278 | return false; 2279 | } 2280 | const senderName = config.SENDER_NAME || '事件通知系统'; 2281 | try { 2282 | const response = await fetch('https://api.resend.com/emails', { 2283 | method: 'POST', 2284 | headers: { 2285 | 'Content-Type': 'application/json', 2286 | 'Authorization': `Bearer ${config.RESEND_API_KEY}`, 2287 | }, 2288 | body: 2289 | JSON.stringify({ 2290 | from: `${senderName} <${config.SENDER_EMAIL}>`, 2291 | to: [config.RECIPIENT_EMAIL], 2292 | subject: title, 2293 | html: content.replace(/\n/g, '
'), 2294 | }) 2295 | }); 2296 | const data = await response.json(); 2297 | if (data.id) { 2298 | return true; 2299 | } else { 2300 | console.error('[Email] Resend API 错误:', data); 2301 | return false; 2302 | } 2303 | } catch (error) { 2304 | console.error('[Email] 发送邮件失败:', error); 2305 | return false; 2306 | } 2307 | } 2308 | 2309 | 2310 | async function sendXiZhiNotification(title, content, config) { 2311 | try { 2312 | if (!config.XIZHI_PUSH_URL) { 2313 | console.log('[XiZhi] 推送URL未配置'); 2314 | return false; 2315 | } 2316 | const url = config.XIZHI_PUSH_URL; 2317 | const response = await fetch(url, { 2318 | method: 'POST', 2319 | headers: { 'Content-Type': 'application/json' }, 2320 | body: JSON.stringify({ title, content }) 2321 | }); 2322 | const result = await response.json(); 2323 | if (result.code !== 200) console.error('[XiZhi] 发送结果失败:', result); 2324 | return result.code === 200; 2325 | } catch (error) { 2326 | console.error('[XiZhi] 发送通知失败:', error); 2327 | return false; 2328 | } 2329 | } 2330 | 2331 | async function sendBarkNotification(title, content, config) { 2332 | try { 2333 | if (!config.BARK_PUSH_URL) { 2334 | console.log('[Bark] 推送URL未配置'); 2335 | return false; 2336 | } 2337 | const url = `${config.BARK_PUSH_URL.replace(/\/$/, '')}/${encodeURIComponent(title)}/${encodeURIComponent(content)}`; 2338 | const response = await fetch(url, { 2339 | method: 'GET', 2340 | }); 2341 | if (response.ok) { 2342 | console.log('[Bark] 通知发送成功'); 2343 | return true; 2344 | } else { 2345 | const errorText = await response.text(); 2346 | console.error(`[Bark] 发送失败: Status ${response.status}`, errorText); 2347 | return false; 2348 | } 2349 | } catch (error) { 2350 | console.error('[Bark] 发送通知时发生网络错误:', error); 2351 | return false; 2352 | } 2353 | } 2354 | 2355 | async function sendToAllEnabledChannels(title, content, description, config) { 2356 | const promises = []; 2357 | if (!config.ENABLED_NOTIFIERS || config.ENABLED_NOTIFIERS.length === 0) { 2358 | console.log("No notification channels enabled."); 2359 | return; 2360 | } 2361 | 2362 | if (config.ENABLED_NOTIFIERS.includes('wxpusher')) { 2363 | const mdContent = '### ' + title + '\n\n' + content; 2364 | promises.push(sendWXPusherNotification(title, mdContent, config)); 2365 | } 2366 | if (config.ENABLED_NOTIFIERS.includes('xizhi')) { 2367 | const xizhiContent = content.replace(/🚨|⚠️|📅/g, '').replace(/\*\*/g, ''); 2368 | promises.push(sendXiZhiNotification(title, xizhiContent, config)); 2369 | } 2370 | if (config.ENABLED_NOTIFIERS.includes('notifyx')) { 2371 | promises.push(sendNotifyXNotification(title, '## ' + title + '\n\n' + content, description, config)); 2372 | } 2373 | if (config.ENABLED_NOTIFIERS.includes('email')) { 2374 | promises.push(sendResendEmailNotification(title, content, config)); 2375 | } 2376 | if (config.ENABLED_NOTIFIERS.includes('telegram')) { 2377 | const tgContent = `*${title}*\n\n${content.replace(/\*\*/g, '*')}`; 2378 | promises.push(sendTelegramNotification(title, tgContent, config)); 2379 | } 2380 | if (config.ENABLED_NOTIFIERS.includes('bark')) { 2381 | const barkContent = content.replace(/🚨|⚠️|📅/g, '').replace(/\*\*/g, ''); 2382 | promises.push(sendBarkNotification(title, barkContent, config)); 2383 | } 2384 | 2385 | await Promise.all(promises); 2386 | } 2387 | 2388 | async function testSingleSubscriptionNotification(id, env) { 2389 | try { 2390 | const subscription = await getSubscription(id, env); 2391 | if (!subscription) return { success: false, message: '未找到该事件' }; 2392 | 2393 | const config = await getConfig(env); 2394 | const title = '手动测试通知: ' + subscription.name; 2395 | const description = '这是一个对事件 "' + subscription.name + '" 的手动测试通知。'; 2396 | const expiryDateBJT = new Date(subscription.expiryDate).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); 2397 | let content = `**事件详情**:\n- **类型**: ${subscription.customType || 2398 | '其他'}\n- **到期日**: ${expiryDateBJT}\n- **备注**: ${subscription.notes || '无'}`; 2399 | await sendToAllEnabledChannels(title, content, description, config); 2400 | return { success: true, message: '测试通知已发送至所有已启用的渠道' }; 2401 | } catch (error) { 2402 | console.error('[手动测试] 发送失败:', error); 2403 | return { success: false, message: '发送时发生错误: ' + error.message }; 2404 | } 2405 | } 2406 | 2407 | async function handleScheduledTasks(env) { 2408 | const config = await getConfig(env); 2409 | try { 2410 | let subscriptions = await getAllSubscriptions(env); 2411 | const now_utc = new Date(); 2412 | 2413 | let notificationsToSend = []; 2414 | let hasUpdates = false; 2415 | 2416 | // PATCH 2: 修复通知逻辑 2417 | for (const sub of subscriptions) { 2418 | if (sub.isActive === false) continue; 2419 | 2420 | const originalExpiryDate = new Date(sub.expiryDate); // 捕获原始到期日 2421 | const now_utc = new Date(); // 2422 | 2423 | // --- 修复点 1: 逻辑顺序调整 --- 2424 | // 先检查过期状态,并使用 *原始* 的 sub 对象生成通知 2425 | const hasExpired = originalExpiryDate < now_utc; 2426 | if (hasExpired && !sub.expiredNotificationSent) { 2427 | notificationsToSend.push({ type: '已到期', sub: { ...sub }}); // 推送 *当前* 的 sub 副本 2428 | sub.expiredNotificationSent = true; 2429 | hasUpdates = true; 2430 | } 2431 | 2432 | // --- 修复点 2: 自动续订逻辑 --- 2433 | // 检查过期后,再处理自动续订 2434 | if (originalExpiryDate < now_utc && sub.autoRenew !== false) { // 2435 | let newExpiryDate = new Date(originalExpiryDate); 2436 | while (newExpiryDate < now_utc) { 2437 | const periodValue = sub.periodValue || 1; 2438 | if (sub.periodUnit === 'day') newExpiryDate.setDate(newExpiryDate.getDate() + periodValue); 2439 | else if (sub.periodUnit === 'week') newExpiryDate.setDate(newExpiryDate.getDate() + periodValue * 7); 2440 | else if (sub.periodUnit === 'month') newExpiryDate.setMonth(newExpiryDate.getMonth() + periodValue); 2441 | else if (sub.periodUnit === 'year') newExpiryDate.setFullYear(newExpiryDate.getFullYear() + periodValue); 2442 | else break; 2443 | } 2444 | sub.expiryDate = newExpiryDate.toISOString(); // 更新到期日 2445 | // 为新的周期重置标志 2446 | delete sub.expiredNotificationSent; 2447 | delete sub.sentCustomNotifications; 2448 | hasUpdates = true; 2449 | } 2450 | 2451 | // 自定义时间通知逻辑 (保持不变) 2452 | if (sub.customNotifyTimes && Array.isArray(sub.customNotifyTimes)) { 2453 | sub.sentCustomNotifications = sub.sentCustomNotifications || []; 2454 | for (const notifyTimeISO of sub.customNotifyTimes) { 2455 | const notifyTime = new Date(notifyTimeISO); 2456 | if (now_utc >= notifyTime && !sub.sentCustomNotifications.includes(notifyTimeISO)) { 2457 | notificationsToSend.push({ type: '自定义提醒', sub: { ...sub }, notifyTime: notifyTimeISO }); 2458 | sub.sentCustomNotifications.push(notifyTimeISO); 2459 | hasUpdates = true; 2460 | } 2461 | } 2462 | } 2463 | } // for 循环结束 2464 | // PATCH 2: 修复通知逻辑 结束 2465 | 2466 | if (hasUpdates) { 2467 | await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); 2468 | } 2469 | 2470 | if (notificationsToSend.length > 0) { 2471 | const uniqueNotifications = new Map(); 2472 | notificationsToSend.forEach(item => { 2473 | const key = item.sub.id + '_' + item.type + '_' + (item.notifyTime || ''); 2474 | if (!uniqueNotifications.has(key)) { 2475 | uniqueNotifications.set(key, item); 2476 | } 2477 | }); 2478 | const content = Array.from(uniqueNotifications.values()) 2479 | .sort((a, b) => new Date(a.sub.expiryDate) - new Date(b.sub.expiryDate)) 2480 | .map(item => { 2481 | let statusText = ''; 2482 | const expiryDateFormatted = new Date(item.sub.expiryDate).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); 2483 | 2484 | if (item.type === '已到期') { 2485 | statusText = `🚨 **${item.sub.name}** 已到期`; 2486 | 2487 | } else if (item.type === '自定义提醒') { 2488 | statusText = `📅 **${item.sub.name}** 自定义提醒`; 2489 | } 2490 | 2491 | statusText += `\n 到期日: ${expiryDateFormatted}`; 2492 | if (item.sub.notes) statusText += `\n 备注: ${item.sub.notes}`; 2493 | 2494 | 2495 | return statusText; 2496 | }).join('\n\n'); 2497 | if (content) { 2498 | const title = '事件提醒'; 2499 | await sendToAllEnabledChannels(title, content, title, config); 2500 | } 2501 | } 2502 | } catch (error) { 2503 | console.error('[定时任务] 检查事件失败:', error); 2504 | await sendToAllEnabledChannels('事件提醒任务失败', '检查过程中发生错误: ' + error.message, '任务失败', config); 2505 | } 2506 | } 2507 | 2508 | 2509 | function getCookieValue(cookieString, key) { 2510 | if (!cookieString) return null; 2511 | const match = cookieString.match(new RegExp('(^| )' + key + '=([^;]+)')); 2512 | return match ? match[2] : null; 2513 | } 2514 | 2515 | async function handleRequest(request, env, ctx) { 2516 | return new Response(loginPage, { 2517 | headers: { 'Content-Type': 'text/html; charset=utf-8' } 2518 | }); 2519 | } 2520 | 2521 | const CryptoJS = { 2522 | HmacSHA256: function(message, key) { 2523 | const keyData = new TextEncoder().encode(key); 2524 | const messageData = new TextEncoder().encode(message); 2525 | 2526 | return crypto.subtle.importKey( 2527 | "raw", 2528 | keyData, 2529 | { name: "HMAC", hash: {name: "SHA-256"} }, 2530 | false, 2531 | ["sign"] 2532 | ).then(cryptoKey => { 2533 | return crypto.subtle.sign("HMAC", cryptoKey, messageData); 2534 | }).then(buffer => { 2535 | const hashArray = Array.from(new Uint8Array(buffer)); 2536 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 2537 | }); 2538 | } 2539 | }; 2540 | 2541 | export default { 2542 | async fetch(request, env, ctx) { 2543 | const url = new URL(request.url); 2544 | if (url.pathname.startsWith('/api')) { 2545 | return api.handleRequest(request, env, ctx); 2546 | } else if (url.pathname.startsWith('/admin')) { 2547 | return admin.handleRequest(request, env, ctx); 2548 | } else { 2549 | return handleRequest(request, env, ctx); 2550 | } 2551 | }, 2552 | 2553 | async scheduled(event, env, ctx) { 2554 | ctx.waitUntil(handleScheduledTasks(env)); 2555 | } 2556 | }; 2557 | --------------------------------------------------------------------------------