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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from typing import Optional from typing import Optional
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from app.database import get_db 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", ""), "label": data.get("label", ""),
}) })
m.canvas_data["connections"] = conns m.canvas_data["connections"] = conns
# D2(2026-10-20):裸 JSON 列不做就地变更跟踪;显式标脏,与模型层 MutableDict 双保险
flag_modified(m, "canvas_data")
db.commit() db.commit()
# updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409 # updated_at: 供前端采纳为新基准(并发保护 base_updated_at),避免自伤 409
return {"connections": conns, "updated_at": _stamp(m.updated_at)} 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, "连线不存在") raise HTTPException(404, "连线不存在")
m.canvas_data["connections"] = new_conns m.canvas_data["connections"] = new_conns
# D2(2026-10-20):换新列表赋值同样不被裸 JSON 列跟踪 ⇒ 显式标脏(根因修复点)
flag_modified(m, "canvas_data")
db.commit() db.commit()
return {"connections": new_conns, "removed": {"from": from_id, "to": to_id}, return {"connections": new_conns, "removed": {"from": from_id, "to": to_id},
"updated_at": _stamp(m.updated_at)} "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(kpi_governance.router)
app.include_router(templates.router) app.include_router(templates.router)
app.include_router(maps.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(dashboard.router)
app.include_router(data.router) app.include_router(data.router)
app.include_router(alerts.router) app.include_router(alerts.router)
@@ -56,8 +61,6 @@ app.include_router(action_plans.router)
app.include_router(alignment.router) app.include_router(alignment.router)
app.include_router(org.router) app.include_router(org.router)
app.include_router(objectives.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(versions.router)
app.include_router(budget.router) app.include_router(budget.router)
app.include_router(value_sources.router) app.include_router(value_sources.router)
+2 -1
View File
@@ -1,5 +1,6 @@
"""管理会计OS 数据模型""" """管理会计OS 数据模型"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, ForeignKey, Boolean, JSON, func, text, UniqueConstraint, Index, Numeric, Date 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.database import Base
from app.models.budget_plan import BudgetPlan from app.models.budget_plan import BudgetPlan
@@ -59,7 +60,7 @@ class StrategicMap(Base):
version = Column(String(20), default="v1.0", comment="版本号") version = Column(String(20), default="v1.0", comment="版本号")
status = Column(String(20), default="draft", comment="draft/published") status = Column(String(20), default="draft", comment="draft/published")
dimensions = Column(JSON, nullable=True, comment="四维度和目标列表") 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_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=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) { function onTreeDragOver(e: DragEvent) {
if (!treeDragSource.value) return if (!treeDragSource.value) return
const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-node-key]') as HTMLElement | null const key = hitTestTreeNodeAt(e.clientX, e.clientY)
const key = el?.getAttribute('data-node-key') || null
dragConnectTarget.value = key && key !== treeDragSource.value.key ? key : null 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) { function onTreeDragLeave(e: DragEvent) {
const related = (e as any).relatedTarget as Node | null const related = (e as any).relatedTarget as Node | null
const box = scrollWrapRef.value const box = scrollWrapRef.value