refactor(time): 重写时区核心模块为单一真相源

修复 #91 / #52 / #166 类时区相关问题的根因:旧 getCurrentTimeInTimezone
只返回 new Date(),被调用方误以为是"用户本地时间对象"。

变化点:
- 新增 getNowInTimezone(tz, now?) 返回 {utc, parts, hourString, isoLocal}
  强制业务代码显式选择需要 UTC 时刻还是用户 TZ 字段
- 新增 getTimezoneHourString(date, tz) 调度器通知时段判断专用
- 新增 getDaysBetween(from, to, tz) 基于用户 TZ 各自零点的整天数差,
  修复"凌晨 0–8 点创建订阅默认日期变前一天"的 #166
- formatLocalDate 替代 formatTimeInTimezone(保留旧名作 alias)
- 旧 API(getCurrentTimeInTimezone / convertUTCToTimezone)保留为兼容
  wrapper,加显眼注释说明其语义陷阱
- 全文 JSDoc 标注 + 中文用途说明,启用 // @ts-check
- 40 条单测覆盖:UTC/北京/纽约 DST、跨日界、#166 边界、非法时区兜底

附带:/debug 页新增"时区诊断"区块,直观展示 UTC vs 用户 TZ 当前小时、
通知时段是否命中——这是用户自助排查"为什么没收到通知"的入口

Refs Task 2 of refactor/v3-product-grade plan.
This commit is contained in:
wangwangit
2026-05-24 17:53:29 +08:00
parent c8096c030f
commit f6bce4b7b3
3 changed files with 782 additions and 132 deletions

View File

@@ -1,59 +1,125 @@
// @ts-check
/**
* 调试页(仅登录后可见)
*
* 用途:
* - 检查 KV 绑定、配置完整性、JWT 密钥状态
* - v3 起新增"时区诊断"区块,直观展示 UTC vs 用户 TZ 的当前小时差异
* 这是 #91 / #52 / #166 类问题的自助排查入口
*
* 维护人v3 重构 (2026-05)
*/
import { getConfig } from '../data/config.js';
import {
getNowInTimezone,
formatTimezoneDisplay,
getTimezoneOffset
} from '../core/time.js';
/** 简单 HTML 转义,防止配置中的字符串污染页面 */
function esc(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* @param {Request} request
* @param {{ SUBSCRIPTIONS_KV: KVNamespace }} env
*/
async function handleDebug(request, env) {
try {
const url = new URL(request.url);
const config = await getConfig(env);
const tz = config.TIMEZONE || 'UTC';
const now = getNowInTimezone(tz);
const notificationHours = Array.isArray(config.NOTIFICATION_HOURS)
? config.NOTIFICATION_HOURS.map((h) => String(h).padStart(2, '0'))
: [];
const inWindow =
notificationHours.length === 0 ||
notificationHours.includes('*') ||
notificationHours.includes('ALL') ||
notificationHours.includes(now.hourString);
const debugInfo = {
timestamp: new Date().toISOString(),
timestamp: now.utc.toISOString(),
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
jwtSecretLength: config.JWT_SECRET ? config.JWT_SECRET.length : 0,
timezone: tz,
timezoneDisplay: formatTimezoneDisplay(tz),
timezoneOffsetHours: getTimezoneOffset(tz),
utcIso: now.utc.toISOString(),
localIso: now.isoLocal,
currentHour: now.hourString,
configuredHours: notificationHours,
inNotificationWindow: inWindow
};
return new Response(`
<!DOCTYPE html>
<html>
return new Response(
`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<title>调试信息</title>
<meta charset="UTF-8" />
<title>调试信息 - SubsTracker</title>
<style>
body { font-family: monospace; padding: 20px; background: #f5f5f5; }
.info { background: white; padding: 15px; margin: 10px 0; border-radius: 5px; }
.success { color: green; }
.error { color: red; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; padding: 20px; background: #f5f5f5; color: #333; }
h1 { font-size: 22px; }
.info { background: white; padding: 15px 20px; margin: 12px 0; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
.info h3 { margin-top: 0; font-size: 16px; color: #555; border-bottom: 1px solid #eee; padding-bottom: 8px; }
.row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.row .k { color: #666; }
.row .v { font-weight: 600; color: #1a1a1a; }
.success { color: #16a34a; }
.error { color: #dc2626; }
.warn { color: #ca8a04; }
code { background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 12px; }
</style>
</head>
<body>
<h1>系统调试信息</h1>
<h1>系统调试信息v3</h1>
<div class="info">
<h3>基本信息</h3>
<p>时间: ${debugInfo.timestamp}</p>
<p>路径: ${debugInfo.pathname}</p>
<p class="${debugInfo.kvBinding ? 'success' : 'error'}">KV绑定: ${debugInfo.kvBinding ? '✓' : '✗'}</p>
<h3>基本</h3>
<div class="row"><span class="k">UTC 时间</span><span class="v">${esc(debugInfo.timestamp)}</span></div>
<div class="row"><span class="k">访问路径</span><span class="v">${esc(debugInfo.pathname)}</span></div>
<div class="row"><span class="k">KV 绑定</span><span class="v ${debugInfo.kvBinding ? 'success' : 'error'}">${debugInfo.kvBinding ? '✓ 已绑定' : '✗ 未绑定'}</span></div>
<div class="row"><span class="k">配置可读</span><span class="v ${debugInfo.configExists ? 'success' : 'error'}">${debugInfo.configExists ? '✓' : '✗'}</span></div>
<div class="row"><span class="k">管理员用户名</span><span class="v">${esc(debugInfo.adminUsername || '(未设置)')}</span></div>
<div class="row"><span class="k">JWT 密钥</span><span class="v ${debugInfo.hasJwtSecret ? 'success' : 'error'}">${debugInfo.hasJwtSecret ? `✓ 已设置 (${debugInfo.jwtSecretLength} 字符)` : '✗ 缺失'}</span></div>
</div>
<div class="info">
<h3>配置信息</h3>
<p class="${debugInfo.configExists ? 'success' : 'error'}">配置存在: ${debugInfo.configExists ? '✓' : '✗'}</p>
<p>管理员用户名: ${String(debugInfo.adminUsername || '').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p class="${debugInfo.hasJwtSecret ? 'success' : 'error'}">JWT密钥: ${debugInfo.hasJwtSecret ? '✓' : '✗'} (长度: ${debugInfo.jwtSecretLength})</p>
<h3>时区诊断v3 通知调度核心信息</h3>
<div class="row"><span class="k">配置的时区</span><span class="v">${esc(debugInfo.timezoneDisplay)}</span></div>
<div class="row"><span class="k">时区偏移</span><span class="v">UTC${debugInfo.timezoneOffsetHours >= 0 ? '+' : ''}${debugInfo.timezoneOffsetHours} 小时</span></div>
<div class="row"><span class="k">当前 UTC</span><span class="v">${esc(debugInfo.utcIso)}</span></div>
<div class="row"><span class="k">当前用户本地时间</span><span class="v">${esc(debugInfo.localIso)}</span></div>
<div class="row"><span class="k">用于通知时段判断的小时</span><span class="v">${esc(debugInfo.currentHour)}</span></div>
<div class="row"><span class="k">配置的通知小时(用户 TZ</span><span class="v">${notificationHours.length === 0 ? '<em class="warn">空(默认全天发送)</em>' : `<code>${esc(notificationHours.join(', '))}</code>`}</span></div>
<div class="row"><span class="k">现在是否允许发送</span><span class="v ${debugInfo.inNotificationWindow ? 'success' : 'warn'}">${debugInfo.inNotificationWindow ? '✓ 在窗口内' : '✗ 不在窗口内'}</span></div>
</div>
<div class="info">
<h3>解决方案</h3>
<p>1. 确保KV命名空间已正确绑定为 SUBSCRIPTIONS_KV</p>
<p>2. 尝试访问 <a href="/">/</a> 进行登录</p>
<p>3. 如果仍有问题请检查Cloudflare Workers日志</p>
<h3>提示</h3>
<p>1. 如果时区诊断中"当前小时"与你预期不符,请检查配置中的 <code>TIMEZONE</code> 是否与你所在地匹配。</p>
<p>2. v3 起 <code>NOTIFICATION_HOURS</code> <strong>按你配置的时区</strong>解释(不再是 UTC。例如想让北京时间 8 点收到通知,<code>TIMEZONE=Asia/Shanghai</code> 时填 <code>08</code>。</p>
<p>3. 详细发送记录请前往后台"通知历史"页v3 后续版本提供)。</p>
<p>4. <a href="/admin">返回管理后台</a></p>
</div>
</body>
</html>`, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
</html>`,
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
} catch (error) {
return new Response(`调试页面错误: ${error.message}`, {
return new Response(`调试页面错误: ${error && error.message ? error.message : error}`, {
status: 500,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});

View File

@@ -1,119 +1,354 @@
// 时间与时区工具
const MS_PER_HOUR = 1000 * 60 * 60;
const MS_PER_DAY = MS_PER_HOUR * 24;
// @ts-check
/**
* 时区核心模块v3 重写)
*
* ── 设计原则 ────────────────────────────────────────────────
* 1. 数据存储层:所有日期一律 ISO 8601 UTC 字符串(如 "2026-05-24T17:30:00.000Z"
* 2. 业务逻辑层:判断"通知时段""剩余天数"前先把 UTC 时刻转到用户配置的时区下取
* 年/月/日/时;本模块是这层的"唯一真相源"
* 3. 展示层:所有面向用户的日期显示都走 formatLocalDate / formatTimezoneDisplay
*
* ── 关键修复点(对比 v2─────────────────────────────────────
* - 旧 getCurrentTimeInTimezone() 只 `return new Date()`,把"当前 UTC 时刻"
* 伪装成"用户本地时间"对象返回;调用方把它当作时区相关 Date 用,导致
* 严重误用(#52 / #91 / #166。本版本改为
* - 保留 getCurrentTimeInTimezone(tz) 作为兼容 wrapper返回原生 Date 即 UTC 时刻)
* - 新增 getNowInTimezone(tz) 返回结构体 {utc, parts, hourString, isoLocal}
* 强制调用方显式选择"我要的是 UTC 时刻"还是"用户 TZ 下的字段"
* - 新增 getDaysBetween(fromIso, toIso, tz) 基于"用户 TZ 各自零点"算整天数差,
* 修复"凌晨 08 点创建订阅默认日期变前一天"的 #166
* - 所有公开函数 JSDoc 标注 + 中文用途说明,从此可被 // @ts-check 守护
*
* 维护人v3 重构 (2026-05)
*/
function getCurrentTimeInTimezone(timezone = 'UTC') {
/** 一小时的毫秒数 */
export const MS_PER_HOUR = 1000 * 60 * 60;
/** 一天的毫秒数 */
export const MS_PER_DAY = MS_PER_HOUR * 24;
/**
* @typedef {Object} TimezoneDateParts 时区下的日期分量
* @property {number} year 年4 位整数)
* @property {number} month 月1-12
* @property {number} day 日1-31
* @property {number} hour 时0-23
* @property {number} minute 分0-59
* @property {number} second 秒0-59
*/
/**
* @typedef {Object} TimezoneNow 当前时刻在某时区下的完整快照
* @property {Date} utc 原生 DateUTC 时刻,等价于 new Date()
* @property {TimezoneDateParts} parts 该时刻在 timezone 下的年月日时分秒
* @property {string} hourString parts.hour 的两位字符串(如 "08"),调度器对比通知时段直接用它
* @property {string} isoLocal "YYYY-MM-DDTHH:mm:ss" 本地表示(不带时区后缀,用于展示)
* @property {string} timezone 实际生效的时区(无效时回退 'UTC'
*/
/**
* 判断字符串是否为 IANA 合法时区。
*
* @param {string} timezone
* @returns {boolean}
*/
export function isValidTimezone(timezone) {
if (typeof timezone !== 'string' || timezone.trim() === '') return false;
try {
return new Date();
} catch (error) {
console.error(`时区转换错误: ${error.message}`);
return new Date();
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
return true;
} catch {
return false;
}
}
function getTimestampInTimezone(timezone = 'UTC') {
return getCurrentTimeInTimezone(timezone).getTime();
/**
* 兜底获取一个安全可用的时区字符串。
*
* @param {string=} timezone 用户传入的时区
* @returns {string} 合法 IANA 时区,非法时返回 'UTC'
*/
function safeTimezone(timezone) {
if (timezone && isValidTimezone(timezone)) return timezone;
return 'UTC';
}
function convertUTCToTimezone(utcTime, timezone = 'UTC') {
try {
return new Date(utcTime);
} catch (error) {
console.error(`时区转换错误: ${error.message}`);
return new Date(utcTime);
/**
* 将一个 Date / ISO 字符串 / 时间戳分解为目标时区下的年月日时分秒。
*
* 内部用 Intl.DateTimeFormaten-US 12h=false解析无 DST/夏令时手算坑。
*
* @param {Date | string | number} date
* @param {string} [timezone='UTC']
* @returns {TimezoneDateParts}
*/
export function getTimezoneDateParts(date, timezone = 'UTC') {
const tz = safeTimezone(timezone);
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) {
// 无效输入,返回当前时间作为兜底
return getTimezoneDateParts(new Date(), tz);
}
}
function getTimezoneDateParts(date, timezone = 'UTC') {
try {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZone: tz,
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const parts = formatter.formatToParts(date);
const parts = formatter.formatToParts(d);
const pick = (type) => {
const part = parts.find(item => item.type === type);
const part = parts.find((item) => item.type === type);
return part ? Number(part.value) : 0;
};
let hour = pick('hour');
// Intl 在某些 runtime 把 24 显示为 0/24 不一致,归一化到 023
if (hour === 24) hour = 0;
return {
year: pick('year'),
month: pick('month'),
day: pick('day'),
hour: pick('hour'),
hour,
minute: pick('minute'),
second: pick('second')
};
} catch (error) {
console.error(`解析时区(${timezone})失败: ${error.message}`);
} catch {
// 极少数 runtime 不支持该时区,回退 UTC
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
second: date.getUTCSeconds()
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
hour: d.getUTCHours(),
minute: d.getUTCMinutes(),
second: d.getUTCSeconds()
};
}
}
function getTimezoneMidnightTimestamp(date, timezone = 'UTC') {
const { year, month, day } = getTimezoneDateParts(date, timezone);
return Date.UTC(year, month - 1, day, 0, 0, 0);
/**
* 获取"当前时刻"在指定时区下的完整快照。
*
* 业务代码请优先使用本函数而非 `getCurrentTimeInTimezone`
* 因为本函数明确告诉你:
* - utcUTC 原生 Date用于持久化、计算时间差
* - parts.hour你设置的时区下的当前小时用于通知时段判断
* - hourString直接拿来和 NOTIFICATION_HOURS 字符串数组比较
*
* @param {string} [timezone='UTC']
* @param {Date} [now] 可选注入当前时间(测试用)
* @returns {TimezoneNow}
*/
export function getNowInTimezone(timezone = 'UTC', now) {
const tz = safeTimezone(timezone);
const utc = now instanceof Date ? new Date(utcMillis(now)) : new Date();
const parts = getTimezoneDateParts(utc, tz);
const hourString = String(parts.hour).padStart(2, '0');
const isoLocal = formatPartsAsIsoLocal(parts);
return { utc, parts, hourString, isoLocal, timezone: tz };
}
function formatTimeInTimezone(time, timezone = 'UTC', format = 'full') {
try {
const date = new Date(time);
/**
* 获取指定时刻在某时区下的小时(两位字符串)。
*
* 调度器判断"现在是不是允许发送通知的小时"专用。
*
* @param {Date | string | number} [date]
* @param {string} [timezone='UTC']
* @returns {string} "00" "23"
*/
export function getTimezoneHourString(date, timezone = 'UTC') {
const d = date == null ? new Date() : date;
const parts = getTimezoneDateParts(d, timezone);
return String(parts.hour).padStart(2, '0');
}
/**
* 计算 from → to 在指定时区下"跨过几个本地零点"的整天数。
*
* 例:
* from = "2026-05-24T16:00:00Z" to = "2026-05-25T16:00:00Z" tz=UTC
* → 1 天
*
* from = "2026-05-24T16:00:00Z" to = "2026-05-25T16:00:00Z" tz=Asia/Shanghai
* → 1 天(本地 24:00 → 次日 00:00
*
* from = "2026-05-24T20:00:00Z" to = "2026-05-24T22:00:00Z" tz=Asia/Shanghai
* → 0 天(本地 04:00 → 06:00 同一天)
*
* 当 to < from 时返回负数。
*
* @param {Date | string | number} from
* @param {Date | string | number} to
* @param {string} [timezone='UTC']
* @returns {number}
*/
export function getDaysBetween(from, to, timezone = 'UTC') {
const tz = safeTimezone(timezone);
const fromMid = getTimezoneMidnightTimestamp(from, tz);
const toMid = getTimezoneMidnightTimestamp(to, tz);
return Math.round((toMid - fromMid) / MS_PER_DAY);
}
/**
* 计算指定时刻在某时区下的"零点"对应的 UTC 时间戳。
*
* 例date=2026-05-24T15:30:00Z, tz=Asia/Shanghai → 2026-05-24 23:30 北京时间
* → 该日北京零点 = 2026-05-24T16:00:00Z (因为北京 00:00 = UTC 前一天 16:00)
* → 返回 1748015200000
*
* @param {Date | string | number} date
* @param {string} [timezone='UTC']
* @returns {number} UTC ms 时间戳
*/
export function getTimezoneMidnightTimestamp(date, timezone = 'UTC') {
const tz = safeTimezone(timezone);
const { year, month, day } = getTimezoneDateParts(date, tz);
// 通过反推tz 下 (year,month,day) 0:00 对应的 UTC 时刻
// 算法:构造一个临时 UTC 时刻 t0 = Date.UTC(y,m-1,d), 求它在 tz 下的偏移分钟数 offsetMin,
// 则 tz 下零点的 UTC ms = t0 - offsetMin*60_000
const t0 = Date.UTC(year, month - 1, day, 0, 0, 0);
const probeParts = getTimezoneDateParts(new Date(t0), tz);
const probeAsUtc = Date.UTC(
probeParts.year,
probeParts.month - 1,
probeParts.day,
probeParts.hour,
probeParts.minute,
probeParts.second
);
const offsetMs = probeAsUtc - t0;
return t0 - offsetMs;
}
/**
* 把日期分量拼成 "YYYY-MM-DDTHH:mm:ss" 本地表示。
*
* @param {TimezoneDateParts} parts
* @returns {string}
*/
function formatPartsAsIsoLocal(parts) {
const pad = (n) => String(n).padStart(2, '0');
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`;
}
/**
* 把 Date 转成 UTC ms 整数(兼容 Date 与时间戳)。
*
* @param {Date | number} d
* @returns {number}
*/
function utcMillis(d) {
return d instanceof Date ? d.getTime() : Number(d);
}
/**
* 在指定时区下格式化日期。
*
* 不同 fmt 用于:
* - 'date' → "2026/05/24"
* - 'datetime' → "2026/05/24 17:30:00"
* - 'full'(默认)→ 带星期等本地化完整字符串
* - 'isoLocal' → "2026-05-24T17:30:00"(无时区后缀)
*
* @param {Date | string | number} time
* @param {string} [timezone='UTC']
* @param {'date'|'datetime'|'full'|'isoLocal'} [format='full']
* @returns {string}
*/
export function formatLocalDate(time, timezone = 'UTC', format = 'full') {
const tz = safeTimezone(timezone);
const d = time instanceof Date ? time : new Date(time);
if (Number.isNaN(d.getTime())) return '';
if (format === 'isoLocal') {
return formatPartsAsIsoLocal(getTimezoneDateParts(d, tz));
}
try {
if (format === 'date') {
return date.toLocaleDateString('zh-CN', {
timeZone: timezone,
return d.toLocaleDateString('zh-CN', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
} else if (format === 'datetime') {
return date.toLocaleString('zh-CN', {
timeZone: timezone,
}
if (format === 'datetime') {
return d.toLocaleString('zh-CN', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} else {
return date.toLocaleString('zh-CN', {
timeZone: timezone
second: '2-digit',
hour12: false
});
}
} catch (error) {
console.error(`时间格式化错误: ${error.message}`);
return new Date(time).toISOString();
return d.toLocaleString('zh-CN', { timeZone: tz });
} catch {
return d.toISOString();
}
}
function getTimezoneOffset(timezone = 'UTC') {
/**
* 同 formatLocalDate保留原 v2 命名以兼容老调用方。
*
* @param {Date | string | number} time
* @param {string} [timezone='UTC']
* @param {'date'|'datetime'|'full'|'isoLocal'} [format='full']
* @returns {string}
*/
export function formatTimeInTimezone(time, timezone = 'UTC', format = 'full') {
return formatLocalDate(time, timezone, format);
}
/**
* 计算时区相对 UTC 的整小时偏移量(夏令时下取当前时刻偏移)。
*
* @param {string} [timezone='UTC']
* @returns {number} 偏移小时数(如 +8 表示 UTC+8
*/
export function getTimezoneOffset(timezone = 'UTC') {
const tz = safeTimezone(timezone);
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}`);
const parts = getTimezoneDateParts(now, tz);
const zoned = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
);
// 用 `+ 0` 归一化 -0 为 +0避免 Object.is 比较时困扰
return Math.round((zoned - now.getTime()) / MS_PER_HOUR) + 0;
} catch {
return 0;
}
}
function formatTimezoneDisplay(timezone = 'UTC') {
/**
* 生成时区显示文本。
*
* 例formatTimezoneDisplay('Asia/Shanghai') → "中国标准时间 (UTC+8)"
*
* @param {string} [timezone='UTC']
* @returns {string}
*/
export function formatTimezoneDisplay(timezone = 'UTC') {
const tz = safeTimezone(timezone);
try {
const offset = getTimezoneOffset(timezone);
const offset = getTimezoneOffset(tz);
const offsetStr = offset >= 0 ? `+${offset}` : `${offset}`;
const timezoneNames = {
'UTC': '世界标准时间',
const names = {
UTC: '世界标准时间',
'Asia/Shanghai': '中国标准时间',
'Asia/Hong_Kong': '香港时间',
'Asia/Taipei': '台北时间',
@@ -132,59 +367,92 @@ function formatTimezoneDisplay(timezone = 'UTC') {
'Australia/Melbourne': '墨尔本时间',
'Pacific/Auckland': '奥克兰时间'
};
const timezoneName = timezoneNames[timezone] || timezone;
return `${timezoneName} (UTC${offsetStr})`;
} catch (error) {
console.error('格式化时区显示失败:', error);
return timezone;
const cn = names[tz] || tz;
return `${cn} (UTC${offsetStr})`;
} catch {
return tz;
}
}
function formatBeijingTime(date = new Date(), format = 'full') {
return formatTimeInTimezone(date, 'Asia/Shanghai', format);
/**
* 北京时间快捷格式化函数。
*
* @param {Date | string | number} [date=new Date()]
* @param {'date'|'datetime'|'full'|'isoLocal'} [format='full']
* @returns {string}
*/
export function formatBeijingTime(date = new Date(), format = 'full') {
return formatLocalDate(date, 'Asia/Shanghai', format);
}
function extractTimezone(request) {
const url = new URL(request.url);
const timezoneParam = url.searchParams.get('timezone');
/**
* 从请求中推断时区query > Header > Accept-Language。
*
* 注意v3 起前端展示用的是 config.TIMEZONE用户配置的时区
* 此函数主要用于 API 兼容场景。
*
* @param {Request} request
* @returns {string}
*/
export function extractTimezone(request) {
try {
const url = new URL(request.url);
const tzParam = url.searchParams.get('timezone');
if (tzParam && isValidTimezone(tzParam)) return tzParam;
if (timezoneParam) return timezoneParam;
const tzHeader = request.headers.get('X-Timezone');
if (tzHeader && isValidTimezone(tzHeader)) return tzHeader;
const timezoneHeader = request.headers.get('X-Timezone');
if (timezoneHeader) return timezoneHeader;
const acceptLanguage = request.headers.get('Accept-Language');
if (acceptLanguage) {
if (acceptLanguage.includes('zh')) return 'Asia/Shanghai';
if (acceptLanguage.includes('en-US')) return 'America/New_York';
if (acceptLanguage.includes('en-GB')) return 'Europe/London';
const accept = request.headers.get('Accept-Language') || '';
if (accept.includes('zh')) return 'Asia/Shanghai';
if (accept.includes('en-US')) return 'America/New_York';
if (accept.includes('en-GB')) return 'Europe/London';
} catch {
/* noop */
}
return 'UTC';
}
function isValidTimezone(timezone) {
try {
new Date().toLocaleString('en-US', { timeZone: timezone });
return true;
} catch (error) {
return false;
}
// ─────────────────────────────────────────────────────────────
// 兼容层(仅供旧调用方使用,新代码请用上面的 getNowInTimezone
// ─────────────────────────────────────────────────────────────
/**
* 兼容老调用:返回当前 UTC 时刻的 Date。
*
* 老 API 名字误导("InTimezone"),但语义就是"当前时刻"。
* 后续 Task 会把所有调用方迁移到 getNowInTimezone。
*
* @param {string} [timezone='UTC']
* @returns {Date}
*/
export function getCurrentTimeInTimezone(timezone = 'UTC') {
void timezone; // 仅占位保持签名Date 本身就是 UTC 时刻
return new Date();
}
export {
MS_PER_HOUR,
MS_PER_DAY,
getCurrentTimeInTimezone,
getTimestampInTimezone,
convertUTCToTimezone,
getTimezoneDateParts,
getTimezoneMidnightTimestamp,
formatTimeInTimezone,
getTimezoneOffset,
formatTimezoneDisplay,
formatBeijingTime,
extractTimezone,
isValidTimezone
};
/**
* 兼容老调用:返回当前 UTC ms 时间戳。
*
* @param {string} [timezone='UTC']
* @returns {number}
*/
export function getTimestampInTimezone(timezone = 'UTC') {
void timezone;
return Date.now();
}
/**
* 兼容老调用:把 UTC 时刻"转换到"目标时区。
*
* 注意这是个语义陷阱——Date 本身永远是 UTC 时刻(绝对时刻),
* "转到"另一个时区只影响显示,不影响 Date 实例。本函数仅返回原 Date 拷贝。
*
* @param {Date | string | number} utcTime
* @param {string} [timezone='UTC']
* @returns {Date}
*/
export function convertUTCToTimezone(utcTime, timezone = 'UTC') {
void timezone;
return utcTime instanceof Date ? new Date(utcTime.getTime()) : new Date(utcTime);
}

316
tests/core/time.test.js Normal file
View File

@@ -0,0 +1,316 @@
// @ts-check
/**
* 时区核心模块单元测试
*
* 覆盖范围:
* - getNowInTimezone注入式时间 + 各时区分量
* - getTimezoneHourString调度器通知时段判断主路径
* - getDaysBetween跨零点 / 跨夏令时 / #166 场景
* - getTimezoneMidnightTimestamp用户 TZ 零点反推
* - formatLocalDate4 种格式
* - 向后兼容 wrappergetCurrentTimeInTimezone / convertUTCToTimezone
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
MS_PER_DAY,
MS_PER_HOUR,
isValidTimezone,
getTimezoneDateParts,
getNowInTimezone,
getTimezoneHourString,
getDaysBetween,
getTimezoneMidnightTimestamp,
formatLocalDate,
formatTimeInTimezone,
formatBeijingTime,
formatTimezoneDisplay,
getTimezoneOffset,
getCurrentTimeInTimezone,
getTimestampInTimezone,
convertUTCToTimezone,
extractTimezone
} from '../../src/core/time.js';
describe('isValidTimezone', () => {
it('合法 IANA 时区返回 true', () => {
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('Asia/Shanghai')).toBe(true);
expect(isValidTimezone('America/New_York')).toBe(true);
});
it('非法字符串返回 false', () => {
expect(isValidTimezone('FooBar/Baz')).toBe(false);
expect(isValidTimezone('')).toBe(false);
expect(isValidTimezone(/** @type {any} */ (null))).toBe(false);
});
});
describe('getTimezoneDateParts', () => {
it('UTC 时区下 UTC 时刻分量正确', () => {
const d = new Date('2026-05-24T03:30:45.000Z');
expect(getTimezoneDateParts(d, 'UTC')).toEqual({
year: 2026,
month: 5,
day: 24,
hour: 3,
minute: 30,
second: 45
});
});
it('Asia/Shanghai 比 UTC 快 8 小时(夏令时无影响)', () => {
const d = new Date('2026-05-24T16:00:00.000Z'); // UTC 16:00 = 北京 24:00 = 5/25 00:00
const parts = getTimezoneDateParts(d, 'Asia/Shanghai');
expect(parts.year).toBe(2026);
expect(parts.month).toBe(5);
expect(parts.day).toBe(25);
expect(parts.hour).toBe(0);
});
it('America/New_York 夏令时5 月)差 4 小时', () => {
const d = new Date('2026-05-24T16:00:00.000Z'); // UTC 16:00 = NYC 12:00 (DST)
const parts = getTimezoneDateParts(d, 'America/New_York');
expect(parts.day).toBe(24);
expect(parts.hour).toBe(12);
});
it('America/New_York 冬令时1 月)差 5 小时', () => {
const d = new Date('2026-01-15T16:00:00.000Z'); // UTC 16:00 = NYC 11:00 (no DST)
const parts = getTimezoneDateParts(d, 'America/New_York');
expect(parts.day).toBe(15);
expect(parts.hour).toBe(11);
});
it('非法时区回退 UTC 不抛异常', () => {
const d = new Date('2026-05-24T03:30:45.000Z');
const parts = getTimezoneDateParts(d, 'Foo/Bar');
expect(parts.year).toBe(2026);
expect(parts.hour).toBe(3); // 走兜底路径
});
});
describe('getNowInTimezone业务主入口', () => {
it('注入特定时刻UTC 0 点 + Asia/Shanghai → 北京 8 点', () => {
const fixed = new Date('2026-05-24T00:00:00.000Z');
const now = getNowInTimezone('Asia/Shanghai', fixed);
expect(now.utc.toISOString()).toBe('2026-05-24T00:00:00.000Z');
expect(now.parts).toEqual({ year: 2026, month: 5, day: 24, hour: 8, minute: 0, second: 0 });
expect(now.hourString).toBe('08');
expect(now.isoLocal).toBe('2026-05-24T08:00:00');
expect(now.timezone).toBe('Asia/Shanghai');
});
it('UTC 23:30 + Asia/Shanghai → 次日 07:30', () => {
const fixed = new Date('2026-05-24T23:30:00.000Z');
const now = getNowInTimezone('Asia/Shanghai', fixed);
expect(now.parts.day).toBe(25);
expect(now.hourString).toBe('07');
});
it('未注入时间时取 new Date()', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-12-31T16:00:00.000Z'));
try {
const now = getNowInTimezone('Asia/Shanghai');
expect(now.parts.year).toBe(2027);
expect(now.parts.month).toBe(1);
expect(now.parts.day).toBe(1);
expect(now.hourString).toBe('00');
} finally {
vi.useRealTimers();
}
});
});
describe('getTimezoneHourString调度器通知时段比对', () => {
it('始终返回两位字符串', () => {
const d = new Date('2026-05-24T01:00:00.000Z');
expect(getTimezoneHourString(d, 'UTC')).toBe('01');
expect(getTimezoneHourString(d, 'Asia/Shanghai')).toBe('09');
});
it('00 / 23 边界', () => {
expect(getTimezoneHourString(new Date('2026-05-24T00:00:00Z'), 'UTC')).toBe('00');
expect(getTimezoneHourString(new Date('2026-05-24T23:00:00Z'), 'UTC')).toBe('23');
});
});
describe('getTimezoneMidnightTimestamp', () => {
it('UTC 时区下:等价 Date.UTC(y,m-1,d)', () => {
const ts = getTimezoneMidnightTimestamp(new Date('2026-05-24T15:00:00Z'), 'UTC');
expect(ts).toBe(Date.UTC(2026, 4, 24));
});
it('Asia/Shanghai北京零点 = UTC 前一天 16:00', () => {
const ts = getTimezoneMidnightTimestamp(new Date('2026-05-24T15:00:00Z'), 'Asia/Shanghai');
expect(new Date(ts).toISOString()).toBe('2026-05-23T16:00:00.000Z');
});
it('Asia/Shanghai 跨 UTC 日界UTC 18:00 → 北京次日 02:00 → 当日零点 = 当日 UTC 16:00', () => {
const ts = getTimezoneMidnightTimestamp(new Date('2026-05-24T18:00:00Z'), 'Asia/Shanghai');
expect(new Date(ts).toISOString()).toBe('2026-05-24T16:00:00.000Z');
});
});
describe('getDaysBetween剩余天数计算 / #166 修复)', () => {
it('同一 UTC 日的不同时刻 → 0 天', () => {
expect(
getDaysBetween('2026-05-24T01:00:00Z', '2026-05-24T23:59:00Z', 'UTC')
).toBe(0);
});
it('跨 UTC 日 → 1 天', () => {
expect(
getDaysBetween('2026-05-24T23:30:00Z', '2026-05-25T00:30:00Z', 'UTC')
).toBe(1);
});
it('Asia/ShanghaiUTC 17:00 与 UTC 23:00 同一北京日 → 0 天', () => {
expect(
getDaysBetween('2026-05-24T17:00:00Z', '2026-05-24T23:00:00Z', 'Asia/Shanghai')
).toBe(0);
});
it('Asia/ShanghaiUTC 15:00 与 UTC 17:00 跨北京日 → 1 天', () => {
// UTC 15:00 = 北京 23:00 (5/24)
// UTC 17:00 = 北京 01:00 (5/25)
expect(
getDaysBetween('2026-05-24T15:00:00Z', '2026-05-24T17:00:00Z', 'Asia/Shanghai')
).toBe(1);
});
it('#166 场景UTC 凌晨 02:00北京 10:00创建订阅期望"今天"是北京 5/24 而非 UTC 5/24', () => {
// 用户在北京时间 2026-05-24 10:00 创建订阅,开始日期取"今日"
// 此时 UTC 是 2026-05-24 02:00
const utcNow = new Date('2026-05-24T02:00:00Z');
const userTzNow = getNowInTimezone('Asia/Shanghai', utcNow);
expect(userTzNow.parts.day).toBe(24);
expect(userTzNow.parts.hour).toBe(10);
// 选一个明显落在北京 5/27 中段的到期时刻(北京 5/27 14:00 = UTC 06:00
const expiry = new Date('2026-05-27T06:00:00Z');
expect(getDaysBetween(utcNow, expiry, 'Asia/Shanghai')).toBe(3);
});
it('#166 边界场景UTC 23:30北京次日 07:30创建订阅"今日"应是次日北京日期', () => {
const utcNow = new Date('2026-05-23T23:30:00Z');
const userTzNow = getNowInTimezone('Asia/Shanghai', utcNow);
expect(userTzNow.parts.day).toBe(24); // 北京 5/24
expect(userTzNow.parts.hour).toBe(7);
});
it('to 早于 from 时返回负数', () => {
expect(
getDaysBetween('2026-05-25T00:00:00Z', '2026-05-23T00:00:00Z', 'UTC')
).toBe(-2);
});
});
describe('formatLocalDate', () => {
const utcInstant = '2026-05-24T03:30:45.000Z';
it('format=date 返回本地日期', () => {
const s = formatLocalDate(utcInstant, 'Asia/Shanghai', 'date');
expect(s).toContain('2026');
expect(s).toContain('05'); // 11:30 北京 5/24
});
it('format=datetime 包含时分秒', () => {
const s = formatLocalDate(utcInstant, 'Asia/Shanghai', 'datetime');
expect(s).toContain('11');
expect(s).toContain('30');
});
it('format=isoLocal 返回 YYYY-MM-DDTHH:mm:ss 无时区后缀', () => {
const s = formatLocalDate(utcInstant, 'Asia/Shanghai', 'isoLocal');
expect(s).toBe('2026-05-24T11:30:45');
});
it('无效输入返回空串', () => {
expect(formatLocalDate('not a date', 'UTC', 'date')).toBe('');
});
it('formatTimeInTimezone 与 formatLocalDate 等价(兼容老调用)', () => {
expect(formatTimeInTimezone(utcInstant, 'UTC', 'isoLocal')).toBe('2026-05-24T03:30:45');
});
it('formatBeijingTime 走 Asia/Shanghai', () => {
expect(formatBeijingTime(utcInstant, 'isoLocal')).toBe('2026-05-24T11:30:45');
});
});
describe('getTimezoneOffset & formatTimezoneDisplay', () => {
it('UTC 偏移 0', () => {
expect(getTimezoneOffset('UTC')).toBe(0);
});
it('Asia/Shanghai 偏移 +8', () => {
expect(getTimezoneOffset('Asia/Shanghai')).toBe(8);
});
it('formatTimezoneDisplay 包含中文名 + 偏移', () => {
const s = formatTimezoneDisplay('Asia/Shanghai');
expect(s).toContain('中国');
expect(s).toContain('+8');
});
});
describe('extractTimezone', () => {
it('?timezone=Asia/Tokyo 优先级最高', () => {
const req = new Request('https://x/?timezone=Asia/Tokyo', {
headers: { 'X-Timezone': 'UTC', 'Accept-Language': 'en-US' }
});
expect(extractTimezone(req)).toBe('Asia/Tokyo');
});
it('Header X-Timezone 次之', () => {
const req = new Request('https://x/', {
headers: { 'X-Timezone': 'Asia/Tokyo', 'Accept-Language': 'en-US' }
});
expect(extractTimezone(req)).toBe('Asia/Tokyo');
});
it('Accept-Language zh → Asia/Shanghai', () => {
const req = new Request('https://x/', { headers: { 'Accept-Language': 'zh-CN' } });
expect(extractTimezone(req)).toBe('Asia/Shanghai');
});
it('无任何提示 → UTC', () => {
expect(extractTimezone(new Request('https://x/'))).toBe('UTC');
});
it('非法 ?timezone 被忽略,回退到 Header / Accept-Language', () => {
const req = new Request('https://x/?timezone=Foo/Bar', {
headers: { 'Accept-Language': 'zh' }
});
expect(extractTimezone(req)).toBe('Asia/Shanghai');
});
});
describe('向后兼容 wrapper', () => {
it('getCurrentTimeInTimezone 返回 Date 实例UTC 时刻)', () => {
const d = getCurrentTimeInTimezone('Asia/Shanghai');
expect(d).toBeInstanceOf(Date);
expect(Math.abs(d.getTime() - Date.now())).toBeLessThan(1000);
});
it('getTimestampInTimezone 返回数字时间戳', () => {
const t = getTimestampInTimezone('UTC');
expect(typeof t).toBe('number');
expect(Math.abs(t - Date.now())).toBeLessThan(1000);
});
it('convertUTCToTimezone 返回的 Date 与原始相同 UTC 时刻', () => {
const orig = new Date('2026-05-24T03:30:00Z');
const converted = convertUTCToTimezone(orig, 'Asia/Shanghai');
expect(converted.getTime()).toBe(orig.getTime());
expect(converted).not.toBe(orig); // 拷贝
});
});
describe('常量', () => {
it('MS_PER_HOUR 与 MS_PER_DAY', () => {
expect(MS_PER_HOUR).toBe(3600_000);
expect(MS_PER_DAY).toBe(86_400_000);
});
});