Files
Neo-ZQYY/apps/etl/connectors/feiqiu/scripts/gen_audit_dashboard.py

584 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""审计一览表生成脚本 — 解析模块
从 docs/audit/changes/ 目录扫描审计源记录 Markdown 文件,
提取结构化信息(日期、标题、修改文件、风险等级、变更类型、影响模块)。
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from pathlib import Path
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
# 文件名格式YYYY-MM-DD__slug.md
_FILENAME_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})__(.+)\.md$")
# 文件路径 → 功能模块映射(按最长前缀优先匹配)
MODULE_MAP: dict[str, str] = {
"api/": "API 层",
"tasks/ods": "ODS 层",
"tasks/dwd": "DWD 层",
"tasks/dws": "DWS 层",
"tasks/index": "指数算法",
"loaders/": "数据装载",
"database/": "数据库",
"orchestration/": "调度",
"config/": "配置",
"cli/": "CLI",
"models/": "模型",
"scd/": "SCD2",
"docs/": "文档",
"scripts/": "脚本工具",
"tests/": "测试",
"quality/": "质量校验",
"gui/": "GUI",
"utils/": "工具库",
}
# 按前缀长度降序排列,确保最长前缀优先匹配
_SORTED_PREFIXES: list[tuple[str, str]] = sorted(
MODULE_MAP.items(), key=lambda kv: len(kv[0]), reverse=True
)
# 所有合法模块名称(含兜底"其他"
VALID_MODULES: frozenset[str] = frozenset(MODULE_MAP.values()) | {"其他"}
# ---------------------------------------------------------------------------
# 数据类
# ---------------------------------------------------------------------------
@dataclass
class AuditEntry:
"""从单个审计源记录文件解析出的结构化数据"""
date: str # YYYY-MM-DD从文件名提取
slug: str # 文件名中 __ 后的标识符
title: str # Markdown 一级标题
filename: str # 源文件名(不含路径)
changed_files: list[str] = field(default_factory=list) # 修改的文件路径列表
modules: set[str] = field(default_factory=set) # 影响的功能模块集合
risk_level: str = "未知" # 风险等级:高/中/低/极低
change_type: str = "功能" # 变更类型bugfix/功能/文档/重构/清理
projects: set[str] = field(default_factory=set) # 所属项目集合
# ---------------------------------------------------------------------------
# 项目归属分类
# ---------------------------------------------------------------------------
# 文件路径 → 项目归属映射(按最长前缀优先匹配)
PROJECT_MAP: dict[str, str] = {
"apps/etl/connectors/feiqiu/": "ETL-feiqiu",
"apps/backend/": "后端",
"apps/admin-web/": "管理后台",
"apps/miniprogram/": "小程序",
"gui/": "桌面GUI",
"packages/shared/": "共享包",
"db/etl_feiqiu/": "ETL-feiqiu",
"db/zqyy_app/": "后端",
"db/fdw/": "跨库(FDW)",
"db/": "数据库",
}
_SORTED_PROJECT_PREFIXES: list[tuple[str, str]] = sorted(
PROJECT_MAP.items(), key=lambda kv: len(kv[0]), reverse=True
)
def classify_project(filepath: str) -> str:
"""根据 PROJECT_MAP 将文件路径映射到所属项目。
对于 monorepo 迁移前的历史审计记录changed_files 可能是 ETL 内部相对路径
(如 api/xxx.py、tasks/dwd/xxx.py此时回退匹配 MODULE_MAP 前缀归为 ETL-feiqiu。
无任何前缀命中时返回 "项目级"
"""
normalized = filepath.replace("\\", "/").lstrip("./")
for prefix, project_name in _SORTED_PROJECT_PREFIXES:
if normalized.startswith(prefix):
return project_name
# 回退:匹配 ETL 内部模块前缀(历史记录兼容)
for prefix in MODULE_MAP:
if normalized.startswith(prefix):
return "ETL-feiqiu"
return "项目级"
# ---------------------------------------------------------------------------
# 模块分类
# ---------------------------------------------------------------------------
def classify_module(filepath: str) -> str:
"""根据 MODULE_MAP 将文件路径映射到功能模块。
匹配规则:按前缀长度降序逐一比较,首个命中即返回。
无任何前缀命中时返回 "其他"
"""
# 统一为正斜杠,去除前导 ./ 或 /
normalized = filepath.replace("\\", "/").lstrip("./")
for prefix, module_name in _SORTED_PREFIXES:
if normalized.startswith(prefix):
return module_name
return "其他"
# ---------------------------------------------------------------------------
# 解析辅助函数
# ---------------------------------------------------------------------------
def _extract_title(content: str) -> str | None:
"""从 Markdown 内容中提取第一个一级标题(# ...)。"""
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("# "):
return stripped[2:].strip()
return None
# 匹配"修改文件清单"/"文件清单"/"Changed"/"变更范围"/"变更摘要" 等章节标题
_FILE_SECTION_RE = re.compile(
r"^##\s+.*(修改文件|文件清单|Changed|变更范围|变更摘要).*$",
re.IGNORECASE,
)
# 从表格行提取文件路径:| `path` | ... 或 | path | ...
_TABLE_FILE_RE = re.compile(
r"^\|\s*`?([^`|]+?)`?\s*\|"
)
# 从列表行提取文件路径:- path 或 - `path`(忽略纯描述行)
_LIST_FILE_RE = re.compile(
r"^[-*]\s+`?([^\s`(]+\.[a-zA-Z0-9_]+)`?"
)
# 从含 → 的行提取源路径和目标路径
_ARROW_PATH_RE = re.compile(
r"`([^`]+?)`\s*→\s*`([^`]+?)`"
)
# 子章节标题(### ...),用于在文件清单章节内继续扫描
_SUB_HEADING_RE = re.compile(r"^###\s+")
def _extract_changed_files(content: str) -> list[str]:
"""从审计文件内容中提取修改文件路径列表。
扫描策略:
1. 找到"修改文件清单"/"文件清单"/"Changed"/"变更范围"等二级章节
2. 在该章节内解析表格行和列表行中的文件路径
3. 遇到下一个同级(##)章节时停止
"""
lines = content.splitlines()
results: list[str] = []
in_section = False
for line in lines:
stripped = line.strip()
if _FILE_SECTION_RE.match(stripped):
in_section = True
continue
# 遇到下一个二级章节,退出扫描
if in_section and stripped.startswith("## ") and not _FILE_SECTION_RE.match(stripped):
break
if not in_section:
continue
# 跳过表头分隔行
if re.match(r"^\|[-\s|:]+\|$", stripped):
continue
# 跳过子章节标题(### 新增文件 等),但继续扫描
if _SUB_HEADING_RE.match(stripped):
continue
# 尝试表格行
m = _TABLE_FILE_RE.match(stripped)
if m:
path = m.group(1).strip()
# 排除表头行("文件"、"文件/对象" 等)
if path and not re.match(r"^(文件|File|路径|对象)", path, re.IGNORECASE):
results.append(path)
continue
# 尝试含 → 的移动/重命名行(提取源和目标路径)
m_arrow = _ARROW_PATH_RE.search(stripped)
if m_arrow:
src, dst = m_arrow.group(1).strip(), m_arrow.group(2).strip()
if "/" in src:
results.append(src)
if "/" in dst:
results.append(dst)
continue
# 尝试列表行
m = _LIST_FILE_RE.match(stripped)
if m:
path = m.group(1).strip()
if path and "/" in path:
results.append(path)
continue
return results
# 风险等级关键词(按优先级排列)
_RISK_KEYWORDS: list[tuple[str, str]] = [
("极低", "极低"),
("", ""),
("", ""),
("", ""),
]
# 匹配风险相关章节标题
_RISK_SECTION_RE = re.compile(
r"^##\s+.*(风险|Risk).*$", re.IGNORECASE
)
def _extract_risk_level(content: str) -> str:
"""从审计文件内容中提取风险等级。
扫描策略(按优先级):
1. 头部元数据行:`- 风险等级:低` 或 `- 风险:极低`
2. 风险相关二级章节内的关键词
3. 兜底:全文搜索含"风险"的行
"""
lines = content.splitlines()
# 策略 1头部元数据通常在前 15 行内)
_meta_risk_re = re.compile(r"^-\s*风险[等级]*[:]\s*(.+)$")
for line in lines[:15]:
m = _meta_risk_re.match(line.strip())
if m:
val = m.group(1)
if "极低" in val:
return "极低"
if "" in val:
return ""
if "" in val:
return ""
if "" in val:
return ""
# 策略 2风险相关二级章节
in_section = False
section_text = ""
for line in lines:
stripped = line.strip()
if _RISK_SECTION_RE.match(stripped):
in_section = True
continue
if in_section and stripped.startswith("## "):
break
if in_section:
section_text += stripped + " "
# 策略 3兜底全文搜索含"风险"的行
if not section_text:
for line in lines:
if "风险" in line:
section_text += line.strip() + " "
if not section_text:
return "未知"
# 按优先级匹配:先检查"极低",再检查独立的"高/中/低"
if "极低" in section_text:
return "极低"
if re.search(r"风险[:]\s*高|高风险", section_text):
return ""
if re.search(r"风险[:]\s*中|中等风险", section_text):
return ""
# "纯文档" 等描述中含"低"但不含"极低"时匹配为"低"
if re.search(r"风险[:]\s*低|低风险|风险.*低", section_text):
return ""
# 推断:描述中含"纯文档/无运行时影响/纯分析"等表述视为极低
if re.search(r"纯文档|无运行时影响|纯分析|无逻辑改动|无代码", section_text):
return "极低"
return "未知"
# 变更类型推断关键词
_CHANGE_TYPE_PATTERNS: list[tuple[str, str]] = [
("bugfix", "bugfix"),
("bug", "bugfix"),
("修复", "bugfix"),
("重构", "重构"),
("清理", "清理"),
("纯文档", "文档"),
("无逻辑改动", "文档"),
("文档", "文档"),
]
def _infer_change_type(content: str) -> str:
"""从审计文件内容推断变更类型。
按优先级扫描关键词,首个命中即返回。
默认返回 "功能"
"""
lower = content.lower()
for keyword, ctype in _CHANGE_TYPE_PATTERNS:
if keyword in lower:
return ctype
return "功能"
# ---------------------------------------------------------------------------
# 核心解析函数
# ---------------------------------------------------------------------------
def parse_audit_file(filepath: str | Path) -> AuditEntry | None:
"""解析单个审计源记录文件,返回 AuditEntry。
文件名必须符合 YYYY-MM-DD__slug.md 格式,否则返回 None 并打印警告。
"""
filepath = Path(filepath)
filename = filepath.name
# 校验文件名格式
m = _FILENAME_RE.match(filename)
if not m:
print(f"[警告] 文件名格式不符,已跳过:{filename}")
return None
date_str = m.group(1)
slug = m.group(2)
# 读取文件内容
try:
content = filepath.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError) as exc:
print(f"[警告] 无法读取文件,已跳过:{filename}{exc}")
return None
# 提取标题(缺失时用 slug 兜底)
title = _extract_title(content) or slug
# 提取修改文件列表
changed_files = _extract_changed_files(content)
# 推导影响模块
if changed_files:
modules = {classify_module(f) for f in changed_files}
projects = {classify_project(f) for f in changed_files}
else:
modules = {"其他"}
projects = {"项目级"}
# 提取风险等级
risk_level = _extract_risk_level(content)
# 推断变更类型
change_type = _infer_change_type(content)
return AuditEntry(
date=date_str,
slug=slug,
title=title,
filename=filename,
changed_files=changed_files,
modules=modules,
risk_level=risk_level,
change_type=change_type,
projects=projects,
)
def scan_audit_dir(dirpath: str | Path) -> list[AuditEntry]:
"""扫描审计目录,返回按日期倒序排列的 AuditEntry 列表。
跳过非 .md 文件和格式不合规的文件。
目录为空或不存在时返回空列表。
"""
dirpath = Path(dirpath)
if not dirpath.is_dir():
return []
entries: list[AuditEntry] = []
for child in sorted(dirpath.iterdir()):
if not child.is_file() or child.suffix != ".md":
continue
entry = parse_audit_file(child)
if entry is not None:
entries.append(entry)
# 按日期倒序
entries.sort(key=lambda e: e.date, reverse=True)
return entries
# ---------------------------------------------------------------------------
# 渲染函数
# ---------------------------------------------------------------------------
def render_timeline_table(entries: list[AuditEntry]) -> str:
"""按时间倒序生成 Markdown 表格。
输入的 entries 应已按日期倒序排列(由 scan_audit_dir 保证)。
空列表时返回"暂无审计记录"提示。
"""
if not entries:
return "> 暂无审计记录\n"
lines: list[str] = [
"| 日期 | 项目 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |",
"|------|------|----------|----------|----------|------|------|",
]
for e in entries:
modules_str = ", ".join(sorted(e.modules))
projects_str = ", ".join(sorted(e.projects))
link = f"[链接](changes/{e.filename})"
lines.append(
f"| {e.date} | {projects_str} | {e.title} | {e.change_type} | {modules_str} | {e.risk_level} | {link} |"
)
return "\n".join(lines) + "\n"
def render_module_index(entries: list[AuditEntry]) -> str:
"""按模块分组生成 Markdown 章节。
每个模块一个三级标题 + 表格,模块按字母序排列。
空列表时返回"暂无审计记录"提示。
"""
if not entries:
return "> 暂无审计记录\n"
# 按模块分组
module_entries: dict[str, list[AuditEntry]] = {}
for e in entries:
for mod in e.modules:
module_entries.setdefault(mod, []).append(e)
sections: list[str] = []
for mod in sorted(module_entries.keys()):
mod_list = module_entries[mod]
section_lines: list[str] = [
f"### {mod}",
"",
"| 日期 | 需求摘要 | 变更类型 | 风险 | 详情 |",
"|------|----------|----------|------|------|",
]
for e in mod_list:
link = f"[链接](changes/{e.filename})"
section_lines.append(
f"| {e.date} | {e.title} | {e.change_type} | {e.risk_level} | {link} |"
)
sections.append("\n".join(section_lines) + "\n")
return "\n".join(sections)
def render_project_index(entries: list[AuditEntry]) -> str:
"""按项目分组生成 Markdown 章节。
每个项目一个三级标题 + 表格,项目按固定顺序排列。
空列表时返回"暂无审计记录"提示。
"""
if not entries:
return "> 暂无审计记录\n"
# 固定排序ETL 连接器优先,然后各 APP最后项目级
_PROJECT_ORDER = [
"ETL-feiqiu", "后端", "管理后台", "小程序",
"桌面GUI", "共享包", "跨库(FDW)", "数据库", "项目级",
]
project_entries: dict[str, list[AuditEntry]] = {}
for e in entries:
for proj in e.projects:
project_entries.setdefault(proj, []).append(e)
def sort_key(name: str) -> int:
try:
return _PROJECT_ORDER.index(name)
except ValueError:
return len(_PROJECT_ORDER)
sections: list[str] = []
for proj in sorted(project_entries.keys(), key=sort_key):
proj_list = project_entries[proj]
section_lines: list[str] = [
f"### {proj}",
"",
"| 日期 | 需求摘要 | 变更类型 | 影响模块 | 风险 | 详情 |",
"|------|----------|----------|----------|------|------|",
]
for e in proj_list:
modules_str = ", ".join(sorted(e.modules))
link = f"[链接](changes/{e.filename})"
section_lines.append(
f"| {e.date} | {e.title} | {e.change_type} | {modules_str} | {e.risk_level} | {link} |"
)
sections.append("\n".join(section_lines) + "\n")
return "\n".join(sections)
def render_dashboard(entries: list[AuditEntry]) -> str:
"""组合时间线和模块索引生成完整 dashboard Markdown 文档。
包含:标题、生成时间戳、时间线视图、模块索引视图。
"""
from datetime import datetime
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
parts: list[str] = [
"# 审计一览表",
"",
f"> 自动生成于 {timestamp},请勿手动编辑。",
"",
"## 时间线视图",
"",
render_timeline_table(entries),
"## 项目索引",
"",
render_project_index(entries),
"## 模块索引",
"",
render_module_index(entries),
]
return "\n".join(parts)
# ---------------------------------------------------------------------------
# 主入口
# ---------------------------------------------------------------------------
def main() -> None:
"""扫描审计源记录 → 解析 → 渲染 → 写入 audit_dashboard.md。"""
audit_dir = Path("docs/audit/changes")
output_path = Path("docs/audit/audit_dashboard.md")
# 扫描并解析
entries = scan_audit_dir(audit_dir)
# 渲染完整 dashboard
content = render_dashboard(entries)
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
output_path.write_text(content, encoding="utf-8")
# 输出摘要
print(f"已解析 {len(entries)} 条审计记录")
print(f"输出文件:{output_path}")
if __name__ == "__main__":
main()