时间计算三连坑:错误的校准锚点、被误读的经典公式、消失的一小时
我们站上有个五行排盘小工具,输入出生日期时间输出生辰八字。前几天收到用户反馈:
1988-05-11 08:00 出生,正常应该是 戊辰 丁巳 丙寅 壬辰,你们算成了 戊寅 和 丙辰。
年柱月柱对、日柱时柱错。顺着这条线索排查下去,最终挖出了三个互相独立的时间计算坑——每一个单拎出来都够写一条事故记录。这篇文章按发现顺序完整复盘,最后沉淀几条可以带走的方法论。
坑一:日柱错 2 个天干——因为"校准用例"本身是错的
八字的日柱是纯算术:60 甲子逐日循环,与天文无关。实现上就是「距某个基准日的天数 + 偏移量,对 60 取模」:
function getDayGanZhiIndex(year: number, month: number, day: number): number {
return mod(daysSinceReference(year, month, day) + 22, 60);
}
这个 +22 的注释写着:"已用已知案例(1990-05-15 应为壬辰日)校准"。
问题在于:1990-05-15 根本不是壬辰日,是庚辰日。 用一个错误的"已知案例"做校准,等于把整条日柱时间轴平移了 12 个甲子序号——所有日期的日柱天干集体错 2 位,而且因为地支恰好不变(12 的整数倍),错得非常隐蔽。
时柱跟着错则是连带伤害:时柱天干由日干按五鼠遁推算,日干错,时干必错。用户看到的"两个错误",根因只有一个。
修复方式不是换一个"正确用例"重新校准,而是换校准方法——用多个可考证的史料锚点交叉验证:
| 日期 | 公认日柱 | 依据 |
|---|---|---|
| 1949-10-01 | 甲子 | 开国大典当日,史料广泛记载 |
| 2000-01-01 | 戊午 | 各天文年历一致 |
| 1900-01-01 | 甲戌 | 万年历基准年 |
三个锚点两两之间的天数差与甲子序号差完全自洽,才能确认偏移量应为 +10。单点校准的问题在于:你无法区分"用例错了"和"公式错了";两个以上互相独立的锚点才有纠错能力。
坑二:节气边界偏晚 6.5 小时——两个 bug 叠加,方向相反
修完日柱本以为结束了,用户接着问了一句:"时间计算这么重要,你们的计算方式靠谱吗?"于是我们对年柱/月柱的根基——24 节气交节时刻——做了一轮精度实测,拿计算值对比天文年历的权威值:
| 节气 | 我们的计算值 | 权威值 | 偏差 |
|---|---|---|---|
| 2024 立春 | 02-04 22:39 | 02-04 16:27 | +372 分钟 |
| 2025 立春 | 02-04 04:27 | 02-03 22:10 | +377 分钟 |
| 1988 立春 | 02-05 05:23 | 02-04 22:43 | +400 分钟 |
| 2024 大雪 | 12-07 05:44 | 12-06 23:17 | +387 分钟 |
所有节气边界系统性偏晚约 6.5 小时。 八字以立春换年柱、以十二节换月柱,这意味着任何一个"节"交节后 6.5 小时内出生的人,月柱会错一个月;立春附近的还会错年柱。
拆开看是两个 bug 叠加,而且方向相反:
Bug A:时区语义被误读(+8 小时)。 我们用的是流传了二十多年的经典万年历节气公式:
// 基准:1900-01-06 02:05 小寒
31556925974.7 * (year - 1900) + TERM_OFFSETS[i] * 60000 + Date.UTC(1900, 0, 6, 2, 5)
注意那个 Date.UTC(1900, 0, 6, 2, 5)——它的本义是"北京时间 1900 年 1 月 6 日 02:05,借用 UTC 字段存储"。原始算法配套的读取方式是 getUTCDate(),取出来的直接就是中国日历的日期。这是一种"墙上时间装进 UTC 容器"的老式惯用法。我们的代码把这个值当成了真正的 UTC,去和真正的 UTC 时间戳比较——固定引入 8 小时偏差。
Bug B:线性外推的世纪级漂移(−1.3 到 −1.8 小时)。 公式假设每个回归年严格等长(31556925974.7 毫秒),从 1900 年线性外推。但节气的实际间隔受轨道摄动影响并不均匀,离基准年越远误差越大——1988 到 2025 年实测早了 80 到 110 分钟,且仍在增长。
两个 bug 一正一负,叠加后剩 +6.2~6.7 小时。这也是它能潜伏这么久的原因:如果只有 8 小时那个 bug,边界错到大半天,早就有人发现了。两个错误互相抵消掉一部分,把问题压到了"多数人测不到、少数人测到了也说不清"的区间。
解法:预生成天文精度表,运行时零依赖
节气本质是"太阳到达特定黄经的时刻",正确做法是用天文算法。我们没有把天文库引入运行时,而是折中:
- 引入
tyme4ts(寿星天文历法的 TS 实现)作为 devDependency; - 写一个生成脚本,把 1899–2101 年共 203 年 × 24 节气的精确交节时刻预生成为一张分钟级静态表(34KB)入库;
- 运行时查表,零运行时依赖,客户端 bundle 不受影响。
生成脚本里有两道防呆断言,都来自实际踩坑:
- 年份归属断言:tyme4ts 以冬至为岁首,
SolarTerm.fromName(2024, "冬至")返回的是 2023 年 12 月的冬至。想要公历 2024 年内的冬至,得传 2025。不逐条断言"每个节气落在预期的公历月份",这种坑会静默产出一张错表。 - 单调性断言:全表 4872 个时刻必须严格递增。
验证环节做了三层:权威值抽查(误差 ±15 秒内)、立春前后 5 分钟年柱翻转测试、以及与 tyme4ts 直接排盘做 500 个随机时刻的四柱互证——499 个完全一致,唯一不一致发生在 1916 年惊蛰交节的那一分钟之内(我们的表是分钟粒度,权威时刻是 05:37:21,输入 05:37 落在亚分钟歧义区)。输入本身就是分钟粒度,这个歧义不可消除,记录在案即可。
坑三:消失的一小时——1986–1991 年中国夏令时
第三个坑和算法无关,是史实:1986 至 1991 年中国实行过夏令时,每年 4 月中旬到 9 月中旬钟表拨快 1 小时。这几年夏天出生的人,出生证上的钟表时间比北京标准时间快 1 小时——专业排盘前要先减掉。
开头那位用户的 1988-05-11 恰好落在夏令时期间。钟表 08:00,标准时间其实是 07:00。
这里还有个嵌套的小坑:夏令时起始日期不能按"四月中旬第一个星期日"的字面规则推。按字面推 1988 年会得出 4 月 17 日,实际是 4 月 10 日——真实执行口径与 tzdata Asia/Shanghai 的规则一致:Apr Sun>=10(4 月 10 日起的第一个星期日)。我们最终把六段区间硬编码,来源用维基百科、百度百科、tzdata 三方交叉核实:
1986-05-04 ~ 09-14(首年特殊) 1989-04-16 ~ 09-17
1987-04-12 ~ 09-13 1990-04-15 ~ 09-16
1988-04-10 ~ 09-11 1991-04-14 ~ 09-15
均为凌晨 2 时切换。回拨日 01:00–01:59 会出现两次,属于不可消解的二义时段,我们选择按夏令时处理并写进注释。
尾声:三层校正叠加后,"正确答案"变了
三个坑修完,用户那条 URL 的输出反而和他的预期不一样了——因为校正是叠加的:
钟表 08:00 →(夏令时 −1h)→ 标准时 07:00 →(保定真太阳时 −14min)→ 06:46 → 卯时
时柱从壬辰变成了辛卯。用户参考的工具给出壬辰,说明它没有叠加真太阳时(或夏令时)——这属于流派差异,不是谁算错了。我们的处理方式是把每一步换算摆在报告页面上:夏令时换算一行、真太阳时校正卡片一张、临近时辰边界再加一条警告(06:46 离 07:00 的卯辰边界只有 14 分钟)。用户有完整信息自行判断,而不是拿到一个无法追问的黑盒结果。
能带走的几条方法论
- 校准常量必须用两个以上互相独立、可考证的锚点交叉验证。 单点校准无法区分"用例错了"和"公式错了"。
- 借用别人的经典公式前,先搞清它的时间语义。 "墙上时间装进 UTC 容器"是老代码的常见惯用法,断章取义地取一半就会引入整时区的偏差。
- 警惕互相抵消的双 bug。 误差比预期小不等于没有 bug,可能是两个大 bug 打了个折。分解验证每一层,而不是只看端到端结果。
- 历法、天文类数据优先"构建期预生成 + 运行时查表"。 精度来自天文库,运行时零依赖,还能对生成结果做全量断言。
- 涉及历史时间的系统要记得夏令时、时区改制这类史实,而且史实日期要用 tzdata 这类工程化数据源核对,别背字面规则。
- 有流派差异的领域,把中间步骤全部展示给用户。 透明比"权威"更能建立信任。
文中所有偏差数据均为本站修复过程中的实测值;排盘工具现已上线全部修复,见 五行排盘。