Files
gongxue-base/scripts/launch-persona-smoke.js
2026-07-01 08:45:20 +08:00

638 lines
23 KiB
JavaScript

import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import pg from 'pg';
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const cliOptions = parseCliArgs(process.argv.slice(2));
const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL;
const outputDir =
cliOptions.outputDir || resolvePathOption(process.env.LAUNCH_SMOKE_OUTPUT_DIR || 'docs/refactor/launch-artifacts');
const stableJsonPath = cliOptions.writePath || resolvePathOption(process.env.LAUNCH_SMOKE_WRITE_PATH || '');
const stableMdPath = cliOptions.writeMdPath || resolvePathOption(process.env.LAUNCH_SMOKE_WRITE_MD_PATH || '');
const startServer = boolEnv('LAUNCH_SMOKE_START_SERVER', !process.env.LAUNCH_SMOKE_API_BASE);
const fixedPort = Number(process.env.LAUNCH_SMOKE_API_PORT || 0) || 0;
const authMode = normalizeAuthMode(process.env.LAUNCH_SMOKE_AUTH_MODE || 'app_session');
const authSessionSecret = process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me';
const ids = {
tenant: '00000000-0000-0000-0000-000000000001',
partnerTenant: '00000000-0000-0000-0000-000000000901',
student: '00000000-0000-0000-0000-000000000101',
tenantAdmin: '00000000-0000-0000-0000-000000000102',
platformAdmin: '00000000-0000-0000-0000-000000000999',
region: '00000000-0000-0000-0000-000000000301',
plan: '00000000-0000-0000-0000-000000000201',
};
let apiBase = process.env.LAUNCH_SMOKE_API_BASE || process.env.API_BASE || '';
let serverProcess = null;
let serverLogs = '';
let authTokens = {};
function resolvePathOption(value) {
if (!value) return '';
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
}
function readValueArg(argv, index, flag) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a file path value.`);
return value;
}
function parseCliArgs(argv = []) {
const options = {
outputDir: '',
writePath: '',
writeMdPath: '',
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--json') {
continue;
}
if (arg === '--output-dir') {
options.outputDir = resolvePathOption(readValueArg(argv, index, arg));
index += 1;
continue;
}
if (arg.startsWith('--output-dir=')) {
options.outputDir = resolvePathOption(arg.slice('--output-dir='.length));
continue;
}
if (arg === '--write') {
options.writePath = resolvePathOption(readValueArg(argv, index, arg));
index += 1;
continue;
}
if (arg.startsWith('--write=')) {
options.writePath = resolvePathOption(arg.slice('--write='.length));
continue;
}
if (arg === '--write-md') {
options.writeMdPath = resolvePathOption(readValueArg(argv, index, arg));
index += 1;
continue;
}
if (arg.startsWith('--write-md=')) {
options.writeMdPath = resolvePathOption(arg.slice('--write-md='.length));
continue;
}
throw new Error(`Unknown launch persona smoke option: ${arg}`);
}
return options;
}
function boolEnv(key, fallback) {
const value = process.env[key];
if (value === undefined || value === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
}
function normalizeAuthMode(value) {
const normalized = String(value || '').toLowerCase().replace(/[-_]/g, '');
if (normalized === 'legacy' || normalized === 'headers') return 'legacy';
if (normalized === 'appsession' || normalized === 'session' || normalized === 'tk') return 'app_session';
return 'app_session';
}
function nowIso() {
return new Date().toISOString();
}
function shanghaiStamp(date = new Date()) {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date).map(part => [part.type, part.value]),
);
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
}
function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on('error', reject);
server.listen(fixedPort, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
});
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function waitForHealth(timeoutMs = 20_000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(new URL('/health', apiBase));
const payload = await response.json().catch(() => ({}));
if (response.ok && payload.ok) return;
} catch (error) {
lastError = error;
}
await sleep(250);
}
throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`);
}
async function startServerIfNeeded() {
if (!startServer) return;
const port = await getFreePort();
apiBase = `http://127.0.0.1:${port}`;
serverProcess = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], {
cwd: process.cwd(),
env: {
...process.env,
PORT: String(port),
DATABASE_URL: databaseUrl,
AUTH_SESSION_SECRET: authSessionSecret,
ALLOW_LEGACY_AUTH_HEADERS: authMode === 'legacy' ? 'true' : 'false',
ALLOW_PLATFORM_ADMIN_KEY: authMode === 'legacy' ? 'true' : 'false',
MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576',
MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760',
},
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
serverProcess.stdout.on('data', chunk => {
serverLogs += chunk.toString();
});
serverProcess.stderr.on('data', chunk => {
serverLogs += chunk.toString();
});
await waitForHealth();
}
function stopServer() {
if (serverProcess && !serverProcess.killed) serverProcess.kill();
}
function buildUrl(pathname, query = {}) {
const target = new URL(pathname, apiBase);
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') target.searchParams.set(key, String(value));
}
return target;
}
async function request(pathname, options = {}) {
const token = authTokenFor(options.persona || 'student');
const useSession = authMode === 'app_session' && token;
const response = await fetch(buildUrl(pathname, options.query), {
method: options.method || 'GET',
headers: {
'content-type': 'application/json',
...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || ids.tenant }),
...(useSession ? { authorization: `Bearer ${token}` } : {}),
...(authMode === 'legacy' && options.userId !== false ? { 'x-user-id': options.userId || ids.student } : {}),
...(options.headers || {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json().catch(() => ({}));
const expectedStatus = options.expectStatus || 200;
assert.equal(response.status, expectedStatus, `${options.method || 'GET'} ${pathname} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
return payload;
}
function createSessionToken() {
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
}
function hashSessionToken(token) {
return crypto.createHmac('sha256', authSessionSecret).update(token).digest('hex');
}
function authTokenFor(persona) {
if (authMode !== 'app_session') return '';
return authTokens[persona] || authTokens.student || '';
}
async function createSmokeAuthSession(pool, persona, userId, tenantId = ids.tenant) {
if (authMode !== 'app_session') return '';
const token = createSessionToken();
await pool.query(
`
insert into app_private.auth_sessions (
tenant_id, user_id, token_hash, provider, expires_at, metadata
)
values (
$1, $2, $3, 'launch_persona_smoke',
now() + interval '2 hours',
$4::jsonb
)
`,
[
tenantId,
userId,
hashSessionToken(token),
JSON.stringify({ createdBy: 'launch-persona-smoke', persona, authMode }),
],
);
return token;
}
async function createSmokeAuthSessions(pool) {
if (authMode !== 'app_session') {
authTokens = {};
return;
}
authTokens = {
student: await createSmokeAuthSession(pool, 'student', ids.student, ids.tenant),
tenantAdmin: await createSmokeAuthSession(pool, 'tenantAdmin', ids.tenantAdmin, ids.tenant),
platformAdmin: await createSmokeAuthSession(pool, 'platformAdmin', ids.platformAdmin, ids.tenant),
};
}
async function one(pool, sql, params = []) {
const result = await pool.query(sql, params);
return result.rows[0] || null;
}
function pickAnswer(question) {
const correctIndices = Array.isArray(question.correctOptionIndices) ? question.correctOptionIndices : [];
if (correctIndices.length) return { selectedOptions: correctIndices.map(item => String(item)) };
if (question.correctOptionIndex !== undefined && question.correctOptionIndex !== null) return { selectedOptions: [String(question.correctOptionIndex)] };
if (Array.isArray(question.options) && question.options.length) return { selectedOptions: ['0'] };
return { answerText: 'launch smoke answer', selfJudgedCorrect: true };
}
async function ensureSvipEntitlement(pool) {
await pool.query(
`
insert into public.entitlements (
tenant_id, user_id, entitlement_type, scope_type, scope_id,
source_type, legacy_source_id, starts_at, expires_at, status, metadata
)
values (
$1, $2, 'svip', 'tenant', null,
'launch_persona_smoke', 'launch-persona-smoke', now() - interval '1 minute',
now() + interval '1 day', 'active',
'{"source":"launch_persona_smoke","temporary":true}'::jsonb
)
on conflict do nothing
`,
[ids.tenant, ids.student],
);
}
async function discoverStudentPath(pool) {
const entry = await one(
pool,
`
select id, name
from public.content_entries
where tenant_id = $1 and is_active = true and entry_type = 'question_practice'
order by sort_order asc, created_at asc
limit 1
`,
[ids.tenant],
);
assert.ok(entry, 'launch smoke needs an active question_practice content entry');
const collection = await one(
pool,
`
select qc.id, qc.name, qc.node_id as content_node_id
from public.question_collections qc
where qc.tenant_id = $1
and qc.status = 'active'
and exists (
select 1
from public.question_collection_items qci
join public.questions q on q.tenant_id = qci.tenant_id and q.id = qci.question_id
where qci.tenant_id = qc.tenant_id
and qci.collection_id = qc.id
and q.status = 'published'
)
order by qc.created_at asc
limit 1
`,
[ids.tenant],
);
assert.ok(collection, 'launch smoke needs an active question collection with questions');
const blueprint = await one(
pool,
`
select id, name, mode
from public.practice_blueprints
where tenant_id = $1 and collection_id = $2 and status = 'active'
order by case mode when 'sequential' then 0 when 'random' then 1 else 2 end, created_at asc
limit 1
`,
[ids.tenant, collection.id],
);
assert.ok(blueprint, 'launch smoke needs an active practice blueprint');
return { entry, collection, blueprint };
}
async function studentJourney(pool) {
await ensureSvipEntitlement(pool);
const pathInfo = await discoverStudentPath(pool);
const profile = await request('/api/profile/me');
assert.equal(profile.item?.userId, ids.student, 'student profile should load current user');
const entitlements = await request('/api/commerce/entitlements');
assert.equal(entitlements.summary?.isSvip, true, 'student should be SVIP before practice journey');
const entries = await request('/api/catalog/content-entries', { query: { entryType: 'question_practice' } });
assert.ok(entries.items?.some(item => item.id === pathInfo.entry.id), 'student should see question practice entry');
const collections = await request('/api/catalog/question-collections', { query: { nodeId: pathInfo.collection.content_node_id } });
assert.ok(collections.items?.some(item => item.id === pathInfo.collection.id), 'student should see question collection');
const session = await request('/api/learning/practice-sessions', {
method: 'POST',
body: {
blueprintId: pathInfo.blueprint.id,
collectionId: pathInfo.collection.id,
questionLimit: 3,
metadata: { source: 'launch_persona_smoke' },
},
});
assert.ok(session.item?.id, 'student should create practice session');
assert.notEqual(session.item?.accessMode, 'free', 'SVIP student practice should not be constrained by free quota');
const detail = await request('/api/learning/practice-sessions/detail', {
query: { practiceSessionId: session.item.id },
});
const questions = detail.item?.questions || [];
assert.ok(questions.length > 0, 'practice detail should include questions');
const firstQuestion = questions[0];
await request('/api/learning/favorites/questions', {
method: 'POST',
body: { questionId: firstQuestion.id, favorite: true },
});
await request('/api/learning/answers', {
method: 'POST',
body: {
practiceSessionId: session.item.id,
questionId: firstQuestion.id,
...pickAnswer(firstQuestion),
},
});
const report = await request('/api/learning/practice-sessions/submit', {
method: 'POST',
body: { practiceSessionId: session.item.id },
});
assert.equal(report.item?.practiceSessionId, session.item.id, 'student should submit practice and receive report');
const favorites = await request('/api/learning/favorites/questions', { query: { limit: 20 } });
assert.ok(favorites.items?.some(item => item.id === firstQuestion.id || item.questionId === firstQuestion.id), 'favorite list should include toggled question');
const wrong = await request('/api/learning/wrong-questions', { query: { limit: 20 } });
const wrongPlan = await request('/api/learning/wrong-questions/review-plan');
const favoriteReview = await request('/api/learning/practice-sessions', {
method: 'POST',
body: { mode: 'favorite_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } },
});
assert.equal(favoriteReview.item?.mode, 'favorite_review', 'student should create favorite review session');
const wrongReview =
wrong.items?.length > 0
? await request('/api/learning/practice-sessions', {
method: 'POST',
body: { mode: 'wrong_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } },
})
: null;
return {
profile: { profileId: profile.item?.id, userId: profile.item?.userId, avatarPreset: profile.item?.avatarPreset || null },
entitlement: { isSvip: entitlements.summary?.isSvip, count: entitlements.items?.length || 0 },
practice: {
sessionId: session.item.id,
accessMode: session.item.accessMode,
questionCount: questions.length,
reportId: report.item?.id,
correctCount: report.item?.correctCount,
wrongCount: report.item?.wrongCount,
},
favorite: { questionId: firstQuestion.id, favoriteCount: favorites.items?.length || 0, reviewSessionId: favoriteReview.item?.id },
wrongReview: {
wrongCount: wrong.items?.length || 0,
reviewPlanCount: wrongPlan.items?.length || 0,
reviewSessionId: wrongReview?.item?.id || null,
skippedReason: wrong.items?.length > 0 ? null : 'No wrong questions were present for this student after the sampled answer.',
},
};
}
async function tenantAdminJourney() {
const overview = await request('/api/tenant-admin/overview', { persona: 'tenantAdmin', userId: ids.tenantAdmin });
const dashboard = await request('/api/tenant-admin/dashboard', {
persona: 'tenantAdmin',
userId: ids.tenantAdmin,
query: { timeRange: '7d', regionId: ids.region },
});
const theme = await request('/api/tenant-admin/theme', { persona: 'tenantAdmin', userId: ids.tenantAdmin });
const students = await request('/api/tenant-admin/students', {
persona: 'tenantAdmin',
userId: ids.tenantAdmin,
query: { limit: 10 },
});
const conversion = await request('/api/referral/conversion-report', {
persona: 'tenantAdmin',
userId: ids.tenantAdmin,
query: { limit: 10 },
});
const studentDenied = await request('/api/tenant-admin/dashboard', { expectStatus: 403 });
const crossTenantDenied = await request('/api/tenant-admin/dashboard', {
persona: 'tenantAdmin',
tenantId: ids.partnerTenant,
userId: ids.tenantAdmin,
expectStatus: 403,
});
return {
overview: { tenantId: overview.item?.id, name: overview.item?.name },
dashboard: {
tenantId: dashboard.item?.scope?.tenantId,
trendBuckets: dashboard.item?.trends?.length || 0,
activeHourBuckets: dashboard.item?.activeHours?.length || 0,
questionCount: dashboard.item?.cards?.content?.questions || 0,
},
theme: { status: theme.item?.status || null, activeTemplateCode: theme.item?.activeTemplateCode || null },
students: { count: students.items?.length || 0 },
referralConversion: {
mode: conversion.item?.aggregation?.mode || 'realtime_or_legacy',
leadCount: conversion.item?.summary?.leadCount || 0,
paidSourceCount: conversion.item?.summary?.paidSourceCount || 0,
},
guards: {
studentDashboardDenied: studentDenied.code,
crossTenantDenied: crossTenantDenied.code,
},
};
}
async function platformAdminJourney() {
const headers = authMode === 'legacy' ? { 'x-platform-admin-key': process.env.PLATFORM_ADMIN_API_KEY || 'local-platform-admin-key' } : {};
const overview = await request('/api/platform-admin/overview', {
persona: 'platformAdmin',
tenantId: false,
userId: false,
headers,
});
const tenants = await request('/api/platform-admin/tenants', {
persona: 'platformAdmin',
tenantId: false,
userId: false,
headers,
query: { limit: 10 },
});
const plans = await request('/api/platform-admin/plans', {
persona: 'platformAdmin',
tenantId: false,
userId: false,
headers,
});
const auditAlerts = await request('/api/platform-admin/audit-alerts', {
persona: 'platformAdmin',
tenantId: false,
userId: false,
headers,
query: { status: 'open', limit: 10 },
});
const studentDenied = await request('/api/platform-admin/overview', {
tenantId: false,
expectStatus: 403,
});
return {
overview: {
tenantTotal: overview.item?.tenants?.total || 0,
activeTenantCount: overview.item?.tenants?.active || 0,
},
tenants: { count: tenants.items?.length || 0 },
plans: { count: plans.items?.length || 0 },
auditAlerts: { openCount: auditAlerts.items?.length || 0 },
guards: { studentPlatformDenied: studentDenied.code },
};
}
function stepCell(step, formatPassed) {
if (step.status === 'pass') return formatPassed(step.result);
return `失败:${step.error?.message || 'unknown error'}`;
}
async function writeReport(report) {
await fs.mkdir(outputDir, { recursive: true });
const jsonPath = path.join(outputDir, `launch-persona-smoke-${shanghaiStamp()}.json`);
const mdPath = jsonPath.replace(/\.json$/, '.md');
const jsonPayload = `${JSON.stringify(report, null, 2)}\n`;
await fs.writeFile(jsonPath, jsonPayload, 'utf8');
const lines = [
'# 上线前角色旅程烟测报告',
'',
`生成时间:${new Date(report.finishedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
'',
'| 角色 | 结果 | 关键验证 |',
'| --- | --- | --- |',
`| 普通学生 | ${report.student.status} | ${stepCell(report.student, result => `SVIP=${result.entitlement.isSvip}; session=${result.practice.sessionId}; favoriteReview=${result.favorite.reviewSessionId}; wrongReview=${result.wrongReview.reviewSessionId || result.wrongReview.skippedReason}`)} |`,
`| 租户管理员 | ${report.tenantAdmin.status} | ${stepCell(report.tenantAdmin, result => `dashboardBuckets=${result.dashboard.trendBuckets}; studentDenied=${result.guards.studentDashboardDenied}; crossTenant=${result.guards.crossTenantDenied}`)} |`,
`| 平台管理员 | ${report.platformAdmin.status} | ${stepCell(report.platformAdmin, result => `tenants=${result.overview.tenantTotal}; studentDenied=${result.guards.studentPlatformDenied}`)} |`,
'',
'说明:该脚本会写入少量 `launch_persona_smoke` 测试权益、练习 session、答题和收藏记录。生产环境仅建议在灰度/演练租户运行。',
'',
];
const mdPayload = `${lines.join('\n')}\n`;
await fs.writeFile(mdPath, mdPayload, 'utf8');
if (stableJsonPath) {
await fs.mkdir(path.dirname(stableJsonPath), { recursive: true });
await fs.writeFile(stableJsonPath, jsonPayload, 'utf8');
}
if (stableMdPath) {
await fs.mkdir(path.dirname(stableMdPath), { recursive: true });
await fs.writeFile(stableMdPath, mdPayload, 'utf8');
}
return { jsonPath, mdPath, stableJsonPath, stableMdPath };
}
async function runStep(name, fn) {
const startedAt = nowIso();
try {
const result = await fn();
return { name, status: 'pass', startedAt, finishedAt: nowIso(), result };
} catch (error) {
return {
name,
status: 'fail',
startedAt,
finishedAt: nowIso(),
error: {
message: error?.message || String(error),
stack: error?.stack || '',
},
};
}
}
async function main() {
const pool = new pg.Pool({ connectionString: databaseUrl, max: 4 });
try {
await createSmokeAuthSessions(pool);
await startServerIfNeeded();
if (!apiBase) throw new Error('LAUNCH_SMOKE_API_BASE is required when LAUNCH_SMOKE_START_SERVER=false.');
const report = {
startedAt: nowIso(),
finishedAt: '',
apiBase,
authMode,
databaseUrl: databaseUrl.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'),
student: await runStep('student', () => studentJourney(pool)),
tenantAdmin: await runStep('tenant-admin', tenantAdminJourney),
platformAdmin: await runStep('platform-admin', platformAdminJourney),
};
report.finishedAt = nowIso();
report.status = [report.student, report.tenantAdmin, report.platformAdmin].every(item => item.status === 'pass') ? 'pass' : 'fail';
const files = await writeReport(report);
console.log(`[launch-smoke] status=${report.status}`);
console.log(`[launch-smoke] wrote ${files.jsonPath}`);
console.log(`[launch-smoke] wrote ${files.mdPath}`);
if (files.stableJsonPath) console.log(`[launch-smoke] wrote ${files.stableJsonPath}`);
if (files.stableMdPath) console.log(`[launch-smoke] wrote ${files.stableMdPath}`);
if (report.status !== 'pass') process.exitCode = 1;
} finally {
await pool.end();
stopServer();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch(error => {
console.error(error);
if (serverLogs) console.error(serverLogs);
stopServer();
process.exit(1);
});
}
export { parseCliArgs };