Compare commits

...

1 Commits

Author SHA1 Message Date
becomer a7180eebba v1.2.80:根治 cookie 失效误报——统一识别登录态失效并自动续期
ci/woodpecker/tag/build Pipeline was successful
① 问题:用户赵佳威(芜湖职业技术大学)反馈「无法获取签到任务」。
   日志显示 whit.campusphere.net 两类查询均报
   "解析任务列表响应失败: invalid character '<' looking for beginning of value"
   —— 今日校园返回 HTML(请求被 302 到 authserver 登录页),即 cookie 已失效。
   其 MOD_AUTH_CAS 票为 09-11 签发、早已过期;而 last_login_at 显示 09-21 是
   jwt 中间件 touchDailyLogin 刷新的"当天使用"标记,并非真实重登,故造成
   "看起来刚登录过、却取不到任务"的假象。

② 根因:原 isCookieExpired 仅匹配错误文案「未登录/登录已过期」,而 HTML 响应的
   报错是 invalid character '<',匹配不到 → 既无法识别、更不会触发续期,
   用户只看到空任务列表。该问题影响所有「已登录后调用今日校园」的接口
   (任务列表、用户信息同步、请销假等)。

③ 修复:
   - cpdaily 层新增 ErrCookieExpired 与 unmarshalBody:解析响应体前先判断
     是否为 HTML,是则返回 ErrCookieExpired(42 处 body 解析统一接入;
     登录/发码/滑块等登录流程不涉及用户 cookie,保持原样不误伤);
   - FetchAllRecentTasks 改为返回 error(两类查询都失败时),供上层识别续期;
   - isCookieExpired 合并 ErrCookieExpired 判定 → 既有 25 处调用点自动受益;
   - 新增 withCookieRefresh(通用包装):检测到登录态失效 → refreshCpdailyCookie
     (长期凭证优先,其次密码重登)→ 用新 cookie 重试一次;续期失败返回
     ErrCookieExpired,上层转「登录凭证已过期,请重新登录」;
   - 应用于 GetTaskTitles、GetPendingLeaves(含新/旧套件)、syncUserProfile(用户信息)。
   - 防递归:refreshCpdailyCookie 内部同步用户信息时禁续期
     (syncUserProfileWith allowRefresh=false),避免「续期→同步→再续期」风暴。

④ MFA 学校说明:MFA 通道登录签发短期 JWT,到期即要求重新登录,本就不需自动续期;
   非 MFA 学校(如本例)的 cookie 由上述链路自动续期,用户不再被莫名踢出。

⑤ 验证(真实数据,只读):
   - 用赵佳威过期 cookie 调 FetchAllRecentTasks:由原来的 invalid character '<'
     变为 cpdaily_cookie_expired,errors.Is(err, ErrCookieExpired)=true;
   - 模拟完整链路:旧 cookie 失败 → 用其保存密码重登成功(6 个 cookie、含新 MOD_AUTH_CAS、
     无 MFA)→ 新 cookie 重试成功取到任务「查寝」;
   - go build / go vet / go test ./... 全过。
2026-09-21 16:57:21 +08:00
12 changed files with 204 additions and 70 deletions
+35 -5
View File
@@ -353,17 +353,47 @@ func CompleteGuide(c *fiber.Ctx) error {
return utils.Success(c, nil)
}
// syncUserProfile 同步 cpdaily 用户信息到数据库
// syncUserProfile 同步 cpdaily 用户信息到数据库。
// 登录态失效(cookie 过期)时自动续期并重试一次,避免用户信息长期同步不上。
func syncUserProfile(user *model.SysUser, cookieStr string, wecloudUrl string) {
syncUserProfileWith(user, cookieStr, wecloudUrl, true)
}
// syncUserProfileWith allowRefresh=false 时不做 cookie 续期,
// 供续期流程内部调用,避免「续期 → 同步 → 再续期」的递归风暴。
func syncUserProfileWith(user *model.SysUser, cookieStr string, wecloudUrl string, allowRefresh bool) {
cookieMap := cpd.ParseCookieJSON(cookieStr)
profile, err := cpd.FetchUserProfile(cookieMap, wecloudUrl)
if err != nil {
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
fetch := func(cm map[string]string) (*cpd.CpdailyUserProfile, error) {
return cpd.FetchUserProfile(cm, wecloudUrl)
}
var profile *cpd.CpdailyUserProfile
if allowRefresh {
_, err := withCookieRefresh(user, cookieMap, wecloudUrl, func(cm map[string]string) error {
var e error
profile, e = fetch(cm)
return e
})
if err != nil {
slog.Warn("同步用户信息失败", "userId", user.ID, "error", err)
return
}
} else {
p, err := fetch(cookieMap)
if err != nil {
slog.Warn("同步用户信息失败(禁续期)", "userId", user.ID, "error", err)
return
}
profile = p
}
if profile == nil {
return
}
updates := syncProfileToUser(profile)
updates["cookie"] = cookieStr // 确保 cookie 最新
// 同步结果不覆盖 cookie:cookie 由调用方(登录/续期流程)负责维护,
// 避免异步同步与续期流程并发写 cookie 造成相互覆盖。
database.DB.Model(&user).Updates(updates)
slog.Info("用户信息同步成功", "userId", user.ID, "name", profile.Name)
}
+48 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strings"
@@ -46,16 +47,28 @@ func updateRawPassword(userID uint, rawPassword string) error {
return saveRawPassword(userID, rawPassword)
}
// isCookieExpired 判断错误是否由 cpdaily cookie 过期引起
// isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。
// 两种来源:
// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页),
// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'");
// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。
func isCookieExpired(err error) bool {
if err == nil {
return false
}
if errors.Is(err, cpd.ErrCookieExpired) {
return true
}
msg := err.Error()
return strings.Contains(msg, "未登录") ||
strings.Contains(msg, "登录已过期")
}
// isCpdailyAuthError 语义化别名:今日校园登录态失效。
func isCpdailyAuthError(err error) bool {
return isCookieExpired(err)
}
// refreshCpdailyCookie 使用数据库中的 raw_password 重新登录 cpdaily 并更新 cookie
// 密码错误时返回 ErrPasswordChanged
func refreshCpdailyCookie(user *model.SysUser) error {
@@ -141,12 +154,12 @@ func refreshCpdailyCookie(user *model.SysUser) error {
return err
}
// 同步用户信息(异步)
go syncUserProfile(user, cookieStr, school.WecloudUrl)
// 更新传入用户的 cookie(引用),上层重试时使用
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
user.Cookie = cookieStr
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
slog.Info("刷新cookie: 成功", "userId", user.ID)
return nil
}
@@ -185,3 +198,33 @@ func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
user.Cookie = cookieStr
return cookieStr, nil
}
// withCookieRefresh 执行一次今日校园调用;若因登录态失效失败,则尝试续期 cookie 后重试一次。
// - 返回 (调用结果, 续期后的 cookieMap, error)。cookieMap 有变化时调用方可用于后续调用。
// - 续期失败(如 MFA 学校无法自动续期、密码已改)时,返回错误里带上 ErrCookieExpired,
// 由上层转成「登录凭证已过期,请重新登录」引导用户重登。
//
// 说明:MFA 学校登录后签发的是短期 JWT,到期会要求重新登录,本就无需自动续期;
// 普通学校(非 MFA)的 cookie 会在此处用长期凭证/密码自动续期,避免用户被莫名踢出。
func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host string,
fn func(cm map[string]string) error) (map[string]string, error) {
err := fn(cookieMap)
if err == nil || !isCpdailyAuthError(err) {
return cookieMap, err
}
slog.Info("检测到今日校园登录态失效,尝试自动续期 cookie", "userId", user.ID, "host", host)
if refreshErr := refreshCpdailyCookie(user); refreshErr != nil {
// 续期失败:保留原始错误语义,让上层引导重新登录
slog.Warn("cookie 自动续期失败", "userId", user.ID, "error", refreshErr)
return cookieMap, fmt.Errorf("%w: %v", cpd.ErrCookieExpired, refreshErr)
}
// 用续期后的 cookie 重试一次
newMap := cpd.ParseCookieJSON(user.Cookie)
if retryErr := fn(newMap); retryErr != nil {
return newMap, retryErr
}
return newMap, nil
}
+15 -21
View File
@@ -37,31 +37,25 @@ func GetPendingLeaves(c *fiber.Ctx) error {
host := getSchoolHost(user.SchoolID)
// 应用套件分流:新套(leave-new)走独立链路
if schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew {
items, err := fetchLeaveNewPendingList(cookieMap, host, &user)
if err != nil {
slog.Error("获取待销假列表失败(新套)", "userId", userID, "error", err)
if isCookieExpired(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
}
return utils.Success(c, items)
}
isNewFlavor := schoolLeaveFlavor(&user, cookieMap) == cpd.LeaveFlavorNew
items, err := cpd.FetchLeaveList(cookieMap, host)
if err != nil && isCookieExpired(err) {
slog.Info("获取待销假列表cookie过期,尝试刷新", "userId", userID)
if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil {
cookieMap = cpd.ParseCookieJSON(user.Cookie)
items, err = cpd.FetchLeaveList(cookieMap, host)
} else if errors.Is(refreshErr, ErrPasswordChanged) {
err = ErrPasswordChanged
var items []cpd.LeaveItem
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
if isNewFlavor {
// 套件判定用的是会话开始时解析的 cookie;续期后重新判定以保持准确
if schoolLeaveFlavor(&user, cm) == cpd.LeaveFlavorNew {
var e error
items, e = fetchLeaveNewPendingList(cm, host, &user)
return e
}
}
}
var e error
items, e = cpd.FetchLeaveList(cm, host)
return e
})
if err != nil {
slog.Error("获取待销假列表失败", "userId", userID, "error", err)
if errors.Is(err, ErrPasswordChanged) {
if isCpdailyAuthError(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取待销假列表失败: "+err.Error())
+14 -1
View File
@@ -36,7 +36,20 @@ func GetTaskTitles(c *fiber.Ctx) error {
cookieMap := cpd.ParseCookieJSON(user.Cookie)
host := getSchoolHost(user.SchoolID)
tasks := cpd.FetchAllRecentTasks(cookieMap, host)
// 登录态失效时自动续期并重试一次;仍失败则明确提示重新登录
var tasks []cpd.TodayTaskInfo
_, err := withCookieRefresh(&user, cookieMap, host, func(cm map[string]string) error {
var fetchErr error
tasks, fetchErr = cpd.FetchAllRecentTasks(cm, host)
return fetchErr
})
if err != nil {
if isCpdailyAuthError(err) {
return utils.Fail(c, 401, "登录凭证已过期,请重新登录")
}
return utils.Fail(c, 503, "获取打卡任务失败: "+err.Error())
}
return utils.Success(c, tasks)
}
+2 -2
View File
@@ -72,7 +72,7 @@ func GetUploadPolicy(cookieMap map[string]string, host string, taskType TaskType
ErrMsg string `json:"errMsg"`
Datas *CpImageUploadPolicy `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析上传策略失败: %w", err)
}
if result.ErrCode != 0 || result.Datas == nil {
@@ -175,7 +175,7 @@ func GetPreviewUrl(cookieMap map[string]string, host string, taskType TaskType,
Message string `json:"message"`
Datas json.RawMessage `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return "", fmt.Errorf("解析预览 URL 响应失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
+4 -4
View File
@@ -186,7 +186,7 @@ func FetchLeaveList(cookieMap map[string]string, host string) ([]LeaveItem, erro
Rows []map[string]interface{} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析待销假列表失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
@@ -273,7 +273,7 @@ func FetchLeaveDetail(cookieMap map[string]string, host, leaveID string) (*Leave
} `json:"reportPlaces"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
}
if result.ErrCode != 0 {
@@ -342,7 +342,7 @@ func FetchReportApprovers(cookieMap map[string]string, host string, leaveCodeNum
} `json:"approveNodes"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析销假审批人失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
@@ -434,7 +434,7 @@ func SubmitLeaveReport(cookieMap map[string]string, host string, form map[string
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
@@ -42,7 +42,7 @@ func CheckOnceLeave(cookieMap map[string]string, host string) error {
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析请假资格响应失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
@@ -77,7 +77,7 @@ func FetchLeaveTypes(cookieMap map[string]string, host string) ([]LeaveType, err
} `json:"leaveType"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
}
code, _ := result.Code.Int64()
@@ -144,7 +144,7 @@ func FetchLeaveTypeCfg(cookieMap map[string]string, host string, code int) (*Lea
LimitDay interface{} `json:"limitDay"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
}
codeVal, _ := result.Code.Int64()
@@ -332,7 +332,7 @@ func SubmitLeaveApply(cookieMap map[string]string, host string, form map[string]
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()
+16 -16
View File
@@ -126,7 +126,7 @@ type leaveNewResponse struct {
// leaveNewResultErr 解析通用响应:code != "0" 时返回带 message 的错误
func leaveNewResultErr(body []byte, action string) (*leaveNewResponse, error) {
var result leaveNewResponse
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("%s响应解析失败: %w (body: %.200s)", action, err, string(body))
}
if result.Code != "0" {
@@ -184,7 +184,7 @@ func FetchLeaveAppFlavor(cookieMap map[string]string, host string) (LeaveAppFlav
Code string `json:"code"`
Datas []LeaveAppInfo `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil || result.Code != "0" {
if err := unmarshalBody(body, &result); err != nil || result.Code != "0" {
slog.Warn("解析辅导猫应用列表失败", "error", err, "body", string(body))
return LeaveFlavorUnknown, ""
}
@@ -213,7 +213,7 @@ func PingLeaveNew(cookieMap map[string]string, host string) bool {
return false
}
var result leaveNewResponse
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return false
}
return result.Code == "0"
@@ -251,7 +251,7 @@ func FetchLeaveNewTypes(cookieMap map[string]string, host string) ([]LeaveNewTyp
TotalSize interface{} `json:"totalSize"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型失败: %w (body: %.200s)", err, string(body))
}
for _, r := range result.Datas.Rows {
@@ -296,7 +296,7 @@ func FetchLeaveNewTypeDetail(cookieMap map[string]string, host string, typeWid i
TypeApplyDesc string `json:"typeApplyDesc"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假类型配置失败: %w (body: %.200s)", err, string(body))
}
e := result.Datas.ExtendInfoBean
@@ -361,7 +361,7 @@ func FetchLeaveNewApproveFlow(cookieMap map[string]string, host string, typeWid
FlowData map[string]interface{} `json:"flowData"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假审批人失败: %w (body: %.200s)", err, string(body))
}
return &LeaveNewFlow{
@@ -386,7 +386,7 @@ func FetchLeaveNewCcSearch(cookieMap map[string]string, host, searchKey string)
var result struct {
Datas []LeaveNewApprover `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析抄送人失败: %w (body: %.200s)", err, string(body))
}
return result.Datas, nil
@@ -404,7 +404,7 @@ func FetchLeaveNewLastCc(cookieMap map[string]string, host string) ([]LeaveNewAp
var result struct {
Datas []LeaveNewApprover `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析上次抄送人失败: %w (body: %.200s)", err, string(body))
}
return result.Datas, nil
@@ -464,7 +464,7 @@ func FetchLeaveNewList(cookieMap map[string]string, host string, pageNumber, pag
Rows []map[string]interface{} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假记录失败: %w (body: %.200s)", err, string(body))
}
list := make([]LeaveNewItem, 0, len(result.Datas.Rows))
@@ -540,7 +540,7 @@ func FetchLeaveNewDetail(cookieMap map[string]string, host, leaveID string) (*Le
Rows []map[string]interface{} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析请假详情失败: %w (body: %.200s)", err, string(body))
}
if len(result.Datas.Rows) == 0 {
@@ -613,7 +613,7 @@ func FetchLeaveNewReportPlaces(cookieMap map[string]string, host string) ([]Leav
} `json:"rows"`
} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析规定销假位置失败: %w (body: %.200s)", err, string(body))
}
list := make([]LeaveNewReportPlace, 0, len(result.Datas.Rows))
@@ -645,7 +645,7 @@ func FetchLeaveNewReportApprovers(cookieMap map[string]string, host, leaveID str
var result struct {
Datas []LeaveNewApprover `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析销假确认人失败: %w (body: %.200s)", err, string(body))
}
return result.Datas, nil
@@ -745,7 +745,7 @@ func SubmitLeaveNewReport(cookieMap map[string]string, host string, form map[str
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析销假响应失败: %w, body: %s", err, string(body))
}
slog.Info("leave-new/report 响应", "code", result.Code, "message", result.Message, "body", string(body))
@@ -888,7 +888,7 @@ func SubmitLeaveNewApply(cookieMap map[string]string, host string, form map[stri
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析请假响应失败: %w, body: %s", err, string(body))
}
slog.Info("leave-new/apply 响应", "code", result.Code, "message", result.Message, "body", string(body))
@@ -947,7 +947,7 @@ func GetLeaveNewUploadPolicy(cookieMap map[string]string, host string) (*LeaveNe
var result struct {
Datas *LeaveNewUploadPolicy `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析上传策略失败: %w (body: %.200s)", err, string(body))
}
if result.Datas == nil || result.Datas.FileName == "" {
@@ -970,7 +970,7 @@ func GetLeaveNewPreviewURL(cookieMap map[string]string, host, obsKey string) (st
var result struct {
Datas string `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return "", fmt.Errorf("解析预览 URL 失败: %w (body: %.200s)", err, string(body))
}
if result.Datas == "" {
+1 -1
View File
@@ -57,7 +57,7 @@ func fetchFromWecloud(wecloudUrl string, cookieMap map[string]string) (*CpdailyU
MobilePhone string `json:"mobilePhone"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析 wecloud 用户信息失败: %w", err)
}
if result.ErrCode != 0 || result.Data == nil {
@@ -17,6 +17,44 @@ import (
"github.com/go-resty/resty/v2"
)
// ========== 登录态失效的统一识别 ==========
// ErrCookieExpired 表示今日校园的登录态(cookie)已失效:
// 请求被重定向到统一身份认证登录页,返回 HTML 而非接口 JSON。
// handler 层据此触发 cookie 续期(raw_password 重登 或 长期凭证换取),
// 续期失败则引导用户重新登录,而不是把「解析失败」这种模糊错误抛给用户。
var ErrCookieExpired = fmt.Errorf("cpdaily_cookie_expired")
// looksLikeHTML 判断响应体是否为一个 HTML 页面(而非接口 JSON)。
// 登录态失效时,今日校园会把请求 302 到 authserver 登录页,响应体是 HTML。
func looksLikeHTML(body []byte) bool {
head := body
if len(head) > 512 {
head = head[:512]
}
trimmed := strings.TrimSpace(string(head))
if trimmed == "" {
return false
}
if strings.HasPrefix(trimmed, "<") {
return true
}
lower := strings.ToLower(trimmed)
return strings.Contains(lower, "<!doctype html") ||
strings.Contains(lower, "<html")
}
// unmarshalBody 解析接口 JSON 响应体。
// 若响应体是 HTML(登录态失效被重定向到认证页),返回 ErrCookieExpired,
// 以便上层触发 cookie 续期或引导重新登录,而不是抛出难以理解的
// "invalid character '<' looking for beginning of value"。
func unmarshalBody(body []byte, v interface{}) error {
if looksLikeHTML(body) {
return ErrCookieExpired
}
return json.Unmarshal(body, v)
}
// ========== HTTP 客户端 ==========
var httpClient *resty.Client
@@ -97,7 +97,7 @@ func (r *submitCaptchaResolver) GetValidationInfo() (*ValidationInfo, error) {
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析验证信息响应失败: %w", err)
}
@@ -165,7 +165,7 @@ func (r *submitCaptchaResolver) createCaptcha(info *ValidationInfo) (*CpSubmitCa
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return nil, fmt.Errorf("解析创建验证码响应失败: %w", err)
}
@@ -261,7 +261,7 @@ func (r *submitCaptchaResolver) submitCaptcha(info *ValidationInfo, captchaInfo
body, _ := io.ReadAll(resp.RawBody())
var apiResp apiResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
if err := unmarshalBody(body, &apiResp); err != nil {
return "", fmt.Errorf("解析提交验证码响应失败: %w", err)
}
+24 -8
View File
@@ -161,7 +161,7 @@ func fetchTasksByEndpoint(cookieMap map[string]string, host string, taskType Tas
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务列表响应失败: %w", err)
}
if result.ErrCode != 0 {
@@ -241,8 +241,10 @@ func parseTasksFromDatas(datas map[string]interface{}) []TodayTaskInfo {
return tasks
}
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回
func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskInfo {
// FetchAllRecentTasks 并发查询 sign + sleep_check 两种任务类型,聚合去重后返回。
// 若两类查询都失败则返回错误(供上层识别登录态失效并续期重试);
// 只要有一类成功就返回结果(另一类失败仅记录日志)。
func FetchAllRecentTasks(cookieMap map[string]string, host string) ([]TodayTaskInfo, error) {
type result struct {
tasks []TodayTaskInfo
err error
@@ -267,9 +269,18 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
seen := map[string]bool{}
var all []TodayTaskInfo
var firstErr error
successCount := 0
for i := 0; i < 2; i++ {
r := <-ch
if r.err != nil {
if firstErr == nil {
firstErr = r.err
}
} else {
successCount++
}
for _, t := range r.tasks {
key := t.TaskName + "|" + t.TaskType
if !seen[key] {
@@ -279,10 +290,15 @@ func FetchAllRecentTasks(cookieMap map[string]string, host string) []TodayTaskIn
}
}
if len(all) == 0 {
return []TodayTaskInfo{}
// 两类都失败:返回错误(cookie 失效等场景由上层续期重试)
if successCount == 0 && firstErr != nil {
return nil, firstErr
}
return all
if len(all) == 0 {
return []TodayTaskInfo{}, nil
}
return all, nil
}
func fetchTasksByYearMonth(cookieMap map[string]string, host string, taskType TaskType, year, month string) ([]TodayTaskInfo, error) {
yearMonth := fmt.Sprintf("%s-%s", year, month)
@@ -439,7 +455,7 @@ func fetchTaskDetailByWid(cookieMap map[string]string, host string, taskType Tas
ErrMsg string `json:"errMsg"`
Datas map[string]interface{} `json:"datas"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return nil, fmt.Errorf("解析任务详情响应失败: %w", err)
}
if result.ErrCode != 0 {
@@ -1429,7 +1445,7 @@ func SubmitSign(cookieMap map[string]string, host string, taskType TaskType, sub
Code json.Number `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &result); err != nil {
if err := unmarshalBody(body, &result); err != nil {
return fmt.Errorf("解析签到响应失败: %w, body: %s", err, string(body))
}
code, _ := result.Code.Int64()