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

82
go/crypto.go Normal file
View File

@@ -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-CBCIV=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[:])
}

431
go/electric.go Normal file
View File

@@ -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
}

3
go/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module electric-monitor
go 1.18

253
go/history.go Normal file
View File

@@ -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
}

130
go/lzuapi.go Normal file
View File

@@ -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
}

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)
}
}