- /api/history 充值记录获取失败时触发 fullLoginFlow 重建会话 - 重建后重试一次, 彻底失败才降级为无充值计算 - 补充 fullLoginFlow 失败日志便于排查 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
433 lines
12 KiB
Go
433 lines
12 KiB
Go
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
|
||
}
|