From a64f1845250257dd9f2cb97512832e0ebe636a92 Mon Sep 17 00:00:00 2001 From: Hermes CI Fix Date: Wed, 26 Aug 2026 07:51:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E5=88=86=E7=BA=A7=20=E2=80=94=20=E9=87=8D=E8=A6=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=A0=87=E8=AE=B0+=E8=B5=84=E4=BA=A7=E6=B8=85?= =?UTF-8?q?=E5=8D=95+CSV=E5=AF=BC=E5=87=BA=EF=BC=888/20=E8=AF=84=E4=BC=B0?= =?UTF-8?q?=E5=8A=9E=E6=B3=95=E6=94=BF=E7=AD=96=E9=A9=B1=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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例覆盖(含多租户隔离验证) --- backend/app/api/data_classification.py | 387 ++++++++++++++++++++++ backend/app/main.py | 3 +- backend/app/models/__init__.py | 6 + backend/tests/test_data_classification.py | 247 ++++++++++++++ frontend/src/permission.ts | 9 +- frontend/src/router/index.ts | 1 + frontend/src/views/DataClassification.vue | 377 +++++++++++++++++++++ 7 files changed, 1025 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/data_classification.py create mode 100644 backend/tests/test_data_classification.py create mode 100644 frontend/src/views/DataClassification.vue diff --git a/backend/app/api/data_classification.py b/backend/app/api/data_classification.py new file mode 100644 index 00000000..042e75ae --- /dev/null +++ b/backend/app/api/data_classification.py @@ -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} diff --git a/backend/app/main.py b/backend/app/main.py index 5bc29f82..72ca0da8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6ba918cd..a12ef01e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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()) diff --git a/backend/tests/test_data_classification.py b/backend/tests/test_data_classification.py new file mode 100644 index 00000000..0a1ab375 --- /dev/null +++ b/backend/tests/test_data_classification.py @@ -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 diff --git a/frontend/src/permission.ts b/frontend/src/permission.ts index 848965bb..6e86c0ae 100644 --- a/frontend/src/permission.ts +++ b/frontend/src/permission.ts @@ -9,10 +9,10 @@ interface MenuItem { // ── 角色路由映射 ── export const ROLE_ROUTES: Record = { - ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance'], - finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance'], - business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance'], - it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance'], + ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], + finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], + business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'], + it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'], } export const ROLE_ACTIONS: Record = { @@ -61,6 +61,7 @@ export const MENU_ITEMS: MenuItem[] = [ // ── GROUP 5: 基础数据与知识── { path: '/kpis', label: 'KPI字典', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' }, { path: '/data', label: '数据管理', icon: 'Connection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' }, + { path: '/data-classification', label: '数据分类分级', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' }, { path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' }, { path: '/okr-templates', label: 'OKR模板库', icon: 'Collection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 93a0e4ac..2e773999 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -39,6 +39,7 @@ const routes = [ { path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'], editable: true } }, { path: 'okr/:id', name: 'OkrDetail', component: () => import('@/views/OkrDetail.vue'), meta: { title: 'OKR详情', roles: ['ceo', 'finance', 'it'] } }, { path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } }, + { path: 'data-classification', name: 'DataClassification', component: () => import('@/views/DataClassification.vue'), meta: { title: '数据分类分级', roles: ['ceo', 'finance', 'business', 'it'], editable: true } }, { path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } }, { path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } }, { path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } }, diff --git a/frontend/src/views/DataClassification.vue b/frontend/src/views/DataClassification.vue new file mode 100644 index 00000000..6583d874 --- /dev/null +++ b/frontend/src/views/DataClassification.vue @@ -0,0 +1,377 @@ + + + + +