Files
dorm-power-monitor/go/electric.go
fallensigh b2ad61e3fe 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>
2026-08-23 21:29:33 +08:00

432 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}