feat: 核心电费查询 Go 服务

- FasterLZU 登录 + xiaofubao 6 步电费链路

- shiroJID 会话缓存 + 每日记录存储

- 定时任务: 每天 00:00 记录, 失败重试 5 次

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-23 21:29:33 +08:00
commit b2ad61e3fe
6 changed files with 1122 additions and 0 deletions

223
go/main.go Normal file
View File

@@ -0,0 +1,223 @@
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()
allDaily := buildDailyStats(records, "") // 全量(用于可用月份列表)
monthDaily := buildDailyStats(records, month)
// 可用月份列表(降序)
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,
"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)
}
}