diff --git a/backed/internal/handler/auth_helper.go b/backed/internal/handler/auth_helper.go index 023e648..a097871 100644 --- a/backed/internal/handler/auth_helper.go +++ b/backed/internal/handler/auth_helper.go @@ -69,9 +69,9 @@ func updateRawPassword(userID uint, rawPassword string) error { // isCookieExpired 判断错误是否由 cpdaily 登录态失效(cookie 过期)引起。 // 两种来源: -// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页), -// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'"); -// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。 +// 1. cpd.ErrCookieExpired:响应体是 HTML(被重定向到统一身份认证登录页), +// 这是最常见、也最容易被漏判的情形(原文案匹配不到 "invalid character '<'"); +// 2. 接口以 JSON 形式返回的「未登录 / 登录已过期」。 func isCookieExpired(err error) bool { if err == nil { return false @@ -244,7 +244,39 @@ func withCookieRefresh(user *model.SysUser, cookieMap map[string]string, host st // 用续期后的 cookie 重试一次 newMap := cpd.ParseCookieJSON(user.Cookie) if retryErr := fn(newMap); retryErr != nil { + // 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效 + // (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。 + // 若不在这里升级,原始 ErrCookieExpired 会一路透传, + // 最终在界面上显示 cpdaily_cookie_expired 这种内部错误码。 + if isCpdailyAuthError(retryErr) { + slog.Warn("续期后重试仍提示登录态失效,判定需重新登录", + "userId", user.ID, "host", host, "error", retryErr) + return newMap, fmt.Errorf("%w: %v", ErrNeedReLogin, retryErr) + } return newMap, retryErr } return newMap, nil } + +// userFacingError 把内部错误转成可直接展示给用户的文案。 +// +// 背景:ErrCookieExpired 的文本是 `cpdaily_cookie_expired`, +// Go 的 `%w` 包装会把它拼进错误链外层(如 +// "获取签到表单失败: cpdaily_cookie_expired"), +// 最终原样出现在界面上,用户完全看不懂。 +// +// 统一在输出前做一次兜底:任何残留的登录态失效语义都转成 +// 「登录凭证已过期,请重新登录」,并保留原始前缀便于定位。 +// 这样即使某条调用链漏做了 wrapNeedReLogin,也不会把内部错误码暴露出去。 +func userFacingError(prefix string, err error) (msg string, needReLogin bool) { + if err == nil { + return prefix, false + } + if needReLoginSignal(err) || isCookieExpired(err) { + if prefix == "" { + return "登录凭证已过期,请重新登录", true + } + return prefix + "登录凭证已过期,请重新登录", true + } + return prefix + err.Error(), false +} diff --git a/backed/internal/handler/auth_helper_expired_test.go b/backed/internal/handler/auth_helper_expired_test.go new file mode 100644 index 0000000..bc36906 --- /dev/null +++ b/backed/internal/handler/auth_helper_expired_test.go @@ -0,0 +1,91 @@ +package handler + +import ( + "errors" + "fmt" + "strings" + "testing" + + cpd "cpdaily-new/internal/service/cpdaily" +) + +// userFacingError:保证内部错误码 cpdaily_cookie_expired 不会原样展示给用户。 +// +// 线上问题:用户拿到 "获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败: +// cpdaily_cookie_expired",既看不懂也没引导重新登录。 +func TestUserFacingErrorHidesInternalCode(t *testing.T) { + cases := []struct { + name string + prefix string + err error + wantContains string + wantReLogin bool + wantNotSecret string + }{ + { + name: "原始 ErrCookieExpired", + prefix: "获取签到表单失败: ", + err: cpd.ErrCookieExpired, + wantContains: "登录凭证已过期,请重新登录", + wantReLogin: true, + wantNotSecret: "cpdaily_cookie_expired", + }, + { + name: "多层包装后仍能识别", + prefix: "获取签到表单失败: ", + err: fmt.Errorf("获取当日任务列表失败: %w", fmt.Errorf("解析任务列表响应失败: %w", cpd.ErrCookieExpired)), + wantContains: "登录凭证已过期,请重新登录", + wantReLogin: true, + wantNotSecret: "cpdaily_cookie_expired", + }, + { + name: "ErrNeedReLogin", + prefix: "签到失败(晚查寝): ", + err: fmt.Errorf("%w: 续期失败", ErrNeedReLogin), + wantContains: "登录凭证已过期,请重新登录", + wantReLogin: true, + wantNotSecret: "cpdaily_cookie_expired", + }, + { + name: "JSON 形式未登录", + prefix: "获取签到表单失败: ", + err: errors.New("接口返回: 未登录"), + wantContains: "登录凭证已过期,请重新登录", + wantReLogin: true, + wantNotSecret: "", + }, + { + name: "普通错误原样保留", + prefix: "获取签到表单失败: ", + err: errors.New("网络超时"), + wantContains: "网络超时", + wantReLogin: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + msg, needLogin := userFacingError(c.prefix, c.err) + if needLogin != c.wantReLogin { + t.Fatalf("needReLogin=%v, want %v", needLogin, c.wantReLogin) + } + if !strings.Contains(msg, c.wantContains) { + t.Fatalf("文案 %q 应包含 %q", msg, c.wantContains) + } + if c.wantNotSecret != "" && strings.Contains(msg, c.wantNotSecret) { + t.Fatalf("文案 %q 不应包含内部错误码 %q", msg, c.wantNotSecret) + } + }) + } +} + +// nil 错误不应触发重新登录。 +func TestUserFacingErrorNil(t *testing.T) { + msg, need := userFacingError("前缀: ", nil) + if need { + t.Fatal("nil 不应要求重新登录") + } + if msg != "前缀: " { + t.Fatalf("应原样返回前缀,实际 %q", msg) + } +} diff --git a/backed/internal/handler/task.go b/backed/internal/handler/task.go index 4d46e14..248172b 100644 --- a/backed/internal/handler/task.go +++ b/backed/internal/handler/task.go @@ -667,22 +667,27 @@ func executeSignTask(c *fiber.Ctx) error { if refreshErr := refreshCpdailyCookie(&user); refreshErr == nil { cookieMap = cpd.ParseCookieJSON(user.Cookie) detail, err = cpd.FetchTodayTaskDetail(cookieMap, host, cpd.TaskType(task.TaskType), task.Title) + // 续期成功但仍过期:不能让原始 ErrCookieExpired 透传到用户面前 + // (否则界面显示 cpdaily_cookie_expired 这种内部错误码)。 + // 说明自动续期拿到的凭证依然无效,等同于需要用户重新登录。 + if err != nil && isCookieExpired(err) { + slog.Warn("自动续期后仍提示登录态失效,判定需重新登录", "taskId", id, "error", err) + err = wrapNeedReLogin(err) + } } else { err = wrapNeedReLogin(refreshErr) } } if err != nil { - errMsg := "获取签到表单失败: " + err.Error() - if needReLoginSignal(err) { - errMsg = "登录凭证已过期,请重新登录" - } + // 统一兜底:避免 cpdaily_cookie_expired 等内部错误码直接展示给用户 + errMsg, needLoginNow := userFacingError("获取签到表单失败: ", err) slog.Error("获取表单失败", "taskId", id, "error", err) database.DB.Model(&task).Updates(map[string]interface{}{ "task_status": "fail", "error_message": errMsg, }) saveLog(false, errMsg, taskTitle(task)) - sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)}) + sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow}) return } @@ -765,17 +770,16 @@ func executeSignTask(c *fiber.Ctx) error { } } if err != nil { - errMsg := fmt.Sprintf("签到失败(%s): %s", taskTitle(task), err.Error()) - if needReLoginSignal(err) { - errMsg = "登录凭证已过期,请重新登录" - } + // 同上一处:签到失败也要避免内部错误码外泄 + errMsg, needLoginNow := userFacingError( + fmt.Sprintf("签到失败(%s): ", taskTitle(task)), err) slog.Error("签到失败", "taskId", id, "error", err) database.DB.Model(&task).Updates(map[string]interface{}{ "task_status": "fail", "error_message": errMsg, }) saveLog(false, errMsg, taskTitle(task)) - sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needReLoginSignal(err)}) + sendEvent("task_error", map[string]interface{}{"status": "error", "message": errMsg, "needReLogin": needLoginNow}) return }