feat: add crm dead-letter operations and benchmark summary

This commit is contained in:
Codex
2026-06-30 23:07:43 +08:00
parent 90ad9e90a3
commit 9891fe9ef3
22 changed files with 1284 additions and 110 deletions

View File

@@ -76,6 +76,7 @@ const ids = {
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
partnerSubscription: '00000000-0000-0000-0000-000000000902',
platformOverdueInvoice: crypto.randomUUID(),
crmDeadLetterQueue: crypto.randomUUID(),
};
const paymentFixture = (() => {
@@ -3235,6 +3236,7 @@ async function testProfile() {
}
async function testLearningLeaderboard() {
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
const disabled = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
expectStatus: 403,
@@ -3242,55 +3244,69 @@ async function testLearningLeaderboard() {
assert.equal(disabled.code, 'LEADERBOARD_DISABLED', 'leaderboard should be disabled by default for tenants');
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', true);
try {
const questions = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
});
assert.equal(questions.metric, 'questions', 'question leaderboard should echo metric');
assert.ok(Array.isArray(questions.items), 'question leaderboard should return list items');
const currentQuestions = questions.currentUser;
assert.equal(currentQuestions?.userId, USER_ID, 'question leaderboard should include current user rank');
assert.equal(currentQuestions?.isCurrentUser, true, 'current user rank should be flagged');
const questions = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
});
assert.equal(questions.metric, 'questions', 'question leaderboard should echo metric');
assert.ok(questions.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'question leaderboard should include second student');
assert.ok(questions.items?.some(item => item.userId === USER_ID), 'question leaderboard should include current student');
const secondQuestions = questions.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
const currentQuestions = questions.currentUser;
assert.ok(secondQuestions?.value >= currentQuestions?.value, 'second student should rank at least as high as smoke user by questions');
assert.equal(currentQuestions?.userId, USER_ID, 'question leaderboard should include current user rank');
assert.equal(currentQuestions?.isCurrentUser, true, 'current user rank should be flagged');
const classScoped = await request('/api/learning/leaderboard', {
query: { metric: 'questions', classId: ids.tenantClass, limit: 10 },
});
assert.ok(classScoped.items?.some(item => item.userId === USER_ID), 'class scoped leaderboard should include class student');
assert.ok(!classScoped.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'class scoped leaderboard should exclude other class student');
const score = await request('/api/learning/leaderboard', {
query: { metric: 'score', period: 'all', limit: 10 },
});
const scoreLeader = score.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.ok(scoreLeader?.value >= 30, 'score leaderboard should use platform user score');
assert.ok(score.currentUser?.value >= 10, 'score leaderboard should include current user score');
const secondClassScoped = await request('/api/learning/leaderboard', {
query: { metric: 'questions', classId: ids.tenantClassOther, limit: 10 },
});
const secondQuestions = secondClassScoped.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.ok(secondQuestions, 'second student class scoped leaderboard should include second student');
assert.ok(secondQuestions?.value >= currentQuestions?.value, 'second student should rank at least as high as smoke user by questions');
const vocabulary = await request('/api/learning/leaderboard', {
query: { metric: 'vocabulary', period: 'all', limit: 10 },
});
const vocabularyLeader = vocabulary.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(vocabularyLeader?.value, 2, 'vocabulary leaderboard should count mastered words');
const score = await request('/api/learning/leaderboard', {
query: { metric: 'score', period: 'all', classId: ids.tenantClassOther, limit: 10 },
});
const scoreLeader = score.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.ok(scoreLeader?.value >= 30, 'score leaderboard should use platform user score');
const mockExam = await request('/api/learning/leaderboard', {
query: { metric: 'mock_exam', period: '30d', limit: 10 },
});
const mockLeader = mockExam.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(mockLeader?.value, 95, 'mock exam leaderboard should use best report score');
assert.ok(mockExam.currentUser?.value >= 70, 'mock exam leaderboard should include current user best score');
const currentScore = await request('/api/learning/leaderboard', {
query: { metric: 'score', period: 'all', classId: ids.tenantClass, limit: 10 },
});
assert.ok(currentScore.currentUser?.value >= 10, 'score leaderboard should include current user score');
const classScoped = await request('/api/learning/leaderboard', {
query: { metric: 'questions', classId: ids.tenantClass, limit: 10 },
});
assert.ok(classScoped.items?.some(item => item.userId === USER_ID), 'class scoped leaderboard should include class student');
assert.ok(!classScoped.items?.some(item => item.userId === SECOND_STUDENT_USER_ID), 'class scoped leaderboard should exclude other class student');
const vocabulary = await request('/api/learning/leaderboard', {
query: { metric: 'vocabulary', period: 'all', classId: ids.tenantClassOther, limit: 10 },
});
const vocabularyLeader = vocabulary.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(vocabularyLeader?.value, 2, 'vocabulary leaderboard should count mastered words');
const trustedLogin = await loginBySms('13800000000');
const crossTenantDenied = await request('/api/learning/leaderboard', {
tenantId: PARTNER_TENANT_ID,
userId: false,
headers: { authorization: `Bearer ${trustedLogin.session.token}` },
expectStatus: 403,
});
assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access');
const mockExam = await request('/api/learning/leaderboard', {
query: { metric: 'mock_exam', period: '30d', classId: ids.tenantClassOther, limit: 10 },
});
const mockLeader = mockExam.items?.find(item => item.userId === SECOND_STUDENT_USER_ID);
assert.equal(mockLeader?.value, 95, 'mock exam leaderboard should use best report score');
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
const currentMockExam = await request('/api/learning/leaderboard', {
query: { metric: 'mock_exam', period: '30d', classId: ids.tenantClass, limit: 10 },
});
assert.ok(currentMockExam.currentUser?.value >= 70, 'mock exam leaderboard should include current user best score');
const trustedLogin = await loginBySms('13800000000');
const crossTenantDenied = await request('/api/learning/leaderboard', {
tenantId: PARTNER_TENANT_ID,
userId: false,
headers: { authorization: `Bearer ${trustedLogin.session.token}` },
expectStatus: 403,
});
assert.equal(crossTenantDenied.code, 'AUTH_TENANT_MISMATCH', 'leaderboard must reject trusted session cross-tenant access');
} finally {
await setTenantFeatureFlag(MAIN_TENANT_ID, 'enableLeaderboard', false);
}
const disabledAfterRestore = await request('/api/learning/leaderboard', {
query: { metric: 'questions', period: 'all', limit: 10 },
expectStatus: 403,
@@ -10363,6 +10379,170 @@ async function testReferralAndCrmGrowth() {
);
assert.ok(!JSON.stringify(crmQueue).includes('crm-secret-smoke'), 'CRM queue should not leak secret');
const crmDeadLetterPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await crmDeadLetterPool.query(
`
insert into public.crm_webhook_queue (
id, tenant_id, record_id, status, scheduled_at, attempts, next_attempt_at,
last_error, last_http_code, lead_id, source, payload, idempotency_key,
target_url, provider, last_attempt_at, last_response_summary, dead_lettered_at
)
values (
$1::uuid, $2, $3, 'failed', now(), 3, null,
$4, 500, $5, 'tenant.student.crm_push',
$6::jsonb, $7, $8, 'dingtalk', now(), $9, now()
)
on conflict (id) do update set status = excluded.status,
last_error = excluded.last_error,
target_url = excluded.target_url,
payload = excluded.payload,
dead_lettered_at = now(),
updated_at = now()
`,
[
ids.crmDeadLetterQueue,
MAIN_TENANT_ID,
USER_ID,
'HTTP 500 access_token=dead-letter-secret should be redacted',
manual.lead.item.id,
JSON.stringify({
eventType: 'student.crm_push',
token: 'payload-secret-token',
student: { userId: USER_ID, avatarPreset: 'male' },
}),
'integration-crm-dead-letter',
'https://oapi.dingtalk.com/robot/send?access_token=dead-letter-secret',
'response contains access_token=dead-letter-secret',
],
);
await crmDeadLetterPool.query(
`
insert into public.crm_webhook_log (
tenant_id, queue_id, record_id, http_code, outcome, error_message, lead_id,
request_body, request_payload, response_summary, signed_at, attempt
)
values (
$1, $2::uuid, $3, 500, 'failed', $4, $5,
$6, $7::jsonb, $8, now(), 3
)
`,
[
MAIN_TENANT_ID,
ids.crmDeadLetterQueue,
USER_ID,
'access_token=dead-letter-secret failed',
manual.lead.item.id,
'{"access_token":"dead-letter-secret","student":{"avatarPreset":"male"}}',
JSON.stringify({ url: 'https://oapi.dingtalk.com/robot/send?access_token=dead-letter-secret', token: 'dead-letter-secret' }),
'remote response access_token=dead-letter-secret',
],
);
} finally {
await crmDeadLetterPool.end();
}
const crmDeadLetters = await request('/api/crm/dead-letters', {
userId: TENANT_ADMIN_USER_ID,
query: { limit: 10 },
});
const deadLetterItem = crmDeadLetters.items?.find(item => item.id === ids.crmDeadLetterQueue);
assert.ok(deadLetterItem, 'CRM dead-letter list should include failed task');
assert.equal(crmDeadLetters.summary?.failed >= 1, true, 'CRM dead-letter summary should count failed tasks');
assert.ok(!JSON.stringify(crmDeadLetters).includes('dead-letter-secret'), 'CRM dead-letter list must redact webhook secrets');
assert.equal(deadLetterItem?.payload?.token, '[redacted]', 'CRM dead-letter payload should redact token fields');
const crmDeadLetterLogs = await request('/api/crm/queue/logs', {
userId: TENANT_ADMIN_USER_ID,
query: { queueId: ids.crmDeadLetterQueue, limit: 10 },
});
assert.ok(crmDeadLetterLogs.items?.some(item => item.outcome === 'failed'), 'CRM queue logs should include failed attempts');
assert.ok(!JSON.stringify(crmDeadLetterLogs).includes('dead-letter-secret'), 'CRM queue logs must redact webhook secrets');
const crmRetryDenied = await request('/api/crm/queue/action', {
userId: TENANT_OPERATOR_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'retry',
},
expectStatus: 403,
});
assert.equal(crmRetryDenied.code, 'TENANT_PERMISSION_REQUIRED', 'CRM retry should require crm:write');
const crmCrossTenantDenied = await request('/api/crm/queue/logs', {
tenantId: PARTNER_TENANT_ID,
userId: PARTNER_TENANT_ADMIN_USER_ID,
query: { queueId: ids.crmDeadLetterQueue },
expectStatus: 404,
});
assert.equal(crmCrossTenantDenied.code, 'CRM_QUEUE_TASK_NOT_FOUND', 'CRM queue logs must be tenant isolated');
const crmRetried = await request('/api/crm/queue/action', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'retry',
note: 'integration retry',
metadata: { source: 'integration-test', accessToken: 'dead-letter-secret' },
},
});
assert.equal(crmRetried.item?.status, 'pending', 'CRM retry should reset failed task to pending');
assert.equal(crmRetried.item?.lastError, null, 'CRM retry should clear last error');
assert.equal(crmRetried.item?.lastOperatorAction, 'retry', 'CRM retry should record operator action');
assert.ok(!JSON.stringify(crmRetried).includes('dead-letter-secret'), 'CRM retry response must redact metadata secrets');
const crmRetriedLogs = await request('/api/crm/queue/logs', {
userId: TENANT_ADMIN_USER_ID,
query: { queueId: ids.crmDeadLetterQueue, limit: 10 },
});
assert.ok(crmRetriedLogs.items?.some(item => item.operation === 'retry'), 'CRM retry should append operator log');
assert.ok(!JSON.stringify(crmRetriedLogs).includes('dead-letter-secret'), 'CRM retry operator log must redact metadata secrets');
const crmIgnorePending = await request('/api/crm/queue/action', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'ignore',
},
expectStatus: 409,
});
assert.equal(crmIgnorePending.code, 'CRM_QUEUE_STATUS_NOT_IGNORABLE', 'CRM ignore should only apply to dead-letter tasks');
const crmDeadLetterResetPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
try {
await crmDeadLetterResetPool.query(
`
update public.crm_webhook_queue
set status = 'failed',
attempts = 4,
last_error = 'retry failed with access_token=dead-letter-secret',
last_http_code = 500,
dead_lettered_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2::uuid
`,
[MAIN_TENANT_ID, ids.crmDeadLetterQueue],
);
} finally {
await crmDeadLetterResetPool.end();
}
const crmIgnored = await request('/api/crm/queue/action', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
queueId: ids.crmDeadLetterQueue,
action: 'ignore',
note: 'integration ignored after manual review',
},
});
assert.equal(crmIgnored.item?.status, 'discarded', 'CRM ignore should move failed task to discarded');
assert.equal(crmIgnored.item?.ignoredBy, TENANT_ADMIN_USER_ID, 'CRM ignore should record operator');
assert.equal(crmIgnored.item?.lastOperatorAction, 'ignore', 'CRM ignore should record operator action');
const partnerReferralDenied = await request('/api/referral/sales-stats', {
tenantId: PARTNER_TENANT_ID,
userId: TENANT_SALES_USER_ID,

View File

@@ -15,6 +15,9 @@ const CONCURRENCY = envNumber('PERF_CONCURRENCY', 6);
const RAMP_SECONDS = envNumber('PERF_RAMP_SECONDS', 3, { allowZero: true });
const INCLUDE_WRITES = envBool('PERF_INCLUDE_WRITES', false);
const INCLUDE_LEADERBOARD = envBool('PERF_INCLUDE_LEADERBOARD', false);
const PRACTICE_FLOW_RATIO = Math.min(Math.max(envNumber('PERF_PRACTICE_FLOW_RATIO', 0.15, { allowZero: true }), 0), 1);
const PRACTICE_FLOW_ANSWERS = envNumber('PERF_PRACTICE_FLOW_ANSWERS', 3);
const ENSURE_SVIP_FOR_WRITES = envBool('PERF_ENSURE_SVIP_FOR_WRITES', true);
const START_PORT = Number(process.env.PERF_API_PORT || 0) || 0;
const TENANT_CODE = process.env.PERF_TENANT_CODE || 'master';
const QUESTION_LIMIT = envNumber('PERF_QUESTION_LIMIT', 20);
@@ -111,7 +114,7 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) {
message: payload?.error || payload?.message || response.statusText,
};
}
return { ok: true, status: response.status, elapsedMs, bytes: JSON.stringify(payload).length };
return { ok: true, status: response.status, elapsedMs, bytes: JSON.stringify(payload).length, payload };
} catch (error) {
return {
ok: false,
@@ -124,6 +127,164 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) {
}
}
function sampleFromResult(name, result, startedAt, finishedAt) {
return {
name,
ok: result.ok,
status: result.status,
elapsedMs: result.elapsedMs,
startedAt,
finishedAt,
};
}
function pickAnswerPayload(question) {
const subQuestions = Array.isArray(question.subQuestions) ? question.subQuestions : [];
if (subQuestions.length) {
return {
subAnswers: subQuestions.slice(0, Math.max(PRACTICE_FLOW_ANSWERS, 1)).map((subQuestion, index) => {
const subCorrectIndices = Array.isArray(subQuestion.correctOptionIndices) ? subQuestion.correctOptionIndices : [];
const subCorrectIndex = subQuestion.correctOptionIndex;
const subAnswer = {
subQuestionId: subQuestion.id || subQuestion.subQuestionId || subQuestion.key || `sub_${index + 1}`,
selectedOptions: [],
answerText: '',
};
if (subCorrectIndices.length) {
subAnswer.selectedOptions = subCorrectIndices.map(item => String(item));
} else if (subCorrectIndex !== null && subCorrectIndex !== undefined && Number.isFinite(Number(subCorrectIndex))) {
subAnswer.selectedOptions = [String(Number(subCorrectIndex))];
} else if (Array.isArray(subQuestion.options) && subQuestion.options.length) {
subAnswer.selectedOptions = ['0'];
} else {
subAnswer.answerText = 'performance benchmark answer';
subAnswer.selfJudgedCorrect = true;
}
return subAnswer;
}),
};
}
const correctIndices = Array.isArray(question.correctOptionIndices) ? question.correctOptionIndices : [];
const correctIndex = question.correctOptionIndex;
if (correctIndices.length) {
return { selectedOptions: correctIndices.map(item => String(item)) };
}
if (correctIndex !== null && correctIndex !== undefined && Number.isFinite(Number(correctIndex))) {
return { selectedOptions: [String(Number(correctIndex))] };
}
if (Array.isArray(question.options) && question.options.length) {
return { selectedOptions: ['0'] };
}
return { answerText: 'performance benchmark answer', selfJudgedCorrect: true };
}
async function runPracticeFlow(context, samples, errors) {
if (!context.blueprint || !context.collection) return false;
const flowStartedAt = performance.now();
const create = await requestJson({
name: 'learning.practice_flow.create',
method: 'POST',
path: '/api/learning/practice-sessions',
body: () => ({
blueprintId: context.blueprint.id,
collectionId: context.collection.id,
mode: context.blueprint.mode || 'sequential',
questionLimit: Math.min(QUESTION_LIMIT, Math.max(PRACTICE_FLOW_ANSWERS, 3)),
metadata: { source: 'api-performance-benchmark' },
}),
}, context);
const flowFinishedCreate = performance.now();
samples.push(sampleFromResult('learning.practice_flow.create', create, flowStartedAt, flowFinishedCreate));
if (!create.ok) {
pushError(errors, 'learning.practice_flow.create', create);
return true;
}
const practiceSessionId = create.payload?.item?.id;
if (!practiceSessionId) {
pushError(errors, 'learning.practice_flow.create', {
status: 200,
elapsedMs: create.elapsedMs,
error: 'PRACTICE_SESSION_ID_MISSING',
message: 'Practice session create response did not include item.id',
});
return true;
}
const detailStartedAt = performance.now();
const detail = await requestJson({
name: 'learning.practice_flow.detail',
method: 'GET',
path: '/api/learning/practice-sessions/detail',
query: { practiceSessionId },
}, context);
const detailFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.detail', detail, detailStartedAt, detailFinishedAt));
if (!detail.ok) {
pushError(errors, 'learning.practice_flow.detail', detail);
return true;
}
const questions = Array.isArray(detail.payload?.item?.questions) ? detail.payload.item.questions : [];
for (const question of questions.slice(0, PRACTICE_FLOW_ANSWERS)) {
if (!question?.id) continue;
const answerStartedAt = performance.now();
const answer = await requestJson({
name: 'learning.practice_flow.answer',
method: 'POST',
path: '/api/learning/answers',
body: () => ({
practiceSessionId,
questionId: question.id,
...pickAnswerPayload(question),
}),
}, context);
const answerFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.answer', answer, answerStartedAt, answerFinishedAt));
if (!answer.ok) {
pushError(errors, 'learning.practice_flow.answer', answer);
return true;
}
}
const submitStartedAt = performance.now();
const submit = await requestJson({
name: 'learning.practice_flow.submit',
method: 'POST',
path: '/api/learning/practice-sessions/submit',
body: () => ({ practiceSessionId }),
}, context);
const submitFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.submit', submit, submitStartedAt, submitFinishedAt));
if (!submit.ok) {
pushError(errors, 'learning.practice_flow.submit', submit);
return true;
}
const reportStartedAt = performance.now();
const report = await requestJson({
name: 'learning.practice_flow.report',
method: 'GET',
path: '/api/learning/practice-sessions/report',
query: { practiceSessionId },
}, context);
const reportFinishedAt = performance.now();
samples.push(sampleFromResult('learning.practice_flow.report', report, reportStartedAt, reportFinishedAt));
if (!report.ok) pushError(errors, 'learning.practice_flow.report', report);
return true;
}
function pushError(errors, endpointName, result) {
if (errors.length >= MAX_ERRORS_TO_KEEP) return;
errors.push({
endpoint: endpointName,
status: result.status,
error: result.error,
message: result.message,
elapsedMs: round(result.elapsedMs),
});
}
async function waitForHealth(timeoutMs = 20_000) {
const started = Date.now();
let lastError = null;
@@ -203,8 +364,14 @@ async function discoverBenchmarkContext() {
select pu.id, coalesce(pu.name, pu.username, pu.phone, pu.legacy_id, pu.id::text) as name
from public.tenant_memberships tm
join public.platform_users pu on pu.id = tm.user_id
left join public.entitlements e on e.tenant_id = tm.tenant_id
and e.user_id = pu.id
and e.entitlement_type = 'svip'
and e.status = 'active'
and (e.expires_at is null or e.expires_at > now())
where tm.tenant_id = $1 and tm.status = 'active' and tm.role = 'student'
order by pu.created_at asc
group by pu.id, pu.name, pu.username, pu.phone, pu.legacy_id, pu.created_at
order by case when count(e.id) > 0 then 0 else 1 end, pu.created_at asc
limit 1
`,
[tenant.id],
@@ -304,6 +471,39 @@ async function discoverBenchmarkContext() {
)
: null;
const hasActiveEntitlement = user
? await one(
pool,
`
select id
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and (expires_at is null or expires_at > now())
limit 1
`,
[tenant.id, user.id],
)
: null;
let benchmarkEntitlementCreated = false;
if (INCLUDE_WRITES && ENSURE_SVIP_FOR_WRITES && user && !hasActiveEntitlement) {
await pool.query(
`
insert into public.entitlements (
tenant_id, user_id, entitlement_type, scope_type, source_type,
starts_at, expires_at, status, metadata
)
values ($1, $2, 'svip', 'tenant', 'performance_benchmark',
now(), now() + interval '1 day', 'active',
'{"createdBy":"api-performance-benchmark"}'::jsonb)
`,
[tenant.id, user.id],
);
benchmarkEntitlementCreated = true;
}
const dbStats = {
before: await captureDbStats(pool),
tableCounts: await many(
@@ -327,7 +527,13 @@ async function discoverBenchmarkContext() {
return {
tenant,
user,
user: {
...user,
entitlement: {
hadActiveSvip: Boolean(hasActiveEntitlement),
benchmarkEntitlementCreated,
},
},
entry,
node,
collection,
@@ -426,24 +632,23 @@ function createEndpointCatalog(context) {
{ name: 'catalog.handbook_entries', weight: 5, method: 'GET', path: '/api/catalog/handbook-entries', query: { chapterId: context.handbookChapter.id, includeContent: 'true' } },
);
}
if (INCLUDE_WRITES && context.blueprint && context.collection) {
endpoints.push({
name: 'learning.practice_session.create',
weight: 1,
method: 'POST',
path: '/api/learning/practice-sessions',
body: () => ({
blueprintId: context.blueprint.id,
collectionId: context.collection.id,
mode: context.blueprint.mode || 'sequential',
questionLimit: Math.min(5, QUESTION_LIMIT),
}),
});
}
return expandWeightedEndpoints(endpoints);
}
function benchmarkEndpointNames(weightedEndpoints, context) {
const names = new Set(weightedEndpoints.map(endpoint => endpoint.name));
if (INCLUDE_WRITES && context.blueprint && context.collection && PRACTICE_FLOW_RATIO > 0) {
for (const name of [
'learning.practice_flow.create',
'learning.practice_flow.detail',
'learning.practice_flow.answer',
'learning.practice_flow.submit',
'learning.practice_flow.report',
]) names.add(name);
}
return [...names].sort();
}
function expandWeightedEndpoints(endpoints) {
const weighted = [];
for (const endpoint of endpoints) {
@@ -542,33 +747,22 @@ async function workerLoop(workerId, weightedEndpoints, context, stopAt, samples,
const rampDelay = RAMP_SECONDS > 0 ? (workerId / Math.max(CONCURRENCY, 1)) * RAMP_SECONDS * 1000 : 0;
if (rampDelay > 0) await sleep(rampDelay);
while (performance.now() < stopAt) {
if (INCLUDE_WRITES && PRACTICE_FLOW_RATIO > 0 && Math.random() < PRACTICE_FLOW_RATIO) {
await runPracticeFlow(context, samples, errors);
continue;
}
const endpoint = pickEndpoint(weightedEndpoints);
const startedAt = performance.now();
const result = await requestJson(endpoint, context);
const finishedAt = performance.now();
samples.push({
name: endpoint.name,
ok: result.ok,
status: result.status,
elapsedMs: result.elapsedMs,
startedAt,
finishedAt,
});
if (!result.ok && errors.length < MAX_ERRORS_TO_KEEP) {
errors.push({
endpoint: endpoint.name,
status: result.status,
error: result.error,
message: result.message,
elapsedMs: round(result.elapsedMs),
});
}
samples.push(sampleFromResult(endpoint.name, result, startedAt, finishedAt));
if (!result.ok) pushError(errors, endpoint.name, result);
}
}
async function runBenchmark(context) {
const weightedEndpoints = createEndpointCatalog(context);
const endpointNames = [...new Set(weightedEndpoints.map(endpoint => endpoint.name))].sort();
const endpointNames = benchmarkEndpointNames(weightedEndpoints, context);
const samples = [];
const errors = [];
const startedAt = new Date();
@@ -596,6 +790,8 @@ async function runBenchmark(context) {
concurrency: CONCURRENCY,
rampSeconds: RAMP_SECONDS,
includeWrites: INCLUDE_WRITES,
practiceFlowRatio: INCLUDE_WRITES ? PRACTICE_FLOW_RATIO : 0,
practiceFlowAnswers: INCLUDE_WRITES ? PRACTICE_FLOW_ANSWERS : 0,
questionLimit: QUESTION_LIMIT,
timeoutMs: DEFAULT_TIMEOUT_MS,
},
@@ -635,6 +831,10 @@ function markdownReport(report) {
lines.push(`- 持续时间:${report.config.durationSeconds}s`);
lines.push(`- Ramp${report.config.rampSeconds}s`);
lines.push(`- 写入压测:${report.config.includeWrites ? '开启' : '关闭'}`);
if (report.config.includeWrites) {
lines.push(`- 刷题闭环比例:${Math.round((report.config.practiceFlowRatio || 0) * 100)}%`);
lines.push(`- 每个刷题闭环提交答案数:${report.config.practiceFlowAnswers || 0}`);
}
lines.push(`- 请求超时:${report.config.timeoutMs}ms`);
lines.push('');
lines.push('## 总览');
@@ -682,7 +882,8 @@ function markdownReport(report) {
}
lines.push('## 说明');
lines.push('');
lines.push('- 默认压测是只读混合工作负载,适合在真实迁移库上做烟测。');
lines.push('- 默认压测是只读混合工作负载,适合在真实迁移库上做烟测;开启写入后会执行“创建练习 session -> 读取详情 -> 提交答案 -> 交卷 -> 读取报告”的刷题闭环。');
lines.push('- 并发 worker 是无停顿请求流,不能直接等同于真实在线人数;真实学生有读题、思考、翻页和网络间隔,应结合前端埋点估算每人 RPS。');
lines.push('- 结果只能代表当前本机 Docker、API 进程和数据库状态4 核 16G 云服务器需要按 runbook 跑阶梯并发。');
lines.push('- 若 P95 明显升高,下一步应结合 PostgreSQL 慢 SQL、`pg_stat_activity` 和 API 日志定位。');
lines.push('');