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 @@
+
+
+
+
+
Y = {{ crosshairStats.yValue.toFixed(1) }} 分钟
+
+ {{ crosshairStats.belowCount }} / {{ crosshairStats.totalCount }} 点在横线以下
+ ({{ crosshairStats.belowPercent.toFixed(1) }}%)
+
+
+
+
+
+
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
+ 说明
+
+
+
+
+
+
+
+
+
+ {{ user.username || '未知用户' }}
+
+
+ {{ user.request_count }}
+
+
+
+ {{ getFrequencyLabel(user.recommended_ttl_minutes) }}
+
+
+
+
+ {{ user.recommended_ttl_minutes }} 分钟
+
+
+
+
+ {{ formatIntervalDescription(user) }}
+
+
+
+
+
+
+
+
+
请求间隔时间线
+
+ 0-5分钟
+ 5-15分钟
+ 15-30分钟
+ 30-60分钟
+ >60分钟
+ 共 {{ userTimelineData.total_points }} 个数据点
+
+
+
+ 加载中...
+
+
+
+
+
+ 暂无数据
+
+
+
+
+
+
+
+
+
+
+
+
+ 未找到符合条件的用户数据
+
+
+ 尝试增加分析天数或降低最小请求数阈值
+
+
+
+
+
+
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 @@
-
-
+
+
@@ -87,7 +94,8 @@ import {
UsageApiFormatTable,
UsageRecordsTable,
ActivityHeatmapCard,
- RequestDetailDrawer
+ RequestDetailDrawer,
+ IntervalTimelineCard
} from '@/features/usage/components'
import {
useUsageData,
From 7de1926fc8809e984975e164f0a41e42b5666fb9 Mon Sep 17 00:00:00 2001
From: fawney19
Date: Thu, 11 Dec 2025 17:53:35 +0800
Subject: [PATCH 4/5] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=E4=BE=9D=E8=B5=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/package-lock.json | 22 ++++++++++++++++++++++
frontend/package.json | 2 ++
2 files changed, 24 insertions(+)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 06bd399..633fa18 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,8 +14,10 @@
"@vueuse/core": "^13.9.0",
"axios": "^1.12.1",
"chart.js": "^4.5.0",
+ "chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.544.0",
@@ -2723,6 +2725,16 @@
"pnpm": ">=8"
}
},
+ "node_modules/chartjs-adapter-date-fns": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
+ "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": ">=2.8.0",
+ "date-fns": ">=2.0.0"
+ }
+ },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -2923,6 +2935,16 @@
"node": ">=20"
}
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 85fd474..5bbd977 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,8 +21,10 @@
"@vueuse/core": "^13.9.0",
"axios": "^1.12.1",
"chart.js": "^4.5.0",
+ "chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"highlight.js": "^11.11.1",
"lucide-vue-next": "^0.544.0",
From 3b8a55adea9d55d733d0794c4c8c9d54a089da61 Mon Sep 17 00:00:00 2001
From: fawney19
Date: Thu, 11 Dec 2025 18:16:19 +0800
Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E9=83=A8?=
=?UTF-8?q?=E7=BD=B2=E6=96=87=E6=A1=A3=E5=92=8C=E9=85=8D=E7=BD=AE=EF=BC=8C?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E9=A2=84=E6=9E=84=E5=BB=BA=E9=95=9C=E5=83=8F?=
=?UTF-8?q?=E5=92=8C=E6=9C=AC=E5=9C=B0=E6=9E=84=E5=BB=BA=E4=B8=A4=E7=A7=8D?=
=?UTF-8?q?=E6=96=B9=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/workflows/docker-publish.yml | 135 +++++++++++++++++++++++++++
README.md | 22 +++--
deploy.sh | 4 +-
docker-compose.build.yml | 78 ++++++++++++++++
docker-compose.yml | 10 +-
5 files changed, 234 insertions(+), 15 deletions(-)
create mode 100644 .github/workflows/docker-publish.yml
create mode 100644 docker-compose.build.yml
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..a3b5f09
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,135 @@
+name: Build and Publish Docker Image
+
+on:
+ push:
+ branches: [master, main]
+ tags: ['v*']
+ pull_request:
+ branches: [master, main]
+ workflow_dispatch:
+ inputs:
+ build_base:
+ description: 'Rebuild base image'
+ required: false
+ default: false
+ type: boolean
+
+env:
+ REGISTRY: ghcr.io
+ BASE_IMAGE_NAME: ${{ github.repository }}-base
+ APP_IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ check-base-changes:
+ runs-on: ubuntu-latest
+ outputs:
+ base_changed: ${{ steps.check.outputs.base_changed }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 2
+
+ - name: Check if base image needs rebuild
+ id: check
+ run: |
+ if [ "${{ github.event.inputs.build_base }}" == "true" ]; then
+ echo "base_changed=true" >> $GITHUB_OUTPUT
+ exit 0
+ fi
+
+ # Check if base-related files changed
+ if git diff --name-only HEAD~1 HEAD | grep -qE '^(Dockerfile\.base|pyproject\.toml|frontend/package.*\.json)$'; then
+ echo "base_changed=true" >> $GITHUB_OUTPUT
+ else
+ echo "base_changed=false" >> $GITHUB_OUTPUT
+ fi
+
+ build-base:
+ needs: check-base-changes
+ if: needs.check-base-changes.outputs.base_changed == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata for base image
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest
+ type=sha,prefix=
+
+ - name: Build and push base image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile.base
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ platforms: linux/amd64,linux/arm64
+
+ build-app:
+ needs: [check-base-changes, build-base]
+ if: always() && (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata for app image
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=
+
+ - name: Update Dockerfile.app to use registry base image
+ run: |
+ sed -i "s|FROM aether-base:latest|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest|g" Dockerfile.app
+
+ - name: Build and push app image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile.app
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ platforms: linux/amd64,linux/arm64
diff --git a/README.md b/README.md
index 2faf73d..693815d 100644
--- a/README.md
+++ b/README.md
@@ -46,7 +46,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
## 部署
-### Docker Compose(推荐)
+### Docker Compose(推荐:预构建镜像)
```bash
# 1. 克隆代码
@@ -58,16 +58,24 @@ cp .env.example .env
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
# 3. 部署
-./deploy.sh # 自动构建、启动、迁移
+docker-compose up -d
+
+# 4. 更新
+docker-compose pull && docker-compose up -d
```
-### 更新
+### Docker Compose(本地构建镜像)
```bash
-# 拉取最新代码
-git pull
+# 1. 克隆代码
+git clone https://github.com/fawney19/Aether.git
+cd aether
-# 自动部署脚本
+# 2. 配置环境变量
+cp .env.example .env
+python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
+
+# 3. 部署 / 更新(自动构建、启动、迁移)
./deploy.sh
```
@@ -75,7 +83,7 @@ git pull
```bash
# 启动依赖
-docker-compose up -d postgres redis
+docker-compose -f docker-compose.build.yml up -d postgres redis
# 后端
uv sync
diff --git a/deploy.sh b/deploy.sh
index 233396e..6e039d3 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -11,9 +11,9 @@ cd "$(dirname "$0")"
# 兼容 docker-compose 和 docker compose
if command -v docker-compose &> /dev/null; then
- DC="docker-compose"
+ DC="docker-compose -f docker-compose.build.yml"
else
- DC="docker compose"
+ DC="docker compose -f docker-compose.build.yml"
fi
# 缓存文件
diff --git a/docker-compose.build.yml b/docker-compose.build.yml
new file mode 100644
index 0000000..e89a878
--- /dev/null
+++ b/docker-compose.build.yml
@@ -0,0 +1,78 @@
+# Aether 部署配置 - 本地构建
+# 使用方法:
+# 首次构建 base: docker build -f Dockerfile.base -t aether-base:latest .
+# 启动服务: docker-compose -f docker-compose.build.yml up -d --build
+
+services:
+ postgres:
+ image: postgres:15
+ container_name: aether-postgres
+ environment:
+ POSTGRES_DB: aether
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: ${DB_PASSWORD}
+ TZ: Asia/Shanghai
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ ports:
+ - "${DB_PORT:-5432}:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+ redis:
+ image: redis:7-alpine
+ container_name: aether-redis
+ command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
+ volumes:
+ - redis_data:/data
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ restart: unless-stopped
+
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile.app
+ image: aether-app:latest
+ container_name: aether-app
+ environment:
+ DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
+ REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
+ PORT: 8084
+ JWT_SECRET_KEY: ${JWT_SECRET_KEY}
+ ENCRYPTION_KEY: ${ENCRYPTION_KEY}
+ JWT_ALGORITHM: HS256
+ JWT_EXPIRATION_DELTA: 86400
+ LOG_LEVEL: ${LOG_LEVEL:-INFO}
+ ADMIN_EMAIL: ${ADMIN_EMAIL}
+ ADMIN_USERNAME: ${ADMIN_USERNAME}
+ ADMIN_PASSWORD: ${ADMIN_PASSWORD}
+ API_KEY_PREFIX: ${API_KEY_PREFIX:-sk}
+ GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
+ TZ: Asia/Shanghai
+ PYTHONIOENCODING: utf-8
+ LANG: C.UTF-8
+ LC_ALL: C.UTF-8
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ ports:
+ - "${APP_PORT:-8084}:80"
+ volumes:
+ - ./logs:/app/logs
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
+ redis_data:
diff --git a/docker-compose.yml b/docker-compose.yml
index efcd7b5..60afccf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,5 @@
-# Aether 部署配置
-# 使用 ./deploy.sh 自动部署
+# Aether 部署配置 - 使用预构建镜像
+# 使用方法: docker-compose up -d
services:
postgres:
@@ -37,7 +37,7 @@ services:
restart: unless-stopped
app:
- image: aether-app:latest
+ image: ghcr.io/fawney19/aether:latest
container_name: aether-app
environment:
DATABASE_URL: postgresql://postgres:${DB_PASSWORD}@postgres:5432/aether
@@ -65,11 +65,9 @@ services:
ports:
- "${APP_PORT:-8084}:80"
volumes:
- # 挂载日志目录到主机,便于调试和持久化
- ./logs:/app/logs
restart: unless-stopped
-
volumes:
postgres_data:
- redis_data:
\ No newline at end of file
+ redis_data: