"""运营域路由:诊断、管理面、支持包与观测(WP-14)。""" from typing import Annotated, Any from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, status from sqlalchemy import func, select from sqlalchemy import text as sql_text from app.auth.dependencies import get_current_user, require_state_change from app.conversations.routes import _domain_of from app.db.session import DbSession from app.models import MediaJob, Message, Run, ToolCall, User from app.ops import service ops_router = APIRouter(prefix="/v1", tags=["ops-extended"]) admin_router = APIRouter(prefix="/v1/admin", tags=["admin"]) @ops_router.post("/runs/{run_id}/diagnostic-grants", status_code=201) async def issue_grant( run_id: str, db: DbSession, user: Annotated[User, Depends(require_state_change)], request: Request, body: dict[str, str], ) -> dict[str, Any]: """签发诊断授权:明文 token 只返回一次;默认 15 分钟、单次、绑定 Run。""" domain = await _domain_of(db, user) grant, token = await service.issue_grant( db, domain.id, run_id, body.get("purpose", ""), getattr(request.state, "trace_id", None), ) return { "grant_id": grant.id, "token": token, # 唯一一次明文返回 "expires_at": grant.expires_at.isoformat(), } @ops_router.get("/runs/{run_id}/diagnostics") async def read_diagnostics( run_id: str, db: DbSession, user: Annotated[User, Depends(get_current_user)], response: Response, diag_token: Annotated[ str | None, Header(alias="Hunjing-Diagnostic-Token") ] = None, diag_purpose: Annotated[ str | None, Header(alias="Hunjing-Diagnostic-Purpose") ] = None, ) -> dict[str, Any]: """双重授权诊断:登录态 + 诊断令牌;响应不落缓存(ADR-004)。""" domain = await _domain_of(db, user) if not diag_token or not diag_purpose: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "缺少诊断令牌或用途") result = await service.read_diagnostics( db, domain.id, run_id, diag_token, diag_purpose ) response.headers["Cache-Control"] = "no-store, private" return result @ops_router.post("/support/bundles") async def support_bundle( db: DbSession, user: Annotated[User, Depends(require_state_change)], ) -> dict[str, Any]: """脱敏支持包:统计聚合,无正文与密钥。""" domain = await _domain_of(db, user) return await service.build_support_bundle(db, domain.id) @admin_router.get("/moderation-cases") async def list_cases( db: DbSession, user: Annotated[User, Depends(get_current_user)], ) -> dict[str, Any]: await service.require_admin(db, user.id) from app.models import Report cases = await service.list_pending_cases(db) out = [] for case in cases: report = await db.get(Report, case.report_id) out.append({ "case_id": case.id, "status": case.status, "report": { "target_type": report.target_type if report else None, "target_id": report.target_id if report else None, "reason": report.reason if report else None, } if report else None, }) return {"data": out} @admin_router.post("/moderation-cases/{case_id}/resolutions") async def resolve_case( case_id: str, db: DbSession, user: Annotated[User, Depends(require_state_change)], request: Request, body: dict[str, str], ) -> dict[str, Any]: domain = await _domain_of(db, user) await service.require_admin(db, user.id) case = await service.resolve_case( db, domain.id, case_id, body.get("action", ""), getattr(request.state, "trace_id", None), ) return {"case_id": case.id, "status": case.status, "action_taken": case.action_taken} @ops_router.get("/readyz", tags=["ops"]) async def readyz(db: DbSession) -> dict[str, str]: await db.execute(sql_text("SELECT 1")) return {"status": "ready"} @ops_router.get("/metrics", tags=["ops"]) async def metrics(db: DbSession) -> Response: """Prometheus 文本格式:真实 SQL 聚合(无正文)。""" run_rows = (await db.execute(select(Run.status, func.count()).group_by(Run.status))).all() msg_total = (await db.execute(select(func.count()).select_from(Message))).scalar_one() tool_rows = ( await db.execute(select(ToolCall.status, func.count()).group_by(ToolCall.status)) ).all() media_rows = ( await db.execute(select(MediaJob.status, func.count()).group_by(MediaJob.status)) ).all() lines = ["# HELP hunjing_runs_total Run 总数(按状态)", "# TYPE hunjing_runs_total counter"] for st, cnt in run_rows: lines.append(f'hunjing_runs_total{{status="{st}"}} {int(cnt)}') lines += [ "# HELP hunjing_messages_total 消息总数", "# TYPE hunjing_messages_total counter", f"hunjing_messages_total {int(msg_total)}", ] groups = [ ("hunjing_tool_calls_total", tool_rows), ("hunjing_media_jobs_total", media_rows), ] for prefix, rows in groups: lines.append(f"# HELP {prefix} 计数(按状态)") lines.append(f"# TYPE {prefix} counter") for st, cnt in rows: lines.append(f'{prefix}{{status="{st}"}} {int(cnt)}') return Response(content="\n".join(lines) + "\n", media_type="text/plain")