diff --git a/backend/app/api/ai_suggestions.py b/backend/app/api/ai_suggestions.py index a3c81efe..273696f8 100644 --- a/backend/app/api/ai_suggestions.py +++ b/backend/app/api/ai_suggestions.py @@ -118,7 +118,10 @@ def _apply_kpi_target(db: Session, sug: AISuggestion, params: dict, current_user kpi_id = params.get("kpi_id") or sug.target_id if not kpi_id: raise HTTPException(400, "缺少 kpi_id") - kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + kpi = db.query(KPIDefinition).filter( + KPIDefinition.id == kpi_id, + KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套 + ).first() if not kpi: raise HTTPException(404, f"KPI {kpi_id} 不存在") before = kpi.target_value @@ -157,7 +160,10 @@ def _apply_budget_adjust(db: Session, sug: AISuggestion, params: dict, current_u kpi_id = params.get("kpi_id") or sug.target_id if not kpi_id: raise HTTPException(400, "缺少 kpi_id") - kpi = db.query(KPIDefinition).filter(KPIDefinition.id == kpi_id).first() + kpi = db.query(KPIDefinition).filter( + KPIDefinition.id == kpi_id, + KPIDefinition.entity_id == sug.entity_id, # 2026-09-18 派生单: 目标KPI须归属建议账套 + ).first() if not kpi: raise HTTPException(404, f"KPI {kpi_id} 不存在") @@ -397,10 +403,14 @@ def apply_suggestion( def dismiss_suggestion( suggestion_id: int, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """忽略建议""" - sug = db.query(AISuggestion).filter(AISuggestion.id == suggestion_id).first() + sug = db.query(AISuggestion).filter( + AISuggestion.id == suggestion_id, + AISuggestion.entity_id == entity_id, # 2026-09-18 派生单: 建议须归属会话账套 + ).first() if not sug: raise HTTPException(404, "建议不存在") sug.status = "dismissed" diff --git a/backend/tests/test_dismiss_suggestion_entity_scope_20260918.py b/backend/tests/test_dismiss_suggestion_entity_scope_20260918.py new file mode 100644 index 00000000..2268c4c6 --- /dev/null +++ b/backend/tests/test_dismiss_suggestion_entity_scope_20260918.py @@ -0,0 +1,275 @@ +"""CMA 跨实体隔离 — AI 建议「忽略」与「应用目标 KPI」账套收敛(2026-09-18 派生单) + +任务:fullstack-20260918-2030-dismiss-suggestion-entity-scope +父单:fullstack-20260918-2025-read-side-entity-scope-gaps + (其 §五 范围外发现 第 1/2 条:写侧同族漏网点) + +缺陷(同文件 ai_suggestions.py 的写侧漏网点): + 1. dismiss_suggestion — POST /{suggestion_id}/dismiss 按 id 直取、无账套条件, + 且会改状态(status="dismissed") + ⇒ 本账套会话带对端账套建议 id 可跨账套忽略对端建议(数据被改写) + 2. _apply_kpi_target — 目标 KPIDefinition 只按 id 查,不校验归属会话账套 + 3. _apply_budget_adjust — 同上(BudgetPlan 侧已按 sug.entity_id,KPI 侧未校验) + ⇒ 建议自身归属校验通过后,其 suggestion_data 指向对端账套 KPI 时, + 应用动作会改到对端账套的 target_value / 预算行(纵深防御缺口) + +对端记录口径 = 404「建议不存在」/「KPI {id} 不存在」(对齐同文件 +apply_suggestion 与 1905b/2015 的写法)。 + +本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2030-red.txt): +- T1 忽略建议:对端账套 → 404 且建议状态保持 unapplied(双向);本账套 → 200 + 且状态 dismissed(双向正对照) +- T2 忽略幂等:同一建议重复忽略仍 200、状态仍 dismissed(既有语义不得改动) +- T3 应用 kpi_target:目标 KPI 非本账套 → 404,对端 KPI target_value 逐值未变, + 建议状态保持 unapplied;本账套应用正常(正对照,目标值已写入) +- T4 应用 budget_adjust:目标 KPI 非本账套 → 404,不落任何预算行(对端/本账套都不落) +- T5 鉴权底线:dismiss 无 token → 401(改前改后都应通过) +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import AISuggestion, BudgetPlan, Entity, KPIDefinition +from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user + +SUGG = "/api/cma/ai/suggestions" + +# kpi_code 全局唯一 → 每账套独立 code +E1_KPI = "ZZ_DSE1_KPI" +E2_KPI = "ZZ_DSE2_KPI" +E1_SUG_TITLE = "ZZ_DS e1 私有建议" +E2_SUG_TITLE = "ZZ_DS e2 私有建议" +E1_CROSS_TITLE = "ZZ_DS e2 指向对端 KPI 的建议" + + +# ────────────────────────────────────────────── +# 夹具(沿用同族风格:双向登录 + 同账套正对照) +# ────────────────────────────────────────────── + +def _ensure_entity(db: Session, eid: int, name: str) -> Entity: + ent = db.query(Entity).filter(Entity.id == eid).first() + if not ent: + ent = Entity(id=eid, name=name, short_name=name, status="active") + db.add(ent) + db.commit() + db.refresh(ent) + return ent + + +def _mk_kpi(db: Session, code: str, eid: int, target: float = 1000.0) -> KPIDefinition: + kpi = KPIDefinition( + entity_id=eid, + kpi_code=code, + kpi_name=f"{code}-名称", + dimension="finance", + category="revenue_growth", + formula="-", + data_source_type="excel", + data_source="Excel导入", + data_owner="财务部", + frequency="monthly", + unit="元", + target_value=target, + kpi_level="operational", + status="active", + ) + db.add(kpi) + db.commit() + db.refresh(kpi) + return kpi + + +def _seed_two_entities(db: Session): + _ensure_entity(db, 1, "酣客") + _ensure_entity(db, 2, "博海") + create_test_user(db) + + +def _login(client: TestClient, entity_id: int) -> str: + resp = client.post("/api/cma/auth/login", json={ + "username": "testadmin", + "password": TEST_USER_PASSWORD, + "entity_id": entity_id, + }) + assert resp.status_code == 200, f"登录 entity{entity_id} 失败: {resp.text[:300]}" + return resp.json()["token"] + + +def _mk_suggestion(client: TestClient, token: str, title: str, + target_id: int, kpi_id: int, target_value: float = 2000.0) -> int: + body = { + "suggestion_type": "kpi_target", + "target_type": "kpi", + "target_id": target_id, + "title": title, + "content": "目标偏低,建议上调", + "suggestion_data": {"kpi_id": kpi_id, "target_value": target_value}, + } + r = client.post(SUGG, json=body, headers=auth_header(token)) + assert r.status_code == 200, f"创建建议失败: {r.status_code} {r.text[:300]}" + return r.json()["data"]["id"] + + +def _sug_status(db: Session, sid: int) -> str: + db.expire_all() + row = db.query(AISuggestion).filter(AISuggestion.id == sid).first() + assert row is not None, f"建议 #{sid} 不存在" + return row.status + + +def _kpi_target(db: Session, kid: int) -> float: + db.expire_all() + row = db.query(KPIDefinition).filter(KPIDefinition.id == kid).first() + assert row is not None, f"KPI #{kid} 不存在" + return row.target_value + + +# ────────────────────────────────────────────── +# T1. 忽略建议按会话账套限定(双向 + 正对照) +# ────────────────────────────────────────────── + +def test_dismiss_suggestion_scoped_to_session_entity(client: TestClient, db: Session): + """对端账套建议 → 404 且状态保持 unapplied;本账套 → 200 且 dismissed(双向)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + k2 = _mk_kpi(db, E2_KPI, 2) + tok1 = _login(client, 1) + tok2 = _login(client, 2) + s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id) + s2 = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id) + + # 方向 A:e2 会话忽略 e1 建议 → 404 且 e1 建议状态未改 + r_a = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok2)) + assert r_a.status_code == 404, ( + f"e2 会话忽略 e1 建议应 404,实际 {r_a.status_code} {r_a.text[:200]}" + " —— dismiss_suggestion 按 id 直取、未按会话账套限定" + ) + assert _sug_status(db, s1) == "unapplied", ( + "跨账套忽略竟改动了对端账套建议状态(应为 unapplied)" + ) + + # 方向 B:e1 会话忽略 e2 建议 → 404 且 e2 建议状态未改 + r_b = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok1)) + assert r_b.status_code == 404, ( + f"e1 会话忽略 e2 建议应 404,实际 {r_b.status_code} {r_b.text[:200]}" + ) + assert _sug_status(db, s2) == "unapplied", ( + "跨账套忽略竟改动了对端账套建议状态(应为 unapplied)" + ) + + # 正对照:本账套忽略自己的建议 → 200 且状态 dismissed + r_ok1 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1)) + assert r_ok1.status_code == 200, f"e1 忽略自己的建议应 200,实际 {r_ok1.status_code}" + assert _sug_status(db, s1) == "dismissed", "本账套忽略未落状态 dismissed" + + r_ok2 = client.post(f"{SUGG}/{s2}/dismiss", headers=auth_header(tok2)) + assert r_ok2.status_code == 200, f"e2 忽略自己的建议应 200,实际 {r_ok2.status_code}" + assert _sug_status(db, s2) == "dismissed", "本账套忽略未落状态 dismissed" + + +# ────────────────────────────────────────────── +# T2. 忽略幂等:既有语义不得改动 +# ────────────────────────────────────────────── + +def test_dismiss_suggestion_repeat_keeps_existing_semantics(client: TestClient, db: Session): + """已 dismissed 的建议重复忽略仍 200、状态仍 dismissed(加账套条件不得改动既有语义)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + tok1 = _login(client, 1) + s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id) + + assert client.post(f"{SUGG}/{s1}/dismiss", + headers=auth_header(tok1)).status_code == 200 + r2 = client.post(f"{SUGG}/{s1}/dismiss", headers=auth_header(tok1)) + assert r2.status_code == 200, ( + f"重复忽略应维持既有 200 语义,实际 {r2.status_code} {r2.text[:200]}" + ) + assert _sug_status(db, s1) == "dismissed", "重复忽略后状态异常" + + +# ────────────────────────────────────────────── +# T3. 应用 kpi_target:目标 KPI 归属校验 +# ────────────────────────────────────────────── + +def test_apply_kpi_target_foreign_kpi_404(client: TestClient, db: Session): + """建议自身属本账套、但其目标 KPI 属对端 → 404;对端 KPI target_value 逐值未变""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0) + _mk_kpi(db, E2_KPI, 2, target=3000.0) + tok1 = _login(client, 1) + tok2 = _login(client, 2) + + # e2 会话创建建议(归属 e2),但 suggestion_data/target_id 指向 e1 的 KPI + # (模拟手填数据 / 迁移遗留 / Bot 通道写入的不自洽数据) + s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=88888.0) + + before_k1 = _kpi_target(db, k1.id) + assert before_k1 == 1000.0, f"前置:e1 KPI 目标值异常 {before_k1}" + + r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2), + json={"action": "kpi_target", "target_value": 99999.0}) + assert r.status_code == 404, ( + f"目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}" + " —— _apply_kpi_target 只按 id 查 KPIDefinition,未校验归属会话账套" + ) + assert _kpi_target(db, k1.id) == before_k1, ( + f"对端账套 KPI target_value 被改写:{before_k1} → {_kpi_target(db, k1.id)}" + ) + assert _sug_status(db, s_cross) == "unapplied", ( + "越权应用竟改动了建议状态(应保持 unapplied)" + ) + + # 正对照:本账套建议 + 本账套 KPI → 200 且目标值已写入 + k2 = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == E2_KPI).first() + s_ok = _mk_suggestion(client, tok2, E2_SUG_TITLE, k2.id, k2.id, target_value=4500.0) + r_ok = client.post(f"{SUGG}/{s_ok}/apply", headers=auth_header(tok2), + json={"action": "kpi_target", "target_value": 4500.0}) + assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:200]}" + assert _kpi_target(db, k2.id) == 4500.0, "本账套应用未写入目标值" + + +# ────────────────────────────────────────────── +# T4. 应用 budget_adjust:目标 KPI 归属校验 +# ────────────────────────────────────────────── + +def test_apply_budget_adjust_foreign_kpi_404(client: TestClient, db: Session): + """budget_adjust 目标 KPI 属对端 → 404 且不落任何预算行(含对端账套)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1, target=1000.0) + _mk_kpi(db, E2_KPI, 2, target=3000.0) + tok2 = _login(client, 2) + s_cross = _mk_suggestion(client, tok2, E1_CROSS_TITLE, k1.id, k1.id, target_value=7777.0) + + db.expire_all() + plans_before = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count() + + r = client.post(f"{SUGG}/{s_cross}/apply", headers=auth_header(tok2), + json={"action": "budget_adjust", "period": "2026-09", "budget_value": 7777.0}) + assert r.status_code == 404, ( + f"budget_adjust 目标 KPI 属对端账套应 404,实际 {r.status_code} {r.text[:200]}" + " —— _apply_budget_adjust 的 KPI 查询未校验归属会话账套" + ) + db.expire_all() + plans_after = db.query(BudgetPlan).filter(BudgetPlan.kpi_id == k1.id).count() + assert plans_after == plans_before, ( + f"越权 budget_adjust 落了预算行:{plans_before} → {plans_after}" + ) + assert _sug_status(db, s_cross) == "unapplied", "越权应用竟改动了建议状态" + + +# ────────────────────────────────────────────── +# T5. 鉴权底线(dismiss / apply 无 token → 401) +# ────────────────────────────────────────────── + +def test_dismiss_and_apply_without_token_401(client: TestClient, db: Session): + """无 token 一律 401(改前改后都应通过)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_KPI, 1) + tok1 = _login(client, 1) + s1 = _mk_suggestion(client, tok1, E1_SUG_TITLE, k1.id, k1.id) + + r1 = client.post(f"{SUGG}/{s1}/dismiss") + assert r1.status_code == 401, f"dismiss 无 token 应 401,实际 {r1.status_code}" + + r2 = client.post(f"{SUGG}/{s1}/apply", json={"action": "kpi_target", "target_value": 1}) + assert r2.status_code == 401, f"apply 无 token 应 401,实际 {r2.status_code}"