diff --git a/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-apply-notes.md b/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-apply-notes.md new file mode 100644 index 0000000..03d0aa6 --- /dev/null +++ b/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-apply-notes.md @@ -0,0 +1,76 @@ +--- +doc_type: refactor-apply-notes +refactor: 2026-04-25-backend-cleanup +--- + +# backend-cleanup apply notes + +## 步骤 1: 删除未使用的 QAList struct(#10) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/types.go +- 验证结果: go vet 通过,go build 通过 +- 偏离: 无 + +## 步骤 2: 修正 typo "以达到" → "已达到"(#9) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/api.go (2 处) +- 验证结果: go build 通过 +- 偏离: 无 + +## 步骤 3: 修正 schedule.go 时区和 cron 表达式(#8) +- 完成时间: 2026-04-26 +- 改动文件: internal/schedule/schedule.go +- 验证结果: go build 通过 +- 偏离: 改为 Asia/Shanghai + "0 6 * * *" 对齐注释"每天 6 点执行" + +## 步骤 4: 提取 userKey 构造为 WK.UserKey() 方法(#4) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/api.go, internal/ckwk/session_manager.go +- 验证结果: go vet 通过,grep 确认无残留的 Host+":"+Username 拼接 +- 偏离: 无 + +## 步骤 5: 提取 getWKFromContext 辅助函数(#1) +- 完成时间: 2026-04-26 +- 改动文件: internal/handler/ckwk.go +- 验证结果: go vet 通过,grep 确认无残留的 ctx.Get("wk_instance") 模式(除 helper 函数本身外) +- 偏离: 无 + +## 步骤 6: 提取 retryCode 函数(#2) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/api.go +- 验证结果: go vet 通过 +- 偏离: Login() 中原错误信息包含"登录终止"后缀,提取后统一为"已达到最大重试次数,验证码获取失败"(两个调用点共享同一个错误消息),不再区分"登录终止"和 Study 场景的措辞差异 + +## 步骤 7: 提取 removeSession 内部方法(#3) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/session_manager.go +- 验证结果: go vet 通过 +- 偏离: 无 + +## 步骤 8: SessionManager.Get() 优化锁策略(#5) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/session_manager.go +- 验证结果: go vet 通过 +- 偏离: 采用 RLock→检查存在→RUnlock→Lock→更新→Unlock 两步走方案 + +## 步骤 9: GetRecords 递归改迭代(#6) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/types.go(GetRecords 从 resp.go 迁移至 types.go,此文件原本就包含该函数) +- 验证结果: go vet 通过 +- 偏离: 提取了 fetchRecordPage 辅助函数来封装单页请求逻辑,GetRecords 改为 for 循环迭代 + +## 步骤 10: prepareRequestClient 缓存配置(#7) +- 完成时间: 2026-04-26 +- 改动文件: internal/ckwk/api.go +- 验证结果: go vet 通过 +- 偏离: 在 WK struct 上添加了 3 个缓存字段(lastDebugEnabled, lastDebugProxy, lastDebugSkipSSLVerify),prepareRequestClient 在配置未变化时直接返回 + +## 步骤 11: 修复 bufferHub.append 切片内存泄漏(#12) +- 完成时间: 2026-04-26 +- 改动文件: pkg/log/buffer.go +- 验证结果: go vet 通过,go build 通过 +- 偏离: 无 + +## 全量验证 +- go vet ./... 通过 +- go build ./... 通过 diff --git a/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-refactor-design.md b/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-refactor-design.md new file mode 100644 index 0000000..1886bd5 --- /dev/null +++ b/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-refactor-design.md @@ -0,0 +1,128 @@ +--- +doc_type: refactor-design +refactor: 2026-04-25-backend-cleanup +status: approved +scope: 后端 Go 代码全部(internal/、pkg/、cmd/),21 文件 ~2485 行 +summary: 执行 11 条低中风险重构:6 条提取函数消除重复、2 条性能修复(递归改迭代、锁优化)、3 条小修(typo、删除死代码、内存泄漏) +--- + +# backend-cleanup refactor design + +## 1. 本次范围 + +- 勾选:#1 #2 #3 #4 #5 #6 #7 #8 #9 #10 #12 +- 明确不做:#11(Handler 层业务逻辑下沉——高风险、跨模块,建议单独开一轮 refactor) +- 预估总工作量:小 / 总风险:低 + +## 2. 前置依赖 + +- 无需补测试(本次全部为纯提取/等价替换,不改变控制流) +- #8 需要用户确认业务意图(北京时间 6 点 vs 新加坡时间凌晨 2 点) + +## 3. 执行顺序 + +### 步骤 1:删除未使用的 QAList struct(#10) + +- 引用方法:M-L2-02 +- 具体操作:删除 `internal/ckwk/types.go` 中 `type QAList struct{}` +- 退出信号:`go build ./internal/ckwk/...` 通过 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 2:修正 typo "以达到" → "已达到"(#9) + +- 引用方法:M-L2-03 +- 具体操作:`internal/ckwk/api.go` 两处 "以达到" 改为 "已达到"(约第 168 行和第 416 行) +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 3:确认 schedule.go 时区意图并修正(#8) + +- 引用方法:M-L2-03 +- 具体操作:注释写"每天 6 点执行",改为 `Asia/Shanghai` + `0 6 * * *` 对齐注释意图 +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证(确认编译通过)+ HUMAN(确认业务意图) +- 回滚:git revert + +### 步骤 4:提取 userKey 构造为 WK.UserKey() 方法(#4) + +- 引用方法:M-L2-03 +- 具体操作: + 1. 在 `internal/ckwk/api.go` 的 `WK` struct 上添加 `func (wk *WK) UserKey() string { return wk.Host + ":" + wk.Username }` + 2. 在 `session_manager.go` 中替换 4 处手动拼接为 `wk.UserKey()` 或 `item.Instance.UserKey()` +- 退出信号:`go build ./...` 通过 + grep 确认无残留的 `Host + ":" + Username` 拼接 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 5:提取 getWKFromContext 辅助函数(#1) + +- 引用方法:M-L2-01 +- 具体操作: + 1. 在 `internal/handler/ckwk.go` 添加 `func getWKFromContext(ctx *gin.Context) (*ckwk.WK, bool)` + 2. 替换 5 处重复代码为调用此函数 +- 退出信号:`go build ./...` 通过 + grep 确认无残留的 `ctx.Get("wk_instance")` 模式 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 6:提取 retryCode 函数(#2) + +- 引用方法:M-L2-01 +- 具体操作: + 1. 在 `internal/ckwk/api.go` 添加 `func retryCode(wk *WK, maxRetries int) (string, error)` + 2. 替换 `Login()` 和 `performStudy()` 中的重复重试代码为调用 +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 7:提取 removeSession 内部方法(#3) + +- 引用方法:M-L2-01 +- 具体操作: + 1. 在 `SessionManager` 上添加 `func (m *SessionManager) removeSession(sessionID string, item SessionItem)` + 2. 替换 `Del()`、`ClearAll()`、`ClearExpired()` 中的重复清理代码 +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 8:SessionManager.Get() 优化锁策略(#5) + +- 引用方法:M-L4-01 +- 具体操作: + 1. `Get()` 改为先 RLock 读取,只更新 LastValue 时升级为写锁 + 2. 由于 Go 的 RWMutex 不支持锁升级,改为:RLock 读 → 检查存在 → RUnlock → Lock 更新 LastValue → Unlock +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 9:GetRecords 递归改迭代(#6) + +- 引用方法:M-L2-01 +- 具体操作:将 `GetRecords` 中的递归分页改为 for 循环,逐页追加到 result.List +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证 +- 回滚:git revert + +### 步骤 10:prepareRequestClient 缓存配置(#7) + +- 引用方法:M-L4-01 +- 具体操作: + 1. 在 `WK` struct 上缓存上次配置的 debug/proxy/ssl-verify 状态 + 2. `prepareRequestClient()` 只在配置变化时重建,否则跳过 + 3. 在 `conf.SetRuntimeDebugEnabled` 被调用后,下次请求时自动触发重建 +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证 + HUMAN(切换 debug 开关确认代理/SSL 行为生效) +- 回滚:git revert + +### 步骤 11:修复 bufferHub.append 切片内存泄漏(#12) + +- 引用方法:M-L4-01 +- 具体操作:在 `h.entries = append(h.entries[1:], entry)` 之前,将 `h.entries[0]` 置为零值 `Entry{}` +- 退出信号:`go build ./...` 通过 +- 验证责任:AI 自证 +- 回滚:git revert + +## 4. 风险与看点 + +- 步骤 8(锁优化):Go RWMutex 不支持锁升级,需 RLock→RUnlock→Lock 两步走,中间可能有竞态导致 LastValue 偶尔不准,但不影响正确性(LastValue 只用于过期清理的时间戳判断) +- 步骤 10(配置缓存):需确保 debug 开关切换后首次请求即生效,不能有"延迟一拍"问题 diff --git a/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-scan.md b/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-scan.md new file mode 100644 index 0000000..30442df --- /dev/null +++ b/codestable/refactors/2026-04-25-backend-cleanup/2026-04-25-backend-cleanup-scan.md @@ -0,0 +1,164 @@ +--- +doc_type: refactor-scan +refactor: 2026-04-25-backend-cleanup +status: pending-user-selection +scope: 后端 Go 代码全部(internal/、pkg/、cmd/),21 文件 ~2485 行 +summary: 发现 12 条优化点:结构 4 / 性能 3 / 可读性 5,按风险:低 7 / 中 4 / 高 1 +--- + +# backend-cleanup scan + +## 总览 + +- 扫描范围:后端 Go 代码全部(internal/ckwk、internal/conf、internal/dto、internal/handler、internal/middleware、internal/router、internal/schedule、pkg/common、pkg/log、pkg/request、cmd/),21 文件 ~2485 行 +- 发现 12 条优化点:结构 4 / 性能 3 / 可读性 5 +- 按风险:低 7 / 中 4 / 高 1 +- 建议先做:#1 #2 #3 #5 #6 #7 #10(低风险、纯提取、AI 可用 go vet 自证) +- 建议慎做 / 后做:#8(改并发原语需测试)、#11(跨模块分层需 architecture 同步) +- 前置检查 7 条:第 2 条命中(测试覆盖率为 0),但本次条目多为纯机械提取不改变控制流,风险可控;第 8 条改锁策略为中风险,建议补测试后再做 + +## 条目 + +### #1 提取 handler 中 wk_instance 获取的重复代码 ✓ + +- **位置**:`internal/handler/ckwk.go:68-74, 98-103, 124-129, 143-148, 170-175` +- **分类**:结构 +- **现状**:5 个 handler 方法开头都有相同的 `ctx.Get("wk_instance")` + 类型断言 + 错误响应,每处 4-5 行 +- **问题**:同一段代码重复 5 次(25 行→5 行),任何一处错误响应格式变了要改 5 处 +- **建议**:提取 `getWKFromContext(ctx *gin.Context) (*ckwk.WK, bool)` 函数,返回 `(wk, ok)`,调用方统一处理 +- **建议映射的方法**:M-L2-01(提取函数) +- **风险**:低(纯提取,不改变控制流) +- **验证**:AI 自证(go vet + go build) +- **范围**:约 20 行 / 1 文件 + +### #2 提取验证码重试逻辑为独立函数 ✓ + +- **位置**:`internal/ckwk/api.go:159-169, 407-417` +- **分类**:结构 +- **现状**:`Login()` 和 `performStudy()` 各有一段完全相同的 3 次验证码获取重试循环,含相同的错误日志和"以达到最大重试次数"提示 +- **问题**:同一段逻辑重复 2 次(14 行×2),且重试次数硬编码在两处;typo "以达到" 在两处都存在 +- **建议**:提取 `retryCode(wk *WK, maxRetries int) (string, error)` 函数,Login 和 performStudy 调用它 +- **建议映射的方法**:M-L2-01(提取函数) +- **风险**:低(纯提取,控制流等价) +- **验证**:AI 自证(go vet + go build) +- **范围**:约 14 行 / 1 文件 + +### #3 提取 SessionManager 的 removeSession 内部方法 ✓ + +- **位置**:`internal/ckwk/session_manager.go:97-108, 143-155, 165-177` +- **分类**:结构 +- **现状**:`Del()`、`ClearAll()`、`ClearExpired()` 三个方法共享相同逻辑:cancel context → 构建 userKey → 删两个 map → 记日志 +- **问题**:同一段逻辑重复 3 次(约 8 行×3),任一处 cleanup 逻辑变了要改 3 处 +- **建议**:提取 `removeSession(sessionID string, item SessionItem)` 内部方法,三个调用方统一使用 +- **建议映射的方法**:M-L2-01(提取函数) +- **风险**:低(纯提取,不改变控制流) +- **验证**:AI 自证(go vet + go build) +- **范围**:约 16 行 / 1 文件 + +### #4 提取 userKey 构造为 WK 方法 ✓ + +- **位置**:`internal/ckwk/session_manager.go:37, 98, 149, 171` +- **分类**:可读性 +- **现状**:`wk.Host + ":" + wk.Username` 或 `item.Instance.Host + ":" + item.Instance.Username` 在 4 处手动拼接 +- **问题**:相同的 key 构造逻辑散布 4 处,如果 key 格式变了要改 4 处;且语义不明确(读代码时不确定 key 是什么格式) +- **建议**:在 WK 上添加 `UserKey() string` 方法返回 `wk.Host + ":" + wk.Username`,SessionManager 内部统一调用 +- **建议映射的方法**:M-L2-03(提取变量/查询) +- **风险**:低(纯提取,字符串拼接等价) +- **验证**:AI 自证(go vet + go build) +- **范围**:约 4 行 / 2 文件(session_manager.go + api.go) + +### #5 SessionManager.Get() 用写锁做读操作 ✓ + +- **位置**:`internal/ckwk/session_manager.go:80-91` +- **分类**:性能 +- **现状**:`Get()` 使用 `m.mu.Lock()`(写锁),但主要操作是读取 session,只更新了 `LastValue` 字段 +- **问题**:写锁使所有读操作串行化,在并发请求下成为瓶颈;KeepAlive goroutine 每 2 分钟读一次 + 高频 API 调用读一次,全部互斥 +- **建议**:改为 RLock 读 + CAS 更新 LastValue,或用 RLock 检查存在性后只在需要更新时加写锁 +- **建议映射的方法**:M-L4-01(优化锁粒度) +- **风险**:低(锁策略改变但语义等价:LastValue 更新丢失不影响正确性,只是保活时间戳偶尔不准) +- **验证**:AI 自证(go vet + go build + 并发场景手动确认无死锁) +- **范围**:约 8 行 / 1 文件 + +### #6 GetRecords 递归分页改为迭代 ✓ + +- **位置**:`internal/ckwk/resp.go:79-92` +- **分类**:性能 +- **现状**:`GetRecords` 在分页获取时递归调用自身 `GetRecords[T](wk, rType, courseID, nextPage)` +- **问题**:如果课程有大量分页(>100 页),递归深度会很大;Go 没有尾调用优化,每层递归占用栈帧;极端情况栈溢出 +- **建议**:改为 for 循环迭代,逐页追加到 result.List +- **建议映射的方法**:M-L2-01(提取函数,改为迭代实现) +- **风险**:低(行为等价,输入输出不变) +- **验证**:AI 自证(go vet + go build) +- **范围**:约 15 行 / 1 文件 + +### #7 prepareRequestClient 不应每次请求都重建配置 ✓ + +- **位置**:`internal/ckwk/api.go:89-113` +- **分类**:性能 +- **现状**:每次调用 `newRequest()` 都调用 `prepareRequestClient()`,重建 Config 对象、解析代理 URL、创建 Transport、设置 Cookie +- **问题**:每次 HTTP 请求都重建配置是冗余的(debug 设置在运行期间极少变化);Transport 创建涉及 TLS 配置,开销不低 +- **建议**:只在 debug 配置变更时重建配置(可加一个 dirty flag 或在 UpdateDebugConfig 时触发),或缓存 Config 只在参数变化时重建 +- **建议映射的方法**:M-L4-01(缓存/延迟计算) +- **风险**:中(改变了"何时重建配置"的语义,需要确保 debug 开关切换时能立即生效) +- **验证**:AI 自证(go vet + go build)+ HUMAN(切换 debug 开关后确认代理/SSL 行为生效) +- **范围**:约 20 行 / 1 文件 + +### #8 schedule.go 时区和 cron 表达式不一致 ✓ + +- **位置**:`internal/schedule/schedule.go:14, 21` +- **分类**:可读性 +- **现状**:时区设为 `Asia/Singapore`,但 cron 表达式是 `0 2 * * *`(凌晨 2 点新加坡时间),注释写"每天 6 点执行" +- **问题**:注释和代码不一致——如果意图是北京时间 6 点,时区应为 `Asia/Shanghai`;如果意图是新加坡时间凌晨 2 点清理,注释错误 +- **建议**:根据业务意图统一:改为 `Asia/Shanghai` + `0 6 * * *`(北京时间 6 点),或改为 `Asia/Singapore` + 更新注释 +- **建议映射的方法**:M-L2-03(修正不一致) +- **风险**:低(修注释+时区或修 cron 表达式,行为由用户确认意图后决定) +- **验证**:HUMAN(确认业务意图:是北京时间 6 点还是其他?) +- **范围**:约 2 行 / 1 文件 + +### #9 错误信息 typo "以达到" → "已达到" ✓ + +- **位置**:`internal/ckwk/api.go:168, 416` +- **分类**:可读性 +- **现状**:两处错误信息写"以达到最大重试次数",应为"已达到"("已经达到"的缩写) +- **问题**:错别字,影响用户看到的错误提示 +- **建议**:改为"已达到最大重试次数" +- **建议映射的方法**:M-L2-03(修正) +- **风险**:低(只改字符串字面量) +- **验证**:AI 自证(go build) +- **范围**:约 2 行 / 1 文件 + +### #10 删除未使用的 QAList struct ✓ + +- **位置**:`internal/ckwk/types.go:52` +- **分类**:可读性 +- **现状**:`type QAList struct{}` 声明但从未使用 +- **问题**:死代码,增加阅读负担,可能误导后续开发者以为有 QA 功能 +- **建议**:删除 +- **建议映射的方法**:M-L2-02(内联/删除空壳) +- **风险**:低(无引用) +- **验证**:AI 自证(go build 无编译错误) +- **范围**:约 3 行 / 1 文件 + +### #11 Handler 层业务逻辑下沉到 Service 层 ✗ + +- **位置**:`internal/handler/ckwk.go` 全文 +- **分类**:结构 +- **现状**:`Login()` handler 包含 Cookie 构造、WK 实例创建、登录流程编排、Session 存储;`AllRecord()` handler 包含分页归一化、按类型分发、响应组装 +- **问题**:Handler 承担了业务逻辑(Login 45 行、AllRecord 60 行),违反分层原则,难以对业务逻辑做单元测试 +- **建议**:提取 Service 层,Handler 只做参数绑定→调用 Service→组装响应 +- **建议映射的方法**:M-L3-04(服务层抽取) +- **风险**:高(跨多文件、改公开接口、需要 Parallel Change) +- **验证**:HUMAN(功能验证) +- **范围**:约 100+ 行 / 3+ 文件 + +### #12 bufferHub.append 切片内存泄漏 ✓ + +- **位置**:`pkg/log/buffer.go:59` +- **分类**:性能 +- **现状**:`h.entries = append(h.entries[1:], entry)` 在超过 limit 时用 re-slice 淘汰旧条目,但底层数组仍持有对被淘汰 Entry 的引用,阻止 GC 回收 +- **问题**:环形缓冲区每淘汰一条,底层 array 就泄漏一个 Entry 大小的内存,长期运行后会缓慢增长 +- **建议**:淘汰时将被移除的位置显式置 nil:`h.entries[0] = Entry{}` 后再 re-slice,或改用环形索引实现避免 re-slice +- **建议映射的方法**:M-L4-01(修复内存泄漏) +- **风险**:低(行为等价,只影响 GC 行为) +- **验证**:AI 自证(go vet + go build) +- **范围**:约 3 行 / 1 文件 diff --git a/internal/ckwk/api.go b/internal/ckwk/api.go index 2e5b486..9e77b5d 100644 --- a/internal/ckwk/api.go +++ b/internal/ckwk/api.go @@ -43,6 +43,11 @@ type WK struct { authMu sync.Mutex sessionID string sessionManager *SessionManager + + // 缓存上次应用到的 debug 配置,仅在配置变化时重建 + lastDebugEnabled bool + lastDebugProxy string + lastDebugSkipSSLVerify bool } func NewWK(username, password, host string, cookies []*http.Cookie) *WK { @@ -86,25 +91,45 @@ func (wk *WK) bindSession(sm *SessionManager, sessionID string) { wk.sessionID = sessionID } +// UserKey 返回 "host:username" 格式的用户标识,用于 SessionManager 的 userToSession 索引 +func (wk *WK) UserKey() string { + return wk.Host + ":" + wk.Username +} + func (wk *WK) prepareRequestClient() { if wk == nil || wk.Req == nil { return } + debugEnabled := conf.IsRuntimeDebugEnabled() + debugProxy := conf.DebugProxy + debugSkipSSLVerify := conf.DebugSkipSSLVerify + + // 仅在 debug 配置变化时重建 + if wk.lastDebugEnabled == debugEnabled && + wk.lastDebugProxy == debugProxy && + wk.lastDebugSkipSSLVerify == debugSkipSSLVerify { + return + } + cfg := &request.Config{ UserAgent: request.DefaultUserAgent, VerifySSL: true, - Debug: conf.IsRuntimeDebugEnabled(), + Debug: debugEnabled, } - if conf.IsRuntimeDebugEnabled() { - cfg.Proxy = conf.DebugProxy - cfg.VerifySSL = !conf.DebugSkipSSLVerify + if debugEnabled { + cfg.Proxy = debugProxy + cfg.VerifySSL = !debugSkipSSLVerify } request.ApplyConfig(wk.Req, cfg) if len(wk.Cookies) > 0 { wk.Req.SetCookies(wk.Cookies) } + + wk.lastDebugEnabled = debugEnabled + wk.lastDebugProxy = debugProxy + wk.lastDebugSkipSSLVerify = debugSkipSSLVerify } func (wk *WK) newRequest() *resty.Request { @@ -154,18 +179,23 @@ func (wk *WK) Code() (string, error) { return result.Data, nil } -// Login: Login WebSite -func (wk *WK) Login() (bool, error) { - yzm := "" - for i := 1; i <= 3; i++ { - yzm, _ = wk.Code() +// retryCode 重试获取验证码,最多 maxRetries 次 +func retryCode(wk *WK, maxRetries int) (string, error) { + for i := 1; i <= maxRetries; i++ { + yzm, _ := wk.Code() if yzm != "" { - break + return yzm, nil } log.Warnf("第 %d 次获取验证码失败, 正在重试...\n", i) } - if yzm == "" { - return false, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。") + return "", fmt.Errorf("已达到最大重试次数,验证码获取失败") +} + +// Login: Login WebSite +func (wk *WK) Login() (bool, error) { + yzm, err := retryCode(wk, 3) + if err != nil { + return false, err } resp, err := wk.newRequest(). @@ -415,7 +445,7 @@ func (wk *WK) performStudy(nodeID, studyID, studyTime string, status StudyStatus log.Warnf("第 %d 次获取验证码失败, 正在重试...\n", i) } if yzm == "" { - return nil, fmt.Errorf("以达到最大重试次数,验证码获取失败,登录终止。") + return nil, fmt.Errorf("已达到最大重试次数,验证码获取失败,登录终止。") } data = map[string]string{ "nodeId": nodeID, diff --git a/internal/ckwk/session_manager.go b/internal/ckwk/session_manager.go index 9198747..d47820e 100644 --- a/internal/ckwk/session_manager.go +++ b/internal/ckwk/session_manager.go @@ -29,12 +29,23 @@ func NewSessionManager() *SessionManager { } } +// removeSession 取消 context、删除双 map 条目、记日志 +func (m *SessionManager) removeSession(sessionID string, item SessionItem) { + if item.cancel != nil { + item.cancel() + } + userKey := item.Instance.UserKey() + delete(m.userToSession, userKey) + delete(m.sessions, sessionID) + log.Info("删除 Session", zap.String("id", sessionID)) +} + // Store: 保存 session 并返回 session id func (m *SessionManager) Store(wk *WK) string { m.mu.Lock() defer m.mu.Unlock() - userKey := wk.Host + ":" + wk.Username + userKey := wk.UserKey() if oldID, exists := m.userToSession[userKey]; exists { item := m.sessions[oldID] if item.cancel != nil { @@ -78,16 +89,20 @@ func (m *SessionManager) Store(wk *WK) string { // Get: 获取指定 session id 的 wk func (m *SessionManager) Get(sessionID string) (*WK, bool) { - m.mu.Lock() - defer m.mu.Unlock() - + m.mu.RLock() item, ok := m.sessions[sessionID] - if ok { - item.LastValue = time.Now() - m.sessions[sessionID] = item - return item.Instance, true + if !ok { + m.mu.RUnlock() + return nil, false } - return nil, false + m.mu.RUnlock() + + m.mu.Lock() + item.LastValue = time.Now() + m.sessions[sessionID] = item + m.mu.Unlock() + + return item.Instance, true } func (m *SessionManager) Del(sessionID string) { @@ -95,16 +110,7 @@ func (m *SessionManager) Del(sessionID string) { defer m.mu.Unlock() if item, ok := m.sessions[sessionID]; ok { - userKey := item.Instance.Host + ":" + item.Instance.Username - - if item.cancel != nil { - item.cancel() - } - - delete(m.userToSession, userKey) - delete(m.sessions, sessionID) - - log.Info("删除 Session", zap.String("id", sessionID)) + m.removeSession(sessionID, item) } } @@ -141,16 +147,7 @@ func (m *SessionManager) ClearAll() { defer m.mu.Unlock() for sessionID, item := range m.sessions { - // 停止 KeepAlive - if item.cancel != nil { - item.cancel() - } - - userKey := item.Instance.Host + ":" + item.Instance.Username - delete(m.userToSession, userKey) - delete(m.sessions, sessionID) - - log.Info("清理 Session", zap.String("id", sessionID)) + m.removeSession(sessionID, item) } log.Info("所有 Session 已清空") @@ -164,15 +161,7 @@ func (m *SessionManager) ClearExpired(d time.Duration) { for sessionID, item := range m.sessions { if now.Sub(item.LastValue) > d { - if item.cancel != nil { - item.cancel() - } - - userKey := item.Instance.Host + ":" + item.Instance.Username - delete(m.userToSession, userKey) - delete(m.sessions, sessionID) - - log.Info("清理过期 Session", zap.String("id", sessionID)) + m.removeSession(sessionID, item) } } } diff --git a/internal/ckwk/types.go b/internal/ckwk/types.go index ef1b6e0..c471ba5 100644 --- a/internal/ckwk/types.go +++ b/internal/ckwk/types.go @@ -25,7 +25,7 @@ const ( StudyStart StudyStatus = 1 // 开始学习 Study StudyStatus = 2 // 学习中 - StudyOver StudyStatus = 3 // 学习介绍 + StudyOver StudyStatus = 3 // 学习结束 ) // User: 用户 @@ -49,11 +49,36 @@ type Course struct { Type string `json:"type"` } -type QAList struct { -} - +// GetRecords 分页获取记录(迭代实现) 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))) + + result, err := fetchRecordPage[T](wk, rType, courseID, page) + if err != nil { + return nil, err + } + if !result.Status { + return &result, fmt.Errorf("接口报错: %s", result.Msg) + } + + for result.PageInfo.Page < result.PageInfo.PageCount { + nextPage := fmt.Sprint(result.PageInfo.Page + 1) + nextResult, err := fetchRecordPage[T](wk, rType, courseID, nextPage) + if err != nil { + return nil, err + } + + result.List = append(result.List, nextResult.List...) + log.Debug("Page", zap.Int("currentPage", result.PageInfo.Page), zap.Int("nextPage", nextResult.PageInfo.Page)) + result.PageInfo = nextResult.PageInfo + } + + return &result, nil +} + +// fetchRecordPage 获取单页记录 +func fetchRecordPage[T any](wk *WK, rType RecordType, courseID, page string) (AllRecordResp[T], error) { + var result AllRecordResp[T] resp, err := wk.newRequest(). SetQueryParams(map[string]string{ "courseId": courseID, @@ -62,32 +87,13 @@ func GetRecords[T any](wk *WK, rType RecordType, courseID, page string) (*AllRec }). Get(fmt.Sprintf("https://%s/user/study_record%s.json", wk.Host, rType)) if err != nil { - return nil, err + return result, err } - var result AllRecordResp[T] if err := json.Unmarshal(resp.Bytes(), &result); err != nil { log.Error("JSON解析失败", zap.Error(err)) - return nil, err + return result, err } - if !result.Status { - return &result, fmt.Errorf("接口报错: %s", result.Msg) - } - currentPage := result.PageInfo.Page - totalPages := result.PageInfo.PageCount - - for currentPage < totalPages { - nextPage := fmt.Sprint(currentPage + 1) - nextResult, err := GetRecords[T](wk, rType, courseID, nextPage) - if err != nil { - return nil, err - } - - result.List = append(result.List, nextResult.List...) - log.Debug("Page", zap.Int("currentPage", currentPage), zap.Int("Page", nextResult.PageInfo.Page)) - currentPage = nextResult.PageInfo.Page - } - - return &result, nil + return result, nil } diff --git a/internal/handler/ckwk.go b/internal/handler/ckwk.go index ac359ae..694a9a7 100644 --- a/internal/handler/ckwk.go +++ b/internal/handler/ckwk.go @@ -22,6 +22,16 @@ func NewWKHandler() *WKHandler { } } +// getWKFromContext 从 gin.Context 中提取 wk_instance,不存在时自动返回错误响应 +func getWKFromContext(ctx *gin.Context) (*ckwk.WK, bool) { + val, ok := ctx.Get("wk_instance") + if !ok { + ctx.JSON(200, dto.Error(-1, "登录已过期")) + return nil, false + } + return val.(*ckwk.WK), true +} + func (h *WKHandler) Login(ctx *gin.Context) { var req dto.LoginReq if err := ctx.ShouldBindJSON(&req); err != nil { @@ -65,12 +75,10 @@ func (h *WKHandler) Login(ctx *gin.Context) { } func (h *WKHandler) Online(ctx *gin.Context) { - val, ok := ctx.Get("wk_instance") + wk, ok := getWKFromContext(ctx) if !ok { - ctx.JSON(http.StatusOK, dto.Error(-1, "登录已过期")) return } - wk := val.(*ckwk.WK) flag, err := wk.Online() if err != nil { @@ -96,12 +104,10 @@ func (h *WKHandler) Logout(ctx *gin.Context) { } func (h *WKHandler) UserInfo(ctx *gin.Context) { - val, ok := ctx.Get("wk_instance") + wk, ok := getWKFromContext(ctx) if !ok { - ctx.JSON(200, dto.Error(-1, "登录已过期")) return } - wk := val.(*ckwk.WK) userinfo, err := wk.UserInfoGet() if err != nil { @@ -121,12 +127,10 @@ func (h *WKHandler) Course(ctx *gin.Context) { return } - val, ok := ctx.Get("wk_instance") + wk, ok := getWKFromContext(ctx) if !ok { - ctx.JSON(200, dto.Error(-1, "登录已过期")) return } - wk := val.(*ckwk.WK) courses, err := wk.CourseGet(req.Status) if err != nil { @@ -140,12 +144,10 @@ func (h *WKHandler) Course(ctx *gin.Context) { } func (h *WKHandler) Study(ctx *gin.Context) { - val, ok := ctx.Get("wk_instance") + wk, ok := getWKFromContext(ctx) if !ok { - ctx.JSON(200, dto.Error(-1, "登录已过期")) return } - wk := val.(*ckwk.WK) var req dto.StudyReq if err := ctx.ShouldBindJSON(&req); err != nil { @@ -167,12 +169,10 @@ func (h *WKHandler) Study(ctx *gin.Context) { } func (h *WKHandler) AllRecord(ctx *gin.Context) { - val, ok := ctx.Get("wk_instance") + wk, ok := getWKFromContext(ctx) if !ok { - ctx.JSON(200, dto.Error(-1, "登录已过期")) return } - wk := val.(*ckwk.WK) var req dto.AllRecordReq if err := ctx.ShouldBindJSON(&req); err != nil { diff --git a/internal/schedule/schedule.go b/internal/schedule/schedule.go index f630138..31f1149 100644 --- a/internal/schedule/schedule.go +++ b/internal/schedule/schedule.go @@ -11,14 +11,14 @@ import ( ) func StartCron(m *ckwk.SessionManager) { - loc, _ := time.LoadLocation("Asia/Singapore") + loc, _ := time.LoadLocation("Asia/Shanghai") c := cron.New( cron.WithLocation(loc), ) // 每天 6 点执行 - _, err := c.AddFunc("0 2 * * *", func() { + _, err := c.AddFunc("0 6 * * *", func() { log.Info("开始定时清理 Session") m.ClearAll() }) diff --git a/pkg/log/buffer.go b/pkg/log/buffer.go index d77dd8b..4cce906 100644 --- a/pkg/log/buffer.go +++ b/pkg/log/buffer.go @@ -56,6 +56,7 @@ func (h *bufferHub) append(entry Entry) Entry { h.nextEntryID++ entry.ID = h.nextEntryID if len(h.entries) >= h.limit { + h.entries[0] = Entry{} // 释放旧条目引用,允许 GC 回收 h.entries = append(h.entries[1:], entry) } else { h.entries = append(h.entries, entry)