Files
electricity-monitor/server.js
EchoZenith e79975f2f5 feat: 初始化智能电量监控系统项目
该提交完成了完整的电费监控系统项目初始化,包含:
1.  后端Node.js+Express服务,支持定时采集、登录认证、数据API
2.  React前端界面,包含登录页和数据仪表盘
3.  Docker容器化配置和docker-compose部署文件
4.  环境变量示例和gitignore、dockerignore配置
5.  完整的项目文档README
2026-05-22 19:12:13 +08:00

416 lines
14 KiB
JavaScript
Raw 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 未设置,企业微信通知功能将不可用');
}
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();
}
function getTodayRecords() {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
return db.prepare(
'SELECT * FROM records WHERE timestamp >= ? ORDER BY timestamp ASC'
).all(todayStart.getTime());
}
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 }
})
});
if (!res.ok) {
console.error(`企业微信通知发送失败: ${res.status}`);
}
} catch (err) {
console.error('企业微信通知发送失败:', err.message);
}
}
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 sendWecomNotification(
'## 电费采集异常\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}`);
return;
}
}
insertStmt.run(data.surplus, data.amount, data.timestamp, data.roomName);
console.log(`[${new Date().toLocaleString()}] 新增记录: ${data.surplus}度, ¥${data.amount}`);
}
function calculateDailyUsage(records) {
if (records.length < 2) return { dailyUsage: 0, todayUsage: 0, 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();
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 usage = Math.round(Math.max(0, firstSurplus - 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,
lastSurplus,
recordCount: dayRecords.length,
hoursSpan,
});
}
const today = getLocalDateStr(new Date());
const todayRecords = dailyMap[today];
let todayUsage = 0;
if (todayRecords && todayRecords.length >= 2) {
const sorted = todayRecords.sort((a, b) => a.timestamp - b.timestamp);
todayUsage = Math.max(0, Math.round((sorted[0].surplus - sorted[sorted.length - 1].surplus) * 100) / 100);
}
return { dailyUsage: todayUsage, todayUsage, 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 { dailyUsage } = calculateDailyUsage(allRecords);
const todayRecords = getTodayRecords();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayRecords = getRecordsByDate(getLocalDateStr(yesterday));
const yesterdayLast = yesterdayRecords.length > 0 ? yesterdayRecords[yesterdayRecords.length - 1] : null;
res.json({
success: true,
current: latest,
todayUsage: dailyUsage,
todayAvgPower: dailyUsage > 0 ? Math.round((dailyUsage / Math.max(1, new Date().getHours())) * 1000) / 1000 : 0,
todayRecords,
yesterdayLastRecord: yesterdayLast,
totalRecords: allRecords.length
});
});
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 totalUsageLast7Days = dailyRecords.slice(-7).reduce((sum, d) => sum + d.usage, 0);
const totalUsageLast15Days = dailyRecords.slice(-15).reduce((sum, d) => sum + d.usage, 0);
const totalUsageLast30Days = dailyRecords.slice(-30).reduce((sum, d) => sum + d.usage, 0);
const totalHours7 = dailyRecords.slice(-7).reduce((sum, d) => sum + d.hoursSpan, 0);
const totalHours15 = dailyRecords.slice(-15).reduce((sum, d) => sum + d.hoursSpan, 0);
const totalHours30 = dailyRecords.slice(-30).reduce((sum, d) => sum + d.hoursSpan, 0);
const days7 = Math.min(7, dailyRecords.length);
const days15 = Math.min(15, dailyRecords.length);
const days30 = Math.min(30, dailyRecords.length);
const avgDaily7 = days7 >= 1 ? Math.round((totalUsageLast7Days / days7) * 100) / 100 : null;
const avgDaily15 = days15 >= 1 ? Math.round((totalUsageLast15Days / days15) * 100) / 100 : null;
const avgDaily30 = days30 >= 1 ? Math.round((totalUsageLast30Days / days30) * 100) / 100 : null;
const avgPower7 = totalHours7 >= 1
? Math.round((totalUsageLast7Days / totalHours7) * 1000) / 1000
: null;
const avgPower15 = totalHours15 >= 1
? Math.round((totalUsageLast15Days / totalHours15) * 1000) / 1000
: null;
const avgPower30 = totalHours30 >= 1
? Math.round((totalUsageLast30Days / totalHours30) * 1000) / 1000
: null;
const estimatedDays7 = avgDaily7 && avgDaily7 > 0 && latest
? Math.round(latest.surplus / avgDaily7 * 10) / 10
: null;
const estimatedDays15 = avgDaily15 && avgDaily15 > 0 && latest
? Math.round(latest.surplus / avgDaily15 * 10) / 10
: null;
const estimatedDays30 = avgDaily30 && avgDaily30 > 0 && latest
? Math.round(latest.surplus / avgDaily30 * 10) / 10
: null;
res.json({
success: true,
current: latest,
dailyRecords: dailyRecords.slice(-30),
stats: {
avgDaily7,
avgDaily15,
avgDaily30,
avgPower7,
avgPower15,
avgPower30,
estimatedDays7,
estimatedDays15,
estimatedDays30
}
});
});
cron.schedule('0 * * * *', async () => {
console.log(`[定时任务] 开始采集数据...`);
await collectData();
});
async function sendDailyReport() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = getLocalDateStr(yesterday);
const records = getRecordsByDate(dateStr);
if (records.length < 2) {
await sendWecomNotification(
'## 电费日报\n\n' +
`> 日期:${dateStr}\n\n` +
`数据不足,无法生成昨日用电报告(仅 ${records.length} 条记录)`
);
return;
}
const first = records[0];
const last = records[records.length - 1];
const usage = Math.round(Math.max(0, first.surplus - last.surplus) * 100) / 100;
const hoursSpan = (last.timestamp - first.timestamp) / (1000 * 60 * 60);
const avgPower = hoursSpan > 0 ? Math.round((usage / hoursSpan) * 1000) / 1000 : 0;
const latest = getLatestRecord();
let content = '## 电费日报\n\n';
content += `> 日期:${dateStr}\n\n`;
content += `**昨日用电**${usage.toFixed(2)}\n`;
content += `**平均功率**${avgPower.toFixed(3)} kW\n`;
content += `**数据记录**${records.length}\n`;
content += `**记录区间**${new Date(first.timestamp).toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' })} ~ ${new Date(last.timestamp).toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' })}\n`;
if (latest) {
content += `\n**当前剩余电量**${latest.surplus.toFixed(2)}\n`;
content += `**当前剩余余额**:¥${latest.amount.toFixed(2)}\n`;
}
await sendWecomNotification(content);
}
cron.schedule('0 0 * * *', async () => {
console.log(`[定时任务] 发送昨日用电报告...`);
await sendDailyReport();
});
app.get('/api/trigger-collect', requireAuth, async (req, res) => {
await collectData();
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(`========================================`);
});