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 路径不扣排除词(预览=上限,排除词在扫描时精确
生效);LIKE 路径(测试库)精确扣。前端文案「预计命中」体现上限语义。
单字词在 MySQL 拒预览(ValueError)——ngram 对单字无用,落 LIKE 前导
通配全扫(86 万行实测 ~16s,b41e8fa0 迁移记录),添加后从命中列表观察。
"""
word = (word or "").strip()
if not word:
return 0
conds = [Message.text.is_not(None), Message.text != ""]
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(
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)
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
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
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}
except ValueError as 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")
async def preview_word(session: SessionDep, body: WordPreviewIn) -> dict:
"""加词前命中量预览(count 为 MySQL 上限语义;warn=超阈值建议收窄)。"""
n = await words_svc.preview_count(session, body.word, body.exclude_words)
"""加词前命中量预览(count 为 MySQL 上限语义;warn=超阈值建议收窄)。
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}
+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
r = await client.delete(f"/api/analytics/sensitive/words/{wid}")
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