From 6e67e9e6314926160436ea79ee04151b22db4e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 4 Sep 2026 20:37:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20C1=20=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E9=BB=98=E8=AE=A4=E8=BF=87=E6=BB=A4+?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=A4=84=E7=BD=AE?= 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/hits.py | 65 ++++++++++++++++++++ yunke-server/app/api/routers/analytics.py | 33 ++++++++++ yunke-server/tests/test_sensitive.py | 29 +++++++++ 3 files changed, 127 insertions(+) create mode 100644 yunke-server/app/analytics/sensitive/hits.py diff --git a/yunke-server/app/analytics/sensitive/hits.py b/yunke-server/app/analytics/sensitive/hits.py new file mode 100644 index 0000000..bf87622 --- /dev/null +++ b/yunke-server/app/analytics/sensitive/hits.py @@ -0,0 +1,65 @@ +"""命中查询与处置——默认只显示 pending+confirmed(误报治理闭环)。""" +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import SensitiveHit, SensitiveWord + +DISPOSITIONS = ("pending", "confirmed", "false_positive", "ignored") +DEFAULT_SHOW = ("pending", "confirmed") + + +async def list_hits( + session: AsyncSession, *, disposition: str | None = None, + word_id: int | None = None, page: int, page_size: int, +) -> dict: + conds = [] + if disposition: + conds.append(SensitiveHit.disposition == disposition) + else: + conds.append(SensitiveHit.disposition.in_(DEFAULT_SHOW)) + if word_id: + conds.append(SensitiveHit.word_id == word_id) + + total = ( + await session.execute( + sa.select(sa.func.count()).select_from(SensitiveHit).where(*conds) + ) + ).scalar_one() + rows = ( + await session.execute( + sa.select(SensitiveHit, SensitiveWord.word, SensitiveWord.category) + .join(SensitiveWord, SensitiveHit.word_id == SensitiveWord.id) + .where(*conds) + # ts 可 NULL/并列,id 决胜列保证翻页稳定 + .order_by(SensitiveHit.ts.desc(), SensitiveHit.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).all() + items = [ + { + "id": h.id, "word_id": h.word_id, "word": word, "category": category, + "msg_id": h.msg_id, "channel": h.channel, + "owner_account_id": h.owner_account_id, "talker_id": h.talker_id, + "room_id": h.room_id, "ts": h.ts, "snippet": h.snippet, + "disposition": h.disposition, + } + for h, word, category in rows + ] + return {"items": items, "total": int(total), "page": page, "page_size": page_size} + + +async def dispose(session: AsyncSession, ids: list[int], disposition: str) -> int: + """批量改 disposition(pending 合法——用于「重开」误报);不写日志(词/snippet 红线)。""" + if disposition not in DISPOSITIONS: + raise ValueError(f"bad disposition: {disposition!r}") + if not ids: + return 0 + res = await session.execute( + sa.update(SensitiveHit) + .where(SensitiveHit.id.in_(ids)) + .values(disposition=disposition) + ) + return int(res.rowcount or 0) diff --git a/yunke-server/app/api/routers/analytics.py b/yunke-server/app/api/routers/analytics.py index e2955b7..6e919b4 100644 --- a/yunke-server/app/api/routers/analytics.py +++ b/yunke-server/app/api/routers/analytics.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.analytics import health as health_svc from app.analytics import metrics as metrics_svc +from app.analytics.sensitive import hits as hits_svc from app.analytics.sensitive import words as words_svc from app.api.deps import get_session @@ -146,3 +147,35 @@ async def delete_word(session: SessionDep, word_id: int) -> dict: return {"ok": True} except LookupError as exc: raise HTTPException(404, str(exc)) from exc + + +class DisposeIn(BaseModel): + ids: list[int] + disposition: str + + +@router.get("/sensitive/hits") +async def list_hits( + session: SessionDep, + disposition: str | None = None, + word_id: int | None = None, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +) -> dict: + """C1 命中列表——不传 disposition 默认只回 pending+confirmed(治理闭环: + 处置完即出队列);word_id 供词详情下钻。""" + return await hits_svc.list_hits( + session, disposition=disposition, word_id=word_id, + page=page, page_size=page_size, + ) + + +@router.patch("/sensitive/hits") +async def dispose_hits(session: SessionDep, body: DisposeIn) -> dict: + """批量处置(pending 合法——重开误报);UPDATE 不写日志(词/snippet 红线)。""" + try: + n = await hits_svc.dispose(session, body.ids, body.disposition) + await session.commit() + return {"updated": n} + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc diff --git a/yunke-server/tests/test_sensitive.py b/yunke-server/tests/test_sensitive.py index b8ccfd6..86c0d21 100644 --- a/yunke-server/tests/test_sensitive.py +++ b/yunke-server/tests/test_sensitive.py @@ -188,3 +188,32 @@ async def test_disposition_preserved(make_client): assert hit2.disposition == "false_positive" await make_client(seed) + + +# ------------------------------------------------------------- C1 命中列表/处置 + +async def test_hits_list_default_filter_and_dispose(make_client): + async def seed(session): + w = await words_svc.create_word(session, word="私单") + session.add(SensitiveHit(word_id=w.id, msg_id="m1", channel="wechat", + ts=1756900000000, snippet="…私单…", + disposition="pending")) + session.add(SensitiveHit(word_id=w.id, msg_id="m2", channel="wechat", + ts=1756900001000, snippet="…私单…", + disposition="ignored")) + await session.commit() + + client = await make_client(seed) + r = await client.get("/api/analytics/sensitive/hits") + body = r.json() + assert body["total"] == 1 # 默认只 pending+confirmed + ids = [h["id"] for h in body["items"]] + r2 = await client.get("/api/analytics/sensitive/hits", + params={"disposition": "ignored"}) + assert r2.json()["total"] == 1 + r3 = await client.patch("/api/analytics/sensitive/hits", + json={"ids": ids, "disposition": "confirmed"}) + assert r3.json()["updated"] == 1 + r4 = await client.get("/api/analytics/sensitive/hits", + params={"disposition": "confirmed"}) + assert r4.json()["total"] == 1