const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { URL } = require('url'); const { Pool } = require('pg'); const PORT = Number(process.env.APP_PORT || 3000); const PUBLIC_DIR = path.join(__dirname, 'public'); const ADMIN_TOKEN = process.env.APP_ADMIN_TOKEN || ''; const pool = new Pool({ host: process.env.PGHOST || '127.0.0.1', port: Number(process.env.PGPORT || 5432), database: process.env.PGDATABASE, user: process.env.PGUSER, password: process.env.PGPASSWORD, ssl: process.env.PGSSLMODE === 'require' ? { rejectUnauthorized: false } : undefined, max: 5, idleTimeoutMillis: 30000, }); const MIME_TYPES = { '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', }; function sendJson(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }); res.end(body); } function sendError(res, status, message) { sendJson(res, status, { error: message }); } function requireAdmin(req, res) { if (!ADMIN_TOKEN) { sendError(res, 503, 'APP_ADMIN_TOKEN 尚未配置'); return false; } const supplied = req.headers['x-admin-token'] || ''; const expected = Buffer.from(ADMIN_TOKEN); const actual = Buffer.from(String(supplied)); if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) { sendError(res, 401, '管理令牌无效'); return false; } return true; } function readBody(req) { return new Promise((resolve, reject) => { let data = ''; req.on('data', (chunk) => { data += chunk; if (data.length > 1024 * 1024) reject(new Error('请求内容过大')); }); req.on('end', () => { if (!data) return resolve({}); try { resolve(JSON.parse(data)); } catch { reject(new Error('请求必须是 JSON')); } }); req.on('error', reject); }); } function validateEmail(value) { return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); } function normalizeModules(modules) { if (!Array.isArray(modules)) return []; return [...new Set(modules.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean))]; } async function listModules() { const result = await pool.query( 'SELECT module_key, module_name, description, sort_order FROM newsletter_modules WHERE enabled = TRUE ORDER BY sort_order, module_key', ); return result.rows; } async function listRecipients() { const result = await pool.query(` SELECT r.id, r.email, r.display_name, r.enabled, r.send_time, r.timezone, r.send_all_modules, COALESCE(array_agg(rm.module_key ORDER BY rm.module_key) FILTER (WHERE rm.module_key IS NOT NULL), '{}') AS modules FROM newsletter_recipients r LEFT JOIN recipient_modules rm ON rm.recipient_id = r.id GROUP BY r.id ORDER BY r.enabled DESC, r.email `); return result.rows; } async function createRecipient(body) { const email = String(body.email || '').trim().toLowerCase(); if (!validateEmail(email)) throw new Error('请输入有效邮箱'); const modules = normalizeModules(body.modules); if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块'); const client = await pool.connect(); try { await client.query('BEGIN'); const result = await client.query(` INSERT INTO newsletter_recipients (email, display_name, send_time, timezone, send_all_modules) VALUES ($1, NULLIF($2, ''), COALESCE(NULLIF($3, '')::time, TIME '07:30'), COALESCE(NULLIF($4, ''), 'Asia/Shanghai'), $5) RETURNING id `, [email, String(body.display_name || '').trim(), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]); const recipientId = result.rows[0].id; if (modules.length) { await client.query( 'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING', [recipientId, modules], ); } await client.query('COMMIT'); return recipientId; } catch (error) { await client.query('ROLLBACK'); if (error.code === '23505') throw new Error('这个邮箱已经存在'); if (error.code === '23503') throw new Error('选择了不存在的模块'); throw error; } finally { client.release(); } } async function updateRecipient(id, body) { const modules = normalizeModules(body.modules); if (!body.send_all_modules && modules.length === 0) throw new Error('至少选择一个推送模块'); const client = await pool.connect(); try { await client.query('BEGIN'); const result = await client.query(` UPDATE newsletter_recipients SET display_name = NULLIF($2, ''), enabled = COALESCE($3, enabled), send_time = COALESCE(NULLIF($4, '')::time, send_time), timezone = COALESCE(NULLIF($5, ''), timezone), send_all_modules = $6, updated_at = NOW() WHERE id = $1 RETURNING id `, [id, String(body.display_name || '').trim(), body.enabled === undefined ? null : Boolean(body.enabled), String(body.send_time || ''), String(body.timezone || ''), Boolean(body.send_all_modules)]); if (!result.rowCount) throw new Error('收件邮箱不存在'); await client.query('DELETE FROM recipient_modules WHERE recipient_id = $1', [id]); if (modules.length) { await client.query( 'INSERT INTO recipient_modules (recipient_id, module_key) SELECT $1, unnest($2::text[]) ON CONFLICT DO NOTHING', [id, modules], ); } await client.query('COMMIT'); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } async function deleteRecipient(id) { const result = await pool.query('DELETE FROM newsletter_recipients WHERE id = $1 RETURNING id', [id]); if (!result.rowCount) throw new Error('收件邮箱不存在'); } async function listReports(query) { const page = Math.max(1, Number(query.get('page') || 1)); const pageSize = Math.min(30, Math.max(1, Number(query.get('page_size') || 10))); const params = []; const conditions = []; const date = query.get('date'); const q = query.get('q'); const category = query.get('category'); if (date) { params.push(date); conditions.push(`r.report_date = $${params.length}::date`); } if (q) { params.push(`%${q}%`); conditions.push(`EXISTS ( SELECT 1 FROM report_articles rqa JOIN news_articles nqa ON nqa.id = rqa.article_id WHERE rqa.report_id = r.id AND (nqa.title ILIKE $${params.length} OR COALESCE(nqa.summary, '') ILIKE $${params.length} OR COALESCE(array_to_string(nqa.keywords, ' '), '') ILIKE $${params.length}) )`); } if (category) { params.push(category); conditions.push(`EXISTS ( SELECT 1 FROM report_articles rca JOIN news_articles nca ON nca.id = rca.article_id WHERE rca.report_id = r.id AND (rca.section = $${params.length} OR nca.category = $${params.length}) )`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const countResult = await pool.query(`SELECT COUNT(*)::int AS count FROM daily_reports r ${where}`, params); const total = countResult.rows[0].count; params.push(pageSize, (page - 1) * pageSize); const result = await pool.query(` SELECT r.id, r.report_date, r.title, r.introduction, r.item_count, r.status, r.email_status, r.created_at, COALESCE(json_agg(json_build_object( 'id', a.id, 'title', a.title, 'translated_title', a.translated_title, 'summary', a.summary, 'translated_summary', a.translated_summary, 'url', a.url, 'source_name', s.name, 'category', a.category, 'topics', a.topics, 'region', a.region, 'published_at', a.published_at, 'section', ra.section, 'display_order', ra.display_order, 'is_highlight', ra.is_highlight ) ORDER BY ra.section, ra.display_order) FILTER (WHERE a.id IS NOT NULL), '[]') AS articles FROM daily_reports r LEFT JOIN report_articles ra ON ra.report_id = r.id LEFT JOIN news_articles a ON a.id = ra.article_id LEFT JOIN news_sources s ON s.id = a.source_id ${where} GROUP BY r.id ORDER BY r.report_date DESC LIMIT $${params.length - 1} OFFSET $${params.length} `, params); return { page, page_size: pageSize, total, reports: result.rows }; } async function routeApi(req, res, requestUrl) { const pathname = requestUrl.pathname; if (req.method === 'GET' && pathname === '/api/health') { try { await pool.query('SELECT 1'); return sendJson(res, 200, { ok: true }); } catch (error) { return sendError(res, 503, `数据库连接失败: ${error.message}`); } } if (req.method === 'GET' && pathname === '/api/modules') { try { return sendJson(res, 200, { modules: await listModules() }); } catch (error) { return sendError(res, 500, error.message); } } if (req.method === 'GET' && pathname === '/api/reports') { try { return sendJson(res, 200, await listReports(requestUrl.searchParams)); } catch (error) { return sendError(res, 500, error.message); } } if (pathname.startsWith('/api/recipients')) { if (!requireAdmin(req, res)) return; try { if (req.method === 'GET' && pathname === '/api/recipients') return sendJson(res, 200, { recipients: await listRecipients() }); if (req.method === 'POST' && pathname === '/api/recipients') { const body = await readBody(req); const id = await createRecipient(body); return sendJson(res, 201, { id }); } const id = Number(pathname.split('/').pop()); if (!Number.isInteger(id)) return sendError(res, 400, '无效的收件邮箱 ID'); if (req.method === 'PUT') { await updateRecipient(id, await readBody(req)); return sendJson(res, 200, { ok: true }); } if (req.method === 'DELETE') { await deleteRecipient(id); return sendJson(res, 200, { ok: true }); } } catch (error) { return sendError(res, error.message.includes('不存在') || error.message.includes('有效邮箱') || error.message.includes('选择') || error.message.includes('已经存在') ? 400 : 500, error.message); } } return sendError(res, 404, '接口不存在'); } function serveStatic(req, res, pathname) { let relative = pathname === '/' ? 'index.html' : pathname.slice(1); if (relative === 'settings') relative = 'settings.html'; if (relative.includes('..')) return sendError(res, 400, '非法路径'); const filePath = path.join(PUBLIC_DIR, relative); fs.readFile(filePath, (error, content) => { if (error) return sendError(res, 404, '页面不存在'); const ext = path.extname(filePath).toLowerCase(); res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' }); res.end(content); }); } const server = http.createServer(async (req, res) => { try { const requestUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`); if (requestUrl.pathname.startsWith('/api/')) return await routeApi(req, res, requestUrl); if (req.method !== 'GET') return sendError(res, 405, '只支持 GET'); return serveStatic(req, res, requestUrl.pathname); } catch (error) { sendError(res, 500, error.message || '服务器错误'); } }); server.listen(PORT, '0.0.0.0', () => { console.log(`Daily news digest web listening on ${PORT}`); }); process.on('SIGTERM', async () => { await pool.end(); server.close(() => process.exit(0)); });