e831fdd34e
- 迁移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
210 lines
9.9 KiB
Python
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
|