feat(data-ana): 完整实现 data-ana 数据分析服务
包含 CDC consumer、analytics/mastery/warning service、grpc server、repository、ClickHouse DDL 等
This commit is contained in:
@@ -1,20 +1,40 @@
|
||||
"""数据分析服务入口.
|
||||
"""数据分析服务入口(FastAPI HTTP :3006).
|
||||
|
||||
支持 ClickHouse 降级模式:当 CLICKHOUSE_HOST 未配置或不可达时,
|
||||
查询端点返回骨架数据,服务仍可启动与响应。
|
||||
端点清单(3 基础 + 11 业务 = 14 个):
|
||||
基础:
|
||||
GET / 根信息
|
||||
GET /healthz 活性检查(liveness)
|
||||
GET /readyz 就绪检查(readiness,检查 4 依赖)
|
||||
|
||||
支持 CDC 消费者:当 KAFKA_BROKERS 配置时,
|
||||
后台启动 aiokafka 消费者,监听 Debezium CDC 事件写入 ClickHouse。
|
||||
业务(全部返回 ActionState[T] 信封):
|
||||
GET /analytics/class/{class_id}/performance 班级成绩分析
|
||||
GET /analytics/student/{student_id}/weakness 学生薄弱知识点
|
||||
GET /analytics/student/{student_id}/trend 学习趋势
|
||||
GET /analytics/student/{student_id}/errorbook 错题本(额外)
|
||||
GET /analytics/teacher/dashboard 教师仪表盘
|
||||
GET /analytics/student/dashboard 学生仪表盘
|
||||
GET /analytics/parent/dashboard 家长仪表盘
|
||||
GET /analytics/admin/dashboard 管理员仪表盘
|
||||
GET /analytics/warnings 预警列表
|
||||
POST /analytics/warnings/trigger 手动触发预警
|
||||
GET /analytics/class/{class_id}/mastery-distribution 班级掌握度分布
|
||||
GET /analytics/student/{student_id}/mastery 学生掌握度明细
|
||||
|
||||
设计要点:
|
||||
- 所有业务端点返回 ActionState[T](coord-cross-review §5.3 P0 整改)
|
||||
- 降级标记在顶层 details.degraded(不放 error.details)
|
||||
- /readyz 检查 4 依赖:clickhouse / cdc_consumer / redis / iam_grpc
|
||||
- gRPC server :50055 在 lifespan 启动
|
||||
- CDC 消费者在 lifespan 启动
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi import APIRouter, Depends, FastAPI, Query
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
@@ -22,23 +42,20 @@ from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from prometheus_client import make_asgi_app
|
||||
|
||||
from .cdc_consumer import run_consumer as run_cdc_consumer
|
||||
from .clickhouse_client import (
|
||||
close_client,
|
||||
query_class_performance,
|
||||
query_dashboard,
|
||||
query_student_errors,
|
||||
)
|
||||
from .clickhouse_client import ping as ch_ping
|
||||
from . import analytics_service, cdc_consumer, grpc_server, warning_service
|
||||
from .config import settings
|
||||
from .repository import (
|
||||
clickhouse_repository,
|
||||
iam_client,
|
||||
kafka_producer,
|
||||
redis_client,
|
||||
)
|
||||
from .shared.action_state import ActionState
|
||||
from .shared.permissions import UserContext, get_user_context
|
||||
|
||||
_logger: structlog.stdlib.BoundLogger | None = None
|
||||
tracer = trace.get_tracer(__name__)
|
||||
|
||||
# CDC 消费者后台任务句柄
|
||||
_cdc_task: asyncio.Task | None = None
|
||||
|
||||
# 日志级别映射
|
||||
_LOG_LEVELS: dict[str, int] = {
|
||||
"DEBUG": 10,
|
||||
"INFO": 20,
|
||||
@@ -49,10 +66,7 @@ _LOG_LEVELS: dict[str, int] = {
|
||||
|
||||
|
||||
def init_logger() -> structlog.stdlib.BoundLogger:
|
||||
"""初始化 structlog logger.
|
||||
|
||||
根据配置的 log_level 设置日志级别。
|
||||
"""
|
||||
"""初始化 structlog logger."""
|
||||
global _logger
|
||||
level = _LOG_LEVELS.get(settings.log_level.upper(), 20)
|
||||
structlog.configure(
|
||||
@@ -70,7 +84,7 @@ def init_logger() -> structlog.stdlib.BoundLogger:
|
||||
|
||||
|
||||
def get_logger() -> structlog.stdlib.BoundLogger:
|
||||
"""获取已初始化的 logger(未初始化时自动初始化)."""
|
||||
"""获取已初始化的 logger."""
|
||||
global _logger
|
||||
if _logger is None:
|
||||
return init_logger()
|
||||
@@ -78,10 +92,7 @@ def get_logger() -> structlog.stdlib.BoundLogger:
|
||||
|
||||
|
||||
def init_tracer() -> None:
|
||||
"""初始化 OpenTelemetry.
|
||||
|
||||
endpoint 从 settings.otel_endpoint 读取(不硬编码)。
|
||||
"""
|
||||
"""初始化 OpenTelemetry."""
|
||||
provider = TracerProvider()
|
||||
endpoint = settings.otel_endpoint.rstrip("/")
|
||||
exporter = OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces")
|
||||
@@ -93,207 +104,418 @@ def init_tracer() -> None:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""应用生命周期.
|
||||
|
||||
1. 初始化 logger(structlog)
|
||||
2. 初始化 OTel tracer(endpoint 从 config 读)
|
||||
3. 触发 ClickHouse 客户端惰性初始化(不阻塞启动,失败进入降级模式)
|
||||
4. 若配置了 kafka_brokers,后台启动 CDC 消费者任务
|
||||
5. 关闭时停止 CDC 任务并释放 ClickHouse 客户端
|
||||
启动顺序:
|
||||
1. 初始化 logger + tracer
|
||||
2. 启动 gRPC server :50055(AnalyticsService 12 RPC)
|
||||
3. 启动 CDC 消费者后台任务(手动 commit)
|
||||
4. Kafka producer 惰性初始化(首次发布时触发)
|
||||
|
||||
关闭顺序:
|
||||
1. 停止 CDC 消费者
|
||||
2. 停止 gRPC server
|
||||
3. 关闭 Kafka producer
|
||||
4. 关闭 Redis / iam gRPC / ClickHouse 客户端
|
||||
"""
|
||||
global _cdc_task
|
||||
logger = init_logger()
|
||||
init_tracer()
|
||||
logger.info(
|
||||
"data_ana_service_starting",
|
||||
port=settings.port,
|
||||
http_port=settings.http_port,
|
||||
grpc_port=settings.grpc_port,
|
||||
dev_mode=settings.dev_mode,
|
||||
clickhouse_configured=bool(settings.clickhouse_host),
|
||||
kafka_brokers=settings.kafka_brokers,
|
||||
kafka_cdc_topics=settings.kafka_cdc_topics,
|
||||
iam_grpc_endpoint=settings.iam_grpc_endpoint,
|
||||
redis_url=settings.redis_url or "not_configured",
|
||||
)
|
||||
# 启动 CDC 消费者后台任务(若未配置 kafka_brokers,run_consumer 内部直接返回)
|
||||
_cdc_task = asyncio.create_task(run_cdc_consumer())
|
||||
|
||||
# 1. 启动 gRPC server(grpcio 未安装则跳过,降级为仅 HTTP)
|
||||
grpc_server_obj = await grpc_server.start_grpc_server()
|
||||
if grpc_server_obj is None:
|
||||
logger.warning("grpc_server_not_started_http_only")
|
||||
|
||||
# 2. 启动 CDC 消费者后台任务
|
||||
await cdc_consumer.start_consumer()
|
||||
|
||||
yield
|
||||
|
||||
logger.info("data_ana_service_stopping")
|
||||
# 取消 CDC 任务
|
||||
if _cdc_task is not None and not _cdc_task.done():
|
||||
_cdc_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await _cdc_task
|
||||
await close_client()
|
||||
|
||||
# 3. 停止 CDC 消费者
|
||||
await cdc_consumer.stop_consumer()
|
||||
|
||||
# 4. 停止 gRPC server
|
||||
await grpc_server.stop_grpc_server()
|
||||
|
||||
# 5. 关闭 Kafka producer
|
||||
await kafka_producer.close_producer()
|
||||
|
||||
# 6. 关闭 Redis / iam gRPC / ClickHouse 客户端
|
||||
await redis_client.close_client()
|
||||
await iam_client.close_grpc()
|
||||
await clickhouse_repository.close_client()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Data Analytics Service",
|
||||
version="0.1.0",
|
||||
version="1.0.0",
|
||||
description="D6 智能洞察领域服务(ClickHouse 宽表 + CDC + 掌握度算法 + 预警)",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# OpenTelemetry FastAPI 自动埋点(HTTP 请求/响应 span)
|
||||
FastAPIInstrumentor.instrument_app(app)
|
||||
|
||||
# Prometheus 指标
|
||||
app.mount("/metrics", make_asgi_app())
|
||||
|
||||
# 业务路由
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict:
|
||||
"""健康检查(liveness).
|
||||
# ===== 基础端点(3 个) =====
|
||||
|
||||
只要进程存活即返回 ok,不依赖 ClickHouse。
|
||||
"""
|
||||
|
||||
@app.get("/")
|
||||
async def root() -> dict[str, Any]:
|
||||
"""根信息."""
|
||||
return {
|
||||
"service": "data-ana",
|
||||
"version": "1.0.0",
|
||||
"http_port": settings.http_port,
|
||||
"grpc_port": settings.grpc_port,
|
||||
"docs": "/docs",
|
||||
"endpoints": {
|
||||
"healthz": "/healthz",
|
||||
"readyz": "/readyz",
|
||||
"business": "/analytics/*",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""健康检查(liveness,只要进程存活即返回 ok)."""
|
||||
return {"status": "ok", "service": "data-ana"}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
async def readyz() -> dict:
|
||||
"""就绪检查(readiness).
|
||||
async def readyz() -> dict[str, Any]:
|
||||
"""就绪检查(readiness,检查 4 依赖).
|
||||
|
||||
ClickHouse 为可选依赖:
|
||||
- 已配置且可达:ready=true
|
||||
- 未配置:ready=true,degraded=true(降级模式仍可服务)
|
||||
- 已配置但不可达:ready=false
|
||||
依赖检查:
|
||||
1. clickhouse:已配置且可达(未配置算降级就绪)
|
||||
2. cdc_consumer:running / disabled
|
||||
3. redis:已配置且可达(未配置算降级就绪)
|
||||
4. iam_grpc:已配置且可达(未配置算降级就绪)
|
||||
|
||||
CDC 消费者状态附加在响应中:
|
||||
- cdc_consumer: running / disabled / failed
|
||||
返回 ready=true 的条件:
|
||||
- ClickHouse 已配置且可达,或未配置(降级就绪)
|
||||
- 不要求所有依赖都健康(降级模式下仍可服务骨架数据)
|
||||
"""
|
||||
cdc_status = "disabled"
|
||||
if _cdc_task is not None:
|
||||
if _cdc_task.done():
|
||||
cdc_status = "failed"
|
||||
elif not settings.kafka_brokers:
|
||||
cdc_status = "disabled"
|
||||
else:
|
||||
cdc_status = "running"
|
||||
# 1. ClickHouse
|
||||
ch_ok = await clickhouse_repository.ping()
|
||||
ch_status = "ok" if ch_ok else ("unreachable" if settings.clickhouse_host else "not_configured")
|
||||
|
||||
if not settings.clickhouse_host:
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": "data-ana",
|
||||
"ready": True,
|
||||
"degraded": True,
|
||||
"clickhouse": "not_configured",
|
||||
"cdc_consumer": cdc_status,
|
||||
"kafka_brokers": settings.kafka_brokers or None,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
# 2. CDC 消费者
|
||||
cdc_status = (
|
||||
"running"
|
||||
if cdc_consumer.is_running()
|
||||
else ("disabled" if not settings.kafka_brokers else "failed")
|
||||
)
|
||||
|
||||
# 3. Redis
|
||||
redis_ok = await redis_client.ping() if settings.redis_url else None
|
||||
redis_status = "ok" if redis_ok else ("unreachable" if settings.redis_url else "not_configured")
|
||||
|
||||
# 4. iam gRPC
|
||||
iam_ok = await iam_client.ping() if settings.iam_grpc_endpoint else None
|
||||
iam_status = (
|
||||
"ok" if iam_ok else ("unreachable" if settings.iam_grpc_endpoint else "not_configured")
|
||||
)
|
||||
|
||||
# 5. gRPC server
|
||||
grpc_status = "running" if grpc_server.is_running() else "stopped"
|
||||
|
||||
# 就绪判定:ClickHouse 可达或未配置(降级就绪)
|
||||
ready = ch_ok or not settings.clickhouse_host
|
||||
degraded = not ch_ok or not redis_ok or not iam_ok
|
||||
|
||||
ch_ok = await ch_ping()
|
||||
return {
|
||||
"status": "ok" if ch_ok else "degraded",
|
||||
"status": "ok" if ready else "not_ready",
|
||||
"service": "data-ana",
|
||||
"ready": ch_ok,
|
||||
"degraded": not ch_ok,
|
||||
"clickhouse": "ok" if ch_ok else "unreachable",
|
||||
"cdc_consumer": cdc_status,
|
||||
"kafka_brokers": settings.kafka_brokers or None,
|
||||
"ready": ready,
|
||||
"degraded": degraded,
|
||||
"dependencies": {
|
||||
"clickhouse": ch_status,
|
||||
"cdc_consumer": cdc_status,
|
||||
"redis": redis_status,
|
||||
"iam_grpc": iam_status,
|
||||
"grpc_server": grpc_status,
|
||||
"kafka_producer": "ok" if settings.kafka_brokers else "not_configured",
|
||||
},
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics/class/{class_id}/performance")
|
||||
async def class_performance(class_id: str) -> dict:
|
||||
"""班级成绩分析.
|
||||
# ===== 业务端点(11 个,全部返回 ActionState[T]) =====
|
||||
|
||||
优先查 ClickHouse;降级时返回骨架数据。
|
||||
"""
|
||||
logger = get_logger()
|
||||
with tracer.start_as_current_span("class_performance") as span:
|
||||
|
||||
@router.get("/analytics/class/{class_id}/performance")
|
||||
async def get_class_performance(
|
||||
class_id: str,
|
||||
subject_id: str = Query(""),
|
||||
start_date: int = Query(0),
|
||||
end_date: int = Query(0),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""班级成绩分析."""
|
||||
with tracer.start_as_current_span("get_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}}
|
||||
result = await analytics_service.get_class_performance(
|
||||
user=user,
|
||||
class_id=class_id,
|
||||
subject_id=subject_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/student/{student_id}/weakness")
|
||||
async def student_weakness(student_id: str) -> dict:
|
||||
"""学生薄弱知识点分析.
|
||||
|
||||
优先查 ClickHouse;降级时返回骨架数据。
|
||||
"""
|
||||
logger = get_logger()
|
||||
with tracer.start_as_current_span("student_weakness") as span:
|
||||
async def get_student_weakness(
|
||||
student_id: str,
|
||||
subject_id: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""学生薄弱知识点."""
|
||||
with tracer.start_as_current_span("get_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,
|
||||
},
|
||||
}
|
||||
result = await analytics_service.get_student_weakness(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
# 从宽表提取薄弱知识点: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": weak_points,
|
||||
"records": result["records"],
|
||||
"total": result["total"],
|
||||
"degraded": False,
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/analytics/student/{student_id}/trend")
|
||||
async def get_learning_trend(
|
||||
student_id: str,
|
||||
subject_id: str = Query(""),
|
||||
start_date: int = Query(0),
|
||||
end_date: int = Query(0),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""学习趋势."""
|
||||
with tracer.start_as_current_span("get_learning_trend") as span:
|
||||
span.set_attribute("student_id", student_id)
|
||||
result = await analytics_service.get_learning_trend(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
@router.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:
|
||||
async def get_student_errorbook(
|
||||
student_id: str,
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""学生错题本."""
|
||||
with tracer.start_as_current_span("get_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": {
|
||||
errors = await clickhouse_repository.query_student_errors(student_id)
|
||||
if errors is None:
|
||||
return ActionState.ok(
|
||||
{"studentId": student_id, "errors": [], "total": 0},
|
||||
degraded=True,
|
||||
degraded_reason="clickhouse_unavailable",
|
||||
)
|
||||
return ActionState.ok(
|
||||
{
|
||||
"studentId": student_id,
|
||||
"errors": result,
|
||||
"total": len(result),
|
||||
"degraded": False,
|
||||
},
|
||||
}
|
||||
"errors": errors,
|
||||
"total": len(errors),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/teacher/dashboard")
|
||||
async def get_teacher_dashboard(
|
||||
class_id: str = Query(""),
|
||||
subject_id: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""教师仪表盘."""
|
||||
with tracer.start_as_current_span("get_teacher_dashboard"):
|
||||
result = await analytics_service.get_teacher_dashboard(
|
||||
user=user,
|
||||
class_id=class_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/student/dashboard")
|
||||
async def get_student_dashboard(
|
||||
subject_id: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""学生仪表盘."""
|
||||
with tracer.start_as_current_span("get_student_dashboard"):
|
||||
result = await analytics_service.get_student_dashboard(
|
||||
user=user,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/parent/dashboard")
|
||||
async def get_parent_dashboard(
|
||||
student_id: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""家长仪表盘."""
|
||||
with tracer.start_as_current_span("get_parent_dashboard"):
|
||||
result = await analytics_service.get_parent_dashboard(
|
||||
user=user,
|
||||
child_id=student_id,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/admin/dashboard")
|
||||
async def get_admin_dashboard(
|
||||
school_id: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""管理员仪表盘."""
|
||||
with tracer.start_as_current_span("get_admin_dashboard"):
|
||||
result = await analytics_service.get_admin_dashboard(
|
||||
user=user,
|
||||
school_id=school_id,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/warnings")
|
||||
async def get_warnings(
|
||||
student_id: str = Query(""),
|
||||
warning_type: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""预警列表查询."""
|
||||
with tracer.start_as_current_span("get_warnings"):
|
||||
warnings = await warning_service.get_warnings(
|
||||
student_id=student_id,
|
||||
warning_type=warning_type,
|
||||
)
|
||||
return ActionState.ok(
|
||||
{
|
||||
"warnings": warnings,
|
||||
"total": len(warnings),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/analytics/warnings/trigger")
|
||||
async def trigger_warning(
|
||||
target_id: str = Query(...),
|
||||
warning_type: str = Query(...),
|
||||
severity: str = Query("WARN"),
|
||||
threshold: float = Query(0.0),
|
||||
current_value: float = Query(0.0),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""手动触发预警."""
|
||||
with tracer.start_as_current_span("trigger_warning"):
|
||||
result = await warning_service.trigger_warning_manual(
|
||||
target_id=target_id,
|
||||
warning_type=warning_type,
|
||||
threshold=threshold,
|
||||
current_value=current_value,
|
||||
severity=severity,
|
||||
)
|
||||
return ActionState.ok(result)
|
||||
|
||||
|
||||
@router.get("/analytics/class/{class_id}/mastery-distribution")
|
||||
async def get_mastery_distribution(
|
||||
class_id: str,
|
||||
subject_id: str = Query(""),
|
||||
knowledge_point_id: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""班级掌握度分布."""
|
||||
with tracer.start_as_current_span("get_mastery_distribution"):
|
||||
result = await analytics_service.get_mastery_distribution(
|
||||
user=user,
|
||||
class_id=class_id,
|
||||
subject_id=subject_id,
|
||||
knowledge_point_id=knowledge_point_id,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/student/{student_id}/mastery")
|
||||
async def get_student_mastery(
|
||||
student_id: str,
|
||||
subject_id: str = Query(""),
|
||||
user: UserContext = Depends(get_user_context),
|
||||
) -> ActionState[dict[str, Any]]:
|
||||
"""学生知识点掌握度明细."""
|
||||
with tracer.start_as_current_span("get_student_mastery"):
|
||||
result = await analytics_service.get_student_mastery(
|
||||
user=user,
|
||||
student_id=student_id,
|
||||
subject_id=subject_id,
|
||||
)
|
||||
degraded = result.get("degraded", False)
|
||||
return ActionState.ok(
|
||||
result,
|
||||
degraded=degraded,
|
||||
degraded_reason=result.get("degraded_reason", "") if degraded else "",
|
||||
)
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
|
||||
Reference in New Issue
Block a user