From abc41c7d3cf47f342c7531b8d66aaf7032e493f6 Mon Sep 17 00:00:00 2001 From: fawney19 Date: Thu, 11 Dec 2025 17:47:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7=E5=92=8C=E4=BD=BF=E7=94=A8=E9=87=8F?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=20API=20=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/usage/routes.py | 181 +++++++ src/api/user_me/routes.py | 33 ++ src/services/cache/aware_scheduler.py | 13 +- .../orchestration/error_classifier.py | 29 +- src/services/usage/service.py | 458 ++++++++++++++++++ 5 files changed, 707 insertions(+), 7 deletions(-) diff --git a/src/api/admin/usage/routes.py b/src/api/admin/usage/routes.py index 7244a9d..80489a6 100644 --- a/src/api/admin/usage/routes.py +++ b/src/api/admin/usage/routes.py @@ -800,3 +800,184 @@ class AdminUsageDetailAdapter(AdminApiAdapter): "tiers": tiers, "source": pricing_source, # 定价来源: 'provider' 或 'global' } + + +# ==================== 缓存亲和性分析 ==================== + + +@router.get("/cache-affinity/ttl-analysis") +async def analyze_cache_affinity_ttl( + request: Request, + user_id: Optional[str] = Query(None, description="指定用户 ID"), + api_key_id: Optional[str] = Query(None, description="指定 API Key ID"), + hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"), + db: Session = Depends(get_db), +): + """ + 分析用户请求间隔分布,推荐合适的缓存亲和性 TTL。 + + 通过分析同一用户连续请求之间的时间间隔,判断用户的使用模式: + - 高频用户(间隔短):5 分钟 TTL 足够 + - 中频用户:15-30 分钟 TTL + - 低频用户(间隔长):需要 60 分钟 TTL + """ + adapter = CacheAffinityTTLAnalysisAdapter( + user_id=user_id, + api_key_id=api_key_id, + hours=hours, + ) + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + +@router.get("/cache-affinity/hit-analysis") +async def analyze_cache_hit( + request: Request, + user_id: Optional[str] = Query(None, description="指定用户 ID"), + api_key_id: Optional[str] = Query(None, description="指定 API Key ID"), + hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"), + db: Session = Depends(get_db), +): + """ + 分析缓存命中情况。 + + 返回缓存命中率、节省的费用等统计信息。 + """ + adapter = CacheHitAnalysisAdapter( + user_id=user_id, + api_key_id=api_key_id, + hours=hours, + ) + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + +class CacheAffinityTTLAnalysisAdapter(AdminApiAdapter): + """缓存亲和性 TTL 分析适配器""" + + def __init__( + self, + user_id: Optional[str], + api_key_id: Optional[str], + hours: int, + ): + self.user_id = user_id + self.api_key_id = api_key_id + self.hours = hours + + async def handle(self, context): # type: ignore[override] + db = context.db + + result = UsageService.analyze_cache_affinity_ttl( + db=db, + user_id=self.user_id, + api_key_id=self.api_key_id, + hours=self.hours, + ) + + context.add_audit_metadata( + action="cache_affinity_ttl_analysis", + user_id=self.user_id, + api_key_id=self.api_key_id, + hours=self.hours, + total_users_analyzed=result.get("total_users_analyzed", 0), + ) + + return result + + +class CacheHitAnalysisAdapter(AdminApiAdapter): + """缓存命中分析适配器""" + + def __init__( + self, + user_id: Optional[str], + api_key_id: Optional[str], + hours: int, + ): + self.user_id = user_id + self.api_key_id = api_key_id + self.hours = hours + + async def handle(self, context): # type: ignore[override] + db = context.db + + result = UsageService.get_cache_hit_analysis( + db=db, + user_id=self.user_id, + api_key_id=self.api_key_id, + hours=self.hours, + ) + + context.add_audit_metadata( + action="cache_hit_analysis", + user_id=self.user_id, + api_key_id=self.api_key_id, + hours=self.hours, + ) + + return result + + +@router.get("/cache-affinity/interval-timeline") +async def get_interval_timeline( + request: Request, + hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"), + limit: int = Query(1000, ge=100, le=5000, description="最大返回数据点数量"), + user_id: Optional[str] = Query(None, description="指定用户 ID"), + include_user_info: bool = Query(False, description="是否包含用户信息(用于管理员多用户视图)"), + db: Session = Depends(get_db), +): + """ + 获取请求间隔时间线数据,用于散点图展示。 + + 返回每个请求的时间点和与上一个请求的间隔(分钟), + 可用于可视化用户请求模式。 + + 当 include_user_info=true 且未指定 user_id 时,返回数据会包含: + - points 中每个点包含 user_id 字段 + - users 字段包含 user_id -> username 的映射 + """ + adapter = IntervalTimelineAdapter( + hours=hours, + limit=limit, + user_id=user_id, + include_user_info=include_user_info, + ) + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + +class IntervalTimelineAdapter(AdminApiAdapter): + """请求间隔时间线适配器""" + + def __init__( + self, + hours: int, + limit: int, + user_id: Optional[str] = None, + include_user_info: bool = False, + ): + self.hours = hours + self.limit = limit + self.user_id = user_id + self.include_user_info = include_user_info + + async def handle(self, context): # type: ignore[override] + db = context.db + + result = UsageService.get_interval_timeline( + db=db, + hours=self.hours, + limit=self.limit, + user_id=self.user_id, + include_user_info=self.include_user_info, + ) + + context.add_audit_metadata( + action="interval_timeline", + hours=self.hours, + limit=self.limit, + user_id=self.user_id, + include_user_info=self.include_user_info, + total_points=result.get("total_points", 0), + ) + + return result diff --git a/src/api/user_me/routes.py b/src/api/user_me/routes.py index 3ed3bc5..c03aae4 100644 --- a/src/api/user_me/routes.py +++ b/src/api/user_me/routes.py @@ -121,6 +121,18 @@ async def get_my_active_requests( return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) +@router.get("/usage/interval-timeline") +async def get_my_interval_timeline( + request: Request, + hours: int = Query(168, ge=1, le=720, description="分析最近多少小时的数据"), + limit: int = Query(1000, ge=100, le=5000, description="最大返回数据点数量"), + db: Session = Depends(get_db), +): + """获取当前用户的请求间隔时间线数据,用于散点图展示""" + adapter = GetMyIntervalTimelineAdapter(hours=hours, limit=limit) + return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) + + @router.get("/providers") async def list_available_providers(request: Request, db: Session = Depends(get_db)): adapter = ListAvailableProvidersAdapter() @@ -676,6 +688,27 @@ class GetActiveRequestsAdapter(AuthenticatedApiAdapter): return {"requests": requests} +@dataclass +class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter): + """获取当前用户的请求间隔时间线适配器""" + + hours: int + limit: int + + async def handle(self, context): # type: ignore[override] + db = context.db + user = context.user + + result = UsageService.get_interval_timeline( + db=db, + hours=self.hours, + limit=self.limit, + user_id=str(user.id), + ) + + return result + + class ListAvailableProvidersAdapter(AuthenticatedApiAdapter): async def handle(self, context): # type: ignore[override] from sqlalchemy.orm import selectinload diff --git a/src/services/cache/aware_scheduler.py b/src/services/cache/aware_scheduler.py index 873262a..553f363 100644 --- a/src/services/cache/aware_scheduler.py +++ b/src/services/cache/aware_scheduler.py @@ -862,13 +862,14 @@ class CacheAwareScheduler: # Key 级别的能力匹配检查 # 注意:模型级别的能力检查已在 _check_model_support 中完成 - if capability_requirements: - from src.core.key_capabilities import check_capability_match + # 始终执行检查,即使 capability_requirements 为空 + # 因为 check_capability_match 会检查 Key 的 EXCLUSIVE 能力是否被浪费 + from src.core.key_capabilities import check_capability_match - key_caps: Dict[str, bool] = dict(key.capabilities or {}) - is_match, skip_reason = check_capability_match(key_caps, capability_requirements) - if not is_match: - return False, skip_reason + key_caps: Dict[str, bool] = dict(key.capabilities or {}) + is_match, skip_reason = check_capability_match(key_caps, capability_requirements) + if not is_match: + return False, skip_reason return True, None diff --git a/src/services/orchestration/error_classifier.py b/src/services/orchestration/error_classifier.py index b5cd168..30b71e9 100644 --- a/src/services/orchestration/error_classifier.py +++ b/src/services/orchestration/error_classifier.py @@ -67,12 +67,13 @@ class ErrorClassifier: # 表示客户端请求错误的关键词(不区分大小写) # 这些错误是由用户请求本身导致的,换 Provider 也无济于事 + # 注意:标准 API 返回的 error.type 已在 CLIENT_ERROR_TYPES 中处理 + # 这里主要用于匹配非标准格式或第三方代理的错误消息 CLIENT_ERROR_PATTERNS: Tuple[str, ...] = ( "could not process image", # 图片处理失败 "image too large", # 图片过大 "invalid image", # 无效图片 "unsupported image", # 不支持的图片格式 - "invalid_request_error", # OpenAI/Claude 通用客户端错误类型 "content_policy_violation", # 内容违规 "invalid_api_key", # 无效的 API Key(不同于认证失败) "context_length_exceeded", # 上下文长度超限 @@ -85,6 +86,7 @@ class ErrorClassifier: "image exceeds", # 图片超出限制 "pdf too large", # PDF 过大 "file too large", # 文件过大 + "tool_use_id", # tool_result 引用了不存在的 tool_use(兼容非标准代理) ) def __init__( @@ -105,10 +107,22 @@ class ErrorClassifier: self.adaptive_manager = adaptive_manager or get_adaptive_manager() self.cache_scheduler = cache_scheduler + # 表示客户端错误的 error type(不区分大小写) + # 这些 type 表明是请求本身的问题,不应重试 + CLIENT_ERROR_TYPES: Tuple[str, ...] = ( + "invalid_request_error", # Claude/OpenAI 标准客户端错误类型 + "invalid_argument", # Gemini 参数错误 + "failed_precondition", # Gemini 前置条件错误 + ) + def _is_client_error(self, error_text: Optional[str]) -> bool: """ 检测错误响应是否为客户端错误(不应重试) + 判断逻辑: + 1. 检查 error.type 是否为已知的客户端错误类型 + 2. 检查错误文本是否包含已知的客户端错误模式 + Args: error_text: 错误响应文本 @@ -118,6 +132,19 @@ class ErrorClassifier: if not error_text: return False + # 尝试解析 JSON 并检查 error type + try: + data = json.loads(error_text) + if isinstance(data.get("error"), dict): + error_type = data["error"].get("type", "") + if error_type and any( + t.lower() in error_type.lower() for t in self.CLIENT_ERROR_TYPES + ): + return True + except (json.JSONDecodeError, TypeError, KeyError): + pass + + # 回退到关键词匹配 error_lower = error_text.lower() return any(pattern.lower() in error_lower for pattern in self.CLIENT_ERROR_PATTERNS) diff --git a/src/services/usage/service.py b/src/services/usage/service.py index cc55fee..60e8a25 100644 --- a/src/services/usage/service.py +++ b/src/services/usage/service.py @@ -1394,3 +1394,461 @@ class UsageService: } for r in records ] + + # ========== 缓存亲和性分析方法 ========== + + @staticmethod + def analyze_cache_affinity_ttl( + db: Session, + user_id: Optional[str] = None, + api_key_id: Optional[str] = None, + hours: int = 168, + ) -> Dict[str, Any]: + """ + 分析用户请求间隔分布,推荐合适的缓存亲和性 TTL + + 通过分析同一用户连续请求之间的时间间隔,判断用户的使用模式: + - 高频用户(间隔短):5 分钟 TTL 足够 + - 中频用户:15-30 分钟 TTL + - 低频用户(间隔长):需要 60 分钟 TTL + + Args: + db: 数据库会话 + user_id: 指定用户 ID(可选,为空则分析所有用户) + api_key_id: 指定 API Key ID(可选) + hours: 分析最近多少小时的数据 + + Returns: + 包含分析结果的字典 + """ + from sqlalchemy import text + + # 计算时间范围 + start_date = datetime.now(timezone.utc) - timedelta(hours=hours) + + # 构建 SQL 查询 - 使用窗口函数计算请求间隔 + # 按 user_id 或 api_key_id 分组,计算同一组内连续请求的时间差 + group_by_field = "api_key_id" if api_key_id else "user_id" + + # 构建过滤条件 + filter_clause = "" + if user_id or api_key_id: + filter_clause = f"AND {group_by_field} = :filter_id" + + sql = text(f""" + WITH user_requests AS ( + SELECT + {group_by_field} as group_id, + created_at, + LAG(created_at) OVER ( + PARTITION BY {group_by_field} + ORDER BY created_at + ) as prev_request_at + FROM usage + WHERE status = 'completed' + AND created_at > :start_date + AND {group_by_field} IS NOT NULL + {filter_clause} + ), + intervals AS ( + SELECT + group_id, + EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes + FROM user_requests + WHERE prev_request_at IS NOT NULL + ), + user_stats AS ( + SELECT + group_id, + COUNT(*) as request_count, + COUNT(*) FILTER (WHERE interval_minutes <= 5) as within_5min, + COUNT(*) FILTER (WHERE interval_minutes > 5 AND interval_minutes <= 15) as within_15min, + COUNT(*) FILTER (WHERE interval_minutes > 15 AND interval_minutes <= 30) as within_30min, + COUNT(*) FILTER (WHERE interval_minutes > 30 AND interval_minutes <= 60) as within_60min, + COUNT(*) FILTER (WHERE interval_minutes > 60) as over_60min, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY interval_minutes) as median_interval, + PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY interval_minutes) as p75_interval, + PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY interval_minutes) as p90_interval, + AVG(interval_minutes) as avg_interval, + MIN(interval_minutes) as min_interval, + MAX(interval_minutes) as max_interval + FROM intervals + GROUP BY group_id + HAVING COUNT(*) >= 2 + ) + SELECT * FROM user_stats + ORDER BY request_count DESC + """) + + params: Dict[str, Any] = { + "start_date": start_date, + } + if user_id: + params["filter_id"] = user_id + elif api_key_id: + params["filter_id"] = api_key_id + + result = db.execute(sql, params) + rows = result.fetchall() + + # 收集所有 user_id 以便批量查询用户信息 + group_ids = [row[0] for row in rows] + + # 如果是按 user_id 分组,查询用户信息 + user_info_map: Dict[str, Dict[str, str]] = {} + if group_by_field == "user_id" and group_ids: + users = db.query(User).filter(User.id.in_(group_ids)).all() + for user in users: + user_info_map[str(user.id)] = { + "username": user.username, + "email": user.email or "", + } + + # 处理结果 + users_analysis = [] + for row in rows: + # row 是一个 tuple,按查询顺序访问 + ( + group_id, + request_count, + within_5min, + within_15min, + within_30min, + within_60min, + over_60min, + median_interval, + p75_interval, + p90_interval, + avg_interval, + min_interval, + max_interval, + ) = row + + # 计算推荐 TTL + recommended_ttl = UsageService._calculate_recommended_ttl( + p75_interval, p90_interval + ) + + # 获取用户信息 + user_info = user_info_map.get(str(group_id), {}) + + # 计算各区间占比 + total_intervals = request_count + users_analysis.append({ + "group_id": group_id, + "username": user_info.get("username"), + "email": user_info.get("email"), + "request_count": request_count, + "interval_distribution": { + "within_5min": within_5min, + "within_15min": within_15min, + "within_30min": within_30min, + "within_60min": within_60min, + "over_60min": over_60min, + }, + "interval_percentages": { + "within_5min": round(within_5min / total_intervals * 100, 1), + "within_15min": round(within_15min / total_intervals * 100, 1), + "within_30min": round(within_30min / total_intervals * 100, 1), + "within_60min": round(within_60min / total_intervals * 100, 1), + "over_60min": round(over_60min / total_intervals * 100, 1), + }, + "percentiles": { + "p50": round(float(median_interval), 2) if median_interval else None, + "p75": round(float(p75_interval), 2) if p75_interval else None, + "p90": round(float(p90_interval), 2) if p90_interval else None, + }, + "avg_interval_minutes": round(float(avg_interval), 2) if avg_interval else None, + "min_interval_minutes": round(float(min_interval), 2) if min_interval else None, + "max_interval_minutes": round(float(max_interval), 2) if max_interval else None, + "recommended_ttl_minutes": recommended_ttl, + "recommendation_reason": UsageService._get_ttl_recommendation_reason( + recommended_ttl, p75_interval, p90_interval + ), + }) + + # 汇总统计 + ttl_distribution = {"5min": 0, "15min": 0, "30min": 0, "60min": 0} + for analysis in users_analysis: + ttl = analysis["recommended_ttl_minutes"] + if ttl <= 5: + ttl_distribution["5min"] += 1 + elif ttl <= 15: + ttl_distribution["15min"] += 1 + elif ttl <= 30: + ttl_distribution["30min"] += 1 + else: + ttl_distribution["60min"] += 1 + + return { + "analysis_period_hours": hours, + "total_users_analyzed": len(users_analysis), + "ttl_distribution": ttl_distribution, + "users": users_analysis, + } + + @staticmethod + def _calculate_recommended_ttl( + p75_interval: Optional[float], + p90_interval: Optional[float], + ) -> int: + """ + 根据请求间隔分布计算推荐的缓存 TTL + + 策略: + - 如果 90% 的请求间隔都在 5 分钟内 → 5 分钟 TTL + - 如果 75% 的请求间隔在 15 分钟内 → 15 分钟 TTL + - 如果 75% 的请求间隔在 30 分钟内 → 30 分钟 TTL + - 否则 → 60 分钟 TTL + """ + if p90_interval is None or p75_interval is None: + return 5 # 默认值 + + # 如果 90% 的间隔都在 5 分钟内 + if p90_interval <= 5: + return 5 + + # 如果 75% 的间隔在 15 分钟内 + if p75_interval <= 15: + return 15 + + # 如果 75% 的间隔在 30 分钟内 + if p75_interval <= 30: + return 30 + + # 低频用户,需要更长的 TTL + return 60 + + @staticmethod + def _get_ttl_recommendation_reason( + ttl: int, + p75_interval: Optional[float], + p90_interval: Optional[float], + ) -> str: + """生成 TTL 推荐理由""" + if p75_interval is None or p90_interval is None: + return "数据不足,使用默认值" + + if ttl == 5: + return f"高频用户:90% 的请求间隔在 {p90_interval:.1f} 分钟内" + elif ttl == 15: + return f"中高频用户:75% 的请求间隔在 {p75_interval:.1f} 分钟内" + elif ttl == 30: + return f"中频用户:75% 的请求间隔在 {p75_interval:.1f} 分钟内" + else: + return f"低频用户:75% 的请求间隔为 {p75_interval:.1f} 分钟,建议使用长 TTL" + + @staticmethod + def get_cache_hit_analysis( + db: Session, + user_id: Optional[str] = None, + api_key_id: Optional[str] = None, + hours: int = 168, + ) -> Dict[str, Any]: + """ + 分析缓存命中情况 + + Args: + db: 数据库会话 + user_id: 指定用户 ID(可选) + api_key_id: 指定 API Key ID(可选) + hours: 分析最近多少小时的数据 + + Returns: + 缓存命中分析结果 + """ + start_date = datetime.now(timezone.utc) - timedelta(hours=hours) + + # 基础查询 + query = db.query( + func.count(Usage.id).label("total_requests"), + func.sum(Usage.input_tokens).label("total_input_tokens"), + func.sum(Usage.cache_read_input_tokens).label("total_cache_read_tokens"), + func.sum(Usage.cache_creation_input_tokens).label("total_cache_creation_tokens"), + func.sum(Usage.cache_read_cost_usd).label("total_cache_read_cost"), + func.sum(Usage.cache_creation_cost_usd).label("total_cache_creation_cost"), + ).filter( + Usage.status == "completed", + Usage.created_at >= start_date, + ) + + if user_id: + query = query.filter(Usage.user_id == user_id) + if api_key_id: + query = query.filter(Usage.api_key_id == api_key_id) + + result = query.first() + + total_requests = result.total_requests or 0 + total_input_tokens = result.total_input_tokens or 0 + total_cache_read_tokens = result.total_cache_read_tokens or 0 + total_cache_creation_tokens = result.total_cache_creation_tokens or 0 + total_cache_read_cost = float(result.total_cache_read_cost or 0) + total_cache_creation_cost = float(result.total_cache_creation_cost or 0) + + # 计算缓存命中率(按 token 数) + # 总输入上下文 = input_tokens + cache_read_tokens(因为 input_tokens 不含 cache_read) + # 或者如果 input_tokens 已经包含 cache_read,则直接用 input_tokens + # 这里假设 cache_read_tokens 是额外的,命中率 = cache_read / (input + cache_read) + total_context_tokens = total_input_tokens + total_cache_read_tokens + cache_hit_rate = 0.0 + if total_context_tokens > 0: + cache_hit_rate = total_cache_read_tokens / total_context_tokens * 100 + + # 计算节省的费用 + # 缓存读取价格是正常输入价格的 10%,所以节省了 90% + # 节省 = cache_read_tokens * (正常价格 - 缓存价格) = cache_read_cost * 9 + # 因为 cache_read_cost 是按 10% 价格算的,如果按 100% 算就是 10 倍 + estimated_savings = total_cache_read_cost * 9 # 节省了 90% + + # 统计有缓存命中的请求数 + requests_with_cache_hit = db.query(func.count(Usage.id)).filter( + Usage.status == "completed", + Usage.created_at >= start_date, + Usage.cache_read_input_tokens > 0, + ) + if user_id: + requests_with_cache_hit = requests_with_cache_hit.filter(Usage.user_id == user_id) + if api_key_id: + requests_with_cache_hit = requests_with_cache_hit.filter(Usage.api_key_id == api_key_id) + requests_with_cache_hit = requests_with_cache_hit.scalar() or 0 + + return { + "analysis_period_hours": hours, + "total_requests": total_requests, + "requests_with_cache_hit": requests_with_cache_hit, + "request_cache_hit_rate": round(requests_with_cache_hit / total_requests * 100, 2) if total_requests > 0 else 0, + "total_input_tokens": total_input_tokens, + "total_cache_read_tokens": total_cache_read_tokens, + "total_cache_creation_tokens": total_cache_creation_tokens, + "token_cache_hit_rate": round(cache_hit_rate, 2), + "total_cache_read_cost_usd": round(total_cache_read_cost, 4), + "total_cache_creation_cost_usd": round(total_cache_creation_cost, 4), + "estimated_savings_usd": round(estimated_savings, 4), + } + + @staticmethod + def get_interval_timeline( + db: Session, + hours: int = 168, + limit: int = 1000, + user_id: Optional[str] = None, + include_user_info: bool = False, + ) -> Dict[str, Any]: + """ + 获取请求间隔时间线数据,用于散点图展示 + + Args: + db: 数据库会话 + hours: 分析最近多少小时的数据 + limit: 最大返回数据点数量 + user_id: 指定用户 ID(可选,为空则返回所有用户) + include_user_info: 是否包含用户信息(用于管理员多用户视图) + + Returns: + 包含时间线数据点的字典 + """ + from sqlalchemy import text + + start_date = datetime.now(timezone.utc) - timedelta(hours=hours) + + # 构建用户过滤条件 + user_filter = "AND u.user_id = :user_id" if user_id else "" + + # 根据是否需要用户信息选择不同的查询 + if include_user_info and not user_id: + # 管理员视图:返回带用户信息的数据点 + sql = text(f""" + WITH request_intervals AS ( + SELECT + u.created_at, + u.user_id, + usr.username, + LAG(u.created_at) OVER ( + PARTITION BY u.user_id + ORDER BY u.created_at + ) as prev_request_at + FROM usage u + LEFT JOIN users usr ON u.user_id = usr.id + WHERE u.status = 'completed' + AND u.created_at > :start_date + AND u.user_id IS NOT NULL + {user_filter} + ) + SELECT + created_at, + user_id, + username, + EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes + FROM request_intervals + WHERE prev_request_at IS NOT NULL + AND EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 <= 120 + ORDER BY created_at + LIMIT :limit + """) + else: + # 普通视图:只返回时间和间隔 + sql = text(f""" + WITH request_intervals AS ( + SELECT + u.created_at, + u.user_id, + LAG(u.created_at) OVER ( + PARTITION BY u.user_id + ORDER BY u.created_at + ) as prev_request_at + FROM usage u + WHERE u.status = 'completed' + AND u.created_at > :start_date + AND u.user_id IS NOT NULL + {user_filter} + ) + SELECT + created_at, + EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 as interval_minutes + FROM request_intervals + WHERE prev_request_at IS NOT NULL + AND EXTRACT(EPOCH FROM (created_at - prev_request_at)) / 60.0 <= 120 + ORDER BY created_at + LIMIT :limit + """) + + params: Dict[str, Any] = {"start_date": start_date, "limit": limit} + if user_id: + params["user_id"] = user_id + + result = db.execute(sql, params) + rows = result.fetchall() + + # 转换为时间线数据点 + points = [] + users_map: Dict[str, str] = {} # user_id -> username + + if include_user_info and not user_id: + for row in rows: + created_at, row_user_id, username, interval_minutes = row + points.append({ + "x": created_at.isoformat(), + "y": round(float(interval_minutes), 2), + "user_id": str(row_user_id), + }) + if row_user_id and username: + users_map[str(row_user_id)] = username + else: + for row in rows: + created_at, interval_minutes = row + points.append({ + "x": created_at.isoformat(), + "y": round(float(interval_minutes), 2) + }) + + response: Dict[str, Any] = { + "analysis_period_hours": hours, + "total_points": len(points), + "points": points, + } + + if include_user_info and not user_id: + response["users"] = users_map + + return response From 9c850c4f8469c94fb470587d73b70bbfe8948dcb Mon Sep 17 00:00:00 2001 From: fawney19 Date: Thu, 11 Dec 2025 17:49:54 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E7=9B=91=E6=8E=A7=E4=BB=AA=E8=A1=A8=E6=9D=BF=E5=92=8C?= =?UTF-8?q?=E6=95=A3=E7=82=B9=E5=9B=BE=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/cache.ts | 113 ++++++ .../src/components/charts/ScatterChart.vue | 372 ++++++++++++++++++ frontend/src/views/admin/CacheMonitoring.vue | 332 ++++++++++++---- 3 files changed, 734 insertions(+), 83 deletions(-) create mode 100644 frontend/src/components/charts/ScatterChart.vue diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts index e0bf159..c8d3b59 100644 --- a/frontend/src/api/cache.ts +++ b/frontend/src/api/cache.ts @@ -156,3 +156,116 @@ export const { clearProviderCache, listAffinities } = cacheApi + +// ==================== 缓存亲和性分析 API ==================== + +export interface TTLAnalysisUser { + group_id: string + username: string | null + email: string | null + request_count: number + interval_distribution: { + within_5min: number + within_15min: number + within_30min: number + within_60min: number + over_60min: number + } + interval_percentages: { + within_5min: number + within_15min: number + within_30min: number + within_60min: number + over_60min: number + } + percentiles: { + p50: number | null + p75: number | null + p90: number | null + } + avg_interval_minutes: number | null + min_interval_minutes: number | null + max_interval_minutes: number | null + recommended_ttl_minutes: number + recommendation_reason: string +} + +export interface TTLAnalysisResponse { + analysis_period_hours: number + total_users_analyzed: number + ttl_distribution: { + '5min': number + '15min': number + '30min': number + '60min': number + } + users: TTLAnalysisUser[] +} + +export interface CacheHitAnalysisResponse { + analysis_period_hours: number + total_requests: number + requests_with_cache_hit: number + request_cache_hit_rate: number + total_input_tokens: number + total_cache_read_tokens: number + total_cache_creation_tokens: number + token_cache_hit_rate: number + total_cache_read_cost_usd: number + total_cache_creation_cost_usd: number + estimated_savings_usd: number +} + +export interface IntervalTimelinePoint { + x: string // ISO 时间字符串 + y: number // 间隔分钟数 + user_id?: string // 用户 ID(仅 include_user_info=true 时存在) +} + +export interface IntervalTimelineResponse { + analysis_period_hours: number + total_points: number + points: IntervalTimelinePoint[] + users?: Record // user_id -> username 映射(仅 include_user_info=true 时存在) +} + +export const cacheAnalysisApi = { + /** + * 分析缓存亲和性 TTL 推荐 + */ + async analyzeTTL(params?: { + user_id?: string + api_key_id?: string + hours?: number + }): Promise { + const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params }) + return response.data + }, + + /** + * 分析缓存命中情况 + */ + async analyzeHit(params?: { + user_id?: string + api_key_id?: string + hours?: number + }): Promise { + const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params }) + return response.data + }, + + /** + * 获取请求间隔时间线数据 + * + * @param params.include_user_info 是否包含用户信息(用于管理员多用户视图) + */ + async getIntervalTimeline(params?: { + hours?: number + limit?: number + user_id?: string + include_user_info?: boolean + }): Promise { + const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params }) + return response.data + } +} diff --git a/frontend/src/components/charts/ScatterChart.vue b/frontend/src/components/charts/ScatterChart.vue new file mode 100644 index 0000000..24e43e4 --- /dev/null +++ b/frontend/src/components/charts/ScatterChart.vue @@ -0,0 +1,372 @@ + + + diff --git a/frontend/src/views/admin/CacheMonitoring.vue b/frontend/src/views/admin/CacheMonitoring.vue index 3d9714e..bc0d9aa 100644 --- a/frontend/src/views/admin/CacheMonitoring.vue +++ b/frontend/src/views/admin/CacheMonitoring.vue @@ -12,10 +12,27 @@ import TableRow from '@/components/ui/table-row.vue' import Input from '@/components/ui/input.vue' import Pagination from '@/components/ui/pagination.vue' import RefreshButton from '@/components/ui/refresh-button.vue' -import { Trash2, Eraser, Search, X } from 'lucide-vue-next' +import Select from '@/components/ui/select.vue' +import SelectTrigger from '@/components/ui/select-trigger.vue' +import SelectContent from '@/components/ui/select-content.vue' +import SelectItem from '@/components/ui/select-item.vue' +import SelectValue from '@/components/ui/select-value.vue' +import ScatterChart from '@/components/charts/ScatterChart.vue' +import { Trash2, Eraser, Search, X, BarChart3, ChevronDown, ChevronRight } from 'lucide-vue-next' import { useToast } from '@/composables/useToast' import { useConfirm } from '@/composables/useConfirm' import { cacheApi, type CacheStats, type CacheConfig, type UserAffinity } from '@/api/cache' +import type { TTLAnalysisUser } from '@/api/cache' +import { formatNumber, formatTokens, formatCost, formatRemainingTime } from '@/utils/format' +import { + useTTLAnalysis, + ANALYSIS_HOURS_OPTIONS, + getTTLBadgeVariant, + getFrequencyLabel, + getFrequencyClass +} from '@/composables/useTTLAnalysis' + +// ==================== 缓存统计与亲和性列表 ==================== const stats = ref(null) const config = ref(null) @@ -27,28 +44,40 @@ const matchedUserId = ref(null) const clearingRowAffinityKey = ref(null) const currentPage = ref(1) const pageSize = ref(20) +const currentTime = ref(Math.floor(Date.now() / 1000)) + const { success: showSuccess, error: showError, info: showInfo } = useToast() const { confirm: showConfirm } = useConfirm() -const currentTime = ref(Math.floor(Date.now() / 1000)) let searchDebounceTimer: ReturnType | null = null let skipNextKeywordWatch = false let countdownTimer: ReturnType | null = null -// 计算分页后的数据 +// ==================== TTL 分析 (使用 composable) ==================== + +const { + ttlAnalysis, + hitAnalysis, + ttlAnalysisLoading, + analysisHours, + expandedUserId, + userTimelineData, + userTimelineLoading, + userTimelineChartData, + toggleUserExpand, + refreshAnalysis +} = useTTLAnalysis() + +// ==================== 计算属性 ==================== + const paginatedAffinityList = computed(() => { const start = (currentPage.value - 1) * pageSize.value const end = start + pageSize.value return affinityList.value.slice(start, end) }) -// 页码变化处理 -function handlePageChange() { - // 分页变化时滚动到顶部 - window.scrollTo({ top: 0, behavior: 'smooth' }) -} +// ==================== 缓存统计方法 ==================== -// 获取缓存统计 async function fetchCacheStats() { loading.value = true try { @@ -61,7 +90,6 @@ async function fetchCacheStats() { } } -// 获取缓存配置 async function fetchCacheConfig() { try { config.value = await cacheApi.getConfig() @@ -70,7 +98,6 @@ async function fetchCacheConfig() { } } -// 获取缓存亲和性列表 async function fetchAffinityList(keyword?: string) { listLoading.value = true try { @@ -107,17 +134,14 @@ async function resetAffinitySearch() { await fetchAffinityList() } -// 清除缓存(按 affinity_key 或用户标识符) async function clearUserCache(identifier: string, displayName?: string) { const target = identifier?.trim() - if (!target) { showError('无法识别标识符') return } const label = displayName || target - const confirmed = await showConfirm({ title: '确认清除', message: `确定要清除 ${label} 的缓存吗?`, @@ -125,12 +149,9 @@ async function clearUserCache(identifier: string, displayName?: string) { variant: 'destructive' }) - if (!confirmed) { - return - } + if (!confirmed) return clearingRowAffinityKey.value = target - try { await cacheApi.clearUserCache(target) showSuccess('清除成功') @@ -144,7 +165,6 @@ async function clearUserCache(identifier: string, displayName?: string) { } } -// 清除所有缓存 async function clearAllCache() { const firstConfirm = await showConfirm({ title: '危险操作', @@ -152,10 +172,7 @@ async function clearAllCache() { confirmText: '继续', variant: 'destructive' }) - - if (!firstConfirm) { - return - } + if (!firstConfirm) return const secondConfirm = await showConfirm({ title: '再次确认', @@ -163,10 +180,7 @@ async function clearAllCache() { confirmText: '确认清除', variant: 'destructive' }) - - if (!secondConfirm) { - return - } + if (!secondConfirm) return try { await cacheApi.clearAllCache() @@ -179,33 +193,39 @@ async function clearAllCache() { } } -// 计算剩余时间(使用实时更新的 currentTime) -function getRemainingTime(expireAt?: number) { - if (!expireAt) return '未知' - const remaining = expireAt - currentTime.value - if (remaining <= 0) return '已过期' +// ==================== 工具方法 ==================== - const minutes = Math.floor(remaining / 60) - const seconds = Math.floor(remaining % 60) - return `${minutes}分${seconds}秒` +function getRemainingTime(expireAt?: number): string { + return formatRemainingTime(expireAt, currentTime.value) } -// 启动倒计时定时器 -function startCountdown() { - if (countdownTimer) { - clearInterval(countdownTimer) +function formatIntervalDescription(user: TTLAnalysisUser): string { + const p90 = user.percentiles.p90 + if (p90 === null || p90 === undefined) return '-' + if (p90 < 1) { + const seconds = Math.round(p90 * 60) + return `90% 请求间隔 < ${seconds} 秒` } + return `90% 请求间隔 < ${p90.toFixed(1)} 分钟` +} + +function handlePageChange() { + window.scrollTo({ top: 0, behavior: 'smooth' }) +} + +// ==================== 定时器管理 ==================== + +function startCountdown() { + if (countdownTimer) clearInterval(countdownTimer) countdownTimer = setInterval(() => { currentTime.value = Math.floor(Date.now() / 1000) - // 过滤掉已过期的项目 const beforeCount = affinityList.value.length - affinityList.value = affinityList.value.filter(item => { - return item.expire_at && item.expire_at > currentTime.value - }) + affinityList.value = affinityList.value.filter( + item => item.expire_at && item.expire_at > currentTime.value + ) - // 如果有项目被移除,显示提示 if (beforeCount > affinityList.value.length) { const removedCount = beforeCount - affinityList.value.length showInfo(`${removedCount} 个缓存已自动过期移除`) @@ -213,7 +233,6 @@ function startCountdown() { }, 1000) } -// 停止倒计时定时器 function stopCountdown() { if (countdownTimer) { clearInterval(countdownTimer) @@ -221,15 +240,25 @@ function stopCountdown() { } } +// ==================== 刷新所有数据 ==================== + +async function refreshData() { + await Promise.all([ + fetchCacheStats(), + fetchCacheConfig(), + fetchAffinityList() + ]) +} + +// ==================== 生命周期 ==================== + watch(tableKeyword, (value) => { if (skipNextKeywordWatch) { skipNextKeywordWatch = false return } - if (searchDebounceTimer) { - clearTimeout(searchDebounceTimer) - } + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) const keyword = value.trim() searchDebounceTimer = setTimeout(() => { @@ -243,21 +272,11 @@ onMounted(() => { fetchCacheConfig() fetchAffinityList() startCountdown() + refreshAnalysis() }) -// 刷新所有数据 -async function refreshData() { - await Promise.all([ - fetchCacheStats(), - fetchCacheConfig(), - fetchAffinityList() - ]) -} - onBeforeUnmount(() => { - if (searchDebounceTimer) { - clearTimeout(searchDebounceTimer) - } + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) stopCountdown() }) @@ -272,31 +291,18 @@ onBeforeUnmount(() => {

- +
- -
命中率
-
- {{ stats ? (stats.affinity_stats.cache_hit_rate * 100).toFixed(1) : '0.0' }}% -
-
- {{ stats?.affinity_stats?.cache_hits || 0 }} / {{ (stats?.affinity_stats?.cache_hits || 0) + (stats?.affinity_stats?.cache_misses || 0) }} -
-
- - - -
活跃缓存
+
活跃亲和性
- {{ stats?.affinity_stats?.total_affinities || 0 }} + {{ stats?.affinity_stats?.active_affinities || 0 }}
TTL {{ config?.cache_ttl_seconds || 300 }}s
-
Provider 切换
@@ -307,7 +313,16 @@ onBeforeUnmount(() => {
- + +
缓存失效
+
+ {{ stats?.affinity_stats?.cache_invalidations || 0 }} +
+
+ 因 Provider 不可用 +
+
+
预留比例 @@ -322,14 +337,13 @@ onBeforeUnmount(() => {
- 失效 {{ stats?.affinity_stats?.cache_invalidations || 0 }} + 当前 {{ stats ? (stats.cache_reservation_ratio * 100).toFixed(0) : '-' }}%
-
@@ -365,8 +379,8 @@ onBeforeUnmount(() => { - 用户 - Key + 用户 + Key Provider 模型 API 格式 / Key @@ -380,12 +394,12 @@ onBeforeUnmount(() => {
独立 - {{ item.username || '未知' }} + {{ item.username || '未知' }}
- {{ item.user_api_key_name || '未命名' }} + {{ item.user_api_key_name || '未命名' }} {{ item.rate_multiplier }}x
{{ item.user_api_key_prefix || '---' }}
@@ -439,5 +453,157 @@ onBeforeUnmount(() => { @update:page-size="pageSize = $event" /> + + + +
+
+
+ +

TTL 分析

+ 分析用户请求间隔,推荐合适的缓存 TTL +
+
+ +
+
+
+ + +
+
+
+
请求命中率
+
{{ hitAnalysis.request_cache_hit_rate }}%
+
{{ formatNumber(hitAnalysis.requests_with_cache_hit) }} / {{ formatNumber(hitAnalysis.total_requests) }} 请求
+
+
+
Token 命中率
+
{{ hitAnalysis.token_cache_hit_rate }}%
+
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens 命中
+
+
+
缓存创建费用
+
{{ formatCost(hitAnalysis.total_cache_creation_cost_usd) }}
+
{{ formatTokens(hitAnalysis.total_cache_creation_tokens) }} tokens
+
+
+
缓存读取费用
+
{{ formatCost(hitAnalysis.total_cache_read_cost_usd) }}
+
{{ formatTokens(hitAnalysis.total_cache_read_tokens) }} tokens
+
+
+
预估节省
+
{{ formatCost(hitAnalysis.estimated_savings_usd) }}
+
+
+
+ + +
+ + + + 用户 + 请求数 + 使用频率 + 推荐 TTL + 说明 + + + + + +
+ + +
+ +

+ 未找到符合条件的用户数据 +

+

+ 尝试增加分析天数或降低最小请求数阈值 +

+
+ + +
+

正在分析用户请求数据...

+
+
From cc4e28ad1606fdd85515137e83e2cba7f02e696d Mon Sep 17 00:00:00 2001 From: fawney19 Date: Thu, 11 Dec 2025 17:52:32 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E7=BB=9F=E8=AE=A1=E5=92=8C=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=88=86=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api/me.ts | 13 ++ .../src/components/stats/ActivityHeatmap.vue | 2 +- frontend/src/composables/useTTLAnalysis.ts | 208 ++++++++++++++++++ .../usage/components/IntervalTimelineCard.vue | 205 +++++++++++++++++ .../src/features/usage/components/index.ts | 1 + frontend/src/utils/format.ts | 17 ++ frontend/src/views/shared/Usage.vue | 20 +- 7 files changed, 459 insertions(+), 7 deletions(-) create mode 100644 frontend/src/composables/useTTLAnalysis.ts create mode 100644 frontend/src/features/usage/components/IntervalTimelineCard.vue diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index 2b2357b..7d2b5c3 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -253,5 +253,18 @@ export const meApi = { }> { const response = await apiClient.put('/api/users/me/model-capabilities', data) return response.data + }, + + // 获取请求间隔时间线(用于散点图) + async getIntervalTimeline(params?: { + hours?: number + limit?: number + }): Promise<{ + analysis_period_hours: number + total_points: number + points: Array<{ x: string; y: number }> + }> { + const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params }) + return response.data } } diff --git a/frontend/src/components/stats/ActivityHeatmap.vue b/frontend/src/components/stats/ActivityHeatmap.vue index c63680b..ee1a048 100644 --- a/frontend/src/components/stats/ActivityHeatmap.vue +++ b/frontend/src/components/stats/ActivityHeatmap.vue @@ -188,7 +188,7 @@ const monthMarkers = computed(() => { if (month === lastMonth) { return } - markers[index] = String(month + 1) + markers[index] = `${month + 1}月` lastMonth = month }) diff --git a/frontend/src/composables/useTTLAnalysis.ts b/frontend/src/composables/useTTLAnalysis.ts new file mode 100644 index 0000000..71d8d96 --- /dev/null +++ b/frontend/src/composables/useTTLAnalysis.ts @@ -0,0 +1,208 @@ +/** + * TTL 分析 composable + * 封装缓存亲和性 TTL 分析相关的状态和逻辑 + */ +import { ref, computed, watch } from 'vue' +import { useToast } from '@/composables/useToast' +import { + cacheAnalysisApi, + type TTLAnalysisResponse, + type CacheHitAnalysisResponse, + type IntervalTimelineResponse +} from '@/api/cache' +import type { ChartData } from 'chart.js' + +// 时间范围选项 +export const ANALYSIS_HOURS_OPTIONS = [ + { value: '12', label: '12 小时' }, + { value: '24', label: '24 小时' }, + { value: '72', label: '3 天' }, + { value: '168', label: '7 天' }, + { value: '336', label: '14 天' }, + { value: '720', label: '30 天' } +] as const + +// 间隔颜色配置 +export const INTERVAL_COLORS = { + short: 'rgba(34, 197, 94, 0.6)', // green: 0-5 分钟 + medium: 'rgba(59, 130, 246, 0.6)', // blue: 5-15 分钟 + normal: 'rgba(168, 85, 247, 0.6)', // purple: 15-30 分钟 + long: 'rgba(249, 115, 22, 0.6)', // orange: 30-60 分钟 + veryLong: 'rgba(239, 68, 68, 0.6)' // red: >60 分钟 +} as const + +/** + * 根据间隔时间获取对应的颜色 + */ +export function getIntervalColor(interval: number): string { + if (interval <= 5) return INTERVAL_COLORS.short + if (interval <= 15) return INTERVAL_COLORS.medium + if (interval <= 30) return INTERVAL_COLORS.normal + if (interval <= 60) return INTERVAL_COLORS.long + return INTERVAL_COLORS.veryLong +} + +/** + * 获取 TTL 推荐的 Badge 样式 + */ +export function getTTLBadgeVariant(ttl: number): 'default' | 'secondary' | 'outline' | 'destructive' { + if (ttl <= 5) return 'default' + if (ttl <= 15) return 'secondary' + if (ttl <= 30) return 'outline' + return 'destructive' +} + +/** + * 获取使用频率标签 + */ +export function getFrequencyLabel(ttl: number): string { + if (ttl <= 5) return '高频' + if (ttl <= 15) return '中高频' + if (ttl <= 30) return '中频' + return '低频' +} + +/** + * 获取使用频率样式类名 + */ +export function getFrequencyClass(ttl: number): string { + if (ttl <= 5) return 'text-success font-medium' + if (ttl <= 15) return 'text-blue-500 font-medium' + if (ttl <= 30) return 'text-muted-foreground' + return 'text-destructive' +} + +export function useTTLAnalysis() { + const { error: showError, info: showInfo } = useToast() + + // 状态 + const ttlAnalysis = ref(null) + const hitAnalysis = ref(null) + const ttlAnalysisLoading = ref(false) + const hitAnalysisLoading = ref(false) + const analysisHours = ref('24') + + // 用户散点图展开状态 + const expandedUserId = ref(null) + const userTimelineData = ref(null) + const userTimelineLoading = ref(false) + + // 计算属性:是否正在加载 + const isLoading = computed(() => ttlAnalysisLoading.value || hitAnalysisLoading.value) + + // 获取 TTL 分析数据 + async function fetchTTLAnalysis() { + ttlAnalysisLoading.value = true + try { + const hours = parseInt(analysisHours.value) + const result = await cacheAnalysisApi.analyzeTTL({ hours }) + ttlAnalysis.value = result + + if (result.total_users_analyzed === 0) { + const periodText = hours >= 24 ? `${hours / 24} 天` : `${hours} 小时` + showInfo(`未找到符合条件的数据(最近 ${periodText})`) + } + } catch (error) { + showError('获取 TTL 分析失败') + console.error(error) + } finally { + ttlAnalysisLoading.value = false + } + } + + // 获取缓存命中分析数据 + async function fetchHitAnalysis() { + hitAnalysisLoading.value = true + try { + hitAnalysis.value = await cacheAnalysisApi.analyzeHit({ + hours: parseInt(analysisHours.value) + }) + } catch (error) { + showError('获取缓存命中分析失败') + console.error(error) + } finally { + hitAnalysisLoading.value = false + } + } + + // 获取指定用户的时间线数据 + async function fetchUserTimeline(userId: string) { + userTimelineLoading.value = true + try { + userTimelineData.value = await cacheAnalysisApi.getIntervalTimeline({ + hours: parseInt(analysisHours.value), + limit: 2000, + user_id: userId + }) + } catch (error) { + showError('获取用户时间线数据失败') + console.error(error) + } finally { + userTimelineLoading.value = false + } + } + + // 切换用户行展开状态 + async function toggleUserExpand(userId: string) { + if (expandedUserId.value === userId) { + expandedUserId.value = null + userTimelineData.value = null + } else { + expandedUserId.value = userId + await fetchUserTimeline(userId) + } + } + + // 刷新所有分析数据 + async function refreshAnalysis() { + expandedUserId.value = null + userTimelineData.value = null + await Promise.all([fetchTTLAnalysis(), fetchHitAnalysis()]) + } + + // 用户时间线散点图数据 + const userTimelineChartData = computed>(() => { + if (!userTimelineData.value || userTimelineData.value.points.length === 0) { + return { datasets: [] } + } + + const points = userTimelineData.value.points + + return { + datasets: [{ + label: '请求间隔', + data: points.map(p => ({ x: p.x, y: p.y })), + backgroundColor: points.map(p => getIntervalColor(p.y)), + borderColor: points.map(p => getIntervalColor(p.y).replace('0.6', '1')), + pointRadius: 3, + pointHoverRadius: 5 + }] + } + }) + + // 监听时间范围变化 + watch(analysisHours, () => { + refreshAnalysis() + }) + + return { + // 状态 + ttlAnalysis, + hitAnalysis, + ttlAnalysisLoading, + hitAnalysisLoading, + analysisHours, + expandedUserId, + userTimelineData, + userTimelineLoading, + isLoading, + userTimelineChartData, + + // 方法 + fetchTTLAnalysis, + fetchHitAnalysis, + fetchUserTimeline, + toggleUserExpand, + refreshAnalysis + } +} diff --git a/frontend/src/features/usage/components/IntervalTimelineCard.vue b/frontend/src/features/usage/components/IntervalTimelineCard.vue new file mode 100644 index 0000000..d9a932b --- /dev/null +++ b/frontend/src/features/usage/components/IntervalTimelineCard.vue @@ -0,0 +1,205 @@ + + + diff --git a/frontend/src/features/usage/components/index.ts b/frontend/src/features/usage/components/index.ts index e41b01c..c477f01 100644 --- a/frontend/src/features/usage/components/index.ts +++ b/frontend/src/features/usage/components/index.ts @@ -5,3 +5,4 @@ export { default as UsageRecordsTable } from './UsageRecordsTable.vue' export { default as ActivityHeatmapCard } from './ActivityHeatmapCard.vue' export { default as RequestDetailDrawer } from './RequestDetailDrawer.vue' export { default as HorizontalRequestTimeline } from './HorizontalRequestTimeline.vue' +export { default as IntervalTimelineCard } from './IntervalTimelineCard.vue' diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 460cc9e..0865876 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -125,4 +125,21 @@ export function formatBillingType(type: string | undefined | null): string { 'free_tier': '免费套餐' } return typeMap[type || ''] || type || '按量付费' +} + +// Format cost with 4 decimal places (for cache analysis) +export function formatCost(cost: number | null | undefined): string { + if (cost === null || cost === undefined) return '-' + return `$${cost.toFixed(4)}` +} + +// Format remaining time from unix timestamp +export function formatRemainingTime(expireAt: number | undefined, currentTime: number): string { + if (!expireAt) return '未知' + const remaining = expireAt - currentTime + if (remaining <= 0) return '已过期' + + const minutes = Math.floor(remaining / 60) + const seconds = Math.floor(remaining % 60) + return `${minutes}分${seconds}秒` } \ No newline at end of file diff --git a/frontend/src/views/shared/Usage.vue b/frontend/src/views/shared/Usage.vue index efa5a80..6a0ba19 100644 --- a/frontend/src/views/shared/Usage.vue +++ b/frontend/src/views/shared/Usage.vue @@ -1,10 +1,17 @@