9 Commits

Author SHA1 Message Date
83ee4bb5ea release: v0.1.3 2026-04-03 14:24:29 +08:00
98839e9782 feat: expose backend debug log endpoints 2026-04-02 23:51:44 +08:00
9ec25b94f1 feat: add debug log stream support 2026-04-02 23:27:46 +08:00
f1c16e89f0 fix: 修复打包问题 2026-03-31 21:54:41 +08:00
1af7ba290c fix: 修复前端错误 2026-03-31 21:36:18 +08:00
4cbc107d1d fix(README): 修改拉取代码方式 2026-03-28 19:47:41 +08:00
5bd8f3e6ca chore: add frontend submodule 2026-03-28 19:42:24 +08:00
5acb536281 fix: 将开发和生产环境进行区分 2026-03-28 19:27:17 +08:00
bbd554a426 fix: 修改 CORS, 删除 开发代理 2026-03-27 20:08:58 +08:00
22 changed files with 1085 additions and 83 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
bin bin
.gocache

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "web/frontend"]
path = web/frontend
url = https://gitea.kmux.cn/cqcst/wk-frontend
branch = main

View File

@@ -7,13 +7,65 @@
- 获取网课记录 - 获取网课记录
- 学习接口 - 学习接口
### 拉取代码
```shell
git clone --recurse-submodules https://gitea.kmux.cn/cqcst/wk-backend
```
更新已有仓库时,建议带上 submodule 一起同步:
```shell
git pull --recurse-submodules
task fe:sync
```
如果前端仓库 `wk-frontend` 有新提交,需要把主仓库里的 submodule 指针更新到最新:
```shell
task fe:update
git status
```
### 代码构建 ### 代码构建
**推荐使用 [Taskfile](https://taskfile.dev/) 进行项目构建** **推荐使用 [Taskfile](https://taskfile.dev/) 进行项目构建**
可通过环境变量配置本地代理 / 跳过 SSL 校验,但只有在设置页手动开启调试后才会生效:
```shell
CKWK_DEBUG_PROXY=http://127.0.0.1:9000
CKWK_DEBUG_SKIP_SSL_VERIFY=true
```
也可以通过环境变量让后端启动时默认开启调试:
```shell
CKWK_DEBUG_ENABLED=true
```
调试日志 WS 在后端调试已开启时可用,连接地址:
```shell
ws://127.0.0.1:8080/api/debug/logs/ws
```
调试日志快照和下载接口:
```shell
GET /api/debug/logs
GET /api/debug/logs/download
```
服务端会保留最近 1000 条内存日志,并持续推送新的入站 HTTP、出站请求和应用日志。
- 支持命令 - 支持命令
``` ```
* fe:sync: 同步前端 submodule 🔁
* fe:update: 拉取前端 submodule 最新提交 ⬆️
* build: 构建前端 + 后端 📦 * build: 构建前端 + 后端 📦
* dev: 同时启动前后端(开发模式)🔥 * dev: 同时启动前后端(开发模式)🔥
* rebuild: 清理并重建 🔁 * rebuild: 清理并重建 🔁
@@ -31,7 +83,7 @@
### 项目结构 ### 项目结构
- 目录 - 目录
**前端项目地址: [wk-frontend](https://gitea.kmux.cn/zhilv/wk-frontend)** **前端项目地址: [wk-frontend](https://gitea.kmux.cn/cqcst/wk-frontend)**
``` ```
. .
├── Taskfile.yml # taskfile 命令定义 ├── Taskfile.yml # taskfile 命令定义

View File

@@ -25,24 +25,41 @@ vars:
-X "ckwk/internal/conf.GitCommit={{.GIT_COMMIT}}" -X "ckwk/internal/conf.GitCommit={{.GIT_COMMIT}}"
tasks: tasks:
# ======================
# 🔁 Git / Submodule
# ======================
fe:sync:
desc: 同步前端 submodule 🔁
cmds:
- git submodule update --init --recursive {{.FRONTEND_DIR}}
fe:update:
desc: 拉取前端 submodule 最新提交 ⬆️
cmds:
- git submodule update --init --remote --recursive {{.FRONTEND_DIR}}
# ====================== # ======================
# 🎨 前端 # 🎨 前端
# ====================== # ======================
fe:install: fe:install:
desc: 安装前端依赖 📦 desc: 安装前端依赖 📦
deps: [fe:sync]
dir: "{{.FRONTEND_DIR}}" dir: "{{.FRONTEND_DIR}}"
cmds: cmds:
- pnpm install - pnpm install
fe:dev: fe:dev:
desc: 启动前端开发服务器 🚀 desc: 启动前端开发服务器 🚀
deps: [fe:sync]
dir: "{{.FRONTEND_DIR}}" dir: "{{.FRONTEND_DIR}}"
cmds: cmds:
- pnpm dev - pnpm dev
fe:build: fe:build:
desc: 构建前端 🏗️ desc: 构建前端 🏗️
deps: [fe:sync]
dir: "{{.FRONTEND_DIR}}" dir: "{{.FRONTEND_DIR}}"
cmds: cmds:
- pnpm build - pnpm build

1
go.mod
View File

@@ -5,6 +5,7 @@ go 1.25.0
require ( require (
github.com/antchfx/htmlquery v1.3.6 github.com/antchfx/htmlquery v1.3.6
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/gorilla/websocket v1.5.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1

2
go.sum
View File

@@ -41,6 +41,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=

View File

@@ -7,17 +7,27 @@ import (
"ckwk/pkg/request" "ckwk/pkg/request"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/antchfx/htmlquery" "github.com/antchfx/htmlquery"
"go.uber.org/zap" "go.uber.org/zap"
"resty.dev/v3" "resty.dev/v3"
) )
var (
ErrLoginTimeout = errors.New("登录超时,请重新登录")
ErrSessionRemoved = errors.New("session_manager 已删除失效会话")
ErrReloginSkipped = errors.New("当前会话缺少账号密码,无法自动重新登录")
)
const maxAutoReloginRetries = 3
type WK struct { type WK struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
@@ -29,6 +39,10 @@ type WK struct {
LoginRegexp *regexp.Regexp LoginRegexp *regexp.Regexp
CourseIDRegexp *regexp.Regexp CourseIDRegexp *regexp.Regexp
TimeRegexp *regexp.Regexp TimeRegexp *regexp.Regexp
authMu sync.Mutex
sessionID string
sessionManager *SessionManager
} }
func NewWK(username, password, host string, cookies []*http.Cookie) *WK { func NewWK(username, password, host string, cookies []*http.Cookie) *WK {
@@ -37,11 +51,17 @@ func NewWK(username, password, host string, cookies []*http.Cookie) *WK {
return nil return nil
} }
req := request.NewClient(&request.Config{ 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", 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",
Proxy: "http://127.0.0.1:9000", VerifySSL: true,
VerifySSL: false, Debug: conf.IsRuntimeDebugEnabled(),
}) }
if conf.IsRuntimeDebugEnabled() {
reqCfg.Proxy = conf.DebugProxy
reqCfg.VerifySSL = !conf.DebugSkipSSLVerify
}
req := request.NewClient(reqCfg)
if len(cookies) > 0 { if len(cookies) > 0 {
req.SetCookies(cookies) req.SetCookies(cookies)
} }
@@ -57,13 +77,41 @@ func NewWK(username, password, host string, cookies []*http.Cookie) *WK {
CourseIDRegexp: regexp.MustCompile(`\?courseId=(\d+)`), CourseIDRegexp: regexp.MustCompile(`\?courseId=(\d+)`),
TimeRegexp: regexp.MustCompile(`\d{4}-\d{2}-\d{2}`), TimeRegexp: regexp.MustCompile(`\d{4}-\d{2}-\d{2}`),
} }
if len(cookies) == 0 && username != "" {
wk.Login()
}
return wk 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 // Cookies: returns cookies
func (wk *WK) Cookie() []*http.Cookie { func (wk *WK) Cookie() []*http.Cookie {
return wk.Cookies return wk.Cookies
@@ -77,8 +125,7 @@ func (wk *WK) SetCookies(cs []*http.Cookie) {
// Code: Get Verify Code // Code: Get Verify Code
func (wk *WK) Code() (string, error) { func (wk *WK) Code() (string, error) {
resp, err := wk.Req. resp, err := wk.newRequest().
R().
SetQueryParam("r", fmt.Sprint(common.RandFloat64())). SetQueryParam("r", fmt.Sprint(common.RandFloat64())).
Get(fmt.Sprintf("https://%s/service/code", wk.Host)) Get(fmt.Sprintf("https://%s/service/code", wk.Host))
@@ -90,8 +137,7 @@ func (wk *WK) Code() (string, error) {
return "", fmt.Errorf("获取验证码失败: code: %d", resp.StatusCode()) return "", fmt.Errorf("获取验证码失败: code: %d", resp.StatusCode())
} }
var result CodeResp var result CodeResp
_, err = wk.Req. _, err = wk.newRequest().
R().
SetFormData(map[string]string{ SetFormData(map[string]string{
"image": base64.StdEncoding.EncodeToString(resp.Bytes()), "image": base64.StdEncoding.EncodeToString(resp.Bytes()),
"probability": "false", "probability": "false",
@@ -122,8 +168,7 @@ func (wk *WK) Login() (bool, error) {
return false, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。") return false, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。")
} }
resp, err := wk.Req. resp, err := wk.newRequest().
R().
SetFormData(map[string]string{ SetFormData(map[string]string{
"username": wk.Username, "username": wk.Username,
"password": wk.Password, "password": wk.Password,
@@ -135,25 +180,50 @@ func (wk *WK) Login() (bool, error) {
if err != nil { if err != nil {
return false, fmt.Errorf("请求登录失败: %w", err) return false, fmt.Errorf("请求登录失败: %w", err)
} }
matchs := wk.LoginRegexp.FindStringSubmatch(string(resp.Bytes())) result, err := wk.parseLoginResp(resp.Bytes())
if len(matchs) <= 1 {
return false, fmt.Errorf("没有找到匹配字符串")
}
var result LoginResp
err = json.Unmarshal([]byte(matchs[1]), &result)
if err != nil { if err != nil {
return false, fmt.Errorf("解析 json data 失败: %w", err) return false, err
} }
if !result.Status { if !result.Status {
return false, fmt.Errorf("登录失败: %s", result.Msg) return false, fmt.Errorf("登录失败: %s", loginErrorMessage(result))
} }
wk.SetCookies(resp.Cookies()) wk.SetCookies(resp.Cookies())
log.Info("登录成功", zap.Any("cookies", wk.Cookies)) log.Info("登录成功", zap.Any("cookies", wk.Req.Cookies()))
return true, nil 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: 课程解析 // CourseParse: 课程解析
func (wk *WK) CourseParse(content string) ([]Course, error) { func (wk *WK) CourseParse(content string) ([]Course, error) {
courses := make([]Course, 0) courses := make([]Course, 0)
@@ -231,8 +301,7 @@ func (wk *WK) CourseParse(content string) ([]Course, error) {
// CourseGet: 课程获取 // CourseGet: 课程获取
func (wk *WK) CourseGet(kind CourseKind) ([]Course, error) { func (wk *WK) CourseGet(kind CourseKind) ([]Course, error) {
var courses []Course var courses []Course
resp, err := wk.Req. resp, err := wk.newRequest().
R().
SetQueryParam("kind", string(kind)). SetQueryParam("kind", string(kind)).
Get(fmt.Sprintf("https://%s/user/index", wk.Host)) Get(fmt.Sprintf("https://%s/user/index", wk.Host))
if err != nil { if err != nil {
@@ -286,8 +355,7 @@ func (wk *WK) UserInfoParse(content string) (User, error) {
// UserGet: 用户信息获取 // UserGet: 用户信息获取
func (wk *WK) UserInfoGet() (User, error) { func (wk *WK) UserInfoGet() (User, error) {
var user User var user User
resp, err := wk.Req. resp, err := wk.newRequest().
R().
Get(fmt.Sprintf("https://%s/user/member", wk.Host)) Get(fmt.Sprintf("https://%s/user/member", wk.Host))
if err != nil { if err != nil {
return user, fmt.Errorf("获取用户信息页面失败: %w", err) return user, fmt.Errorf("获取用户信息页面失败: %w", err)
@@ -303,9 +371,8 @@ func (wk *WK) UserInfoGet() (User, error) {
} }
// Online: 保持账号状态 // Online: 保持账号状态
func (wk *WK) Online() (bool, error) { func (wk *WK) performOnline() (bool, error) {
resp, err := wk.Req. resp, err := wk.newRequest().
R().
SetHeaders(map[string]string{ SetHeaders(map[string]string{
"x-requested-with": "XMLHttpRequest", "x-requested-with": "XMLHttpRequest",
"Accept": "application/json, text/javascript, */*; q=0.01", "Accept": "application/json, text/javascript, */*; q=0.01",
@@ -315,12 +382,19 @@ func (wk *WK) Online() (bool, error) {
if err != nil { if err != nil {
return false, fmt.Errorf("保持账号状态失败: %w", err) return false, fmt.Errorf("保持账号状态失败: %w", err)
} }
if isLoginTimeoutBody(resp.Bytes()) {
return false, fmt.Errorf("保持账号状态失败: %w", ErrLoginTimeout)
}
log.Info("保持账号状态", zap.Any("resp", string(resp.Bytes()))) log.Info("保持账号状态", zap.Any("resp", string(resp.Bytes())))
return true, nil return true, nil
} }
func (wk *WK) Online() (bool, error) {
return withAutoRelogin(wk, "保持账号状态", wk.performOnline)
}
// Study: 学习 // 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 var data map[string]string
switch status { switch status {
case StudyStart: case StudyStart:
@@ -357,8 +431,7 @@ func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*Stu
default: default:
return nil, fmt.Errorf("传入的学习状态不匹配") return nil, fmt.Errorf("传入的学习状态不匹配")
} }
resp, err := wk.Req. resp, err := wk.newRequest().
R().
SetHeaders(map[string]string{ SetHeaders(map[string]string{
"x-requested-with": "XMLHttpRequest", "x-requested-with": "XMLHttpRequest",
"Accept": "application/json, text/javascript, */*; q=0.01", "Accept": "application/json, text/javascript, */*; q=0.01",
@@ -370,6 +443,9 @@ func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*Stu
if err != nil { if err != nil {
return nil, fmt.Errorf("进行学习失败: %w", err) return nil, fmt.Errorf("进行学习失败: %w", err)
} }
if isLoginTimeoutBody(resp.Bytes()) {
return nil, fmt.Errorf("进行学习失败: %w", ErrLoginTimeout)
}
var result StudyResp var result StudyResp
if err := json.Unmarshal(resp.Bytes(), &result); err != nil { if err := json.Unmarshal(resp.Bytes(), &result); err != nil {
@@ -380,6 +456,12 @@ func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*Stu
return &result, nil 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: 获取学习记录 // GetStudyList: 获取学习记录
func (wk *WK) GetStudyList(courseID, page string) (*AllRecordResp[StudyList], error) { func (wk *WK) GetStudyList(courseID, page string) (*AllRecordResp[StudyList], error) {
return GetRecords[StudyList](wk, RecordStudy, courseID, page) return GetRecords[StudyList](wk, RecordStudy, courseID, page)
@@ -401,3 +483,101 @@ func (wk *WK) GetExamList(courseID, page string) (*AllRecordResp[ExamList], erro
func (wk *WK) GetDiscussList(courseID, page string) (*AllRecordResp[ExamList], error) { func (wk *WK) GetDiscussList(courseID, page string) (*AllRecordResp[ExamList], error) {
return GetRecords[ExamList](wk, RecordStudy, courseID, page) 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)
}

View File

@@ -7,12 +7,17 @@ type CodeResp struct {
Data string `json:"data"` Data string `json:"data"`
} }
type LoginFormError struct {
Code string `json:"code"`
}
// 登录响应 // 登录响应
type LoginResp struct { type LoginResp struct {
RefreshCode int `json:"refresh_code"` RefreshCode int `json:"refresh_code"`
Status bool `json:"status"` Status bool `json:"status"`
Msg string `json:"msg"` Msg string `json:"msg"`
Back string `json:"back"` Back string `json:"back"`
FormError LoginFormError `json:"formError"`
} }
type StudyResp struct { type StudyResp struct {

View File

@@ -37,8 +37,15 @@ func (m *SessionManager) Store(wk *WK) string {
userKey := wk.Host + ":" + wk.Username userKey := wk.Host + ":" + wk.Username
if oldID, exists := m.userToSession[userKey]; exists { if oldID, exists := m.userToSession[userKey]; exists {
item := m.sessions[oldID] item := m.sessions[oldID]
if item.cancel != nil {
item.cancel()
}
ctx, cancel := context.WithCancel(context.Background())
item.LastValue = time.Now() item.LastValue = time.Now()
wk.bindSession(m, oldID)
item.Instance = wk item.Instance = wk
item.cancel = cancel
m.sessions[oldID] = item m.sessions[oldID] = item
log.Info("用户已存在,复用旧 Session", log.Info("用户已存在,复用旧 Session",
@@ -46,11 +53,14 @@ func (m *SessionManager) Store(wk *WK) string {
zap.String("user", userKey), zap.String("user", userKey),
) )
go m.KeepAlive(ctx, oldID)
return oldID return oldID
} }
sessionID := uuid.New().String() sessionID := uuid.New().String()
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
wk.bindSession(m, sessionID)
m.userToSession[userKey] = sessionID m.userToSession[userKey] = sessionID
m.sessions[sessionID] = SessionItem{ m.sessions[sessionID] = SessionItem{
@@ -61,18 +71,20 @@ func (m *SessionManager) Store(wk *WK) string {
log.Info("创建新 Session", zap.String("id", sessionID)) log.Info("创建新 Session", zap.String("id", sessionID))
go m.KeepAlive(ctx, sessionID, wk) go m.KeepAlive(ctx, sessionID)
return sessionID return sessionID
} }
// Get: 获取指定 session id 的 wk // Get: 获取指定 session id 的 wk
func (m *SessionManager) Get(sessionID string) (*WK, bool) { func (m *SessionManager) Get(sessionID string) (*WK, bool) {
m.mu.RLock() m.mu.Lock()
defer m.mu.RUnlock() defer m.mu.Unlock()
item, ok := m.sessions[sessionID] item, ok := m.sessions[sessionID]
if ok { if ok {
item.LastValue = time.Now()
m.sessions[sessionID] = item
return item.Instance, true return item.Instance, true
} }
return nil, false return nil, false
@@ -96,7 +108,7 @@ func (m *SessionManager) Del(sessionID string) {
} }
} }
func (m *SessionManager) KeepAlive(ctx context.Context, id string, wk *WK) { func (m *SessionManager) KeepAlive(ctx context.Context, id string) {
ticker := time.NewTicker(2 * time.Minute) ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
@@ -108,7 +120,15 @@ func (m *SessionManager) KeepAlive(ctx context.Context, id string, wk *WK) {
log.Info("KeepAlive 已停止", zap.String("id", id)) log.Info("KeepAlive 已停止", zap.String("id", id))
return return
case <-ticker.C: case <-ticker.C:
_, err := wk.Online() m.mu.RLock()
item, ok := m.sessions[id]
m.mu.RUnlock()
if !ok || item.Instance == nil {
log.Info("Session 已不存在,停止 KeepAlive", zap.String("id", id))
return
}
_, err := item.Instance.Online()
if err != nil { if err != nil {
log.Error("自动保活请求失败", zap.Error(err)) log.Error("自动保活请求失败", zap.Error(err))
} }

View File

@@ -51,8 +51,7 @@ type Course struct {
func GetRecords[T any](wk *WK, rType RecordType, courseID, page string) (*AllRecordResp[T], error) { 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))) log.Debug("获取记录信息", zap.String("host", wk.Host), zap.String("type", string(rType)))
resp, err := wk.Req. resp, err := wk.newRequest().
R().
SetQueryParams(map[string]string{ SetQueryParams(map[string]string{
"courseId": courseID, "courseId": courseID,
"page": page, "page": page,

View File

@@ -1,5 +1,11 @@
package conf package conf
import (
"os"
"strings"
"sync/atomic"
)
// 构建信息 // 构建信息
var ( var (
Mode string = "debug" Mode string = "debug"
@@ -9,4 +15,39 @@ var (
GitAuthor string = "unknown" GitAuthor string = "unknown"
GitEmail string = "unknown" GitEmail string = "unknown"
GitCommit string = "unknown" GitCommit string = "unknown"
DebugProxy string = ""
DebugSkipSSLVerify bool = false
runtimeDebugEnabled atomic.Bool
) )
func init() {
if proxy := os.Getenv("CKWK_DEBUG_PROXY"); proxy != "" {
DebugProxy = proxy
}
DebugSkipSSLVerify = parseEnvBool("CKWK_DEBUG_SKIP_SSL_VERIFY")
runtimeDebugEnabled.Store(parseEnvBool("CKWK_DEBUG_ENABLED"))
}
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) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}

View File

@@ -42,6 +42,10 @@ type LoginReq struct {
Host string `json:"host" binding:"required"` Host string `json:"host" binding:"required"`
} }
type CourseReq struct {
Status ckwk.CourseKind `json:"status"`
}
type StudyReq struct { type StudyReq struct {
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
StudyID string `json:"study_id"` StudyID string `json:"study_id"`

View File

@@ -4,6 +4,7 @@ import (
"ckwk/internal/ckwk" "ckwk/internal/ckwk"
"ckwk/internal/dto" "ckwk/internal/dto"
"ckwk/pkg/log" "ckwk/pkg/log"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -44,25 +45,22 @@ func (h *WKHandler) Login(ctx *gin.Context) {
ctx.JSON(200, dto.Error(-1, "登录失败:请提供账号密码或有效的 Token并确保 Host 正确")) ctx.JSON(200, dto.Error(-1, "登录失败:请提供账号密码或有效的 Token并确保 Host 正确"))
return return
} }
if req.Token == "" {
userinfo, err := wk.UserInfoGet() ok, err := wk.Login()
if err != nil { if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error())) ctx.JSON(200, dto.Error(-1, err.Error()))
return return
} }
if !ok {
courses, err := wk.CourseGet(req.Status) ctx.JSON(200, dto.Error(-1, "登录失败"))
if err != nil { return
ctx.JSON(200, dto.Error(-1, err.Error())) }
return
} }
sessionID := h.Session.Store(wk) sessionID := h.Session.Store(wk)
ctx.JSON(200, dto.Success(map[string]any{ ctx.JSON(200, dto.Success(map[string]any{
"session_id": sessionID, "session_id": sessionID,
"user": userinfo,
"courses": courses,
})) }))
} }
@@ -76,6 +74,10 @@ func (h *WKHandler) Online(ctx *gin.Context) {
flag, err := wk.Online() flag, err := wk.Online()
if err != nil { 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())) ctx.JSON(200, dto.Error(-1, err.Error()))
return return
} }
@@ -93,6 +95,50 @@ func (h *WKHandler) Logout(ctx *gin.Context) {
ctx.JSON(200, dto.Ok()) ctx.JSON(200, dto.Ok())
} }
func (h *WKHandler) UserInfo(ctx *gin.Context) {
val, ok := ctx.Get("wk_instance")
if !ok {
ctx.JSON(200, dto.Error(-1, "登录已过期"))
return
}
wk := val.(*ckwk.WK)
userinfo, err := wk.UserInfoGet()
if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error()))
return
}
ctx.JSON(200, dto.Success(map[string]any{
"user": userinfo,
}))
}
func (h *WKHandler) Course(ctx *gin.Context) {
var req dto.CourseReq
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(200, dto.Error(-1, "请求参数错误"))
return
}
val, ok := ctx.Get("wk_instance")
if !ok {
ctx.JSON(200, dto.Error(-1, "登录已过期"))
return
}
wk := val.(*ckwk.WK)
courses, err := wk.CourseGet(req.Status)
if err != nil {
ctx.JSON(200, dto.Error(-1, err.Error()))
return
}
ctx.JSON(200, dto.Success(map[string]any{
"courses": courses,
}))
}
func (h *WKHandler) Study(ctx *gin.Context) { func (h *WKHandler) Study(ctx *gin.Context) {
val, ok := ctx.Get("wk_instance") val, ok := ctx.Get("wk_instance")
if !ok { if !ok {
@@ -109,6 +155,10 @@ func (h *WKHandler) Study(ctx *gin.Context) {
result, err := wk.Study(req.NodeID, req.StudyID, req.StudyTime, req.Status) result, err := wk.Study(req.NodeID, req.StudyID, req.StudyTime, req.Status)
if err != nil { 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())) ctx.JSON(200, dto.Error(-1, err.Error()))
return return
} }

View File

@@ -0,0 +1,132 @@
package handler
import (
"ckwk/internal/conf"
"encoding/json"
"fmt"
"net/http"
"time"
"ckwk/internal/dto"
"ckwk/pkg/log"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var debugLogUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
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 {
ctx.JSON(http.StatusInternalServerError, dto.Error(500, "日志导出失败"))
return
}
filename := fmt.Sprintf("wk-debug-logs-%s.json", time.Now().Format("20060102-150405"))
ctx.Header("Content-Type", "application/json; charset=utf-8")
ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
ctx.Data(http.StatusOK, "application/json; charset=utf-8", content)
}
func DebugLogWS(ctx *gin.Context) {
if !ensureDebugEnabled(ctx) {
return
}
conn, err := debugLogUpgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
return
}
defer conn.Close()
subID, ch := log.Subscribe()
defer log.Unsubscribe(subID)
for _, entry := range log.Entries() {
if err := conn.WriteJSON(entry); err != nil {
return
}
}
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case entry, ok := <-ch:
if !ok {
return
}
if err := conn.WriteJSON(entry); err != nil {
return
}
case <-ticker.C:
if err := conn.WriteMessage(websocket.PingMessage, []byte("ping")); err != nil {
return
}
}
}
}
func ensureDebugEnabled(ctx *gin.Context) bool {
if conf.IsRuntimeDebugEnabled() {
return true
}
ctx.JSON(http.StatusForbidden, dto.Error(403, "调试功能未开启,请先在设置页手动开启"))
return false
}

View File

@@ -8,11 +8,13 @@ import (
) )
func Version(ctx *gin.Context) { func Version(ctx *gin.Context) {
ctx.JSON(200, dto.Success(map[string]string{ ctx.JSON(200, dto.Success(map[string]any{
"Version": conf.Version, "Mode": conf.Mode,
"BuildAt": conf.BuildAt, "Version": conf.Version,
"GitAuthor": conf.GitAuthor, "BuildAt": conf.BuildAt,
"GitEmail": conf.GitEmail, "GitAuthor": conf.GitAuthor,
"GitCommit": conf.GitCommit, "GitEmail": conf.GitEmail,
"GitCommit": conf.GitCommit,
"DebugEnabled": conf.IsRuntimeDebugEnabled(),
})) }))
} }

View File

@@ -0,0 +1,103 @@
package middleware
import (
"bytes"
"ckwk/internal/conf"
"io"
"net/http"
"strings"
"time"
"ckwk/pkg/log"
"github.com/gin-gonic/gin"
"go.uber.org/zap/zapcore"
)
const maxDebugBodySize = 4 * 1024
type debugBodyWriter struct {
gin.ResponseWriter
body bytes.Buffer
}
func (w *debugBodyWriter) Write(data []byte) (int, error) {
w.body.Write(data)
return w.ResponseWriter.Write(data)
}
func (w *debugBodyWriter) WriteString(data string) (int, error) {
w.body.WriteString(data)
return w.ResponseWriter.WriteString(data)
}
func DebugHTTPLog() gin.HandlerFunc {
return func(ctx *gin.Context) {
if !conf.IsRuntimeDebugEnabled() {
ctx.Next()
return
}
if !shouldCaptureBackendRoute(ctx.Request.URL.Path) {
ctx.Next()
return
}
startAt := time.Now()
requestBody := readRequestBody(ctx.Request)
writer := &debugBodyWriter{ResponseWriter: ctx.Writer}
ctx.Writer = writer
ctx.Next()
fields := map[string]any{
"method": ctx.Request.Method,
"path": ctx.Request.URL.Path,
"rawQuery": ctx.Request.URL.RawQuery,
"status": writer.Status(),
"durationMs": time.Since(startAt).Milliseconds(),
"clientIP": ctx.ClientIP(),
"requestHeader": log.SanitizeHeaders(ctx.Request.Header),
"requestBody": truncate(log.SanitizeBody(ctx.ContentType(), requestBody), maxDebugBodySize),
"responseHeader": log.SanitizeHeaders(http.Header(writer.Header().Clone())),
"responseBody": truncate(log.SanitizeBody(writer.Header().Get("Content-Type"), writer.body.String()), maxDebugBodySize),
"responseSize": writer.Size(),
"handler": ctx.HandlerName(),
"abortWithErrors": ctx.Errors.ByType(gin.ErrorTypeAny).String(),
}
log.Capture(zapcore.DebugLevel, "http", "incoming exchange", fields)
}
}
func readRequestBody(r *http.Request) string {
if r == nil || r.Body == nil {
return ""
}
body, err := io.ReadAll(r.Body)
if err != nil {
return ""
}
r.Body = io.NopCloser(bytes.NewBuffer(body))
return string(body)
}
func truncate(value string, limit int) string {
if len(value) <= limit {
return value
}
return value[:limit] + "...(truncated)"
}
func isDebugLogRoute(path string) bool {
return strings.HasPrefix(path, "/api/debug/")
}
func shouldCaptureBackendRoute(path string) bool {
if !strings.HasPrefix(path, "/api/") {
return false
}
return !isDebugLogRoute(path)
}

View File

@@ -16,21 +16,39 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
func SetupRouter() *gin.Engine { var (
if conf.Mode != gin.ReleaseMode { AllowOrigins []string
AllowMethods []string
AllowHeaders []string
)
func init() {
if conf.IsBuildDebugMode() {
AllowOrigins = []string{"*"}
AllowMethods = []string{"*"}
AllowHeaders = []string{"*"}
gin.SetMode(gin.DebugMode) gin.SetMode(gin.DebugMode)
} else { } else {
AllowOrigins = []string{"*.kmux.cn"}
AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH"}
AllowHeaders = []string{"X-Session-Id"}
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
}
func SetupRouter() *gin.Engine {
r := gin.Default() r := gin.Default()
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*.kmux.cn"}, AllowOrigins: AllowOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH"}, AllowMethods: AllowMethods,
AllowHeaders: []string{"X-Session-Id"}, AllowHeaders: AllowHeaders,
ExposeHeaders: []string{"Content-Length"}, ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true, AllowCredentials: true,
MaxAge: 12 * time.Hour, MaxAge: 12 * time.Hour,
})) }))
r.Use(middleware.DebugHTTPLog())
wkHandler := handler.NewWKHandler() wkHandler := handler.NewWKHandler()
sessionMiddleware := middleware.SessionMiddleware(wkHandler.Session) sessionMiddleware := middleware.SessionMiddleware(wkHandler.Session)
// schedule.StartCron(wkHandler.Session) // schedule.StartCron(wkHandler.Session)
@@ -48,6 +66,15 @@ func SetupRouter() *gin.Engine {
{ {
api.POST("/login", wkHandler.Login) api.POST("/login", wkHandler.Login)
api.Any("/version", handler.Version) api.Any("/version", handler.Version)
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") v1 := api.Group("/v1")
{ {
v1.GET("/host", wkHandler.Host) v1.GET("/host", wkHandler.Host)
@@ -56,6 +83,8 @@ func SetupRouter() *gin.Engine {
v2 := api.Group("/v2", sessionMiddleware) v2 := api.Group("/v2", sessionMiddleware)
{ {
v2.POST("/logout", wkHandler.Logout) v2.POST("/logout", wkHandler.Logout)
v2.POST("/userinfo", wkHandler.UserInfo)
v2.POST("/course", wkHandler.Course)
v2.POST("/study", wkHandler.Study) v2.POST("/study", wkHandler.Study)
v2.POST("/record", wkHandler.AllRecord) v2.POST("/record", wkHandler.AllRecord)
} }

285
pkg/log/buffer.go Normal file
View File

@@ -0,0 +1,285 @@
package log
import (
"encoding/json"
"net/http"
"net/url"
"strings"
"sync"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
const (
DefaultBufferLimit = 1000
)
type Entry struct {
ID int64 `json:"id"`
Time string `json:"time"`
Level string `json:"level"`
Source string `json:"source"`
Message string `json:"message"`
Logger string `json:"logger,omitempty"`
Caller string `json:"caller,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
}
type bufferHub struct {
mu sync.RWMutex
limit int
nextEntryID int64
nextSubID int
entries []Entry
subscribers map[int]chan Entry
}
type memoryCore struct {
level zapcore.LevelEnabler
fields []zap.Field
}
var defaultHub = newBufferHub(DefaultBufferLimit)
func newBufferHub(limit int) *bufferHub {
return &bufferHub{
limit: limit,
entries: make([]Entry, 0, limit),
subscribers: make(map[int]chan Entry),
}
}
func (h *bufferHub) append(entry Entry) Entry {
h.mu.Lock()
h.nextEntryID++
entry.ID = h.nextEntryID
if len(h.entries) >= h.limit {
h.entries = append(h.entries[1:], entry)
} else {
h.entries = append(h.entries, entry)
}
subscribers := make([]chan Entry, 0, len(h.subscribers))
for _, ch := range h.subscribers {
subscribers = append(subscribers, ch)
}
h.mu.Unlock()
for _, ch := range subscribers {
select {
case ch <- entry:
default:
}
}
return entry
}
func (h *bufferHub) snapshot() []Entry {
h.mu.RLock()
defer h.mu.RUnlock()
entries := make([]Entry, len(h.entries))
copy(entries, h.entries)
return entries
}
func (h *bufferHub) subscribe() (int, <-chan Entry) {
h.mu.Lock()
defer h.mu.Unlock()
h.nextSubID++
id := h.nextSubID
ch := make(chan Entry, 256)
h.subscribers[id] = ch
return id, ch
}
func (h *bufferHub) unsubscribe(id int) {
h.mu.Lock()
defer h.mu.Unlock()
ch, ok := h.subscribers[id]
if !ok {
return
}
delete(h.subscribers, id)
close(ch)
}
func Entries() []Entry {
return defaultHub.snapshot()
}
func Subscribe() (int, <-chan Entry) {
return defaultHub.subscribe()
}
func Unsubscribe(id int) {
defaultHub.unsubscribe(id)
}
func Capture(level zapcore.Level, source, message string, fields map[string]any) Entry {
return defaultHub.append(Entry{
Time: time.Now().Format(TimeFormatDateTime),
Level: strings.ToLower(level.String()),
Source: source,
Message: message,
Fields: cloneFields(fields),
})
}
func NewMemoryCore(level zapcore.LevelEnabler) zapcore.Core {
return &memoryCore{level: level}
}
func (c *memoryCore) Enabled(level zapcore.Level) bool {
return c.level.Enabled(level)
}
func (c *memoryCore) With(fields []zap.Field) zapcore.Core {
merged := make([]zap.Field, 0, len(c.fields)+len(fields))
merged = append(merged, c.fields...)
merged = append(merged, fields...)
return &memoryCore{
level: c.level,
fields: merged,
}
}
func (c *memoryCore) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if c.Enabled(entry.Level) {
return checked.AddCore(entry, c)
}
return checked
}
func (c *memoryCore) Write(entry zapcore.Entry, fields []zap.Field) error {
combined := make([]zap.Field, 0, len(c.fields)+len(fields))
combined = append(combined, c.fields...)
combined = append(combined, fields...)
defaultHub.append(Entry{
Time: entry.Time.Format(TimeFormatDateTime),
Level: strings.ToLower(entry.Level.String()),
Source: "app",
Message: entry.Message,
Logger: entry.LoggerName,
Caller: entry.Caller.TrimmedPath(),
Fields: fieldsToMap(combined),
})
return nil
}
func (c *memoryCore) Sync() error {
return nil
}
func fieldsToMap(fields []zap.Field) map[string]any {
if len(fields) == 0 {
return nil
}
encoder := zapcore.NewMapObjectEncoder()
for _, field := range fields {
field.AddTo(encoder)
}
if len(encoder.Fields) == 0 {
return nil
}
return cloneFields(encoder.Fields)
}
func cloneFields(fields map[string]any) map[string]any {
if len(fields) == 0 {
return nil
}
cloned := make(map[string]any, len(fields))
for key, value := range fields {
cloned[key] = value
}
return cloned
}
func SanitizeHeaders(headers http.Header) map[string][]string {
if len(headers) == 0 {
return nil
}
sanitized := make(map[string][]string, len(headers))
for key, values := range headers {
copied := append([]string(nil), values...)
if isSensitiveKey(key) {
for i := range copied {
copied[i] = maskValue(copied[i])
}
}
sanitized[key] = copied
}
return sanitized
}
func SanitizeBody(contentType, body string) string {
if body == "" {
return ""
}
switch {
case strings.Contains(contentType, "application/json"):
var payload any
if err := json.Unmarshal([]byte(body), &payload); err == nil {
maskValueRecursive(payload)
if b, err := json.Marshal(payload); err == nil {
return string(b)
}
}
case strings.Contains(contentType, "application/x-www-form-urlencoded"):
values, err := url.ParseQuery(body)
if err == nil {
for key := range values {
if isSensitiveKey(key) {
values.Set(key, maskValue(values.Get(key)))
}
}
return values.Encode()
}
}
return body
}
func maskValueRecursive(value any) {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
if isSensitiveKey(key) {
typed[key] = maskValue("")
continue
}
maskValueRecursive(item)
}
case []any:
for _, item := range typed {
maskValueRecursive(item)
}
}
}
func isSensitiveKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
switch key {
case "authorization", "cookie", "set-cookie", "x-session-id", "password", "token", "code", "session_id":
return true
default:
return false
}
}
func maskValue(_ string) string {
return "******"
}

View File

@@ -36,6 +36,7 @@ func init() {
zapcore.AddSync(os.Stdout), zapcore.AddSync(os.Stdout),
zap.DebugLevel, zap.DebugLevel,
) )
core = zapcore.NewTee(core, NewMemoryCore(zap.DebugLevel))
logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)) logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
sugar = logger.Sugar() sugar = logger.Sugar()
@@ -96,7 +97,9 @@ func Init(cfg Config) {
core := zapcore.NewTee( core := zapcore.NewTee(
zapcore.NewCore(encoderJson, writeSyncer, zapLevel), zapcore.NewCore(encoderJson, writeSyncer, zapLevel),
zapcore.NewCore(encoderConsole, zapcore.AddSync(os.Stdout), zapLevel)) zapcore.NewCore(encoderConsole, zapcore.AddSync(os.Stdout), zapLevel),
NewMemoryCore(zapLevel),
)
logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)) logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
sugar = logger.Sugar() sugar = logger.Sugar()

View File

@@ -2,9 +2,14 @@ package request
import ( import (
"crypto/tls" "crypto/tls"
"encoding/json"
"net/http" "net/http"
"net/url"
"time" "time"
"ckwk/pkg/log"
"go.uber.org/zap/zapcore"
"resty.dev/v3" "resty.dev/v3"
) )
@@ -15,7 +20,8 @@ var (
const ( 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" 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
) )
type Config struct { type Config struct {
@@ -35,24 +41,91 @@ func DefaultConfg() *Config {
} }
} }
// NewClient 创建一个标准的 Resty 客户端 func normalizeConfig(cfg *Config) *Config {
func NewClient(cfg *Config) *resty.Client { defaults := DefaultConfg()
if cfg == nil { if cfg == nil {
cfg = DefaultConfg() return defaults
} }
client := resty.New() 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("代理地址解析失败,已忽略")
}
}
return transport
}
func ApplyConfig(client *resty.Client, cfg *Config) {
cfg = normalizeConfig(cfg)
client.SetHeader("User-Agent", cfg.UserAgent) client.SetHeader("User-Agent", cfg.UserAgent)
client.SetTimeout(cfg.Timeout) client.SetTimeout(cfg.Timeout)
client.SetRetryCount(3) client.SetRetryCount(3)
client.SetTransport(buildTransport(cfg))
client.SetDebug(cfg.Debug)
client.SetDebugBodyLimit(DefaultDebugBody)
}
client.SetTLSClientConfig(&tls.Config{ // NewClient 创建一个标准的 Resty 客户端
InsecureSkipVerify: !cfg.VerifySSL, 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)
}) })
client.SetDebugLogFormatter(nil)
if cfg.Proxy != "" { ApplyConfig(client, cfg)
client.SetProxy(cfg.Proxy)
}
return client return client
} }

2
web/.gitignore vendored
View File

@@ -1,2 +0,0 @@
frontend
dist

1
web/frontend Submodule

Submodule web/frontend added at 13f0be162b