Files
server-core/app/api/routers/rbac_moderation.py
T
Pine e831fdd34e feat: 内容审核体系(0070)— 违规内容库/举报表/IP采集/统一审核服务/审核API
- 迁移0070:content_moderation_records(违规库,快照+发布者+IP+原因+处置人)、
  content_reports(举报队列);各内容表加 ip 列,dm_messages 加 hidden,
  community_comments/ratings 加 status,users 加 profile_hidden/original_nickname
- moderation_service:hide/restore/delete(物理删除留档)/list/archive/report/
  handle_report/scan + 申诉通知;用户资料隐藏自动脱敏昵称
- rbac_moderation:/admin/moderation/{type} 列表、hide/restore/delete、
  violations 违规库、reports 举报处理、scan 巡查;/content/report 用户举报入口
- IP 采集:任务/岗位/服务/社区帖子/评论/私信/评价/主页提交创建端点写入发布者 IP
- 前台可见性:社区评论/私信查询过滤 hidden
2026-09-07 15:07:43 +08:00

210 lines
9.9 KiB
Python

# -*- coding: utf-8 -*-
"""内容审核与巡查端点(运营方)+ 用户举报入口。
- /admin/moderation/*:运营方内容治理(隐藏/恢复/删除 + 违规库 + 举报处理 + 巡查)
- /content/report:登录用户通用举报入口
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from ..dependencies import get_current_user, get_db
from ...rbac import require_roles, write_audit
from ...infrastructure.repositories import Database
from ...services import moderation_service
from ...services.moderation_service import ModerationError, VIOLATION_REASONS
router = APIRouter(prefix="/admin/moderation", tags=["moderation-admin"])
report_router = APIRouter(prefix="/content", tags=["moderation-report"])
# 支持的内容类型白名单(与 moderation_service 一致)
CONTENT_TYPES = [
"services", "community_posts", "community_comments", "ratings",
"jobs", "talents", "user_pages", "tasks", "dm_messages", "user_profiles",
]
def _check_type(content_type: str) -> str:
if content_type not in CONTENT_TYPES:
raise HTTPException(status_code=400, detail="不支持的内容类型")
return content_type
def _actor(u: dict) -> tuple[str, str]:
return u.get("id", ""), u.get("nickname") or u.get("username", "")
@router.get("/types", summary="支持的内容类型与违规原因预设")
async def moderation_meta(_u: dict = Depends(require_roles("operator"))):
return {"types": CONTENT_TYPES, "reasons": VIOLATION_REASONS}
@router.get("/{content_type}", summary="内容列表(含发布者/用户ID/IP/状态)")
async def moderation_list(content_type: str, status: str = "", keyword: str = "",
db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
_check_type(content_type)
try:
items = await moderation_service.list_content(
db, content_type=content_type, status=status, keyword=keyword)
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {"items": items}
@router.post("/{content_type}/{content_id}/hide", summary="隐藏并移入违规内容库")
async def moderation_hide(content_type: str, content_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
_check_type(content_type)
reason = str(body.get("reason", "")).strip()
if not reason:
raise HTTPException(status_code=400, detail="请填写违规原因")
oid, oname = _actor(actor)
try:
r = await moderation_service.hide(
db, content_type=content_type, content_id=content_id,
reason=reason, severity=str(body.get("severity", "general")),
operator_id=oid, operator_name=oname)
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
await write_audit(db, action="admin.moderation.hide", resource=content_type,
resource_id=content_id, detail=f"{reason} | {body.get('severity', 'general')}",
user=actor, request=request)
return r
@router.post("/{content_type}/{content_id}/restore", summary="恢复内容(违规库同步 recovered)")
async def moderation_restore(content_type: str, content_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
_check_type(content_type)
oid, oname = _actor(actor)
try:
r = await moderation_service.restore(
db, content_type=content_type, content_id=content_id,
note=str(body.get("note", "")), operator_id=oid, operator_name=oname)
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
await write_audit(db, action="admin.moderation.restore", resource=content_type,
resource_id=content_id, detail=str(body.get("note", "")),
user=actor, request=request)
return r
@router.delete("/{content_type}/{content_id}", summary="删除内容并归档违规库(留快照取证)")
async def moderation_delete(content_type: str, content_id: str, request: Request,
reason: str = "内容违法",
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
_check_type(content_type)
reason = reason.strip() or "内容违法"
oid, oname = _actor(actor)
try:
r = await moderation_service.delete(
db, content_type=content_type, content_id=content_id,
reason=reason, operator_id=oid, operator_name=oname)
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
await write_audit(db, action="admin.moderation.delete", resource=content_type,
resource_id=content_id, detail=reason,
user=actor, request=request)
return r
@router.get("/violations", summary="违规内容库(统一归档查询)")
async def moderation_archive(content_type: str = "", severity: str = "", status: str = "",
q: str = "", db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
if content_type:
_check_type(content_type)
return {"items": await moderation_service.archive_list(
db, content_type=content_type, severity=severity, status=status, q=q)}
@router.post("/violations/{record_id}/restore", summary="从违规库恢复内容")
async def moderation_archive_restore(record_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
rec = await db.session.get(moderation_service.ContentModerationRecord, record_id)
if rec is None:
raise HTTPException(status_code=404, detail="违规记录不存在")
oid, oname = _actor(actor)
try:
r = await moderation_service.restore(
db, content_type=rec.content_type, content_id=rec.content_id,
note=str(body.get("note", "")), operator_id=oid, operator_name=oname)
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
await write_audit(db, action="admin.moderation.restore", resource="moderation_record",
resource_id=record_id, detail=str(body.get("note", "")),
user=actor, request=request)
return r
@router.get("/reports", summary="举报队列")
async def moderation_reports(status: str = "pending", db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
from sqlalchemy import select
from ...infrastructure.models import ContentReport
stmt = select(ContentReport).order_by(ContentReport.created_at.desc())
if status:
stmt = stmt.where(ContentReport.status == status)
rows = (await db.session.scalars(stmt)).all()
return {"items": [
{"id": r.id, "contentType": r.content_type, "contentId": r.content_id,
"reporterId": r.reporter_id, "reason": r.reason, "detail": r.detail,
"status": r.status, "createdAt": r.created_at,
"processedAt": r.processed_at, "processorId": r.processor_id, "result": r.result}
for r in rows
]}
@router.post("/reports/{report_id}/handle", summary="处理举报(confirm 确认违规并隐藏 / ignore 驳回)")
async def moderation_report_handle(report_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
action = str(body.get("action", ""))
if action not in ("confirm", "ignore"):
raise HTTPException(status_code=400, detail="action 须为 confirm/ignore")
oid, oname = _actor(actor)
try:
r = await moderation_service.handle_report(
db, report_id=report_id, action=action,
reason=str(body.get("reason", "")), operator_id=oid, operator_name=oname)
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
await write_audit(db, action="admin.moderation.report_handle", resource="content_report",
resource_id=report_id, detail=f"{action} | {body.get('reason', '')}",
user=actor, request=request)
return r
@router.get("/scan", summary="巡查:关键词命中列表")
async def moderation_scan(keyword: str, content_type: str = "", db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
if content_type:
_check_type(content_type)
try:
return {"items": await moderation_service.scan(db, keyword=keyword, content_type=content_type)}
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
@report_router.post("/report", summary="用户举报内容(通用入口)")
async def content_report(body: dict, db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
content_type = str(body.get("contentType", "")).strip()
content_id = str(body.get("contentId", "")).strip()
if content_type not in CONTENT_TYPES or not content_id:
raise HTTPException(status_code=400, detail="缺少内容类型或内容ID")
try:
r = await moderation_service.report(
db, content_type=content_type, content_id=content_id,
reporter_id=user["id"], reason=str(body.get("reason", "")).strip(),
detail=str(body.get("detail", "")).strip())
except ModerationError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return r