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