修 cookie 自动续期后仍失效时把内部错误码暴露给用户

现象(用户反馈):
  获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败: cpdaily_cookie_expired

期望:能自动续期就续期;续不了就明确引导重新登录(前端据此跳登录页)。
实际:既没提示重新登录,还把内部错误码 cpdaily_cookie_expired 直接显示出来。

根因:自动续期成功、但用新 cookie 重试仍判登录态失效时,
重新拿到的错误(仍是 ErrCookieExpired)被原样返回。
而 needReLoginSignal() 只认 ErrPasswordChanged / ErrNeedReLogin,
不认识 ErrCookieExpired,于是:
  - errMsg 保持 "前缀 + cpdaily_cookie_expired";
  - needReLogin=false,前端不会引导重新登录。

这属于「续期拿到了凭证,但凭证其实无效」的情形(长期凭证过期、
学校侧强制下线等),语义上等同于必须重新登录。

改动:
1. withCookieRefresh:续期后重试仍报登录态失效 → 升级为 ErrNeedReLogin,
   这是所有 withCookieRefresh 调用方的统一入口,一处修全覆盖;
2. 签到执行路径:续期后重试仍过期同样升级,并加注释说明;
3. 新增 userFacingError(prefix, err):输出前统一兜底,
   任何残留的登录态失效语义都转成「登录凭证已过期,请重新登录」,
   普通错误保留原文。即使某条链路漏做升级,也不会再泄露内部错误码;
4. 「获取签到表单」与「签到提交」两处失败文案改用 userFacingError,
   needReLogin 一并由它给出(前端据此跳登录页)。

测试:原始 ErrCookieExpired、多层 %w 包装、ErrNeedReLogin、
JSON 文本「未登录」均转成引导重登文案且不含内部错误码;
普通错误原样保留;nil 不触发重登。
This commit is contained in:
becomer
2026-10-18 16:36:31 +08:00
parent 8b647fc31a
commit 07cf121aac
3 changed files with 140 additions and 13 deletions
+35 -3
View File
@@ -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
}
@@ -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)
}
}
+14 -10
View File
@@ -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
}