提交 59948f7c authored 作者: 陈泽健's avatar 陈泽健

feat(performance): 混合场景 mix 每虚拟用户独立登录(CSV账号各自token + 登录重登替换)

- 后端 HttpClient 新增 login_with_credentials(username,password) 与 _extract_token 静态方法
  - 复用 captcha uuid + SHA256 加密 + 签名;不写入 _tokens 缓存(token 由 executor 管理)
- 执行引擎 per-VU 状态:_vu_tokens Dict[int,str] + _vu_tokens_lock + _vu_login_failed
  - _ensure_vu_token 异步幂等(run_in_executor 包裹同步登录),失败记入 _vu_login_failed
  - _resolve_csv_credentials 按 global_thread_idx 分配 CSV 行(idx % len 循环复用)
  - _mix_worker 启动各自登录;登录接口进随机池命中后替换该 VU 自己的 token
  - _send_request 新增 bearer_token 可选参 + 返回 Optional[str](登录 2xx 提取新 token)
  - _build_headers bearer_token 优先于 self._token;登录目标 bearer_token="" 签名且无 Authorization
  - _is_login_target 支持 url_override(识别随机池中的登录接口)
  - _run_mix 校验:CSV 未启用/缺 username/password 列/账号数 < 并发数 → 拒绝执行
  - execute() per_vu_login 时跳过共享预登录
- Model/Schema 增加 per_vu_login 字段(默认 False)+ _ensure_columns 自动迁移
- 前端 types perVuLogin + TaskList.vue「每虚拟用户独立登录」开关(仅 mix 显示)
- 单测 TestPerVuLogin 13 条(各自token/重登替换/失败处理/CSV校验/共享回归)
- 已部署 5.60(含 performance_tasks.per_vu_login 自动迁移);端到端由用户手动验证
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 63ed9c69
此差异已折叠。
...@@ -177,6 +177,8 @@ async def _ensure_columns(conn) -> None: ...@@ -177,6 +177,8 @@ async def _ensure_columns(conn) -> None:
("performance_tasks", "login_username", "VARCHAR(200) DEFAULT NULL"), ("performance_tasks", "login_username", "VARCHAR(200) DEFAULT NULL"),
# 性能测试:自定义登录密码(旧库升级) # 性能测试:自定义登录密码(旧库升级)
("performance_tasks", "login_password", "VARCHAR(200) DEFAULT NULL"), ("performance_tasks", "login_password", "VARCHAR(200) DEFAULT NULL"),
# 性能测试:per-VU 独立登录(mix 场景各虚拟用户独立 token,旧库升级)
("performance_tasks", "per_vu_login", "BOOLEAN DEFAULT 0"),
# 定时任务:用例类型(旧库升级,区分 UI/安全测试) # 定时任务:用例类型(旧库升级,区分 UI/安全测试)
("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"), ("scheduled_tasks", "case_type", "VARCHAR(20) DEFAULT 'ui'"),
# 定时任务:安全测试配置ID(旧库升级) # 定时任务:安全测试配置ID(旧库升级)
......
...@@ -116,6 +116,28 @@ class HttpClient: ...@@ -116,6 +116,28 @@ class HttpClient:
return session return session
@staticmethod
def _extract_token(data: Any) -> Optional[str]:
"""
从登录响应中提取 Token(多路径兼容)
Args:
data: 登录接口 JSON 响应体
Returns:
str: Token,未找到返回 None
"""
if not isinstance(data, dict):
return None
return (
data.get('accessToken') or
data.get('token') or
data.get('access_token') or
data.get('data', {}).get('accessToken') or
data.get('data', {}).get('token') or
data.get('data', {}).get('access_token')
)
@staticmethod @staticmethod
def encrypt_password(password: str) -> str: def encrypt_password(password: str) -> str:
""" """
...@@ -313,16 +335,7 @@ class HttpClient: ...@@ -313,16 +335,7 @@ class HttpClient:
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
# 尝试从响应中提取 Token # 尝试从响应中提取 Token
token = None token = self._extract_token(data)
if isinstance(data, dict):
token = (
data.get('accessToken') or
data.get('token') or
data.get('access_token') or
data.get('data', {}).get('accessToken') or
data.get('data', {}).get('token') or
data.get('data', {}).get('access_token')
)
if token: if token:
self._tokens[account_key] = token self._tokens[account_key] = token
...@@ -338,6 +351,71 @@ class HttpClient: ...@@ -338,6 +351,71 @@ class HttpClient:
return None return None
def login_with_credentials(self, username: str, password: str) -> Optional[str]:
"""
使用明文账号密码登录并获取 Token(per-VU 独立登录用)
与 login() 的区别:凭据由调用方传入(非 config.accounts),
返回的 Token 不写入 self._tokens 缓存(由调用方管理各自的 token)。
Args:
username: 账号用户名
password: 明文密码(内部做 SHA256 加密)
Returns:
str: 访问 Token,失败返回 None
"""
auth_config = self.config.get("auth", {})
login_path = auth_config.get("login_path", "/platform/api/auth/login")
url = f"{self.base_url}{login_path}"
captcha = self.config.get("accounts", {}).get("captcha", "csba")
# 获取验证码 UUID
uuid_val = self.get_captcha_uuid() or ''
# 构造登录请求体
encrypted_pwd = self.encrypt_password(password)
payload = {
"username": username,
"password": encrypted_pwd,
"code": captcha,
"uuid": uuid_val,
}
# 生成签名头部(登录时无 Token,使用随机密钥)
sign_headers = self._generate_sign(body_data=payload, bearer_token="")
# 通用请求头
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/plain, */*',
'referer': '',
}
headers.update(sign_headers)
try:
logger.info(f"per-VU 正在登录: {username}")
resp = self.session.post(
url, json=payload, headers=headers,
verify=self.verify_ssl, timeout=self.timeout
)
if resp.status_code == 200:
data = resp.json()
token = self._extract_token(data)
if token:
logger.info(f"per-VU 登录成功: {username}, Token: {token[:30]}...")
return token
else:
logger.warning(f"per-VU 登录响应中未找到 Token: {json.dumps(data, ensure_ascii=False)[:300]}")
else:
logger.warning(f"per-VU 登录失败: {username}, HTTP {resp.status_code}: {resp.text[:200]}")
except Exception as e:
logger.error(f"per-VU 登录异常 [{username}]: {e}")
return None
def get_token(self, account_key: str = "superadmin") -> Optional[str]: def get_token(self, account_key: str = "superadmin") -> Optional[str]:
""" """
获取指定账号的 Token(如未登录则自动登录) 获取指定账号的 Token(如未登录则自动登录)
......
...@@ -226,6 +226,10 @@ class PerformanceTask(Base): ...@@ -226,6 +226,10 @@ class PerformanceTask(Base):
login_password: Mapped[Optional[str]] = mapped_column( login_password: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True, default=None, comment="自定义登录密码" String(200), nullable=True, default=None, comment="自定义登录密码"
) )
# per-VU 独立登录(mix 场景,CSV 账号各自登录持有独立 token,2026-09-01 新增)
per_vu_login: Mapped[bool] = mapped_column(
Boolean, default=False, comment="每个虚拟用户独立登录(CSV账号各自获取token)"
)
# 签名配置 # 签名配置
sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") sign_request: Mapped[bool] = mapped_column(Boolean, default=False, comment="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
...@@ -379,6 +383,7 @@ class PerformanceTask(Base): ...@@ -379,6 +383,7 @@ class PerformanceTask(Base):
"account_key": self.account_key, "account_key": self.account_key,
"login_username": self.login_username, "login_username": self.login_username,
"login_password": self.login_password, "login_password": self.login_password,
"per_vu_login": self.per_vu_login,
"sign_request": self.sign_request, "sign_request": self.sign_request,
"preset_id": self.preset_id, "preset_id": self.preset_id,
"assertions": self.assertions or [], "assertions": self.assertions or [],
......
...@@ -145,6 +145,8 @@ class PerformanceTaskCreate(BaseModel): ...@@ -145,6 +145,8 @@ class PerformanceTaskCreate(BaseModel):
account_key: str = Field("superadmin", description="使用的账号") account_key: str = Field("superadmin", description="使用的账号")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认") login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认") login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
# per-VU 独立登录(mix 场景:CSV 账号各自登录持有独立 token,2026-09-01 新增)
per_vu_login: bool = Field(False, description="每个虚拟用户独立登录(CSV账号各自获取token)")
# 签名配置 # 签名配置
sign_request: bool = Field(False, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") sign_request: bool = Field(False, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
...@@ -212,6 +214,8 @@ class PerformanceTaskUpdate(BaseModel): ...@@ -212,6 +214,8 @@ class PerformanceTaskUpdate(BaseModel):
account_key: Optional[str] = Field(None, description="使用的账号") account_key: Optional[str] = Field(None, description="使用的账号")
login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认") login_username: Optional[str] = Field(None, description="自定义登录用户名,留空使用默认")
login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认") login_password: Optional[str] = Field(None, description="自定义登录密码,留空使用默认")
# per-VU 独立登录(mix 场景,2026-09-01 新增)
per_vu_login: Optional[bool] = Field(None, description="每个虚拟用户独立登录(CSV账号各自获取token)")
sign_request: Optional[bool] = Field(None, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)") sign_request: Optional[bool] = Field(None, description="请求是否需要签名(X-RANDOM/X-TIMESTAMP/X-SIGN)")
preset_id: Optional[str] = Field(None, description="关联的接口预设ID") preset_id: Optional[str] = Field(None, description="关联的接口预设ID")
assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表") assertions: Optional[List[AssertionRule]] = Field(None, description="断言规则列表")
...@@ -257,6 +261,7 @@ class PerformanceTaskResponse(BaseModel): ...@@ -257,6 +261,7 @@ class PerformanceTaskResponse(BaseModel):
account_key: str = "superadmin" account_key: str = "superadmin"
login_username: Optional[str] = None login_username: Optional[str] = None
login_password: Optional[str] = None login_password: Optional[str] = None
per_vu_login: bool = False
sign_request: bool = False sign_request: bool = False
assertions: List[Dict[str, Any]] = [] assertions: List[Dict[str, Any]] = []
capture_rules: Optional[List[Dict[str, Any]]] = None capture_rules: Optional[List[Dict[str, Any]]] = None
......
...@@ -350,6 +350,7 @@ export interface PerformanceTask { ...@@ -350,6 +350,7 @@ export interface PerformanceTask {
accountKey: string | null accountKey: string | null
loginUsername: string | null loginUsername: string | null
loginPassword: string | null loginPassword: string | null
perVuLogin: boolean
signRequest: boolean signRequest: boolean
assertions: AssertionRule[] assertions: AssertionRule[]
totalRequests: number | null totalRequests: number | null
...@@ -446,6 +447,7 @@ export interface PerformanceTaskCreate { ...@@ -446,6 +447,7 @@ export interface PerformanceTaskCreate {
accountKey?: string | null accountKey?: string | null
loginUsername?: string | null loginUsername?: string | null
loginPassword?: string | null loginPassword?: string | null
perVuLogin?: boolean
signRequest?: boolean signRequest?: boolean
assertions?: AssertionRule[] assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null uniqueFields?: UniqueFieldRule[] | null
...@@ -494,6 +496,7 @@ export interface PerformanceTaskUpdate { ...@@ -494,6 +496,7 @@ export interface PerformanceTaskUpdate {
accountKey?: string | null accountKey?: string | null
loginUsername?: string | null loginUsername?: string | null
loginPassword?: string | null loginPassword?: string | null
perVuLogin?: boolean
signRequest?: boolean signRequest?: boolean
assertions?: AssertionRule[] assertions?: AssertionRule[]
uniqueFields?: UniqueFieldRule[] | null uniqueFields?: UniqueFieldRule[] | null
......
...@@ -459,7 +459,7 @@ ...@@ -459,7 +459,7 @@
<el-form-item> <el-form-item>
<template #label> <template #label>
CSV 数据内容 CSV 数据内容
<FieldTip content="第一行为字段名,后续每行为一条数据。登录多账号场景列名用 username/password,每个虚拟用户取一行" /> <FieldTip content="第一行为字段名,后续每行为一条数据。per-VU 独立登录时列名必须用 username/password,每个虚拟用户取一行;账号数不得少于虚拟用户数" />
</template> </template>
<div style="display: flex; gap: 8px; align-items: flex-start; width: 100%;"> <div style="display: flex; gap: 8px; align-items: flex-start; width: 100%;">
<el-input v-model="form.csvContent" type="textarea" :rows="4" placeholder="第一行为字段名,后续行为数据行&#10;username,password&#10;user1,pass1&#10;user2,pass2" style="flex: 1;" /> <el-input v-model="form.csvContent" type="textarea" :rows="4" placeholder="第一行为字段名,后续行为数据行&#10;username,password&#10;user1,pass1&#10;user2,pass2" style="flex: 1;" />
...@@ -482,6 +482,14 @@ ...@@ -482,6 +482,14 @@
</template> </template>
<el-input v-model="form.csvVariableMapping" type="textarea" :rows="2" placeholder="{&quot;username&quot;: &quot;{{csv.username}}&quot;, &quot;password&quot;: &quot;{{csv.password}}&quot;}" /> <el-input v-model="form.csvVariableMapping" type="textarea" :rows="2" placeholder="{&quot;username&quot;: &quot;{{csv.username}}&quot;, &quot;password&quot;: &quot;{{csv.password}}&quot;}" />
</el-form-item> </el-form-item>
<!-- per-VU 独立登录(仅 mix 场景) -->
<el-form-item v-if="form.scenarioType === 'mix'">
<template #label>
每虚拟用户独立登录
<FieldTip content="开启后每个虚拟用户用 CSV 的 username/password 列各自登录获取独立 token;登录接口可加入随机池模拟会话过期重登,重登成功后替换该 VU 自己的 token。CSV 账号数不得少于虚拟用户数" />
</template>
<el-switch v-model="form.perVuLogin" />
</el-form-item>
</template> </template>
</template> </template>
...@@ -761,6 +769,8 @@ interface TaskForm { ...@@ -761,6 +769,8 @@ interface TaskForm {
csvParameterizationEnabled: boolean csvParameterizationEnabled: boolean
csvContent: string csvContent: string
csvVariableMapping: string csvVariableMapping: string
/** per-VU 独立登录(仅 mix 场景,CSV 账号各自登录持有独立 token) */
perVuLogin: boolean
/** 请求详情采集模式 */ /** 请求详情采集模式 */
requestDetailEnabled: RequestDetailMode requestDetailEnabled: RequestDetailMode
} }
...@@ -836,6 +846,7 @@ const defaultForm = (): TaskForm => ({ ...@@ -836,6 +846,7 @@ const defaultForm = (): TaskForm => ({
csvParameterizationEnabled: false, csvParameterizationEnabled: false,
csvContent: '', csvContent: '',
csvVariableMapping: '', csvVariableMapping: '',
perVuLogin: false,
requestDetailEnabled: 'off', requestDetailEnabled: 'off',
}) })
...@@ -959,6 +970,7 @@ async function openEditDialog(task: PerformanceTaskListItem) { ...@@ -959,6 +970,7 @@ async function openEditDialog(task: PerformanceTaskListItem) {
csvParameterizationEnabled: fullTask.csvParameterizationEnabled ?? false, csvParameterizationEnabled: fullTask.csvParameterizationEnabled ?? false,
csvContent: fullTask.csvContent || '', csvContent: fullTask.csvContent || '',
csvVariableMapping: fullTask.csvVariableMapping ? JSON.stringify(fullTask.csvVariableMapping, null, 2) : '', csvVariableMapping: fullTask.csvVariableMapping ? JSON.stringify(fullTask.csvVariableMapping, null, 2) : '',
perVuLogin: fullTask.perVuLogin ?? false,
requestDetailEnabled: fullTask.requestDetailEnabled || 'off', requestDetailEnabled: fullTask.requestDetailEnabled || 'off',
}) })
dialogVisible.value = true dialogVisible.value = true
...@@ -1268,6 +1280,7 @@ async function handleSave() { ...@@ -1268,6 +1280,7 @@ async function handleSave() {
csvContent: form.csvParameterizationEnabled ? form.csvContent : null, csvContent: form.csvParameterizationEnabled ? form.csvContent : null,
csvVariableMapping: form.csvParameterizationEnabled && form.csvVariableMapping csvVariableMapping: form.csvParameterizationEnabled && form.csvVariableMapping
? safeJsonParse(form.csvVariableMapping) : null, ? safeJsonParse(form.csvVariableMapping) : null,
perVuLogin: form.scenarioType === 'mix' ? form.perVuLogin : false,
requestDetailEnabled: form.requestDetailEnabled || 'off', requestDetailEnabled: form.requestDetailEnabled || 'off',
} }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论