#!/usr/bin/env node /** * export_summary.js * 通过 SSH 隧道连接 RDS,导出转卡汇总报表为 Excel * * 用法: * node export_summary.js # 交互式输入年月 * node export_summary.js -y 2026 -m 3 # 直接指定年月 * * 依赖: npm install mysql2 exceljs tunnel-ssh */ "use strict"; const path = require("path"); const fs = require("fs"); const readline = require("readline"); const { createTunnel } = require("tunnel-ssh"); const mysql = require("mysql2/promise"); const ExcelJS = require("exceljs"); // ───────────────────────────────────────────── // SSH 配置(跳板机) // ───────────────────────────────────────────── const SSH_CONFIG = { host: "47.98.203.17", port: 22, username: "root", privateKey: fs.readFileSync( path.join(process.env.USERPROFILE || process.env.HOME, ".ssh", "id_ed25519") ), }; // ───────────────────────────────────────────── // 数据库配置(RDS,通过 SSH 隧道访问) // ───────────────────────────────────────────── const DB_HOST = "rm-bp1btyuwq77591x0jpo.mysql.rds.aliyuncs.com"; const DB_PORT = 3306; const DB_CONFIG = { host: "127.0.0.1", port: 13306, // 本地隧道转发端口 user: "games", password: "Games0791!!", database: "agent_db", charset: "utf8mb4", connectTimeout: 15000, }; // ───────────────────────────────────────────── // 默认业务参数 // ───────────────────────────────────────────── const DEFAULT_FROM_SALES = 10437216; const DEFAULT_AGENT_ID = "veRa0qrBf0df2K1G4de2tgfmVxB2jxpv"; // ───────────────────────────────────────────── // Excel 样式 // ───────────────────────────────────────────── const COLOR_HEADER = "2E6DA4"; const COLOR_TOTAL_ROW = "FFF2CC"; const COLOR_ALT_ROW = "EBF3FB"; function headerStyle() { return { font: { bold: true, color: { argb: "FFFFFFFF" }, size: 11 }, fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF" + COLOR_HEADER } }, alignment: { horizontal: "center", vertical: "middle", wrapText: true }, border: thinBorder(), }; } function thinBorder() { const s = { style: "thin", color: { argb: "FFBFBFBF" } }; return { left: s, right: s, top: s, bottom: s }; } function applyStyle(cell, style) { Object.assign(cell, style); } // ───────────────────────────────────────────── // 汇总 SQL(不含 ROLLUP,合计行由 JS 端计算) // ───────────────────────────────────────────── const SQL_SUMMARY = ` SELECT t.satr_salesid AS agentId, MAX(su.saus_nickname) AS agentName, COUNT(t.idx) AS transferCount, SUM(t.satr_amount) AS transferTotal FROM sales_transferbill t LEFT JOIN sales_user su ON t.satr_agentid = su.saus_agentid AND t.channel_id = su.saus_channelid AND t.satr_salesid = su.saus_salesid WHERE t.satr_agentid = ? AND t.from_sales = ? AND YEAR(t.satr_transfertime) = ? AND MONTH(t.satr_transfertime) = ? GROUP BY t.satr_salesid ORDER BY SUM(t.satr_amount) DESC `; // ───────────────────────────────────────────── // 建立 SSH 隧道(tunnel-ssh v5) // ───────────────────────────────────────────── async function openTunnel() { const tunnelOptions = { autoClose: true }; const serverOptions = { port: DB_CONFIG.port }; const sshOptions = SSH_CONFIG; const forwardOptions = { srcAddr: "127.0.0.1", srcPort: DB_CONFIG.port, dstAddr: DB_HOST, dstPort: DB_PORT, }; const [server] = await createTunnel(tunnelOptions, serverOptions, sshOptions, forwardOptions); return server; } // ───────────────────────────────────────────── // 写 Excel // ───────────────────────────────────────────── async function writeExcel(rows, year, month, fromSales, outputPath) { const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet("转卡汇总"); ws.properties.tabColor = { argb: "FF" + COLOR_HEADER }; ws.views = [{ state: "frozen", ySplit: 1 }]; // 列定义 const columns = [ { header: "代理ID", key: "agentId", width: 14 }, { header: "代理昵称", key: "agentName", width: 20 }, { header: "转卡次数", key: "transferCount", width: 12 }, { header: "转卡总量", key: "transferTotal", width: 12 }, ]; ws.columns = columns; // 表头样式 const hs = headerStyle(); ws.getRow(1).height = 22; ws.getRow(1).eachCell(cell => applyStyle(cell, hs)); // 数据行 let totalCount = 0, totalAmount = 0; rows.forEach((row, idx) => { const r = ws.addRow({ agentId: row.agentId, agentName: row.agentName || "—", transferCount: Number(row.transferCount), transferTotal: Number(row.transferTotal), }); r.height = 20; const isAlt = (idx % 2 === 1); r.eachCell(cell => { cell.border = thinBorder(); cell.alignment = { horizontal: "center", vertical: "middle" }; if (isAlt) cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF" + COLOR_ALT_ROW } }; }); totalCount += Number(row.transferCount); totalAmount += Number(row.transferTotal); }); // 合计行 const totalRow = ws.addRow({ agentId: "★ 合计", agentName: "—", transferCount: totalCount, transferTotal: totalAmount, }); totalRow.height = 22; totalRow.eachCell(cell => { cell.border = thinBorder(); cell.font = { bold: true, size: 11 }; cell.alignment = { horizontal: "center", vertical: "middle" }; cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF" + COLOR_TOTAL_ROW } }; }); await wb.xlsx.writeFile(outputPath); } // ───────────────────────────────────────────── // 交互式询问 // ───────────────────────────────────────────── async function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); })); } async function getParams() { const args = process.argv.slice(2); const get = (flag) => { const i = args.indexOf(flag); return i !== -1 ? args[i + 1] : null; }; const now = new Date(); let year = get("-y") ? parseInt(get("-y")) : null; let month = get("-m") ? parseInt(get("-m")) : null; let fromSales = get("-f") ? parseInt(get("-f")) : DEFAULT_FROM_SALES; let agentId = get("-a") ? get("-a") : DEFAULT_AGENT_ID; if (!year) { const raw = await prompt(`请输入年份(直接回车使用 ${now.getFullYear()}): `); year = raw ? parseInt(raw) : now.getFullYear(); } if (!month) { const raw = await prompt(`请输入月份 1-12(直接回车使用 ${now.getMonth() + 1}): `); month = raw ? parseInt(raw) : now.getMonth() + 1; } if (month < 1 || month > 12) { console.error("[错误] 月份必须在 1-12 之间"); process.exit(1); } return { year, month, fromSales, agentId }; } // ───────────────────────────────────────────── // 主流程 // ───────────────────────────────────────────── async function main() { const { year, month, fromSales, agentId } = await getParams(); const outputDir = path.join(__dirname, "output"); if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }); const outputFile = path.join(outputDir, `转卡汇总_${year}${String(month).padStart(2, "0")}_${fromSales}.xlsx`); console.log("=".repeat(55)); console.log(` 导出参数: ${year}年${String(month).padStart(2, "0")}月`); console.log(` 发卡代理: ${fromSales}`); console.log(` 代理商ID: ${agentId}`); console.log(` 输出文件: ${outputFile}`); console.log("=".repeat(55)); console.log("\n[SSH] 正在建立隧道 47.98.203.17 → RDS ..."); const tunnel = await openTunnel(); console.log("[SSH] 隧道建立成功"); let conn; try { console.log("[数据库] 正在连接 ..."); conn = await mysql.createConnection(DB_CONFIG); console.log("[数据库] 连接成功"); console.log(`[查询] 执行汇总查询 (${year}年${String(month).padStart(2, "0")}月) ...`); const [rows] = await conn.execute(SQL_SUMMARY, [agentId, fromSales, year, month]); console.log(` 共 ${rows.length} 条代理记录`); if (rows.length === 0) { console.log("[提示] 查询结果为空,未生成文件"); } else { await writeExcel(rows, year, month, fromSales, outputFile); console.log(`\n[完成] 文件已保存: ${outputFile}`); } } finally { if (conn) await conn.end(); tunnel.close(); console.log("[SSH] 隧道已关闭"); } } main().catch(err => { console.error("\n[错误]", err.message); process.exit(1); });