// 订阅续期通知网站 - 基于CloudFlare Workers (完全优化版) // 时区处理工具函数 // 常量:毫秒转换为小时/天,便于全局复用 const MS_PER_HOUR = 1000 * 60 * 60; const MS_PER_DAY = MS_PER_HOUR * 24; function getCurrentTimeInTimezone(timezone = 'UTC') { try { // Workers 环境下 Date 始终存储 UTC 时间,这里直接返回当前时间对象 return new Date(); } catch (error) { console.error(`时区转换错误: ${error.message}`); // 如果时区无效,返回UTC时间 return new Date(); } } function getTimestampInTimezone(timezone = 'UTC') { return getCurrentTimeInTimezone(timezone).getTime(); } function convertUTCToTimezone(utcTime, timezone = 'UTC') { try { // 同 getCurrentTimeInTimezone,一律返回 Date 供后续统一处理 return new Date(utcTime); } catch (error) { console.error(`时区转换错误: ${error.message}`); return new Date(utcTime); } } // 获取指定时区的年/月/日/时/分/秒,便于避免重复的 Intl 解析逻辑 function getTimezoneDateParts(date, timezone = 'UTC') { try { const formatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); const parts = formatter.formatToParts(date); const pick = (type) => { const part = parts.find(item => item.type === type); return part ? Number(part.value) : 0; }; return { year: pick('year'), month: pick('month'), day: pick('day'), hour: pick('hour'), minute: pick('minute'), second: pick('second') }; } catch (error) { console.error(`解析时区(${timezone})失败: ${error.message}`); return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate(), hour: date.getUTCHours(), minute: date.getUTCMinutes(), second: date.getUTCSeconds() }; } } // 计算指定日期在目标时区的午夜时间戳(毫秒),用于统一的“剩余天数”计算 function getTimezoneMidnightTimestamp(date, timezone = 'UTC') { const { year, month, day } = getTimezoneDateParts(date, timezone); return Date.UTC(year, month - 1, day, 0, 0, 0); } function formatTimeInTimezone(time, timezone = 'UTC', format = 'full') { try { const date = new Date(time); if (format === 'date') { return date.toLocaleDateString('zh-CN', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }); } else if (format === 'datetime') { return date.toLocaleString('zh-CN', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); } else { // full format return date.toLocaleString('zh-CN', { timeZone: timezone }); } } catch (error) { console.error(`时间格式化错误: ${error.message}`); return new Date(time).toISOString(); } } function getTimezoneOffset(timezone = 'UTC') { try { const now = new Date(); const { year, month, day, hour, minute, second } = getTimezoneDateParts(now, timezone); const zonedTimestamp = Date.UTC(year, month - 1, day, hour, minute, second); return Math.round((zonedTimestamp - now.getTime()) / MS_PER_HOUR); } catch (error) { console.error(`获取时区偏移量错误: ${error.message}`); return 0; } } // 格式化时区显示,包含UTC偏移 function formatTimezoneDisplay(timezone = 'UTC') { try { const offset = getTimezoneOffset(timezone); const offsetStr = offset >= 0 ? `+${offset}` : `${offset}`; // 时区中文名称映射 const timezoneNames = { 'UTC': '世界标准时间', 'Asia/Shanghai': '中国标准时间', 'Asia/Hong_Kong': '香港时间', 'Asia/Taipei': '台北时间', 'Asia/Singapore': '新加坡时间', 'Asia/Tokyo': '日本时间', 'Asia/Seoul': '韩国时间', 'America/New_York': '美国东部时间', 'America/Los_Angeles': '美国太平洋时间', 'America/Chicago': '美国中部时间', 'America/Denver': '美国山地时间', 'Europe/London': '英国时间', 'Europe/Paris': '巴黎时间', 'Europe/Berlin': '柏林时间', 'Europe/Moscow': '莫斯科时间', 'Australia/Sydney': '悉尼时间', 'Australia/Melbourne': '墨尔本时间', 'Pacific/Auckland': '奥克兰时间' }; const timezoneName = timezoneNames[timezone] || timezone; return `${timezoneName} (UTC${offsetStr})`; } catch (error) { console.error('格式化时区显示失败:', error); return timezone; } } // 兼容性函数 - 保持原有接口 function formatBeijingTime(date = new Date(), format = 'full') { return formatTimeInTimezone(date, 'Asia/Shanghai', format); } // 时区处理中间件函数 function extractTimezone(request) { // 优先级:URL参数 > 请求头 > 默认值 const url = new URL(request.url); const timezoneParam = url.searchParams.get('timezone'); if (timezoneParam) { return timezoneParam; } // 从请求头获取时区 const timezoneHeader = request.headers.get('X-Timezone'); if (timezoneHeader) { return timezoneHeader; } // 从Accept-Language头推断时区(简化处理) const acceptLanguage = request.headers.get('Accept-Language'); if (acceptLanguage) { // 简单的时区推断逻辑 if (acceptLanguage.includes('zh')) { return 'Asia/Shanghai'; } else if (acceptLanguage.includes('en-US')) { return 'America/New_York'; } else if (acceptLanguage.includes('en-GB')) { return 'Europe/London'; } } // 默认返回UTC return 'UTC'; } function isValidTimezone(timezone) { try { // 尝试使用该时区格式化时间 new Date().toLocaleString('en-US', { timeZone: timezone }); return true; } catch (error) { return false; } } // 农历转换工具函数 const lunarCalendar = { // 农历数据 (1900-2100年) lunarInfo: [ 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, // 1900-1909 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, // 1910-1919 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, // 1920-1929 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, // 1930-1939 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, // 1940-1949 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5b0, 0x14573, 0x052b0, 0x0a9a8, 0x0e950, 0x06aa0, // 1950-1959 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, // 1960-1969 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b6a0, 0x195a6, // 1970-1979 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, // 1980-1989 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x055c0, 0x0ab60, 0x096d5, 0x092e0, // 1990-1999 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, // 2000-2009 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, // 2010-2019 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, // 2020-2029 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, // 2030-2039 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, // 2040-2049 0x14b63, 0x09370, 0x14a38, 0x04970, 0x064b0, 0x168a6, 0x0ea50, 0x1a978, 0x16aa0, 0x0a6c0, // 2050-2059 (修正2057: 0x1a978) 0x0aa60, 0x16d63, 0x0d260, 0x0d950, 0x0d554, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, // 2060-2069 0x025d0, 0x092d0, 0x0cab5, 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, // 2070-2079 0x15176, 0x052b0, 0x0a930, 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, // 2080-2089 0x0d260, 0x0ea65, 0x0d530, 0x05aa0, 0x076a3, 0x096d0, 0x04afb, 0x1a4bb, 0x0a4d0, 0x0d0b0, // 2090-2099 (修正2099: 0x0d0b0) 0x0d250 // 2100 ], // 天干地支 gan: ['甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'], zhi: ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'], // 农历月份 months: ['正', '二', '三', '四', '五', '六', '七', '八', '九', '十', '冬', '腊'], // 农历日期 days: ['初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十', '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十', '廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十'], // 获取农历年天数 lunarYearDays: function(year) { let sum = 348; for (let i = 0x8000; i > 0x8; i >>= 1) { sum += (this.lunarInfo[year - 1900] & i) ? 1 : 0; } return sum + this.leapDays(year); }, // 获取闰月天数 leapDays: function(year) { if (this.leapMonth(year)) { return (this.lunarInfo[year - 1900] & 0x10000) ? 30 : 29; } return 0; }, // 获取闰月月份 leapMonth: function(year) { return this.lunarInfo[year - 1900] & 0xf; }, // 获取农历月天数 monthDays: function(year, month) { return (this.lunarInfo[year - 1900] & (0x10000 >> month)) ? 30 : 29; }, // 公历转农历 solar2lunar: function(year, month, day) { if (year < 1900 || year > 2100) return null; const baseDate = Date.UTC(1900, 0, 31); const objDate = Date.UTC(year, month - 1, day); //let offset = Math.floor((objDate - baseDate) / 86400000); let offset = Math.round((objDate - baseDate) / 86400000); let temp = 0; let lunarYear = 1900; for (lunarYear = 1900; lunarYear < 2101 && offset > 0; lunarYear++) { temp = this.lunarYearDays(lunarYear); offset -= temp; } if (offset < 0) { offset += temp; lunarYear--; } let lunarMonth = 1; let leap = this.leapMonth(lunarYear); let isLeap = false; for (lunarMonth = 1; lunarMonth < 13 && offset > 0; lunarMonth++) { if (leap > 0 && lunarMonth === (leap + 1) && !isLeap) { --lunarMonth; isLeap = true; temp = this.leapDays(lunarYear); } else { temp = this.monthDays(lunarYear, lunarMonth); } if (isLeap && lunarMonth === (leap + 1)) isLeap = false; offset -= temp; } if (offset === 0 && leap > 0 && lunarMonth === leap + 1) { if (isLeap) { isLeap = false; } else { isLeap = true; --lunarMonth; } } if (offset < 0) { offset += temp; --lunarMonth; } const lunarDay = offset + 1; // 生成农历字符串 const ganIndex = (lunarYear - 4) % 10; const zhiIndex = (lunarYear - 4) % 12; const yearStr = this.gan[ganIndex] + this.zhi[zhiIndex] + '年'; const monthStr = (isLeap ? '闰' : '') + this.months[lunarMonth - 1] + '月'; const dayStr = this.days[lunarDay - 1]; return { year: lunarYear, month: lunarMonth, day: lunarDay, isLeap: isLeap, yearStr: yearStr, monthStr: monthStr, dayStr: dayStr, fullStr: yearStr + monthStr + dayStr }; } }; // 1. 新增 lunarBiz 工具模块,支持农历加周期、农历转公历、农历距离天数 const lunarBiz = { // 农历加周期,返回新的农历日期对象 addLunarPeriod(lunar, periodValue, periodUnit) { let { year, month, day, isLeap } = lunar; if (periodUnit === 'year') { year += periodValue; const leap = lunarCalendar.leapMonth(year); if (isLeap && leap === month) { isLeap = true; } else { isLeap = false; } } else if (periodUnit === 'month') { let totalMonths = (year - 1900) * 12 + (month - 1) + periodValue; year = Math.floor(totalMonths / 12) + 1900; month = (totalMonths % 12) + 1; const leap = lunarCalendar.leapMonth(year); if (isLeap && leap === month) { isLeap = true; } else { isLeap = false; } } else if (periodUnit === 'day') { const solar = lunarBiz.lunar2solar(lunar); const date = new Date(solar.year, solar.month - 1, solar.day + periodValue); return lunarCalendar.solar2lunar(date.getFullYear(), date.getMonth() + 1, date.getDate()); } let maxDay = isLeap ? lunarCalendar.leapDays(year) : lunarCalendar.monthDays(year, month); let targetDay = Math.min(day, maxDay); while (targetDay > 0) { let solar = lunarBiz.lunar2solar({ year, month, day: targetDay, isLeap }); if (solar) { return { year, month, day: targetDay, isLeap }; } targetDay--; } return { year, month, day, isLeap }; }, // 农历转公历(遍历法,适用1900-2100年) lunar2solar(lunar) { for (let y = lunar.year - 1; y <= lunar.year + 1; y++) { for (let m = 1; m <= 12; m++) { for (let d = 1; d <= 31; d++) { const date = new Date(y, m - 1, d); if (date.getFullYear() !== y || date.getMonth() + 1 !== m || date.getDate() !== d) continue; const l = lunarCalendar.solar2lunar(y, m, d); if ( l && l.year === lunar.year && l.month === lunar.month && l.day === lunar.day && l.isLeap === lunar.isLeap ) { return { year: y, month: m, day: d }; } } } } return null; }, // 距离农历日期还有多少天 daysToLunar(lunar) { const solar = lunarBiz.lunar2solar(lunar); const date = new Date(solar.year, solar.month - 1, solar.day); const now = new Date(); return Math.ceil((date - now) / (1000 * 60 * 60 * 24)); } }; // === 新增:主题模式公共资源 (CSS覆盖 + JS逻辑) === const themeResources = ` `; // 定义HTML模板 const loginPage = ` 订阅管理系统 ${themeResources}

订阅管理系统

登录管理您的订阅提醒

`; const adminPage = ` 订阅管理系统 ${themeResources}

订阅列表

使用搜索与分类快速定位订阅,开启农历显示可同步查看农历日期

名称 类型 到期 金额 提醒 状态 操作
`; const configPage = ` 系统配置 - 订阅管理系统 ${themeResources}

系统配置

管理员账户

留空表示不修改当前密码

显示设置

选择系统的外观风格

控制是否在通知消息中包含农历日期信息

时区设置

选择需要使用时区,系统会按该时区计算剩余时间(提醒 Cron 仍基于 UTC,请在 Cloudflare 控制台换算触发时间)

通知设置

可输入多个小时,使用逗号或空格分隔;留空则默认每天执行一次任务即可

提示

Cloudflare Workers Cron 以 UTC 计算,例如北京时间 08:00 需设置 Cron 为 0 0 * * * 并在此填入 08。

若 Cron 已设置为每小时执行,可用该字段限制实际发送提醒的小时段。

调用 /api/notify/{token} 接口时需携带此令牌;留空表示禁用第三方 API 推送。

Telegram 配置

NotifyX 配置

NotifyX平台 获取的 API Key

Webhook 通知 配置

请填写自建服务或第三方平台提供的 Webhook 地址,例如 https://your-webhook-endpoint.com/path

JSON格式的自定义请求头,留空使用默认

支持变量: {{title}}, {{content}}, {{timestamp}}。留空使用默认格式

企业微信机器人 配置

从企业微信群聊中添加机器人获取的 Webhook URL

选择发送的消息格式类型

需要@的手机号,多个用逗号分隔,留空则不@任何人

邮件通知 配置

Resend控制台 获取的 API Key

必须是已在Resend验证的域名邮箱

显示在邮件中的发件人名称

接收通知邮件的邮箱地址

Bark 配置

Bark 服务器地址,默认为官方服务器,也可以使用自建服务器

Bark iOS 应用 中获取的设备Key

勾选后推送消息会保存到 Bark 的历史记录中

`; // 管理页面 // 与前端一致的分类切割正则,用于提取标签信息 const CATEGORY_SEPARATOR_REGEX = /[\/,,\s]+/; function dashboardPage() { return ` 仪表盘 - SubsTracker ${themeResources}

📊 仪表板

订阅费用和活动概览(统计金额已折合为 CNY)

最近支付

过去7天

即将续费

未来7天

按类型支出排行

年度统计 (折合CNY)

按分类支出统计

年度统计 (折合CNY)
`; } function extractTagsFromSubscriptions(subscriptions = []) { const tagSet = new Set(); (subscriptions || []).forEach(sub => { if (!sub || typeof sub !== 'object') { return; } if (Array.isArray(sub.tags)) { sub.tags.forEach(tag => { if (typeof tag === 'string' && tag.trim().length > 0) { tagSet.add(tag.trim()); } }); } if (typeof sub.category === 'string') { sub.category.split(CATEGORY_SEPARATOR_REGEX) .map(tag => tag.trim()) .filter(tag => tag.length > 0) .forEach(tag => tagSet.add(tag)); } if (typeof sub.customType === 'string' && sub.customType.trim().length > 0) { tagSet.add(sub.customType.trim()); } }); return Array.from(tagSet); } const admin = { async handleRequest(request, env, ctx) { try { const url = new URL(request.url); const pathname = url.pathname; console.log('[管理页面] 访问路径:', pathname); const token = getCookieValue(request.headers.get('Cookie'), 'token'); console.log('[管理页面] Token存在:', !!token); const config = await getConfig(env); const user = token ? await verifyJWT(token, config.JWT_SECRET) : null; console.log('[管理页面] 用户验证结果:', !!user); if (!user) { console.log('[管理页面] 用户未登录,重定向到登录页面'); return new Response('', { status: 302, headers: { 'Location': '/' } }); } if (pathname === '/admin/config') { return new Response(configPage, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } if (pathname === '/admin/dashboard') { return new Response(dashboardPage(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } return new Response(adminPage, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } catch (error) { console.error('[管理页面] 处理请求时出错:', error); return new Response('服务器内部错误', { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); } } }; // 处理API请求 const api = { async handleRequest(request, env, ctx) { const url = new URL(request.url); const path = url.pathname.slice(4); const method = request.method; const config = await getConfig(env); if (path === '/login' && method === 'POST') { const body = await request.json(); if (body.username === config.ADMIN_USERNAME && body.password === config.ADMIN_PASSWORD) { const token = await generateJWT(body.username, config.JWT_SECRET); return new Response( JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json', 'Set-Cookie': 'token=' + token + '; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400' } } ); } else { return new Response( JSON.stringify({ success: false, message: '用户名或密码错误' }), { headers: { 'Content-Type': 'application/json' } } ); } } if (path === '/logout' && (method === 'GET' || method === 'POST')) { return new Response('', { status: 302, headers: { 'Location': '/', 'Set-Cookie': 'token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0' } }); } const token = getCookieValue(request.headers.get('Cookie'), 'token'); const user = token ? await verifyJWT(token, config.JWT_SECRET) : null; if (!user && path !== '/login') { return new Response( JSON.stringify({ success: false, message: '未授权访问' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } if (path === '/config') { if (method === 'GET') { const { JWT_SECRET, ADMIN_PASSWORD, ...safeConfig } = config; return new Response( JSON.stringify(safeConfig), { headers: { 'Content-Type': 'application/json' } } ); } if (method === 'POST') { try { const newConfig = await request.json(); const updatedConfig = { ...config, ADMIN_USERNAME: newConfig.ADMIN_USERNAME || config.ADMIN_USERNAME, THEME_MODE: newConfig.THEME_MODE || 'system', // 保存主题配置 TG_BOT_TOKEN: newConfig.TG_BOT_TOKEN || '', TG_CHAT_ID: newConfig.TG_CHAT_ID || '', NOTIFYX_API_KEY: newConfig.NOTIFYX_API_KEY || '', WEBHOOK_URL: newConfig.WEBHOOK_URL || '', WEBHOOK_METHOD: newConfig.WEBHOOK_METHOD || 'POST', WEBHOOK_HEADERS: newConfig.WEBHOOK_HEADERS || '', WEBHOOK_TEMPLATE: newConfig.WEBHOOK_TEMPLATE || '', SHOW_LUNAR: newConfig.SHOW_LUNAR === true, WECHATBOT_WEBHOOK: newConfig.WECHATBOT_WEBHOOK || '', WECHATBOT_MSG_TYPE: newConfig.WECHATBOT_MSG_TYPE || 'text', WECHATBOT_AT_MOBILES: newConfig.WECHATBOT_AT_MOBILES || '', WECHATBOT_AT_ALL: newConfig.WECHATBOT_AT_ALL || 'false', RESEND_API_KEY: newConfig.RESEND_API_KEY || '', EMAIL_FROM: newConfig.EMAIL_FROM || '', EMAIL_FROM_NAME: newConfig.EMAIL_FROM_NAME || '', EMAIL_TO: newConfig.EMAIL_TO || '', BARK_DEVICE_KEY: newConfig.BARK_DEVICE_KEY || '', BARK_SERVER: newConfig.BARK_SERVER || 'https://api.day.app', BARK_IS_ARCHIVE: newConfig.BARK_IS_ARCHIVE || 'false', ENABLED_NOTIFIERS: newConfig.ENABLED_NOTIFIERS || ['notifyx'], TIMEZONE: newConfig.TIMEZONE || config.TIMEZONE || 'UTC', THIRD_PARTY_API_TOKEN: newConfig.THIRD_PARTY_API_TOKEN || '' }; const rawNotificationHours = Array.isArray(newConfig.NOTIFICATION_HOURS) ? newConfig.NOTIFICATION_HOURS : typeof newConfig.NOTIFICATION_HOURS === 'string' ? newConfig.NOTIFICATION_HOURS.split(',') : []; const sanitizedNotificationHours = rawNotificationHours .map(value => String(value).trim()) .filter(value => value.length > 0) .map(value => { const upperValue = value.toUpperCase(); if (upperValue === '*' || upperValue === 'ALL') { return '*'; } const numeric = Number(upperValue); if (!isNaN(numeric)) { return String(Math.max(0, Math.min(23, Math.floor(numeric)))).padStart(2, '0'); } return upperValue; }); updatedConfig.NOTIFICATION_HOURS = sanitizedNotificationHours; if (newConfig.ADMIN_PASSWORD) { updatedConfig.ADMIN_PASSWORD = newConfig.ADMIN_PASSWORD; } // 确保JWT_SECRET存在且安全 if (!updatedConfig.JWT_SECRET || updatedConfig.JWT_SECRET === 'your-secret-key') { updatedConfig.JWT_SECRET = generateRandomSecret(); console.log('[安全] 生成新的JWT密钥'); } await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig)); return new Response( JSON.stringify({ success: true }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { console.error('配置保存错误:', error); return new Response( JSON.stringify({ success: false, message: '更新配置失败: ' + error.message }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } } } if (path === '/dashboard/stats' && method === 'GET') { try { const subscriptions = await getAllSubscriptions(env); const timezone = config?.TIMEZONE || 'UTC'; const rates = await getDynamicRates(env); // 获取动态汇率 const monthlyExpense = calculateMonthlyExpense(subscriptions, timezone, rates); const yearlyExpense = calculateYearlyExpense(subscriptions, timezone, rates); const recentPayments = getRecentPayments(subscriptions, timezone); // 不需要汇率 const upcomingRenewals = getUpcomingRenewals(subscriptions, timezone); // 不需要汇率 const expenseByType = getExpenseByType(subscriptions, timezone, rates); const expenseByCategory = getExpenseByCategory(subscriptions, timezone, rates); const activeSubscriptions = subscriptions.filter(s => s.isActive); const now = getCurrentTimeInTimezone(timezone); const sevenDaysLater = new Date(now.getTime() + 7 * MS_PER_DAY); const expiringSoon = activeSubscriptions.filter(s => { const expiryDate = new Date(s.expiryDate); return expiryDate >= now && expiryDate <= sevenDaysLater; }).length; return new Response( JSON.stringify({ success: true, data: { monthlyExpense, yearlyExpense, activeSubscriptions: { active: activeSubscriptions.length, total: subscriptions.length, expiringSoon }, recentPayments, upcomingRenewals, expenseByType, expenseByCategory } }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { console.error('获取仪表盘统计失败:', error); return new Response( JSON.stringify({ success: false, message: '获取统计数据失败: ' + error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } } if (path === '/test-notification' && method === 'POST') { try { const body = await request.json(); let success = false; let message = ''; if (body.type === 'telegram') { const testConfig = { ...config, TG_BOT_TOKEN: body.TG_BOT_TOKEN, TG_CHAT_ID: body.TG_CHAT_ID }; const content = '*测试通知*\n\n这是一条测试通知,用于验证Telegram通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); success = await sendTelegramNotification(content, testConfig); message = success ? 'Telegram通知发送成功' : 'Telegram通知发送失败,请检查配置'; } else if (body.type === 'notifyx') { const testConfig = { ...config, NOTIFYX_API_KEY: body.NOTIFYX_API_KEY }; const title = '测试通知'; const content = '## 这是一条测试通知\n\n用于验证NotifyX通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); const description = '测试NotifyX通知功能'; success = await sendNotifyXNotification(title, content, description, testConfig); message = success ? 'NotifyX通知发送成功' : 'NotifyX通知发送失败,请检查配置'; } else if (body.type === 'webhook') { const testConfig = { ...config, WEBHOOK_URL: body.WEBHOOK_URL, WEBHOOK_METHOD: body.WEBHOOK_METHOD, WEBHOOK_HEADERS: body.WEBHOOK_HEADERS, WEBHOOK_TEMPLATE: body.WEBHOOK_TEMPLATE }; const title = '测试通知'; const content = '这是一条测试通知,用于验证Webhook 通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); success = await sendWebhookNotification(title, content, testConfig); message = success ? 'Webhook 通知发送成功' : 'Webhook 通知发送失败,请检查配置'; } else if (body.type === 'wechatbot') { const testConfig = { ...config, WECHATBOT_WEBHOOK: body.WECHATBOT_WEBHOOK, WECHATBOT_MSG_TYPE: body.WECHATBOT_MSG_TYPE, WECHATBOT_AT_MOBILES: body.WECHATBOT_AT_MOBILES, WECHATBOT_AT_ALL: body.WECHATBOT_AT_ALL }; const title = '测试通知'; const content = '这是一条测试通知,用于验证企业微信机器人功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); success = await sendWechatBotNotification(title, content, testConfig); message = success ? '企业微信机器人通知发送成功' : '企业微信机器人通知发送失败,请检查配置'; } else if (body.type === 'email') { const testConfig = { ...config, RESEND_API_KEY: body.RESEND_API_KEY, EMAIL_FROM: body.EMAIL_FROM, EMAIL_FROM_NAME: body.EMAIL_FROM_NAME, EMAIL_TO: body.EMAIL_TO }; const title = '测试通知'; const content = '这是一条测试通知,用于验证邮件通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); success = await sendEmailNotification(title, content, testConfig); message = success ? '邮件通知发送成功' : '邮件通知发送失败,请检查配置'; } else if (body.type === 'bark') { const testConfig = { ...config, BARK_SERVER: body.BARK_SERVER, BARK_DEVICE_KEY: body.BARK_DEVICE_KEY, BARK_IS_ARCHIVE: body.BARK_IS_ARCHIVE }; const title = '测试通知'; const content = '这是一条测试通知,用于验证Bark通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); success = await sendBarkNotification(title, content, testConfig); message = success ? 'Bark通知发送成功' : 'Bark通知发送失败,请检查配置'; } return new Response( JSON.stringify({ success, message }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { console.error('测试通知失败:', error); return new Response( JSON.stringify({ success: false, message: '测试通知失败: ' + error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } } if (path === '/subscriptions') { if (method === 'GET') { const subscriptions = await getAllSubscriptions(env); return new Response( JSON.stringify(subscriptions), { headers: { 'Content-Type': 'application/json' } } ); } if (method === 'POST') { const subscription = await request.json(); const result = await createSubscription(subscription, env); return new Response( JSON.stringify(result), { status: result.success ? 201 : 400, headers: { 'Content-Type': 'application/json' } } ); } } if (path.startsWith('/subscriptions/')) { const parts = path.split('/'); const id = parts[2]; if (parts[3] === 'toggle-status' && method === 'POST') { const body = await request.json(); const result = await toggleSubscriptionStatus(id, body.isActive, env); return new Response( JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } } ); } if (parts[3] === 'test-notify' && method === 'POST') { const result = await testSingleSubscriptionNotification(id, env); return new Response(JSON.stringify(result), { status: result.success ? 200 : 500, headers: { 'Content-Type': 'application/json' } }); } if (parts[3] === 'renew' && method === 'POST') { let options = {}; try { const body = await request.json(); options = body || {}; } catch (e) { // 如果没有请求体,使用默认空对象 } const result = await manualRenewSubscription(id, env, options); return new Response(JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } }); } if (parts[3] === 'payments' && method === 'GET') { const subscription = await getSubscription(id, env); if (!subscription) { return new Response(JSON.stringify({ success: false, message: '订阅不存在' }), { status: 404, headers: { 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ success: true, payments: subscription.paymentHistory || [] }), { headers: { 'Content-Type': 'application/json' } }); } if (parts[3] === 'payments' && parts[4] && method === 'DELETE') { const paymentId = parts[4]; const result = await deletePaymentRecord(id, paymentId, env); return new Response(JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } }); } if (parts[3] === 'payments' && parts[4] && method === 'PUT') { const paymentId = parts[4]; const paymentData = await request.json(); const result = await updatePaymentRecord(id, paymentId, paymentData, env); return new Response(JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } }); } if (method === 'GET') { const subscription = await getSubscription(id, env); return new Response( JSON.stringify(subscription), { headers: { 'Content-Type': 'application/json' } } ); } if (method === 'PUT') { const subscription = await request.json(); const result = await updateSubscription(id, subscription, env); return new Response( JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } } ); } if (method === 'DELETE') { const result = await deleteSubscription(id, env); return new Response( JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } } ); } } // 处理第三方通知API if (path.startsWith('/notify/')) { const pathSegments = path.split('/'); // 允许通过路径、Authorization 头或查询参数三种方式传入访问令牌 const tokenFromPath = pathSegments[2] || ''; const tokenFromHeader = (request.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '').trim(); const tokenFromQuery = url.searchParams.get('token') || ''; const providedToken = tokenFromPath || tokenFromHeader || tokenFromQuery; const expectedToken = config.THIRD_PARTY_API_TOKEN || ''; if (!expectedToken) { return new Response( JSON.stringify({ message: '第三方 API 已禁用,请在后台配置访问令牌后使用' }), { status: 403, headers: { 'Content-Type': 'application/json' } } ); } if (!providedToken || providedToken !== expectedToken) { return new Response( JSON.stringify({ message: '访问未授权,令牌无效或缺失' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } if (method === 'POST') { try { const body = await request.json(); const title = body.title || '第三方通知'; const content = body.content || ''; if (!content) { return new Response( JSON.stringify({ message: '缺少必填参数 content' }), { status: 400, headers: { 'Content-Type': 'application/json' } } ); } const config = await getConfig(env); const bodyTagsRaw = Array.isArray(body.tags) ? body.tags : (typeof body.tags === 'string' ? body.tags.split(/[,,\s]+/) : []); const bodyTags = Array.isArray(bodyTagsRaw) ? bodyTagsRaw.filter(tag => typeof tag === 'string' && tag.trim().length > 0).map(tag => tag.trim()) : []; // 使用多渠道发送通知 await sendNotificationToAllChannels(title, content, config, '[第三方API]', { metadata: { tags: bodyTags } }); return new Response( JSON.stringify({ message: '发送成功', response: { errcode: 0, errmsg: 'ok', msgid: 'MSGID' + Date.now() } }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { console.error('[第三方API] 发送通知失败:', error); return new Response( JSON.stringify({ message: '发送失败', response: { errcode: 1, errmsg: error.message } }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } } } return new Response( JSON.stringify({ success: false, message: '未找到请求的资源' }), { status: 404, headers: { 'Content-Type': 'application/json' } } ); } }; // 工具函数 function generateRandomSecret() { // 生成一个64字符的随机密钥 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; let result = ''; for (let i = 0; i < 64; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } async function getConfig(env) { try { if (!env.SUBSCRIPTIONS_KV) { console.error('[配置] KV存储未绑定'); throw new Error('KV存储未绑定'); } const data = await env.SUBSCRIPTIONS_KV.get('config'); console.log('[配置] 从KV读取配置:', data ? '成功' : '空配置'); const config = data ? JSON.parse(data) : {}; // 确保JWT_SECRET的一致性 let jwtSecret = config.JWT_SECRET; if (!jwtSecret || jwtSecret === 'your-secret-key') { jwtSecret = generateRandomSecret(); console.log('[配置] 生成新的JWT密钥'); // 保存新的JWT密钥 const updatedConfig = { ...config, JWT_SECRET: jwtSecret }; await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig)); } const finalConfig = { ADMIN_USERNAME: config.ADMIN_USERNAME || 'admin', ADMIN_PASSWORD: config.ADMIN_PASSWORD || 'password', JWT_SECRET: jwtSecret, TG_BOT_TOKEN: config.TG_BOT_TOKEN || '', TG_CHAT_ID: config.TG_CHAT_ID || '', NOTIFYX_API_KEY: config.NOTIFYX_API_KEY || '', WEBHOOK_URL: config.WEBHOOK_URL || '', WEBHOOK_METHOD: config.WEBHOOK_METHOD || 'POST', WEBHOOK_HEADERS: config.WEBHOOK_HEADERS || '', WEBHOOK_TEMPLATE: config.WEBHOOK_TEMPLATE || '', SHOW_LUNAR: config.SHOW_LUNAR === true, WECHATBOT_WEBHOOK: config.WECHATBOT_WEBHOOK || '', WECHATBOT_MSG_TYPE: config.WECHATBOT_MSG_TYPE || 'text', WECHATBOT_AT_MOBILES: config.WECHATBOT_AT_MOBILES || '', WECHATBOT_AT_ALL: config.WECHATBOT_AT_ALL || 'false', RESEND_API_KEY: config.RESEND_API_KEY || '', EMAIL_FROM: config.EMAIL_FROM || '', EMAIL_FROM_NAME: config.EMAIL_FROM_NAME || '', EMAIL_TO: config.EMAIL_TO || '', BARK_DEVICE_KEY: config.BARK_DEVICE_KEY || '', BARK_SERVER: config.BARK_SERVER || 'https://api.day.app', BARK_IS_ARCHIVE: config.BARK_IS_ARCHIVE || 'false', ENABLED_NOTIFIERS: config.ENABLED_NOTIFIERS || ['notifyx'], THEME_MODE: config.THEME_MODE || 'system', // 默认主题为跟随系统 TIMEZONE: config.TIMEZONE || 'UTC', // 新增时区字段 NOTIFICATION_HOURS: Array.isArray(config.NOTIFICATION_HOURS) ? config.NOTIFICATION_HOURS : [], THIRD_PARTY_API_TOKEN: config.THIRD_PARTY_API_TOKEN || '' }; console.log('[配置] 最终配置用户名:', finalConfig.ADMIN_USERNAME); return finalConfig; } catch (error) { console.error('[配置] 获取配置失败:', error); const defaultJwtSecret = generateRandomSecret(); return { ADMIN_USERNAME: 'admin', ADMIN_PASSWORD: 'password', JWT_SECRET: defaultJwtSecret, TG_BOT_TOKEN: '', TG_CHAT_ID: '', NOTIFYX_API_KEY: '', WEBHOOK_URL: '', WEBHOOK_METHOD: 'POST', WEBHOOK_HEADERS: '', WEBHOOK_TEMPLATE: '', SHOW_LUNAR: true, WECHATBOT_WEBHOOK: '', WECHATBOT_MSG_TYPE: 'text', WECHATBOT_AT_MOBILES: '', WECHATBOT_AT_ALL: 'false', RESEND_API_KEY: '', EMAIL_FROM: '', EMAIL_FROM_NAME: '', EMAIL_TO: '', ENABLED_NOTIFIERS: ['notifyx'], NOTIFICATION_HOURS: [], TIMEZONE: 'UTC', // 新增时区字段 THIRD_PARTY_API_TOKEN: '' }; } } async function generateJWT(username, secret) { const header = { alg: 'HS256', typ: 'JWT' }; const payload = { username, iat: Math.floor(Date.now() / 1000) }; const headerBase64 = btoa(JSON.stringify(header)); const payloadBase64 = btoa(JSON.stringify(payload)); const signatureInput = headerBase64 + '.' + payloadBase64; const signature = await CryptoJS.HmacSHA256(signatureInput, secret); return headerBase64 + '.' + payloadBase64 + '.' + signature; } async function verifyJWT(token, secret) { try { if (!token || !secret) { console.log('[JWT] Token或Secret为空'); return null; } const parts = token.split('.'); if (parts.length !== 3) { console.log('[JWT] Token格式错误,部分数量:', parts.length); return null; } const [headerBase64, payloadBase64, signature] = parts; const signatureInput = headerBase64 + '.' + payloadBase64; const expectedSignature = await CryptoJS.HmacSHA256(signatureInput, secret); if (signature !== expectedSignature) { console.log('[JWT] 签名验证失败'); return null; } const payload = JSON.parse(atob(payloadBase64)); console.log('[JWT] 验证成功,用户:', payload.username); return payload; } catch (error) { console.error('[JWT] 验证过程出错:', error); return null; } } async function getAllSubscriptions(env) { try { const data = await env.SUBSCRIPTIONS_KV.get('subscriptions'); return data ? JSON.parse(data) : []; } catch (error) { return []; } } async function getSubscription(id, env) { const subscriptions = await getAllSubscriptions(env); return subscriptions.find(s => s.id === id); } // 2. 修改 createSubscription,支持 useLunar 字段 async function createSubscription(subscription, env) { try { const subscriptions = await getAllSubscriptions(env); if (!subscription.name || !subscription.expiryDate) { return { success: false, message: '缺少必填字段' }; } let expiryDate = new Date(subscription.expiryDate); const config = await getConfig(env); const timezone = config?.TIMEZONE || 'UTC'; const currentTime = getCurrentTimeInTimezone(timezone); let useLunar = !!subscription.useLunar; if (useLunar) { let lunar = lunarCalendar.solar2lunar( expiryDate.getFullYear(), expiryDate.getMonth() + 1, expiryDate.getDate() ); if (lunar && subscription.periodValue && subscription.periodUnit) { // 如果到期日<=今天,自动推算到下一个周期 while (expiryDate <= currentTime) { lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit); const solar = lunarBiz.lunar2solar(lunar); expiryDate = new Date(solar.year, solar.month - 1, solar.day); } subscription.expiryDate = expiryDate.toISOString(); } } else { if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) { while (expiryDate < currentTime) { if (subscription.periodUnit === 'day') { expiryDate.setDate(expiryDate.getDate() + subscription.periodValue); } else if (subscription.periodUnit === 'month') { expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue); } else if (subscription.periodUnit === 'year') { expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue); } } subscription.expiryDate = expiryDate.toISOString(); } } const reminderSetting = resolveReminderSetting(subscription); const initialPaymentDate = subscription.startDate || currentTime.toISOString(); const newSubscription = { id: Date.now().toString(), // 前端使用本地时间戳 name: subscription.name, subscriptionMode: subscription.subscriptionMode || 'cycle', // 默认循环订阅 customType: subscription.customType || '', category: subscription.category ? subscription.category.trim() : '', startDate: subscription.startDate || null, expiryDate: subscription.expiryDate, periodValue: subscription.periodValue || 1, periodUnit: subscription.periodUnit || 'month', reminderUnit: reminderSetting.unit, reminderValue: reminderSetting.value, reminderDays: reminderSetting.unit === 'day' ? reminderSetting.value : undefined, reminderHours: reminderSetting.unit === 'hour' ? reminderSetting.value : undefined, notes: subscription.notes || '', amount: subscription.amount || null, currency: subscription.currency || 'CNY', // 使用传入的币种,默认为CNY lastPaymentDate: initialPaymentDate, paymentHistory: subscription.amount ? [{ id: Date.now().toString(), date: initialPaymentDate, amount: subscription.amount, type: 'initial', note: '初始订阅', periodStart: subscription.startDate || initialPaymentDate, periodEnd: subscription.expiryDate }] : [], isActive: subscription.isActive !== false, autoRenew: subscription.autoRenew !== false, useLunar: useLunar, createdAt: new Date().toISOString() }; subscriptions.push(newSubscription); await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); return { success: true, subscription: newSubscription }; } catch (error) { console.error("创建订阅异常:", error && error.stack ? error.stack : error); return { success: false, message: error && error.message ? error.message : '创建订阅失败' }; } } // 3. 修改 updateSubscription,支持 useLunar 字段 async function updateSubscription(id, subscription, env) { try { const subscriptions = await getAllSubscriptions(env); const index = subscriptions.findIndex(s => s.id === id); if (index === -1) { return { success: false, message: '订阅不存在' }; } if (!subscription.name || !subscription.expiryDate) { return { success: false, message: '缺少必填字段' }; } let expiryDate = new Date(subscription.expiryDate); const config = await getConfig(env); const timezone = config?.TIMEZONE || 'UTC'; const currentTime = getCurrentTimeInTimezone(timezone); let useLunar = !!subscription.useLunar; if (useLunar) { let lunar = lunarCalendar.solar2lunar( expiryDate.getFullYear(), expiryDate.getMonth() + 1, expiryDate.getDate() ); if (!lunar) { return { success: false, message: '农历日期超出支持范围(1900-2100年)' }; } if (lunar && expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) { // 新增:循环加周期,直到 expiryDate > currentTime do { lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit); const solar = lunarBiz.lunar2solar(lunar); expiryDate = new Date(solar.year, solar.month - 1, solar.day); } while (expiryDate < currentTime); subscription.expiryDate = expiryDate.toISOString(); } } else { if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) { while (expiryDate < currentTime) { if (subscription.periodUnit === 'day') { expiryDate.setDate(expiryDate.getDate() + subscription.periodValue); } else if (subscription.periodUnit === 'month') { expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue); } else if (subscription.periodUnit === 'year') { expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue); } } subscription.expiryDate = expiryDate.toISOString(); } } const reminderSource = { reminderUnit: subscription.reminderUnit !== undefined ? subscription.reminderUnit : subscriptions[index].reminderUnit, reminderValue: subscription.reminderValue !== undefined ? subscription.reminderValue : subscriptions[index].reminderValue, reminderHours: subscription.reminderHours !== undefined ? subscription.reminderHours : subscriptions[index].reminderHours, reminderDays: subscription.reminderDays !== undefined ? subscription.reminderDays : subscriptions[index].reminderDays }; const reminderSetting = resolveReminderSetting(reminderSource); const oldSubscription = subscriptions[index]; const newAmount = subscription.amount !== undefined ? subscription.amount : oldSubscription.amount; let paymentHistory = oldSubscription.paymentHistory || []; if (newAmount !== oldSubscription.amount) { const initialPaymentIndex = paymentHistory.findIndex(p => p.type === 'initial'); if (initialPaymentIndex !== -1) { paymentHistory[initialPaymentIndex] = { ...paymentHistory[initialPaymentIndex], amount: newAmount }; } } subscriptions[index] = { ...subscriptions[index], name: subscription.name, subscriptionMode: subscription.subscriptionMode || subscriptions[index].subscriptionMode || 'cycle', // 如果没有提供 subscriptionMode,则使用旧的 subscriptionMode customType: subscription.customType || subscriptions[index].customType || '', category: subscription.category !== undefined ? subscription.category.trim() : (subscriptions[index].category || ''), startDate: subscription.startDate || subscriptions[index].startDate, expiryDate: subscription.expiryDate, periodValue: subscription.periodValue || subscriptions[index].periodValue || 1, periodUnit: subscription.periodUnit || subscriptions[index].periodUnit || 'month', reminderUnit: reminderSetting.unit, reminderValue: reminderSetting.value, reminderDays: reminderSetting.unit === 'day' ? reminderSetting.value : undefined, reminderHours: reminderSetting.unit === 'hour' ? reminderSetting.value : undefined, notes: subscription.notes || '', amount: newAmount, // 使用新的变量 currency: subscription.currency || subscriptions[index].currency || 'CNY', // 更新币种 lastPaymentDate: subscriptions[index].lastPaymentDate || subscriptions[index].startDate || subscriptions[index].createdAt || currentTime.toISOString(), paymentHistory: paymentHistory, // 保存更新后的支付历史 isActive: subscription.isActive !== undefined ? subscription.isActive : subscriptions[index].isActive, autoRenew: subscription.autoRenew !== undefined ? subscription.autoRenew : (subscriptions[index].autoRenew !== undefined ? subscriptions[index].autoRenew : true), useLunar: useLunar, updatedAt: new Date().toISOString() }; await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); return { success: true, subscription: subscriptions[index] }; } catch (error) { return { success: false, message: '更新订阅失败' }; } } async function deleteSubscription(id, env) { try { const subscriptions = await getAllSubscriptions(env); const filteredSubscriptions = subscriptions.filter(s => s.id !== id); if (filteredSubscriptions.length === subscriptions.length) { return { success: false, message: '订阅不存在' }; } await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(filteredSubscriptions)); return { success: true }; } catch (error) { return { success: false, message: '删除订阅失败' }; } } async function manualRenewSubscription(id, env, options = {}) { try { const subscriptions = await getAllSubscriptions(env); const index = subscriptions.findIndex(s => s.id === id); if (index === -1) { return { success: false, message: '订阅不存在' }; } const subscription = subscriptions[index]; if (!subscription.periodValue || !subscription.periodUnit) { return { success: false, message: '订阅未设置续订周期' }; } const config = await getConfig(env); const timezone = config?.TIMEZONE || 'UTC'; const currentTime = getCurrentTimeInTimezone(timezone); const todayMidnight = getTimezoneMidnightTimestamp(currentTime, timezone); // 参数处理 const paymentDate = options.paymentDate ? new Date(options.paymentDate) : currentTime; const amount = options.amount !== undefined ? options.amount : subscription.amount || 0; const periodMultiplier = options.periodMultiplier || 1; const note = options.note || '手动续订'; const mode = subscription.subscriptionMode || 'cycle'; // 获取订阅模式 let newStartDate; let currentExpiryDate = new Date(subscription.expiryDate); // 1. 确定新的周期起始日 (New Start Date) if (mode === 'reset') { // 重置模式:忽略旧的到期日,从今天(或支付日)开始 newStartDate = new Date(paymentDate); } else { // 循环模式 (Cycle) // 如果当前还没过期,从旧的 expiryDate 接着算 (无缝衔接) // 如果已经过期了,为了避免补交过去空窗期的费,通常从今天开始算(或者你可以选择补齐,这里采用通用逻辑:过期则从今天开始) if (currentExpiryDate.getTime() > paymentDate.getTime()) { newStartDate = new Date(currentExpiryDate); } else { newStartDate = new Date(paymentDate); } } // 2. 计算新的到期日 (New Expiry Date) let newExpiryDate; if (subscription.useLunar) { // 农历逻辑 const solarStart = { year: newStartDate.getFullYear(), month: newStartDate.getMonth() + 1, day: newStartDate.getDate() }; let lunar = lunarCalendar.solar2lunar(solarStart.year, solarStart.month, solarStart.day); let nextLunar = lunar; for (let i = 0; i < periodMultiplier; i++) { nextLunar = lunarBiz.addLunarPeriod(nextLunar, subscription.periodValue, subscription.periodUnit); } const solar = lunarBiz.lunar2solar(nextLunar); newExpiryDate = new Date(solar.year, solar.month - 1, solar.day); } else { // 公历逻辑 newExpiryDate = new Date(newStartDate); const totalPeriodValue = subscription.periodValue * periodMultiplier; if (subscription.periodUnit === 'day') { newExpiryDate.setDate(newExpiryDate.getDate() + totalPeriodValue); } else if (subscription.periodUnit === 'month') { newExpiryDate.setMonth(newExpiryDate.getMonth() + totalPeriodValue); } else if (subscription.periodUnit === 'year') { newExpiryDate.setFullYear(newExpiryDate.getFullYear() + totalPeriodValue); } } const paymentRecord = { id: Date.now().toString(), date: paymentDate.toISOString(), amount: amount, type: 'manual', note: note, periodStart: newStartDate.toISOString(), // 记录实际的计费开始日 periodEnd: newExpiryDate.toISOString() }; const paymentHistory = subscription.paymentHistory || []; paymentHistory.push(paymentRecord); subscriptions[index] = { ...subscription, startDate: newStartDate.toISOString(), // 关键修复:更新 startDate,这样下次编辑时,Start + Period = Expiry 成立 expiryDate: newExpiryDate.toISOString(), lastPaymentDate: paymentDate.toISOString(), paymentHistory }; await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); return { success: true, subscription: subscriptions[index], message: '续订成功' }; } catch (error) { console.error('手动续订失败:', error); return { success: false, message: '续订失败: ' + error.message }; } } async function deletePaymentRecord(subscriptionId, paymentId, env) { try { const subscriptions = await getAllSubscriptions(env); const index = subscriptions.findIndex(s => s.id === subscriptionId); if (index === -1) { return { success: false, message: '订阅不存在' }; } const subscription = subscriptions[index]; const paymentHistory = subscription.paymentHistory || []; const paymentIndex = paymentHistory.findIndex(p => p.id === paymentId); if (paymentIndex === -1) { return { success: false, message: '支付记录不存在' }; } const deletedPayment = paymentHistory[paymentIndex]; // 删除支付记录 paymentHistory.splice(paymentIndex, 1); // 回退订阅周期和更新 lastPaymentDate let newExpiryDate = subscription.expiryDate; let newLastPaymentDate = subscription.lastPaymentDate; if (paymentHistory.length > 0) { // 找到剩余支付记录中 periodEnd 最晚的那条(最新的续订) const sortedByPeriodEnd = [...paymentHistory].sort((a, b) => { const dateA = a.periodEnd ? new Date(a.periodEnd) : new Date(0); const dateB = b.periodEnd ? new Date(b.periodEnd) : new Date(0); return dateB - dateA; }); // 订阅的到期日期应该是最新续订的 periodEnd if (sortedByPeriodEnd[0].periodEnd) { newExpiryDate = sortedByPeriodEnd[0].periodEnd; } // 找到最新的支付记录日期 const sortedByDate = [...paymentHistory].sort((a, b) => new Date(b.date) - new Date(a.date)); newLastPaymentDate = sortedByDate[0].date; } else { // 如果没有支付记录了,回退到初始状态 // expiryDate 保持不变或使用 periodStart(如果删除的记录有) if (deletedPayment.periodStart) { newExpiryDate = deletedPayment.periodStart; } newLastPaymentDate = subscription.startDate || subscription.createdAt || subscription.expiryDate; } subscriptions[index] = { ...subscription, expiryDate: newExpiryDate, paymentHistory, lastPaymentDate: newLastPaymentDate }; await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); return { success: true, subscription: subscriptions[index], message: '支付记录已删除' }; } catch (error) { console.error('删除支付记录失败:', error); return { success: false, message: '删除失败: ' + error.message }; } } async function updatePaymentRecord(subscriptionId, paymentId, paymentData, env) { try { const subscriptions = await getAllSubscriptions(env); const index = subscriptions.findIndex(s => s.id === subscriptionId); if (index === -1) { return { success: false, message: '订阅不存在' }; } const subscription = subscriptions[index]; const paymentHistory = subscription.paymentHistory || []; const paymentIndex = paymentHistory.findIndex(p => p.id === paymentId); if (paymentIndex === -1) { return { success: false, message: '支付记录不存在' }; } // 更新支付记录 paymentHistory[paymentIndex] = { ...paymentHistory[paymentIndex], date: paymentData.date || paymentHistory[paymentIndex].date, amount: paymentData.amount !== undefined ? paymentData.amount : paymentHistory[paymentIndex].amount, note: paymentData.note !== undefined ? paymentData.note : paymentHistory[paymentIndex].note }; // 更新 lastPaymentDate 为最新的支付记录日期 const sortedPayments = [...paymentHistory].sort((a, b) => new Date(b.date) - new Date(a.date)); const newLastPaymentDate = sortedPayments[0].date; subscriptions[index] = { ...subscription, paymentHistory, lastPaymentDate: newLastPaymentDate }; await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); return { success: true, subscription: subscriptions[index], message: '支付记录已更新' }; } catch (error) { console.error('更新支付记录失败:', error); return { success: false, message: '更新失败: ' + error.message }; } } async function toggleSubscriptionStatus(id, isActive, env) { try { const subscriptions = await getAllSubscriptions(env); const index = subscriptions.findIndex(s => s.id === id); if (index === -1) { return { success: false, message: '订阅不存在' }; } subscriptions[index] = { ...subscriptions[index], isActive: isActive, updatedAt: new Date().toISOString() }; await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions)); return { success: true, subscription: subscriptions[index] }; } catch (error) { return { success: false, message: '更新订阅状态失败' }; } } async function testSingleSubscriptionNotification(id, env) { try { const subscription = await getSubscription(id, env); if (!subscription) { return { success: false, message: '未找到该订阅' }; } const config = await getConfig(env); const title = `手动测试通知: ${subscription.name}`; // 检查是否显示农历(从配置中获取,默认不显示) const showLunar = config.SHOW_LUNAR === true; let lunarExpiryText = ''; if (showLunar) { // 计算农历日期 const expiryDateObj = new Date(subscription.expiryDate); const lunarExpiry = lunarCalendar.solar2lunar(expiryDateObj.getFullYear(), expiryDateObj.getMonth() + 1, expiryDateObj.getDate()); lunarExpiryText = lunarExpiry ? ` (农历: ${lunarExpiry.fullStr})` : ''; } // 格式化到期日期(使用所选时区) const timezone = config?.TIMEZONE || 'UTC'; const formattedExpiryDate = formatTimeInTimezone(new Date(subscription.expiryDate), timezone, 'date'); const currentTime = formatTimeInTimezone(new Date(), timezone, 'datetime'); // 获取日历类型和自动续期状态 const calendarType = subscription.useLunar ? '农历' : '公历'; const autoRenewText = subscription.autoRenew ? '是' : '否'; const amountText = subscription.amount ? `\n金额: ¥${subscription.amount.toFixed(2)}/周期` : ''; const commonContent = `**订阅详情** 类型: ${subscription.customType || '其他'}${amountText} 日历类型: ${calendarType} 到期日期: ${formattedExpiryDate}${lunarExpiryText} 自动续期: ${autoRenewText} 备注: ${subscription.notes || '无'} 发送时间: ${currentTime} 当前时区: ${formatTimezoneDisplay(timezone)}`; // 使用多渠道发送 const tags = extractTagsFromSubscriptions([subscription]); await sendNotificationToAllChannels(title, commonContent, config, '[手动测试]', { metadata: { tags } }); return { success: true, message: '测试通知已发送到所有启用的渠道' }; } catch (error) { console.error('[手动测试] 发送失败:', error); return { success: false, message: '发送时发生错误: ' + error.message }; } } async function sendWebhookNotification(title, content, config, metadata = {}) { try { if (!config.WEBHOOK_URL) { console.error('[Webhook通知] 通知未配置,缺少URL'); return false; } console.log('[Webhook通知] 开始发送通知到: ' + config.WEBHOOK_URL); let requestBody; let headers = { 'Content-Type': 'application/json' }; // 处理自定义请求头 if (config.WEBHOOK_HEADERS) { try { const customHeaders = JSON.parse(config.WEBHOOK_HEADERS); headers = { ...headers, ...customHeaders }; } catch (error) { console.warn('[Webhook通知] 自定义请求头格式错误,使用默认请求头'); } } const tagsArray = Array.isArray(metadata.tags) ? metadata.tags.filter(tag => typeof tag === 'string' && tag.trim().length > 0).map(tag => tag.trim()) : []; const tagsBlock = tagsArray.length ? tagsArray.map(tag => `- ${tag}`).join('\n') : ''; const tagsLine = tagsArray.length ? '标签:' + tagsArray.join('、') : ''; const timestamp = formatTimeInTimezone(new Date(), config?.TIMEZONE || 'UTC', 'datetime'); const formattedMessage = [title, content, tagsLine, `发送时间:${timestamp}`] .filter(section => section && section.trim().length > 0) .join('\n\n'); const templateData = { title, content, tags: tagsBlock, tagsLine, rawTags: tagsArray, timestamp, formattedMessage, message: formattedMessage }; const escapeForJson = (value) => { if (value === null || value === undefined) { return ''; } return JSON.stringify(String(value)).slice(1, -1); }; const applyTemplate = (template, data) => { const templateString = JSON.stringify(template); const replaced = templateString.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => { if (Object.prototype.hasOwnProperty.call(data, key)) { return escapeForJson(data[key]); } return ''; }); return JSON.parse(replaced); }; // 处理消息模板 if (config.WEBHOOK_TEMPLATE) { try { const template = JSON.parse(config.WEBHOOK_TEMPLATE); requestBody = applyTemplate(template, templateData); } catch (error) { console.warn('[Webhook通知] 消息模板格式错误,使用默认格式'); requestBody = { title, content, tags: tagsArray, tagsLine, timestamp, message: formattedMessage }; } } else { requestBody = { title, content, tags: tagsArray, tagsLine, timestamp, message: formattedMessage }; } const response = await fetch(config.WEBHOOK_URL, { method: config.WEBHOOK_METHOD || 'POST', headers: headers, body: JSON.stringify(requestBody) }); const result = await response.text(); console.log('[Webhook通知] 发送结果:', response.status, result); return response.ok; } catch (error) { console.error('[Webhook通知] 发送通知失败:', error); return false; } } async function sendWechatBotNotification(title, content, config) { try { if (!config.WECHATBOT_WEBHOOK) { console.error('[企业微信机器人] 通知未配置,缺少Webhook URL'); return false; } console.log('[企业微信机器人] 开始发送通知到: ' + config.WECHATBOT_WEBHOOK); // 构建消息内容 let messageData; const msgType = config.WECHATBOT_MSG_TYPE || 'text'; if (msgType === 'markdown') { // Markdown 消息格式 const markdownContent = `# ${title}\n\n${content}`; messageData = { msgtype: 'markdown', markdown: { content: markdownContent } }; } else { // 文本消息格式 - 优化显示 const textContent = `${title}\n\n${content}`; messageData = { msgtype: 'text', text: { content: textContent } }; } // 处理@功能 if (config.WECHATBOT_AT_ALL === 'true') { // @所有人 if (msgType === 'text') { messageData.text.mentioned_list = ['@all']; } } else if (config.WECHATBOT_AT_MOBILES) { // @指定手机号 const mobiles = config.WECHATBOT_AT_MOBILES.split(',').map(m => m.trim()).filter(m => m); if (mobiles.length > 0) { if (msgType === 'text') { messageData.text.mentioned_mobile_list = mobiles; } } } console.log('[企业微信机器人] 发送消息数据:', JSON.stringify(messageData, null, 2)); const response = await fetch(config.WECHATBOT_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(messageData) }); const responseText = await response.text(); console.log('[企业微信机器人] 响应状态:', response.status); console.log('[企业微信机器人] 响应内容:', responseText); if (response.ok) { try { const result = JSON.parse(responseText); if (result.errcode === 0) { console.log('[企业微信机器人] 通知发送成功'); return true; } else { console.error('[企业微信机器人] 发送失败,错误码:', result.errcode, '错误信息:', result.errmsg); return false; } } catch (parseError) { console.error('[企业微信机器人] 解析响应失败:', parseError); return false; } } else { console.error('[企业微信机器人] HTTP请求失败,状态码:', response.status); return false; } } catch (error) { console.error('[企业微信机器人] 发送通知失败:', error); return false; } } // 优化通知内容格式 function resolveReminderSetting(subscription) { const defaultDays = subscription && subscription.reminderDays !== undefined ? Number(subscription.reminderDays) : 7; let unit = subscription && subscription.reminderUnit === 'hour' ? 'hour' : 'day'; let value; if (unit === 'hour') { if (subscription && subscription.reminderValue !== undefined && subscription.reminderValue !== null && !isNaN(Number(subscription.reminderValue))) { value = Number(subscription.reminderValue); } else if (subscription && subscription.reminderHours !== undefined && subscription.reminderHours !== null && !isNaN(Number(subscription.reminderHours))) { value = Number(subscription.reminderHours); } else { value = 0; } } else { if (subscription && subscription.reminderValue !== undefined && subscription.reminderValue !== null && !isNaN(Number(subscription.reminderValue))) { value = Number(subscription.reminderValue); } else if (!isNaN(defaultDays)) { value = Number(defaultDays); } else { value = 7; } } if (value < 0 || isNaN(value)) { value = 0; } return { unit, value }; } function shouldTriggerReminder(reminder, daysDiff, hoursDiff) { if (!reminder) { return false; } if (reminder.unit === 'hour') { if (reminder.value === 0) { return hoursDiff >= 0 && hoursDiff < 1; } return hoursDiff >= 0 && hoursDiff <= reminder.value; } if (reminder.value === 0) { return daysDiff === 0; } return daysDiff >= 0 && daysDiff <= reminder.value; } function formatNotificationContent(subscriptions, config) { const showLunar = config.SHOW_LUNAR === true; const timezone = config?.TIMEZONE || 'UTC'; let content = ''; for (const sub of subscriptions) { const typeText = sub.customType || '其他'; const periodText = (sub.periodValue && sub.periodUnit) ? `(周期: ${sub.periodValue} ${ { day: '天', month: '月', year: '年' }[sub.periodUnit] || sub.periodUnit})` : ''; const categoryText = sub.category ? sub.category : '未分类'; const reminderSetting = resolveReminderSetting(sub); // 格式化到期日期(使用所选时区) const expiryDateObj = new Date(sub.expiryDate); const formattedExpiryDate = formatTimeInTimezone(expiryDateObj, timezone, 'date'); // 农历日期 let lunarExpiryText = ''; if (showLunar) { const lunarExpiry = lunarCalendar.solar2lunar(expiryDateObj.getFullYear(), expiryDateObj.getMonth() + 1, expiryDateObj.getDate()); lunarExpiryText = lunarExpiry ? ` 农历日期: ${lunarExpiry.fullStr}` : ''; } // 状态和到期时间 let statusText = ''; let statusEmoji = ''; if (sub.daysRemaining === 0) { statusEmoji = '⚠️'; statusText = '今天到期!'; } else if (sub.daysRemaining < 0) { statusEmoji = '🚨'; statusText = `已过期 ${Math.abs(sub.daysRemaining)} 天`; } else { statusEmoji = '📅'; statusText = `将在 ${sub.daysRemaining} 天后到期`; } const reminderSuffix = reminderSetting.value === 0 ? '(仅到期时提醒)' : (reminderSetting.unit === 'hour' ? '(小时级提醒)' : ''); const reminderText = reminderSetting.unit === 'hour' ? `提醒策略: 提前 ${reminderSetting.value} 小时${reminderSuffix}` : `提醒策略: 提前 ${reminderSetting.value} 天${reminderSuffix}`; // 获取日历类型和自动续期状态 const calendarType = sub.useLunar ? '农历' : '公历'; const autoRenewText = sub.autoRenew ? '是' : '否'; const amountText = sub.amount ? `\n金额: ¥${sub.amount.toFixed(2)}/周期` : ''; // 构建格式化的通知内容 const subscriptionContent = `${statusEmoji} **${sub.name}** 类型: ${typeText} ${periodText} 分类: ${categoryText}${amountText} 日历类型: ${calendarType} 到期日期: ${formattedExpiryDate}${lunarExpiryText} 自动续期: ${autoRenewText} ${reminderText} 到期状态: ${statusText}`; // 添加备注 let finalContent = sub.notes ? subscriptionContent + `\n备注: ${sub.notes}` : subscriptionContent; content += finalContent + '\n\n'; } // 添加发送时间和时区信息 const currentTime = formatTimeInTimezone(new Date(), timezone, 'datetime'); content += `发送时间: ${currentTime}\n当前时区: ${formatTimezoneDisplay(timezone)}`; return content; } async function sendNotificationToAllChannels(title, commonContent, config, logPrefix = '[定时任务]', options = {}) { const metadata = options.metadata || {}; if (!config.ENABLED_NOTIFIERS || config.ENABLED_NOTIFIERS.length === 0) { console.log(`${logPrefix} 未启用任何通知渠道。`); return; } if (config.ENABLED_NOTIFIERS.includes('notifyx')) { const notifyxContent = `## ${title}\n\n${commonContent}`; const success = await sendNotifyXNotification(title, notifyxContent, `订阅提醒`, config); console.log(`${logPrefix} 发送NotifyX通知 ${success ? '成功' : '失败'}`); } if (config.ENABLED_NOTIFIERS.includes('telegram')) { const telegramContent = `*${title}*\n\n${commonContent}`; const success = await sendTelegramNotification(telegramContent, config); console.log(`${logPrefix} 发送Telegram通知 ${success ? '成功' : '失败'}`); } if (config.ENABLED_NOTIFIERS.includes('webhook')) { const webhookContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); const success = await sendWebhookNotification(title, webhookContent, config, metadata); console.log(`${logPrefix} 发送Webhook通知 ${success ? '成功' : '失败'}`); } if (config.ENABLED_NOTIFIERS.includes('wechatbot')) { const wechatbotContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); const success = await sendWechatBotNotification(title, wechatbotContent, config); console.log(`${logPrefix} 发送企业微信机器人通知 ${success ? '成功' : '失败'}`); } if (config.ENABLED_NOTIFIERS.includes('email')) { const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); const success = await sendEmailNotification(title, emailContent, config); console.log(`${logPrefix} 发送邮件通知 ${success ? '成功' : '失败'}`); } if (config.ENABLED_NOTIFIERS.includes('bark')) { const barkContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); const success = await sendBarkNotification(title, barkContent, config); console.log(`${logPrefix} 发送Bark通知 ${success ? '成功' : '失败'}`); } } async function sendTelegramNotification(message, config) { try { if (!config.TG_BOT_TOKEN || !config.TG_CHAT_ID) { console.error('[Telegram] 通知未配置,缺少Bot Token或Chat ID'); return false; } console.log('[Telegram] 开始发送通知到 Chat ID: ' + config.TG_CHAT_ID); const url = 'https://api.telegram.org/bot' + config.TG_BOT_TOKEN + '/sendMessage'; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: config.TG_CHAT_ID, text: message, parse_mode: 'Markdown' }) }); const result = await response.json(); console.log('[Telegram] 发送结果:', result); return result.ok; } catch (error) { console.error('[Telegram] 发送通知失败:', error); return false; } } async function sendNotifyXNotification(title, content, description, config) { try { if (!config.NOTIFYX_API_KEY) { console.error('[NotifyX] 通知未配置,缺少API Key'); return false; } console.log('[NotifyX] 开始发送通知: ' + title); const url = 'https://www.notifyx.cn/api/v1/send/' + config.NOTIFYX_API_KEY; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title, content: content, description: description || '' }) }); const result = await response.json(); console.log('[NotifyX] 发送结果:', result); return result.status === 'queued'; } catch (error) { console.error('[NotifyX] 发送通知失败:', error); return false; } } async function sendBarkNotification(title, content, config) { try { if (!config.BARK_DEVICE_KEY) { console.error('[Bark] 通知未配置,缺少设备Key'); return false; } console.log('[Bark] 开始发送通知到设备: ' + config.BARK_DEVICE_KEY); const serverUrl = config.BARK_SERVER || 'https://api.day.app'; const url = serverUrl + '/push'; const payload = { title: title, body: content, device_key: config.BARK_DEVICE_KEY }; // 如果配置了保存推送,则添加isArchive参数 if (config.BARK_IS_ARCHIVE === 'true') { payload.isArchive = 1; } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(payload) }); const result = await response.json(); console.log('[Bark] 发送结果:', result); // Bark API返回code为200表示成功 return result.code === 200; } catch (error) { console.error('[Bark] 发送通知失败:', error); return false; } } async function sendEmailNotification(title, content, config) { try { if (!config.RESEND_API_KEY || !config.EMAIL_FROM || !config.EMAIL_TO) { console.error('[邮件通知] 通知未配置,缺少必要参数'); return false; } console.log('[邮件通知] 开始发送邮件到: ' + config.EMAIL_TO); // 生成HTML邮件内容 const htmlContent = ` ${title}

📅 ${title}

${content.replace(/\n/g, '
')}

此邮件由订阅管理系统自动发送,请及时处理相关订阅事务。

`; const fromEmail = config.EMAIL_FROM_NAME ? `${config.EMAIL_FROM_NAME} <${config.EMAIL_FROM}>` : config.EMAIL_FROM; const response = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${config.RESEND_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: fromEmail, to: config.EMAIL_TO, subject: title, html: htmlContent, text: content // 纯文本备用 }) }); const result = await response.json(); console.log('[邮件通知] 发送结果:', response.status, result); if (response.ok && result.id) { console.log('[邮件通知] 邮件发送成功,ID:', result.id); return true; } else { console.error('[邮件通知] 邮件发送失败:', result); return false; } } catch (error) { console.error('[邮件通知] 发送邮件失败:', error); return false; } } async function sendNotification(title, content, description, config) { if (config.NOTIFICATION_TYPE === 'notifyx') { return await sendNotifyXNotification(title, content, description, config); } else { return await sendTelegramNotification(content, config); } } // 4. 修改定时任务 checkExpiringSubscriptions,支持农历周期自动续订和农历提醒 async function checkExpiringSubscriptions(env) { try { const config = await getConfig(env); const timezone = config?.TIMEZONE || 'UTC'; const currentTime = getCurrentTimeInTimezone(timezone); // 统一计算当天的零点时间,用于比较天数差异 const currentMidnight = getTimezoneMidnightTimestamp(currentTime, timezone); console.log(`[定时任务] 开始检查 - 当前时间: ${currentTime.toISOString()} (${timezone})`); // --- 检查当前小时是否允许发送通知 --- const rawNotificationHours = Array.isArray(config.NOTIFICATION_HOURS) ? config.NOTIFICATION_HOURS : []; const normalizedNotificationHours = rawNotificationHours .map(value => String(value).trim()) .filter(value => value.length > 0) .map(value => value === '*' ? '*' : value.toUpperCase() === 'ALL' ? 'ALL' : value.padStart(2, '0')); const allowAllHours = normalizedNotificationHours.includes('*') || normalizedNotificationHours.includes('ALL'); const hourFormatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour12: false, hour: '2-digit' }); const currentHour = hourFormatter.format(currentTime); const shouldNotifyThisHour = allowAllHours || normalizedNotificationHours.length === 0 || normalizedNotificationHours.includes(currentHour); const subscriptions = await getAllSubscriptions(env); const expiringSubscriptions = []; const updatedSubscriptions = []; let hasUpdates = false; for (const subscription of subscriptions) { // 1. 跳过未启用的订阅 if (subscription.isActive === false) { continue; } const reminderSetting = resolveReminderSetting(subscription); // 计算当前剩余时间(基础计算) let expiryDate = new Date(subscription.expiryDate); // 为了准确计算 daysDiff,需要根据农历或公历获取"逻辑上的午夜时间" let expiryMidnight; if (subscription.useLunar) { const lunar = lunarCalendar.solar2lunar(expiryDate.getFullYear(), expiryDate.getMonth() + 1, expiryDate.getDate()); if(lunar) { const solar = lunarBiz.lunar2solar(lunar); const lunarDate = new Date(solar.year, solar.month - 1, solar.day); expiryMidnight = getTimezoneMidnightTimestamp(lunarDate, timezone); } else { expiryMidnight = getTimezoneMidnightTimestamp(expiryDate, timezone); } } else { expiryMidnight = getTimezoneMidnightTimestamp(expiryDate, timezone); } // 1. 获取当前时间的 UTC 时间戳 const nowTs = currentTime.getTime(); const tzOffset = getTimezoneOffset(timezone); // 修正后的到期时间 = 原始UTC时间 - 时区偏移小时 const adjustedExpiryTime = expiryDate.getTime() - (tzOffset * MS_PER_HOUR); let daysDiff = Math.round((expiryMidnight - currentMidnight) / MS_PER_DAY); // 使用修正后的时间计算差值 let diffMs = adjustedExpiryTime - currentTime.getTime(); let diffHours = diffMs / MS_PER_HOUR; // ========================================== // 核心逻辑:自动续费处理 // ========================================== if (daysDiff < 0 && subscription.periodValue && subscription.periodUnit && subscription.autoRenew !== false) { console.log(`[定时任务] 订阅 "${subscription.name}" 已过期 (${daysDiff}天),准备自动续费...`); const mode = subscription.subscriptionMode || 'cycle'; // cycle | reset // 1. 确定计算基准点 (Base Point) // newStartDate 将作为新周期的"开始日期"保存到数据库,解决前端编辑时日期错乱问题 let newStartDate; if (mode === 'reset') { // 注意:为了整洁,通常从当天的 00:00 或当前时间开始,这里取 currentTime 保持精确 newStartDate = new Date(currentTime); } else { // Cycle 模式:无缝接续,从"旧的到期日"开始 newStartDate = new Date(subscription.expiryDate); } // 2. 计算新的到期日 (循环补齐直到未来) let newExpiryDate = new Date(newStartDate); // 初始化 let periodsAdded = 0; // 定义增加一个周期的函数 (同时处理 newStartDate 和 newExpiryDate 的推进) const addOnePeriod = (baseDate) => { let targetDate; if (subscription.useLunar) { const solarBase = { year: baseDate.getFullYear(), month: baseDate.getMonth() + 1, day: baseDate.getDate() }; let lunarBase = lunarCalendar.solar2lunar(solarBase.year, solarBase.month, solarBase.day); // 农历加周期 let nextLunar = lunarBiz.addLunarPeriod(lunarBase, subscription.periodValue, subscription.periodUnit); const solarNext = lunarBiz.lunar2solar(nextLunar); targetDate = new Date(solarNext.year, solarNext.month - 1, solarNext.day); } else { targetDate = new Date(baseDate); if (subscription.periodUnit === 'day') targetDate.setDate(targetDate.getDate() + subscription.periodValue); else if (subscription.periodUnit === 'month') targetDate.setMonth(targetDate.getMonth() + subscription.periodValue); else if (subscription.periodUnit === 'year') targetDate.setFullYear(targetDate.getFullYear() + subscription.periodValue); } return targetDate; }; // Reset模式下 newStartDate 是今天,加一次肯定在未来,循环只会执行一次 do { // 在推进到期日之前,现有的 newExpiryDate 就变成了这一轮的"开始日" // (仅在非第一次循环时有效,用于 Cycle 模式推进 start 日期) if (periodsAdded > 0) { newStartDate = new Date(newExpiryDate); } // 计算下一个到期日 newExpiryDate = addOnePeriod(newStartDate); periodsAdded++; // 获取新到期日的午夜时间用于判断是否仍过期 const newExpiryMidnight = getTimezoneMidnightTimestamp(newExpiryDate, timezone); daysDiff = Math.round((newExpiryMidnight - currentMidnight) / MS_PER_DAY); } while (daysDiff < 0); // 只要还过期,就继续加 console.log(`[定时任务] 续费完成. 新开始日: ${newStartDate.toISOString()}, 新到期日: ${newExpiryDate.toISOString()}`); // 3. 生成支付记录 const paymentRecord = { id: Date.now().toString() + '_' + Math.random().toString(36).substr(2, 9), date: currentTime.toISOString(), // 实际扣款时间是现在 amount: subscription.amount || 0, type: 'auto', note: `自动续订 (${mode === 'reset' ? '重置模式' : '接续模式'}${periodsAdded > 1 ? ', 补齐' + periodsAdded + '周期' : ''})`, periodStart: newStartDate.toISOString(), // 记录准确的计费周期开始 periodEnd: newExpiryDate.toISOString() }; const paymentHistory = subscription.paymentHistory || []; paymentHistory.push(paymentRecord); // 4. 更新订阅对象 const updatedSubscription = { ...subscription, startDate: newStartDate.toISOString(), expiryDate: newExpiryDate.toISOString(), lastPaymentDate: currentTime.toISOString(), paymentHistory }; updatedSubscriptions.push(updatedSubscription); hasUpdates = true; // 5. 检查续费后是否需要立即提醒 (例如续费后只剩1天) diffMs = newExpiryDate.getTime() - currentTime.getTime(); diffHours = diffMs / MS_PER_HOUR; const shouldRemindAfterRenewal = shouldTriggerReminder(reminderSetting, daysDiff, diffHours); if (shouldRemindAfterRenewal) { expiringSubscriptions.push({ ...updatedSubscription, daysRemaining: daysDiff, hoursRemaining: Math.round(diffHours) }); } continue; // 处理下一个订阅 } // ========================================== // 普通提醒逻辑 (未过期,或过期但不自动续费) // ========================================== const shouldRemind = shouldTriggerReminder(reminderSetting, daysDiff, diffHours); if (daysDiff < 0 && subscription.autoRenew === false) { // 已过期且不自动续费 -> 发送过期通知 expiringSubscriptions.push({ ...subscription, daysRemaining: daysDiff, hoursRemaining: Math.round(diffHours) }); } else if (shouldRemind) { // 正常到期提醒 expiringSubscriptions.push({ ...subscription, daysRemaining: daysDiff, hoursRemaining: Math.round(diffHours) }); } } // --- 保存更改 --- if (hasUpdates) { const mergedSubscriptions = subscriptions.map(sub => { const updated = updatedSubscriptions.find(u => u.id === sub.id); return updated || sub; }); await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(mergedSubscriptions)); console.log(`[定时任务] 已更新 ${updatedSubscriptions.length} 个自动续费订阅`); } // --- 发送通知 --- if (expiringSubscriptions.length > 0) { if (!shouldNotifyThisHour) { console.log(`[定时任务] 当前小时 ${currentHour} 未在通知时段内 (${normalizedNotificationHours.join(',')}),跳过发送`); } else { console.log(`[定时任务] 发送 ${expiringSubscriptions.length} 条提醒通知`); // 按到期时间排序 expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining); const commonContent = formatNotificationContent(expiringSubscriptions, config); const metadataTags = extractTagsFromSubscriptions(expiringSubscriptions); await sendNotificationToAllChannels('订阅到期/续费提醒', commonContent, config, '[定时任务]', { metadata: { tags: metadataTags } }); } } } catch (error) { console.error('[定时任务] 执行失败:', error); } } function getCookieValue(cookieString, key) { if (!cookieString) return null; const match = cookieString.match(new RegExp('(^| )' + key + '=([^;]+)')); return match ? match[2] : null; } async function handleRequest(request, env, ctx) { return new Response(loginPage, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } const CryptoJS = { HmacSHA256: function(message, key) { const keyData = new TextEncoder().encode(key); const messageData = new TextEncoder().encode(message); return Promise.resolve().then(() => { return crypto.subtle.importKey( "raw", keyData, { name: "HMAC", hash: {name: "SHA-256"} }, false, ["sign"] ); }).then(cryptoKey => { return crypto.subtle.sign( "HMAC", cryptoKey, messageData ); }).then(buffer => { const hashArray = Array.from(new Uint8Array(buffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); }); } }; function getCurrentTime(config) { const timezone = config?.TIMEZONE || 'UTC'; const currentTime = getCurrentTimeInTimezone(timezone); const formatter = new Intl.DateTimeFormat('zh-CN', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); return { date: currentTime, localString: formatter.format(currentTime), isoString: currentTime.toISOString() }; } export default { async fetch(request, env, ctx) { const url = new URL(request.url); // 添加调试页面 if (url.pathname === '/debug') { try { const config = await getConfig(env); const debugInfo = { timestamp: new Date().toISOString(), // 使用UTC时间戳 pathname: url.pathname, kvBinding: !!env.SUBSCRIPTIONS_KV, configExists: !!config, adminUsername: config.ADMIN_USERNAME, hasJwtSecret: !!config.JWT_SECRET, jwtSecretLength: config.JWT_SECRET ? config.JWT_SECRET.length : 0 }; return new Response(` 调试信息

系统调试信息

基本信息

时间: ${debugInfo.timestamp}

路径: ${debugInfo.pathname}

KV绑定: ${debugInfo.kvBinding ? '✓' : '✗'}

配置信息

配置存在: ${debugInfo.configExists ? '✓' : '✗'}

管理员用户名: ${debugInfo.adminUsername}

JWT密钥: ${debugInfo.hasJwtSecret ? '✓' : '✗'} (长度: ${debugInfo.jwtSecretLength})

解决方案

1. 确保KV命名空间已正确绑定为 SUBSCRIPTIONS_KV

2. 尝试访问 / 进行登录

3. 如果仍有问题,请检查Cloudflare Workers日志

`, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); } catch (error) { return new Response(`调试页面错误: ${error.message}`, { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); } } if (url.pathname.startsWith('/api')) { return api.handleRequest(request, env, ctx); } else if (url.pathname.startsWith('/admin')) { return admin.handleRequest(request, env, ctx); } else { return handleRequest(request, env, ctx); } }, async scheduled(event, env, ctx) { const config = await getConfig(env); const timezone = config?.TIMEZONE || 'UTC'; const currentTime = getCurrentTimeInTimezone(timezone); console.log('[Workers] 定时任务触发 UTC:', new Date().toISOString(), timezone + ':', currentTime.toLocaleString('zh-CN', {timeZone: timezone})); await checkExpiringSubscriptions(env); } }; // ==================== 仪表盘统计函数 ==================== // 汇率配置 (以 CNY 为基准,当 API 不可用或缺少特定币种如 TWD 时使用,属于兜底汇率) // 您可以根据需要修改此处的汇率 const FALLBACK_RATES = { 'CNY': 1, 'USD': 6.98, 'HKD': 0.90, 'TWD': 0.22, 'JPY': 0.044, 'EUR': 8.16, 'GBP': 9.40, 'KRW': 0.0048, 'TRY': 0.16 }; // 获取动态汇率 (核心逻辑:KV缓存 -> API请求 -> 兜底合并) async function getDynamicRates(env) { const CACHE_KEY = 'SYSTEM_EXCHANGE_RATES'; const CACHE_TTL = 86400000; // 24小时 (毫秒) try { const cached = await env.SUBSCRIPTIONS_KV.get(CACHE_KEY, { type: 'json' }); // A. 尝试从 KV 读取缓存 if (cached && cached.ts && (Date.now() - cached.ts < CACHE_TTL)) { return cached.rates; // console.log('[汇率] 使用 KV 缓存'); } const response = await fetch('https://api.frankfurter.dev/v1/latest?base=CNY'); // B. 缓存失效或不存在,请求 Frankfurter API if (response.ok) { const data = await response.json(); const newRates = { // C. 合并逻辑:以 API 数据覆盖兜底数据 (保留 API 没有的币种,如 TWD) ...FALLBACK_RATES, ...data.rates, 'CNY': 1 }; await env.SUBSCRIPTIONS_KV.put(CACHE_KEY, JSON.stringify({ // D. 写入 KV 缓存 ts: Date.now(), rates: newRates })); return newRates; } else { console.warn('[汇率] API 请求失败,使用兜底汇率'); } } catch (error) { console.error('[汇率] 获取过程出错:', error); } return FALLBACK_RATES; // E. 发生任何错误,返回兜底汇率 } // 辅助函数:将金额转换为基准货币 (CNY) function convertToCNY(amount, currency, rates) { if (!amount || amount <= 0) return 0; const code = currency || 'CNY'; if (code === 'CNY') return amount; // 如果是基准货币,直接返回 const rate = rates[code]; // 获取汇率 if (!rate) return amount; // 如果没有汇率,原样返回(或者你可以选择抛出错误/返回0) return amount / rate; } // 修改函数签名,增加 rates 参数 function calculateMonthlyExpense(subscriptions, timezone, rates) { const now = getCurrentTimeInTimezone(timezone); const parts = getTimezoneDateParts(now, timezone); const currentYear = parts.year; const currentMonth = parts.month; let amount = 0; // 遍历所有订阅的支付历史 subscriptions.forEach(sub => { const paymentHistory = sub.paymentHistory || []; paymentHistory.forEach(payment => { if (!payment.amount || payment.amount <= 0) return; const paymentDate = new Date(payment.date); const paymentParts = getTimezoneDateParts(paymentDate, timezone); if (paymentParts.year === currentYear && paymentParts.month === currentMonth) { amount += convertToCNY(payment.amount, sub.currency, rates); // 传入 rates 参数 } }); }); // 计算上月数据用于趋势对比 const lastMonth = currentMonth === 1 ? 12 : currentMonth - 1; const lastMonthYear = currentMonth === 1 ? currentYear - 1 : currentYear; let lastMonthAmount = 0; subscriptions.forEach(sub => { const paymentHistory = sub.paymentHistory || []; paymentHistory.forEach(payment => { if (!payment.amount || payment.amount <= 0) return; const paymentDate = new Date(payment.date); const paymentParts = getTimezoneDateParts(paymentDate, timezone); if (paymentParts.year === lastMonthYear && paymentParts.month === lastMonth) { lastMonthAmount += convertToCNY(payment.amount, sub.currency, rates); // 使用 convertToCNY 进行汇率转换 } }); }); let trend = 0; let trendDirection = 'flat'; if (lastMonthAmount > 0) { trend = Math.round(((amount - lastMonthAmount) / lastMonthAmount) * 100); if (trend > 0) trendDirection = 'up'; else if (trend < 0) trendDirection = 'down'; } else if (amount > 0) { trend = 100; // 上月无支出,本月有支出,视为增长 trendDirection = 'up'; } return { amount, trend: Math.abs(trend), trendDirection }; } function calculateYearlyExpense(subscriptions, timezone, rates) { const now = getCurrentTimeInTimezone(timezone); const parts = getTimezoneDateParts(now, timezone); const currentYear = parts.year; let amount = 0; // 遍历所有订阅的支付历史 subscriptions.forEach(sub => { const paymentHistory = sub.paymentHistory || []; paymentHistory.forEach(payment => { if (!payment.amount || payment.amount <= 0) return; const paymentDate = new Date(payment.date); const paymentParts = getTimezoneDateParts(paymentDate, timezone); if (paymentParts.year === currentYear) { amount += convertToCNY(payment.amount, sub.currency, rates); } }); }); const monthlyAverage = amount / parts.month; return { amount, monthlyAverage }; } function getRecentPayments(subscriptions, timezone) { const now = getCurrentTimeInTimezone(timezone); const sevenDaysAgo = new Date(now.getTime() - 7 * MS_PER_DAY); const recentPayments = []; // 遍历所有订阅的支付历史 subscriptions.forEach(sub => { const paymentHistory = sub.paymentHistory || []; paymentHistory.forEach(payment => { if (!payment.amount || payment.amount <= 0) return; const paymentDate = new Date(payment.date); if (paymentDate >= sevenDaysAgo && paymentDate <= now) { recentPayments.push({ name: sub.name, amount: payment.amount, currency: sub.currency || 'CNY', // 传递币种给前端显示 customType: sub.customType, paymentDate: payment.date, note: payment.note }); } }); }); return recentPayments.sort((a, b) => new Date(b.paymentDate) - new Date(a.paymentDate)); } function getUpcomingRenewals(subscriptions, timezone) { const now = getCurrentTimeInTimezone(timezone); const sevenDaysLater = new Date(now.getTime() + 7 * MS_PER_DAY); return subscriptions .filter(sub => { if (!sub.isActive) return false; const renewalDate = new Date(sub.expiryDate); return renewalDate >= now && renewalDate <= sevenDaysLater; }) .map(sub => { const renewalDate = new Date(sub.expiryDate); const daysUntilRenewal = Math.ceil((renewalDate - now) / MS_PER_DAY); return { name: sub.name, amount: sub.amount || 0, currency: sub.currency || 'CNY', customType: sub.customType, renewalDate: sub.expiryDate, daysUntilRenewal }; }) .sort((a, b) => a.daysUntilRenewal - b.daysUntilRenewal); } function getExpenseByType(subscriptions, timezone, rates) { const now = getCurrentTimeInTimezone(timezone); const parts = getTimezoneDateParts(now, timezone); const currentYear = parts.year; const typeMap = {}; let total = 0; // 遍历所有订阅的支付历史 subscriptions.forEach(sub => { const paymentHistory = sub.paymentHistory || []; paymentHistory.forEach(payment => { if (!payment.amount || payment.amount <= 0) return; const paymentDate = new Date(payment.date); const paymentParts = getTimezoneDateParts(paymentDate, timezone); if (paymentParts.year === currentYear) { const type = sub.customType || '未分类'; const amountCNY = convertToCNY(payment.amount, sub.currency, rates); typeMap[type] = (typeMap[type] || 0) + amountCNY; total += amountCNY; } }); }); return Object.entries(typeMap) .map(([type, amount]) => ({ type, amount, percentage: total > 0 ? Math.round((amount / total) * 100) : 0 })) .sort((a, b) => b.amount - a.amount); } function getExpenseByCategory(subscriptions, timezone, rates) { const now = getCurrentTimeInTimezone(timezone); const parts = getTimezoneDateParts(now, timezone); const currentYear = parts.year; const categoryMap = {}; let total = 0; // 遍历所有订阅的支付历史 subscriptions.forEach(sub => { const paymentHistory = sub.paymentHistory || []; paymentHistory.forEach(payment => { if (!payment.amount || payment.amount <= 0) return; const paymentDate = new Date(payment.date); const paymentParts = getTimezoneDateParts(paymentDate, timezone); if (paymentParts.year === currentYear) { const categories = sub.category ? sub.category.split(CATEGORY_SEPARATOR_REGEX).filter(c => c.trim()) : ['未分类']; const amountCNY = convertToCNY(payment.amount, sub.currency, rates); categories.forEach(category => { const cat = category.trim() || '未分类'; categoryMap[cat] = (categoryMap[cat] || 0) + amountCNY / categories.length; }); total += amountCNY; } }); }); return Object.entries(categoryMap) .map(([category, amount]) => ({ category, amount, percentage: total > 0 ? Math.round((amount / total) * 100) : 0 })) .sort((a, b) => b.amount - a.amount); }