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:
82
go/crypto.go
Normal file
82
go/crypto.go
Normal 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-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[:])
|
||||
}
|
||||
431
go/electric.go
Normal file
431
go/electric.go
Normal 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
|
||||
}
|
||||
253
go/history.go
Normal file
253
go/history.go
Normal 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
130
go/lzuapi.go
Normal 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
223
go/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user