commit b2ad61e3fe927a0255877b0283c0f45abed5f5d2 Author: fallensigh Date: Sun Aug 23 21:29:33 2026 +0800 feat: 核心电费查询 Go 服务 - FasterLZU 登录 + xiaofubao 6 步电费链路 - shiroJID 会话缓存 + 每日记录存储 - 定时任务: 每天 00:00 记录, 失败重试 5 次 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus diff --git a/go/crypto.go b/go/crypto.go new file mode 100644 index 0000000..460a174 --- /dev/null +++ b/go/crypto.go @@ -0,0 +1,82 @@ +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "encoding/hex" + "fmt" + "strings" +) + +// crypto.go - AES-CBC 加密/解密 + MD5 签名(与 FasterLZU APP 行为一致) +// 密钥必须通过环境变量 LZU_AES_KEY / LZU_MD5_KEY 提供(16 字节) + +var ( + aesKey = envOr("LZU_AES_KEY", "") // 16 字节,由部署环境注入 + md5Key = envOr("LZU_MD5_KEY", "") +) + +// aesEncrypt 使用 AES-CBC,IV=key,零填充,输出小写 hex +func aesEncrypt(plaintext string) (string, error) { + key := []byte(aesKey) + if len(key) != 16 { + return "", fmt.Errorf("AES key 长度不是 16 字节") + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + data := []byte(plaintext) + // 零填充到 blockSize 的整数倍 + blockSize := aes.BlockSize + paddedLen := len(data) + if paddedLen%blockSize != 0 { + paddedLen = len(data) + (blockSize - len(data)%blockSize) + } + padded := make([]byte, paddedLen) + copy(padded, data) + // 剩余部分保持 0x00(零填充) + + iv := key + mode := cipher.NewCBCEncrypter(block, iv) + ciphertext := make([]byte, paddedLen) + mode.CryptBlocks(ciphertext, padded) + return hex.EncodeToString(ciphertext), nil +} + +// aesDecrypt AES-CBC 解密,去除尾部零填充 +func aesDecrypt(hexStr string) (string, error) { + key := []byte(aesKey) + if len(key) != 16 { + return "", fmt.Errorf("AES key 长度不是 16 字节") + } + encrypted, err := hex.DecodeString(hexStr) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + iv := key + mode := cipher.NewCBCDecrypter(block, iv) + decrypted := make([]byte, len(encrypted)) + mode.CryptBlocks(decrypted, encrypted) + // 去除尾部零填充 + validLen := len(decrypted) + for i := len(decrypted) - 1; i >= 0 && i >= len(decrypted)-16; i-- { + if decrypted[i] != 0 { + break + } + validLen-- + } + return string(decrypted[:validLen]), nil +} + +// md5Encrypt 签名: 参数值以 | 连接,末尾拼接 key,返回 MD5 hex +func md5Encrypt(params []string, key string) string { + data := strings.Join(params, "|") + "|" + key + sum := md5.Sum([]byte(data)) + return hex.EncodeToString(sum[:]) +} diff --git a/go/electric.go b/go/electric.go new file mode 100644 index 0000000..e5d65d2 --- /dev/null +++ b/go/electric.go @@ -0,0 +1,431 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +// electric.go - xiaofubao 一码通电费查询链路 + shiroJID 缓存 +// 对应 Python lzu_electric_full.py 的 6 步流程 + +// 平台常量(来自环境变量,默认值对齐 .env) +var ( + xfPlatform = envOr("XF_PLATFORM", "QHIT_EDU") + xfAuthAppid = envOr("XF_AUTH_APPID", "2606161276416311297") + xfSchoolCode = envOr("XF_SCHOOL_CODE", "10730") + xfYmAppid = envOr("XF_YM_APPID", "1810181825222034") + xfServiceID = envOr("LZU_SERVICE_ID", "29615") +) + +var ( + browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36" + // 查询互斥锁:防止定时任务与手动刷新并发登录 + queryLock = make(chan struct{}, 1) +) + +// roomInfo 绑定房间信息 +type roomInfo struct { + AreaID string `json:"areaId"` + BuildingCode string `json:"buildingCode"` + FloorCode string `json:"floorCode"` + RoomCode string `json:"roomCode"` + RoomName string `json:"roomName"` + AreaName string `json:"areaName"` + BuildingName string `json:"buildingName"` + FloorName string `json:"floorName"` +} + +// cacheData 缓存结构(shiroJID 会话 + 房间) +type cacheData struct { + AppShiro string `json:"app_shiro"` + Room roomInfo `json:"room"` + CachedAt int64 `json:"cached_at"` +} + +// electricResult 查询结果 +type electricResult struct { + OK bool `json:"ok"` + Room string `json:"room"` + Surplus float64 `json:"surplus"` + Amount float64 `json:"amount"` + Status string `json:"status"` + Source string `json:"source"` + Time int64 `json:"time"` + Error string `json:"error,omitempty"` +} + +var cacheFilePath = envOr("CACHE_FILE", "data/electric_cache.json") + +// ---------- 缓存 ---------- +func loadCache() (*cacheData, error) { + b, err := os.ReadFile(cacheFilePath) + if err != nil { + return nil, err + } + var c cacheData + if err := json.Unmarshal(b, &c); err != nil { + return nil, err + } + return &c, nil +} + +func saveCache(c *cacheData) error { + if err := os.MkdirAll(dirName(cacheFilePath), 0o755); err != nil { + return err + } + b, _ := json.MarshalIndent(c, "", " ") + return os.WriteFile(cacheFilePath, b, 0o644) +} + +func clearCache() { + _ = os.Remove(cacheFilePath) +} + +func dirName(p string) string { + i := strings.LastIndexByte(p, '/') + if i < 0 { + i = strings.LastIndexByte(p, '\\') + } + if i < 0 { + return "." + } + return p[:i] +} + +// ---------- HTTP 辅助 ---------- +type xfSession struct { + client *http.Client + cookie string // shiroJID +} + +func newXfSession(shiroJID string) *xfSession { + return &xfSession{client: &http.Client{}, cookie: shiroJID} +} + +func (s *xfSession) postForm(urlStr, body, referer string) ([]byte, http.Header, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", urlStr, strings.NewReader(body)) + if err != nil { + return nil, nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8") + req.Header.Set("User-Agent", browserUA) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("Accept", "application/json, text/plain, */*") + if referer != "" { + req.Header.Set("Referer", referer) + } + if s.cookie != "" { + req.AddCookie(&http.Cookie{Name: "shiroJID", Value: s.cookie}) + } + resp, err := s.client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + bodyBytes, _ := io.ReadAll(resp.Body) + return bodyBytes, resp.Header, nil +} + +func (s *xfSession) get(urlStr string, allowRedirect bool) (*http.Response, error) { + client := s.client + if !allowRedirect { + // 不跟随重定向 + client = &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", browserUA) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + if s.cookie != "" { + req.AddCookie(&http.Cookie{Name: "shiroJID", Value: s.cookie}) + } + return client.Do(req) +} + +// ---------- 查询链路 ---------- + +// step1GetCodeV2 ST票据 -> ymCode(无需 cookie) +func step1GetCodeV2(startURL string) (string, error) { + cb := url.QueryEscape(startURL) + u := fmt.Sprintf("https://auth.xiaofubao.com/authoriz/getCodeV2?bindSkip=1&ymAppId=%s&authType=3&authAppid=%s&callbackUrl=%s", + xfYmAppid, xfAuthAppid, cb) + s := newXfSession("") + resp, err := s.get(u, false) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusFound { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("getCodeV2 状态码异常: %d %s", resp.StatusCode, string(body)) + } + loc := resp.Header.Get("Location") + re := regexp.MustCompile(`ymCode=([0-9a-f]+)`) + m := re.FindStringSubmatch(loc) + if len(m) < 2 { + return "", fmt.Errorf("getCodeV2 未返回 ymCode: %s", loc) + } + return m[1], nil +} + +// extractShiroJID 从 Set-Cookie 头提取最终生效的 shiroJID。 +// 服务端可能返回多值: 设置 -> deleteMe(删除) -> 重设,须按顺序取最后一个有效值。 +func extractShiroJID(hdr http.Header) (string, error) { + found := "" + for _, c := range hdr.Values("Set-Cookie") { + parts := strings.Split(c, ";") + kv := strings.SplitN(strings.TrimSpace(parts[0]), "=", 2) + if len(kv) != 2 || kv[0] != "shiroJID" { + continue + } + if kv[1] == "deleteMe" { + found = "" // 删除之前的 + continue + } + found = kv[1] + } + if found == "" { + return "", errors.New("未返回有效的 shiroJID cookie") + } + return found, nil +} + +// step2GetUserByCodeV2 ymCode -> 用户信息 + webapp shiroJID +func step2GetUserByCodeV2(ymCode, startURL string) (string, error) { + s := newXfSession("") + body := fmt.Sprintf("code=%s&platform=%s", ymCode, xfPlatform) + _, hdr, err := s.postForm("https://webapp.xiaofubao.com/user/getUserByCodeV2", body, startURL) + if err != nil { + return "", err + } + return extractShiroJID(hdr) +} + +// flexInt 兼容 JSON 中字符串或数字的 statusCode +type flexInt int + +func (f *flexInt) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), `"`) + if s == "" { + *f = 0 + return nil + } + var n int + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + return err + } + *f = flexInt(n) + return nil +} + +// step3GetUserCode 生成一次性 code(必须带 referer) +func step3GetUserCode(shiroJID, startURL string) (string, error) { + s := newXfSession(shiroJID) + body := fmt.Sprintf("ymAppId=%s&ymAuthType=2&platform=%s", xfYmAppid, xfPlatform) + b, _, err := s.postForm("https://webapp.xiaofubao.com/routeauth/auth/route/user/getUserCode", body, startURL) + if err != nil { + return "", err + } + var res struct { + StatusCode flexInt `json:"statusCode"` + Message string `json:"message"` + Data string `json:"data"` + } + if err := json.Unmarshal(b, &res); err != nil { + return "", err + } + if res.StatusCode != 0 { + return "", fmt.Errorf("getUserCode 失败 %d: %s", res.StatusCode, res.Message) + } + return res.Data, nil +} + +// step4GetUser4Authorize code -> application 域 shiroJID +func step4GetUser4Authorize(code string) (string, error) { + s := newXfSession("") + body := fmt.Sprintf("code=%s&authType=2&platform=%s", code, xfPlatform) + _, hdr, err := s.postForm("https://application.xiaofubao.com/app/login/getUser4Authorize", body, "https://application.xiaofubao.com/") + if err != nil { + return "", err + } + return extractShiroJID(hdr) +} + +// step5QueryBind 查询绑定房间 +func step5QueryBind(shiroJID string) (*roomInfo, error) { + s := newXfSession(shiroJID) + body := fmt.Sprintf("bindType=3&platform=%s", xfPlatform) + b, _, err := s.postForm("https://application.xiaofubao.com/app/electric/queryBind", body, "https://application.xiaofubao.com/") + if err != nil { + return nil, err + } + var res struct { + StatusCode flexInt `json:"statusCode"` + Rows []struct { + AreaID string `json:"areaId"` + BuildingCode string `json:"buildingCode"` + FloorCode string `json:"floorCode"` + RoomCode string `json:"roomCode"` + RoomName string `json:"roomName"` + AreaName string `json:"areaName"` + BuildingName string `json:"buildingName"` + FloorName string `json:"floorName"` + } `json:"rows"` + } + if err := json.Unmarshal(b, &res); err != nil { + return nil, err + } + if res.StatusCode != 0 || len(res.Rows) == 0 { + return nil, fmt.Errorf("queryBind 失败: %s", string(b)) + } + r := res.Rows[0] + return &roomInfo{ + AreaID: r.AreaID, BuildingCode: r.BuildingCode, FloorCode: r.FloorCode, + RoomCode: r.RoomCode, RoomName: r.RoomName, AreaName: r.AreaName, + BuildingName: r.BuildingName, FloorName: r.FloorName, + }, nil +} + +// step6QuerySurplus 查询剩余电量 +func step6QuerySurplus(shiroJID string, room *roomInfo) (float64, float64, string, error) { + s := newXfSession(shiroJID) + body := fmt.Sprintf("areaId=%s&buildingCode=%s&floorCode=%s&roomCode=%s&platform=%s", + room.AreaID, room.BuildingCode, room.FloorCode, room.RoomCode, xfPlatform) + b, _, err := s.postForm("https://application.xiaofubao.com/app/electric/queryISIMSRoomSurplus", body, "https://application.xiaofubao.com/") + if err != nil { + return 0, 0, "", err + } + var res struct { + StatusCode flexInt `json:"statusCode"` + Data struct { + DisplayRoomName string `json:"displayRoomName"` + SurplusList []struct { + Surplus float64 `json:"surplus"` + Amount float64 `json:"amount"` + RoomStatus string `json:"roomStatus"` + } `json:"surplusList"` + } `json:"data"` + } + if err := json.Unmarshal(b, &res); err != nil { + return 0, 0, "", err + } + if res.StatusCode != 0 || len(res.Data.SurplusList) == 0 { + return 0, 0, "", fmt.Errorf("queryISIMSRoomSurplus 失败: %s", string(b)) + } + item := res.Data.SurplusList[0] + return item.Surplus, item.Amount, item.RoomStatus, nil +} + +// ---------- 完整登录流程 ---------- +func fullLoginFlow() (string, *roomInfo, *electricResult, error) { + lzu := newLzuClient(envOr("LZU_USERNAME", ""), envOr("LZU_PASSWORD", "")) + if lzu.username == "" || lzu.password == "" { + return "", nil, nil, errors.New("缺少 LZU_USERNAME / LZU_PASSWORD 配置") + } + if _, _, err := lzu.Login(); err != nil { + return "", nil, nil, err + } + st, err := lzu.GetSt(xfServiceID) + if err != nil { + return "", nil, nil, err + } + startURL := fmt.Sprintf("https://webapp.xiaofubao.com/card/apps_all.shtml?authAppid=%s&platform=%s&schoolCode=%s&PersonID=%s&st=%s&ticket=%s", + xfAuthAppid, xfPlatform, xfSchoolCode, lzu.username, st, st) + + ymCode, err := step1GetCodeV2(startURL) + if err != nil { + return "", nil, nil, err + } + webappShiro, err := step2GetUserByCodeV2(ymCode, startURL) + if err != nil { + return "", nil, nil, err + } + code, err := step3GetUserCode(webappShiro, startURL) + if err != nil { + return "", nil, nil, err + } + appShiro, err := step4GetUser4Authorize(code) + if err != nil { + return "", nil, nil, err + } + room, err := step5QueryBind(appShiro) + if err != nil { + return "", nil, nil, err + } + surplus, amount, status, err := step6QuerySurplus(appShiro, room) + if err != nil { + return "", nil, nil, err + } + result := &electricResult{ + OK: true, Room: displayRoomName(room), Surplus: round2(surplus), + Amount: round2(amount), Status: status, Source: "login", Time: time.Now().Unix(), + } + return appShiro, room, result, nil +} + +// ---------- 查询入口(缓存优先) ---------- +func queryElectric() *electricResult { + queryLock <- struct{}{} + defer func() { <-queryLock }() + + // 1. 尝试缓存 + if c, err := loadCache(); err == nil { + if time.Now().Unix()-c.CachedAt < cacheTTL() { + surplus, amount, status, err := step6QuerySurplus(c.AppShiro, &c.Room) + if err == nil { + return &electricResult{ + OK: true, Room: displayRoomName(&c.Room), Surplus: round2(surplus), + Amount: round2(amount), Status: status, Source: "cache", Time: time.Now().Unix(), + } + } + fmt.Println("[缓存] 会话失效:", err) + } + } + + // 2. 完整登录 + appShiro, room, result, err := fullLoginFlow() + if err != nil { + clearCache() + return &electricResult{OK: false, Error: err.Error()} + } + _ = saveCache(&cacheData{AppShiro: appShiro, Room: *room, CachedAt: time.Now().Unix()}) + return result +} + +func displayRoomName(r *roomInfo) string { + return fmt.Sprintf("%s%s%s%s", r.AreaName, r.BuildingName, r.FloorName, r.RoomName) +} + +func round2(f float64) float64 { + return float64(int(f*100+0.5)) / 100 +} + +func cacheTTL() int64 { + ttl := int64(6 * 24 * 3600) + if v := envOr("XF_CACHE_TTL", ""); v != "" { + var n int64 + if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 { + ttl = n + } + } + return ttl +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..e60f602 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module electric-monitor + +go 1.18 diff --git a/go/history.go b/go/history.go new file mode 100644 index 0000000..e735195 --- /dev/null +++ b/go/history.go @@ -0,0 +1,253 @@ +package main + +import ( + "encoding/json" + "os" + "sort" + "strings" + "sync" + "time" +) + +// history.go - 每日电费记录存储(JSON 文件)与耗电统计 + +type record struct { + Date string `json:"date"` // YYYY-MM-DD + Surplus float64 `json:"surplus"` + Amount float64 `json:"amount"` + Source string `json:"source"` // auto / manual + CreatedAt int64 `json:"created_at"` +} + +type dailyStat struct { + Date string `json:"date"` + Surplus float64 `json:"surplus"` + Amount float64 `json:"amount"` + Usage *float64 `json:"usage"` // nil 表示无前一天基线 +} + +// monthSummary 月度汇总 +type monthSummary struct { + Month string `json:"month"` // YYYY-MM + RecordCount int `json:"record_count"` // 记录天数 + StartSurplus *float64 `json:"start_surplus"` // 月初首次电量 + EndSurplus *float64 `json:"end_surplus"` // 月末末次电量 + TotalUsage *float64 `json:"total_usage"` // 月总耗电 + AvgUsage *float64 `json:"avg_usage"` // 日均耗电 + RechargeDays int `json:"recharge_days"` // 疑似充值天数(电量回升) + MaxUsage *float64 `json:"max_usage"` // 单日最大耗电 +} + +var ( + historyPath = envOr("HISTORY_FILE", "data/electric_history.json") + historyLock sync.Mutex + historyLoaded bool + historyCache []record +) + +// loadHistory 读取历史记录 +func loadHistory() []record { + historyLock.Lock() + defer historyLock.Unlock() + if historyLoaded { + return historyCache + } + b, err := os.ReadFile(historyPath) + if err != nil { + historyLoaded = true + return nil + } + _ = json.Unmarshal(b, &historyCache) + historyLoaded = true + return historyCache +} + +// saveHistory 持久化历史记录 +func saveHistory(records []record) error { + historyLock.Lock() + defer historyLock.Unlock() + if err := os.MkdirAll(dirName(historyPath), 0o755); err != nil { + return err + } + b, _ := json.MarshalIndent(records, "", " ") + if err := os.WriteFile(historyPath, b, 0o644); err != nil { + return err + } + historyCache = records + return nil +} + +// hasRecord 判断指定日期是否已有记录 +func hasRecord(dateStr string) bool { + for _, r := range loadHistory() { + if r.Date == dateStr { + return true + } + } + return false +} + +// addRecord 写入(或覆盖)一条记录 +func addRecord(dateStr string, surplus, amount float64, source string) error { + records := loadHistory() + updated := false + for i, r := range records { + if r.Date == dateStr { + records[i] = record{Date: dateStr, Surplus: surplus, Amount: amount, Source: source, CreatedAt: time.Now().Unix()} + updated = true + break + } + } + if !updated { + records = append(records, record{Date: dateStr, Surplus: surplus, Amount: amount, Source: source, CreatedAt: time.Now().Unix()}) + } + return saveHistory(records) +} + +// getRecords 返回降序(新→旧)记录列表 +func getRecords() []record { + records := loadHistory() + sort.Slice(records, func(i, j int) bool { + return records[i].Date > records[j].Date + }) + return records +} + +// buildDailyStats 按日期升序生成每日统计(含日耗电 = 前一天剩余 - 当天剩余) +// 注意: 耗电计算跨月时使用上月末记录作为基线(若存在) +func buildDailyStats(records []record, month string) []dailyStat { + all := loadHistory() + // 若指定月份,额外包含上一月最后一条记录作为基线(不计入本月输出) + var prevMonthLast *record + if month != "" { + prevPrefix := prevMonth(month) + "-" + var last *record + for i := range all { + if strings.HasPrefix(all[i].Date, prevPrefix) { + if last == nil || all[i].Date > last.Date { + last = &all[i] + } + } + } + prevMonthLast = last + } + + // 筛选目标记录 + var target []record + if month != "" { + target = filterByMonth(records, month) + } else { + target = records + } + sort.Slice(target, func(i, j int) bool { + return target[i].Date < target[j].Date + }) + + var daily []dailyStat + var prevSurplus *float64 + if prevMonthLast != nil { + prevSurplus = &prevMonthLast.Surplus + } + for _, r := range target { + var usage *float64 + if prevSurplus != nil { + u := round2(*prevSurplus - r.Surplus) + if u < 0 { + u = 0 // 充值/异常归零 + } + usage = &u + } + daily = append(daily, dailyStat{Date: r.Date, Surplus: r.Surplus, Amount: r.Amount, Usage: usage}) + s := r.Surplus + prevSurplus = &s + } + return daily +} + +// prevMonth 返回上个月的 YYYY-MM +func prevMonth(month string) string { + t, err := time.Parse("2006-01", month) + if err != nil { + return "" + } + return t.AddDate(0, -1, 0).Format("2006-01") +} + +// filterByMonth 按月份(YYYY-MM)筛选记录 +func filterByMonth(records []record, month string) []record { + if month == "" { + return records + } + prefix := month + "-" + var out []record + for _, r := range records { + if strings.HasPrefix(r.Date, prefix) { + out = append(out, r) + } + } + return out +} + +// buildMonthSummary 生成某月的月度汇总 +func buildMonthSummary(daily []dailyStat, month string) *monthSummary { + if len(daily) == 0 { + return &monthSummary{Month: month, RecordCount: 0} + } + // daily 已是升序 + m := month + if m == "" { + m = daily[0].Date[:7] + } + start := daily[0].Surplus + end := daily[len(daily)-1].Surplus + + var total float64 + var max float64 + rechargeDays := 0 + countWithUsage := 0 + for _, d := range daily { + if d.Usage != nil { + total += *d.Usage + if *d.Usage > max { + max = *d.Usage + } + countWithUsage++ + } + // 疑似充值:当天剩余比前一天高 + } + // 充值与最大耗电基于原始升序记录重新精确计算 + if len(daily) >= 2 { + rechargeDays = countRecharges(daily) + } + + var totalP, avgP, maxP, startP, endP *float64 + startP, endP = &start, &end + totalP = &total + if countWithUsage > 0 { + avg := round2(total / float64(countWithUsage)) + avgP = &avg + } + maxP = &max + + return &monthSummary{ + Month: m, + RecordCount: len(daily), + StartSurplus: startP, + EndSurplus: endP, + TotalUsage: totalP, + AvgUsage: avgP, + RechargeDays: rechargeDays, + MaxUsage: maxP, + } +} + +// countRecharges 统计疑似充值天数(当天剩余较前一天回升) +func countRecharges(daily []dailyStat) int { + n := 0 + for i := 1; i < len(daily); i++ { + if daily[i].Surplus > daily[i-1].Surplus { + n++ + } + } + return n +} diff --git a/go/lzuapi.go b/go/lzuapi.go new file mode 100644 index 0000000..4f2ae2e --- /dev/null +++ b/go/lzuapi.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// lzuapi.go - FasterLZU (appservice.lzu.edu.cn) 登录客户端 +// 对应 Python lzuapi.py 中的 login() 与 get_st() + +const ( + appServiceBase = "https://appservice.lzu.edu.cn" + lzuUserAgent = "Mozilla/5.0 (Linux; Android 12; SM-S7110 Build/V417IR; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/101.0.4951.61 Mobile Safari/537.36 lzdx_ua JHZF_LZDXAPP" +) + +// lzuClient FasterLZU API 客户端 +type lzuClient struct { + http *http.Client + username string + password string + loginToken string + gatewayToken string +} + +func newLzuClient(username, password string) *lzuClient { + return &lzuClient{ + http: &http.Client{}, + username: username, + password: password, + } +} + +// encryptedHeaders 加密请求头 +func encryptedHeaders() http.Header { + h := http.Header{} + h.Set("Content-Type", "application/json;charset=UTF-8") + h.Set("Transfer-Encrypt", "true") + h.Set("User-Agent", lzuUserAgent) + return h +} + +// Login 登录,返回 (loginToken, gatewayToken) +func (c *lzuClient) Login() (string, string, error) { + url := appServiceBase + "/api/eusp-unify-terminal/app-user/login" + data := map[string]any{ + "app_os": 2, + "name": c.username, + "pwd": c.password, + } + plaintext, _ := json.Marshal(data) // 无空格,与 Python separators=(",",":") 一致 + ciphertext, err := aesEncrypt(string(plaintext)) + if err != nil { + return "", "", err + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBufferString(ciphertext)) + if err != nil { + return "", "", err + } + req.Header = encryptedHeaders() + resp, err := c.http.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + decrypted, err := aesDecrypt(strings.TrimSpace(string(body))) + if err != nil { + return "", "", fmt.Errorf("登录响应解密失败: %w", err) + } + var res struct { + Code int `json:"code"` + Data struct { + LoginToken string `json:"login_token"` + GatewayToken string `json:"gateway_token"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(decrypted), &res); err != nil { + return "", "", fmt.Errorf("登录响应解析失败: %w", err) + } + if res.Code != 1 { + return "", "", fmt.Errorf("登录失败 code=%d", res.Code) + } + c.loginToken = res.Data.LoginToken + c.gatewayToken = res.Data.GatewayToken + return c.loginToken, c.gatewayToken, nil +} + +// GetSt 获取服务令牌 ST +func (c *lzuClient) GetSt(serviceID string) (string, error) { + url := fmt.Sprintf("%s/api/eusp-unify-terminal/app-user/getSt?loginToken=%s&serviceId=%s&service=", + appServiceBase, c.loginToken, serviceID) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return "", err + } + req.Header = encryptedHeaders() + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + decrypted, err := aesDecrypt(strings.TrimSpace(string(body))) + if err != nil { + return "", fmt.Errorf("getSt 响应解密失败: %w", err) + } + var res struct { + Code int `json:"code"` + Data string `json:"data"` + } + if err := json.Unmarshal([]byte(decrypted), &res); err != nil { + return "", fmt.Errorf("getSt 响应解析失败: %w", err) + } + if res.Code != 1 { + return "", fmt.Errorf("getSt 失败 code=%d", res.Code) + } + return res.Data, nil +} diff --git a/go/main.go b/go/main.go new file mode 100644 index 0000000..3304249 --- /dev/null +++ b/go/main.go @@ -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) + } +}