diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a10851b --- /dev/null +++ b/AGENTS.md @@ -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 验证要求 + +- 涉及前端视觉/交互的改动,必须在浏览器里人工验证 diff --git a/codestable/architecture/ARCHITECTURE.md b/codestable/architecture/ARCHITECTURE.md new file mode 100644 index 0000000..ed95a35 --- /dev/null +++ b/codestable/architecture/ARCHITECTURE.md @@ -0,0 +1,38 @@ +# 刷课平台后端 架构总入口 + +> 状态:骨架(待填充) +> 创建日期:2026-04-25 + +## 1. 项目简介 + +刷课平台后端,提供登录、课程列表获取、网课记录查询、学习接口等功能。前端项目为 wk-frontend(git 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. 已知约束 / 硬边界 + +(待填充) diff --git a/codestable/compound/.gitkeep b/codestable/compound/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/codestable/features/.gitkeep b/codestable/features/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/codestable/issues/.gitkeep b/codestable/issues/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/codestable/issues/2026-04-25-six-bugs/2026-04-25-six-bugs-fix-note.md b/codestable/issues/2026-04-25-six-bugs/2026-04-25-six-bugs-fix-note.md new file mode 100644 index 0000000..d44a229 --- /dev/null +++ b/codestable/issues/2026-04-25-six-bugs/2026-04-25-six-bugs-fix-note.md @@ -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 默认值为 0(float32 零值),行为等价 + +## 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 中会丢失。但由于之前完全不可用,现在至少能返回数据,比返回错误更好 diff --git a/codestable/reference/code-dimensions.md b/codestable/reference/code-dimensions.md new file mode 100644 index 0000000..8342f46 --- /dev/null +++ b/codestable/reference/code-dimensions.md @@ -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 但代码里外部输入没校验,就是不达标。 diff --git a/codestable/reference/maintainer-notes.md b/codestable/reference/maintainer-notes.md new file mode 100644 index 0000000..c628144 --- /dev/null +++ b/codestable/reference/maintainer-notes.md @@ -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/`,不要散落在各子技能里 \ No newline at end of file diff --git a/codestable/reference/requirement-example.md b/codestable/reference/requirement-example.md new file mode 100644 index 0000000..3bbb6e3 --- /dev/null +++ b/codestable/reference/requirement-example.md @@ -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 里删掉。 diff --git a/codestable/reference/shared-conventions.md b/codestable/reference/shared-conventions.md new file mode 100644 index 0000000..f2e6c29 --- /dev/null +++ b/codestable/reference/shared-conventions.md @@ -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`(落盘即确认,无 draft);design = `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` 之外保留自己的专属 frontmatter(learning 的 `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 的推进计划,要么记成顺手发现留到后续。 + +不许偷偷拆完继续写,也不许忽略信号硬冲。默认动作是停、问、再继续。 \ No newline at end of file diff --git a/codestable/reference/system-overview.md b/codestable/reference/system-overview.md new file mode 100644 index 0000000..aedd198 --- /dev/null +++ b/codestable/reference/system-overview.md @@ -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` — 项目架构总入口 diff --git a/codestable/reference/tools.md b/codestable/reference/tools.md new file mode 100644 index 0000000..2610228 --- /dev/null +++ b/codestable/reference/tools.md @@ -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 +``` diff --git a/codestable/requirements/.gitkeep b/codestable/requirements/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/codestable/roadmap/.gitkeep b/codestable/roadmap/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/codestable/tools/search-yaml.py b/codestable/tools/search-yaml.py new file mode 100644 index 0000000..5d18605 --- /dev/null +++ b/codestable/tools/search-yaml.py @@ -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() diff --git a/codestable/tools/validate-yaml.py b/codestable/tools/validate-yaml.py new file mode 100644 index 0000000..9f9a5a3 --- /dev/null +++ b/codestable/tools/validate-yaml.py @@ -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() diff --git a/internal/ckwk/api.go b/internal/ckwk/api.go index 55ebdb6..2e5b486 100644 --- a/internal/ckwk/api.go +++ b/internal/ckwk/api.go @@ -193,6 +193,7 @@ func (wk *WK) Login() (bool, error) { return true, nil } +// parseLoginResp: 解析登录响应 func (wk *WK) parseLoginResp(body []byte) (LoginResp, error) { var result LoginResp if len(body) == 0 { @@ -273,12 +274,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`) @@ -352,6 +355,11 @@ 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 @@ -467,10 +475,9 @@ func (wk *WK) GetStudyList(courseID, page string) (*AllRecordResp[StudyList], er 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: 获取考试记录 @@ -478,10 +485,9 @@ 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 { diff --git a/internal/ckwk/resp.go b/internal/ckwk/resp.go index c8c4224..e31cd59 100644 --- a/internal/ckwk/resp.go +++ b/internal/ckwk/resp.go @@ -77,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"` diff --git a/internal/ckwk/types.go b/internal/ckwk/types.go index 5018b72..ef1b6e0 100644 --- a/internal/ckwk/types.go +++ b/internal/ckwk/types.go @@ -49,6 +49,9 @@ type Course struct { Type string `json:"type"` } +type QAList struct { +} + func GetRecords[T any](wk *WK, rType RecordType, courseID, page string) (*AllRecordResp[T], error) { log.Debug("获取记录信息", zap.String("host", wk.Host), zap.String("type", string(rType))) resp, err := wk.newRequest(). diff --git a/internal/handler/ckwk.go b/internal/handler/ckwk.go index bd350c2..ac359ae 100644 --- a/internal/handler/ckwk.go +++ b/internal/handler/ckwk.go @@ -224,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, }, })) } diff --git a/internal/handler/debug_log.go b/internal/handler/debug_log.go index a900472..c427a22 100644 --- a/internal/handler/debug_log.go +++ b/internal/handler/debug_log.go @@ -16,7 +16,11 @@ import ( var debugLogUpgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { - return true + if conf.IsBuildDebugMode() { + return true + } + origin := r.Header.Get("Origin") + return origin == "" }, } diff --git a/internal/router/router.go b/internal/router/router.go index fb8281d..152c30b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -73,7 +73,6 @@ func SetupRouter() *gin.Engine { debug.GET("/logs", handler.DebugLogs) debug.GET("/logs/download", handler.DebugLogsDownload) debug.GET("/logs/ws", handler.DebugLogWS) - debug.GET("/ws/logs", handler.DebugLogWS) } v1 := api.Group("/v1") { diff --git a/pkg/common/rand.go b/pkg/common/rand.go index 4c15e2c..d9c610d 100644 --- a/pkg/common/rand.go +++ b/pkg/common/rand.go @@ -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) }