From c7b3ce9d1ce115f7e0116b3922a72dfd8dae1ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 4 Sep 2026 19:48:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(analytics):=20=E8=AF=8D=E5=BA=93=E5=8D=95?= =?UTF-8?q?=E5=AD=97MySQL=E9=A2=84=E8=A7=88=E6=8B=92=E6=89=AB+IntegrityErr?= =?UTF-8?q?or=20409+MATCH=E7=9F=AD=E8=AF=AD=E5=87=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- yunke-server/app/analytics/sensitive/words.py | 13 ++++++-- yunke-server/app/api/routers/analytics.py | 14 ++++++-- yunke-server/tests/test_sensitive.py | 33 +++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/yunke-server/app/analytics/sensitive/words.py b/yunke-server/app/analytics/sensitive/words.py index 935f2c0..345ee4d 100644 --- a/yunke-server/app/analytics/sensitive/words.py +++ b/yunke-server/app/analytics/sensitive/words.py @@ -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() diff --git a/yunke-server/app/api/routers/analytics.py b/yunke-server/app/api/routers/analytics.py index 6a12617..e2955b7 100644 --- a/yunke-server/app/api/routers/analytics.py +++ b/yunke-server/app/api/routers/analytics.py @@ -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} diff --git a/yunke-server/tests/test_sensitive.py b/yunke-server/tests/test_sensitive.py index 79de177..1779dc5 100644 --- a/yunke-server/tests/test_sensitive.py +++ b/yunke-server/tests/test_sensitive.py @@ -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