一、项目介绍
基于 Python + Django + DRF + Vue3 + MySQL + Celery + Redis + Playwright + Airtest + Allure
基于 Django REST Framework + Vue3 前后端分离架构搭建的 TestHub 智能测试管理平台,
把测试工作中分散的工具整合到统一平台:在线编写用例、用例评审、接口测试、
Web UI 自动化、Android APP 自动化、AI 需求分析与用例生成、测试报告集成。
平台覆盖从需求文档到测试报告的全链路:上传 PDF/Word/TXT 需求文档 →
调用大模型解析业务需求 → 批量生成测试用例 → 进入评审流程 →
关联到接口/UI/APP 自动化执行 → 生成 Allure 报告,实现测试资产的一站式管理。技术栈明细
二、项目结构说明
python
testhub_platform/ # 项目根目录
├── backend/ # Django 项目配置包
│ ├── __init__.py # PyMySQL 伪装为 MySQLdb 的兼容入口
│ ├── settings.py # 全局配置(数据库/JWT/DRF/Celery/日志)
│ ├── urls.py # 根路由:统一按 /api/<模块>/ 分发
│ ├── wsgi.py # 生产 HTTP 入口(gunicorn 加载)
│ ├── asgi.py # WebSocket 入口(Daphne 加载)
│ ├── celery.py # Celery 应用初始化
│ └── middleware.py # 自定义中间件(CSRF 控制)
├── apps/ # 15 个业务应用(模块化拆分)
│ ├── users/ # 自定义用户模型 + JWT 登录/登出
│ ├── projects/ # 项目、成员角色、环境配置
│ ├── testcases/ # 测试用例、步骤、附件、评论
│ ├── testsuites/ # 测试套件
│ ├── executions/ # 测试计划、执行、结果
│ ├── reports/ # 测试报告
│ ├── reviews/ # 用例评审流程、模板、检查项
│ ├── versions/ # 版本管理
│ ├── assistant/ # Dify AI 助手
│ ├── requirement_analysis/ # AI 需求分析 + 用例生成 + AI 模型配置
│ ├── api_testing/ # 接口测试(HTTP/WebSocket)
│ ├── ui_automation/ # Web UI 自动化(Selenium/Playwright)
│ ├── app_automation/ # Android APP 自动化(Airtest)
│ ├── core/ # 公共能力:变量解析/定位策略/定时调度
│ └── data_factory/ # 数据工厂:造数、编码、加密、JSON 工具
├── frontend/ # Vue3 前端
│ ├── src/
│ │ ├── views/ # 84 个页面(按模块分目录)
│ │ ├── api/ # 接口封装,按模块拆分
│ │ ├── stores/ # Pinia 状态管理(user / app)
│ │ ├── router/ # Vue Router 路由与登录守卫
│ │ ├── utils/api.js # axios 实例:JWT 注入 + 自动刷新
│ │ └── layout/ # 整体布局
│ ├── vite.config.js # Vite 配置(端口 3000,代理 /api → 8000)
│ └── package.json
├── docs/ # 20 篇功能与排查文档
├── allure/ # Allure 命令行工具
├── media/ # 上传文件与报告产物
├── logs/ # 运行日志
├── manage.py # 开发期命令入口
├── requirements.txt # 依赖清单
├── .env # 环境变量(数据库/密钥/Redis)
└── start-testhub.ps1 # 一键启动脚本(数据库/后端/前端/Redis)
业务应用内部结构:
python
apps/api_testing/
├── models.py # 数据模型
├── serializers.py # 校验 + 序列化
├── views.py # 业务逻辑
├── urls.py # 路由
├── admin.py # 后台注册
├── request_utils.py # 请求组装与响应提取
├── utils.py # 断言执行
├── variable_resolver.py # 变量解析(委托 core)
└── tasks.py # Celery 异步任务
三、核心代码
1.程序主入口与请求链路
Django四个入口:
|-------------------|----------------------------------|
| 入口 | 用途 |
| manage.py | 开发期命令(runserver/ migrate/ shell) |
| backend/wsgi.py | 生产 HTTP(gunicorn 加载) |
| backend/asgi.py | WebSocket(Daphne 加载) |
| backend/celery.py | 异步任务 |
代码如下:manage.py
python
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
代码说明
设置配置模块
python
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
- 告诉 Django 到哪个模块读取配置。
- 用 setdefault 而不是直接赋值,是为了允许环境变量覆盖,生产环境可以通过外部变量切换不同 settings,不用改代码。
Django 命令行框架
python
execute_from_command_line(sys.argv)
- 把命令行参数交给 Django 解析并执行对应命令
- 万能入口:runserver、migrate、createsuperuser、shell全部走这里,不只是"启动服务器"
判断__name__ == 'main:
- 保证该文件被其他模块 import 时不会自动执行命令行逻辑
启动链路
python
manage.py:9 设置 DJANGO_SETTINGS_MODULE='backend.settings'
manage.py:13 execute_from_command_line(sys.argv)
↓
backend/settings.py 读取配置
├ MIDDLEWARE(CORS / CSRF / Session / 认证)
├ ROOT_URLCONF = 'backend.urls' ← 真正的路由中枢
├ AUTH_USER_MODEL = 'users.User' ← 自定义用户模型
└ CELERY_BROKER_URL ← 读 .env 的 REDIS_URL
↓
backend/urls.py 按路径分发到各模块
↓
apps/<模块>/urls.py → views.py → serializers.py → models.py → MySQL
manage.py 只是启动器,真正决定应用行为的是 settings.py(配置)和 urls.py(路由)
路由分发:backend/urls.py
python
urlpatterns = [
path('admin/', admin.site.urls),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('api/auth/', include('apps.users.urls')),
path('api/projects/', include('apps.projects.urls')),
path('api/testcases/', include('apps.testcases.urls')),
path('api/requirement-analysis/', include('apps.requirement_analysis.urls')),
path('api/ui-automation/', include('apps.ui_automation.urls')),
path('api/app-automation/', include('apps.app_automation.urls')),
path('api/data-factory/', include('apps.data_factory.urls')),
...
]
- 所有接口统一以 /api/为前缀,根据 URL 查找对应代码目录。
2. 统一变量解析引擎(platforms 核心亮点)
接口测试和 UI 自动化都需要动态参数:随机手机号、时间戳、加密串等。
平台用一套 ${function_name(args)} 语法统一解决。
代码如下:apps/core/variable_resolver.py
python
# 工具名 → (工具类别, 真实实现函数)
TOOL_FUNCTIONS = {
# ---------- 随机工具 ----------
'random_int': ('random', RandomTools.random_int),
'random_uuid': ('random', RandomTools.random_uuid),
'random_date': ('random', RandomTools.random_date),
# ---------- 测试数据工具 ----------
'random_phone': ('test_data', TestDataTools.generate_chinese_phone),
'generate_id_card': ('test_data', TestDataTools.generate_id_card),
# ---------- 编码工具 ----------
'base64_encode': ('encoding', EncodingTools.base64_encode),
'generate_qrcode': ('encoding', EncodingTools.generate_qrcode),
# ---------- 加密工具 ----------
'md5': ('encryption', EncryptionTools.md5_hash),
'sha256_hash': ('encryption', EncryptionTools.sha256_hash),
# ---------- 时间日期函数(本地实现)----------
'timestamp': ('time', '_time_timestamp'),
'date': ('time', '_time_date'),
...
}
class VariableResolver:
"""统一变量解析器 - 使用数据工厂工具"""
functions = TOOL_FUNCTIONS
def __init__(self):
# 类别 → 参数翻译器
self.BUILDERS = {
'random': self._build_random_kwargs,
'test_data': self._build_test_data_kwargs,
'string': self._build_string_kwargs,
'encoding': self._build_encoding_kwargs,
'encryption': self._build_encryption_kwargs,
'crontab': self._build_crontab_kwargs,
}
def resolve(self, text):
"""解析文本中的动态函数占位符
Args:
text: 包含动态函数的文本,如 "Hello ${random_string(8)}"
Returns:
解析后的文本
"""
if not isinstance(text, str):
return text
# 匹配 ${function_name(args)} 模式
pattern = r'\$\{([^}]+)\}'
def replace_func(match):
expression = match.group(1)
try:
return str(self._evaluate_expression(expression))
except Exception as e:
if isinstance(e, UnicodeEncodeError):
return match.group(0)
try:
print(f"[WARNING] Variable resolution failed: ${{{expression}}} - {str(e)}")
except UnicodeEncodeError:
print(f"[WARNING] Variable resolution failed: ${{{expression}}}")
return match.group(0)
return re.sub(pattern, replace_func, text)
def _evaluate_expression(self, expression):
"""评估单个表达式,如 "random_int(100, 200)" 或 "timestamp()" """
match = re.match(r'(\w+)\((.*)\)', expression.strip())
if not match:
# 无参数函数
func_name = expression.strip()
args = []
else:
func_name = match.group(1)
args_str = match.group(2)
args = self._parse_args(args_str)
# 只查一次表:函数名 → (调用类别, 实现函数)
spec = TOOL_FUNCTIONS.get(func_name)
if spec is None:
return _Unresolved(func_name)
kind, tool_func = spec
tool_func = _load(tool_func)
if kind == 'time':
# 时间函数是本地实现,直接按 (func_name, args) 调用
return tool_func(self, func_name, args)
builder = self.BUILDERS.get(kind)
if builder is None:
return _Unresolved(func_name)
return _run_tool(func_name, args, tool_func, builder)
方法说明:
单一登记表(注册表模式)
python
TOOL_FUNCTIONS = {
'random_int': ('random', RandomTools.random_int),
...
}
- 用字典隐射函数名映射到"类别+真是实现函数"
- 新增函数只需注册一行,不改解析逻辑(对扩展开放、对修改封闭)
- 所有实现最终都委托给 data_factory`的各类工具类
参数解析:
python
match = re.match(r'(\w+)\((.*)\)', expression.strip())
- (\w+)捕获函数名,\((.*)\) 捕获括号内全部参数串。
- 匹配失败说明是无参函数,走 args = \[\]分支。
异常兜底
python
except Exception as e:
if isinstance(e, UnicodeEncodeError):
return match.group(0)
...
return match.group(0)
- 解析失败时返回原始占位符而不是抛异常。
- 单个变量写错不会导致整个请求失败,便于定位问题。
- 对 UnicodeEncodeError 单独判断,是为了兼容 Windows GBK 控制台(打印中文/emoji 会报编码错误)
示例:
python
# 接口测试的请求体配置
body:
phone: "${random_phone()}"
name: "${generate_chinese_name()}"
sign: "${md5(123456)}"
ts: "${timestamp()}"
3.接口测试:
代码如下:apps/api_testing/request_utils.py
python
"""
接口测试公共工具模块。
集中此前散落在 views.py / utils.py 中重复拷贝的逻辑:
- 环境变量 {{var}} 替换(字符串 / 递归 dict)
- 动态函数 ${func()} 解析(委托 core.VariableResolver)
- 环境变量合并(GLOBAL + LOCAL,局部覆盖全局)
- 请求组装(headers / params / body,支持 json / raw / form-urlencoded / form-data)
- 响应提取(extractors:json_path / header / status_code / regex)→ 供接口关联使用
"""
import json
import re
from .variable_resolver import VariableResolver
# ---------------------------------------------------------------------------
# 1. 环境变量 {{var}} 替换
# ---------------------------------------------------------------------------
def replace_env_variables(text, variables):
"""把文本中的 {{key}} 占位符替换为环境变量值。
变量值支持两种形态:普通标量,或前端变量对象 {currentValue, initialValue}。
"""
if not isinstance(text, str):
return text
result = text
for key, value in (variables or {}).items():
if isinstance(value, dict):
replacement = str(value.get('currentValue', '') or value.get('initialValue', ''))
else:
replacement = str(value) if value is not None else ''
result = result.replace(f'{{{{{key}}}}}', replacement)
return result
def replace_env_variables_in_obj(data, variables):
"""递归替换 dict / list / str 中的 {{var}}。"""
if isinstance(data, dict):
return {k: replace_env_variables_in_obj(v, variables) for k, v in data.items()}
if isinstance(data, list):
return [replace_env_variables_in_obj(item, variables) for item in data]
if isinstance(data, str):
return replace_env_variables(data, variables)
return data
def resolve_dynamic_in_obj(data, resolver):
"""递归解析 dict / list / str 中的 ${func()} 动态函数。"""
if isinstance(data, dict):
return {k: resolve_dynamic_in_obj(v, resolver) for k, v in data.items()}
if isinstance(data, list):
return [resolve_dynamic_in_obj(item, resolver) for item in data]
if isinstance(data, str):
return resolver.resolve(data)
return data
代码说明:
变量:
|-----------|-----------|--------------|
| 语法 | 语义 | 数据来源 |
| {{key}} | 环境变量静态替换 | 环境配置表(全局+局部) |
| ${func()} | 动态函数运行时生成 | 数据工厂工具 |
- 分开的好处:环境变量是"配置"(如 {{base_url}}、{{token}}),动态函数是"生成"(如 ${random_phone()}),语义清晰、便于排查。如果混用一种语法,出问题时分不清是配置错了还是函数错了。
递归处理嵌套结构
python
def replace_env_variables_in_obj(data, variables):
if isinstance(data, dict):
return {k: replace_env_variables_in_obj(v, variables) for k, v in data.items()}
if isinstance(data, list):
return [replace_env_variables_in_obj(item, variables) for item in data]
if isinstance(data, str):
return replace_env_variables(data, variables)
return data
- 请求体是多层嵌套的 JSON(如 `{"data": {"user": {"phone": "{{phone}}"}}}`),
必须递归才能替换到最内层。 - 三个分支覆盖 dict/ list / str,最后 return data 兜底数字、布尔等类型。
- 递归遍历(对树形结构做递归映射):按节点类型分派,容器节点递归处理子节点,叶子节点就地处理,碰到非容器类型即终止。写法短且不会漏层级。
兼容两种变量值形态:
python
if isinstance(value, dict):
replacement = str(value.get('currentValue', '') or value.get('initialValue', ''))
else:
replacement = str(value) if value is not None else ''
- 前端传来的变量可能是**普通标量**,也可能是对象{currentValue, initialValue}(用于界面展示当前值与原值)。
- 用 or 短路:currentValue 为空时回退到 initialValue。
4.断言执行引擎
代码如下:apps/api_testing/utils.py
python
def execute_assertions(response, assertions):
"""执行断言验证"""
results = []
for assertion in assertions:
result = {
'name': assertion.get('name', '未命名断言'),
'type': assertion.get('type'),
'passed': False,
'expected': assertion.get('expected'),
'actual': None,
'error': None
}
try:
assertion_type = assertion.get('type')
expected = assertion.get('expected')
actual = None
passed = False
if assertion_type == 'status_code':
actual = response.status_code
passed = actual == expected
elif assertion_type == 'response_time':
# 响应时间断言在调用方处理
actual = assertion.get('actual_time')
passed = actual <= expected if actual else False
elif assertion_type == 'contains':
text = response.text or ''
pattern = str(expected)
actual = text[:200] + '...' if len(text) > 200 else text
passed = pattern in str(text)
elif assertion_type == 'json_path':
json_path = assertion.get('json_path', '')
expected_value = assertion.get('expected')
actual = None
passed = False
try:
# 检查响应是否为JSON格式
content_type = response.headers.get('content-type', '').lower()
if 'application/json' not in content_type:
raise ValueError(f"响应不是JSON格式,Content-Type: {content_type}")
response_json = json.loads(response.text)
# 检查JSONPath表达式是否为空
if not json_path:
raise ValueError("JSON路径表达式不能为空")
from jsonpath_ng import parse
matches = parse(json_path).find(response_json)
actual = matches[0].value if matches else None
passed = str(actual) == str(expected_value)
# 确保actual值被正确设置到result中
result['actual'] = actual
except json.JSONDecodeError as e:
actual = None
代码说明
统一的断言结果结构
python
result = {
'name': ..., 'type': ..., 'passed': False,
'expected': ..., 'actual': None, 'error': None
}
- 每条断言都产出结构一致的结果对象,这样前端可以直接渲染成表格(断言名/预期/实际/是否通过),不需要为每种断言类型写不同的展示逻辑。
六种断言类型:
|---------------|--------|---------------|------------------------------------------------|
| type | 界面显示名 | 校验内容 | 实现要点 |
| status_code | 状态码 | HTTP状态码 | 直接比较response.status_code |
| response_time | 响应时间 | 响应时间是否超时 | 耗时由调用方测量后传入 |
| contains | 包含文本 | 响应文本是否含子串 | 阶段到200字符避免报告过长 |
| json_path | JSON路径 | 按JSONPath取值比较 | 用jsonpath_ng解析 |
| header | 响应头 | 指定响应头的值 | response.headers.get(header_name) |
| equals | 完全匹配 | 响应全文与预期完全相等 | response.text.strip() == str(expected).strip() |
json_path比equals更常用。
5.JWT双Token+前端无感刷新
后端:Token策略配置(backend/settings.py)
python
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), # access_token 60分钟
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # refresh_token 7天
'ROTATE_REFRESH_TOKENS': True, # 刷新时轮换 refresh_token
'BLACKLIST_AFTER_ROTATION': True, # 旧的 refresh_token 加入黑名单
'UPDATE_LAST_LOGIN': True, # 更新最后登录时间
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
}
登录接口签发双Token(apps/users/views.py)
python
@api_view(['POST'])
@permission_classes([permissions.AllowAny])
@csrf_exempt
def login_view(request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
# JWT Token (优先使用JWT)
refresh = RefreshToken.for_user(user)
access_token = str(refresh.access_token)
refresh_token = str(refresh)
return Response({
'user': UserSerializer(user).data,
'access': access_token, # JWT access token
'refresh': refresh_token, # JWT refresh token
'message': '登录成功'
})
代码说明:
双token
|------|--------------|---------------|
| | Access Token | Refresh Token |
| 有效期 | 60分钟 | 7天 |
| 携带频率 | 每次业务请求都带 | 只在刷新时用 |
防重放:
python
'ROTATE_REFRESH_TOKENS': True, # 刷新时签发新 refresh
'BLACKLIST_AFTER_ROTATION': True, # 旧 refresh 立即进黑名单
- 每次刷新都换一对新 Token,旧的 refresh 立刻作废
- 这样即使 refresh 被窃取,攻击者用过一次后原用户下次刷新就会失败,从而暴露异常
前端:请求排队无感刷新(frontend/src/utils/api.js)
python
// 正在刷新的标志
let isRefreshing = false
// 等待刷新的请求队列
let failedQueue = []
// 处理队列中的请求
const processQueue = (error, token = null) => {
failedQueue.forEach(prom => {
if (error) {
prom.reject(error)
} else {
prom.resolve(token)
}
})
failedQueue = []
}
// 请求拦截器
api.interceptors.request.use(
async (config) => {
const userStore = useUserStore()
// 检查是否是刷新token的请求
if (config.url === '/auth/token/refresh/') {
return config
}
// 如果有access token
if (userStore.accessToken) {
// 检查token是否即将过期(5分钟内)
if (userStore.isTokenExpiringSoon && !userStore.isTokenExpired) {
// 如果没有正在刷新,开始刷新
if (!isRefreshing) {
isRefreshing = true
try {
const newToken = await userStore.refreshAccessToken()
processQueue(null, newToken)
// 更新当前请求的token
config.headers.Authorization = `Bearer ${newToken}`
} catch (error) {
processQueue(error, null)
return Promise.reject(error)
} finally {
isRefreshing = false
}
} else {
// 如果正在刷新,将请求加入队列
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject })
}).then(token => {
config.headers.Authorization = `Bearer ${token}`
return config
})
}
}
config.headers.Authorization = `Bearer ${userStore.accessToken}`
}
return config
},
(error) => Promise.reject(error)
)
用"标志位+队列"把并发的刷新请求串行化,保证同一时刻只有一个刷新
python
let isRefreshing = false // 是否正在刷新
let failedQueue = [] // 等待刷新的请求队列
|--------------|------------------|
| 变量 | 作用 |
| isRefreshing | 保证同一时刻只有一个刷新 |
| failedQueue | 挂起等待中的请求,刷新完统一唤醒 |
6.AI模型统一接入层
代码如下:apps/requirement_analysis/models.py
python
class AIModelConfig(models.Model):
"""AI模型配置"""
ROLE_CHOICES = [
('writer', '测试用例编写专家'),
('reviewer', '测试评审专家'),
('browser_use_text', 'Browser Use - 文本模式'),
('browser_use_vision', 'Browser Use - 视觉模式'),
]
name = models.CharField(max_length=100, verbose_name='配置名称')
model_type = models.CharField(max_length=50, choices=MODEL_TYPE_CHOICES,
verbose_name='模型类型')
role = models.CharField(max_length=20, choices=ROLE_CHOICES, verbose_name='角色')
api_key = models.CharField(max_length=200, verbose_name='API Key', blank=True, null=True)
base_url = models.URLField(verbose_name='API Base URL')
model_name = models.CharField(max_length=100, verbose_name='模型名称')
max_tokens = models.IntegerField(default=4096, verbose_name='最大Token数')
temperature = models.FloatField(default=0.7, verbose_name='温度')
is_active = models.BooleanField(default=True, verbose_name='是否启用')
@classmethod
def get_active_config(cls, model_type: str, role: str):
return cls.objects.filter(
model_type=model_type,
role=role,
is_active=True,
).first()
base_url归一化管理
python
base_url = config.base_url.rstrip('/')
if not base_url.endswith('/chat/completions'):
version_match = re.search(r'/v(\d+)/?$', base_url)
if version_match:
url = f"{base_url}/chat/completions"
else:
# 但对于某些API(如DeepSeek),base_url可能已经是 https://api.deepseek.com
url = f"{base_url}/v1/chat/completions"
else:
url = base_url
角色路由:按用途分类
python
ROLE_CHOICES = [
('writer', '测试用例编写专家'),
('reviewer', '测试评审专家'),
...
]
API Key 的脱敏处理(apps/requirement_analysis/serializers.py)
python
class AIModelConfigSerializer(serializers.ModelSerializer):
api_key_masked = serializers.SerializerMethodField(read_only=True)
class Meta:
fields = ['id', 'name', 'model_type', 'role', 'api_key', 'api_key_masked',
'base_url', 'model_name', ...]
extra_kwargs = {
'api_key': {'write_only': True} # API Key只用于写入,不在响应中返回
}
def get_api_key_masked(self, obj):
if obj.api_key:
if len(obj.api_key) > 7:
return f"{obj.api_key[:3]}{'*' * (len(obj.api_key) - 7)}{obj.api_key[-4:]}"
return '*' * len(obj.api_key)