13 Commits
v0.1.0 ... main

Author SHA1 Message Date
c38470e104 chore: update frontend submodule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 21:13:40 +08:00
87441dbdc3 chore: 将 .claude/ 和 skills-lock.json 加入 .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 14:08:17 +08:00
536aa506f9 refactor: 后端代码优化 11 项(codestable/refactors/2026-04-25-backend-cleanup)
- 提取 getWKFromContext 辅助函数,消除 handler 中 5 处重复代码
- 提取 retryCode 函数,消除 Login/performStudy 中验证码重试重复
- 提取 removeSession 内部方法,消除 Del/ClearAll/ClearExpired 中 3 处重复
- 提取 WK.UserKey() 方法,消除 4 处 userKey 手动拼接
- SessionManager.Get() 改用 RLock 优化读性能
- GetRecords 递归分页改为迭代,避免栈溢出
- prepareRequestClient 添加配置缓存,仅在 debug 设置变化时重建
- 修正 schedule.go 时区为 Asia/Shanghai + cron "0 6 * * *"
- 修正 typo "以达到" → "已达到"
- 删除未使用的 QAList struct
- 修复 bufferHub.append 切片内存泄漏

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 13:07:45 +08:00
2a6732ffe7 fix: 修复6个bug并接入CodeStable工作流
Bug修复:
- GetWorkList 使用了错误的 RecordType (RecordStudy→RecordWork)
- AllRecord handler 返回错误的分页信息 (page硬编码1, pageSize用RecordsCount)
- CourseParse creditNode nil panic (加nil检查)
- WebSocket CheckOrigin 安全漏洞 (release模式限制为同源)
- math/rand 可预测 (替换为 crypto/rand)
- GetDiscussList 未实现 (补全实现, 移除重复路由)

其他:
- 接入 CodeStable 工作流体系 (codestable/ 骨架 + AGENTS.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 19:55:57 +08:00
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
43 changed files with 3117 additions and 181 deletions

3
.gitignore vendored
View File

@@ -1 +1,4 @@
bin
.gocache
.claude/
skills-lock.json

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

26
AGENTS.md Normal file
View File

@@ -0,0 +1,26 @@
# AGENTS.md
本文件是 AI 协作的项目级硬约束入口。所有 CodeStable 子工作流默认遵守本文件的所有规则。
## 代码规范
- Go 语言项目,遵循标准 Go 项目布局cmd / internal / pkg
- 使用 Taskfile 管理构建流程,不直接调用 go build
- 前端代码在 web/frontend/ 下,通过 git submodule 管理
- API 路由定义在 internal/router/handler 在 internal/handler/
## 禁止事项
- 不要修改 go.mod / go.sum 中的依赖,除非明确要求
- 不要直接修改 web/frontend/ 下的前端代码(它是 submodule
- 不要跳过 Taskfile 直接运行构建命令
## 已知坑
- 前端 submodule 需要用 `task fe:sync` 同步,不能只 git pull
- 调试功能需要手动开启,环境变量 CKWK_DEBUG_ENABLED=true 可默认开启
- 代理和跳过 SSL 校验只在设置页开启调试后才生效
## UI 验证要求
- 涉及前端视觉/交互的改动,必须在浏览器里人工验证

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/) 进行项目构建**
可通过环境变量配置本地代理 / 跳过 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: 构建前端 + 后端 📦
* dev: 同时启动前后端(开发模式)🔥
* 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 命令定义

View File

@@ -25,24 +25,41 @@ vars:
-X "ckwk/internal/conf.GitCommit={{.GIT_COMMIT}}"
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:
desc: 安装前端依赖 📦
deps: [fe:sync]
dir: "{{.FRONTEND_DIR}}"
cmds:
- pnpm install
fe:dev:
desc: 启动前端开发服务器 🚀
deps: [fe:sync]
dir: "{{.FRONTEND_DIR}}"
cmds:
- pnpm dev
fe:build:
desc: 构建前端 🏗️
deps: [fe:sync]
dir: "{{.FRONTEND_DIR}}"
cmds:
- pnpm build

View File

@@ -0,0 +1,38 @@
# 刷课平台后端 架构总入口
> 状态:骨架(待填充)
> 创建日期2026-04-25
## 1. 项目简介
刷课平台后端,提供登录、课程列表获取、网课记录查询、学习接口等功能。前端项目为 wk-frontendgit submodule通过 Taskfile 管理构建与开发流程。
## 2. 核心概念 / 术语表
(待填充)
## 3. 子系统 / 模块索引
| 目录 | 职责 |
|---|---|
| `cmd/` | 启动入口 |
| `internal/ckwk/` | 网课接口封装 |
| `internal/conf/` | 项目配置 |
| `internal/dto/` | 请求响应实体 |
| `internal/handler/` | 控制层 |
| `internal/middleware/` | 中间件 |
| `internal/router/` | 路由定义 |
| `internal/schedule/` | 定时任务 |
| `pkg/common/` | 通用工具 |
| `pkg/log/` | 日志 |
| `pkg/request/` | 请求库 |
| `web/frontend/` | 前端项目submodule |
| `web/web.go` | 构建时读取前端输出目录 |
## 4. 关键架构决定
(待填充)
## 5. 已知约束 / 硬边界
(待填充)

View File

View File

View File

View File

@@ -0,0 +1,55 @@
---
doc_type: issue-fix
issue: 2026-04-25-six-bugs
status: fixed
date: 2026-04-25
severity: high
tags: [bug, security, nil-panic, wrong-data, crypto]
---
# 2026-04-25 six-bugs fix notes
6 个 bug 定点修复,来源于 refactor 扫描阶段的前置检查发现。
## Fix #1: GetWorkList 使用了错误的 RecordType
- **文件**: `internal/ckwk/api.go:479`
- **问题**: `GetWorkList` 调用 `GetRecords[WorkList](wk, RecordStudy, ...)` 传了 `RecordStudy` 而非 `RecordWork`,导致请求作业记录时实际返回的是学习记录
- **修复**: `RecordStudy``RecordWork`
- **风险**: 低,单行改动,类型签名已约束泛型
## Fix #2: AllRecord handler 返回错误的分页信息
- **文件**: `internal/handler/ckwk.go:227-228`
- **问题**: `page_info.page` 硬编码为 `1``page_info.pageSize` 使用了 `RecordsCount`(总记录数)而非 `PageSize`
- **修复**: `page: 1``page: pageInfo.Page``pageSize: pageInfo.RecordsCount``pageSize: pageInfo.PageSize`
- **风险**: 低,字段语义对齐
## Fix #3: CourseParse creditNode nil panic
- **文件**: `internal/ckwk/api.go:276-277`
- **问题**: `htmlquery.FindOne` 可能返回 nil下一行 `htmlquery.InnerText(creditNode)` 会 panic
- **修复**: 加 nil 检查,`creditNode != nil` 时才提取学分
- **风险**: 低credit 默认值为 0float32 零值),行为等价
## Fix #4: WebSocket CheckOrigin 安全漏洞
- **文件**: `internal/handler/debug_log.go:17-21`
- **问题**: `CheckOrigin` 始终返回 `true`,允许任意来源的 WebSocket 连接,存在跨站劫持风险
- **修复**: debug 模式下允许所有来源开发需要release 模式下只允许无 Origin 的请求(同源请求)
- **风险**: 低debug 模式行为不变release 模式收紧安全策略
- **附**: 移除了 `router.go` 中重复的 `/api/debug/ws/logs` 路由(与 `/api/debug/logs/ws` 重复)
## Fix #5: 非安全随机数用于验证码等场景
- **文件**: `pkg/common/rand.go`
- **问题**: 使用 `math/rand` + `time.Now().UnixNano()` 种子,可预测。`Rand()` 用于验证码请求参数,`RandFloat64()` 用于防缓存时间戳
- **修复**: 替换为 `crypto/rand`,使用 `crypto/rand.Int` 生成不可预测的随机数
- **风险**: 中,`Rand()` 在验证码获取中用作 cache-bust 参数(`r=0.xxxx`),改为 crypto/rand 不影响功能但确保不可预测
## Fix #6: GetDiscussList 未实现但已接入路由
- **文件**: `internal/ckwk/api.go:489-492`
- **问题**: `GetDiscussList` 只返回 `errors.New("func not implement")`,但已通过 `AllRecord` handler 接入路由,用户请求讨论记录会得到不友好的错误
- **修复**: 实现为 `GetRecords[StudyList](wk, RecordDiscuss, courseID, page)`,与其他记录类型一致。讨论记录暂复用 `StudyList` 类型,后续如有专门的 `DiscussList` 类型可替换
- **风险**: 中,讨论记录的返回结构可能和 StudyList 不同,如果上游 API 返回的字段不在 StudyList 中会丢失。但由于之前完全不可用,现在至少能返回数据,比返回错误更好

View File

@@ -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.goGetRecords 从 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, lastDebugSkipSSLVerifyprepareRequestClient 在配置未变化时直接返回
## 步骤 11: 修复 bufferHub.append 切片内存泄漏(#12
- 完成时间: 2026-04-26
- 改动文件: pkg/log/buffer.go
- 验证结果: go vet 通过go build 通过
- 偏离: 无
## 全量验证
- go vet ./... 通过
- go build ./... 通过

View File

@@ -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
- 明确不做:#11Handler 层业务逻辑下沉——高风险、跨模块,建议单独开一轮 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
### 步骤 8SessionManager.Get() 优化锁策略(#5
- 引用方法M-L4-01
- 具体操作:
1. `Get()` 改为先 RLock 读取,只更新 LastValue 时升级为写锁
2. 由于 Go 的 RWMutex 不支持锁升级改为RLock 读 → 检查存在 → RUnlock → Lock 更新 LastValue → Unlock
- 退出信号:`go build ./...` 通过
- 验证责任AI 自证
- 回滚git revert
### 步骤 9GetRecords 递归改迭代(#6
- 引用方法M-L2-01
- 具体操作:将 `GetRecords` 中的递归分页改为 for 循环,逐页追加到 result.List
- 退出信号:`go build ./...` 通过
- 验证责任AI 自证
- 回滚git revert
### 步骤 10prepareRequestClient 缓存配置(#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 开关切换后首次请求即生效,不能有"延迟一拍"问题

View File

@@ -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 文件

View File

@@ -0,0 +1,97 @@
# 代码维度速查
写代码前先确认每个维度的档位。没明说的走默认,偏离默认的地方要标出来让用户确认。
这份文档是 CodeStable 子技能共享的口径,被 design / fastforward / issue-fix 等阶段引用。项目内的权威副本在 `codestable/reference/code-dimensions.md`,由 `cs-onboard` 从技能包释放。
---
## 核心四维(每次都要定)
### 健壮性 Robustness —— 错误处理的严苛程度
- **L1 快跑**happy path 跑通就行,异常直接崩、让它炸。适合一次性脚本、探索代码。
- **L2 够用**:捕获预期错误(文件不存在、网络超时),非预期错误往上抛。适合内部工具。
- **L3 严防**:所有外部输入验证、所有失败路径都有明确处理、关键操作幂等可重试。适合对外接口、生产系统。
### 结构 Structure —— 代码组织的颗粒度
- **inline**:全写在一起,十几行搞定的那种。
- **functions**:按职责拆函数,同一个文件内。
- **modules**:拆多个文件/模块,有明确的导入关系。
- **layers**:分层架构(如 handler / service / repository有依赖方向约束。
### 性能 Performance —— 对开销的关注度
- **careless**怎么方便怎么写O(n²) 也无所谓。
- **reasonable**:避开明显的坑(循环里查 DB、重复计算但不刻意优化。
- **budgeted**有明确的性能预算延迟、内存、QPS按预算设计数据结构和算法。
- **extreme**:榨性能,要 profiling、要基准测试、可以牺牲可读性。
### 可读性 Readability —— 写给谁看
- **self**:自己当下看得懂就行,命名可以随意。
- **team**:队友半年后还能快速上手,命名规范、关键处有注释。
- **public**:外部开发者能无背景读懂,公共 API 要有文档、示例。
- **teaching**:代码本身就是教材,每一步意图清晰、刻意展示模式。
---
## 场景维度(相关时才定)
### 可演进性 Evolvability —— 预期会怎么变
- **frozen**:接口锁死,不许改(如已发布的库 API
- **stable**:偶尔变,变动要走流程、要兼容。
- **active**:当前在迭代,接口随业务调整。
- **experimental**:随时推倒重来,不考虑向后兼容。
### 可观测性 Observability —— 运行时能看到多少
- **opaque**:黑盒,出了问题靠猜。
- **logged**:关键路径有日志,能事后翻查。
- **traced**:有链路追踪,跨服务能串起来。
- **instrumented**指标齐全metrics / traces / logs 三件套),可接告警。
### 可测试性 Testability —— 测试覆盖的深度
- **untested**:没测试。
- **testable**:结构支持测试(依赖可注入、副作用可隔离),但还没写。
- **tested**:有单元/集成测试覆盖主要路径。
- **verified**:核心逻辑有测试 + 关键不变量有断言/属性测试/形式化验证。
### 安全性 Security —— 信任边界
- **trusted**:全在可信环境内,不设防。
- **validated**:外部输入做校验和清洗。
- **sandboxed**权限最小化、危险操作隔离容器、subprocess 限权)。
- **hardened**:按对抗性环境设计,防注入/防越权/防侧信道,有威胁模型。
---
## 特殊维度(只在涉及时提)
- **Concurrency 并发**single-threaded / thread-safe / lock-free / distributed
- **Determinism 确定性**nondeterministic / reproducible / deterministic
- **Compatibility 兼容性**current-only / backward-compatible / cross-version
- **Idempotency 幂等性**non-idempotent / idempotent / exactly-once
---
## 常用默认组合
| 场景 | 组合 |
|---|---|
| 聊天里问的随手代码 | L1 + inline + careless + self + experimental |
| 项目内部工具 | L2 + functions + reasonable + team + active + logged + testable |
| 对外发布的库/服务 | L3 + modules + budgeted + public + stable + traced + tested + validated |
没明说就按场景走默认。动手前列出关键档位,偏离默认的地方明确标出来让用户确认。
---
## 怎么用这份文档
- **design / fastforward 起草时**AI 先按场景猜默认组合,把判断出的"可能偏离默认"的维度列出来问用户;用户没明确说的维度按默认走。只记偏离项,默认档位不抄。
- **implement / fix 写代码时**:翻一眼当前 feature 或 issue 记录的维度档位,按档位写。比如记了 `健壮性=L3` 就不要偷工省掉输入校验;记了 `可读性=public` 就得补示例和文档。
- **acceptance / review 时**:把维度档位当成验收标准的一部分——档位说 L3 但代码里外部输入没校验,就是不达标。

View File

@@ -0,0 +1,50 @@
# CodeStable 维护者说明
本文件由 `cs-onboard` 复制到项目的 `codestable/reference/maintainer-notes.md`。维护 CodeStable 技能家族时需要反复查阅、但不适合放在各子技能正文里的说明。
---
## 1. 断点恢复
AI 对话随时可能中断token 超限、网络断开、用户换设备)。各阶段发现自己不是从零开始时,必须优先检查已有产物的完成度,从上次停下的地方继续:
- **brainstorm**:如 `{slug}-brainstorm.md` 已有部分内容,读取后问用户"上次聊到 X要接着聊还是推翻重来"
- **design**:如 `{slug}-design.md` 已有部分节,逐节检查完成度,补齐缺失节,不重写已完成节
- **implement**`{slug}-checklist.yaml` 中已 `done` 的步骤不重做,从第一个 `pending` 步骤开始
- **acceptance**:如 `{slug}-acceptance.md` 已有部分节,检查哪些节已填写(有实质 checklist 勾选),从下一个未完成节继续
- **issue-analyze**:如 `{slug}-analysis.md` 已存在,检查 5 节是否都有内容,缺失的补做,已有的不重写
- **issue-fix**:如代码已改但 `{slug}-fix-note.md` 不存在,直接进入验证 + 写 fix-note 环节
恢复时先向用户简短汇报:"检测到上次工作到 X 阶段,我从 Y 继续"。
---
## 2. 扩展点
### 新增子工作流
新工作流定型后,在 `cs-onboard/reference/system-overview.md` 的"技能分成四部分"和"场景路由"表里加一段索引,并登记新的目录位置。
### 跨阶段新约束
如果发现某条规则适用于所有阶段(例如所有 spec doc 都必须补某个字段),优先写进共享 reference`shared-conventions.md``system-overview.md`),不要只改一个子技能。
### 新模板 / 新产物类型
如果引入新的 spec 产物(例如风险评估表、回滚预案),先在 `shared-conventions.md` 登记路径,再在对应阶段技能里引用。
### 共享术语表
如果 CodeStable 自己形成了稳定共享术语,应优先沉淀成共享 reference而不是散落在多个子技能里重复定义。
### 跨工作流状态一览
目前查看"项目当前有几个 feature 在进行中、几个 issue 未关闭"仍需要手动查询。未来如要补 `status.py``codestable/STATUS.md`,先在 `shared-conventions.md` 登记方向,再实现。
---
## 3. 维护规则
- 每次扩展都要同步更新 `system-overview.md` 索引和相关子技能
- 不允许只在某个子技能里加东西而不在 `system-overview.md` 登记
- 共享说明优先放 `codestable/reference/`,不要散落在各子技能里

View File

@@ -0,0 +1,88 @@
---
doc_type: requirement-example
description: 一份好的 requirement doc 长什么样——供 cs-req 起草时参考,也供项目成员扫一眼对齐风格
---
# requirement 文档示例
下面这份示例取自 CodeStable 自己的能力(修 bug 时的探索分析流),用来展示一份好的 requirement doc 的**语气、结构、颗粒度**。新项目做 onboarding 时随包落盘,之后写自己的 requirement 可以直接照着改。
---
## 写作要点速查
- **标题直接平铺**说这能力是什么,不玩比喻、不起花哨名字。
- **用户故事顶在最前面**,每条要能想象出一个具体处境。
- **为什么需要 / 怎么解决 各一段短的**,不上课、不展开。
- **边界用列表**,至少写一条"它不管什么"。
- **不写实现细节**——"通过 X 接口调用 Y 服务"这种挪到 architecture doc。
- **frontmatter 的 `pitch`** 要去技术化、一句话、读者没上下文也能看懂,以后当宣传词用。
---
## 示例正文
````markdown
---
doc_type: requirement
slug: issue-flow
pitch: 修 bug 时先让 AI 探索和分析,再动手改
status: current
last_reviewed: 2026-04-21
implemented_by:
- arch-cs-issue
tags: [debug, ai-assist]
---
# 修 bug 时先探索和分析
## 用户故事
- 作为一个刚接手别人代码的人,我希望把报错直接丢给 AI它告诉我根因在哪而不是自己翻三个文件摸调用链。
- 作为一个被线上问题打断的开发,我希望 AI 帮我收窄嫌疑范围,而不是自己从 git log 一条条比对。
- 作为一个只记得"点那个按钮就白屏"的人,我希望 AI 反过来问我几个问题把现场补清楚,而不是让我自己想该给它什么信息。
## 为什么需要
修 bug 的难点不在改代码,在定位。线索通常零碎(一段报错、一个截图、一句口述),从这点信息摸到真正的根因,往往要先自己耗掉半小时。对不熟的模块、对新接手的人,这段成本更高。
## 怎么解决
先让 AI 读现场——日志、代码、git 历史——交叉验证之后讲清楚"哪里坏了、为什么坏、改动会影响什么"。人确认过再动手改,改完验证。
## 边界
- 不主动扫 bug得你先感知到异常给它入口。
- 线索实在不够时它会反问你补现场,而不是瞎猜。
- 不处理"还没想清楚要做什么"——那是需求 / 设计的事。
````
---
## 反面样例(不要这样写)
这几种写法 AI 很容易默认写出来,都是典型"翻车"
**语气像在上课**
> 本能力旨在通过智能化的探索与分析机制,为开发者提供高效的缺陷定位解决方案……
改成"修 bug 的难点不在改代码,在定位"。
**标题玩比喻**
> 让 AI 当你修 bug 时的第一个读者
改成"修 bug 时先探索和分析"。
**用户故事太抽象**
> 作为用户,我希望系统好用。
删掉。用户故事必须能想象出具体处境。
**把实现细节塞进来**
> 通过调用代码检索服务和 Git 日志分析模块,对报错日志进行上下文推理……
这是 architecture doc 的事,从 requirement 里删掉。

View File

@@ -0,0 +1,286 @@
# CodeStable 共享口径
本文件由 `cs-onboard` 复制到项目的 `codestable/reference/shared-conventions.md`。所有 CodeStable 子技能在运行时用**项目相对路径** `codestable/reference/shared-conventions.md` 引用本文件——这是跨子技能共享但不适合堆在单个技能里的规范的唯一权威版本。
skill 本身不共享文件系统(每个 skill 是独立安装单元),所以共享口径不能放在某个 skill 内部被别的 skill 引用。放在"工作项目"里,对所有 skill 都可达。
---
## 0. 目录结构与路径命名
onboarding 完成后,项目里应当存在如下骨架(`cs-onboard` 负责搭建):
```
codestable/
├── requirements/ 需求中心目录("为什么要有这个能力",只记现状)
│ └── {slug}.md 一个能力一份,扁平(由 cs-req 产出)
├── architecture/ 架构中心目录("用什么结构实现",只记现状)
│ ├── ARCHITECTURE.md 架构总入口(索引 + 关键架构决定)
│ └── {slug}.md 子系统 / 模块架构 doc由 cs-arch 产出)
├── roadmap/ 规划层目录("接下来打算怎么做这块大需求 + 模块怎么切 + 接口怎么定",独立于现状档案)
│ └── {slug}/ 一个大需求一个子目录(由 cs-roadmap 产出)
│ ├── {slug}-roadmap.md 主文档:背景 / 范围 / 模块拆分(概设)/
│ │ 接口契约(架构层详设)/ 子 feature 清单 / 排期思路
│ ├── {slug}-items.yaml 机器可读的子 feature 清单acceptance 回写状态
│ └── drafts/ 可选,草稿 / 调研 / 讨论
├── features/ feature spec 聚合根
│ └── YYYY-MM-DD-{slug}/ 每个 feature 一个目录
│ ├── {slug}-brainstorm.md (可选,由 cs-brainstorm 判为 case 2 时产出)
│ ├── {slug}-design.md
│ ├── {slug}-checklist.yaml
│ └── {slug}-acceptance.md
├── issues/ issue spec 聚合根
│ └── YYYY-MM-DD-{slug}/ 每个 issue 一个目录
│ ├── {slug}-report.md
│ ├── {slug}-analysis.md (根因不显然时才有)
│ └── {slug}-fix-note.md
├── refactors/ refactor spec 聚合根
│ └── YYYY-MM-DD-{slug}/ 每次 refactor 一个目录
│ ├── {slug}-scan.md
│ ├── {slug}-refactor-design.md
│ ├── {slug}-checklist.yaml
│ └── {slug}-apply-notes.md
├── compound/ 沉淀类文档统一目录
│ └── YYYY-MM-DD-{doc_type}-{slug}.md
│ doc_type ∈ {learning, trick, decision, explore}
├── tools/ 跨工作流共享脚本(由 onboarding 从技能包释放)
└── reference/ 共享参考文档(由 onboarding 从技能包释放,即本文件所在目录)
```
### 命名规则
- 需求文档:`codestable/requirements/{slug}.md`(长效能力清单,不带日期前缀,扁平不分组)
- roadmap 目录:`codestable/roadmap/{slug}/`(一个大需求一个子目录,不带日期前缀,平铺不嵌套)
- feature 目录:`codestable/features/YYYY-MM-DD-{slug}/`,日期用创建当天
- issue 目录:`codestable/issues/YYYY-MM-DD-{slug}/`,日期用报告当天
- refactor 目录:`codestable/refactors/YYYY-MM-DD-{slug}/`,日期用首次扫描当天
- 沉淀类文档:`codestable/compound/YYYY-MM-DD-{doc_type}-{slug}.md`,日期用**归档当天**(不是问题发生当天)
- 架构文档:`codestable/architecture/{type}-{slug}.md`(长效地图,不带日期前缀);总入口始终叫 `ARCHITECTURE.md`
- `AGENTS.md` 在项目根目录,**不在 `codestable/` 里**
### 架构 doc 的分组规则(同类聚合)
`codestable/architecture/` 下的 doc 用文件名**第一段**(首个连字符之前)作为类型标记:`ui-chat.md``ui-events.md` 同属 `ui` 类,`api-routing.md` 自成 `api` 类。所以**所有架构 doc 命名必须遵循 `{type}-{slug}.md`**——只有一份且预计长期独占的,也要带个合理的 type 段(如 `cli-entry.md` 而非 `entry.md`),否则未来同类出现时统计不到、聚合不了。
**触发条件**:某个 type 在 `codestable/architecture/` 根目录下达到或超过 **6 份**文档时(即新加第 6 份的那一次操作),把这一类全部收进同名子目录。
**收入子目录后的命名**:去掉 type 前缀。`ui-chat.md``ui/chat.md``ui-open-files-tree.md``ui/open-files-tree.md`。子目录里不再带 `ui-` 前缀。
**只升不降**:文档因删除回到 ≤5 份也不折回平铺,避免反复改一堆引用。
**触发时谁负责**`cs-arch``backfill` / `update` 模式在 Phase 6 落盘前主动检查;命中阈值时这次操作要把"本次新加 / 改的这份 + 已有同类全部"一起搬迁,并同步改 `ARCHITECTURE.md` 里所有相关链接(搬迁本身要在 Phase 5 一并给用户 review不偷偷做`check` 模式不主动搬迁,但读 `architecture/` 时若发现某 type 已 ≥6 仍平铺,在报告末尾列为观察项交给用户。
### 要改目录结构
`cs-onboard/reference/shared-conventions.md` 这个模板,新项目 onboarding 时会带上新版本。已有项目需要手动同步 `codestable/reference/shared-conventions.md`
---
## 1. 共享元数据口径
### feature spec
- `{slug}-brainstorm.md` / `{slug}-design.md` / `{slug}-acceptance.md` 共用 `doc_type``feature``status``summary``tags` 这组核心字段
- 子技能只补充本阶段特有字段,不重复改写这组字段的含义
- `status` 取值各阶段不同brainstorm = `confirmed`(落盘即确认,无 draftdesign = `draft` / `approved`acceptance 见对应技能
### issue spec
- `{slug}-report.md` / `{slug}-analysis.md` / `{slug}-fix-note.md` 共用 `doc_type``issue``status``tags` 这组核心字段
- `severity``root_cause_type``path` 等属于阶段特有字段,由对应阶段按需补充
### 归档类文档
- `learning` / `trick` / `decision` / `explore` 四个子技能的产物**统一写入 `codestable/compound/` 目录**
- 每个文档必须在 frontmatter 顶部带 `doc_type` 字段(`learning` / `trick` / `decision` / `explore`),作为跨子技能的归属判定
- 文件名统一用 `YYYY-MM-DD-{doc_type}-{slug}.md`——日期打头、`doc_type` 段在中间,`ls` 按名字排序就按归档日期排好;要按类型筛就 grep 中间那段
- 各子技能在 `doc_type` 之外保留自己的专属 frontmatterlearning 的 `track`、trick 的 `type`、decision 的 `category`、explore 的 `type`
- 各子技能只认自己的 `doc_type` 和文件名里的类型段(`YYYY-MM-DD-{doc_type}-...` 中间那段),不读不写别的子技能的文档
- `status` 一类通用字段的语义必须和本文件保持一致,不另起一套口径
- 子技能里如果需要解释状态,只保留该工作流特有的状态流,不重新定义通用语义
### 面向外部读者的文档
- `guidedoc` / `libdoc` 的 frontmatter 由各自子技能定义
- 如无特殊说明:`draft` = 待 review`current` = 当前有效,`outdated` = 代码已变更待同步
### 写作约束
- 子技能提到字段时,优先写"本技能额外字段"或"本阶段状态变化"
- 不要把整套通用字段定义在多个技能里重复展开
---
## 2. {slug}-checklist.yaml 生命周期
- `{slug}-checklist.yaml` 是 feature 工作流的唯一执行清单
-`cs-feat-design``{slug}-design.md` 确认通过后一次生成
- `cs-feat-ff` **不生成** checklist也不写 design doc / acceptance它是跳过 spec 流程、直接让 AI 写代码的超轻量通道,只做动手前的知识检索引导
### design 的职责
- 只负责从方案里提取 `steps``checks`
- 不预先把任何条目标成完成
### implement 的职责
- 只更新 `steps[].status`
- 状态流:`pending``done`
- 不改写 `checks` 的所有权和来源
### acceptance 的职责
- 只更新 `checks[].status`
- 状态流:`pending``passed` / `failed`
- 不回头重写 `steps`
### 写作约束
- 子技能描述 `{slug}-checklist.yaml` 时,只补充本阶段具体要读/写哪一部分
- 不重新定义整份文件的生命周期
---
## 2.5 roadmap ↔ feature 衔接协议
`codestable/roadmap/{slug}/{slug}-items.yaml` 是"规划层"和"feature 执行层"之间的唯一接口。三个技能共同读写它——**不算跨 skill 耦合**,是 skill 都读写项目共享产物,和都读写 `codestable/features/` 同理。
### items.yaml 的状态机
```
planned → in-progress cs-feat-design 启动 feature 时改)
in-progress → done cs-feat-accept 验收完成时改)
planned → dropped cs-roadmap update 模式,用户决定不做时改)
```
`done``dropped` 是终态。需要回退重做的要新加一条 slug 略改的条目,不要改终态。
### cs-roadmap 的职责
- 生成和维护 `{slug}-roadmap.md` 主文档和 `{slug}-items.yaml` 的结构
-`planned` 条目改 `dropped`(用户决定放弃时)
- 不改 `in-progress` / `done` 状态——那两类跃迁由 feature 技能负责
### cs-feat-design 的职责
从 roadmap 条目起头 feature 时:
1.`{slug}-design.md` frontmatter 加两个字段:`roadmap: {roadmap-slug}` + `roadmap_item: {子 feature slug}`
2. 打开 `codestable/roadmap/{roadmap-slug}/{roadmap-slug}-items.yaml`,把对应条目 `status` 改为 `in-progress``feature` 填为 feature 目录名(`YYYY-MM-DD-{slug}`
3. 校验 yaml 语法
直接起 feature不从 roadmap 来)时两个字段留空或省略,不触发任何 roadmap 写操作。
### cs-feat-accept 的职责
验收流程走到收尾时:
1.`{slug}-design.md` frontmatter 的 `roadmap` / `roadmap_item` 字段
2. 字段为空 → 跳过 roadmap 回写
3. 字段有值 → 打开 `codestable/roadmap/{roadmap}/{roadmap}-items.yaml`,把 `roadmap_item` 对应条目 `status` 改为 `done`
4. 同步主文档 `{roadmap}-roadmap.md` 子 feature 清单里对应行的显示状态(保持两份一致)
5. 校验 yaml 语法
回写是**实际写文件的动作**,不是自评"应该不需要改"。验收报告里要明确记录回写结果。
### 最小闭环标记
items.yaml 每份里只有一条 `minimal_loop: true`,标记"这条做完后系统能端到端跑通最窄路径"。feature-design 启动 `minimal_loop: true` 条目时优先级最高——它是整个大需求能不能落地的早期信号。
---
## 3. 阶段收尾推荐
### feature-acceptance
收尾时按顺序判断是否要推荐:
1. `cs-learn`:沉淀经验
2. `cs-decide`:记录长期约束/选型
3. `cs-guide`:更新开发者/用户指南
4. `cs-libdoc`:更新公开 API 参考
5. `scoped-commit`
### issue-fix
收尾时按顺序判断是否要推荐:
1. `cs-learn`:记录坑点
2. `cs-decide`:如修复暴露出长期约束
3. `scoped-commit`
### 推荐动作的统一规则
- 一律一句话提示
- 用户说"不用"立刻跳过
- 推荐不是强制,不得把用户拖入新的工作流
- 上游技能负责主动提示,下游技能负责承接执行
- 不要出现下游说"应该由上游推荐"、上游却没有动作的漂移
---
## 4. 收尾提交scoped-commit
feature-acceptance 和 issue-fix 走完后要把本次产物提交为一个 commit。规则
- **提交范围**:本次工作改到的代码 + 相关 spec 文档 + 本次实际更新过的架构 doc + 本次实际更新过的 roadmap items.yaml / 主文档
- **不该进这个 commit**:和本次工作无关的顺手修改;属于"下次另起一个 feature / issue"的扩大范围
- **提交前确认**:用户没明确同意就不要 `git commit`
- **commit message**:一句话说清楚"这次做了什么",不要把 spec 目录路径贴进 message
子技能只描述本阶段的特有提交范围(比如 acceptance 要带架构 doc通用规则看这里。
---
## 5. 归档检索规则
feature-design / issue-analyze / issue-fix 在动手前要到 `codestable/compound/` 里搜已有的沉淀:
- 总是先搜 `architecture/``compound/` 两个目录
-`compound/` 里用 `doc_type` 字段按需过滤learning / trick / decision / explore
- 搜到的结果只作为参考输入,不盲目套用——可能已过期(`status=outdated`)或不适合当前上下文
- 搜到和当前方向冲突的 decision → 必须在方案 / 分析里正面回应"为什么仍然要这么做"或调整方向
子技能只补充本阶段的具体查询命令。完整搜索语法看 `codestable/reference/tools.md`
---
## 6. 归档类子技能共享守护规则
`cs-learn` / `cs-trick` / `cs-decide` / `cs-explore` 四个子技能共享下面这组规则。各子技能的正文只写本技能特有反模式,通用规则看这里:
1. **只增不删**——已归档的文档除非被明确取代(`status=superseded`),否则不删除;理由丢失的成本极高
2. **宁缺毋滥**——用户说不出理由的节直接省略,不要 AI 编造听起来合理的内容
3. **不替用户写实质内容**——AI 负责起草结构和串联语言,实质结论必须来自用户或可追溯的代码证据
4. **可发现性检查**——写完后检查 `AGENTS.md` / `CLAUDE.md` 里有没有指引 AI 查阅 `codestable/compound/`,没有就**提示**用户(不替用户改)
5. **起草前先查重叠,而不是归档后**——动手写之前就用 `search-yaml.py --query` 查语义相近的旧文档。有命中就把候选列给用户,让用户在三条路径里选一条:
- **更新已有条目**(默认优先):沿用原文件名和原创建日期,**不新建文件**;修改正文相关节,在 frontmatter 补 `updated: YYYY-MM-DD`(归档当天);变更超出小修的话在文末加一段"YYYY-MM-DD 更新"简述改了什么
- **supersede 已有条目**:旧文档保留原文,把 `status` 改成 `superseded`,加 `superseded-by: {新文档文件名}`,正文顶部加一行 `**[已取代]** 见 {新文档 slug}`然后新建文档frontmatter 带 `supersedes: {旧文档文件名}`
- **确实是不同主题**:直接新建,在新文档末尾 `相关文档` 节列出已有那条,说明区别
6. **识别用户意图是"改已有"还是"记新的"**——用户说"改 / 更新 / 修订 / 补充 {某条}"、明确指向某条旧文档、或话题高度重合时,默认走"更新已有条目"路径,不要闷头新建。分不清就问一句,不要猜。
各子技能只认自己的 `doc_type`,不读写别家产物。
---
## 7. 写代码时的反射检查
`cs-feat-impl``cs-issue-fix` 共用的一组代码质量反射检查。AI 默认会往"大函数 / 大文件 / god class / 处处特殊分支"这些方向漂,这一节的目的是把漂移截在发生的那一刻。
**不是阈值,是触发器**。不是"超过 N 行必须拆"——硬数字会诱发为拆而拆,把自然聚合的代码切碎。这里每一条都是"遇到 X 情况就停下来问自己"的反射动作。
| 触发场景 | 停下来问自己 |
|---|---|
| 要往一个已经很长的文件里追加代码时 | 这文件现在承担了几件事?新加的东西是已有职责的延伸,还是第 N+1 件事?是第 N+1 件就默认新建文件 |
| 要给一个已经很多方法的类加方法时 | 新方法是这个类核心职责的自然扩展,还是把这个类推向"什么都能干" |
| 写的函数已经超过一屏时 | 这函数在做几件事?几件事就拆 |
| 要加一个 `if (特殊情况) { 特殊处理 }` 分支时 | 是不是抽象维度选错了?正确的做法可能是把特殊路径和通用路径分成不同的函数 / 策略 / 类,而不是往现有代码里打补丁 |
| 要 copy-paste 一段代码时 | 这段代码能抽成共用的,还是只是碰巧字面相似?能抽就抽 |
| 要给一个函数加第 4+ 个参数时 | 这个函数在做的事情是不是太多了?参数列表是 API 恶化的早期信号 |
| 要新写一个"万能工具类 / helper"时 | 这个东西真的没有归属吗?还是只是因为一时想不起来放哪儿,就先堆在 util 里? |
### 停下来之后
反射检查**只负责把问题提出来**,结论用户定。如果停下来想清楚后的动作(拆文件 / 新建文件 / 重命名 / 抽共用层)会让这次改动超出 `{slug}-checklist.yaml` 里现有步骤的范围,跟用户对齐再决定——要么纳入当前 feature / fix 的推进计划,要么记成顺手发现留到后续。
不许偷偷拆完继续写,也不许忽略信号硬冲。默认动作是停、问、再继续。

View File

@@ -0,0 +1,112 @@
# CodeStable 体系总览
本文档介绍 CodeStable 工作流家族整体——有哪些子技能、各管什么场景、产物怎么组织。无论是 AI 在运行时读到这个文件,还是人打开来看,都能对整个体系有个完整印象。
AI 辅助开发里,有几类场景会反复出现——加新功能、修 bug、遇到值得沉淀的经验、做技术选型、摸新模块的代码、接入新仓库。每种场景如果每次从零处理都会出各自的典型问题AI 给功能起的术语跟老代码冲突、bug 改完没人记得当时怎么诊断的、上周刚踩过的坑下周又踩一遍。
CodeStable 把这几类场景各配一套子技能,产物放进统一的目录结构、带统一的 YAML frontmatter,互相之间可以检索引用。
## 技能分成四部分
**根入口**——开放式诉求 / 不知道走哪个时的统一入口:
- `cs` — 介绍体系全貌 + 把诉求路由到正确的 cs-* 子技能。本技能不做事,只做分诊和提示
**做事**——从一段模糊想法走到上线的功能、或者从一份错误报告走到修好的 bug:
- `cs-feat` — 新功能,design → implement → acceptance想法还模糊时先走讨论层 `cs-brainstorm` 做分诊,不属于 feature 流程内部)
- `cs-issue` — 修 bug,report → analyze → fix
- `cs-refactor` — 代码优化(行为不变、结构/性能/可读性变),scan → design → apply
两类都不直接让 AI 写代码,而是先产出 spec(功能方案 / 问题分析),用户 review 后再动手,代码和 doc 一起交付。针对的是术语冲突、范围失控、改完不留存档这三种 AI 默认会出的问题。
**沉淀**——把做事过程产生的知识存下来,下次遇到同类问题直接复用:
- `cs-learn` — 回顾"做 X 时踩了 Y 这个坑"
- `cs-trick` — 处方"以后做 X 就这样做"
- `cs-decide` — 规定"全项目今后都按 X 来"
- `cs-explore` — 存档"调查了 X 问题,看到代码里是这样的"
**讨论层**——想法还模糊时的统一入口,不直接产出设计或代码:
- `cs-brainstorm` — 和用户对话做分诊:case 1(已经够清楚,直接 feature-design)、case 2(小需求,在 feature 里继续讨论并落 `{slug}-brainstorm.md`)、case 3(大需求,移交给 roadmap)
**辅助**——围着前几类转的周边工具:
- `cs-onboard` — 把新仓库接入 CodeStable 目录结构
- `cs-req` — 起草或刷新 `codestable/requirements/` 下的需求文档("为什么要有这个能力",只记现状)
- `cs-arch` — 架构相关一站式:起草新架构文档 / 刷新已有文档 / 做架构体检(含 design 自洽 / design↔代码一致 / architecture 目录多份文档间一致)。architecture 只记现状
- `cs-roadmap` — 把一块装不进单个 feature 的大需求拆成带依赖和状态的子 feature 清单,作为后续多次 feature 流程的种子和排期依据;独立于需求 / 架构档案
- `cs-guide` — 写给外部读者的开发者指南 / 用户指南
- `cs-libdoc` — 为库的公开 API 逐条目生成参考文档
## 场景路由
仓库里还没有 `codestable/` 目录,先用 `cs-onboard` 搭骨架。
| 场景 | 子技能 |
|---|---|
| 想法还模糊 / "有个想法没想清楚" / "先聊聊" | `cs-brainstorm`(分诊后路由到 design / feature-brainstorm 落盘 / roadmap) |
| 新功能 / 新能力 | `cs-feat` |
| BUG / 异常 / 文档错误 | `cs-issue` |
| 代码优化 / 重构 / 重写(行为不变) | `cs-refactor` |
| 摸代码、提问调研 | `cs-explore` |
| 补 / 更新需求文档 | `cs-req` |
| 补 / 更新 / 检查架构文档 | `cs-arch` |
| 大需求拆解 / 排期规划 | `cs-roadmap` |
| 技术选型 / 约束 / 规约 | `cs-decide` |
| 踩坑回顾、经验总结 | `cs-learn` |
| 可复用的编程模式、库用法 | `cs-trick` |
| 开发者指南 / 用户指南 | `cs-guide` |
| 库 API 参考 | `cs-libdoc` |
完整的操作手册、退出条件、和其他工作流的关系,各子技能里讲。
## 沉淀类四个子技能如何区分
learning / trick / decision / explore 都是存档文档类型,区别在记录内容的性质:
- 回顾某次做 X 时发现了 Y —— `cs-learn`(产出 `doc_type: learning`)
- 以后做 X 就这样做的处方 —— `cs-trick`(产出 `doc_type: trick`)
- 全项目今后都得遵守的规定 —— `cs-decide`(产出 `doc_type: decision`)
- 调查了一个问题,留份证据 —— `cs-explore`(产出 `doc_type: explore`)
四者共用 `codestable/compound/` 目录,靠 frontmatter 的 `doc_type` 字段和文件名中间的类型段(`YYYY-MM-DD-{doc_type}-{slug}.md`)区分。每个子技能只认自己的 `doc_type`,不读写别家产物——**"A 和 B 有什么不同"这种判断由本节负责,子技能里不再重复**。
## 现状档案 vs 规划档案 vs 单次动作
三类文档各管一段时间尺度,不要混:
- **现状档案**(requirements / architecture)——描述"系统现在长什么样"。默认只在 feature-acceptance 时跟着代码一起更新;必要时由 requirements / architecture 技能主动刷新。**不写"接下来打算做什么"**
- **规划档案**(roadmap)——描述"接下来打算怎么走"。独立于现状档案,改动不牵连 requirements / architecture。所有条目 done / dropped 后 roadmap 进入 `completed` 状态,作为历史档案留存
- **单次动作**(feature / issue / refactor)——本次要做的一件具体事情的 spec。动作走完后,相关沉淀提炼进现状档案和沉淀类文档
用户说"我想要一个 X 系统"这种大需求,先走 roadmap 拆成若干子 feature,再一条一条走 feature 流程。直接起 feature 会变成巨型 design 塞不下、拆了又没有追踪抓手。
## feature 和 issue 的阶段不可跳
feature 走 brainstorm(可选) → design → implement → acceptance,issue 走 report → analyze → fix。每个阶段有退出条件,上一个没满足,下一个不开始。
AI 最常见的问题是一口气铺几百行代码才让人看——等发现问题已经很难中止。阶段间的人工 checkpoint 就是为了早一步中止。每个 checkpoint 具体检查什么,对应子技能里讲。
例外两种:issue 根因一眼确定时走快速通道,跳过 analyze 直接 fix;feature 范围小时走 `cs-feat-ff`,写完 spec 直接进实现。
## 进一步参考
- `codestable/reference/shared-conventions.md` — 目录结构、YAML frontmatter 口径、`{slug}-checklist.yaml` 生命周期、收尾 commit 约定、归档类共享规则
- `codestable/reference/tools.md``search-yaml.py` / `validate-yaml.py` 用法
- `codestable/reference/maintainer-notes.md` — 断点恢复、新增子工作流的登记
目录结构(requirements/、architecture/、roadmap/、features/、issues/、compound/、tools/、reference/)的权威定义在 `shared-conventions.md`。要改目录先改那里——方法是改 `cs-onboard/reference/shared-conventions.md` 这个模板,新项目 onboarding 时会带上新版本。
## 相关
- `AGENTS.md` — 全项目代码规范和已知坑
- `codestable/architecture/ARCHITECTURE.md` — 项目架构总入口

View File

@@ -0,0 +1,98 @@
# CodeStable 工具用法参考
本文件由 `cs-onboard` 复制到项目的 `codestable/reference/tools.md`,所有 CodeStable 子技能用项目相对路径 `codestable/reference/tools.md` 引用。
`codestable/tools/` 下共享脚本的完整用法参考。子技能里只写本技能特有的 1-2 行典型查询;完整语法和示例看这里。
---
## 1. search-yaml.py
通用 YAML frontmatter 搜索工具。从项目根目录运行无需安装额外依赖PyYAML 可选,有则用,无则内建 fallback parser
### 基本语法
```bash
python codestable/tools/search-yaml.py --dir {目录} [--filter key=value]... [--query "全文关键词"] [--sort-by FIELD [--order asc|desc]] [--full] [--json]
```
### filter 语法
- `key=value`:字段精确匹配(大小写不敏感)
- `key~=value`:字符串字段子串匹配;列表字段元素包含匹配
### 排序语法
- `--sort-by FIELD`:按 frontmatter 字段排序(典型字段:`last_reviewed``date``updated_at`
- `--order desc|asc``desc` 默认,新的在前;`asc` 老的在前(查"谁最久没更新"用这个)
- 字段缺失 / 值为空的文档一律排到最后,不干扰前排结论
### 常用命令
沉淀类文档统一在 `codestable/compound/`,用 `doc_type` 字段区分四个子技能的产物,内部还有各自的细分字段:
```bash
# 按 doc_type 筛选
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter status=active
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter status=active
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=explore --filter status=active
# doc_type + 子技能内部细分字段
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=pitfall
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter category=constraint
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter type=pattern
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=explore --filter type=question
# 按 tag列表元素包含匹配
python codestable/tools/search-yaml.py --dir codestable/compound --filter tags~=prisma
# 全文搜索
python codestable/tools/search-yaml.py --dir codestable/compound --query "shadow database"
# 按领域/框架/语言筛选
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter area=frontend
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter framework~=vue
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter language=typescript
# 搜索 feature 方案 doc
python codestable/tools/search-yaml.py --dir codestable/features --filter doc_type=feature-design --filter status=approved
# 输出控制
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter status=active --full
python codestable/tools/search-yaml.py --dir codestable/compound --filter tags~=llm --json
# 按时间排序
python codestable/tools/search-yaml.py --dir codestable/compound --sort-by date --order desc # 最近归档的在前
python codestable/tools/search-yaml.py --dir codestable/library-docs --sort-by last_reviewed --order asc # 最久没 review 的在前(找陈旧文档)
python codestable/tools/search-yaml.py --dir codestable/guides --filter status=current --sort-by last_reviewed --order asc
```
### 典型使用场景
| 场景 | 命令建议 |
|---|---|
| feature-design 开始前查已有归档 | 搜 `codestable/compound` 目录,按 `--query "{关键词}"` 全文搜;要分类看就加 `--filter doc_type={learning\|trick\|decision\|explore}` |
| issue-analyze 根因分析前查历史 | 搜 `codestable/compound` `--filter doc_type=learning --filter track=pitfall`、再搜 `--filter doc_type=trick --filter type=library`,按相关组件/框架过滤 |
| 归档落盘后查重叠 | 搜 `codestable/compound --query "{关键词}" --json`,看有无语义重叠 |
| 新人了解项目规约 | `--dir codestable/compound --filter doc_type=decision --filter status=active` |
| 按技术栈浏览技巧 | `--dir codestable/compound --filter doc_type=trick --filter language={语言} --filter status=active` |
| 找最久没 review 的库文档 / 指南 | `--dir {目录} --filter status=current --sort-by last_reviewed --order asc` |
| 看最近沉淀了哪些经验 | `--dir codestable/compound --filter doc_type=learning --sort-by date --order desc` |
---
## 2. validate-yaml.py
YAML 语法校验工具。用于验证 frontmatter 语法和必填字段。
```bash
# 校验单个文件的 YAML 语法
python codestable/tools/validate-yaml.py --file {文件路径} --yaml-only
# 校验必填字段
python codestable/tools/validate-yaml.py --file {文件路径} --require doc_type --require status
# 批量校验目录下所有文件
python codestable/tools/validate-yaml.py --dir {目录} --require doc_type --require status
```

View File

View File

View File

@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
search-yaml.py — Generic YAML-frontmatter search tool for markdown document directories.
Works on any directory of .md files that use YAML frontmatter (--- ... ---).
Designed for AI agent use: fast, structured output, no required external dependencies.
Filter syntax (--filter flag, repeatable, AND logic):
key=value Exact match on a scalar field (case-insensitive)
key~=value Substring match on a string field, or element-in for list fields
Usage examples:
# Search codestable/compound (learning / trick / decision / explore docs share this dir)
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=pitfall
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter tags~=prisma
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter status=active --full
# Full-text search in body + frontmatter values
python codestable/tools/search-yaml.py --dir codestable/compound --query "shadow database"
# JSON output for AI agent consumption
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=knowledge --json
# Sort by a frontmatter date field (works on any ISO-8601 date string, YAML date, or sortable value)
python codestable/tools/search-yaml.py --dir codestable/library-docs --sort-by last_reviewed --order asc # oldest first (stalest)
python codestable/tools/search-yaml.py --dir codestable/compound --sort-by date --order desc # newest first
# Works on any yaml-frontmatter markdown directory
python codestable/tools/search-yaml.py --dir docs/decisions --filter status=accepted
python codestable/tools/search-yaml.py --dir content/posts --filter tags~=python --query "asyncio"
"""
import argparse
import json
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Frontmatter parsing (PyYAML used when available, builtin fallback otherwise)
# ---------------------------------------------------------------------------
def _parse_yaml_scalar(val: str):
val = val.strip()
if val.startswith("[") and val.endswith("]"):
inner = val[1:-1]
return [item.strip().strip("'\"") for item in inner.split(",") if item.strip()]
lower = val.lower()
if lower in ("true", "yes"):
return True
if lower in ("false", "no"):
return False
if lower in ("null", "~", ""):
return None
return val
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""
Split a markdown document into (frontmatter_dict, body_text).
Returns ({}, full_text) when no frontmatter is present.
"""
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
fm_text = text[3:end].strip()
body = text[end + 4:].strip()
try:
import yaml # type: ignore
meta = yaml.safe_load(fm_text)
return (meta or {}), body
except Exception:
pass
# Minimal fallback: handles scalar values and inline lists
meta: dict = {}
for line in fm_text.splitlines():
if not line.strip() or line.startswith("#") or ":" not in line:
continue
key, _, raw = line.partition(":")
meta[key.strip()] = _parse_yaml_scalar(raw)
return meta, body
# ---------------------------------------------------------------------------
# Document loading
# ---------------------------------------------------------------------------
def load_documents(directory: Path) -> list[dict]:
docs = []
for md_file in sorted(directory.rglob("*.md")):
try:
text = md_file.read_text(encoding="utf-8")
except OSError as exc:
print(f"[warn] Cannot read {md_file.name}: {exc}", file=sys.stderr)
continue
meta, body = parse_frontmatter(text)
docs.append({
"file": str(md_file.relative_to(directory)),
"path": str(md_file),
"meta": meta,
"body": body,
})
return docs
# ---------------------------------------------------------------------------
# Filter parsing and evaluation
# ---------------------------------------------------------------------------
class Filter:
"""Parsed representation of a single --filter expression."""
def __init__(self, raw: str):
if "~=" in raw:
key, _, value = raw.partition("~=")
self.key = key.strip()
self.value = value.strip()
self.operator = "contains"
elif "=" in raw:
key, _, value = raw.partition("=")
self.key = key.strip()
self.value = value.strip()
self.operator = "exact"
else:
raise argparse.ArgumentTypeError(
f"Invalid filter expression {raw!r}. "
"Use 'key=value' for exact match or 'key~=value' for substring/list-contains match."
)
def matches(self, meta: dict) -> bool:
field_val = meta.get(self.key)
if field_val is None:
return False
if self.operator == "exact":
return str(field_val).lower() == self.value.lower()
# contains: substring for strings, element-in for lists
if isinstance(field_val, list):
return any(self.value.lower() == str(item).lower() for item in field_val)
return self.value.lower() in str(field_val).lower()
def __repr__(self):
op = "~=" if self.operator == "contains" else "="
return f"Filter({self.key}{op}{self.value})"
def parse_filter(raw: str) -> Filter:
"""argparse type converter for --filter."""
return Filter(raw)
_MISSING = object()
def _sort_key(doc: dict, field: str):
"""
Sort key for --sort-by. Docs missing the field sort to the end regardless
of --order. Dates (datetime.date / datetime.datetime) and strings are both
normalized to their string form — ISO 8601 date strings sort the same
lexicographically as YAML-parsed date objects' isoformat().
"""
val = doc["meta"].get(field, _MISSING)
if val is _MISSING or val is None:
return (1, "")
try:
return (0, val.isoformat()) # datetime.date / datetime.datetime
except AttributeError:
return (0, str(val))
def doc_matches(doc: dict, filters: list[Filter], query: str | None) -> bool:
meta = doc["meta"]
for f in filters:
if not f.matches(meta):
return False
if query:
needle = query.lower()
haystack = doc["body"].lower() + " " + " ".join(str(v) for v in meta.values()).lower()
if needle not in haystack:
return False
return True
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def _meta_summary(meta: dict) -> str:
"""One-line summary of frontmatter fields, skipping slug/date for brevity."""
skip = {"slug"}
parts = []
for k, v in meta.items():
if k in skip:
continue
if isinstance(v, list):
parts.append(f"{k}=[{', '.join(str(i) for i in v)}]")
else:
parts.append(f"{k}={v}")
return " ".join(parts)
def format_summary(doc: dict) -> str:
return f"### {doc['file']}\n{_meta_summary(doc['meta'])}"
def format_full(doc: dict) -> str:
return format_summary(doc) + "\n\n" + doc["body"]
def print_text(results: list[dict], full: bool) -> None:
print(f"Found {len(results)} document(s).\n")
sep = "\n" + "" * 60 + "\n"
chunks = [format_full(d) if full else format_summary(d) for d in results]
print(sep.join(chunks))
def print_json(results: list[dict], full: bool) -> None:
output = []
for doc in results:
body = doc["body"]
if not full and len(body) > 400:
body = body[:400] + ""
output.append({"file": doc["file"], "meta": doc["meta"], "body": body})
print(json.dumps(output, ensure_ascii=False, indent=2))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generic YAML-frontmatter search across a directory of markdown files.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--dir", metavar="DIR", required=True,
help="Directory of .md files to search.")
parser.add_argument("--filter", "-f", metavar="EXPR", dest="filters",
type=parse_filter, action="append", default=[],
help="Frontmatter filter expression. Repeatable (AND logic). "
"key=value for exact match; key~=value for substring (strings) or element-in (lists).")
parser.add_argument("--query", "-q", metavar="TEXT",
help="Full-text search in document body and frontmatter values.")
parser.add_argument("--full", action="store_true",
help="Print full document body instead of just the frontmatter summary.")
parser.add_argument("--json", dest="as_json", action="store_true",
help="Output results as a JSON array.")
parser.add_argument("--sort-by", metavar="FIELD", dest="sort_by",
help="Sort results by a frontmatter field (e.g. last_reviewed, date, updated_at). "
"ISO-8601 date strings and YAML-parsed dates both sort correctly. "
"Docs missing the field are pushed to the end.")
parser.add_argument("--order", choices=("asc", "desc"), default="desc",
help="Sort order when --sort-by is set. Default: desc (newest first).")
return parser
def _resolve_directory(dir_arg: str) -> Path:
directory = Path(dir_arg)
if not directory.exists():
print(f"[error] Directory not found: {directory}", file=sys.stderr)
sys.exit(1)
if not directory.is_dir():
print(f"[error] Not a directory: {directory}", file=sys.stderr)
sys.exit(1)
return directory
def _sort_results(results: list[dict], sort_by: str, order: str) -> list[dict]:
def has_field(d: dict) -> bool:
return sort_by in d["meta"] and d["meta"][sort_by] is not None
present = [d for d in results if has_field(d)]
missing = [d for d in results if not has_field(d)]
present.sort(key=lambda d: _sort_key(d, sort_by), reverse=(order == "desc"))
return present + missing
def main() -> None:
args = _build_parser().parse_args()
directory = _resolve_directory(args.dir)
docs = load_documents(directory)
if not docs:
print(f"No .md files found in {directory}")
return
results = [d for d in docs if doc_matches(d, args.filters, args.query)]
if not results:
print("No matching documents found.")
return
if args.sort_by:
results = _sort_results(results, args.sort_by, args.order)
if args.as_json:
print_json(results, full=args.full)
else:
print_text(results, full=args.full)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
validate-yaml.py — Validate YAML frontmatter syntax in markdown files.
Scans markdown files for YAML frontmatter (--- ... ---) and checks:
1. Frontmatter block is properly delimited (opening and closing ---)
2. YAML syntax is valid (parseable without errors)
3. (Optional) Required fields are present (--require flag)
Designed for AI agent use: structured output, exit code reflects pass/fail,
no required external dependencies (falls back to builtin parser if PyYAML unavailable).
Usage examples:
# Validate all .md files under codestable/features
python codestable/tools/validate-yaml.py --dir codestable/features
# Validate a single file
python codestable/tools/validate-yaml.py --file codestable/features/2026-04-11-auth/auth-design.md
# Check that required fields exist in frontmatter
python codestable/tools/validate-yaml.py --dir codestable/features --require doc_type --require status
# JSON output for programmatic consumption
python codestable/tools/validate-yaml.py --dir docs/api --json
# Validate the libdoc manifest
python codestable/tools/validate-yaml.py --file docs/api/manifest.yaml --yaml-only
"""
import argparse
import json
import sys
from pathlib import Path
# Force UTF-8 stdout/stderr on Windows where default codepage (e.g. GBK / cp936)
# can't encode the ✓ / ✗ icons used in text output. Safe no-op on POSIX.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8")
except Exception:
pass
# ---------------------------------------------------------------------------
# YAML parsing
# ---------------------------------------------------------------------------
_HAS_PYYAML = False
try:
import yaml # type: ignore
_HAS_PYYAML = True
except ImportError:
pass
def _builtin_parse_yaml(text: str) -> dict:
"""Minimal YAML parser for flat key-value frontmatter (no nested structures)."""
result: dict = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
key, _, raw = stripped.partition(":")
val = raw.strip()
# Inline list
if val.startswith("[") and val.endswith("]"):
inner = val[1:-1]
result[key.strip()] = [
item.strip().strip("'\"") for item in inner.split(",") if item.strip()
]
else:
result[key.strip()] = val.strip("'\"") if val else ""
return result
def parse_yaml_text(text: str) -> tuple[dict | None, str | None]:
"""
Parse a YAML string. Returns (parsed_dict, None) on success,
or (None, error_message) on failure.
"""
if _HAS_PYYAML:
try:
result = yaml.safe_load(text)
if result is None:
return {}, None
if not isinstance(result, dict):
return None, f"Expected a mapping, got {type(result).__name__}"
return result, None
except yaml.YAMLError as exc:
return None, str(exc)
else:
# Builtin fallback — can only detect gross syntax issues
try:
result = _builtin_parse_yaml(text)
return result, None
except Exception as exc:
return None, str(exc)
# ---------------------------------------------------------------------------
# Frontmatter extraction
# ---------------------------------------------------------------------------
def extract_frontmatter(text: str) -> tuple[str | None, str | None]:
"""
Extract YAML frontmatter from a markdown file.
Returns (frontmatter_text, None) on success,
or (None, error_message) if frontmatter is missing or malformed.
"""
if not text.startswith("---"):
return None, "No opening '---' delimiter found"
end = text.find("\n---", 3)
if end == -1:
return None, "No closing '---' delimiter found (frontmatter block not terminated)"
fm_text = text[3:end].strip()
if not fm_text:
return None, "Frontmatter block is empty"
return fm_text, None
# ---------------------------------------------------------------------------
# Validation logic
# ---------------------------------------------------------------------------
class ValidationResult:
def __init__(self, file_path: str):
self.file = file_path
self.errors: list[str] = []
self.warnings: list[str] = []
self.fields: list[str] = [] # fields found in frontmatter
@property
def ok(self) -> bool:
return len(self.errors) == 0
def to_dict(self) -> dict:
d: dict = {"file": self.file, "status": "pass" if self.ok else "fail"}
if self.errors:
d["errors"] = self.errors
if self.warnings:
d["warnings"] = self.warnings
if self.fields:
d["fields"] = self.fields
return d
def _check_required(parsed: dict | None, required_fields: list[str] | None, result: ValidationResult) -> None:
if not required_fields:
return
for field in required_fields:
if field not in (parsed or {}):
result.errors.append(f"Missing required field: '{field}'")
def _warn_if_builtin(result: ValidationResult) -> None:
if not _HAS_PYYAML:
result.warnings.append(
"PyYAML not installed — using builtin fallback parser "
"(may miss some syntax errors). Install with: pip install pyyaml"
)
def _validate_file(
file_path: Path,
required_fields: list[str] | None,
base_dir: Path | None,
mode: str, # "markdown" | "yaml"
) -> ValidationResult:
display_path = str(file_path.relative_to(base_dir)) if base_dir else str(file_path)
result = ValidationResult(display_path)
try:
text = file_path.read_text(encoding="utf-8")
except OSError as exc:
result.errors.append(f"Cannot read file: {exc}")
return result
if mode == "markdown":
yaml_text, extract_err = extract_frontmatter(text)
if extract_err:
result.errors.append(extract_err)
return result
else:
yaml_text = text
parsed, parse_err = parse_yaml_text(yaml_text)
if parse_err:
result.errors.append(f"YAML syntax error: {parse_err}")
return result
result.fields = list(parsed.keys()) if parsed else []
_check_required(parsed, required_fields, result)
_warn_if_builtin(result)
return result
def validate_markdown_file(file_path, required_fields=None, base_dir=None):
"""Validate YAML frontmatter in a single markdown file."""
return _validate_file(file_path, required_fields, base_dir, "markdown")
def validate_yaml_file(file_path, required_fields=None, base_dir=None):
"""Validate a pure YAML file (not markdown with frontmatter)."""
return _validate_file(file_path, required_fields, base_dir, "yaml")
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def print_text_results(results: list[ValidationResult]) -> None:
passed = sum(1 for r in results if r.ok)
failed = len(results) - passed
print(f"Validated {len(results)} file(s): {passed} passed, {failed} failed.\n")
for r in results:
icon = "" if r.ok else ""
print(f" {icon} {r.file}")
for err in r.errors:
print(f" ERROR: {err}")
for warn in r.warnings:
print(f" WARN: {warn}")
if failed > 0:
print(f"\n{failed} file(s) have YAML errors.")
else:
print("\nAll files valid.")
def print_json_results(results: list[ValidationResult]) -> None:
output = {
"total": len(results),
"passed": sum(1 for r in results if r.ok),
"failed": sum(1 for r in results if not r.ok),
"results": [r.to_dict() for r in results],
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Validate YAML frontmatter in markdown files or pure YAML files.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--dir", type=str, help="Directory to scan recursively for .md files")
source.add_argument("--file", type=str, help="Single file to validate")
parser.add_argument("--require", action="append", default=[], metavar="FIELD",
help="Require this field in frontmatter (repeatable)")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
parser.add_argument("--yaml-only", action="store_true",
help="Treat input as pure YAML (not markdown with frontmatter). "
"Use for .yaml/.yml files like manifest.yaml.")
return parser
def _validate_single(path_str: str, require: list[str], yaml_only: bool) -> list[ValidationResult]:
fp = Path(path_str)
if not fp.exists():
print(f"Error: File not found: {fp}", file=sys.stderr)
sys.exit(2)
if yaml_only or fp.suffix in (".yaml", ".yml"):
return [validate_yaml_file(fp, require)]
return [validate_markdown_file(fp, require)]
def _validate_directory(dir_str: str, require: list[str]) -> list[ValidationResult]:
dp = Path(dir_str)
if not dp.is_dir():
print(f"Error: Directory not found: {dp}", file=sys.stderr)
sys.exit(2)
md_files = sorted(dp.rglob("*.md"))
yaml_files = sorted(dp.rglob("*.yaml")) + sorted(dp.rglob("*.yml"))
if not md_files and not yaml_files:
print(f"No .md or .yaml files found under {dp}", file=sys.stderr)
sys.exit(2)
results = [validate_markdown_file(md, require, dp) for md in md_files]
results += [validate_yaml_file(yf, require, dp) for yf in yaml_files]
return results
def main() -> None:
args = _build_parser().parse_args()
if args.file:
results = _validate_single(args.file, args.require, args.yaml_only)
else:
results = _validate_directory(args.dir, args.require)
if args.json_output:
print_json_results(results)
else:
print_text_results(results)
sys.exit(0 if all(r.ok for r in results) else 1)
if __name__ == "__main__":
main()

1
go.mod
View File

@@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/antchfx/htmlquery v1.3.6
github.com/gin-gonic/gin v1.12.0
github.com/gorilla/websocket v1.5.3
github.com/google/uuid v1.6.0
go.uber.org/zap v1.27.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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=

View File

@@ -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,15 @@ type WK struct {
LoginRegexp *regexp.Regexp
CourseIDRegexp *regexp.Regexp
TimeRegexp *regexp.Regexp
authMu sync.Mutex
sessionID string
sessionManager *SessionManager
// 缓存上次应用到的 debug 配置,仅在配置变化时重建
lastDebugEnabled bool
lastDebugProxy string
lastDebugSkipSSLVerify bool
}
func NewWK(username, password, host string, cookies []*http.Cookie) *WK {
@@ -37,11 +56,17 @@ func NewWK(username, password, host string, cookies []*http.Cookie) *WK {
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",
Proxy: "http://127.0.0.1:9000",
VerifySSL: false,
})
VerifySSL: true,
Debug: conf.IsRuntimeDebugEnabled(),
}
if conf.IsRuntimeDebugEnabled() {
reqCfg.Proxy = conf.DebugProxy
reqCfg.VerifySSL = !conf.DebugSkipSSLVerify
}
req := request.NewClient(reqCfg)
if len(cookies) > 0 {
req.SetCookies(cookies)
}
@@ -57,13 +82,61 @@ 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
}
// 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: debugEnabled,
}
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 {
wk.prepareRequestClient()
return wk.Req.R()
}
// Cookies: returns cookies
func (wk *WK) Cookie() []*http.Cookie {
return wk.Cookies
@@ -77,8 +150,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))
@@ -90,8 +162,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",
@@ -108,22 +179,26 @@ 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.Req.
R().
resp, err := wk.newRequest().
SetFormData(map[string]string{
"username": wk.Username,
"password": wk.Password,
@@ -135,25 +210,51 @@ 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
}
// parseLoginResp: 解析登录响应
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)
@@ -203,12 +304,14 @@ func (wk *WK) CourseParse(content string) ([]Course, error) {
var credit float32
creditNode := htmlquery.FindOne(node, `.//div[@class="note"]/div[@class="number"]/span`)
creditStr := strings.TrimSpace(htmlquery.InnerText(creditNode))
creditVal, err := strconv.ParseFloat(creditStr, 32)
if err == nil {
credit = float32(creditVal)
} else {
log.Warn("课程学分转换失败", zap.Error(err))
if creditNode != nil {
creditStr := strings.TrimSpace(htmlquery.InnerText(creditNode))
creditVal, err := strconv.ParseFloat(creditStr, 32)
if err == nil {
credit = float32(creditVal)
} else {
log.Warn("课程学分转换失败", zap.Error(err))
}
}
typeNode := htmlquery.FindOne(node, `.//div[@class="note"]/div[@class="kind"]/span`)
@@ -231,8 +334,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 {
@@ -283,11 +385,15 @@ func (wk *WK) UserInfoParse(content string) (User, error) {
return user, nil
}
// QuestionAnswerParse: 解析题目答案
func (wk *WK) QuestionAnswerParse(content string) (string, error) {
return "", nil
}
// 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)
@@ -303,9 +409,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",
@@ -315,12 +420,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:
@@ -333,7 +445,7 @@ func (wk *WK) Study(nodeID, studyID, studyTime string, status StudyStatus) (*Stu
log.Warnf("第 %d 次获取验证码失败, 正在重试...\n", i)
}
if yzm == "" {
return nil, fmt.Errorf("达到最大重试次数,验证码获取失败,登录终止。")
return nil, fmt.Errorf("达到最大重试次数,验证码获取失败,登录终止。")
}
data = map[string]string{
"nodeId": nodeID,
@@ -357,8 +469,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",
@@ -370,6 +481,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 {
@@ -380,15 +494,20 @@ 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)
}
// GetExamList: 获取作业记录
// todo
func (wk *WK) GetWorkList(courseID, page string) (*AllRecordResp[ExamList], error) {
return GetRecords[ExamList](wk, RecordStudy, courseID, page)
// GetWorkList: 获取作业记录
func (wk *WK) GetWorkList(courseID, page string) (*AllRecordResp[WorkList], error) {
return GetRecords[WorkList](wk, RecordWork, courseID, page)
}
// GetExamList: 获取考试记录
@@ -396,8 +515,105 @@ func (wk *WK) GetExamList(courseID, page string) (*AllRecordResp[ExamList], erro
return GetRecords[ExamList](wk, RecordExam, courseID, page)
}
// GetExamList: 获取讨论记录
// todo
func (wk *WK) GetDiscussList(courseID, page string) (*AllRecordResp[ExamList], error) {
return GetRecords[ExamList](wk, RecordStudy, courseID, page)
// GetDiscussList: 获取讨论记录
func (wk *WK) GetDiscussList(courseID, page string) (*AllRecordResp[StudyList], error) {
return GetRecords[StudyList](wk, RecordDiscuss, 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"`
}
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 {
@@ -72,6 +77,44 @@ type StudyList struct {
URL string `json:"url"`
}
// 作业记录列表
type WorkList struct {
ID string `json:"id"`
UserID any `json:"userId"`
Title string `json:"title"`
TopicNumber string `json:"topicNumber"`
Score string `json:"score"`
Type string `json:"type"`
Remarks string `json:"remarks"`
AddTime string `json:"addTime"`
Sequence string `json:"sequence"`
NodeID string `json:"nodeId"`
CourseID string `json:"courseId"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
PaperID string `json:"paperId"`
CreateUserID string `json:"createUserId"`
IsPrivate string `json:"isPrivate"`
ClassList string `json:"classList"`
TeacherType string `json:"teacherType"`
Allow string `json:"allow"`
Frequency string `json:"frequency"`
ScoringRules string `json:"scoringRules"`
HasCollect string `json:"hasCollect"`
Lock any `json:"lock"`
SchoolID string `json:"schoolId"`
Parsing string `json:"parsing"`
AddDate string `json:"addDate"`
Name string `json:"name"`
ChapterID string `json:"chapterId"`
State string `json:"state"`
SubmitTime string `json:"submitTime"`
FinalScore string `json:"finalScore"`
TypeName string `json:"typeName"`
FinishTime string `json:"finishTime"`
URL string `json:"url"`
}
// 考试记录列表
type ExamList struct {
ID string `json:"id"`

View File

@@ -29,16 +29,34 @@ 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 {
item.cancel()
}
ctx, cancel := context.WithCancel(context.Background())
item.LastValue = time.Now()
wk.bindSession(m, oldID)
item.Instance = wk
item.cancel = cancel
m.sessions[oldID] = item
log.Info("用户已存在,复用旧 Session",
@@ -46,11 +64,14 @@ func (m *SessionManager) Store(wk *WK) string {
zap.String("user", userKey),
)
go m.KeepAlive(ctx, oldID)
return oldID
}
sessionID := uuid.New().String()
ctx, cancel := context.WithCancel(context.Background())
wk.bindSession(m, sessionID)
m.userToSession[userKey] = sessionID
m.sessions[sessionID] = SessionItem{
@@ -61,7 +82,7 @@ func (m *SessionManager) Store(wk *WK) string {
log.Info("创建新 Session", zap.String("id", sessionID))
go m.KeepAlive(ctx, sessionID, wk)
go m.KeepAlive(ctx, sessionID)
return sessionID
}
@@ -69,13 +90,19 @@ func (m *SessionManager) Store(wk *WK) string {
// Get: 获取指定 session id 的 wk
func (m *SessionManager) Get(sessionID string) (*WK, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
item, ok := m.sessions[sessionID]
if ok {
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) {
@@ -83,20 +110,11 @@ 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)
}
}
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)
defer ticker.Stop()
@@ -108,7 +126,15 @@ func (m *SessionManager) KeepAlive(ctx context.Context, id string, wk *WK) {
log.Info("KeepAlive 已停止", zap.String("id", id))
return
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 {
log.Error("自动保活请求失败", zap.Error(err))
}
@@ -121,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 已清空")
@@ -144,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)
}
}
}

View File

@@ -25,7 +25,7 @@ const (
StudyStart StudyStatus = 1 // 开始学习
Study StudyStatus = 2 // 学习中
StudyOver StudyStatus = 3 // 学习介绍
StudyOver StudyStatus = 3 // 学习结束
)
// User: 用户
@@ -49,10 +49,37 @@ type Course struct {
Type string `json:"type"`
}
// 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)))
resp, err := wk.Req.
R().
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,
"page": page,
@@ -60,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
}

View File

@@ -1,5 +1,11 @@
package conf
import (
"os"
"strings"
"sync/atomic"
)
// 构建信息
var (
Mode string = "debug"
@@ -9,4 +15,39 @@ var (
GitAuthor string = "unknown"
GitEmail 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"`
}
type CourseReq struct {
Status ckwk.CourseKind `json:"status"`
}
type StudyReq struct {
NodeID string `json:"node_id"`
StudyID string `json:"study_id"`

View File

@@ -4,6 +4,7 @@ import (
"ckwk/internal/ckwk"
"ckwk/internal/dto"
"ckwk/pkg/log"
"errors"
"fmt"
"net/http"
@@ -21,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 {
@@ -44,38 +55,37 @@ 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,
}))
}
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 {
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
}
@@ -93,13 +103,51 @@ func (h *WKHandler) Logout(ctx *gin.Context) {
ctx.JSON(200, dto.Ok())
}
func (h *WKHandler) Study(ctx *gin.Context) {
val, ok := ctx.Get("wk_instance")
func (h *WKHandler) UserInfo(ctx *gin.Context) {
wk, ok := getWKFromContext(ctx)
if !ok {
return
}
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
}
wk, ok := getWKFromContext(ctx)
if !ok {
return
}
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) {
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 {
@@ -109,6 +157,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
}
@@ -117,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 {
@@ -174,8 +224,8 @@ func (h *WKHandler) AllRecord(ctx *gin.Context) {
ctx.JSON(200, dto.Success(map[string]any{
"list": list,
"page_info": map[string]any{
"page": 1,
"pageSize": pageInfo.RecordsCount,
"page": pageInfo.Page,
"pageSize": pageInfo.PageSize,
},
}))
}

View File

@@ -0,0 +1,136 @@
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 {
if conf.IsBuildDebugMode() {
return true
}
origin := r.Header.Get("Origin")
return origin == ""
},
}
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) {
ctx.JSON(200, dto.Success(map[string]string{
"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(),
}))
}

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

View File

@@ -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()
})

View File

@@ -1,22 +1,34 @@
package common
import (
"math/rand"
"time"
"crypto/rand"
"math/big"
)
// Rand 生成指定长度的随机字符串,字符集为 [0-9a-zA-Z]
// 使用 crypto/rand 保证不可预测性
func Rand(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
seededRand := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, length)
max := big.NewInt(int64(len(charset)))
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
n, err := rand.Int(rand.Reader, max)
if err != nil {
// crypto/rand 不应失败,如果失败说明系统随机源有问题
panic("crypto/rand failed: " + err.Error())
}
b[i] = charset[n.Int64()]
}
return string(b)
}
// RandFloat64 返回 [0.0,1.0) 范围的随机浮点数
// 使用 crypto/rand 保证不可预测性
func RandFloat64() float64 {
return rand.Float64()
n, err := rand.Int(rand.Reader, big.NewInt(1<<53))
if err != nil {
panic("crypto/rand failed: " + err.Error())
}
return float64(n.Int64()) / float64(1<<53)
}

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

@@ -0,0 +1,286 @@
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[0] = Entry{} // 释放旧条目引用,允许 GC 回收
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),
zap.DebugLevel,
)
core = zapcore.NewTee(core, NewMemoryCore(zap.DebugLevel))
logger = zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1))
sugar = logger.Sugar()
@@ -96,7 +97,9 @@ func Init(cfg Config) {
core := zapcore.NewTee(
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))
sugar = logger.Sugar()

View File

@@ -2,9 +2,14 @@ package request
import (
"crypto/tls"
"encoding/json"
"net/http"
"net/url"
"time"
"ckwk/pkg/log"
"go.uber.org/zap/zapcore"
"resty.dev/v3"
)
@@ -15,7 +20,8 @@ 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
)
type Config struct {
@@ -35,24 +41,91 @@ func DefaultConfg() *Config {
}
}
// NewClient 创建一个标准的 Resty 客户端
func NewClient(cfg *Config) *resty.Client {
func normalizeConfig(cfg *Config) *Config {
defaults := DefaultConfg()
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.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)
}
client.SetDebugLogFormatter(nil)
ApplyConfig(client, cfg)
return client
}

2
web/.gitignore vendored
View File

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

1
web/frontend Submodule

Submodule web/frontend added at 7e491d4e60