"""运营域:诊断双授权、管理面治理、支持包与观测(WP-14 / ADR-004)。""" import hashlib import secrets from datetime import UTC, datetime, timedelta from typing import Any from fastapi import HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models import ( AuditEvent, Comment, CommunityContent, DiagnosticGrant, MediaJob, Message, ModelCall, ModerationCase, Report, Run, ToolCall, Turn, ) GRANT_TTL = timedelta(minutes=15) # ADR-004:默认 15 分钟、单次、绑定 Run def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() async def issue_grant( db: AsyncSession, domain_id: str, run_id: str, purpose: str, trace_id: str | None ) -> tuple[DiagnosticGrant, str]: """签发诊断授权:明文 token 只返回这一次,数据库只存哈希。""" if not purpose.strip(): raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "purpose 不能为空") run = await db.get(Run, run_id) if run is None or run.personal_domain_id != domain_id: raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在") token = f"hjd_{secrets.token_urlsafe(32)}" grant = DiagnosticGrant( run_id=run_id, domain_id=domain_id, purpose=purpose, token_hash=_hash_token(token), expires_at=datetime.now(UTC) + GRANT_TTL, ) db.add(grant) await db.flush() db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="diagnostic.grant_issued", aggregate_type="diagnostic_grant", aggregate_id=grant.id, payload={"run_id": run_id, "purpose": purpose}, trace_id=trace_id, ) ) await db.flush() return grant, token async def read_diagnostics( db: AsyncSession, domain_id: str, run_id: str, token: str, purpose: str, ) -> dict[str, Any]: """双重授权读取:登录态 + 诊断授权(用途匹配、未过期、单次)。 返回运行观测摘要(usage/TTFT/工具轨迹);不含消息正文(结构保证)。 """ run = await db.get(Run, run_id) if run is None or run.personal_domain_id != domain_id: raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在") grant = ( await db.execute( select(DiagnosticGrant).where( DiagnosticGrant.run_id == run_id, DiagnosticGrant.token_hash == _hash_token(token), ) ) ).scalar_one_or_none() if grant is None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权无效") if grant.used_at is not None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权已使用") if grant.expires_at <= datetime.now(UTC): raise HTTPException(status.HTTP_401_UNAUTHORIZED, "诊断授权已过期") if grant.purpose != purpose: raise HTTPException(status.HTTP_403_FORBIDDEN, "用途与授权不符") grant.used_at = datetime.now(UTC) # 单次:读取即消耗 db.add( AuditEvent( actor_type="user", actor_id=domain_id, action="diagnostic.read", aggregate_type="diagnostic_grant", aggregate_id=grant.id, payload={"run_id": run_id}, trace_id=None, ) ) turns = list( ( await db.scalars( select(Turn).where(Turn.run_id == run_id).order_by(Turn.index.asc()) ) ).all() ) calls = list( ( await db.scalars( select(ModelCall).where(ModelCall.turn_id.in_([t.id for t in turns])) ) ).all() ) tools = list( ( await db.scalars(select(ToolCall).where(ToolCall.run_id == run_id)) ).all() ) return { "run": {"id": run_id, "status": run.status}, "turns": [ { "index": t.index, "model_calls": [ { "status": c.status, "usage": c.usage, "ttft_ms": c.ttft_ms, "error": c.error, } for c in calls if c.turn_id == t.id ], } for t in turns ], "tool_calls": [ {"tool": tc.tool_name, "status": tc.status, "error": tc.error} for tc in tools ], } async def require_admin(db: AsyncSession, user_id: str) -> None: from app.models import User user = await db.get(User, user_id) if user is None or user.role != "admin": raise HTTPException(status.HTTP_403_FORBIDDEN, "需要管理员角色") async def list_pending_cases(db: AsyncSession) -> list[ModerationCase]: return list( ( await db.scalars( select(ModerationCase) .where(ModerationCase.status == "pending") .order_by(ModerationCase.created_at.asc()) .limit(100) ) ).all() ) async def resolve_case( db: AsyncSession, admin_domain_id: str, case_id: str, action: str, trace_id: str | None, ) -> ModerationCase: """处置司议案:content_removed 联动下架目标内容(治理闭环)。""" case = await db.get(ModerationCase, case_id) if case is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "案件不存在") if case.status != "pending": raise HTTPException(status.HTTP_409_CONFLICT, "案件已处理") if action == "content_removed": report = await db.get(Report, case.report_id) assert report is not None if report.target_type == "content": content = await db.get(CommunityContent, report.target_id) if content is not None: content.status = "removed" # 下架传播(列表/详情/评论不可见) elif report.target_type == "comment": comment = await db.get(Comment, report.target_id) if comment is not None: comment.status = "removed" case.status = "resolved" case.action_taken = action db.add( AuditEvent( actor_type="admin", actor_id=admin_domain_id, action="moderation.resolved", aggregate_type="moderation_case", aggregate_id=case.id, payload={"action": action}, trace_id=trace_id, ) ) await db.flush() return case async def build_support_bundle(db: AsyncSession, domain_id: str) -> dict[str, Any]: """脱敏支持包:统计与错误码聚合,无正文、无密钥(契约运营域)。""" run_rows = ( await db.execute( select(Run.status, func.count()) .where(Run.personal_domain_id == domain_id) .group_by(Run.status) ) ).all() message_count = ( await db.execute( select(func.count()).select_from(Message).join( Run, Message.run_id == Run.id ).where(Run.personal_domain_id == domain_id) ) ).scalar_one() tool_rows = ( await db.execute( select(ToolCall.status, func.count()) .join(Run, ToolCall.run_id == Run.id) .where(Run.personal_domain_id == domain_id) .group_by(ToolCall.status) ) ).all() media_rows = ( await db.execute( select(MediaJob.status, func.count()) .where(MediaJob.domain_id == domain_id) .group_by(MediaJob.status) ) ).all() return { "generated_at": datetime.now(UTC).isoformat(), "runs_by_status": {s: int(c) for s, c in run_rows}, "messages_total": int(message_count), "tool_calls_by_status": {s: int(c) for s, c in tool_rows}, "media_jobs_by_status": {s: int(c) for s, c in media_rows}, }