From d03e322a81c40a273644d4d91485216038d954e4 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Fri, 18 Sep 2026 20:12:46 +0800 Subject: [PATCH] fix(cma): action plan entity_id at the three residual construction sites alert-to-plan, deviation-push review topic and AI-suggestion-to-plan built ActionPlan(...) without entity_id, so non-e1 sessions wrote rows with the model default 1 and entity_id stayed untrustworthy on those paths. Write the session entity at all three sites, scope the deviation-push topic dedupe by entity (a foreign same-title topic no longer suppresses the session entity's own topic), and require the applied suggestion to belong to the session entity. Adds 7 regression tests (both directions + cross-entity negative controls + auth floor). --- backend/app/api/ai_suggestions.py | 7 +- backend/app/api/alerts.py | 1 + backend/app/api/deviation_push.py | 10 +- ...an_entity_write_residual_sites_20260918.py | 375 ++++++++++++++++++ 4 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_actionplan_entity_write_residual_sites_20260918.py diff --git a/backend/app/api/ai_suggestions.py b/backend/app/api/ai_suggestions.py index 9bcb6d50..4789f415 100644 --- a/backend/app/api/ai_suggestions.py +++ b/backend/app/api/ai_suggestions.py @@ -238,6 +238,7 @@ def _apply_action_plan(db: Session, sug: AISuggestion, params: dict, current_use due_date = None plan = ActionPlan( + entity_id=sug.entity_id, # 2026-09-18 派生单: 写建议归属账套(=会话账套,端点已校验) kpi_id=kpi_id, title=title, description=params.get("description") or sug.content or f"由AI建议 #{sug.id} 生成: {sug.title}", @@ -340,6 +341,7 @@ def apply_suggestion( suggestion_id: int, data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """应用建议:改KPI目标 / 调预算 / 建行动方案(写库+操作日志留痕) @@ -349,7 +351,10 @@ def apply_suggestion( {"action": "budget_adjust", "period": "2026-09", "budget_value": 100000} {"action": "action_plan", "title": "...", "assignee": "...", "priority": "high", "due_date": "2026-09-30"} """ - 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, "建议不存在") if sug.status == "applied": diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index 1c88e63e..f852c7d0 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -115,6 +115,7 @@ def create_action_plan_from_alert(alert_id: int, data: dict, db: Session = Depen if alert.action_plan_linked_id: raise HTTPException(400, "已关联改善计划") plan = ActionPlan( + entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1) alert_id=alert.id, kpi_id=alert.kpi_id, title=f"改善: {alert.alert_message}", diff --git a/backend/app/api/deviation_push.py b/backend/app/api/deviation_push.py index 3fcc11f1..9b26bf72 100644 --- a/backend/app/api/deviation_push.py +++ b/backend/app/api/deviation_push.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db from app.auth_middleware import require_auth, require_role +from app.deps import get_entity_id from app.models import StrategicMap, KPIDefinition, KPIValue, KPIAlert, OperationLog, ActionPlan, BudgetPlan import json import logging @@ -22,6 +23,7 @@ router = APIRouter(prefix="/api/cma/deviation-push", tags=["差异反打"], def push_deviation_to_map( data: dict, db: Session = Depends(get_db), + entity_id: int = Depends(get_entity_id), current_user=Depends(require_auth), ): """从差异分析回写实际值到战略地图节点 @@ -47,9 +49,9 @@ def push_deviation_to_map( if new_value is None: raise HTTPException(400, "缺少 newValue") - # 1. 查找战略地图 + # 1. 查找战略地图(2026-09-18 派生单: 地图须归属会话账套,NULL 归属按 e1 兼容) m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first() - if not m: + if not m or (m.entity_id or 1) != entity_id: raise HTTPException(404, "战略地图不存在") # 2. 解析 node_id 格式: "finance-0" @@ -89,7 +91,7 @@ def push_deviation_to_map( kpi_code = kpi_codes[0] kpi = db.query(KPIDefinition).filter(KPIDefinition.kpi_code == kpi_code).first() - if not kpi: + if not kpi or (kpi.entity_id or 1) != entity_id: raise HTTPException(404, f"KPI {kpi_code} 不存在") # 3. 更新实际值到 KPIValue 表 @@ -147,11 +149,13 @@ def push_deviation_to_map( if create_review_topic: topic_title = f"【差异反打】{kpi.kpi_name}偏差回写 — {target_obj.get('name')}" existing_topic = db.query(ActionPlan).filter( + ActionPlan.entity_id == entity_id, # 2026-09-18 派生单: 去重按账套隔离 ActionPlan.title == topic_title, ActionPlan.status.in_(["pending", "in_progress"]), ).first() if not existing_topic: topic = ActionPlan( + entity_id=entity_id, # 2026-09-18 派生单: 写会话账套(原落模型默认值 1) kpi_id=kpi.id, title=topic_title, description=f"由差异分析自动生成:将实际值{new_value}回写至战略地图[{target_dim.get('name')}→{target_obj.get('name')}]节点。差异ID: {deviation_id or 'N/A'}", diff --git a/backend/tests/test_actionplan_entity_write_residual_sites_20260918.py b/backend/tests/test_actionplan_entity_write_residual_sites_20260918.py new file mode 100644 index 00000000..b8d315a2 --- /dev/null +++ b/backend/tests/test_actionplan_entity_write_residual_sites_20260918.py @@ -0,0 +1,375 @@ +"""CMA 跨实体隔离 — ActionPlan 归属写侧「残余三处构造点」(2026-09-18 派生单) + +任务:fullstack-20260918-2015-actionplan-entity-write-residual-sites +父单:fullstack-20260918-1905b-actionplan-entity-write-and-okr-read + (其 §五 范围外只读侦查登记的三处同族缺口) + +缺陷(`ActionPlan.entity_id` 模型默认 1,以下三处构造均不写会话账套): + 1. app/api/alerts.py::create_action_plan_from_alert — 预警关联生成改善计划 + 2. app/api/deviation_push.py::push_deviation_to_map — 差异反打自动生成回顾会议题 + 3. app/api/ai_suggestions.py::_apply_action_plan — AI 建议转行动计划 + +后果:非 e1 账套经这三个入口创建的计划落 `entity_id=1`(不可信字段)。一旦读路径按 +`ActionPlan.entity_id` 过滤(1905b 已在 OKR 分解视图加该条件),即出现跨账套泄漏或 +「自己建的计划看不见」的假阴性。 + +本用例(改前失败条目见 _reports/evidence-fullstack-20260918-2015-red.txt): +- T1 预警关联生成计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向 + 正对照); + 并附跨账套负对照(e2 token 打 e1 预警 → 404 且不落行) +- T2 差异反打生成议题:e2 会话 → 库内 entity_id=2(kpi_id 亦须落在本账套 KPI 上); + e1 会话 → 1(双向) +- T3 差异反打去重按账套隔离:对端账套存在同名 pending 议题,不得抑制本账套创建 +- T4 AI 建议转计划:e2 会话 → 库内 entity_id=2;e1 会话 → 1(双向) +- T5 AI 建议越权负对照:对端账套的建议不得被本账套会话应用(404 + 零落行) +- T6 鉴权底线:三个入口无 token → 401(改前改后都应通过) +""" +import datetime as _dt +from datetime import time as _time + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import ActionPlan, AISuggestion, Entity, KPIAlert, KPIDefinition, StrategicMap +from tests.conftest import TEST_USER_PASSWORD, auth_header, create_test_user + +ALERTS = "/api/cma/alerts" +DPUSH = "/api/cma/deviation-push/push-to-map" +SUGG = "/api/cma/ai/suggestions" + +# kpi_code 全局唯一(models.KPIDefinition.kpi_code unique=True)→ 每个账套用独立 code +E1_ALERT_KPI = "ZZ_APRES_E1_ALERT_KPI" +E2_ALERT_KPI = "ZZ_APRES_E2_ALERT_KPI" +E1_DPUSH_KPI = "ZZ_APRES_E1_DPUSH_KPI" +E2_DPUSH_KPI = "ZZ_APRES_E2_DPUSH_KPI" +E1_SUG_KPI = "ZZ_APRES_E1_SUG_KPI" +E2_SUG_KPI = "ZZ_APRES_E2_SUG_KPI" + + +# ────────────────────────────────────────────── +# 夹具(沿用 1905b 同族风格:双向登录 + 同账套正对照) +# ────────────────────────────────────────────── + +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 _mk_alert(db: Session, eid: int, kpi_id: int, message: str) -> KPIAlert: + alert = KPIAlert( + entity_id=eid, + kpi_id=kpi_id, + alert_level="red", + alert_message=message, + status="pending", + ) + db.add(alert) + db.commit() + db.refresh(alert) + return alert + + +def _mk_map(db: Session, eid: int, title: str, kpi_code: str, obj_name: str) -> StrategicMap: + m = StrategicMap( + entity_id=eid, + title=title, + version="v1.0", + status="draft", + dimensions=[{ + "key": "finance", "name": "财务维度", "icon": "F", "color": "#409eff", + "objectives": [{"name": obj_name, "kpis": [kpi_code]}], + }], + canvas_data={"connections": []}, + ) + db.add(m) + db.commit() + db.refresh(m) + return m + + +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 _plans_by_title(db: Session, title: str): + db.expire_all() + return db.query(ActionPlan).filter(ActionPlan.title == title).all() + + +def _alert_plan_title(message: str) -> str: + return f"改善: {message}" + + +def _dpush_topic_title(kpi: KPIDefinition, obj_name: str) -> str: + return f"【差异反打】{kpi.kpi_name}偏差回写 — {obj_name}" + + +def _period() -> str: + return _dt.date.today().strftime("%Y-%m") + + +# ────────────────────────────────────────────── +# T1. 预警关联生成计划 → 会话账套(双向 + 跨账套负对照) +# ────────────────────────────────────────────── + +def test_alert_create_action_plan_writes_session_entity(client: TestClient, db: Session): + """e1 会话 → 库内 entity_id=1;e2 会话 → 库内 entity_id=2(不得落模型默认值 1)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_ALERT_KPI, 1) + k2 = _mk_kpi(db, E2_ALERT_KPI, 2) + a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 营收严重下滑") + a2 = _mk_alert(db, 2, k2.id, "ZZ_APRES e2 营收严重下滑") + + t1 = _alert_plan_title(a1.alert_message) + r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan", + headers=auth_header(_login(client, 1)), json={"assignee": "甲"}) + assert r1.status_code == 200, f"e1 创建应 200,实际 {r1.status_code} {r1.text[:200]}" + rows1 = _plans_by_title(db, t1) + assert len(rows1) == 1, f"e1 预警未生成计划: {rows1}" + assert rows1[0].entity_id == 1, ( + f"e1 会话经预警生成计划的 entity_id 应为 1,实际 {rows1[0].entity_id}" + ) + + t2 = _alert_plan_title(a2.alert_message) + r2 = client.post(f"{ALERTS}/{a2.id}/create-action-plan", + headers=auth_header(_login(client, 2)), json={"assignee": "乙"}) + assert r2.status_code == 200, f"e2 创建应 200,实际 {r2.status_code} {r2.text[:200]}" + rows2 = _plans_by_title(db, t2) + assert len(rows2) == 1, f"e2 预警未生成计划: {rows2}" + assert rows2[0].entity_id == 2, ( + f"e2 会话经预警生成计划的 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}" + " —— alerts.py 构造 ActionPlan 未写 entity_id,落模型默认值 1" + ) + assert rows2[0].kpi_id == k2.id, ( + f"e2 计划应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}" + ) + + +def test_alert_create_action_plan_cross_entity_404(client: TestClient, db: Session): + """e2 token 打 e1 预警 → 404(读侧既有护栏,不得因写侧修复而退化)+ 零落行""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_ALERT_KPI, 1) + a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 跨账套负对照") + + r = client.post(f"{ALERTS}/{a1.id}/create-action-plan", + headers=auth_header(_login(client, 2)), json={"assignee": "乙"}) + assert r.status_code == 404, f"跨账套应 404,实际 {r.status_code} {r.text[:200]}" + assert _plans_by_title(db, _alert_plan_title(a1.alert_message)) == [], ( + "跨账套 create-action-plan 竟为对端账套预警建了计划" + ) + + +# ────────────────────────────────────────────── +# T2. 差异反打生成议题 → 会话账套(双向) +# ────────────────────────────────────────────── + +def test_deviation_push_writes_session_entity(client: TestClient, db: Session): + """e1 会话 → 议题 entity_id=1;e2 会话 → 议题 entity_id=2(不得落默认值 1)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_DPUSH_KPI, 1) + k2 = _mk_kpi(db, E2_DPUSH_KPI, 2) + m1 = _mk_map(db, 1, "ZZ_APRES e1 地图", E1_DPUSH_KPI, "e1 目标") + m2 = _mk_map(db, 2, "ZZ_APRES e2 地图", E2_DPUSH_KPI, "e2 目标") + + t1 = _dpush_topic_title(k1, "e1 目标") + r1 = client.post(DPUSH, headers=auth_header(_login(client, 1)), json={ + "mapId": m1.id, "nodeId": "finance-0", "newValue": 900.0, + "period": _period(), "createReviewTopic": True, + }) + assert r1.status_code == 200, f"e1 反打应 200,实际 {r1.status_code} {r1.text[:300]}" + assert r1.json()["review_topic_created"] is True, r1.text[:200] + rows1 = _plans_by_title(db, t1) + assert len(rows1) == 1, f"e1 反打未生成议题: {rows1}" + assert rows1[0].entity_id == 1, ( + f"e1 会话经差异反打生成议题的 entity_id 应为 1,实际 {rows1[0].entity_id}" + ) + + t2 = _dpush_topic_title(k2, "e2 目标") + r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={ + "mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0, + "period": _period(), "createReviewTopic": True, + }) + assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}" + assert r2.json()["review_topic_created"] is True, r2.text[:200] + rows2 = _plans_by_title(db, t2) + assert len(rows2) == 1, f"e2 反打未生成议题: {rows2}" + assert rows2[0].entity_id == 2, ( + f"e2 会话经差异反打生成议题的 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}" + " —— deviation_push.py 构造 ActionPlan 未写 entity_id,落模型默认值 1" + ) + assert rows2[0].kpi_id == k2.id, ( + f"e2 议题应挂 e2 自己的 KPI#{k2.id},实际 #{rows2[0].kpi_id}" + ) + + +# ────────────────────────────────────────────── +# T3. 差异反打去重按账套隔离(对端同名议题不得抑制本账套创建) +# ────────────────────────────────────────────── + +def test_deviation_push_dedupe_scoped_to_entity(client: TestClient, db: Session): + """e1 已有同名 pending 议题(含历史遗留 entity_id=1 行)不得抑制 e2 创建自己的议题""" + _seed_two_entities(db) + _mk_kpi(db, E1_DPUSH_KPI, 1) + k2 = _mk_kpi(db, E2_DPUSH_KPI, 2) + m2 = _mk_map(db, 2, "ZZ_APRES e2 地图去重", E2_DPUSH_KPI, "e2 去重目标") + + t2 = _dpush_topic_title(k2, "e2 去重目标") + # 对端账套(e1)先落一条同标题的 pending 议题(模拟历史遗留/另一账套真实办理中) + foreign = ActionPlan( + entity_id=1, kpi_id=db.query(KPIDefinition).filter( + KPIDefinition.kpi_code == E1_DPUSH_KPI).first().id, + title=t2, assignee="甲", priority="medium", status="pending", progress=0, + ) + db.add(foreign) + db.commit() + + r2 = client.post(DPUSH, headers=auth_header(_login(client, 2)), json={ + "mapId": m2.id, "nodeId": "finance-0", "newValue": 900.0, + "period": _period(), "createReviewTopic": True, + }) + assert r2.status_code == 200, f"e2 反打应 200,实际 {r2.status_code} {r2.text[:300]}" + assert r2.json()["review_topic_created"] is True, ( + "对端账套同名议题抑制了本账套创建(去重条件未按账套隔离)" + ) + rows = _plans_by_title(db, t2) + assert len(rows) == 2, f"应有 e1 遗留 + e2 新建共 2 条,实际 {len(rows)}" + own = [x for x in rows if x.entity_id == 2] + assert len(own) == 1, ( + f"e2 本账套议题未落库或落错账套: {[(x.id, x.entity_id) for x in rows]}" + ) + + +# ────────────────────────────────────────────── +# T4. AI 建议转行动计划 → 会话账套(双向) +# ────────────────────────────────────────────── + +def _mk_suggestion(client: TestClient, token: str, kpi_id: int, title: str): + body = { + "suggestion_type": "action_plan", + "target_type": "kpi", + "target_id": kpi_id, + "title": title, + "content": "达成率低于阈值", + "suggestion_data": {"kpi_id": kpi_id}, + } + 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 test_ai_suggestion_action_plan_writes_session_entity(client: TestClient, db: Session): + """e1 会话 → 计划 entity_id=1;e2 会话 → 计划 entity_id=2(不得落默认值 1)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_SUG_KPI, 1) + k2 = _mk_kpi(db, E2_SUG_KPI, 2) + + tok1 = _login(client, 1) + s1 = _mk_suggestion(client, tok1, k1.id, "ZZ_APRES e1 建议转计划") + r1 = client.post(f"{SUGG}/{s1}/apply", headers=auth_header(tok1), + json={"action": "action_plan", "title": "ZZ_APRES e1 转出的计划"}) + assert r1.status_code == 200, f"e1 应用应 200,实际 {r1.status_code} {r1.text[:300]}" + rows1 = _plans_by_title(db, "ZZ_APRES e1 转出的计划") + assert len(rows1) == 1, f"e1 应用未生成计划: {rows1}" + assert rows1[0].entity_id == 1, ( + f"e1 会话经 AI 建议生成的计划 entity_id 应为 1,实际 {rows1[0].entity_id}" + ) + + tok2 = _login(client, 2) + s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 建议转计划") + r2 = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2), + json={"action": "action_plan", "title": "ZZ_APRES e2 转出的计划"}) + assert r2.status_code == 200, f"e2 应用应 200,实际 {r2.status_code} {r2.text[:300]}" + rows2 = _plans_by_title(db, "ZZ_APRES e2 转出的计划") + assert len(rows2) == 1, f"e2 应用未生成计划: {rows2}" + assert rows2[0].entity_id == 2, ( + f"e2 会话经 AI 建议生成的计划 entity_id 应为 2(会话账套),实际 {rows2[0].entity_id}" + " —— ai_suggestions.py 构造 ActionPlan 未写 entity_id,落模型默认值 1" + ) + + +def test_ai_suggestion_apply_cross_entity_404(client: TestClient, db: Session): + """e1 会话应用 e2 的建议 → 404(建议须归属会话账套)+ 零落行;本账套应用正常(正对照)""" + _seed_two_entities(db) + _mk_kpi(db, E1_SUG_KPI, 1) + k2 = _mk_kpi(db, E2_SUG_KPI, 2) + + tok2 = _login(client, 2) + s2 = _mk_suggestion(client, tok2, k2.id, "ZZ_APRES e2 私有建议") + tok1 = _login(client, 1) + r = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok1), + json={"action": "action_plan", "title": "ZZ_APRES 越权转出的计划"}) + assert r.status_code == 404, f"跨账套应用应 404,实际 {r.status_code} {r.text[:300]}" + assert _plans_by_title(db, "ZZ_APRES 越权转出的计划") == [], ( + "跨账套应用竟为对端账套建议建了计划" + ) + db.expire_all() + sug = db.query(AISuggestion).filter(AISuggestion.id == s2).first() + assert sug.status == "unapplied", f"越权应用改动了建议状态: {sug.status}" + + # 正对照:本账套会话应用自己账套的建议仍然正常 + r_ok = client.post(f"{SUGG}/{s2}/apply", headers=auth_header(tok2), + json={"action": "action_plan", "title": "ZZ_APRES e2 自救计划"}) + assert r_ok.status_code == 200, f"本账套应用应 200,实际 {r_ok.status_code} {r_ok.text[:300]}" + assert len(_plans_by_title(db, "ZZ_APRES e2 自救计划")) == 1 + + +# ────────────────────────────────────────────── +# T5. 鉴权底线(三个入口无 token → 401) +# ────────────────────────────────────────────── + +def test_residual_sites_without_token_401(client: TestClient, db: Session): + """三个入口无 token 一律 401(改前改后都应通过)""" + _seed_two_entities(db) + k1 = _mk_kpi(db, E1_ALERT_KPI, 1) + a1 = _mk_alert(db, 1, k1.id, "ZZ_APRES e1 鉴权底线") + m1 = _mk_map(db, 1, "ZZ_APRES e1 鉴权地图", E1_ALERT_KPI, "e1 鉴权目标") + + r1 = client.post(f"{ALERTS}/{a1.id}/create-action-plan", json={}) + assert r1.status_code == 401, f"预警入口无 token 应 401,实际 {r1.status_code}" + + r2 = client.post(DPUSH, json={"mapId": m1.id, "nodeId": "finance-0", + "newValue": 800.0, "createReviewTopic": False}) + assert r2.status_code == 401, f"差异反打无 token 应 401,实际 {r2.status_code}" + + r3 = client.post(SUGG, json={"suggestion_type": "action_plan", "title": "无 token"}) + assert r3.status_code == 401, f"AI 建议无 token 应 401,实际 {r3.status_code}"