From 91c957d85e117f28bfa2913bf5de32283470f997 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:18:27 +0000 Subject: [PATCH] feat: add trust proxy fix + CN LLM relay service for Guangzhou MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工单一: 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> --- .github/workflows/deploy-cn-llm-relay.yml | 173 ++++++++++ .../workflows/deploy-to-zhuyuan-server.yml | 11 + server/app/modules/domestic-llm-gateway.js | 126 +++++++- server/app/server.js | 4 + server/cn-llm-relay/ecosystem.config.js | 56 ++++ server/cn-llm-relay/package.json | 20 ++ server/cn-llm-relay/relay-server.js | 297 ++++++++++++++++++ server/proxy/config/cn-llm-relay-config.json | 79 +++++ server/proxy/config/server-registry.json | 41 ++- 9 files changed, 799 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/deploy-cn-llm-relay.yml create mode 100644 server/cn-llm-relay/ecosystem.config.js create mode 100644 server/cn-llm-relay/package.json create mode 100644 server/cn-llm-relay/relay-server.js create mode 100644 server/proxy/config/cn-llm-relay-config.json diff --git a/.github/workflows/deploy-cn-llm-relay.yml b/.github/workflows/deploy-cn-llm-relay.yml new file mode 100644 index 00000000..cfd3b51f --- /dev/null +++ b/.github/workflows/deploy-cn-llm-relay.yml @@ -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 diff --git a/.github/workflows/deploy-to-zhuyuan-server.yml b/.github/workflows/deploy-to-zhuyuan-server.yml index b116508e..6a75b63b 100644 --- a/.github/workflows/deploy-to-zhuyuan-server.yml +++ b/.github/workflows/deploy-to-zhuyuan-server.yml @@ -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配置 diff --git a/server/app/modules/domestic-llm-gateway.js b/server/app/modules/domestic-llm-gateway.js index e329f727..bfe58c3d 100644 --- a/server/app/modules/domestic-llm-gateway.js +++ b/server/app/modules/domestic-llm-gateway.js @@ -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 + } }; } diff --git a/server/app/server.js b/server/app/server.js index 771d0dcc..731d729a 100644 --- a/server/app/server.js +++ b/server/app/server.js @@ -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'); diff --git a/server/cn-llm-relay/ecosystem.config.js b/server/cn-llm-relay/ecosystem.config.js new file mode 100644 index 00000000..8f297603 --- /dev/null +++ b/server/cn-llm-relay/ecosystem.config.js @@ -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 + } + ] +}; diff --git a/server/cn-llm-relay/package.json b/server/cn-llm-relay/package.json new file mode 100644 index 00000000..f64bdc78 --- /dev/null +++ b/server/cn-llm-relay/package.json @@ -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 +} diff --git a/server/cn-llm-relay/relay-server.js b/server/cn-llm-relay/relay-server.js new file mode 100644 index 00000000..ae90137c --- /dev/null +++ b/server/cn-llm-relay/relay-server.js @@ -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')} +═══════════════════════════════════════════════ + `); +}); diff --git a/server/proxy/config/cn-llm-relay-config.json b/server/proxy/config/cn-llm-relay-config.json new file mode 100644 index 00000000..8c6b2f4a --- /dev/null +++ b/server/proxy/config/cn-llm-relay-config.json @@ -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鉴权密钥 (新加坡→广州通信用)" + ] + } +} diff --git a/server/proxy/config/server-registry.json b/server/proxy/config/server-registry.json index 4252ff63..068d768f 100644 --- a/server/proxy/config/server-registry.json +++ b/server/proxy/config/server-registry.json @@ -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",