946 lines
33 KiB
JavaScript
946 lines
33 KiB
JavaScript
const crypto = require('crypto');
|
||
const { spawn } = require('child_process');
|
||
const axios = require('axios');
|
||
const express = require('express');
|
||
const http = require('http');
|
||
const path = require('path');
|
||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||
const cors = require('cors');
|
||
const bodyParser = require('body-parser');
|
||
const updateRouter = require('./src/routes/update'); // 新增更新路由
|
||
const { createClient } = require('redis');
|
||
const { log } = require('./src/utils');
|
||
const userManager = require('./src/userManager');
|
||
const { MiniProgramLoginSession } = require('./src/qrlib/session');
|
||
|
||
// OAuth Dependencies
|
||
const session = require('express-session');
|
||
const passport = require('passport');
|
||
const OAuth2Strategy = require('passport-oauth2');
|
||
|
||
// 初始化 Express App
|
||
const app = express();
|
||
const server = http.createServer(app);
|
||
const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
|
||
const redisPublisher = createClient({ url: redisUrl });
|
||
const redisSubscriber = createClient({ url: redisUrl });
|
||
const rpcPending = new Map();
|
||
let workerStarting = null;
|
||
let workerProcess = null;
|
||
let updateRunning = null;
|
||
const workerMode = (process.env.FARMBOT_WORKER_MODE || 'detached').toLowerCase();
|
||
const adminWhitelistRaw = process.env.ADMIN_WHITELIST || '';
|
||
const adminWhitelist = new Set(
|
||
adminWhitelistRaw.split(/[,;\s]+/).map(item => item.trim().toLowerCase()).filter(Boolean)
|
||
);
|
||
redisPublisher.on('error', (err) => {
|
||
log('Redis', `连接异常: ${err.message}`);
|
||
});
|
||
redisPublisher.connect().then(() => {
|
||
log('Redis', 'Publisher 已连接');
|
||
}).catch((err) => {
|
||
log('Redis', `连接失败: ${err.message}`);
|
||
});
|
||
redisSubscriber.on('error', (err) => {
|
||
log('Redis', `订阅异常: ${err.message}`);
|
||
});
|
||
redisSubscriber.connect().then(async () => {
|
||
log('Redis', 'Subscriber 已连接');
|
||
await redisSubscriber.subscribe('bot:rpc:res', (message) => {
|
||
const data = safeParseJson(message);
|
||
const id = data && data.id;
|
||
if (!id) return;
|
||
const resolver = rpcPending.get(id);
|
||
if (!resolver) return;
|
||
rpcPending.delete(id);
|
||
resolver(data);
|
||
});
|
||
await redisSubscriber.pSubscribe('bot-log-*', (message, channel) => {
|
||
const email = typeof channel === 'string' ? channel.slice('bot-log-'.length) : '';
|
||
if (!email) return;
|
||
const data = safeParseJson(message);
|
||
userManager.saveBotLog(email, data);
|
||
});
|
||
await redisSubscriber.pSubscribe('bot-steal-*', (message, channel) => {
|
||
const email = typeof channel === 'string' ? channel.slice('bot-steal-'.length) : '';
|
||
if (!email) return;
|
||
const data = safeParseJson(message);
|
||
const name = data && data.name;
|
||
const count = Number(data && data.count);
|
||
userManager.saveUnlucky(email, name, count);
|
||
});
|
||
}).catch((err) => {
|
||
log('Redis', `订阅连接失败: ${err.message}`);
|
||
});
|
||
|
||
function safeParseJson(input) {
|
||
try {
|
||
return JSON.parse(input);
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function isAdminEmail(email) {
|
||
if (!email) return false;
|
||
return adminWhitelist.has(String(email).toLowerCase());
|
||
}
|
||
|
||
function getRequestEmail(req) {
|
||
const headerEmail = req.headers['x-admin-email'];
|
||
if (headerEmail) return String(headerEmail);
|
||
if (req.query && req.query.email) return String(req.query.email);
|
||
if (req.body && req.body.email) return String(req.body.email);
|
||
return '';
|
||
}
|
||
|
||
function requireAdmin(req, res, next) {
|
||
const email = getRequestEmail(req);
|
||
if (!isAdminEmail(email)) {
|
||
return res.status(403).json({ error: 'Admin only' });
|
||
}
|
||
req.adminEmail = email;
|
||
return next();
|
||
}
|
||
|
||
function startWorkerProcess() {
|
||
if (workerMode === 'external') {
|
||
log('Worker', '外部模式,跳过拉起');
|
||
return;
|
||
}
|
||
if (workerProcess && !workerProcess.killed) return;
|
||
const detached = workerMode === 'detached';
|
||
workerProcess = spawn(process.execPath, ['bot-worker.js'], {
|
||
cwd: __dirname,
|
||
stdio: detached ? 'ignore' : 'inherit',
|
||
detached,
|
||
windowsHide: true
|
||
});
|
||
if (detached) {
|
||
workerProcess.unref();
|
||
} else {
|
||
workerProcess.on('exit', (code, signal) => {
|
||
log('Worker', `Worker exited (code=${code}, signal=${signal || 'none'})`);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function sendRpc(action, email, payload, timeoutMs = 6000) {
|
||
if (!redisPublisher.isOpen || !redisSubscriber.isOpen) {
|
||
return { ok: false, error: 'Redis 未就绪', status: 503 };
|
||
}
|
||
const id = crypto.randomUUID();
|
||
const body = JSON.stringify({
|
||
id,
|
||
action,
|
||
email,
|
||
payload,
|
||
ts: Date.now()
|
||
});
|
||
return new Promise((resolve) => {
|
||
const timer = setTimeout(() => {
|
||
rpcPending.delete(id);
|
||
resolve({ ok: false, error: 'Worker 响应超时', status: 504 });
|
||
}, timeoutMs);
|
||
rpcPending.set(id, (response) => {
|
||
clearTimeout(timer);
|
||
resolve(response);
|
||
});
|
||
redisPublisher.publish('bot:rpc:req', body).catch((err) => {
|
||
clearTimeout(timer);
|
||
rpcPending.delete(id);
|
||
resolve({ ok: false, error: err.message || 'RPC 发送失败', status: 503 });
|
||
});
|
||
});
|
||
}
|
||
|
||
async function ensureWorkerReady() {
|
||
if (workerStarting) return workerStarting;
|
||
workerStarting = (async () => {
|
||
const ping = await sendRpc('worker.ping', null, null, 800);
|
||
if (ping.ok) return true;
|
||
startWorkerProcess();
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
const retry = await sendRpc('worker.ping', null, null, 1500);
|
||
if (!retry.ok) {
|
||
return { ok: false, error: retry.error || 'Worker 未就绪' };
|
||
}
|
||
return true;
|
||
})();
|
||
try {
|
||
return await workerStarting;
|
||
} finally {
|
||
workerStarting = null;
|
||
}
|
||
}
|
||
|
||
async function callWorker(action, email, payload, timeoutMs) {
|
||
try {
|
||
const ready = await ensureWorkerReady();
|
||
if (ready !== true) {
|
||
return { ok: false, error: ready && ready.error ? ready.error : 'Worker 未就绪', status: 503 };
|
||
}
|
||
return sendRpc(action, email, payload, timeoutMs);
|
||
} catch (err) {
|
||
return { ok: false, error: err && err.message ? err.message : 'Worker 未就绪', status: 503 };
|
||
}
|
||
}
|
||
|
||
function runGit(args, cwd) {
|
||
return new Promise((resolve) => {
|
||
const child = spawn('git', args, { cwd, windowsHide: true });
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout.on('data', (chunk) => {
|
||
stdout += chunk.toString();
|
||
});
|
||
child.stderr.on('data', (chunk) => {
|
||
stderr += chunk.toString();
|
||
});
|
||
child.on('error', (err) => {
|
||
resolve({ code: 1, stdout: '', stderr: err && err.message ? err.message : 'spawn failed' });
|
||
});
|
||
child.on('close', (code) => {
|
||
resolve({ code, stdout: stdout.trim(), stderr: stderr.trim() });
|
||
});
|
||
});
|
||
}
|
||
|
||
async function fetchRemoteUpdateSummary() {
|
||
const repo = 'Tony/Tieldalaes';
|
||
const base = 'https://gitea.infras.host/api/v1';
|
||
const commitsUrl = `${base}/repos/${repo}/commits?limit=5`;
|
||
const releasesUrl = `${base}/repos/${repo}/releases?limit=5`;
|
||
const [commitsRes, releasesRes] = await Promise.allSettled([
|
||
axios.get(commitsUrl, { timeout: 6000 }),
|
||
axios.get(releasesUrl, { timeout: 6000 })
|
||
]);
|
||
const commits = commitsRes.status === 'fulfilled' && Array.isArray(commitsRes.value.data)
|
||
? commitsRes.value.data.map((item) => ({
|
||
sha: String(item?.sha || '').slice(0, 7),
|
||
message: String(item?.commit?.message || '').split('\n')[0],
|
||
author: String(item?.commit?.author?.name || ''),
|
||
date: String(item?.commit?.author?.date || '')
|
||
}))
|
||
: [];
|
||
const releases = releasesRes.status === 'fulfilled' && Array.isArray(releasesRes.value.data)
|
||
? releasesRes.value.data.map((item) => ({
|
||
tag: String(item?.tag_name || ''),
|
||
name: String(item?.name || ''),
|
||
date: String(item?.published_at || item?.created_at || ''),
|
||
body: String(item?.body || '')
|
||
}))
|
||
: [];
|
||
return { commits, releases };
|
||
}
|
||
|
||
const oauthEnabled = Boolean(process.env.OAUTH_AUTHENTIK_ISSUER)
|
||
&& Boolean(process.env.OAUTH_AUTHENTIK_CLIENT_ID)
|
||
&& Boolean(process.env.OAUTH_AUTHENTIK_CLIENT_SECRET)
|
||
&& Boolean(process.env.OAUTH_AUTHENTIK_REDIRECT_URI)
|
||
&& Boolean(process.env.OAUTH_AUTHENTIK_SCOPE);
|
||
|
||
// OAuth Setup
|
||
if (oauthEnabled) {
|
||
app.use(session({
|
||
secret: 'farm-session-secret',
|
||
resave: false,
|
||
saveUninitialized: false
|
||
}));
|
||
app.use(passport.initialize());
|
||
app.use(passport.session());
|
||
|
||
passport.serializeUser((user, done) => done(null, user.email));
|
||
passport.deserializeUser((email, done) => {
|
||
const user = userManager.getUser(email);
|
||
done(null, user);
|
||
});
|
||
|
||
passport.use('authentik', new OAuth2Strategy({
|
||
authorizationURL: `${process.env.OAUTH_AUTHENTIK_ISSUER}authorize/`,
|
||
tokenURL: `${process.env.OAUTH_AUTHENTIK_ISSUER}token/`,
|
||
clientID: process.env.OAUTH_AUTHENTIK_CLIENT_ID,
|
||
clientSecret: process.env.OAUTH_AUTHENTIK_CLIENT_SECRET,
|
||
callbackURL: process.env.OAUTH_AUTHENTIK_REDIRECT_URI,
|
||
scope: process.env.OAUTH_AUTHENTIK_SCOPE.split(' ')
|
||
}, async (accessToken, refreshToken, profile, cb) => {
|
||
try {
|
||
// Fetch user info using accessToken
|
||
const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch(...args));
|
||
const userInfoRes = await fetch(`${process.env.OAUTH_AUTHENTIK_ISSUER}userinfo/`, {
|
||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||
});
|
||
const userInfo = await userInfoRes.json();
|
||
|
||
const email = userInfo.email || userInfo.preferred_username; // Adjust based on Authentik response
|
||
|
||
// Register or Login
|
||
const user = await userManager.register(email, null, 'authentik', userInfo.sub);
|
||
return cb(null, user);
|
||
} catch (err) {
|
||
return cb(err);
|
||
}
|
||
}));
|
||
|
||
// OAuth Routes
|
||
app.get('/api/auth/oauth/authentik', passport.authenticate('authentik', { state: true }));
|
||
|
||
app.get('/api/auth/oauth/authentik/callback', (req, res, next) => {
|
||
passport.authenticate('authentik', { state: true }, (err, user, info) => {
|
||
const frontendUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:5173' : '';
|
||
|
||
if (err) {
|
||
console.error('OAuth Error:', err);
|
||
return res.redirect(`${frontendUrl}/login?error=oauth_failed`);
|
||
}
|
||
if (!user) {
|
||
console.error('OAuth Failed: No user returned');
|
||
return res.redirect(`${frontendUrl}/login?error=oauth_failed`);
|
||
}
|
||
|
||
req.logIn(user, (loginErr) => {
|
||
if (loginErr) {
|
||
console.error('Login Error:', loginErr);
|
||
return res.redirect(`${frontendUrl}/login?error=login_err`);
|
||
}
|
||
res.redirect(`${frontendUrl}/login?email=${encodeURIComponent(user.email)}&oauth=success&_t=${Date.now()}`);
|
||
});
|
||
})(req, res, next);
|
||
});
|
||
}
|
||
|
||
// 中间件
|
||
app.use(cors());
|
||
app.use(bodyParser.json());
|
||
if (process.env.NODE_ENV === 'production') {
|
||
app.use(express.static(path.join(__dirname, '../web/dist')));
|
||
}
|
||
|
||
app.use('/api/update', updateRouter); // 注册更新路由
|
||
|
||
app.get('/api/auth/oauth/status', (req, res) => {
|
||
res.json({ enabled: oauthEnabled });
|
||
});
|
||
|
||
|
||
function normalizeSettings(settings) {
|
||
if (!settings || typeof settings !== 'object') return {};
|
||
const result = {};
|
||
const farmIntervalSec = Number(settings.farmIntervalSec);
|
||
if (Number.isFinite(farmIntervalSec) && farmIntervalSec >= 1) {
|
||
result.farmCheckInterval = Math.floor(farmIntervalSec * 1000);
|
||
}
|
||
const friendIntervalSec = Number(settings.friendIntervalSec);
|
||
if (Number.isFinite(friendIntervalSec) && friendIntervalSec >= 1) {
|
||
result.friendCheckInterval = Math.floor(friendIntervalSec * 1000);
|
||
}
|
||
if (settings.seedStrategy === 'forceLowest') {
|
||
result.forceLowestLevelCrop = true;
|
||
} else if (settings.seedStrategy === 'default') {
|
||
result.forceLowestLevelCrop = false;
|
||
}
|
||
if (typeof settings.enableFriendOps === 'boolean') {
|
||
result.enableFriendOps = settings.enableFriendOps;
|
||
}
|
||
if (typeof settings.enableSteal === 'boolean') {
|
||
result.enableSteal = settings.enableSteal;
|
||
}
|
||
if (typeof settings.enableNormalFertilize === 'boolean') {
|
||
result.enableNormalFertilize = settings.enableNormalFertilize;
|
||
}
|
||
if (typeof settings.enableOrganicFertilize === 'boolean') {
|
||
result.enableOrganicFertilize = settings.enableOrganicFertilize;
|
||
}
|
||
if (typeof settings.allowTicketFertilizerPurchase === 'boolean') {
|
||
result.allowTicketFertilizerPurchase = settings.allowTicketFertilizerPurchase;
|
||
}
|
||
if (typeof settings.enableAutoSell === 'boolean') {
|
||
result.enableAutoSell = settings.enableAutoSell;
|
||
}
|
||
if (typeof settings.allowBuySeeds === 'boolean') {
|
||
result.allowBuySeeds = settings.allowBuySeeds;
|
||
}
|
||
if (typeof settings.allowRemove === 'boolean') {
|
||
result.allowRemove = settings.allowRemove;
|
||
}
|
||
if (settings.idleStrategy === 'task') {
|
||
result.idleStrategy = 'task';
|
||
} else if (settings.idleStrategy === 'exp') {
|
||
result.idleStrategy = 'exp';
|
||
}
|
||
return result;
|
||
}
|
||
|
||
async function handleRpcResponse(res, response, fallback = {}) {
|
||
if (!response || response.ok !== true) {
|
||
const status = response && response.status ? response.status : 500;
|
||
const error = response && response.error ? response.error : 'Worker 错误';
|
||
res.status(status).json({ error });
|
||
return false;
|
||
}
|
||
res.json(response.data ?? fallback);
|
||
return true;
|
||
}
|
||
|
||
function getTodayKey() {
|
||
const now = new Date();
|
||
const y = now.getFullYear();
|
||
const m = `${now.getMonth() + 1}`.padStart(2, '0');
|
||
const d = `${now.getDate()}`.padStart(2, '0');
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
const qrLogState = new Map();
|
||
function logQrOnce(message, minIntervalMs = 20000) {
|
||
const now = Date.now();
|
||
const lastAt = qrLogState.get(message) || 0;
|
||
if (now - lastAt < minIntervalMs) return;
|
||
qrLogState.set(message, now);
|
||
log('扫码', message);
|
||
}
|
||
|
||
// ============ API 路由 ============
|
||
|
||
// QR Code Login API (Mini Program)
|
||
app.post('/api/qr/create', async (req, res) => {
|
||
try {
|
||
logQrOnce('正在获取登录二维码...');
|
||
const result = await MiniProgramLoginSession.requestLoginCode();
|
||
logQrOnce('已获取登录二维码');
|
||
res.json({
|
||
success: true,
|
||
qrsig: result.code,
|
||
qrcode: `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(result.url)}`,
|
||
url: result.url
|
||
});
|
||
} catch (error) {
|
||
console.error('QR Create Error:', error);
|
||
res.status(500).json({ success: false, message: error.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/qr/check', async (req, res) => {
|
||
const { qrsig } = req.body;
|
||
if (!qrsig) {
|
||
return res.status(400).json({ success: false, message: 'Missing qrsig/code' });
|
||
}
|
||
|
||
try {
|
||
const result = await MiniProgramLoginSession.queryStatus(qrsig);
|
||
|
||
if (result.status === 'Wait') {
|
||
res.json({ success: true, status: 'Wait', msg: '等待扫码...' });
|
||
} else if (result.status === 'Used') {
|
||
res.json({ success: true, status: 'Used', msg: '二维码已失效' });
|
||
} else if (result.status === 'OK') {
|
||
const ticket = result.ticket;
|
||
// Determine AppID (Farm AppID)
|
||
const appid = MiniProgramLoginSession.Presets.farm.appid; // QQ Classic Farm
|
||
const code = await MiniProgramLoginSession.getAuthCode(ticket, appid);
|
||
|
||
res.json({
|
||
success: true,
|
||
status: 'OK',
|
||
msg: '登录成功',
|
||
code: code // This is the Game Code we need
|
||
});
|
||
} else {
|
||
res.json({ success: true, status: 'Error', msg: '状态查询错误' });
|
||
}
|
||
} catch (error) {
|
||
res.status(500).json({ success: false, message: error.message });
|
||
}
|
||
});
|
||
|
||
// 注册 API
|
||
app.post('/api/auth/register', async (req, res) => {
|
||
const { email, password } = req.body;
|
||
if (!email || !password) {
|
||
return res.status(400).json({ error: '请输入邮箱和密码' });
|
||
}
|
||
|
||
try {
|
||
if (userManager.getUser(email)) {
|
||
return res.status(400).json({ error: '该邮箱已被注册' });
|
||
}
|
||
|
||
const user = userManager.register(email, password);
|
||
log('Auth', `New user registered: ${email}`);
|
||
|
||
res.json({
|
||
success: true,
|
||
user: {
|
||
email: user.email,
|
||
hasCode: !!user.code
|
||
}
|
||
});
|
||
} catch (e) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
// 登录 API (仅登录)
|
||
app.post('/api/auth/login', async (req, res) => {
|
||
const { email, password } = req.body;
|
||
if (!email || !password) {
|
||
return res.status(400).json({ error: '请输入邮箱和密码' });
|
||
}
|
||
|
||
try {
|
||
const user = userManager.login(email, password);
|
||
if (!user) {
|
||
return res.status(401).json({ error: '邮箱或密码错误' });
|
||
}
|
||
|
||
const rpcRes = await callWorker('bot.isRunning', email, null, 2000);
|
||
const botRunning = rpcRes && rpcRes.ok && rpcRes.data && rpcRes.data.running === true;
|
||
res.json({
|
||
success: true,
|
||
user: {
|
||
email: user.email,
|
||
code: user.code,
|
||
hasCode: !!user.code
|
||
},
|
||
botRunning
|
||
});
|
||
} catch (e) {
|
||
res.status(500).json({ error: e.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/auth/profile', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const user = userManager.getUser(email);
|
||
if (!user) {
|
||
return res.status(404).json({ error: 'User not found' });
|
||
}
|
||
res.json({
|
||
user: {
|
||
email: user.email,
|
||
hasCode: !!user.code,
|
||
auth_provider: user.auth_provider || 'local'
|
||
}
|
||
});
|
||
});
|
||
|
||
// 好友 API
|
||
app.get('/api/friends', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('friends.list', email, null);
|
||
await handleRpcResponse(res, response, { friends: [] });
|
||
});
|
||
|
||
// 访问好友农场 API
|
||
app.post('/api/friends/visit', async (req, res) => {
|
||
const { email, friendUid } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('friends.visit', email, { friendUid });
|
||
await handleRpcResponse(res, response, { lands: [] });
|
||
});
|
||
|
||
// 好友互动 API (偷菜/浇水等)
|
||
app.post('/api/friends/action', async (req, res) => {
|
||
const { email, friendUid, actionType, landIds } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('friends.action', email, { friendUid, actionType, landIds });
|
||
await handleRpcResponse(res, response, { success: true });
|
||
});
|
||
|
||
// 启动 Bot API
|
||
app.post('/api/bot/start', async (req, res) => {
|
||
const { email, code, settings } = req.body; // 如果提供了 code,更新并启动
|
||
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
|
||
const user = userManager.getUser(email);
|
||
if (!user) {
|
||
return res.status(404).json({ error: 'User not found' });
|
||
}
|
||
|
||
// 如果提供了新 code,更新用户数据
|
||
let gameCode = typeof code === 'string' ? code.trim() : code;
|
||
if (gameCode) {
|
||
userManager.updateCode(email, gameCode);
|
||
} else {
|
||
gameCode = typeof user.code === 'string' ? user.code.trim() : user.code;
|
||
}
|
||
|
||
if (!gameCode) {
|
||
return res.status(400).json({ error: 'Game code is required. Please provide it.' });
|
||
}
|
||
|
||
try {
|
||
const botConfig = { code: gameCode, ...normalizeSettings(settings) };
|
||
const response = await callWorker('bot.start', email, botConfig, 12000);
|
||
await handleRpcResponse(res, response, { success: true, message: 'Bot started successfully.' });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 停止 Bot API
|
||
app.post('/api/bot/stop', (req, res) => {
|
||
const { email } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
callWorker('bot.stop', email, null).then((response) => {
|
||
handleRpcResponse(res, response, { success: true });
|
||
}).catch((err) => {
|
||
res.status(500).json({ error: err.message });
|
||
});
|
||
});
|
||
|
||
app.post('/api/bot/settings', async (req, res) => {
|
||
const { email, settings } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const user = userManager.getUser(email);
|
||
if (!user) {
|
||
return res.status(404).json({ error: 'User not found' });
|
||
}
|
||
try {
|
||
const botConfig = normalizeSettings(settings);
|
||
const response = await callWorker('bot.updateConfig', email, { settings: botConfig }, 8000);
|
||
await handleRpcResponse(res, response, { success: true });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
// 获取状态 API
|
||
app.get('/api/status', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('bot.status', email, null, 5000);
|
||
if (!response || response.ok !== true) {
|
||
return res.json({ status: 'stopped', user: null });
|
||
}
|
||
res.json(response.data);
|
||
});
|
||
|
||
app.get('/api/announcement', (req, res) => {
|
||
const message = typeof process.env.LOGIN_ANNOUNCEMENT === 'string' ? process.env.LOGIN_ANNOUNCEMENT : '';
|
||
res.json({ message });
|
||
});
|
||
|
||
app.get('/api/logs', async (req, res) => {
|
||
const { email, limit } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const count = Number(limit);
|
||
try {
|
||
const logs = await userManager.getBotLogs(email, count);
|
||
res.json({ logs });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/admin/me', (req, res) => {
|
||
const { email } = req.query;
|
||
res.json({ isAdmin: isAdminEmail(email) });
|
||
});
|
||
|
||
app.get('/api/admin/logs', requireAdmin, async (req, res) => {
|
||
const { email, limit } = req.query;
|
||
const count = Number(limit);
|
||
try {
|
||
const logs = await userManager.getAllBotLogs(count, email);
|
||
res.json({ logs });
|
||
} catch (error) {
|
||
res.status(500).json({ error: error.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/admin/worker/stop-all', requireAdmin, async (req, res) => {
|
||
const response = await callWorker('worker.stopAll', null, null, 5000);
|
||
await handleRpcResponse(res, response, { success: true });
|
||
});
|
||
|
||
app.post('/api/admin/worker/shutdown', requireAdmin, async (req, res) => {
|
||
const response = await callWorker('worker.shutdown', null, null, 5000);
|
||
await handleRpcResponse(res, response, { success: true });
|
||
});
|
||
|
||
app.post('/api/admin/worker/start', requireAdmin, async (req, res) => {
|
||
const ready = await ensureWorkerReady();
|
||
if (ready === true) {
|
||
return res.json({ success: true });
|
||
}
|
||
res.status(503).json({ error: ready && ready.error ? ready.error : 'Worker not ready' });
|
||
});
|
||
|
||
app.get('/api/admin/update/summary', requireAdmin, async (req, res) => {
|
||
const repoRoot = path.join(__dirname, '..');
|
||
try {
|
||
const [localHeadRes, remoteHeadRes, branchRes, remoteSummary] = await Promise.all([
|
||
runGit(['rev-parse', 'HEAD'], repoRoot),
|
||
runGit(['ls-remote', 'origin', '-h', 'refs/heads/main'], repoRoot),
|
||
runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot),
|
||
fetchRemoteUpdateSummary()
|
||
]);
|
||
const localHead = localHeadRes.stdout || '';
|
||
const remoteLine = remoteHeadRes.stdout || '';
|
||
const remoteHead = remoteLine.split('\t')[0] || '';
|
||
const branch = branchRes.stdout || '';
|
||
res.json({
|
||
local: { head: localHead, branch },
|
||
remote: { head: remoteHead },
|
||
updateNeeded: Boolean(localHead && remoteHead && localHead !== remoteHead),
|
||
commits: remoteSummary.commits,
|
||
releases: remoteSummary.releases,
|
||
repo: 'https://gitea.infras.host/Tony/Tieldalaes'
|
||
});
|
||
} catch (error) {
|
||
res.status(500).json({ error: error && error.message ? error.message : '更新信息获取失败' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/admin/update/run', requireAdmin, async (req, res) => {
|
||
if (updateRunning) {
|
||
return res.status(409).json({ error: '更新进行中' });
|
||
}
|
||
const repoRoot = path.join(__dirname, '..');
|
||
updateRunning = (async () => {
|
||
const before = await runGit(['rev-parse', 'HEAD'], repoRoot);
|
||
const pull = await runGit(['pull', '--rebase', 'origin', 'main'], repoRoot);
|
||
const after = await runGit(['rev-parse', 'HEAD'], repoRoot);
|
||
return {
|
||
ok: pull.code === 0,
|
||
before: before.stdout || '',
|
||
after: after.stdout || '',
|
||
output: pull.stdout || '',
|
||
error: pull.stderr || ''
|
||
};
|
||
})();
|
||
try {
|
||
const result = await updateRunning;
|
||
if (!result.ok) {
|
||
return res.status(500).json({ error: result.error || '更新失败', output: result.output, before: result.before, after: result.after });
|
||
}
|
||
res.json({ success: true, output: result.output, before: result.before, after: result.after });
|
||
} finally {
|
||
updateRunning = null;
|
||
}
|
||
});
|
||
|
||
app.get('/api/debug/fertilizer', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('debug.fertilizer', email, null, 12000);
|
||
await handleRpcResponse(res, response, {});
|
||
});
|
||
|
||
app.get('/api/leaderboard/unlucky', (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
userManager.getUnluckyTop(email, 10).then((items) => {
|
||
res.json({ items });
|
||
}).catch((err) => {
|
||
res.status(500).json({ error: err.message });
|
||
});
|
||
});
|
||
|
||
app.get('/api/tasks', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('tasks.list', email, null);
|
||
await handleRpcResponse(res, response, { tasks: [] });
|
||
});
|
||
|
||
app.post('/api/tasks/claim', async (req, res) => {
|
||
const { email } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('tasks.claimAll', email, null, 12000);
|
||
await handleRpcResponse(res, response, { claimedCount: 0, failed: [], tasks: [] });
|
||
});
|
||
|
||
app.post('/api/tasks/claim-one', async (req, res) => {
|
||
const { email, taskId, doShared } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const id = Number(taskId);
|
||
if (!Number.isFinite(id) || id <= 0) {
|
||
return res.status(400).json({ error: 'Invalid taskId' });
|
||
}
|
||
const response = await callWorker('tasks.claimOne', email, { taskId: id, doShared: Boolean(doShared) }, 12000);
|
||
await handleRpcResponse(res, response, { claimedCount: 0, failedCount: 0, tasks: [] });
|
||
});
|
||
|
||
// Get lands API
|
||
app.get('/api/lands', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('lands.list', email, null);
|
||
await handleRpcResponse(res, response, { lands: [] });
|
||
});
|
||
|
||
app.post('/api/lands/remove', async (req, res) => {
|
||
const { email, landIds } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
if (!Array.isArray(landIds)) {
|
||
return res.status(400).json({ error: 'Invalid landIds' });
|
||
}
|
||
const ids = landIds.map(Number).filter(id => Number.isFinite(id) && id > 0);
|
||
if (ids.length === 0) {
|
||
return res.status(400).json({ error: 'Invalid landIds' });
|
||
}
|
||
const response = await callWorker('lands.remove', email, { landIds: ids });
|
||
await handleRpcResponse(res, response, { success: true, removed: ids.length });
|
||
});
|
||
|
||
app.post('/api/lands/unlock', async (req, res) => {
|
||
const { email, landId, doShared } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const id = Number(landId);
|
||
if (!Number.isFinite(id) || id <= 0) {
|
||
return res.status(400).json({ error: 'Invalid landId' });
|
||
}
|
||
const response = await callWorker('lands.unlock', email, { landId: id, doShared: Boolean(doShared) }, 12000);
|
||
await handleRpcResponse(res, response, { success: true, landId: id });
|
||
});
|
||
|
||
// Shop API
|
||
app.get('/api/shop', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('shop.list', email, null);
|
||
await handleRpcResponse(res, response, { goods: [] });
|
||
});
|
||
|
||
app.post('/api/shop/buy', async (req, res) => {
|
||
const { email, goodsId, count, price } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('shop.buy', email, { goodsId, count, price }, 12000);
|
||
await handleRpcResponse(res, response, { success: true });
|
||
});
|
||
|
||
app.post('/api/paymall/buy', async (req, res) => {
|
||
const { email, itemId, count } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const id = Number(itemId);
|
||
if (!Number.isFinite(id) || id <= 0) {
|
||
return res.status(400).json({ error: 'Invalid itemId' });
|
||
}
|
||
const buyCount = Number(count ?? 1);
|
||
if (!Number.isFinite(buyCount) || buyCount <= 0) {
|
||
return res.status(400).json({ error: 'Invalid count' });
|
||
}
|
||
const response = await callWorker('shop.paymallBuy', email, { itemId: id, count: buyCount }, 12000);
|
||
await handleRpcResponse(res, response, { success: true });
|
||
});
|
||
|
||
// Warehouse API
|
||
app.get('/api/warehouse', async (req, res) => {
|
||
const { email } = req.query;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('warehouse.list', email, null);
|
||
await handleRpcResponse(res, response, {});
|
||
});
|
||
|
||
app.post('/api/warehouse/sell', async (req, res) => {
|
||
const { email, items } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('warehouse.sell', email, { items });
|
||
await handleRpcResponse(res, response, { success: true, gold: 0 });
|
||
});
|
||
|
||
app.post('/api/warehouse/use', async (req, res) => {
|
||
const { email, items } = req.body;
|
||
if (!email) {
|
||
return res.status(400).json({ error: 'Email is required' });
|
||
}
|
||
const response = await callWorker('warehouse.use', email, { items }, 12000);
|
||
await handleRpcResponse(res, response, {});
|
||
});
|
||
|
||
// System stats API
|
||
app.get('/api/system/stats', (req, res) => {
|
||
const memory = process.memoryUsage();
|
||
// Convert to MB
|
||
const memoryUsage = {
|
||
rss: Math.round(memory.rss / 1024 / 1024),
|
||
heapTotal: Math.round(memory.heapTotal / 1024 / 1024),
|
||
heapUsed: Math.round(memory.heapUsed / 1024 / 1024),
|
||
};
|
||
callWorker('worker.stats', null, null, 2000).then((response) => {
|
||
const workerData = response && response.ok && response.data ? response.data : null;
|
||
const activeBots = workerData && Number.isFinite(workerData.activeBots) ? workerData.activeBots : 0;
|
||
res.json({
|
||
memory: memoryUsage,
|
||
uptime: Math.floor(process.uptime()),
|
||
activeBots,
|
||
worker: workerData ? {
|
||
memory: workerData.memory || null,
|
||
uptime: workerData.uptime || 0,
|
||
activeBots
|
||
} : null
|
||
});
|
||
}).catch(() => {
|
||
res.json({
|
||
memory: memoryUsage,
|
||
uptime: Math.floor(process.uptime()),
|
||
activeBots: 0,
|
||
worker: null
|
||
});
|
||
});
|
||
});
|
||
|
||
// 所有其他请求返回前端 index.html (SPA 支持)
|
||
app.get('*', (req, res) => {
|
||
if (process.env.NODE_ENV === 'production') {
|
||
res.sendFile(path.join(__dirname, '../web/dist/index.html'));
|
||
} else {
|
||
res.status(404).send('Not Found (Development Mode: Use Frontend Dev Server at port 5173)');
|
||
}
|
||
});
|
||
|
||
// 启动服务器
|
||
const PORT = process.env.PORT || 3000;
|
||
server.listen(PORT, () => {
|
||
log('Server', `Server running on http://localhost:${PORT}`);
|
||
});
|