Files
server-core/app/services/moderation_service.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

533 lines
22 KiB
Python

# -*- coding: utf-8 -*-
"""统一内容审核服务(内容治理中台)。
依据《网络安全法》第47条、《网络信息内容生态治理规定》(网信办令第5号):
平台对违法/不良信息须及时处置(隐藏/删除)、保存处置记录、可追溯发布者。
覆盖内容类型:services / community_posts / community_comments / ratings / jobs /
talents / user_pages / tasks / dm_messages / user_profiles。
处置语义(按类型):
- services/community_posts/community_comments/ratings/jobs/tasks → status=hidden(恢复回快照原值)
- talents → published=False(恢复回原值)
- user_pages → review_status=hidden + published=False(恢复回原值)
- dm_messages → hidden=1(双方不可见)
- user_profiles → users.profile_hidden=1 + 昵称脱敏(原昵称留档,恢复还原)
"""
from __future__ import annotations
import json
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from ..infrastructure.models import (
CommunityComment,
CommunityPost,
ContentModerationRecord,
ContentReport,
DmMessage,
Job,
OpcService,
Rating,
Task,
TalentProfile,
User,
UserPage,
)
from ..infrastructure.repositories import Database, new_id, utcnow_iso
# 违规原因预设(admin 弹窗可选;可自定义补充)
VIOLATION_REASONS = [
"政治敏感",
"涉黄低俗",
"暴力恐怖",
"人身攻击/辱骂",
"谣言虚假信息",
"诈骗/引流",
"广告营销",
"侵权抄袭",
"违反公序良俗",
"其他",
]
# 可隐藏的任务状态(进行中的任务不可隐藏,避免破坏业务流)
_TASK_HIDABLE = {"draft", "pending", "review", "published"}
class ModerationError(Exception):
"""内容治理业务错误(message 直接给用户)。"""
def _model_for(content_type: str):
return {
"services": OpcService,
"community_posts": CommunityPost,
"community_comments": CommunityComment,
"ratings": Rating,
"jobs": Job,
"talents": TalentProfile,
"user_pages": UserPage,
"tasks": Task,
"dm_messages": DmMessage,
"user_profiles": User,
}.get(content_type)
def _snapshot(row) -> dict:
"""整行快照(取证:原始内容 + 发布者 + 时间)。"""
d = {}
for c in row.__table__.columns:
v = getattr(row, c.name, None)
if isinstance(v, (bytes, bytearray)):
v = v.decode("utf-8", "ignore")
d[c.name] = v
return d
def _display(content_type: str, row) -> dict:
"""管理端列表展示字段(内容预览 + 发布者 + IP + 状态 + 时间)。"""
base = {
"contentType": content_type,
"id": row.id if hasattr(row, "id") else getattr(row, "user_id", ""),
"publisherId": "",
"publisherName": "",
"ip": getattr(row, "ip", "") or "",
"status": "",
"createdAt": getattr(row, "created_at", "") or "",
"content": "",
"extra": {},
}
if content_type == "services":
base.update(publisherId=row.opc_id or "", publisherName=row.opc_name or "",
status=row.status, content=f"{row.title}\n{row.description}",
extra={"category": row.category, "price": row.price})
elif content_type == "community_posts":
base.update(publisherId=row.author_id or "", publisherName=row.author_name or "",
status=row.status, content=f"{row.title}\n{row.content}",
extra={"topic": row.topic, "pinned": bool(row.pinned)})
elif content_type == "community_comments":
base.update(publisherId=row.author_id or "", publisherName=row.author_name or "",
status=row.status, content=row.content, extra={"postId": row.post_id})
elif content_type == "ratings":
base.update(publisherId=row.from_id or "", publisherName="",
status=row.status, content=row.comment or "",
extra={"taskId": row.task_id, "score": row.score, "toId": row.to_id})
elif content_type == "jobs":
base.update(publisherId=row.publisher_id or "", publisherName=row.company_name or "",
status=row.status, content=f"{row.title}\n{row.description}\n{row.requirement}",
extra={"category": row.category, "salary": f"{row.salary_min}-{row.salary_max}"})
elif content_type == "talents":
base.update(publisherId=row.user_id or "", publisherName=row.display_name or "",
status="published" if row.published else "hidden",
content=f"{row.headline or ''}\n{row.bio or ''}",
extra={"talentType": row.talent_type})
elif content_type == "user_pages":
base.update(publisherId=row.user_id or "", publisherName="",
status=row.review_status,
content=row.html_url or row.embed_url or row.html_content or "",
extra={"pageType": row.page_type, "published": bool(row.published)})
elif content_type == "tasks":
base.update(publisherId=row.publisher_id or "", publisherName=row.publisher_name or "",
status=row.status, content=f"{row.title}\n{row.description or ''}",
extra={"category": row.category, "mode": row.mode})
elif content_type == "dm_messages":
base.update(publisherId=row.sender_id or "", publisherName="",
status="hidden" if row.hidden else "published",
content=row.content, extra={"receiverId": row.receiver_id})
elif content_type == "user_profiles":
base.update(publisherId=row.id or "", publisherName=row.nickname or row.username or "",
status="hidden" if row.profile_hidden else "published",
content=row.nickname or "",
extra={"username": row.username, "intro": getattr(row, "intro", "") or ""})
return base
async def _load_row(db: Database, content_type: str, content_id: str):
model = _model_for(content_type)
if model is None:
raise ModerationError(f"不支持的内容类型: {content_type}")
row = await db.session.get(model, content_id)
if row is None:
raise ModerationError("内容不存在")
return row
async def _hide_row(row, content_type: str, session: AsyncSession) -> None:
if content_type == "services":
row.status = "hidden"
elif content_type == "community_posts":
row.status = "hidden"
elif content_type == "community_comments":
row.status = "hidden"
elif content_type == "ratings":
row.status = "hidden"
elif content_type == "jobs":
row.status = "hidden"
elif content_type == "tasks":
if row.status not in _TASK_HIDABLE:
raise ModerationError("进行中的任务不可隐藏,请先关闭任务")
row.status = "hidden"
elif content_type == "talents":
row.published = False
elif content_type == "user_pages":
row.review_status = "hidden"
row.published = False
elif content_type == "dm_messages":
row.hidden = 1
elif content_type == "user_profiles":
if not row.profile_hidden:
row.profile_original_nickname = row.nickname or ""
row.nickname = f"用户{row.id[-4:] if row.id else ''}"
row.profile_hidden = 1
async def _restore_row(row, content_type: str, session: AsyncSession) -> None:
if content_type == "services":
row.status = "published"
elif content_type == "community_posts":
row.status = "published"
elif content_type == "community_comments":
row.status = "published"
elif content_type == "ratings":
row.status = "published"
elif content_type == "jobs":
row.status = "published"
elif content_type == "tasks":
row.status = "published"
elif content_type == "talents":
row.published = True
elif content_type == "user_pages":
row.review_status = "approved"
row.published = True
elif content_type == "dm_messages":
row.hidden = 0
elif content_type == "user_profiles":
if row.profile_original_nickname:
row.nickname = row.profile_original_nickname
row.profile_original_nickname = ""
row.profile_hidden = 0
async def _delete_row(row, content_type: str, session: AsyncSession) -> None:
await session.delete(row)
def _publisher_of(content_type: str, row) -> tuple[str, str, str]:
"""(publisher_id, publisher_name, ip)"""
if content_type == "services":
return row.opc_id or "", row.opc_name or "", getattr(row, "ip", "") or ""
if content_type == "community_posts":
return row.author_id or "", row.author_name or "", getattr(row, "ip", "") or ""
if content_type == "community_comments":
return row.author_id or "", row.author_name or "", getattr(row, "ip", "") or ""
if content_type == "ratings":
return row.from_id or "", "", getattr(row, "ip", "") or ""
if content_type == "jobs":
return row.publisher_id or "", row.company_name or "", getattr(row, "ip", "") or ""
if content_type == "talents":
return row.user_id or "", row.display_name or "", ""
if content_type == "user_pages":
return row.user_id or "", "", getattr(row, "ip", "") or ""
if content_type == "tasks":
return row.publisher_id or "", row.publisher_name or "", getattr(row, "ip", "") or ""
if content_type == "dm_messages":
return row.sender_id or "", "", getattr(row, "ip", "") or ""
if content_type == "user_profiles":
return row.id or "", row.nickname or row.username or "", getattr(row, "last_login_ip", "") or ""
return "", "", ""
def _type_label(content_type: str) -> str:
return {
"services": "服务", "community_posts": "社区帖子", "community_comments": "社区评论",
"ratings": "评价", "jobs": "岗位", "talents": "人才", "user_pages": "个人主页",
"tasks": "任务", "dm_messages": "沟通记录", "user_profiles": "用户资料",
}.get(content_type, content_type)
async def hide(db: Database, *, content_type: str, content_id: str,
reason: str, severity: str = "general",
operator_id: str = "", operator_name: str = "") -> dict:
"""隐藏并移入违规库(留快照 + 通知发布者)。"""
row = await _load_row(db, content_type, content_id)
await _hide_row(row, content_type, db.session)
pub_id, pub_name, ip = _publisher_of(content_type, row)
snap = _snapshot(row)
if content_type == "user_profiles":
snap["original_nickname"] = row.profile_original_nickname or pub_name
now = utcnow_iso()
# 已有 active 违规记录则更新(同一内容重复处置)
existing = (await db.session.scalars(
select(ContentModerationRecord).where(
ContentModerationRecord.content_type == content_type,
ContentModerationRecord.content_id == content_id,
ContentModerationRecord.status == "active",
)
)).first()
if existing is not None:
existing.snapshot_json = json.dumps(snap, ensure_ascii=False)
existing.reason = reason
existing.severity = severity
existing.operator_id = operator_id
existing.operator_name = operator_name
existing.operated_at = now
rec = existing
else:
rec = ContentModerationRecord(
id=new_id("mod"), content_type=content_type, content_id=content_id,
snapshot_json=json.dumps(snap, ensure_ascii=False),
publisher_id=pub_id, publisher_name=pub_name, ip=ip,
reason=reason, severity=severity, action="hidden",
status="active", operator_id=operator_id, operator_name=operator_name,
operated_at=now,
)
db.session.add(rec)
await db.session.commit()
await _notify(db, pub_id, _type_label(content_type),
f"你的{_type_label(content_type)}因「{reason}」被隐藏,如有异议可申诉",
f"/opc/moderation-appeal?type={content_type}&id={content_id}")
return {"ok": True, "recordId": rec.id}
async def restore(db: Database, *, content_type: str, content_id: str,
note: str = "", operator_id: str = "", operator_name: str = "") -> dict:
"""恢复内容(违规库 status=recovered)。"""
rec = (await db.session.scalars(
select(ContentModerationRecord).where(
ContentModerationRecord.content_type == content_type,
ContentModerationRecord.content_id == content_id,
ContentModerationRecord.status == "active",
)
)).first()
if rec is None:
raise ModerationError("违规库中不存在该内容的生效记录")
row = await _load_row(db, content_type, content_id)
await _restore_row(row, content_type, db.session)
rec.status = "recovered"
rec.recover_note = note
await db.session.commit()
await _notify(db, rec.publisher_id or "", _type_label(content_type),
f"你的{_type_label(content_type)}已恢复展示",
"")
return {"ok": True, "recordId": rec.id}
async def delete(db: Database, *, content_type: str, content_id: str,
reason: str, operator_id: str = "", operator_name: str = "") -> dict:
"""删除内容并归档违规库(保留快照取证)。"""
row = await _load_row(db, content_type, content_id)
pub_id, pub_name, ip = _publisher_of(content_type, row)
snap = _snapshot(row)
if content_type == "user_profiles":
snap["original_nickname"] = row.nickname or ""
await _delete_row(row, content_type, db.session)
now = utcnow_iso()
existing = (await db.session.scalars(
select(ContentModerationRecord).where(
ContentModerationRecord.content_type == content_type,
ContentModerationRecord.content_id == content_id,
ContentModerationRecord.status == "active",
)
)).first()
if existing is not None:
existing.snapshot_json = json.dumps(snap, ensure_ascii=False)
existing.reason = reason
existing.action = "deleted"
existing.operator_id = operator_id
existing.operator_name = operator_name
existing.operated_at = now
rec = existing
else:
rec = ContentModerationRecord(
id=new_id("mod"), content_type=content_type, content_id=content_id,
snapshot_json=json.dumps(snap, ensure_ascii=False),
publisher_id=pub_id, publisher_name=pub_name, ip=ip,
reason=reason, severity="severe", action="deleted",
status="active", operator_id=operator_id, operator_name=operator_name,
operated_at=now,
)
db.session.add(rec)
await db.session.commit()
await _notify(db, pub_id, _type_label(content_type),
f"你的{_type_label(content_type)}因「{reason}」被删除",
"")
return {"ok": True, "recordId": rec.id}
async def list_content(db: Database, *, content_type: str, status: str = "",
keyword: str = "") -> list[dict]:
"""管理端内容列表(含发布者/用户ID/IP/状态)。"""
model = _model_for(content_type)
if model is None:
raise ModerationError(f"不支持的内容类型: {content_type}")
stmt = select(model)
if status:
if content_type == "talents":
stmt = stmt.where(model.published == (status != "hidden"))
elif content_type == "user_pages":
stmt = stmt.where(model.review_status == status)
elif content_type == "dm_messages":
stmt = stmt.where(model.hidden == (1 if status == "hidden" else 0))
elif content_type == "user_profiles":
stmt = stmt.where(model.profile_hidden == (1 if status == "hidden" else 0))
else:
stmt = stmt.where(model.status == status)
if keyword and keyword.strip():
kw = f"%{keyword.strip()}%"
# 按内容字段模糊检索
like_cols = []
if content_type == "services":
like_cols = [model.title, model.description]
elif content_type == "community_posts":
like_cols = [model.title, model.content]
elif content_type == "community_comments":
like_cols = [model.content]
elif content_type == "ratings":
like_cols = [model.comment]
elif content_type == "jobs":
like_cols = [model.title, model.description, model.requirement]
elif content_type == "talents":
like_cols = [model.display_name, model.headline, model.bio]
elif content_type == "user_pages":
like_cols = [model.html_url, model.embed_url]
elif content_type == "tasks":
like_cols = [model.title, model.description]
elif content_type == "dm_messages":
like_cols = [model.content]
elif content_type == "user_profiles":
like_cols = [model.nickname, model.username]
if like_cols:
stmt = stmt.where(or_(*[c.like(kw) for c in like_cols]))
# 排序:最新在前
order_col = getattr(model, "created_at", None) or getattr(model, "updated_at", None)
if order_col is not None:
stmt = stmt.order_by(order_col.desc())
rows = (await db.session.scalars(stmt)).all()
items = [_display(content_type, r) for r in rows]
# 补充发布者昵称(ratings/dm 无 name 时按用户表补)
ids = {i["publisherId"] for i in items if i["publisherId"]}
if ids:
users = (await db.session.scalars(select(User).where(User.id.in_(ids)))).all()
umap = {u.id: u for u in users}
for i in items:
if not i["publisherName"] and i["publisherId"] in umap:
u = umap[i["publisherId"]]
i["publisherName"] = u.nickname or u.username or u.id
return items
async def archive_list(db: Database, *, content_type: str = "", severity: str = "",
status: str = "", q: str = "") -> list[dict]:
"""违规内容库查询。"""
stmt = select(ContentModerationRecord).order_by(ContentModerationRecord.operated_at.desc())
if content_type:
stmt = stmt.where(ContentModerationRecord.content_type == content_type)
if severity:
stmt = stmt.where(ContentModerationRecord.severity == severity)
if status:
stmt = stmt.where(ContentModerationRecord.status == status)
if q and q.strip():
kw = f"%{q.strip()}%"
stmt = stmt.where(or_(
ContentModerationRecord.content_id.like(kw),
ContentModerationRecord.publisher_name.like(kw),
ContentModerationRecord.reason.like(kw),
))
rows = (await db.session.scalars(stmt)).all()
out = []
for r in rows:
try:
snap = json.loads(r.snapshot_json or "{}")
except Exception: # noqa: BLE001
snap = {}
out.append({
"id": r.id, "contentType": r.content_type, "contentId": r.content_id,
"snapshot": snap, "publisherId": r.publisher_id, "publisherName": r.publisher_name,
"ip": r.ip, "reason": r.reason, "severity": r.severity, "action": r.action,
"status": r.status, "operatorId": r.operator_id, "operatorName": r.operator_name,
"operatedAt": r.operated_at, "recoverNote": r.recover_note,
})
return out
async def report(db: Database, *, content_type: str, content_id: str,
reporter_id: str, reason: str, detail: str = "") -> dict:
"""用户举报入队(同内容同人 pending 去重)。"""
model = _model_for(content_type)
if model is None:
raise ModerationError("不支持的内容类型")
row = await db.session.get(model, content_id)
if row is None:
raise ModerationError("内容不存在")
dup = (await db.session.scalars(
select(ContentReport).where(
ContentReport.content_type == content_type,
ContentReport.content_id == content_id,
ContentReport.reporter_id == reporter_id,
ContentReport.status == "pending",
)
)).first()
if dup is not None:
return {"ok": True, "duplicated": True}
r = ContentReport(id=new_id("rpt"), content_type=content_type, content_id=content_id,
reporter_id=reporter_id, reason=reason or "其他",
detail=detail, status="pending", created_at=utcnow_iso())
db.session.add(r)
await db.session.commit()
return {"ok": True, "duplicated": False}
async def handle_report(db: Database, *, report_id: str, action: str,
reason: str = "", operator_id: str = "", operator_name: str = "") -> dict:
"""处理举报:confirm → 确认违规并隐藏归档;ignore → 驳回。"""
r = await db.session.get(ContentReport, report_id)
if r is None:
raise ModerationError("举报不存在")
if r.status != "pending":
raise ModerationError("该举报已处理")
now = utcnow_iso()
if action == "confirm":
await hide(db, content_type=r.content_type, content_id=r.content_id,
reason=reason or r.reason or "经举报核实违规",
severity="general", operator_id=operator_id, operator_name=operator_name)
r.status = "processed"
r.result = f"确认违规并隐藏:{reason or r.reason}"
else:
r.status = "ignored"
r.result = f"举报不成立:{reason or '无充分依据'}"
r.processed_at = now
r.processor_id = operator_id
await db.session.commit()
return {"ok": True, "status": r.status}
async def scan(db: Database, *, keyword: str, content_type: str = "") -> list[dict]:
"""巡查:按关键词在指定/全部类型中检索命中内容。"""
keyword = (keyword or "").strip()
if not keyword:
raise ModerationError("请输入巡查关键词")
types = [content_type] if content_type else list({
"services", "community_posts", "community_comments", "ratings",
"jobs", "talents", "user_pages", "tasks", "dm_messages", "user_profiles",
})
hits: list[dict] = []
for t in types:
try:
items = await list_content(db, content_type=t, keyword=keyword)
hits.extend(items)
except ModerationError:
continue
return hits
async def _notify(db: Database, user_id: str, _type: str, content: str, link: str) -> None:
if not user_id:
return
try:
from .notification_service import notify as _n
await _n(db, user_id, "system", "内容审核通知", content,
event_code="moderation.disposed", level="warning", link=link)
except Exception: # noqa: BLE001
pass