261 lines
10 KiB
JavaScript
261 lines
10 KiB
JavaScript
#!/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);
|
||
});
|