Files
RuoYi-Vue3-FastAPI/ruoyi-fastapi-backend/utils/time_util.py
T
insistence a89aec54aa feat: 新增 OAuth 2.0 / OIDC 统一认证中心 (#129)
* feat: 新增统一认证中心

* perf: 优化代码

* fix: 修复问题

* perf: 优化页面

* fix: 修复部分接口缺失Request参数的问题

* perf: 优化访问控制机制

* perf: 优化代码

* fix: 修复 Logout Token 缺少 exp

* perf&fix: 优化和修复

* perf: 优化代码

* perf: 优化代码

* fix: 修复测试问题
2026-10-01 22:56:05 +08:00

365 lines
13 KiB
Python

import re
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from functools import cache
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from exceptions.exception import ServiceWarning
class TimezoneUtil:
"""
时区工具类
"""
RFC3339_PATTERN = re.compile(
r'\d{4}-\d{2}-\d{2}[Tt](?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d'
r'(?:\.\d+)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)'
)
ISO_DATE_PATTERN = re.compile(r'\d{4}-\d{2}-\d{2}')
@classmethod
def get_timezone(cls, timezone_name: str) -> ZoneInfo:
"""
获取并缓存有效的IANA时区对象
:param timezone_name: IANA时区名称
:return: 时区对象
"""
if not isinstance(timezone_name, str) or not timezone_name.strip():
raise ValueError('IANA时区不能为空')
return cls._load_timezone(timezone_name.strip())
@classmethod
@cache
def _load_timezone(cls, timezone_name: str) -> ZoneInfo:
"""
加载时区对象,仅缓存校验成功的名称
:param timezone_name: IANA时区名称
:return: 时区对象
"""
try:
return ZoneInfo(timezone_name)
except (ZoneInfoNotFoundError, ValueError):
raise ValueError(f'不是有效的IANA时区:{timezone_name}') from None
@classmethod
def validate_timezone_name(cls, timezone_name: str) -> str:
"""
校验并返回规范化的IANA时区名称
:param timezone_name: IANA时区名称
:return: 去除首尾空白后的有效时区名称
"""
return cls.get_timezone(timezone_name).key
@classmethod
def validate_timezone_preference(cls, value: str) -> str:
"""
校验账号时区偏好,auto表示由客户端提供设备时区
:param value: auto或IANA时区名称
:return: 校验后的账号偏好
"""
return 'auto' if value == 'auto' else cls.validate_timezone_name(value)
@classmethod
def get_request_timezone(cls) -> str:
"""
获取当前请求的显示时区,未指定时回退到业务时区
:return: 当前请求使用的IANA时区名称
"""
from common.context import RequestContext # noqa: PLC0415
return RequestContext.get_current_timezone() or cls.get_app_timezone().key
@classmethod
def parse_business_date(cls, value: str | date) -> date:
"""
解析纯业务日期
仅接受date对象或YYYY-MM-DD字符串,拒绝日期时间和数字。
:param value: 待校验的输入值
:return: 不含时间和时区的日期对象
"""
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, str) and cls.ISO_DATE_PATTERN.fullmatch(value):
try:
return date.fromisoformat(value)
except ValueError:
pass
raise ValueError('业务日期必须是有效的YYYY-MM-DD')
@classmethod
def parse_rfc3339(cls, value: str | datetime) -> datetime:
"""
解析带时区的RFC 3339时刻并归一化为UTC毫秒
兼容Python 3.10的ISO格式解析,不接受无时区的日期时间。
:param value: 待校验的输入值
:return: 携带UTC时区的毫秒精度时刻
"""
if isinstance(value, datetime):
return cls.to_utc_milliseconds(value)
if isinstance(value, str) and cls.RFC3339_PATTERN.fullmatch(value):
try:
return cls.to_utc_milliseconds(datetime.fromisoformat(value.upper().replace('Z', '+00:00')))
except ValueError:
pass
raise ValueError('时刻必须是有效的带offset的RFC 3339字符串或aware datetime')
@classmethod
def get_app_timezone(cls) -> ZoneInfo:
"""
获取应用配置的业务时区
:return: APP_TIMEZONE对应的时区对象
"""
# 配置校验也会调用本模块,延迟导入避免循环依赖。
from config.env import AppConfig # noqa: PLC0415
return cls.get_timezone(AppConfig.app_timezone)
@classmethod
def utc_now(cls) -> datetime:
"""
获取当前UTC时刻
:return: 携带UTC时区的当前时刻
"""
return datetime.now(timezone.utc)
@classmethod
def ensure_aware(cls, value: datetime, *, field_name: str = 'datetime') -> datetime:
"""
校验时刻是否携带有效时区信息
:param value: 待校验时刻
:param field_name: 校验错误中使用的字段名称
:return: 校验通过的原时刻
"""
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError(f'{field_name}必须携带时区信息')
return value
@classmethod
def to_utc(cls, value: datetime) -> datetime:
"""
将带时区的时刻转换为UTC
:param value: 携带时区信息的时刻
:return: 携带UTC时区的时刻
"""
return cls.ensure_aware(value).astimezone(timezone.utc)
@classmethod
def to_optional_utc(cls, value: datetime | None) -> datetime | None:
"""
将可选的带时区时刻转换为 UTC,空值原样保留。
:param value: 可选的带时区时刻
:return: UTC 时刻或 None
"""
return cls.to_utc(value) if value is not None else None
@classmethod
def to_utc_milliseconds(cls, value: datetime) -> datetime:
"""
将带时区的时刻转换为毫秒精度UTC
:param value: 携带时区信息的时刻
:return: 截断微秒尾数后的UTC时刻
"""
utc_value = cls.to_utc(value)
return utc_value.replace(microsecond=(utc_value.microsecond // 1000) * 1000)
@classmethod
def to_business_time(cls, value: datetime, timezone_name: str | None = None) -> datetime:
"""
将时刻转换为指定业务时区
:param value: 携带时区信息的时刻
:param timezone_name: 目标IANA时区名称,省略时使用APP_TIMEZONE
:return: 目标业务时区下的时刻
"""
target_timezone = cls.get_timezone(timezone_name) if timezone_name else cls.get_app_timezone()
return cls.ensure_aware(value).astimezone(target_timezone)
@classmethod
def local_date_range_to_utc(
cls,
begin_date: date,
end_date: date,
timezone_name: str | None = None,
) -> tuple[datetime, datetime]:
"""
将包含首尾日期的业务日期范围转换为UTC半开区间
:param begin_date: 起始业务日期
:param end_date: 结束业务日期,包含当天
:param timezone_name: 业务IANA时区名称,省略时使用APP_TIMEZONE
:return: UTC半开区间[start, end)
"""
if end_date < begin_date:
raise ValueError('结束日期不能早于开始日期')
business_timezone = cls.get_timezone(timezone_name) if timezone_name else cls.get_app_timezone()
start = datetime.combine(begin_date, time.min, tzinfo=business_timezone)
end = datetime.combine(end_date + timedelta(days=1), time.min, tzinfo=business_timezone)
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
@classmethod
def local_date_strings_to_utc(
cls,
begin_date: str | date | None,
end_date: str | date | None,
timezone_name: str | None = None,
) -> tuple[datetime | None, datetime | None] | None:
"""
将可选业务日期边界转换为UTC半开区间
:param begin_date: 起始业务日期,为空时不限制下界
:param end_date: 结束业务日期,包含当天,为空时不限制上界
:param timezone_name: 业务IANA时区名称,省略时使用APP_TIMEZONE
:return: UTC起止边界;缺失的边界为None,两边均缺失时返回None
"""
if not begin_date and not end_date:
return None
begin = cls.parse_business_date(begin_date) if begin_date else None
end = cls.parse_business_date(end_date) if end_date else None
if begin and end and end < begin:
raise ValueError('结束日期不能早于开始日期')
if end == date.max:
raise ValueError('结束日期必须早于9999-12-31')
business_timezone = cls.get_timezone(timezone_name) if timezone_name else cls.get_app_timezone()
start = datetime.combine(begin, time.min, tzinfo=business_timezone).astimezone(timezone.utc) if begin else None
stop = (
datetime.combine(end + timedelta(days=1), time.min, tzinfo=business_timezone).astimezone(timezone.utc)
if end
else None
)
return start, stop
@classmethod
def rfc3339_range_to_utc(
cls,
begin_time: str | datetime | None,
end_time: str | datetime | None,
) -> tuple[datetime | None, datetime | None] | None:
"""
解析可选的RFC 3339时刻范围
:param begin_time: 起始时刻,为空时不限制下界
:param end_time: 结束时刻,为空时不限制上界
:return: UTC起止时刻;缺失的边界为None,两边均缺失时返回None
"""
if not begin_time and not end_time:
return None
start = cls.parse_rfc3339(begin_time) if begin_time else None
end = cls.parse_rfc3339(end_time) if end_time else None
if start and end and end < start:
raise ValueError('结束时间不能早于开始时间')
return start, end
@classmethod
def format_rfc3339(cls, value: datetime) -> str:
"""
将时刻格式化为固定毫秒精度的UTC字符串
:param value: 携带时区信息的时刻
:return: 以Z结尾的RFC 3339字符串
"""
utc_value = cls.to_utc(value)
return utc_value.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
@dataclass(frozen=True)
class TimeSemantics:
"""
时间字段在Python、ORM、API和页面控件间的类型映射
"""
kind: str
python_type: str
sqlalchemy_type: str
vo_type: str
control: str
class GenTimeUtil:
"""
代码生成器时间字段工具类
"""
DATE = TimeSemantics(
kind='date', python_type='date', sqlalchemy_type='Date', vo_type='BusinessDate', control='date'
)
TIME = TimeSemantics(
kind='time', python_type='time', sqlalchemy_type='Time', vo_type='BusinessTime', control='time'
)
INSTANT = TimeSemantics(
kind='instant',
python_type='datetime',
sqlalchemy_type='DbUtcDateTime',
vo_type='ApiUtcDateTime',
control='datetime',
)
@classmethod
def normalize_db_type(cls, column_type: str) -> str:
"""
规范化数据库类型名称,保留PostgreSQL时区限定
:param column_type: 包含精度等修饰的数据库列类型
:return: 去除精度和unsigned修饰后的类型名称
"""
normalized_type = re.sub(r'\([^)]*\)', '', column_type.lower()).removesuffix(' unsigned')
return ' '.join(normalized_type.split())
@classmethod
def get_time_semantics(cls, column_type: str, db_type: str) -> TimeSemantics | None:
"""
根据数据库列类型获取时间字段语义
:param column_type: 数据库列类型
:param db_type: 数据源类型
:return: 时间字段的类型映射,非时间字段返回None
"""
data_type = cls.normalize_db_type(column_type)
if data_type == 'date':
return cls.DATE
if data_type in {'time', 'time without time zone'}:
return cls.TIME
if db_type == 'mysql' and data_type in {'datetime', 'timestamp'}:
return cls.INSTANT
if db_type == 'postgresql':
if data_type in {'timestamptz', 'timestamp with time zone'}:
return cls.INSTANT
if data_type in {'timestamp', 'timestamp without time zone'}:
raise ServiceWarning(
message='PostgreSQL 无时区 timestamp 含义不明确,请将真实时刻列改为 TIMESTAMP(3) WITH TIME ZONE 后重新导入生成配置'
)
if data_type in {'timetz', 'time with time zone'}:
raise ServiceWarning(
message='带时区的 time 不能作为一天内时间生成,请使用 TIME WITHOUT TIME ZONE;真实时刻请使用 TIMESTAMP(3) WITH TIME ZONE'
)
return None
@classmethod
def validate_time_control(cls, column_type: str, db_type: str, control: str | None) -> None:
"""
校验MySQL TIME字段是否已确认表示一天内时间
:param column_type: 数据库列类型
:param db_type: 数据源类型
:param control: 代码生成器配置的显示类型
"""
if db_type == 'mysql' and cls.normalize_db_type(column_type) == 'time' and control != 'time':
raise ServiceWarning(
message='MySQL TIME 也可表示时长。确认字段表示一天内时间后,将显示类型设为“时间(一天内)”;时长请使用明确单位的数值列后重新导入'
)