Files
yunke/yunke-server/app/analytics/sensitive/scanner.py
T

211 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""敏感词扫描器——增量水位(fetched_at)+ 首次全量回填。
水位语义(spec §3.6 P2):last_fetched_at = MAX(message.fetched_at)。增量
补扫拉回的历史消息 timestamp 早于水位但 fetched_at 是新的,按 timestamp
做水位会永久漏扫。比较用 >=:fetched_at 是 MySQL DATETIME 秒精度,SELECT
快照后同秒 commit 的行 fetched_at == 水位,严格 > 会永久丢弃(只丢不重
扫)——>= 每 tick 重扫 ≤1 秒边界行,UNIQUE(word_id,msg_id)+已有命中
跳过去重,幂等(st.scanned 计数随边界重扫微膨胀,可接受)。
方言:MySQL ngram MATCH(≥2字,快);单字词与其他方言走 LIKE。语义差
异(MATCH 分词 vs LIKE 子串)可接受——命中可处置,误报治理闭环在。
分批:增量直接拉水位后行集(4h 增量仅数千);全量回填按词扫——MATCH 在
服务端把 86 万行预筛成命中行(每词仅数千),整批取回后按 500 行分片插
入/flush 控挂起对象数。不流式消费游标:MySQL 驱动(asyncmy)下结果未
取尽时同连接发下一条语句会 out-of-sync,全量路径无测试覆盖,取保守形。
"""
from __future__ import annotations
import datetime as dt
import logging
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics.sensitive.words import _hit_exclude
from app.db.models import Message, SensitiveHit, SensitiveScanState, SensitiveWord
logger = logging.getLogger(__name__)
ENTITY = "message_text"
_SNIPPET_RADIUS = 30 # 命中词前后各取 30 字
def _snippet(text: str, word: str) -> str:
i = text.find(word)
if i < 0:
return text[: _SNIPPET_RADIUS * 2]
lo, hi = max(0, i - _SNIPPET_RADIUS), min(len(text), i + len(word) + _SNIPPET_RADIUS)
return text[lo:hi]
def _text_match_cond(word: str, dialect: str) -> list:
"""全量回填路径:MySQL ≥2字 → ngram MATCH;否则 LIKE(单字 ngram 不可用)。
已知漏召:ngram 对嵌入标点的拉丁词可能分词不齐(如 "we-chat" 不含
"we-chat" 整词 token)——增量路径 Python 子串精确无此问题;起步词
全 CJK,当前无影响。
"""
if dialect == "mysql" and len(word) >= 2:
phrase = word.replace('"', "").replace("*", "")
return [
sa.text("MATCH(message.text) AGAINST (:w IN BOOLEAN MODE)")
.bindparams(w=f'"{phrase}"')
]
escaped = word.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return [Message.text.like(f"%{escaped}%", escape="\\")]
async def _get_state(session: AsyncSession) -> SensitiveScanState:
st = await session.get(SensitiveScanState, ENTITY)
if st is None:
st = SensitiveScanState(entity=ENTITY)
session.add(st)
await session.flush()
return st
async def _insert_hits(
session: AsyncSession, word: SensitiveWord, rows: list, existing: set[str]
) -> int:
n = 0
for msg_id, channel, owner, talker, room, ts, text in rows:
if msg_id in existing:
continue
if _hit_exclude(text, word.word, word.exclude_words or None):
continue
session.add(
SensitiveHit(
word_id=word.id, msg_id=msg_id, channel=channel,
owner_account_id=owner, talker_id=talker, room_id=room,
ts=ts, snippet=_snippet(text or "", word.word),
)
)
existing.add(msg_id)
n += 1
return n
async def _scan_batch(
session: AsyncSession, word: SensitiveWord, rows: list
) -> int:
"""对一个词扫一批 (msg_id..text) 行:查已有命中、插新命中。"""
sel_rows = [tuple(r[:7]) for r in rows if word.word in (r[6] or "")]
if not sel_rows:
return 0
existing = set(
(
await session.execute(
sa.select(SensitiveHit.msg_id).where(
SensitiveHit.word_id == word.id,
SensitiveHit.msg_id.in_(
[r[0] for r in sel_rows] or ["-"]
),
)
)
).scalars().all()
)
return await _insert_hits(session, word, sel_rows, existing)
async def run_incremental(session: AsyncSession) -> dict:
"""增量扫描:fetched_at > 水位 的行 × 全部 enabled 词。
增量行少(4h 数千)——统一 Python 侧子串判定(=LIKE 语义,精确;MATCH
只在全量回填的 MySQL 路径用,见 _text_match_cond)。
"""
words = (
await session.execute(
sa.select(SensitiveWord).where(SensitiveWord.enabled.is_(True))
)
).scalars().all()
st = await _get_state(session)
# 无水位自卫:无水位首跑=全表(86 万行 MEDIUMTEXT)进 API 进程内存。
# 增量由调度 tick 直调,大表首跑必须走 CLI sensitive-scan --full 建水位。
if st.last_fetched_at is None:
n = (await session.execute(
sa.select(sa.func.count()).select_from(Message)
)).scalar_one()
if n > 20_000:
raise RuntimeError(
"message 表超阈值且无扫描水位——首跑请先 CLI sensitive-scan"
" --full 建水位"
)
conds = [Message.text.is_not(None), Message.text != ""]
if st.last_fetched_at is not None:
# >=(非 >):fetched_at 秒精度,快照后同秒 commit 的行等值——严格 >
# 永久丢弃;>= 每 tick 重扫 ≤1s 边界行,existing/UNIQUE 去重。
conds.append(Message.fetched_at >= st.last_fetched_at)
rows = (
await session.execute(
sa.select(
Message.msg_id, Message.channel, Message.owner_account_id,
Message.talker_id, Message.room_id, Message.timestamp,
Message.text, Message.fetched_at,
).where(*conds)
)
).all()
scanned = len(rows)
hits = 0
for w in words:
hits += await _scan_batch(session, w, rows)
new_wm = max((r[7] for r in rows), default=None) if rows else None
if new_wm is not None:
st.last_fetched_at = new_wm
st.last_run_at = dt.datetime.now(dt.UTC)
st.scanned += scanned
st.hits += hits
await session.flush()
return {"scanned": scanned, "hits": hits, "new_watermark": new_wm}
async def run_full(session: AsyncSession) -> dict:
"""全量回填(CLI 一次性):每词单独扫全库;水位在扫描前取(见内注)。
每词结果整批取回(MATCH/LIKE 已在服务端预筛成命中行)再分片复用
_scan_batch——不在未取尽的结果上发新语句/flush(MySQL 驱动会
out-of-sync)。日志只打数量,不打词与正文。
"""
dialect = session.bind.dialect.name if session.bind else ""
words = (
await session.execute(
sa.select(SensitiveWord).where(SensitiveWord.enabled.is_(True))
)
).scalars().all()
st = await _get_state(session)
# 水位在扫描前取——回填期间新入库的行 fetched_at > 水位,下轮增量自然
# 扫到;若在词循环后才取,回填窗口内 commit 的行 fetched_at ≤ 水位,
# >= 增量也扫不到(首部署夜间回填与 02:00 全量批写重叠时漏扫)。
# >= 边界重扫由 UNIQUE(word_id,msg_id) 去重。
new_wm = (
await session.execute(sa.select(sa.func.max(Message.fetched_at)))
).scalar_one_or_none()
if new_wm is not None:
st.last_fetched_at = new_wm
total_hits = 0
scanned_rows = 0
for w in words:
conds = [Message.text.is_not(None), Message.text != ""]
conds += _text_match_cond(w.word, dialect)
stmt = sa.select(
Message.msg_id, Message.channel, Message.owner_account_id,
Message.talker_id, Message.room_id, Message.timestamp, Message.text,
).where(*conds)
rows = (await session.execute(stmt)).all()
scanned_rows += len(rows) # MATCH 预筛返回行数,按词累计(非去重消息数)
for i in range(0, len(rows), 500):
total_hits += await _scan_batch(session, w, rows[i:i + 500])
await session.flush() # 行已 .all() 物化,分片 flush 控挂起对象数
st.scanned += scanned_rows
st.hits += total_hits
st.last_run_at = dt.datetime.now(dt.UTC)
await session.flush()
logger.info("[sensitive-scan] full backfill done: words=%s hits=%s",
len(words), total_hits)
return {"words": len(words), "hits": total_hits}