查看历史记录-history-viewer

This commit is contained in:
gongwenxin 2025-06-26 10:13:39 +08:00
parent ff71f2aed6
commit 3d8b83e992
20 changed files with 42100 additions and 6219 deletions

View File

@ -23,3 +23,5 @@ make run_stages
### 开发
cursor-memory-bank在memory-bank目录下,对话前先传给cursor或其他ai

BIN
assets/.DS_Store vendored

Binary file not shown.

478
assets/doc/门户详情.txt Normal file
View File

@ -0,0 +1,478 @@
{
"id": "685518af31d38b669955a139",
"userId": "gyrj_portal",
"userName": "工业软件门户",
"loginButton": "<span style='margin-left:10px;margin-top:5px'><i class='icon-user size-large text-info' ></i><a href='/openapi/admin/market' style='padding:5px' title='进入个人中心'>工业软件门户,您好!</a></span>",
"appDocs": [
{
"_id": {
"timestamp": 1745727813,
"counter": 4108062,
"randomValue1": 16518548,
"randomValue2": 19111
},
"appName": "接口集成应用",
"appId": "lcap"
},
{
"_id": {
"timestamp": 1747877242,
"counter": 4731629,
"randomValue1": 2166043,
"randomValue2": 18860
},
"appName": "平台开发应用",
"appId": "xtkf_app"
},
{
"_id": {
"timestamp": 1748419576,
"counter": 6179296,
"randomValue1": 243350,
"randomValue2": 621
},
"appName": "协同开发应用",
"appId": "xtkf_application"
},
{
"_id": {
"timestamp": 1749102403,
"counter": 16528014,
"randomValue1": 6571710,
"randomValue2": 23361
},
"appName": "演示应用001",
"appId": "gyrj_001"
},
{
"_id": {
"timestamp": 1749106842,
"counter": 16538052,
"randomValue1": 6571710,
"randomValue2": 23361
},
"appName": "算法中试平台",
"appId": "Algopilot"
},
{
"_id": {
"timestamp": 1750124658,
"counter": 5109861,
"randomValue1": 3265419,
"randomValue2": 26265
},
"appName": "fadssd",
"appId": "asdfads"
}
],
"doMain": "https://lcapdev.cnpc.com.cn:8082/gateway",
"apiId": "6855143668dd72277d795036",
"callApplyFlag": false,
"changeLogDocs": [
{
"remark": "调用",
"createTime": "2025-06-24 11:20:25",
"userId": "13693104300",
"userName": "许茂",
"version": "1.0",
"logType": "发布"
},
{
"createTime": "2025-06-20 16:15:43",
"userId": "13693104300",
"userName": "许茂",
"version": "1.0",
"logType": "发布"
}
],
"serviceApproverUserIds": "13693104300",
"openBlackAndWhite": "0",
"permissions": "",
"configName": "嘴流计算",
"configId": "GYRJ_TENANTALGOPILOT_API_250620013",
"mapUrl": "https://lcapdev.cnpc.com.cn:8082/gateway/gyrj_tenant/gyrj_tenantalgopilot/v1/api/algopilot/property/chockQl",
"beanId": "ServiceRegisterRest",
"modelId": "",
"beanMethodName": "redirectUrl",
"methodType": "POST",
"produces": "application/json;charset=utf-8",
"consumes": "*",
"extAttribute": "",
"state": "1",
"effectiveUser": "",
"mockResponseConfigId": "",
"regServiceUrl": "",
"transaction": false,
"joinRestId": "",
"configType": "REG",
"registerFlag": 1,
"requestBodyFlag": true,
"bodyEscapeFlag": false,
"viewId": "",
"logType": 2,
"syncFlag": true,
"scsPlugConfig": "[]",
"regServerId": "",
"bindingGatewayType": "",
"responseSample": "",
"failResponseSample": "",
"syncAnnotation": true,
"remark": "",
"tags": "",
"categoryId": "gyrj_tenantGYRJ_TENANTALGOPILOT_CATEGORY_250620002",
"businessClassIds": "test_ddd",
"version": "1.0",
"parentId": "",
"deprecated": false,
"requestBodyRequired": false,
"requestBodyDataType": "",
"requestBodySchemaRef": "",
"requestBodySampleStr": "\r\n { \"Pt\": 1.2,\r\n \"Ph\": 1,\r\n \"Dchock\": 8.467,\r\n \"Rowl\": 906,\r\n \"fw\": 0.5,\r\n \"GLR\": 100,\r\n \"k\": 1.299\r\n}",
"sqlConfigId": "",
"filters": "",
"retryNum": 0,
"retrySleep": 0,
"loadBalanceId": "WeightRandomServer",
"authType": 2,
"approveAuthType": 3,
"serializeNull": false,
"visibleUserIds": "",
"ownerUserId": "",
"connecterId": "",
"requestCompatibleFlag": 1,
"paramsDocs": [
{
"id": "f9ff5b91868d41ccb755c1f2a6e7779b",
"fieldName": "",
"fieldId": "Pt",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "1.2",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "841c158c430a46748e391f078caf2c62",
"fieldName": "",
"fieldId": "Ph",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "1",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "25decf3885734b9ea3db3ba654518f2b",
"fieldName": "",
"fieldId": "Dchock",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "8.467",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "24f9497dc90249ff82abe6362a638042",
"fieldName": "",
"fieldId": "Rowl",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "906",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "68940892b97243f7bb686b4ba66449b1",
"fieldName": "",
"fieldId": "fw",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "0.5",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "3a6b09b7e4bd404e84585875f7de8134",
"fieldName": "",
"fieldId": "GLR",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "100",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "60419eb45bc641c68a14424944c7f1a8",
"fieldName": "",
"fieldId": "k",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "1.299",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
}
],
"responseDocs": [],
"outputParamsDocs": [],
"hotFlag": 0,
"recommendFlag": 0,
"clickNum": 7,
"iconUrl": "",
"price": "会员免费",
"score": 80,
"describeBody": "<p></p>",
"publishStatus": "1",
"backendMethod": "*",
"backendUrl": "${$config.gyrj_tenantgyrj_tenantAlgopilot.server}/api/algopilot/property/chockQl",
"backendServerId": "",
"backendHaderTransparent": 0,
"backendHeaderParams": "[]",
"backendConnectTimeout": 30000,
"backendParamType": 1,
"backendResponseBodyStreamFlag": false,
"backendResponseBodyChunkedStreamFlag": false,
"backendRequestBodyTemplate": "",
"webServiceRequestXmlFlag": true,
"webServiceResponseXmlFlag": true,
"webServiceRequestBody": "",
"webServiceXmlSubNode": "",
"webServiceRequestXmlFillType": 1,
"webServiceWsdlUrl": "",
"webServiceWsdlDocument": "",
"webServiceRequestBodyFlag": true,
"xmlTemplateId": "",
"webServiceResponseBody": "",
"webServiceResponseXmlFillType": 1,
"dubboApplicationName": "",
"dubboConnectionType": "",
"dubboZookeeper": "",
"dubboInterface": "",
"dubboMethodName": "",
"dubboParamsType": "",
"dubboVersion": "",
"processId": "",
"procedureId": "",
"bindServicesConfigId": "",
"businessDataTagConfigs": "",
"changeMethod": "",
"slaLevel": 1,
"isSystem": 0,
"source": "GATEWAY",
"responseCookie": false,
"businessCodePath": "",
"businessMessagePath": "",
"configScore": 0,
"allowRetry": 0,
"assessConfigFlag": "0",
"gRpcServer": "",
"gRpcPackage": "",
"gRpcService": "",
"gRpcMethod": "",
"ferry": false,
"fileForwardPath": "",
"fileForwardSelectPathType": "",
"fileForwardSelectPath": "",
"connectionPool": 0,
"publishPortalStatus": "1",
"sourceModuleType": "",
"appId": "Algopilot",
"createTime": "2025-06-20 16:13:01",
"creator": "13693104300",
"creatorName": "许茂",
"editTime": "2025-06-24 13:36:15",
"editor": "13693104300",
"editorName": "许茂",
"tenantCode": "gyrj_tenant",
"paramsDocList": [
{
"id": "f9ff5b91868d41ccb755c1f2a6e7779b",
"fieldName": "",
"fieldId": "Pt",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "1.2",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "841c158c430a46748e391f078caf2c62",
"fieldName": "",
"fieldId": "Ph",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "1",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "25decf3885734b9ea3db3ba654518f2b",
"fieldName": "",
"fieldId": "Dchock",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "8.467",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "24f9497dc90249ff82abe6362a638042",
"fieldName": "",
"fieldId": "Rowl",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "906",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "68940892b97243f7bb686b4ba66449b1",
"fieldName": "",
"fieldId": "fw",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "0.5",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "3a6b09b7e4bd404e84585875f7de8134",
"fieldName": "",
"fieldId": "GLR",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "100",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
},
{
"id": "60419eb45bc641c68a14424944c7f1a8",
"fieldName": "",
"fieldId": "k",
"fieldType": "Number",
"hidden": false,
"breakFlag": false,
"required": false,
"sampleValue": "1.299",
"enCode": 0,
"order": 0,
"in": "body",
"errorCode": "",
"maxLength": 0,
"minLength": 0,
"deprecated": false,
"sortNum": 1
}
],
"subscribeApplyFlag": true,
"isBlackAndWhite": "0",
"publishTime": "2025-06-20 16:13:01",
"url": "/gyrj_tenant/gyrj_tenantalgopilot/v1/api/algopilot/property/chockQl",
"accessRouting": "https://lcapdev.cnpc.com.cn:8082/gateway",
"commentDocs": [],
"header": "{\"Content-Type\":\"*\"}",
"appName": "算法中试平台"
}

View File

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

271
history_viewer.py Normal file
View File

@ -0,0 +1,271 @@
import os
import sys
import json
import logging
import sqlite3
from pathlib import Path
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask, request, jsonify, send_from_directory, session, redirect, url_for, render_template, g, flash, get_flashed_messages, abort
from flask_cors import CORS
from functools import wraps
import markdown
app = Flask(__name__, static_folder='static', template_folder='templates')
CORS(app)
# --- 基本配置 ---
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
APP_ROOT = os.path.dirname(os.path.abspath(__file__))
DATABASE = os.path.join(APP_ROOT, 'users.db')
REPORTS_DIR = os.path.join(APP_ROOT, 'test_reports')
app.config['SECRET_KEY'] = os.urandom(24)
app.config['DATABASE'] = DATABASE
app.config['REPORTS_DIR'] = REPORTS_DIR
os.makedirs(app.config['REPORTS_DIR'], exist_ok=True)
# --- 数据库 Schema 和辅助函数 (与 flask_app.py 相同) ---
DB_SCHEMA = '''
DROP TABLE IF EXISTS user;
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
);
'''
def get_db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect(app.config['DATABASE'])
db.row_factory = sqlite3.Row
return db
@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()
def init_db(force_create=False):
if force_create or not os.path.exists(app.config['DATABASE']):
with app.app_context():
db = get_db()
db.cursor().executescript(DB_SCHEMA)
db.commit()
logger.info("数据库已初始化!")
create_default_user()
else:
logger.info("数据库已存在。")
def create_default_user(username="admin", password="admin123"):
with app.app_context():
db = get_db()
user = db.execute('SELECT * FROM user WHERE username = ?', (username,)).fetchone()
if user is None:
db.execute("INSERT INTO user (username, password_hash) VALUES (?, ?)", (username, generate_password_hash(password)))
db.commit()
logger.info(f"已创建默认用户: {username}")
else:
logger.info(f"默认用户 {username} 已存在。")
@app.cli.command('init-db')
def init_db_command():
init_db(force_create=True)
print("已初始化数据库。")
# --- 用户认证 (与 flask_app.py 相同) ---
@app.route('/login', methods=('GET', 'POST'))
def login():
if g.user:
return redirect(url_for('list_history'))
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
db = get_db()
error = None
user = db.execute('SELECT * FROM user WHERE username = ?', (username,)).fetchone()
if user is None:
error = '用户名不存在。'
elif not check_password_hash(user['password_hash'], password):
error = '密码错误。'
if error is None:
session.clear()
session['user_id'] = user['id']
session['username'] = user['username']
return redirect(url_for('list_history'))
flash(error)
return render_template('login.html')
@app.route('/logout')
def logout():
session.clear()
flash('您已成功登出。')
return redirect(url_for('login'))
def login_required(view):
@wraps(view)
def wrapped_view(**kwargs):
if g.user is None:
return redirect(url_for('login'))
return view(**kwargs)
return wrapped_view
@app.before_request
def load_logged_in_user():
user_id = session.get('user_id')
if user_id is None:
g.user = None
else:
g.user = get_db().execute('SELECT * FROM user WHERE id = ?', (user_id,)).fetchone()
# --- LLM配置视图 ---
CRITERIA_FILE_PATH = os.path.join(APP_ROOT, 'custom_testcases', 'llm', 'compliance_criteria.json')
@app.route('/llm-config', methods=['GET', 'POST'])
@login_required
def llm_config():
criteria_for_template = []
file_exists = os.path.exists(CRITERIA_FILE_PATH)
if request.method == 'POST':
# 从表单获取所有名为'criteria'的输入项,作为一个列表
criteria_list = request.form.getlist('criteria')
# 过滤掉用户可能提交的空规则
criteria_list = [item.strip() for item in criteria_list if item.strip()]
try:
# 将规则列表格式化为美观的JSON并保存
pretty_content = json.dumps(criteria_list, indent=2, ensure_ascii=False)
with open(CRITERIA_FILE_PATH, 'w', encoding='utf-8') as f:
f.write(pretty_content)
flash('LLM合规性标准已成功保存', 'success')
except Exception as e:
flash(f'保存文件时发生未知错误: {e}', 'error')
# 无论是GET还是POST请求后都重新从文件中读取最新的规则列表用于显示
if file_exists:
try:
with open(CRITERIA_FILE_PATH, 'r', encoding='utf-8') as f:
criteria_for_template = json.load(f)
# 确保文件内容确实是一个列表
if not isinstance(criteria_for_template, list):
flash('配置文件格式错误内容应为JSON数组。已重置为空列表。', 'error')
criteria_for_template = []
except Exception as e:
flash(f'读取配置文件时出错: {e}', 'error')
criteria_for_template = []
# 准备一个用于页面展示的示例API信息
example_api_info = {
"path_template": "/api/dms/instance/v1/message/push/myschema/1.0",
"method": "POST",
"title": "数据推送接口",
"description": "用于向系统推送标准格式的数据。",
"schema_request_body": {"...": "... (此处为请求体Schema定义)"},
"instance_url": "http://example.com/api/dms/instance/v1/message/push/myschema/1.0",
"instance_request_headers": {"X-Tenant-ID": "tenant-001", "...": "..."},
"instance_request_body": {"id": "123", "data": "example"},
"instance_response_status": 200,
"instance_response_body": {"code": 0, "message": "success", "data": True}
}
return render_template('llm_config.html', criteria=criteria_for_template, file_exists=file_exists, example_api_info=json.dumps(example_api_info, indent=2, ensure_ascii=False))
# --- 文件下载路由 ---
@app.route('/download/<path:run_id>/<path:filename>')
@login_required
def download_report(run_id, filename):
"""安全地提供指定运行记录中的报告文件下载。"""
# 清理输入,防止目录遍历攻击
run_id_safe = Path(run_id).name
filename_safe = Path(filename).name
reports_dir = Path(app.config['REPORTS_DIR']).resolve()
run_dir = (reports_dir / run_id_safe).resolve()
# 安全检查确保请求的目录是REPORTS_DIR的子目录
if not run_dir.is_dir() or run_dir.parent != reports_dir:
abort(404, "找不到指定的测试记录或权限不足。")
return send_from_directory(run_dir, filename_safe, as_attachment=True)
# --- 历史记录视图 ---
@app.route('/')
@login_required
def list_history():
history = []
reports_path = Path(app.config['REPORTS_DIR'])
if not reports_path.is_dir():
flash('报告目录不存在。')
return render_template('history.html', history=[])
# 获取所有子目录(即测试运行记录)
run_dirs = [d for d in reports_path.iterdir() if d.is_dir()]
# 按名称(时间戳)降序排序
run_dirs.sort(key=lambda x: x.name, reverse=True)
for run_dir in run_dirs:
summary_path = run_dir / 'summary.json'
details_path = run_dir / 'api_call_details.md'
run_info = {'id': run_dir.name, 'summary': None, 'has_details': details_path.exists()}
if summary_path.exists():
try:
with open(summary_path, 'r', encoding='utf-8') as f:
summary_data = json.load(f)
run_info['summary'] = summary_data
except (json.JSONDecodeError, IOError) as e:
logger.error(f"无法读取或解析摘要文件 {summary_path}: {e}")
run_info['summary'] = {'error': '无法加载摘要'}
history.append(run_info)
return render_template('history.html', history=history)
@app.route('/details/<run_id>')
@login_required
def show_details(run_id):
run_id = Path(run_id).name # Sanitize input
run_dir = Path(app.config['REPORTS_DIR']) / run_id
if not run_dir.is_dir():
return "找不到指定的测试记录。", 404
summary_path = run_dir / 'summary.json'
details_path = run_dir / 'api_call_details.md'
summary_content = "{}"
details_content = "### 未找到API调用详情报告"
if summary_path.exists():
try:
with open(summary_path, 'r', encoding='utf-8') as f:
summary_data = json.load(f)
summary_content = json.dumps(summary_data, indent=2, ensure_ascii=False)
except Exception as e:
summary_content = f"加载摘要文件出错: {e}"
if details_path.exists():
try:
with open(details_path, 'r', encoding='utf-8') as f:
# 将Markdown转换为HTML
details_content = markdown.markdown(f.read(), extensions=['fenced_code', 'tables', 'def_list', 'attr_list'])
except Exception as e:
details_content = f"加载详情文件出错: {e}"
return render_template('history_detail.html', run_id=run_id, summary_content=summary_content, details_content=details_content)
# --- 根路径重定向 ---
@app.route('/index')
def index_redirect():
return redirect(url_for('list_history'))
if __name__ == '__main__':
# 首次运行时确保数据库和用户存在
init_db()
app.run(debug=True, host='0.0.0.0', port=5051)

File diff suppressed because one or more lines are too long

View File

@ -17,4 +17,5 @@ prance[osv]>=23.0.0,<24.0.0
# pytest-cov>=4.0.0,<5.0.0
# httpx>=0.20.0,<0.28.0 # for testing API calls
Flask-Cors>=3.0
Flask-Cors>=3.0
markdown

View File

@ -13,6 +13,7 @@ import sys
import json
import logging
import argparse
import datetime
from pathlib import Path
from typing import List, Optional
@ -264,13 +265,13 @@ def save_api_call_details_to_file(api_call_details: List[APICallDetail], output_
markdown_content.append("- **Headers:**")
markdown_content.append("```json")
markdown_content.append(json.dumps(detail.request_headers, indent=2, ensure_ascii=False))
markdown_content.append(" ```")
markdown_content.append("```")
if detail.request_params:
markdown_content.append("- **Query Parameters:**")
markdown_content.append("```json")
markdown_content.append(json.dumps(detail.request_params, indent=2, ensure_ascii=False))
markdown_content.append(" ```")
markdown_content.append("```")
if detail.request_body is not None:
markdown_content.append("- **Body:**")
@ -293,7 +294,7 @@ def save_api_call_details_to_file(api_call_details: List[APICallDetail], output_
markdown_content.append(f"```{body_lang}")
markdown_content.append(formatted_body)
markdown_content.append(" ```")
markdown_content.append("```")
markdown_content.append("### Response Details")
markdown_content.append(f"- **Status Code:** `{detail.response_status_code}`")
@ -302,7 +303,7 @@ def save_api_call_details_to_file(api_call_details: List[APICallDetail], output_
markdown_content.append("- **Headers:**")
markdown_content.append("```json")
markdown_content.append(json.dumps(detail.response_headers, indent=2, ensure_ascii=False))
markdown_content.append(" ```")
markdown_content.append("```")
if detail.response_body is not None:
markdown_content.append("- **Body:**")
@ -329,7 +330,7 @@ def save_api_call_details_to_file(api_call_details: List[APICallDetail], output_
markdown_content.append(f"```{resp_body_lang}")
markdown_content.append(formatted_resp_body)
markdown_content.append(" ```")
markdown_content.append("```")
markdown_content.append("") # Add a blank line for spacing before next --- or EOF
markdown_content.append("---") # Separator
@ -376,27 +377,25 @@ def main():
categories = args.categories.split(',') if args.categories else None
tags = args.tags.split(',') if args.tags else None
DEFAULT_OUTPUT_DIR = Path("./test_reports")
output_directory: Path
main_report_file_path: Path
# 确定基础输出目录
base_output_dir = Path("./test_reports")
if args.output:
output_arg_path = Path(args.output)
if output_arg_path.suffix and output_arg_path.name: # Check if it looks like a file
output_directory = output_arg_path.parent
main_report_file_path = output_arg_path
else:
output_directory = output_arg_path
main_report_file_path = output_directory / f"summary.{args.format}"
else:
output_directory = DEFAULT_OUTPUT_DIR
main_report_file_path = output_directory / f"summary.{args.format}"
# 如果提供了 --output将其作为所有时间戳报告的基础目录。
# 这简化了逻辑:--output 始终是一个目录。
base_output_dir = Path(args.output)
# 为本次测试运行创建一个唯一的、带时间戳的子目录
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
output_directory = base_output_dir / timestamp
# 主摘要报告的文件路径现在位于新目录内
main_report_file_path = output_directory / f"summary.{args.format}"
try:
output_directory.mkdir(parents=True, exist_ok=True)
logger.info(f"主输出目录设置为: {output_directory.resolve()}")
logger.info(f"测试报告将保存到: {output_directory.resolve()}")
except OSError as e:
logger.error(f"创建输出目录失败 {output_directory}: {e}")
logger.error(f"创建输出目录失败 {output_directory}: {e}")
sys.exit(1)
orchestrator = APITestOrchestrator(
@ -468,36 +467,12 @@ def main():
# 保存主测试摘要 (现在可能包含测试阶段结果)
save_results(test_summary, str(main_report_file_path), args.format)
api_calls_output_path_str: Optional[str] = None
# 默认文件名现在是 .md
api_calls_filename: str = "api_call_details.md"
if args.api_calls_output:
api_calls_output_file = Path(args.api_calls_output)
# 确保后缀是 .md如果用户提供了其他后缀或没有后缀
if api_calls_output_file.suffix.lower() not in ['.md', '.markdown']:
api_calls_output_file = api_calls_output_file.with_suffix('.md')
logger.info(f"API调用详情输出文件名已调整为 Markdown 格式: {api_calls_output_file.name}")
api_calls_output_path_str = str(api_calls_output_file.parent)
api_calls_filename = api_calls_output_file.name
logger.info(f"API调用详情将以 Markdown 格式保存到: {api_calls_output_file}")
elif args.output:
output_arg_path = Path(args.output)
if output_arg_path.is_dir():
api_calls_output_path_str = str(output_arg_path)
else:
api_calls_output_path_str = str(output_arg_path.parent)
logger.info(f"API调用详情将以 Markdown 格式保存到目录 '{api_calls_output_path_str}' (使用默认文件名 '{api_calls_filename}')")
else:
api_calls_output_path_str = "."
logger.info(f"API调用详情将以 Markdown 格式保存到当前目录 '.' (使用默认文件名 '{api_calls_filename}')")
# 保存API调用详情
if orchestrator and api_calls_output_path_str:
# API调用详情报告也应保存在同一时间戳目录中
api_calls_filename = "api_call_details.md"
if orchestrator:
save_api_call_details_to_file(
orchestrator.get_api_call_details(),
api_calls_output_path_str,
str(output_directory),
filename=api_calls_filename
)

215
static/history_style.css Normal file
View File

@ -0,0 +1,215 @@
/* history_style.css */
/* Header styles */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.header-controls {
text-align: right;
}
.user-info {
font-size: 0.9em;
color: #555;
margin-bottom: 10px;
}
.user-info a {
text-decoration: none;
color: #007bff;
}
.user-info a:hover {
text-decoration: underline;
}
.download-btn {
background-color: #6c757d;
color: white !important;
margin-left: 5px;
}
.download-btn:hover {
background-color: #5a6268;
}
/* 悬浮导航按钮 */
.floating-nav {
position: fixed;
bottom: 30px;
right: 30px;
display: flex;
flex-direction: column;
gap: 10px; /* 按钮之间的间距 */
z-index: 1000;
}
.floating-nav .nav-btn {
background-color: #007bff;
color: white !important;
padding: 10px 15px;
border-radius: 5px;
text-decoration: none;
text-align: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: background-color 0.2s, transform 0.2s;
}
.floating-nav .nav-btn:hover {
background-color: #0056b3;
transform: scale(1.05); /* 轻微放大效果 */
}
/* Table styles */
.history-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.history-table th, .history-table td {
border: 1px solid #ddd;
padding: 12px 15px;
text-align: left;
}
.history-table th {
background-color: #f8f9fa;
font-weight: bold;
}
.history-table tbody tr:nth-child(even) {
background-color: #f2f2f2;
}
.history-table tbody tr:hover {
background-color: #e9ecef;
}
/* 根据最终状态为行添加柔和的背景色 */
.status-row-passed:nth-child(even) {
background-color: #e6f7ec;
}
.status-row-passed:nth-child(odd) {
background-color: #f2fbf5;
}
.status-row-failed:nth-child(even) {
background-color: #fbeae5;
}
.status-row-failed:nth-child(odd) {
background-color: #fef4f1;
}
.error-row {
color: #dc3545;
font-style: italic;
text-align: center !important;
}
/* Status badge styles */
.status-badge {
padding: 5px 10px;
border-radius: 12px;
color: white;
font-weight: bold;
font-size: 0.85em;
text-transform: uppercase;
}
.status-passed {
background-color: #28a745; /* Green */
}
.status-failed {
background-color: #dc3545; /* Red */
}
/* Button styles */
.button {
display: inline-block;
padding: 8px 12px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
transition: background-color 0.2s;
}
.button:hover {
background-color: #0056b3;
}
/* Detail page styles */
.details-container {
margin-top: 20px;
}
.json-output {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
padding: 15px;
border-radius: 5px;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Courier New', Courier, monospace;
}
/* Markdown body styles */
.markdown-body {
margin-top: 20px;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #fff;
line-height: 1.6;
}
.markdown-body h1, .markdown-body h2, .markdown-body h3 {
border-bottom: 1px solid #eee;
padding-bottom: 0.3em;
margin-top: 24px;
margin-bottom: 16px;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
margin-top: 1em;
margin-bottom: 1em;
}
.markdown-body th, .markdown-body td {
border: 1px solid #ddd;
padding: 8px;
}
.markdown-body th {
background-color: #f2f2f2;
}
.markdown-body pre {
background-color: #f6f8fa;
padding: 16px;
border-radius: 3px;
overflow: auto;
}
.markdown-body code {
font-family: 'Courier New', Courier, monospace;
background-color: rgba(27,31,35,0.05);
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
border-radius: 3px;
}
.markdown-body pre code {
background: none;
padding: 0;
margin: 0;
font-size: 100%;
}

93
templates/history.html Normal file
View File

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>测试历史记录</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='history_style.css') }}">
</head>
<body>
<div class="container">
<div class="header">
<h1>测试历史记录</h1>
<div class="user-info">
<a href="{{ url_for('llm_config') }}">LLM 标准配置</a> |
<span>欢迎, {{ g.user['username'] }}</span> |
<a href="{{ url_for('logout') }}">登出</a>
</div>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul class=flashes>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<table class="history-table">
<thead>
<tr>
<th>测试时间</th>
<th>状态</th>
<th>总计</th>
<th>通过</th>
<th>失败</th>
<th>错误</th>
<th>跳过</th>
<th>耗时 (秒)</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% if history %}
{% for run in history %}
{% set summary = run.summary %}
{% if summary and 'error' not in summary and 'overall_summary' in summary %}
{% set overall = summary.overall_summary %}
{# 根据成功率计算渐变色 (HSL: 0=红, 120=绿) #}
{% set success_rate_str = overall.test_case_success_rate | default('0%') %}
{% set success_rate = success_rate_str.replace('%', '')|float %}
{% set hue = success_rate * 1.2 %}
{% set status_text = 'PASSED' if overall.test_cases_failed == 0 and overall.test_cases_error == 0 and overall.stages_failed == 0 and overall.stages_error == 0 else 'FAILED' %}
{# 如果通过,行背景为淡绿色,否则为淡红色 #}
<tr class="status-row-{{ 'passed' if status_text == 'PASSED' else 'failed' }}">
<td>{{ run.id.replace('_', ' ') }}</td>
<td>
{# 状态标签使用动态计算的HSL颜色 #}
<span class="status-badge" style="background-color: hsl({{ hue }}, 85%, 45%);">
{{ "%.1f"|format(success_rate) }}%
</span>
</td>
<td>{{ overall.total_test_cases_executed }}</td>
<td>{{ overall.test_cases_passed }}</td>
<td>{{ overall.test_cases_failed }}</td>
<td>{{ overall.test_cases_error }}</td>
<td>{{ overall.test_cases_skipped_in_endpoint }}</td>
<td>{{ "%.2f"|format(summary.duration_seconds|float) }}</td>
<td>
<a href="{{ url_for('show_details', run_id=run.id) }}" class="button">查看详情</a>
</td>
</tr>
{% else %}
<tr>
<td>{{ run.id.replace('_', ' ') }}</td>
<td colspan="8" class="error-row">无法加载此运行的摘要信息 (未运行结束或报告格式不正确)。</td>
</tr>
{% endif %}
{% endfor %}
{% else %}
<tr>
<td colspan="9" style="text-align: center;">没有找到任何测试记录。</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</body>
</html>

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>测试详情 - {{ run_id }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='history_style.css') }}">
</head>
<body>
<div class="container">
<div class="header">
<h1>测试详情: {{ run_id }}</h1>
<div class="header-controls">
<div class="user-info">
<a href="{{ url_for('list_history') }}">返回列表</a> |
<a href="{{ url_for('llm_config') }}">LLM 标准配置</a> |
<span>欢迎, {{ g.user['username'] }}</span> |
<a href="{{ url_for('logout') }}">登出</a>
</div>
<div class="download-actions">
<a href="{{ url_for('download_report', run_id=run_id, filename='summary.json') }}" class="button download-btn">下载JSON报告</a>
<a href="{{ url_for('download_report', run_id=run_id, filename='api_call_details.md') }}" class="button download-btn">下载MD报告</a>
</div>
</div>
</div>
<div class="details-container">
<h2 id="summary-section">测试摘要 (JSON)</h2>
<pre class="json-output">{{ summary_content }}</pre>
<h2 id="details-section">API 调用详情</h2>
<div class="markdown-body">
{{ details_content|safe }}
</div>
</div>
<div class="floating-nav">
<a href="#summary-section" class="button nav-btn" title="跳转到摘要">测试摘要</a>
<a href="#details-section" class="button nav-btn" title="跳转到详情">API调用详情</a>
</div>
</div>
</body>
</html>

117
templates/llm_config.html Normal file
View File

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>LLM 合规性标准配置</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='history_style.css') }}">
<style>
.editor-section { margin-bottom: 20px; }
.info-box { background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 15px; border-radius: 5px; margin-top: 10px; }
.info-box h3 { margin-top: 0; }
.criteria-list-container { margin-bottom: 15px; }
.criteria-item { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.criteria-item input[type="text"] { flex-grow: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
.delete-btn { background-color: #dc3545; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; }
.delete-btn:hover { background-color: #c82333; }
.add-btn { background-color: #28a745; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>LLM 合规性标准配置</h1>
<div class="user-info">
<a href="{{ url_for('list_history') }}">返回列表</a> |
<span>欢迎, {{ g.user['username'] }}</span> |
<a href="{{ url_for('logout') }}">登出</a>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="config-container">
<div class="editor-section">
<h2>编辑合规性标准</h2>
<p>在这里,您可以直接增删改用于 <code>TC-LLM-COMPLIANCE-001</code> 测试用例的合规性标准规则。</p>
{% if file_exists %}
<form method="post">
<div id="criteria-list-container">
{% for rule in criteria %}
<div class="criteria-item">
<input type="text" name="criteria" value="{{ rule }}" class="form-control">
<button type="button" class="delete-btn">删除</button>
</div>
{% endfor %}
</div>
<button type="button" id="add-rule-btn" class="button add-btn">添加规则</button>
<hr style="margin: 20px 0;">
<input type="submit" value="保存所有标准" class="button">
</form>
{% else %}
<div class="flash-error">
错误:无法找到配置文件 <code>custom_testcases/llm/compliance_criteria.json</code>。请确保该文件存在于正确的位置。
</div>
{% endif %}
</div>
<div class="info-section">
<div class="info-box">
<h3>工作原理说明</h3>
<p>
此测试用例 (<code>TC-LLM-COMPLIANCE-001</code>) 利用大语言模型LLM自动评估API是否符合您在左侧定义的合规标准。
</p>
<p>
在测试运行时系统会收集API的**所有可用信息**包括URL、请求方法、描述、参数定义、请求体Schema、示例请求和实际响应等连同您定义的合规标准列表一同发送给大模型。
</p>
<p>
<strong>重要提示:</strong>大模型仅能基于其接收到的信息进行判断。因此,它非常适合检查命名规范、结构约定、数据格式等问题,但无法验证需要外部知识或业务上下文的规则(例如"此接口必须与XX系统同步")。
</p>
</div>
<div class="info-box">
<h3>发送给LLM的API信息示例</h3>
<p>为了做出判断系统会向LLM提供类似如下结构的API信息</p>
<pre class="json-output">{{ example_api_info }}</pre>
</div>
</div>
</div>
</div>
<template id="criteria-item-template">
<div class="criteria-item">
<input type="text" name="criteria" value="" class="form-control" placeholder="输入新的合规标准...">
<button type="button" class="delete-btn">删除</button>
</div>
</template>
<script>
document.addEventListener('DOMContentLoaded', function () {
const listContainer = document.getElementById('criteria-list-container');
const addBtn = document.getElementById('add-rule-btn');
const template = document.getElementById('criteria-item-template');
if (addBtn) {
addBtn.addEventListener('click', function () {
const clone = template.content.cloneNode(true);
listContainer.appendChild(clone);
});
}
if (listContainer) {
listContainer.addEventListener('click', function (e) {
if (e.target && e.target.classList.contains('delete-btn')) {
e.target.closest('.criteria-item').remove();
}
});
}
});
</script>
</body>
</html>

33
templates/login.html Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录 - DDMS合规性测试</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="container">
<h2>请登录</h2>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flashes">
{% for message in messages %}
<p class="flash-error">{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post" class="form-group">
<label for="username">用户名:</label>
<input type="text" id="username" name="username" required>
<label for="password">密码:</label>
<input type="password" id="password" name="password" required>
<input type="submit" value="登录">
</form>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff