feat: add system test validation and router admin controls
This commit is contained in:
@@ -97,6 +97,8 @@ export function createAdminApp(services) {
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -315,6 +317,71 @@ export function createAdminApp(services) {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/system-tests/accounts', requireAdmin, async (_req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const accounts = await systemTestAccountService.listAccounts();
|
||||
res.json({ accounts });
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/accounts', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.createAccount({
|
||||
label: req.body?.label,
|
||||
username: req.body?.username,
|
||||
password: req.body?.password,
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return res.status(400).json({ message: result.message ?? '保存测试账号失败' });
|
||||
}
|
||||
res.status(201).json({ account: result.account });
|
||||
});
|
||||
|
||||
adminApi.delete('/system-tests/accounts/:accountId', requireAdmin, async (req, res) => {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const result = await systemTestAccountService.deleteAccount(req.params.accountId);
|
||||
if (!result.ok) {
|
||||
return res.status(404).json({ message: result.message ?? '测试账号不存在' });
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
|
||||
if (!adminSystemTestService?.runSkillValidation) {
|
||||
return res.status(503).json({ message: '系统测试服务未启用' });
|
||||
}
|
||||
const accountId = String(req.body?.accountId ?? '').trim();
|
||||
let username = String(req.body?.username ?? '').trim();
|
||||
let password = String(req.body?.password ?? '');
|
||||
const skillName = String(req.body?.skillName ?? '').trim();
|
||||
if (accountId) {
|
||||
if (!systemTestAccountService) {
|
||||
return res.status(503).json({ message: '系统测试账号服务未启用' });
|
||||
}
|
||||
const account = await systemTestAccountService.getAccountSecret(accountId);
|
||||
if (!account) {
|
||||
return res.status(404).json({ message: '所选测试账号不存在,请刷新后重试' });
|
||||
}
|
||||
username = account.username;
|
||||
password = account.password;
|
||||
}
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ message: '请输入账号和密码' });
|
||||
}
|
||||
const result = await adminSystemTestService.runSkillValidation({
|
||||
username,
|
||||
password,
|
||||
skillName,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
adminApi.get('/wechat/bindings', requireAdmin, async (req, res) => {
|
||||
if (!wechatAdmin) return res.status(503).json({ message: '服务号管理未启用' });
|
||||
res.json(await wechatAdmin.listBindings(req.query));
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from 'node:path';
|
||||
import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
||||
import { projectRoot } from './load-env.mjs';
|
||||
import { importMemind, resolveMemindLib } from './lib-path.mjs';
|
||||
import { createSystemTestAccountService, ensureSystemTestAccountSchema } from './system-test-accounts.mjs';
|
||||
|
||||
export async function bootstrapAdminServices() {
|
||||
if (!isDatabaseConfigured()) {
|
||||
@@ -28,6 +29,7 @@ export async function bootstrapAdminServices() {
|
||||
updateMindSpaceConfig,
|
||||
} = await importMemind('mindspace-config.mjs');
|
||||
const { createMemoryV2AdminConfigService } = await importMemind('memory-v2-admin-config.mjs');
|
||||
const { createAdminSystemTestService } = await importMemind('admin-system-tests.mjs');
|
||||
const { createOpsApi } = await importMemind('admin-routes.mjs');
|
||||
const { createWordFilterService, ensureWordFilterSchema } = await importMemind('word-filter.mjs');
|
||||
const {
|
||||
@@ -86,6 +88,11 @@ export async function bootstrapAdminServices() {
|
||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool, {
|
||||
env: process.env,
|
||||
});
|
||||
const adminSystemTestService = createAdminSystemTestService({
|
||||
pool,
|
||||
userAuth,
|
||||
portalBaseUrl: `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`,
|
||||
});
|
||||
const wechatMpConfig = loadWechatMpConfig();
|
||||
const wechatMpService = createWechatMpService({
|
||||
config: wechatMpConfig,
|
||||
@@ -111,6 +118,8 @@ export async function bootstrapAdminServices() {
|
||||
const subscriptionService = createSubscriptionService(pool, {
|
||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||
});
|
||||
await ensureSystemTestAccountSchema(pool);
|
||||
const systemTestAccountService = createSystemTestAccountService(pool);
|
||||
|
||||
console.log(`Admin DB connected (${process.env.MYSQL_DATABASE ?? 'via DATABASE_URL'})`);
|
||||
console.log(`Users root: ${usersRoot}`);
|
||||
@@ -127,6 +136,8 @@ export async function bootstrapAdminServices() {
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -104,6 +104,8 @@ ready
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
@@ -123,6 +125,8 @@ ready
|
||||
loadMindSpaceConfig,
|
||||
updateMindSpaceConfig,
|
||||
memoryV2ConfigService,
|
||||
adminSystemTestService,
|
||||
systemTestAccountService,
|
||||
wordFilterService,
|
||||
planCatalogService,
|
||||
subscriptionService,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const TABLE = 'h5_admin_system_test_accounts';
|
||||
|
||||
function resolveEncryptionKey(explicitKey) {
|
||||
const raw =
|
||||
explicitKey ??
|
||||
process.env.H5_SETTINGS_ENCRYPTION_KEY ??
|
||||
process.env.TKMIND_SERVER__SECRET_KEY ??
|
||||
'local-dev-secret';
|
||||
return crypto.createHash('sha256').update(raw).digest();
|
||||
}
|
||||
|
||||
function encryptSecret(plaintext, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
|
||||
const key = resolveEncryptionKey(encryptionKey);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
const plain = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plain.toString('utf8');
|
||||
}
|
||||
|
||||
function maskApiKey(apiKey) {
|
||||
if (!apiKey) return '';
|
||||
if (apiKey.length <= 8) return '*'.repeat(apiKey.length);
|
||||
const head = apiKey.slice(0, 4);
|
||||
const tail = apiKey.slice(-4);
|
||||
return `${head}${'*'.repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function toAccountRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
passwordMasked: maskApiKey(row.password_preview ?? ''),
|
||||
createdAt: Number(row.created_at ?? 0) || 0,
|
||||
updatedAt: Number(row.updated_at ?? 0) || 0,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureSystemTestAccountSchema(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
label VARCHAR(120) NOT NULL,
|
||||
username VARCHAR(120) NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
password_iv VARCHAR(255) NOT NULL,
|
||||
password_tag VARCHAR(255) NOT NULL,
|
||||
password_preview VARCHAR(255) NOT NULL DEFAULT '',
|
||||
updated_by CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uniq_${TABLE}_username (username)
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
export function createSystemTestAccountService(pool) {
|
||||
if (!pool) {
|
||||
throw new Error('system test account service requires pool');
|
||||
}
|
||||
|
||||
async function listAccounts() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
ORDER BY updated_at DESC, created_at DESC`,
|
||||
);
|
||||
return rows.map(toAccountRow);
|
||||
}
|
||||
|
||||
async function getAccountSecret(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_ciphertext, password_iv, password_tag
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
username: row.username,
|
||||
password: decryptSecret({
|
||||
ciphertext: row.password_ciphertext,
|
||||
iv: row.password_iv,
|
||||
tag: row.password_tag,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function createAccount({ label, username, password, updatedBy }) {
|
||||
const normalizedUsername = normalizeText(username);
|
||||
const normalizedLabel = normalizeText(label) || normalizedUsername;
|
||||
if (!normalizedUsername || !password) {
|
||||
return { ok: false, message: '账号和密码不能为空' };
|
||||
}
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
const encrypted = encryptSecret(String(password));
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO ${TABLE}
|
||||
(id, label, username, password_ciphertext, password_iv, password_tag, password_preview, updated_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
normalizedLabel,
|
||||
normalizedUsername,
|
||||
encrypted.ciphertext,
|
||||
encrypted.iv,
|
||||
encrypted.tag,
|
||||
maskApiKey(String(password)),
|
||||
updatedBy ?? null,
|
||||
now,
|
||||
now,
|
||||
],
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'ER_DUP_ENTRY') {
|
||||
return { ok: false, message: '该测试账号已存在,请直接使用或先删除旧账号' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const account = await getAccountById(id);
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
async function getAccountById(accountId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, label, username, password_preview, updated_by, created_at, updated_at
|
||||
FROM ${TABLE}
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[accountId],
|
||||
);
|
||||
const row = rows[0] ?? null;
|
||||
return row ? toAccountRow(row) : null;
|
||||
}
|
||||
|
||||
async function deleteAccount(accountId) {
|
||||
const existing = await getAccountById(accountId);
|
||||
if (!existing) return { ok: false, message: '测试账号不存在' };
|
||||
await pool.query(`DELETE FROM ${TABLE} WHERE id = ? LIMIT 1`, [accountId]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
listAccounts,
|
||||
getAccountSecret,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user