Files
api-debug/server/routes/debug.js
jason 96bdc292bb Initial commit: API Debug Tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 04:32:35 +08:00

215 lines
8.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const router = express.Router();
const db = require('../db');
const axios = require('axios');
const crypto = require('crypto');
const auth = require('../middleware/auth');
router.use(auth);
/**
* 将私钥(裸 base64 PKCS8 DER 或 PEM加载为 KeyObject
*/
function loadPrivateKey(raw) {
if (!raw) return null;
if (raw.includes('-----BEGIN')) {
return crypto.createPrivateKey(raw);
}
const derBuf = Buffer.from(raw.trim(), 'base64');
return crypto.createPrivateKey({ key: derBuf, format: 'der', type: 'pkcs8' });
}
// 获取 DingTalk Access Token
async function getAccessToken(appKey, appSecret) {
// 1. 检查缓存
const [rows] = await db.query(
'SELECT * FROM token_cache WHERE app_key = ? AND expires_at > NOW()',
[appKey]
);
if (rows.length > 0) {
return rows[0].access_token;
}
// 2. 调用钉钉接口获取
try {
const response = await axios.post('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
appKey,
appSecret
});
const { accessToken, expireIn } = response.data;
// 计算过期时间 (提前 5 分钟过期以保证安全)
const expiresAt = new Date(Date.now() + (expireIn - 300) * 1000);
// 3. 更新缓存
await db.query(
'INSERT INTO token_cache (app_key, access_token, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE access_token = ?, expires_at = ?',
[appKey, accessToken, expiresAt, accessToken, expiresAt]
);
return accessToken;
} catch (err) {
console.error('获取钉钉 Token 失败:', err.response ? err.response.data : err.message);
throw new Error('获取 AccessToken 失败: ' + (err.response?.data?.message || err.message));
}
}
// 旗舰版PP 接口调试转发
router.post('/execute-pp', async (req, res) => {
try {
const { tenantId, endpointId, body, queryParams } = req.body;
if (!tenantId || !endpointId) {
return res.status(400).json({ error: 'tenantId and endpointId are required' });
}
// 1. 获取租户和接口信息(验证归属当前用户)
const [[tenantInfo]] = await db.query('SELECT * FROM tenants WHERE id = ? AND user_id = ?', [tenantId, req.user.username]);
const [[endpointInfo]] = await db.query('SELECT * FROM endpoints WHERE id = ? AND user_id = ?', [endpointId, req.user.username]);
if (!tenantInfo || !endpointInfo) {
return res.status(404).json({ error: '租户或接口信息不存在' });
}
// The endpoint URL is an absolute URL (e.g., https://api.dingtalk.com/...)
if (!endpointInfo.url) {
return res.status(400).json({ error: 'Endpoint url is missing' });
}
let finalUrl = endpointInfo.url;
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
let requestConfig = {
method: endpointInfo.method.toLowerCase(),
url: finalUrl,
headers,
timeout: 15000 // 15s timeout
};
// Standard PP Logic
if (tenantInfo.type === '标品PP') {
// 1. Basic Auth using API Key
if (tenantInfo.api_key) {
const encodedKey = Buffer.from(`${tenantInfo.api_key}:`).toString('base64');
headers['Authorization'] = `Basic ${encodedKey}`;
}
// 2. Prepare Query Parameters for Signature
const mergedParams = { ...(queryParams || {}) };
// Auto inject missing parameters
if (!mergedParams.timestamp) mergedParams.timestamp = Date.now().toString();
if (!mergedParams.nonce) {
// 文档要求:字母数字,最多 8 字符
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
mergedParams.nonce = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
if (!mergedParams.entCode && tenantInfo.ent_code) mergedParams.entCode = tenantInfo.ent_code;
if (!mergedParams.apiCode && endpointInfo.api_code) mergedParams.apiCode = endpointInfo.api_code;
// userName 为非必填,有值时加入签名(文档示例:...&userName=xiao@qq.com
if (!mergedParams.userName && endpointInfo.user_name) mergedParams.userName = endpointInfo.user_name;
// 3. RSA-MD5 Signature Generation
if (tenantInfo.private_key) {
// Collect keys and sort alphabetically
const keys = Object.keys(mergedParams).sort();
const signParts = keys.map(k => `${k}=${mergedParams[k]}`);
const signString = signParts.join('&');
try {
const privateKey = loadPrivateKey(tenantInfo.private_key);
const signGenerator = crypto.createSign('RSA-MD5');
signGenerator.update(signString, 'utf8');
const signatureBase64 = signGenerator.sign(privateKey, 'base64');
// Add signature to params (axios params will URL-encode it automatically)
mergedParams.sign = signatureBase64;
} catch (signErr) {
console.error('Failed to generate signature via RSA-MD5', signErr);
// Continue anyway, it might fail on server but we let them debug it
}
}
requestConfig.params = mergedParams;
} else if (tenantInfo.type === '旗舰版PP') {
// DingTalk specific logic
// 2. 获取 Token
const token = (await getAccessToken(tenantInfo.app_key, tenantInfo.app_secret)).trim();
headers['x-acs-dingtalk-access-token'] = token;
} else {
return res.status(400).json({ error: `不支持的租户类型: ${tenantInfo.type}` });
}
// Apply Data Body
if (requestConfig.method !== 'get' && body && Object.keys(body).length > 0) {
requestConfig.data = body;
}
// 仅供复制 CURL 使用 (近似的命令)
let curlCmd = `curl -X ${endpointInfo.method.toUpperCase()} '${finalUrl}${requestConfig.params ? '?' + new URLSearchParams(requestConfig.params).toString() : ''}'`;
Object.entries(headers).forEach(([k, v]) => {
curlCmd += ` \\\n -H '${k}: ${v}'`;
});
if (requestConfig.data) {
// 格式化输出 JSON
curlCmd += ` \\\n -d '${JSON.stringify(requestConfig.data, null, 2)}'`;
}
console.log('--- [TRACE] 发送请求详情 ---');
console.log('URL:', requestConfig.url);
console.log('Headers:', JSON.stringify(requestConfig.headers, null, 2));
if (requestConfig.params) console.log('Query Params:', JSON.stringify(requestConfig.params, null, 2));
if (requestConfig.data) console.log('Payload:', JSON.stringify(requestConfig.data, null, 2));
console.log('等效 CURL 命令:\n', curlCmd);
// 3. 执行转发请求
const response = await axios(requestConfig);
// 4. 保存历史记录
await db.query(
'INSERT INTO debug_history (tenant_id, endpoint_id, url, curl_cmd, response_data) VALUES (?, ?, ?, ?, ?)',
[tenantInfo.id, endpointInfo.id, finalUrl, curlCmd, JSON.stringify(response.data)]
);
console.log('--- [TRACE] 接口响应成功并已保存历史 ---');
res.json({
curlCmd: curlCmd,
data: response.data
});
} catch (err) {
const errorMsg = err.response ? err.response.data : err.message;
console.error('--- 调试请求失败 ---');
console.error('错误信息:', err.response ? JSON.stringify(errorMsg, null, 2) : errorMsg);
// 失败也保存历史以便排查 (可选,这里为保持简单先不存,或可根据需求扩展)
res.status(err.response ? err.response.status : 500).json({
error: errorMsg
});
}
});
// 获取调试历史
router.get('/history', async (req, res) => {
try {
const [rows] = await db.query(
`SELECT dh.*, t.name as tenant_name, e.name as endpoint_name
FROM debug_history dh
LEFT JOIN tenants t ON dh.tenant_id = t.id
LEFT JOIN endpoints e ON dh.endpoint_id = e.id
WHERE t.user_id = ?
ORDER BY dh.created_at DESC LIMIT 50`,
[req.user.username]
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;