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 }