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:
SpecialX
2026-07-09 08:58:39 +08:00
parent 5f18821302
commit 421edd8a41
8 changed files with 1242 additions and 593 deletions

View File

@@ -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. 初始化 loggerstructlog
2. 初始化 OTel tracerendpoint 从 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=truedegraded=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,
},
}