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:
2026-08-23 21:29:33 +08:00
commit b2ad61e3fe
6 changed files with 1122 additions and 0 deletions

431
go/electric.go Normal file
View 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
}