时间筛选看起来只是两个参数:开始时间和结束时间。真正进入生产环境后,它往往会变成最容易出现隐性错误的模块之一。
同一个接口可能同时接收日期、带时分秒的时间、本地时区时间和 UTC 时间。如果系统没有统一的解析规则,就容易出现结束边界遗漏、夏令时偏移、跨天查询不一致等问题。本文用 Python 标准库和 pytest 实现一个可测试的时间区间解析器。
1. 先明确区间语义
时间区间常见的语义有两种:
- 闭区间 start, end:开始和结束时刻都包含在结果中;
- 左闭右开区间 [start, end):包含开始时刻,不包含结束时刻。
后端系统更适合使用左闭右开区间。它能自然拼接相邻窗口,并避免同一条记录在两个窗口中重复出现。例如:
text
[2026-08-01 00:00, 2026-08-02 00:00)
[2026-08-02 00:00, 2026-08-03 00:00)
两个窗口没有重叠,也没有空隙。
2. 定义统一的数据结构
内部统一使用带时区的 UTC 时间,只有在输入和输出边界才处理本地时区。
python
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class TimeRange:
start: datetime
end: datetime
def __post_init__(self) -> None:
if self.start.tzinfo is None or self.end.tzinfo is None:
raise ValueError("时间必须包含时区")
if self.start >= self.end:
raise ValueError("start 必须早于 end")
def contains(self, value: datetime) -> bool:
if value.tzinfo is None:
raise ValueError("待比较时间必须包含时区")
return self.start <= value < self.end
使用不可变数据类可以避免区间对象在传递过程中被意外修改。contains 明确实现左闭右开的判断逻辑。
3. 解析 ISO 8601 时间
Python 3.11 的 datetime.fromisoformat 可以处理常见 ISO 8601 字符串。对于结尾为 Z 的 UTC 时间,可以先转换为 +00:00:
python
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
def parse_datetime(value: str, default_tz: str = "Asia/Shanghai") -> datetime:
normalized = value.strip()
if not normalized:
raise ValueError("时间不能为空")
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise ValueError(f"无法解析时间: {value}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=ZoneInfo(default_tz))
return parsed.astimezone(UTC)
这段代码采用一个明确约定:不带时区的输入按 Asia/Shanghai 解释,然后转换为 UTC。实际项目也可以选择直接拒绝无时区输入,但不应该让服务器所在时区决定结果。
4. 支持纯日期输入
用户输入 2026-08-01 时,通常表达的是一个自然日,而不是某个瞬间。可以将开始日期解释为当天零点,将结束日期解释为次日零点:
python
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
def date_range(day: date, timezone: str = "Asia/Shanghai") -> TimeRange:
tz = ZoneInfo(timezone)
start_local = datetime.combine(day, time.min, tzinfo=tz)
end_local = datetime.combine(day + timedelta(days=1), time.min, tzinfo=tz)
return TimeRange(
start=start_local.astimezone(UTC),
end=end_local.astimezone(UTC),
)
不要简单地把结束时间写成 23:59:59。数据库可能保存微秒或纳秒精度,这种写法仍会漏掉当天最后一秒内的记录。
5. 解析完整区间
python
def parse_range(start: str, end: str) -> TimeRange:
return TimeRange(
start=parse_datetime(start),
end=parse_datetime(end),
)
解析、时区归一化和区间合法性检查被拆成不同层次,便于分别测试,也方便未来增加 Unix 时间戳等输入格式。
6. 使用 pytest 覆盖边界
测试不只需要覆盖正常路径,还要覆盖开始边界、结束边界、非法顺序和无时区时间。
python
from datetime import UTC, datetime
import pytest
def test_left_closed_right_open() -> None:
window = parse_range(
"2026-08-01T00:00:00+08:00",
"2026-08-02T00:00:00+08:00",
)
assert window.contains(datetime(2026, 7, 31, 16, 0, tzinfo=UTC))
assert not window.contains(datetime(2026, 8, 1, 16, 0, tzinfo=UTC))
def test_z_suffix_is_supported() -> None:
value = parse_datetime("2026-08-01T12:30:00Z")
assert value == datetime(2026, 8, 1, 12, 30, tzinfo=UTC)
@pytest.mark.parametrize(
("start", "end"),
[
("2026-08-02T00:00:00Z", "2026-08-01T00:00:00Z"),
("2026-08-01T00:00:00Z", "2026-08-01T00:00:00Z"),
],
)
def test_invalid_range_is_rejected(start: str, end: str) -> None:
with pytest.raises(ValueError):
parse_range(start, end)
7. 夏令时测试不能省略
一些时区会切换夏令时,因此"一个自然日"不一定等于 24 小时。下面的测试使用纽约时区验证夏令时开始日:
python
from datetime import date, timedelta
def test_daylight_saving_day_can_be_23_hours() -> None:
window = date_range(date(2026, 3, 8), "America/New_York")
assert window.end - window.start == timedelta(hours=23)
如果系统直接使用 start + timedelta(hours=24) 计算自然日结束时间,这个测试会失败。正确方式是在目标时区中先计算下一个本地零点,再转换为 UTC。
8. 数据库查询保持同一语义
无论使用 SQL 还是 ORM,都应保持左闭右开的条件:
sql
WHERE created_at >= :start
AND created_at < :end
不要在 SQL 中对时间列执行日期格式化后再比较,这通常会使索引失效。应先在应用层计算 UTC 边界,再把边界作为查询参数传入。
9. 上线前检查清单
- 明确区间是闭区间还是左闭右开区间;
- 内部时间统一为带时区 UTC;
- 无时区输入有明确的拒绝或默认规则;
- 自然日使用下一个本地零点作为结束边界;
- 覆盖相等边界、逆序区间和格式错误;
- 覆盖至少一个夏令时切换日期;
- 数据库使用 >= start AND < end 并保留时间索引。
时间处理的难点不在语法,而在语义一致。只要输入规则、内部表示、数据库查询和测试使用同一套区间定义,大多数跨时区和边界错误都能在上线前被发现。