生成pdf报告

This commit is contained in:
gongwenxin 2025-06-27 17:58:51 +08:00
parent 38cfb67325
commit fca20523ca
34 changed files with 29439 additions and 6178 deletions

BIN
assets/.DS_Store vendored

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,7 +4,7 @@ import json
class ResponseSchemaValidationCase(BaseAPITestCase):
id = "TC-CORE-FUNC-001"
name = "Response Body JSON Schema Validation"
name = "返回体JSON Schema验证"
description = "验证API响应体是否符合API规范中定义的JSON Schema。"
severity = TestSeverity.CRITICAL
tags = ["core-functionality", "schema-validation", "output-format"]

View File

@ -7,8 +7,8 @@ import string
class InvalidEnumValueCase(BaseAPITestCase):
id = "TC-ERROR-4006"
name = "Error Code 4006 - Invalid Enum Value Validation"
description = "测试当发送的参数值不在指定的枚举范围内时API是否按预期返回code=-1的错误状态码应为200"
name = "非法枚举值检查"
description = "测试当发送的参数值不在指定的枚举范围内时API是否按预期返回code=4006的错误"
severity = TestSeverity.MEDIUM
tags = ["error-handling", "appendix-b", "4006", "invalid-enum"]
execution_order = 204 # 在数值越界之后执行

View File

@ -5,8 +5,8 @@ from ddms_compliance_suite.utils import schema_utils # Keep this import for util
class MissingRequiredFieldBodyCase(BaseAPITestCase):
id = "TC-ERROR-4003-BODY"
name = "Error Code -1 - Missing Required Request Body Field Validation"
description = "测试当请求体中缺少API规范定义的必填字段时API是否按预期返回code=-1的错误。"
name = "缺失必填请求体字段检查"
description = "测试当请求体中缺少API规范定义的必填字段时API是否按预期返回code=4003的错误。"
severity = TestSeverity.HIGH
tags = ["error-handling", "appendix-b", "4003", "required-fields", "request-body"]
execution_order = 210

View File

@ -4,8 +4,8 @@ import copy
class MissingRequiredFieldQueryCase(BaseAPITestCase):
id = "TC-ERROR-4003-QUERY"
name = "Error Code -1 - Missing Required Query Parameter Validation"
description = "测试当请求中缺少API规范定义的必填查询参数时API是否按预期返回code=-1的错误。"
name = "缺失必填查询参数检查"
description = "测试当请求中缺少API规范定义的必填查询参数时API是否按预期返回code=4003的错误。"
severity = TestSeverity.HIGH
tags = ["error-handling", "appendix-b", "4003", "required-fields", "query-parameters"]
execution_order = 211 # After body, before original combined one might have been

View File

@ -5,8 +5,8 @@ from ddms_compliance_suite.utils import schema_utils
class NumberOutOfRangeCase(BaseAPITestCase):
id = "TC-ERROR-4002"
name = "Error Code -1 - Number Value Out of Range Validation"
description = "测试当发送的数值参数超出范围限制时API是否按预期返回code=-1的错误状态码应为200"
name = "数值参数越界检查"
description = "测试当发送的数值参数超出范围限制时API是否按预期返回code=4002的错误"
severity = TestSeverity.MEDIUM
tags = ["error-handling", "appendix-b", "4002", "out-of-range"]
execution_order = 203 # 在类型不匹配测试之后执行

View File

@ -6,8 +6,8 @@ from ddms_compliance_suite.utils import schema_utils
class TypeMismatchBodyCase(BaseAPITestCase):
id = "TC-ERROR-4001-BODY"
name = "Error Code -1 - Request Body Type Mismatch Validation"
description = "测试当发送的请求体中字段的数据类型与API规范定义不符时API是否按预期返回code=-1的错误。"
name = "请求体字段类型不匹配检查"
description = "测试当发送的请求体中字段的数据类型与API规范定义不符时API是否按预期返回code=4001的错误。"
severity = TestSeverity.MEDIUM
tags = ["error-handling", "appendix-b", "4001", "request-body"]
execution_order = 202 # Slightly after query param one

View File

@ -6,8 +6,8 @@ from ddms_compliance_suite.utils import schema_utils
class TypeMismatchQueryParamCase(BaseAPITestCase):
id = "TC-ERROR-4001-QUERY"
name = "Error Code -1 - Query Parameter Type Mismatch Validation"
description = "测试当发送的查询参数数据类型与API规范定义不符时API是否按预期返回code=-1的错误。"
name = "查询参数类型不匹配检查"
description = "测试当发送的查询参数数据类型与API规范定义不符时API是否按预期返回code=4001的错误。"
severity = TestSeverity.MEDIUM
tags = ["error-handling", "appendix-b", "4001", "query-parameters"]
execution_order = 201 # Slightly after the combined one might have been

View File

@ -4,7 +4,7 @@ import urllib.parse
class HTTPSMandatoryCase(BaseAPITestCase):
id = "TC-SECURITY-001"
name = "HTTPS Protocol Mandatory Verification"
name = "HTTPS 协议强制性检查"
description = "验证API端点是否通过HTTPS提供服务以及HTTP请求是否被拒绝或重定向到HTTPS。"
severity = TestSeverity.CRITICAL
tags = ["security", "https", "transport-security"]

View File

@ -1,3 +1,2 @@
[
"API应该使用正确的HTTP方法GET用于检索POST用于创建PUT用于更新DELETE用于删除"
]

View File

@ -21,6 +21,15 @@ class LLMComplianceCheckTestCase(BaseAPITestCase):
def validate_response(self, response_context: APIResponseContext, request_context: APIRequestContext) -> List[ValidationResult]:
results = []
# 如果合规性标准列表为空,则跳过测试
if not self.compliance_criteria:
return [ValidationResult(
passed=True,
message="合规性标准列表为空跳过LLM合规性检查。",
details={"reason": "compliance_criteria.json is empty or contains an empty list."}
)]
# 收集API所有关键信息包括实例数据和Schema定义
api_info = {
# API元数据

File diff suppressed because one or more lines are too long

View File

@ -14,8 +14,26 @@ import json
import logging
import argparse
import datetime
import subprocess
from pathlib import Path
from typing import List, Optional
import string # 导入string模块用于字符过滤
import unicodedata # 导入unicodedata用于字符净化
import html
# PDF生成库 - 使用ReportLab并打包字体
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont, TTFError
reportlab_available = True
except ImportError:
reportlab_available = False
from ddms_compliance_suite.api_caller.caller import APICallDetail
from ddms_compliance_suite.test_orchestrator import APITestOrchestrator, TestSummary
@ -39,6 +57,7 @@ def parse_args():
parser.add_argument('--output', '-o', help='输出目录或主报告文件路径 (例如 ./test_reports/ 或 ./test_reports/summary.json)')
parser.add_argument('--format', choices=['json', 'html'], default='json', help='主测试摘要报告的输出格式')
parser.add_argument('--api-calls-output', help='API 调用详情的 Markdown 输出文件路径 (例如 ./test_reports/api_calls.md)。如果未提供,将尝试使用 --output 目录和默认文件名 api_call_details.md。始终会额外生成一个同名的 .txt 文件包含纯 cURL 命令。')
parser.add_argument('--generate-pdf', action='store_true', help='是否生成中文PDF报告', default=True)
# API定义参数
api_group = parser.add_argument_group('API定义源')
@ -341,17 +360,153 @@ def save_api_call_details_to_file(api_call_details: List[APICallDetail], output_
except Exception as e:
logger.error(f"保存API调用详情到 Markdown 文件 {md_output_file} 失败: {e}", exc_info=True)
# 额外生成 .txt 文件,只包含 cURL 命令
# txt_output_filename = md_output_file.with_suffix('.txt').name
# txt_output_file_path = output_dir / txt_output_filename
def save_pdf_report(summary_data, output_path: Path):
"""将测试摘要保存为格式化的PDF文件"""
logger.info(f"开始生成PDF报告: {output_path}")
output_path.parent.mkdir(parents=True, exist_ok=True)
# try:
# with open(txt_output_file_path, 'w', encoding='utf-8') as f_txt:
# for detail in api_call_details:
# f_txt.write(detail.curl_command + '\n')
# logger.info(f"可直接执行的cURL命令已保存到纯文本文件: {txt_output_file_path}")
# except Exception as e:
# logger.error(f"保存cURL命令到文本文件 {txt_output_file_path} 失败: {e}", exc_info=True)
try:
# --- 统一的字体管理和注册 ---
font_name = 'SimSun' # 使用一个简单清晰的注册名
font_path = 'assets/fonts/STHeiti-Medium-4.ttc'
if not Path(font_path).exists():
logger.error(f"字体文件未找到: {Path(font_path).resolve()}")
return
# 关键修复: 对于 .ttc (TrueType Collection) 文件, 必须指定 subfontIndex
pdfmetrics.registerFont(TTFont(font_name, font_path, subfontIndex=0))
# 将注册的字体关联到 'SimSun' 字体族
pdfmetrics.registerFontFamily(font_name, normal=font_name, bold=font_name, italic=font_name, boldItalic=font_name)
doc = SimpleDocTemplate(str(output_path), pagesize=A4, title="API测试报告")
elements = []
# --- 统一样式定义, 全部使用注册的字体名 ---
styles = getSampleStyleSheet()
title_style = ParagraphStyle('ChineseTitle', parent=styles['Title'], fontName=font_name, fontSize=22, leading=28)
heading_style = ParagraphStyle('ChineseHeading', parent=styles['Heading1'], fontName=font_name, fontSize=16, leading=20, spaceAfter=8)
normal_style = ParagraphStyle('ChineseNormal', parent=styles['Normal'], fontName=font_name, fontSize=10, leading=14)
def to_para(text, style=normal_style, escape=True):
"""
根据用户建议移除 textwrap 以进行诊断
此版本只包含净化和基本的换行符替换
"""
if text is None:
content = ""
else:
content = str(text)
if escape:
content = html.escape(content)
# 依然保留Unicode控制字符的净化
content = "".join(ch for ch in content if unicodedata.category(ch)[0] != 'C')
if not content.strip():
# 对于完全空白或None的输入返回一个安全的非换行空格
return Paragraph(' ', style)
# 只使用基本的换行符替换
content = content.replace('\n', '<br/>')
return Paragraph(content, style)
# 3. 填充PDF内容
elements.append(to_para("API 测试报告", title_style, escape=False))
elements.append(Spacer(1, 20))
elements.append(to_para("测试摘要", heading_style, escape=False))
overall = summary_data.get('overall_summary', {})
# 从JSON提取并格式化时间
try:
start_time_str = summary_data.get('start_time', 'N/A')
end_time_str = summary_data.get('end_time', 'N/A')
duration = summary_data.get('duration_seconds', summary_data.get('duration', 0.0))
start_time_formatted = datetime.datetime.fromisoformat(start_time_str).strftime('%Y-%m-%d %H:%M:%S') if start_time_str != 'N/A' else 'N/A'
end_time_formatted = datetime.datetime.fromisoformat(end_time_str).strftime('%Y-%m-%d %H:%M:%S') if end_time_str != 'N/A' else 'N/A'
except:
start_time_formatted = start_time_str
end_time_formatted = end_time_str
summary_table_data = [
[to_para("<b>开始时间</b>", escape=False), to_para(start_time_formatted)],
[to_para("<b>结束时间</b>", escape=False), to_para(end_time_formatted)],
[to_para("<b>总耗时</b>", escape=False), to_para(f"{float(duration):.2f}")],
[to_para("<b>测试的端点数</b>", escape=False), to_para(overall.get('endpoints_tested', 'N/A'))],
[to_para("<b>执行的用例总数</b>", escape=False), to_para(overall.get('total_test_cases_executed', 'N/A'))],
]
summary_table = Table(summary_table_data, colWidths=[120, '*'])
summary_table.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.grey), ('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
elements.append(summary_table)
elements.append(Spacer(1, 20))
elements.append(to_para("结果统计", heading_style, escape=False))
results_table_data = [
[to_para("<b>指标</b>", escape=False), to_para("<b>通过 ✅</b>", escape=False), to_para("<b>失败 ❌</b>", escape=False), to_para("<b>错误 ⚠️</b>", escape=False), to_para("<b>成功率</b>", escape=False)],
[to_para("端点"), to_para(overall.get('endpoints_passed', 'N/A')), to_para(overall.get('endpoints_failed', 'N/A')), to_para(overall.get('endpoints_error', 'N/A')), to_para(f"<b>{overall.get('endpoint_success_rate', 'N/A')}%</b>", escape=False)],
[to_para("测试用例"), to_para(overall.get('test_cases_passed', 'N/A')), to_para(overall.get('test_cases_failed', 'N/A')), to_para(overall.get('test_cases_error', 'N/A')), to_para(f"<b>{overall.get('test_case_success_rate', 'N/A')}%</b>", escape=False)]
]
results_table = Table(results_table_data, colWidths=['*', 60, 60, 60, 80])
results_table.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.grey), ('BACKGROUND', (0,0), (-1,0), colors.lightgrey), ('ALIGN', (0,0), (-1,-1), 'CENTER'), ('VALIGN', (0,0), (-1,-1), 'MIDDLE')]))
elements.append(results_table)
elements.append(Spacer(1, 20))
elements.append(to_para("详细测试结果", heading_style, escape=False))
detailed_results = summary_data.get('endpoint_results', [])
if not detailed_results:
elements.append(to_para("无详细测试结果。"))
else:
# --- 全新实现:放弃表格,使用卡片式布局 ---
status_map = {
"PASSED": ("通过", colors.green),
"FAILED": ("失败", colors.red),
"ERROR": ("错误", colors.orange),
"SKIPPED": ("跳过", colors.grey)
}
for endpoint_result in detailed_results:
endpoint_name = endpoint_result.get('endpoint_name', 'N/A')
# 为每个端点添加一个子标题 (移除<b>)
endpoint_style = ParagraphStyle('endpoint_heading', parent=heading_style, fontSize=12, spaceBefore=12, spaceAfter=6)
elements.append(to_para(f"端点: {endpoint_name}", style=endpoint_style))
test_cases = endpoint_result.get('executed_test_cases', [])
if not test_cases:
elements.append(to_para("该端点没有执行测试用例。", style=normal_style))
continue
for tc_result in test_cases:
# 移除 <b>
elements.append(to_para(f"用例: {tc_result.get('test_case_name', 'N/A')}"))
# 使用 <font> 标签为状态词语上色
status_en = tc_result.get('status', 'N/A')
status_cn, status_color = status_map.get(status_en, (status_en, colors.black))
status_text = f"状态: <font color='{status_color.hexval()}'>{status_cn}</font>"
elements.append(to_para(status_text, escape=False)) # escape=False 以正确渲染font标签
# 将 "消息:" 标签和内容分离,不再使用 <br>
elements.append(to_para("消息:"))
message_text = tc_result.get('message', '')
message_style = ParagraphStyle('message_style', parent=normal_style, leftIndent=15)
elements.append(to_para(message_text, style=message_style, escape=True))
# 在每个测试用例后添加一条水平分割线
elements.append(Spacer(1, 6))
elements.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey))
elements.append(Spacer(1, 6))
# 构建PDF
doc.build(elements)
logger.info(f"PDF报告已成功生成: {output_path}")
except Exception as e:
logger.error(f"构建PDF文档时出错: {e}", exc_info=True)
def main():
"""主函数"""
@ -464,9 +619,23 @@ def main():
test_summary.finalize_summary() # 重新计算总时长等
test_summary.print_summary_to_console() # 打印包含阶段结果的更新摘要
# 保存主测试摘要 (现在可能包含测试阶段结果)
# 保存主测试摘要,格式由用户指定
main_report_file_path = output_directory / f"summary.{args.format}"
save_results(test_summary, str(main_report_file_path), args.format)
# 如果需要生成PDF报告
if args.generate_pdf:
json_summary_path_for_pdf = output_directory / "summary.json"
# 如果用户请求的不是json我们需要额外生成一份json文件作为PDF的数据源
if args.format != 'json':
with open(json_summary_path_for_pdf, 'w', encoding='utf-8') as f:
f.write(test_summary.to_json(pretty=True))
logger.info(f"为生成PDF而创建临时JSON摘要: {json_summary_path_for_pdf}")
pdf_report_path = output_directory / "report_cn.pdf"
save_pdf_report(test_summary.to_dict(), pdf_report_path)
# API调用详情报告也应保存在同一时间戳目录中
api_calls_filename = "api_call_details.md"
if orchestrator:

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff