feat: 战略地图多租户隔离 — 酣客/博海地图按企业显示
- strategic_maps/map_objectives 加 entity_id 列+模型+回填(按标题: 酣客→1/博海→2) - maps: list按企业过滤, create/create-with-template强制token企业, update/delete/batch-delete/connections/review 跨企业404 - objectives/versions: 按map归属过滤 - _sync_map_objectives 继承地图entity - 实证: 酣客token→map32(酣客), 博海token→map49(博海), 跨企业读404 - pytest 482 passed - 多租户隔离专项最终收官(含周报遗留项)
This commit is contained in:
+27
-17
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_auth, require_role
|
from app.auth_middleware import require_auth, require_role
|
||||||
from app.models import StrategicMap, OperationLog, MapObjective
|
from app.models import StrategicMap, OperationLog, MapObjective
|
||||||
import json
|
import json
|
||||||
@@ -60,12 +61,13 @@ STRATEGIC_MAP_TEMPLATE = [
|
|||||||
# ── CRUD ────────────────────────────────────
|
# ── CRUD ────────────────────────────────────
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_maps(db: Session = Depends(get_db)):
|
def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all()
|
# 账套隔离: 仅当前企业地图 (2026-08-25)
|
||||||
|
maps = db.query(StrategicMap).filter(StrategicMap.entity_id == entity_id).order_by(StrategicMap.updated_at.desc()).all()
|
||||||
return {"data": [m_to_dict(m, db) for m in maps]}
|
return {"data": [m_to_dict(m, db) for m in maps]}
|
||||||
|
|
||||||
@router.post("")
|
@router.post("")
|
||||||
def create_map(data: dict, db: Session = Depends(get_db)):
|
def create_map(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
# 空白地图默认四层空结构(避免dimensions=NULL导致加载报错)
|
||||||
if "dimensions" not in data or data.get("dimensions") is None:
|
if "dimensions" not in data or data.get("dimensions") is None:
|
||||||
data = {**data, "dimensions": [
|
data = {**data, "dimensions": [
|
||||||
@@ -76,6 +78,7 @@ def create_map(data: dict, db: Session = Depends(get_db)):
|
|||||||
]}
|
]}
|
||||||
if "canvas_data" not in data or data.get("canvas_data") is None:
|
if "canvas_data" not in data or data.get("canvas_data") is None:
|
||||||
data = {**data, "canvas_data": {"connections": []}}
|
data = {**data, "canvas_data": {"connections": []}}
|
||||||
|
data["entity_id"] = entity_id # 账套隔离: 强制token企业
|
||||||
m = StrategicMap(**data)
|
m = StrategicMap(**data)
|
||||||
db.add(m)
|
db.add(m)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -85,12 +88,13 @@ def create_map(data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/create-with-template")
|
@router.post("/create-with-template")
|
||||||
def create_map_with_template(data: dict, db: Session = Depends(get_db)):
|
def create_map_with_template(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""一键创建带四维度模板的战略地图"""
|
"""一键创建带四维度模板的战略地图(账套隔离: 强制token企业)"""
|
||||||
m = StrategicMap(
|
m = StrategicMap(
|
||||||
title=data.get("title", "新建战略地图"),
|
title=data.get("title", "新建战略地图"),
|
||||||
version=data.get("version", "v1.0"),
|
version=data.get("version", "v1.0"),
|
||||||
status="draft",
|
status="draft",
|
||||||
|
entity_id=entity_id,
|
||||||
dimensions=STRATEGIC_MAP_TEMPLATE,
|
dimensions=STRATEGIC_MAP_TEMPLATE,
|
||||||
canvas_data={"connections": []},
|
canvas_data={"connections": []},
|
||||||
)
|
)
|
||||||
@@ -102,10 +106,13 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{map_id}")
|
@router.put("/{map_id}")
|
||||||
def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
|
def update_map(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
# 账套隔离: 禁止跨企业修改
|
||||||
|
if m.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
old_status = m.status
|
old_status = m.status
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
@@ -127,25 +134,27 @@ def update_map(map_id: int, data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{map_id}")
|
@router.delete("/{map_id}")
|
||||||
def delete_map(map_id: int, db: Session = Depends(get_db)):
|
def delete_map(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""删除战略地图"""
|
"""删除战略地图(账套隔离: 禁止跨企业删除)"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
if m.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "战略地图不存在")
|
||||||
db.delete(m)
|
db.delete(m)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "已删除"}
|
return {"message": "已删除"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/batch-delete")
|
@router.post("/batch-delete")
|
||||||
def batch_delete_maps(data: dict, db: Session = Depends(get_db)):
|
def batch_delete_maps(data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""批量删除战略地图"""
|
"""批量删除战略地图(账套隔离: 仅本企业地图)"""
|
||||||
ids = data.get("ids", [])
|
ids = data.get("ids", [])
|
||||||
if not ids:
|
if not ids:
|
||||||
raise HTTPException(400, "请选择要删除的地图")
|
raise HTTPException(400, "请选择要删除的地图")
|
||||||
deleted = 0
|
deleted = 0
|
||||||
for mid in ids:
|
for mid in ids:
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == mid).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == mid, StrategicMap.entity_id == entity_id).first()
|
||||||
if m:
|
if m:
|
||||||
db.delete(m)
|
db.delete(m)
|
||||||
deleted += 1
|
deleted += 1
|
||||||
@@ -169,9 +178,9 @@ def _get_connections(m: StrategicMap) -> list:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{map_id}/connections")
|
@router.post("/{map_id}/connections")
|
||||||
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
def add_connection(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
|
"""新增因果连线: {"from": "learning-0", "to": "process-0"}"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
@@ -209,9 +218,9 @@ def add_connection(map_id: int, data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{map_id}/connections")
|
@router.delete("/{map_id}/connections")
|
||||||
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db)):
|
def delete_connection_by_key(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""根据 from/to 删除连线"""
|
"""根据 from/to 删除连线"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
@@ -298,6 +307,7 @@ def _sync_map_objectives(m, db):
|
|||||||
for i, obj in enumerate(dim.get("objectives", [])):
|
for i, obj in enumerate(dim.get("objectives", [])):
|
||||||
mo = MapObjective(
|
mo = MapObjective(
|
||||||
map_id=m.id,
|
map_id=m.id,
|
||||||
|
entity_id=getattr(m, "entity_id", 1) or 1, # 账套隔离: 继承地图企业
|
||||||
dimension_key=dim.get("key", ""),
|
dimension_key=dim.get("key", ""),
|
||||||
name=obj.get("name", ""),
|
name=obj.get("name", ""),
|
||||||
description=obj.get("description", ""),
|
description=obj.get("description", ""),
|
||||||
@@ -345,14 +355,14 @@ def _merge_map_objectives(m, db):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{map_id}/review")
|
@router.get("/{map_id}/review")
|
||||||
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db)):
|
def get_map_review(map_id: int, level: Optional[str] = None, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""战略回顾会:返回目标状态、KPI值、改善行动
|
"""战略回顾会:返回目标状态、KPI值、改善行动
|
||||||
|
|
||||||
level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI;
|
level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI;
|
||||||
不传则返回全部KPI(向后兼容)。
|
不传则返回全部KPI(向后兼容)。
|
||||||
"""
|
"""
|
||||||
from app.models import KPIDefinition, KPIValue, ActionPlan
|
from app.models import KPIDefinition, KPIValue, ActionPlan
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id, StrategicMap.entity_id == entity_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
from app.models import MapObjective, StrategicMap, KPIDefinition
|
from app.models import MapObjective, StrategicMap, KPIDefinition
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"],
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{map_id}/objectives")
|
@router.get("/{map_id}/objectives")
|
||||||
def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
def list_objectives(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""获取某地图下的所有目标"""
|
"""获取某地图下的所有目标"""
|
||||||
objs = db.query(MapObjective).filter(
|
objs = db.query(MapObjective).filter(
|
||||||
MapObjective.map_id == map_id
|
MapObjective.map_id == map_id
|
||||||
@@ -20,9 +21,9 @@ def list_objectives(map_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{map_id}/objectives")
|
@router.post("/{map_id}/objectives")
|
||||||
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
def create_objective(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""新增目标"""
|
"""新增目标"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id), StrategicMap.entity_id == entity_id.first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
obj = MapObjective(
|
obj = MapObjective(
|
||||||
@@ -40,7 +41,7 @@ def create_objective(map_id: int, data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{map_id}/objectives/{obj_id}")
|
@router.put("/{map_id}/objectives/{obj_id}")
|
||||||
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db)):
|
def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""修改目标"""
|
"""修改目标"""
|
||||||
obj = db.query(MapObjective).filter(
|
obj = db.query(MapObjective).filter(
|
||||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||||
@@ -55,7 +56,7 @@ def update_objective(map_id: int, obj_id: int, data: dict, db: Session = Depends
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{map_id}/objectives/{obj_id}")
|
@router.delete("/{map_id}/objectives/{obj_id}")
|
||||||
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""删除目标"""
|
"""删除目标"""
|
||||||
obj = db.query(MapObjective).filter(
|
obj = db.query(MapObjective).filter(
|
||||||
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
MapObjective.id == obj_id, MapObjective.map_id == map_id
|
||||||
@@ -68,7 +69,7 @@ def delete_objective(map_id: int, obj_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{map_id}/objectives/sort")
|
@router.put("/{map_id}/objectives/sort")
|
||||||
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db)):
|
def sort_objectives(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""批量排序: {"ids": [3, 1, 2]}"""
|
"""批量排序: {"ids": [3, 1, 2]}"""
|
||||||
ids = data.get("ids", [])
|
ids = data.get("ids", [])
|
||||||
for idx, obj_id in enumerate(ids):
|
for idx, obj_id in enumerate(ids):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
from app.models import StrategicMap, StrategicMapVersion
|
from app.models import StrategicMap, StrategicMapVersion
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"],
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{map_id}/versions")
|
@router.get("/{map_id}/versions")
|
||||||
def list_versions(map_id: int, db: Session = Depends(get_db)):
|
def list_versions(map_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""查看版本历史"""
|
"""查看版本历史"""
|
||||||
versions = db.query(StrategicMapVersion).filter(
|
versions = db.query(StrategicMapVersion).filter(
|
||||||
StrategicMapVersion.map_id == map_id
|
StrategicMapVersion.map_id == map_id
|
||||||
@@ -20,9 +21,9 @@ def list_versions(map_id: int, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{map_id}/versions/snapshot")
|
@router.post("/{map_id}/versions/snapshot")
|
||||||
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""手动创建快照"""
|
"""手动创建快照"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id), StrategicMap.entity_id == entity_id.first()
|
||||||
if not m:
|
if not m:
|
||||||
raise HTTPException(404, "战略地图不存在")
|
raise HTTPException(404, "战略地图不存在")
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ def create_snapshot(map_id: int, data: dict, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
@router.post("/{map_id}/versions/{ver_id}/rollback")
|
||||||
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db)):
|
def rollback_version(map_id: int, ver_id: int, db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)):
|
||||||
"""回滚到指定版本"""
|
"""回滚到指定版本"""
|
||||||
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
m = db.query(StrategicMap).filter(StrategicMap.id == map_id).first()
|
||||||
if not m:
|
if not m:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class StrategicMap(Base):
|
|||||||
"""战略地图"""
|
"""战略地图"""
|
||||||
__tablename__ = "strategic_maps"
|
__tablename__ = "strategic_maps"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
|
||||||
title = Column(String(200), nullable=False, comment="地图名称")
|
title = Column(String(200), nullable=False, comment="地图名称")
|
||||||
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")
|
||||||
@@ -271,6 +272,7 @@ class MapObjective(Base):
|
|||||||
"""战略地图目标: 每个维度下的具体目标"""
|
"""战略地图目标: 每个维度下的具体目标"""
|
||||||
__tablename__ = "map_objectives"
|
__tablename__ = "map_objectives"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, comment="企业ID (多租户隔离 2026-08-25)")
|
||||||
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
map_id = Column(Integer, ForeignKey("strategic_maps.id", ondelete="CASCADE"), nullable=False, comment="关联地图")
|
||||||
dimension_key = Column(String(50), nullable=False, comment="所属维度: finance/customer/process/learning")
|
dimension_key = Column(String(50), nullable=False, comment="所属维度: finance/customer/process/learning")
|
||||||
name = Column(String(200), nullable=False, comment="目标名称")
|
name = Column(String(200), nullable=False, comment="目标名称")
|
||||||
|
|||||||
Reference in New Issue
Block a user