diff --git a/images/add_new_sub.png b/images/add_new_sub.png new file mode 100644 index 0000000..a773929 Binary files /dev/null and b/images/add_new_sub.png differ diff --git a/images/email_cnf.png b/images/email_cnf.png new file mode 100644 index 0000000..465f89c Binary files /dev/null and b/images/email_cnf.png differ diff --git a/images/subscribe_list.png b/images/subscribe_list.png new file mode 100644 index 0000000..f6e4eb9 Binary files /dev/null and b/images/subscribe_list.png differ diff --git a/index_zzz.js b/index_zzz.js new file mode 100644 index 0000000..d331c59 --- /dev/null +++ b/index_zzz.js @@ -0,0 +1,9026 @@ +// 订阅续期通知网站 - 基于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 控制台换算触发时间)

+
+
+ + +
+

通知设置

+
+
+ + +

Comma-separated hours; empty = allow any hour (used when per-item hours are not set)

+
+
+

提示

+

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

+

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

+
+
+
+ +
+ + + + + + + + +
+ +
+ +
+ +
+
+ + +
+ +
+

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

+
+ +
+

Telegram 配置

+
+
+ +
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+

NotifyX 配置

+
+ +
+ + +
+

NotifyX平台 获取的 API Key

+
+
+ +
+
+ +
+

Email config

+
+
+ + + +
+
+ + + + + + +
+ +
+

Webhook 通知 配置

+
+
+ + +

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

+
+
+ + +
+
+ + +

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

+
+
+ + +

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

+
+
+
+ +
+
+ +
+

企业微信机器人 配置

+
+
+ + +

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

+
+
+ + +

选择发送的消息格式类型

+
+
+ + +

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

+
+
+ + +
+
+
+ +
+
+ +
+

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 || config.RESEND_API_KEY || '', + RESEND_FROM: newConfig.RESEND_FROM || config.RESEND_FROM || '', + RESEND_FROM_NAME: newConfig.RESEND_FROM_NAME || config.RESEND_FROM_NAME || '', + RESEND_TO: newConfig.RESEND_TO || config.RESEND_TO || '', + MAILGUN_API_KEY: newConfig.MAILGUN_API_KEY || config.MAILGUN_API_KEY || '', + MAILGUN_FROM: newConfig.MAILGUN_FROM || config.MAILGUN_FROM || '', + MAILGUN_FROM_NAME: newConfig.MAILGUN_FROM_NAME || config.MAILGUN_FROM_NAME || '', + MAILGUN_TO: newConfig.MAILGUN_TO || config.MAILGUN_TO || '', + EMAIL_FROM: newConfig.EMAIL_FROM || config.EMAIL_FROM || '', + EMAIL_FROM_NAME: newConfig.EMAIL_FROM_NAME || config.EMAIL_FROM_NAME || '', + EMAIL_TO: newConfig.EMAIL_TO || config.EMAIL_TO || '', + SMTP_HOST: newConfig.SMTP_HOST || config.SMTP_HOST || '', + SMTP_PORT: newConfig.SMTP_PORT || config.SMTP_PORT || '', + SMTP_USER: newConfig.SMTP_USER || config.SMTP_USER || '', + SMTP_PASS: newConfig.SMTP_PASS || config.SMTP_PASS || '', + SMTP_FROM: newConfig.SMTP_FROM || config.SMTP_FROM || '', + SMTP_FROM_NAME: newConfig.SMTP_FROM_NAME || config.SMTP_FROM_NAME || '', + SMTP_TO: newConfig.SMTP_TO || config.SMTP_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 expiringSoon = activeSubscriptions.filter(s => { + const expiryDate = new Date(s.expiryDate); + const diffMs = expiryDate.getTime() - now.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + // 获取订阅的提醒设置 + const reminder = resolveReminderSetting(s, 7); + + // 根据提醒单位判断是否即将到期 + const isSoon = reminder.unit === 'minute' + ? diffMs >= 0 && diffMs <= reminder.value * 60 * 1000 + : reminder.unit === 'hour' + ? diffHours >= 0 && diffHours <= reminder.value + : diffDays >= 0 && diffDays <= reminder.value; + + return isSoon; + }).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_resend' || body.type === 'email_smtp' || body.type === 'email_mailgun') { + const testConfig = { + ...config, + RESEND_API_KEY: body.RESEND_API_KEY, + RESEND_FROM: body.RESEND_FROM, + RESEND_FROM_NAME: body.RESEND_FROM_NAME, + RESEND_TO: body.RESEND_TO, + MAILGUN_API_KEY: body.MAILGUN_API_KEY, + MAILGUN_FROM: body.MAILGUN_FROM, + MAILGUN_FROM_NAME: body.MAILGUN_FROM_NAME, + MAILGUN_TO: body.MAILGUN_TO, + EMAIL_FROM: body.EMAIL_FROM, + EMAIL_FROM_NAME: body.EMAIL_FROM_NAME, + EMAIL_TO: body.EMAIL_TO, + SMTP_HOST: body.SMTP_HOST, + SMTP_PORT: body.SMTP_PORT, + SMTP_USER: body.SMTP_USER, + SMTP_PASS: body.SMTP_PASS, + SMTP_FROM: body.SMTP_FROM, + SMTP_FROM_NAME: body.SMTP_FROM_NAME, + SMTP_TO: body.SMTP_TO + }; + const emailProvider = body.EMAIL_PROVIDER + || (body.type === 'email_smtp' ? 'smtp' : (body.type === 'email_mailgun' ? 'mailgun' : 'resend')); + + const title = '测试通知'; + const content = '这是一条测试通知,用于验证邮件通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); + + if (emailProvider === 'smtp') { + const htmlContent = '
' + content.replace(/\n/g, '
') + '
'; + const detail = await sendSmtpEmailNotificationDetailed( + title, + content, + htmlContent, + testConfig, + normalizeEmailRecipients(testConfig.SMTP_TO || testConfig.EMAIL_TO) + ); + success = detail.success; + message = success ? '邮件通知发送成功' : 'SMTP发送失败: ' + (detail.message || '未知错误'); + } else { + success = await sendEmailNotification(title, content, testConfig, { provider: emailProvider }); + 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] === 'batch' && method === 'DELETE') { + const { paymentIds } = await request.json(); + const result = await batchDeletePaymentRecords(id, paymentIds, env); + return new Response(JSON.stringify(result), { status: result.success ? 200 : 400, 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 || '', + RESEND_FROM: config.RESEND_FROM || config.EMAIL_FROM || '', + RESEND_FROM_NAME: config.RESEND_FROM_NAME || config.EMAIL_FROM_NAME || '', + RESEND_TO: config.RESEND_TO || config.EMAIL_TO || '', + MAILGUN_API_KEY: config.MAILGUN_API_KEY || '', + MAILGUN_FROM: config.MAILGUN_FROM || config.EMAIL_FROM || '', + MAILGUN_FROM_NAME: config.MAILGUN_FROM_NAME || config.EMAIL_FROM_NAME || '', + MAILGUN_TO: config.MAILGUN_TO || config.EMAIL_TO || '', + EMAIL_FROM: config.EMAIL_FROM || '', + EMAIL_FROM_NAME: config.EMAIL_FROM_NAME || '', + EMAIL_TO: config.EMAIL_TO || '', + SMTP_HOST: config.SMTP_HOST || '', + SMTP_PORT: config.SMTP_PORT || '', + SMTP_USER: config.SMTP_USER || '', + SMTP_PASS: config.SMTP_PASS || '', + SMTP_FROM: config.SMTP_FROM || config.EMAIL_FROM || '', + SMTP_FROM_NAME: config.SMTP_FROM_NAME || config.EMAIL_FROM_NAME || '', + SMTP_TO: config.SMTP_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: '', + RESEND_FROM: '', + RESEND_FROM_NAME: '', + RESEND_TO: '', + MAILGUN_API_KEY: '', + MAILGUN_FROM: '', + MAILGUN_FROM_NAME: '', + MAILGUN_TO: '', + EMAIL_FROM: '', + EMAIL_FROM_NAME: '', + EMAIL_TO: '', + SMTP_HOST: '', + SMTP_PORT: '', + SMTP_USER: '', + SMTP_PASS: '', + SMTP_FROM: '', + SMTP_FROM_NAME: '', + SMTP_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 === 'minute') { + expiryDate.setMinutes(expiryDate.getMinutes() + subscription.periodValue); + } else if (subscription.periodUnit === 'hour') { + expiryDate.setHours(expiryDate.getHours() + subscription.periodValue); + } else 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 hasNotificationHours = Object.prototype.hasOwnProperty.call(subscription, 'notificationHours'); + const hasEnabledNotifiers = Object.prototype.hasOwnProperty.call(subscription, 'enabledNotifiers'); + const hasEmailTo = Object.prototype.hasOwnProperty.call(subscription, 'emailTo'); + const normalizedNotificationHours = hasNotificationHours + ? normalizeNotificationHours(subscription.notificationHours) + : undefined; + const normalizedNotifiers = hasEnabledNotifiers + ? normalizeNotifierList(subscription.enabledNotifiers) + : undefined; + const normalizedEmailTo = hasEmailTo + ? normalizeEmailRecipients(subscription.emailTo) + : undefined; + + 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.unit === 'minute') ? reminderSetting.value : undefined, + notificationHours: normalizedNotificationHours, + enabledNotifiers: normalizedNotifiers, + emailTo: normalizedEmailTo, + 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 === 'minute') { + expiryDate.setMinutes(expiryDate.getMinutes() + subscription.periodValue); + } else if (subscription.periodUnit === 'hour') { + expiryDate.setHours(expiryDate.getHours() + subscription.periodValue); + } else 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 hasNotificationHours = Object.prototype.hasOwnProperty.call(subscription, 'notificationHours'); + const hasEnabledNotifiers = Object.prototype.hasOwnProperty.call(subscription, 'enabledNotifiers'); + const hasEmailTo = Object.prototype.hasOwnProperty.call(subscription, 'emailTo'); + const normalizedNotificationHours = hasNotificationHours + ? normalizeNotificationHours(subscription.notificationHours) + : subscriptions[index].notificationHours; + const normalizedNotifiers = hasEnabledNotifiers + ? normalizeNotifierList(subscription.enabledNotifiers) + : subscriptions[index].enabledNotifiers; + const normalizedEmailTo = hasEmailTo + ? normalizeEmailRecipients(subscription.emailTo) + : subscriptions[index].emailTo; + + 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.unit === 'minute') ? reminderSetting.value : undefined, + notificationHours: normalizedNotificationHours, + enabledNotifiers: normalizedNotifiers, + emailTo: normalizedEmailTo, + 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 batchDeletePaymentRecords(subscriptionId, paymentIds, env) { + try { + if (!paymentIds || !Array.isArray(paymentIds) || paymentIds.length === 0) { + return { success: false, message: '请提供要删除的支付记录ID' }; + } + + const subscriptions = await getAllSubscriptions(env); + const index = subscriptions.findIndex(s => s.id === subscriptionId); + + if (index === -1) { + return { success: false, message: '订阅不存在' }; + } + + const subscription = subscriptions[index]; + let paymentHistory = subscription.paymentHistory || []; + + // 过滤掉要删除的支付记录 + const deletedPayments = paymentHistory.filter(p => paymentIds.includes(p.id)); + paymentHistory = paymentHistory.filter(p => !paymentIds.includes(p.id)); + + if (deletedPayments.length === 0) { + return { success: false, message: '未找到要删除的支付记录' }; + } + + // 重新计算订阅到期时间和最后支付日期 + 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 { + // 如果没有支付记录了,回退到初始状态 + // 使用第一条被删除记录的 periodStart(如果有) + const firstDeleted = deletedPayments.sort((a, b) => new Date(a.date) - new Date(b.date))[0]; + if (firstDeleted.periodStart) { + newExpiryDate = firstDeleted.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: `已删除 ${deletedPayments.length} 条支付记录` + }; + } 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, 'datetime'); + 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 }, + notifiers: subscription.enabledNotifiers, + emailTo: subscription.emailTo + }); + + 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 ? subscription.reminderUnit : 'day'; + + // 兼容旧数据:如果没有 reminderUnit 但有 reminderHours,则推断为 hour + if (!subscription.reminderUnit && subscription.reminderHours !== undefined) { + unit = 'hour'; + } + + let value; + if (unit === 'minute' || 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; + } + let subscriptionName = subscription.name + return { unit, value, subscriptionName }; +} + +function shouldTriggerReminder(reminder, daysDiff, hoursDiff, minutesDiff) { + console.log('shouldTriggerReminder', reminder, daysDiff, hoursDiff.toFixed(2), minutesDiff.toFixed(2)) + if (!reminder) { + return false; + } + // Cloudflare Cron 容错窗口:允许 1 分钟的延迟 + const CRON_TOLERANCE_MINUTES = 1; + if (reminder.unit === 'minute') { + if (reminder.value === 0) { + // 到期时提醒:允许在到期前1分钟到到期后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && minutesDiff <= 0; + } + // 提前X分钟提醒:允许在目标时间前后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && minutesDiff <= reminder.value; + } + if (reminder.unit === 'hour') { + if (reminder.value === 0) { + // 到期时提醒:允许在到期前1小时到到期后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && hoursDiff <= 0; + } + // 提前X小时提醒:允许在目标时间后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && hoursDiff <= reminder.value; + } + if (reminder.value === 0) { + // 到期当天提醒:允许在到期后x分钟内触发 + return daysDiff === 0 || (daysDiff === -1 && minutesDiff >= -CRON_TOLERANCE_MINUTES && minutesDiff < 0); + } + return daysDiff >= 0 && daysDiff <= reminder.value; +} + +const NOTIFIER_KEYS = ['telegram', 'notifyx', 'webhook', 'wechatbot', 'email_smtp', 'email_resend', 'email_mailgun', 'bark']; + +function normalizeNotificationHours(raw) { + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(/[,,\s]+/) + : []; + + return values + .map(value => String(value).trim()) + .filter(value => value.length > 0) + .map(value => { + const upperValue = value.toUpperCase(); + if (upperValue === '*' || upperValue === 'ALL') { + return '*'; + } + // 支持 HH:MM 格式(如 08:30, 12:15) + if (value.includes(':')) { + const parts = value.split(':'); + if (parts.length === 2) { + const hour = parseInt(parts[0]); + const minute = parseInt(parts[1]); + if (!isNaN(hour) && !isNaN(minute) && hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { + return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0'); + } + } + } + // 仅小时格式(如 08, 12, 20) + const numeric = Number(upperValue); + if (!isNaN(numeric)) { + return String(Math.max(0, Math.min(23, Math.floor(numeric)))).padStart(2, '0'); + } + return upperValue; + }); +} + +function normalizeNotifierList(raw) { + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(/[,,\s]+/) + : []; + + return values + .map(value => { + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'email' || normalized === 'resend' || normalized === 'email_resend') { + return 'email_resend'; + } + if (normalized === 'mailgun' || normalized === 'email_mailgun') { + return 'email_mailgun'; + } + if (normalized === 'smtp' || normalized === 'email_smtp') { + return 'email_smtp'; + } + return normalized; + }) + .filter(value => value.length > 0) + .filter(value => NOTIFIER_KEYS.includes(value)); +} + +function normalizeEmailRecipients(raw) { + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(/[,,\s]+/) + : []; + + return values + .map(value => String(value).trim()) + .filter(value => value.length > 0); +} + +function resolveEmailProvider(provider) { + const normalized = String(provider || '').toLowerCase(); + if (normalized === 'smtp') { + return 'smtp'; + } + if (normalized === 'mailgun' || normalized === 'email_mailgun') { + return 'mailgun'; + } + return 'resend'; +} + +function resolveEmailConfigForProvider(provider, config) { + const legacyFrom = config?.EMAIL_FROM || ''; + const legacyFromName = config?.EMAIL_FROM_NAME || ''; + const legacyTo = config?.EMAIL_TO || ''; + if (provider === 'smtp') { + return { + apiKey: '', + from: config?.SMTP_FROM || legacyFrom || config?.SMTP_USER || '', + fromName: config?.SMTP_FROM_NAME || legacyFromName || '', + to: config?.SMTP_TO || legacyTo + }; + } + if (provider === 'mailgun') { + return { + apiKey: config?.MAILGUN_API_KEY || '', + from: config?.MAILGUN_FROM || legacyFrom || '', + fromName: config?.MAILGUN_FROM_NAME || legacyFromName || '', + to: config?.MAILGUN_TO || legacyTo + }; + } + return { + apiKey: config?.RESEND_API_KEY || '', + from: config?.RESEND_FROM || legacyFrom || '', + fromName: config?.RESEND_FROM_NAME || legacyFromName || '', + to: config?.RESEND_TO || legacyTo + }; +} + +function formatEmailFrom(address, name) { + const trimmedAddress = String(address || '').trim(); + const trimmedName = String(name || '').trim(); + if (!trimmedAddress) { + return ''; + } + return trimmedName ? `${trimmedName} <${trimmedAddress}>` : trimmedAddress; +} + +function extractEmailAddress(value) { + const raw = String(value || '').trim(); + const match = raw.match(/<([^>]+)>/); + return (match ? match[1] : raw).trim(); +} + +function extractEmailDomain(value) { + const address = extractEmailAddress(value); + const atIndex = address.lastIndexOf('@'); + return atIndex !== -1 ? address.slice(atIndex + 1).trim() : ''; +} + +function resolveSubscriptionNotificationHours(subscription, config) { + if (subscription && Object.prototype.hasOwnProperty.call(subscription, 'notificationHours')) { + return normalizeNotificationHours(subscription.notificationHours); + } + return normalizeNotificationHours(config?.NOTIFICATION_HOURS); +} + +function resolveSubscriptionNotifiers(subscription, config) { + if (subscription && Object.prototype.hasOwnProperty.call(subscription, 'enabledNotifiers')) { + return normalizeNotifierList(subscription.enabledNotifiers); + } + const fallback = normalizeNotifierList(config?.ENABLED_NOTIFIERS); + return fallback.length ? fallback : NOTIFIER_KEYS.slice(); +} + +function resolveSubscriptionEmailRecipients(subscription) { + return normalizeEmailRecipients(subscription?.emailTo); +} + +function shouldNotifyAtCurrentHour(notificationHours, currentHour, currentMinute, expiryDate = null, timezone = 'UTC') { + const normalized = normalizeNotificationHours(notificationHours); + if (normalized.length === 0 || normalized.includes('*')) { + return true; + } + + // 格式化当前时间为 HH:MM + const currentTimeStr = String(currentHour).padStart(2, '0') + ':' + String(currentMinute).padStart(2, '0'); + const currentHourStr = String(currentHour).padStart(2, '0'); + + // 如果提供了过期时间,检查当前时间是否与过期时间的 HH:MM 相等 + if (expiryDate) { + try { + const expiryTimeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour12: false, + hour: '2-digit', + minute: '2-digit' + }); + const expiryTimeParts = expiryTimeFormatter.formatToParts(new Date(expiryDate)); + const expiryHour = expiryTimeParts.find(p => p.type === 'hour')?.value || '00'; + const expiryMinute = expiryTimeParts.find(p => p.type === 'minute')?.value || '00'; + const expiryTimeStr = expiryHour + ':' + expiryMinute; + // 如果当前时间与过期时间的 HH:MM 相等,直接返回 true + if (currentTimeStr === expiryTimeStr) { + return true; + } + } catch (error) { + console.error('解析过期时间失败:', error); + } + } + + // 检查是否匹配配置的通知时间 + for (const time of normalized) { + if (time.includes(':')) { + // 对于 HH:MM 格式,允许在同一分钟内触发(考虑到定时任务可能不是精确在该分钟的0秒执行) + // 比如设置了 13:48,那么 13:48:00 到 13:48:59 都应该允许 + const [targetHour, targetMinute] = time.split(':').map(v => parseInt(v)); + const currentHourInt = parseInt(currentHour); + const currentMinuteInt = parseInt(currentMinute); + + if (targetHour === currentHourInt && targetMinute === currentMinuteInt) { + return true; + } + } else { + // 仅匹配小时(整个小时内都允许) + if (time === currentHourStr) { + return true; + } + } + } + + return false; +} + +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} ${{ minute: '分钟', hour: '小时', 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, 'datetime'); + + // 农历日期 + 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.periodUnit === 'minute') { + // 分钟级订阅:显示分钟 + const minutesRemaining = sub.minutesRemaining !== undefined ? sub.minutesRemaining : (sub.hoursRemaining ? sub.hoursRemaining * 60 : sub.daysRemaining * 24 * 60); + if (Math.abs(minutesRemaining) < 1) { + statusEmoji = '⚠️'; + statusText = '即将到期!'; + } else if (minutesRemaining < 0) { + statusEmoji = '🚨'; + statusText = `已过期 ${Math.abs(Math.round(minutesRemaining))} 分钟`; + } else { + statusEmoji = '📅'; + statusText = `将在 ${Math.round(minutesRemaining)} 分钟后到期`; + } + } else if (sub.periodUnit === 'hour') { + // 小时级订阅:显示小时 + const hoursRemaining = sub.hoursRemaining !== undefined ? sub.hoursRemaining : sub.daysRemaining * 24; + if (Math.abs(hoursRemaining) < 1) { + statusEmoji = '⚠️'; + statusText = '即将到期!'; + } else if (hoursRemaining < 0) { + statusEmoji = '🚨'; + statusText = `已过期 ${Math.abs(Math.round(hoursRemaining))} 小时`; + } else { + statusEmoji = '📅'; + statusText = `将在 ${Math.round(hoursRemaining)} 小时后到期`; + } + } else { + // 天级订阅:显示天数 + 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 === 'minute' + ? '(分钟级提醒)' + : reminderSetting.unit === 'hour' + ? '(小时级提醒)' + : ''; + + const reminderText = reminderSetting.unit === 'minute' + ? `提醒策略: 提前 ${reminderSetting.value} 分钟${reminderSuffix}` + : 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 || {}; + const requestedNotifiers = normalizeNotifierList(options.notifiers); + const globalNotifiers = normalizeNotifierList(config.ENABLED_NOTIFIERS); + const baseNotifiers = requestedNotifiers.length ? requestedNotifiers : globalNotifiers; + const effectiveNotifiers = globalNotifiers.length + ? baseNotifiers.filter(item => globalNotifiers.includes(item)) + : baseNotifiers; + + if (!effectiveNotifiers || effectiveNotifiers.length === 0) { + console.log(`${logPrefix} 未启用任何通知渠道。`); + return; + } + + if (effectiveNotifiers.includes('notifyx')) { + const notifyxContent = `## ${title}\n\n${commonContent}`; + const success = await sendNotifyXNotification(title, notifyxContent, `订阅提醒`, config); + console.log(`${logPrefix} 发送NotifyX通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('telegram')) { + const telegramContent = `*${title}*\n\n${commonContent}`; + const success = await sendTelegramNotification(telegramContent, config); + console.log(`${logPrefix} 发送Telegram通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('webhook')) { + const webhookContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendWebhookNotification(title, webhookContent, config, metadata); + console.log(`${logPrefix} 发送Webhook通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('wechatbot')) { + const wechatbotContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendWechatBotNotification(title, wechatbotContent, config); + console.log(`${logPrefix} 发送企业微信机器人通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('email_resend')) { + const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendEmailNotification(title, emailContent, config, { + provider: 'resend', + emailTo: options.emailTo + }); + console.log(`${logPrefix} 发送邮件通知(Resend) ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('email_mailgun')) { + const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendEmailNotification(title, emailContent, config, { + provider: 'mailgun', + emailTo: options.emailTo + }); + console.log(`${logPrefix} 发送邮件通知(Mailgun) ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('email_smtp')) { + const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendEmailNotification(title, emailContent, config, { + provider: 'smtp', + emailTo: options.emailTo + }); + console.log(`${logPrefix} 发送邮件通知(SMTP) ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.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, options = {}) { + try { + const provider = resolveEmailProvider(options.provider); + const recipients = normalizeEmailRecipients(options.emailTo); + const providerConfig = resolveEmailConfigForProvider(provider, config); + const targetRecipients = recipients.length + ? recipients + : normalizeEmailRecipients(providerConfig.to); + const fromEmail = formatEmailFrom(providerConfig.from, providerConfig.fromName); + + // 生成HTML邮件内容 + const htmlContent = ` + + + + + + ${title} + + + +
+
+

📅 ${title}

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

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

+
+ +
+ +`; + + if (provider === 'smtp') { + console.log('[Email Notification] Using SMTP provider'); + if (targetRecipients.length === 0) { + console.error('[Email Notification] Missing SMTP recipients'); + return false; + } + return await sendSmtpEmailNotification(title, content, htmlContent, config, targetRecipients); + } + + if (provider === 'mailgun') { + if (!providerConfig.apiKey || !providerConfig.from || targetRecipients.length === 0) { + console.error('[Email Notification] Missing Mailgun config or recipients'); + return false; + } + const domain = extractEmailDomain(providerConfig.from); + if (!domain) { + console.error('[Email Notification] Unable to resolve Mailgun domain from from address'); + return false; + } + console.log('[Email Notification] Sending via Mailgun to: ' + targetRecipients.join(', ')); + const auth = btoa('api:' + providerConfig.apiKey); + const response = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}` + }, + body: new URLSearchParams({ + from: fromEmail, + to: targetRecipients.join(', '), + subject: title, + html: htmlContent, + text: content + }) + }); + + let result; + try { + result = await response.json(); + } catch (parseError) { + result = await response.text(); + } + console.log('[Email Notification] Mailgun response', response.status, result); + return response.ok; + } + + if (!providerConfig.apiKey || !providerConfig.from || targetRecipients.length === 0) { + console.error('[Email Notification] Missing Resend config or recipients'); + return false; + } + + console.log('[Email Notification] Sending via Resend to: ' + targetRecipients.join(', ')); + + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${providerConfig.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: fromEmail, + to: targetRecipients, + subject: title, + html: htmlContent, + text: content + }) + }); + + const result = await response.json(); + console.log('[Email Notification] Resend response', response.status, result); + + if (response.ok && result.id) { + console.log('[Email Notification] Resend message accepted, ID:', result.id); + return true; + } + console.error('[Email Notification] Resend send failed', result); + return false; + + } catch (error) { + console.error('[邮件通知] 发送邮件失败:', error); + return false; + } +} + +async function sendSmtpEmailNotification(title, content, htmlContent, config, recipients) { + const result = await sendSmtpEmailNotificationDetailed(title, content, htmlContent, config, recipients); + return result.success; +} + +async function sendSmtpEmailNotificationDetailed(title, content, htmlContent, config, recipients) { + try { + const smtpPort = Number(config.SMTP_PORT); + const smtpFrom = config.SMTP_FROM || config.EMAIL_FROM || config.SMTP_USER || ''; + const smtpFromName = config.SMTP_FROM_NAME || config.EMAIL_FROM_NAME || ''; + if (!config.SMTP_HOST || !smtpPort || !config.SMTP_USER || !config.SMTP_PASS || !smtpFrom || !recipients.length) { + console.error('[SMTP邮件通知] 通知未配置,缺少必要参数'); + return { success: false, message: 'Missing SMTP config or recipients' }; + } + + const fromEmail = smtpFromName ? + `${smtpFromName} <${smtpFrom}>` : + smtpFrom; + + console.log('[SMTP邮件通知] 开始发送邮件到: ' + recipients.join(', ')); + + const response = await fetch('https://smtpjs.com/v3/smtpjs.aspx', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + Host: config.SMTP_HOST, + Port: smtpPort, + Username: config.SMTP_USER, + Password: config.SMTP_PASS, + To: recipients.join(','), + From: fromEmail, + Subject: title, + Body: htmlContent, + Secure: smtpPort === 465 + }) + }); + + const resultText = await response.text(); + console.log('[SMTP邮件通知] 发送结果:', response.status, resultText); + + if (response.ok && resultText && resultText.toLowerCase().includes('ok')) { + return { success: true, message: 'OK' }; + } + console.error('[SMTP邮件通知] 发送失败:', resultText); + return { + success: false, + message: 'SMTPJS response: ' + (resultText || 'empty response'), + status: response.status + }; + } catch (error) { + console.error('[SMTP邮件通知] 发送邮件失败:', error); + return { success: false, message: error.message || 'SMTP send error' }; + } +} + +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 timeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour12: false, + hour: '2-digit', + minute: '2-digit' + }); + const timeParts = timeFormatter.formatToParts(currentTime); + const currentHour = timeParts.find(p => p.type === 'hour')?.value || '00'; + const currentMinute = timeParts.find(p => p.type === 'minute')?.value || '00'; + + 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); + const subscriptionNotificationHours = resolveSubscriptionNotificationHours(subscription, config); + const shouldNotifyThisHour = shouldNotifyAtCurrentHour( + subscriptionNotificationHours, + currentHour, + currentMinute, + subscription.expiryDate, + timezone + ); + + // 计算当前剩余时间(基础计算) + 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); + } + + let daysDiff = Math.round((expiryMidnight - currentMidnight) / MS_PER_DAY); + // 直接计算时间差(expiryDate 和 currentTime 都是 UTC 时间戳) + let diffMs = expiryDate.getTime() - currentTime.getTime(); + let diffHours = diffMs / MS_PER_HOUR; + let diffMinutes = diffMs / (1000 * 60); + // ========================================== + // 核心逻辑:自动续费处理 + // ========================================== + // 修复:对于分钟级和小时级订阅,使用精确的时间差判断是否过期 + let isExpiredForRenewal = diffMs <= 0; + if (isExpiredForRenewal && subscription.periodValue && subscription.periodUnit && subscription.autoRenew !== false) { + console.log(`[定时任务] 订阅:"${subscription.name}" 已过期,准备自动续费...`); + + 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 === 'minute') targetDate.setMinutes(targetDate.getMinutes() + subscription.periodValue); + else if (subscription.periodUnit === 'hour') targetDate.setHours(targetDate.getHours() + subscription.periodValue); + else 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(`[定时任务] 订阅:${subscription.name} 续费完成. 新开始日: ${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天) + let diffMs1 = newExpiryDate.getTime() - currentTime.getTime(); + let diffHours1 = diffMs1 / MS_PER_HOUR; + let diffMinutes1 = diffMs1 / (1000 * 60); + const shouldRemindAfterRenewal = shouldTriggerReminder(reminderSetting, daysDiff, diffHours1, diffMinutes1); + + if (shouldRemindAfterRenewal && shouldNotifyThisHour) { + expiringSubscriptions.push({ + ...updatedSubscription, + daysRemaining: daysDiff, + hoursRemaining: Math.round(diffHours1) + }); + } else if (shouldRemindAfterRenewal && !shouldNotifyThisHour) { + console.log(`[Scheduler] Hour ${currentHour} not in reminder hours for "${subscription.name}", skipping notification`); + } + + // continue; // 处理下一个订阅 + } + + // ========================================== + // 普通提醒逻辑 (未过期,或过期但不自动续费) + // ========================================== + const shouldRemind = shouldTriggerReminder(reminderSetting, daysDiff, diffHours, diffMinutes); + + // 格式化剩余时间显示 + let remainingTimeStr; + if (daysDiff >= 1) { + remainingTimeStr = `${daysDiff}天`; + } else if (diffHours >= 1) { + remainingTimeStr = `${diffHours.toFixed(2)}小时`; + } else { + remainingTimeStr = `${diffMinutes.toFixed(2)}分钟`; + } + + console.log(`[定时任务] ${subscription.name} | 当前时间: ${currentHour}:${currentMinute} | 通知时间点: ${JSON.stringify(subscriptionNotificationHours)} | 时间匹配: ${shouldNotifyThisHour} | 提醒判断: ${shouldRemind} | 剩余: ${remainingTimeStr}`); + + // 修复:对于分钟级和小时级订阅,使用精确的时间差判断是否过期 + let isExpiredForNotification = false; + if (subscription.periodUnit === 'minute' || subscription.periodUnit === 'hour') { + isExpiredForNotification = diffMs < 0; + } else { + isExpiredForNotification = daysDiff < 0; + } + + if (isExpiredForNotification && subscription.autoRenew === false) { + // 已过期且不自动续费 -> 发送过期通知 + if (shouldNotifyThisHour) { + expiringSubscriptions.push({ + ...subscription, + daysRemaining: daysDiff, + hoursRemaining: Math.round(diffHours), + minutesRemaining: Math.round(diffMinutes) + }); + } else { + console.log(`[Scheduler] Hour ${currentHour} not in reminder hours for "${subscription.name}", skipping notification`); + } + } else if (shouldRemind) { + // 正常到期提醒 + if (shouldNotifyThisHour) { + expiringSubscriptions.push({ + ...subscription, + daysRemaining: daysDiff, + hoursRemaining: Math.round(diffHours), + minutesRemaining: Math.round(diffMinutes) + }); + } else { + console.log(`[Scheduler] Hour ${currentHour} not in reminder hours for "${subscription.name}", skipping notification`); + } + } + } + + // --- 保存更改 --- + 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) { + console.log(`[Scheduler] Sending ${expiringSubscriptions.length} reminder notification(s)`); + // Sort by due time + expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining); + + for (const subscription of expiringSubscriptions) { + const commonContent = formatNotificationContent([subscription], config); + const metadataTags = extractTagsFromSubscriptions([subscription]); + await sendNotificationToAllChannels(`Subscription reminder: ${subscription.name}`, commonContent, config, '[Scheduler]', { + metadata: { tags: metadataTags }, + notifiers: resolveSubscriptionNotifiers(subscription, config), + emailTo: resolveSubscriptionEmailRecipients(subscription) + }); + } + } + } 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); + // 修复:计算完整的天数差,使用 Math.floor() 向下取整 + // 例如:1.9天显示为"1天后",2.1天显示为"2天后" + const diffMs = renewalDate - now; + const daysUntilRenewal = Math.max(0, Math.floor(diffMs / 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); +} \ No newline at end of file diff --git a/index_zzz_0210.js b/index_zzz_0210.js new file mode 100644 index 0000000..319aa0d --- /dev/null +++ b/index_zzz_0210.js @@ -0,0 +1,8836 @@ +// 订阅续期通知网站 - 基于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 控制台换算触发时间)

+
+
+ + +
+

通知设置

+
+
+ + +

Comma-separated hours; empty = allow any hour (used when per-item hours are not set)

+
+
+

提示

+

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

+

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

+
+
+
+ +
+ + + + + + + + +
+ +
+ +
+ +
+
+ + +
+ +
+

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

+
+ +
+

Telegram 配置

+
+
+ +
+ + +
+
+
+ + +
+
+
+ +
+
+ +
+

NotifyX 配置

+
+ +
+ + +
+

NotifyX平台 获取的 API Key

+
+
+ +
+
+ +
+

Email config

+
+
+ + + +
+
+ + + + + + +
+ +
+

Webhook 通知 配置

+
+
+ + +

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

+
+
+ + +
+
+ + +

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

+
+
+ + +

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

+
+
+
+ +
+
+ +
+

企业微信机器人 配置

+
+
+ + +

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

+
+
+ + +

选择发送的消息格式类型

+
+
+ + +

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

+
+
+ + +
+
+
+ +
+
+ +
+

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 || config.RESEND_API_KEY || '', + RESEND_FROM: newConfig.RESEND_FROM || config.RESEND_FROM || '', + RESEND_FROM_NAME: newConfig.RESEND_FROM_NAME || config.RESEND_FROM_NAME || '', + RESEND_TO: newConfig.RESEND_TO || config.RESEND_TO || '', + MAILGUN_API_KEY: newConfig.MAILGUN_API_KEY || config.MAILGUN_API_KEY || '', + MAILGUN_FROM: newConfig.MAILGUN_FROM || config.MAILGUN_FROM || '', + MAILGUN_FROM_NAME: newConfig.MAILGUN_FROM_NAME || config.MAILGUN_FROM_NAME || '', + MAILGUN_TO: newConfig.MAILGUN_TO || config.MAILGUN_TO || '', + EMAIL_FROM: newConfig.EMAIL_FROM || config.EMAIL_FROM || '', + EMAIL_FROM_NAME: newConfig.EMAIL_FROM_NAME || config.EMAIL_FROM_NAME || '', + EMAIL_TO: newConfig.EMAIL_TO || config.EMAIL_TO || '', + SMTP_HOST: newConfig.SMTP_HOST || config.SMTP_HOST || '', + SMTP_PORT: newConfig.SMTP_PORT || config.SMTP_PORT || '', + SMTP_USER: newConfig.SMTP_USER || config.SMTP_USER || '', + SMTP_PASS: newConfig.SMTP_PASS || config.SMTP_PASS || '', + SMTP_FROM: newConfig.SMTP_FROM || config.SMTP_FROM || '', + SMTP_FROM_NAME: newConfig.SMTP_FROM_NAME || config.SMTP_FROM_NAME || '', + SMTP_TO: newConfig.SMTP_TO || config.SMTP_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 expiringSoon = activeSubscriptions.filter(s => { + const expiryDate = new Date(s.expiryDate); + const diffMs = expiryDate.getTime() - now.getTime(); + const diffHours = diffMs / (1000 * 60 * 60); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + + // 获取订阅的提醒设置 + const reminder = resolveReminderSetting(s, 7); + + // 根据提醒单位判断是否即将到期 + const isSoon = reminder.unit === 'minute' + ? diffMs >= 0 && diffMs <= reminder.value * 60 * 1000 + : reminder.unit === 'hour' + ? diffHours >= 0 && diffHours <= reminder.value + : diffDays >= 0 && diffDays <= reminder.value; + + return isSoon; + }).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_resend' || body.type === 'email_smtp' || body.type === 'email_mailgun') { + const testConfig = { + ...config, + RESEND_API_KEY: body.RESEND_API_KEY, + RESEND_FROM: body.RESEND_FROM, + RESEND_FROM_NAME: body.RESEND_FROM_NAME, + RESEND_TO: body.RESEND_TO, + MAILGUN_API_KEY: body.MAILGUN_API_KEY, + MAILGUN_FROM: body.MAILGUN_FROM, + MAILGUN_FROM_NAME: body.MAILGUN_FROM_NAME, + MAILGUN_TO: body.MAILGUN_TO, + EMAIL_FROM: body.EMAIL_FROM, + EMAIL_FROM_NAME: body.EMAIL_FROM_NAME, + EMAIL_TO: body.EMAIL_TO, + SMTP_HOST: body.SMTP_HOST, + SMTP_PORT: body.SMTP_PORT, + SMTP_USER: body.SMTP_USER, + SMTP_PASS: body.SMTP_PASS, + SMTP_FROM: body.SMTP_FROM, + SMTP_FROM_NAME: body.SMTP_FROM_NAME, + SMTP_TO: body.SMTP_TO + }; + const emailProvider = body.EMAIL_PROVIDER + || (body.type === 'email_smtp' ? 'smtp' : (body.type === 'email_mailgun' ? 'mailgun' : 'resend')); + + const title = '测试通知'; + const content = '这是一条测试通知,用于验证邮件通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime(); + + if (emailProvider === 'smtp') { + const htmlContent = '
' + content.replace(/\n/g, '
') + '
'; + const detail = await sendSmtpEmailNotificationDetailed( + title, + content, + htmlContent, + testConfig, + normalizeEmailRecipients(testConfig.SMTP_TO || testConfig.EMAIL_TO) + ); + success = detail.success; + message = success ? '邮件通知发送成功' : 'SMTP发送失败: ' + (detail.message || '未知错误'); + } else { + success = await sendEmailNotification(title, content, testConfig, { provider: emailProvider }); + 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 || '', + RESEND_FROM: config.RESEND_FROM || config.EMAIL_FROM || '', + RESEND_FROM_NAME: config.RESEND_FROM_NAME || config.EMAIL_FROM_NAME || '', + RESEND_TO: config.RESEND_TO || config.EMAIL_TO || '', + MAILGUN_API_KEY: config.MAILGUN_API_KEY || '', + MAILGUN_FROM: config.MAILGUN_FROM || config.EMAIL_FROM || '', + MAILGUN_FROM_NAME: config.MAILGUN_FROM_NAME || config.EMAIL_FROM_NAME || '', + MAILGUN_TO: config.MAILGUN_TO || config.EMAIL_TO || '', + EMAIL_FROM: config.EMAIL_FROM || '', + EMAIL_FROM_NAME: config.EMAIL_FROM_NAME || '', + EMAIL_TO: config.EMAIL_TO || '', + SMTP_HOST: config.SMTP_HOST || '', + SMTP_PORT: config.SMTP_PORT || '', + SMTP_USER: config.SMTP_USER || '', + SMTP_PASS: config.SMTP_PASS || '', + SMTP_FROM: config.SMTP_FROM || config.EMAIL_FROM || '', + SMTP_FROM_NAME: config.SMTP_FROM_NAME || config.EMAIL_FROM_NAME || '', + SMTP_TO: config.SMTP_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: '', + RESEND_FROM: '', + RESEND_FROM_NAME: '', + RESEND_TO: '', + MAILGUN_API_KEY: '', + MAILGUN_FROM: '', + MAILGUN_FROM_NAME: '', + MAILGUN_TO: '', + EMAIL_FROM: '', + EMAIL_FROM_NAME: '', + EMAIL_TO: '', + SMTP_HOST: '', + SMTP_PORT: '', + SMTP_USER: '', + SMTP_PASS: '', + SMTP_FROM: '', + SMTP_FROM_NAME: '', + SMTP_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 === 'minute') { + expiryDate.setMinutes(expiryDate.getMinutes() + subscription.periodValue); + } else if (subscription.periodUnit === 'hour') { + expiryDate.setHours(expiryDate.getHours() + subscription.periodValue); + } else 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 hasNotificationHours = Object.prototype.hasOwnProperty.call(subscription, 'notificationHours'); + const hasEnabledNotifiers = Object.prototype.hasOwnProperty.call(subscription, 'enabledNotifiers'); + const hasEmailTo = Object.prototype.hasOwnProperty.call(subscription, 'emailTo'); + const normalizedNotificationHours = hasNotificationHours + ? normalizeNotificationHours(subscription.notificationHours) + : undefined; + const normalizedNotifiers = hasEnabledNotifiers + ? normalizeNotifierList(subscription.enabledNotifiers) + : undefined; + const normalizedEmailTo = hasEmailTo + ? normalizeEmailRecipients(subscription.emailTo) + : undefined; + + 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.unit === 'minute') ? reminderSetting.value : undefined, + notificationHours: normalizedNotificationHours, + enabledNotifiers: normalizedNotifiers, + emailTo: normalizedEmailTo, + 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 === 'minute') { + expiryDate.setMinutes(expiryDate.getMinutes() + subscription.periodValue); + } else if (subscription.periodUnit === 'hour') { + expiryDate.setHours(expiryDate.getHours() + subscription.periodValue); + } else 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 hasNotificationHours = Object.prototype.hasOwnProperty.call(subscription, 'notificationHours'); + const hasEnabledNotifiers = Object.prototype.hasOwnProperty.call(subscription, 'enabledNotifiers'); + const hasEmailTo = Object.prototype.hasOwnProperty.call(subscription, 'emailTo'); + const normalizedNotificationHours = hasNotificationHours + ? normalizeNotificationHours(subscription.notificationHours) + : subscriptions[index].notificationHours; + const normalizedNotifiers = hasEnabledNotifiers + ? normalizeNotifierList(subscription.enabledNotifiers) + : subscriptions[index].enabledNotifiers; + const normalizedEmailTo = hasEmailTo + ? normalizeEmailRecipients(subscription.emailTo) + : subscriptions[index].emailTo; + + 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.unit === 'minute') ? reminderSetting.value : undefined, + notificationHours: normalizedNotificationHours, + enabledNotifiers: normalizedNotifiers, + emailTo: normalizedEmailTo, + 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, 'datetime'); + 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 }, + notifiers: subscription.enabledNotifiers, + emailTo: subscription.emailTo + }); + + 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 ? subscription.reminderUnit : 'day'; + + // 兼容旧数据:如果没有 reminderUnit 但有 reminderHours,则推断为 hour + if (!subscription.reminderUnit && subscription.reminderHours !== undefined) { + unit = 'hour'; + } + + let value; + if (unit === 'minute' || 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; + } + let subscriptionName = subscription.name + return { unit, value, subscriptionName }; +} + +function shouldTriggerReminder(reminder, daysDiff, hoursDiff, minutesDiff) { + console.log('shouldTriggerReminder', reminder, daysDiff, hoursDiff.toFixed(2), minutesDiff.toFixed(2)) + if (!reminder) { + return false; + } + // Cloudflare Cron 容错窗口:允许 1 分钟的延迟 + const CRON_TOLERANCE_MINUTES = 1; + if (reminder.unit === 'minute') { + if (reminder.value === 0) { + // 到期时提醒:允许在到期前1分钟到到期后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && minutesDiff <= 0; + } + // 提前X分钟提醒:允许在目标时间前后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && minutesDiff <= reminder.value; + } + if (reminder.unit === 'hour') { + if (reminder.value === 0) { + // 到期时提醒:允许在到期前1小时到到期后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && hoursDiff <= 0; + } + // 提前X小时提醒:允许在目标时间后x分钟内触发 + return minutesDiff >= -CRON_TOLERANCE_MINUTES && hoursDiff <= reminder.value; + } + if (reminder.value === 0) { + // 到期当天提醒:允许在到期后x分钟内触发 + return daysDiff === 0 || (daysDiff === -1 && minutesDiff >= -CRON_TOLERANCE_MINUTES && minutesDiff < 0); + } + return daysDiff >= 0 && daysDiff <= reminder.value; +} + +const NOTIFIER_KEYS = ['telegram', 'notifyx', 'webhook', 'wechatbot', 'email_smtp', 'email_resend', 'email_mailgun', 'bark']; + +function normalizeNotificationHours(raw) { + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(/[,,\s]+/) + : []; + + return values + .map(value => String(value).trim()) + .filter(value => value.length > 0) + .map(value => { + const upperValue = value.toUpperCase(); + if (upperValue === '*' || upperValue === 'ALL') { + return '*'; + } + // 支持 HH:MM 格式(如 08:30, 12:15) + if (value.includes(':')) { + const parts = value.split(':'); + if (parts.length === 2) { + const hour = parseInt(parts[0]); + const minute = parseInt(parts[1]); + if (!isNaN(hour) && !isNaN(minute) && hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { + return String(hour).padStart(2, '0') + ':' + String(minute).padStart(2, '0'); + } + } + } + // 仅小时格式(如 08, 12, 20) + const numeric = Number(upperValue); + if (!isNaN(numeric)) { + return String(Math.max(0, Math.min(23, Math.floor(numeric)))).padStart(2, '0'); + } + return upperValue; + }); +} + +function normalizeNotifierList(raw) { + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(/[,,\s]+/) + : []; + + return values + .map(value => { + const normalized = String(value).trim().toLowerCase(); + if (normalized === 'email' || normalized === 'resend' || normalized === 'email_resend') { + return 'email_resend'; + } + if (normalized === 'mailgun' || normalized === 'email_mailgun') { + return 'email_mailgun'; + } + if (normalized === 'smtp' || normalized === 'email_smtp') { + return 'email_smtp'; + } + return normalized; + }) + .filter(value => value.length > 0) + .filter(value => NOTIFIER_KEYS.includes(value)); +} + +function normalizeEmailRecipients(raw) { + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(/[,,\s]+/) + : []; + + return values + .map(value => String(value).trim()) + .filter(value => value.length > 0); +} + +function resolveEmailProvider(provider) { + const normalized = String(provider || '').toLowerCase(); + if (normalized === 'smtp') { + return 'smtp'; + } + if (normalized === 'mailgun' || normalized === 'email_mailgun') { + return 'mailgun'; + } + return 'resend'; +} + +function resolveEmailConfigForProvider(provider, config) { + const legacyFrom = config?.EMAIL_FROM || ''; + const legacyFromName = config?.EMAIL_FROM_NAME || ''; + const legacyTo = config?.EMAIL_TO || ''; + if (provider === 'smtp') { + return { + apiKey: '', + from: config?.SMTP_FROM || legacyFrom || config?.SMTP_USER || '', + fromName: config?.SMTP_FROM_NAME || legacyFromName || '', + to: config?.SMTP_TO || legacyTo + }; + } + if (provider === 'mailgun') { + return { + apiKey: config?.MAILGUN_API_KEY || '', + from: config?.MAILGUN_FROM || legacyFrom || '', + fromName: config?.MAILGUN_FROM_NAME || legacyFromName || '', + to: config?.MAILGUN_TO || legacyTo + }; + } + return { + apiKey: config?.RESEND_API_KEY || '', + from: config?.RESEND_FROM || legacyFrom || '', + fromName: config?.RESEND_FROM_NAME || legacyFromName || '', + to: config?.RESEND_TO || legacyTo + }; +} + +function formatEmailFrom(address, name) { + const trimmedAddress = String(address || '').trim(); + const trimmedName = String(name || '').trim(); + if (!trimmedAddress) { + return ''; + } + return trimmedName ? `${trimmedName} <${trimmedAddress}>` : trimmedAddress; +} + +function extractEmailAddress(value) { + const raw = String(value || '').trim(); + const match = raw.match(/<([^>]+)>/); + return (match ? match[1] : raw).trim(); +} + +function extractEmailDomain(value) { + const address = extractEmailAddress(value); + const atIndex = address.lastIndexOf('@'); + return atIndex !== -1 ? address.slice(atIndex + 1).trim() : ''; +} + +function resolveSubscriptionNotificationHours(subscription, config) { + if (subscription && Object.prototype.hasOwnProperty.call(subscription, 'notificationHours')) { + return normalizeNotificationHours(subscription.notificationHours); + } + return normalizeNotificationHours(config?.NOTIFICATION_HOURS); +} + +function resolveSubscriptionNotifiers(subscription, config) { + if (subscription && Object.prototype.hasOwnProperty.call(subscription, 'enabledNotifiers')) { + return normalizeNotifierList(subscription.enabledNotifiers); + } + const fallback = normalizeNotifierList(config?.ENABLED_NOTIFIERS); + return fallback.length ? fallback : NOTIFIER_KEYS.slice(); +} + +function resolveSubscriptionEmailRecipients(subscription) { + return normalizeEmailRecipients(subscription?.emailTo); +} + +function shouldNotifyAtCurrentHour(notificationHours, currentHour, currentMinute) { + const normalized = normalizeNotificationHours(notificationHours); + if (normalized.length === 0 || normalized.includes('*')) { + return true; + } + + // 格式化当前时间为 HH:MM + const currentTimeStr = String(currentHour).padStart(2, '0') + ':' + String(currentMinute).padStart(2, '0'); + const currentHourStr = String(currentHour).padStart(2, '0'); + + // 检查是否匹配 + for (const time of normalized) { + if (time.includes(':')) { + // 对于 HH:MM 格式,允许在同一分钟内触发(考虑到定时任务可能不是精确在该分钟的0秒执行) + // 比如设置了 13:48,那么 13:48:00 到 13:48:59 都应该允许 + const [targetHour, targetMinute] = time.split(':').map(v => parseInt(v)); + const currentHourInt = parseInt(currentHour); + const currentMinuteInt = parseInt(currentMinute); + + if (targetHour === currentHourInt && targetMinute === currentMinuteInt) { + return true; + } + } else { + // 仅匹配小时(整个小时内都允许) + if (time === currentHourStr) { + return true; + } + } + } + + return false; +} + +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} ${{ minute: '分钟', hour: '小时', 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, 'datetime'); + + // 农历日期 + 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.periodUnit === 'minute') { + // 分钟级订阅:显示分钟 + const minutesRemaining = sub.minutesRemaining !== undefined ? sub.minutesRemaining : (sub.hoursRemaining ? sub.hoursRemaining * 60 : sub.daysRemaining * 24 * 60); + if (Math.abs(minutesRemaining) < 1) { + statusEmoji = '⚠️'; + statusText = '即将到期!'; + } else if (minutesRemaining < 0) { + statusEmoji = '🚨'; + statusText = `已过期 ${Math.abs(Math.round(minutesRemaining))} 分钟`; + } else { + statusEmoji = '📅'; + statusText = `将在 ${Math.round(minutesRemaining)} 分钟后到期`; + } + } else if (sub.periodUnit === 'hour') { + // 小时级订阅:显示小时 + const hoursRemaining = sub.hoursRemaining !== undefined ? sub.hoursRemaining : sub.daysRemaining * 24; + if (Math.abs(hoursRemaining) < 1) { + statusEmoji = '⚠️'; + statusText = '即将到期!'; + } else if (hoursRemaining < 0) { + statusEmoji = '🚨'; + statusText = `已过期 ${Math.abs(Math.round(hoursRemaining))} 小时`; + } else { + statusEmoji = '📅'; + statusText = `将在 ${Math.round(hoursRemaining)} 小时后到期`; + } + } else { + // 天级订阅:显示天数 + 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 === 'minute' + ? '(分钟级提醒)' + : reminderSetting.unit === 'hour' + ? '(小时级提醒)' + : ''; + + const reminderText = reminderSetting.unit === 'minute' + ? `提醒策略: 提前 ${reminderSetting.value} 分钟${reminderSuffix}` + : 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 || {}; + const requestedNotifiers = normalizeNotifierList(options.notifiers); + const globalNotifiers = normalizeNotifierList(config.ENABLED_NOTIFIERS); + const baseNotifiers = requestedNotifiers.length ? requestedNotifiers : globalNotifiers; + const effectiveNotifiers = globalNotifiers.length + ? baseNotifiers.filter(item => globalNotifiers.includes(item)) + : baseNotifiers; + + if (!effectiveNotifiers || effectiveNotifiers.length === 0) { + console.log(`${logPrefix} 未启用任何通知渠道。`); + return; + } + + if (effectiveNotifiers.includes('notifyx')) { + const notifyxContent = `## ${title}\n\n${commonContent}`; + const success = await sendNotifyXNotification(title, notifyxContent, `订阅提醒`, config); + console.log(`${logPrefix} 发送NotifyX通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('telegram')) { + const telegramContent = `*${title}*\n\n${commonContent}`; + const success = await sendTelegramNotification(telegramContent, config); + console.log(`${logPrefix} 发送Telegram通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('webhook')) { + const webhookContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendWebhookNotification(title, webhookContent, config, metadata); + console.log(`${logPrefix} 发送Webhook通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('wechatbot')) { + const wechatbotContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendWechatBotNotification(title, wechatbotContent, config); + console.log(`${logPrefix} 发送企业微信机器人通知 ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('email_resend')) { + const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendEmailNotification(title, emailContent, config, { + provider: 'resend', + emailTo: options.emailTo + }); + console.log(`${logPrefix} 发送邮件通知(Resend) ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('email_mailgun')) { + const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendEmailNotification(title, emailContent, config, { + provider: 'mailgun', + emailTo: options.emailTo + }); + console.log(`${logPrefix} 发送邮件通知(Mailgun) ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.includes('email_smtp')) { + const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, ''); + const success = await sendEmailNotification(title, emailContent, config, { + provider: 'smtp', + emailTo: options.emailTo + }); + console.log(`${logPrefix} 发送邮件通知(SMTP) ${success ? '成功' : '失败'}`); + } + if (effectiveNotifiers.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, options = {}) { + try { + const provider = resolveEmailProvider(options.provider); + const recipients = normalizeEmailRecipients(options.emailTo); + const providerConfig = resolveEmailConfigForProvider(provider, config); + const targetRecipients = recipients.length + ? recipients + : normalizeEmailRecipients(providerConfig.to); + const fromEmail = formatEmailFrom(providerConfig.from, providerConfig.fromName); + + // 生成HTML邮件内容 + const htmlContent = ` + + + + + + ${title} + + + +
+
+

📅 ${title}

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

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

+
+ +
+ +`; + + if (provider === 'smtp') { + console.log('[Email Notification] Using SMTP provider'); + if (targetRecipients.length === 0) { + console.error('[Email Notification] Missing SMTP recipients'); + return false; + } + return await sendSmtpEmailNotification(title, content, htmlContent, config, targetRecipients); + } + + if (provider === 'mailgun') { + if (!providerConfig.apiKey || !providerConfig.from || targetRecipients.length === 0) { + console.error('[Email Notification] Missing Mailgun config or recipients'); + return false; + } + const domain = extractEmailDomain(providerConfig.from); + if (!domain) { + console.error('[Email Notification] Unable to resolve Mailgun domain from from address'); + return false; + } + console.log('[Email Notification] Sending via Mailgun to: ' + targetRecipients.join(', ')); + const auth = btoa('api:' + providerConfig.apiKey); + const response = await fetch(`https://api.mailgun.net/v3/${domain}/messages`, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}` + }, + body: new URLSearchParams({ + from: fromEmail, + to: targetRecipients.join(', '), + subject: title, + html: htmlContent, + text: content + }) + }); + + let result; + try { + result = await response.json(); + } catch (parseError) { + result = await response.text(); + } + console.log('[Email Notification] Mailgun response', response.status, result); + return response.ok; + } + + if (!providerConfig.apiKey || !providerConfig.from || targetRecipients.length === 0) { + console.error('[Email Notification] Missing Resend config or recipients'); + return false; + } + + console.log('[Email Notification] Sending via Resend to: ' + targetRecipients.join(', ')); + + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${providerConfig.apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + from: fromEmail, + to: targetRecipients, + subject: title, + html: htmlContent, + text: content + }) + }); + + const result = await response.json(); + console.log('[Email Notification] Resend response', response.status, result); + + if (response.ok && result.id) { + console.log('[Email Notification] Resend message accepted, ID:', result.id); + return true; + } + console.error('[Email Notification] Resend send failed', result); + return false; + + } catch (error) { + console.error('[邮件通知] 发送邮件失败:', error); + return false; + } +} + +async function sendSmtpEmailNotification(title, content, htmlContent, config, recipients) { + const result = await sendSmtpEmailNotificationDetailed(title, content, htmlContent, config, recipients); + return result.success; +} + +async function sendSmtpEmailNotificationDetailed(title, content, htmlContent, config, recipients) { + try { + const smtpPort = Number(config.SMTP_PORT); + const smtpFrom = config.SMTP_FROM || config.EMAIL_FROM || config.SMTP_USER || ''; + const smtpFromName = config.SMTP_FROM_NAME || config.EMAIL_FROM_NAME || ''; + if (!config.SMTP_HOST || !smtpPort || !config.SMTP_USER || !config.SMTP_PASS || !smtpFrom || !recipients.length) { + console.error('[SMTP邮件通知] 通知未配置,缺少必要参数'); + return { success: false, message: 'Missing SMTP config or recipients' }; + } + + const fromEmail = smtpFromName ? + `${smtpFromName} <${smtpFrom}>` : + smtpFrom; + + console.log('[SMTP邮件通知] 开始发送邮件到: ' + recipients.join(', ')); + + const response = await fetch('https://smtpjs.com/v3/smtpjs.aspx', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + Host: config.SMTP_HOST, + Port: smtpPort, + Username: config.SMTP_USER, + Password: config.SMTP_PASS, + To: recipients.join(','), + From: fromEmail, + Subject: title, + Body: htmlContent, + Secure: smtpPort === 465 + }) + }); + + const resultText = await response.text(); + console.log('[SMTP邮件通知] 发送结果:', response.status, resultText); + + if (response.ok && resultText && resultText.toLowerCase().includes('ok')) { + return { success: true, message: 'OK' }; + } + console.error('[SMTP邮件通知] 发送失败:', resultText); + return { + success: false, + message: 'SMTPJS response: ' + (resultText || 'empty response'), + status: response.status + }; + } catch (error) { + console.error('[SMTP邮件通知] 发送邮件失败:', error); + return { success: false, message: error.message || 'SMTP send error' }; + } +} + +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 timeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour12: false, + hour: '2-digit', + minute: '2-digit' + }); + const timeParts = timeFormatter.formatToParts(currentTime); + const currentHour = timeParts.find(p => p.type === 'hour')?.value || '00'; + const currentMinute = timeParts.find(p => p.type === 'minute')?.value || '00'; + + 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); + const subscriptionNotificationHours = resolveSubscriptionNotificationHours(subscription, config); + const shouldNotifyThisHour = shouldNotifyAtCurrentHour(subscriptionNotificationHours, currentHour, currentMinute); + + // 计算当前剩余时间(基础计算) + 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); + } + + let daysDiff = Math.round((expiryMidnight - currentMidnight) / MS_PER_DAY); + // 直接计算时间差(expiryDate 和 currentTime 都是 UTC 时间戳) + let diffMs = expiryDate.getTime() - currentTime.getTime(); + let diffHours = diffMs / MS_PER_HOUR; + let diffMinutes = diffMs / (1000 * 60); + + // ========================================== + // 核心逻辑:自动续费处理 + // ========================================== + // 修复:对于分钟级和小时级订阅,使用精确的时间差判断是否过期 + let isExpiredForRenewal = false; + if (subscription.periodUnit === 'minute' || subscription.periodUnit === 'hour') { + isExpiredForRenewal = diffMs < 0; // 使用精确的毫秒差 + } else { + isExpiredForRenewal = daysDiff < 0; // 使用天数差 + } + + if (isExpiredForRenewal && subscription.periodValue && subscription.periodUnit && subscription.autoRenew !== false) { + console.log(`[定时任务] 订阅 "${subscription.name}" 已过期,准备自动续费...`); + + 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 === 'minute') targetDate.setMinutes(targetDate.getMinutes() + subscription.periodValue); + else if (subscription.periodUnit === 'hour') targetDate.setHours(targetDate.getHours() + subscription.periodValue); + else 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天) + let diffMs1 = newExpiryDate.getTime() - currentTime.getTime(); + let diffHours1 = diffMs1 / MS_PER_HOUR; + let diffMinutes1 = diffMs1 / (1000 * 60); + const shouldRemindAfterRenewal = shouldTriggerReminder(reminderSetting, daysDiff, diffHours1, diffMinutes1); + + if (shouldRemindAfterRenewal && shouldNotifyThisHour) { + expiringSubscriptions.push({ + ...updatedSubscription, + daysRemaining: daysDiff, + hoursRemaining: Math.round(diffHours1) + }); + } else if (shouldRemindAfterRenewal && !shouldNotifyThisHour) { + console.log(`[Scheduler] Hour ${currentHour} not in reminder hours for "${subscription.name}", skipping notification`); + } + + // continue; // 处理下一个订阅 + } + + // ========================================== + // 普通提醒逻辑 (未过期,或过期但不自动续费) + // ========================================== + const shouldRemind = shouldTriggerReminder(reminderSetting, daysDiff, diffHours, diffMinutes); + + // 格式化剩余时间显示 + let remainingTimeStr; + if (daysDiff >= 1) { + remainingTimeStr = `${daysDiff}天`; + } else if (diffHours >= 1) { + remainingTimeStr = `${diffHours.toFixed(2)}小时`; + } else { + remainingTimeStr = `${diffMinutes.toFixed(2)}分钟`; + } + + console.log(`[定时任务] ${subscription.name} | 当前时间: ${currentHour}:${currentMinute} | 通知时间点: ${JSON.stringify(subscriptionNotificationHours)} | 时间匹配: ${shouldNotifyThisHour} | 提醒判断: ${shouldRemind} | 剩余: ${remainingTimeStr}`); + + // 修复:对于分钟级和小时级订阅,使用精确的时间差判断是否过期 + let isExpiredForNotification = false; + if (subscription.periodUnit === 'minute' || subscription.periodUnit === 'hour') { + isExpiredForNotification = diffMs < 0; + } else { + isExpiredForNotification = daysDiff < 0; + } + + if (isExpiredForNotification && subscription.autoRenew === false) { + // 已过期且不自动续费 -> 发送过期通知 + if (shouldNotifyThisHour) { + expiringSubscriptions.push({ + ...subscription, + daysRemaining: daysDiff, + hoursRemaining: Math.round(diffHours), + minutesRemaining: Math.round(diffMinutes) + }); + } else { + console.log(`[Scheduler] Hour ${currentHour} not in reminder hours for "${subscription.name}", skipping notification`); + } + } else if (shouldRemind) { + // 正常到期提醒 + if (shouldNotifyThisHour) { + expiringSubscriptions.push({ + ...subscription, + daysRemaining: daysDiff, + hoursRemaining: Math.round(diffHours), + minutesRemaining: Math.round(diffMinutes) + }); + } else { + console.log(`[Scheduler] Hour ${currentHour} not in reminder hours for "${subscription.name}", skipping notification`); + } + } + } + + // --- 保存更改 --- + 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) { + console.log(`[Scheduler] Sending ${expiringSubscriptions.length} reminder notification(s)`); + // Sort by due time + expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining); + + for (const subscription of expiringSubscriptions) { + const commonContent = formatNotificationContent([subscription], config); + const metadataTags = extractTagsFromSubscriptions([subscription]); + await sendNotificationToAllChannels(`Subscription reminder: ${subscription.name}`, commonContent, config, '[Scheduler]', { + metadata: { tags: metadataTags }, + notifiers: resolveSubscriptionNotifiers(subscription, config), + emailTo: resolveSubscriptionEmailRecipients(subscription) + }); + } + } + } 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); + // 修复:计算完整的天数差,使用 Math.floor() 向下取整 + // 例如:1.9天显示为"1天后",2.1天显示为"2天后" + const diffMs = renewalDate - now; + const daysUntilRenewal = Math.max(0, Math.floor(diffMs / 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); +} \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml index d9a246b..60c8f51 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,5 @@ name = "subscription-manager" -main = "index.js" +main = "index_zzz.js" compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] @@ -17,7 +17,7 @@ id = "your-production-kv-namespace-id" # 定时任务配置 - 每天早上8点检查 [triggers] -crons = ["0 8 * * *"] +crons = ["* * * * *"] # 环境变量(可选,也可以在 Cloudflare Dashboard 中设置) [vars]