阅读主题
持续时间与日历运算
用户在晚上十一点开通“七天会员”,七天后的晚上十一点到期,还是第七天结束时到期?两种安排都能实现,但提供的权益不同。上一节区分了持续时间与日历量,这一节把这种区别落实到具体的截止计算,再讨论每月续费和续期起点。
1.4 持续时间与日历运算
假设会员在北京时间 2027 年 3 月 1 日 23 点生效。“七天”至少可以有三种约定:
- 连续 168 小时:从生效瞬间增加固定长度,截止为 3 月 8 日 23 点。
- 生效当天算第一天,到第七天结束:3 月 1 日至 7 日共七个日期,截止为 3 月 8 日零点。
- 七个日历日后的同一当地时刻:日期从 3 月 1 日推进到 8 日,保留 23 点,再按业务时区确定截止。
第二种约定中,第一天只剩一个小时,却仍算一天,所以比第一种少了 23 小时。这种差异不需要夏令时就会出现。第一种与第三种在本例中结果相同,是因为这一段时间的时区偏移没有变化;换一个地区和日期,它们也可能分开。
计算之前,应先说明“七天”保持的是经过的小时数、覆盖的日期数,还是日历推进后的钟表读数。Java 的 Duration.ofDays(7) 将一天固定按 24 小时处理,得到 168 小时;Period.ofDays(7) 表达七个日历日,本身不指定对应多少小时。Java Duration、Java Period
1.4.1 从日界推导一天的长度
先看一个完整自然日。它从当地某个日期的开始延续到下一日期的开始。要知道这一天经过多少小时,应将两端分别转换成瞬间,再计算差值;不能先把答案定成 24 小时,再从起点加过去。
上一节已经说明,纽约在 2025 年 3 月 9 日发生春季跳时。当天开始时采用 -05:00,次日开始时已采用 -04:00。把两个日界都换成 UTC,就能直接相减:
text
当地日期的开始 同一瞬间的 UTC 表示
2025-03-09T00:00-05:00 = 2025-03-09T05:00Z
2025-03-10T00:00-04:00 = 2025-03-10T04:00Z
从 3 月 9 日 05:00 到 3 月 10 日 04:00:23 小时当地日期从 9 日变成了 10 日,钟表却跳过了两点到三点这一段读数。再看秋季回拨:11 月 2 日开始时采用 -04:00,次日开始时改为 -05:00:
text
2025-11-02T00:00-04:00 = 2025-11-02T04:00Z
2025-11-03T00:00-05:00 = 2025-11-03T05:00Z
从 11 月 2 日 04:00 到 11 月 3 日 05:00:25 小时这次钟表从两点拨回一点,一点到两点经历了两次,所以多出一个小时。图 1-2 展示这两次跳变;具体日期与规则可核对 NIST 夏令时说明。
图 1-2:春季跳过一段当地读数,使当天少一小时;秋季重复一段读数,使当天多一小时。图中使用纽约 2025 年的两次调整,横向不按比例。
两次计算遵循同一关系:当地两个午夜在日历表示上相隔一天,但转换为 UTC 时,各自要减去对应的偏移量。结束端的偏移量比开始端多一小时,得到的间隔就少一小时;偏移量少一小时,间隔就多一小时。自然日的长度由相邻日界对应的瞬间决定。 23 和 25 小时是纽约这两天的结果,不能把一小时调整当作所有地区和历史时期的共同规则。日界本身发生跳变的情况,将在 1.5 节展开。
同样的区别也适用于“明天同一时刻”。从纽约时间 2025 年 3 月 8 日中午十二点出发:
text
起点: 3 月 8 日 12:00-05:00 = 3 月 8 日 17:00Z
日历推进一天: 3 月 9 日 12:00-04:00 = 3 月 9 日 16:00Z(经过 23 小时)
增加 24 小时: 3 月 9 日 13:00-04:00 = 3 月 9 日 17:00Z(经过 24 小时)前一种计算保留了当地十二点,后一种保留了经过 24 小时。Java 的 ZonedDateTime.plusDays(1) 按当地日期时间推进,plusHours(24) 按时间轴增加长度,因此会得到上面的两个结果。如果推进后的当地时刻落入缺口或重叠,库还会应用默认解析行为;业务是否接受这种行为,需要另行决定,1.6 节会详细讨论。Java 按日与按小时运算
因此,设备租赁若承诺连续使用 24 小时,就应增加固定持续时间;权益若覆盖某个自然日,则应计算下一日期的开始。验证时也要检查承诺本身:前者检查两端相差的时长,后者检查当地日期边界。只用没有偏移变化的日期测试,可能让两种算法都通过。
1.4.2 月末截断为什么会丢失锚点
按日历增加一个月还会遇到另一种变化:月份长度不同。假设订阅在北京时间 2027 年 1 月 31 日上午十点生效,约定以后每月按原日号续费,目标月没有该日号时使用月末,续费时刻仍为上午十点。
二月没有 31 日,因此第一次续费落在 2 月 28 日。接下来若直接给这个结果再加一个月,就会得到 3 月 28 日;但按原来的 31 日计算,三月明明有 31 日,应恢复到这一天。
问题在于,2 月 28 日只能说明上一次算到了哪里,不能说明为什么算到这里。1 月 28、29、30、31 日分别加一个月,在“缺日取月末”的规则下都会得到 2 月 28 日。多个输入变成同一个结果,原始日号就丢失了。这种将超出目标月的日号压到月末的处理,称为月末截断。
表 1-3:连续使用上次结果与保留原始锚点的区别。年份均为 2027 年,各边界均为北京时间上午十点;期索引 0 表示初始边界。
| 期索引 n | 从上次结果继续加月 | 从原始 1 月 31 日锚点推算 |
|---|---|---|
| 0 | 01-31 | 01-31 |
| 1 | 02-28 | 02-28 |
| 2 | 03-28 | 03-31 |
| 3 | 04-28 | 04-30 |
Java 的 LocalDate.plusMonths 在目标月份没有原日号时使用该月最后有效日。因此,从 1 月 31 日连续两次加一个月,得到 3 月 28 日;从原始日期直接加两个月,得到 3 月 31 日。两次调用都符合库的规则,差别在于第二次使用了哪个输入。Java 月份运算
为了兑现“每月按原日号”的承诺,需要保留最初的日期作为锚点,并用期索引表示距起始月份有多少个月。每一期都从原始锚点计算,不能让截断后的结果替代原始日号。 代码可以直接表达这个过程:
java
static LocalDate boundaryFromAnchor(LocalDate anchor, long periodIndex) {
YearMonth target = YearMonth.from(anchor).plusMonths(periodIndex);
int day = Math.min(anchor.getDayOfMonth(), target.lengthOfMonth());
return target.atDay(day);
}函数先用起始年月和期索引求目标月份,再比较原始日号与目标月天数,取较小值。比如期索引为 2 时,目标是三月,原日号 31 没有超出三月天数,结果就是 3 月 31 日。计算不读取二月的结果,因此不会继承二月的截断。
这个函数只生成日期。调用方还需补上约定的上午十点和业务时区,才能得到执行瞬间;正向生成账期时,应限制期索引为非负,并处理超出日期可表示范围的输入。它适用于本例的原日号规则,不能直接代替“始终月末”等其他规则。
验证也应覆盖一次截断之后发生的事。只检查 1 月 31 日到 2 月 28 日,两种模型都能通过;继续检查三月恢复到 31 日、四月退到 30 日,才能识别是否保留了原始日号。跨到闰年的二月时,还应按该年实际月长得到 29 日,而非固定使用 28 日。
1.4.3 将日历策略与续期政策分开
“缺少原日号时取月末”和“始终在月末”是两种不同策略。若订阅在 2027 年 4 月 30 日生效,前者的下次日期是 5 月 30 日,后者则是 5 月 31 日。单看起始日期,两种规则都说得通;因此,是否始终月末应是明确的业务选择,不能从“初始日期恰好是月末”推断出来。
年度订阅也有同样的问题。2024 年 2 月 29 日生效,平年采用 2 月末还是 3 月 1 日,需要约定。即使选择 2 月末,如果每次从上次调整后的日期再加一年,到 2028 年仍可能停在 2 月 28 日。要在闰年恢复 29 日,就必须保留原始月日。这与每月恢复 31 日遵循同一个道理。
这些策略解决的是“给定起点,怎么算”。续期还要确定“从哪个起点算”。假设另一种会员每次续费增加连续 168 小时,当前截止为北京时间 2027 年 7 月 10 日上午十点。用户在 7 月 8 日上午十点续费:如果从续费时刻增加,截止是 7 月 15 日上午十点;如果从原截止增加,则是 7 月 17 日上午十点。前一种算法会让用户损失还没用完的两天。
若产品承诺保留剩余权益,会员仍有效时就从原截止继续增加;若会员已于 7 月 10 日到期,用户到 7 月 12 日上午十点才续费,并约定立即重新生效,则应从这次生效时刻增加,得到 7 月 19 日上午十点。对于这类会员,起点可以取原截止与本次生效时刻中较晚的一个,再增加固定长度。
这个选择只适用于上述续期承诺。固定每月账期的订阅可能仍按原账单日生成边界;用户晚付款如何处理属于另一项政策,不能自动把付款日改成新锚点。暂停、赠送和人工调整也应说明改的是起算依据、计算规则,还是最终截止,并记录调整原因。否则系统虽然知道何时到期,却无法解释为什么到这一天。
起算依据与计算规则共同决定截止。 保留了锚点,仍可能选错日历策略;选对了增加时长的算法,也可能因为从付款时刻起算而损失剩余权益。接下来把日历边界用于日报:确定了要统计哪一天,怎样选出属于这一天的记录?