品牌活动调整

This commit is contained in:
2026-11-03 17:57:19 +08:00
parent a3c6c77d6b
commit d8313d7822
37 changed files with 3044 additions and 466 deletions
+90
View File
@@ -0,0 +1,90 @@
# 登山活动 1300 人并发报名压测
本目录用于本地或已明确授权的测试环境。JMeter 会真实调用登录和报名接口,并向报名表写入数据,禁止直接对生产环境执行。
## 文件说明
- `prepare-brand-activity-users.ps1`:从当前项目数据库导出活动范围内、未禁用且尚未报名的人员。
- `brand-activity-mountain-signup-1300.jmx`:每线程独立登录,所有线程到达报名步骤后同时提交。
- `data/brand-activity-users.csv`:运行准备脚本后生成,不包含密码、手机号、身份证号等敏感数据。
当前登山活动参数:
- 活动 ID:`11619eee67574256ad8ccc8a16630023`
- 子活动 ID:`986bf4d48a5948e1810857483f9e5af2`
- 容量:1500 人
## 1. 准备人员数据
在项目根目录执行:
```powershell
powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1300 -PermissionMode AllScope
```
`AllScope` 会导出1300名活动范围人员。根据当前数据库,约290名人员没有报名菜单权限,其报名请求会被权限校验拒绝。
如果只测试具备权限的人员:
```powershell
powershell -ExecutionPolicy Bypass -File .\jmeter\prepare-brand-activity-users.ps1 -UserCount 1010 -PermissionMode Authorized
```
当前数据库只有1010名“有报名权限且尚未报名”的人员,因此 `Authorized` 模式不能生成1300名不同人员。
## 2. 先进行10人试跑
启动本地项目,确认80端口和 Redis 6378 可访问,然后执行:
```powershell
jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx `
-Jthreads=10 `
-JloginRampSeconds=10 `
-Jhost=127.0.0.1 `
-Jport=80 `
-JusersFile=jmeter/data/brand-activity-users.csv `
-l .\jmeter\result-smoke.jtl `
-j .\jmeter\jmeter-smoke.log
```
试跑会真实产生报名记录。再次运行前必须重新生成 CSV,以排除已经报名的人员。
## 3. 执行1300人并发
确认测试数据库已备份、应用和 Redis 正常、活动仍在报名时间内后执行:
```powershell
jmeter -n -t .\jmeter\brand-activity-mountain-signup-1300.jmx `
-Jthreads=1300 `
-JloginRampSeconds=60 `
-JsyncTimeoutMs=300000 `
-Jhost=127.0.0.1 `
-Jport=80 `
-JusersFile=jmeter/data/brand-activity-users.csv `
-l .\jmeter\result-1300.jtl `
-j .\jmeter\jmeter-1300.log
```
登录在60秒内逐步完成;所有仍在运行的线程会在“07-集合后并发提交登山活动报名”前等待,然后同时发送报名请求。
## 4. 生成结果报告
```powershell
jmeter -g .\jmeter\result-1300.jtl -o .\jmeter\report-1300
```
重点查看:
- `05-人员账号登录`:独立账号登录是否成功。
- `06-报名资格预校验`:活动范围、时间、重复报名和名额检查。
- `07-集合后并发提交登山活动报名`:真正的并发报名结果。
报名结果只有返回 `code=0` 且 `msg=报名成功` 才计为成功,避免后端异常分支返回成功状态码但消息为“报名失败”时被误判。
## 注意事项
- 登录验证码由应用写入本机 Redis,JMX 仅在授权测试环境中从 Redis 读取对应验证码答案。
- 当前 `cas.enable=false`。现有登录实现只在 CAS 模式开启时拒绝密码不匹配,因此 JMX 使用测试占位密码;该行为不应复制到生产环境。
- Controller 使用单 JVM `ReentrantLock`,单节点下报名请求会排队;多节点下该锁无法跨节点保证一致性。
- JMeter 1300线程会消耗较多内存。若出现堆内存不足,可在启动前提高 `HEAP`,例如 `-Xms2g -Xmx4g`。
- 不提供自动删除报名数据的脚本,避免误删真实报名记录。
@@ -0,0 +1,291 @@
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="品牌活动-登山活动1300人并发报名" enabled="true">
<stringProp name="TestPlan.comments">每个线程使用独立用户和 Cookie 登录,登录完成后在报名接口前集合并发。仅限本地或经授权的测试环境。</stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="业务参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="activityId" elementType="Argument">
<stringProp name="Argument.name">activityId</stringProp>
<stringProp name="Argument.value">${__P(activityId,11619eee67574256ad8ccc8a16630023)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="courseId" elementType="Argument">
<stringProp name="Argument.name">courseId</stringProp>
<stringProp name="Argument.value">${__P(courseId,986bf4d48a5948e1810857483f9e5af2)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="redisHost" elementType="Argument">
<stringProp name="Argument.name">redisHost</stringProp>
<stringProp name="Argument.value">${__P(redisHost,127.0.0.1)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="redisPort" elementType="Argument">
<stringProp name="Argument.name">redisPort</stringProp>
<stringProp name="Argument.value">${__P(redisPort,6378)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="testPassword" elementType="Argument">
<stringProp name="Argument.name">testPassword</stringProp>
<stringProp name="Argument.value">${__P(testPassword,LoadTestOnly@123)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
</TestPlan>
<hashTree>
<CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet" testname="读取数据库人员CSV" enabled="true">
<stringProp name="delimiter">,</stringProp>
<stringProp name="fileEncoding">UTF-8</stringProp>
<stringProp name="filename">${__P(usersFile,jmeter/data/brand-activity-users.csv)}</stringProp>
<boolProp name="ignoreFirstLine">false</boolProp>
<boolProp name="quotedData">true</boolProp>
<boolProp name="recycle">false</boolProp>
<stringProp name="shareMode">shareMode.all</stringProp>
<boolProp name="stopThread">true</boolProp>
<stringProp name="variableNames"></stringProp>
</CSVDataSet>
<hashTree/>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="独立用户登录并并发报名" enabled="true">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="循环控制器" enabled="true">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">1</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">${__P(threads,10)}</stringProp>
<stringProp name="ThreadGroup.ramp_time">${__P(loginRampSeconds,30)}</stringProp>
<boolProp name="ThreadGroup.scheduler">false</boolProp>
<stringProp name="ThreadGroup.duration"></stringProp>
<stringProp name="ThreadGroup.delay"></stringProp>
<boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
</ThreadGroup>
<hashTree>
<ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP默认配置" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="用户定义变量" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain">${__P(host,127.0.0.1)}</stringProp>
<stringProp name="HTTPSampler.port">${__P(port,80)}</stringProp>
<stringProp name="HTTPSampler.protocol">${__P(protocol,http)}</stringProp>
<stringProp name="HTTPSampler.connect_timeout">${__P(connectTimeout,10000)}</stringProp>
<stringProp name="HTTPSampler.response_timeout">${__P(responseTimeout,120000)}</stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
</ConfigTestElement>
<hashTree/>
<CookieManager guiclass="CookiePanel" testclass="CookieManager" testname="每线程独立Cookie" enabled="true">
<collectionProp name="CookieManager.cookies"/>
<boolProp name="CookieManager.clearEachIteration">true</boolProp>
<boolProp name="CookieManager.controlledByThreadGroup">false</boolProp>
</CookieManager>
<hashTree/>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="请求头" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/x-www-form-urlencoded;charset=UTF-8</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json, text/plain, */*</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="01-获取图形验证码" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="请求参数" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/login/captcha</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="提取验证码Key" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">captchaKey</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.key</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
<JSR223Sampler guiclass="TestBeanGUI" testclass="JSR223Sampler" testname="02-从本地Redis读取压测验证码" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
def captchaKey = vars.get('captchaKey')
if (!captchaKey || captchaKey == 'NOT_FOUND') {
throw new IllegalStateException('获取验证码Key失败')
}
def redisKey = 'budwk5mini:ucenter:captcha:' + captchaKey
def socket = new Socket(vars.get('redisHost'), vars.get('redisPort') as int)
socket.soTimeout = 3000
try {
def keyBytes = redisKey.getBytes('UTF-8')
def command = '*2\r\n$3\r\nGET\r\n$' + keyBytes.length + '\r\n' + redisKey + '\r\n'
socket.outputStream.write(command.getBytes('UTF-8'))
socket.outputStream.flush()
def reader = new BufferedReader(new InputStreamReader(socket.inputStream, 'UTF-8'))
def header = reader.readLine()
if (!header || !header.startsWith('$') || header == '$-1') {
throw new IllegalStateException('Redis中未找到验证码:' + redisKey)
}
def captchaCode = reader.readLine()
if (!captchaCode) {
throw new IllegalStateException('验证码内容为空')
}
vars.put('captchaCode', captchaCode)
} finally {
socket.close()
}
]]></stringProp>
</JSR223Sampler>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="03-获取登录RSA公钥" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="请求参数" enabled="true">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/login/publicKey</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="提取公钥和KeyId" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">publicKey;keyId</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.data.publicKey;$.data.keyId</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1;1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOT_FOUND;NOT_FOUND</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
<JSR223Sampler guiclass="TestBeanGUI" testclass="JSR223Sampler" testname="04-RSA加密测试密码" enabled="true">
<stringProp name="cacheKey">true</stringProp>
<stringProp name="filename"></stringProp>
<stringProp name="parameters"></stringProp>
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
import java.security.KeyFactory
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
def publicKeyText = vars.get('publicKey')
if (!publicKeyText || publicKeyText == 'NOT_FOUND') {
throw new IllegalStateException('获取RSA公钥失败')
}
def keyBytes = Base64.decoder.decode(publicKeyText)
def publicKey = KeyFactory.getInstance('RSA').generatePublic(new X509EncodedKeySpec(keyBytes))
def cipher = Cipher.getInstance('RSA/ECB/PKCS1Padding')
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
def encrypted = cipher.doFinal(vars.get('testPassword').getBytes('UTF-8'))
vars.put('encryptedPassword', Base64.encoder.encodeToString(encrypted))
]]></stringProp>
</JSR223Sampler>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="05-人员账号登录" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="登录参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="username" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">username</stringProp><stringProp name="Argument.value">${loginname}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="password" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">password</stringProp><stringProp name="Argument.value">${encryptedPassword}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="platformKey" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">platformKey</stringProp><stringProp name="Argument.value">${captchaKey}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="platformCaptcha" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">platformCaptcha</stringProp><stringProp name="Argument.value">${captchaCode}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
<elementProp name="keyId" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">keyId</stringProp><stringProp name="Argument.value">${keyId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/login/doLogin</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree>
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="断言登录成功" enabled="true">
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
import groovy.json.JsonSlurper
try {
def json = new JsonSlurper().parseText(prev.getResponseDataAsString())
if (json.code != 0) {
prev.setSuccessful(false)
prev.setResponseMessage('登录失败:' + json.msg)
}
} catch (Exception e) {
prev.setSuccessful(false)
prev.setResponseMessage('登录响应不是有效JSON:' + e.message)
}
]]></stringProp>
</JSR223PostProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="06-报名资格预校验" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="校验参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="courseId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">courseId</stringProp><stringProp name="Argument.value">${courseId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
<elementProp name="currentFamilyNumber" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">currentFamilyNumber</stringProp><stringProp name="Argument.value">0</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/trainSignUp/apply/validateSignUp</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree/>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="07-集合后并发提交登山活动报名" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="报名参数" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="activityId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">activityId</stringProp><stringProp name="Argument.value">${activityId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
<elementProp name="courseId" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">courseId</stringProp><stringProp name="Argument.value">${courseId}</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
<elementProp name="mobileColumnsValue" elementType="HTTPArgument"><boolProp name="HTTPArgument.always_encode">true</boolProp><stringProp name="Argument.name">mobileColumnsValue</stringProp><stringProp name="Argument.value">[]</stringProp><stringProp name="Argument.metadata">=</stringProp><boolProp name="HTTPArgument.use_equals">true</boolProp></elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.path">/platform/trainSignUp/apply/doSignUp</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
</HTTPSamplerProxy>
<hashTree>
<SyncTimer guiclass="TestBeanGUI" testclass="SyncTimer" testname="等待本线程组全部人员后同时报名" enabled="true">
<intProp name="groupSize">0</intProp>
<stringProp name="timeoutInMs">${__P(syncTimeoutMs,180000)}</stringProp>
</SyncTimer>
<hashTree/>
<JSR223PostProcessor guiclass="TestBeanGUI" testclass="JSR223PostProcessor" testname="断言报名成功" enabled="true">
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script"><![CDATA[
import groovy.json.JsonSlurper
try {
def json = new JsonSlurper().parseText(prev.getResponseDataAsString())
if (json.code != 0 || json.msg != '报名成功') {
prev.setSuccessful(false)
prev.setResponseMessage('报名未成功;expectedAuthorized=' + vars.get('expectedAuthorized') + ';返回:' + json.msg)
}
} catch (Exception e) {
prev.setSuccessful(false)
prev.setResponseMessage('报名响应不是有效JSON;expectedAuthorized=' + vars.get('expectedAuthorized') + ';' + e.message)
}
]]></stringProp>
</JSR223PostProcessor>
<hashTree/>
</hashTree>
</hashTree>
</hashTree>
</hashTree>
</jmeterTestPlan>
+132
View File
@@ -0,0 +1,132 @@
param(
[ValidateRange(1, 1300)]
[int]$UserCount = 1300,
[ValidateSet("AllScope", "Authorized")]
[string]$PermissionMode = "AllScope",
[ValidatePattern("^[0-9a-fA-F]{32}$")]
[string]$ActivityId = "11619eee67574256ad8ccc8a16630023",
[string]$OutputFile = (Join-Path $PSScriptRoot "data\brand-activity-users.csv"),
[string]$ApplicationConfig = (Join-Path $PSScriptRoot "..\src\main\resources\application.yaml")
)
$ErrorActionPreference = "Stop"
function Get-YamlScalar {
param(
[string[]]$Lines,
[string]$Name
)
$matchedLine = $Lines | Where-Object { $_ -match ("^\s*" + [regex]::Escape($Name) + "\s*:\s*(.+?)\s*$") } | Select-Object -First 1
if (-not $matchedLine) {
throw "未在 application.yaml 中找到配置项:$Name"
}
return ([regex]::Match($matchedLine, "^\s*[^:]+:\s*(.+?)\s*$")).Groups[1].Value.Trim('"', "'")
}
if (-not (Test-Path -LiteralPath $ApplicationConfig)) {
throw "找不到应用配置文件:$ApplicationConfig"
}
$mysqlCommand = Get-Command mysql -ErrorAction SilentlyContinue
if (-not $mysqlCommand) {
throw "未找到 mysql 命令,请先把 MySQL 客户端 bin 目录加入 PATH。"
}
$configLines = Get-Content -LiteralPath $ApplicationConfig -Encoding UTF8
$jdbcUrl = Get-YamlScalar -Lines $configLines -Name "url"
$dbUser = Get-YamlScalar -Lines $configLines -Name "username"
$dbPassword = Get-YamlScalar -Lines $configLines -Name "password"
$jdbcMatch = [regex]::Match($jdbcUrl, "jdbc:mysql://(?<host>[^:/?]+)(:(?<port>\d+))?/(?<database>[^?]+)")
if (-not $jdbcMatch.Success) {
throw "无法解析 JDBC 地址:$jdbcUrl"
}
$dbHost = $jdbcMatch.Groups["host"].Value
$dbPort = if ($jdbcMatch.Groups["port"].Success) { $jdbcMatch.Groups["port"].Value } else { "3306" }
$database = $jdbcMatch.Groups["database"].Value
# 仅导出活动范围内、账号未禁用且尚未报名的人员;不读取密码、身份证号、手机号等敏感字段。
$permissionCondition = if ($PermissionMode -eq "Authorized") {
@"
AND EXISTS (
SELECT 1
FROM sys_user_role ur
JOIN sys_role_menu rm ON rm.roleId = ur.roleId
JOIN sys_menu m ON m.id = rm.menuId
WHERE ur.userId = u.id
AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply')
)
"@
} else {
""
}
$query = @"
SELECT
u.loginname,
CASE WHEN EXISTS (
SELECT 1
FROM sys_user_role ur
JOIN sys_role_menu rm ON rm.roleId = ur.roleId
JOIN sys_menu m ON m.id = rm.menuId
WHERE ur.userId = u.id
AND m.permission IN ('trainSignUp.apply', 'h5.trainSignUp.apply')
) THEN 1 ELSE 0 END AS expectedAuthorized
FROM train_sign_up_activity a
JOIN activity_user_scope aus ON aus.groupId = a.activityGroupId
JOIN sys_user u ON u.id = aus.userId
LEFT JOIN train_sign_up_user su ON su.activityId = a.id AND su.userId = u.id
WHERE a.id = '$ActivityId'
AND u.disabled = 0
AND u.loginname IS NOT NULL
AND u.loginname <> ''
AND su.id IS NULL
$permissionCondition
GROUP BY u.id, u.loginname
ORDER BY expectedAuthorized DESC, u.loginname
LIMIT $UserCount;
"@
$previousMysqlPassword = $env:MYSQL_PWD
try {
$env:MYSQL_PWD = $dbPassword
$rows = @(& $mysqlCommand.Source --batch --raw --skip-column-names -h $dbHost -P $dbPort -u $dbUser $database -e $query)
if ($LASTEXITCODE -ne 0) {
throw "查询压测人员失败,mysql 退出码:$LASTEXITCODE"
}
} finally {
$env:MYSQL_PWD = $previousMysqlPassword
}
$users = @($rows | Where-Object { $_ -and $_.Trim() } | ForEach-Object {
$columns = $_ -split "`t", 2
[pscustomobject]@{
loginname = $columns[0]
expectedAuthorized = $columns[1]
}
})
if ($users.Count -lt $UserCount) {
throw "符合条件的人员只有 $($users.Count) 人,少于要求的 $UserCount 人。PermissionMode=$PermissionMode"
}
$outputDirectory = Split-Path -Parent $OutputFile
if (-not (Test-Path -LiteralPath $outputDirectory)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
$users | Export-Csv -LiteralPath $OutputFile -NoTypeInformation -Encoding UTF8 -UseQuotes Always
$authorizedCount = @($users | Where-Object { $_.expectedAuthorized -eq "1" }).Count
$unauthorizedCount = $users.Count - $authorizedCount
Write-Host "已生成 UTF-8 用户文件:$OutputFile"
Write-Host "总人数:$($users.Count),具备报名权限:$authorizedCount,无报名权限:$unauthorizedCount"
if ($unauthorizedCount -gt 0) {
Write-Warning "无报名权限的线程仍会访问报名接口,但会被权限校验拒绝。"
}