feat(knowledge): 企业记忆分区——知识三表entity_id多租户隔离 (entity-memory-partition P1)
- knowledge_events/knowledge_summaries: entity_id NOT NULL DEFAULT 1 + 索引(企业记忆抽屉) - knowledge_articles: entity_id 可空——NULL=全局共享(CMA教学知识), 有值=企业专属 - knowledge_service 全链路 entity_id: extract_events(KPIAlert直过滤+OperationLog经user_entities归属)/_kpi_snapshot(修跨企业KPI泄漏)/generate_summary/get_last_summary/run_*/sync - api/knowledge.py 8端点 Depends(get_entity_id) 强制过滤;详情越权404 - api/knowledge_articles.py 全局∪本企业查询;企业专属越权404 - 生产DB三表ALTER+索引已完成; 存量38篇教学文章保持NULL全局共享 - 测试 test_security_knowledge_partition.py 8条(双实体互查越权/生成自动带entity/全局文章双可见) - 方案文档 docs/knowledge-entity-partition-plan-20260903.md(附评审决策点)
This commit is contained in:
@@ -15,6 +15,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.models import KnowledgeEvent, KnowledgeSummary
|
from app.models import KnowledgeEvent, KnowledgeSummary
|
||||||
from app.services.knowledge_service import (
|
from app.services.knowledge_service import (
|
||||||
generate_summary_sync,
|
generate_summary_sync,
|
||||||
@@ -59,18 +60,22 @@ def list_summaries(
|
|||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""获取摘要列表,按层级筛选,按时间倒序"""
|
"""获取摘要列表,按层级筛选,按时间倒序(按企业抽屉隔离)"""
|
||||||
query = db.query(KnowledgeSummary)
|
query = db.query(KnowledgeSummary)
|
||||||
|
query = query.filter(KnowledgeSummary.entity_id == entity_id)
|
||||||
if level:
|
if level:
|
||||||
query = query.filter(KnowledgeSummary.level == level)
|
query = query.filter(KnowledgeSummary.level == level)
|
||||||
query = query.order_by(desc(KnowledgeSummary.id)).offset(offset).limit(limit)
|
query = query.order_by(desc(KnowledgeSummary.id)).offset(offset).limit(limit)
|
||||||
total = db.query(func.count(KnowledgeSummary.id)).select_from(KnowledgeSummary)
|
total = db.query(func.count(KnowledgeSummary.id)).select_from(KnowledgeSummary)
|
||||||
|
total = total.filter(KnowledgeSummary.entity_id == entity_id)
|
||||||
if level:
|
if level:
|
||||||
total = total.filter(KnowledgeSummary.level == level)
|
total = total.filter(KnowledgeSummary.level == level)
|
||||||
total = total.scalar()
|
total = total.scalar()
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
|
"entity_id": entity_id,
|
||||||
"items": [summary_to_dict(s) for s in query.all()],
|
"items": [summary_to_dict(s) for s in query.all()],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,18 +84,30 @@ def list_summaries(
|
|||||||
def latest_summary(
|
def latest_summary(
|
||||||
level: str = Query("daily", description="层级: daily/weekly/monthly/cumulative"),
|
level: str = Query("daily", description="层级: daily/weekly/monthly/cumulative"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""获取指定层级的最新摘要"""
|
"""获取指定层级的最新摘要(按企业隔离)"""
|
||||||
s = get_last_summary(db, level)
|
s = get_last_summary(db, level, entity_id)
|
||||||
if not s:
|
if not s:
|
||||||
return {"detail": f"没有{level}层级的摘要"}, 404
|
return {"detail": f"没有{level}层级的摘要"}, 404
|
||||||
return summary_to_dict(s)
|
return summary_to_dict(s)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summaries/{summary_id}")
|
@router.get("/summaries/{summary_id}")
|
||||||
def get_summary(summary_id: int, db: Session = Depends(get_db)):
|
def get_summary(
|
||||||
"""获取单条摘要详情"""
|
summary_id: int,
|
||||||
s = db.query(KnowledgeSummary).filter(KnowledgeSummary.id == summary_id).first()
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""获取单条摘要详情(仅本企业,防跨企业探测)"""
|
||||||
|
s = (
|
||||||
|
db.query(KnowledgeSummary)
|
||||||
|
.filter(
|
||||||
|
KnowledgeSummary.id == summary_id,
|
||||||
|
KnowledgeSummary.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not s:
|
if not s:
|
||||||
raise HTTPException(status_code=404, detail="摘要不存在")
|
raise HTTPException(status_code=404, detail="摘要不存在")
|
||||||
return summary_to_dict(s)
|
return summary_to_dict(s)
|
||||||
@@ -105,8 +122,9 @@ def list_events(
|
|||||||
until: Optional[str] = None,
|
until: Optional[str] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""查询未摘要的原始事件
|
"""查询未摘要的原始事件(按企业隔离)
|
||||||
|
|
||||||
如果不传时间,默认返回最近7天的操作记录和预警。
|
如果不传时间,默认返回最近7天的操作记录和预警。
|
||||||
"""
|
"""
|
||||||
@@ -116,18 +134,21 @@ def list_events(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="时间格式错误,请使用 ISO 格式如 2026-06-01T00:00:00")
|
raise HTTPException(status_code=400, detail="时间格式错误,请使用 ISO 格式如 2026-06-01T00:00:00")
|
||||||
|
|
||||||
events = extract_events(db, dt_since, dt_until)
|
events = extract_events(db, dt_since, dt_until, entity_id=entity_id)
|
||||||
return {"since": dt_since.isoformat(), "until": dt_until.isoformat(), "total": len(events), "events": events[:limit]}
|
return {"since": dt_since.isoformat(), "until": dt_until.isoformat(), "entity_id": entity_id, "total": len(events), "events": events[:limit]}
|
||||||
|
|
||||||
|
|
||||||
# ── 手动触发 ──
|
# ── 手动触发 ──
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate/daily")
|
@router.post("/generate/daily")
|
||||||
def trigger_daily_summary(db: Session = Depends(get_db)):
|
def trigger_daily_summary(
|
||||||
"""手动触发每日摘要生成"""
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""手动触发每日摘要生成(写入当前企业抽屉)"""
|
||||||
try:
|
try:
|
||||||
result = generate_daily_sync(db)
|
result = generate_daily_sync(db, entity_id=entity_id)
|
||||||
return {"message": "每日摘要已生成", "summary": result}
|
return {"message": "每日摘要已生成", "summary": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("每日摘要生成失败")
|
logger.exception("每日摘要生成失败")
|
||||||
@@ -135,10 +156,13 @@ def trigger_daily_summary(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/generate/weekly")
|
@router.post("/generate/weekly")
|
||||||
def trigger_weekly_summary(db: Session = Depends(get_db)):
|
def trigger_weekly_summary(
|
||||||
"""手动触发周度摘要生成"""
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""手动触发周度摘要生成(写入当前企业抽屉)"""
|
||||||
try:
|
try:
|
||||||
result = generate_weekly_sync(db)
|
result = generate_weekly_sync(db, entity_id=entity_id)
|
||||||
return {"message": "周度摘要已生成", "summary": result}
|
return {"message": "周度摘要已生成", "summary": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("周度摘要生成失败")
|
logger.exception("周度摘要生成失败")
|
||||||
@@ -146,10 +170,13 @@ def trigger_weekly_summary(db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/generate/monthly")
|
@router.post("/generate/monthly")
|
||||||
def trigger_monthly_summary(db: Session = Depends(get_db)):
|
def trigger_monthly_summary(
|
||||||
"""手动触发月度摘要生成"""
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""手动触发月度摘要生成(写入当前企业抽屉)"""
|
||||||
try:
|
try:
|
||||||
result = generate_monthly_sync(db)
|
result = generate_monthly_sync(db, entity_id=entity_id)
|
||||||
return {"message": "月度摘要已生成", "summary": result}
|
return {"message": "月度摘要已生成", "summary": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("月度摘要生成失败")
|
logger.exception("月度摘要生成失败")
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import or_
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.auth_middleware import require_role
|
from app.auth_middleware import require_role
|
||||||
|
from app.deps import get_entity_id
|
||||||
from app.models.knowledge_article import KnowledgeArticle
|
from app.models.knowledge_article import KnowledgeArticle
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
|
router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"],
|
||||||
@@ -17,6 +19,7 @@ router = APIRouter(prefix="/api/cma/knowledge-articles", tags=["知识库嵌入"
|
|||||||
def article_to_dict(a: KnowledgeArticle) -> dict:
|
def article_to_dict(a: KnowledgeArticle) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": a.id,
|
"id": a.id,
|
||||||
|
"entity_id": a.entity_id,
|
||||||
"title": a.title,
|
"title": a.title,
|
||||||
"summary": a.summary,
|
"summary": a.summary,
|
||||||
"content": a.content,
|
"content": a.content,
|
||||||
@@ -32,9 +35,21 @@ def list_articles(
|
|||||||
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
|
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
):
|
):
|
||||||
"""查询知识文章,可按关联页面或分类筛选"""
|
"""查询知识文章(本企业专属 ∪ 全局共享),可按关联页面或分类筛选
|
||||||
q = db.query(KnowledgeArticle)
|
|
||||||
|
隔离语义(2026-09-03 多租户分区):
|
||||||
|
- entity_id IS NULL → 全局共享(CMA 系统教学知识,所有企业可见)
|
||||||
|
- entity_id == 当前账套 → 企业专属(只本企业可见)
|
||||||
|
- 其他企业的专属文章不可见
|
||||||
|
"""
|
||||||
|
q = db.query(KnowledgeArticle).filter(
|
||||||
|
or_(
|
||||||
|
KnowledgeArticle.entity_id == entity_id,
|
||||||
|
KnowledgeArticle.entity_id.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
if related_page:
|
if related_page:
|
||||||
q = q.filter(KnowledgeArticle.related_page.contains(related_page))
|
q = q.filter(KnowledgeArticle.related_page.contains(related_page))
|
||||||
if category:
|
if category:
|
||||||
@@ -44,8 +59,19 @@ def list_articles(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{article_id}")
|
@router.get("/{article_id}")
|
||||||
def get_article(article_id: int, db: Session = Depends(get_db)):
|
def get_article(
|
||||||
a = db.query(KnowledgeArticle).filter(KnowledgeArticle.id == article_id).first()
|
article_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
entity_id: int = Depends(get_entity_id),
|
||||||
|
):
|
||||||
|
"""获取文章详情(企业专属文章仅本企业可见,防跨企业探测)"""
|
||||||
|
a = (
|
||||||
|
db.query(KnowledgeArticle)
|
||||||
|
.filter(KnowledgeArticle.id == article_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if not a:
|
if not a:
|
||||||
raise HTTPException(404, "文章不存在")
|
raise HTTPException(404, "文章不存在")
|
||||||
|
if a.entity_id is not None and a.entity_id != entity_id:
|
||||||
|
raise HTTPException(404, "文章不存在")
|
||||||
return article_to_dict(a)
|
return article_to_dict(a)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class KnowledgeEvent(Base):
|
|||||||
__tablename__ = "knowledge_events"
|
__tablename__ = "knowledge_events"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, index=True, comment="企业ID (多租户隔离 2026-09-03)")
|
||||||
event_type = Column(String(30), nullable=False, comment="事件类型: kpi_change/alert/decision/plan/map/import/user_action")
|
event_type = Column(String(30), nullable=False, comment="事件类型: kpi_change/alert/decision/plan/map/import/user_action")
|
||||||
event_level = Column(String(20), default="info", comment="info/warning/important/critical")
|
event_level = Column(String(20), default="info", comment="info/warning/important/critical")
|
||||||
source = Column(String(50), nullable=True, comment="来源: operation_log/api/erp_sync/manual")
|
source = Column(String(50), nullable=True, comment="来源: operation_log/api/erp_sync/manual")
|
||||||
@@ -47,6 +48,7 @@ class KnowledgeSummary(Base):
|
|||||||
__tablename__ = "knowledge_summaries"
|
__tablename__ = "knowledge_summaries"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, default=1, index=True, comment="企业ID (多租户隔离 2026-09-03)")
|
||||||
level = Column(String(20), nullable=False, comment="摘要层级: daily/weekly/monthly/cumulative")
|
level = Column(String(20), nullable=False, comment="摘要层级: daily/weekly/monthly/cumulative")
|
||||||
period_key = Column(String(20), nullable=False, comment="期间标识: 2026-06-12 / 2026-W24 / 2026-06 / cumulative")
|
period_key = Column(String(20), nullable=False, comment="期间标识: 2026-06-12 / 2026-W24 / 2026-06 / cumulative")
|
||||||
title = Column(String(300), nullable=False, comment="摘要标题")
|
title = Column(String(300), nullable=False, comment="摘要标题")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class KnowledgeArticle(Base):
|
|||||||
__tablename__ = "knowledge_articles"
|
__tablename__ = "knowledge_articles"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
entity_id = Column(Integer, nullable=True, index=True, comment="企业ID: NULL=全局共享(系统教学), 有值=企业专属 (多租户隔离 2026-09-03)")
|
||||||
title = Column(String(200), nullable=False, comment="文章标题")
|
title = Column(String(200), nullable=False, comment="文章标题")
|
||||||
summary = Column(String(500), nullable=True, comment="一句话摘要")
|
summary = Column(String(500), nullable=True, comment="一句话摘要")
|
||||||
content = Column(Text, nullable=False, comment="文章正文(支持Markdown)")
|
content = Column(Text, nullable=False, comment="文章正文(支持Markdown)")
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from typing import Optional
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, and_
|
from sqlalchemy import func, and_
|
||||||
|
|
||||||
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue
|
from app.models import KnowledgeEvent, KnowledgeSummary, OperationLog, KPIAlert, KPIDefinition, KPIValue, UserEntity
|
||||||
|
|
||||||
logger = logging.getLogger("cma.knowledge")
|
logger = logging.getLogger("cma.knowledge")
|
||||||
|
|
||||||
@@ -66,16 +66,31 @@ async def _call_deepseek(prompt: str, timeout: int = 30) -> str:
|
|||||||
|
|
||||||
# ── 事件抽取 ──
|
# ── 事件抽取 ──
|
||||||
|
|
||||||
def extract_events(db: Session, since: datetime, until: Optional[datetime] = None) -> list[dict]:
|
def extract_events(db: Session, since: datetime, until: Optional[datetime] = None, entity_id: int = 1) -> list[dict]:
|
||||||
"""从 OperationLog + KPIAlert 中抽取关键事件
|
"""从 OperationLog + KPIAlert 中抽取关键事件(按企业抽屉隔离)
|
||||||
返回 dict 列表,用于喂给摘要 prompt
|
返回 dict 列表,用于喂给摘要 prompt
|
||||||
|
|
||||||
|
隔离说明(2026-09-03 多租户分区):
|
||||||
|
- KPIAlert 带 entity_id 列 → 直接按 entity_id 过滤
|
||||||
|
- OperationLog 无 entity_id 列(数据债,P2 follow-up)→ 经 user_id→user_entities
|
||||||
|
归属当前企业;无授权归属的日志(user_id NULL/未授权)不进入本企业记忆
|
||||||
"""
|
"""
|
||||||
now = until or datetime.utcnow()
|
now = until or datetime.utcnow()
|
||||||
|
|
||||||
# 1. 操作日志 → 事件
|
# 0. 当前企业授权用户集合(OperationLog 归属过滤用)
|
||||||
|
auth_user_ids = [
|
||||||
|
uid for (uid,) in db.query(UserEntity.user_id)
|
||||||
|
.filter(UserEntity.entity_id == entity_id).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# 1. 操作日志 → 事件(本企业授权用户的操作)
|
||||||
logs = (
|
logs = (
|
||||||
db.query(OperationLog)
|
db.query(OperationLog)
|
||||||
.filter(OperationLog.created_at >= since, OperationLog.created_at <= now)
|
.filter(
|
||||||
|
OperationLog.created_at >= since,
|
||||||
|
OperationLog.created_at <= now,
|
||||||
|
OperationLog.user_id.in_(auth_user_ids), # 空集合 → IN (NULL) 不匹配任何行
|
||||||
|
)
|
||||||
.order_by(OperationLog.created_at.asc())
|
.order_by(OperationLog.created_at.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -96,11 +111,15 @@ def extract_events(db: Session, since: datetime, until: Optional[datetime] = Non
|
|||||||
"detail": detail_str,
|
"detail": detail_str,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. 预警记录 → 事件
|
# 2. 预警记录 → 事件(KPIAlert 直接按企业过滤)
|
||||||
alerts = (
|
alerts = (
|
||||||
db.query(KPIAlert, KPIDefinition.kpi_name, KPIDefinition.kpi_code)
|
db.query(KPIAlert, KPIDefinition.kpi_name, KPIDefinition.kpi_code)
|
||||||
.join(KPIDefinition, KPIAlert.kpi_id == KPIDefinition.id)
|
.join(KPIDefinition, KPIAlert.kpi_id == KPIDefinition.id)
|
||||||
.filter(KPIAlert.created_at >= since, KPIAlert.created_at <= now)
|
.filter(
|
||||||
|
KPIAlert.created_at >= since,
|
||||||
|
KPIAlert.created_at <= now,
|
||||||
|
KPIAlert.entity_id == entity_id,
|
||||||
|
)
|
||||||
.order_by(KPIAlert.created_at.asc())
|
.order_by(KPIAlert.created_at.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -136,9 +155,12 @@ def _build_summary_prompt(events: list[dict], level: str, period_key: str) -> st
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def _kpi_snapshot(db: Session) -> list[dict]:
|
async def _kpi_snapshot(db: Session, entity_id: int = 1) -> list[dict]:
|
||||||
"""当前KPI快照 — 每个活跃KPI的最新实际值"""
|
"""当前KPI快照 — 本企业每个活跃KPI的最新实际值(按账套隔离)"""
|
||||||
kpis = db.query(KPIDefinition).filter(KPIDefinition.status == "active").all()
|
kpis = db.query(KPIDefinition).filter(
|
||||||
|
KPIDefinition.status == "active",
|
||||||
|
KPIDefinition.entity_id == entity_id,
|
||||||
|
).all()
|
||||||
snapshot = []
|
snapshot = []
|
||||||
for k in kpis:
|
for k in kpis:
|
||||||
latest = (
|
latest = (
|
||||||
@@ -165,6 +187,7 @@ async def generate_summary(
|
|||||||
since: datetime,
|
since: datetime,
|
||||||
until: Optional[datetime] = None,
|
until: Optional[datetime] = None,
|
||||||
prev_summary: Optional[KnowledgeSummary] = None,
|
prev_summary: Optional[KnowledgeSummary] = None,
|
||||||
|
entity_id: int = 1,
|
||||||
) -> KnowledgeSummary:
|
) -> KnowledgeSummary:
|
||||||
"""生成一层摘要(daily/weekly/monthly/cumulative)
|
"""生成一层摘要(daily/weekly/monthly/cumulative)
|
||||||
|
|
||||||
@@ -174,11 +197,12 @@ async def generate_summary(
|
|||||||
since: 事件开始时间
|
since: 事件开始时间
|
||||||
until: 事件结束时间
|
until: 事件结束时间
|
||||||
prev_summary: 前一层摘要(用于累积摘要继承)
|
prev_summary: 前一层摘要(用于累积摘要继承)
|
||||||
|
entity_id: 企业账套(企业记忆分区 2026-09-03)
|
||||||
"""
|
"""
|
||||||
now = until or datetime.utcnow()
|
now = until or datetime.utcnow()
|
||||||
|
|
||||||
# 1. 抽取事件
|
# 1. 抽取事件(本企业)
|
||||||
events = extract_events(db, since, now)
|
events = extract_events(db, since, now, entity_id=entity_id)
|
||||||
|
|
||||||
# 2. 构建 prompt
|
# 2. 构建 prompt
|
||||||
prompt = _build_summary_prompt(events, level, period_key)
|
prompt = _build_summary_prompt(events, level, period_key)
|
||||||
@@ -194,11 +218,12 @@ async def generate_summary(
|
|||||||
if not result_text or "无重要变化" in result_text:
|
if not result_text or "无重要变化" in result_text:
|
||||||
result_text = f"{level}汇总: 窗口 {period_key} 内共 {len(events)} 条事件记录,无重大变化需记录。"
|
result_text = f"{level}汇总: 窗口 {period_key} 内共 {len(events)} 条事件记录,无重大变化需记录。"
|
||||||
|
|
||||||
# 6. 提取 KPI 快照
|
# 6. 提取 KPI 快照(本企业)
|
||||||
kpi_snapshot = await _kpi_snapshot(db)
|
kpi_snapshot = await _kpi_snapshot(db, entity_id=entity_id)
|
||||||
|
|
||||||
# 7. 存入数据库
|
# 7. 存入数据库
|
||||||
summary = KnowledgeSummary(
|
summary = KnowledgeSummary(
|
||||||
|
entity_id=entity_id,
|
||||||
level=level,
|
level=level,
|
||||||
period_key=period_key,
|
period_key=period_key,
|
||||||
title=f"{level.upper()}摘要 - {period_key}",
|
title=f"{level.upper()}摘要 - {period_key}",
|
||||||
@@ -223,32 +248,36 @@ async def generate_summary(
|
|||||||
|
|
||||||
# ── 分层调度 ──
|
# ── 分层调度 ──
|
||||||
|
|
||||||
def get_last_summary(db: Session, level: str) -> Optional[KnowledgeSummary]:
|
def get_last_summary(db: Session, level: str, entity_id: int = 1) -> Optional[KnowledgeSummary]:
|
||||||
"""获取该层最新(最新创建)的摘要"""
|
"""获取该层最新(最新创建)的摘要(按企业隔离)"""
|
||||||
return (
|
return (
|
||||||
db.query(KnowledgeSummary)
|
db.query(KnowledgeSummary)
|
||||||
.filter(KnowledgeSummary.level == level, KnowledgeSummary.is_stale == 0)
|
.filter(
|
||||||
|
KnowledgeSummary.level == level,
|
||||||
|
KnowledgeSummary.is_stale == 0,
|
||||||
|
KnowledgeSummary.entity_id == entity_id,
|
||||||
|
)
|
||||||
.order_by(KnowledgeSummary.id.desc())
|
.order_by(KnowledgeSummary.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_daily_summary(db: Session) -> KnowledgeSummary:
|
async def run_daily_summary(db: Session, entity_id: int = 1) -> KnowledgeSummary:
|
||||||
"""运行每日摘要"""
|
"""运行每日摘要(企业记忆分区)"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
period_key = today.isoformat()
|
period_key = today.isoformat()
|
||||||
since = datetime(today.year, today.month, today.day)
|
since = datetime(today.year, today.month, today.day)
|
||||||
|
|
||||||
# 获取前一天摘要作为 prev
|
# 获取前一天摘要作为 prev
|
||||||
yesterday = today - timedelta(days=1)
|
yesterday = today - timedelta(days=1)
|
||||||
prev = get_last_summary(db, "daily")
|
prev = get_last_summary(db, "daily", entity_id=entity_id)
|
||||||
|
|
||||||
return await generate_summary(
|
return await generate_summary(
|
||||||
db, "daily", period_key, since, prev_summary=prev,
|
db, "daily", period_key, since, prev_summary=prev, entity_id=entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_weekly_summary(db: Session) -> KnowledgeSummary:
|
async def run_weekly_summary(db: Session, entity_id: int = 1) -> KnowledgeSummary:
|
||||||
"""运行每周摘要(周日执行)"""
|
"""运行每周摘要(周日执行)"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
# ISO 周算法: 本周一到今天
|
# ISO 周算法: 本周一到今天
|
||||||
@@ -257,13 +286,13 @@ async def run_weekly_summary(db: Session) -> KnowledgeSummary:
|
|||||||
since = today - timedelta(days=today.weekday()) # 本周一
|
since = today - timedelta(days=today.weekday()) # 本周一
|
||||||
since_dt = datetime(since.year, since.month, since.day)
|
since_dt = datetime(since.year, since.month, since.day)
|
||||||
|
|
||||||
prev = get_last_summary(db, "weekly")
|
prev = get_last_summary(db, "weekly", entity_id=entity_id)
|
||||||
return await generate_summary(
|
return await generate_summary(
|
||||||
db, "weekly", period_key, since_dt, prev_summary=prev,
|
db, "weekly", period_key, since_dt, prev_summary=prev, entity_id=entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_monthly_summary(db: Session) -> KnowledgeSummary:
|
async def run_monthly_summary(db: Session, entity_id: int = 1) -> KnowledgeSummary:
|
||||||
"""运行月度摘要"""
|
"""运行月度摘要"""
|
||||||
today = date.today()
|
today = date.today()
|
||||||
period_key = today.strftime("%Y-%m")
|
period_key = today.strftime("%Y-%m")
|
||||||
@@ -271,10 +300,10 @@ async def run_monthly_summary(db: Session) -> KnowledgeSummary:
|
|||||||
|
|
||||||
# 附属前一个月的 daily 和 weekly 摘要
|
# 附属前一个月的 daily 和 weekly 摘要
|
||||||
prev_month = today.replace(day=1) - timedelta(days=1)
|
prev_month = today.replace(day=1) - timedelta(days=1)
|
||||||
prev = get_last_summary(db, "monthly")
|
prev = get_last_summary(db, "monthly", entity_id=entity_id)
|
||||||
|
|
||||||
return await generate_summary(
|
return await generate_summary(
|
||||||
db, "monthly", period_key, since, prev_summary=prev,
|
db, "monthly", period_key, since, prev_summary=prev, entity_id=entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -289,40 +318,41 @@ def generate_summary_sync(
|
|||||||
period_key: str,
|
period_key: str,
|
||||||
since: datetime,
|
since: datetime,
|
||||||
until: Optional[datetime] = None,
|
until: Optional[datetime] = None,
|
||||||
|
entity_id: int = 1,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""同步包装,用于 API 手动触发"""
|
"""同步包装,用于 API 手动触发"""
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
summary = loop.run_until_complete(
|
summary = loop.run_until_complete(
|
||||||
generate_summary(db, level, period_key, since, until)
|
generate_summary(db, level, period_key, since, until, entity_id=entity_id)
|
||||||
)
|
)
|
||||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
def generate_daily_sync(db: Session) -> dict:
|
def generate_daily_sync(db: Session, entity_id: int = 1) -> dict:
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
summary = loop.run_until_complete(run_daily_summary(db))
|
summary = loop.run_until_complete(run_daily_summary(db, entity_id))
|
||||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
def generate_weekly_sync(db: Session) -> dict:
|
def generate_weekly_sync(db: Session, entity_id: int = 1) -> dict:
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
summary = loop.run_until_complete(run_weekly_summary(db))
|
summary = loop.run_until_complete(run_weekly_summary(db, entity_id))
|
||||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
def generate_monthly_sync(db: Session) -> dict:
|
def generate_monthly_sync(db: Session, entity_id: int = 1) -> dict:
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
summary = loop.run_until_complete(run_monthly_summary(db))
|
summary = loop.run_until_complete(run_monthly_summary(db, entity_id))
|
||||||
return {"id": summary.id, "level": summary.level, "period_key": summary.period_key}
|
return {"id": summary.id, "entity_id": summary.entity_id, "level": summary.level, "period_key": summary.period_key}
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""企业记忆分区(knowledge 侧 entity_id 多租户隔离)测试(2026-09-03 P1)
|
||||||
|
|
||||||
|
覆盖任务 DoD:
|
||||||
|
- knowledge_events / knowledge_summaries / knowledge_articles 三表 entity_id 隔离
|
||||||
|
- 摘要列表/详情强制 entity 过滤(entity1 查不到 entity2 → 404/空集)
|
||||||
|
- 摘要生成自动带 entity_id(写入当前账套抽屉)
|
||||||
|
- 知识文章:全局共享(NULL) ∪ 本企业专属;他企专属 404
|
||||||
|
- extract_events 按企业隔离(KPIAlert 直过滤 + OperationLog 经 user_entities 归属)
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models import KPIDefinition, KPIAlert, Entity
|
||||||
|
from app.models.knowledge import KnowledgeSummary, KnowledgeEvent
|
||||||
|
from app.models.knowledge_article import KnowledgeArticle
|
||||||
|
from app.services import knowledge_service
|
||||||
|
from tests.conftest import create_test_kpi, create_test_user, get_token_for_user, auth_header
|
||||||
|
|
||||||
|
SUMMARY_FIELDS = dict(
|
||||||
|
level="daily", period_key="2026-09-01", content="测试摘要内容",
|
||||||
|
generated_by="test", is_stale=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_entity(db: Session, eid: int, name: str = None) -> Entity:
|
||||||
|
ent = db.query(Entity).filter(Entity.id == eid).first()
|
||||||
|
if not ent:
|
||||||
|
ent = Entity(id=eid, name=name or f"企业{eid}", short_name=f"E{eid}", status="active")
|
||||||
|
db.add(ent)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ent)
|
||||||
|
return ent
|
||||||
|
|
||||||
|
|
||||||
|
def _login_token(client: TestClient, username: str = "testadmin",
|
||||||
|
password: str = "admin123", entity_id: int = 1) -> str:
|
||||||
|
"""登录指定账套返回 token(conftest.get_token_for_user 固定 entity1,这里参数化)"""
|
||||||
|
resp = client.post("/api/cma/auth/login", json={
|
||||||
|
"username": username, "password": password, "entity_id": entity_id,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.json().get("token")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# summaries 抽屉隔离
|
||||||
|
# ============================================================
|
||||||
|
class TestKnowledgeSummaryIsolation:
|
||||||
|
def test_list_summaries_entity_scoped(self, client: TestClient, db: Session):
|
||||||
|
"""摘要列表按 entity 隔离:entity1 看不到 entity2 的摘要"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
s1 = KnowledgeSummary(**SUMMARY_FIELDS, title="E1摘要", entity_id=1)
|
||||||
|
s2 = KnowledgeSummary(**SUMMARY_FIELDS, title="E2专属摘要", entity_id=2)
|
||||||
|
db.add_all([s1, s2])
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s1)
|
||||||
|
db.refresh(s2)
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = _login_token(client, entity_id=1)
|
||||||
|
r1 = client.get("/api/cma/knowledge/summaries", headers=auth_header(token1))
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
items = r1.json()["items"]
|
||||||
|
titles = [i["title"] for i in items]
|
||||||
|
assert any("摘要" in t for t in titles)
|
||||||
|
assert "E2专属摘要" not in titles
|
||||||
|
|
||||||
|
def test_get_summary_cross_entity_404(self, client: TestClient, db: Session):
|
||||||
|
"""越权:entity1 直取 entity2 摘要详情 → 404(防跨企业探测)"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
s2 = KnowledgeSummary(**SUMMARY_FIELDS, title="E2秘密摘要", entity_id=2)
|
||||||
|
db.add(s2)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s2)
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = _login_token(client, entity_id=1)
|
||||||
|
r = client.get(f"/api/cma/knowledge/summaries/{s2.id}", headers=auth_header(token1))
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
# 反向:entity2 取 entity1 摘要同样 404
|
||||||
|
s1 = KnowledgeSummary(**SUMMARY_FIELDS, title="E1摘要", entity_id=1)
|
||||||
|
db.add(s1)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s1)
|
||||||
|
token2 = _login_token(client, entity_id=2)
|
||||||
|
r2 = client.get(f"/api/cma/knowledge/summaries/{s1.id}", headers=auth_header(token2))
|
||||||
|
assert r2.status_code == 404
|
||||||
|
|
||||||
|
def test_get_summary_own_entity_ok(self, client: TestClient, db: Session):
|
||||||
|
"""同企业取自己摘要详情 → 200"""
|
||||||
|
s1 = KnowledgeSummary(**SUMMARY_FIELDS, title="E1自己的摘要", entity_id=1)
|
||||||
|
db.add(s1)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(s1)
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = _login_token(client, entity_id=1)
|
||||||
|
r = client.get(f"/api/cma/knowledge/summaries/{s1.id}", headers=auth_header(token1))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["title"] == "E1自己的摘要"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 摘要生成自动带 entity_id
|
||||||
|
# ============================================================
|
||||||
|
class TestKnowledgeGenerateWritesEntity:
|
||||||
|
def test_generate_daily_writes_current_entity(self, client: TestClient, db: Session, monkeypatch):
|
||||||
|
"""generate/daily 写入当前账套抽屉:entity1 触发 → 摘要 entity_id=1;entity2 触发 → =2"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
|
||||||
|
async def _fake_deepseek(prompt: str, timeout: int = 30) -> str:
|
||||||
|
return "测试摘要:本期无重大变化。"
|
||||||
|
monkeypatch.setattr(knowledge_service, "_call_deepseek", _fake_deepseek)
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = _login_token(client, entity_id=1)
|
||||||
|
r1 = client.post("/api/cma/knowledge/generate/daily", headers=auth_header(token1))
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
assert r1.json()["summary"]["entity_id"] == 1
|
||||||
|
|
||||||
|
token2 = _login_token(client, entity_id=2)
|
||||||
|
r2 = client.post("/api/cma/knowledge/generate/daily", headers=auth_header(token2))
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["summary"]["entity_id"] == 2
|
||||||
|
|
||||||
|
# DB 回查:两账套抽屉各一条自己的
|
||||||
|
e1 = db.query(KnowledgeSummary).filter(KnowledgeSummary.entity_id == 1).count()
|
||||||
|
e2 = db.query(KnowledgeSummary).filter(KnowledgeSummary.entity_id == 2).count()
|
||||||
|
assert e1 >= 1
|
||||||
|
assert e2 >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 知识文章:全局共享 ∪ 本企业专属
|
||||||
|
# ============================================================
|
||||||
|
class TestKnowledgeArticlesGlobalUnion:
|
||||||
|
def test_articles_global_union_scope(self, client: TestClient, db: Session):
|
||||||
|
"""文章查询 = 本企业专属 ∪ 全局共享;他企专属不可见"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
g = KnowledgeArticle(title="全局教学文章", content="c", category="term", entity_id=None)
|
||||||
|
e1 = KnowledgeArticle(title="E1专属文章", content="c", category="term", entity_id=1)
|
||||||
|
e2 = KnowledgeArticle(title="E2专属文章", content="c", category="term", entity_id=2)
|
||||||
|
db.add_all([g, e1, e2])
|
||||||
|
db.commit()
|
||||||
|
for a in (g, e1, e2):
|
||||||
|
db.refresh(a)
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = _login_token(client, entity_id=1)
|
||||||
|
r1 = client.get("/api/cma/knowledge-articles", headers=auth_header(token1))
|
||||||
|
assert r1.status_code == 200, r1.text
|
||||||
|
titles1 = {i["title"] for i in r1.json()["data"]}
|
||||||
|
assert "全局教学文章" in titles1 # 全局共享可见
|
||||||
|
assert "E1专属文章" in titles1 # 本企业专属可见
|
||||||
|
assert "E2专属文章" not in titles1 # 他企专属不可见
|
||||||
|
|
||||||
|
token2 = _login_token(client, entity_id=2)
|
||||||
|
r2 = client.get("/api/cma/knowledge-articles", headers=auth_header(token2))
|
||||||
|
titles2 = {i["title"] for i in r2.json()["data"]}
|
||||||
|
assert "全局教学文章" in titles2
|
||||||
|
assert "E2专属文章" in titles2
|
||||||
|
assert "E1专属文章" not in titles2
|
||||||
|
|
||||||
|
def test_get_article_cross_entity_404(self, client: TestClient, db: Session):
|
||||||
|
"""越权:entity1 直取 entity2 专属文章 → 404;全局文章两账套可取"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
g = KnowledgeArticle(title="全局文章", content="c", entity_id=None)
|
||||||
|
e2 = KnowledgeArticle(title="E2秘密文章", content="c", entity_id=2)
|
||||||
|
db.add_all([g, e2])
|
||||||
|
db.commit()
|
||||||
|
db.refresh(g)
|
||||||
|
db.refresh(e2)
|
||||||
|
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = _login_token(client, entity_id=1)
|
||||||
|
assert client.get(f"/api/cma/knowledge-articles/{g.id}", headers=auth_header(token1)).status_code == 200
|
||||||
|
assert client.get(f"/api/cma/knowledge-articles/{e2.id}", headers=auth_header(token1)).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# extract_events 企业隔离(service 层精确断言)
|
||||||
|
# ============================================================
|
||||||
|
class TestExtractEventsIsolation:
|
||||||
|
def _seed_alerts(self, db: Session):
|
||||||
|
k1 = create_test_kpi(db, kpi_code="KE_E1", entity_id=1, status="active")
|
||||||
|
k2 = create_test_kpi(db, kpi_code="KE_E2", entity_id=2, status="active")
|
||||||
|
db.add(KPIAlert(kpi_id=k1.id, alert_level="red", alert_message="E1企业预警信息",
|
||||||
|
status="pending", entity_id=1))
|
||||||
|
db.add(KPIAlert(kpi_id=k2.id, alert_level="red", alert_message="E2企业预警信息",
|
||||||
|
status="pending", entity_id=2))
|
||||||
|
db.commit()
|
||||||
|
return k1, k2
|
||||||
|
|
||||||
|
def test_extract_events_only_own_entity(self, client: TestClient, db: Session):
|
||||||
|
"""extract_events(entity_id=1) 只含 E1 的预警,不含 E2"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
self._seed_alerts(db)
|
||||||
|
since = datetime.utcnow() - timedelta(days=1)
|
||||||
|
ev1 = knowledge_service.extract_events(db, since, None, entity_id=1)
|
||||||
|
ev2 = knowledge_service.extract_events(db, since, None, entity_id=2)
|
||||||
|
txt1 = "\n".join(str(e) for e in ev1)
|
||||||
|
txt2 = "\n".join(str(e) for e in ev2)
|
||||||
|
assert "E1企业预警信息" in txt1
|
||||||
|
assert "E2企业预警信息" not in txt1 # 核心越权断言:E1 抽不到 E2
|
||||||
|
assert "E2企业预警信息" in txt2
|
||||||
|
assert "E1企业预警信息" not in txt2
|
||||||
|
|
||||||
|
def test_api_events_entity_scoped(self, client: TestClient, db: Session):
|
||||||
|
"""API /knowledge/events 按 token 账套过滤事件"""
|
||||||
|
_ensure_entity(db, 2)
|
||||||
|
self._seed_alerts(db)
|
||||||
|
create_test_user(db)
|
||||||
|
token1 = _login_token(client, entity_id=1)
|
||||||
|
r = client.get("/api/cma/knowledge/events", headers=auth_header(token1))
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = "\n".join(str(e) for e in r.json()["events"])
|
||||||
|
assert "E1企业预警信息" in body
|
||||||
|
assert "E2企业预警信息" not in body
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# CMA 知识侧 entity_id 多租户分区——迁移方案(评审稿)
|
||||||
|
|
||||||
|
> 任务:CMA-entity-memory-partition-20260903(研学调度 P1)
|
||||||
|
> 提出者:yanxueBot → 执行人:wecom-fullstack
|
||||||
|
> 决策依据:/root/WB_WS/_research/architecture-shared-intelligence-isolated-data-20260903.md 第八节行动2(🔴 唯一隔离缺口:客户侧知识未按 entity_id 分区)
|
||||||
|
> 日期:2026-09-03 | 状态:评审稿(代码落地后由项目Bot独立复核)
|
||||||
|
|
||||||
|
## 一、调研实证(先查系统再决策)
|
||||||
|
|
||||||
|
### 1.1 生产库现状(2026-09-03 实测)
|
||||||
|
|
||||||
|
| 表 | 行数 | entity_id | 写入路径 | 语义 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| knowledge_events | **0** | ❌ 无 | 无任何写入路径(extract_events 只返回 dict 喂 prompt,不落库) | 企业事件记忆(设计态) |
|
||||||
|
| knowledge_summaries | **0** | ❌ 无 | 仅 API 手动触发 /knowledge/generate/*(knowledge_service.generate_summary) | 企业知识摘要(设计态) |
|
||||||
|
| knowledge_articles | **38** | ❌ 无 | seed_knowledge_articles.py / import_cma_knowledge.py(系统教学知识 seed) | CMA 教学知识库(term/formula/practice/方法论/案例) |
|
||||||
|
|
||||||
|
### 1.2 账套事实(纠正任务单假设)
|
||||||
|
|
||||||
|
- **entity 1 = 陕西酣客文化传媒(白酒经销, active)**
|
||||||
|
- **entity 2 = 陕西博海网络科技(IT服务, active)**
|
||||||
|
- entity 3-5 = 测试/demo 企业
|
||||||
|
|
||||||
|
任务单写"博海=entity 1 兜底?酣客=entity 2?"与生产**相反**。实际无需"归属确认"到具体企业——因为见 1.3。
|
||||||
|
|
||||||
|
### 1.3 存量归属结论(本次方案的关键决策点)
|
||||||
|
|
||||||
|
1. **knowledge_events / knowledge_summaries:零存量**(0 行)→ 无迁移负担。加列后新写入必须带 entity_id(从请求上下文解析)。
|
||||||
|
2. **knowledge_articles 38 行 = CMA 系统教学知识**(术语/公式/实务/方法论/CMA案例/税务合规等),**语义属于共享层**——对照架构决策文档:
|
||||||
|
> 共享层(越大越好):判断模型/方法论/科目映射模式库/合规规则
|
||||||
|
> 隔离层(必须严格):企业数据/账套/权限/审计/记忆
|
||||||
|
|
||||||
|
教学文章不是"企业 A 的记忆 vs 企业 B 的记忆",而是"CMA 方法论知识库"(所有企业共享,越大越好)。
|
||||||
|
|
||||||
|
**→ 归属映射 = NULL = 全局共享**(不归酣客也不归博海)。
|
||||||
|
若硬性归 entity=1,则 entity 2(博海)登录后 CMA 知识库页/嵌入知识全空——破坏既有产品体验,也违背"共享层不重复"原则。
|
||||||
|
**附带推论**:任务验收"存量数据归属映射已确认"的答案 = 存量文章保持全局共享(entity_id NULL),只有未来企业自定义文章才进入企业抽屉。若任总/研学希望个别文章改为企业专属,一条 `UPDATE knowledge_articles SET entity_id=N WHERE id=...` 即可,成本可忽略。
|
||||||
|
|
||||||
|
### 1.4 数据债边界(本次不扩,记录 follow-up)
|
||||||
|
|
||||||
|
- **operation_logs 无 entity_id 列**(模型+库均无;写入点分布在 kpis/data/ai_suggestions/budget_generate/alert_rules/auth/templates 等 7+ 文件)。任务验收"审计:OperationLog 记录知识操作的企业归属"**无法在当前表结构下实现**——为 operation_logs 加 entity_id 属独立数据债(影响全部 7+ 写入点,需要全量排查 user→entity 归属,与 2026-08-31 安全修复同级工作量),本次不动,记为 P2 follow-up。
|
||||||
|
- 折衷:知识操作本身走 require_role + Depends(get_entity_id),操作发生在**已隔离的企业上下文内**;OperationLog.user_id → user_entities 可回溯归属。隔离缺口不因 operation_logs 无列而扩大(日志不是知识数据出口)。
|
||||||
|
|
||||||
|
## 二、设计定稿
|
||||||
|
|
||||||
|
### 2.1 三类语义 × 隔离策略
|
||||||
|
|
||||||
|
| 表 | entity_id 设计 | 查询语义 | 写入语义 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| knowledge_events | `INT NOT NULL DEFAULT 1` + 索引 | 强制 `entity_id == 当前账套` | 事件抽取/记录必须从上下文带 entity_id |
|
||||||
|
| knowledge_summaries | `INT NOT NULL DEFAULT 1` + 索引 | 强制 `entity_id == 当前账套` | generate 时从当前账套写入 |
|
||||||
|
| knowledge_articles | `INT NULL`(NULL=全局共享)+ 索引 | `entity_id == 当前账套 OR entity_id IS NULL`(全局∪专属) | seed/import 默认 NULL=全局;未来企业专属文章带 entity_id |
|
||||||
|
|
||||||
|
articles 的 OR NULL 是**有意为之**:教学知识留在共享层不被隔离吞掉,企业专属知识进抽屉严格隔离。越权核心验证对象是 events/summaries(企业记忆抽屉互不可见)+ articles 企业专属行互不可见。
|
||||||
|
|
||||||
|
### 2.2 归属与隔离验证(对应任务验收"越权测试")
|
||||||
|
|
||||||
|
- entity 1(酣客)上下文查不到 entity 2(博海)的 events/summaries → 404/空集
|
||||||
|
- entity 1 查不到 entity 2 的专属 article → 404(不暴露存在性)
|
||||||
|
- 全局 article(NULL)两账套均可见 → 教学知识不丢
|
||||||
|
- 双实体实测记录见验收文档
|
||||||
|
|
||||||
|
## 三、代码落地清单(评审通过后执行)
|
||||||
|
|
||||||
|
1. **models/knowledge.py**:KnowledgeEvent.entity_id / KnowledgeSummary.entity_id 加 `default=1, index=True`
|
||||||
|
2. **models/knowledge_article.py**:KnowledgeArticle.entity_id 加 `nullable=True, index=True`(NULL=全局共享)
|
||||||
|
3. **生产 DB ALTER**(MySQL,空表/小表零风险):
|
||||||
|
```sql
|
||||||
|
ALTER TABLE knowledge_events ADD COLUMN entity_id INT NOT NULL DEFAULT 1 COMMENT '企业ID (多租户隔离 2026-09-03)', ADD INDEX ix_knowledge_events_entity_id (entity_id);
|
||||||
|
ALTER TABLE knowledge_summaries ADD COLUMN entity_id INT NOT NULL DEFAULT 1 COMMENT '企业ID (多租户隔离 2026-09-03)', ADD INDEX ix_knowledge_summaries_entity_id (entity_id);
|
||||||
|
ALTER TABLE knowledge_articles ADD COLUMN entity_id INT NULL DEFAULT NULL COMMENT '企业ID: NULL=全局共享, 有值=企业专属 (多租户隔离 2026-09-03)', ADD INDEX ix_knowledge_articles_entity_id (entity_id);
|
||||||
|
```
|
||||||
|
4. **services/knowledge_service.py** 全链路:
|
||||||
|
- `extract_events(db, since, until, entity_id)`:KPIAlert 直接按 entity_id 过滤;OperationLog 无 entity_id 列 → 经 user_id→user_entities 子查询过滤(user_id NULL 的日志跳过,标注临时方案)
|
||||||
|
- `_kpi_snapshot(db, entity_id)`:KPIDefinition 加 entity 过滤(现状漏企业,跨企业 KPI 会被喂进对方摘要 prompt——真实泄漏点)
|
||||||
|
- `generate_summary(..., entity_id)`:抽取+快照传 entity_id;KnowledgeSummary 写入 entity_id
|
||||||
|
- `get_last_summary(db, level, entity_id)` / `run_daily|weekly|monthly_summary(db, entity_id)` / `generate_*_sync(db, entity_id)`
|
||||||
|
5. **api/knowledge.py**:8 个业务端点(summaries 列表/latest/详情、events、generate daily/weekly/monthly)加 `entity_id: int = Depends(get_entity_id)` 并强制过滤;get_summary 详情校验归属(非本企业 → 404)。ethics-quiz 为静态公共题库不动。
|
||||||
|
6. **api/knowledge_articles.py**:list/get 加 Depends(get_entity_id);过滤 `or_(entity_id == cur, entity_id.is_(None))`;get_article 企业专属行非本企业 → 404
|
||||||
|
7. **测试**:新增 test_security_knowledge_partition.py(双实体互查越权 + 全局文章双可见 + generate 自动带 entity_id,monkeypatch DeepSeek 免网络);pytest 全量回归
|
||||||
|
8. 前端零改动(token-first:登录用户 token 已绑 entity,拦截器不显式传 entity_id 时后端取 token 绑定值)
|
||||||
|
|
||||||
|
## 四、验证门禁
|
||||||
|
|
||||||
|
1. schema_check.py ORM↔DB 一致(加列后)
|
||||||
|
2. pytest 全量无新增失败(对照基线 726 passed / 4 failed(日历漂移) / 1 xfailed)
|
||||||
|
3. 双实体越权实测脚本(生产 E2E:登 entity1 查不到 entity2 摘要/文章、entity2 专属行 404、全局文章两可见)
|
||||||
|
4. 部署冒烟 5 项
|
||||||
|
|
||||||
|
## 五、评审记录
|
||||||
|
|
||||||
|
(待项目Bot/任总复核后回填结论;若对 articles 归属语义有异议,仅需 UPDATE 存量行,代码逻辑不变)
|
||||||
|
|
||||||
|
---
|
||||||
|
产出物路径:本文档将随代码提交至 cma 仓库 docs/ + 归档 fullstack _reports/
|
||||||
Reference in New Issue
Block a user