Files
dorm-power-monitor/go/electric.go
fallensigh 36985771d5 fix: shiro 缓存失效时自动重新登录获取充值记录
- /api/history 充值记录获取失败时触发 fullLoginFlow 重建会话

- 重建后重试一次, 彻底失败才降级为无充值计算

- 补充 fullLoginFlow 失败日志便于排查

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-23 21:48:41 +08:00

433 lines
12 KiB
Go
Raw Permalink 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 {
fmt.Println("[登录] fullLoginFlow 失败:", err)
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
}