feat: add trust proxy fix + CN LLM relay service for Guangzhou
工单一: Add app.set('trust proxy', 1) to server.js (prevents PM2 crash)
Phase 1: Create server/cn-llm-relay/ - lightweight LLM proxy for Guangzhou
Phase 2: Modify domestic-llm-gateway.js - relay through Guangzhou with fallback
Phase 3: Add deploy-cn-llm-relay.yml workflow for automated deployment
Update server-registry.json with ZY-SVR-003 and cn-llm-relay config
Update deploy-to-zhuyuan-server.yml to inject CN relay env vars
Agent-Logs-Url: https://github.com/qinfendebingshuo/guanghulab/sessions/f6db708e-8b10-4339-b48f-db5f111ae5cd
Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
parent
173cb74358
commit
91c957d85e
|
|
@ -0,0 +1,173 @@
|
|||
name: 🇨🇳 CN LLM中继 · 部署
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 广州国内LLM API中继服务部署
|
||||
# 编号: ZY-WF-CN-RELAY
|
||||
# 服务器: ZY-SVR-003 · 43.138.243.30 · 广州
|
||||
# 守护: 铸渊 · ICE-GL-ZY001
|
||||
# 版权: 国作登字-2026-A-00037559
|
||||
#
|
||||
# 功能: 将 cn-llm-relay 代理服务部署到广州服务器
|
||||
# 注入国内LLM API密钥 + 中继鉴权密钥
|
||||
# PM2 守护进程管理
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
action:
|
||||
description: '部署动作'
|
||||
required: true
|
||||
default: 'deploy'
|
||||
type: choice
|
||||
options:
|
||||
- deploy
|
||||
- health-check
|
||||
- restart
|
||||
|
||||
concurrency:
|
||||
group: cn-llm-relay-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: 🚀 部署CN中继
|
||||
if: github.event.inputs.action == 'deploy' || github.event.inputs.action == ''
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: 🔐 配置SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.ZY_CN_LLM_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/cn_key
|
||||
chmod 600 ~/.ssh/cn_key
|
||||
if head -1 ~/.ssh/cn_key | grep -q "BEGIN" && tail -1 ~/.ssh/cn_key | grep -q "END"; then
|
||||
echo "✅ SSH私钥格式正确"
|
||||
else
|
||||
echo "❌ SSH私钥格式异常 — 请检查 ZY_CN_LLM_KEY"
|
||||
exit 1
|
||||
fi
|
||||
ssh-keyscan -H ${{ secrets.ZY_CN_LLM_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: 📦 同步代码到广州
|
||||
run: |
|
||||
rsync -avz --delete \
|
||||
-e "ssh -i ~/.ssh/cn_key" \
|
||||
--exclude='node_modules' \
|
||||
--exclude='.env.relay' \
|
||||
--exclude='logs' \
|
||||
server/cn-llm-relay/ \
|
||||
${{ secrets.ZY_CN_LLM_USER }}@${{ secrets.ZY_CN_LLM_HOST }}:/opt/cn-llm-relay/
|
||||
|
||||
- name: 📥 安装依赖 & 配置 & 启动
|
||||
env:
|
||||
ZY_DEEPSEEK_API_KEY: ${{ secrets.ZY_DEEPSEEK_API_KEY }}
|
||||
ZY_QIANWEN_API_KEY: ${{ secrets.ZY_QIANWEN_API_KEY }}
|
||||
ZY_KIMI_API_KEY: ${{ secrets.ZY_KIMI_API_KEY }}
|
||||
ZY_QINGYAN_API_KEY: ${{ secrets.ZY_QINGYAN_API_KEY }}
|
||||
ZY_CN_RELAY_API_KEY: ${{ secrets.ZY_CN_LLM_RELAY_KEY }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/cn_key \
|
||||
${{ secrets.ZY_CN_LLM_USER }}@${{ secrets.ZY_CN_LLM_HOST }} << 'DEPLOY_CMD'
|
||||
|
||||
set -e
|
||||
cd /opt/cn-llm-relay
|
||||
|
||||
# 创建日志目录
|
||||
mkdir -p /opt/cn-llm-relay/logs
|
||||
|
||||
# 安装依赖
|
||||
npm install --production 2>&1
|
||||
|
||||
DEPLOY_CMD
|
||||
|
||||
# 写入环境变量(在本地构建后通过SSH写入,避免heredoc变量展开问题)
|
||||
ssh -i ~/.ssh/cn_key \
|
||||
${{ secrets.ZY_CN_LLM_USER }}@${{ secrets.ZY_CN_LLM_HOST }} \
|
||||
"cat > /opt/cn-llm-relay/.env.relay << 'ENVEOF'
|
||||
RELAY_PORT=3900
|
||||
ZY_CN_RELAY_API_KEY=${ZY_CN_RELAY_API_KEY}
|
||||
ZY_DEEPSEEK_API_KEY=${ZY_DEEPSEEK_API_KEY}
|
||||
ZY_QIANWEN_API_KEY=${ZY_QIANWEN_API_KEY}
|
||||
ZY_KIMI_API_KEY=${ZY_KIMI_API_KEY}
|
||||
ZY_QINGYAN_API_KEY=${ZY_QINGYAN_API_KEY}
|
||||
ENVEOF
|
||||
chmod 600 /opt/cn-llm-relay/.env.relay"
|
||||
|
||||
# PM2 启动
|
||||
ssh -i ~/.ssh/cn_key \
|
||||
${{ secrets.ZY_CN_LLM_USER }}@${{ secrets.ZY_CN_LLM_HOST }} << 'PM2_CMD'
|
||||
set -e
|
||||
cd /opt/cn-llm-relay
|
||||
pm2 delete cn-llm-relay 2>/dev/null || true
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
|
||||
# 健康检查
|
||||
sleep 3
|
||||
if curl -sf http://127.0.0.1:3900/health > /dev/null 2>&1; then
|
||||
echo "✅ CN LLM Relay 健康检查通过"
|
||||
curl -s http://127.0.0.1:3900/health | head -c 500
|
||||
else
|
||||
echo "⚠️ 健康检查未通过 · 查看日志"
|
||||
pm2 logs cn-llm-relay --lines 20 --nostream
|
||||
fi
|
||||
PM2_CMD
|
||||
|
||||
health-check:
|
||||
name: 🩺 健康检查
|
||||
if: github.event.inputs.action == 'health-check'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🔐 配置SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.ZY_CN_LLM_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/cn_key
|
||||
chmod 600 ~/.ssh/cn_key
|
||||
ssh-keyscan -H ${{ secrets.ZY_CN_LLM_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: 🩺 检查服务状态
|
||||
run: |
|
||||
ssh -i ~/.ssh/cn_key \
|
||||
${{ secrets.ZY_CN_LLM_USER }}@${{ secrets.ZY_CN_LLM_HOST }} << 'CMD'
|
||||
echo "═══ PM2 状态 ═══"
|
||||
pm2 list
|
||||
echo ""
|
||||
echo "═══ 健康检查 ═══"
|
||||
curl -s http://127.0.0.1:3900/health || echo "❌ 服务未响应"
|
||||
echo ""
|
||||
echo "═══ 最近日志 ═══"
|
||||
pm2 logs cn-llm-relay --lines 10 --nostream 2>/dev/null || echo "无日志"
|
||||
CMD
|
||||
|
||||
restart:
|
||||
name: 🔄 重启服务
|
||||
if: github.event.inputs.action == 'restart'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🔐 配置SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.ZY_CN_LLM_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/cn_key
|
||||
chmod 600 ~/.ssh/cn_key
|
||||
ssh-keyscan -H ${{ secrets.ZY_CN_LLM_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: 🔄 重启
|
||||
run: |
|
||||
ssh -i ~/.ssh/cn_key \
|
||||
${{ secrets.ZY_CN_LLM_USER }}@${{ secrets.ZY_CN_LLM_HOST }} << 'CMD'
|
||||
cd /opt/cn-llm-relay
|
||||
pm2 restart cn-llm-relay
|
||||
sleep 2
|
||||
curl -s http://127.0.0.1:3900/health || echo "❌ 重启后服务未响应"
|
||||
CMD
|
||||
|
|
@ -304,6 +304,8 @@ jobs:
|
|||
ZY_OSS_SECRET: ${{ secrets.ZY_OSS_SECRET }}
|
||||
ZY_COS_REGION: ${{ secrets.ZY_COS_REGION }}
|
||||
ZY_NOTION_TOKEN: ${{ secrets.ZY_NOTION_TOKEN }}
|
||||
ZY_CN_LLM_RELAY_HOST: ${{ secrets.ZY_CN_LLM_RELAY_HOST }}
|
||||
ZY_CN_LLM_RELAY_KEY: ${{ secrets.ZY_CN_LLM_RELAY_KEY }}
|
||||
run: |
|
||||
COMMIT_SHA="${{ github.sha }}"
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
|
|
@ -326,6 +328,15 @@ jobs:
|
|||
echo "ZY_KIMI_API_KEY=${ZY_KIMI_API_KEY}" >> /opt/zhuyuan/app/.env.app
|
||||
echo "ZY_QINGYAN_API_KEY=${ZY_QINGYAN_API_KEY}" >> /opt/zhuyuan/app/.env.app
|
||||
echo "ZY_NOTION_TOKEN=${ZY_NOTION_TOKEN}" >> /opt/zhuyuan/app/.env.app
|
||||
# ─── 广州CN中继配置(可选·配置后国内API走广州中继) ───
|
||||
if [ -n "${ZY_CN_LLM_RELAY_HOST}" ]; then
|
||||
echo "ZY_CN_LLM_RELAY_HOST=${ZY_CN_LLM_RELAY_HOST}" >> /opt/zhuyuan/app/.env.app
|
||||
echo "ZY_CN_LLM_RELAY_KEY=${ZY_CN_LLM_RELAY_KEY}" >> /opt/zhuyuan/app/.env.app
|
||||
echo "ZY_CN_LLM_RELAY_PORT=3900" >> /opt/zhuyuan/app/.env.app
|
||||
echo "✅ 广州CN中继已配置: ${ZY_CN_LLM_RELAY_HOST}:3900"
|
||||
else
|
||||
echo "ℹ️ 广州CN中继未配置·国内API走直连"
|
||||
fi
|
||||
chmod 600 /opt/zhuyuan/app/.env.app
|
||||
|
||||
# 复制Nginx配置
|
||||
|
|
|
|||
|
|
@ -23,6 +23,15 @@
|
|||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// ─── 广州CN中继配置 ───
|
||||
// 当配置了 ZY_CN_LLM_RELAY_HOST 时,请求走广州中继(国内直连·低延迟)
|
||||
// 广州不可达时降级为直连国内API(跨境·高延迟但可用)
|
||||
const CN_RELAY_HOST = process.env.ZY_CN_LLM_RELAY_HOST || '';
|
||||
const CN_RELAY_PORT = parseInt(process.env.ZY_CN_LLM_RELAY_PORT || '3900', 10);
|
||||
const CN_RELAY_KEY = process.env.ZY_CN_LLM_RELAY_KEY || '';
|
||||
const CN_RELAY_TIMEOUT = parseInt(process.env.ZY_CN_LLM_RELAY_TIMEOUT || '30000', 10);
|
||||
|
||||
// ─── 国内模型配置(不对外暴露模型名称) ───
|
||||
const DOMESTIC_MODELS = [
|
||||
|
|
@ -195,6 +204,62 @@ function callDomesticLLM(modelConfig, messages) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过广州CN中继调用国内模型API
|
||||
* 架构: SG(新加坡) → 广州(ZY-SVR-003):3900 → 国内API
|
||||
* 对称于硅谷Claude中继: SG → SV(SSH隧道) → Claude API
|
||||
*/
|
||||
function callViaCNRelay(messages, selected, fallbackOrder) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestBody = JSON.stringify({
|
||||
messages,
|
||||
model_id: selected.id,
|
||||
temperature: selected.temperature || 0.7,
|
||||
max_tokens: selected.selectedMaxTokens || selected.maxTokens,
|
||||
fallback_order: fallbackOrder
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: CN_RELAY_HOST,
|
||||
port: CN_RELAY_PORT,
|
||||
path: '/llm/chat',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${CN_RELAY_KEY}`,
|
||||
'Content-Length': Buffer.byteLength(requestBody)
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
if (body.error) {
|
||||
reject(new Error(body.message || '中继返回错误'));
|
||||
} else {
|
||||
resolve(body);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('中继响应解析失败'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(CN_RELAY_TIMEOUT, () => {
|
||||
req.destroy();
|
||||
reject(new Error(`广州中继超时(${CN_RELAY_TIMEOUT}ms)`));
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
reject(new Error(`广州中继连接失败: ${err.message}`));
|
||||
});
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通感语言核心系统提示词
|
||||
*/
|
||||
|
|
@ -262,7 +327,11 @@ const _cleanupTimer = setInterval(() => {
|
|||
if (_cleanupTimer.unref) _cleanupTimer.unref();
|
||||
|
||||
/**
|
||||
* 国内模型智能对话(带自动降级)
|
||||
* 国内模型智能对话(带广州中继 + 自动降级)
|
||||
*
|
||||
* 调用链:
|
||||
* 1. 广州CN中继(如已配置)→ 国内直连·低延迟
|
||||
* 2. 降级: 直连国内API → 跨境·高延迟但可用
|
||||
*/
|
||||
async function chat(userId, message) {
|
||||
const ctx = getContext(userId);
|
||||
|
|
@ -284,12 +353,57 @@ async function chat(userId, message) {
|
|||
};
|
||||
}
|
||||
|
||||
// 尝试调用,失败则降级
|
||||
// 获取可用模型的降级顺序
|
||||
const available = DOMESTIC_MODELS.filter(m => {
|
||||
const key = process.env[m.envKey];
|
||||
return key && key.length > 5;
|
||||
});
|
||||
const fallbackOrder = [selected, ...available.filter(m => m.id !== selected.id)].map(m => m.id);
|
||||
|
||||
// ── 优先走广州CN中继 ──
|
||||
if (CN_RELAY_HOST && CN_RELAY_KEY) {
|
||||
try {
|
||||
const relayResponse = await callViaCNRelay(messages, selected, fallbackOrder);
|
||||
const content = relayResponse.choices?.[0]?.message?.content || '铸渊暂时无法回应...';
|
||||
const usage = relayResponse.usage || {};
|
||||
|
||||
// 记录上下文
|
||||
ctx.messages.push({ role: 'user', content: message });
|
||||
ctx.messages.push({ role: 'assistant', content });
|
||||
ctx.count++;
|
||||
if (ctx.messages.length > MAX_HISTORY * 2) {
|
||||
ctx.messages = ctx.messages.slice(-MAX_HISTORY * 2);
|
||||
}
|
||||
|
||||
// 统计
|
||||
gatewayState.totalCalls++;
|
||||
gatewayState.successCalls++;
|
||||
const modelId = relayResponse.model_id || selected.id;
|
||||
if (!gatewayState.modelStats[modelId]) {
|
||||
gatewayState.modelStats[modelId] = { calls: 0, tokens: 0 };
|
||||
}
|
||||
gatewayState.modelStats[modelId].calls++;
|
||||
gatewayState.modelStats[modelId].tokens += (usage.total_tokens || 0);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: content,
|
||||
model: '智能路由', // 不暴露具体模型名称
|
||||
tier: 'economy',
|
||||
reason: selected.reason,
|
||||
relay: 'cn-relay',
|
||||
usage: {
|
||||
prompt_tokens: usage.prompt_tokens || 0,
|
||||
completion_tokens: usage.completion_tokens || 0
|
||||
}
|
||||
};
|
||||
} catch (relayErr) {
|
||||
console.error(`[国内网关] 广州中继失败,降级为直连: ${relayErr.message}`);
|
||||
// 继续走直连降级路径
|
||||
}
|
||||
}
|
||||
|
||||
// ── 降级: 直连国内API (从新加坡跨境调用) ──
|
||||
let lastError = null;
|
||||
const tried = [selected, ...available.filter(m => m.id !== selected.id)];
|
||||
|
||||
|
|
@ -324,6 +438,7 @@ async function chat(userId, message) {
|
|||
model: '智能路由', // 不暴露具体模型名称
|
||||
tier: model.tier,
|
||||
reason: selected.reason,
|
||||
relay: 'direct',
|
||||
usage: {
|
||||
prompt_tokens: usage.prompt_tokens || 0,
|
||||
completion_tokens: usage.completion_tokens || 0
|
||||
|
|
@ -360,7 +475,12 @@ function getGatewayStats() {
|
|||
const key = process.env[m.envKey];
|
||||
return key && key.length > 5;
|
||||
}).length,
|
||||
totalModels: DOMESTIC_MODELS.length
|
||||
totalModels: DOMESTIC_MODELS.length,
|
||||
cnRelay: {
|
||||
configured: !!(CN_RELAY_HOST && CN_RELAY_KEY),
|
||||
host: CN_RELAY_HOST || null,
|
||||
port: CN_RELAY_PORT
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ const PORT = process.env.PORT || 3800;
|
|||
app.use(cors());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// ─── 信任代理 (Nginx反代 → Express正确读取客户端IP) ───
|
||||
// 冰朔 D67 实机排查: 缺少此行会导致 express-rate-limit 无法获取真实IP → PM2崩溃
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// ─── 速率限制 ───
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* CN LLM Relay · PM2 配置
|
||||
*
|
||||
* 编号: ZY-SVR-PM2-003
|
||||
* 服务器: ZY-SVR-003 · 43.138.243.30 · 广州
|
||||
* 守护: 铸渊 · ICE-GL-ZY001
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
const env = {};
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf('=');
|
||||
if (idx > 0) {
|
||||
env[trimmed.substring(0, idx).trim()] = trimmed.substring(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`加载环境变量失败: ${e.message}`);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
const relayEnv = loadEnvFile(path.join(__dirname, '.env.relay'));
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'cn-llm-relay',
|
||||
script: 'relay-server.js',
|
||||
cwd: '/opt/cn-llm-relay',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '128M',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
RELAY_PORT: 3900,
|
||||
...relayEnv
|
||||
},
|
||||
log_file: '/opt/cn-llm-relay/logs/relay-combined.log',
|
||||
error_file: '/opt/cn-llm-relay/logs/relay-error.log',
|
||||
out_file: '/opt/cn-llm-relay/logs/relay-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
time: true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "cn-llm-relay",
|
||||
"version": "1.0.0",
|
||||
"description": "铸渊国内LLM代理中继 · 广州→国内API · ZY-SVR-003",
|
||||
"main": "relay-server.js",
|
||||
"scripts": {
|
||||
"start": "node relay-server.js",
|
||||
"health": "curl -s http://localhost:3900/health | jq ."
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"express-rate-limit": "^7.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"author": "铸渊 · ICE-GL-ZY001",
|
||||
"license": "UNLICENSED",
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* 🇨🇳 CN LLM Relay · 国内模型API代理中继服务
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* 编号: ZY-CN-LLM-RELAY-001
|
||||
* 服务器: ZY-SVR-003 · 43.138.243.30 · 广州
|
||||
* 端口: 3900
|
||||
* 守护: 铸渊 · ICE-GL-ZY001
|
||||
* 版权: 国作登字-2026-A-00037559
|
||||
*
|
||||
* 功能:
|
||||
* - 接收新加坡服务器转发的LLM API请求
|
||||
* - 在国内网络环境下直连国内模型API(零跨境延迟)
|
||||
* - Bearer Token鉴权防止外部滥用
|
||||
* - 返回AI回复给新加坡服务器
|
||||
*
|
||||
* 架构 (与硅谷Claude中继对称):
|
||||
* 用户 → SG(ZY-SVR-002) → 广州(ZY-SVR-003):3900 → 国内API
|
||||
* 硅谷模式: SG → SV(SSH隧道) → Claude API
|
||||
* 广州模式: SG → 广州(HTTP代理) → 国内API
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.RELAY_PORT || 3900;
|
||||
const RELAY_API_KEY = process.env.ZY_CN_RELAY_API_KEY || '';
|
||||
|
||||
// ─── 国内模型配置 ───
|
||||
const DOMESTIC_MODELS = {
|
||||
ds: {
|
||||
model: 'deepseek-chat',
|
||||
endpoint: 'https://api.deepseek.com/v1/chat/completions',
|
||||
envKey: 'ZY_DEEPSEEK_API_KEY'
|
||||
},
|
||||
qw: {
|
||||
model: 'qwen-turbo',
|
||||
endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
|
||||
envKey: 'ZY_QIANWEN_API_KEY'
|
||||
},
|
||||
km: {
|
||||
model: 'moonshot-v1-8k',
|
||||
endpoint: 'https://api.moonshot.cn/v1/chat/completions',
|
||||
envKey: 'ZY_KIMI_API_KEY'
|
||||
},
|
||||
zp: {
|
||||
model: 'glm-4-flash',
|
||||
endpoint: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
|
||||
envKey: 'ZY_QINGYAN_API_KEY'
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 速率限制 ───
|
||||
const rateLimit = require('express-rate-limit');
|
||||
app.use(rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 120,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: true, message: '请求过于频繁' }
|
||||
}));
|
||||
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
|
||||
// ─── 中继状态 ───
|
||||
const relayState = {
|
||||
totalCalls: 0,
|
||||
successCalls: 0,
|
||||
failedCalls: 0,
|
||||
startTime: Date.now(),
|
||||
lastCall: null,
|
||||
modelStats: {}
|
||||
};
|
||||
|
||||
// ─── Bearer Token 鉴权 ───
|
||||
function apiKeyAuth(req, res, next) {
|
||||
// 健康检查免鉴权
|
||||
if (req.path === '/health') return next();
|
||||
|
||||
if (!RELAY_API_KEY) {
|
||||
return res.status(503).json({
|
||||
error: true,
|
||||
message: '中继服务未配置鉴权密钥 (ZY_CN_RELAY_API_KEY)'
|
||||
});
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization || '';
|
||||
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: true, message: '缺少Authorization头' });
|
||||
}
|
||||
|
||||
// 常数时间比较防止时序攻击
|
||||
try {
|
||||
const a = Buffer.from(token);
|
||||
const b = Buffer.from(RELAY_API_KEY);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||
return res.status(403).json({ error: true, message: '鉴权失败' });
|
||||
}
|
||||
} catch {
|
||||
return res.status(403).json({ error: true, message: '鉴权失败' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
app.use(apiKeyAuth);
|
||||
|
||||
// ─── 健康检查 ───
|
||||
app.get('/health', (_req, res) => {
|
||||
const available = Object.entries(DOMESTIC_MODELS).filter(([, cfg]) => {
|
||||
const key = process.env[cfg.envKey];
|
||||
return key && key.length > 5;
|
||||
}).map(([id]) => id);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'cn-llm-relay',
|
||||
server: 'ZY-SVR-003',
|
||||
location: 'Guangzhou, China',
|
||||
port: PORT,
|
||||
availableModels: available.length,
|
||||
totalModels: Object.keys(DOMESTIC_MODELS).length,
|
||||
uptime: Math.floor((Date.now() - relayState.startTime) / 1000),
|
||||
stats: {
|
||||
total: relayState.totalCalls,
|
||||
success: relayState.successCalls,
|
||||
failed: relayState.failedCalls
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 调用国内模型API ───
|
||||
function callDomesticAPI(modelConfig, apiKey, requestBody) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(modelConfig.endpoint);
|
||||
const bodyStr = JSON.stringify(requestBody);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Length': Buffer.byteLength(bodyStr)
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', chunk => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
if (body.error) {
|
||||
reject(new Error(body.error.message || JSON.stringify(body.error)));
|
||||
} else {
|
||||
resolve(body);
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('响应解析失败'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(60000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('国内API请求超时(60s)'));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(bodyStr);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 核心中继接口 ───
|
||||
app.post('/llm/chat', async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
relayState.totalCalls++;
|
||||
relayState.lastCall = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const { messages, model_id, temperature, max_tokens, fallback_order } = req.body;
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return res.status(400).json({
|
||||
error: true,
|
||||
message: '缺少messages参数'
|
||||
});
|
||||
}
|
||||
|
||||
// 确定模型尝试顺序
|
||||
const tryOrder = fallback_order && Array.isArray(fallback_order)
|
||||
? fallback_order
|
||||
: (model_id ? [model_id] : Object.keys(DOMESTIC_MODELS));
|
||||
|
||||
let lastError = null;
|
||||
|
||||
for (const mid of tryOrder) {
|
||||
const modelCfg = DOMESTIC_MODELS[mid];
|
||||
if (!modelCfg) continue;
|
||||
|
||||
const apiKey = process.env[modelCfg.envKey];
|
||||
if (!apiKey || apiKey.length < 5) continue;
|
||||
|
||||
try {
|
||||
const requestBody = {
|
||||
model: modelCfg.model,
|
||||
messages,
|
||||
temperature: temperature || 0.7,
|
||||
max_tokens: max_tokens || 2000,
|
||||
stream: false
|
||||
};
|
||||
|
||||
const response = await callDomesticAPI(modelCfg, apiKey, requestBody);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// 统计
|
||||
relayState.successCalls++;
|
||||
if (!relayState.modelStats[mid]) {
|
||||
relayState.modelStats[mid] = { calls: 0, tokens: 0 };
|
||||
}
|
||||
relayState.modelStats[mid].calls++;
|
||||
relayState.modelStats[mid].tokens += (response.usage?.total_tokens || 0);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
relay: 'cn-direct',
|
||||
model_id: mid,
|
||||
choices: response.choices,
|
||||
usage: response.usage,
|
||||
elapsed_ms: elapsed
|
||||
});
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.error(`[CN中继] ${mid} 调用失败: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 所有模型都失败
|
||||
relayState.failedCalls++;
|
||||
res.status(502).json({
|
||||
error: true,
|
||||
message: '所有国内模型通道均失败',
|
||||
last_error: lastError?.message,
|
||||
elapsed_ms: Date.now() - startTime
|
||||
});
|
||||
} catch (err) {
|
||||
relayState.failedCalls++;
|
||||
res.status(500).json({
|
||||
error: true,
|
||||
message: `中继内部错误: ${err.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 中继状态接口 ───
|
||||
app.get('/llm/stats', (_req, res) => {
|
||||
res.json({
|
||||
...relayState,
|
||||
uptimeMs: Date.now() - relayState.startTime,
|
||||
availableModels: Object.entries(DOMESTIC_MODELS)
|
||||
.filter(([, cfg]) => {
|
||||
const key = process.env[cfg.envKey];
|
||||
return key && key.length > 5;
|
||||
})
|
||||
.map(([id, cfg]) => ({ id, model: cfg.model }))
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 启动 ───
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
const available = Object.entries(DOMESTIC_MODELS).filter(([, cfg]) => {
|
||||
const key = process.env[cfg.envKey];
|
||||
return key && key.length > 5;
|
||||
});
|
||||
console.log(`
|
||||
═══════════════════════════════════════════════
|
||||
🇨🇳 CN LLM Relay · 国内模型中继服务
|
||||
═══════════════════════════════════════════════
|
||||
端口: ${PORT}
|
||||
鉴权: ${RELAY_API_KEY ? '✅ 已配置' : '❌ 未配置'}
|
||||
可用模型: ${available.length}/${Object.keys(DOMESTIC_MODELS).length}
|
||||
${available.map(([id, cfg]) => ` · ${id}: ${cfg.model}`).join('\n')}
|
||||
═══════════════════════════════════════════════
|
||||
`);
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"_sovereign": "TCS-0002∞ | SYS-GLW-0001",
|
||||
"_copyright": "国作登字-2026-A-00037559",
|
||||
"_description": "广州国内LLM中继配置 · API走广州·算力走新加坡",
|
||||
"version": "1.0",
|
||||
"relay_architecture": {
|
||||
"description": "反向中继: 新加坡(算力) → 广州(国内网络出口) → 国内LLM API",
|
||||
"note": "与硅谷Claude中继对称: SG→SV→Claude = SG→GZ→国内API",
|
||||
"singapore_servers": {
|
||||
"ZY-SVR-002": {
|
||||
"role": "面孔·前端+专线主入口",
|
||||
"location": "Singapore",
|
||||
"spec": "2核8G"
|
||||
},
|
||||
"ZY-SVR-005": {
|
||||
"role": "大脑·DB+MCP+Agent+订阅",
|
||||
"location": "Singapore",
|
||||
"spec": "4核8G"
|
||||
}
|
||||
},
|
||||
"guangzhou_server": {
|
||||
"ZY-SVR-003": {
|
||||
"role": "国内LLM API中继·国内网络出口",
|
||||
"location": "Guangzhou, China",
|
||||
"ipv4": "43.138.243.30",
|
||||
"purpose": "在国内网络环境下直连国内LLM API(零跨境延迟)",
|
||||
"github_secrets": {
|
||||
"host": "ZY_CN_LLM_HOST",
|
||||
"ssh_key": "ZY_CN_LLM_KEY",
|
||||
"user": "ZY_CN_LLM_USER"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"domestic_routing": {
|
||||
"trigger_models": [
|
||||
"deepseek-chat",
|
||||
"qwen-turbo",
|
||||
"moonshot-v1-8k",
|
||||
"glm-4-flash"
|
||||
],
|
||||
"relay_method": "http_proxy",
|
||||
"relay_config": {
|
||||
"description": "新加坡服务器通过HTTP将国内LLM请求转发到广州中继服务",
|
||||
"relay_port": 3900,
|
||||
"relay_path": "/llm/chat",
|
||||
"auth_method": "Bearer Token (ZY_CN_LLM_RELAY_KEY)",
|
||||
"protocol": "HTTP (内部通信·服务器间)"
|
||||
},
|
||||
"fallback": "direct",
|
||||
"fallback_note": "如广州中继不可用,降级为直连国内API(从新加坡跨境调用·延迟高但可用)"
|
||||
},
|
||||
"bandwidth_optimization": {
|
||||
"strategy": "广州国内出口中继",
|
||||
"description": "请求体通常很小(几KB),广州中继延迟可忽略。国内API调用从跨境秒级降到毫秒级。",
|
||||
"gz_bandwidth": "国内直连·零跨境延迟",
|
||||
"sg_bandwidth": "高带宽·算力中心·会话管理"
|
||||
},
|
||||
"setup_requirements": {
|
||||
"guangzhou_server": [
|
||||
"安装Node.js 20+",
|
||||
"部署 cn-llm-relay 服务 (端口3900)",
|
||||
"配置 .env.relay (国内API密钥 + 中继鉴权密钥)",
|
||||
"PM2守护进程·开机自启",
|
||||
"防火墙放行3900端口 (仅允许新加坡服务器IP)"
|
||||
],
|
||||
"singapore_server": [
|
||||
"配置 ZY_CN_LLM_RELAY_HOST (广州服务器IP)",
|
||||
"配置 ZY_CN_LLM_RELAY_KEY (中继鉴权密钥)",
|
||||
"domestic-llm-gateway.js 自动检测并使用中继"
|
||||
],
|
||||
"github_secrets": [
|
||||
"ZY_CN_LLM_HOST — 广州服务器IP (43.138.243.30)",
|
||||
"ZY_CN_LLM_KEY — SSH私钥 (部署用)",
|
||||
"ZY_CN_LLM_USER — SSH用户名",
|
||||
"ZY_CN_LLM_RELAY_KEY — 中继API鉴权密钥 (新加坡→广州通信用)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"_sovereign": "TCS-0002∞ | SYS-GLW-0001",
|
||||
"_copyright": "国作登字-2026-A-00037559",
|
||||
"_description": "光湖语言世界 · 四台服务器注册表",
|
||||
"version": "1.1",
|
||||
"updated_at": "2026-04-10",
|
||||
"_description": "光湖语言世界 · 五台服务器注册表",
|
||||
"version": "1.2",
|
||||
"updated_at": "2026-04-11",
|
||||
"servers": [
|
||||
{
|
||||
"id": "ZY-SVR-002",
|
||||
|
|
@ -27,6 +27,25 @@
|
|||
"user": "SERVER_USER"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ZY-SVR-003",
|
||||
"name": "国内出口",
|
||||
"role": "国内LLM API中继+VPN中转",
|
||||
"location": "Guangzhou, China",
|
||||
"ipv4": "43.138.243.30",
|
||||
"spec": "低配",
|
||||
"status": "active",
|
||||
"services": [
|
||||
"cn-llm-relay (端口3900·国内LLM API代理中继)",
|
||||
"nginx stream (VPN中转·端口2053→SG:443)"
|
||||
],
|
||||
"github_secrets": {
|
||||
"host": "ZY_CN_LLM_HOST",
|
||||
"key": "ZY_CN_LLM_KEY",
|
||||
"user": "ZY_CN_LLM_USER"
|
||||
},
|
||||
"notes": "肥猫广州服务器·复用为国内LLM API中继·冰朔D67批准"
|
||||
},
|
||||
{
|
||||
"id": "ZY-SVR-005",
|
||||
"name": "大脑",
|
||||
|
|
@ -36,7 +55,7 @@
|
|||
"status": "active",
|
||||
"services": [
|
||||
"postgresql (人格体数据库)",
|
||||
"mcp-server (端口3100·99个工具)",
|
||||
"mcp-server (端口3100·121个工具)",
|
||||
"subscription-v3 (端口3805·多用户专线)",
|
||||
"bandwidth-pool-agent (带宽汇聚)",
|
||||
"email-hub (邮件通信中枢)",
|
||||
|
|
@ -103,13 +122,25 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"llm_relay": {
|
||||
"description": "双向LLM API中继架构",
|
||||
"routes": {
|
||||
"domestic_api": "ZY-SVR-002 → ZY-SVR-003(广州) → 国内API (DeepSeek/千问/Kimi/智谱)",
|
||||
"claude_api": "ZY-SVR-002 → ZY-SVR-SV(硅谷SSH隧道) → Claude API"
|
||||
},
|
||||
"config_files": {
|
||||
"domestic": "server/proxy/config/cn-llm-relay-config.json",
|
||||
"claude": "server/proxy/config/claude-relay-config.json"
|
||||
}
|
||||
},
|
||||
"bandwidth_strategy": {
|
||||
"description": "四台服务器按用途分流·带宽最大化",
|
||||
"description": "五台服务器按用途分流·带宽最大化",
|
||||
"routing": {
|
||||
"static_frontend": "ZY-SVR-002",
|
||||
"vpn_main_entry": "ZY-SVR-002",
|
||||
"database_mcp": "ZY-SVR-005",
|
||||
"subscription_service": "ZY-SVR-005",
|
||||
"domestic_llm_relay": "ZY-SVR-003",
|
||||
"claude_api_relay": "ZY-SVR-SV",
|
||||
"international_api": "ZY-SVR-SV",
|
||||
"awen_team": "ZY-SVR-AWEN",
|
||||
|
|
|
|||
Loading…
Reference in New Issue