#!/usr/bin/env node import { execFileSync } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { verifyPilotExportContract } from './lib/pilot-export-contract.mjs'; const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); const args = new Map(process.argv.slice(2).map((arg) => { const [key, ...rest] = arg.replace(/^--/, '').split('='); return [key, rest.join('=') || 'true']; })); const baseUrl = args.get('base-url') || process.env.AIHR_BASE_URL || 'https://wygj-api.localhost'; const verbose = args.has('verbose'); const parsedBaseUrl = new URL(baseUrl); if (parsedBaseUrl.protocol === 'https:' && parsedBaseUrl.hostname.endsWith('.localhost') && !process.env.NODE_EXTRA_CA_CERTS) { // ponytail: local portless CA is not visible to Node fetch; only relax TLS for .localhost dev verification. process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; } const file = path.resolve(root, args.get('file') || 'docs/20260708/发布会演示候选题单.md'); const prefixes = (args.get('prefix') || 'Q').split(',').map((item) => item.trim()).filter(Boolean); const ids = new Set((args.get('ids') || '').split(',').map((item) => item.trim()).filter(Boolean)); const limit = Number(args.get('limit') || 0); const orgDryRun = args.has('org-dry-run'); const pilotExport = args.has('pilot-export'); const pilotStartDate = args.get('start-date') || ''; const pilotEndDate = args.get('end-date') || ''; function readEnv(filePath) { const env = {}; const text = fs.readFileSync(filePath, 'utf8'); for (const line of text.split(/\r?\n/)) { const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/); if (!match) continue; env[match[1]] = match[2].replace(/^['"]|['"]$/g, ''); } return env; } function pem(label, body) { return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`; } const frontendEnv = readEnv(path.join(root, 'frontend/.env.development')); const clientid = process.env.AIHR_CLIENT_ID || frontendEnv.VITE_APP_CLIENT_ID; const requestPublicKey = pem('PUBLIC KEY', frontendEnv.VITE_APP_RSA_PUBLIC_KEY); const responsePrivateKey = pem('PRIVATE KEY', frontendEnv.VITE_APP_RSA_PRIVATE_KEY); function aesEncrypt(text, keyText) { const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null); cipher.setAutoPadding(true); return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64'); } function aesDecrypt(base64Text, key) { const decipher = crypto.createDecipheriv('aes-256-ecb', key, null); decipher.setAutoPadding(true); return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8'); } function encryptPayload(payload) { const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32); const encryptedBody = aesEncrypt(JSON.stringify(payload), keyText); const keyBase64 = Buffer.from(keyText, 'utf8').toString('base64'); const encryptedKey = crypto.publicEncrypt( { key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(keyBase64, 'utf8') ).toString('base64'); return { encryptedBody, encryptedKey }; } function decryptResponse(text, encryptedKey) { if (!encryptedKey) return JSON.parse(text); const keyBase64 = crypto.privateDecrypt( { key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(encryptedKey, 'base64') ).toString('utf8'); return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64'))); } async function request(method, endpoint, body, token, encrypted = false) { const headers = { clientid, 'Content-Language': 'zh_CN' }; let payload; if (token) headers.Authorization = `Bearer ${token}`; if (body !== undefined) { headers['Content-Type'] = 'application/json;charset=utf-8'; if (encrypted) { const encryptedPayload = encryptPayload(body); payload = encryptedPayload.encryptedBody; headers['encrypt-key'] = encryptedPayload.encryptedKey; } else { payload = JSON.stringify(body); } } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 70000); try { const response = await fetch(`${baseUrl}${endpoint}`, { method, headers, body: payload, signal: controller.signal }); const text = await response.text(); const data = encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text); return { status: response.status, data }; } finally { clearTimeout(timeout); } } async function requestText(method, endpoint, token) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 70000); try { const response = await fetch(`${baseUrl}${endpoint}`, { method, headers: { clientid, 'Content-Language': 'zh_CN', Authorization: `Bearer ${token}` }, signal: controller.signal }); return { status: response.status, contentType: response.headers.get('content-type') || '', disposition: response.headers.get('content-disposition') || '', text: await response.text() }; } finally { clearTimeout(timeout); } } async function login() { if (process.env.AIHR_VERIFY_TOKEN) return process.env.AIHR_VERIFY_TOKEN; const codeResponse = await request('GET', '/auth/code'); const uuid = codeResponse.data?.data?.uuid; if (!uuid) throw new Error('captcha uuid missing'); const redisKey = `global:captcha_codes:${uuid}`; const redisValue = execFileSync( 'docker', ['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', redisKey], { encoding: 'utf8' } ).trim().split('\n').pop().replace(/^"|"$/g, ''); const loginResponse = await request('POST', '/auth/login', { tenantId: '000000', username: process.env.AIHR_VERIFY_USER || 'admin', password: process.env.AIHR_VERIFY_PASSWORD || 'admin123', rememberMe: false, code: redisValue, uuid, clientId: clientid, grantType: 'password' }, undefined, true); const token = loginResponse.data?.data?.access_token; if (!token) throw new Error(`login failed: ${loginResponse.data?.msg || loginResponse.status}`); return token; } function parseQuestions(markdown) { const rows = []; for (const line of markdown.split(/\r?\n/)) { if (!line.startsWith('|')) continue; const cells = line.split('|').slice(1, -1).map((cell) => cell.trim()); if (cells.length < 5 || !/^[A-Z]\d+$/u.test(cells[0])) continue; if (ids.size && !ids.has(cells[0])) continue; if (!prefixes.some((prefix) => cells[0].startsWith(prefix))) continue; rows.push({ id: cells[0], scene: cells[1], question: cells[2], sources: [...cells[3].matchAll(/`([^`]+)`/g)].map((match) => match[1]) }); } return limit > 0 ? rows.slice(0, limit) : rows; } function hasExpectedSource(result, expectedSources) { if (!expectedSources.length) return true; const haystack = [ ...(result.docs || []).map((item) => item.title || item.docName || item.name || ''), ...(result.snippets || []).map((item) => item.title || item.docName || item.name || '') ].join('\n'); return expectedSources.some((source) => haystack.includes(source.replace(/\s+/g, ' ').trim())); } function evidenceTitles(result) { return [ ...(result.docs || []).map((item) => item.title || item.docName || item.name || ''), ...(result.snippets || []).map((item) => item.title || item.docName || item.name || '') ].filter(Boolean); } const token = await login(); if (pilotExport) { if (!/^\d{4}-\d{2}-\d{2}$/.test(pilotStartDate) || !/^\d{4}-\d{2}-\d{2}$/.test(pilotEndDate)) { throw new Error('--pilot-export requires --start-date=YYYY-MM-DD and --end-date=YYYY-MM-DD'); } const missingWindow = await requestText('GET', '/api/train/practice/export', token); let missingWindowData; try { missingWindowData = JSON.parse(missingWindow.text); } catch { missingWindowData = null; } if (missingWindow.status < 400 && (missingWindow.contentType.includes('text/csv') || missingWindowData?.code === 200)) { console.error('FAIL: pilot export accepted a request without an explicit date window'); process.exit(1); } const endpoint = `/api/train/practice/export?startDate=${encodeURIComponent(pilotStartDate)}&endDate=${encodeURIComponent(pilotEndDate)}`; const response = await requestText('GET', endpoint, token); if (response.status !== 200 || !response.contentType.includes('text/csv')) { console.error(`FAIL: pilot export HTTP ${response.status}, content-type=${response.contentType}`); if (verbose) console.error(response.text.slice(0, 1000)); process.exit(1); } let verifiedExport; try { verifiedExport = verifyPilotExportContract({ csv: response.text, startDate: pilotStartDate, endDate: pilotEndDate, contentDisposition: response.disposition }); } catch (error) { console.error(`FAIL: ${error.message}`); process.exit(1); } const { metrics } = verifiedExport; console.log( `PILOT_EXPORT window=${pilotStartDate}..${pilotEndDate}` + ` org_login=${metrics.get('formal_org_login_count')}` + ` people=${metrics.get('formal_pilot_people_count')}` + ` projects=${metrics.get('formal_pilot_project_count')}` + ` ten_sessions=${metrics.get('completion_people_count')}/${metrics.get('completion_people_total')}` + ` calibration=${metrics.get('calibration_sample_count')}` + ` sop=${metrics.get('sop_review_sample_count')}` + ` ready=${metrics.get('pilot_metric_ready')}` ); process.exit(0); } if (orgDryRun) { const response = await request('POST', '/api/aihr/org/sync', { dryRun: true, replaceExisting: true, pageSize: 500, maxPages: 200 }, token); const data = response.data?.data; if (response.status !== 200 || response.data?.code !== 200 || !data?.dryRun) { console.error(`FAIL: org dry-run failed: ${response.data?.msg || response.status}`); process.exit(1); } console.log(`ORG_DRY_RUN employees=${data.employeeCount} phone_linked=${data.phoneLinked} masked_phone=${data.maskedPhone} suspect_text=${data.suspectText} synced=${data.syncedCount}`); for (const warning of data.warnings || []) { console.log(`WARN ${warning}`); } process.exit(0); } const questions = parseQuestions(fs.readFileSync(file, 'utf8')); if (!questions.length) { console.error(`FAIL: no questions found in ${file} for prefix ${prefixes.join(',')}`); process.exit(1); } const vector = await request('GET', '/api/knowledge/doc/vector-index-status', undefined, token); const vectorStatus = vector.data?.data; if (!vectorStatus?.matched) { console.error(`FAIL: vector index mismatch: ${vectorStatus?.message || 'unknown'}`); process.exit(1); } let failed = 0; let warned = 0; console.log(`VERIFY ${questions.length} question(s), prefix=${prefixes.join(',')}, base=${baseUrl}`); for (const item of questions) { const response = await request('POST', '/api/knowledge/search', { queryText: item.question, category: 'sop', position: '生活顾问', source: 'verify_demo_questions' }, token); const data = response.data?.data || {}; const docs = data.docs || []; const snippets = data.snippets || []; const ok = response.status === 200 && response.data?.code === 200 && Boolean((data.answer || '').trim()) && (docs.length || snippets.length); const sourceOk = hasExpectedSource(data, item.sources); if (!ok) { failed += 1; console.log(`FAIL ${item.id} ${item.scene}: ${item.question}`); continue; } if (!sourceOk) { warned += 1; console.log(`WARN ${item.id} ${item.scene}: answered, but top evidence missed listed source`); if (verbose) { console.log(` evidence: ${evidenceTitles(data).join(' | ') || '-'}`); } } else { console.log(`OK ${item.id} ${item.scene}`); if (verbose) { console.log(` evidence: ${evidenceTitles(data).join(' | ') || '-'}`); } } } console.log(`SUMMARY ok=${questions.length - failed}/${questions.length} warnings=${warned} vector=${vectorStatus.embeddedFragments}/${vectorStatus.fragments}`); process.exit(failed ? 1 : 0);