mirror of
https://github.com/insistence/RuoYi-Vue3-FastAPI.git
synced 2026-10-14 13:06:46 +00:00
* feat: 新增统一认证中心 * perf: 优化代码 * fix: 修复问题 * perf: 优化页面 * fix: 修复部分接口缺失Request参数的问题 * perf: 优化访问控制机制 * perf: 优化代码 * fix: 修复 Logout Token 缺少 exp * perf&fix: 优化和修复 * perf: 优化代码 * perf: 优化代码 * fix: 修复测试问题
365 lines
13 KiB
Python
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 也可表示时长。确认字段表示一天内时间后,将显示类型设为“时间(一天内)”;时长请使用明确单位的数值列后重新导入'
|
|
)
|