From 2f9d943647e8f4358b98986c97a450049be5458f Mon Sep 17 00:00:00 2001 From: fawney19 Date: Sat, 13 Dec 2025 23:50:59 +0800 Subject: [PATCH] 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 --- src/api/dashboard/routes.py | 62 ++++++++++++++++-------- src/services/system/cleanup_scheduler.py | 44 ++++++++++------- src/services/system/stats_aggregator.py | 3 +- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/src/api/dashboard/routes.py b/src/api/dashboard/routes.py index c25459e..365603f 100644 --- a/src/api/dashboard/routes.py +++ b/src/api/dashboard/routes.py @@ -107,11 +107,18 @@ class AdminDashboardStatsAdapter(AdminApiAdapter): @cache_result(key_prefix="dashboard:admin:stats", ttl=60, user_specific=False) async def handle(self, context): # type: ignore[override] """管理员仪表盘统计 - 使用预聚合数据优化性能""" + from zoneinfo import ZoneInfo + from src.services.system.stats_aggregator import APP_TIMEZONE + db = context.db - now = datetime.now(timezone.utc) - today = now.replace(hour=0, minute=0, second=0, microsecond=0) - yesterday = today - timedelta(days=1) - last_month = today - timedelta(days=30) + # 使用业务时区计算日期,与 stats_daily 表保持一致 + app_tz = ZoneInfo(APP_TIMEZONE) + now_local = datetime.now(app_tz) + 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 + 今日实时数据获取全局统计 @@ -428,12 +435,19 @@ class AdminDashboardStatsAdapter(AdminApiAdapter): class UserDashboardStatsAdapter(DashboardAdapter): @cache_result(key_prefix="dashboard:user:stats", ttl=30, user_specific=True) async def handle(self, context): # type: ignore[override] + from zoneinfo import ZoneInfo + from src.services.system.stats_aggregator import APP_TIMEZONE + db = context.db user = context.user - now = datetime.now(timezone.utc) - today = now.replace(hour=0, minute=0, second=0, microsecond=0) - last_month = today - timedelta(days=30) - yesterday = today - timedelta(days=1) + # 使用业务时区计算日期,确保与用户感知的"今天"一致 + app_tz = ZoneInfo(APP_TIMEZONE) + now_local = datetime.now(app_tz) + 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() active_keys = ( @@ -688,16 +702,23 @@ class DashboardDailyStatsAdapter(DashboardAdapter): @cache_result(key_prefix="dashboard:daily:stats", ttl=300, user_specific=True) async def handle(self, context): # type: ignore[override] + from zoneinfo import ZoneInfo + from src.services.system.stats_aggregator import APP_TIMEZONE + db = context.db user = context.user is_admin = user.role == UserRole.ADMIN - now = datetime.now(timezone.utc) - today = now.replace(hour=0, minute=0, second=0, microsecond=0) - end_date = now.replace(hour=23, minute=59, second=59, microsecond=999999) - start_date = (end_date - timedelta(days=self.days - 1)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) + # 使用业务时区计算日期,确保每日统计与业务日期一致 + app_tz = ZoneInfo(APP_TIMEZONE) + now_local = datetime.now(app_tz) + today_local = now_local.replace(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: @@ -708,8 +729,10 @@ class DashboardDailyStatsAdapter(DashboardAdapter): .order_by(StatsDaily.date.asc()) .all() ) + # stats_daily.date 存储的是业务日期对应的 UTC 开始时间 + # 需要转回业务时区再取日期,才能与日期序列匹配 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, "tokens": stat.input_tokens + stat.output_tokens + stat.cache_creation_tokens + stat.cache_read_tokens, "cost": stat.total_cost, @@ -723,7 +746,7 @@ class DashboardDailyStatsAdapter(DashboardAdapter): # 今日实时数据 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: # 今日平均响应时间需要单独查询 today_avg_rt = ( @@ -800,10 +823,11 @@ class DashboardDailyStatsAdapter(DashboardAdapter): 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] = [] - while current_date <= end_date.date(): + while current_date <= end_date_date: date_str = current_date.isoformat() stat = stats_map.get(date_str) if stat: diff --git a/src/services/system/cleanup_scheduler.py b/src/services/system/cleanup_scheduler.py index 62d6f64..d2a18b5 100644 --- a/src/services/system/cleanup_scheduler.py +++ b/src/services/system/cleanup_scheduler.py @@ -162,8 +162,6 @@ class CleanupScheduler: app_tz = ZoneInfo(APP_TIMEZONE) now_local = datetime.now(app_tz) 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: # 启动时检查并回填缺失的日期 @@ -190,40 +188,49 @@ class CleanupScheduler: ) if latest_stat: - latest_date = latest_stat.date.replace(tzinfo=timezone.utc) - # 计算缺失的天数(从最新记录的下一天到昨天) - yesterday = today - timedelta(days=1) - missing_start = latest_date + timedelta(days=1) + latest_date_utc = latest_stat.date + if latest_date_utc.tzinfo is None: + latest_date_utc = latest_date_utc.replace(tzinfo=timezone.utc) + else: + latest_date_utc = latest_date_utc.astimezone(timezone.utc) - if missing_start <= yesterday: - missing_days = (yesterday - missing_start).days + 1 + # 使用业务日期计算缺失区间(避免用 UTC 年月日导致日期偏移,且对 DST 更安全) + 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( 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() - while current_date <= yesterday: + while current_date <= yesterday_business_date: 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: try: StatsAggregatorService.aggregate_user_daily_stats( - db, user_id, current_date + db, user_id, current_date_local ) except Exception as e: logger.warning( - f"回填用户 {user_id} 日期 {current_date.date()} 失败: {e}" + f"回填用户 {user_id} 日期 {current_date} 失败: {e}" ) try: db.rollback() except Exception: pass except Exception as e: - logger.warning(f"回填日期 {current_date.date()} 失败: {e}") + logger.warning(f"回填日期 {current_date} 失败: {e}") try: db.rollback() except Exception: @@ -239,15 +246,16 @@ class CleanupScheduler: 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() for (user_id,) in users: try: - StatsAggregatorService.aggregate_user_daily_stats(db, user_id, yesterday) + StatsAggregatorService.aggregate_user_daily_stats(db, user_id, yesterday_local) except Exception as e: logger.warning(f"聚合用户 {user_id} 统计数据失败: {e}") # 回滚当前用户的失败操作,继续处理其他用户 diff --git a/src/services/system/stats_aggregator.py b/src/services/system/stats_aggregator.py index d0b59a2..cff7b0e 100644 --- a/src/services/system/stats_aggregator.py +++ b/src/services/system/stats_aggregator.py @@ -194,7 +194,8 @@ class StatsAggregatorService: db.add(stats) db.commit() - logger.info(f"[StatsAggregator] 聚合日期 {day_start.date()} 完成: {total_requests} 请求") + # 日志使用业务日期(输入参数),而不是 UTC 日期 + logger.info(f"[StatsAggregator] 聚合日期 {date.date()} 完成: {total_requests} 请求") return stats @staticmethod