diff --git a/app/api/routers/rbac_opc.py b/app/api/routers/rbac_opc.py index 9f235e5..66704d4 100644 --- a/app/api/routers/rbac_opc.py +++ b/app/api/routers/rbac_opc.py @@ -366,6 +366,134 @@ async def opc_compute_usage(user: dict = Depends(require_roles("opc_member"))): return data +@router.get("/compute/usage/logs", summary="我的消耗明细(分页)") +async def opc_compute_usage_logs( + page: int = 1, + page_size: int = 20, + user: dict = Depends(require_roles("opc_member")), +): + """消耗流水明细,支持分页。""" + try: + data = await compute_client.user_logs(user.get("username"), page=page, page_size=page_size) + except compute_client.ComputeError as exc: + raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc + + items = data if isinstance(data, list) else data.get("items", []) + total = data.get("total", len(items)) if isinstance(data, dict) else len(items) + + # 统一字段格式(snake_case → 前端期望的格式) + out = [] + for it in items or []: + it = dict(it) + out.append({ + "id": it.get("id") or it.get("log_id") or "", + "time": it.get("created_at") or it.get("time") or it.get("created_time") or "", + "model": it.get("model_name") or it.get("model") or "", + "type": it.get("type") or it.get("mode") or "chat", + "input_tokens": it.get("input_tokens") or it.get("prompt_tokens") or 0, + "output_tokens": it.get("output_tokens") or it.get("completion_tokens") or 0, + "cost": it.get("cost") or it.get("cost_quota") or 0, + "status": it.get("status") or "success", + }) + + return {"items": out, "total": total, "page": page, "page_size": page_size} + + +@router.get("/compute/usage/trend", summary="消耗趋势统计(近7天/模型分布/时段分布)") +async def opc_compute_usage_trend( + days: int = 7, + user: dict = Depends(require_roles("opc_member")), +): + """消耗趋势统计:近N天消耗趋势、模型分布、时段分布。""" + from collections import defaultdict + from datetime import datetime, timedelta + + try: + # 获取最近的消耗日志(最多取 500 条用于统计) + data = await compute_client.user_logs(user.get("username"), page=1, page_size=500) + except compute_client.ComputeError as exc: + # 引擎对接失败时返回空数据,不报错 + data = {"items": []} + + items = data if isinstance(data, list) else data.get("items", []) + + # 近N天消耗趋势 + now = datetime.now() + trend_map = {} + for i in range(days): + d = (now - timedelta(days=days - 1 - i)).strftime("%m-%d") + trend_map[d] = {"date": d, "cost": 0, "tokens": 0} + + # 模型分布 + model_dist = defaultdict(lambda: {"name": "", "value": 0, "cost": 0}) + + # 时段分布(24小时) + hourly_dist = [{"hour": f"{h:02d}", "cost": 0} for h in range(24)] + + total_cost = 0 + total_tokens = 0 + + for it in items or []: + it = dict(it) + # 解析时间 + time_str = it.get("created_at") or it.get("time") or it.get("created_time") or "" + cost = float(it.get("cost") or it.get("cost_quota") or 0) + input_tokens = int(it.get("input_tokens") or it.get("prompt_tokens") or 0) + output_tokens = int(it.get("output_tokens") or it.get("completion_tokens") or 0) + tokens = input_tokens + output_tokens + model_name = it.get("model_name") or it.get("model") or "unknown" + + total_cost += cost + total_tokens += tokens + + # 趋势统计 + if time_str: + try: + dt = datetime.fromisoformat(time_str.replace("Z", "+00:00")) if "T" in time_str else datetime.strptime(time_str[:10], "%Y-%m-%d") + date_key = dt.strftime("%m-%d") + if date_key in trend_map: + trend_map[date_key]["cost"] += cost + trend_map[date_key]["tokens"] += tokens + + # 时段统计 + hour = dt.hour + hourly_dist[hour]["cost"] += cost + except (ValueError, TypeError): + pass + + # 模型分布统计 + model_dist[model_name]["name"] = model_name + model_dist[model_name]["value"] += 1 + model_dist[model_name]["cost"] += cost + + # 模型分布取 Top 5,其余归为"其他" + model_list = sorted(model_dist.values(), key=lambda x: x["value"], reverse=True) + if len(model_list) > 5: + top5 = model_list[:5] + others = model_list[5:] + other_value = sum(x["value"] for x in others) + other_cost = sum(x["cost"] for x in others) + top5.append({"name": "其他", "value": other_value, "cost": other_cost}) + model_list = top5 + + # 模型分布颜色 + colors = ["#6284ff", "#8f7bff", "#3fb68b", "#e8830c", "#e0526e", "#38a8e0"] + for i, m in enumerate(model_list): + m["color"] = colors[i % len(colors)] + + return { + "trend": list(trend_map.values()), + "model_distribution": model_list, + "hourly_distribution": hourly_dist, + "summary": { + "total_cost": total_cost, + "total_tokens": total_tokens, + "total_requests": len(items or []), + "days": days, + }, + } + + @router.get("/compute/balance", summary="我的算力余额") async def opc_compute_balance(user: dict = Depends(require_roles("opc_member"))): try: diff --git a/app/services/compute_client.py b/app/services/compute_client.py index cd92ccf..574b15b 100644 --- a/app/services/compute_client.py +++ b/app/services/compute_client.py @@ -174,6 +174,19 @@ async def user_usage(username: str) -> dict: return data.get("data") or {} +async def user_logs(username: str, page: int = 1, page_size: int = 50) -> dict: + """用户消耗流水/日志(PAT 鉴权)。""" + key = await _user_key(username) + if not key: + return {"items": [], "total": 0} + data = await _request( + method="GET", + path=f"/api/log/self?page={page}&page_size={page_size}", + headers={"Authorization": f"Bearer {key}"}, + ) + return data.get("data") or data + + async def delete_token(token_id: int) -> dict: """删除引擎令牌。""" return await _request(