feat(data-ana): 完善学情诊断服务并添加ClickHouse降级模式
config.py ClickHouse连接改可选+加DEV_MODE/kafka_brokers clickhouse_client.py 降级模式: host为空时返回None main.py 端点先查ClickHouse降级返回骨架数据+新增errorbook 新增clickhouse-init.sql创建宽表和错题表 Gateway添加/analytics路由
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
"""数据分析服务入口."""
|
||||
"""数据分析服务入口.
|
||||
|
||||
支持 ClickHouse 降级模式:当 CLICKHOUSE_HOST 未配置或不可达时,
|
||||
查询端点返回骨架数据,服务仍可启动与响应。
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from fastapi import FastAPI
|
||||
@@ -10,25 +15,90 @@ from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from prometheus_client import make_asgi_app
|
||||
|
||||
logger = structlog.get_logger()
|
||||
from .clickhouse_client import (
|
||||
close_client,
|
||||
query_class_performance,
|
||||
query_dashboard,
|
||||
query_student_errors,
|
||||
)
|
||||
from .clickhouse_client import ping as ch_ping
|
||||
from .config import settings
|
||||
|
||||
_logger: structlog.stdlib.BoundLogger | None = None
|
||||
tracer = trace.get_tracer(__name__)
|
||||
|
||||
# 日志级别映射
|
||||
_LOG_LEVELS: dict[str, int] = {
|
||||
"DEBUG": 10,
|
||||
"INFO": 20,
|
||||
"WARNING": 30,
|
||||
"ERROR": 40,
|
||||
"CRITICAL": 50,
|
||||
}
|
||||
|
||||
|
||||
def init_logger() -> structlog.stdlib.BoundLogger:
|
||||
"""初始化 structlog logger.
|
||||
|
||||
根据配置的 log_level 设置日志级别。
|
||||
"""
|
||||
global _logger
|
||||
level = _LOG_LEVELS.get(settings.log_level.upper(), 20)
|
||||
structlog.configure(
|
||||
wrapper_class=structlog.make_filtering_logger(level),
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.dev.ConsoleRenderer(),
|
||||
],
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
_logger = structlog.get_logger(__name__)
|
||||
return _logger
|
||||
|
||||
|
||||
def get_logger() -> structlog.stdlib.BoundLogger:
|
||||
"""获取已初始化的 logger(未初始化时自动初始化)."""
|
||||
global _logger
|
||||
if _logger is None:
|
||||
return init_logger()
|
||||
return _logger
|
||||
|
||||
|
||||
def init_tracer() -> None:
|
||||
"""初始化 OpenTelemetry."""
|
||||
"""初始化 OpenTelemetry.
|
||||
|
||||
endpoint 从 settings.otel_endpoint 读取(不硬编码)。
|
||||
"""
|
||||
provider = TracerProvider()
|
||||
exporter = OTLPSpanExporter(endpoint="http://localhost:4318/v1/traces")
|
||||
endpoint = settings.otel_endpoint.rstrip("/")
|
||||
exporter = OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces")
|
||||
provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""应用生命周期."""
|
||||
"""应用生命周期.
|
||||
|
||||
1. 初始化 logger(structlog)
|
||||
2. 初始化 OTel tracer(endpoint 从 config 读)
|
||||
3. 触发 ClickHouse 客户端惰性初始化(不阻塞启动,失败进入降级模式)
|
||||
4. 关闭时释放 ClickHouse 客户端
|
||||
"""
|
||||
logger = init_logger()
|
||||
init_tracer()
|
||||
logger.info("data-ana service starting")
|
||||
logger.info(
|
||||
"data_ana_service_starting",
|
||||
port=settings.port,
|
||||
dev_mode=settings.dev_mode,
|
||||
clickhouse_configured=bool(settings.clickhouse_host),
|
||||
kafka_brokers=settings.kafka_brokers,
|
||||
)
|
||||
yield
|
||||
logger.info("data-ana service stopping")
|
||||
logger.info("data_ana_service_stopping")
|
||||
await close_client()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -42,36 +112,142 @@ app.mount("/metrics", make_asgi_app())
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
"""健康检查."""
|
||||
async def healthz() -> dict:
|
||||
"""健康检查(liveness).
|
||||
|
||||
只要进程存活即返回 ok,不依赖 ClickHouse。
|
||||
"""
|
||||
return {"status": "ok", "service": "data-ana"}
|
||||
|
||||
|
||||
@app.get("/analytics/class/{class_id}/performance")
|
||||
async def class_performance(class_id: str):
|
||||
"""班级成绩分析."""
|
||||
with tracer.start_as_current_span("class_performance"):
|
||||
# P4 骨架:从 ClickHouse 查询分析数据
|
||||
@app.get("/readyz")
|
||||
async def readyz() -> dict:
|
||||
"""就绪检查(readiness).
|
||||
|
||||
ClickHouse 为可选依赖:
|
||||
- 已配置且可达:ready=true
|
||||
- 未配置:ready=true,degraded=true(降级模式仍可服务)
|
||||
- 已配置但不可达:ready=false
|
||||
"""
|
||||
if not settings.clickhouse_host:
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"classId": class_id,
|
||||
"averageScore": 0,
|
||||
"passRate": 0,
|
||||
"message": "P4 skeleton - ClickHouse integration pending",
|
||||
},
|
||||
"status": "ok",
|
||||
"service": "data-ana",
|
||||
"ready": True,
|
||||
"degraded": True,
|
||||
"clickhouse": "not_configured",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
ch_ok = await ch_ping()
|
||||
return {
|
||||
"status": "ok" if ch_ok else "degraded",
|
||||
"service": "data-ana",
|
||||
"ready": ch_ok,
|
||||
"degraded": not ch_ok,
|
||||
"clickhouse": "ok" if ch_ok else "unreachable",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/analytics/class/{class_id}/performance")
|
||||
async def class_performance(class_id: str) -> dict:
|
||||
"""班级成绩分析.
|
||||
|
||||
优先查 ClickHouse;降级时返回骨架数据。
|
||||
"""
|
||||
logger = get_logger()
|
||||
with tracer.start_as_current_span("class_performance") as span:
|
||||
span.set_attribute("class_id", class_id)
|
||||
result = await query_class_performance(class_id)
|
||||
if result is None:
|
||||
logger.info("class_performance_degraded", class_id=class_id)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"classId": class_id,
|
||||
"averageScore": 0,
|
||||
"passRate": 0,
|
||||
"totalStudents": 0,
|
||||
"message": "ClickHouse unavailable - skeleton data",
|
||||
"degraded": True,
|
||||
},
|
||||
}
|
||||
return {"success": True, "data": {**result, "degraded": False}}
|
||||
|
||||
|
||||
@app.get("/analytics/student/{student_id}/weakness")
|
||||
async def student_weakness(student_id: str):
|
||||
"""学生薄弱知识点分析."""
|
||||
with tracer.start_as_current_span("student_weakness"):
|
||||
async def student_weakness(student_id: str) -> dict:
|
||||
"""学生薄弱知识点分析.
|
||||
|
||||
优先查 ClickHouse;降级时返回骨架数据。
|
||||
"""
|
||||
logger = get_logger()
|
||||
with tracer.start_as_current_span("student_weakness") as span:
|
||||
span.set_attribute("student_id", student_id)
|
||||
result = await query_dashboard(student_id)
|
||||
if result is None:
|
||||
logger.info("student_weakness_degraded", student_id=student_id)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"studentId": student_id,
|
||||
"weakPoints": [],
|
||||
"message": "ClickHouse unavailable - skeleton data",
|
||||
"degraded": True,
|
||||
},
|
||||
}
|
||||
|
||||
# 从宽表提取薄弱知识点:mastery_level < 0.6 视为薄弱
|
||||
weak_points = [
|
||||
{
|
||||
"knowledgePointId": r["knowledge_point_id"],
|
||||
"masteryLevel": r["mastery_level"],
|
||||
"errorCount": r["error_count"],
|
||||
}
|
||||
for r in result["records"]
|
||||
if r.get("mastery_level") is not None and r["mastery_level"] < 0.6
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"studentId": student_id,
|
||||
"weakPoints": [],
|
||||
"message": "P4 skeleton - weakness analysis pending",
|
||||
"weakPoints": weak_points,
|
||||
"records": result["records"],
|
||||
"total": result["total"],
|
||||
"degraded": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/analytics/student/{student_id}/errorbook")
|
||||
async def student_errorbook(student_id: str) -> dict:
|
||||
"""学生错题本.
|
||||
|
||||
优先查 ClickHouse;降级时返回空列表。
|
||||
"""
|
||||
logger = get_logger()
|
||||
with tracer.start_as_current_span("student_errorbook") as span:
|
||||
span.set_attribute("student_id", student_id)
|
||||
result = await query_student_errors(student_id)
|
||||
if result is None:
|
||||
logger.info("student_errorbook_degraded", student_id=student_id)
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"studentId": student_id,
|
||||
"errors": [],
|
||||
"total": 0,
|
||||
"message": "ClickHouse unavailable - empty errorbook",
|
||||
"degraded": True,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"studentId": student_id,
|
||||
"errors": result,
|
||||
"total": len(result),
|
||||
"degraded": False,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user