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, buyRecords := fetchRechargeData() 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), }) } // fetchRechargeData 获取充值记录。缓存会话失效时自动重新登录并重试一次。 // 返回 (date->充值电量 map, 充值记录列表);彻底失败时返回空(降级为无充值计算)。 func fetchRechargeData() (map[string]float64, []buyRecord) { rechargeMap := map[string]float64{} empty := []buyRecord{} // 辅助:用当前缓存会话获取充值记录 tryFetch := func() (map[string]float64, []buyRecord, bool) { c, err := loadCache() if err != nil { return nil, nil, false } recs, err := getBuyRecordsCached(c.AppShiro, &c.Room) if err != nil { return nil, nil, false } return rechargeKwhByDate(recs), recs, true } // 第一次尝试(可能命中 10 分钟缓存或有效会话) if m, recs, ok := tryFetch(); ok { return m, recs } // 失败:通过 queryElectric 重建会话(内部 fullLoginFlow + 回写缓存) fmt.Println("[充值] 会话失效,尝试重新登录...") res := queryElectric() if res == nil || !res.OK { fmt.Println("[充值] 重新登录失败,降级为无充值计算") return rechargeMap, empty } // 重建后重试一次 if m, recs, ok := tryFetch(); ok { return m, recs } fmt.Println("[充值] 重建会话后仍失败,降级为无充值计算") return rechargeMap, empty } // 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) } }