forked from wangziqi/gongxue-base
feat: add crm dead-letter operations and benchmark summary
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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('');
|
||||
|
||||
Reference in New Issue
Block a user