Files
bigwo/test2/server/app.js
2026-03-12 12:47:56 +08:00

189 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const db = require('./db');
const voiceRoutes = require('./routes/voice');
const chatRoutes = require('./routes/chat');
const sessionRoutes = require('./routes/session');
// ========== 环境变量校验 ==========
function validateEnv() {
const required = [
{ key: 'VOLC_RTC_APP_ID', desc: 'RTC 应用 ID' },
{ key: 'VOLC_RTC_APP_KEY', desc: 'RTC 应用密钥' },
{ key: 'VOLC_ACCESS_KEY_ID', desc: '火山引擎 AccessKey ID' },
{ key: 'VOLC_SECRET_ACCESS_KEY', desc: '火山引擎 Secret Access Key' },
{ key: 'VOLC_S2S_APP_ID', desc: 'S2S 端到端语音 AppID' },
{ key: 'VOLC_S2S_TOKEN', desc: 'S2S 端到端语音 Token' },
{ key: 'VOLC_ARK_ENDPOINT_ID', desc: '方舟 LLM 推理接入点 ID' },
];
const missing = [];
const placeholder = [];
for (const { key, desc } of required) {
const val = process.env[key];
if (!val) {
missing.push({ key, desc });
} else if (val.startsWith('your_')) {
placeholder.push({ key, desc });
}
}
if (missing.length > 0) {
console.warn('\n⚠ 以下必需环境变量未设置:');
missing.forEach(({ key, desc }) => console.warn(`${key}${desc}`));
}
if (placeholder.length > 0) {
console.warn('\n⚠ 以下环境变量仍为占位符Mock 模式):');
placeholder.forEach(({ key, desc }) => console.warn(` 🔶 ${key}${desc}`));
}
const ready = missing.length === 0 && placeholder.length === 0;
if (ready) {
console.log('✅ 所有环境变量已配置');
} else {
console.warn('\n💡 请编辑 server/.env 填入真实凭证,当前将以 Mock 模式运行');
}
// 可选变量提示
const optional = [
{ key: 'COZE_API_TOKEN', desc: 'Coze 智能体(文字对话)' },
{ key: 'COZE_BOT_ID', desc: 'Coze Bot ID' },
{ key: 'VOLC_WEBSEARCH_API_KEY', desc: '联网搜索' },
{ key: 'VOLC_S2S_SPEAKER_ID', desc: '自定义音色' },
{ key: 'VOLC_ARK_KNOWLEDGE_BASE_IDS', desc: '方舟私域知识库(语音)' },
];
const configuredOptional = optional.filter(({ key }) => {
const v = process.env[key];
return v && !v.startsWith('your_');
});
if (configuredOptional.length > 0) {
console.log(`📦 可选功能已启用:${configuredOptional.map(o => o.desc).join('、')}`);
}
return ready;
}
// ========== Express 应用 ==========
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
// RTC Function Calling 回调不带 Content-Type必须在标准 body parser 之前手动读取
// 全局序列号:在 body 读取前同步分配,确保反映真实请求到达顺序
let fcCallbackSeq = 0;
app.post('/api/voice/fc_callback', (req, res, next) => {
const seq = ++fcCallbackSeq; // 同步分配,在任何异步操作之前
if (!req.headers['content-type']) {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBuf = Buffer.concat(chunks);
const raw = rawBuf.toString('utf-8');
console.log(`[RawBody] seq=${seq} Read ${rawBuf.length} bytes`);
// 将所有回调原始内容追加到日志文件
try {
const fs = require('fs');
fs.appendFileSync('fc_all_callbacks.log', `\n=== SEQ=${seq} TIME=${new Date().toISOString()} BYTES=${rawBuf.length} ===\n${raw}\n`);
} catch(e) {}
try { req.body = JSON.parse(raw); } catch (e) { console.error('[RawBody] JSON parse failed:', e.message); req.body = { _raw: raw }; }
req.body._seq = seq;
next();
});
req.on('error', (err) => {
console.error('[RawBody] Error:', err.message);
next();
});
} else {
req.body = req.body || {};
req.body._seq = seq;
next();
}
});
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// 请求日志中间件
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const ms = Date.now() - start;
if (req.path.startsWith('/api/')) {
console.log(`[${req.method}] ${req.path}${res.statusCode} (${ms}ms)`);
}
});
next();
});
app.use('/api/voice', voiceRoutes);
app.use('/api/chat', chatRoutes);
app.use('/api/session', sessionRoutes);
// 静态文件服务
app.use(express.static('../client/dist'));
// 处理单页应用路由
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});
app.get('/api/health', (req, res) => {
const envReady = !process.env.VOLC_RTC_APP_ID?.startsWith('your_');
res.json({
status: 'ok',
mode: 's2s-hybrid',
apiVersion: '2024-12-01',
configured: envReady,
features: {
voiceChat: true,
textChat: !!process.env.COZE_API_TOKEN && !process.env.COZE_API_TOKEN.startsWith('your_') && !!process.env.COZE_BOT_ID && !process.env.COZE_BOT_ID.startsWith('your_'),
textChatProvider: 'coze',
webSearch: !!process.env.VOLC_WEBSEARCH_API_KEY && !process.env.VOLC_WEBSEARCH_API_KEY.startsWith('your_'),
customSpeaker: !!process.env.VOLC_S2S_SPEAKER_ID && !process.env.VOLC_S2S_SPEAKER_ID.startsWith('your_'),
arkKnowledgeBase: !!process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS && !process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS.startsWith('your_'),
},
});
});
// 统一错误处理中间件
app.use((err, req, res, _next) => {
console.error(`[Error] ${req.method} ${req.path}:`, err.message);
const status = err.status || 500;
res.status(status).json({
success: false,
error: err.message || 'Internal Server Error',
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
});
});
// 404 处理
app.use((req, res) => {
res.status(404).json({ success: false, error: `Route not found: ${req.method} ${req.path}` });
});
// 启动服务(先初始化数据库)
async function start() {
try {
await db.initialize();
} catch (err) {
console.error('[DB] MySQL initialization failed:', err.message);
console.warn('[DB] Continuing without database — context switching will use in-memory fallback');
}
app.listen(PORT, () => {
console.log('\n========================================');
console.log(` 🚀 Voice Chat Backend`);
console.log(` 📡 http://localhost:${PORT}`);
console.log(` 🔧 Mode: S2S端到端 + LLM混合 (API v2024-12-01)`);
console.log('========================================\n');
validateEnv();
console.log('');
});
}
start();