# -*- coding: utf-8 -*- """ 管理端 — AI 监控后台路由。 端点清单(13 个,全部需要 JWT + admin 角色): - GET /api/admin/ai/dashboard — 总览统计 - GET /api/admin/ai/trigger-jobs — 调度任务分页列表 - GET /api/admin/ai/trigger-jobs/{job_id} — 调度任务详情 - POST /api/admin/ai/trigger-jobs/{job_id}/retry — 手动重跑 - GET /api/admin/ai/run-logs — 调用记录分页列表 - GET /api/admin/ai/run-logs/{log_id} — 调用记录详情 - POST /api/admin/ai/cache/invalidate — 缓存失效 - GET /api/admin/ai/budget — Token 预算 - POST /api/admin/ai/batch-run — 创建批量执行(返回预估) - POST /api/admin/ai/batch-run/confirm — 确认批量执行 - GET /api/admin/ai/alerts — 告警列表 - POST /api/admin/ai/alerts/{log_id}/ack — 确认告警 - POST /api/admin/ai/alerts/{log_id}/ignore — 忽略告警 需求: A1.1, A2.1, A2.4, A3.1, A4.1, A4.3, A5.1, A6.1, A7.1, A7.3, A8.1, A8.2, A8.3, A9.1, A9.2, A9.3 """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from app.auth.dependencies import CurrentUser, get_current_user from app.middleware.permission import require_permission # 保留给可能的其他依赖 from app.schemas.admin_ai import ( AlertActionResponse, AlertListResponse, BatchRunConfirm, BatchRunConfirmResponse, BatchRunEstimate, BatchRunRequest, BudgetResponse, CacheInvalidateRequest, CacheInvalidateResponse, DashboardResponse, ManualTriggerRequest, ManualTriggerResponse, PrewarmProgressResponse, RetryResponse, RunAppRequest, RunAppResponse, RunLogDetailResponse, RunLogListResponse, TriggerItem, TriggerJobDetailResponse, TriggerJobListResponse, TriggerUpdateRequest, ) from app.services.ai.admin_service import AdminAIService logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/admin/ai", tags=["admin-ai"]) # ── 模块级服务实例 ──────────────────────────────────────── _admin_svc = AdminAIService() # ── 权限依赖 ────────────────────────────────────────────── def _require_admin(): """ 管理端依赖:直接从 JWT 读 roles 判定是否 admin(site_admin / tenant_admin / super_admin)。 2026-04-21:改为不依赖 auth.users.status 查询(admin-web 登录用 admin_users 表, 与 require_permission 走的 auth.users 不是同一张表)。status 实时校验通过 admin_users.is_active。 """ async def _dependency( user: CurrentUser = Depends(get_current_user), ) -> CurrentUser: admin_roles = {"site_admin", "tenant_admin", "super_admin"} if not admin_roles.intersection(user.roles): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限(site_admin / tenant_admin / super_admin)", ) # 实时校验 admin_users 表的 is_active(若 user_id 在该表) from app.database import get_connection conn = get_connection() try: with conn.cursor() as cur: cur.execute( "SELECT is_active FROM admin_users WHERE id = %s", (user.user_id,), ) row = cur.fetchone() conn.commit() finally: conn.close() # 在 admin_users 中找到且未激活 → 拒绝 if row is not None and not row[0]: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="管理员账号已禁用", ) # 不在 admin_users 中但 JWT 带 admin 角色(如 xcx 用户临时升权),也允许通过 return user return _dependency # ── Dashboard ───────────────────────────────────────────── @router.get("/dashboard", response_model=DashboardResponse) async def get_dashboard( site_id: Optional[int] = Query(None, description="门店 ID 筛选"), range_days: Optional[int] = Query(None, ge=1, le=365, description="回溯天数:1=今日 / 3 / 7 / 10"), date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD(与 date_to 成对使用)"), date_to: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), user: CurrentUser = Depends(_require_admin()), ) -> DashboardResponse: """总览统计(支持 site_id + 时间范围筛选)。""" data = await _admin_svc.get_dashboard( site_id=site_id, range_days=range_days, date_from=date_from, date_to=date_to, ) return DashboardResponse(**data) # ── 调度任务 ────────────────────────────────────────────── @router.get("/trigger-jobs", response_model=TriggerJobListResponse) async def list_trigger_jobs( event_type: Optional[str] = Query(None), status_filter: Optional[str] = Query(None, alias="status"), site_id: Optional[int] = Query(None), date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"), date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user: CurrentUser = Depends(_require_admin()), ) -> TriggerJobListResponse: """调度任务分页列表。""" filters: dict = {} if event_type is not None: filters["event_type"] = event_type if status_filter is not None: filters["status"] = status_filter if site_id is not None: filters["site_id"] = site_id if date_from is not None: filters["date_from"] = date_from if date_to is not None: filters["date_to"] = date_to data = await _admin_svc.list_trigger_jobs(filters, page=page, page_size=page_size) return TriggerJobListResponse(**data) @router.get("/trigger-jobs/{job_id}", response_model=TriggerJobDetailResponse) async def get_trigger_job( job_id: int, user: CurrentUser = Depends(_require_admin()), ) -> TriggerJobDetailResponse: """调度任务详情。""" data = await _admin_svc.get_trigger_job(job_id) if data is None: raise HTTPException(status_code=404, detail="调度任务不存在") return TriggerJobDetailResponse(**data) @router.post("/trigger-jobs/{job_id}/retry", response_model=RetryResponse) async def retry_trigger_job( job_id: int, user: CurrentUser = Depends(_require_admin()), ) -> RetryResponse: """手动重跑:创建新 trigger_job(is_forced=true),异步执行。""" try: new_job_id = await _admin_svc.retry_trigger_job(job_id) except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return RetryResponse(trigger_job_id=new_job_id, status="pending") # ── 调用记录 ────────────────────────────────────────────── @router.get("/run-logs", response_model=RunLogListResponse) async def list_run_logs( app_type: Optional[str] = Query(None), status_filter: Optional[str] = Query(None, alias="status"), trigger_type: Optional[str] = Query(None), site_id: Optional[int] = Query(None), date_from: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD"), date_to: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD"), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user: CurrentUser = Depends(_require_admin()), ) -> RunLogListResponse: """调用记录分页列表。""" filters: dict = {} if app_type is not None: filters["app_type"] = app_type if status_filter is not None: filters["status"] = status_filter if trigger_type is not None: filters["trigger_type"] = trigger_type if site_id is not None: filters["site_id"] = site_id if date_from is not None: filters["date_from"] = date_from if date_to is not None: filters["date_to"] = date_to data = await _admin_svc.list_run_logs(filters, page=page, page_size=page_size) return RunLogListResponse(**data) @router.get("/run-logs/{log_id}", response_model=RunLogDetailResponse) async def get_run_log( log_id: int, user: CurrentUser = Depends(_require_admin()), ) -> RunLogDetailResponse: """调用记录详情(含完整 prompt/response/error,不脱敏)。""" data = await _admin_svc.get_run_log(log_id) if data is None: raise HTTPException(status_code=404, detail="调用记录不存在") return RunLogDetailResponse(**data) # ── 缓存管理 ────────────────────────────────────────────── @router.post("/cache/invalidate", response_model=CacheInvalidateResponse) async def invalidate_cache( body: CacheInvalidateRequest, user: CurrentUser = Depends(_require_admin()), ) -> CacheInvalidateResponse: """批量缓存失效:将匹配条件的 ai_cache.status 设为 invalidated。""" affected = await _admin_svc.invalidate_cache( site_id=body.site_id, app_type=body.app_type, member_id=body.member_id, ) return CacheInvalidateResponse(affected_count=affected) # ── Token 预算 ──────────────────────────────────────────── @router.get("/budget", response_model=BudgetResponse) async def get_budget( user: CurrentUser = Depends(_require_admin()), ) -> BudgetResponse: """Token 预算使用情况:日/月已用量、上限、百分比。""" data = await _admin_svc.get_budget() return BudgetResponse(**data) # ── 批量执行 ────────────────────────────────────────────── @router.post("/batch-run", response_model=BatchRunEstimate) async def create_batch_run( body: BatchRunRequest, user: CurrentUser = Depends(_require_admin()), ) -> BatchRunEstimate: """创建批量执行请求,返回预估(不立即执行)。""" data = await _admin_svc.estimate_batch( app_types=body.app_types, member_ids=body.member_ids, site_id=body.site_id, ) return BatchRunEstimate(**data) @router.post("/batch-run/confirm", response_model=BatchRunConfirmResponse) async def confirm_batch_run( body: BatchRunConfirm, user: CurrentUser = Depends(_require_admin()), ) -> BatchRunConfirmResponse: """确认批量执行,后台异步执行。""" try: await _admin_svc.confirm_batch(batch_id=body.batch_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return BatchRunConfirmResponse(status="started") # ── 告警管理 ────────────────────────────────────────────── @router.get("/alerts", response_model=AlertListResponse) async def list_alerts( alert_status: Optional[str] = Query(None, description="pending / acknowledged / ignored"), site_id: Optional[int] = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user: CurrentUser = Depends(_require_admin()), ) -> AlertListResponse: """告警列表(ai_run_logs WHERE status IN ('failed','timeout','circuit_open'))。""" data = await _admin_svc.list_alerts( alert_status=alert_status, site_id=site_id, page=page, page_size=page_size, ) return AlertListResponse(**data) @router.post("/alerts/{log_id}/ack", response_model=AlertActionResponse) async def ack_alert( log_id: int, user: CurrentUser = Depends(_require_admin()), ) -> AlertActionResponse: """确认告警:alert_status → acknowledged。""" new_status = await _admin_svc.ack_alert(log_id) return AlertActionResponse(id=log_id, alert_status=new_status) @router.post("/alerts/{log_id}/ignore", response_model=AlertActionResponse) async def ignore_alert( log_id: int, user: CurrentUser = Depends(_require_admin()), ) -> AlertActionResponse: """忽略告警:alert_status → ignored。""" new_status = await _admin_svc.ignore_alert(log_id) return AlertActionResponse(id=log_id, alert_status=new_status) # ── 按需执行单个 App(admin-web 重新生成按钮用)────────── _SUPPORTED_APP_TYPES = { "app2_finance", "app2a_finance_area", # 2026-04-23 新增:区域财务洞察 "app3_clue", "app4_analysis", "app5_tactics", "app6_note", "app7_customer", "app8_consolidation", } @router.post("/run/{app_type}", response_model=RunAppResponse) async def run_single_app( app_type: str, body: RunAppRequest, user: CurrentUser = Depends(_require_admin()), ) -> RunAppResponse: """按需执行单个 App,跳过链路编排。 使用场景:admin-web 缓存详情页 / 告警页的"重新生成"按钮。 熔断/限流/预算检查由 dispatcher._run_step 自动执行。 结果写入 ai_cache,失败不抛异常,通过 success=False 返回。 """ if app_type not in _SUPPORTED_APP_TYPES: raise HTTPException( status_code=400, detail=f"不支持的 app_type: {app_type};支持 {sorted(_SUPPORTED_APP_TYPES)}", ) from app.ai.dispatcher import get_dispatcher try: dispatcher = get_dispatcher() except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc context = body.model_dump(exclude_none=True) try: result = await dispatcher.run_single_app( app_type=app_type, context=context, triggered_by=f"admin:{user.user_id}", ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if result is None: return RunAppResponse( app_type=app_type, success=False, error="AI 调用失败(详情见 ai_run_logs,可能为熔断/限流/预算/超时)", ) return RunAppResponse(app_type=app_type, success=True, result=result) # ── 触发器管理(biz.trigger_jobs)───────────────────────── @router.get("/triggers", response_model=list[TriggerItem]) async def list_triggers( _user: CurrentUser = Depends(_require_admin()), ) -> list[TriggerItem]: """列出所有 AI 相关触发器(job_type=ai_* 或 task_generator)。""" rows = await _admin_svc.list_triggers() return [TriggerItem(**r) for r in rows] @router.patch("/triggers/{trigger_id}", response_model=TriggerItem) async def update_trigger( trigger_id: int, body: TriggerUpdateRequest, _user: CurrentUser = Depends(_require_admin()), ) -> TriggerItem: """更新触发器:启用/禁用、修改 cron 表达式、修改描述。""" try: row = await _admin_svc.update_trigger( trigger_id, status_new=body.status, cron_expression=body.cron_expression, description=body.description, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return TriggerItem(**row) # ── 预热进度查询 ───────────────────────────────────────── @router.get("/prewarm/progress", response_model=PrewarmProgressResponse) async def get_prewarm_progress( site_id: int = Query(..., description="门店 ID"), _user: CurrentUser = Depends(_require_admin()), ) -> PrewarmProgressResponse: """查询 app2_finance 72 组合预热进度(done / missing)。""" data = await _admin_svc.get_prewarm_progress(site_id) return PrewarmProgressResponse(**data) # ── 手动事件触发(跨越去重)────────────────────────────── @router.post("/trigger-event", response_model=ManualTriggerResponse) async def manual_trigger_event( body: ManualTriggerRequest, user: CurrentUser = Depends(_require_admin()), ) -> ManualTriggerResponse: """手动触发 AI 事件链,默认 is_forced=True 跳过去重。 事件类型:consumption / dws_completed / note_created / task_assigned """ from app.ai.dispatcher import TriggerEvent, get_dispatcher valid_events = {"consumption", "dws_completed", "note_created", "task_assigned"} if body.event_type not in valid_events: raise HTTPException( status_code=400, detail=f"非法 event_type: {body.event_type};支持 {sorted(valid_events)}", ) try: dispatcher = get_dispatcher() except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc payload = dict(body.payload or {}) if body.assistant_id is not None: payload.setdefault("assistant_id", body.assistant_id) event = TriggerEvent( event_type=body.event_type, site_id=body.site_id, member_id=body.member_id, payload=payload, is_forced=body.is_forced, ) logger.info( "admin 手动触发事件: user=%s event=%s site_id=%s member_id=%s forced=%s", user.user_id, body.event_type, body.site_id, body.member_id, body.is_forced, ) job_id = await dispatcher.handle_trigger(event) return ManualTriggerResponse(trigger_job_id=job_id)