07cf121aac
现象(用户反馈): 获取签到表单失败: 获取当日任务列表失败: 解析任务列表响应失败: 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 不触发重登。
283 lines
11 KiB
Go
283 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
|
||
"cpdaily-new/internal/database"
|
||
"cpdaily-new/internal/model"
|
||
cpd "cpdaily-new/internal/service/cpdaily"
|
||
)
|
||
|
||
// ErrPasswordChanged 当 cpdaily 密码错误时返回此错误
|
||
var ErrPasswordChanged = errors.New("cpdaily_password_error")
|
||
|
||
// ErrNeedReLogin 当登录态失效且无法自动续期(如手机号登录无密码无法自动重登)、
|
||
// 需要用户手动重新登录时返回此错误。与 ErrPasswordChanged 一起作为前端「重新登录」引导的信号。
|
||
var ErrNeedReLogin = errors.New("cpdaily_need_relogin")
|
||
|
||
// needReLoginSignal 判定错误是否需要引导用户重新登录。
|
||
// 只要登录凭证已过期且无法自动续期(密码错误 或 无密码无法自动重登等),都返回 true。
|
||
func needReLoginSignal(err error) bool {
|
||
return errors.Is(err, ErrPasswordChanged) || errors.Is(err, ErrNeedReLogin)
|
||
}
|
||
|
||
// wrapNeedReLogin 把「cookie 过期后自动续期失败」的错误升级为「需要重新登录」信号:
|
||
// 密码错误保持 ErrPasswordChanged 原语义;其余失败(无密码、MFA、学校接口异常等)
|
||
// 统一包装为 ErrNeedReLogin,让上层能识别并引导用户重新登录。
|
||
func wrapNeedReLogin(e error) error {
|
||
if errors.Is(e, ErrPasswordChanged) {
|
||
return ErrPasswordChanged
|
||
}
|
||
return fmt.Errorf("%w: %v", ErrNeedReLogin, e)
|
||
}
|
||
|
||
// encryptRawPassword AES 加密明文密码并 base64 编码
|
||
func encryptRawPassword(rawPassword string) (string, error) {
|
||
encrypted := cpd.AesEncrypt([]byte(rawPassword))
|
||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||
}
|
||
|
||
// decryptRawPassword base64 解码后 AES 解密
|
||
func decryptRawPassword(encrypted string) (string, error) {
|
||
data, err := base64.StdEncoding.DecodeString(encrypted)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
decrypted := cpd.AesDecrypt(data)
|
||
return string(decrypted), nil
|
||
}
|
||
|
||
// saveRawPassword 将明文密码 AES 加密后存入数据库
|
||
func saveRawPassword(userID uint, rawPassword string) error {
|
||
encrypted, err := encryptRawPassword(rawPassword)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return database.DB.Model(&model.SysUser{}).Where("id = ?", userID).
|
||
Update("raw_password", encrypted).Error
|
||
}
|
||
|
||
// updateRawPassword 更新用户的 raw_password(密码可能已修改)
|
||
func updateRawPassword(userID uint, rawPassword string) error {
|
||
return saveRawPassword(userID, rawPassword)
|
||
}
|
||
|
||
// 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 {
|
||
// 重新从数据库加载用户(获取最新 raw_password 与 cookie)
|
||
var fresh model.SysUser
|
||
if err := database.DB.First(&fresh, user.ID).Error; err != nil {
|
||
slog.Error("刷新cookie: 查询用户失败", "userId", user.ID, "error", err)
|
||
return err
|
||
}
|
||
|
||
// 优先尝试用长期 cookie(sessionToken/CASTGC)直接换取新的 CAS cookie。
|
||
// 手机号验证码登录的账号没有 raw_password,只能走此路径自动续期。
|
||
if refreshed, err := refreshCookieByLongTermToken(&fresh); err == nil && refreshed != "" {
|
||
if err := database.DB.Model(&fresh).Update("cookie", refreshed).Error; err != nil {
|
||
slog.Warn("刷新cookie: 写回长期凭证续期结果失败", "userId", user.ID, "error", err)
|
||
} else {
|
||
slog.Info("刷新cookie: 已用长期凭证续期成功", "userId", user.ID)
|
||
return nil
|
||
}
|
||
} else if err != nil {
|
||
slog.Info("刷新cookie: 长期凭证续期未成功,回退密码重登", "userId", user.ID, "error", err)
|
||
}
|
||
|
||
if fresh.RawPassword == "" {
|
||
slog.Warn("刷新cookie: 未保存密码", "userId", user.ID)
|
||
return fmt.Errorf("%w: 未保存登录密码,无法自动重新登录", ErrNeedReLogin)
|
||
}
|
||
|
||
// 解密密码
|
||
rawPassword, err := decryptRawPassword(fresh.RawPassword)
|
||
if err != nil {
|
||
slog.Error("刷新cookie: 解密密码失败", "userId", user.ID, "error", err)
|
||
return err
|
||
}
|
||
|
||
// 获取学校详情
|
||
school, err := cpd.GetSchoolDetail(fresh.SchoolID)
|
||
if err != nil {
|
||
slog.Error("刷新cookie: 获取学校详情失败", "userId", user.ID, "schoolId", fresh.SchoolID, "error", err)
|
||
return err
|
||
}
|
||
|
||
slog.Info("刷新cookie: 尝试重新登录cpdaily", "userId", user.ID, "school", school.Name)
|
||
|
||
// 执行 cpdaily 登录
|
||
cookies, mfaCtx, err := cpd.CpdailyLogin(context.Background(), school, fresh.StudentID, rawPassword)
|
||
if err != nil && !errors.Is(err, cpd.ErrMFARequired) {
|
||
errMsg := err.Error()
|
||
slog.Warn("刷新cookie: cpdaily登录失败", "userId", user.ID, "error", errMsg)
|
||
|
||
// 检测密码错误
|
||
if strings.Contains(errMsg, "账号或密码错误") ||
|
||
strings.Contains(errMsg, "密码错误") ||
|
||
strings.Contains(errMsg, "密码不匹配") ||
|
||
strings.Contains(errMsg, "账号密码") {
|
||
return ErrPasswordChanged
|
||
}
|
||
return err
|
||
}
|
||
// 多因子认证学校:重新登录需人工输入验证码,无法自动续期
|
||
if errors.Is(err, cpd.ErrMFARequired) || mfaCtx != nil {
|
||
slog.Warn("刷新cookie: 该账号登录需多因子认证,无法自动续期", "userId", user.ID, "school", school.Name)
|
||
return fmt.Errorf("%w: 该账号所属学校开启登录验证码校验,需重新登录完成验证", ErrNeedReLogin)
|
||
}
|
||
|
||
// 检查 MOD_ cookie 确保登录成功
|
||
hasMOD := false
|
||
for _, cookie := range cookies {
|
||
if strings.HasPrefix(cookie.Name, "MOD_") {
|
||
hasMOD = true
|
||
break
|
||
}
|
||
}
|
||
if !hasMOD {
|
||
slog.Warn("刷新cookie: 未获取到MOD_cookie", "userId", user.ID)
|
||
return ErrPasswordChanged
|
||
}
|
||
|
||
// 序列化并更新 cookie
|
||
cookieStr := cpd.SerializeCookies(cookies)
|
||
if err := database.DB.Model(&fresh).Update("cookie", cookieStr).Error; err != nil {
|
||
slog.Error("刷新cookie: 更新数据库失败", "userId", user.ID, "error", err)
|
||
return err
|
||
}
|
||
|
||
// 先更新传入用户的 cookie(引用),上层重试与后续同步均使用最新值
|
||
user.Cookie = cookieStr
|
||
|
||
// 同步用户信息(异步,禁续期:避免「续期 → 同步 → 再续期」递归)
|
||
go syncUserProfileWith(user, cookieStr, school.WecloudUrl, false)
|
||
|
||
slog.Info("刷新cookie: 成功", "userId", user.ID)
|
||
return nil
|
||
}
|
||
|
||
// refreshCookieByLongTermToken 用已保存的长期 cookie(sessionToken/CASTGC)重新换取 CAS cookie。
|
||
// 适用于手机号验证码登录(无 raw_password)的账号自动续期;失败时返回错误,由调用方回退。
|
||
func refreshCookieByLongTermToken(user *model.SysUser) (string, error) {
|
||
cookieMap := cpd.ParseCookieJSON(user.Cookie)
|
||
if cookieMap["sessionToken"] == "" {
|
||
return "", errors.New("缺少长期凭证 sessionToken")
|
||
}
|
||
|
||
school, err := cpd.GetSchoolDetail(user.SchoolID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
cookies, err := cpd.RefreshCasCookie(context.Background(), cookieMap, school.WecloudUrl, user.MobileCode)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
hasMOD := false
|
||
for _, c := range cookies {
|
||
if strings.HasPrefix(c.Name, "MOD_") {
|
||
hasMOD = true
|
||
break
|
||
}
|
||
}
|
||
if !hasMOD {
|
||
return "", errors.New("续期未获取到 MOD_ cookie")
|
||
}
|
||
|
||
cookieStr := cpd.SerializeCookies(cookies)
|
||
// 回写传入用户,供上层重试使用
|
||
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 {
|
||
// 续期成功但重试仍判登录态失效:说明拿到的凭证依然无效
|
||
// (长期凭证过期、学校侧强制下线等),等同于需要用户重新登录。
|
||
// 若不在这里升级,原始 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
|
||
}
|