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,
})),