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
533 lines
22 KiB
Python
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
|