Files
prop-ai-hr/scripts/verify-knowledge-platform-local.mjs
T

219 lines
9.7 KiB
JavaScript
Executable File

#!/usr/bin/env node
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { verifyKnowledgePlatform } from './verify-knowledge-platform.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const baseUrl = process.env.AIHR_BASE_URL || 'https://wygj-api.localhost';
const frontendEnv = readEnv(path.join(root, 'frontend/.env.development'));
const adminClientId = 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);
if (new URL(baseUrl).hostname.endsWith('.localhost')) {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
function readEnv(filePath) {
const values = {};
for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/);
if (match) values[match[1]] = match[2].replace(/^['"]|['"]$/g, '');
}
return values;
}
function pem(label, body) {
return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`;
}
function encryptPayload(payload) {
const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32);
const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null);
cipher.setAutoPadding(true);
const body = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]).toString('base64');
const encryptedKey = crypto.publicEncrypt(
{ key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8')
).toString('base64');
return { body, 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');
const decipher = crypto.createDecipheriv('aes-256-ecb', Buffer.from(keyBase64, 'base64'), null);
decipher.setAutoPadding(true);
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString('utf8'));
}
async function request(method, endpoint, body, { encrypted = false, clientId = adminClientId } = {}) {
const headers = { clientid: clientId, 'Content-Language': 'zh_CN' };
let payload;
if (body !== undefined) {
headers['Content-Type'] = 'application/json;charset=utf-8';
if (encrypted) {
const value = encryptPayload(body);
headers['encrypt-key'] = value.encryptedKey;
payload = value.body;
} else {
payload = JSON.stringify(body);
}
}
const response = await fetch(`${baseUrl}${endpoint}`, {
method,
headers,
body: payload,
signal: AbortSignal.timeout(20_000)
});
const text = await response.text();
return encrypted ? decryptResponse(text, response.headers.get('encrypt-key')) : JSON.parse(text);
}
function docker(container, command) {
return execFileSync('docker', ['exec', container, ...command], { encoding: 'utf8' }).trim();
}
function mysql(sql) {
return docker('wygj-mysql', ['mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', 'ry-vue', '-Nse', sql]);
}
async function adminLogin() {
const captcha = await request('GET', '/auth/code');
const uuid = captcha.data?.uuid;
if (!uuid) throw new Error('本地管理员登录未返回验证码UUID');
const redis = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--raw', 'get', `global:captcha_codes:${uuid}`]);
const code = redis.split('\n').pop().replace(/^"|"$/g, '');
const response = await request('POST', '/auth/login', {
tenantId: '000000',
username: process.env.AIHR_VERIFY_USER || 'admin',
password: process.env.AIHR_VERIFY_PASSWORD || 'admin123',
rememberMe: false,
code,
uuid,
clientId: adminClientId,
grantType: 'password'
}, { encrypted: true });
if (!response.data?.access_token) throw new Error(`本地管理员登录失败: ${response.msg || response.code}`);
return response.data.access_token;
}
async function mobileLogin(phonenumber) {
const mobileClientId = '428a8310cd442757ae699df5d894f051';
const sent = await request('GET', `/resource/sms/code?phonenumber=${encodeURIComponent(phonenumber)}`,
undefined, { clientId: mobileClientId });
if (Number(sent.code) !== 200) throw new Error(`本地手机号 ${phonenumber} 验证码准备失败: ${sent.msg || sent.code}`);
const response = await request('POST', '/auth/mobile/sms-login', {
phonenumber,
smsCode: process.env.AIHR_VERIFY_SMS_CODE || '123456',
tenantId: '000000'
}, { clientId: mobileClientId });
if (!response.data?.access_token) throw new Error(`本地手机号 ${phonenumber} 登录失败: ${response.msg || response.code}`);
return response.data.access_token;
}
async function prepareSharedDocumentFixture(adminToken, fileName, marker) {
const form = new FormData();
form.append('file', new Blob([`${marker}\n员工应遵守基本制度,包括信息保密、考勤和服务规范。\n报修受理后第一步应登记业主信息、报修事项和联系方式并生成工单。\n该文件仅用于验证同一OSS在两个知识空间中的独立成员关系。\n`],
{ type: 'text/plain;charset=utf-8' }), fileName);
form.append('spaceCodes', 'yc_public_policy');
form.append('spaceCodes', 'yc_property_sop');
const response = await fetch(`${baseUrl}/api/knowledge/doc/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${adminToken}`, clientid: adminClientId },
body: form,
signal: AbortSignal.timeout(60_000)
});
const payload = await response.json();
if (Number(payload.code) !== 200) throw new Error(`双空间测试文档准备失败: ${payload.msg || payload.code}`);
console.log('PASS 双空间测试文档已准备');
}
function installTemporaryMeituToken() {
const token = `ak_mt_card_miniapp_${crypto.randomBytes(32).toString('base64url')}`;
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
mysql(`update aihr_knowledge_app set token_hash='${tokenHash}', status='ACTIVE', rate_limit_per_minute=10, expires_time=now()+interval 1 day where tenant_id='100001' and app_code='mt_card_miniapp' and auth_type='API_TOKEN'`);
return token;
}
function disableTemporaryMeituToken() {
mysql("update aihr_knowledge_app set token_hash=null, status='DISABLED', rate_limit_per_minute=60, expires_time=null where tenant_id='100001' and app_code='mt_card_miniapp'");
}
function clearLocalAppRateLimit() {
const output = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--scan', '--pattern', '*knowledge:app:*']);
for (const key of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', 'del', key]);
}
}
function clearLocalMobileAuthFixture() {
const output = docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', '--scan', '--pattern', '*1390000000*']);
for (const key of output.split('\n').map((value) => value.trim()).filter(Boolean)) {
docker('wygj-redis', ['redis-cli', '-a', 'ruoyi123', 'del', key]);
}
}
async function expectExternalRejected(token, label) {
const response = await fetch(`${baseUrl}/api/open/knowledge/query`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ queryText: '认证状态验证', spaceCodes: ['mt_customer_service'] }),
signal: AbortSignal.timeout(20_000)
});
const payload = await response.json();
if (Number(payload.code) !== 401) throw new Error(`${label}: 期望业务码401,实际${payload.code}`);
console.log(`PASS ${label}`);
}
async function main() {
const externalToken = installTemporaryMeituToken();
clearLocalAppRateLimit();
clearLocalMobileAuthFixture();
try {
const [adminToken, employeeToken, supervisorToken] = await Promise.all([
adminLogin(), mobileLogin('13900000001'), mobileLogin('13900000002')
]);
const sharedDocumentName = process.env.AIHR_SHARED_DOC_NAME || 'knowledge-platform-shared-verification.txt';
const sharedDocumentQuery = process.env.AIHR_SHARED_DOC_QUERY || '知识空间共享验证标记';
if (process.env.AIHR_VERIFY_UNBIND === 'true') {
await prepareSharedDocumentFixture(adminToken, sharedDocumentName, sharedDocumentQuery);
}
const results = await verifyKnowledgePlatform({
AIHR_BASE_URL: baseUrl,
AIHR_SILVER_ADMIN_TOKEN: adminToken,
AIHR_SILVER_EMPLOYEE_TOKEN: employeeToken,
AIHR_SILVER_SUPERVISOR_TOKEN: supervisorToken,
AIHR_MEITU_APP_TOKEN: externalToken,
AIHR_SHARED_DOC_QUERY: sharedDocumentQuery,
AIHR_MEITU_QUERY: process.env.AIHR_MEITU_QUERY || 'MEITU_VERIFY_20260716',
AIHR_MEITU_RATE_LIMIT: 10,
AIHR_VERIFY_RATE_LIMIT: true,
AIHR_VERIFY_UNBIND: process.env.AIHR_VERIFY_UNBIND === 'true',
AIHR_SHARED_DOC_NAME: sharedDocumentName,
AIHR_VERIFY_TIMEOUT_MS: 20_000
});
mysql("update aihr_knowledge_app set status='DISABLED' where tenant_id='100001' and app_code='mt_card_miniapp'");
await expectExternalRejected(externalToken, '停用应用令牌被拒绝');
mysql("update aihr_knowledge_app set status='ACTIVE', expires_time=now()-interval 1 minute where tenant_id='100001' and app_code='mt_card_miniapp'");
await expectExternalRejected(externalToken, '过期应用令牌被拒绝');
console.log(`LOCAL_HTTP_RESULT ${results.length + 2}/${results.length + 2}`);
} finally {
disableTemporaryMeituToken();
}
}
main().catch((error) => {
console.error(`本地知识平台验证失败: ${error.message}`);
process.exitCode = 1;
});