"""数据分类分级 — 重要数据标记(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}