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}
+
+
+
+
+
+
+
+
+
系统配置
+
+
+
+
+
+
+
+
+`;
+
+// 管理页面
+// 与前端一致的分类切割正则,用于提取标签信息
+const CATEGORY_SEPARATOR_REGEX = /[\/,,\s]+/;
+
+
+function dashboardPage() {
+ return `
+
+
+
+
+ 仪表盘 - SubsTracker
+
+
+ ${themeResources}
+
+
+
+
+
+
+
📊 仪表板
+
订阅费用和活动概览(统计金额已折合为 CNY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
按类型支出排行
+
+
年度统计 (折合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}
+
+
+
+
+
+
+
+ ${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}
+
+
+
+
+
+
+
+
+
系统配置
+
+
+
+
+
+
+
+
+`;
+
+// 管理页面
+// 与前端一致的分类切割正则,用于提取标签信息
+const CATEGORY_SEPARATOR_REGEX = /[\/,,\s]+/;
+
+
+function dashboardPage() {
+ return `
+
+
+
+
+ 仪表盘 - SubsTracker
+
+
+ ${themeResources}
+
+
+
+
+
+
+
📊 仪表板
+
订阅费用和活动概览(统计金额已折合为 CNY)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
按类型支出排行
+
+
年度统计 (折合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}
+
+
+
+
+
+
+
+ ${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]