"""GET /api/analytics/* —— 数据分析板块端组(前缀分组=未来角色权限落点)。""" from __future__ import annotations from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession from app.analytics import health as health_svc from app.analytics import metrics as metrics_svc from app.analytics.sensitive import words as words_svc from app.api.deps import get_session router = APIRouter(prefix="/api/analytics", tags=["analytics"]) SessionDep = Annotated[AsyncSession, Depends(get_session)] ChannelParam = Annotated[Literal["wechat", "whatsapp"], Query()] ScopeParam = Annotated[Literal["today", "week", "month"], Query()] @router.get("/health") async def analytics_health(session: SessionDep) -> dict[str, Any]: """E0-lite:数据截至 + 实体新鲜度。""" as_of = await health_svc.data_as_of(session) return {"data_as_of": as_of, "entities": await health_svc.entity_freshness(session)} @router.get("/overview") async def analytics_overview( session: SessionDep, channel: ChannelParam = "wechat", scope: ScopeParam = "today", ) -> dict[str, Any]: """A1 经营总览:卡片+趋势+异常警示一次返回(单往返)。 cards 不含 data_as_of——数据截至走 /health(data_as_of 为 ms-epoch int 与 entities[].last_seen 为 ISO 字符串的双格式)。 """ return await metrics_svc.overview(session, channel=channel, scope=scope) @router.get("/churn") async def churn_list( session: SessionDep, channel: ChannelParam = "wechat", type: Literal["real", "inactive", "handover"] = "real", tier: int = Query(30, ge=30, le=90), min_msgs: int = Query(5, ge=1, le=100), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ) -> dict[str, Any]: """A3 流失预警名单(三分法)。tier 只认 30/60/90。""" if tier not in (30, 60, 90): raise HTTPException(422, "tier must be 30/60/90") return await metrics_svc.churn( session, channel=channel, kind=type, tier=tier, min_msgs=min_msgs, page=page, page_size=page_size, ) @router.get("/unreplied") async def unreplied_list( session: SessionDep, channel: ChannelParam = "wechat", hours: int = Query(2, ge=1, le=168), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ) -> dict[str, Any]: """B2 该回未回清单(spec §3.4)。hours=超时阈值(默认 2h)。""" return await metrics_svc.unreplied( session, channel=channel, hours=hours, page=page, page_size=page_size ) class WordIn(BaseModel): word: str category: str | None = None min_len: int = 2 exclude_words: list[str] | None = None class WordPreviewIn(BaseModel): word: str exclude_words: list[str] | None = None @router.get("/sensitive/words") async def list_words(session: SessionDep) -> list[dict]: """C1 词库清单(enabled=false 的词 scanner 跳过)。""" return [ {"id": w.id, "word": w.word, "category": w.category, "enabled": w.enabled, "min_len": w.min_len, "exclude_words": w.exclude_words or []} for w in await words_svc.list_words(session) ] @router.post("/sensitive/words") async def create_word(session: SessionDep, body: WordIn) -> dict: """加词(重复 409)。词内容是敏感数据,错误信息只回 id 不回词。""" try: w = await words_svc.create_word( session, word=body.word, category=body.category, min_len=body.min_len, exclude_words=body.exclude_words, ) await session.commit() return {"id": w.id, "word": w.word, "enabled": w.enabled} except ValueError as exc: raise HTTPException(409, str(exc)) from exc @router.post("/sensitive/words/preview") async def preview_word(session: SessionDep, body: WordPreviewIn) -> dict: """加词前命中量预览(count 为 MySQL 上限语义;warn=超阈值建议收窄)。""" n = await words_svc.preview_count(session, body.word, body.exclude_words) return {"count": n, "warn": n >= words_svc.PREVIEW_WARN_THRESHOLD} @router.patch("/sensitive/words/{word_id}") async def update_word(session: SessionDep, word_id: int, body: dict) -> dict: """改词(启停/分类/排除词);字段 None=不改。""" try: w = await words_svc.update_word(session, word_id, **body) await session.commit() return {"id": w.id, "enabled": w.enabled, "category": w.category} except LookupError as exc: raise HTTPException(404, str(exc)) from exc @router.delete("/sensitive/words/{word_id}") async def delete_word(session: SessionDep, word_id: int) -> dict: """删词=级联删命中(UI 二次确认)。""" try: await words_svc.delete_word(session, word_id) await session.commit() return {"ok": True} except LookupError as exc: raise HTTPException(404, str(exc)) from exc