mirror of
https://github.com/wangwangit/SubsTracker.git
synced 2026-09-03 07:25:02 +08:00
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:
120
src/api/debug.js
120
src/api/debug.js
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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, '<').replace(/>/g, '>')}</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' }
|
||||
});
|
||||
|
||||
478
src/core/time.js
478
src/core/time.js
@@ -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 各自零点"算整天数差,
|
||||
* 修复"凌晨 0–8 点创建订阅默认日期变前一天"的 #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 原生 Date(UTC 时刻,等价于 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.DateTimeFormat(en-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 不一致,归一化到 0–23
|
||||
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`,
|
||||
* 因为本函数明确告诉你:
|
||||
* - utc:UTC 原生 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
316
tests/core/time.test.js
Normal file
@@ -0,0 +1,316 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* 时区核心模块单元测试
|
||||
*
|
||||
* 覆盖范围:
|
||||
* - getNowInTimezone:注入式时间 + 各时区分量
|
||||
* - getTimezoneHourString:调度器通知时段判断主路径
|
||||
* - getDaysBetween:跨零点 / 跨夏令时 / #166 场景
|
||||
* - getTimezoneMidnightTimestamp:用户 TZ 零点反推
|
||||
* - formatLocalDate:4 种格式
|
||||
* - 向后兼容 wrapper:getCurrentTimeInTimezone / 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/Shanghai:UTC 17:00 与 UTC 23:00 同一北京日 → 0 天', () => {
|
||||
expect(
|
||||
getDaysBetween('2026-05-24T17:00:00Z', '2026-05-24T23:00:00Z', 'Asia/Shanghai')
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('Asia/Shanghai:UTC 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user