diff --git a/server.py b/server.py new file mode 100644 index 0000000..29c1833 --- /dev/null +++ b/server.py @@ -0,0 +1,270 @@ +# -*- coding: utf-8 -*- +""" +宿舍电费监控 Web 服务 - 零依赖版 +- 提供 /api/electric 实时电费查询接口 +- 提供 /api/history 历史记录与每日耗电统计 +- 每天 24:00(00:00) 定时查询并记录,失败重试 5 次 +- 静态前端: static/ (index.html / style.css / app.js) + +运行: python server.py (默认 http://127.0.0.1:8080) +""" +import json +import os +import sqlite3 +import sys +import threading +import time +from datetime import datetime, timedelta +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + +# 将 final/ 目录加入 sys.path,复用 lzu_electric_full 模块 +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # final/ +sys.path.insert(0, BASE_DIR) + +import lzu_electric_full as lef + +STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "electric_history.db") + +HOST = "127.0.0.1" +PORT = 8000 +RETRY_TIMES = 5 +RETRY_INTERVAL = 60 # 秒 +CHECK_INTERVAL = 30 # 定时循环检查间隔(秒) + +# 查询互斥锁:防止定时任务与手动刷新并发登录 +_query_lock = threading.Lock() + + +# ---------- 数据库 ---------- +def db_init(): + conn = sqlite3.connect(DB_PATH) + conn.execute(""" + CREATE TABLE IF NOT EXISTS electric_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD + surplus REAL NOT NULL, -- 剩余电量(度) + amount REAL NOT NULL, -- 预估金额(元) + source TEXT NOT NULL, -- auto=定时记录 / manual=手动记录 + created_at INTEGER NOT NULL -- Unix 时间戳 + ) + """) + conn.commit() + conn.close() + + +def db_record(date_str, surplus, amount, source): + conn = sqlite3.connect(DB_PATH) + try: + conn.execute( + "INSERT OR REPLACE INTO electric_records (date, surplus, amount, source, created_at) VALUES (?,?,?,?,?)", + (date_str, surplus, amount, source, int(time.time())), + ) + conn.commit() + finally: + conn.close() + + +def db_get_records(): + conn = sqlite3.connect(DB_PATH) + try: + cur = conn.execute( + "SELECT date, surplus, amount, source FROM electric_records ORDER BY date DESC LIMIT 90" + ) + rows = [{"date": r[0], "surplus": r[1], "amount": r[2], "source": r[3]} for r in cur.fetchall()] + return rows + finally: + conn.close() + + +def db_has_today(date_str): + conn = sqlite3.connect(DB_PATH) + try: + cur = conn.execute("SELECT 1 FROM electric_records WHERE date=?", (date_str,)) + return cur.fetchone() is not None + finally: + conn.close() + + +# ---------- 电费查询 ---------- +def query_electric(): + """实时查询电费。返回 dict 或 None。 + 优先用缓存会话,失效则完整登录,并回写缓存。""" + with _query_lock: + result = lef.try_query_with_cache() + if result is not None: + item = result["list"][0] + return { + "ok": True, + "room": result["display"], + "surplus": round(item["surplus"], 2), + "amount": round(item["amount"], 2), + "status": item.get("roomStatus", ""), + "source": "cache", + "time": int(time.time()), + } + # 缓存失效 -> 完整登录 + ret = lef.full_login_flow() + if ret is None: + return None + app_shiro, room, result = ret + lef.save_cache(app_shiro, room) + item = result["list"][0] + return { + "ok": True, + "room": result["display"], + "surplus": round(item["surplus"], 2), + "amount": round(item["amount"], 2), + "status": item.get("roomStatus", ""), + "source": "login", + "time": int(time.time()), + } + + +def query_and_record(source="auto"): + """查询电费并写入当日记录。成功返回 True。""" + data = query_electric() + if not data or not data.get("ok"): + return False + today = datetime.now().strftime("%Y-%m-%d") + db_record(today, data["surplus"], data["amount"], source) + print("[记录] %s 剩余 %.2f 度 (%.2f 元) [%s]" % (today, data["surplus"], data["amount"], source)) + return True + + +# ---------- 每日耗电统计 ---------- +def build_daily_stats(records): + """records 按日期降序。返回升序的每日统计,含日耗电。""" + recs = sorted(records, key=lambda r: r["date"]) # 升序 + daily = [] + prev_surplus = None + for r in recs: + usage = None + if prev_surplus is not None: + usage = round(prev_surplus - r["surplus"], 2) + # 负数说明充值/数据异常,归零处理 + if usage < 0: + usage = 0.0 + daily.append({ + "date": r["date"], + "surplus": r["surplus"], + "amount": r["amount"], + "usage": usage, + }) + prev_surplus = r["surplus"] + return daily + + +# ---------- 定时任务 ---------- +def scheduler_loop(): + """每 CHECK_INTERVAL 秒检查一次,到达次日 00:00 且当日无记录时执行。""" + last_try_date = None + while True: + try: + now = datetime.now() + today = now.strftime("%Y-%m-%d") + # 00:00 ~ 00:05 窗口内触发 + if now.hour == 0 and now.minute < 5 and last_try_date != today: + last_try_date = today + if not db_has_today(today): + print("[定时] %s 00:00 触发每日电费记录" % today) + run_daily_record_with_retry() + time.sleep(CHECK_INTERVAL * 2) # 避免窗口内重复触发 + else: + # 若某天任务失败,每分钟补记一次直至成功或窗口关闭 + if now.hour == 0 and not db_has_today(today) and last_try_date != today: + last_try_date = today + print("[补记] 今日无记录,尝试补记") + run_daily_record_with_retry() + except Exception as e: + print("[定时] 异常:", e) + time.sleep(CHECK_INTERVAL) + + +def run_daily_record_with_retry(): + """执行一次记录,失败重试 RETRY_TIMES 次。""" + for i in range(1, RETRY_TIMES + 1): + if query_and_record("auto"): + return + print("[定时] 第 %d 次尝试失败,%d 秒后重试" % (i, RETRY_INTERVAL)) + time.sleep(RETRY_INTERVAL) + print("[定时] %d 次重试均失败,等待下轮" % RETRY_TIMES) + + +# ---------- HTTP Handler ---------- +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=STATIC_DIR, **kwargs) + + def log_message(self, fmt, *args): + sys.stdout.write("[%s] %s\n" % (datetime.now().strftime("%H:%M:%S"), fmt % args)) + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + + if path == "/api/electric": + self._send_json(self._handle_electric()) + elif path == "/api/history": + self._send_json(self._handle_history()) + elif path == "/": + self.path = "/index.html" + super().do_GET() + elif path.startswith("/static/"): + self.path = path[len("/static/"):] + super().do_GET() + else: + self.send_error(404) + + # -- API 实现 -- + def _handle_electric(self): + try: + data = query_electric() + if data is None: + return {"ok": False, "error": "电费查询失败,请查看服务器日志"} + return data + except Exception as e: + return {"ok": False, "error": str(e)} + + def _handle_history(self): + records = db_get_records() + daily = build_daily_stats(records) + return {"ok": True, "records": records, "daily": daily} + + def _send_json(self, obj): + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + +def main(): + db_init() + # 启动定时任务线程(守护) + threading.Thread(target=scheduler_loop, daemon=True).start() + # 启动时立即记录一次当日数据(若缺失),确保首日有基线 + today = datetime.now().strftime("%Y-%m-%d") + if not db_has_today(today): + print("[启动] 今日无记录,立即查询并记录") + threading.Thread(target=run_daily_record_with_retry, daemon=True).start() + + server = ThreadingHTTPServer((HOST, PORT), Handler) + print("=" * 50) + print("宿舍电费监控服务已启动") + print(" 访问地址: http://%s:%d" % (HOST, PORT)) + print(" 定时记录: 每天 00:00,失败重试 %d 次" % RETRY_TIMES) + print(" 数据库: %s" % DB_PATH) + print("=" * 50) + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n服务已停止") + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..6f090a8 --- /dev/null +++ b/static/app.js @@ -0,0 +1,153 @@ +// 宿舍电费监控 - 前端逻辑 +const POLL_INTERVAL = 30000; // 自动刷新间隔 30s +let usageChart = null; + +const $ = (id) => document.getElementById(id); + +// ---------- 工具 ---------- +function formatTime(ts) { + if (!ts) return "--"; + const d = new Date(ts * 1000); + return d.toLocaleString("zh-CN", { hour12: false }); +} + +function formatDateStr(dateStr) { + const d = new Date(dateStr); + return d.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }); +} + +function setBadge(state, text) { + const el = $("statusBadge"); + el.className = "status-badge " + state; + el.textContent = text; +} + +// ---------- 当前电费 ---------- +async function fetchCurrent() { + setBadge("loading", "查询中..."); + try { + const resp = await fetch("/api/electric"); + const data = await resp.json(); + if (data.ok) { + $("surplusValue").textContent = data.surplus.toFixed(2); + $("amountValue").textContent = data.amount.toFixed(2); + $("updateTime").textContent = formatTime(data.time); + $("roomInfo").textContent = "🏠 " + data.room; + setBadge("ok", "实时"); + } else { + setBadge("err", "查询失败"); + $("roomInfo").textContent = "⚠️ " + (data.error || "查询失败"); + } + } catch (e) { + setBadge("err", "网络错误"); + } +} + +// ---------- 历史记录 ---------- +function renderChart(daily) { + $("chartEmpty").style.display = daily.length ? "none" : "block"; + if (!daily.length) return; + + const labels = daily.map((d) => formatDateStr(d.date)); + const usage = daily.map((d) => d.usage); + const surplus = daily.map((d) => d.surplus); + + const ctx = $("usageChart").getContext("2d"); + if (usageChart) usageChart.destroy(); + usageChart = new Chart(ctx, { + data: { + labels, + datasets: [ + { + type: "bar", + label: "日耗电(度)", + data: usage, + backgroundColor: "rgba(255, 167, 38, 0.75)", + borderRadius: 6, + yAxisID: "y", + }, + { + type: "line", + label: "剩余电量(度)", + data: surplus, + borderColor: "#4fc3f7", + backgroundColor: "rgba(79, 195, 247, 0.15)", + tension: 0.3, + pointRadius: 4, + yAxisID: "y1", + }, + ], + }, + options: { + responsive: true, + interaction: { mode: "index", intersect: false }, + plugins: { + legend: { + labels: { color: "#e8edf2", usePointStyle: true }, + }, + }, + scales: { + x: { ticks: { color: "#9fb3c8" }, grid: { color: "rgba(255,255,255,0.05)" } }, + y: { + position: "left", + title: { display: true, text: "耗电(度)", color: "#ffa726" }, + ticks: { color: "#9fb3c8" }, + grid: { color: "rgba(255,255,255,0.05)" }, + }, + y1: { + position: "right", + title: { display: true, text: "剩余(度)", color: "#4fc3f7" }, + ticks: { color: "#9fb3c8" }, + grid: { drawOnChartArea: false }, + }, + }, + }, + }); +} + +function renderHistory(records) { + const body = $("historyBody"); + if (!records.length) { + body.innerHTML = '
| 日期 | +剩余电量(度) | +日耗电(度) | +记录方式 | +
|---|---|---|---|
| 加载中... | |||