Files
electricity-monitor/server.js
EchoZenith dbdca20cba refactor: 清理冗余代码与废弃功能
1. 移除全局css变量中多余的定义
2. 简化通知发送函数参数与逻辑
3. 删除测试通知接口与前端按钮
4. 修复图表容器盒模型样式问题
2026-05-24 23:11:21 +08:00

524 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require('dotenv').config();
const express = require('express');
const path = require('path');
const fs = require('fs');
const cron = require('node-cron');
const cookieParser = require('cookie-parser');
const Database = require('better-sqlite3');
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(cookieParser(process.env.COOKIE_SECRET || 'electricity-monitor-secret'));
const DB_PATH = path.join(__dirname, 'data', 'electricity.db');
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
surplus REAL NOT NULL,
amount REAL NOT NULL,
timestamp INTEGER NOT NULL,
room_name TEXT DEFAULT ''
)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_records_timestamp ON records(timestamp)
`);
const insertStmt = db.prepare(
'INSERT INTO records (surplus, amount, timestamp, room_name) VALUES (?, ?, ?, ?)'
);
const updateStmt = db.prepare(
'UPDATE records SET surplus = ?, amount = ?, timestamp = ? WHERE id = ?'
);
const API_URL = 'https://application.xiaofubao.com/app/electric/queryRoomSurplus';
const API_BODY = 'areaId=2105355156363427841&buildingCode=34&floorCode=54&roomCode=12828&platform=YUNMA_WXAPP_CHONGD';
const COOKIE = process.env.SHIRO_COOKIE;
if (!COOKIE) {
console.error('错误: 环境变量 SHIRO_COOKIE 未设置,请在 .env 文件中配置');
process.exit(1);
}
const LOGIN_USERNAME = process.env.LOGIN_USERNAME || 'admin';
const LOGIN_PASSWORD = process.env.LOGIN_PASSWORD;
const WECOM_WEBHOOK_URL = process.env.WECOM_WEBHOOK_URL;
if (!WECOM_WEBHOOK_URL) {
console.warn('警告: 环境变量 WECOM_WEBHOOK_URL 未设置,企业微信通知功能将不可用');
}
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID;
if (TELEGRAM_BOT_TOKEN && TELEGRAM_CHAT_ID) {
console.log('Telegram 机器人通知已启用');
} else if (TELEGRAM_BOT_TOKEN || TELEGRAM_CHAT_ID) {
console.warn('警告: 需要同时设置 TELEGRAM_BOT_TOKEN 和 TELEGRAM_CHAT_ID 才能使用 Telegram 通知');
} else {
console.log('Telegram 机器人通知未配置');
}
const ALERT_THRESHOLD = parseFloat(process.env.ALERT_THRESHOLD) || 0;
if (ALERT_THRESHOLD > 0) {
console.log(`电费预警: 当余额低于 ¥${ALERT_THRESHOLD} 时将发送通知`);
}
let lastAlertedAmount = null;
function requireAuth(req, res, next) {
const auth = req.signedCookies.auth;
if (auth === LOGIN_USERNAME) {
return next();
}
res.status(401).json({ success: false, message: '未登录' });
}
function getLatestRecord() {
const row = db.prepare('SELECT * FROM records ORDER BY timestamp DESC LIMIT 1').get();
return row || null;
}
function getAllRecords() {
return db.prepare('SELECT * FROM records ORDER BY timestamp ASC').all();
}
async function sendWecomNotification(content) {
if (!WECOM_WEBHOOK_URL) return;
try {
const res = await fetch(WECOM_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msgtype: 'markdown',
markdown: { content }
})
});
const body = await res.json();
if (body.errcode !== 0) {
console.error(`企业微信通知发送失败: errcode=${body.errcode}, errmsg=${body.errmsg}`);
}
} catch (err) {
console.error('企业微信通知发送失败:', err.message);
}
}
async function sendTelegramNotification(text) {
if (!TELEGRAM_BOT_TOKEN || !TELEGRAM_CHAT_ID) return;
const tgText = text
.replace(/^## (.+)$/gm, '*$1*')
.replace(/^> /gm, '')
.trim();
try {
const res = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: TELEGRAM_CHAT_ID,
text: tgText,
parse_mode: 'Markdown',
disable_web_page_preview: true,
})
});
const body = await res.json();
if (!body.ok) {
console.error(`Telegram 通知发送失败: ${body.description}`);
}
} catch (err) {
console.error('Telegram 通知发送失败:', err.message);
}
}
async function sendAllNotifications(markdownContent) {
await Promise.all([
sendWecomNotification(markdownContent),
sendTelegramNotification(markdownContent),
]);
}
function getLocalDateStr(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function getRecordsByDate(dateStr) {
const [year, month, day] = dateStr.split('-').map(Number);
const startOfDay = new Date(year, month - 1, day);
const endOfDay = new Date(year, month - 1, day, 23, 59, 59, 999);
return db.prepare(
'SELECT * FROM records WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC'
).all(startOfDay.getTime(), endOfDay.getTime());
}
async function fetchData() {
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Cookie': COOKIE,
'x-requested-with': 'XMLHttpRequest',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) wxwork/5.0.8 MicroMessenger/7.0.0(0x17000000) MacWechat/5.0.8(0x15000800) MiniProgramEnv/Mac MiniProgram/',
'Referer': 'https://application.xiaofubao.com/',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh-Hans;q=0.9',
'Origin': 'https://application.xiaofubao.com'
},
body: API_BODY
});
if (!response.ok) {
console.error(`请求失败: ${response.status}`);
return null;
}
const text = await response.text();
const json = JSON.parse(text);
if (json.success && json.data) {
return {
surplus: json.data.surplus,
amount: json.data.amount,
timestamp: Date.now(),
roomName: json.data.displayRoomName || ''
};
}
return null;
} catch (err) {
console.error('请求出错:', err.message);
return null;
}
}
async function collectData() {
const data = await fetchData();
if (!data) {
console.log(`[${new Date().toLocaleString()}] 采集失败: 接口请求异常`);
await sendAllNotifications(
'## 电费采集异常\n\n' +
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
'电费接口请求失败,请检查网络或 Cookie 是否过期。'
);
return;
}
const latest = getLatestRecord();
if (latest) {
const sameHour = new Date(latest.timestamp).getHours() === new Date(data.timestamp).getHours();
const sameDay = new Date(latest.timestamp).toDateString() === new Date(data.timestamp).toDateString();
if (sameDay && sameHour) {
updateStmt.run(data.surplus, data.amount, data.timestamp, latest.id);
console.log(`[${new Date().toLocaleString()}] 更新本小时记录: ${data.surplus}度, ¥${data.amount}`);
await checkThresholdAndAlert(data.amount);
return;
}
}
insertStmt.run(data.surplus, data.amount, data.timestamp, data.roomName);
console.log(`[${new Date().toLocaleString()}] 新增记录: ${data.surplus}度, ¥${data.amount}`);
await checkThresholdAndAlert(data.amount);
}
async function checkThresholdAndAlert(amount) {
if (ALERT_THRESHOLD <= 0 || amount > ALERT_THRESHOLD) {
lastAlertedAmount = null;
return;
}
if (lastAlertedAmount !== null && amount >= lastAlertedAmount) return;
lastAlertedAmount = amount;
console.log(`[预警] 余额 ¥${amount} 低于阈值 ¥${ALERT_THRESHOLD}`);
await sendAllNotifications(
'## 电费余额预警\n\n' +
`> 时间:${new Date().toLocaleString('zh-CN')}\n\n` +
`当前余额 **¥${amount.toFixed(2)}**\n\n` +
`已低于预警阈值 **¥${ALERT_THRESHOLD.toFixed(2)}**,请及时充值!`
);
}
function calculateDailyUsage(records) {
if (records.length < 2) return { dailyRecords: [] };
const dailyMap = {};
records.forEach(record => {
const dateStr = getLocalDateStr(new Date(record.timestamp));
if (!dailyMap[dateStr]) {
dailyMap[dateStr] = [];
}
dailyMap[dateStr].push(record);
});
const dailyRecords = [];
const sortedDates = Object.keys(dailyMap).sort();
let prevLastSurplus = null;
for (let i = 0; i < sortedDates.length; i++) {
const date = sortedDates[i];
const dayRecords = dailyMap[date].sort((a, b) => a.timestamp - b.timestamp);
const firstSurplus = dayRecords[0].surplus;
const lastSurplus = dayRecords[dayRecords.length - 1].surplus;
const base = prevLastSurplus !== null ? prevLastSurplus : firstSurplus;
const usage = Math.round(Math.max(0, base - lastSurplus) * 100) / 100;
const hoursSpan = (dayRecords[dayRecords.length - 1].timestamp - dayRecords[0].timestamp) / (1000 * 60 * 60);
const avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
dailyRecords.push({
date,
usage,
avgPower,
firstSurplus: base,
lastSurplus,
recordCount: dayRecords.length,
hoursSpan,
});
prevLastSurplus = lastSurplus;
}
return { dailyRecords };
}
const CLIENT_DIR = path.join(__dirname, 'client', 'dist');
if (fs.existsSync(CLIENT_DIR)) {
app.use(express.static(CLIENT_DIR));
}
app.post('/api/login', (req, res) => {
const { username, password } = req.body || {};
if (username === LOGIN_USERNAME && password === LOGIN_PASSWORD) {
res.cookie('auth', LOGIN_USERNAME, {
signed: true,
maxAge: 30 * 24 * 60 * 60 * 1000,
httpOnly: true
});
return res.json({ success: true });
}
res.status(401).json({ success: false, message: '用户名或密码错误' });
});
app.post('/api/logout', (req, res) => {
res.clearCookie('auth');
res.json({ success: true });
});
app.get('/api/check-auth', (req, res) => {
const auth = req.signedCookies.auth;
if (auth === LOGIN_USERNAME) {
return res.json({ success: true });
}
res.status(401).json({ success: false, message: '未登录' });
});
app.get('/api/current', requireAuth, (req, res) => {
const allRecords = getAllRecords();
if (allRecords.length === 0) {
return res.json({ success: false, message: '暂无数据' });
}
const latest = allRecords[allRecords.length - 1];
const todayStr = getLocalDateStr(new Date());
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = getLocalDateStr(yesterday);
const todayRecordsDB = getRecordsByDate(todayStr);
const yesterdayRecords = getRecordsByDate(yesterdayStr);
const yesterdayLast = yesterdayRecords.length > 0 ? yesterdayRecords[yesterdayRecords.length - 1] : null;
const sortedToday = [...todayRecordsDB].sort((a, b) => a.timestamp - b.timestamp);
let todayUsage = 0;
let todayCost = 0;
if (sortedToday.length >= 1) {
const base = yesterdayLast || sortedToday[0];
todayUsage = Math.max(0, Math.round((base.surplus - sortedToday[sortedToday.length - 1].surplus) * 100) / 100);
todayCost = Math.max(0, Math.round((base.amount - sortedToday[sortedToday.length - 1].amount) * 100) / 100);
}
res.json({
success: true,
current: latest,
todayUsage,
todayCost,
todayAvgPower: todayUsage > 0 ? Math.round((todayUsage / Math.max(1, new Date().getHours())) * 1000) / 1000 : 0,
todayRecords: sortedToday,
yesterdayLastRecord: yesterdayLast,
totalRecords: allRecords.length
});
});
app.get('/api/records-by-date', requireAuth, (req, res) => {
const dateStr = req.query.date;
if (!dateStr) return res.json({ success: false, message: '缺少 date 参数' });
const dayBefore = new Date(dateStr);
dayBefore.setDate(dayBefore.getDate() - 1);
const dayBeforeStr = getLocalDateStr(dayBefore);
const records = getRecordsByDate(dateStr);
const dayBeforeRecords = getRecordsByDate(dayBeforeStr);
const prevLast = dayBeforeRecords.length > 0 ? dayBeforeRecords[dayBeforeRecords.length - 1] : null;
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
let usage = 0;
let cost = 0;
if (sorted.length >= 1) {
const base = prevLast || sorted[0];
usage = Math.max(0, Math.round((base.surplus - sorted[sorted.length - 1].surplus) * 100) / 100);
cost = Math.max(0, Math.round((base.amount - sorted[sorted.length - 1].amount) * 100) / 100);
}
res.json({
success: true,
records: sorted,
prevLastRecord: prevLast,
usage,
cost,
});
});
app.get('/api/history', requireAuth, (req, res) => {
const allRecords = getAllRecords();
const { dailyRecords } = calculateDailyUsage(allRecords);
const latest = allRecords.length > 0 ? allRecords[allRecords.length - 1] : null;
const calcStats = (dailyRecords, n) => {
const records = dailyRecords.slice(-n);
if (records.length < 1) return { avgDaily: null, avgPower: null, estimatedDays: null };
const todayStr = getLocalDateStr(new Date());
let totalUsage = 0;
let dayCount = 0;
records.forEach(r => {
if (r.date === todayStr) {
const elapsed = Math.max(1, r.hoursSpan + 1);
const normalized = Math.round((r.usage / elapsed) * 24 * 100) / 100;
totalUsage += normalized;
} else {
totalUsage += r.usage;
}
dayCount++;
});
const avgDaily = Math.round((totalUsage / dayCount) * 100) / 100;
const avgPower = Math.round((avgDaily / 24) * 1000) / 1000;
const estimatedDays = avgDaily > 0 && latest
? Math.round(latest.surplus / avgDaily * 10) / 10
: null;
return { avgDaily, avgPower, estimatedDays };
};
const s7 = calcStats(dailyRecords, 7);
const s15 = calcStats(dailyRecords, 15);
const s30 = calcStats(dailyRecords, 30);
res.json({
success: true,
current: latest,
dailyRecords: dailyRecords.slice(-30),
stats: {
avgDaily7: s7.avgDaily,
avgDaily15: s15.avgDaily,
avgDaily30: s30.avgDaily,
avgPower7: s7.avgPower,
avgPower15: s15.avgPower,
avgPower30: s30.avgPower,
estimatedDays7: s7.estimatedDays,
estimatedDays15: s15.estimatedDays,
estimatedDays30: s30.estimatedDays,
}
});
});
cron.schedule('0 * * * *', async () => {
console.log(`[定时任务] 开始采集数据...`);
await collectData();
});
async function sendDailyReport() {
const now = new Date();
const dateStr = getLocalDateStr(now);
const records = getRecordsByDate(dateStr);
if (records.length < 1) {
await sendAllNotifications(
'## 电费日报\n\n' +
`> 日期:${dateStr}\n\n` +
`今日无数据记录`
);
return;
}
const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const dayBefore = new Date(now);
dayBefore.setDate(dayBefore.getDate() - 1);
const dayBeforeRecords = getRecordsByDate(getLocalDateStr(dayBefore));
const dayBeforeLast = dayBeforeRecords.length > 0 ? dayBeforeRecords[dayBeforeRecords.length - 1] : null;
const baseSurplus = dayBeforeLast ? dayBeforeLast.surplus : first.surplus;
const baseAmount = dayBeforeLast ? dayBeforeLast.amount : first.amount;
const usage = Math.round(Math.max(0, baseSurplus - last.surplus) * 100) / 100;
const cost = Math.round(Math.max(0, baseAmount - last.amount) * 100) / 100;
const hoursSpan = (last.timestamp - first.timestamp) / (1000 * 60 * 60);
const reportHours = hoursSpan > 0 ? hoursSpan : Math.max(1, (Date.now() - first.timestamp) / 3600000);
const avgPower = Math.round((usage / reportHours) * 1000) / 1000;
const latest = getLatestRecord();
let content = '## 电费日报\n\n';
content += `> 日期:${dateStr}\n\n`;
content += `**今日用电**${usage.toFixed(2)}\n`;
content += `**今日电费**:¥${cost.toFixed(2)}\n`;
content += `**平均功率**${avgPower.toFixed(3)} kW\n`;
content += `**数据记录**${sorted.length}\n`;
if (latest) {
content += `\n**当前剩余电量**${latest.surplus.toFixed(2)}\n`;
content += `**当前剩余余额**:¥${latest.amount.toFixed(2)}\n`;
}
await sendAllNotifications(content);
}
cron.schedule('30 23 * * *', async () => {
console.log(`[定时任务] 发送今日用电报告...`);
await sendDailyReport();
});
app.get('/api/trigger-collect', requireAuth, async (req, res) => {
await collectData();
res.json({ success: true, message: '采集完成' });
});
app.get('/api/send-report', requireAuth, async (req, res) => {
await sendDailyReport();
res.json({ success: true, message: '日报已发送' });
});
app.get('*', (req, res) => {
if (fs.existsSync(CLIENT_DIR)) {
res.sendFile(path.join(CLIENT_DIR, 'index.html'));
} else {
res.status(200).json({ message: 'API 服务运行中,前端请通过 Vite 开发服务器访问 (port 5173)' });
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`========================================`);
console.log(` 电费监控系统已启动`);
console.log(` 访问地址: http://localhost:${PORT}`);
console.log(` 数据库: ${DB_PATH}`);
console.log(`========================================`);
});