feat: 数据分类分级 — 重要数据标记+资产清单+CSV导出(8/20评估办法政策驱动)
- subjects/kpi_definitions 新增 important_flag/data_level/data_category 列 - 新增 /api/cma/data-classification/* API: inventory/stats/标记/批量/export/industry-reference - 数据级别: core核心/important重要/general一般(GB/T 43697 简化) - 前端 DataClassification.vue: 统计概览+清单+打标+导出+行业参考目录 - pytest 17例覆盖(含多租户隔离验证)
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
"""数据分类分级 — 重要数据标记(2026-08-26 政策驱动)
|
||||
|
||||
背景:《网络数据安全风险评估办法》(2026-08-20 三部门施行)
|
||||
- 重要数据处理者每年一次强制评估
|
||||
- 评估前提 = 先分类分级("不知道重要数据在哪,评估无从谈起")
|
||||
- 本模块 = "分类分级工具"第一版(标记+清单),不做评估算法
|
||||
|
||||
数据级别(参考 GB/T 43697 简化):
|
||||
- core 核心数据(一旦遭篡改/破坏/泄露,直接危害国家安全/经济运行)
|
||||
- important 重要数据(一旦遭篡改/破坏/泄露,危害公共利益/企业关键业务)
|
||||
- general 一般数据(其余)
|
||||
|
||||
覆盖对象:
|
||||
- subjects 会计科目(全局,无 entity_id,与现有科目管理一致)
|
||||
- kpi_definitions KPI字典(按 entity_id 多租户隔离)
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, or_
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import io
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_entity_id
|
||||
from app.auth_middleware import require_role, require_auth
|
||||
from app.models import Subject, KPIDefinition, Entity
|
||||
|
||||
router = APIRouter(prefix="/api/cma/data-classification", tags=["数据分类分级"],
|
||||
dependencies=[Depends(require_role("ceo", "finance", "business", "it"))],
|
||||
)
|
||||
|
||||
# 写操作只允许 ceo/finance/it(与 KPI 字典一致)
|
||||
WRITE_ROLES = Depends(require_role("ceo", "finance", "it"))
|
||||
|
||||
VALID_LEVELS = {"core", "important", "general"}
|
||||
|
||||
LEVEL_LABELS = {
|
||||
"core": "核心数据",
|
||||
"important": "重要数据",
|
||||
"general": "一般数据",
|
||||
}
|
||||
|
||||
# 行业参考目录(内置提示,可按行业自定义扩展)
|
||||
INDUSTRY_REFERENCE = [
|
||||
{"industry": "通用/企业服务", "category": "客户数据", "suggest_level": "important",
|
||||
"desc": "客户名单、合同、订单、应收账款等客户经营数据"},
|
||||
{"industry": "通用/企业服务", "category": "财务数据", "suggest_level": "important",
|
||||
"desc": "财务报表、银行账户、税务申报、薪酬数据"},
|
||||
{"industry": "通用/企业服务", "category": "员工数据", "suggest_level": "important",
|
||||
"desc": "员工身份、薪酬、社保、考勤、健康信息"},
|
||||
{"industry": "金融", "category": "账户交易", "suggest_level": "core",
|
||||
"desc": "客户账户、交易流水、信贷记录(金融行业核心数据)"},
|
||||
{"industry": "医疗", "category": "健康医疗", "suggest_level": "core",
|
||||
"desc": "病历、健康档案、基因数据(医疗行业核心数据)"},
|
||||
{"industry": "工业", "category": "关键基础设施", "suggest_level": "important",
|
||||
"desc": "生产控制、工艺参数、供应链关键环节数据"},
|
||||
{"industry": "互联网", "category": "用户个人信息", "suggest_level": "important",
|
||||
"desc": "个人信息、日志、位置数据(万人以上规模需重点评估)"},
|
||||
{"industry": "能源", "category": "能源数据", "suggest_level": "important",
|
||||
"desc": "能源生产、传输、消费监测数据"},
|
||||
]
|
||||
|
||||
|
||||
def _subject_to_item(s: Subject) -> dict:
|
||||
return {
|
||||
"type": "subject",
|
||||
"id": s.id,
|
||||
"code": s.subject_code,
|
||||
"name": s.subject_name,
|
||||
"dimension": None,
|
||||
"category": s.new_standard_category or s.category or "",
|
||||
"important_flag": 1 if s.important_flag else 0,
|
||||
"data_level": s.data_level or "general",
|
||||
"data_category": s.data_category or "",
|
||||
"data_owner": None,
|
||||
"storage": "会计科目台账",
|
||||
}
|
||||
|
||||
|
||||
def _kpi_to_item(k: KPIDefinition) -> dict:
|
||||
return {
|
||||
"type": "kpi",
|
||||
"id": k.id,
|
||||
"code": k.kpi_code,
|
||||
"name": k.kpi_name,
|
||||
"dimension": k.dimension,
|
||||
"category": k.category or "",
|
||||
"important_flag": 1 if k.important_flag else 0,
|
||||
"data_level": k.data_level or "general",
|
||||
"data_category": k.data_category or "",
|
||||
"data_owner": k.data_owner,
|
||||
"storage": f"KPI台账(entity_id={k.entity_id})",
|
||||
}
|
||||
|
||||
|
||||
def _query_items(
|
||||
data_type: Optional[str],
|
||||
data_level: Optional[str],
|
||||
important: Optional[int],
|
||||
keyword: Optional[str],
|
||||
entity_id: int,
|
||||
db: Session,
|
||||
) -> list:
|
||||
"""内部清单查询(供 inventory/export 复用,避免依赖注入问题)"""
|
||||
items: list[dict] = []
|
||||
|
||||
if not data_type or data_type == "subject":
|
||||
q = db.query(Subject).filter(Subject.is_active == 1)
|
||||
if data_level:
|
||||
q = q.filter(Subject.data_level == data_level)
|
||||
if important == 1:
|
||||
q = q.filter(Subject.important_flag == 1)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
Subject.subject_name.like(like) | Subject.subject_code.like(like)
|
||||
)
|
||||
for s in q.order_by(Subject.subject_code).all():
|
||||
items.append(_subject_to_item(s))
|
||||
|
||||
if not data_type or data_type == "kpi":
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
)
|
||||
if data_level:
|
||||
q = q.filter(KPIDefinition.data_level == data_level)
|
||||
if important == 1:
|
||||
q = q.filter(KPIDefinition.important_flag == 1)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
q = q.filter(
|
||||
KPIDefinition.kpi_name.like(like) | KPIDefinition.kpi_code.like(like)
|
||||
)
|
||||
for k in q.order_by(KPIDefinition.kpi_code).all():
|
||||
items.append(_kpi_to_item(k))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _stats_of(items: list) -> dict:
|
||||
return {
|
||||
"total": len(items),
|
||||
"marked": sum(1 for i in items if i["important_flag"] == 1),
|
||||
"by_level": {
|
||||
"core": sum(1 for i in items if i["data_level"] == "core"),
|
||||
"important": sum(1 for i in items if i["data_level"] == "important"),
|
||||
"general": sum(1 for i in items if i["data_level"] == "general"),
|
||||
},
|
||||
"by_type": {
|
||||
"subject": sum(1 for i in items if i["type"] == "subject"),
|
||||
"kpi": sum(1 for i in items if i["type"] == "kpi"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/inventory")
|
||||
def get_inventory(
|
||||
data_type: Optional[str] = Query(None, description="subject/kpi,缺省返回全部"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||
keyword: Optional[str] = Query(None, description="编码/名称搜索"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""数据资产清单(科目 + KPI 合并输出,含重要级别)"""
|
||||
if data_level and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
items = _query_items(data_type, data_level, important, keyword, entity_id, db)
|
||||
return {"total": len(items), "items": items, "stats": _stats_of(items)}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""数据分类分级统计概览(评估前准备看板)"""
|
||||
subject_total = db.query(Subject).filter(Subject.is_active == 1).count()
|
||||
subject_marked = db.query(Subject).filter(
|
||||
Subject.is_active == 1, Subject.important_flag == 1
|
||||
).count()
|
||||
kpi_total = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active", KPIDefinition.entity_id == entity_id
|
||||
).count()
|
||||
kpi_marked = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.status == "active",
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.important_flag == 1,
|
||||
).count()
|
||||
|
||||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"entity_name": ent.name if ent else "",
|
||||
"subjects": {"total": subject_total, "marked": subject_marked},
|
||||
"kpis": {"total": kpi_total, "marked": kpi_marked},
|
||||
"total": subject_total + kpi_total,
|
||||
"marked_total": subject_marked + kpi_marked,
|
||||
"coverage_pct": round((subject_marked + kpi_marked) / (subject_total + kpi_total) * 100, 1)
|
||||
if (subject_total + kpi_total) else 0,
|
||||
"policy_note": "《网络数据安全风险评估办法》(2026-08-20施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级",
|
||||
}
|
||||
|
||||
|
||||
@router.put("/subjects/{subject_id}")
|
||||
def mark_subject(
|
||||
subject_id: int,
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""标记单个科目:重要数据标记 + 数据分级 + 行业分类"""
|
||||
subject = db.query(Subject).filter(Subject.id == subject_id).first()
|
||||
if not subject:
|
||||
raise HTTPException(404, "科目不存在")
|
||||
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
if important_flag is not None:
|
||||
subject.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
subject.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
subject.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||
if data_category is not None:
|
||||
subject.data_category = data_category or None
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "更新成功",
|
||||
"subject_id": subject_id,
|
||||
"important_flag": subject.important_flag,
|
||||
"data_level": subject.data_level,
|
||||
"data_category": subject.data_category,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/kpis/{kpi_id}")
|
||||
def mark_kpi(
|
||||
kpi_id: int,
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""标记单个KPI:重要数据标记 + 数据分级 + 行业分类(按账套隔离)"""
|
||||
kpi = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id == kpi_id,
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
).first()
|
||||
if not kpi:
|
||||
raise HTTPException(404, "KPI不存在或不属于当前账套")
|
||||
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
|
||||
if important_flag is not None:
|
||||
kpi.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
kpi.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
kpi.important_flag = 1 # 核心/重要级别自动视为重要数据
|
||||
if data_category is not None:
|
||||
kpi.data_category = data_category or None
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "更新成功",
|
||||
"kpi_id": kpi_id,
|
||||
"important_flag": kpi.important_flag,
|
||||
"data_level": kpi.data_level,
|
||||
"data_category": kpi.data_category,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/batch")
|
||||
def batch_mark(
|
||||
data_type: str = Query(..., description="subject/kpi"),
|
||||
ids: List[int] = Query(..., description="ID列表"),
|
||||
important_flag: Optional[int] = Query(None, description="1=重要数据, 0=取消"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general"),
|
||||
data_category: Optional[str] = Query(None, description="行业参考分类(自定义)"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""批量标记(科目全局 / KPI按账套隔离)"""
|
||||
if data_type not in ("subject", "kpi"):
|
||||
raise HTTPException(400, f"无效的数据类型: {data_type},可选 subject/kpi")
|
||||
if data_level is not None and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level},可选 {sorted(VALID_LEVELS)}")
|
||||
if not ids:
|
||||
raise HTTPException(400, "ids 不能为空")
|
||||
|
||||
updated = 0
|
||||
if data_type == "subject":
|
||||
q = db.query(Subject).filter(Subject.id.in_(ids), Subject.is_active == 1)
|
||||
targets = q.all()
|
||||
for s in targets:
|
||||
if important_flag is not None:
|
||||
s.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
s.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
s.important_flag = 1
|
||||
if data_category is not None:
|
||||
s.data_category = data_category or None
|
||||
updated += 1
|
||||
else:
|
||||
q = db.query(KPIDefinition).filter(
|
||||
KPIDefinition.id.in_(ids),
|
||||
KPIDefinition.entity_id == entity_id,
|
||||
KPIDefinition.status == "active",
|
||||
)
|
||||
targets = q.all()
|
||||
for k in targets:
|
||||
if important_flag is not None:
|
||||
k.important_flag = 1 if important_flag else 0
|
||||
if data_level is not None:
|
||||
k.data_level = data_level
|
||||
if data_level in ("core", "important"):
|
||||
k.important_flag = 1
|
||||
if data_category is not None:
|
||||
k.data_category = data_category or None
|
||||
updated += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"批量更新成功", "data_type": data_type, "updated_count": updated}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_csv(
|
||||
data_type: Optional[str] = Query(None, description="subject/kpi,缺省全部"),
|
||||
data_level: Optional[str] = Query(None, description="core/important/general 过滤"),
|
||||
important: Optional[int] = Query(None, description="1=仅已标记重要数据"),
|
||||
entity_id: int = Depends(get_entity_id),
|
||||
db: Session = Depends(get_db),
|
||||
current_user = Depends(require_auth),
|
||||
):
|
||||
"""导出数据分类分级清单(评估前准备材料)CSV"""
|
||||
if data_level and data_level not in VALID_LEVELS:
|
||||
raise HTTPException(400, f"无效的数据级别: {data_level}")
|
||||
|
||||
items = _query_items(data_type, data_level, important, None, entity_id, db)
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["类型", "编码", "名称", "维度", "分类", "重要标记", "数据级别", "行业参考分类", "数据责任人", "存储位置"])
|
||||
for i in items:
|
||||
writer.writerow([
|
||||
"科目" if i["type"] == "subject" else "KPI",
|
||||
i["code"],
|
||||
i["name"],
|
||||
i["dimension"] or "",
|
||||
i["category"] or "",
|
||||
"是" if i["important_flag"] else "否",
|
||||
LEVEL_LABELS.get(i["data_level"], i["data_level"]),
|
||||
i["data_category"] or "",
|
||||
i["data_owner"] or "",
|
||||
i["storage"] or "",
|
||||
])
|
||||
|
||||
content = "\ufeff" + buf.getvalue() # BOM 兼容 Excel
|
||||
filename = f"data-classification-inventory_{datetime.now().strftime('%Y%m%d')}.csv"
|
||||
from starlette.responses import Response
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/industry-reference")
|
||||
def industry_reference():
|
||||
"""行业参考目录(内置提示,按行业给出重点数据分类建议)"""
|
||||
return {"items": INDUSTRY_REFERENCE}
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from dotenv import load_dotenv
|
||||
from app.database import init_db
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products
|
||||
from app.api import auth, kpis, kpi_governance, templates, maps, dashboard, data, alerts, ai_analysis, alert_rules, users, thresholds, notifications, permissions, action_plans, alignment, org, objectives, versions, budget, cost, predict, reports, security, knowledge, bot_bridge, bot_bridge_v2, lead, tenant, customer_dashboard, deviation_push, budget_generate, knowledge_articles, kpi_causality, data_quality, bi_reports, entities, bsc_layers, okr, okr_templates, subjects, driver_budget, bot_kpis, ontology, bot_iron_law, analysis_results, expenses, cash, tax_compliance, verify, growth_quality, products, data_classification
|
||||
from app.utils.cache import clear_all as clear_cache, delete as delete_cache
|
||||
from scripts.erp_sync import run_sync as run_erp_sync
|
||||
from app.auth_middleware import require_auth
|
||||
@@ -80,6 +80,7 @@ app.include_router(bot_iron_law.router)
|
||||
app.include_router(analysis_results.router)
|
||||
app.include_router(expenses.router)
|
||||
app.include_router(cash.router)
|
||||
app.include_router(data_classification.router)
|
||||
app.include_router(tax_compliance.router)
|
||||
app.include_router(verify.router)
|
||||
|
||||
|
||||
@@ -86,6 +86,9 @@ class KPIDefinition(Base):
|
||||
threshold_yellow = Column(String(100), nullable=True, comment="黄灯阈值")
|
||||
threshold_red = Column(String(100), nullable=True, comment="红灯阈值")
|
||||
category = Column(String(50), nullable=True, comment="BSC二级类别: revenue_growth/profitability/cost_control/asset_efficiency/cash_risk/customer_scale/customer_concentration/customer_satisfaction/supply_chain/delivery_quality/talent_pipeline/employee_engagement/innovation")
|
||||
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||
responsible_dept = Column(String(200), nullable=True, comment="负责部门")
|
||||
responsible_user = Column(String(100), nullable=True, comment="负责人")
|
||||
kpi_level = Column(String(20), default="operational", comment="strategic/operational")
|
||||
@@ -574,6 +577,9 @@ class Subject(Base):
|
||||
level = Column(Integer, default=1, comment="科目级别 1-4")
|
||||
category = Column(String(50), nullable=True, comment="科目类别")
|
||||
new_standard_category = Column(String(20), nullable=True, comment="新30号准则分类: operating/investing/financing/tax/discontinued")
|
||||
important_flag = Column(Integer, default=0, comment="是否重要数据(1=是, 数据分类分级 2026-08-26)")
|
||||
data_level = Column(String(20), nullable=True, comment="数据分级: core核心/important重要/general一般")
|
||||
data_category = Column(String(50), nullable=True, comment="行业参考分类(自定义,如金融/医疗/工业)")
|
||||
is_active = Column(Integer, default=1, comment="是否启用")
|
||||
remark = Column(String(500), nullable=True, comment="备注")
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""数据分类分级 API 测试 — 重要数据标记 + 资产清单 + 导出"""
|
||||
import hashlib
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Subject, KPIDefinition, Entity
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header
|
||||
|
||||
|
||||
def create_test_subject(db: Session, **kwargs) -> Subject:
|
||||
"""创建测试科目"""
|
||||
defaults = {
|
||||
"subject_code": "1001",
|
||||
"subject_name": "库存现金",
|
||||
"level": 1,
|
||||
"is_active": 1,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
s = Subject(**defaults)
|
||||
db.add(s)
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return s
|
||||
|
||||
|
||||
def create_test_kpi(db: Session, **kwargs) -> KPIDefinition:
|
||||
"""创建测试KPI(带entity_id)"""
|
||||
defaults = {
|
||||
"entity_id": 1,
|
||||
"kpi_code": "F_TEST_REVENUE",
|
||||
"kpi_name": "营业收入(万元)",
|
||||
"dimension": "finance",
|
||||
"status": "active",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
k = KPIDefinition(**defaults)
|
||||
db.add(k)
|
||||
db.commit()
|
||||
db.refresh(k)
|
||||
return k
|
||||
|
||||
|
||||
class TestInventory:
|
||||
def test_inventory_empty(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/inventory", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["total"] == 0
|
||||
assert data["stats"]["marked"] == 0
|
||||
|
||||
def test_inventory_contains_subject_and_kpi(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
create_test_subject(db)
|
||||
create_test_kpi(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/inventory", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
types = {i["type"] for i in data["items"]}
|
||||
assert types == {"subject", "kpi"}
|
||||
assert data["total"] == 2
|
||||
# 未标记时级别默认 general
|
||||
assert data["stats"]["by_level"]["general"] == 2
|
||||
|
||||
def test_filter_by_data_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
create_test_subject(db)
|
||||
create_test_kpi(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/inventory",
|
||||
params={"data_type": "kpi"}, headers=auth_header(token))
|
||||
data = r.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["type"] == "kpi"
|
||||
|
||||
def test_filter_by_level(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
create_test_subject(db, data_level="important", important_flag=1)
|
||||
create_test_kpi(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/inventory",
|
||||
params={"data_level": "important"}, headers=auth_header(token))
|
||||
data = r.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["name"] == "库存现金"
|
||||
|
||||
def test_filter_important_only(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
create_test_subject(db, data_level="important", important_flag=1)
|
||||
create_test_kpi(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/inventory",
|
||||
params={"important": 1}, headers=auth_header(token))
|
||||
data = r.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_invalid_level_400(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/inventory",
|
||||
params={"data_level": "secret"}, headers=auth_header(token))
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestMarkSubject:
|
||||
def test_mark_subject(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
s = create_test_subject(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.put(f"/api/cma/data-classification/subjects/{s.id}",
|
||||
params={"data_level": "important", "data_category": "财务数据"},
|
||||
headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# 重要级别自动视为重要数据
|
||||
assert body["important_flag"] == 1
|
||||
assert body["data_level"] == "important"
|
||||
assert body["data_category"] == "财务数据"
|
||||
|
||||
def test_mark_subject_general_clears(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
s = create_test_subject(db, data_level="important", important_flag=1)
|
||||
token = get_token_for_user(client)
|
||||
r = client.put(f"/api/cma/data-classification/subjects/{s.id}",
|
||||
params={"data_level": "general", "important_flag": 0},
|
||||
headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["data_level"] == "general"
|
||||
assert r.json()["important_flag"] == 0
|
||||
|
||||
def test_mark_subject_not_found(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.put("/api/cma/data-classification/subjects/9999",
|
||||
params={"data_level": "important"}, headers=auth_header(token))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestMarkKpi:
|
||||
def test_mark_kpi(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
k = create_test_kpi(db, entity_id=1)
|
||||
token = get_token_for_user(client)
|
||||
r = client.put(f"/api/cma/data-classification/kpis/{k.id}",
|
||||
params={"data_level": "core", "data_category": "财务数据"},
|
||||
headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["important_flag"] == 1
|
||||
assert r.json()["data_level"] == "core"
|
||||
|
||||
def test_kpi_entity_isolation(self, client: TestClient, db: Session):
|
||||
"""多租户隔离:entity 2 的KPI,entity 1 的token不可见、不可标记"""
|
||||
create_test_user(db)
|
||||
k2 = create_test_kpi(db, entity_id=2, kpi_code="F_OTHER_ENTITY")
|
||||
token = get_token_for_user(client) # token 绑定 entity 1
|
||||
# 不可标记
|
||||
r = client.put(f"/api/cma/data-classification/kpis/{k2.id}",
|
||||
params={"data_level": "important"}, headers=auth_header(token))
|
||||
assert r.status_code == 404
|
||||
# 清单中不可见
|
||||
r2 = client.get("/api/cma/data-classification/inventory",
|
||||
params={"data_type": "kpi"}, headers=auth_header(token))
|
||||
data = r2.json()
|
||||
assert all(i["code"] != "F_OTHER_ENTITY" for i in data["items"])
|
||||
|
||||
|
||||
class TestBatch:
|
||||
def test_batch_mark_subjects(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
s1 = create_test_subject(db, subject_code="1001", subject_name="库存现金")
|
||||
s2 = create_test_subject(db, subject_code="1002", subject_name="银行存款")
|
||||
token = get_token_for_user(client)
|
||||
r = client.put("/api/cma/data-classification/batch",
|
||||
params={"data_type": "subject", "ids": [s1.id, s2.id],
|
||||
"data_level": "important", "data_category": "财务数据"},
|
||||
headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
assert r.json()["updated_count"] == 2
|
||||
# 回查确认生效
|
||||
db.expire_all()
|
||||
assert db.query(Subject).get(s1.id).important_flag == 1
|
||||
assert db.query(Subject).get(s2.id).data_level == "important"
|
||||
|
||||
def test_batch_invalid_type(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.put("/api/cma/data-classification/batch",
|
||||
params={"data_type": "bad", "ids": [1]}, headers=auth_header(token))
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestExport:
|
||||
def test_export_csv(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
create_test_subject(db, data_level="important", important_flag=1)
|
||||
create_test_kpi(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/export", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
assert "text/csv" in r.headers["content-type"]
|
||||
text = r.text
|
||||
# 表头 + 类型 + 中文标签
|
||||
assert "类型" in text and "数据级别" in text
|
||||
assert "库存现金" in text
|
||||
assert "科目" in text and "KPI" in text
|
||||
assert "重要数据" in text
|
||||
|
||||
def test_export_filtered(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
create_test_subject(db, data_level="important", important_flag=1)
|
||||
create_test_kpi(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/export",
|
||||
params={"important": 1}, headers=auth_header(token))
|
||||
text = r.text
|
||||
assert "库存现金" in text
|
||||
assert "营业收入(万元)" not in text
|
||||
|
||||
|
||||
class TestStats:
|
||||
def test_stats(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
create_test_subject(db, data_level="important", important_flag=1)
|
||||
create_test_subject(db, subject_code="1002", subject_name="银行存款")
|
||||
create_test_kpi(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/stats", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["subjects"]["total"] == 2
|
||||
assert data["subjects"]["marked"] == 1
|
||||
assert data["kpis"]["total"] == 1
|
||||
assert data["marked_total"] == 1
|
||||
assert "policy_note" in data
|
||||
|
||||
def test_industry_reference(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
r = client.get("/api/cma/data-classification/industry-reference", headers=auth_header(token))
|
||||
assert r.status_code == 200
|
||||
items = r.json()["items"]
|
||||
assert len(items) >= 5
|
||||
industries = {i["industry"] for i in items}
|
||||
assert "金融" in industries and "医疗" in industries
|
||||
Reference in New Issue
Block a user