Files
dorm-power-monitor/go/main.go
fallensigh 36985771d5 fix: shiro 缓存失效时自动重新登录获取充值记录
- /api/history 充值记录获取失败时触发 fullLoginFlow 重建会话

- 重建后重试一次, 彻底失败才降级为无充值计算

- 补充 fullLoginFlow 失败日志便于排查

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-23 21:48:41 +08:00

269 lines
7.2 KiB
Go
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.
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)
}
}