Files
Neo-ZQYY/apps/backend/app/routers/admin_dev_trace.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

375 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
管理端 — 开发调试全链路日志路由。
端点清单8 个,全部需要 JWT + admin 角色):
- GET /api/admin/dev-trace/dates — 有日志数据的日期列表
- GET /api/admin/dev-trace/requests — 按条件分页查询请求列表
- GET /api/admin/dev-trace/request/{id} — 指定 request_id 的完整 trace
- POST /api/admin/dev-trace/cleanup — 按日期范围手动清理日志
- GET /api/admin/dev-trace/settings — 当前设置
- PUT /api/admin/dev-trace/settings — 更新运行时设置
- GET /api/admin/dev-trace/coverage — 最近一次覆盖率扫描结果
- POST /api/admin/dev-trace/coverage/scan — 手动触发覆盖率扫描
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from app.auth.dependencies import CurrentUser, get_current_user
from app.trace.cleanup import cleanup_date_range
from app.trace.config import get_trace_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/dev-trace", tags=["开发调试日志"])
# 日期目录名格式
_DATE_FORMAT = "%Y-%m-%d"
# ── 权限依赖 ──────────────────────────────────────────────
def _require_admin():
"""管理端依赖:仅要求 JWT 认证通过。
dev-trace 是开发调试工具,不涉及业务数据,无需检查业务角色
site_admin / tenant_admin。只要是 admin-web 的已认证用户即可访问。
"""
async def _dependency(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
return user
return _dependency
# ── Pydantic 请求/响应模型 ────────────────────────────────
class CleanupRequest(BaseModel):
"""手动清理请求体。"""
start_date: str
end_date: str
class SettingsUpdate(BaseModel):
"""运行时设置更新请求体(所有字段可选)。"""
enabled: Optional[bool] = None
retention_days: Optional[int] = None
log_sql: Optional[bool] = None
log_params: Optional[bool] = None
# ── 辅助函数 ──────────────────────────────────────────────
def _get_base_dir() -> Path:
"""获取日志根目录 Path 对象。"""
return Path(get_trace_config().log_dir)
def _is_date_dir(name: str) -> bool:
"""判断目录名是否为 YYYY-MM-DD 格式。"""
try:
datetime.strptime(name, _DATE_FORMAT)
return True
except ValueError:
return False
def _read_jsonl_file(filepath: Path) -> list[dict[str, Any]]:
"""逐行读取 .jsonl 文件,跳过解析失败的行。"""
records: list[dict[str, Any]] = []
if not filepath.exists():
return records
with open(filepath, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
continue
return records
def _match_filter(record: dict[str, Any], **filters: Any) -> bool:
"""检查单条 trace 记录是否满足所有筛选条件。"""
# trace_type
if filters.get("trace_type") and record.get("trace_type") != filters["trace_type"]:
return False
# method
if filters.get("method") and record.get("method", "").upper() != filters["method"].upper():
return False
# path_contains
if filters.get("path_contains") and filters["path_contains"].lower() not in (record.get("path") or "").lower():
return False
# status_code
if filters.get("status_code") is not None and record.get("status_code") != filters["status_code"]:
return False
# min_duration
if filters.get("min_duration") is not None and (record.get("total_duration_ms") or 0) < filters["min_duration"]:
return False
# has_error
if filters.get("has_error") is not None:
has_err = record.get("error") is not None
if filters["has_error"] != has_err:
return False
# span_type — 检查 spans 列表中是否包含指定类型
if filters.get("span_type"):
span_types = {s.get("span_type") for s in record.get("spans", [])}
if filters["span_type"] not in span_types:
return False
# start_time / end_time — 基于 record.timestamp
ts_str = record.get("timestamp", "")
if ts_str and (filters.get("start_time") or filters.get("end_time")):
try:
rec_dt = datetime.fromisoformat(ts_str)
rec_time = rec_dt.time()
if filters.get("start_time") and rec_time < filters["start_time"]:
return False
if filters.get("end_time") and rec_time > filters["end_time"]:
return False
except (ValueError, TypeError):
pass
return True
# ── 1. GET /dates — 有日志数据的日期列表 ─────────────────
@router.get("/dates")
async def list_dates(
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, list[str]]:
"""返回有日志数据的日期列表(降序排列)。"""
base = _get_base_dir()
if not base.exists():
return {"dates": []}
dates = sorted(
[d.name for d in base.iterdir() if d.is_dir() and _is_date_dir(d.name)],
reverse=True,
)
return {"dates": dates}
# ── 2. GET /requests — 按条件分页查询请求列表 ─────────────
@router.get("/requests")
async def list_requests(
date: str = Query(..., description="日期,格式 YYYY-MM-DD"),
start_time: Optional[str] = Query(None, description="起始时间 HH:MM:SS"),
end_time: Optional[str] = Query(None, description="结束时间 HH:MM:SS"),
trace_type: Optional[str] = Query(None, description="trace 类型http/sse/ws/job"),
method: Optional[str] = Query(None, description="HTTP 方法GET/POST/PUT/DELETE"),
path_contains: Optional[str] = Query(None, description="路径关键词"),
status_code: Optional[int] = Query(None, description="HTTP 状态码"),
min_duration: Optional[float] = Query(None, description="最小耗时ms"),
has_error: Optional[bool] = Query(None, description="是否有错误"),
span_type: Optional[str] = Query(None, description="包含的 span 类型"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(50, ge=1, le=200, description="每页条数"),
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, Any]:
"""按条件分页查询指定日期的请求列表。"""
# 解析时间参数
parsed_start = None
parsed_end = None
if start_time:
try:
parsed_start = datetime.strptime(start_time, "%H:%M:%S").time()
except ValueError:
raise HTTPException(status_code=400, detail="start_time 格式无效,应为 HH:MM:SS")
if end_time:
try:
parsed_end = datetime.strptime(end_time, "%H:%M:%S").time()
except ValueError:
raise HTTPException(status_code=400, detail="end_time 格式无效,应为 HH:MM:SS")
# 读取指定日期目录下所有 .jsonl 文件
date_dir = _get_base_dir() / date
if not date_dir.exists() or not date_dir.is_dir():
return {"items": [], "total": 0, "page": page, "page_size": page_size}
all_records: list[dict[str, Any]] = []
for f in sorted(date_dir.glob("*.jsonl")):
all_records.extend(_read_jsonl_file(f))
# 过滤
filtered = [
r for r in all_records
if _match_filter(
r,
trace_type=trace_type,
method=method,
path_contains=path_contains,
status_code=status_code,
min_duration=min_duration,
has_error=has_error,
span_type=span_type,
start_time=parsed_start,
end_time=parsed_end,
)
]
# 按时间降序排列
filtered.sort(key=lambda r: r.get("timestamp", ""), reverse=True)
total = len(filtered)
start_idx = (page - 1) * page_size
items = filtered[start_idx : start_idx + page_size]
# 列表项不返回完整 spans只返回摘要字段
summary_items = []
for item in items:
summary_items.append({
"request_id": item.get("request_id"),
"trace_type": item.get("trace_type"),
"timestamp": item.get("timestamp"),
"method": item.get("method"),
"path": item.get("path"),
"status_code": item.get("status_code"),
"total_duration_ms": item.get("total_duration_ms"),
"user_id": item.get("user_id"),
"site_id": item.get("site_id"),
"db_query_count": item.get("db_query_count"),
"db_total_ms": item.get("db_total_ms"),
"error": item.get("error"),
"span_count": len(item.get("spans", [])),
})
return {"items": summary_items, "total": total, "page": page, "page_size": page_size}
# ── 3. GET /request/{request_id} — 完整 trace 记录 ──────
@router.get("/request/{request_id}")
async def get_request_detail(
request_id: str,
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, Any]:
"""返回指定 request_id 的完整 trace 记录(含所有 spans"""
base = _get_base_dir()
if not base.exists():
raise HTTPException(status_code=404, detail="未找到该 request_id 的 trace 记录")
# 搜索所有日期目录下的 .jsonl 文件
for date_dir in sorted(base.iterdir(), reverse=True):
if not date_dir.is_dir() or not _is_date_dir(date_dir.name):
continue
for f in date_dir.glob("*.jsonl"):
for record in _read_jsonl_file(f):
if record.get("request_id") == request_id:
return record
raise HTTPException(status_code=404, detail="未找到该 request_id 的 trace 记录")
# ── 4. POST /cleanup — 按日期范围手动清理 ────────────────
@router.post("/cleanup")
async def cleanup_logs(
body: CleanupRequest,
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, Any]:
"""按日期范围手动清理日志目录。"""
# 校验日期格式
try:
datetime.strptime(body.start_date, _DATE_FORMAT)
datetime.strptime(body.end_date, _DATE_FORMAT)
except ValueError:
raise HTTPException(status_code=400, detail="日期格式无效,应为 YYYY-MM-DD")
if body.start_date > body.end_date:
raise HTTPException(status_code=400, detail="start_date 不能晚于 end_date")
result = cleanup_date_range(body.start_date, body.end_date)
return {
"deleted_dates": result["deleted_dirs"],
"deleted_files": result["deleted_count"],
}
# ── 5. GET /settings — 当前设置 ──────────────────────────
@router.get("/settings")
async def get_settings(
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, Any]:
"""返回当前 trace 运行时设置。"""
return get_trace_config().get_settings()
# ── 6. PUT /settings — 更新运行时设置 ────────────────────
@router.put("/settings")
async def update_settings(
body: SettingsUpdate,
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, Any]:
"""更新 trace 运行时设置(不需重启,重启后回退到 .env 值)。"""
cfg = get_trace_config()
cfg.update_settings(
enabled=body.enabled,
retention_days=body.retention_days,
log_sql=body.log_sql,
log_params=body.log_params,
)
return cfg.get_settings()
# ── 7. GET /coverage — 最近一次覆盖率扫描结果 ────────────
@router.get("/coverage")
async def get_coverage(
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, Any]:
"""返回最近一次覆盖率扫描结果(缓存)。"""
from app.trace.coverage import get_cached_coverage, run_coverage_scan
result = get_cached_coverage()
if result is None:
# 首次访问时自动扫描一次
result = run_coverage_scan()
return result
# ── 8. POST /coverage/scan — 手动触发覆盖率扫描 ──────────
@router.post("/coverage/scan")
async def trigger_coverage_scan(
user: CurrentUser = Depends(_require_admin()),
) -> dict[str, Any]:
"""手动触发覆盖率扫描,返回最新结果。"""
from app.trace.coverage import run_coverage_scan
return run_coverage_scan()