fix(system): fix timezone handling in dashboard and stats services

- Use app timezone instead of UTC for date calculations in dashboard routes
- Ensure consistency between stats_daily.date and timezone-aware comparisons
- Fix date calculations in cleanup scheduler to handle DST correctly
- Update log message in stats aggregator to use business date
This commit is contained in:
fawney19
2025-12-13 23:50:59 +08:00
parent 77613795ed
commit 393d4d13ff
3 changed files with 71 additions and 38 deletions

View File

@@ -107,11 +107,18 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
@cache_result(key_prefix="dashboard:admin:stats", ttl=60, user_specific=False) @cache_result(key_prefix="dashboard:admin:stats", ttl=60, user_specific=False)
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
"""管理员仪表盘统计 - 使用预聚合数据优化性能""" """管理员仪表盘统计 - 使用预聚合数据优化性能"""
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
db = context.db db = context.db
now = datetime.now(timezone.utc) # 使用业务时区计算日期,与 stats_daily 表保持一致
today = now.replace(hour=0, minute=0, second=0, microsecond=0) app_tz = ZoneInfo(APP_TIMEZONE)
yesterday = today - timedelta(days=1) now_local = datetime.now(app_tz)
last_month = today - timedelta(days=30) today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
# 转换为 UTC 用于与 stats_daily.date 比较(存储的是业务日期对应的 UTC 开始时间)
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
# ==================== 使用预聚合数据 ==================== # ==================== 使用预聚合数据 ====================
# 从 stats_summary + 今日实时数据获取全局统计 # 从 stats_summary + 今日实时数据获取全局统计
@@ -428,12 +435,19 @@ class AdminDashboardStatsAdapter(AdminApiAdapter):
class UserDashboardStatsAdapter(DashboardAdapter): class UserDashboardStatsAdapter(DashboardAdapter):
@cache_result(key_prefix="dashboard:user:stats", ttl=30, user_specific=True) @cache_result(key_prefix="dashboard:user:stats", ttl=30, user_specific=True)
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
db = context.db db = context.db
user = context.user user = context.user
now = datetime.now(timezone.utc) # 使用业务时区计算日期,确保与用户感知的"今天"一致
today = now.replace(hour=0, minute=0, second=0, microsecond=0) app_tz = ZoneInfo(APP_TIMEZONE)
last_month = today - timedelta(days=30) now_local = datetime.now(app_tz)
yesterday = today - timedelta(days=1) today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
# 转换为 UTC 用于数据库查询
today = today_local.astimezone(timezone.utc)
yesterday = (today_local - timedelta(days=1)).astimezone(timezone.utc)
last_month = (today_local - timedelta(days=30)).astimezone(timezone.utc)
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar() user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar()
active_keys = ( active_keys = (
@@ -688,16 +702,23 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
@cache_result(key_prefix="dashboard:daily:stats", ttl=300, user_specific=True) @cache_result(key_prefix="dashboard:daily:stats", ttl=300, user_specific=True)
async def handle(self, context): # type: ignore[override] async def handle(self, context): # type: ignore[override]
from zoneinfo import ZoneInfo
from src.services.system.stats_aggregator import APP_TIMEZONE
db = context.db db = context.db
user = context.user user = context.user
is_admin = user.role == UserRole.ADMIN is_admin = user.role == UserRole.ADMIN
now = datetime.now(timezone.utc) # 使用业务时区计算日期,确保每日统计与业务日期一致
today = now.replace(hour=0, minute=0, second=0, microsecond=0) app_tz = ZoneInfo(APP_TIMEZONE)
end_date = now.replace(hour=23, minute=59, second=59, microsecond=999999) now_local = datetime.now(app_tz)
start_date = (end_date - timedelta(days=self.days - 1)).replace( today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
hour=0, minute=0, second=0, microsecond=0 # 转换为 UTC 用于数据库查询
) today = today_local.astimezone(timezone.utc)
end_date_local = now_local.replace(hour=23, minute=59, second=59, microsecond=999999)
end_date = end_date_local.astimezone(timezone.utc)
start_date_local = (today_local - timedelta(days=self.days - 1))
start_date = start_date_local.astimezone(timezone.utc)
# ==================== 使用预聚合数据优化 ==================== # ==================== 使用预聚合数据优化 ====================
if is_admin: if is_admin:
@@ -708,8 +729,10 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
.order_by(StatsDaily.date.asc()) .order_by(StatsDaily.date.asc())
.all() .all()
) )
# stats_daily.date 存储的是业务日期对应的 UTC 开始时间
# 需要转回业务时区再取日期,才能与日期序列匹配
stats_map = { stats_map = {
stat.date.replace(tzinfo=timezone.utc).date().isoformat(): { stat.date.replace(tzinfo=timezone.utc).astimezone(app_tz).date().isoformat(): {
"requests": stat.total_requests, "requests": stat.total_requests,
"tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens, "tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens,
"cost": stat.total_cost, "cost": stat.total_cost,
@@ -723,7 +746,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
# 今日实时数据 # 今日实时数据
today_stats = StatsAggregatorService.get_today_realtime_stats(db) today_stats = StatsAggregatorService.get_today_realtime_stats(db)
today_str = today.date().isoformat() today_str = today_local.date().isoformat()
if today_stats["total_requests"] > 0: if today_stats["total_requests"] > 0:
# 今日平均响应时间需要单独查询 # 今日平均响应时间需要单独查询
today_avg_rt = ( today_avg_rt = (
@@ -800,10 +823,11 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
for stat in user_daily_stats for stat in user_daily_stats
} }
# 构建完整日期序列 # 构建完整日期序列(使用业务时区日期)
current_date = start_date.date() current_date = start_date_local.date()
end_date_date = end_date_local.date()
formatted: List[dict] = [] formatted: List[dict] = []
while current_date <= end_date.date(): while current_date <= end_date_date:
date_str = current_date.isoformat() date_str = current_date.isoformat()
stat = stats_map.get(date_str) stat = stats_map.get(date_str)
if stat: if stat:

View File

@@ -162,8 +162,6 @@ class CleanupScheduler:
app_tz = ZoneInfo(APP_TIMEZONE) app_tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(app_tz) now_local = datetime.now(app_tz)
today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) today_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
# 转换为 UTC 用于数据库查询stats_daily.date 存储的是 UTC
today = today_local.astimezone(timezone.utc).replace(tzinfo=timezone.utc)
if backfill: if backfill:
# 启动时检查并回填缺失的日期 # 启动时检查并回填缺失的日期
@@ -190,40 +188,49 @@ class CleanupScheduler:
) )
if latest_stat: if latest_stat:
latest_date = latest_stat.date.replace(tzinfo=timezone.utc) latest_date_utc = latest_stat.date
# 计算缺失的天数(从最新记录的下一天到昨天) if latest_date_utc.tzinfo is None:
yesterday = today - timedelta(days=1) latest_date_utc = latest_date_utc.replace(tzinfo=timezone.utc)
missing_start = latest_date + timedelta(days=1) else:
latest_date_utc = latest_date_utc.astimezone(timezone.utc)
if missing_start <= yesterday: # 使用业务日期计算缺失区间(避免用 UTC 年月日导致日期偏移,且对 DST 更安全)
missing_days = (yesterday - missing_start).days + 1 latest_business_date = latest_date_utc.astimezone(app_tz).date()
yesterday_business_date = (today_local.date() - timedelta(days=1))
missing_start_date = latest_business_date + timedelta(days=1)
if missing_start_date <= yesterday_business_date:
missing_days = (yesterday_business_date - missing_start_date).days + 1
logger.info( logger.info(
f"检测到缺失 {missing_days} 天的统计数据 " f"检测到缺失 {missing_days} 天的统计数据 "
f"({missing_start.date()} ~ {yesterday.date()}),开始回填..." f"({missing_start_date} ~ {yesterday_business_date}),开始回填..."
) )
current_date = missing_start current_date = missing_start_date
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all() users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
while current_date <= yesterday: while current_date <= yesterday_business_date:
try: try:
StatsAggregatorService.aggregate_daily_stats(db, current_date) current_date_local = datetime.combine(
current_date, datetime.min.time(), tzinfo=app_tz
)
StatsAggregatorService.aggregate_daily_stats(db, current_date_local)
# 聚合用户数据 # 聚合用户数据
for (user_id,) in users: for (user_id,) in users:
try: try:
StatsAggregatorService.aggregate_user_daily_stats( StatsAggregatorService.aggregate_user_daily_stats(
db, user_id, current_date db, user_id, current_date_local
) )
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"回填用户 {user_id} 日期 {current_date.date()} 失败: {e}" f"回填用户 {user_id} 日期 {current_date} 失败: {e}"
) )
try: try:
db.rollback() db.rollback()
except Exception: except Exception:
pass pass
except Exception as e: except Exception as e:
logger.warning(f"回填日期 {current_date.date()} 失败: {e}") logger.warning(f"回填日期 {current_date} 失败: {e}")
try: try:
db.rollback() db.rollback()
except Exception: except Exception:
@@ -239,15 +246,16 @@ class CleanupScheduler:
return return
# 定时任务:聚合昨天的数据 # 定时任务:聚合昨天的数据
yesterday = (today - timedelta(days=1)) # 注意aggregate_daily_stats 期望业务时区的日期,不是 UTC
yesterday_local = today_local - timedelta(days=1)
StatsAggregatorService.aggregate_daily_stats(db, yesterday) StatsAggregatorService.aggregate_daily_stats(db, yesterday_local)
# 聚合所有用户的昨日数据 # 聚合所有用户的昨日数据
users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all() users = db.query(DBUser.id).filter(DBUser.is_active.is_(True)).all()
for (user_id,) in users: for (user_id,) in users:
try: try:
StatsAggregatorService.aggregate_user_daily_stats(db, user_id, yesterday) StatsAggregatorService.aggregate_user_daily_stats(db, user_id, yesterday_local)
except Exception as e: except Exception as e:
logger.warning(f"聚合用户 {user_id} 统计数据失败: {e}") logger.warning(f"聚合用户 {user_id} 统计数据失败: {e}")
# 回滚当前用户的失败操作,继续处理其他用户 # 回滚当前用户的失败操作,继续处理其他用户

View File

@@ -194,7 +194,8 @@ class StatsAggregatorService:
db.add(stats) db.add(stats)
db.commit() db.commit()
logger.info(f"[StatsAggregator] 聚合日期 {day_start.date()} 完成: {total_requests} 请求") # 日志使用业务日期(输入参数),而不是 UTC 日期
logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 完成: {total_requests} 请求")
return stats return stats
@staticmethod @staticmethod