码桶
发现社区成员的开源项目
server.js10.5 KB
/**
* 主入口 — Express 服务器
* 启动: node server.js 或 npm start
* 开发: npm run dev (自动重载)
*/
const express = require('express');
const session = require('express-session');
const FileStore = require('session-file-store')(session);
const path = require('path');
const fs = require('fs');
const bcrypt = require('bcryptjs');
// 最早安装日志落地:拦截 console.* 并写入 data/logs/app-YYYY-MM-DD.log,供「日志控制台」页面读取
require('./lib/logger');
// 启动横幅(ASCII Art)+ 开源地址
// 用 console.log(而非 process.stdout.write)以便同时写入 data/logs,日志控制台页面也能看到
try {
const BANNER = fs.readFileSync(path.join(__dirname, 'lib', 'banner.txt'), 'utf8');
console.log('\n' + BANNER + '\n');
console.log(' ng-webot 开源地址: https://github.com/naigoucn/ng-webot\n');
} catch (e) { /* 横幅缺失不阻塞启动 */ }
const config = require('./config');
const db = require('./lib/db');
const settings = require('./lib/settings');
const app = express();
// ========== 中间件 ==========
// CORS(允许前端开发时跨域调试)
const cors = require('cors');
app.use(cors({
origin: true,
credentials: true,
}));
// Body 解析
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session(文件存储,兼容 PHP 的 data 目录模式)
const sessionDir = path.join(__dirname, 'data');
if (!fs.existsSync(sessionDir)) fs.mkdirSync(sessionDir, { recursive: true });
app.use(session({
store: new FileStore({
path: sessionDir,
ttl: 86400 * 30, // 30天
retries: 0,
}),
name: config.session_name || 'wxbot_sid',
secret: config.session_secret,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 86400 * 30 * 1000,
httpOnly: true,
sameSite: 'lax',
},
}));
// ========== 访问校验(开源单用户版:未登录禁止打开后台)==========
// 机器人轮询走 /worker?key= 校验,不受此限;登录/登出接口公开。
const PUBLIC_API = ['/api/login', '/api/logout'];
function isStaticAsset(p) {
return /\.[a-z0-9]+$/i.test(p) || p.startsWith('/docs') || p.startsWith('/assets');
}
app.use((req, res, next) => {
const p = req.path;
if (p.startsWith('/worker')) return next(); // 机器人轮询:key 校验
if (PUBLIC_API.includes(p)) return next(); // 登录/登出公开
const loggedIn = !!(req.session && req.session.user_id);
if (p.startsWith('/api/')) {
return loggedIn ? next() : res.status(401).json({ ok: false, msg: '请先登录' });
}
// SPA 导航
if (loggedIn) return next(); // 已登录交给 SPA fallback
if (isStaticAsset(p)) return next(); // 静态资源(含登录页 JS/CSS)放行
if (p === '/login') return next(); // 登录页 HTML
if (req.get('X-Requested-With') === 'XMLHttpRequest') {
return res.status(401).json({ ok: false, msg: '请先登录' });
}
return res.redirect('/login');
});
// 静态文件
app.use(express.static(path.join(__dirname, 'public')));
// 文档(插件开发指南 / 安装使用说明),供前端市场页入口访问
app.use('/docs', express.static(path.join(__dirname, 'docs')));
// ========== 路由 ==========
// 技能系统(自定义技能 CRUD + 安装管理)
app.use('/api/skill', require('./routes/skill'));
// 插件市场(必须挂在 /api 之前,避免被 /api 的 catch-all 拦截)
app.use('/api/market', require('./routes/market'));
// 推送插件路由(外部 Webhook 接收 + 前端管理接口)
app.use('/api/push', require('./routes/push'));
// 系统统计路由(须在 /api 之前挂载,避免被 api 路由的 404 兜底拦截)
app.use('/api/stats', require('./routes/stats'));
// API 路由
app.use('/api', require('./routes/api'));
// Worker HTTP 路由
const { handleRequest: workerHandler, runPoll } = require('./services/worker');
app.get('/worker', workerHandler);
app.post('/worker', workerHandler);
// ========== 前端 SPA(React + shadcn/ui,构建产物在 web/dist)==========
const distDir = path.join(__dirname, 'web', 'dist');
if (fs.existsSync(distDir)) {
// 带哈希的 JS/CSS 可长期缓存;index.html 不缓存,避免重建后旧 HTML 引用已删除的旧 JS 导致白屏
app.use(express.static(distDir, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
},
}));
}
app.get('*', (req, res, next) => {
// API / Worker 不走 SPA
if (req.path.startsWith('/api/') || req.path === '/worker' || req.path.startsWith('/api/market')) return next();
const indexFile = path.join(distDir, 'index.html');
if (fs.existsSync(indexFile)) {
res.setHeader('Cache-Control', 'no-cache');
return res.sendFile(indexFile);
}
res.status(200).type('text/html').send(
'<h2>前端未构建</h2><p>生产环境请先运行 <code>cd web && npm run build</code>;' +
'本地开发请使用 Vite:<code>cd web && npm run dev</code>(默认 http://localhost:5173,已代理到本服务)。</p>'
);
});
// ========== 404(仅 API)==========
app.use((req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ ok: false, msg: '接口不存在' });
}
res.status(404).type('text/html').send('<h2>404</h2><p>页面不存在</p>');
});
// ========== 错误处理 ==========
app.use((err, req, res, next) => {
console.error('[Error]', err.message);
const ct = req.get('Content-Type') || '';
if (req.path.startsWith('/api/') || req.xhr) {
return res.status(500).json({ ok: false, msg: '服务器内部错误,请稍后重试' });
}
res.status(500).send('<h2>服务器错误</h2><p>系统繁忙,请稍后重试</p>');
});
// ========== 全局异常捕获(防止未处理异步错误导致进程崩溃)==========
process.on('unhandledRejection', (reason, promise) => {
console.error('[unhandledRejection]', reason instanceof Error ? reason.message : String(reason));
console.error('[unhandledRejection] stack:', reason instanceof Error ? reason.stack : 'N/A');
});
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err.message);
console.error('[uncaughtException] stack:', err.stack);
// 不退出进程,让 PM2 决定是否重启(严重错误仍会导致后续请求失败,但不会瞬间挂掉)
});
// ========== 启动时自动初始化(建库 + 自动创建本地管理员)==========
async function autoInit() {
try {
await db.testConnect(); // 自动建表
} catch (e) {
console.error(' [auto-init] 建库失败:', e.message);
return false;
}
// 开源单用户版:无登录体系,自动创建本地管理员
try {
const Auth = require('./lib/auth');
await Auth.ensureAdmin();
} catch (e) {
console.error(' [auto-init] 创建本地管理员失败:', e.message);
}
return true;
}
// ========== 启动 ==========
const PORT = config.port;
const server = app.listen(PORT, async () => {
await autoInit();
// 预热插件缓存:启动时提前加载各机器人已启用的插件模块,
// 消除首条消息到达时才 require(尤其 smart 较重)的延迟。
try {
const plugins = require('./lib/plugins');
const activeBots = await db.rows(
"SELECT id FROM bots WHERE login_status = 'confirmed' AND bot_token IS NOT NULL"
);
for (const b of activeBots) {
try { await plugins.getEnabledModules(b.id); } catch (e) {}
}
if (activeBots.length) {
console.log(` [preheat] 已预热 ${activeBots.length} 个机器人的插件缓存`);
}
} catch (e) {
console.error('[preheat] 插件预热失败:', e.message);
}
console.log('');
console.log(' ========================================');
console.log(' 奶狗WeBot平台 (Node.js)');
console.log(' http://localhost:' + PORT);
console.log(' ========================================');
console.log('');
console.log(' Worker: http://localhost:' + PORT + '/worker?key=' + config.worker_key);
console.log('');
// 应用已配置的代理(若设置中启用了代理)
try {
const r = await require('./lib/proxy').applyProxy();
if (r && r.enabled && !r.error) {
console.log(' Proxy: 已启用代理(出站流量经代理转发)');
}
} catch (e) { /* 代理应用失败不阻塞启动 */ }
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error('\n[启动失败] 端口 ' + PORT + ' 已被占用。');
console.error(' 请先结束占用该端口的进程,或修改 .env 中的 PORT 配置。');
console.error(' Windows 排查: netstat -ano | findstr :' + PORT);
process.exit(1);
} else {
console.error('[启动失败]', err);
process.exit(1);
}
});
// ========== 内置消息自动轮询(本地预览无需单独运行 node worker.js)==========
// 设为 0 / false 可关闭(此时请用独立 `node worker.js` 进程拉取消息)
if (process.env.AUTO_POLL !== '0' && process.env.AUTO_POLL !== 'false') {
(async function autoPoll() {
console.log(' [auto-poll] 消息自动轮询已开启(关闭请设 AUTO_POLL=0)');
// 预检结果缓存 30s:避免每秒都查库判断有无活跃机器人;
// runPoll 内部仍每轮读取最新 upd_buf,故不影响消息拉取的实时性。
const CHECK_TTL = 30000;
let hasActiveBots = false;
let checkAt = 0;
while (true) {
let interval = 500;
try {
const nowMs = Date.now();
if (nowMs - checkAt > CHECK_TTL) {
// 以 bot_token 是否存在作为"已绑定"判据(token 即绑定凭证);
// 不依赖 login_status='confirmed',因其可能因前端轮询未及时更新而滞后,
// 导致 auto-poll 预检误判无活跃机器人、整体跳过消息拉取。
const active = await db.rows(
"SELECT id FROM bots WHERE bot_token IS NOT NULL LIMIT 1"
);
hasActiveBots = active.length > 0;
checkAt = nowMs;
}
if (hasActiveBots) {
await runPoll(false);
} else {
interval = 5000; // 无活跃机器人时降低空转频率
}
} catch (e) {
console.error('[auto-poll] 错误:', e.message);
}
await new Promise(r => setTimeout(r, interval));
}
})();
}
module.exports = app;