"""内容审核域 Router(CR-01 / CR-02 / CR-03 / CR-STATS-01 / CR-DECISION-BATCH)。 本 router 实现内容审核域的全部 HTTP 端点,覆盖管理员主动触发的 dry-run 预审核、审核历史查询、审核统计与批量人工决定共 5 个操作。内容审核是 跨渠道的聚合能力,路径中 **不** 包含 ``{channel_type}`` 段;子 router 不自行设置 prefix,根前缀 ``/channels`` 由 ``channels_router`` 聚合 router 统一追加。 模板选型: - CR-01 走模板 A(控制面端口路由)——端点函数体仅做 ``operator = build_operator`` → ``use_cases.content_review.previewContent`` → ``raiseOnControlFailure`` → ``return {"success": True, "data": serialize_control_data(result.data)}``。 ``previewContent`` 内部由 ``ChannelControlService`` 走控制面管道 (auth → permission → rate_limit → dispatch → audit),dispatch 阶段 调度 ``ContentModerationAdapter.review`` 执行审核并写入审核历史存储 (``source=manual_preview``),审计日志经 ``audit_stage`` 写入 (``tx_strategy=COOPERATIVE``)。 - CR-STATS-01 走模板 A(控制面端口路由)——与 CR-01 同模板,经 ``ChannelControlService.getReviewStats`` 走控制面管道,dispatch 阶段 聚合审核记录计算统计指标,审计日志记录 ``admin_query``(谁查看了 统计指标)。``start_time`` / ``end_time`` 由 DTO ``__post_init__`` 校验 ``start_time < end_time``。 - CR-DECISION-BATCH 走模板 A(控制面端口路由)——经 ``ChannelControlService.batchReviewDecision`` 走控制面管道,dispatch 阶段使用 ``_executeBatch`` 逐条独立事务(Pattern D)执行人工决定 覆盖,审计日志记录 ``content_review_decided``,返回部分成功结果 (``total`` / ``succeeded`` / ``failed``)。``apply_to_pending_messages=True`` 时显式抛 ``NotImplementedError``(501),不静默忽略。 - CR-02 / CR-03 走模板 B(数据面端口路由)——端点函数体仅做 构造查询命令 → ``use_cases.content_review_query.(cmd)`` → ``return {"success": True, "data": dataclass_to_dict(result)}``。 CR-03 额外构造 ``operator``(供 ``NotFoundError.trace_id`` 透传), CR-02 无此需求(列表查询不抛 ``NotFoundError``)。数据面用例直接 读取 ``ContentReviewRepositoryPort``,**不** 经控制面管道,**不** 调 ``raiseOnControlFailure``(数据面异常由全局 ``unified_error_handler`` 统一映射为 HTTP 响应),**不** 写审计日志(查询操作无副作用)。 鉴权策略:全部端点使用 ``get_admin_user`` 依赖,要求管理员或超级管理员角色。 角色校验由依赖函数完成,router 内不做角色判断(规范 §4)。 路径设计:静态后缀(``/preview`` / ``/history`` / ``/stats`` / ``/history/batch-decision``)先于动态单段路径(``/history/{review_id}``) 声明,避免 ``preview`` / ``history`` / ``stats`` / ``batch-decision`` 被 捕获为 ``review_id``(规范 §6.5)。 协议翻译:CR-02 的 ``verdict`` 查询参数为 string 类型,需在 router 层 翻译为枚举;CR-02 与 CR-STATS-01 的 ``start_time`` / ``end_time`` 查询 参数为 string 类型,需在 router 层翻译为 datetime;失败显式抛 ``ValidationError``,由全局异常处理器映射为 400 ``VALIDATION_ERROR``。 时间范围 ``start_time < end_time`` 由各 DTO ``__post_init__`` 统一校验。 端点清单(对应《内容审核域设计方案》§2.1): - POST /content-review/preview CR-01 preview_content - GET /content-review/history CR-02 list_review_history - GET /content-review/history/{review_id} CR-03 get_review_detail - GET /content-review/stats CR-STATS-01 get_review_stats - POST /content-review/history/batch-decision CR-DECISION-BATCH batch_review_decision """ from __future__ import annotations from typing import Any, Literal from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, ConfigDict, Field from yuxi.channels.contract.dtos.channel import ChannelType from yuxi.channels.contract.dtos.content_review import ( BatchReviewDecisionCmd, ContentReviewDetailQueryCmd, ContentReviewHistoryQueryCmd, ContentReviewResourceType, ContentReviewStatsQuery, ContentReviewVerdict, ReviewDecisionItem, ) from yuxi.channels.contract.errors import ValidationError from yuxi.storage.postgres.models_business import User from server.routers.channels import ( Granularity, build_operator, dataclass_to_dict, get_channel_use_cases, parse_datetime, raiseOnControlFailure, serialize_control_data, ) from server.utils.auth_middleware import get_admin_user content_review_router = APIRouter(tags=["channels-content-review"]) class ContentReviewPreviewRequest(BaseModel): """内容预审核请求体(CR-01)。 frozen=True 保证请求体在端点函数内不被意外修改;字段约束与 ``ContentReviewResourceType`` 枚举值由 dispatch 阶段二次校验,router 层仅做基本的非空与长度约束。 """ model_config = ConfigDict(frozen=True) channel_type: ChannelType = Field(..., description="渠道类型") account_id: str = Field(..., min_length=1, description="账户 ID") resource_type: ContentReviewResourceType = Field( ..., description="资源类型:message_text / message_attachment / user_profile", ) content: str = Field(..., min_length=1, max_length=10000, description="待审核文本") peer_id: str | None = Field(default=None, description="对端 ID(可选,用于上下文)") class ReviewDecisionItemRequest(BaseModel): """单条审核决定请求体(CR-DECISION-BATCH)。 ``decision`` 取值 ``pass`` / ``block``,由 Pydantic ``Literal`` 约束 在入参边界拦截非法值(与 DTO ``ReviewDecisionItem.__post_init__`` 二次校验形成纵深防御);``reason`` / ``categories`` 为可选字段, ``categories`` 默认空列表。 """ model_config = ConfigDict(frozen=True) review_id: str = Field(..., min_length=1, description="审核记录 ID") decision: Literal["pass", "block"] = Field(..., description="决定结果:pass / block") reason: str | None = Field(default=None, description="决定原因(可选)") categories: list[str] = Field(default_factory=list, description="命中分类(可选)") class BatchReviewDecisionRequest(BaseModel): """批量审核决定请求体(CR-DECISION-BATCH)。 ``decisions`` 数量限制 1-100,由 dispatch 阶段二次校验; ``apply_to_pending_messages`` 为预留字段,本期不实现关联处理。 """ model_config = ConfigDict(frozen=True) decisions: list[ReviewDecisionItemRequest] = Field( ..., min_length=1, max_length=100, description="决定条目列表(1-100)" ) apply_to_pending_messages: bool = Field(default=False, description="是否关联处理待审消息(本期预留)") def _parse_verdict(raw: str) -> ContentReviewVerdict: """string → ContentReviewVerdict,失败抛 ValidationError。 将 CR-02 的 ``verdict`` 查询参数(string)翻译为 ``ContentReviewVerdict`` 枚举;非法值由全局异常处理器映射为 400 ``VALIDATION_ERROR``。 """ try: return ContentReviewVerdict(raw) except ValueError as exc: raise ValidationError( "verdict", f"unsupported verdict: {raw} (expected: pass | review | block)", ) from exc def _parse_resource_type(raw: str) -> ContentReviewResourceType: """string → ContentReviewResourceType,失败抛 ValidationError。 将 CR-02 的 ``resource_type`` 查询参数(string)翻译为 ``ContentReviewResourceType`` 枚举;非法值由全局异常处理器映射为 400 ``VALIDATION_ERROR``。 """ try: return ContentReviewResourceType(raw) except ValueError as exc: raise ValidationError( "resource_type", f"unsupported resource_type: {raw} " "(expected: message_text | message_attachment | user_profile)", ) from exc # ---------------- 静态路径端点(须先于动态路径声明) ---------------- @content_review_router.post( "/content-review/preview", response_model=dict, ) async def preview_content( payload: ContentReviewPreviewRequest, request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """内容预审核(dry-run,FR-30,CR-01)。 对应控制面操作 ``content_review/preview``(由 ``ChannelControlService.previewContent`` 内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。dry-run 语义:不触发消息投递、 不触发告警、不修改消息状态、不触发任何 webhook 回调;仅记录审核结果 (``source=manual_preview``)与审计日志(``audit_type= content_review_previewed``)。 编排链路:HTTP 入参 → ``build_operator`` 构造操作人 → ``use_cases.content_review.previewContent`` 调用端口方法 → 控制面管道 调度 ``ContentModerationAdapter.review`` 获取审核结论 → 生成 ``review_id`` 并写入 ``ContentReviewRepositoryPort`` → ``ControlResult`` 经 ``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化审核结果返回。 """ operator = build_operator(current_user, request) result = await use_cases.content_review.previewContent( channel_type=payload.channel_type, account_id=payload.account_id, resource_type=payload.resource_type, content=payload.content, peer_id=payload.peer_id, operator=operator, ) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @content_review_router.get( "/content-review/history", response_model=dict, ) async def list_review_history( channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"), account_id: str | None = Query(default=None, description="按账户 ID 过滤"), verdict: str | None = Query( default=None, description="按审核结论过滤:pass / review / block", ), resource_type: str | None = Query( default=None, description="按资源类型过滤:message_text / message_attachment / user_profile", ), trace_id: str | None = Query( default=None, description="按链路追踪 ID 过滤", ), start_time: str | None = Query( default=None, description="起始时间过滤(ISO 8601,含)", ), end_time: str | None = Query( default=None, description="结束时间过滤(ISO 8601,含)", ), limit: int = Query(default=20, ge=1, le=100, description="每页数量(1-100)"), offset: int = Query(default=0, ge=0, description="分页偏移量"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """查询审核历史列表(FR-30,CR-02)。 非控制面管道路径(模板 B):由 ``ContentReviewQueryService`` 直接读取 ``ContentReviewRepositoryPort``,不经控制面管道,不写审计日志(查询 操作无副作用)。``verdict`` / ``resource_type`` / ``start_time`` / ``end_time`` 查询参数经协议翻译辅助函数转换为枚举 / datetime,失败抛 ``ValidationError``。 编排链路:HTTP 入参 → 协议翻译(``_parse_verdict`` / ``_parse_resource_type`` / ``parse_datetime``)→ 构造 ``ContentReviewHistoryQueryCmd`` (DTO ``__post_init__`` 校验 limit/offset/time_range)→ ``use_cases.content_review_query.listHistory`` → ``dataclass_to_dict`` 序列化 ``ContentReviewHistoryList`` 返回。 数据面查询无副作用,不写审计日志,故不构造 ``operator``。 """ cmd = ContentReviewHistoryQueryCmd( channel_type=channel_type, account_id=account_id, verdict=_parse_verdict(verdict) if verdict is not None else None, resource_type=_parse_resource_type(resource_type) if resource_type is not None else None, trace_id=trace_id, start_time=parse_datetime("start_time", start_time), end_time=parse_datetime("end_time", end_time), limit=limit, offset=offset, ) result = await use_cases.content_review_query.listHistory(cmd) return {"success": True, "data": dataclass_to_dict(result)} @content_review_router.get( "/content-review/stats", response_model=dict, ) async def get_review_stats( request: Request, channel_type: ChannelType | None = Query(default=None, description="按渠道类型过滤"), account_id: str | None = Query(default=None, description="按账户 ID 过滤"), start_time: str | None = Query( default=None, description="起始时间过滤(ISO 8601,含)", ), end_time: str | None = Query( default=None, description="结束时间过滤(ISO 8601,含)", ), granularity: Granularity = Query(default="day", description="时间粒度"), use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """审核统计(FR-30,CR-STATS-01)。 对应控制面操作 ``content_review/stats``(由 ``ChannelControlService.getReviewStats`` 内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。聚合审核记录,计算通过/拦截/待复核 计数、通过率、拦截率、人工介入率、平均决策时长与按分类切片的统计及 时间桶趋势。``start_time`` / ``end_time`` 可选,同时提供时需满足 ``start_time < end_time``。 编排链路:HTTP 入参 → ``build_operator`` 构造操作人 → 协议翻译 (``parse_datetime``)→ 构造 ``ContentReviewStatsQuery`` → ``use_cases.content_review.getReviewStats`` → ``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化统计结果返回。 """ operator = build_operator(current_user, request) query = ContentReviewStatsQuery( channel_type=channel_type, account_id=account_id, start_time=parse_datetime("start_time", start_time), end_time=parse_datetime("end_time", end_time), granularity=granularity, ) result = await use_cases.content_review.getReviewStats(query=query, operator=operator) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} @content_review_router.post( "/content-review/history/batch-decision", response_model=dict, ) async def batch_review_decision( payload: BatchReviewDecisionRequest, request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """批量审核决定(FR-30,CR-DECISION-BATCH)。 对应控制面操作 ``content_review/batch_decision``(由 ``ChannelControlService.batchReviewDecision`` 内部构造 ``ControlCmd`` 并委托 ``_executeControl`` 执行控制面管道)。dispatch 阶段使用 ``_executeBatch`` 逐条独立事务(Pattern D)对审核记录进行人工决定覆盖,``reviewer`` 记录 为 ``operator.user_id``,单条失败不回滚已成功条目,返回部分成功结果 (``total`` / ``succeeded`` / ``failed``)。 编排链路:HTTP 入参 → ``build_operator`` 构造操作人 → 构造 ``BatchReviewDecisionCmd``(DTO 含 ``ReviewDecisionItem`` 元组)→ ``use_cases.content_review.batchReviewDecision`` → ``raiseOnControlFailure`` 转译失败 → ``serialize_control_data`` 序列化 部分成功结果返回。 """ operator = build_operator(current_user, request) cmd = BatchReviewDecisionCmd( decisions=tuple( ReviewDecisionItem( review_id=item.review_id, decision=item.decision, reason=item.reason, categories=tuple(item.categories), ) for item in payload.decisions ), operator=operator, apply_to_pending_messages=payload.apply_to_pending_messages, ) result = await use_cases.content_review.batchReviewDecision(cmd=cmd) raiseOnControlFailure(result) return {"success": True, "data": serialize_control_data(result.data)} # ---------------- 动态路径端点 ---------------- @content_review_router.get( "/content-review/history/{review_id}", response_model=dict, ) async def get_review_detail( review_id: str, request: Request, use_cases=Depends(get_channel_use_cases), current_user: User = Depends(get_admin_user), ) -> dict[str, Any]: """查询单次审核详情(FR-30,CR-03)。 非控制面管道路径(模板 B):由 ``ContentReviewQueryService`` 直接读取 ``ContentReviewRepositoryPort``,返回完整审核记录含命中片段。 ``review_id`` 不存在时由 ``ContentReviewQueryService.getDetail`` 显式 转换为 ``NotFoundError``(INV-7 错误显式化),由全局异常处理器映射为 404 ``NOT_FOUND``。 编排链路:HTTP 入参 → ``build_operator`` 构造操作人 → 构造 ``ContentReviewDetailQueryCmd``(DTO ``__post_init__`` 校验 review_id 非空)→ ``use_cases.content_review_query.getDetail`` → ``dataclass_to_dict`` 序列化 ``ContentReviewDetail`` 返回。 """ operator = build_operator(current_user, request) cmd = ContentReviewDetailQueryCmd( review_id=review_id, operator=operator, ) result = await use_cases.content_review_query.getDetail(cmd) return {"success": True, "data": dataclass_to_dict(result)}