- 新增 queryISIMSRoomBuyRecord 充值记录获取 + 10分钟缓存 - 日耗电 = 剩余变化 + 当天充值电量(电价0.535元/度) - 月度汇总增加充值次数/电量/金额 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
238 lines
6.2 KiB
Go
238 lines
6.2 KiB
Go
package main
|
||
|
||
import (
|
||
"embed"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// main.go - 宿舍电费监控 Web 服务(零第三方依赖,单二进制)
|
||
// 路由:
|
||
// GET / -> 静态前端
|
||
// GET /api/electric -> 实时电费
|
||
// GET /api/history -> 历史记录 + 每日耗电统计
|
||
// 定时任务: 每天 00:00 记录电费,失败重试 5 次(间隔 60s)
|
||
|
||
//go:embed static
|
||
var staticFS embed.FS
|
||
|
||
const (
|
||
retryTimes = 5
|
||
retryInterval = 60 * time.Second
|
||
checkInterval = 30 * time.Second
|
||
)
|
||
|
||
var (
|
||
host = envOr("HOST", "0.0.0.0")
|
||
port = envOr("PORT", "8000")
|
||
)
|
||
|
||
// envOr 读取环境变量,为空时返回默认值
|
||
func envOr(key, def string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
// ---------- 定时任务 ----------
|
||
func schedulerLoop() {
|
||
lastTryDate := ""
|
||
for {
|
||
now := time.Now()
|
||
today := now.Format("2006-01-02")
|
||
// 00:00~00:05 窗口触发
|
||
if now.Hour() == 0 && now.Minute() < 5 && lastTryDate != today {
|
||
lastTryDate = today
|
||
if !hasRecord(today) {
|
||
fmt.Printf("[定时] %s 00:00 触发每日电费记录\n", today)
|
||
runDailyRecordWithRetry()
|
||
time.Sleep(checkInterval * 2)
|
||
}
|
||
} else if now.Hour() == 0 && !hasRecord(today) && lastTryDate != today {
|
||
// 失败补记
|
||
lastTryDate = today
|
||
fmt.Println("[补记] 今日无记录,尝试补记")
|
||
runDailyRecordWithRetry()
|
||
}
|
||
time.Sleep(checkInterval)
|
||
}
|
||
}
|
||
|
||
func runDailyRecordWithRetry() {
|
||
for i := 1; i <= retryTimes; i++ {
|
||
if recordAndLog() {
|
||
return
|
||
}
|
||
fmt.Printf("[定时] 第 %d 次尝试失败,%d 秒后重试\n", i, retryInterval/time.Second)
|
||
time.Sleep(retryInterval)
|
||
}
|
||
fmt.Printf("[定时] %d 次重试均失败,等待下轮\n", retryTimes)
|
||
}
|
||
|
||
func recordAndLog() bool {
|
||
res := queryElectric()
|
||
if !res.OK {
|
||
return false
|
||
}
|
||
today := time.Now().Format("2006-01-02")
|
||
if err := addRecord(today, res.Surplus, res.Amount, "auto"); err != nil {
|
||
fmt.Println("[记录] 写入失败:", err)
|
||
return false
|
||
}
|
||
fmt.Printf("[记录] %s 剩余 %.2f 度 (%.2f 元) [auto]\n", today, res.Surplus, res.Amount)
|
||
return true
|
||
}
|
||
|
||
// ---------- HTTP 处理 ----------
|
||
func apiElectric(w http.ResponseWriter, r *http.Request) {
|
||
res := queryElectric()
|
||
writeJSON(w, res)
|
||
}
|
||
|
||
func apiHistory(w http.ResponseWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
month := q.Get("month") // YYYY-MM,空=全部
|
||
|
||
records := getRecords()
|
||
|
||
// 获取充值记录(用于精确耗电计算)
|
||
rechargeMap := map[string]float64{}
|
||
var buyRecords []buyRecord
|
||
if c, err := loadCache(); err == nil {
|
||
if recs, err := getBuyRecordsCached(c.AppShiro, &c.Room); err == nil {
|
||
rechargeMap = rechargeKwhByDate(recs)
|
||
buyRecords = recs
|
||
} else {
|
||
fmt.Println("[充值] 获取充值记录失败:", err)
|
||
}
|
||
}
|
||
|
||
allDaily := buildDailyStats(records, "", rechargeMap) // 全量(用于可用月份列表)
|
||
monthDaily := buildDailyStats(records, month, rechargeMap)
|
||
|
||
// 可用月份列表(降序)
|
||
months := availableMonths(records)
|
||
|
||
summary := buildMonthSummary(monthDaily, month)
|
||
writeJSON(w, map[string]any{
|
||
"ok": true,
|
||
"month": month,
|
||
"months": months,
|
||
"records": monthDaily, // 升序每日统计(含 usage)
|
||
"daily": monthDaily,
|
||
"summary": summary,
|
||
"recharges": buyRecords, // 充值记录明细
|
||
"all_count": len(allDaily),
|
||
})
|
||
}
|
||
|
||
// availableMonths 返回所有有记录的月份(YYYY-MM,降序)
|
||
func availableMonths(records []record) []string {
|
||
seen := map[string]bool{}
|
||
var months []string
|
||
for _, r := range records {
|
||
if len(r.Date) >= 7 {
|
||
m := r.Date[:7]
|
||
if !seen[m] {
|
||
seen[m] = true
|
||
months = append(months, m)
|
||
}
|
||
}
|
||
}
|
||
sort.Sort(sort.Reverse(sort.StringSlice(months)))
|
||
return months
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, v any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
_ = json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
// ---------- 静态文件 ----------
|
||
// 直接从 embed FS 读取并写出,避免 http.FileServer 的目录重定向问题
|
||
func serveStatic(w http.ResponseWriter, r *http.Request) {
|
||
p := r.URL.Path
|
||
orig := p
|
||
if strings.HasPrefix(p, "/static/") {
|
||
p = strings.TrimPrefix(p, "/static/")
|
||
}
|
||
if p == "/" {
|
||
p = "/index.html"
|
||
}
|
||
if p == "" {
|
||
p = "/index.html"
|
||
}
|
||
|
||
// 规范化为以 / 开头的 embed 路径
|
||
if !strings.HasPrefix(p, "/") {
|
||
p = "/" + p
|
||
}
|
||
full := "static" + p
|
||
data, err := staticFS.ReadFile(full)
|
||
if err != nil {
|
||
fmt.Printf("[静态] %s -> %s: 读取失败 %v\n", orig, full, err)
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
// 设置 Content-Type
|
||
switch {
|
||
case strings.HasSuffix(p, ".html"):
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
case strings.HasSuffix(p, ".css"):
|
||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||
case strings.HasSuffix(p, ".js"):
|
||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||
case strings.HasSuffix(p, ".png"):
|
||
w.Header().Set("Content-Type", "image/png")
|
||
default:
|
||
w.Header().Set("Content-Type", "application/octet-stream")
|
||
}
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
_, _ = w.Write(data)
|
||
}
|
||
|
||
func main() {
|
||
// 启动定时任务
|
||
go schedulerLoop()
|
||
|
||
// 启动时若今日无记录,立即查询建立基线
|
||
today := time.Now().Format("2006-01-02")
|
||
if !hasRecord(today) {
|
||
fmt.Println("[启动] 今日无记录,立即查询并记录")
|
||
go runDailyRecordWithRetry()
|
||
}
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("/api/electric", apiElectric)
|
||
mux.HandleFunc("/api/history", apiHistory)
|
||
mux.HandleFunc("/", serveStatic)
|
||
|
||
addr := fmt.Sprintf("%s:%s", host, port)
|
||
fmt.Println("=" + strings.Repeat("=", 49))
|
||
fmt.Println("宿舍电费监控服务已启动")
|
||
fmt.Println(" 访问地址: http://" + addr)
|
||
fmt.Println(" 定时记录: 每天 00:00,失败重试", retryTimes, "次")
|
||
fmt.Println("=" + strings.Repeat("=", 49))
|
||
|
||
// 带超时的 HTTP 服务器:防止慢速/空闲连接堆积 goroutine
|
||
srv := &http.Server{
|
||
Addr: addr,
|
||
Handler: mux,
|
||
ReadHeaderTimeout: 10 * time.Second,
|
||
ReadTimeout: 30 * time.Second,
|
||
WriteTimeout: 60 * time.Second,
|
||
IdleTimeout: 90 * time.Second,
|
||
}
|
||
if err := srv.ListenAndServe(); err != nil {
|
||
fmt.Println("服务启动失败:", err)
|
||
os.Exit(1)
|
||
}
|
||
}
|