215 lines
8.6 KiB
JavaScript
215 lines
8.6 KiB
JavaScript
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;
|