fix(strategy-map): 战略地图三处 P1 缺陷(D1 完备度路由 422 / D2 连线删除不落库 / D3 窄屏拖拽失效)

D1 GET /api/cma/maps/completeness/objectives 恒 422:真抢占者是先注册的
   objectives.router GET /{map_id}/objectives(map_id 被当 int 解析)。把
   map_completeness / map_summary 两个字面路径路由器前移到 objectives 之前,
   不动 prefix、不删参数化路由。新增同前缀族全量巡检用例。

D2 DELETE /{id}/connections 返回 200 不落库:canvas_data 为裸 Column(JSON),
   就地/换列表赋值不被 ORM 判脏。模型改 MutableDict.as_mutable(JSON)(不改 DDL),
   add_connection / delete_connection_by_key 均显式 flag_modified 双保险。

D3 窄视口 .kp-panel(fixed/z-1000) 遮画布,onTreeDragOver 用 elementFromPoint
   取节点恒 null ⇒ 拖拽建连线失效。改为遍历画布内 [data-node-key] 做 rect 包含
   判定、多点取中心最近者,不再回退 elementFromPoint。

测试:新增 backend/tests/test_map_route_shadowing_20261020.py(路由解析断言到
endpoint.__module__)与 test_map_connection_persistence_20261020.py(新 session
原生 SQL 复读 + 负测)。零生产写、零上线。
This commit is contained in:
Hermes CI Fix
2026-10-21 02:04:28 +08:00
parent 86e24c6310
commit b736e0727a
6 changed files with 275 additions and 5 deletions
@@ -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)