From 83ee4bb5eae0cb188eb66dad31ce975a3fae8b28 Mon Sep 17 00:00:00 2001 From: zhilv Date: Fri, 3 Apr 2026 14:24:29 +0800 Subject: [PATCH] release: v0.1.3 --- .gitignore | 1 + README.md | 10 +- internal/ckwk/api.go | 236 ++++++++++++++++++++++---- internal/ckwk/resp.go | 13 +- internal/ckwk/session_manager.go | 2 + internal/ckwk/types.go | 3 +- internal/conf/var.go | 18 +- internal/handler/ckwk.go | 32 ++-- internal/handler/debug_log.go | 44 +++++ internal/handler/version.go | 15 +- internal/middleware/debug_http_log.go | 18 +- internal/router/router.go | 22 ++- pkg/request/client.go | 129 ++++++++------ web/frontend | 2 +- 14 files changed, 413 insertions(+), 132 deletions(-) diff --git a/.gitignore b/.gitignore index ba077a4..4a9d43c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ bin +.gocache diff --git a/README.md b/README.md index afde4fb..1f84402 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,20 @@ git status **推荐使用 [Taskfile](https://taskfile.dev/) 进行项目构建** -调试模式下可通过环境变量开启本地代理/跳过 SSL 校验: +可通过环境变量配置本地代理 / 跳过 SSL 校验,但只有在设置页手动开启调试后才会生效: ```shell CKWK_DEBUG_PROXY=http://127.0.0.1:9000 CKWK_DEBUG_SKIP_SSL_VERIFY=true ``` -`release` 模式下会自动忽略这两个调试开关。 +也可以通过环境变量让后端启动时默认开启调试: -调试日志 WS 仅在 `debug` 模式开启,连接地址: +```shell +CKWK_DEBUG_ENABLED=true +``` + +调试日志 WS 在后端调试已开启时可用,连接地址: ```shell ws://127.0.0.1:8080/api/debug/logs/ws diff --git a/internal/ckwk/api.go b/internal/ckwk/api.go index c803a5a..55ebdb6 100644 --- a/internal/ckwk/api.go +++ b/internal/ckwk/api.go @@ -7,17 +7,27 @@ import ( "ckwk/pkg/request" "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "regexp" "strconv" "strings" + "sync" "github.com/antchfx/htmlquery" "go.uber.org/zap" "resty.dev/v3" ) +var ( + ErrLoginTimeout = errors.New("登录超时,请重新登录") + ErrSessionRemoved = errors.New("session_manager 已删除失效会话") + ErrReloginSkipped = errors.New("当前会话缺少账号密码,无法自动重新登录") +) + +const maxAutoReloginRetries = 3 + type WK struct { Username string `json:"username"` Password string `json:"password"` @@ -29,6 +39,10 @@ type WK struct { LoginRegexp *regexp.Regexp CourseIDRegexp *regexp.Regexp TimeRegexp *regexp.Regexp + + authMu sync.Mutex + sessionID string + sessionManager *SessionManager } func NewWK(username, password, host string, cookies []*http.Cookie) *WK { @@ -40,9 +54,9 @@ func NewWK(username, password, host string, cookies []*http.Cookie) *WK { reqCfg := &request.Config{ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0", VerifySSL: true, - Debug: conf.IsDebugMode(), + Debug: conf.IsRuntimeDebugEnabled(), } - if conf.IsDebugMode() { + if conf.IsRuntimeDebugEnabled() { reqCfg.Proxy = conf.DebugProxy reqCfg.VerifySSL = !conf.DebugSkipSSLVerify } @@ -63,13 +77,41 @@ func NewWK(username, password, host string, cookies []*http.Cookie) *WK { CourseIDRegexp: regexp.MustCompile(`\?courseId=(\d+)`), TimeRegexp: regexp.MustCompile(`\d{4}-\d{2}-\d{2}`), } - if len(cookies) == 0 && username != "" { - wk.Login() - } return wk } +func (wk *WK) bindSession(sm *SessionManager, sessionID string) { + wk.sessionManager = sm + wk.sessionID = sessionID +} + +func (wk *WK) prepareRequestClient() { + if wk == nil || wk.Req == nil { + return + } + + cfg := &request.Config{ + UserAgent: request.DefaultUserAgent, + VerifySSL: true, + Debug: conf.IsRuntimeDebugEnabled(), + } + if conf.IsRuntimeDebugEnabled() { + cfg.Proxy = conf.DebugProxy + cfg.VerifySSL = !conf.DebugSkipSSLVerify + } + + request.ApplyConfig(wk.Req, cfg) + if len(wk.Cookies) > 0 { + wk.Req.SetCookies(wk.Cookies) + } +} + +func (wk *WK) newRequest() *resty.Request { + wk.prepareRequestClient() + return wk.Req.R() +} + // Cookies: returns cookies func (wk *WK) Cookie() []*http.Cookie { return wk.Cookies @@ -83,8 +125,7 @@ func (wk *WK) SetCookies(cs []*http.Cookie) { // Code: Get Verify Code func (wk *WK) Code() (string, error) { - resp, err := wk.Req. - R(). + resp, err := wk.newRequest(). SetQueryParam("r", fmt.Sprint(common.RandFloat64())). Get(fmt.Sprintf("https://%s/service/code", wk.Host)) @@ -96,8 +137,7 @@ func (wk *WK) Code() (string, error) { return "", fmt.Errorf("获取验证码失败: code: %d", resp.StatusCode()) } var result CodeResp - _, err = wk.Req. - R(). + _, err = wk.newRequest(). SetFormData(map[string]string{ "image": base64.StdEncoding.EncodeToString(resp.Bytes()), "probability": "false", @@ -128,8 +168,7 @@ func (wk *WK) Login() (bool, error) { return false, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。") } - resp, err := wk.Req. - R(). + resp, err := wk.newRequest(). SetFormData(map[string]string{ "username": wk.Username, "password": wk.Password, @@ -141,25 +180,50 @@ func (wk *WK) Login() (bool, error) { if err != nil { return false, fmt.Errorf("请求登录失败: %w", err) } - matchs := wk.LoginRegexp.FindStringSubmatch(string(resp.Bytes())) - if len(matchs) <= 1 { - return false, fmt.Errorf("没有找到匹配字符串") - } - - var result LoginResp - err = json.Unmarshal([]byte(matchs[1]), &result) + result, err := wk.parseLoginResp(resp.Bytes()) if err != nil { - return false, fmt.Errorf("解析 json data 失败: %w", err) + return false, err } if !result.Status { - return false, fmt.Errorf("登录失败: %s", result.Msg) + return false, fmt.Errorf("登录失败: %s", loginErrorMessage(result)) } wk.SetCookies(resp.Cookies()) - log.Info("登录成功", zap.Any("cookies", wk.Cookies)) + log.Info("登录成功", zap.Any("cookies", wk.Req.Cookies())) return true, nil } +func (wk *WK) parseLoginResp(body []byte) (LoginResp, error) { + var result LoginResp + if len(body) == 0 { + return result, fmt.Errorf("登录响应为空") + } + + matchs := wk.LoginRegexp.FindStringSubmatch(string(body)) + if len(matchs) > 1 { + if err := json.Unmarshal([]byte(strings.TrimSpace(matchs[1])), &result); err != nil { + return result, fmt.Errorf("解析登录响应失败: %w", err) + } + return result, nil + } + + if err := json.Unmarshal(body, &result); err != nil { + return result, fmt.Errorf("解析登录响应失败: %w", err) + } + + return result, nil +} + +func loginErrorMessage(result LoginResp) string { + if msg := strings.TrimSpace(result.FormError.Code); msg != "" { + return msg + } + if msg := strings.TrimSpace(result.Msg); msg != "" { + return msg + } + return "未知错误" +} + // CourseParse: 课程解析 func (wk *WK) CourseParse(content string) ([]Course, error) { courses := make([]Course, 0) @@ -237,8 +301,7 @@ func (wk *WK) CourseParse(content string) ([]Course, error) { // CourseGet: 课程获取 func (wk *WK) CourseGet(kind CourseKind) ([]Course, error) { var courses []Course - resp, err := wk.Req. - R(). + resp, err := wk.newRequest(). SetQueryParam("kind", string(kind)). Get(fmt.Sprintf("https://%s/user/index", wk.Host)) if err != nil { @@ -292,8 +355,7 @@ func (wk *WK) UserInfoParse(content string) (User, error) { // UserGet: 用户信息获取 func (wk *WK) UserInfoGet() (User, error) { var user User - resp, err := wk.Req. - R(). + resp, err := wk.newRequest(). Get(fmt.Sprintf("https://%s/user/member", wk.Host)) if err != nil { return user, fmt.Errorf("获取用户信息页面失败: %w", err) @@ -309,9 +371,8 @@ func (wk *WK) UserInfoGet() (User, error) { } // Online: 保持账号状态 -func (wk *WK) Online() (bool, error) { - resp, err := wk.Req. - R(). +func (wk *WK) performOnline() (bool, error) { + resp, err := wk.newRequest(). SetHeaders(map[string]string{ "x-requested-with": "XMLHttpRequest", "Accept": "application/json, text/javascript, */*; q=0.01", @@ -321,12 +382,19 @@ func (wk *WK) Online() (bool, error) { if err != nil { return false, fmt.Errorf("保持账号状态失败: %w", err) } + if isLoginTimeoutBody(resp.Bytes()) { + return false, fmt.Errorf("保持账号状态失败: %w", ErrLoginTimeout) + } log.Info("保持账号状态", zap.Any("resp", string(resp.Bytes()))) return true, nil } +func (wk *WK) Online() (bool, error) { + return withAutoRelogin(wk, "保持账号状态", wk.performOnline) +} + // Study: 学习 -func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*StudyResp, error) { +func (wk *WK) performStudy(nodeID, studyID, studyTime string, status StudyStatus) (*StudyResp, error) { var data map[string]string switch status { case StudyStart: @@ -363,8 +431,7 @@ func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*Stu default: return nil, fmt.Errorf("传入的学习状态不匹配") } - resp, err := wk.Req. - R(). + resp, err := wk.newRequest(). SetHeaders(map[string]string{ "x-requested-with": "XMLHttpRequest", "Accept": "application/json, text/javascript, */*; q=0.01", @@ -376,6 +443,9 @@ func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*Stu if err != nil { return nil, fmt.Errorf("进行学习失败: %w", err) } + if isLoginTimeoutBody(resp.Bytes()) { + return nil, fmt.Errorf("进行学习失败: %w", ErrLoginTimeout) + } var result StudyResp if err := json.Unmarshal(resp.Bytes(), &result); err != nil { @@ -386,6 +456,12 @@ func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*Stu return &result, nil } +func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*StudyResp, error) { + return withAutoRelogin(wk, "进行学习", func() (*StudyResp, error) { + return wk.performStudy(nodeID, studyID, studyTime, status) + }) +} + // GetStudyList: 获取学习记录 func (wk *WK) GetStudyList(courseID, page string) (*AllRecordResp[StudyList], error) { return GetRecords[StudyList](wk, RecordStudy, courseID, page) @@ -407,3 +483,101 @@ func (wk *WK) GetExamList(courseID, page string) (*AllRecordResp[ExamList], erro func (wk *WK) GetDiscussList(courseID, page string) (*AllRecordResp[ExamList], error) { return GetRecords[ExamList](wk, RecordStudy, courseID, page) } + +func isLoginTimeoutBody(body []byte) bool { + if len(body) == 0 { + return false + } + + var payload struct { + Offline int `json:"offline"` + Status bool `json:"status"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return false + } + + return payload.Offline == 1 && !payload.Status && strings.Contains(payload.Msg, "登录超时") +} + +func (wk *WK) reloginForExpiredSession(action string, attempt int) error { + wk.authMu.Lock() + defer wk.authMu.Unlock() + + if strings.TrimSpace(wk.Username) == "" || strings.TrimSpace(wk.Password) == "" { + return fmt.Errorf("%w: 检测到 %s 登录超时", ErrReloginSkipped, action) + } + + log.Warn("检测到登录超时,开始自动重新登录", + zap.String("action", action), + zap.String("host", wk.Host), + zap.String("username", wk.Username), + zap.Int("attempt", attempt), + zap.Int("maxAttempts", maxAutoReloginRetries), + ) + + ok, err := wk.Login() + if err != nil { + return fmt.Errorf("第 %d 次自动重新登录失败: %w", attempt, err) + } + if !ok { + return fmt.Errorf("第 %d 次自动重新登录失败", attempt) + } + + log.Info("自动重新登录成功", + zap.String("action", action), + zap.String("host", wk.Host), + zap.String("username", wk.Username), + zap.Int("attempt", attempt), + ) + return nil +} + +func (wk *WK) removeBoundSession(action string, cause error) { + if wk == nil || wk.sessionManager == nil || wk.sessionID == "" { + return + } + + log.Warn("自动重新登录失败,删除失效会话", + zap.String("action", action), + zap.String("session_id", wk.sessionID), + zap.String("host", wk.Host), + zap.String("username", wk.Username), + zap.Error(cause), + ) + wk.sessionManager.Del(wk.sessionID) +} + +func withAutoRelogin[T any](wk *WK, action string, fn func() (T, error)) (T, error) { + result, err := fn() + if !errors.Is(err, ErrLoginTimeout) { + return result, err + } + + lastErr := err + for attempt := 1; attempt <= maxAutoReloginRetries; attempt++ { + reloginErr := wk.reloginForExpiredSession(action, attempt) + if reloginErr != nil { + lastErr = reloginErr + if errors.Is(reloginErr, ErrReloginSkipped) { + break + } + continue + } + + result, err = fn() + if !errors.Is(err, ErrLoginTimeout) { + return result, err + } + lastErr = err + } + + wk.removeBoundSession(action, lastErr) + + var zero T + if lastErr == nil { + lastErr = ErrLoginTimeout + } + return zero, fmt.Errorf("%w: %s 自动重新登录 %d 次后仍未恢复: %v", ErrSessionRemoved, action, maxAutoReloginRetries, lastErr) +} diff --git a/internal/ckwk/resp.go b/internal/ckwk/resp.go index c2cfe0e..c8c4224 100644 --- a/internal/ckwk/resp.go +++ b/internal/ckwk/resp.go @@ -7,12 +7,17 @@ type CodeResp struct { Data string `json:"data"` } +type LoginFormError struct { + Code string `json:"code"` +} + // 登录响应 type LoginResp struct { - RefreshCode int `json:"refresh_code"` - Status bool `json:"status"` - Msg string `json:"msg"` - Back string `json:"back"` + RefreshCode int `json:"refresh_code"` + Status bool `json:"status"` + Msg string `json:"msg"` + Back string `json:"back"` + FormError LoginFormError `json:"formError"` } type StudyResp struct { diff --git a/internal/ckwk/session_manager.go b/internal/ckwk/session_manager.go index 6defb31..9198747 100644 --- a/internal/ckwk/session_manager.go +++ b/internal/ckwk/session_manager.go @@ -43,6 +43,7 @@ func (m *SessionManager) Store(wk *WK) string { ctx, cancel := context.WithCancel(context.Background()) item.LastValue = time.Now() + wk.bindSession(m, oldID) item.Instance = wk item.cancel = cancel m.sessions[oldID] = item @@ -59,6 +60,7 @@ func (m *SessionManager) Store(wk *WK) string { sessionID := uuid.New().String() ctx, cancel := context.WithCancel(context.Background()) + wk.bindSession(m, sessionID) m.userToSession[userKey] = sessionID m.sessions[sessionID] = SessionItem{ diff --git a/internal/ckwk/types.go b/internal/ckwk/types.go index b3efa92..5018b72 100644 --- a/internal/ckwk/types.go +++ b/internal/ckwk/types.go @@ -51,8 +51,7 @@ type Course struct { func GetRecords[T any](wk *WK, rType RecordType, courseID, page string) (*AllRecordResp[T], error) { log.Debug("获取记录信息", zap.String("host", wk.Host), zap.String("type", string(rType))) - resp, err := wk.Req. - R(). + resp, err := wk.newRequest(). SetQueryParams(map[string]string{ "courseId": courseID, "page": page, diff --git a/internal/conf/var.go b/internal/conf/var.go index f6e9a84..0def7fd 100644 --- a/internal/conf/var.go +++ b/internal/conf/var.go @@ -3,6 +3,7 @@ package conf import ( "os" "strings" + "sync/atomic" ) // 构建信息 @@ -17,23 +18,30 @@ var ( DebugProxy string = "" DebugSkipSSLVerify bool = false + + runtimeDebugEnabled atomic.Bool ) func init() { - if !IsDebugMode() { - return - } - if proxy := os.Getenv("CKWK_DEBUG_PROXY"); proxy != "" { DebugProxy = proxy } DebugSkipSSLVerify = parseEnvBool("CKWK_DEBUG_SKIP_SSL_VERIFY") + runtimeDebugEnabled.Store(parseEnvBool("CKWK_DEBUG_ENABLED")) } -func IsDebugMode() bool { +func IsBuildDebugMode() bool { return !strings.EqualFold(Mode, "release") } +func IsRuntimeDebugEnabled() bool { + return runtimeDebugEnabled.Load() +} + +func SetRuntimeDebugEnabled(enabled bool) { + runtimeDebugEnabled.Store(enabled) +} + func parseEnvBool(key string) bool { value := strings.TrimSpace(os.Getenv(key)) switch strings.ToLower(value) { diff --git a/internal/handler/ckwk.go b/internal/handler/ckwk.go index 9b4cba5..bd350c2 100644 --- a/internal/handler/ckwk.go +++ b/internal/handler/ckwk.go @@ -4,6 +4,7 @@ import ( "ckwk/internal/ckwk" "ckwk/internal/dto" "ckwk/pkg/log" + "errors" "fmt" "net/http" @@ -44,25 +45,22 @@ func (h *WKHandler) Login(ctx *gin.Context) { ctx.JSON(200, dto.Error(-1, "登录失败:请提供账号密码或有效的 Token,并确保 Host 正确")) return } - - userinfo, err := wk.UserInfoGet() - if err != nil { - ctx.JSON(200, dto.Error(-1, err.Error())) - return - } - - courses, err := wk.CourseGet(req.Status) - if err != nil { - ctx.JSON(200, dto.Error(-1, err.Error())) - return + if req.Token == "" { + ok, err := wk.Login() + if err != nil { + ctx.JSON(200, dto.Error(-1, err.Error())) + return + } + if !ok { + ctx.JSON(200, dto.Error(-1, "登录失败")) + return + } } sessionID := h.Session.Store(wk) ctx.JSON(200, dto.Success(map[string]any{ "session_id": sessionID, - "user": userinfo, - "courses": courses, })) } @@ -76,6 +74,10 @@ func (h *WKHandler) Online(ctx *gin.Context) { flag, err := wk.Online() if err != nil { + if errors.Is(err, ckwk.ErrSessionRemoved) { + ctx.JSON(http.StatusUnauthorized, dto.Error(401, err.Error())) + return + } ctx.JSON(200, dto.Error(-1, err.Error())) return } @@ -153,6 +155,10 @@ func (h *WKHandler) Study(ctx *gin.Context) { result, err := wk.Study(req.NodeID, req.StudyID, req.StudyTime, req.Status) if err != nil { + if errors.Is(err, ckwk.ErrSessionRemoved) { + ctx.JSON(http.StatusUnauthorized, dto.Error(401, err.Error())) + return + } ctx.JSON(200, dto.Error(-1, err.Error())) return } diff --git a/internal/handler/debug_log.go b/internal/handler/debug_log.go index 2ad7a72..a900472 100644 --- a/internal/handler/debug_log.go +++ b/internal/handler/debug_log.go @@ -1,6 +1,7 @@ package handler import ( + "ckwk/internal/conf" "encoding/json" "fmt" "net/http" @@ -19,13 +20,44 @@ var debugLogUpgrader = websocket.Upgrader{ }, } +type debugConfigReq struct { + Enabled bool `json:"enabled"` +} + +func DebugConfig(ctx *gin.Context) { + ctx.JSON(http.StatusOK, dto.Success(map[string]any{ + "enabled": conf.IsRuntimeDebugEnabled(), + "proxy": conf.DebugProxy, + "skip_ssl_verify": conf.DebugSkipSSLVerify, + "build_mode": conf.Mode, + "proxy_configured": conf.DebugProxy != "", + })) +} + +func UpdateDebugConfig(ctx *gin.Context) { + var req debugConfigReq + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, dto.Error(400, "请求参数错误")) + return + } + + conf.SetRuntimeDebugEnabled(req.Enabled) + DebugConfig(ctx) +} + func DebugLogs(ctx *gin.Context) { + if !ensureDebugEnabled(ctx) { + return + } ctx.JSON(http.StatusOK, dto.Success(map[string]any{ "list": log.Entries(), })) } func DebugLogsDownload(ctx *gin.Context) { + if !ensureDebugEnabled(ctx) { + return + } entries := log.Entries() content, err := json.MarshalIndent(entries, "", " ") if err != nil { @@ -40,6 +72,9 @@ func DebugLogsDownload(ctx *gin.Context) { } func DebugLogWS(ctx *gin.Context) { + if !ensureDebugEnabled(ctx) { + return + } conn, err := debugLogUpgrader.Upgrade(ctx.Writer, ctx.Request, nil) if err != nil { return @@ -86,3 +121,12 @@ func DebugLogWS(ctx *gin.Context) { } } } + +func ensureDebugEnabled(ctx *gin.Context) bool { + if conf.IsRuntimeDebugEnabled() { + return true + } + + ctx.JSON(http.StatusForbidden, dto.Error(403, "调试功能未开启,请先在设置页手动开启")) + return false +} diff --git a/internal/handler/version.go b/internal/handler/version.go index 7a27a64..8c853f8 100644 --- a/internal/handler/version.go +++ b/internal/handler/version.go @@ -8,12 +8,13 @@ import ( ) func Version(ctx *gin.Context) { - ctx.JSON(200, dto.Success(map[string]string{ - "Mode": conf.Mode, - "Version": conf.Version, - "BuildAt": conf.BuildAt, - "GitAuthor": conf.GitAuthor, - "GitEmail": conf.GitEmail, - "GitCommit": conf.GitCommit, + ctx.JSON(200, dto.Success(map[string]any{ + "Mode": conf.Mode, + "Version": conf.Version, + "BuildAt": conf.BuildAt, + "GitAuthor": conf.GitAuthor, + "GitEmail": conf.GitEmail, + "GitCommit": conf.GitCommit, + "DebugEnabled": conf.IsRuntimeDebugEnabled(), })) } diff --git a/internal/middleware/debug_http_log.go b/internal/middleware/debug_http_log.go index df3181c..c867472 100644 --- a/internal/middleware/debug_http_log.go +++ b/internal/middleware/debug_http_log.go @@ -2,6 +2,7 @@ package middleware import ( "bytes" + "ckwk/internal/conf" "io" "net/http" "strings" @@ -32,7 +33,12 @@ func (w *debugBodyWriter) WriteString(data string) (int, error) { func DebugHTTPLog() gin.HandlerFunc { return func(ctx *gin.Context) { - if isDebugLogRoute(ctx.Request.URL.Path) { + if !conf.IsRuntimeDebugEnabled() { + ctx.Next() + return + } + + if !shouldCaptureBackendRoute(ctx.Request.URL.Path) { ctx.Next() return } @@ -85,5 +91,13 @@ func truncate(value string, limit int) string { } func isDebugLogRoute(path string) bool { - return strings.HasPrefix(path, "/api/debug/ws/logs") + return strings.HasPrefix(path, "/api/debug/") +} + +func shouldCaptureBackendRoute(path string) bool { + if !strings.HasPrefix(path, "/api/") { + return false + } + + return !isDebugLogRoute(path) } diff --git a/internal/router/router.go b/internal/router/router.go index e501da0..fb8281d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -23,7 +23,7 @@ var ( ) func init() { - if conf.Mode != gin.ReleaseMode { + if conf.IsBuildDebugMode() { AllowOrigins = []string{"*"} AllowMethods = []string{"*"} AllowHeaders = []string{"*"} @@ -48,9 +48,7 @@ func SetupRouter() *gin.Engine { AllowCredentials: true, MaxAge: 12 * time.Hour, })) - if conf.IsDebugMode() { - r.Use(middleware.DebugHTTPLog()) - } + r.Use(middleware.DebugHTTPLog()) wkHandler := handler.NewWKHandler() sessionMiddleware := middleware.SessionMiddleware(wkHandler.Session) // schedule.StartCron(wkHandler.Session) @@ -68,14 +66,14 @@ func SetupRouter() *gin.Engine { { api.POST("/login", wkHandler.Login) api.Any("/version", handler.Version) - if conf.IsDebugMode() { - debug := api.Group("/debug") - { - debug.GET("/logs", handler.DebugLogs) - debug.GET("/logs/download", handler.DebugLogsDownload) - debug.GET("/logs/ws", handler.DebugLogWS) - debug.GET("/ws/logs", handler.DebugLogWS) - } + debug := api.Group("/debug") + { + debug.GET("/config", handler.DebugConfig) + debug.POST("/config", handler.UpdateDebugConfig) + debug.GET("/logs", handler.DebugLogs) + debug.GET("/logs/download", handler.DebugLogsDownload) + debug.GET("/logs/ws", handler.DebugLogWS) + debug.GET("/ws/logs", handler.DebugLogWS) } v1 := api.Group("/v1") { diff --git a/pkg/request/client.go b/pkg/request/client.go index 2972edb..a20e8ba 100644 --- a/pkg/request/client.go +++ b/pkg/request/client.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "encoding/json" "net/http" + "net/url" "time" "ckwk/pkg/log" @@ -19,7 +20,7 @@ var ( const ( DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0" - DefaultTimeout = 10 * time.Second + DefaultTimeout = 30 * time.Second DefaultDebugBody = 4 * 1024 ) @@ -40,67 +41,91 @@ func DefaultConfg() *Config { } } -// NewClient 创建一个标准的 Resty 客户端 -func NewClient(cfg *Config) *resty.Client { +func normalizeConfig(cfg *Config) *Config { defaults := DefaultConfg() if cfg == nil { - cfg = defaults - } else { - // 合并零值,避免调用方只覆盖部分字段时丢失默认超时和 User-Agent。 - if cfg.Timeout <= 0 { - cfg.Timeout = defaults.Timeout - } - if cfg.UserAgent == "" { - cfg.UserAgent = defaults.UserAgent + return defaults + } + + if cfg.Timeout <= 0 { + cfg.Timeout = defaults.Timeout + } + if cfg.UserAgent == "" { + cfg.UserAgent = defaults.UserAgent + } + + return cfg +} + +func buildTransport(cfg *Config) *http.Transport { + baseTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + baseTransport = &http.Transport{} + } + + transport := baseTransport.Clone() + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: !cfg.VerifySSL, + } + transport.Proxy = http.ProxyFromEnvironment + + if cfg.Proxy != "" { + proxyURL, err := url.Parse(cfg.Proxy) + if err == nil { + transport.Proxy = http.ProxyURL(proxyURL) + } else { + log.Warn("代理地址解析失败,已忽略") } } - client := resty.New() + return transport +} + +func ApplyConfig(client *resty.Client, cfg *Config) { + cfg = normalizeConfig(cfg) + client.SetHeader("User-Agent", cfg.UserAgent) client.SetTimeout(cfg.Timeout) client.SetRetryCount(3) + client.SetTransport(buildTransport(cfg)) + client.SetDebug(cfg.Debug) + client.SetDebugBodyLimit(DefaultDebugBody) +} - client.SetTLSClientConfig(&tls.Config{ - InsecureSkipVerify: !cfg.VerifySSL, +// NewClient 创建一个标准的 Resty 客户端 +func NewClient(cfg *Config) *resty.Client { + client := resty.New() + client.OnDebugLog(func(debugLog *resty.DebugLog) { + fields := map[string]any{ + "request": map[string]any{ + "host": debugLog.Request.Host, + "uri": debugLog.Request.URI, + "method": debugLog.Request.Method, + "proto": debugLog.Request.Proto, + "header": log.SanitizeHeaders(debugLog.Request.Header), + "attempt": debugLog.Request.Attempt, + "body": log.SanitizeBody(debugLog.Request.Header.Get("Content-Type"), debugLog.Request.Body), + }, + "response": map[string]any{ + "statusCode": debugLog.Response.StatusCode, + "status": debugLog.Response.Status, + "proto": debugLog.Response.Proto, + "receivedAt": debugLog.Response.ReceivedAt.Format(time.RFC3339Nano), + "durationMs": debugLog.Response.Duration.Milliseconds(), + "size": debugLog.Response.Size, + "header": log.SanitizeHeaders(debugLog.Response.Header), + "body": log.SanitizeBody(debugLog.Response.Header.Get("Content-Type"), debugLog.Response.Body), + }, + } + if debugLog.TraceInfo != nil { + if traceJSON, err := json.Marshal(debugLog.TraceInfo); err == nil { + fields["trace"] = json.RawMessage(traceJSON) + } + } + log.Capture(zapcore.DebugLevel, "resty", "outbound exchange", fields) }) - - if cfg.Proxy != "" { - client.SetProxy(cfg.Proxy) - } - if cfg.Debug { - client.SetDebug(true) - client.SetDebugBodyLimit(DefaultDebugBody) - client.OnDebugLog(func(debugLog *resty.DebugLog) { - fields := map[string]any{ - "request": map[string]any{ - "host": debugLog.Request.Host, - "uri": debugLog.Request.URI, - "method": debugLog.Request.Method, - "proto": debugLog.Request.Proto, - "header": log.SanitizeHeaders(debugLog.Request.Header), - "attempt": debugLog.Request.Attempt, - "body": log.SanitizeBody(debugLog.Request.Header.Get("Content-Type"), debugLog.Request.Body), - }, - "response": map[string]any{ - "statusCode": debugLog.Response.StatusCode, - "status": debugLog.Response.Status, - "proto": debugLog.Response.Proto, - "receivedAt": debugLog.Response.ReceivedAt.Format(time.RFC3339Nano), - "durationMs": debugLog.Response.Duration.Milliseconds(), - "size": debugLog.Response.Size, - "header": log.SanitizeHeaders(debugLog.Response.Header), - "body": log.SanitizeBody(debugLog.Response.Header.Get("Content-Type"), debugLog.Response.Body), - }, - } - if debugLog.TraceInfo != nil { - if traceJSON, err := json.Marshal(debugLog.TraceInfo); err == nil { - fields["trace"] = json.RawMessage(traceJSON) - } - } - log.Capture(zapcore.DebugLevel, "resty", "outbound exchange", fields) - }) - client.SetDebugLogFormatter(nil) - } + client.SetDebugLogFormatter(nil) + ApplyConfig(client, cfg) return client } diff --git a/web/frontend b/web/frontend index 1396592..13f0be1 160000 --- a/web/frontend +++ b/web/frontend @@ -1 +1 @@ -Subproject commit 13965921418b94d6f4a23c531539b2b067b542ec +Subproject commit 13f0be162b8b37eb5092d25e2b5547a43956a41a