diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index 5143dfff..a3e0c62d 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -7,7 +7,11 @@ from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import StrategicMap, OperationLog, MapObjective, Objective, KR, KPIDefinition, ReviewRecord, User +from app.utils.kr_name import sanitize_kr_name import json +import logging + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/cma/maps", tags=["战略地图"], dependencies=[Depends(require_role("ceo", "finance"))], @@ -481,6 +485,16 @@ def _sync_objectives_krs(m, db): if kpi and kpi.threshold_green: op2, tgt2 = _parse_kr_operator_target(kpi.threshold_green) op, tgt = op2, (tgt2 if tgt is None else tgt) + # 护栏(2026-09-16):名称尾部「≥/≤+数字」与目标值不一致 = 历史污染截断值, + # 在此透传点(JSON/模板名 → krs.title)规整,防止污染继续传染(判据见 app/utils/kr_name.py) + clean_title, _polluted = sanitize_kr_name(title, kr_item.get("target_value"), tgt) + if _polluted: + logger.warning( + "KR名称污染已规整: map=%s objective=%s %r -> %r (target=%s)", + m.id, objective.id, title, clean_title, tgt, + ) + title = clean_title + kr_item["name"] = clean_title # 同步回写 JSON,保持展示口径=落库口径 kr = existing_krs.get(title) if kr is None: kr = KR( diff --git a/backend/app/api/okr.py b/backend/app/api/okr.py index 3c2d01d1..cd7cd207 100644 --- a/backend/app/api/okr.py +++ b/backend/app/api/okr.py @@ -11,6 +11,10 @@ from app.database import get_db from app.deps import get_entity_id from app.auth_middleware import require_role from app.models import Objective, ActionPlan, KR, ObjectiveKPI, KPIDefinition +from app.utils.kr_name import sanitize_kr_name +import logging + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/cma/okr", tags=["OKR目标管理"], dependencies=[Depends(require_role("ceo", "finance", "it"))], @@ -329,6 +333,14 @@ def sync_krs(objective_id: int, data: dict, db: Session = Depends(get_db), kpi_id = kpi.id if kpi else None operator, target_value = _inherit_kpi_direction(db, kpi_id, item.get("operator"), item.get("target_value")) + # 护栏(2026-09-16):画布节点/接口传来的 KR 名若带历史污染尾巴(尾部「≥/≤+被截断的数字」) + # 在此规整,防污染经 krs.title 继续传染(判据见 app/utils/kr_name.py) + _tv = item.get("target_value") + title, _polluted = sanitize_kr_name(title, _tv if _tv is not None else target_value) + if _polluted: + logger.warning("KR名称污染已规整: objective=%s %r -> %r (target=%s)", + objective_id, item.get("title") or item.get("name"), title, _tv) + kr = None if item.get("id") and int(item["id"]) in existing: kr = existing[int(item["id"])] diff --git a/backend/app/api/okr_templates.py b/backend/app/api/okr_templates.py index 05c3c479..797681ea 100644 --- a/backend/app/api/okr_templates.py +++ b/backend/app/api/okr_templates.py @@ -7,6 +7,7 @@ from typing import Optional from app.database import get_db from app.auth_middleware import require_role from app.models import OKRTemplate, StrategicMap +from app.utils.kr_name import sanitize_kr_name router = APIRouter(prefix="/api/cma/okr-templates", tags=["OKR模板库"], dependencies=[Depends(require_role("ceo", "finance", "it"))], @@ -273,7 +274,8 @@ def apply_okr_template(template_id: int, data: dict, db: Session = Depends(get_d "kpis": [], "krs": [ { - "name": kr.get("name", ""), + # 护栏(2026-09-16):模板名 → 新地图 KR 名 透传点,污染名(尾部符号+截断数字)在此规整 + "name": sanitize_kr_name(kr.get("name", ""), kr.get("target_value"))[0], "target_value": kr.get("target_value", ""), "weight": kr.get("weight", 33), "metric_kpi_id": kr.get("metric_kpi_id"), diff --git a/backend/app/utils/kr_name.py b/backend/app/utils/kr_name.py new file mode 100644 index 00000000..436623cf --- /dev/null +++ b/backend/app/utils/kr_name.py @@ -0,0 +1,54 @@ +"""KR 名称规整(历史名称污染防护)。 + +背景:2026-09-11 前 `MapCanvasDialogs.vue` 的逐键写入点会把「方向符号 + 已输入的首位数字」 +拼进 KR 名称(输入 780 → 名称变 `营业收入≥7`)。写入口已删,但**存量数据仍在传染**: +对话框用模板 `preset_krs` 预填 KR 名 → 沿用到新目标/新地图。 + +判据(勿一刀切): +- 名称尾部「≥/≤ + 数字」且该数字 **≠** 目标值 → 截断值(污染)→ 去掉尾部片段; +- 数字 **==** 目标值 → 有意命名(如 `渠道留存率≥90` / `新客户数≥10` / `A级渠道占比≥30%`)→ 原样保留; +- 目标值里取不到数字(如 `为正` / `TOP3` / 空值)→ 无法判定 → 原样保留(不擅改)。 + +用法(护栏放在「JSON/模板名 → krs.title」的透传点,而不是用户输入点): + from app.utils.kr_name import sanitize_kr_name + clean, changed = sanitize_kr_name(name, target_value) +""" +import re +from typing import Any, Optional, Tuple + +_NUM = re.compile(r"[-+]?\d*\.?\d+") +_TAIL = re.compile(r"^(?P.*?)\s*(?P[≥≤])\s*(?P\d+(?:\.\d+)?)\s*$") + + +def target_number(target_value: Any) -> Optional[float]: + """从目标值里取数字(兼容 780 / '780' / '≥780' / '≤45天' / '≥20%');取不到返回 None。""" + if target_value is None: + return None + if isinstance(target_value, bool): + return None + if isinstance(target_value, (int, float)): + return float(target_value) + m = _NUM.search(str(target_value)) + return float(m.group(0)) if m else None + + +def sanitize_kr_name( + name: Any, + target_value: Any = None, + target_number_value: Optional[float] = None, +) -> Tuple[str, bool]: + """返回 (规整后名称, 是否发生变更)。target_number_value 可显式传入已解析的目标数(优先)。""" + if not isinstance(name, str): + return ("" if name is None else str(name)), False + m = _TAIL.match(name) + if not m: + return name, False + tn = target_number_value if target_number_value is not None else target_number(target_value) + if tn is None: + return name, False + if abs(float(m.group("num")) - tn) < 1e-9: + return name, False + clean = m.group("base").strip() + if not clean: + return name, False + return clean, True diff --git a/backend/tests/test_kr_name_pollution_20260916.py b/backend/tests/test_kr_name_pollution_20260916.py new file mode 100644 index 00000000..77d2b376 --- /dev/null +++ b/backend/tests/test_kr_name_pollution_20260916.py @@ -0,0 +1,178 @@ +"""KR 名称污染(历史截断值)护栏测试 — 任务 cma-kr-name-pollution-cleanup-20260916 + +覆盖: +1. 判据单测(sanitize_kr_name / target_number):污染截断、有意命名、无法判定三类 +2. 「JSON/模板名 → krs.title」透传点护栏: + - PUT /api/cma/maps/{id}(_sync_objectives_krs) + - PUT /api/cma/okr/{objective_id}/krs/sync(画布节点同步) + - POST /api/cma/okr-templates/{id}/apply(模板 → 新地图) +3. 阴性对照:合法命名(渠道留存率≥90 / 新客户数≥10)经同样路径不得被改 +""" +import json + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import KR, Objective, OKRTemplate, StrategicMap +from app.utils.kr_name import sanitize_kr_name, target_number +from tests.conftest import create_test_user, get_token_for_user, auth_header + + +# ────────────────────────── 1. 判据单测 ────────────────────────── + +class TestSanitizeKrName: + @pytest.mark.parametrize("name,target,expect", [ + ("营业收入≥2", 2500, "营业收入"), # 输入 2500 时截断成首位数字 + ("新客户数≥1", 5, "新客户数"), + ("营业收入≥7", 780, "营业收入"), + ("净利润≥6", 60, "净利润"), + ("费用率≤3", 30, "费用率"), + ("经营性现金流≥5", 50, "经营性现金流"), + ("营业收入≥2", "2500", "营业收入"), # 目标值为字符串数字 + ("应收账款天数≤4", "≤45天", "应收账款天数"), # 目标值带单位文本 + ]) + def test_polluted_tail_stripped(self, name, target, expect): + clean, changed = sanitize_kr_name(name, target) + assert changed is True + assert clean == expect + + @pytest.mark.parametrize("name,target", [ + ("渠道留存率≥90", 90), # 数字==目标值 = 有意命名 + ("新客户数≥10", 10), + ("A级渠道占比≥30%", 30), + ("营业收入≥780", 780), + ("费用率≤30", 30), + ("现金折扣收益≤收入20%", 20), # 尾部不是「符号+数字」 + ("库存周转天数≤45天", "≤45天"), + ("数据质量评分≥90", 90.0), + ("剔除现金折扣后经营性净利≥0", 0), + ]) + def test_legal_names_untouched(self, name, target): + clean, changed = sanitize_kr_name(name, target) + assert changed is False + assert clean == name + + @pytest.mark.parametrize("name,target", [ + ("NPS净推荐值≥60", "为正"), # 目标值无数字 → 无法判定,原样保留 + ("总资产周转率≥1.0", None), + ("自由现金流≥0", ""), + ("经营现金流为正", "为正"), # 无尾部符号+数字 + ]) + def test_unknown_target_untouched(self, name, target): + clean, changed = sanitize_kr_name(name, target) + assert changed is False + assert clean == name + + def test_empty_base_kept(self): + """名称主体为空(只剩符号+数字)时不动,避免产出空标题""" + clean, changed = sanitize_kr_name("≥2", 2500) + assert changed is False + assert clean == "≥2" + + def test_non_string_input(self): + clean, changed = sanitize_kr_name(None, 100) + assert changed is False and clean == "" + + def test_target_number_parsing(self): + assert target_number("≥45天") == 45 + assert target_number("≥20%") == 20 + assert target_number(780) == 780 + assert target_number("为正") is None + assert target_number(None) is None + + +# ────────────────────────── 2. 透传点护栏 ────────────────────────── + +def _dims_with_krs(name, target_value): + return [ + {"key": "finance", "name": "财务层", "icon": "💰", "color": "#F56C6C", + "objectives": [{"name": "营收目标", "kpis": [], + "krs": [{"name": name, "target_value": target_value, + "weight": 70, "operator": ">="}]}]}, + {"key": "customer", "name": "客户层", "icon": "🤝", "color": "#67c23a", "objectives": []}, + ] + + +class TestPassThroughGuards: + def _setup(self, db: Session): + user = create_test_user(db) + return user + + def test_map_save_strips_polluted_kr_title(self, client: TestClient, db: Session): + """PUT /maps/{id} 的 JSON→krs.title 同步点:污染名被规整(库 + JSON 双清)""" + self._setup(db) + token = get_token_for_user(client) + m = StrategicMap(title="测试地图", status="draft", entity_id=1, + dimensions=_dims_with_krs("营业收入≥2", 25000), + canvas_data={"connections": []}) + db.add(m) + db.commit() + db.refresh(m) + + resp = client.put(f"/api/cma/maps/{m.id}", headers=auth_header(token), + json={"dimensions": _dims_with_krs("营业收入≥2", 25000)}) + assert resp.status_code == 200, resp.text + + kr = db.query(KR).filter(KR.objective_id.isnot(None)).first() + assert kr is not None and kr.title == "营业收入" + assert float(kr.target_value) == 25000.0 + # JSON 回写:展示口径 = 落库口径(回查须先失效身份映射,防读到旧快照) + db.expire_all() + m2 = db.query(StrategicMap).filter(StrategicMap.id == m.id).first() + dims = m2.dimensions + dims = json.loads(dims) if isinstance(dims, str) else dims + assert dims[0]["objectives"][0]["krs"][0]["name"] == "营业收入" + + def test_map_save_keeps_legal_kr_title(self, client: TestClient, db: Session): + """阴性对照:合法命名(数字==目标值)不得被改""" + self._setup(db) + token = get_token_for_user(client) + m = StrategicMap(title="测试地图", status="draft", entity_id=1, + dimensions=_dims_with_krs("渠道留存率≥90", 90), + canvas_data={"connections": []}) + db.add(m) + db.commit() + db.refresh(m) + + resp = client.put(f"/api/cma/maps/{m.id}", headers=auth_header(token), + json={"dimensions": _dims_with_krs("渠道留存率≥90", 90)}) + assert resp.status_code == 200, resp.text + kr = db.query(KR).filter(KR.objective_id.isnot(None)).first() + assert kr is not None and kr.title == "渠道留存率≥90" + + def test_kr_sync_endpoint_strips_polluted_title(self, client: TestClient, db: Session): + """PUT /okr/{oid}/krs/sync(画布节点同步)同样被护栏覆盖""" + self._setup(db) + token = get_token_for_user(client) + obj = Objective(entity_id=1, title="营收目标", dimension="finance", status="active", + quarter="2026Q3") + db.add(obj) + db.commit() + db.refresh(obj) + + resp = client.put(f"/api/cma/okr/{obj.id}/krs/sync", headers=auth_header(token), + json={"krs": [{"title": "新客户数≥1", "operator": ">=", "target_value": 100, + "weight": 30}]}) + assert resp.status_code == 200, resp.text + krs = db.query(KR).filter(KR.objective_id == obj.id).all() + assert [k.title for k in krs] == ["新客户数"] + + def test_apply_template_strips_polluted_kr_name(self, client: TestClient, db: Session): + """POST /okr-templates/{id}/apply:存量污染模板 → 新地图 KR 名干净""" + self._setup(db) + token = get_token_for_user(client) + t = OKRTemplate(name="营收目标", description="", dimension="finance", layer="level3", + industry_tag="general", source="system", + preset_krs=[{"name": "营业收入≥2", "target_value": 2500, "weight": 70}, + {"name": "渠道留存率≥90", "target_value": 90, "weight": 30}]) + db.add(t) + db.commit() + db.refresh(t) + + resp = client.post(f"/api/cma/okr-templates/{t.id}/apply", headers=auth_header(token), + json={"title": "套用模板-护栏验证"}) + assert resp.status_code == 200, resp.text + m = db.query(StrategicMap).filter(StrategicMap.id == resp.json()["map_id"]).first() + names = [k["name"] for k in m.dimensions[0]["objectives"][0]["krs"]] + assert names == ["营业收入", "渠道留存率≥90"] diff --git a/frontend/src/components/map-canvas/MapCanvasDialogs.vue b/frontend/src/components/map-canvas/MapCanvasDialogs.vue index bca88503..e3b40d2f 100644 --- a/frontend/src/components/map-canvas/MapCanvasDialogs.vue +++ b/frontend/src/components/map-canvas/MapCanvasDialogs.vue @@ -251,6 +251,7 @@ import { ref, computed, watch, onMounted } from "vue" import { useRouter } from "vue-router" import { ElMessage } from "element-plus" import { okrTemplateApi } from "../../api/index" +import { sanitizeKrName } from "../../utils/krName" // ── Props ── const props = defineProps<{ @@ -560,7 +561,8 @@ function nextStep() { krs: tmpl.preset_krs.map((kr: any) => { const parsed = parseKrTarget(kr.target_value) return { - name: kr.name, + // 护栏(2026-09-16):模板名 → KR 名 预填透传点;历史污染名(尾部被截断的方向值)在此规整 + name: sanitizeKrName(kr.name, kr.target_value).name, operator: parsed.operator, target_value: parsed.value, weight: Number(kr.weight) || 33, diff --git a/frontend/src/utils/krName.ts b/frontend/src/utils/krName.ts new file mode 100644 index 00000000..6f175f17 --- /dev/null +++ b/frontend/src/utils/krName.ts @@ -0,0 +1,32 @@ +/** + * KR 名称规整(历史名称污染防护)— 与后端 backend/app/utils/kr_name.py 同规则。 + * + * 背景:2026-09-11 前的逐键写入点会把「方向符号 + 已输入的首位数字」拼进 KR 名称 + * (输入 780 → 名称变 `营业收入≥7`)。写入口已删,但存量模板名仍会经「模板 → KR」预填传染。 + * + * 判据(勿一刀切): + * - 名称尾部「≥/≤ + 数字」且该数字 ≠ 目标值 → 截断值(污染)→ 去掉尾部片段; + * - 数字 == 目标值 → 有意命名(`渠道留存率≥90` / `新客户数≥10`)→ 保留; + * - 目标值取不到数字(`为正`/`TOP3`/空)→ 无法判定 → 保留。 + */ +const NUM = /[-+]?\d*\.?\d+/; +const TAIL = /^(.*?)\s*[≥≤]\s*(\d+(?:\.\d+)?)\s*$/; + +export function targetNumber(targetValue: any): number | null { + if (targetValue === null || targetValue === undefined || targetValue === "") return null; + if (typeof targetValue === "number") return Number.isFinite(targetValue) ? targetValue : null; + const m = String(targetValue).match(NUM); + return m ? parseFloat(m[0]) : null; +} + +export function sanitizeKrName(name: any, targetValue: any): { name: string; changed: boolean } { + const raw = name === null || name === undefined ? "" : String(name); + const m = raw.match(TAIL); + if (!m) return { name: raw, changed: false }; + const tn = targetNumber(targetValue); + if (tn === null) return { name: raw, changed: false }; + if (Math.abs(parseFloat(m[2]) - tn) < 1e-9) return { name: raw, changed: false }; + const base = m[1].trim(); + if (!base) return { name: raw, changed: false }; + return { name: base, changed: true }; +} diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index 55290862..0ec9b948 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -183,6 +183,7 @@ import KnowledgePanel from '../components/KnowledgePanel.vue' import api from "../api/index" import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers" import { useFormGuard } from "../composables/useFormGuard" +import { sanitizeKrName } from "../utils/krName" import StrategyLayer from "../components/strategy-map/StrategyLayer.vue" import ConnectionLines from "../components/strategy/ConnectionLines.vue" import MapCanvasToolbar from "../components/map-canvas/MapCanvasToolbar.vue" @@ -909,7 +910,9 @@ async function saveUserTemplate(data: any, layer: string) { description: (data.description || '').trim(), dimension: layer, preset_krs: (data.krs || []).map((kr: any) => ({ - name: kr.name, + // 护栏(2026-09-16):画布 KR 名 → 模板库 透传点,历史污染尾巴(截断值)在此规整, + // 防污染经用户自定义模板再次传染新目标 + name: sanitizeKrName(kr.name, kr.target_value).name, target_value: kr.target_value, weight: kr.weight || 33, })),