CodeReview/backend/app/services/report_generator.py

477 lines
16 KiB
Python

"""
PDF 报告生成服务 - 专业审计版 (WeasyPrint)
"""
import io
from datetime import datetime
from typing import List, Dict, Any
import math
import os
import sys
import base64
# macOS Homebrew compatibility fix
if sys.platform == 'darwin':
os.environ['DYLD_FALLBACK_LIBRARY_PATH'] = '/opt/homebrew/lib:' + os.environ.get('DYLD_FALLBACK_LIBRARY_PATH', '')
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
from jinja2 import Template
class ReportGenerator:
"""
基于 HTML/CSS 的专业 PDF 报告生成器
风格:严谨、高密度、企业级审计报告风格
"""
# --- HTML 模板 ---
_TEMPLATE = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>代码审计报告</title>
<style>
@page {
size: A4;
margin: 2.5cm 2cm;
@top-left {
content: element(logoRunning);
vertical-align: middle;
}
@top-right {
content: "DeepAudit Audit Report";
font-size: 8pt;
color: #666;
font-family: sans-serif;
vertical-align: middle;
}
@bottom-center {
content: counter(page);
font-size: 9pt;
font-family: serif;
}
}
body {
font-family: "Songti SC", "SimSun", "Times New Roman", serif;
color: #000;
line-height: 1.3; /* Tighter line height */
font-size: 10pt;
margin: 0;
}
/* 页眉 Logo 定义 */
.running-logo {
position: running(logoRunning);
height: 30px;
width: auto;
margin-bottom: 10px;
}
/* 头部 */
.header {
padding-bottom: 10px;
display: table;
width: 100%;
}
.header-line {
border-bottom: 2px solid #000;
margin-bottom: 20px;
margin-top: 5px;
}
.header-left {
display: table-cell;
vertical-align: middle;
}
/* Logo removed from here */
.title-group {
display: block; /* Changed to block since it's the only child */
vertical-align: middle;
}
.title {
font-size: 18pt;
font-weight: bold;
font-family: sans-serif;
margin: 0 0 5px 0;
color: #000;
line-height: 1.1;
}
.subtitle {
font-size: 10pt;
color: #444;
font-family: sans-serif;
margin: 0;
line-height: 1.3;
}
.meta-info {
display: table-cell;
text-align: right;
vertical-align: middle;
font-size: 9pt;
color: #333;
width: 250px;
}
.meta-item {
margin-bottom: 2px;
}
/* 通用工具类 */
.text-right { text-align: right; }
.text-center { text-align: center; }
.bold { font-weight: bold; }
.mono { font-family: "Menlo", "Consolas", "Courier New", "PingFang SC", "Microsoft YaHei", monospace; }
/* 概览表格 */
.section-header {
font-size: 11pt;
font-weight: bold;
font-family: sans-serif;
border-left: 4px solid #000;
padding-left: 8px;
margin-top: 25px;
margin-bottom: 10px;
background-color: #f3f4f6;
padding-top: 5px;
padding-bottom: 5px;
}
/* 评分栏 */
.score-box {
border: 1px solid #000;
padding: 15px;
margin-bottom: 20px;
display: table;
width: 100%;
box-sizing: border-box;
}
.score-left {
display: table-cell;
vertical-align: middle;
width: 40%;
}
.score-right {
display: table-cell;
vertical-align: middle;
text-align: right;
width: 60%;
}
.score-val {
font-size: 24pt;
font-weight: bold;
font-family: sans-serif;
line-height: 1;
}
/* 统计数据表格 */
.stats-table {
width: 100%;
border-collapse: collapse;
}
.stats-table td {
text-align: center;
padding: 0 10px;
border-left: 1px solid #ddd;
}
.stats-table td:first-child {
border-left: none;
}
.stat-label {
font-size: 8pt;
color: #666;
text-transform: uppercase;
margin-bottom: 3px;
display: block;
}
.stat-value {
font-size: 11pt;
font-weight: bold;
display: block;
}
/* 问题列表 - 高密度排版 */
.issue-item {
border-bottom: 1px solid #e5e7eb;
padding: 10px 0; /* Reduced padding */
break-inside: avoid;
}
.issue-item:last-child {
border-bottom: none;
}
.issue-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 6px; /* Reduced margin */
}
.issue-title {
font-size: 10.5pt;
font-weight: bold;
font-family: sans-serif;
flex: 1;
margin-right: 15px;
}
.issue-severity {
font-size: 8.5pt;
font-weight: bold;
text-transform: uppercase;
font-family: sans-serif;
white-space: nowrap;
}
.issue-meta {
font-size: 8pt;
color: #555;
margin-bottom: 6px; /* Reduced margin */
background: #f3f4f6;
padding: 2px 6px;
display: inline-block;
border-radius: 2px;
font-family: monospace;
}
.issue-desc {
text-align: justify;
margin-bottom: 8px; /* Reduced margin */
line-height: 1.4;
font-size: 9.5pt;
}
/* 代码块 - 浅色主题,紧凑 */
.code-snippet {
background-color: #f8f9fa;
border: 1px solid #e5e7eb;
border-left: 3px solid #333;
color: #1f2937;
padding: 8px; /* Reduced padding */
font-size: 8.5pt; /* Smaller font */
line-height: 1.3;
white-space: pre-wrap;
word-break: break-all;
margin: 8px 0; /* Reduced margin */
font-family: "Menlo", "Consolas", "Courier New", "PingFang SC", "Microsoft YaHei", monospace;
}
/* 建议 - 无框风格 */
.suggestion {
margin-top: 6px;
font-style: italic;
color: #333;
font-size: 9pt;
line-height: 1.4;
}
</style>
</head>
<body>
<!-- 定义页眉 Logo (Running Element) -->
{% if logo_b64 %}
<img src="data:image/png;base64,{{ logo_b64 }}" class="running-logo" alt="Logo"/>
{% endif %}
<div class="header">
<div class="header-left">
<div class="title-group">
<h1 class="title">{{ title }}</h1>
<div class="subtitle">{{ subtitle }}</div>
</div>
</div>
<div class="meta-info">
<div class="meta-item">报告编号: <span class="mono">{{ report_id }}</span></div>
<div class="meta-item">生成时间: {{ generated_at }}</div>
</div>
</div>
<div class="header-line"></div>
<!-- 概览区域 -->
<div class="score-box">
<div class="score-left">
<span style="font-size: 10pt; font-weight: bold; margin-right: 10px; vertical-align: middle;">代码质量评分</span>
<span class="score-val" style="vertical-align: middle;">{{ score|int }}</span>
<span style="font-size: 10pt; color: #666; margin-left: 5px; vertical-align: middle;">/ 100</span>
</div>
<div class="score-right">
<table class="stats-table">
<tr>
{% for label, value in stats %}
<td>
<span class="stat-label">{{ label }}</span>
<span class="stat-value">{{ value }}</span>
</td>
{% endfor %}
</tr>
</table>
</div>
</div>
<!-- 问题详情 -->
{% if issues %}
<div class="section-header">审计发现明细 ({{ issues|length }})</div>
<div class="issue-list">
{% for issue in issues %}
<div class="issue-item">
<div class="issue-title-row">
<div class="issue-title">{{ loop.index }}. {{ issue.title }}</div>
<div class="issue-severity color-{{ issue.severity }}">[{{ issue.severity_label }}]</div>
</div>
{% if issue.file_path or issue.line %}
<div class="issue-meta mono">
{% if issue.file_path %}FILE: {{ issue.file_path }}{% endif %}
{% if issue.line %}{% if issue.file_path %} | {% endif %}LINE: {{ issue.line }}{% endif %}
</div>
{% endif %}
<div class="issue-desc">{{ issue.description }}</div>
{% if issue.code_snippet %}
<div class="code-snippet mono">{{ issue.code_snippet }}</div>
{% endif %}
{% if issue.suggestion %}
<div class="suggestion">
<strong>建议:</strong> {{ issue.suggestion }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div style="padding: 20px; text-align: center; border: 1px dashed #ccc; margin-top: 20px;">
<strong>未发现代码问题</strong>
<p style="font-size: 9pt; color: #666; margin-top: 5px;">本次扫描未发现任何违规或潜在风险,代码质量符合标准。</p>
</div>
{% endif %}
<!-- 页脚声明 -->
<div style="margin-top: 40px; font-size: 8pt; color: #999; text-align: center; border-top: 1px solid #eee; padding-top: 10px;">
本报告由 AI 自动生成,注意核实鉴别。
</div>
</body>
</html>
"""
@classmethod
def _get_logo_base64(cls) -> str:
"""读取并编码 Logo 图片"""
try:
current_dir = os.path.dirname(os.path.abspath(__file__))
# 尝试多个可能的路径
possible_paths = [
# Docker 容器内路径
os.path.join(current_dir, '../../static/images/logo_nobg.png'),
# 本地开发路径
os.path.abspath(os.path.join(current_dir, '../../../frontend/public/images/logo_nobg.png')),
]
for logo_path in possible_paths:
if os.path.exists(logo_path):
with open(logo_path, "rb") as image_file:
return base64.b64encode(image_file.read()).decode('utf-8')
except Exception as e:
print(f"Error loading logo: {e}")
return ""
return ""
@classmethod
def _process_issues(cls, issues: List[Dict]) -> List[Dict]:
processed = []
order = {'critical': 0, 'high': 1, 'medium': 2, 'low': 3}
sorted_issues = sorted(issues, key=lambda x: order.get(x.get('severity', 'low'), 4))
sev_labels = {
'critical': 'CRITICAL',
'high': 'HIGH',
'medium': 'MEDIUM',
'low': 'LOW'
}
for i in sorted_issues:
item = i.copy()
item['severity'] = item.get('severity', 'low')
item['severity_label'] = sev_labels.get(item['severity'], 'UNKNOWN')
item['line'] = item.get('line_number') or item.get('line')
# 确保代码片段存在 (处理可能的字段名差异)
code = item.get('code_snippet') or item.get('code') or item.get('context')
if isinstance(code, list):
code = '\n'.join(code)
item['code_snippet'] = code
processed.append(item)
return processed
@classmethod
def _render_pdf(cls, context: Dict[str, Any]) -> bytes:
# 注入 Logo
context['logo_b64'] = cls._get_logo_base64()
template = Template(cls._TEMPLATE)
html_content = template.render(**context)
font_config = FontConfiguration()
pdf_file = io.BytesIO()
HTML(string=html_content).write_pdf(
pdf_file,
font_config=font_config,
presentational_hints=True
)
pdf_file.seek(0)
return pdf_file.getvalue()
@classmethod
def generate_instant_report(cls, result: Dict[str, Any], language: str, time: float) -> bytes:
score = result.get('quality_score', 0)
issues = result.get('issues', [])
context = {
'title': '代码审计报告',
'subtitle': f'即时分析 | 语言: {language.capitalize()}',
'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'report_id': f"INST-{int(datetime.now().timestamp())}",
'score': score,
'stats': [
('问题总数', len(issues)),
('耗时', f"{time:.2f}s"),
],
'issues': cls._process_issues(issues)
}
return cls._render_pdf(context)
@classmethod
def generate_task_report(cls, task: Dict[str, Any], issues: List[Dict[str, Any]], project: str = "项目") -> bytes:
score = task.get('quality_score', 0)
context = {
'title': '项目代码审计报告',
'subtitle': f"项目: {project} | 分支: {task.get('branch_name', 'default')}",
'generated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'report_id': f"TASK-{task.get('id', '')[:8]}",
'score': score,
'stats': [
('扫描文件', task.get('scanned_files', 0)),
('代码行数', f"{task.get('total_lines', 0):,}"),
('问题总数', len(issues))
],
'issues': cls._process_issues(issues)
}
return cls._render_pdf(context)