fix(analytics): 词库单字MySQL预览拒扫+IntegrityError 409+MATCH短语净化

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 19:48:30 +08:00
parent 0c14be0777
commit c7b3ce9d1c
3 changed files with 55 additions and 5 deletions
+10 -3
View File
@@ -69,16 +69,23 @@ async def preview_count(
口径注:MySQL MATCH 路径不扣排除词(预览=上限,排除词在扫描时精确 口径注:MySQL MATCH 路径不扣排除词(预览=上限,排除词在扫描时精确
生效);LIKE 路径(测试库)精确扣。前端文案「预计命中」体现上限语义。 生效);LIKE 路径(测试库)精确扣。前端文案「预计命中」体现上限语义。
单字词在 MySQL 拒预览(ValueError)——ngram 对单字无用,落 LIKE 前导
通配全扫(86 万行实测 ~16s,b41e8fa0 迁移记录),添加后从命中列表观察。
""" """
word = (word or "").strip() word = (word or "").strip()
if not word: if not word:
return 0 return 0
conds = [Message.text.is_not(None), Message.text != ""]
dialect = session.bind.dialect.name if session.bind else "" dialect = session.bind.dialect.name if session.bind else ""
if dialect == "mysql" and len(word) >= 2: if len(word) < 2 and dialect == "mysql":
raise ValueError("单字词不做预览(MySQL 全扫);添加后从命中列表观察")
conds = [Message.text.is_not(None), Message.text != ""]
if dialect == "mysql":
# 短语净化:剥 " 与 * —— BOOLEAN MODE 里裸引号/星号是语法符,
# 混入会 1064 语法错;剥后包引号做整词短语匹配。
phrase = word.replace('"', "").replace("*", "")
conds.append( conds.append(
sa.text("MATCH(message.text) AGAINST (:w IN BOOLEAN MODE)") sa.text("MATCH(message.text) AGAINST (:w IN BOOLEAN MODE)")
.bindparams(w=f'"{word}"') .bindparams(w=f'"{phrase}"')
) )
stmt = sa.select(sa.func.count()).select_from(Message).where(*conds) stmt = sa.select(sa.func.count()).select_from(Message).where(*conds)
n = (await session.execute(stmt)).scalar_one() n = (await session.execute(stmt)).scalar_one()
+12 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -107,12 +108,21 @@ async def create_word(session: SessionDep, body: WordIn) -> dict:
return {"id": w.id, "word": w.word, "enabled": w.enabled} return {"id": w.id, "word": w.word, "enabled": w.enabled}
except ValueError as exc: except ValueError as exc:
raise HTTPException(409, str(exc)) from exc raise HTTPException(409, str(exc)) from exc
except sa.exc.IntegrityError as exc:
# 并发撞 word UNIQUE(两请求同词,预检都未见)→ 409,不裸 500。
raise HTTPException(409, "词已存在") from exc
@router.post("/sensitive/words/preview") @router.post("/sensitive/words/preview")
async def preview_word(session: SessionDep, body: WordPreviewIn) -> dict: async def preview_word(session: SessionDep, body: WordPreviewIn) -> dict:
"""加词前命中量预览(count 为 MySQL 上限语义;warn=超阈值建议收窄)。""" """加词前命中量预览(count 为 MySQL 上限语义;warn=超阈值建议收窄)。
n = await words_svc.preview_count(session, body.word, body.exclude_words)
MySQL 单字词拒扫(400)——LIKE 前导通配全扫 ~16s,加词后从命中列表观察。
"""
try:
n = await words_svc.preview_count(session, body.word, body.exclude_words)
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
return {"count": n, "warn": n >= words_svc.PREVIEW_WARN_THRESHOLD} return {"count": n, "warn": n >= words_svc.PREVIEW_WARN_THRESHOLD}
+33
View File
@@ -59,3 +59,36 @@ async def test_api_words_crud(make_client):
assert r.status_code == 200 and r.json()["enabled"] is False assert r.status_code == 200 and r.json()["enabled"] is False
r = await client.delete(f"/api/analytics/sensitive/words/{wid}") r = await client.delete(f"/api/analytics/sensitive/words/{wid}")
assert r.status_code == 200 assert r.status_code == 200
# 注:单字词 MySQL 拒预览分支(words.py ValueError)在 SQLite 测试库不可达
# ——dialect 判定不满足,不做单测;由注释 + 路由 400 映射保障。
async def test_api_patch_unknown_404(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.patch("/api/analytics/sensitive/words/999",
json={"enabled": False})
assert r.status_code == 404
async def test_api_delete_unknown_404(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.delete("/api/analytics/sensitive/words/999")
assert r.status_code == 404
async def test_api_create_empty_word_409(make_client):
async def seed(session):
...
client = await make_client(seed)
r = await client.post("/api/analytics/sensitive/words",
json={"word": " "})
assert r.status_code == 409