码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/server.js
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 &amp;&amp; npm run build</code>;' +
    '本地开发请使用 Vite:<code>cd web &amp;&amp; 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;