# -*- 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