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:
@@ -4,9 +4,11 @@
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from typing import Optional
|
||||
from app.database import get_db
|
||||
from app.auth_middleware import require_role
|
||||
from app.deps import get_entity_id
|
||||
from app.models.knowledge_article import KnowledgeArticle
|
||||
|
||||
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:
|
||||
return {
|
||||
"id": a.id,
|
||||
"entity_id": a.entity_id,
|
||||
"title": a.title,
|
||||
"summary": a.summary,
|
||||
"content": a.content,
|
||||
@@ -32,9 +35,21 @@ def list_articles(
|
||||
related_page: Optional[str] = Query(None, description="按关联页面路由筛选"),
|
||||
category: Optional[str] = Query(None),
|
||||
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:
|
||||
q = q.filter(KnowledgeArticle.related_page.contains(related_page))
|
||||
if category:
|
||||
@@ -44,8 +59,19 @@ def list_articles(
|
||||
|
||||
|
||||
@router.get("/{article_id}")
|
||||
def get_article(article_id: int, db: Session = Depends(get_db)):
|
||||
a = db.query(KnowledgeArticle).filter(KnowledgeArticle.id == article_id).first()
|
||||
def get_article(
|
||||
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:
|
||||
raise HTTPException(404, "文章不存在")
|
||||
if a.entity_id is not None and a.entity_id != entity_id:
|
||||
raise HTTPException(404, "文章不存在")
|
||||
return article_to_dict(a)
|
||||
|
||||
Reference in New Issue
Block a user