diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index feca4482..7ab6e829 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import text from sqlalchemy.orm import Session +from sqlalchemy.orm.attributes import flag_modified from typing import Optional from datetime import date, datetime, timedelta from app.database import get_db @@ -558,6 +559,8 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entit "label": data.get("label", ""), }) m.canvas_data["connections"] = conns + # D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险 + flag_modified(m, "canvas_data") db.commit() # updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409 return {"connections": conns, "updated_at": _stamp(m.updated_at)} @@ -580,6 +583,8 @@ def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_ raise HTTPException(404, "连线不存在") m.canvas_data["connections"] = new_conns + # D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点) + flag_modified(m, "canvas_data") db.commit() return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}, "updated_at": _stamp(m.updated_at)} diff --git a/backend/app/main.py b/backend/app/main.py index b5c98863..b0bd5800 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -41,6 +41,11 @@ app.include_router(kpis.router) app.include_router(kpi_governance.router) app.include_router(templates.router) app.include_router(maps.router) +# 顺序铁律(2026-10-20 D1 修复):同前缀族的路由必须把**字面路径段**路由器 include 在 +# 任何 `/{map_id}/` 参数化路由(objectives.router 的 GET /{map_id}/objectives)之前, +# 否则 /api/cma/maps/completeness/objectives 会被判 map_id="completeness" → 恒 422。 +app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0) +app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿) app.include_router(dashboard.router) app.include_router(data.router) app.include_router(alerts.router) @@ -56,8 +61,6 @@ app.include_router(action_plans.router) app.include_router(alignment.router) app.include_router(org.router) app.include_router(objectives.router) -app.include_router(map_completeness.router) # 战略地图数据完备度(口径 CMA-CALIBER-MAP-COMPLETENESS-20261018 v1.0) -app.include_router(map_summary.router) # 战略地图摘要条(收入分线/新式毛利率/管理指标 · 口径 CMA-A1 终稿) app.include_router(versions.router) app.include_router(budget.router) app.include_router(value_sources.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c6e6ca47..79f66bc7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ """管理会计OS 数据模型""" from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date +from sqlalchemy.ext.mutable import MutableDict from app.database import Base from app.models.budget_plan import BudgetPlan @@ -59,7 +60,7 @@ class StrategicMap(Base): version = Column(String(20), default="v1.0", comment="版本号") status = Column(String(20), default="draft", comment="draft/published") dimensions = Column(JSON, nullable=True, comment="四维度和目标列表") - canvas_data = Column(JSON, nullable=True, comment="画布连线数据") + canvas_data = Column(MutableDict.as_mutable(JSON), nullable=True, comment="画布连线数据") created_by = Column(Integer, nullable=True) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/tests/test_map_connection_persistence_20261020.py b/backend/tests/test_map_connection_persistence_20261020.py new file mode 100644 index 00000000..1e8f8e39 --- /dev/null +++ b/backend/tests/test_map_connection_persistence_20261020.py @@ -0,0 +1,128 @@ +"""D2(2026-10-20 fullstack-20261020-strategy-map-3defects-fix) + +缺陷:`DELETE /api/cma/maps/{id}/connections` 返回 200 但库里没变(POST 同样不可靠)。 +根因:`StrategicMap.canvas_data` 是裸 `Column(JSON)`,无变更跟踪;接口用 +「`m.canvas_data["connections"] = new_conns`」就地改,flush 时该列未被判脏 ⇒ 不写库。 + +判据铁律(任务 §1.2):**不能**用「响应体里 connections 变短了」当落库证据 —— +必须新开 session 用原生 SQL 复读 `canvas_data`。 +""" +import json + +from sqlalchemy import text + +from app.models import StrategicMap + + +def _raw_canvas_data(map_id: int) -> dict: + """**新 session + 原生 SQL** 复读(不复用请求内 session)。""" + from tests.conftest import TEST_SESSION_LOCAL + + session = TEST_SESSION_LOCAL() + try: + row = session.execute( + text("SELECT canvas_data FROM strategic_maps WHERE id = :mid"), {"mid": map_id} + ).first() + assert row is not None, f"strategic_maps.id={map_id} 不存在" + raw = row[0] + finally: + session.close() + if isinstance(raw, (str, bytes)): + return json.loads(raw) + return raw or {} + + +def _pairs(canvas: dict): + return [(c.get("from"), c.get("to")) for c in (canvas.get("connections") or [])] + + +def test_add_connection_persists_across_sessions(client, db): + from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user + + create_test_user(db) + m = create_test_map(db, entity_id=1) # canvas_data={"connections": []} + db.commit() + token = get_token_for_user(client) + h = auth_header(token) + + resp = client.post( + f"/api/cma/maps/{m.id}/connections", + json={"from": "learning-0", "to": "process-0"}, + headers=h, + ) + assert resp.status_code == 200, resp.text + assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(m.id)), ( + "POST /connections 返回 200 但新 session 原生 SQL 读不到该连线(未落库)" + ) + + +def test_delete_connection_persists_across_sessions(client, db): + from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user + + create_test_user(db) + m = create_test_map( + db, + entity_id=1, + canvas_data={"connections": [ + {"from": "learning-0", "to": "process-0", "style": "solid", "effect": "positive", "label": ""}, + {"from": "process-0", "to": "customer-0", "style": "solid", "effect": "positive", "label": ""}, + ]}, + ) + db.commit() + token = get_token_for_user(client) + h = auth_header(token) + + resp = client.request( + "DELETE", + f"/api/cma/maps/{m.id}/connections", + json={"from": "learning-0", "to": "process-0"}, + headers=h, + ) + assert resp.status_code == 200, resp.text + + persisted = _pairs(_raw_canvas_data(m.id)) + assert ("learning-0", "process-0") not in persisted, ( + f"DELETE 返回 200 但新 session 原生 SQL 仍读到已删连线:{persisted}" + ) + assert ("process-0", "customer-0") in persisted, "无关连线被误删" + + +def test_delete_missing_connection_still_404(client, db): + """负测:删除不存在的 from/to 仍 404(不得因标脏改动变成 200)。""" + from tests.conftest import auth_header, create_test_map, create_test_user, get_token_for_user + + create_test_user(db) + m = create_test_map(db, entity_id=1) + db.commit() + token = get_token_for_user(client) + h = auth_header(token) + + resp = client.request( + "DELETE", + f"/api/cma/maps/{m.id}/connections", + json={"from": "learning-9", "to": "process-9"}, + headers=h, + ) + assert resp.status_code == 404, resp.text + + +def test_canvas_data_mutation_tracked_by_mutable_dict(db): + """模型层双保险:MutableDict 包装后,**不经 flag_modified** 的 dict 赋值也应落库。 + + 说明:`MutableDict.as_mutable(JSON)` 返回的仍是 JSON 类型实例(跟踪靠 mapper 级 + attribute 事件 + coerce),因此这里用**行为断言**而不是断言列类型类名。 + """ + m = StrategicMap(title="MutableDict 行为验证", entity_id=1, canvas_data={"connections": []}) + db.add(m) + db.commit() + mid = m.id + db.expire_all() + + loaded = db.query(StrategicMap).filter(StrategicMap.id == mid).first() + # 就地改 dict(等价于接口里的 `m.canvas_data["connections"] = new_conns`) + loaded.canvas_data["connections"] = [{"from": "learning-0", "to": "process-0"}] + db.commit() # 不调用 flag_modified + + assert ("learning-0", "process-0") in _pairs(_raw_canvas_data(mid)), ( + "canvas_data 未被 MutableDict 跟踪:dict 赋值后 commit 未落库" + ) diff --git a/backend/tests/test_map_route_shadowing_20261020.py b/backend/tests/test_map_route_shadowing_20261020.py new file mode 100644 index 00000000..be76e034 --- /dev/null +++ b/backend/tests/test_map_route_shadowing_20261020.py @@ -0,0 +1,108 @@ +"""D1(2026-10-20 fullstack-20261020-strategy-map-3defects-fix) + +缺陷:`GET /api/cma/maps/completeness/objectives` 恒 422。 +根因(项目Bot 复核修正):`main.py` 先 include 的 `objectives.router` +(prefix `/api/cma/maps`,`GET /{map_id}/objectives`)把 `map_id="completeness"` +当 int 解析 → 422;真抢占者不是 `maps.router`。 + +修复:`map_completeness.router` / `map_summary.router` 前移到 `objectives.router` 之前。 +本文件钉住三件事: + 1) 该 path 的首个 FULL match 落在 `app.api.map_completeness`(比 200/401 更硬,不依赖 DB); + 2) `/completeness/summary` 不回归; + 3) `/maps/{map_id}/objectives` 仍命中 `app.api.objectives`; + 4) 同前缀族全量巡检:任何字面路径路由都不得被先注册的参数化路由吃掉。 +""" +from pathlib import Path + +from fastapi.routing import APIRoute +from starlette.routing import Match + +from app.main import app + +COMPLETENESS_OBJECTIVES = "/api/cma/maps/completeness/objectives" + + +def _first_full_match(path: str, method: str = "GET"): + """按注册顺序返回首个 FULL match 的 route(= Starlette 实际分发结果)。""" + scope = {"type": "http", "method": method, "path": path, "headers": []} + for route in app.routes: + if not hasattr(route, "matches"): + continue + match, _child = route.matches(scope) + if match == Match.FULL: + return route + return None + + +def _ep(route) -> str: + endpoint = getattr(route, "endpoint", None) + return f"{getattr(endpoint, '__module__', '?')}.{getattr(endpoint, '__name__', '?')}" + + +def test_app_imported_from_this_worktree(): + """防呆:pytest.ini 的 pythonpath 指向部署树,必须断言用的是当前工作区代码。""" + from app import main as app_main + + repo_backend = Path(__file__).resolve().parents[1] + assert Path(app_main.__file__).resolve().is_relative_to(repo_backend), ( + f"导入了非本工作区的 app.main:{app_main.__file__}(期望在 {repo_backend} 下)" + ) + + +def test_completeness_objectives_route_resolves_to_map_completeness(): + route = _first_full_match(COMPLETENESS_OBJECTIVES) + assert route is not None, f"没有路由匹配 {COMPLETENESS_OBJECTIVES}" + assert _ep(route) == "app.api.map_completeness.completeness_objectives", ( + f"{COMPLETENESS_OBJECTIVES} 被抢先解析为 {_ep(route)}" + ) + assert getattr(route, "path", None) == COMPLETENESS_OBJECTIVES + + +def test_completeness_summary_not_regressed(): + route = _first_full_match("/api/cma/maps/completeness/summary") + assert route is not None + assert _ep(route) == "app.api.map_completeness.completeness_summary", _ep(route) + + +def test_map_summary_not_shadowed(): + route = _first_full_match("/api/cma/maps/summary") + assert route is not None + assert _ep(route) == "app.api.map_summary.map_summary", _ep(route) + + +def test_map_objectives_endpoint_not_regressed(): + route = _first_full_match("/api/cma/maps/65/objectives") + assert route is not None + assert _ep(route) == "app.api.objectives.list_objectives", _ep(route) + + +def test_no_literal_path_shadowed_in_maps_prefix_family(): + """同前缀族全量巡检:/api/cma/maps* 下所有**字面**路径的首个匹配必须是自己。""" + family = [ + r for r in app.routes + if isinstance(r, APIRoute) + and r.path.startswith("/api/cma/maps") + and "{" not in r.path + ] + assert family, "未发现 /api/cma/maps* 字面路径路由,巡检面为空(异常)" + + problems = [] + for r in family: + for method in sorted((r.methods or set()) - {"HEAD", "OPTIONS"}): + first = _first_full_match(r.path, method) + if first is not r: + problems.append(f"{method} {r.path} -> {_ep(first)}(应为 {_ep(r)})") + assert not problems, "同前缀族路由被抢占:\n" + "\n".join(problems) + + +def test_completeness_objectives_http_not_422(client, db): + """端到端:带凭据请求不得出现 loc=["path","map_id"] / input="completeness" 的 422。""" + from tests.conftest import auth_header, create_test_user, get_token_for_user + + create_test_user(db) + token = get_token_for_user(client) + resp = client.get(COMPLETENESS_OBJECTIVES, headers=auth_header(token)) + assert resp.status_code != 422, resp.text + assert resp.status_code == 200, resp.text + body = resp.json() + assert "objectives" in body or isinstance(body, dict) diff --git a/frontend/src/views/MapCanvas.vue b/frontend/src/views/MapCanvas.vue index 58d6420a..9d1b02cb 100644 --- a/frontend/src/views/MapCanvas.vue +++ b/frontend/src/views/MapCanvas.vue @@ -994,10 +994,35 @@ function onTreeDragStart(payload: { dimKey: string; index: number; obj: any }) { } function onTreeDragOver(e: DragEvent) { if (!treeDragSource.value) return - const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-node-key]') as HTMLElement | null - const key = el?.getAttribute('data-node-key') || null + const key = hitTestTreeNodeAt(e.clientX, e.clientY) dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null } + +/** + * D3(2026-10-20):窄视口下 .kp-panel 是 fixed + z-index:1000 的浮层,会盖住画布主体; + * 旧实现用 document.elementFromPoint() 取节点 —— 命中面板时 .closest('[data-node-key]') 恒 null, + * 拖拽建连线必走 cancelLink()。改为**几何命中**:直接遍历画布内全部 [data-node-key], + * 用 getBoundingClientRect() 做包含判断(rect 已含 transform: scale,与 clientX/Y 同坐标系); + * 多点重叠时取中心距拖拽点最近者。**不再回退 elementFromPoint**(否则窄屏复发)。 + * 性能:19 个节点的 rect 遍历一帧一次 ≈ 微秒级,无需 rAF/节流,避免节流造成 drop 时刻判据滞后。 + */ +function hitTestTreeNodeAt(x: number, y: number): string | null { + const box = scrollWrapRef.value + if (!box) return null + const nodes = box.querySelectorAll('[data-node-key]') + let bestKey: string | null = null + let bestDist = Number.POSITIVE_INFINITY + for (let i = 0; i < nodes.length; i++) { + const r = nodes[i].getBoundingClientRect() + if (r.width === 0 && r.height === 0) continue // 隐藏层/折叠层 + if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue + const dx = x - (r.left + r.width / 2) + const dy = y - (r.top + r.height / 2) + const d = dx * dx + dy * dy + if (d < bestDist) { bestDist = d; bestKey = nodes[i].getAttribute('data-node-key') } + } + return bestKey +} function onTreeDragLeave(e: DragEvent) { const related = (e as any).relatedTarget as Node | null const box = scrollWrapRef.value