fix(analytics): 词库单字MySQL预览拒扫+IntegrityError 409+MATCH短语净化
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user