diff --git a/backend/app/api/maps.py b/backend/app/api/maps.py index e9e6703a..9b17a302 100644 --- a/backend/app/api/maps.py +++ b/backend/app/api/maps.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from typing import Optional from app.database import get_db +from app.deps import get_entity_id from app.auth_middleware import require_auth, require_role from app.models import StrategicMap, OperationLog, MapObjective import json @@ -60,12 +61,13 @@ STRATEGIC_MAP_TEMPLATE = [ # ── CRUD ──────────────────────────────────── @router.get("") -def list_maps(db: Session = Depends(get_db)): - maps = db.query(StrategicMap).order_by(StrategicMap.updated_at.desc()).all() +def list_maps(db: Session = Depends(get_db), entity_id: int = Depends(get_entity_id)): + # 账套隔离: 仅当前企业地图 (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]} @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导致加载报错) if "dimensions" not in data or data.get("dimensions") is None: 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: data = {**data, "canvas_data": {"connections": []}} + data["entity_id"] = entity_id # 账套隔离: 强制token企业 m = StrategicMap(**data) db.add(m) db.commit() @@ -85,12 +88,13 @@ def create_map(data: dict, db: Session = Depends(get_db)): @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( title=data.get("title", "新建战略地图"), version=data.get("version", "v1.0"), status="draft", + entity_id=entity_id, dimensions=STRATEGIC_MAP_TEMPLATE, canvas_data={"connections": []}, ) @@ -102,10 +106,13 @@ def create_map_with_template(data: dict, db: Session = Depends(get_db)): @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() if not m: raise HTTPException(404, "战略地图不存在") + # 账套隔离: 禁止跨企业修改 + if m.entity_id != entity_id: + raise HTTPException(404, "战略地图不存在") old_status = m.status 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}") -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() if not m: raise HTTPException(404, "战略地图不存在") + if m.entity_id != entity_id: + raise HTTPException(404, "战略地图不存在") db.delete(m) db.commit() return {"message": "已删除"} @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", []) if not ids: raise HTTPException(400, "请选择要删除的地图") deleted = 0 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: db.delete(m) deleted += 1 @@ -169,9 +178,9 @@ def _get_connections(m: StrategicMap) -> list: @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"}""" - 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: 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") -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 删除连线""" - 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: raise HTTPException(404, "战略地图不存在") @@ -298,6 +307,7 @@ def _sync_map_objectives(m, db): for i, obj in enumerate(dim.get("objectives", [])): mo = MapObjective( map_id=m.id, + entity_id=getattr(m, "entity_id", 1) or 1, # 账套隔离: 继承地图企业 dimension_key=dim.get("key", ""), name=obj.get("name", ""), description=obj.get("description", ""), @@ -345,14 +355,14 @@ def _merge_map_objectives(m, db): @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值、改善行动 level: 可选 strategic/operational — 战略回顾(默认strategic)只显示战略级KPI; 不传则返回全部KPI(向后兼容)。 """ 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: raise HTTPException(404, "战略地图不存在") diff --git a/backend/app/api/objectives.py b/backend/app/api/objectives.py index a6000c5b..8f30aeb5 100644 --- a/backend/app/api/objectives.py +++ b/backend/app/api/objectives.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db +from app.deps import get_entity_id from app.auth_middleware import require_role from app.models import MapObjective, StrategicMap, KPIDefinition @@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图目标"], @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( 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") -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: raise HTTPException(404, "战略地图不存在") 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}") -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( 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}") -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( 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") -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 = data.get("ids", []) for idx, obj_id in enumerate(ids): diff --git a/backend/app/api/versions.py b/backend/app/api/versions.py index a41524a7..5e682b64 100644 --- a/backend/app/api/versions.py +++ b/backend/app/api/versions.py @@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db +from app.deps import get_entity_id from app.auth_middleware import require_role from app.models import StrategicMap, StrategicMapVersion @@ -11,7 +12,7 @@ router = APIRouter(prefix="/api/cma/maps", tags=["战略地图版本"], @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( 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") -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: 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") -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() if not m: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e8c69e41..6ba918cd 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -48,6 +48,7 @@ class StrategicMap(Base): """战略地图""" __tablename__ = "strategic_maps" 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="地图名称") version = Column(String(20), default="v1.0", comment="版本号") status = Column(String(20), default="draft", comment="draft/published") @@ -271,6 +272,7 @@ class MapObjective(Base): """战略地图目标: 每个维度下的具体目标""" __tablename__ = "map_objectives" 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="关联地图") dimension_key = Column(String(50), nullable=False, comment="所属维度: finance/customer/process/learning") name = Column(String(200), nullable=False, comment="目标名称")