Author SHA1 Message Date
Hermes CI Fix b736e0727a 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 复读 + 负测)。零生产写、零上线。
2026-10-21 02:04:28 +08:00
6 changed files with 275 additions and 5 deletions
+5
View File
@@ -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)}
+5 -2
View File
@@ -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}/<literal>` 参数化路由(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)
+2 -1
View File
@@ -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())
@@ -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 未落库"
)
@@ -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)
+27 -2
View File
@@ -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<HTMLElement>('[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