// 订阅续期通知网站 - 基于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)
`;
}
function extractTagsFromSubscriptions(subscriptions = []) {
const tagSet = new Set();
(subscriptions || []).forEach(sub => {
if (!sub || typeof sub !== 'object') {
return;
}
if (Array.isArray(sub.tags)) {
sub.tags.forEach(tag => {
if (typeof tag === 'string' && tag.trim().length > 0) {
tagSet.add(tag.trim());
}
});
}
if (typeof sub.category === 'string') {
sub.category.split(CATEGORY_SEPARATOR_REGEX)
.map(tag => tag.trim())
.filter(tag => tag.length > 0)
.forEach(tag => tagSet.add(tag));
}
if (typeof sub.customType === 'string' && sub.customType.trim().length > 0) {
tagSet.add(sub.customType.trim());
}
});
return Array.from(tagSet);
}
const admin = {
async handleRequest(request, env, ctx) {
try {
const url = new URL(request.url);
const pathname = url.pathname;
console.log('[管理页面] 访问路径:', pathname);
const token = getCookieValue(request.headers.get('Cookie'), 'token');
console.log('[管理页面] Token存在:', !!token);
const config = await getConfig(env);
const user = token ? await verifyJWT(token, config.JWT_SECRET) : null;
console.log('[管理页面] 用户验证结果:', !!user);
if (!user) {
console.log('[管理页面] 用户未登录,重定向到登录页面');
return new Response('', {
status: 302,
headers: { 'Location': '/' }
});
}
if (pathname === '/admin/config') {
return new Response(configPage, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
if (pathname === '/admin/dashboard') {
return new Response(dashboardPage(), {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
return new Response(adminPage, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
} catch (error) {
console.error('[管理页面] 处理请求时出错:', error);
return new Response('服务器内部错误', {
status: 500,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
}
};
// 处理API请求
const api = {
async handleRequest(request, env, ctx) {
const url = new URL(request.url);
const path = url.pathname.slice(4);
const method = request.method;
const config = await getConfig(env);
if (path === '/login' && method === 'POST') {
const body = await request.json();
if (body.username === config.ADMIN_USERNAME && body.password === config.ADMIN_PASSWORD) {
const token = await generateJWT(body.username, config.JWT_SECRET);
return new Response(
JSON.stringify({ success: true }),
{
headers: {
'Content-Type': 'application/json',
'Set-Cookie': 'token=' + token + '; HttpOnly; Path=/; SameSite=Strict; Max-Age=86400'
}
}
);
} else {
return new Response(
JSON.stringify({ success: false, message: '用户名或密码错误' }),
{ headers: { 'Content-Type': 'application/json' } }
);
}
}
if (path === '/logout' && (method === 'GET' || method === 'POST')) {
return new Response('', {
status: 302,
headers: {
'Location': '/',
'Set-Cookie': 'token=; HttpOnly; Path=/; SameSite=Strict; Max-Age=0'
}
});
}
const token = getCookieValue(request.headers.get('Cookie'), 'token');
const user = token ? await verifyJWT(token, config.JWT_SECRET) : null;
if (!user && path !== '/login') {
return new Response(
JSON.stringify({ success: false, message: '未授权访问' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
if (path === '/config') {
if (method === 'GET') {
const { JWT_SECRET, ADMIN_PASSWORD, ...safeConfig } = config;
return new Response(
JSON.stringify(safeConfig),
{ headers: { 'Content-Type': 'application/json' } }
);
}
if (method === 'POST') {
try {
const newConfig = await request.json();
const updatedConfig = {
...config,
ADMIN_USERNAME: newConfig.ADMIN_USERNAME || config.ADMIN_USERNAME,
THEME_MODE: newConfig.THEME_MODE || 'system', // 保存主题配置
TG_BOT_TOKEN: newConfig.TG_BOT_TOKEN || '',
TG_CHAT_ID: newConfig.TG_CHAT_ID || '',
NOTIFYX_API_KEY: newConfig.NOTIFYX_API_KEY || '',
WEBHOOK_URL: newConfig.WEBHOOK_URL || '',
WEBHOOK_METHOD: newConfig.WEBHOOK_METHOD || 'POST',
WEBHOOK_HEADERS: newConfig.WEBHOOK_HEADERS || '',
WEBHOOK_TEMPLATE: newConfig.WEBHOOK_TEMPLATE || '',
SHOW_LUNAR: newConfig.SHOW_LUNAR === true,
WECHATBOT_WEBHOOK: newConfig.WECHATBOT_WEBHOOK || '',
WECHATBOT_MSG_TYPE: newConfig.WECHATBOT_MSG_TYPE || 'text',
WECHATBOT_AT_MOBILES: newConfig.WECHATBOT_AT_MOBILES || '',
WECHATBOT_AT_ALL: newConfig.WECHATBOT_AT_ALL || 'false',
RESEND_API_KEY: newConfig.RESEND_API_KEY || '',
EMAIL_FROM: newConfig.EMAIL_FROM || '',
EMAIL_FROM_NAME: newConfig.EMAIL_FROM_NAME || '',
EMAIL_TO: newConfig.EMAIL_TO || '',
BARK_DEVICE_KEY: newConfig.BARK_DEVICE_KEY || '',
BARK_SERVER: newConfig.BARK_SERVER || 'https://api.day.app',
BARK_IS_ARCHIVE: newConfig.BARK_IS_ARCHIVE || 'false',
ENABLED_NOTIFIERS: newConfig.ENABLED_NOTIFIERS || ['notifyx'],
TIMEZONE: newConfig.TIMEZONE || config.TIMEZONE || 'UTC',
THIRD_PARTY_API_TOKEN: newConfig.THIRD_PARTY_API_TOKEN || ''
};
const rawNotificationHours = Array.isArray(newConfig.NOTIFICATION_HOURS)
? newConfig.NOTIFICATION_HOURS
: typeof newConfig.NOTIFICATION_HOURS === 'string'
? newConfig.NOTIFICATION_HOURS.split(',')
: [];
const sanitizedNotificationHours = rawNotificationHours
.map(value => String(value).trim())
.filter(value => value.length > 0)
.map(value => {
const upperValue = value.toUpperCase();
if (upperValue === '*' || upperValue === 'ALL') {
return '*';
}
const numeric = Number(upperValue);
if (!isNaN(numeric)) {
return String(Math.max(0, Math.min(23, Math.floor(numeric)))).padStart(2, '0');
}
return upperValue;
});
updatedConfig.NOTIFICATION_HOURS = sanitizedNotificationHours;
if (newConfig.ADMIN_PASSWORD) {
updatedConfig.ADMIN_PASSWORD = newConfig.ADMIN_PASSWORD;
}
// 确保JWT_SECRET存在且安全
if (!updatedConfig.JWT_SECRET || updatedConfig.JWT_SECRET === 'your-secret-key') {
updatedConfig.JWT_SECRET = generateRandomSecret();
console.log('[安全] 生成新的JWT密钥');
}
await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig));
return new Response(
JSON.stringify({ success: true }),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('配置保存错误:', error);
return new Response(
JSON.stringify({ success: false, message: '更新配置失败: ' + error.message }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
}
}
if (path === '/dashboard/stats' && method === 'GET') {
try {
const subscriptions = await getAllSubscriptions(env);
const timezone = config?.TIMEZONE || 'UTC';
const rates = await getDynamicRates(env); // 获取动态汇率
const monthlyExpense = calculateMonthlyExpense(subscriptions, timezone, rates);
const yearlyExpense = calculateYearlyExpense(subscriptions, timezone, rates);
const recentPayments = getRecentPayments(subscriptions, timezone); // 不需要汇率
const upcomingRenewals = getUpcomingRenewals(subscriptions, timezone); // 不需要汇率
const expenseByType = getExpenseByType(subscriptions, timezone, rates);
const expenseByCategory = getExpenseByCategory(subscriptions, timezone, rates);
const activeSubscriptions = subscriptions.filter(s => s.isActive);
const now = getCurrentTimeInTimezone(timezone);
const sevenDaysLater = new Date(now.getTime() + 7 * MS_PER_DAY);
const expiringSoon = activeSubscriptions.filter(s => {
const expiryDate = new Date(s.expiryDate);
return expiryDate >= now && expiryDate <= sevenDaysLater;
}).length;
return new Response(
JSON.stringify({
success: true,
data: {
monthlyExpense,
yearlyExpense,
activeSubscriptions: {
active: activeSubscriptions.length,
total: subscriptions.length,
expiringSoon
},
recentPayments,
upcomingRenewals,
expenseByType,
expenseByCategory
}
}),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('获取仪表盘统计失败:', error);
return new Response(
JSON.stringify({ success: false, message: '获取统计数据失败: ' + error.message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
if (path === '/test-notification' && method === 'POST') {
try {
const body = await request.json();
let success = false;
let message = '';
if (body.type === 'telegram') {
const testConfig = {
...config,
TG_BOT_TOKEN: body.TG_BOT_TOKEN,
TG_CHAT_ID: body.TG_CHAT_ID
};
const content = '*测试通知*\n\n这是一条测试通知,用于验证Telegram通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendTelegramNotification(content, testConfig);
message = success ? 'Telegram通知发送成功' : 'Telegram通知发送失败,请检查配置';
} else if (body.type === 'notifyx') {
const testConfig = {
...config,
NOTIFYX_API_KEY: body.NOTIFYX_API_KEY
};
const title = '测试通知';
const content = '## 这是一条测试通知\n\n用于验证NotifyX通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
const description = '测试NotifyX通知功能';
success = await sendNotifyXNotification(title, content, description, testConfig);
message = success ? 'NotifyX通知发送成功' : 'NotifyX通知发送失败,请检查配置';
} else if (body.type === 'webhook') {
const testConfig = {
...config,
WEBHOOK_URL: body.WEBHOOK_URL,
WEBHOOK_METHOD: body.WEBHOOK_METHOD,
WEBHOOK_HEADERS: body.WEBHOOK_HEADERS,
WEBHOOK_TEMPLATE: body.WEBHOOK_TEMPLATE
};
const title = '测试通知';
const content = '这是一条测试通知,用于验证Webhook 通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendWebhookNotification(title, content, testConfig);
message = success ? 'Webhook 通知发送成功' : 'Webhook 通知发送失败,请检查配置';
} else if (body.type === 'wechatbot') {
const testConfig = {
...config,
WECHATBOT_WEBHOOK: body.WECHATBOT_WEBHOOK,
WECHATBOT_MSG_TYPE: body.WECHATBOT_MSG_TYPE,
WECHATBOT_AT_MOBILES: body.WECHATBOT_AT_MOBILES,
WECHATBOT_AT_ALL: body.WECHATBOT_AT_ALL
};
const title = '测试通知';
const content = '这是一条测试通知,用于验证企业微信机器人功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendWechatBotNotification(title, content, testConfig);
message = success ? '企业微信机器人通知发送成功' : '企业微信机器人通知发送失败,请检查配置';
} else if (body.type === 'email') {
const testConfig = {
...config,
RESEND_API_KEY: body.RESEND_API_KEY,
EMAIL_FROM: body.EMAIL_FROM,
EMAIL_FROM_NAME: body.EMAIL_FROM_NAME,
EMAIL_TO: body.EMAIL_TO
};
const title = '测试通知';
const content = '这是一条测试通知,用于验证邮件通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendEmailNotification(title, content, testConfig);
message = success ? '邮件通知发送成功' : '邮件通知发送失败,请检查配置';
} else if (body.type === 'bark') {
const testConfig = {
...config,
BARK_SERVER: body.BARK_SERVER,
BARK_DEVICE_KEY: body.BARK_DEVICE_KEY,
BARK_IS_ARCHIVE: body.BARK_IS_ARCHIVE
};
const title = '测试通知';
const content = '这是一条测试通知,用于验证Bark通知功能是否正常工作。\n\n发送时间: ' + formatBeijingTime();
success = await sendBarkNotification(title, content, testConfig);
message = success ? 'Bark通知发送成功' : 'Bark通知发送失败,请检查配置';
}
return new Response(
JSON.stringify({ success, message }),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('测试通知失败:', error);
return new Response(
JSON.stringify({ success: false, message: '测试通知失败: ' + error.message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
if (path === '/subscriptions') {
if (method === 'GET') {
const subscriptions = await getAllSubscriptions(env);
return new Response(
JSON.stringify(subscriptions),
{ headers: { 'Content-Type': 'application/json' } }
);
}
if (method === 'POST') {
const subscription = await request.json();
const result = await createSubscription(subscription, env);
return new Response(
JSON.stringify(result),
{
status: result.success ? 201 : 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
if (path.startsWith('/subscriptions/')) {
const parts = path.split('/');
const id = parts[2];
if (parts[3] === 'toggle-status' && method === 'POST') {
const body = await request.json();
const result = await toggleSubscriptionStatus(id, body.isActive, env);
return new Response(
JSON.stringify(result),
{
status: result.success ? 200 : 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
if (parts[3] === 'test-notify' && method === 'POST') {
const result = await testSingleSubscriptionNotification(id, env);
return new Response(JSON.stringify(result), { status: result.success ? 200 : 500, headers: { 'Content-Type': 'application/json' } });
}
if (parts[3] === 'renew' && method === 'POST') {
let options = {};
try {
const body = await request.json();
options = body || {};
} catch (e) {
// 如果没有请求体,使用默认空对象
}
const result = await manualRenewSubscription(id, env, options);
return new Response(JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } });
}
if (parts[3] === 'payments' && method === 'GET') {
const subscription = await getSubscription(id, env);
if (!subscription) {
return new Response(JSON.stringify({ success: false, message: '订阅不存在' }), { status: 404, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ success: true, payments: subscription.paymentHistory || [] }), { headers: { 'Content-Type': 'application/json' } });
}
if (parts[3] === 'payments' && parts[4] && method === 'DELETE') {
const paymentId = parts[4];
const result = await deletePaymentRecord(id, paymentId, env);
return new Response(JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } });
}
if (parts[3] === 'payments' && parts[4] && method === 'PUT') {
const paymentId = parts[4];
const paymentData = await request.json();
const result = await updatePaymentRecord(id, paymentId, paymentData, env);
return new Response(JSON.stringify(result), { status: result.success ? 200 : 400, headers: { 'Content-Type': 'application/json' } });
}
if (method === 'GET') {
const subscription = await getSubscription(id, env);
return new Response(
JSON.stringify(subscription),
{ headers: { 'Content-Type': 'application/json' } }
);
}
if (method === 'PUT') {
const subscription = await request.json();
const result = await updateSubscription(id, subscription, env);
return new Response(
JSON.stringify(result),
{
status: result.success ? 200 : 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
if (method === 'DELETE') {
const result = await deleteSubscription(id, env);
return new Response(
JSON.stringify(result),
{
status: result.success ? 200 : 400,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
// 处理第三方通知API
if (path.startsWith('/notify/')) {
const pathSegments = path.split('/');
// 允许通过路径、Authorization 头或查询参数三种方式传入访问令牌
const tokenFromPath = pathSegments[2] || '';
const tokenFromHeader = (request.headers.get('Authorization') || '').replace(/^Bearer\s+/i, '').trim();
const tokenFromQuery = url.searchParams.get('token') || '';
const providedToken = tokenFromPath || tokenFromHeader || tokenFromQuery;
const expectedToken = config.THIRD_PARTY_API_TOKEN || '';
if (!expectedToken) {
return new Response(
JSON.stringify({ message: '第三方 API 已禁用,请在后台配置访问令牌后使用' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
if (!providedToken || providedToken !== expectedToken) {
return new Response(
JSON.stringify({ message: '访问未授权,令牌无效或缺失' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
if (method === 'POST') {
try {
const body = await request.json();
const title = body.title || '第三方通知';
const content = body.content || '';
if (!content) {
return new Response(
JSON.stringify({ message: '缺少必填参数 content' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const config = await getConfig(env);
const bodyTagsRaw = Array.isArray(body.tags)
? body.tags
: (typeof body.tags === 'string' ? body.tags.split(/[,,\s]+/) : []);
const bodyTags = Array.isArray(bodyTagsRaw)
? bodyTagsRaw.filter(tag => typeof tag === 'string' && tag.trim().length > 0).map(tag => tag.trim())
: [];
// 使用多渠道发送通知
await sendNotificationToAllChannels(title, content, config, '[第三方API]', {
metadata: { tags: bodyTags }
});
return new Response(
JSON.stringify({
message: '发送成功',
response: {
errcode: 0,
errmsg: 'ok',
msgid: 'MSGID' + Date.now()
}
}),
{ headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('[第三方API] 发送通知失败:', error);
return new Response(
JSON.stringify({
message: '发送失败',
response: {
errcode: 1,
errmsg: error.message
}
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
}
return new Response(
JSON.stringify({ success: false, message: '未找到请求的资源' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
};
// 工具函数
function generateRandomSecret() {
// 生成一个64字符的随机密钥
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
let result = '';
for (let i = 0; i < 64; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
async function getConfig(env) {
try {
if (!env.SUBSCRIPTIONS_KV) {
console.error('[配置] KV存储未绑定');
throw new Error('KV存储未绑定');
}
const data = await env.SUBSCRIPTIONS_KV.get('config');
console.log('[配置] 从KV读取配置:', data ? '成功' : '空配置');
const config = data ? JSON.parse(data) : {};
// 确保JWT_SECRET的一致性
let jwtSecret = config.JWT_SECRET;
if (!jwtSecret || jwtSecret === 'your-secret-key') {
jwtSecret = generateRandomSecret();
console.log('[配置] 生成新的JWT密钥');
// 保存新的JWT密钥
const updatedConfig = { ...config, JWT_SECRET: jwtSecret };
await env.SUBSCRIPTIONS_KV.put('config', JSON.stringify(updatedConfig));
}
const finalConfig = {
ADMIN_USERNAME: config.ADMIN_USERNAME || 'admin',
ADMIN_PASSWORD: config.ADMIN_PASSWORD || 'password',
JWT_SECRET: jwtSecret,
TG_BOT_TOKEN: config.TG_BOT_TOKEN || '',
TG_CHAT_ID: config.TG_CHAT_ID || '',
NOTIFYX_API_KEY: config.NOTIFYX_API_KEY || '',
WEBHOOK_URL: config.WEBHOOK_URL || '',
WEBHOOK_METHOD: config.WEBHOOK_METHOD || 'POST',
WEBHOOK_HEADERS: config.WEBHOOK_HEADERS || '',
WEBHOOK_TEMPLATE: config.WEBHOOK_TEMPLATE || '',
SHOW_LUNAR: config.SHOW_LUNAR === true,
WECHATBOT_WEBHOOK: config.WECHATBOT_WEBHOOK || '',
WECHATBOT_MSG_TYPE: config.WECHATBOT_MSG_TYPE || 'text',
WECHATBOT_AT_MOBILES: config.WECHATBOT_AT_MOBILES || '',
WECHATBOT_AT_ALL: config.WECHATBOT_AT_ALL || 'false',
RESEND_API_KEY: config.RESEND_API_KEY || '',
EMAIL_FROM: config.EMAIL_FROM || '',
EMAIL_FROM_NAME: config.EMAIL_FROM_NAME || '',
EMAIL_TO: config.EMAIL_TO || '',
BARK_DEVICE_KEY: config.BARK_DEVICE_KEY || '',
BARK_SERVER: config.BARK_SERVER || 'https://api.day.app',
BARK_IS_ARCHIVE: config.BARK_IS_ARCHIVE || 'false',
ENABLED_NOTIFIERS: config.ENABLED_NOTIFIERS || ['notifyx'],
THEME_MODE: config.THEME_MODE || 'system', // 默认主题为跟随系统
TIMEZONE: config.TIMEZONE || 'UTC', // 新增时区字段
NOTIFICATION_HOURS: Array.isArray(config.NOTIFICATION_HOURS) ? config.NOTIFICATION_HOURS : [],
THIRD_PARTY_API_TOKEN: config.THIRD_PARTY_API_TOKEN || ''
};
console.log('[配置] 最终配置用户名:', finalConfig.ADMIN_USERNAME);
return finalConfig;
} catch (error) {
console.error('[配置] 获取配置失败:', error);
const defaultJwtSecret = generateRandomSecret();
return {
ADMIN_USERNAME: 'admin',
ADMIN_PASSWORD: 'password',
JWT_SECRET: defaultJwtSecret,
TG_BOT_TOKEN: '',
TG_CHAT_ID: '',
NOTIFYX_API_KEY: '',
WEBHOOK_URL: '',
WEBHOOK_METHOD: 'POST',
WEBHOOK_HEADERS: '',
WEBHOOK_TEMPLATE: '',
SHOW_LUNAR: true,
WECHATBOT_WEBHOOK: '',
WECHATBOT_MSG_TYPE: 'text',
WECHATBOT_AT_MOBILES: '',
WECHATBOT_AT_ALL: 'false',
RESEND_API_KEY: '',
EMAIL_FROM: '',
EMAIL_FROM_NAME: '',
EMAIL_TO: '',
ENABLED_NOTIFIERS: ['notifyx'],
NOTIFICATION_HOURS: [],
TIMEZONE: 'UTC', // 新增时区字段
THIRD_PARTY_API_TOKEN: ''
};
}
}
async function generateJWT(username, secret) {
const header = { alg: 'HS256', typ: 'JWT' };
const payload = { username, iat: Math.floor(Date.now() / 1000) };
const headerBase64 = btoa(JSON.stringify(header));
const payloadBase64 = btoa(JSON.stringify(payload));
const signatureInput = headerBase64 + '.' + payloadBase64;
const signature = await CryptoJS.HmacSHA256(signatureInput, secret);
return headerBase64 + '.' + payloadBase64 + '.' + signature;
}
async function verifyJWT(token, secret) {
try {
if (!token || !secret) {
console.log('[JWT] Token或Secret为空');
return null;
}
const parts = token.split('.');
if (parts.length !== 3) {
console.log('[JWT] Token格式错误,部分数量:', parts.length);
return null;
}
const [headerBase64, payloadBase64, signature] = parts;
const signatureInput = headerBase64 + '.' + payloadBase64;
const expectedSignature = await CryptoJS.HmacSHA256(signatureInput, secret);
if (signature !== expectedSignature) {
console.log('[JWT] 签名验证失败');
return null;
}
const payload = JSON.parse(atob(payloadBase64));
console.log('[JWT] 验证成功,用户:', payload.username);
return payload;
} catch (error) {
console.error('[JWT] 验证过程出错:', error);
return null;
}
}
async function getAllSubscriptions(env) {
try {
const data = await env.SUBSCRIPTIONS_KV.get('subscriptions');
return data ? JSON.parse(data) : [];
} catch (error) {
return [];
}
}
async function getSubscription(id, env) {
const subscriptions = await getAllSubscriptions(env);
return subscriptions.find(s => s.id === id);
}
// 2. 修改 createSubscription,支持 useLunar 字段
async function createSubscription(subscription, env) {
try {
const subscriptions = await getAllSubscriptions(env);
if (!subscription.name || !subscription.expiryDate) {
return { success: false, message: '缺少必填字段' };
}
let expiryDate = new Date(subscription.expiryDate);
const config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
let useLunar = !!subscription.useLunar;
if (useLunar) {
let lunar = lunarCalendar.solar2lunar(
expiryDate.getFullYear(),
expiryDate.getMonth() + 1,
expiryDate.getDate()
);
if (lunar && subscription.periodValue && subscription.periodUnit) {
// 如果到期日<=今天,自动推算到下一个周期
while (expiryDate <= currentTime) {
lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit);
const solar = lunarBiz.lunar2solar(lunar);
expiryDate = new Date(solar.year, solar.month - 1, solar.day);
}
subscription.expiryDate = expiryDate.toISOString();
}
} else {
if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) {
while (expiryDate < currentTime) {
if (subscription.periodUnit === 'day') {
expiryDate.setDate(expiryDate.getDate() + subscription.periodValue);
} else if (subscription.periodUnit === 'month') {
expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue);
} else if (subscription.periodUnit === 'year') {
expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue);
}
}
subscription.expiryDate = expiryDate.toISOString();
}
}
const reminderSetting = resolveReminderSetting(subscription);
const initialPaymentDate = subscription.startDate || currentTime.toISOString();
const newSubscription = {
id: Date.now().toString(), // 前端使用本地时间戳
name: subscription.name,
subscriptionMode: subscription.subscriptionMode || 'cycle', // 默认循环订阅
customType: subscription.customType || '',
category: subscription.category ? subscription.category.trim() : '',
startDate: subscription.startDate || null,
expiryDate: subscription.expiryDate,
periodValue: subscription.periodValue || 1,
periodUnit: subscription.periodUnit || 'month',
reminderUnit: reminderSetting.unit,
reminderValue: reminderSetting.value,
reminderDays: reminderSetting.unit === 'day' ? reminderSetting.value : undefined,
reminderHours: reminderSetting.unit === 'hour' ? reminderSetting.value : undefined,
notes: subscription.notes || '',
amount: subscription.amount || null,
currency: subscription.currency || 'CNY', // 使用传入的币种,默认为CNY
lastPaymentDate: initialPaymentDate,
paymentHistory: subscription.amount ? [{
id: Date.now().toString(),
date: initialPaymentDate,
amount: subscription.amount,
type: 'initial',
note: '初始订阅',
periodStart: subscription.startDate || initialPaymentDate,
periodEnd: subscription.expiryDate
}] : [],
isActive: subscription.isActive !== false,
autoRenew: subscription.autoRenew !== false,
useLunar: useLunar,
createdAt: new Date().toISOString()
};
subscriptions.push(newSubscription);
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));
return { success: true, subscription: newSubscription };
} catch (error) {
console.error("创建订阅异常:", error && error.stack ? error.stack : error);
return { success: false, message: error && error.message ? error.message : '创建订阅失败' };
}
}
// 3. 修改 updateSubscription,支持 useLunar 字段
async function updateSubscription(id, subscription, env) {
try {
const subscriptions = await getAllSubscriptions(env);
const index = subscriptions.findIndex(s => s.id === id);
if (index === -1) {
return { success: false, message: '订阅不存在' };
}
if (!subscription.name || !subscription.expiryDate) {
return { success: false, message: '缺少必填字段' };
}
let expiryDate = new Date(subscription.expiryDate);
const config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
let useLunar = !!subscription.useLunar;
if (useLunar) {
let lunar = lunarCalendar.solar2lunar(
expiryDate.getFullYear(),
expiryDate.getMonth() + 1,
expiryDate.getDate()
);
if (!lunar) {
return { success: false, message: '农历日期超出支持范围(1900-2100年)' };
}
if (lunar && expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) {
// 新增:循环加周期,直到 expiryDate > currentTime
do {
lunar = lunarBiz.addLunarPeriod(lunar, subscription.periodValue, subscription.periodUnit);
const solar = lunarBiz.lunar2solar(lunar);
expiryDate = new Date(solar.year, solar.month - 1, solar.day);
} while (expiryDate < currentTime);
subscription.expiryDate = expiryDate.toISOString();
}
} else {
if (expiryDate < currentTime && subscription.periodValue && subscription.periodUnit) {
while (expiryDate < currentTime) {
if (subscription.periodUnit === 'day') {
expiryDate.setDate(expiryDate.getDate() + subscription.periodValue);
} else if (subscription.periodUnit === 'month') {
expiryDate.setMonth(expiryDate.getMonth() + subscription.periodValue);
} else if (subscription.periodUnit === 'year') {
expiryDate.setFullYear(expiryDate.getFullYear() + subscription.periodValue);
}
}
subscription.expiryDate = expiryDate.toISOString();
}
}
const reminderSource = {
reminderUnit: subscription.reminderUnit !== undefined ? subscription.reminderUnit : subscriptions[index].reminderUnit,
reminderValue: subscription.reminderValue !== undefined ? subscription.reminderValue : subscriptions[index].reminderValue,
reminderHours: subscription.reminderHours !== undefined ? subscription.reminderHours : subscriptions[index].reminderHours,
reminderDays: subscription.reminderDays !== undefined ? subscription.reminderDays : subscriptions[index].reminderDays
};
const reminderSetting = resolveReminderSetting(reminderSource);
const oldSubscription = subscriptions[index];
const newAmount = subscription.amount !== undefined ? subscription.amount : oldSubscription.amount;
let paymentHistory = oldSubscription.paymentHistory || [];
if (newAmount !== oldSubscription.amount) {
const initialPaymentIndex = paymentHistory.findIndex(p => p.type === 'initial');
if (initialPaymentIndex !== -1) {
paymentHistory[initialPaymentIndex] = {
...paymentHistory[initialPaymentIndex],
amount: newAmount
};
}
}
subscriptions[index] = {
...subscriptions[index],
name: subscription.name,
subscriptionMode: subscription.subscriptionMode || subscriptions[index].subscriptionMode || 'cycle', // 如果没有提供 subscriptionMode,则使用旧的 subscriptionMode
customType: subscription.customType || subscriptions[index].customType || '',
category: subscription.category !== undefined ? subscription.category.trim() : (subscriptions[index].category || ''),
startDate: subscription.startDate || subscriptions[index].startDate,
expiryDate: subscription.expiryDate,
periodValue: subscription.periodValue || subscriptions[index].periodValue || 1,
periodUnit: subscription.periodUnit || subscriptions[index].periodUnit || 'month',
reminderUnit: reminderSetting.unit,
reminderValue: reminderSetting.value,
reminderDays: reminderSetting.unit === 'day' ? reminderSetting.value : undefined,
reminderHours: reminderSetting.unit === 'hour' ? reminderSetting.value : undefined,
notes: subscription.notes || '',
amount: newAmount, // 使用新的变量
currency: subscription.currency || subscriptions[index].currency || 'CNY', // 更新币种
lastPaymentDate: subscriptions[index].lastPaymentDate || subscriptions[index].startDate || subscriptions[index].createdAt || currentTime.toISOString(),
paymentHistory: paymentHistory, // 保存更新后的支付历史
isActive: subscription.isActive !== undefined ? subscription.isActive : subscriptions[index].isActive,
autoRenew: subscription.autoRenew !== undefined ? subscription.autoRenew : (subscriptions[index].autoRenew !== undefined ? subscriptions[index].autoRenew : true),
useLunar: useLunar,
updatedAt: new Date().toISOString()
};
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));
return { success: true, subscription: subscriptions[index] };
} catch (error) {
return { success: false, message: '更新订阅失败' };
}
}
async function deleteSubscription(id, env) {
try {
const subscriptions = await getAllSubscriptions(env);
const filteredSubscriptions = subscriptions.filter(s => s.id !== id);
if (filteredSubscriptions.length === subscriptions.length) {
return { success: false, message: '订阅不存在' };
}
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(filteredSubscriptions));
return { success: true };
} catch (error) {
return { success: false, message: '删除订阅失败' };
}
}
async function manualRenewSubscription(id, env, options = {}) {
try {
const subscriptions = await getAllSubscriptions(env);
const index = subscriptions.findIndex(s => s.id === id);
if (index === -1) {
return { success: false, message: '订阅不存在' };
}
const subscription = subscriptions[index];
if (!subscription.periodValue || !subscription.periodUnit) {
return { success: false, message: '订阅未设置续订周期' };
}
const config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
const todayMidnight = getTimezoneMidnightTimestamp(currentTime, timezone);
// 参数处理
const paymentDate = options.paymentDate ? new Date(options.paymentDate) : currentTime;
const amount = options.amount !== undefined ? options.amount : subscription.amount || 0;
const periodMultiplier = options.periodMultiplier || 1;
const note = options.note || '手动续订';
const mode = subscription.subscriptionMode || 'cycle'; // 获取订阅模式
let newStartDate;
let currentExpiryDate = new Date(subscription.expiryDate);
// 1. 确定新的周期起始日 (New Start Date)
if (mode === 'reset') {
// 重置模式:忽略旧的到期日,从今天(或支付日)开始
newStartDate = new Date(paymentDate);
} else {
// 循环模式 (Cycle)
// 如果当前还没过期,从旧的 expiryDate 接着算 (无缝衔接)
// 如果已经过期了,为了避免补交过去空窗期的费,通常从今天开始算(或者你可以选择补齐,这里采用通用逻辑:过期则从今天开始)
if (currentExpiryDate.getTime() > paymentDate.getTime()) {
newStartDate = new Date(currentExpiryDate);
} else {
newStartDate = new Date(paymentDate);
}
}
// 2. 计算新的到期日 (New Expiry Date)
let newExpiryDate;
if (subscription.useLunar) {
// 农历逻辑
const solarStart = {
year: newStartDate.getFullYear(),
month: newStartDate.getMonth() + 1,
day: newStartDate.getDate()
};
let lunar = lunarCalendar.solar2lunar(solarStart.year, solarStart.month, solarStart.day);
let nextLunar = lunar;
for (let i = 0; i < periodMultiplier; i++) {
nextLunar = lunarBiz.addLunarPeriod(nextLunar, subscription.periodValue, subscription.periodUnit);
}
const solar = lunarBiz.lunar2solar(nextLunar);
newExpiryDate = new Date(solar.year, solar.month - 1, solar.day);
} else {
// 公历逻辑
newExpiryDate = new Date(newStartDate);
const totalPeriodValue = subscription.periodValue * periodMultiplier;
if (subscription.periodUnit === 'day') {
newExpiryDate.setDate(newExpiryDate.getDate() + totalPeriodValue);
} else if (subscription.periodUnit === 'month') {
newExpiryDate.setMonth(newExpiryDate.getMonth() + totalPeriodValue);
} else if (subscription.periodUnit === 'year') {
newExpiryDate.setFullYear(newExpiryDate.getFullYear() + totalPeriodValue);
}
}
const paymentRecord = {
id: Date.now().toString(),
date: paymentDate.toISOString(),
amount: amount,
type: 'manual',
note: note,
periodStart: newStartDate.toISOString(), // 记录实际的计费开始日
periodEnd: newExpiryDate.toISOString()
};
const paymentHistory = subscription.paymentHistory || [];
paymentHistory.push(paymentRecord);
subscriptions[index] = {
...subscription,
startDate: newStartDate.toISOString(), // 关键修复:更新 startDate,这样下次编辑时,Start + Period = Expiry 成立
expiryDate: newExpiryDate.toISOString(),
lastPaymentDate: paymentDate.toISOString(),
paymentHistory
};
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));
return { success: true, subscription: subscriptions[index], message: '续订成功' };
} catch (error) {
console.error('手动续订失败:', error);
return { success: false, message: '续订失败: ' + error.message };
}
}
async function deletePaymentRecord(subscriptionId, paymentId, env) {
try {
const subscriptions = await getAllSubscriptions(env);
const index = subscriptions.findIndex(s => s.id === subscriptionId);
if (index === -1) {
return { success: false, message: '订阅不存在' };
}
const subscription = subscriptions[index];
const paymentHistory = subscription.paymentHistory || [];
const paymentIndex = paymentHistory.findIndex(p => p.id === paymentId);
if (paymentIndex === -1) {
return { success: false, message: '支付记录不存在' };
}
const deletedPayment = paymentHistory[paymentIndex];
// 删除支付记录
paymentHistory.splice(paymentIndex, 1);
// 回退订阅周期和更新 lastPaymentDate
let newExpiryDate = subscription.expiryDate;
let newLastPaymentDate = subscription.lastPaymentDate;
if (paymentHistory.length > 0) {
// 找到剩余支付记录中 periodEnd 最晚的那条(最新的续订)
const sortedByPeriodEnd = [...paymentHistory].sort((a, b) => {
const dateA = a.periodEnd ? new Date(a.periodEnd) : new Date(0);
const dateB = b.periodEnd ? new Date(b.periodEnd) : new Date(0);
return dateB - dateA;
});
// 订阅的到期日期应该是最新续订的 periodEnd
if (sortedByPeriodEnd[0].periodEnd) {
newExpiryDate = sortedByPeriodEnd[0].periodEnd;
}
// 找到最新的支付记录日期
const sortedByDate = [...paymentHistory].sort((a, b) => new Date(b.date) - new Date(a.date));
newLastPaymentDate = sortedByDate[0].date;
} else {
// 如果没有支付记录了,回退到初始状态
// expiryDate 保持不变或使用 periodStart(如果删除的记录有)
if (deletedPayment.periodStart) {
newExpiryDate = deletedPayment.periodStart;
}
newLastPaymentDate = subscription.startDate || subscription.createdAt || subscription.expiryDate;
}
subscriptions[index] = {
...subscription,
expiryDate: newExpiryDate,
paymentHistory,
lastPaymentDate: newLastPaymentDate
};
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));
return { success: true, subscription: subscriptions[index], message: '支付记录已删除' };
} catch (error) {
console.error('删除支付记录失败:', error);
return { success: false, message: '删除失败: ' + error.message };
}
}
async function updatePaymentRecord(subscriptionId, paymentId, paymentData, env) {
try {
const subscriptions = await getAllSubscriptions(env);
const index = subscriptions.findIndex(s => s.id === subscriptionId);
if (index === -1) {
return { success: false, message: '订阅不存在' };
}
const subscription = subscriptions[index];
const paymentHistory = subscription.paymentHistory || [];
const paymentIndex = paymentHistory.findIndex(p => p.id === paymentId);
if (paymentIndex === -1) {
return { success: false, message: '支付记录不存在' };
}
// 更新支付记录
paymentHistory[paymentIndex] = {
...paymentHistory[paymentIndex],
date: paymentData.date || paymentHistory[paymentIndex].date,
amount: paymentData.amount !== undefined ? paymentData.amount : paymentHistory[paymentIndex].amount,
note: paymentData.note !== undefined ? paymentData.note : paymentHistory[paymentIndex].note
};
// 更新 lastPaymentDate 为最新的支付记录日期
const sortedPayments = [...paymentHistory].sort((a, b) => new Date(b.date) - new Date(a.date));
const newLastPaymentDate = sortedPayments[0].date;
subscriptions[index] = {
...subscription,
paymentHistory,
lastPaymentDate: newLastPaymentDate
};
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));
return { success: true, subscription: subscriptions[index], message: '支付记录已更新' };
} catch (error) {
console.error('更新支付记录失败:', error);
return { success: false, message: '更新失败: ' + error.message };
}
}
async function toggleSubscriptionStatus(id, isActive, env) {
try {
const subscriptions = await getAllSubscriptions(env);
const index = subscriptions.findIndex(s => s.id === id);
if (index === -1) {
return { success: false, message: '订阅不存在' };
}
subscriptions[index] = {
...subscriptions[index],
isActive: isActive,
updatedAt: new Date().toISOString()
};
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(subscriptions));
return { success: true, subscription: subscriptions[index] };
} catch (error) {
return { success: false, message: '更新订阅状态失败' };
}
}
async function testSingleSubscriptionNotification(id, env) {
try {
const subscription = await getSubscription(id, env);
if (!subscription) {
return { success: false, message: '未找到该订阅' };
}
const config = await getConfig(env);
const title = `手动测试通知: ${subscription.name}`;
// 检查是否显示农历(从配置中获取,默认不显示)
const showLunar = config.SHOW_LUNAR === true;
let lunarExpiryText = '';
if (showLunar) {
// 计算农历日期
const expiryDateObj = new Date(subscription.expiryDate);
const lunarExpiry = lunarCalendar.solar2lunar(expiryDateObj.getFullYear(), expiryDateObj.getMonth() + 1, expiryDateObj.getDate());
lunarExpiryText = lunarExpiry ? ` (农历: ${lunarExpiry.fullStr})` : '';
}
// 格式化到期日期(使用所选时区)
const timezone = config?.TIMEZONE || 'UTC';
const formattedExpiryDate = formatTimeInTimezone(new Date(subscription.expiryDate), timezone, 'date');
const currentTime = formatTimeInTimezone(new Date(), timezone, 'datetime');
// 获取日历类型和自动续期状态
const calendarType = subscription.useLunar ? '农历' : '公历';
const autoRenewText = subscription.autoRenew ? '是' : '否';
const amountText = subscription.amount ? `\n金额: ¥${subscription.amount.toFixed(2)}/周期` : '';
const commonContent = `**订阅详情**
类型: ${subscription.customType || '其他'}${amountText}
日历类型: ${calendarType}
到期日期: ${formattedExpiryDate}${lunarExpiryText}
自动续期: ${autoRenewText}
备注: ${subscription.notes || '无'}
发送时间: ${currentTime}
当前时区: ${formatTimezoneDisplay(timezone)}`;
// 使用多渠道发送
const tags = extractTagsFromSubscriptions([subscription]);
await sendNotificationToAllChannels(title, commonContent, config, '[手动测试]', {
metadata: { tags }
});
return { success: true, message: '测试通知已发送到所有启用的渠道' };
} catch (error) {
console.error('[手动测试] 发送失败:', error);
return { success: false, message: '发送时发生错误: ' + error.message };
}
}
async function sendWebhookNotification(title, content, config, metadata = {}) {
try {
if (!config.WEBHOOK_URL) {
console.error('[Webhook通知] 通知未配置,缺少URL');
return false;
}
console.log('[Webhook通知] 开始发送通知到: ' + config.WEBHOOK_URL);
let requestBody;
let headers = { 'Content-Type': 'application/json' };
// 处理自定义请求头
if (config.WEBHOOK_HEADERS) {
try {
const customHeaders = JSON.parse(config.WEBHOOK_HEADERS);
headers = { ...headers, ...customHeaders };
} catch (error) {
console.warn('[Webhook通知] 自定义请求头格式错误,使用默认请求头');
}
}
const tagsArray = Array.isArray(metadata.tags)
? metadata.tags.filter(tag => typeof tag === 'string' && tag.trim().length > 0).map(tag => tag.trim())
: [];
const tagsBlock = tagsArray.length ? tagsArray.map(tag => `- ${tag}`).join('\n') : '';
const tagsLine = tagsArray.length ? '标签:' + tagsArray.join('、') : '';
const timestamp = formatTimeInTimezone(new Date(), config?.TIMEZONE || 'UTC', 'datetime');
const formattedMessage = [title, content, tagsLine, `发送时间:${timestamp}`]
.filter(section => section && section.trim().length > 0)
.join('\n\n');
const templateData = {
title,
content,
tags: tagsBlock,
tagsLine,
rawTags: tagsArray,
timestamp,
formattedMessage,
message: formattedMessage
};
const escapeForJson = (value) => {
if (value === null || value === undefined) {
return '';
}
return JSON.stringify(String(value)).slice(1, -1);
};
const applyTemplate = (template, data) => {
const templateString = JSON.stringify(template);
const replaced = templateString.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, key) => {
if (Object.prototype.hasOwnProperty.call(data, key)) {
return escapeForJson(data[key]);
}
return '';
});
return JSON.parse(replaced);
};
// 处理消息模板
if (config.WEBHOOK_TEMPLATE) {
try {
const template = JSON.parse(config.WEBHOOK_TEMPLATE);
requestBody = applyTemplate(template, templateData);
} catch (error) {
console.warn('[Webhook通知] 消息模板格式错误,使用默认格式');
requestBody = {
title,
content,
tags: tagsArray,
tagsLine,
timestamp,
message: formattedMessage
};
}
} else {
requestBody = {
title,
content,
tags: tagsArray,
tagsLine,
timestamp,
message: formattedMessage
};
}
const response = await fetch(config.WEBHOOK_URL, {
method: config.WEBHOOK_METHOD || 'POST',
headers: headers,
body: JSON.stringify(requestBody)
});
const result = await response.text();
console.log('[Webhook通知] 发送结果:', response.status, result);
return response.ok;
} catch (error) {
console.error('[Webhook通知] 发送通知失败:', error);
return false;
}
}
async function sendWechatBotNotification(title, content, config) {
try {
if (!config.WECHATBOT_WEBHOOK) {
console.error('[企业微信机器人] 通知未配置,缺少Webhook URL');
return false;
}
console.log('[企业微信机器人] 开始发送通知到: ' + config.WECHATBOT_WEBHOOK);
// 构建消息内容
let messageData;
const msgType = config.WECHATBOT_MSG_TYPE || 'text';
if (msgType === 'markdown') {
// Markdown 消息格式
const markdownContent = `# ${title}\n\n${content}`;
messageData = {
msgtype: 'markdown',
markdown: {
content: markdownContent
}
};
} else {
// 文本消息格式 - 优化显示
const textContent = `${title}\n\n${content}`;
messageData = {
msgtype: 'text',
text: {
content: textContent
}
};
}
// 处理@功能
if (config.WECHATBOT_AT_ALL === 'true') {
// @所有人
if (msgType === 'text') {
messageData.text.mentioned_list = ['@all'];
}
} else if (config.WECHATBOT_AT_MOBILES) {
// @指定手机号
const mobiles = config.WECHATBOT_AT_MOBILES.split(',').map(m => m.trim()).filter(m => m);
if (mobiles.length > 0) {
if (msgType === 'text') {
messageData.text.mentioned_mobile_list = mobiles;
}
}
}
console.log('[企业微信机器人] 发送消息数据:', JSON.stringify(messageData, null, 2));
const response = await fetch(config.WECHATBOT_WEBHOOK, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(messageData)
});
const responseText = await response.text();
console.log('[企业微信机器人] 响应状态:', response.status);
console.log('[企业微信机器人] 响应内容:', responseText);
if (response.ok) {
try {
const result = JSON.parse(responseText);
if (result.errcode === 0) {
console.log('[企业微信机器人] 通知发送成功');
return true;
} else {
console.error('[企业微信机器人] 发送失败,错误码:', result.errcode, '错误信息:', result.errmsg);
return false;
}
} catch (parseError) {
console.error('[企业微信机器人] 解析响应失败:', parseError);
return false;
}
} else {
console.error('[企业微信机器人] HTTP请求失败,状态码:', response.status);
return false;
}
} catch (error) {
console.error('[企业微信机器人] 发送通知失败:', error);
return false;
}
}
// 优化通知内容格式
function resolveReminderSetting(subscription) {
const defaultDays = subscription && subscription.reminderDays !== undefined ? Number(subscription.reminderDays) : 7;
let unit = subscription && subscription.reminderUnit === 'hour' ? 'hour' : 'day';
let value;
if (unit === 'hour') {
if (subscription && subscription.reminderValue !== undefined && subscription.reminderValue !== null && !isNaN(Number(subscription.reminderValue))) {
value = Number(subscription.reminderValue);
} else if (subscription && subscription.reminderHours !== undefined && subscription.reminderHours !== null && !isNaN(Number(subscription.reminderHours))) {
value = Number(subscription.reminderHours);
} else {
value = 0;
}
} else {
if (subscription && subscription.reminderValue !== undefined && subscription.reminderValue !== null && !isNaN(Number(subscription.reminderValue))) {
value = Number(subscription.reminderValue);
} else if (!isNaN(defaultDays)) {
value = Number(defaultDays);
} else {
value = 7;
}
}
if (value < 0 || isNaN(value)) {
value = 0;
}
return { unit, value };
}
function shouldTriggerReminder(reminder, daysDiff, hoursDiff) {
if (!reminder) {
return false;
}
if (reminder.unit === 'hour') {
if (reminder.value === 0) {
return hoursDiff >= 0 && hoursDiff < 1;
}
return hoursDiff >= 0 && hoursDiff <= reminder.value;
}
if (reminder.value === 0) {
return daysDiff === 0;
}
return daysDiff >= 0 && daysDiff <= reminder.value;
}
function formatNotificationContent(subscriptions, config) {
const showLunar = config.SHOW_LUNAR === true;
const timezone = config?.TIMEZONE || 'UTC';
let content = '';
for (const sub of subscriptions) {
const typeText = sub.customType || '其他';
const periodText = (sub.periodValue && sub.periodUnit) ? `(周期: ${sub.periodValue} ${ { day: '天', month: '月', year: '年' }[sub.periodUnit] || sub.periodUnit})` : '';
const categoryText = sub.category ? sub.category : '未分类';
const reminderSetting = resolveReminderSetting(sub);
// 格式化到期日期(使用所选时区)
const expiryDateObj = new Date(sub.expiryDate);
const formattedExpiryDate = formatTimeInTimezone(expiryDateObj, timezone, 'date');
// 农历日期
let lunarExpiryText = '';
if (showLunar) {
const lunarExpiry = lunarCalendar.solar2lunar(expiryDateObj.getFullYear(), expiryDateObj.getMonth() + 1, expiryDateObj.getDate());
lunarExpiryText = lunarExpiry ? `
农历日期: ${lunarExpiry.fullStr}` : '';
}
// 状态和到期时间
let statusText = '';
let statusEmoji = '';
if (sub.daysRemaining === 0) {
statusEmoji = '⚠️';
statusText = '今天到期!';
} else if (sub.daysRemaining < 0) {
statusEmoji = '🚨';
statusText = `已过期 ${Math.abs(sub.daysRemaining)} 天`;
} else {
statusEmoji = '📅';
statusText = `将在 ${sub.daysRemaining} 天后到期`;
}
const reminderSuffix = reminderSetting.value === 0
? '(仅到期时提醒)'
: (reminderSetting.unit === 'hour' ? '(小时级提醒)' : '');
const reminderText = reminderSetting.unit === 'hour'
? `提醒策略: 提前 ${reminderSetting.value} 小时${reminderSuffix}`
: `提醒策略: 提前 ${reminderSetting.value} 天${reminderSuffix}`;
// 获取日历类型和自动续期状态
const calendarType = sub.useLunar ? '农历' : '公历';
const autoRenewText = sub.autoRenew ? '是' : '否';
const amountText = sub.amount ? `\n金额: ¥${sub.amount.toFixed(2)}/周期` : '';
// 构建格式化的通知内容
const subscriptionContent = `${statusEmoji} **${sub.name}**
类型: ${typeText} ${periodText}
分类: ${categoryText}${amountText}
日历类型: ${calendarType}
到期日期: ${formattedExpiryDate}${lunarExpiryText}
自动续期: ${autoRenewText}
${reminderText}
到期状态: ${statusText}`;
// 添加备注
let finalContent = sub.notes ?
subscriptionContent + `\n备注: ${sub.notes}` :
subscriptionContent;
content += finalContent + '\n\n';
}
// 添加发送时间和时区信息
const currentTime = formatTimeInTimezone(new Date(), timezone, 'datetime');
content += `发送时间: ${currentTime}\n当前时区: ${formatTimezoneDisplay(timezone)}`;
return content;
}
async function sendNotificationToAllChannels(title, commonContent, config, logPrefix = '[定时任务]', options = {}) {
const metadata = options.metadata || {};
if (!config.ENABLED_NOTIFIERS || config.ENABLED_NOTIFIERS.length === 0) {
console.log(`${logPrefix} 未启用任何通知渠道。`);
return;
}
if (config.ENABLED_NOTIFIERS.includes('notifyx')) {
const notifyxContent = `## ${title}\n\n${commonContent}`;
const success = await sendNotifyXNotification(title, notifyxContent, `订阅提醒`, config);
console.log(`${logPrefix} 发送NotifyX通知 ${success ? '成功' : '失败'}`);
}
if (config.ENABLED_NOTIFIERS.includes('telegram')) {
const telegramContent = `*${title}*\n\n${commonContent}`;
const success = await sendTelegramNotification(telegramContent, config);
console.log(`${logPrefix} 发送Telegram通知 ${success ? '成功' : '失败'}`);
}
if (config.ENABLED_NOTIFIERS.includes('webhook')) {
const webhookContent = commonContent.replace(/(\**|\*|##|#|`)/g, '');
const success = await sendWebhookNotification(title, webhookContent, config, metadata);
console.log(`${logPrefix} 发送Webhook通知 ${success ? '成功' : '失败'}`);
}
if (config.ENABLED_NOTIFIERS.includes('wechatbot')) {
const wechatbotContent = commonContent.replace(/(\**|\*|##|#|`)/g, '');
const success = await sendWechatBotNotification(title, wechatbotContent, config);
console.log(`${logPrefix} 发送企业微信机器人通知 ${success ? '成功' : '失败'}`);
}
if (config.ENABLED_NOTIFIERS.includes('email')) {
const emailContent = commonContent.replace(/(\**|\*|##|#|`)/g, '');
const success = await sendEmailNotification(title, emailContent, config);
console.log(`${logPrefix} 发送邮件通知 ${success ? '成功' : '失败'}`);
}
if (config.ENABLED_NOTIFIERS.includes('bark')) {
const barkContent = commonContent.replace(/(\**|\*|##|#|`)/g, '');
const success = await sendBarkNotification(title, barkContent, config);
console.log(`${logPrefix} 发送Bark通知 ${success ? '成功' : '失败'}`);
}
}
async function sendTelegramNotification(message, config) {
try {
if (!config.TG_BOT_TOKEN || !config.TG_CHAT_ID) {
console.error('[Telegram] 通知未配置,缺少Bot Token或Chat ID');
return false;
}
console.log('[Telegram] 开始发送通知到 Chat ID: ' + config.TG_CHAT_ID);
const url = 'https://api.telegram.org/bot' + config.TG_BOT_TOKEN + '/sendMessage';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: config.TG_CHAT_ID,
text: message,
parse_mode: 'Markdown'
})
});
const result = await response.json();
console.log('[Telegram] 发送结果:', result);
return result.ok;
} catch (error) {
console.error('[Telegram] 发送通知失败:', error);
return false;
}
}
async function sendNotifyXNotification(title, content, description, config) {
try {
if (!config.NOTIFYX_API_KEY) {
console.error('[NotifyX] 通知未配置,缺少API Key');
return false;
}
console.log('[NotifyX] 开始发送通知: ' + title);
const url = 'https://www.notifyx.cn/api/v1/send/' + config.NOTIFYX_API_KEY;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: title,
content: content,
description: description || ''
})
});
const result = await response.json();
console.log('[NotifyX] 发送结果:', result);
return result.status === 'queued';
} catch (error) {
console.error('[NotifyX] 发送通知失败:', error);
return false;
}
}
async function sendBarkNotification(title, content, config) {
try {
if (!config.BARK_DEVICE_KEY) {
console.error('[Bark] 通知未配置,缺少设备Key');
return false;
}
console.log('[Bark] 开始发送通知到设备: ' + config.BARK_DEVICE_KEY);
const serverUrl = config.BARK_SERVER || 'https://api.day.app';
const url = serverUrl + '/push';
const payload = {
title: title,
body: content,
device_key: config.BARK_DEVICE_KEY
};
// 如果配置了保存推送,则添加isArchive参数
if (config.BARK_IS_ARCHIVE === 'true') {
payload.isArchive = 1;
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify(payload)
});
const result = await response.json();
console.log('[Bark] 发送结果:', result);
// Bark API返回code为200表示成功
return result.code === 200;
} catch (error) {
console.error('[Bark] 发送通知失败:', error);
return false;
}
}
async function sendEmailNotification(title, content, config) {
try {
if (!config.RESEND_API_KEY || !config.EMAIL_FROM || !config.EMAIL_TO) {
console.error('[邮件通知] 通知未配置,缺少必要参数');
return false;
}
console.log('[邮件通知] 开始发送邮件到: ' + config.EMAIL_TO);
// 生成HTML邮件内容
const htmlContent = `
${title}
${content.replace(/\n/g, '
')}
此邮件由订阅管理系统自动发送,请及时处理相关订阅事务。
`;
const fromEmail = config.EMAIL_FROM_NAME ?
`${config.EMAIL_FROM_NAME} <${config.EMAIL_FROM}>` :
config.EMAIL_FROM;
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.RESEND_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: fromEmail,
to: config.EMAIL_TO,
subject: title,
html: htmlContent,
text: content // 纯文本备用
})
});
const result = await response.json();
console.log('[邮件通知] 发送结果:', response.status, result);
if (response.ok && result.id) {
console.log('[邮件通知] 邮件发送成功,ID:', result.id);
return true;
} else {
console.error('[邮件通知] 邮件发送失败:', result);
return false;
}
} catch (error) {
console.error('[邮件通知] 发送邮件失败:', error);
return false;
}
}
async function sendNotification(title, content, description, config) {
if (config.NOTIFICATION_TYPE === 'notifyx') {
return await sendNotifyXNotification(title, content, description, config);
} else {
return await sendTelegramNotification(content, config);
}
}
// 4. 修改定时任务 checkExpiringSubscriptions,支持农历周期自动续订和农历提醒
async function checkExpiringSubscriptions(env) {
try {
const config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
// 统一计算当天的零点时间,用于比较天数差异
const currentMidnight = getTimezoneMidnightTimestamp(currentTime, timezone);
console.log(`[定时任务] 开始检查 - 当前时间: ${currentTime.toISOString()} (${timezone})`);
// --- 检查当前小时是否允许发送通知 ---
const rawNotificationHours = Array.isArray(config.NOTIFICATION_HOURS) ? config.NOTIFICATION_HOURS : [];
const normalizedNotificationHours = rawNotificationHours
.map(value => String(value).trim())
.filter(value => value.length > 0)
.map(value => value === '*' ? '*' : value.toUpperCase() === 'ALL' ? 'ALL' : value.padStart(2, '0'));
const allowAllHours = normalizedNotificationHours.includes('*') || normalizedNotificationHours.includes('ALL');
const hourFormatter = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour12: false, hour: '2-digit' });
const currentHour = hourFormatter.format(currentTime);
const shouldNotifyThisHour = allowAllHours || normalizedNotificationHours.length === 0 || normalizedNotificationHours.includes(currentHour);
const subscriptions = await getAllSubscriptions(env);
const expiringSubscriptions = [];
const updatedSubscriptions = [];
let hasUpdates = false;
for (const subscription of subscriptions) {
// 1. 跳过未启用的订阅
if (subscription.isActive === false) {
continue;
}
const reminderSetting = resolveReminderSetting(subscription);
// 计算当前剩余时间(基础计算)
let expiryDate = new Date(subscription.expiryDate);
// 为了准确计算 daysDiff,需要根据农历或公历获取"逻辑上的午夜时间"
let expiryMidnight;
if (subscription.useLunar) {
const lunar = lunarCalendar.solar2lunar(expiryDate.getFullYear(), expiryDate.getMonth() + 1, expiryDate.getDate());
if(lunar) {
const solar = lunarBiz.lunar2solar(lunar);
const lunarDate = new Date(solar.year, solar.month - 1, solar.day);
expiryMidnight = getTimezoneMidnightTimestamp(lunarDate, timezone);
} else {
expiryMidnight = getTimezoneMidnightTimestamp(expiryDate, timezone);
}
} else {
expiryMidnight = getTimezoneMidnightTimestamp(expiryDate, timezone);
}
// 1. 获取当前时间的 UTC 时间戳
const nowTs = currentTime.getTime();
const tzOffset = getTimezoneOffset(timezone);
// 修正后的到期时间 = 原始UTC时间 - 时区偏移小时
const adjustedExpiryTime = expiryDate.getTime() - (tzOffset * MS_PER_HOUR);
let daysDiff = Math.round((expiryMidnight - currentMidnight) / MS_PER_DAY);
// 使用修正后的时间计算差值
let diffMs = adjustedExpiryTime - currentTime.getTime();
let diffHours = diffMs / MS_PER_HOUR;
// ==========================================
// 核心逻辑:自动续费处理
// ==========================================
if (daysDiff < 0 && subscription.periodValue && subscription.periodUnit && subscription.autoRenew !== false) {
console.log(`[定时任务] 订阅 "${subscription.name}" 已过期 (${daysDiff}天),准备自动续费...`);
const mode = subscription.subscriptionMode || 'cycle'; // cycle | reset
// 1. 确定计算基准点 (Base Point)
// newStartDate 将作为新周期的"开始日期"保存到数据库,解决前端编辑时日期错乱问题
let newStartDate;
if (mode === 'reset') {
// 注意:为了整洁,通常从当天的 00:00 或当前时间开始,这里取 currentTime 保持精确
newStartDate = new Date(currentTime);
} else {
// Cycle 模式:无缝接续,从"旧的到期日"开始
newStartDate = new Date(subscription.expiryDate);
}
// 2. 计算新的到期日 (循环补齐直到未来)
let newExpiryDate = new Date(newStartDate); // 初始化
let periodsAdded = 0;
// 定义增加一个周期的函数 (同时处理 newStartDate 和 newExpiryDate 的推进)
const addOnePeriod = (baseDate) => {
let targetDate;
if (subscription.useLunar) {
const solarBase = { year: baseDate.getFullYear(), month: baseDate.getMonth() + 1, day: baseDate.getDate() };
let lunarBase = lunarCalendar.solar2lunar(solarBase.year, solarBase.month, solarBase.day);
// 农历加周期
let nextLunar = lunarBiz.addLunarPeriod(lunarBase, subscription.periodValue, subscription.periodUnit);
const solarNext = lunarBiz.lunar2solar(nextLunar);
targetDate = new Date(solarNext.year, solarNext.month - 1, solarNext.day);
} else {
targetDate = new Date(baseDate);
if (subscription.periodUnit === 'day') targetDate.setDate(targetDate.getDate() + subscription.periodValue);
else if (subscription.periodUnit === 'month') targetDate.setMonth(targetDate.getMonth() + subscription.periodValue);
else if (subscription.periodUnit === 'year') targetDate.setFullYear(targetDate.getFullYear() + subscription.periodValue);
}
return targetDate;
};
// Reset模式下 newStartDate 是今天,加一次肯定在未来,循环只会执行一次
do {
// 在推进到期日之前,现有的 newExpiryDate 就变成了这一轮的"开始日"
// (仅在非第一次循环时有效,用于 Cycle 模式推进 start 日期)
if (periodsAdded > 0) {
newStartDate = new Date(newExpiryDate);
}
// 计算下一个到期日
newExpiryDate = addOnePeriod(newStartDate);
periodsAdded++;
// 获取新到期日的午夜时间用于判断是否仍过期
const newExpiryMidnight = getTimezoneMidnightTimestamp(newExpiryDate, timezone);
daysDiff = Math.round((newExpiryMidnight - currentMidnight) / MS_PER_DAY);
} while (daysDiff < 0); // 只要还过期,就继续加
console.log(`[定时任务] 续费完成. 新开始日: ${newStartDate.toISOString()}, 新到期日: ${newExpiryDate.toISOString()}`);
// 3. 生成支付记录
const paymentRecord = {
id: Date.now().toString() + '_' + Math.random().toString(36).substr(2, 9),
date: currentTime.toISOString(), // 实际扣款时间是现在
amount: subscription.amount || 0,
type: 'auto',
note: `自动续订 (${mode === 'reset' ? '重置模式' : '接续模式'}${periodsAdded > 1 ? ', 补齐' + periodsAdded + '周期' : ''})`,
periodStart: newStartDate.toISOString(), // 记录准确的计费周期开始
periodEnd: newExpiryDate.toISOString()
};
const paymentHistory = subscription.paymentHistory || [];
paymentHistory.push(paymentRecord);
// 4. 更新订阅对象
const updatedSubscription = {
...subscription,
startDate: newStartDate.toISOString(),
expiryDate: newExpiryDate.toISOString(),
lastPaymentDate: currentTime.toISOString(),
paymentHistory
};
updatedSubscriptions.push(updatedSubscription);
hasUpdates = true;
// 5. 检查续费后是否需要立即提醒 (例如续费后只剩1天)
diffMs = newExpiryDate.getTime() - currentTime.getTime();
diffHours = diffMs / MS_PER_HOUR;
const shouldRemindAfterRenewal = shouldTriggerReminder(reminderSetting, daysDiff, diffHours);
if (shouldRemindAfterRenewal) {
expiringSubscriptions.push({
...updatedSubscription,
daysRemaining: daysDiff,
hoursRemaining: Math.round(diffHours)
});
}
continue; // 处理下一个订阅
}
// ==========================================
// 普通提醒逻辑 (未过期,或过期但不自动续费)
// ==========================================
const shouldRemind = shouldTriggerReminder(reminderSetting, daysDiff, diffHours);
if (daysDiff < 0 && subscription.autoRenew === false) {
// 已过期且不自动续费 -> 发送过期通知
expiringSubscriptions.push({
...subscription,
daysRemaining: daysDiff,
hoursRemaining: Math.round(diffHours)
});
} else if (shouldRemind) {
// 正常到期提醒
expiringSubscriptions.push({
...subscription,
daysRemaining: daysDiff,
hoursRemaining: Math.round(diffHours)
});
}
}
// --- 保存更改 ---
if (hasUpdates) {
const mergedSubscriptions = subscriptions.map(sub => {
const updated = updatedSubscriptions.find(u => u.id === sub.id);
return updated || sub;
});
await env.SUBSCRIPTIONS_KV.put('subscriptions', JSON.stringify(mergedSubscriptions));
console.log(`[定时任务] 已更新 ${updatedSubscriptions.length} 个自动续费订阅`);
}
// --- 发送通知 ---
if (expiringSubscriptions.length > 0) {
if (!shouldNotifyThisHour) {
console.log(`[定时任务] 当前小时 ${currentHour} 未在通知时段内 (${normalizedNotificationHours.join(',')}),跳过发送`);
} else {
console.log(`[定时任务] 发送 ${expiringSubscriptions.length} 条提醒通知`);
// 按到期时间排序
expiringSubscriptions.sort((a, b) => a.daysRemaining - b.daysRemaining);
const commonContent = formatNotificationContent(expiringSubscriptions, config);
const metadataTags = extractTagsFromSubscriptions(expiringSubscriptions);
await sendNotificationToAllChannels('订阅到期/续费提醒', commonContent, config, '[定时任务]', {
metadata: { tags: metadataTags }
});
}
}
} catch (error) {
console.error('[定时任务] 执行失败:', error);
}
}
function getCookieValue(cookieString, key) {
if (!cookieString) return null;
const match = cookieString.match(new RegExp('(^| )' + key + '=([^;]+)'));
return match ? match[2] : null;
}
async function handleRequest(request, env, ctx) {
return new Response(loginPage, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
const CryptoJS = {
HmacSHA256: function(message, key) {
const keyData = new TextEncoder().encode(key);
const messageData = new TextEncoder().encode(message);
return Promise.resolve().then(() => {
return crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: {name: "SHA-256"} },
false,
["sign"]
);
}).then(cryptoKey => {
return crypto.subtle.sign(
"HMAC",
cryptoKey,
messageData
);
}).then(buffer => {
const hashArray = Array.from(new Uint8Array(buffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
});
}
};
function getCurrentTime(config) {
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
const formatter = new Intl.DateTimeFormat('zh-CN', {
timeZone: timezone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
return {
date: currentTime,
localString: formatter.format(currentTime),
isoString: currentTime.toISOString()
};
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 添加调试页面
if (url.pathname === '/debug') {
try {
const config = await getConfig(env);
const debugInfo = {
timestamp: new Date().toISOString(), // 使用UTC时间戳
pathname: url.pathname,
kvBinding: !!env.SUBSCRIPTIONS_KV,
configExists: !!config,
adminUsername: config.ADMIN_USERNAME,
hasJwtSecret: !!config.JWT_SECRET,
jwtSecretLength: config.JWT_SECRET ? config.JWT_SECRET.length : 0
};
return new Response(`
调试信息
系统调试信息
基本信息
时间: ${debugInfo.timestamp}
路径: ${debugInfo.pathname}
KV绑定: ${debugInfo.kvBinding ? '✓' : '✗'}
配置信息
配置存在: ${debugInfo.configExists ? '✓' : '✗'}
管理员用户名: ${debugInfo.adminUsername}
JWT密钥: ${debugInfo.hasJwtSecret ? '✓' : '✗'} (长度: ${debugInfo.jwtSecretLength})
解决方案
1. 确保KV命名空间已正确绑定为 SUBSCRIPTIONS_KV
2. 尝试访问 / 进行登录
3. 如果仍有问题,请检查Cloudflare Workers日志
`, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
} catch (error) {
return new Response(`调试页面错误: ${error.message}`, {
status: 500,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});
}
}
if (url.pathname.startsWith('/api')) {
return api.handleRequest(request, env, ctx);
} else if (url.pathname.startsWith('/admin')) {
return admin.handleRequest(request, env, ctx);
} else {
return handleRequest(request, env, ctx);
}
},
async scheduled(event, env, ctx) {
const config = await getConfig(env);
const timezone = config?.TIMEZONE || 'UTC';
const currentTime = getCurrentTimeInTimezone(timezone);
console.log('[Workers] 定时任务触发 UTC:', new Date().toISOString(), timezone + ':', currentTime.toLocaleString('zh-CN', {timeZone: timezone}));
await checkExpiringSubscriptions(env);
}
};
// ==================== 仪表盘统计函数 ====================
// 汇率配置 (以 CNY 为基准,当 API 不可用或缺少特定币种如 TWD 时使用,属于兜底汇率)
// 您可以根据需要修改此处的汇率
const FALLBACK_RATES = {
'CNY': 1,
'USD': 6.98,
'HKD': 0.90,
'TWD': 0.22,
'JPY': 0.044,
'EUR': 8.16,
'GBP': 9.40,
'KRW': 0.0048,
'TRY': 0.16
};
// 获取动态汇率 (核心逻辑:KV缓存 -> API请求 -> 兜底合并)
async function getDynamicRates(env) {
const CACHE_KEY = 'SYSTEM_EXCHANGE_RATES';
const CACHE_TTL = 86400000; // 24小时 (毫秒)
try {
const cached = await env.SUBSCRIPTIONS_KV.get(CACHE_KEY, { type: 'json' }); // A. 尝试从 KV 读取缓存
if (cached && cached.ts && (Date.now() - cached.ts < CACHE_TTL)) {
return cached.rates; // console.log('[汇率] 使用 KV 缓存');
}
const response = await fetch('https://api.frankfurter.dev/v1/latest?base=CNY'); // B. 缓存失效或不存在,请求 Frankfurter API
if (response.ok) {
const data = await response.json();
const newRates = { // C. 合并逻辑:以 API 数据覆盖兜底数据 (保留 API 没有的币种,如 TWD)
...FALLBACK_RATES,
...data.rates,
'CNY': 1
};
await env.SUBSCRIPTIONS_KV.put(CACHE_KEY, JSON.stringify({ // D. 写入 KV 缓存
ts: Date.now(),
rates: newRates
}));
return newRates;
} else {
console.warn('[汇率] API 请求失败,使用兜底汇率');
}
} catch (error) {
console.error('[汇率] 获取过程出错:', error);
}
return FALLBACK_RATES; // E. 发生任何错误,返回兜底汇率
}
// 辅助函数:将金额转换为基准货币 (CNY)
function convertToCNY(amount, currency, rates) {
if (!amount || amount <= 0) return 0;
const code = currency || 'CNY';
if (code === 'CNY') return amount; // 如果是基准货币,直接返回
const rate = rates[code]; // 获取汇率
if (!rate) return amount; // 如果没有汇率,原样返回(或者你可以选择抛出错误/返回0)
return amount / rate;
}
// 修改函数签名,增加 rates 参数
function calculateMonthlyExpense(subscriptions, timezone, rates) {
const now = getCurrentTimeInTimezone(timezone);
const parts = getTimezoneDateParts(now, timezone);
const currentYear = parts.year;
const currentMonth = parts.month;
let amount = 0;
// 遍历所有订阅的支付历史
subscriptions.forEach(sub => {
const paymentHistory = sub.paymentHistory || [];
paymentHistory.forEach(payment => {
if (!payment.amount || payment.amount <= 0) return;
const paymentDate = new Date(payment.date);
const paymentParts = getTimezoneDateParts(paymentDate, timezone);
if (paymentParts.year === currentYear && paymentParts.month === currentMonth) {
amount += convertToCNY(payment.amount, sub.currency, rates); // 传入 rates 参数
}
});
});
// 计算上月数据用于趋势对比
const lastMonth = currentMonth === 1 ? 12 : currentMonth - 1;
const lastMonthYear = currentMonth === 1 ? currentYear - 1 : currentYear;
let lastMonthAmount = 0;
subscriptions.forEach(sub => {
const paymentHistory = sub.paymentHistory || [];
paymentHistory.forEach(payment => {
if (!payment.amount || payment.amount <= 0) return;
const paymentDate = new Date(payment.date);
const paymentParts = getTimezoneDateParts(paymentDate, timezone);
if (paymentParts.year === lastMonthYear && paymentParts.month === lastMonth) {
lastMonthAmount += convertToCNY(payment.amount, sub.currency, rates); // 使用 convertToCNY 进行汇率转换
}
});
});
let trend = 0;
let trendDirection = 'flat';
if (lastMonthAmount > 0) {
trend = Math.round(((amount - lastMonthAmount) / lastMonthAmount) * 100);
if (trend > 0) trendDirection = 'up';
else if (trend < 0) trendDirection = 'down';
} else if (amount > 0) {
trend = 100; // 上月无支出,本月有支出,视为增长
trendDirection = 'up';
}
return { amount, trend: Math.abs(trend), trendDirection };
}
function calculateYearlyExpense(subscriptions, timezone, rates) {
const now = getCurrentTimeInTimezone(timezone);
const parts = getTimezoneDateParts(now, timezone);
const currentYear = parts.year;
let amount = 0;
// 遍历所有订阅的支付历史
subscriptions.forEach(sub => {
const paymentHistory = sub.paymentHistory || [];
paymentHistory.forEach(payment => {
if (!payment.amount || payment.amount <= 0) return;
const paymentDate = new Date(payment.date);
const paymentParts = getTimezoneDateParts(paymentDate, timezone);
if (paymentParts.year === currentYear) {
amount += convertToCNY(payment.amount, sub.currency, rates);
}
});
});
const monthlyAverage = amount / parts.month;
return { amount, monthlyAverage };
}
function getRecentPayments(subscriptions, timezone) {
const now = getCurrentTimeInTimezone(timezone);
const sevenDaysAgo = new Date(now.getTime() - 7 * MS_PER_DAY);
const recentPayments = [];
// 遍历所有订阅的支付历史
subscriptions.forEach(sub => {
const paymentHistory = sub.paymentHistory || [];
paymentHistory.forEach(payment => {
if (!payment.amount || payment.amount <= 0) return;
const paymentDate = new Date(payment.date);
if (paymentDate >= sevenDaysAgo && paymentDate <= now) {
recentPayments.push({
name: sub.name,
amount: payment.amount,
currency: sub.currency || 'CNY', // 传递币种给前端显示
customType: sub.customType,
paymentDate: payment.date,
note: payment.note
});
}
});
});
return recentPayments.sort((a, b) => new Date(b.paymentDate) - new Date(a.paymentDate));
}
function getUpcomingRenewals(subscriptions, timezone) {
const now = getCurrentTimeInTimezone(timezone);
const sevenDaysLater = new Date(now.getTime() + 7 * MS_PER_DAY);
return subscriptions
.filter(sub => {
if (!sub.isActive) return false;
const renewalDate = new Date(sub.expiryDate);
return renewalDate >= now && renewalDate <= sevenDaysLater;
})
.map(sub => {
const renewalDate = new Date(sub.expiryDate);
const daysUntilRenewal = Math.ceil((renewalDate - now) / MS_PER_DAY);
return {
name: sub.name,
amount: sub.amount || 0,
currency: sub.currency || 'CNY',
customType: sub.customType,
renewalDate: sub.expiryDate,
daysUntilRenewal
};
})
.sort((a, b) => a.daysUntilRenewal - b.daysUntilRenewal);
}
function getExpenseByType(subscriptions, timezone, rates) {
const now = getCurrentTimeInTimezone(timezone);
const parts = getTimezoneDateParts(now, timezone);
const currentYear = parts.year;
const typeMap = {};
let total = 0;
// 遍历所有订阅的支付历史
subscriptions.forEach(sub => {
const paymentHistory = sub.paymentHistory || [];
paymentHistory.forEach(payment => {
if (!payment.amount || payment.amount <= 0) return;
const paymentDate = new Date(payment.date);
const paymentParts = getTimezoneDateParts(paymentDate, timezone);
if (paymentParts.year === currentYear) {
const type = sub.customType || '未分类';
const amountCNY = convertToCNY(payment.amount, sub.currency, rates);
typeMap[type] = (typeMap[type] || 0) + amountCNY;
total += amountCNY;
}
});
});
return Object.entries(typeMap)
.map(([type, amount]) => ({
type,
amount,
percentage: total > 0 ? Math.round((amount / total) * 100) : 0
}))
.sort((a, b) => b.amount - a.amount);
}
function getExpenseByCategory(subscriptions, timezone, rates) {
const now = getCurrentTimeInTimezone(timezone);
const parts = getTimezoneDateParts(now, timezone);
const currentYear = parts.year;
const categoryMap = {};
let total = 0;
// 遍历所有订阅的支付历史
subscriptions.forEach(sub => {
const paymentHistory = sub.paymentHistory || [];
paymentHistory.forEach(payment => {
if (!payment.amount || payment.amount <= 0) return;
const paymentDate = new Date(payment.date);
const paymentParts = getTimezoneDateParts(paymentDate, timezone);
if (paymentParts.year === currentYear) {
const categories = sub.category ? sub.category.split(CATEGORY_SEPARATOR_REGEX).filter(c => c.trim()) : ['未分类'];
const amountCNY = convertToCNY(payment.amount, sub.currency, rates);
categories.forEach(category => {
const cat = category.trim() || '未分类';
categoryMap[cat] = (categoryMap[cat] || 0) + amountCNY / categories.length;
});
total += amountCNY;
}
});
});
return Object.entries(categoryMap)
.map(([category, amount]) => ({
category,
amount,
percentage: total > 0 ? Math.round((amount / total) * 100) : 0
}))
.sort((a, b) => b.amount - a.amount);
}