diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..93b9d1b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# 仓库内文本统一以 LF 存储,避免 Windows/WSL 混用产生全量行尾差异。 +# 行尾差异会淹没真实改动,也会让 gitea.env 之类的配置在 bash 中带上 \r。 +* text=auto eol=lf + +*.py text eol=lf +*.md text eol=lf +*.json text eol=lf +*.ps1 text eol=crlf + +*.png binary +*.jpg binary +*.zip binary diff --git a/.gitea/issue_template/epic.md b/.gitea/issue_template/epic.md new file mode 100644 index 0000000..d908c1e --- /dev/null +++ b/.gitea/issue_template/epic.md @@ -0,0 +1,31 @@ +## 背景 + + + +## 目标 + +- + +## 非目标 + +- + +## 总体方案 + + + +## 阶段路线 + +1. + +## MVP 与任务索引 + +- [ ] # MVP 工单 + +## 依赖、风险和回退 + +- + +## 最终验收标准 + +- [ ] diff --git a/.gitea/issue_template/mvp.md b/.gitea/issue_template/mvp.md new file mode 100644 index 0000000..eba1f36 --- /dev/null +++ b/.gitea/issue_template/mvp.md @@ -0,0 +1,27 @@ +## 基本信息 + +- 所属 Epic:# + +## MVP 目标 + + + +## 包含范围 + +- + +## 排除范围 + +- + +## 阶段与单元任务 + +- [ ] # 单元任务 + +## 集成风险和回退 + +- + +## MVP 验收标准 + +- [ ] diff --git a/.gitea/issue_template/task.md b/.gitea/issue_template/task.md new file mode 100644 index 0000000..ba355b9 --- /dev/null +++ b/.gitea/issue_template/task.md @@ -0,0 +1,103 @@ +## 基本信息 + +- 类型:需求 / 缺陷 / 重构 +- 所属 Epic:# +- 所属 MVP / 版本:# +- 阶段: + +## 依赖与并行 + +- 前置工单:无 / #编号 +- 是否允许与前置工单并行:是 / 否 +- 原因: + +## 子项目影响 + + + +- 仅影响的子项目 / 交付单元: +- 是否跨子项目:是 / 否 +- 是否修改共享接口或契约:是 / 否;唯一事实来源: +- 各子项目需要执行的验证: + +## 原始需求 + +- 来源:用户对话 / Gitea / 其他 +- 提出时间: +- 关键原话或脱敏摘要: + + + +## 要解决什么 + + + +## 做什么 / 不做什么 + +- 做: +- 不做: + +## 已确认方案 + + + +预计修改文件: + +- + +## 需求变化记录 + + + +| 日期 | 变化内容 | 原因 | 用户确认 | +|---|---|---|---| +| | | | 是 / 否 | + +## 设计与原型门禁 + + + +- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug +- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据 +- 可编辑设计源链接、版本或事实来源: +- 本地 HTML 审核快照路径和版本(不适用时说明原因): +- 本地浏览方式和资源完整性检查: +- 版本、revision 或确认日期: +- 状态:无 / 草稿 / 已确认 / 已废弃 +- 确认人、确认时间和覆盖范围: +- 无需 UI 原型或无需任何原型的原因: + + + +## 文档影响 + + + +- [ ] 不影响长期文档,原因: +- [ ] 更新项目档案或本地开发与验证 +- [ ] 更新架构与代码地图 +- [ ] 更新业务规则与术语 +- [ ] 更新常见修改或故障排查 +- [ ] 更新其他 Wiki 页面: + +## 交付文档影响 + + + +- [ ] 无交付文档影响,原因: +- [ ] 更新已有交付文档,受众与页面: +- [ ] 新增交付文档,受众与页面: +- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式: + +## 验收标准 + +- [ ] +- [ ] + +## 验证方式 + + + +## 风险和回退 + + diff --git a/.gitignore b/.gitignore index 5b90e79..4eb5cf4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,14 @@ go.work.sum # env file .env + +# Python 缓存(Harness 工具) +__pycache__/ +*.pyc + +# 本地运行产物 +/data/ +/uploads/ + +# Gitea 凭据,禁止提交 +gitea.env diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..865696c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,233 @@ +# Agent 开发规则 + +本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录。`docs/` 默认保存核心 Wiki 的只读镜像,`docs/task/` 只保存人工按需导出的任务归档快照。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。 + +开始工作前先阅读任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。 + +[项目档案](docs/00-project-profile.md) 按需阅读,不作为每次任务的固定前置。出现下列情况之一时必须读:需要环境、配置或凭据来源;需要确认目录边界;需要判断子项目与交付单元划分;需要 DevHarness 来源与基线;需要项目专用验收要求。只为查命令不必打开项目档案。 + +## 常用命令 + +所有命令默认从仓库根目录执行,与项目档案保持一致。 + +| 用途 | 命令 | +|---|---| +| 查看工作区 | `git status --short --branch` | +| 检查模板结构 | `python dev_scripts/harness.py check --strict` | +| 运行单元测试 | `python -m unittest discover -s tests -v` | +| 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` | +| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` | +| 创建任务归档 | `python dev_scripts/harness.py archive 123 "修复登录超时"` | +| 增量导出任务归档 | `python dev_scripts/harness.py export` | +| 全量导出任务归档 | `python dev_scripts/harness.py export --all` | + +## 1. 永久规则 + +- 不把密码、令牌、Cookie、私钥、个人数据或生产数据写入代码、日志、工单、Wiki 和文档。 +- 不执行未经用户明确授权的发布、付款、删除数据、破坏性迁移或其他不可逆操作。 +- 保留用户已有和任务无关的工作区改动,不擅自重置、覆盖或混入提交。 +- 发现需求与安全规则、已确认方案或现有数据冲突时,先停止实施并说明影响。 +- 测试结果必须真实;未执行或无法覆盖的验证必须明确记录。 + +项目专用红线写入本文件的“项目专用规则”或对应子目录的 `AGENTS.md`,不要散落在聊天记录中。 + +## 2. 哪些改动需要工单 + +新功能、缺陷修复、重构以及任何用户可感知或改变程序行为的修改,必须先建立单元任务工单。 + +以下小改动可以直接提交,不要求工单和任务归档: + +- 只改错别字、注释或文档措辞; +- 只做格式化、导入排序或不跨文件的内部变量改名; +- 补充类型标注或文档字符串且不改变行为; +- 删除已经确认无人使用的死代码。 +- 只修改用户看到的界面显示文案,并且满足本文件“工单与设计证据双门禁”的全部豁免条件。 + +除严格符合界面显示文案豁免的修改外,只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案,不适用豁免。 + +## 3. 需求到实施 + +1. 先复述目标,阅读相关代码、日志和文档,区分事实与假设。 +2. 给出目标、非目标、方案、影响范围、风险、回退方式、验证方法和文档影响。 +3. 方案没有得到用户明确确认前,只做只读诊断和方案整理,不实施正式代码。 +4. 方案确认后,先建立单元任务工单,再修改代码。 +5. 建立新工单不要求其他工单已经完成;开始实施前检查工单声明的前置工单。真实依赖未满足时保持“待实施”,允许并行时必须写明原因。 +6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。 +7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。 +8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。 +9. 长期核心文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/harness.py sync` 导出本地镜像;任务归档默认只更新 Wiki,不自动导出本地。不得直接编辑镜像后反向覆盖 Wiki。 + +Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。 + +### 新项目 Wiki 初始化门禁 + +- 从本模板创建新项目时,先创建 Gitea 远端仓库并启用工单和 Wiki,再配置 `wiki-docs.json`;不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据。 +- 优先使用项目已配置的 Gitea MCP 查询和写入 Wiki;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。 +- 先查询线上页面列表;`Home` 不存在时必须先创建 `Home`,在线回读正文并记录 revision,然后再逐页创建或更新其他核心映射页面。 +- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化,不得开始产品代码。 +- 产品编码前必须运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示线上 Wiki 和核心镜像初始化完成。 + +### 工单与设计证据双门禁 + +- 纯界面显示文案只有在不改变业务含义、流程、权限、状态、接口、数据、法律/安全/支付/单位等高风险含义、国际化键、程序标识符、布局和可访问性,且没有任何不确定时,才免工单和原型;修改后执行最小界面检查。 +- 新页面、独立用户功能、重大交互或导航变化,必须先用 Quant-UX 或其他合适工具制作可审阅原型;用户确认原型、文字需求和覆盖范围后,才能建立或放行实现工单并编写生产代码。 +- 上述完整原型形成待审核版本后,必须在用户审核前生成本地可浏览 HTML 快照,保存到 `prototypes/<工单号>/<版本>/index.html`;版本目录内资源使用相对路径。可编辑设计源仍在原设计工具,Git HTML 是版本化审核证据,Wiki 和工单只保存索引。 +- 已确认的 HTML 快照不得原位覆盖;页面结构、流程、状态、权限、异常处理或验收结果变化时,使用新版本目录重新导出并重新确认。提交审核前检查入口、主要交互和资源完整性,并删除凭据、账号、个人信息和生产数据。 +- 现有界面的小范围样式或布局调整使用标注截图、低保真图或明确复用的现有规范;新组件记录状态、错误和边界。两者只要不符合纯文案豁免就必须建单。 +- 后端、接口、数据处理和定时任务不强制 UI 原型,但必须先确认架构、API、数据、状态或流程设计;恢复既有确认行为的 Bug 可以复用原设计、截图、复现步骤或已有验收证据。 +- 需要设计证据的工单记录链接或路径、版本/revision 或日期、状态、确认人、确认时间和覆盖范围;没有 UI 原型时记录替代技术设计或原因。 +- 页面结构、主要流程、状态、权限、异常处理或验收结果变化时,必须更新原型或文字需求并重新确认后再继续正式编码。 +- 草稿原型可以用于讨论;写入 Git/Wiki、多人协作或单独实施时建立设计任务。草稿和经明确授权的隔离技术验证都不得直接作为生产实现。 +- 设计工具无法生成可用 HTML 时,在工单记录限制并停止审核,等待用户确认等效的本地可浏览方案;不得只保留难以访问的线上链接后直接编码。纯显示文案、小范围 UI、非 UI 需求和恢复既有行为的 Bug 不强制生成 HTML。 + +### 自然语言快捷指令 + +快捷指令只是本工作流的自然语言别名,不得绕过方案确认、前置依赖、安全规则、工单范围、Wiki 主源、必要验证或人工验收: + +- `只分析`:只读检查并给出方案;不建单、不修改,停在等待确认。 +- `建工单`:根据已确认方案创建单元任务工单;建单后停止,不修改代码。 +- `执行工单 #N`:检查工单和依赖,实施、测试、提交、归档、推送并回写证据;停在“待验收”。 +- `建工单并做`:依次建单和执行,`建工单,做`、`建工单,做` 含义相同;停在“待验收”。 +- `继续工单 #N`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。 +- `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。 +- `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。 +- `导出任务归档`:人工触发 `python dev_scripts/harness.py export`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。 +- `导出全部任务归档`:人工触发 `python dev_scripts/harness.py export --all`,读取并导出全部线上任务归档;不删除本地文件、不自动提交。 +- `#N 验收通过`:仅在用户明确验收后,更新 Wiki 归档、同步必要的核心文档、推送、同步父工单并关闭任务;不自动导出任务归档。 + +方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。任务归档导出必须由用户明确提出,其他指令不得隐式执行。Gitea 工单不导出全文,`docs/task/` 只是可能不完整的 Wiki 任务归档快照。详细语义见 [开发工作流](docs/01-workflow.md)。 + +### 需求记录与流转 + +- 创建单元任务工单时,记录原始需求的来源、提出时间,以及能表达用户目的、场景和限制的少量关键原话或脱敏摘要;不得臆造用户原话。 +- 工单中的目标、非目标、已确认方案、验收标准和文档影响构成确认后的正式任务需求。 +- 影响范围、接口、数据、风险或验收的需求变化必须记录日期、内容、原因和用户确认;会改变已确认结果时先更新工单并等待再次确认。 +- 不复制完整聊天,不保存 Agent 内部推理,不写入密码、令牌、个人数据或生产数据;包含敏感信息的原话必须删除敏感部分或改写为脱敏摘要。 +- 长期有效的产品需求、业务规则和系统边界进入对应 Wiki 主题页并导出核心 `docs/`;完成结果进入 Wiki 任务归档,仅在用户明确要求时导出 `docs/task/`。Gitea 工单全文不导出到仓库。 + +详细记录边界见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。 + +### 效率与范围控制 + +本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。 + +#### 严格控制范围 + +- 默认严格按用户确认的目标和单元任务范围执行,不主动扩展相邻问题。 +- 除非任务目标、仓库强制规则或已发现的真实阻塞需要,不新增额外文档、辅助脚本、备份文件、框架、重构或扩展性设计。 +- 不执行与本次验收无关的验证;安全检查、受影响范围测试、回归测试和仓库规定的闭环验证不属于“额外验证”。 +- 新发现的相邻问题最多用一句话提示或记录到独立工单,不自动修复或混入当前提交。 + +#### 渐进执行和修复 + +- 完成已知必要的安全与前置检查后,优先执行能够产生真实反馈的最小命令。 +- 一次执行后先处理首个可定位、可行动的真实错误,不同时猜测并修改多个可能原因。 +- 采用“执行 → 查看错误 → 最小修复 → 从失败点继续或按需重跑”的闭环。 +- 不在真实证据出现前堆叠与已知风险无关的预防性检查。 +- 涉及凭据、权限、安全、数据、迁移、并发、删除、发布或不可逆操作时,必须先完成相应前置检查,不得通过试错获取风险反馈。 + +#### 复用已验证事实 + +- 在同一任务和同一环境状态下,已经通过的路由、连接、恢复和环境检查不重复执行。 +- 只有会话、环境、代码、配置、依赖、凭据、远端状态或关键前提发生变化时才重新检查。 +- 代码修改后,受影响测试和最终验收必须重新执行;提交前工作区检查、推送前远端分支检查不得因为之前通过而省略。 +- Skill 和平台规则是否需要重新读取,按当前 Agent 平台和任务触发规则执行,不自行跳过。 + +#### 明确停止条件 + +- 完成用户确认的验收标准和仓库规定的必要闭环后立即停止,不主动继续优化。 +- “最小验收条件”包括当前工单要求的实现、必要测试、文档影响处理、Wiki 镜像检查、提交和证据回写,不等同于功能第一次运行成功。 +- 未影响当前验收的相邻问题只提示或建单,不顺手处理。 + +## 4. 工单层级 + +- 单元任务是唯一正式实施单位,记录方案、范围、验收标准、过程和测试结果。 +- Epic 和 MVP 只维护目标、风险、汇总及 `- [ ] #编号` / `- [x] #编号` 子工单索引,不复制单元任务全文。 +- 新任务先建单元工单,再把编号同步到所属 MVP 和 Epic。 +- 详细层级、状态和模板入口见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。 + +## 5. 实施中的变化 + +- 范围、接口、数据结构、依赖、验收标准或风险变化时,先更新单元工单。 +- 变化影响 MVP 或 Epic 时,同时更新父工单。 +- 会改变用户已确认结果的变化,更新工单后必须再次等待用户确认。 +- 阻塞、失败方案和新发现根因不能只留在聊天或代码注释中。 +- Wiki 页面删除、重命名或事实源边界变化时,必须先更新工单并等待用户确认;同步工具不得自动传播删除或重命名。 +- 工单状态应使用:待确认、待实施、进行中、阻塞、待验收、已完成。 + +## 6. Git 与验证 + +- 提交只包含当前工单相关文件。 +- 实现提交信息引用工单号,例如:`fix: 修复登录超时 (#123)`。 +- 不为流程制造空提交。 +- 优先运行项目档案中记录的格式检查、静态检查、单元测试和必要的集成测试。 +- 不能验证的真机、生产、迁移或并发行为必须写入工单和归档。 + +## 7. 完成、验收和归档 + +1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。 +2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。 +3. 运行 `python dev_scripts/harness.py archive <编号> "<短标题>"`,只创建 Wiki 任务归档,不登记或导出本地镜像。 +4. 读取确认 Wiki,运行 `python dev_scripts/harness.py sync --check` 检查核心镜像,并把任务归档页面、revision 和提交哈希写回工单。只有用户明确提出时才增量或全量导出任务归档。 +5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。 + +MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。 + +详细归档顺序和字段见 [开发工作流](docs/01-workflow.md)。 + +## 8. 可维护性 + +- 优先使用直白、常见的实现;不要为了少写几行引入晦涩技巧。 +- 类和函数保持单一职责,名称表达业务含义。 +- 注释解释原因、边界和风险,不逐行翻译代码。 +- 错误必须可定位,不静默吞掉失败。 +- Wiki 文档先写结论和用途,再写步骤;示例命令应可直接复制,本地 `docs/` 由同步工具生成。 +- 面向初级维护者说明从哪里开始读、怎样运行和怎样验证。 + +### 修改风险 + +- 低风险修改可由初级程序员在 Agent 协助下处理;接口、配置、依赖、跨模块逻辑和数据结构由 Agent 实现并验证。 +- 权限、安全、并发、迁移、支付、删除数据和不可逆操作属于高风险;高风险修改必须停止,由 Agent 分析并等待人工确认。 +- 风险按影响范围判断,不按代码行数判断;示例见 [常见修改指南](docs/05-common-changes.md)。 + +### 文档影响 + +- 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。 +- 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 Wiki。 +- 部署命令变化时,有常驻服务的项目更新自己的 `Deployment-and-Operations` 页面(由 [部署文档模板](docs/templates/deployment.md) 复制建立);没有常驻服务的项目记录为无部署文档影响,不创建空的部署页。 +- 普通内部重构只有在入口、行为、配置和验证方式均未改变时,才可以记录为不影响长期文档。 +- 必需核心页面及结构以 `python dev_scripts/harness.py check --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。 + +## 9. 引导提交例外 + +从本模板创建全新仓库时,Gitea 远端和工单尚不存在,允许一次不带工单号的初始引导提交。该提交只能包含仓库骨架、Harness 规则和远端配置准备,不能包含产品功能。 + +远端建立并推送后,这个例外立即失效。 + +## 10. 项目专用规则 + + + +- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 默认保存显式映射生成的核心只读镜像;`docs/task/` 是人工按需快照,可能不完整或不是最新状态。 +- 核心 Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出核心 `docs` → 校验差异 → 提交镜像。 +- 任务归档默认只保存在 Wiki;`导出任务归档` 或 `导出全部任务归档` 必须由用户明确提出,且不得自动传播删除或重命名。 +- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。 +- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。 +- `dev_scripts/` 只存放 DevHarness 自身工具;业务项目的通用脚本必须使用独立目录,不得混放。 + +## chorus 项目专用红线 + +以下规则针对本项目,优先级不低于上面的通用规则。违反其中任何一条的改动必须停止并等待人工确认。 + +1. `internal/core` 不得 import gin 或 go-admin,只依赖 GORM 和标准库。 +2. 生成链路不得读写点数:无扣费、无退款、无余额校验、无配额。点数只读展示。 +3. `retryable` 判定不得随手改:`429 / 5xx / 超时 / 连接错误` 换下一家;`400 / 401 / 内容策略拒绝` 立即返回。修改必须附带覆盖四种情况的单元测试。 +4. 对 provider `base_url` 的出站请求必须经过 SSRF 拦截(DNS 解析后、连接前的 `DialContext` 钩子)。不得为了联调临时关闭。 +5. provider `api_key` 必须 AES-GCM 加密落库;明文密钥不得进入代码、日志、响应、工单、Wiki、原型或截图。 +6. 生产不得使用 GORM AutoMigrate;表结构只经 `migrations/` 演进,每个迁移的 up 与 down 都必须验证过。 +7. 管理员(`sys_user`)与终端用户(`users`)分表,不得合并或互相复用凭据。 +8. 提交生成的同步链路不得调用上游;上游调用只发生在 worker 中。 +9. `generations` 的 `rendered_prompt` 与 `attempts` 必须落库,失败时必须写 `error_code` 与 `error_message`。 +10. 不得使用真实上游额度做反复试错;调试用 mock 上游。 +11. 不得把真实用户数据、生成图片或生产数据库内容带入测试、工单、Wiki 或原型。 +12. 明确不做的范围(计费扣点、充值与支付回调、汇率、软件授权与设备绑定、内容审核、cmshopee 专用端点、每日生成配额)不得在实施工单中悄悄加回;确有需要先建 Epic 重新确认。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1e2bac7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,31 @@ +# Claude Code 项目入口 + +@AGENTS.md + +## Claude Code 专用说明 + +- 上方导入的 `AGENTS.md` 是所有编码 Agent 的共同规则事实来源,Claude Code 必须完整遵守。 +- 本文件只记录 Claude Code 特有的模型路由、工具和 Agent 协作规则。 +- 共同规则变化时只修改 `AGENTS.md`,不要在本文件重复维护。 + +## 模型路由 + +- 目标、范围和修改位置已经明确时,默认使用 Sonnet 分析、建单和实施。 +- 需求模糊、根因不明,或涉及跨模块架构、安全、权限、并发、迁移和不可逆操作时,使用 Opus 或 `opusplan` 制定方案。 +- Opus 输出方案后必须等待用户确认;确认后由 Sonnet 根据方案创建工单并实施。 +- Haiku 只用于范围明确的只读任务,例如查找代码入口、读取项目文档、提取日志事实和整理调用关系。 +- 不让 Haiku 决定最终根因、技术方案、风险等级或验收结论。 +- 当前模型足以完成任务时不升级模型,也不为了形式固定依次调用三个模型。 + +## Agent 交接 + +- Opus 向 Sonnet 交接目标、非目标、事实、假设、方案、修改范围、风险、回退方式和验收标准。 +- Haiku 向主 Agent 交接结论、证据位置和仍不确定的内容,不返回与任务无关的大段原文。 +- Sonnet 严格按照已确认方案和工单实施;发现范围变化时返回主流程重新确认。 +- 不同 Agent 复用仍然有效的检查结果;会话、环境或关键前提变化时才重新检查。 + +## Haiku 只读约束 + +- Haiku 子 Agent 只允许使用读取、搜索和只读代码图谱工具。 +- 禁止 Haiku 修改文件、创建或更新工单、提交 Git、更新 Wiki 或执行具有副作用的命令。 +- 只读必须通过子 Agent 工具权限实现,不能只依赖提示语;未配置只读权限时不得调用 Haiku 处理项目内容。 diff --git a/README.md b/README.md index 3e39371..addbddd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ # chorus +chorus 是把 cmhub(Django)中的「提示词 + 原图 → 新图」和「提示词 → 文本」能力抽出来重写的**独立 Go 服务**,包含一个面向终端用户的 Web 端和一个运营管理端。 + +- 不改动 cmhub,也不依赖 cmhub 运行;核心逻辑是**重写移植**而非代码复用。 +- 技术栈:Go + Gin + GORM + go-admin(管理端)+ html/template + HTMX + Alpine(用户端),MySQL 8.0,单库。 +- 开发过程遵循 DevHarness:Gitea 工单管理任务、Gitea Wiki 管理长期文档、Git 记录代码变更、人工确认与验收。 + +## 当前阶段 + +项目处于 **MVP-0(跑通全流程的最小闭环)**。目标不是把方案里的功能一次做全,而是先让一条数据从「用户提交提示词」走到「页面看到结果」全程可运行、可验证,再逐步补齐多 Provider 路由、熔断、管理端定制页等能力。 + +MVP-0 的范围、非目标和验收口径见 [产品需求总览](docs/09-product-requirements-overview.md)。 + +## 快速开始(Wiki 初始化门禁) + +chorus 的线上 Gitea Wiki 尚未初始化。开始产品代码前必须按顺序完成: + +1. 创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki。 +2. 配置 `wiki-docs.json` 和安全访问方式;优先使用已配置的 Gitea MCP,MCP 不可用时才使用 Gitea API 并记录原因。令牌只通过环境变量或 MCP 安全配置提供。 +3. 按 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 查询线上 Wiki;`Home` 不存在时先创建并回读 `Home`,取得 revision 后再创建其他核心页面。 +4. 逐页把 `docs/` 中的初始内容写入 Wiki 并回读确认;本地 `docs/` 的存在不能证明线上 Wiki 已初始化。 +5. 把项目不可违反的安全规则写入根目录或子目录的 `AGENTS.md`。 +6. 使用 `.gitea/issue_template/` 中的模板创建第一个 Epic、MVP 和单元任务。 +7. 运行: + + ```powershell + python dev_scripts/harness.py sync --verify + ``` + +新仓库在 Gitea 尚未建立前允许一次不关联工单的引导提交。远端和工单系统配置完成后,所有改变程序行为的工作都必须先有单元任务工单。 + +## 文档入口 + +| 想知道什么 | 读哪里 | +|---|---| +| 项目目标、环境、命令、目录边界 | [docs/00-project-profile.md](docs/00-project-profile.md) | +| 长期需求、MVP 分期、状态 | [docs/09-product-requirements-overview.md](docs/09-product-requirements-overview.md) | +| 代码从哪里开始读、两条主要执行路径 | [docs/02-architecture-and-code-map.md](docs/02-architecture-and-code-map.md) | +| 术语、状态机、不能破坏的规则 | [docs/03-business-rules-and-glossary.md](docs/03-business-rules-and-glossary.md) | +| 怎样跑起来、怎样验证 | [docs/04-local-development-and-verification.md](docs/04-local-development-and-verification.md) | +| 简单修改怎么做、什么时候必须停 | [docs/05-common-changes.md](docs/05-common-changes.md) | +| 报错了先查什么 | [docs/06-troubleshooting.md](docs/06-troubleshooting.md) | +| 建单、实施、验收、归档流程 | [docs/01-workflow.md](docs/01-workflow.md) | + +`docs/` 在 Wiki 初始化完成后是 Wiki 的只读镜像,不是编辑入口。当前 Wiki 尚未初始化,详见 [项目档案](docs/00-project-profile.md) 的“文档状态”。 + +## 目录 + +```text +AGENTS.md Agent 的通用工作规则 +CLAUDE.md Claude Code 的规则入口 +.gitea/issue_template/ Epic、MVP、单元任务工单模板 +docs/ 核心长期文档(Wiki 初始化后转为只读镜像) +docs/task/ 人工按需导出的 Wiki 任务归档快照 +prototypes/ 按工单和版本保存的本地 HTML 审核快照 +wiki-docs.json 核心 Wiki 页面到本地镜像的显式映射 +dev_scripts/harness.py check / sync / archive / export 单一入口 +tests/ Harness 工具自动化测试 +``` + +Go 代码目录(`internal/core`、`portal/`、`admin/`、`migrations/`)在 MVP-0 首个实现工单落地,规划见架构与代码地图。 diff --git a/dev_scripts/harness.py b/dev_scripts/harness.py new file mode 100644 index 0000000..4275f9a --- /dev/null +++ b/dev_scripts/harness.py @@ -0,0 +1,725 @@ +"""DevHarness 单一命令行入口。 + +子命令: + check 检查必需文件、核心文档和任务归档结构 + sync 从 Gitea Wiki 单向导出或校验核心 docs 镜像 + archive 在 Gitea Wiki 创建任务归档 + export 人工按需把 Wiki 任务归档导出到 docs/task + +各子命令的实现逻辑取自原来的 check_harness.py、sync_wiki_docs.py、 +new_task_archive.py 和 export_task_archives.py,行为未改变。 +""" + +from __future__ import annotations + +import argparse +import re +from datetime import date +from pathlib import Path +from typing import Any + +from wiki_docs import ( + DEFAULT_CONFIG, + WikiClient, + WikiDocsError, + dirty_paths, + load_config, + parse_mirror, + sync_all, + write_mirror, +) + + +# ---------------------------------------------------------------- 结构检查 + +ROOT = Path(__file__).resolve().parents[1] +CORE_PAGE_PATHS = { + "Home": "docs/README.md", + "Project-Profile": "docs/00-project-profile.md", + "Development-Workflow": "docs/01-workflow.md", + "Architecture-and-Code-Map": "docs/02-architecture-and-code-map.md", + "Business-Rules-and-Glossary": "docs/03-business-rules-and-glossary.md", + "Local-Development-and-Verification": ( + "docs/04-local-development-and-verification.md" + ), + "Common-Changes": "docs/05-common-changes.md", + "Troubleshooting": "docs/06-troubleshooting.md", + "New-Project-Documentation-Setup": ( + "docs/07-new-project-documentation-setup.md" + ), + "Existing-Project-Adoption-Guide": ( + "docs/08-existing-project-adoption.md" + ), + "Product-Requirements-Overview": ( + "docs/09-product-requirements-overview.md" + ), + "Delivery-Documentation-Guide": "docs/delivery/README.md", + "Audience-Document-Template": ( + "docs/delivery/audience-document-template.md" + ), + "Task-Archive-Template": "docs/templates/task-archive.md", +} +CORE_DOCUMENT_REQUIREMENTS = { + "docs/README.md": ( + "## 第一次阅读", + "## 五分钟开始", + "## 简单修改从哪里开始", + "## 事实来源", + ), + "docs/00-project-profile.md": ( + "## 基本信息", + "## DevHarness 来源与基线", + "## 子项目与交付单元", + "## 技术栈与运行环境", + "## 阅读入口", + "## 常用命令", + "## 环境、配置与凭据", + ), + "docs/01-workflow.md": ( + "## 新项目 Wiki 初始化门禁", + "## 工单与设计证据双门禁", + "### 先判断是否需要工单", + "### 再判断设计证据", + "### 本地 HTML 审核快照", + "### 记录和重新确认", + "## 面向初级维护者的修改边界", + "## 每个任务的文档影响", + "## 需求记录与流转", + "## 稳定文档与任务归档", + "## 自然语言快捷指令", + "## 效率与范围控制", + "### 严格控制范围", + "### 渐进执行和修复", + "### 复用已验证事实", + "### 明确停止条件", + ), + "docs/02-architecture-and-code-map.md": ( + "## 项目定位", + "## 代码地图", + "## 两条主要执行路径", + "## 不可破坏的边界", + ), + "docs/03-business-rules-and-glossary.md": ( + "## 核心术语", + "## 工单状态", + "## 稳定业务规则", + "## 新项目需要补充什么", + ), + "docs/04-local-development-and-verification.md": ( + "## 环境要求", + "## 第一次运行", + "## 常用调试方式", + "## 完成修改前", + ), + "docs/05-common-changes.md": ( + "## 风险分级", + "## 修改 Wiki 文案", + "## 调整 Harness 检查", + "## 看懂 Agent 的修改", + ), + "docs/06-troubleshooting.md": ( + "## 排查顺序", + "## 必须停止的情况", + ), + "docs/07-new-project-documentation-setup.md": ( + "## 初始化顺序", + "#### 需求总览启用条件", + "### 2. 选择建设基线", + "#### 判断案例", + "### 3. 识别子项目与交付单元", + "### 7. 确定交付对象和文档", + "#### 在线创建与回读门禁", + "## 完成标准", + ), + "docs/08-existing-project-adoption.md": ( + "## 与新项目初始化的区别", + "## 接入前只读盘点", + "## 已有内容保护原则", + "## 多应用单仓库判断", + "### 适合继续单仓库", + "### 可以考虑拆仓", + "### 保持单仓库时的最小规则", + "## 增量接入顺序", + "## 后续升级", + "### 升级步骤", + "### 可复制升级指令", + "## 冲突处理和停止条件", + "## 可复制 Agent 指令", + "### 只分析", + "### 方案确认后实施", + "## 最小验收清单", + "## 回退原则", + ), + "docs/09-product-requirements-overview.md": ( + "## 本页用途", + "## 事实来源边界", + "## 当前需求索引", + "## 登记规则", + "## 原型与设计资产", + "### 原型门禁", + "### 本地 HTML 审核快照", + "### 原型确认记录", + "## 状态规则", + "## 更新时机", + "## 最小验收清单", + ), + "docs/delivery/README.md": ( + "## 什么时候需要交付文档", + "## 受众与文档选择", + "## 内部文档与交付文档边界", + "## 编写和维护流程", + "## 最小验收清单", + ), + "docs/delivery/audience-document-template.md": ( + "## 文档信息", + "## 目的与适用范围", + "## 前置条件", + "## 操作步骤", + "## 常见错误与恢复", + "## 安全与权限", + "## 已知限制", + "## 支持与升级处理", + "## 版本记录", + "## 交付前检查", + ), +} +REQUIRED_FILES = ( + "AGENTS.md", + "CLAUDE.md", + "README.md", + "docs/00-project-profile.md", + "docs/01-workflow.md", + "docs/templates/deployment.md", + "docs/templates/task-archive.md", + *CORE_DOCUMENT_REQUIREMENTS, + "wiki-docs.json", + "dev_scripts/wiki_docs.py", + "dev_scripts/harness.py", + ".gitea/issue_template/epic.md", + ".gitea/issue_template/mvp.md", + ".gitea/issue_template/task.md", +) +ARCHIVE_HEADINGS = ( + "## 背景与目标", + "## 最终方案", + "## 修改文件", + "## 验收结果", + "## 测试", + "## 相关提交", +) + + +def check_required_files(errors: list[str]) -> None: + for relative_path in REQUIRED_FILES: + if not (ROOT / relative_path).is_file(): + errors.append(f"缺少必需文件:{relative_path}") + + +def check_project_profile(errors: list[str], warnings: list[str], strict: bool) -> None: + profile = ROOT / "docs" / "00-project-profile.md" + if not profile.is_file(): + return + if "<填写" in profile.read_text(encoding="utf-8"): + message = "项目档案仍有未填写内容" + (errors if strict else warnings).append(message) + + +def check_archives(errors: list[str]) -> None: + task_dir = ROOT / "docs" / "task" + for path in task_dir.glob("*.md"): + if not re.match(r"^\d+-.+\.md$", path.name): + errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}") + content = path.read_text(encoding="utf-8") + try: + metadata, _ = parse_mirror(content) + except WikiDocsError as exc: + errors.append(f"{path.name} 的任务镜像无效:{exc}") + continue + if re.fullmatch(r"Task-\d+-.+", metadata.get("wiki_page", "")) is None: + errors.append(f"{path.name} 的 wiki_page 不是任务归档页面") + if re.fullmatch(r"[0-9a-f]{40,64}", metadata.get("wiki_revision", "")) is None: + errors.append(f"{path.name} 的 wiki_revision 无效") + for heading in ARCHIVE_HEADINGS: + if heading not in content: + errors.append(f"{path.name} 缺少章节:{heading}") + if "**未验证部分**:" not in content: + errors.append(f"{path.name} 没有记录未验证部分") + + +def missing_sections(content: str, required: tuple[str, ...]) -> list[str]: + return [section for section in required if section not in content] + + +def check_core_documents(errors: list[str], root: Path = ROOT) -> None: + """检查初级维护者所需主题页的固定结构。""" + + for relative_path, required in CORE_DOCUMENT_REQUIREMENTS.items(): + path = root / relative_path + if not path.is_file(): + continue + try: + _, body = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError): + continue + for section in missing_sections(body, required): + errors.append(f"{relative_path} 缺少核心章节:{section}") + + +def check_task_template(errors: list[str], root: Path = ROOT) -> None: + path = root / ".gitea" / "issue_template" / "task.md" + if not path.is_file(): + return + content = path.read_text(encoding="utf-8") + required = ( + "## 依赖与并行", + "- 前置工单:无 / #编号", + "- 是否允许与前置工单并行:是 / 否", + "- 原因:", + "## 子项目影响", + "- 仅影响的子项目 / 交付单元:", + "- 是否跨子项目:是 / 否", + "- 是否修改共享接口或契约:是 / 否;唯一事实来源:", + "- 各子项目需要执行的验证:", + "## 原始需求", + "- 来源:用户对话 / Gitea / 其他", + "- 提出时间:", + "- 关键原话或脱敏摘要:", + "## 需求变化记录", + "| 日期 | 变化内容 | 原因 | 用户确认 |", + "## 设计与原型门禁", + "- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug", + "- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据", + "- 可编辑设计源链接、版本或事实来源:", + "- 本地 HTML 审核快照路径和版本(不适用时说明原因):", + "- 本地浏览方式和资源完整性检查:", + "- 版本、revision 或确认日期:", + "- 状态:无 / 草稿 / 已确认 / 已废弃", + "- 确认人、确认时间和覆盖范围:", + "- 无需 UI 原型或无需任何原型的原因:", + "## 文档影响", + "- [ ] 不影响长期文档,原因:", + "- [ ] 更新架构与代码地图", + "- [ ] 更新业务规则与术语", + "- [ ] 更新常见修改或故障排查", + "## 交付文档影响", + "- [ ] 无交付文档影响,原因:", + "- [ ] 更新已有交付文档,受众与页面:", + "- [ ] 新增交付文档,受众与页面:", + "- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:", + ) + for section in missing_sections(content, required): + errors.append(f"单元任务模板缺少:{section}") + + +def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None: + path = root / "AGENTS.md" + if not path.is_file(): + return + content = path.read_text(encoding="utf-8") + required = ( + "### 效率与范围控制", + "#### 严格控制范围", + "#### 渐进执行和修复", + "#### 复用已验证事实", + "#### 明确停止条件", + "单元任务是唯一正式实施单位", + "高风险修改必须停止", + "用户没有明确验收通过前不得关闭", + "长期核心文档必须先修改 Wiki", + "### 新项目 Wiki 初始化门禁", + "`Home` 不存在时必须先创建 `Home`", + "不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据", + "提交只包含当前工单相关文件", + "### 工单与设计证据双门禁", + "新页面、独立用户功能、重大交互或导航变化", + "`prototypes/<工单号>/<版本>/index.html`", + "已确认的 HTML 快照不得原位覆盖", + "代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案", + "### 自然语言快捷指令", + "`只分析`", + "`建工单`", + "`执行工单 #N`", + "`建工单并做`", + "`继续工单 #N`", + "`检查工单 #N`", + "`同步文档`", + "`导出任务归档`", + "`导出全部任务归档`", + "`#N 验收通过`", + "### 需求记录与流转", + "不得臆造用户原话", + "不复制完整聊天", + "Gitea 工单全文不导出到仓库", + ) + for section in missing_sections(content, required): + errors.append(f"AGENTS.md 缺少:{section}") + + +def check_repository_readme(errors: list[str], root: Path = ROOT) -> None: + """检查快速开始包含线上 Wiki 初始化顺序和产品编码门禁。""" + + path = root / "README.md" + if not path.is_file(): + return + content = path.read_text(encoding="utf-8") + required = ( + "创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki", + "优先使用已配置的 Gitea MCP", + "`Home` 不存在时先创建并回读 `Home`", + "本地 `docs/` 的存在不能证明线上 Wiki 已初始化", + "python dev_scripts/harness.py sync --verify", + ) + for section in missing_sections(content, required): + errors.append(f"README.md 缺少:{section}") + + remote_index = content.find("创建 Gitea 远端仓库") + wiki_index = content.find("`Home` 不存在时先创建") + if remote_index < 0 or wiki_index < 0 or remote_index > wiki_index: + errors.append("README.md 必须先创建 Gitea 远端,再创建 Wiki Home") + + +def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None: + """检查 Claude Code 入口直接复用共同 Agent 规则。""" + + path = root / "CLAUDE.md" + if not path.is_file(): + return + content = path.read_text(encoding="utf-8") + lines = {line.strip() for line in content.splitlines()} + if "@AGENTS.md" not in lines: + errors.append("CLAUDE.md 缺少独立的 @AGENTS.md 导入") + required = ( + "共同规则事实来源", + "只记录 Claude Code 特有", + "只修改 `AGENTS.md`", + "## 模型路由", + "## Agent 交接", + "## Haiku 只读约束", + "当前模型足以完成任务时不升级模型", + "Opus 输出方案后必须等待用户确认", + "不让 Haiku 决定最终根因", + "只读必须通过子 Agent 工具权限实现", + ) + for section in missing_sections(content, required): + errors.append(f"CLAUDE.md 缺少:{section}") + + +def core_mapping_errors(configured_mappings: dict[str, str]) -> list[str]: + errors: list[str] = [] + for page, expected_path in CORE_PAGE_PATHS.items(): + if configured_mappings.get(page) != expected_path: + errors.append( + f"核心 Wiki 页面映射缺失或路径错误:{page} -> {expected_path}" + ) + return errors + + +def check_wiki_mirrors(errors: list[str]) -> None: + """检查核心映射与镜像头;任务快照由 check_archives 单独检查。""" + + try: + config = load_config() + except WikiDocsError as exc: + errors.append(str(exc)) + return + + configured_mappings = {mapping.page: mapping.path for mapping in config.mappings} + errors.extend(core_mapping_errors(configured_mappings)) + + mapped_paths = {mapping.path for mapping in config.mappings} + actual_paths = { + path.relative_to(ROOT).as_posix() for path in (ROOT / "docs").rglob("*.md") + if path.parent != ROOT / "docs" / "task" + } + for path in sorted(actual_paths - mapped_paths): + errors.append(f"docs 中存在未登记的 Wiki 镜像:{path}") + + for mapping in config.mappings: + path = ROOT / mapping.path + if not path.is_file(): + errors.append(f"缺少 Wiki 镜像:{mapping.path}") + continue + try: + metadata, _ = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + errors.append(f"Wiki 镜像无效 {mapping.path}:{exc}") + continue + if metadata.get("generated") != "true (请先修改 Gitea Wiki,禁止直接编辑本文件)": + errors.append(f"{mapping.path} 没有只读镜像标记") + if metadata.get("wiki_page") != mapping.page: + errors.append(f"{mapping.path} 的 wiki_page 与映射不一致") + revision = metadata.get("wiki_revision", "") + if re.fullmatch(r"[0-9a-f]{40,64}", revision) is None: + errors.append(f"{mapping.path} 的 wiki_revision 无效") + if not metadata.get("synchronized_at"): + errors.append(f"{mapping.path} 缺少 synchronized_at") + + +# ---------------------------------------------------------------- 任务归档 + +def safe_title(title: str) -> str: + """把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。""" + + cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip()) + cleaned = re.sub(r"\s+", "-", cleaned) + cleaned = re.sub(r"-+", "-", cleaned) + return cleaned.strip(".-") + + +def build_archive( + template: str, + issue_number: str, + title: str, + page_name: str, + issue_url: str, +) -> str: + content = template.replace("<工单号>", issue_number, 1) + content = content.replace("<标题>", title.strip(), 1) + content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1) + content = content.replace("<链接>", issue_url, 1) + return content.replace("<页面名>", page_name, 1) + + +# ---------------------------------------------------------------- 归档导出 + +TASK_PAGE_PATTERN = re.compile(r"^Task-(?P\d+)-(?P.+)$") + + +def task_revision(metadata: dict[str, Any], page_name: str) -> str: + last_commit = metadata.get("last_commit") + revision = last_commit.get("sha") if isinstance(last_commit, dict) else None + if not isinstance(revision, str) or not revision: + raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}") + return revision + + +def existing_task_mirrors(root: Path = ROOT) -> dict[str, Path]: + """按镜像头匹配已有文件,兼容历史自定义文件名。""" + + mirrors: dict[str, Path] = {} + task_dir = root / "docs" / "task" + if not task_dir.is_dir(): + return mirrors + for path in task_dir.glob("*.md"): + try: + metadata, _ = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + raise WikiDocsError(f"已有任务镜像无效 {path.name}:{exc}") from exc + page_name = metadata.get("wiki_page", "") + if not TASK_PAGE_PATTERN.fullmatch(page_name): + raise WikiDocsError(f"已有任务镜像页面名无效 {path.name}:{page_name}") + if page_name in mirrors: + raise WikiDocsError(f"任务页面存在重复本地镜像:{page_name}") + mirrors[page_name] = path + return mirrors + + +def task_target(page_name: str, root: Path = ROOT) -> Path: + match = TASK_PAGE_PATTERN.fullmatch(page_name) + if match is None: + raise WikiDocsError(f"不是任务归档页面:{page_name}") + title = safe_title(match.group("title")) + if not title: + raise WikiDocsError(f"任务归档标题无效:{page_name}") + return root / "docs" / "task" / f"{match.group('number')}-{title}.md" + + +def export_task_archives( + client: WikiClient, *, export_all: bool = False, root: Path = ROOT +) -> list[str]: + """增量或全量读取任务归档;绝不删除本地文件。""" + + dirty = dirty_paths(["docs/task"], root) + if dirty: + raise WikiDocsError( + "本地任务镜像存在未提交改动,已停止以防覆盖:\n" + "\n".join(dirty) + ) + + existing = existing_task_mirrors(root) + pages = [] + for metadata in client.list_pages(): + title = metadata.get("title") + if isinstance(title, str) and TASK_PAGE_PATTERN.fullmatch(title): + pages.append((int(title.split("-", 2)[1]), title, metadata)) + pages.sort(key=lambda item: (item[0], item[1])) + + messages: list[str] = [] + targets: set[Path] = set() + for _, page_name, metadata in pages: + target = existing.get(page_name, task_target(page_name, root)) + if target in targets: + raise WikiDocsError(f"多个任务页面映射到同一本地路径:{target.name}") + targets.add(target) + revision = task_revision(metadata, page_name) + if not export_all and target.is_file(): + local_metadata, _ = parse_mirror(target.read_text(encoding="utf-8")) + if ( + local_metadata.get("wiki_page") == page_name + and local_metadata.get("wiki_revision") == revision + ): + messages.append(f"跳过:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + continue + page = client.get_page_from_metadata(metadata, page_name) + changed = write_mirror(target, page) + action = "已导出" if changed else "无变化" + messages.append(f"{action}:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + return messages + + +# ---------------------------------------------------------------- 子命令入口 + + +def run_check(args: argparse.Namespace) -> int: + errors: list[str] = [] + warnings: list[str] = [] + check_required_files(errors) + check_project_profile(errors, warnings, args.strict) + check_wiki_mirrors(errors) + check_core_documents(errors) + check_task_template(errors) + check_agent_efficiency_rules(errors) + check_repository_readme(errors) + check_claude_code_entry(errors) + check_archives(errors) + + for warning in warnings: + print(f"警告:{warning}") + for error in errors: + print(f"错误:{error}") + + if errors: + print(f"检查失败:{len(errors)} 个问题") + return 1 + print("DevHarness 检查通过") + return 0 + + +def run_sync(args: argparse.Namespace) -> int: + """--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。""" + + if args.verify: + steps = ( + ("同步", lambda: run_sync( + argparse.Namespace(check=False, verify=False, config=args.config))), + ("结构检查", lambda: run_check(argparse.Namespace(strict=True))), + ("一致性校验", lambda: run_sync( + argparse.Namespace(check=True, verify=False, config=args.config))), + ) + for name, step in steps: + code = step() + if code != 0: + print(f"错误:{name}未通过,已停止") + return code + return 0 + + try: + config = load_config(Path(args.config).resolve()) + messages = sync_all(config, WikiClient(config), check=args.check) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + for message in messages: + print(message) + print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成") + return 0 + + +def run_archive(args: argparse.Namespace) -> int: + short_title = safe_title(args.title) + if not args.issue_number.isdigit(): + print("错误:工单号必须是数字") + return 1 + if not short_title: + print("错误:标题不能为空") + return 1 + + try: + config = load_config(Path(args.config).resolve()) + page_name = f"Task-{args.issue_number}-{short_title}" + client = WikiClient(config) + if any(item.get("title") == page_name for item in client.list_pages()): + raise WikiDocsError(f"任务归档已经存在:{page_name}") + template = client.get_page("Task-Archive-Template").text + issue_url = ( + f"{config.gitea_url}/{config.owner}/{config.repository}/issues/" + f"{args.issue_number}" + ) + content = build_archive( + template, args.issue_number, args.title, page_name, issue_url + ) + page = client.create_page( + page_name, + content, + f"docs: 创建任务 #{args.issue_number} 归档草稿", + ) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + + print(f"已创建 Wiki:{page.html_url}") + print("未导出本地任务归档;需要时运行 harness.py export") + return 0 + + +def run_export(args: argparse.Namespace) -> int: + try: + config = load_config(Path(args.config).resolve()) + messages = export_task_archives(WikiClient(config), export_all=args.all) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + for message in messages: + print(message) + print("任务归档全量导出完成" if args.all else "任务归档增量导出完成") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="DevHarness 检查、同步与归档工具") + sub = parser.add_subparsers(dest="command", required=True) + + p_check = sub.add_parser("check", help="检查 DevHarness 项目结构") + p_check.add_argument( + "--strict", action="store_true", help="项目档案有占位内容时返回失败" + ) + p_check.set_defaults(func=run_check) + + p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像") + p_sync.add_argument( + "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" + ) + p_sync.add_argument( + "--verify", + action="store_true", + help="依次执行导出、check --strict 和一致性校验", + ) + p_sync.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" + ) + p_sync.set_defaults(func=run_sync) + + p_archive = sub.add_parser("archive", help="在 Gitea Wiki 创建任务归档") + p_archive.add_argument("issue_number", help="Gitea 工单号,例如 123") + p_archive.add_argument("title", help="简短任务标题") + p_archive.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置" + ) + p_archive.set_defaults(func=run_archive) + + p_export = sub.add_parser("export", help="人工按需导出 Gitea Wiki 任务归档") + p_export.add_argument( + "--all", + action="store_true", + help="全量读取全部线上任务归档;默认按 revision 增量", + ) + p_export.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置" + ) + p_export.set_defaults(func=run_export) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/wiki_docs.py b/dev_scripts/wiki_docs.py new file mode 100644 index 0000000..6b1008c --- /dev/null +++ b/dev_scripts/wiki_docs.py @@ -0,0 +1,422 @@ +"""Gitea Wiki 到本地 docs 镜像的共享实现。""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONFIG = ROOT / "wiki-docs.json" +MIRROR_START = "<!-- gitea-wiki-mirror:start -->" +MIRROR_END = "<!-- gitea-wiki-mirror:end -->" +HEADER_PATTERN = re.compile( + rf"\A{re.escape(MIRROR_START)}\n(?P<metadata>.*?)\n" + rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z", + re.DOTALL, +) + + +class WikiDocsError(RuntimeError): + """可供命令行直接展示的 Wiki 文档错误。""" + + +@dataclass(frozen=True) +class Mapping: + page: str + path: str + + +@dataclass(frozen=True) +class Config: + path: Path + gitea_url: str + owner: str + repository: str + mappings: tuple[Mapping, ...] + + +@dataclass(frozen=True) +class WikiPage: + title: str + sub_url: str + text: str + revision: str + html_url: str + + +def _required_string(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise WikiDocsError(f"配置字段 {key!r} 必须是非空字符串") + return value.strip() + + +def validate_mappings(raw_mappings: Any) -> tuple[Mapping, ...]: + """校验显式页面映射,确保只会写入 docs 下的 Markdown。""" + + if not isinstance(raw_mappings, list) or not raw_mappings: + raise WikiDocsError("配置字段 'mappings' 必须是非空数组") + + mappings: list[Mapping] = [] + pages: set[str] = set() + paths: set[str] = set() + for index, item in enumerate(raw_mappings, start=1): + if not isinstance(item, dict): + raise WikiDocsError(f"第 {index} 个映射必须是对象") + page = _required_string(item, "page") + path = _required_string(item, "path").replace("\\", "/") + pure_path = PurePosixPath(path) + if ( + pure_path.is_absolute() + or ".." in pure_path.parts + or not pure_path.parts + or pure_path.parts[0] != "docs" + or pure_path.suffix.lower() != ".md" + ): + raise WikiDocsError(f"镜像路径必须是 docs/ 下的 Markdown:{path}") + if page in pages: + raise WikiDocsError(f"Wiki 页面重复映射:{page}") + if path in paths: + raise WikiDocsError(f"本地路径重复映射:{path}") + pages.add(page) + paths.add(path) + mappings.append(Mapping(page=page, path=path)) + return tuple(mappings) + + +def load_config(path: Path = DEFAULT_CONFIG) -> Config: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WikiDocsError(f"无法读取 Wiki 映射配置 {path}: {exc}") from exc + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise WikiDocsError("wiki-docs.json 的 schema_version 必须为 1") + configured_url = _required_string(raw, "gitea_url") + gitea_url = os.environ.get("GITEA_URL", configured_url).rstrip("/") + if gitea_url.endswith("/api/v1"): + gitea_url = gitea_url[: -len("/api/v1")] + return Config( + path=path, + gitea_url=gitea_url, + owner=_required_string(raw, "owner"), + repository=_required_string(raw, "repository"), + mappings=validate_mappings(raw.get("mappings")), + ) + + +class WikiClient: + """只使用标准库访问 Gitea Wiki API。""" + + def __init__(self, config: Config, token: str | None = None) -> None: + self.config = config + self.token = token if token is not None else os.environ.get("GITEA_TOKEN") + + def _request( + self, + method: str, + api_path: str, + *, + payload: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + ) -> Any: + url = f"{self.config.gitea_url}/api/v1{api_path}" + if query: + url = f"{url}?{urlencode(query)}" + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + data = None + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + body = response.read() + except HTTPError as exc: + if self.token and method == "GET" and exc.code in {401, 403, 404}: + # 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于 + # 另一个实例或已失效。只对只读请求安全降级为匿名访问。 + anonymous_headers = {"Accept": "application/json"} + anonymous_request = Request( + url, data=data, headers=anonymous_headers, method=method + ) + try: + with urlopen(anonymous_request, timeout=30) as response: + body = response.read() + except HTTPError as anonymous_exc: + detail = anonymous_exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 " + f"{anonymous_exc.code}: {detail}" + ) from anonymous_exc + except URLError as anonymous_exc: + raise WikiDocsError( + f"无法连接 Gitea:{anonymous_exc.reason}" + ) from anonymous_exc + else: + detail = exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}" + ) from exc + except URLError as exc: + raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WikiDocsError("Gitea API 返回了无效的 UTF-8 JSON") from exc + + def list_pages(self) -> list[dict[str, Any]]: + pages: list[dict[str, Any]] = [] + page_number = 1 + while True: + batch = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/pages", + query={"page": page_number, "limit": 50}, + ) + if not isinstance(batch, list): + raise WikiDocsError("Gitea Wiki 页面列表格式无效") + pages.extend(item for item in batch if isinstance(item, dict)) + if len(batch) < 50: + return pages + page_number += 1 + + def get_page(self, page_name: str) -> WikiPage: + metadata = next( + ( + item + for item in self.list_pages() + if item.get("title") == page_name or item.get("sub_url") == page_name + ), + None, + ) + if metadata is None: + raise WikiDocsError( + f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像" + ) + return self.get_page_from_metadata(metadata, page_name) + + def get_page_from_metadata( + self, metadata: dict[str, Any], page_name: str | None = None + ) -> WikiPage: + """使用页面列表元数据读取正文,避免重复获取完整页面列表。""" + + resolved_name = page_name or _required_string(metadata, "title") + sub_url = _required_string(metadata, "sub_url") + page = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/page/" + f"{quote(sub_url, safe='%')}", + ) + if not isinstance(page, dict): + raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}") + encoded_content = page.get("content_base64") + if not isinstance(encoded_content, str): + raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_name}") + try: + text = base64.b64decode(encoded_content, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise WikiDocsError( + f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_name}" + ) from exc + last_commit = page.get("last_commit") + revision = last_commit.get("sha") if isinstance(last_commit, dict) else None + if not isinstance(revision, str) or not revision: + raise WikiDocsError(f"Wiki 页面缺少 revision:{resolved_name}") + title = page.get("title") + resolved_title = title if isinstance(title, str) and title else resolved_name + html_url = ( + f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}" + ) + return WikiPage( + title=resolved_title, + sub_url=sub_url, + text=normalize_body(text), + revision=revision, + html_url=html_url, + ) + + def create_page(self, title: str, content: str, message: str) -> WikiPage: + if not self.token: + raise WikiDocsError("创建 Wiki 页面需要通过 GITEA_TOKEN 提供写入令牌") + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + self._request( + "POST", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/new", + payload={"title": title, "content_base64": encoded, "message": message}, + ) + return self.get_page(title) + + +def normalize_body(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n" + + +def parse_mirror(text: str) -> tuple[dict[str, str], str]: + match = HEADER_PATTERN.match(text.replace("\r\n", "\n").replace("\r", "\n")) + if match is None: + raise WikiDocsError("缺少或损坏 gitea-wiki-mirror 元数据头") + metadata: dict[str, str] = {} + for line in match.group("metadata").splitlines(): + key, separator, value = line.partition(": ") + if not separator or not key or not value: + raise WikiDocsError(f"无效的镜像元数据行:{line}") + metadata[key] = value + return metadata, normalize_body(match.group("body")) + + +def render_mirror(page: WikiPage, existing: str | None = None) -> str: + synchronized_at: str | None = None + if existing is not None: + try: + metadata, body = parse_mirror(existing) + except WikiDocsError: + pass + else: + if metadata.get("wiki_revision") == page.revision and body == page.text: + synchronized_at = metadata.get("synchronized_at") + if not synchronized_at: + synchronized_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace( + "+00:00", "Z" + ) + header = "\n".join( + ( + MIRROR_START, + "generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)", + f"wiki_page: {page.title}", + f"wiki_url: {page.html_url}", + f"wiki_revision: {page.revision}", + f"synchronized_at: {synchronized_at}", + MIRROR_END, + ) + ) + return f"{header}\n\n{page.text}" + + +def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]: + return dirty_paths([mapping.path for mapping in config.mappings], root) + + +def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]: + """返回指定路径中已有、修改或未跟踪的工作区条目。""" + + if not paths: + return [] + result = subprocess.run( + ["git", "status", "--porcelain", "--", *paths], + cwd=root, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def _write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + handle, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + os.replace(temporary_name, path) + except BaseException: + Path(temporary_name).unlink(missing_ok=True) + raise + + +def write_mirror(path: Path, page: WikiPage) -> bool: + """写入一份 Wiki 镜像;内容无变化时返回 False。""" + + existing = path.read_text(encoding="utf-8") if path.is_file() else None + rendered = render_mirror(page, existing) + if existing == rendered: + return False + _write_atomic(path, rendered) + return True + + +def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]: + if not path.is_file(): + return [f"缺少镜像:{mapping.path}"] + try: + metadata, body = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + return [f"镜像无效 {mapping.path}: {exc}"] + expected = { + "wiki_page": page.title, + "wiki_url": page.html_url, + "wiki_revision": page.revision, + } + errors = [ + f"{mapping.path} 的 {key} 不一致" + for key, value in expected.items() + if metadata.get(key) != value + ] + if not metadata.get("synchronized_at"): + errors.append(f"{mapping.path} 缺少 synchronized_at") + if body != page.text: + errors.append(f"{mapping.path} 的正文与 Wiki 不一致") + return errors + + +def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]: + """检查或写入所有显式映射;绝不处理映射外的文件。""" + + if not check: + dirty = dirty_mirror_paths(config) + if dirty: + details = "\n".join(dirty) + raise WikiDocsError( + "已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details + ) + + messages: list[str] = [] + for mapping in config.mappings: + page = client.get_page(mapping.page) + target = ROOT / PurePosixPath(mapping.path) + if check: + errors = check_mirror(mapping, page, target) + if errors: + raise WikiDocsError("\n".join(errors)) + messages.append(f"一致:{mapping.path} <- {page.title}@{page.revision[:12]}") + continue + existing = target.read_text(encoding="utf-8") if target.is_file() else None + rendered = render_mirror(page, existing) + if existing != rendered: + _write_atomic(target, rendered) + messages.append(f"已更新:{mapping.path} <- {page.title}@{page.revision[:12]}") + else: + messages.append(f"无变化:{mapping.path} <- {page.title}@{page.revision[:12]}") + return messages + + +def append_mapping(config: Config, mapping: Mapping) -> None: + raw = json.loads(config.path.read_text(encoding="utf-8")) + mappings = validate_mappings(raw.get("mappings")) + if any(item.page == mapping.page or item.path == mapping.path for item in mappings): + raise WikiDocsError(f"页面或路径已经登记:{mapping.page} -> {mapping.path}") + raw["mappings"].append({"page": mapping.page, "path": mapping.path}) + rendered = json.dumps(raw, ensure_ascii=False, indent=2) + "\n" + _write_atomic(config.path, rendered) diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md new file mode 100644 index 0000000..cb57a3e --- /dev/null +++ b/docs/00-project-profile.md @@ -0,0 +1,121 @@ +# 项目档案 + +本页记录不经常变化、所有维护者都需要知道的信息。Wiki 初始化完成后,Wiki 的 `Project-Profile` 是事实来源,本文件是只读镜像。 + +## 文档状态 + +当前 chorus 的线上 Gitea Wiki **尚未初始化**,`docs/` 是初始人工版本。按 [新项目文档初始化](07-new-project-documentation-setup.md) 完成线上创建与回读后,本目录转为镜像,之后只改 Wiki。在此之前不要用本地文件的存在证明 Wiki 已就绪。 + +## 基本信息 + +| 项目 | 内容 | +|---|---| +| 项目名称 | chorus | +| 一句话目标 | 提供独立于 cmhub 的 Go 生图生文服务:用户提交提示词与原图得到新图或文本,运营在管理端配置上游并查看记录 | +| 主要使用者 | 终端用户(Web 端生成)、运营与管理员(管理端)、Claude/Codex Agent、接手简单维护的初级程序员 | +| Gitea 地址 | https://git.ilapage.cn | +| 仓库 | `OPC/chorus` | +| 默认分支 | `main` | +| 主要维护者 | `ila` | +| 需求来源 | Obsidian 笔记《cmgen · 从 cmhub 抽取生图生文服务 — 需求与 Go 技术方案》(2026-08-20);项目代号由 `cmgen` 改为 `chorus` | +| 文档适用范围 | 默认分支当前版本 | + +## DevHarness 来源与基线 + +| 项目 | 内容 | +|---|---| +| DevHarness 来源仓库 | `https://git.ilapage.cn/OPC/dev_harness` | +| 当前基线提交 | `3696663781c569c57f47bb26e3b5b6369180fdaa` | +| 最后接入或升级日期 | 2026-08-20 | +| 项目适配说明 | 完整保留 Harness 规则、工单模板、Wiki 镜像与结构检查工具;`docs/00`、`02`–`06`、`09` 和 `docs/README.md` 改写为 chorus 内容,`docs/01`、`07`、`08`、`delivery/`、`templates/` 沿用上游文本 | + +## 子项目与交付单元 + +| 子项目 / 交付单元 | 职责 | 技术栈 | 构建与测试 | 版本与发布方式 | 规则入口 | 共享边界 | +|---|---|---|---|---|---|---| +| `internal/core` 核心域库 | provider 调用、选路、生成编排、队列、存储、加密、SSRF 防护 | Go 1.22+、GORM | `go build ./...`;`go test ./internal/...` | 不单独发布,随两个二进制发布 | 根 `AGENTS.md` | 被 portal 与 admin 共用;**不 import gin、不 import go-admin** | +| `portal` 用户端二进制 | 用户 Web 界面、JSON API、API Key 端点,MVP 阶段内嵌 worker | Go + Gin + html/template + HTMX + Alpine + Tailwind | `go build ./portal/...`;`go test ./portal/...` | 跟随 `main` 分支构建单二进制 | 根 `AGENTS.md` | 依赖 `internal/core`,与 admin 共用同一 MySQL 库 | +| `admin` 管理端二进制 | Provider/Model/路由池/记录/用户与点数管理 | Go + go-admin(Gin + GORM + Casbin + JWT)+ go-admin-ui(Vue 2) | `go build ./admin/...` | 跟随 `main` 分支构建 | 根 `AGENTS.md` | 依赖 `internal/core`;不得绕过核心域直接实现生成逻辑 | +| `migrations` 数据库迁移 | 业务表结构演进 | golang-migrate(SQL 文件) | `migrate up` / `migrate down` | 随代码提交,按序号前进 | 根 `AGENTS.md` | 表结构是三个交付单元的唯一共享契约 | + +共享契约的唯一事实来源是 `migrations/` 中的 SQL 与 [业务规则与术语](03-business-rules-and-glossary.md);不得在多处维护互不确认的表结构描述。MVP-0 只要求 `internal/core` 与 `portal` 可运行,`admin` 与 `migrations` 的完整形态在后续阶段补齐。 + +## 技术栈与运行环境 + +| 部分 | 技术 | 说明 | +|---|---|---| +| HTTP 框架 | Gin | 与 go-admin 一致,减少两端框架分裂 | +| ORM 与迁移 | GORM + golang-migrate | GORM 只做查询,**生产不使用 AutoMigrate** | +| 数据库 | MySQL 8.0 | 单库;队列用 `SELECT … FOR UPDATE SKIP LOCKED` | +| 任务队列 | 无 Redis / 无 MQ | 租约 + 心跳轮询,少一个运行组件 | +| 管理端 | go-admin-team/go-admin + go-admin-ui | 自带 RBAC / JWT / 代码生成器 | +| 用户端会话 | alexedwards/scs | 无需 Redis | +| 熔断 | sony/gobreaker | 多 provider 故障转移,MVP-0 之后启用 | +| 缩略图 | disintegration/imaging | 纯 Go,无 CGO | +| 限流 | ulule/limiter | 用户维度令牌桶 | +| 上游调用 | 手写 net/http 客户端 | 不用 go-openai SDK:多 provider 在多图上传、`extra_body` 透传、`b64_json` vs `url` 上差异大,强类型 SDK 反而挡路 | +| 用户端前端 | html/template + HTMX 2.x + Alpine 3.x + Tailwind 4.x(standalone CLI) | 无 npm、无构建链、无 SPA | +| 开发环境 | Windows + PowerShell + Git;MySQL 8.0 本地或容器 | Harness 工具需 Python 3 标准库 | + +### 建设基线评估结论 + +- 管理端**采用开源基线** go-admin(MIT):已具备 RBAC、JWT、审计和代码生成器,Provider/Model/用户/点数等 CRUD 可近似零成本产出,定制范围可控。已知代价:go-admin-ui 属 vue-element-admin 系(Vue 2 已 EOL),定制页超过 3 个时须重新评估是否改为自建服务端渲染后台。 +- 核心生成域**从零开发**:cmhub 是 Django,语言已变,能带走的是设计(provider 抽象、任务租约、SSRF 校验、密钥加密)而非代码;现成 Go 系 LLM 网关(如各类 one-api 变体)与本项目在图生图多图上传、图片角色规则和自有点数展示上差异大,改造与跟随上游成本高于自建约 200 行 provider 层。 +- 基础组件按需复用:gobreaker、imaging、limiter、scs 均为单一职责库,各自记录用途、版本和可替换方案。 + +## 阅读入口 + +- 新人入口:[Home](README.md)。 +- 产品需求与 MVP 分期:[产品需求总览](09-product-requirements-overview.md)。 +- 代码入口:[架构与代码地图](02-architecture-and-code-map.md)。 +- 业务边界:[业务规则与术语](03-business-rules-and-glossary.md)。 +- 运行验证:[本地开发与验证](04-local-development-and-verification.md)。 +- 简单维护:[常见修改指南](05-common-changes.md)。 +- 错误定位:[故障排查](06-troubleshooting.md)。 + +## 常用命令 + +所有命令默认从仓库根目录执行。标注“MVP-0 落地后可用”的命令在首个实现工单完成前不存在。 + +| 用途 | 命令 | 预期结果 | +|---|---|---| +| 查看工作区 | `git status --short --branch` | 显示分支且没有无关修改 | +| 检查文档结构 | `python dev_scripts/harness.py check --strict` | 输出“DevHarness 检查通过” | +| 运行 Harness 测试 | `python -m unittest discover -s tests -v` | 所有测试通过 | +| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` | Wiki 初始化后输出核心镜像一致 | +| 编译(MVP-0 落地后可用) | `go build ./...` | 无错误 | +| 单元测试(MVP-0 落地后可用) | `go test ./...` | 全部通过 | +| 静态检查(MVP-0 落地后可用) | `go vet ./...` | 无输出 | +| 数据库迁移(MVP-0 落地后可用) | `migrate -path migrations -database "$CHORUS_DSN" up` | 迁移版本前进且无错误 | +| 启动用户端(MVP-0 落地后可用) | `go run ./portal` | 监听 `:8080`,日志显示 worker 已启动 | + +## 目录边界 + +| 目录 | 职责 | 不应放入 | +|---|---|---| +| `internal/core/` | 与框架无关的核心域 | gin、go-admin 依赖,HTTP 处理,模板 | +| `portal/` | 用户端 Gin 服务、页面模板与静态资源 | 上游调用与选路逻辑(属于 core) | +| `admin/` | go-admin 脚手架与业务 app | 绕过 core 的生成实现 | +| `migrations/` | golang-migrate SQL 文件 | 测试数据、一次性修数据脚本 | +| `docs/` | 核心长期文档(Wiki 初始化后为只读镜像) | 人工直接维护的最终事实(初始化后) | +| `docs/task/` | 人工按需导出的任务归档快照 | 讨论过程和临时方案 | +| `prototypes/` | 按工单和版本保存的 HTML 审核快照 | 凭据、个人信息、生产数据 | +| `dev_scripts/` | Harness 检查与 Wiki 同步工具 | 产品功能代码 | +| `tests/` | Harness 工具测试(Go 测试与被测代码同目录) | 生产数据 | + +## 环境、配置与凭据 + +- 核心 Wiki 同步配置:`wiki-docs.json`;Gitea 地址可用 `GITEA_URL` 覆盖。 +- Gitea PAT 仅通过 `GITEA_TOKEN` 或 MCP 安全配置提供,不写入仓库。 +- 数据库连接串通过 `CHORUS_DSN` 环境变量提供,不写入仓库。 +- Provider 的 `api_key` 使用 AES-GCM 加密落库,主密钥来自环境变量 `CHORUS_MASTER_KEY`,须预留轮换方案;明文密钥不得进入日志、工单、Wiki 或截图。 +- 生成物默认落本地文件系统(路径由配置指定),`storage` 从第一天就是接口,预留 S3/OSS。 +- 测试只使用构造数据和 mock 上游,不使用生产数据和真实上游密钥。 + +## 项目专用验收要求 + +- 长期核心文档必须先更新 Wiki,再导出本地镜像;Wiki 初始化未完成前,文档修改直接在 `docs/` 进行并在工单说明。 +- `python dev_scripts/harness.py check --strict` 必须通过。 +- MVP-0 落地后:`go build ./...`、`go vet ./...`、`go test ./...` 必须通过。 +- 涉及 `retryable` 判定、SSRF 校验、密钥加解密和迁移的修改必须有针对性单元测试。 +- 未执行或无法覆盖的验证必须记录到工单。 diff --git a/docs/01-workflow.md b/docs/01-workflow.md new file mode 100644 index 0000000..3a58958 --- /dev/null +++ b/docs/01-workflow.md @@ -0,0 +1,314 @@ +# 开发工作流 + +## 事实来源边界 + +- Gitea 工单记录任务状态、讨论、阻塞、方案变化、验证和验收。 +- Gitea Wiki 保存架构说明、开发规范、操作手册和完成后的任务归档。 +- Git 保存源码、与特定代码版本强绑定的文档,以及 Wiki 的本地镜像。 +- 本地 `docs/` 仅供浏览和审查,不是长期文档编辑入口。 + +## 新项目 Wiki 初始化门禁 + +从 DevHarness 创建新项目时,本地 `docs/` 即使完整存在,也只能证明模板镜像存在,不能证明新项目的线上 Wiki 已初始化。开始任何产品代码前必须完成以下闭环: + +1. 先创建 Gitea 远端仓库并启用 Wiki,再把 `wiki-docs.json` 指向该仓库。 +2. 优先使用项目已配置的 Gitea MCP 查询 Wiki 页面;MCP 不可用或不支持所需写操作时,才使用 Gitea API,并在初始化工单记录回退原因。凭据只从环境或 MCP 安全配置读取。 +3. 查询线上页面列表;没有 `Home` 时先创建 `Home`,回读正文并记录 revision,然后再创建或更新其他核心映射页面。 +4. 每个核心页面写入后都要在线回读;页面可读取且取得 revision 才算创建成功,不能用本地 `docs/` 文件替代这项证据。 +5. 运行 `python dev_scripts/harness.py sync --verify`。任一映射页面不存在、无法回读或镜像不一致时,停止产品编码并完成初始化。 + +Gitea 暂时不可用时可以准备工单和 Wiki 草稿,但不得把本地草稿宣称为线上事实,也不得绕过此门禁开始产品功能开发。 + +## 工单与设计证据双门禁 + +开始正式实现前依次判断“是否需要工单”和“需要什么设计证据”。原型确认不能代替方案、工单、安全检查或技术验证;工单存在也不能绕过原型确认。 + +### 先判断是否需要工单 + +只有纯界面显示文案同时满足以下全部条件时,才可以免工单、免原型: + +- 只修改用户看到的组件显示名称、按钮文字、标题、提示语或其他文案; +- 不改变业务含义、操作流程、权限、状态、接口、数据和验收结果; +- 不涉及法律条款、安全提示、支付、金额、单位或其他高风险含义; +- 不修改国际化键、代码组件名、类名、变量、API 字段、数据库字段或其他程序标识符; +- 不造成明显布局、截断、换行、可访问性或支持平台问题; +- 有任何不确定时不使用豁免。 + +豁免修改只执行与受影响界面相称的最小检查,确认文字正确且没有明显布局或可访问性问题,然后停止。只要任一条件不满足,或涉及用户行为、样式布局、交互和导航,就建立单元任务工单。 + +### 再判断设计证据 + +| 修改类型 | 最低设计证据 | 正式编码门禁 | +|---|---|---| +| 纯显示文案且满足全部豁免条件 | 无原型 | 完成最小界面检查即可 | +| 现有界面的小范围样式或布局调整 | 标注截图或低保真线框图;没有设计不确定性时说明复用的现有规范 | 工单确认设计证据后编码 | +| 新组件但复用现有设计体系 | 组件状态、错误和边界说明;按需提供低保真图 | 工单确认状态和复用边界后编码 | +| 新页面、独立用户功能、重大交互或导航变化 | Quant-UX 或其他合适工具制作的可审阅原型 | 用户确认原型和文字需求后才能编写生产代码 | +| 后端、接口、数据处理或定时任务 | 架构、API、数据、状态或流程设计 | 用户确认技术方案后编码,不制作无意义的 UI 原型 | +| 恢复既有确认行为的 Bug | 原设计、已确认截图、复现步骤或现有验收证据 | 确认是恢复而不是改变行为后修复 | + +采用最低成本、足以让用户确认的证据,不为了形式制作高保真原型。草稿原型可以用于需求讨论;草稿需要写入 Git/Wiki、多人协作或单独实施时,应建立设计任务。草稿原型和临时技术验证都不能直接作为生产实现。 + +### 本地 HTML 审核快照 + +新页面、独立用户功能、重大交互或导航变化使用 Quant-UX 或等效工具形成待审核版本后,必须在用户审核前生成本地可浏览 HTML 快照: + +- 可编辑设计源仍保存在 Quant-UX 或原设计工具;Git 中的 HTML 只是与需求和代码版本绑定的审核证据,Wiki 和工单只保存索引与确认记录。 +- 快照放入 `prototypes/<工单号>/<版本>/index.html`;图片、样式、脚本和字体使用该版本目录内的相对路径。需要网络资源才能显示时,不得标记为可离线浏览。 +- 用户通过 `index.html` 审核;浏览器限制直接打开时,在工单记录最小本地静态服务命令和访问地址,不新增项目专用服务脚本。 +- 提交或请求审核前检查入口可打开、主要页面和交互可访问、图片和字体不缺失,并删除令牌、账号、个人信息和生产数据。 +- 页面结构、主要流程、状态、权限、异常处理或验收结果发生变化时,在新版本目录重新导出并重新确认;不得覆盖已经确认的版本。 +- 纯显示文案、小范围现有 UI 调整、非 UI 需求和恢复既有行为的 Bug 不强制生成 HTML;仍使用双门禁表规定的最低证据。 +- 设计工具无法生成可用 HTML 时必须在工单说明限制并停止审核,由用户确认等效的本地可浏览原型方案;不得只保留难以访问的线上链接后直接编码。 + +### 记录和重新确认 + +需要设计证据的工单必须记录: + +- 原型或设计的链接、Git 路径或对应事实来源; +- 版本、revision 或确认日期; +- 状态:无、草稿、已确认或已废弃; +- 确认人和确认时间; +- 本次确认覆盖的页面、组件、流程和边界; +- 不需要 UI 原型时采用的技术设计,或无需任何原型的原因。 + +页面结构、主要流程、状态、权限、异常处理或验收结果变化时,先更新原型或文字需求并重新确认,再继续正式编码。只读技术检查可以在确认前进行;确需可行性代码验证时,必须由用户明确同意,隔离为不可进入生产的技术验证,不得悄悄扩展成正式实现。 +## 一次任务怎样完成 + +### 1. 讨论 + +用户描述需求或故障。Agent 先检查现状,再给出目标、非目标、方案、风险、回退和验证方法。存在不同实现方向时,说明取舍并等待用户确认。 + +### 2. 建单 + +方案确认后,使用 `.gitea/issue_template/task.md` 创建单元任务工单。没有工单号之前不修改产品代码或正式文档。 + +新产品或较大版本先建立 Epic,再建立 MVP: + +```text +[Epic] 产品或长期目标 +└── [MVP] 第一个可交付版本 + ├── #101 单元任务 + ├── #102 单元任务 + └── #103 单元任务 +``` + +每个单元任务都应目标单一,能够独立测试、提交和回退。 + +#### 依赖与并行 + +建立新工单不要求其他工单已经完成,也不按工单编号限制实施顺序。每个单元任务必须声明: + +- 前置工单,没有时填写“无”; +- 是否允许与未完成的前置工单并行; +- 判断可以或不可以并行的原因。 + +开始修改前,Agent 检查工单声明的前置工单: + +- 没有前置工单,或前置工单已经完成,可以进入“进行中”; +- 前置工单未完成且存在实际依赖时,不得开始实施,工单保持“待实施”; +- 与前置工单没有实施冲突、允许并行时,可以进入“进行中”,但必须在工单写明原因; +- 已经进入实施后出现计划外、当前无法解除的问题,才使用“阻塞”。 + +依赖不改变单元任务边界。依赖满足后,该任务仍须拥有独立的范围、提交、测试和回退方式。 + +### 3. 实施 + +Agent 检查分支和工作区,只修改工单范围内的文件。发现新问题时先记录到工单;如果不影响当前验收,则另建工单,不扩大当前任务。 + +重要进度及时写回工单: + +- 已确认的根因; +- 方案或范围变化; +- 测试结果; +- 阻塞和未验证内容; +- Git 提交哈希; +- 相关 Wiki 页面及 revision。 + +长期核心文档遵循唯一顺序: + +```text +修改 Wiki → 读取确认 → 导出核心 docs → 校验差异 → 提交镜像 +``` + +任务归档默认只更新 Wiki,不自动导出到 `docs/task/`。不得先编辑本地镜像再反向覆盖 Wiki。 + +### 4. 待验收 + +实现和测试完成后,Agent 提交实现代码并将工单更新为“待验收”。用户验收前工单保持开启。 + +### 5. 归档和关闭 + +使用以下命令只在 Wiki 创建任务归档页: + +```powershell +python dev_scripts/harness.py archive 123 "修复登录超时" +``` + +归档内容以 Wiki 页面为事实来源。默认不修改 `wiki-docs.json`,也不写入 `docs/task/`。把 Wiki 页面、revision 和实现提交哈希写回工单;用户明确验收通过后,关闭单元工单并勾选父工单中的任务。 + +只有用户明确提出时才导出任务归档: + +```powershell +python dev_scripts/harness.py export # 增量:新增或 revision 变化 +python dev_scripts/harness.py export --all # 全量:读取全部线上任务归档 +``` + +导出不得自动删除本地文件。`docs/task/` 只是人工按需生成的只读快照,可能不是完整或最新的任务历史。 + +## 文档同步规则 + +- 核心页面映射保存在 `wiki-docs.json`;普通同步只处理这些核心长期文档。 +- 任务归档不逐页登记映射,由按需导出工具根据 `Task-<编号>-<标题>` 动态发现;已有镜像优先按镜像头匹配原页面。 +- 所有同步和导出只实现 Wiki → `docs/`,不提供反向同步。 +- 镜像头必须记录页面名、页面地址、revision 和同步时间。 +- 已跟踪镜像存在未提交改动时,同步必须停止;确认改动来源后再处理。 +- 核心同步的 `--check` 只检查核心镜像,不要求线上任务归档全部存在于本地。 +- 已经导出的任务镜像仍必须具有来源页面、revision 和同步时间,并通过 Harness 格式检查。 +- 页面删除和重命名不会自动传播,必须先更新工单并人工确认映射变化。 +- Wiki 更新成功而导出失败时,在工单记录部分完成状态,不得把任务标为完成。 +- 与具体代码版本强绑定的接口或迁移资料可直接随代码维护,但必须在 Wiki 提供入口或适用版本说明。 + +## 面向初级维护者的修改边界 + +| 风险 | 示例 | 处理方式 | +|---|---|---| +| 低 | 文案、简单校验、查询条件、独立 UI、小范围回归 Bug | 初级程序员可在 Agent 协助下理解、修改和验证 | +| 中 | API、配置、依赖、跨模块逻辑、数据结构 | 由 Agent 实现,程序员检查差异并执行验证 | +| 高 | 权限、安全、并发、迁移、支付、删除数据、不可逆操作 | 停止修改,由 Agent 分析并等待人工确认 | + +风险由影响范围决定,不按代码行数判断。 + +## 每个任务的文档影响 + +单元任务必须明确选择: + +- 不影响长期文档,并说明原因; +- 更新项目档案或运行验证; +- 更新架构与代码地图; +- 更新业务规则与术语; +- 更新常见修改或故障排查; +- 新增或调整其他 Wiki 页面。 + +以下变化必须更新相关 Wiki: + +- 启动、测试、部署或排错命令变化; +- 模块入口、目录职责或主要调用路径变化; +- 配置项、API、数据结构或状态变化; +- 业务规则、安全边界或权限变化; +- 日志位置、错误定位或常见处理方式变化。 + +部署命令的落点:有常驻服务的项目更新自己的 `Deployment-and-Operations` 页面(由[部署文档模板](Deployment-Template.-)复制建立);没有常驻服务的项目在工单记录“无部署文档影响”及原因,不要创建空的部署页。 + +普通内部重构如果入口、行为、配置和验证方式均未改变,可以记录“不影响长期文档”及原因。 + +## 需求记录与流转 + +聊天用于分析和确认,不是正式需求的长期事实来源。创建单元任务工单时,Agent 应记录: + +- 原始需求的来源和提出时间; +- 能表达用户目的、使用场景和限制的少量关键原话; +- 整理后的目标、非目标、确认方案、验收标准和文档影响; +- 实施期间影响范围、接口、数据、风险或验收的需求变化,以及变化原因和用户确认。 + +只摘录完成追踪所需的内容,不复制完整聊天,不保存 Agent 内部推理,不写入密码、令牌、个人数据或生产数据。包含敏感信息的原话必须删除敏感部分或改写为脱敏摘要。 + +需求按以下边界流转: + +| 内容 | 事实来源 | 本地镜像 | +|---|---|---| +| 关键原始需求、确认后的单次任务需求 | Gitea 单元任务工单 | 无 | +| 讨论、决定和需求变化 | Gitea 工单正文或评论 | 无 | +| 长期有效的产品需求、业务规则和系统边界 | 对应 Gitea Wiki 主题页 | `docs/` | +| 完成后的实现、验证和遗留问题 | Wiki 任务归档 | 人工按需导出的 `docs/task/` 快照(可能不完整) | + +任务产生长期结论时,先更新对应 Wiki 主题页,再导出本地镜像。Gitea 工单全文不导出到仓库,避免形成第二份任务过程记录。 + +## 稳定文档与任务归档 + +- Home、项目档案、代码地图、业务规则、开发验证、常见修改和故障排查描述项目现在怎样工作。 +- 工单和 Wiki 任务归档解释某次为什么修改、实际改了什么以及如何验证;本地任务快照不是完整历史。 +- 新人先读稳定主题页,只有追查历史原因时才读任务归档。 +- 任务产生的长期结论必须合并到主题页,不能只留在归档。 + +## 效率与范围控制 + +本节用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。 + +### 严格控制范围 + +- 默认严格按用户确认的目标和单元任务范围执行,不主动扩展相邻问题。 +- 除非任务目标、仓库强制规则或已发现的真实阻塞需要,不新增额外文档、辅助脚本、备份文件、框架、重构或扩展性设计。 +- 不执行与本次验收无关的验证;安全检查、受影响范围测试、回归测试和仓库规定的闭环验证不属于“额外验证”。 +- 新发现的相邻问题最多用一句话提示或记录到独立工单,不自动修复或混入当前提交。 + +### 渐进执行和修复 + +- 完成已知必要的安全与前置检查后,优先执行能够产生真实反馈的最小命令。 +- 一次执行后先处理首个可定位、可行动的真实错误,不同时猜测并修改多个可能原因。 +- 采用“执行 → 查看错误 → 最小修复 → 从失败点继续或按需重跑”的闭环。 +- 不在真实证据出现前堆叠与已知风险无关的预防性检查。 +- 涉及凭据、权限、安全、数据、迁移、并发、删除、发布或不可逆操作时,必须先完成相应前置检查,不得通过试错获取风险反馈。 + +### 复用已验证事实 + +- 在同一任务和同一环境状态下,已经通过的路由、连接、恢复和环境检查不重复执行。 +- 只有会话、环境、代码、配置、依赖、凭据、远端状态或关键前提发生变化时才重新检查。 +- 代码修改后,受影响测试和最终验收必须重新执行;提交前工作区检查、推送前远端分支检查不得因为之前通过而省略。 +- Skill 和平台规则是否需要重新读取,按当前 Agent 平台和任务触发规则执行,不自行跳过。 + +### 明确停止条件 + +- 完成用户确认的验收标准和仓库规定的必要闭环后立即停止,不主动继续优化。 +- “最小验收条件”包括当前工单要求的实现、必要测试、文档影响处理、Wiki 镜像检查、提交和证据回写,不等同于功能第一次运行成功。 +- 未影响当前验收的相邻问题只提示或建单,不顺手处理。 + +## 自然语言快捷指令 + +快捷指令是对本工作流的自然语言别名,供 Claude Code、Codex 和维护者使用。它们只减少重复描述,不得绕过方案确认、前置依赖、安全规则、工单范围、Wiki 主源、必要验证或人工验收。 + +| 指令 | 执行动作 | 停止位置 | +|---|---|---| +| `只分析` | 只读检查需求、代码、日志和文档,区分事实与假设并给出方案 | 输出方案并等待确认;不建单、不修改 | +| `建工单` | 根据已经确认的方案创建单元任务工单 | 工单创建并记录完成;不修改代码 | +| `执行工单 #N` | 读取工单和前置依赖,实施、测试、提交、更新 Wiki、导出镜像、推送并回写证据 | 工单保持“待验收” | +| `建工单并做` | 依次执行“建工单”和“执行工单”;`建工单,做`、`建工单,做` 含义相同 | 工单保持“待验收” | +| `继续工单 #N` | 核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查 | 到达该工单当前流程的停止条件 | +| `检查工单 #N` | 只读对照范围、验收标准、测试和证据,报告通过项、缺失项及未验证部分 | 输出检查报告;不自动修复 | +| `同步文档` | 读取 Wiki,导出核心长期文档镜像并检查一致性,不处理任务归档 | 显示结果和差异;不修改 Wiki、不自动提交 | +| `导出任务归档` | 人工触发增量导出,只写入新增或 revision 已变化的任务归档 | 显示导出或跳过结果;不删除本地文件、不自动提交 | +| `导出全部任务归档` | 人工触发全量读取并导出线上全部任务归档 | 显示导出结果;不删除本地文件、不自动提交 | +| `#N 验收通过` | 记录明确验收,更新 Wiki 归档为“已完成”,同步必要的核心文档,推送、同步父工单并关闭任务;不自动导出任务归档 | 工单“已完成”并关闭 | + +补充边界: + +- 方案未确认时,`建工单`、`建工单并做` 和 `执行工单 #N` 不得绕过确认;Agent 应停在方案确认。 +- 前置依赖未满足且不允许并行时,实施类指令停在“待实施”。 +- `#N 验收通过` 必须来自用户明确表达;其他快捷指令不得关闭待验收工单。 +- `同步文档` 或任务归档导出发现目标镜像有未提交改动时停止,不覆盖现有修改。 +- `导出任务归档` 和 `导出全部任务归档` 必须由用户明确提出,其他快捷指令不隐式执行。 +- Gitea 工单保留讨论和过程,不把工单全文导出到本地;`docs/task/` 只保存人工按需导出的 Wiki 最终任务归档快照。 + +## 什么时候重新确认方案 + +以下变化必须先更新工单,再由用户确认: + +- 交付结果或用户操作发生变化; +- 增加或删除接口、数据库字段或迁移; +- 安全边界、权限或不可逆操作发生变化; +- 原方案不可行,需要更换主要技术路线; +- 任务范围明显扩大; +- Wiki 页面删除、重命名或事实源边界改变。 + +普通内部实现细节不需要反复确认,但重要取舍应记录在工单中。 + +## 工单、Wiki 与 Git 分别写什么 + +| 信息 | Gitea 工单 | Gitea Wiki | Git / `docs` 镜像 | +|---|---:|---:|---:| +| 讨论过程和临时方案 | 是 | 否 | 否 | +| 实施进度和阻塞 | 是 | 否 | 否 | +| 长期有效的最终方案 | 链接 | 是 | 镜像 | +| 测试结果与未验证内容 | 是 | 任务归档 | 按需镜像 | +| 提交哈希 | 是 | 任务归档 | 按需镜像 | +| 与具体代码版本绑定的说明 | 可链接 | 提供入口 | 是 | diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md new file mode 100644 index 0000000..ebc59d1 --- /dev/null +++ b/docs/02-architecture-and-code-map.md @@ -0,0 +1,102 @@ +# 架构与代码地图 + +## 项目定位 + +chorus 是一个独立运行的 Go 服务,对外提供两种生成能力: + +- **图生图**:提示词 + 一张或多张原图 → 新图; +- **文生文**:提示词 → 文本。 + +上游是各家兼容 OpenAI 规范的模型服务。chorus 负责:接收请求、合成最终提示词、把任务放入队列、由 worker 选择一个可用上游执行、落盘结果与缩略图、把状态反馈给页面。管理端负责配置上游、组织路由池和查看记录。 + +一个仓库、两个二进制、一个共享库: + +```text +chorus/ +├── go.mod +├── internal/core/ ★ 核心域,两端共享,不依赖 gin / go-admin +│ ├── model/ GORM 模型(业务表) +│ ├── provider/ chat / images / images_edits / gemini 四种实现 +│ ├── router/ 选路、加权、gobreaker 熔断、故障转移 +│ ├── generate/ prompt 合成、调用编排、落盘、缩略图 +│ ├── queue/ SKIP LOCKED 租约、心跳、重试 +│ ├── storage/ 本地 FS + S3 接口 +│ ├── crypto/ provider api_key AES-GCM +│ └── security/ SSRF 拦截(DialContext 钩子) +├── admin/ go-admin 脚手架,业务 app 在 app/chorus/ +├── admin-ui/ go-admin-ui,加自定义页面 +├── portal/ ★ 用户端:独立 Gin +│ ├── handler/ 页面 + JSON API + openapi(API Key) +│ └── web/{templates,static} +└── migrations/ golang-migrate(业务表;sys_* 交给 go-admin 初始化) +``` + +## MVP-0 的最小形态 + +MVP-0 的唯一目标是**让全流程跑通**,因此只实现上面结构的一条竖切: + +| 目录 | MVP-0 实现 | 暂缓 | +|---|---|---| +| `internal/core/model` | `users`、`providers`、`provider_models`、`generations`、`generation_inputs`、`generation_outputs` | 点数、路由池、健康、审计表 | +| `internal/core/provider` | `chat` 与 `images_edits` 两种实现 | `images`、`gemini` | +| `internal/core/router` | 直接取唯一启用的 provider_model | 加权选路、熔断、故障转移 | +| `internal/core/generate` | prompt 合成、调用、落盘、缩略图 | 模板三层覆盖的管理端层 | +| `internal/core/queue` | `SKIP LOCKED` 取任务 + 租约 + 失败置错 | 心跳续租、多次重试、清理任务 | +| `internal/core/crypto`、`security` | 从第一天就有:AES-GCM、SSRF 拦截 | 密钥轮换 | +| `portal` | 登录、双栏首页、提交生成、HTMX 轮询卡片、结果展示 | API Key 端点、限流、无限滚动分页 | +| `admin` | 暂不接入,provider 配置用迁移或一次性命令写入 | 全部管理端页面 | + +暂缓项都在 [产品需求总览](09-product-requirements-overview.md) 中登记为后续阶段,不是被取消的需求。 + +## 代码地图 + +| 想改什么 | 从哪个文件开始读 | 相关测试 | +|---|---|---| +| 页面长什么样 | `portal/web/templates/` | 手工浏览器检查 + `prototypes/` 审核快照 | +| 提交生成的入参与校验 | `portal/handler/generate.go` | `portal/handler/generate_test.go` | +| 生成卡片轮询与停止条件 | `portal/handler/card.go` 与对应模板 | 同上 | +| 最终提示词怎么拼出来 | `internal/core/generate/prompt.go` | `internal/core/generate/prompt_test.go` | +| 怎么调上游、怎么解析响应 | `internal/core/provider/` 下各实现 | 各实现的 `_test.go`,用 mock HTTP 服务 | +| 失败了换不换下一家 | `internal/core/router/retryable.go` | `internal/core/router/retryable_test.go` | +| 任务怎么被取走和重试 | `internal/core/queue/` | `internal/core/queue/queue_test.go`(需 MySQL) | +| 结果文件放哪 | `internal/core/storage/` | `internal/core/storage/local_test.go` | +| 表结构 | `migrations/` 中序号最大的 SQL | 迁移 up/down 各跑一次 | + +文件名是 MVP-0 的规划命名;实现工单如需调整,必须同步更新本表。 + +## 两条主要执行路径 + +### 路径一:用户提交生成(同步部分) + +```text +浏览器表单 / HTMX 提交 + → portal/handler/generate.go:校验、保存上传原图、写 generations(status=pending) 与 generation_inputs + → 立即返回一张 loading 卡片(带 hx-trigger="every 2s") +``` + +提交链路**不调用上游**,也不阻塞等待结果。任何在这一步做上游调用的改动都属于架构变更,必须先建单确认。 + +### 路径二:worker 执行生成(异步部分) + +```text +queue:SELECT … FOR UPDATE SKIP LOCKED 取一条 pending,写 lease_until,置 running + → generate:合成 rendered_prompt(管理端模板 → 用户 role_rule → 每张图 role/note) + → router:挑选候选 provider_model(MVP-0 只有一个) + → provider:调用上游,得到图片字节或文本 + → storage:落盘原图/结果图,生成 256px 缩略图 + → 写 generation_outputs,generations 置 succeeded/failed,记录 attempts 与 latency_ms + → 下一次轮询请求返回不带 hx-trigger 的成品卡片,轮询自动停止 +``` + +worker 跑在 portal 进程内(goroutine pool + 优雅退出),不放进 go-admin 的 `app/jobs`,避免管理端重启影响生成。 + +## 不可破坏的边界 + +- `internal/core` **不 import gin、不 import go-admin**,只依赖 GORM 和标准库。管理端换框架或用户端换渲染方式时,核心不需要改。 +- 管理员与终端用户**物理隔离在两张表**:管理端用 go-admin 的 `sys_user` + JWT + Casbin,用户端用 `users` + session cookie(scs),程序调用用 API Key 哈希比对同样落在 `users`。不得把两类身份合并到一张表。 +- **生成链路完全不碰点数**:点数只读展示,无扣费、无退款、无余额校验、无配额。cmhub 的 `precharge_call / refund_call_points / mark_call_success` 三段式事务全部不移植。 +- **`retryable` 判定是全局最容易出错的地方**:`429 / 5xx / 超时 / 连接错误` 换下一家;`400 / 401 / 内容策略拒绝` 立即返回不换家。写反了,一个参数错误会把所有 provider 的配额烧一遍。 +- **SSRF 校验不能省**:provider `base_url` 由管理员填写,是打内网的直接入口。必须在 **DNS 解析后、连接前**用 `DialContext` 钩子拦截私网与回环地址,防 DNS rebinding。 +- **生产不使用 GORM AutoMigrate**:表结构只经 `migrations/` 演进。 +- **`rendered_prompt` 必须落库**,`generations.attempts` 必须记录每次尝试的 provider、错误和耗时;这两项是排障的唯一依据。 +- 结果图必须同步生成缩略图(`generation_outputs.thumb_path`),预览列表不得直接加载原图。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md new file mode 100644 index 0000000..c26a854 --- /dev/null +++ b/docs/03-business-rules-and-glossary.md @@ -0,0 +1,75 @@ +# 业务规则与术语 + +## 核心术语 + +| 术语 | 含义 | +|---|---| +| **Provider** | 一个上游服务商配置:`base_url`、加密后的 `api_key`、`api_type`、权重、启停。默认只支持 OpenAI 规范。 | +| **api_type** | provider 的调用形态,四种:`chat`(文生文)、`images`(纯文生图)、`images_edits`(图生图,多图上传)、`gemini`。 | +| **ProviderModel** | 某个 provider 下的一个具体模型:`model_id`、能力(text / image / vision)、`extra_body`、超时。选路的最小单位。 | +| **RoutePool(路由池)** | 管理端勾选的一组 ProviderModel,按 `kind`(text / image)分类,用于均衡与故障转移。 | +| **Generation(生成记录)** | 一次生成请求的完整记录,图生图与文生文**统一在一张表**,区别只在 `kind`。 | +| **kind** | 生成类型:`text`(文生文)或 `image`(图生图)。 | +| **role(图片角色)** | 每张上传原图的身份:`primary`(主体图)或 `reference`(参考图)。默认第一张为主体,其余为参考。 | +| **role_rule(角色规则)** | 描述“第几张图是什么”的一段文字。默认值来自管理端模板,用户端可编辑可清空。 | +| **rendered_prompt** | 实际发给上游的完整内容,由角色规则、每张图的 role 与 note、用户提示词合成。 | +| **attempts** | 本次生成的故障转移链路:`[{provider_model_id, err, ms}, …]`。 | +| **租约(lease)** | worker 取走任务后写入的 `lease_until`;过期未完成的任务可被其他 worker 重新取走。 | +| **点数(point)** | 用户余额,**只做展示**。余额存在 chorus 自有表,由管理端发放。 | + +## 工单状态 + +沿用 DevHarness:待确认、已确认、开发中、待验收、已交付、已停止。状态含义与流转规则见 [开发工作流](01-workflow.md) 和 [产品需求总览](09-product-requirements-overview.md)。 + +## 稳定业务规则 + +### 生成流程 + +1. 提交生成是**异步**的:接口立即返回,页面插入 loading 占位卡片,完成后原地替换为缩略图。提交链路不得同步调用上游。 +2. `generations.status` 状态机:`pending → running → succeeded | failed`。只允许沿箭头前进,不允许从终态回到 `running`;重试通过新的 attempt 记录在同一条记录上,`attempt_count` 递增。 +3. 租约到期是唯一允许把 `running` 拉回可取走状态的机制,且必须写入失败原因或递增 `attempt_count`,不得静默重放。 +4. 一次生成的 `rendered_prompt`、`attempts`、`latency_ms` 必须落库,失败时还要写 `error_code` 与 `error_message`。 +5. 成功的图片结果必须同时产出 256px 缩略图;没有缩略图的输出视为未完成。 +6. `idempotency_key` 相同的提交视为同一次生成,不重复创建记录。 + +### 选路与失败处理 + +7. 候选来自路由池中启用且未熔断的 ProviderModel,按 `weight` 平滑加权后打乱顺序。 +8. 故障转移最多尝试 `maxFailover` 家(默认 3),每次尝试都要记入 `attempts`。 +9. `retryable` 判定:`429`、`5xx`、超时、连接错误 → 换下一家;`400`、`401`、内容策略拒绝 → 立即返回,不换家。这条规则写反的代价是烧光所有 provider 配额。 +10. 连续 N 次失败打开熔断,冷却后半开试探(gobreaker 默认语义)。熔断中的候选不参与选路。 +11. 按 provider 分池限流,避免单家并发超限触发 429。 + +### 提示词与图片角色 + +12. 角色规则三层可覆盖,后一层覆盖前一层:管理端 `prompt_templates`(按 kind 区分,可多套)→ 用户端「高级设置」可编辑文本框(可改、可清空)→ 与每张图的 `role` / `note` 合成,最终写入 `rendered_prompt`。 +13. 用户提交的 `role_rule` 为空时使用管理端默认模板;不得把角色规则硬编码在代码里。 +14. 每张上传图可选填一句备注(例如“参考这张的配色”),备注参与提示词合成。 + +### 点数与限流 + +15. 点数**只读展示**:生成链路不做扣费、退款、余额校验,也没有每日配额。 +16. 管理端调整余额时**必须在同一事务内写 `point_ledger` 并记 `operator_id`**。虽不涉真钱,可追溯是底线。 +17. 去掉配额后的刹车是限流:用户维度令牌桶 + 同一用户并发生成数上限(默认 2),防止单人打满所有 provider 配额。 + +### 安全与审计 + +18. provider 的 `api_key` 必须 AES-GCM 加密落库,主密钥来自环境变量;明文不得进入日志、响应、工单或 Wiki。 +19. 所有对 provider `base_url` 的出站请求必须经过 SSRF 拦截(DNS 解析后、连接前)。 +20. 管理端的配置变更写 `config_audit_logs`,记录操作者、动作、目标和变更内容。 +21. 管理员(`sys_user`)与终端用户(`users`)分表,互不混用凭据。 + +### 明确不做 + +计费扣点、充值与支付回调、汇率、软件授权与设备绑定、内容审核、cmshopee 专用端点、每日生成配额。这些不是“以后再说”,而是本服务的范围之外;有需要时须先建 Epic 重新确认。 + +## 新项目需要补充什么 + +以下内容尚未确认,实施到对应阶段前必须由负责人拍板,不得由 Agent 假设: + +- **角色规则默认模板的措辞**:cmhub 那段是电商专用文案,作为 chorus 默认模板是否改为中性表述,取决于目标用户。 +- **点数来源**:当前定为自有表 + 管理端发放;若要与 cmhub 账本打通,需另设计同步或子账户方案。 +- **生成物保留期**:本地磁盘增长很快,保留期与清理策略必须在上线前定。 +- **注册赠送点数**:是否开启、赠送多少,做成一条系统配置。 +- **限流具体阈值**:令牌桶速率、并发上限的生产取值。 +- **上游具体名单**:首个可用于联调的 provider、模型和额度来源。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md new file mode 100644 index 0000000..f054208 --- /dev/null +++ b/docs/04-local-development-and-verification.md @@ -0,0 +1,115 @@ +# 本地开发与验证 + +本页覆盖两类工作:**文档与流程**(现在就能跑)和 **Go 服务**(MVP-0 实现工单落地后可跑)。标注“MVP-0 落地后”的步骤在代码存在前会失败,这是预期的。 + +## 环境要求 + +| 项 | 要求 | 检查命令 | +|---|---|---| +| Git | 任意近期版本 | `git --version` | +| Python 3 | 仅标准库,供 Harness 工具使用 | `python --version` | +| Go | 1.22 或更高 | `go version` | +| MySQL | 8.0(本地或容器),需支持 `SKIP LOCKED` | `mysql --version` | +| golang-migrate | 命令行工具 | `migrate -version` | +| Tailwind CLI | standalone 可执行文件,无需 npm | `tailwindcss --help` | + +不需要 Node.js、Redis 或消息队列。用到的环境变量: + +| 变量 | 用途 | 缺失后果 | +|---|---|---| +| `CHORUS_DSN` | MySQL 连接串 | 服务无法启动 | +| `CHORUS_MASTER_KEY` | provider api_key 的 AES-GCM 主密钥 | 无法解密上游密钥,生成全部失败 | +| `GITEA_TOKEN` | Wiki 同步与工单操作 | 只影响文档同步,不影响服务 | + +凭据只通过环境变量提供,不写入仓库。 + +## 第一次运行 + +### 1. 检查工作区与文档结构 + +```powershell +git status --short --branch +python dev_scripts/harness.py check --strict +python -m unittest discover -s tests -v +``` + +预期:分支干净、输出“DevHarness 检查通过”、所有测试通过。 + +### 2. 准备数据库(MVP-0 落地后) + +```powershell +mysql -u root -p -e "CREATE DATABASE chorus DEFAULT CHARSET utf8mb4;" +$env:CHORUS_DSN = "user:pass@tcp(127.0.0.1:3306)/chorus?parseTime=true&charset=utf8mb4" +migrate -path migrations -database "mysql://$env:CHORUS_DSN" up +``` + +预期:迁移版本前进,`generations` 等表已创建。 + +### 3. 配置一个上游(MVP-0 落地后) + +MVP-0 阶段管理端尚未接入,用一次性命令写入一个 provider 与 provider_model: + +```powershell +$env:CHORUS_MASTER_KEY = "<32 字节 base64 主密钥>" +go run ./cmd/seedprovider --name dev --base-url https://<上游> --api-type chat --model <模型名> +``` + +预期:命令输出新建的 provider_model id;数据库中 `api_key_enc` 是密文而非明文。 + +### 4. 启动服务并走通一次生成(MVP-0 落地后) + +```powershell +go run ./portal +``` + +预期日志包含监听端口与“worker started”。然后在浏览器打开 `http://127.0.0.1:8080`: + +1. 注册或用种子用户登录,进入双栏首页; +2. 切到「文生文」,输入一句提示词,点生成; +3. 左栏立即出现 loading 卡片,右栏可见状态; +4. 数秒后卡片原地替换为结果,轮询停止(响应中不再有 `hx-trigger`); +5. 切到「图生图」,上传 1~2 张图,标记主体图与参考图,再生成一次; +6. 数据库中该条 `generations` 的 `status=succeeded`,`rendered_prompt` 非空,`attempts` 有一条记录,`generation_outputs.thumb_path` 有值。 + +**第 6 步是 MVP-0 的验收口径**:页面看到结果只是表象,落库字段齐全才算流程真正跑通。 + +## 常用调试方式 + +| 症状 | 先看哪里 | +|---|---| +| 卡片一直转圈 | `generations.status`。仍是 `pending` 说明 worker 没取到任务;`running` 且 `lease_until` 已过期说明 worker 崩了或卡在上游 | +| 结果不对但没报错 | `generations.rendered_prompt`——多数“模型不听话”其实是提示词合成错了 | +| 生成失败 | `generations.attempts` 与 `error_message`,能看出是哪一家、什么错误、耗时多少 | +| 上游连不上 | 先确认不是 SSRF 拦截命中(日志中的拦截记录),再查 `base_url` 与密钥解密 | +| 页面样式丢失 | Tailwind CLI 是否在 watch,`portal/web/static/` 产物是否存在 | + +调试上游时用 mock HTTP 服务代替真实上游:`internal/core/provider` 的测试已提供可复用的 mock,能构造 429、超时、400 和内容拒绝四种响应。不要用真实额度反复试错。 + +单条 SQL 快速定位: + +```sql +SELECT id, kind, status, provider_model_id, attempt_count, error_code, latency_ms +FROM generations ORDER BY id DESC LIMIT 10; +``` + +## 完成修改前 + +按顺序执行,全部通过才算完成: + +```powershell +git status --short --branch +go build ./... +go vet ./... +go test ./... +python dev_scripts/harness.py check --strict +python -m unittest discover -s tests -v +python dev_scripts/harness.py sync --check +``` + +另外确认: + +- 改了表结构 → 有对应 `migrations/` 文件,且 up 与 down 各跑过一次; +- 改了 `retryable`、SSRF、加解密 → 有针对性单元测试; +- 改了页面结构、流程、状态、权限或异常处理 → 有 `prototypes/<工单号>/<版本>/index.html` 审核快照并已确认; +- 改了入口、命令、业务规则或排错方式 → 已评估对 `docs/` 的影响; +- 未执行或无法覆盖的验证 → 已写进工单,不得默认“应该没问题”。 diff --git a/docs/05-common-changes.md b/docs/05-common-changes.md new file mode 100644 index 0000000..f9769ad --- /dev/null +++ b/docs/05-common-changes.md @@ -0,0 +1,93 @@ +# 常见修改指南 + +本页面向接手简单维护的初级程序员。每一类修改都给出改哪里、验证什么、什么时候必须停下来。 + +## 风险分级 + +| 级别 | 例子 | 处理方式 | +|---|---|---| +| **低** | 页面显示文案、注释、文档措辞、日志文本 | 可以直接改,跑最小验证 | +| **中** | 现有页面的样式与布局微调、新增一个只读字段展示、增加一条日志 | 建单,按本页步骤改,跑完整验证 | +| **高** | 选路与 `retryable` 判定、熔断参数、SSRF 校验、密钥加解密、数据库迁移、队列租约、身份与权限、点数余额写入、删除数据或不可逆操作 | **停止**,交给 Agent 分析并等待人工确认方案 | + +判断不了级别时按高风险处理。“看起来只改一行”不是低风险的理由——`retryable` 的一行就能烧光所有上游配额。 + +## 修改用户端显示文案 + +1. 在 `portal/web/templates/` 中定位文案所在模板。 +2. 只改用户看到的文字,不要顺手改变量名、模板名、CSS 类名或字段名。 +3. 验证:`go build ./...`,启动 `go run ./portal`,在浏览器确认该页面文字与布局。 + +纯显示文案且不改变语义、流程、权限、状态、接口、布局或可访问性时不需要建单,但仍要做最小界面检查。改的是错误提示的**含义**、按钮的**行为暗示**或任何影响用户判断的措辞时,按中风险建单处理。 + +## 修改角色规则默认模板 + +角色规则不是代码常量,是数据:改 `prompt_templates` 表中对应 `kind` 的默认模板,或通过管理端页面修改。 + +- **不要**把角色规则写回 Go 代码里——三层覆盖(管理端模板 → 用户端可编辑框 → 每张图 role/note)是已确认的设计。 +- 改完用一次真实生成验证,检查 `generations.rendered_prompt` 是否如预期。 + +## 增加或调整一个上游 Provider + +1. 优先通过管理端页面配置(管理端接入前用种子命令,见 [本地开发与验证](04-local-development-and-verification.md))。 +2. 确认 `api_type` 选对:文生文用 `chat`,图生图用 `images_edits`。 +3. 保存后用「连通性测试」打一次真实请求验证;没有该按钮时手工提交一次生成。 +4. `api_key` 只填进配置,不要出现在工单、截图或日志里。 + +新增的是一种**上游协议形态**(而非一家新服务商)时属于高风险:需要在 `internal/core/provider/` 新增实现,必须建单并补齐 mock 测试。 + +## 修改 Wiki 文案 + +长期文档的事实来源是 Gitea Wiki,`docs/` 是只读镜像(Wiki 初始化完成后生效)。 + +```text +修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像 +``` + +```powershell +python dev_scripts/harness.py sync +python dev_scripts/harness.py sync --check +``` + +不要直接编辑带 `generated: true` 头的本地文件。Wiki 尚未初始化的阶段允许直接改 `docs/`,但必须在工单说明,并在初始化时把内容搬到 Wiki。 + +## 调整 Harness 检查 + +`dev_scripts/harness.py` 的 `check` 子命令负责校验必需文件、核心文档章节和工单模板字段。 + +1. 修改 `CORE_DOCUMENT_REQUIREMENTS`、`REQUIRED_FILES` 或模板字段列表。 +2. 在 `tests/` 中同时补充**成功用例和失败用例**——只有成功用例的检查等于没有检查。 +3. 验证: + + ```powershell + python -m unittest discover -s tests -v + python dev_scripts/harness.py check --strict + ``` + +新增核心文档时必须同时更新 `wiki-docs.json` 映射、`docs/README.md` 导航和 Harness 检查,三者缺一会导致同步或检查失败。 + +## 看懂 Agent 的修改 + +审查 Agent 提交时按这个顺序看: + +1. **范围**:改动文件是否都落在工单声明的范围内?出现工单没提到的文件要问清楚。 +2. **边界**:`internal/core` 里有没有混进 gin 或 go-admin 的 import?生成链路有没有碰点数? +3. **状态机**:`generations.status` 的写入是否只沿 `pending → running → succeeded | failed` 前进? +4. **失败路径**:`retryable` 的分支有没有写反?失败时 `attempts`、`error_code`、`error_message` 是否都落库? +5. **迁移**:表结构变更有没有对应 SQL 文件?down 能不能跑? +6. **测试**:新增逻辑有没有测试,测试是否覆盖失败分支而不只是happy path。 +7. **文档**:入口、命令、规则变了,`docs/` 是否同步评估过。 + +任何一条答不上来就退回让 Agent 解释,不要凭“测试过了”放行。 + +## 必须停止的情况 + +遇到以下情况停止修改,交给 Agent 分析并等待人工确认: + +- 需要改选路、熔断、`retryable`、SSRF、加解密、队列租约中的任意一项; +- 需要新增或修改数据库迁移; +- 需要触碰身份认证、权限或 API Key; +- 需要写入点数余额或 `point_ledger`; +- 需要删除数据、清理生成物或执行任何不可逆操作; +- 修改会改变已确认原型的页面结构、流程、状态、权限或异常处理; +- 你不确定这属于哪一级风险。 diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md new file mode 100644 index 0000000..39c6007 --- /dev/null +++ b/docs/06-troubleshooting.md @@ -0,0 +1,79 @@ +# 故障排查 + +## 排查顺序 + +遇到问题按下面的顺序走,**不要跳步,也不要直接重置工作区或覆盖本地文档**。 + +### 1. 确认环境与工作区 + +```powershell +git status --short --branch +go version +``` + +工作区有不属于当前任务的修改时先处理干净,否则后面的判断都不可靠。 + +### 2. 判断问题落在哪一段 + +chorus 的请求分成同步提交和异步执行两段,绝大多数问题只在其中一段: + +| 现象 | 大概率在哪段 | +|---|---| +| 点了生成没反应、报 4xx | 同步段(`portal/handler`) | +| 卡片出现了但一直转圈 | 异步段(queue / worker) | +| 结果出来了但内容不对 | 提示词合成(`generate/prompt.go`) | +| 结果失败并有错误码 | provider 调用或选路 | + +### 3. 查库,不要靠猜 + +```sql +SELECT id, kind, status, provider_model_id, attempt_count, + error_code, error_message, lease_until, latency_ms, created_at, finished_at +FROM generations ORDER BY id DESC LIMIT 10; +``` + +| 观察到 | 含义与下一步 | +|---|---| +| `status=pending` 且长时间不动 | worker 没起来或没连上库;看 portal 启动日志有没有 “worker started” | +| `status=running` 且 `lease_until` 已过期 | worker 崩了或卡在上游;查上游超时设置与进程日志 | +| `status=failed`,`error_code` 是 429/5xx | 上游限流或故障;看 `attempts` 是否按预期换了下一家 | +| `status=failed`,`error_code` 是 400/401 | 参数或密钥问题;**不应该**发生故障转移,若 `attempts` 有多条说明 `retryable` 写反了 | +| `status=succeeded` 但页面无图 | 查 `generation_outputs` 的 `path` 与 `thumb_path`,再查存储目录权限 | +| `rendered_prompt` 与预期不符 | 角色规则模板或三层覆盖顺序的问题,不是模型的问题 | + +### 4. 隔离上游 + +用 mock 上游重跑一次(`internal/core/provider` 的测试 mock 可构造 429、超时、400、内容拒绝)。mock 下正常、真实上游异常,问题在配置或网络;mock 下同样失败,问题在代码。 + +不要拿真实额度反复试错。 + +### 5. 检查出站是否被 SSRF 拦截 + +`base_url` 指向私网、回环地址或解析结果落在私网时会被 `internal/core/security` 拦截,表现是“连不上但网络看起来正常”。日志中有拦截记录。**拦截生效是正确行为**,要改的是配置,不是拦截规则。 + +### 6. 文档与 Harness 相关问题 + +| 症状 | 处理 | +|---|---| +| `harness.py check --strict` 报缺少章节 | 按提示补回该标题,标题文字必须完全一致 | +| `harness.py check --strict` 报“项目档案仍有未填写内容” | `docs/00-project-profile.md` 里还有 `<填写` 占位符 | +| `sync --check` 报不一致 | 先确认是 Wiki 更新了还是本地被手工改了;本地被改过要恢复后重新同步 | +| `sync` 中止并提示存在未提交修改 | 已映射镜像有未提交改动,先提交或还原 | +| Wiki 页面读不到、没有 revision | 停止初始化或同步,等 Gitea 恢复后从首个失败页面继续 | + +### 7. 仍未定位 + +在工单中记录:最小复现步骤、`generations` 的相关行、`attempts` 内容、相关日志片段(**去掉密钥**)、已排除的可能性。然后交给 Agent 分析。 + +## 必须停止的情况 + +以下情况立即停止自行处理,交给 Agent 分析并等待人工确认: + +- 需要修改 `retryable`、熔断参数、选路逻辑或 SSRF 规则; +- 需要修改数据库迁移,或需要手工改生产/共享库中的数据; +- 需要触碰密钥、加解密、身份认证、权限或 API Key; +- 需要写入点数余额或 `point_ledger`; +- 需要删除生成物、清理表数据或任何不可逆操作; +- 需要绕过失败重跑任务(可能造成重复计费或重复生成); +- 日志或报错中出现疑似泄露的密钥、个人数据或生产数据——先停止传播,不要把原文贴进工单或 Wiki; +- 你已经在同一处试了两次仍不确定根因。 diff --git a/docs/07-new-project-documentation-setup.md b/docs/07-new-project-documentation-setup.md new file mode 100644 index 0000000..45e07c0 --- /dev/null +++ b/docs/07-new-project-documentation-setup.md @@ -0,0 +1,202 @@ +# 新项目文档初始化 + +## 本页用途 + +从 DevHarness 创建新项目时,指导 Claude/Codex Agent快速建立可供初级程序员阅读的项目文档。初始化只生成可靠的第一版,不允许 Agent 臆造业务规则、凭据、部署环境或生产数据。 + +## 初始化顺序 + +### 1. 建立项目边界 + +由项目负责人确认: + +- 项目名称和一句话目标; +- 用户和主要使用场景; +- 技术栈和支持环境; +- Gitea 仓库、默认分支和维护者; +- 安全、权限、数据和发布红线。 + +把项目专用红线写入根目录或子目录 `AGENTS.md`。 + +#### 需求总览启用条件 + +从模板创建项目时保留 Product-Requirements-Overview 这一核心页面。仅有探索性想法时可以只记录已确认目标和待确认项;形成 MVP、长期需求超过少量工单或开始制作原型时,必须建立并持续维护需求索引,把需求领域、状态、主题 Wiki、工单、原型和验收入口关联起来。不要复制完整工单或聊天记录。 + +### 2. 选择建设基线 + +确定技术方案前,优先评估是否存在功能和架构匹配、持续维护、许可证兼容且工程流程完善的开源项目。这里要求的是“先评估”,不是强制采用开源项目,也不能只根据知名度、Star 数量或演示效果决定。 + +至少检查: + +- 核心功能、架构和支持平台是否匹配,哪些能力可以直接保留; +- 许可证是否允许预期的使用、修改、分发和商业模式;不确定时交由负责人或法律专业人员确认; +- 最近维护活跃度、发布频率、Issue 处理和社区或维护团队的持续性; +- 已知安全问题、依赖健康度、供应链风险和安全响应方式; +- 自动化测试、CI、发布、升级、回退和文档是否足以支持长期维护; +- 定制、学习、迁移和后续跟踪上游的总成本是否低于从零开发; +- 是否能够固定上游仓库和基线版本,并建立合并上游更新、兼容验证和退出方案。 + +满足适配、许可证、安全、维护和总成本条件时,优先在该基线上二次开发。不存在合适基线,或引入后会增加不可接受的许可证、安全、架构或维护风险时,可以从零开发,但必须记录排除候选项目和选择从零开发的主要原因。 + +采用开源基线时,在 Project-Profile 的“技术栈与运行环境”记录上游项目名称、仓库地址、基线版本或提交、许可证、保留能力、定制范围和上游升级策略。尚未确认的候选和取舍先写入首个技术方案工单,不得把假设写成项目事实。 + +#### 判断案例 + +以下案例用于说明判断方式,不代表必须选择某种技术或具体开源项目。 + +##### 案例一:适合基于成熟项目二次开发 + +计划开发企业内部管理系统。候选项目已经具备用户、权限、审计日志、基础数据管理和自动化测试;功能与目标架构基本匹配,许可证允许预期使用,项目持续维护,发布与升级流程完整,预计只需修改业务模块和界面。 + +- 结论:优先基于该项目二次开发。 +- 原因:可以减少通用功能的开发和验证成本,定制范围可控。 +- 记录:上游仓库、基线版本、许可证、保留功能、定制模块和上游升级方式。 + +##### 案例二:项目成熟但许可证不兼容 + +候选项目功能完整、维护活跃、文档充分,但许可证与当前产品的闭源分发、商业模式或交付条件不兼容。 + +- 结论:不采用该项目作为建设基线。 +- 原因:技术成熟度不能消除许可证风险;不确定结论必须交由负责人或法律专业人员确认。 +- 记录:候选项目、许可证限制、确认人员和排除原因。 + +##### 案例三:功能相似但改造成本过高 + +候选项目表面上覆盖大部分功能,但数据模型、权限体系和部署结构与目标项目差异很大,需要大量删除模块、重写主要接口,并长期维护上游冲突。 + +- 结论:不直接基于完整项目二次开发,可以评估只复用合适的组件或设计思路。 +- 原因:二次开发的总成本、理解成本和长期维护风险已经高于自主实现核心业务。 +- 记录:主要结构差异、改造估算、长期维护风险和最终选择。 + +##### 案例四:只复用成熟框架或组件 + +没有功能高度匹配的完整开源产品,但存在成熟的应用框架、更新组件、日志组件或通信库。 + +- 结论:从零开发业务功能,同时复用经过评估的成熟框架或组件。 +- 原因:复用基础能力不等于必须采用完整产品,可以避免被不匹配的业务架构绑定。 +- 记录:每个依赖的用途、版本、许可证、安全边界、升级方式和可替换方案。 + +每个案例的实际评估都必须记录候选项目、判断依据、最终选择、未采用原因,以及升级或退出方式。 + +### 3. 识别子项目与交付单元 + +先判断仓库中有几个应用、服务、客户端、库或其他可独立交付的部分。对每个部分确认: + +- 职责和目录边界; +- 技术栈、依赖和支持环境; +- 构建、测试和运行命令; +- 是否拥有独立版本号和发布方式; +- 适用的根目录或子目录 `AGENTS.md`; +- 与其他子项目共享的接口、数据或业务流程; +- 共享契约的唯一事实来源和兼容要求。 + +把结果写入 Project-Profile 的“子项目与交付单元”。单应用项目只填写一个交付单元;多应用单仓库为规则不同的目录增加子目录 `AGENTS.md`,但不因为技术栈不同自动拆仓,也不强制统一版本和发布周期。 + +### 4. 建立 Gitea + +创建远端仓库并完成允许的初始引导提交,开启工单和 Wiki;必须先有远端仓库,才能填写该仓库的线上 Wiki。配置项目已有的 Gitea MCP 和安全凭据;优先使用 MCP,MCP 不可用或不支持所需写操作时才回退到 Gitea API,并在初始化工单记录原因。凭据只通过环境或 MCP 安全配置提供。 + +任何产品功能开发在引导提交后都必须先有单元任务工单,并且必须通过第 8 步的线上 Wiki 初始化门禁。 + +### 5. 修改镜像配置 + +把 `wiki-docs.json` 中的地址、owner 和 repository 改成新项目;只保留核心主题映射,任务归档不逐页登记。 + +确认当前目录确实是新项目副本、且 DevHarness 历史归档快照不需要保留后,可以移除对应 `docs/task/` 文件。不要在原 DevHarness 仓库或已有业务项目中执行这项清理。 + +不要把 PAT 写入配置。 + +### 6. Agent 检查项目事实 + +Agent 只读检查: + +- README、配置和依赖文件; +- 启动入口; +- 主要模块和目录规则; +- 测试、格式和静态检查命令; +- 日志、示例配置和测试数据; +- 已存在的接口、数据模型和状态。 + +区分“代码中确认的事实”“负责人确认的业务规则”和“仍待确认的假设”。 + +### 7. 确定交付对象和文档 + +由项目负责人确认哪些岗位或客户会实际使用、部署、管理、支持、集成或验收产品,并为每类对象确定: + +- 需要完成的工作; +- 所需文档类型; +- 文档可见范围; +- 适用版本、负责人和验证人; +- 不得对外披露的内部信息。 + +按照[交付文档指南](Delivery-Documentation-Guide.-)选择文档,使用[岗位文档模板](Audience-Document-Template.-)按需创建。没有明确读者的文档不创建,不预建空白的用户手册、管理员手册或运维手册。 + +### 8. 先创建线上 Wiki + +#### 在线创建与回读门禁 + +1. 使用配置好的 Gitea MCP 查询目标仓库的 Wiki 页面列表;MCP 不可用时使用 Gitea API,并记录回退原因。 +2. 如果 `Home` 不存在,先创建 `Home`。创建后立即在线回读正文并记录 revision;`Home` 可读取后才能继续。 +3. 依照 `wiki-docs.json` 逐页创建或更新其他核心页面。每页写入后在线回读正文,记录页面名和 revision。 +4. 本地 `docs/` 是模板或 Wiki 镜像;本地文件存在、标题完整或 `harness.py check --strict` 通过,都不能单独证明线上 Wiki 已初始化。 +5. 页面缺失、回读失败或没有 revision 时停止初始化,不得开始产品代码;Gitea 恢复后从首个失败页面继续。 + +至少创建或填写: + +1. Home; +2. Project-Profile; +3. Product-Requirements-Overview; +4. Architecture-and-Code-Map; +5. Business-Rules-and-Glossary; +6. Local-Development-and-Verification; +7. Common-Changes; +8. Troubleshooting; +9. Development-Workflow; +10. Delivery-Documentation-Guide; +11. Audience-Document-Template; +12. Task-Archive-Template。 + +Home 给出建议阅读顺序;每个命令必须有预期结果;代码地图必须指出入口和测试位置。具体岗位文档仅按第 7 步确认的受众创建。 + +部署页按需创建,不属于必需核心页面:项目负责人确认存在需要部署的常驻服务时,复制[部署文档模板](Deployment-Template.-)在本项目 Wiki 建立 `Deployment-and-Operations` 页面,并在本项目 `wiki-docs.json` 增加映射(建议镜像到 `docs/10-deployment-and-operations.md`);确认没有常驻服务时,在初始化工单记录原因,不创建该页面。 + +### 9. 人工确认 + +项目负责人至少确认: + +- 一句话目标和业务术语; +- 关键业务规则和状态; +- 权限、安全和数据边界; +- 真实运行、测试和部署命令; +- 本项目是否有需要部署的常驻服务; +- 哪些修改属于高风险; +- 交付对象、文档可见范围和外部信息边界。 + +### 10. 导出镜像并检查 + +```powershell +python dev_scripts/harness.py sync --verify +python -m unittest discover -s tests -v +``` + +只有线上 Wiki 确认后才导出核心 `docs/`。任务归档默认不导出;用户明确要求时再运行 `python dev_scripts/harness.py export` 或加 `--all`。旧项目的任务归档快照不能带入新项目历史。 + +`harness.py sync --check` 会在线读取全部显式映射页面;任一页面不存在、无法读取或 revision 与镜像不一致时,初始化不通过。只有上述命令全部成功后才允许开始产品代码。 + +## 完成标准 + +初级程序员应能仅依靠 Home 和链接页面回答: + +- 项目解决什么问题; +- 当前有哪些长期需求、状态如何,详细规则、工单、原型和验收入口在哪里; +- 怎样启动和运行测试; +- 常用功能从哪个目录和入口开始读; +- 一个简单修改通常要改哪里、验证什么; +- 哪些情况必须停止并交给 Agent 或负责人; +- 项目采用哪个建设基线,为什么适合二次开发,或者为什么选择从零开发; +- 采用开源基线时,上游仓库、基线版本、许可证、定制范围和升级策略是什么; +- 项目包含哪些子项目和独立交付单元,各自怎样构建、测试和发布; +- 跨子项目共享什么接口或契约,其唯一事实来源在哪里; +- 项目需要向哪些岗位交付什么文档,以及哪些内容不能对外提供。 + +回答不了的问题应继续补充主题文档,而不是堆入任务归档。 diff --git a/docs/08-existing-project-adoption.md b/docs/08-existing-project-adoption.md new file mode 100644 index 0000000..93fffb9 --- /dev/null +++ b/docs/08-existing-project-adoption.md @@ -0,0 +1,227 @@ +# 已有项目接入 DevHarness 指南 + +## 本页用途 + +本页用于把 DevHarness 的文档模板和开发流程增量接入已经存在的项目。已有项目通常已经有代码、规则、文档、工单、Wiki、Git 历史和未完成工作,因此接入目标是补齐必要能力,不是把项目重置成 DevHarness 模板副本。 + +接入必须先只读盘点、确认差异方案,再建立单元任务工单实施。未经确认不得覆盖、删除、重命名或批量迁移已有内容。 + +## 与新项目初始化的区别 + +| 场景 | 新项目初始化 | 已有项目接入 | +|---|---|---| +| 项目事实 | 从代码骨架和负责人确认开始建立 | 优先保留并核对已有事实 | +| 规则文件 | 可以从模板建立第一版 | 必须合并已有规则,不能直接覆盖 | +| 文档 | 创建核心主题页 | 逐页判断保留、迁移、合并或停止维护 | +| 工单和 Wiki | 新建并开始使用 | 先检查已有工单、Wiki 和状态体系 | +| Git 历史 | 允许一次引导提交 | 保留全部历史,不使用引导提交例外 | +| 任务归档 | 从新项目任务开始 | 不复制 DevHarness 或其他项目的历史归档 | +| 接入方式 | 一次建立最小骨架 | 分阶段增量接入并逐步验收 | + +从模板创建全新仓库时使用[新项目文档初始化](New-Project-Documentation-Setup.-);项目已有业务提交、用户或维护历史时使用本页。 + +## 接入前只读盘点 + +Agent 在提出方案前只读检查: + +- 根目录和相关子目录中的 `AGENTS.md`、`CLAUDE.md` 及其他 Agent 规则; +- README、现有 `docs/`、Wiki 页面、工单模板和任务状态; +- Git 默认分支、远端、提交历史、未提交修改和忽略规则; +- 语言、框架、依赖、启动入口、主要模块和目录职责; +- 格式检查、静态检查、单元测试和必要集成测试命令; +- 配置、日志、接口、数据模型、权限、安全、部署和发布边界; +- 已完成、进行中、阻塞和待验收任务; +- 现有长期文档的事实来源、负责人和更新方式。 + +输出时区分: + +1. 从代码、配置或现有系统确认的事实; +2. 项目负责人确认的业务规则; +3. 尚待确认的假设; +4. DevHarness 与现有规则的冲突; +5. 与接入无关、必须保留的工作区改动。 + +只读盘点不授权修改文件、创建 Wiki、迁移文档或改变工单状态。 + +## 已有内容保护原则 + +- 保留 Git 历史、分支、标签和当前任务状态。 +- 保留已有 `AGENTS.md`、README、规则和项目专用红线;DevHarness 规则按冲突结果增量合并。 +- 保留与接入无关的未提交改动,不重置、不覆盖、不混入提交。 +- 不复制 DevHarness 的 `docs/task/`、任务归档映射和历史工单。 +- 不因采用 Wiki-first 就立即删除原本地文档;先逐页确认事实来源和迁移状态。 +- 不把模板占位值当成项目事实,不臆造技术栈、命令、业务规则、凭据或环境。 +- 不把密码、令牌、Cookie、私钥、个人数据或生产数据带入工单、Wiki和镜像。 +- 页面删除、重命名、历史清理和事实来源切换必须单独确认。 + +## 多应用单仓库判断 + +一个 Git 仓库可以包含多个技术栈不同、能够独立构建和发布的应用或终端。技术栈不同本身不是拆仓理由;先把每个子项目和交付单元记录到 Project-Profile,再根据实际协作边界判断。 + +### 适合继续单仓库 + +- 多个应用共同完成一条产品或业务链路; +- 由同一团队维护,仓库权限基本一致; +- 接口变更需要在一个工单中同步修改或验证多端; +- 共享契约、业务规则和任务归档放在一起更容易保持一致; +- 仓库体积、测试时间和工具性能尚未明显影响开发; +- 初级维护者和 Agent 能通过目录、子目录 `AGENTS.md` 和文档入口清楚定位。 + +### 可以考虑拆仓 + +- 长期由不同团队独立负责并需要不同访问权限; +- 发布周期、版本策略和验收负责人已经完全独立; +- 某个应用被多个产品复用或需要单独对外提供; +- 仓库体积、检出、索引或测试耗时已经持续影响效率; +- 共享接口已经版本化、兼容周期明确,并有跨仓契约测试; +- 跨应用任务很少,拆仓后的协调成本低于继续共仓。 + +不满足这些条件时,优先保持单仓库并完善边界,不为了目录整洁或技术栈不同而拆仓。 + +### 保持单仓库时的最小规则 + +- 根目录 `AGENTS.md` 只放共同流程、安全和跨项目规则,技术栈专用规则写入子目录 `AGENTS.md`。 +- 每个交付单元拥有自己的构建、测试、版本和发布方式,不强制统一版本。 +- 单元任务必须声明只影响哪个子项目、是否跨子项目、是否修改共享接口,以及各端需要执行的验证。 +- 共享接口或契约只能指定一个事实来源;其他文档引用它,不复制一个“差不多”的版本。 +- 跨子项目契约变更在同一工单中更新事实来源,并验证所有受影响端。 +- 拆仓属于事实来源、任务和发布边界变化,必须另建工单、确认迁移和回退方案后实施。 + +## 增量接入顺序 + +### 1. 确认差异方案 + +根据盘点结果列出目标、非目标、复用项、改写项、冲突项、影响范围、风险、回退、验证和文档影响。方案得到用户明确确认前不实施。 + +### 2. 建立单元任务工单 + +使用目标项目的 Gitea 建立接入工单,记录原始需求、范围、依赖、方案和验收标准。目标项目没有可用 Gitea 时,先提交完整工单草稿并说明阻塞,不默认绕过。 + +### 3. 接入共同规则和工单流程 + +优先增量合并根规则、Claude 入口和单元任务模板。项目专用安全、业务和目录规则继续有效;冲突时由负责人决定最终表述。 + +### 4. 确定长期文档事实来源 + +为每份已有文档标记: + +- 保留在 Git:与特定代码版本强绑定; +- 迁移到 Wiki:长期架构、业务规则、开发规范或操作说明; +- 合并:内容重复但各有有效事实; +- 暂不迁移:事实未确认或当前不影响接入; +- 停止维护:必须由负责人确认,不能由 Agent 自行删除。 + +切换到 Wiki-first 的页面必须先在线上创建或更新、读取确认,再建立 `wiki-docs.json` 映射并导出本地镜像。避免 Wiki 和手写本地文档长期形成双事实源。 + +### 5. 接入 Harness 工具 + +仅复制当前项目实际需要的 `dev_scripts/` 工具、配置和测试。业务脚本使用独立目录。根据目标项目调整核心页面、路径、命令和结构检查,不照搬 DevHarness 项目值。 + +### 6. 分阶段验证 + +先验证工单和规则入口,再验证 Wiki 映射,最后启用严格检查。每阶段采用“执行 → 首个真实错误 → 最小修复 → 继续”的闭环,不用一次接入全部旧文档。 + +### 7. 提交和验收 + +提交只包含当前接入工单相关文件。记录测试、未验证部分、Wiki revision 和提交哈希,创建任务归档并保持工单“待验收”,等待用户明确验收后再关闭。 + +## 后续升级 + +已接入的项目必须以 Project-Profile 中记录的 DevHarness 来源和当前基线为起点升级,不得重新复制整个模板,也不得用“最新版本”代替可复现的目标提交。 + +### 升级步骤 + +1. 读取目标项目的 Project-Profile,确认 DevHarness 来源仓库、当前基线完整提交、最后升级日期和项目适配说明;字段缺失时先补齐可验证事实,无法确认则停止。 +2. 选择一个明确、已审阅的 DevHarness 目标提交,记录旧基线和新基线。先比较两个上游提交之间的变化,再判断这些变化如何作用于目标项目。 +3. 只读比较与 Harness 有关的 `AGENTS.md`、`CLAUDE.md`、工单模板、`dev_scripts/`、Harness 测试和核心 Wiki 结构,把差异分为“直接采用、按项目改写、冲突待确认、不采用”。不得把 DevHarness 的项目事实、工单或任务归档带入目标项目。 +4. 在目标项目建立单元任务工单,写明升级范围、差异分类、项目专用规则、风险、回退、验证和文档影响。会改变产品行为的内容必须拆成独立任务。 +5. 按工单最小合并,保留目标项目更具体的业务、安全、权限和目录规则,以及 Git 历史和无关工作区修改。无法判断哪一方规则有效时停止并等待负责人确认。 +6. 长期文档先更新目标项目 Wiki,读取确认后再同步目标项目的核心 `docs/` 镜像;不得用 DevHarness 的本地镜像覆盖目标项目文档。 +7. 执行目标项目规定的必要检查和受影响测试,提交并回写证据。工单保持“待验收”。 +8. 用户验收通过后,确认目标项目 Project-Profile 已记录新 DevHarness 基线完整提交和升级日期,再关闭工单。升级失败或回退时保留旧基线。 + +### 升级停止条件 + +除本页已有的冲突停止条件外,来源仓库与记录不一致、旧基线不存在、目标提交未明确、差异跨越过大而无法可靠分类,或升级需要覆盖项目专用安全规则时,都必须停止并请求确认。可以把升级拆成多个单元任务,但每个任务都要声明最终采用的同一目标基线。 + +### 可复制升级指令 + +```text +请把当前项目从 Project-Profile 记录的 DevHarness 基线升级到 +<DevHarness 目标完整提交哈希>。 + +先只读比较来源仓库中“旧基线..目标基线”的 Harness 变化和当前项目 +适配,列出直接采用、按项目改写、冲突待确认和不采用的内容,以及 +风险、回退、验证和文档影响。不要覆盖项目专用规则、业务文档、Git +历史或无关改动,不复制 DevHarness 工单和任务归档。方案确认后在 +当前项目建单并实施;长期文档先改当前项目 Wiki,再同步本地镜像。 +工单保持待验收,验收通过后确认 Project-Profile 已记录新基线。 +``` + +路径和目标完整提交哈希必须替换为真实值;目标提交未明确时只分析,不实施。 + +## 冲突处理和停止条件 + +出现以下情况时停止实施并请求负责人确认: + +- 现有规则与 DevHarness 的安全、权限、事实来源或验收规则冲突; +- 无法判断某份文档应该保留、迁移、合并还是停止维护; +- 需要删除、重命名 Wiki 页面、覆盖已有文件或清理历史归档; +- 需要改变接口、数据库、权限、部署、发布或其他产品行为; +- 工作区存在可能与接入文件重叠的未知修改; +- Gitea、Wiki、凭据或远端权限不可用; +- 真实命令、环境或业务规则无法从证据或负责人确认。 + +相邻问题最多提示或另建工单,不混入接入任务。 + +## 可复制 Agent 指令 + +### 只分析 + +```text +请把 <DevHarness 路径> 的文档模板和开发流程接入当前已有项目。 + +先只分析,不修改文件、工单或 Wiki: + +1. 阅读 DevHarness 的 AGENTS.md、README.md、项目档案、开发工作流、 + 新项目文档初始化和已有项目接入指南。 +2. 阅读当前项目已有的 Agent 规则、README、docs、Gitea 工单模板、 + Wiki 配置、代码入口、测试命令和目录结构。 +3. 列出已有规则、文档、任务状态、Git 历史和未提交改动。 +4. 对比后列出可复用项、必须改写项、冲突项、旧文档处理方式、 + 最小接入范围、风险、回退、验证和文档影响。 +5. 不复制 DevHarness 任务归档,不覆盖、删除或重命名已有内容, + 不把模板占位值当成项目事实。 +6. 输出方案后停止,等待我确认。 +``` + +### 方案确认后实施 + +```text +按照已确认方案建工单并做。 + +严格按工单范围增量接入 DevHarness,保留当前项目已有规则、历史、 +任务状态和无关改动。长期文档先更新 Gitea Wiki,读取确认后再导出 +本地 docs 镜像。执行必要测试,提交实现和任务归档,然后把工单保持 +为“待验收”;未经我明确验收,不关闭工单。 +``` + +路径、仓库地址和项目名称必须替换为当前环境的真实值。第二段指令只有在第一段方案已经明确确认后使用。 + +## 最小验收清单 + +- [ ] 已盘点规则、文档、任务、Git 历史和未提交改动。 +- [ ] 已识别所有子项目和独立交付单元。 +- [ ] 跨子项目共享契约已经指定唯一事实来源。 +- [ ] 已明确复用、改写、冲突和暂不处理内容。 +- [ ] 已保留项目专用规则、历史和无关改动。 +- [ ] 未复制 DevHarness 历史归档或模板项目事实。 +- [ ] 已为每类长期文档明确事实来源和迁移状态。 +- [ ] Wiki-first 页面已经读取确认并具有显式镜像映射。 +- [ ] Harness 检查已按目标项目调整并通过。 +- [ ] 必要测试、未验证部分、提交和归档证据已记录。 +- [ ] 工单处于待验收,未提前关闭。 + +## 回退原则 + +接入应拆成可回退的小提交。普通回退恢复本次新增或修改的规则、配置、检查和镜像映射,不触碰原有业务提交。Wiki 页面删除、重命名、历史清理或事实来源反向切换不是普通回退,必须另行建单并等待确认。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md new file mode 100644 index 0000000..4860df2 --- /dev/null +++ b/docs/09-product-requirements-overview.md @@ -0,0 +1,221 @@ +# 产品需求总览 + +## 本页用途 + +本页是产品需求的统一导航入口,帮助项目负责人、Agent 和初级维护者快速回答:项目有哪些长期需求、当前状态是什么、详细规则和实施证据在哪里。 + +本页只保存稳定摘要、状态和链接,不复制完整工单、主题文档或聊天内容。需求详情仍在对应事实来源维护,避免形成两份不一致的正式需求。 + +## 事实来源边界 + +| 信息 | 唯一事实来源 | 本页怎样记录 | +|---|---|---| +| 项目目标、用户、范围和技术基线 | Project-Profile | 链接和一句话摘要 | +| 长期功能需求、业务规则和系统边界 | 对应 Wiki 主题页 | 需求领域和详情链接 | +| 单次实现范围、变化和验收标准 | Gitea 单元任务工单 | 工单编号和当前状态 | +| 已完成方案、测试和遗留问题 | Wiki 任务归档 | 验收入口 | +| 与版本绑定的原型、设计图或交互稿 | Git 中的 `design/` 或 `prototypes/` | 路径、版本和确认状态 | +| 外部原型 | 原型平台 | 链接、版本或确认日期;重要版本保留可追溯快照 | + +不保存完整聊天记录、Agent 内部推理、密码、令牌、个人数据或生产数据。 + +## 当前需求索引 + +chorus 尚未建立 Gitea 工单,下表的“实施工单”列在建单后填入编号。全部需求当前状态均为**待确认或已确认但未开工**。 + +| 需求领域 | 用户与场景 | 状态 | 版本 / MVP | 详细说明 | 实施工单 | 原型 | 验收入口 | +|---|---|---|---|---|---|---|---| +| 核心生成域与单上游打通 | 用户提交提示词(+原图)得到结果;先跑通再谈可用性 | 已确认,未开工 | MVP-0 | [架构与代码地图](02-architecture-and-code-map.md)、[业务规则](03-business-rules-and-glossary.md) | 待建单 | 无(非 UI 需求,用架构与数据设计代替) | 待建单 | +| 用户端生成页(双栏 + 异步卡片) | 终端用户在浏览器完成一次生成并看到结果 | 已确认,未开工 | MVP-0 | 本页“用户端布局”一节、[架构与代码地图](02-architecture-and-code-map.md) | 待建单 | **待制作**:新页面,需 HTML 审核快照并经确认 | 待建单 | +| 多 Provider 路由池与故障转移 | 单一上游失效时服务仍可用 | 已确认,未开工 | MVP-1 | [业务规则](03-business-rules-and-glossary.md) 选路与失败处理 | 待建单 | 无(非 UI 需求) | 待建单 | +| 管理端配置与记录查看 | 运营配置 Provider/Model、勾选组池、查看生成记录 | 已确认,未开工 | MVP-1 | 本页“管理端页面”一节 | 待建单 | 代码生成器页面无需原型;三个定制页需低保真图 | 待建单 | +| 图片角色规则可编辑 | 用户按自己的场景描述“第几张图是什么” | 已确认,未开工 | MVP-1 | [业务规则](03-business-rules-and-glossary.md) 提示词与图片角色 | 待建单 | 属现有页面新增组件,需组件状态说明 | 待建单 | +| 点数只读展示与管理端发放 | 用户看到余额;管理员发放并留痕 | 已确认,未开工 | MVP-2 | [业务规则](03-business-rules-and-glossary.md) 点数与限流 | 待建单 | 沿用现有设计体系 | 待建单 | +| API Key 与程序化调用 | 外部程序按 API Key 调用生成 | 已确认,未开工 | MVP-2 | 本页“程序化调用”一节 | 待建单 | 无(接口需求,用 API 设计代替) | 待建单 | +| 限流与生成物保留期 | 防止单人打满上游配额;控制磁盘增长 | 待确认(阈值与保留期未定) | MVP-2 | [业务规则](03-business-rules-and-glossary.md)“新项目需要补充什么” | 待建单 | 无 | 待建单 | + +### MVP 分期 + +分期原则:**先让一条数据从提交走到结果全程可运行**,再补可用性,最后补运营与治理能力。前一期没跑通不进入下一期。 + +#### MVP-0:跑通全流程的最小闭环(当前阶段) + +范围: + +1. 迁移建表:`users`、`providers`、`provider_models`、`generations`、`generation_inputs`、`generation_outputs`; +2. `internal/core`:`chat` 与 `images_edits` 两种 provider 实现、AES-GCM 密钥加解密、SSRF 拦截、本地存储、缩略图; +3. 队列:`SELECT … FOR UPDATE SKIP LOCKED` 取任务 + 租约 + 失败置错; +4. 提示词合成:角色规则 + 每张图 role/note + 用户提示词 → `rendered_prompt` 落库; +5. `portal`:登录、双栏首页、提交生成、HTMX 每 2 秒轮询卡片、成品卡片不带 `hx-trigger` 从而自动停止轮询; +6. worker 跑在 portal 进程内,支持优雅退出; +7. 上游配置用一次性种子命令写入,管理端不接入。 + +非目标(属于后续期,不是被取消):加权选路、熔断、故障转移、`images` 与 `gemini` 两种 api_type、管理端全部页面、API Key、点数、限流、无限滚动分页、清理任务。 + +验收口径(缺一不算通过): + +- [ ] 文生文与图生图各成功一次,浏览器可见结果; +- [ ] 对应 `generations` 行的 `status=succeeded`、`rendered_prompt` 非空、`attempts` 有记录、`latency_ms` 有值; +- [ ] 图生图结果的 `generation_outputs.thumb_path` 有值且缩略图可访问; +- [ ] 上游返回 400 时该条记录 `status=failed`、`error_code`/`error_message` 落库,且**没有**发生故障转移; +- [ ] 上游超时时租约到期后任务可被重新取走,`attempt_count` 递增; +- [ ] provider 的 `api_key` 在库中是密文; +- [ ] `base_url` 指向私网时被 SSRF 拦截并有日志; +- [ ] `go build ./...`、`go vet ./...`、`go test ./...` 全部通过。 + +#### MVP-1:可用性与运营入口 + +路由池与加权选路、gobreaker 熔断与故障转移(含 `retryable` 判定的完整测试)、`images` 与 `gemini` 实现、go-admin 接入与代码生成器 CRUD、三个定制页(路由池编排、Provider 健康看板、生成记录详情)、Provider 连通性测试按钮、角色规则模板三层覆盖的管理端层、生成列表无限滚动。 + +#### MVP-2:治理与开放能力 + +API Key 管理与 openapi 端点、点数只读展示与管理端发放(含 `point_ledger` 事务)、用户维度令牌桶与并发上限、按 provider 分池限流、配置审计日志、生成物保留期清理任务、移动端抽屉布局。 + +### 已锁定决策 + +以下决策已确认,不在实施阶段重新讨论;要改必须先建 Epic: + +1. 用 Go 重写,不做 Django 代码抽取;带走的是设计(provider 抽象、任务租约、SSRF 校验、密钥加密),不是代码。 +2. 管理端用 go-admin + go-admin-ui。 +3. 用户端不用前端框架:html/template 服务端渲染 + HTMX + Alpine.js + Tailwind。 +4. 点数只做展示:无扣费、无退款、无余额校验、无配额;余额来自自有表,管理端发放。 +5. 图片角色规则从硬编码改为用户端可编辑提交,默认值来自管理端模板。 +6. 上游默认只支持 OpenAI 规范;可勾选多个 provider 的 model 做均衡 + 故障转移。 +7. 不引入 Redis / MQ,任务队列用 MySQL 8.0 `SELECT … FOR UPDATE SKIP LOCKED`。 + +### 用户端布局 + +参考 ChatGPT 的双栏结构: + +```text +┌──────────────────────────────────────────────────────┐ +│ Logo 生成 记录 [点数] [API Key] [头像▾] │ ← 导航栏 +├──────────────┬───────────────────────────────────────┤ +│ 预览图列表 │ 结果展示区 │ +│ ┌──┐┌──┐ │ (大图 / 生成的文本,可下载、可复制) │ +│ └──┘└──┘ │ │ +│ ┌──┐┌──┐ ├───────────────────────────────────────┤ +│ └──┘└──┘ │ [图生图 | 文生文] 切换 │ +│ 无限滚动 │ [+ 上传原图,缩略图条,可排序/标角色] │ +│ │ ┌─────────────────────────┐ ┌──────┐ │ +│ │ │ 提示词输入框(多行) │ │ 生成 │ │ +│ │ └─────────────────────────┘ └──────┘ │ +└──────────────┴───────────────────────────────────────┘ +``` + +- 左侧是本人已生成结果的预览列表,点击查看大图;右侧是结果展示 + 类型切换 + 上传区 + 提示词 + 生成按钮。 +- 导航栏含用户信息、点数余额(只读)、API Key 管理入口。 +- 生成为异步:提交后左侧插入 loading 占位卡片,完成后原地替换为缩略图。 +- 每张上传图带角色下拉(主体图 / 参考图,默认第一张主体、其余参考),可拖拽排序,可选填一句备注。 +- 移动端左栏折叠为抽屉(MVP-2)。 + +三个核心交互的实现方式:无限滚动用 `hx-trigger="revealed"`;任务轮询用 `hx-trigger="every 2s"`,完成后返回不带该属性的成品卡片即自动停止;上传区、角色排序和 tab 切换用 Alpine 的 `x-data` 管局部状态,拖拽可加 SortableJS。 + +### 管理端页面 + +- **代码生成器直出**:`providers`、`provider_models`、`prompt_templates`、`point_accounts`、`users`、`api_keys`。 +- **需手写的三个定制页**:路由池编排(候选 model 穿梭框 + 权重滑块 + 拖拽排序)、Provider 健康看板(24h 成功率 / p95 延迟 / 熔断状态 / 最近错误)、生成记录详情(原图、结果图、`rendered_prompt`、`attempts` 故障转移链路)。 +- **Provider 连通性测试**按钮:保存后一键打真实请求验证,省掉大量配置调试时间。 + +定制页超过三个时必须重新评估是否放弃 go-admin-ui 改为自建服务端渲染后台。 + +### 程序化调用 + +外部程序用 API Key(哈希比对,身份落在 `users`)调用 `portal` 的 openapi 端点提交生成与查询状态。接口形态在 MVP-2 的设计工单中确定,届时本节补链接。 + +### 明确不做 + +计费扣点、充值与支付回调、汇率、软件授权与设备绑定、内容审核、cmshopee 专用端点、每日生成配额。 + +### 风险与待定 + +- 项目已定名 `chorus`(原方案代号 `cmgen` 作废)。 +- go-admin 社区活跃度与 Vue 2 EOL:定制页越多升级成本越高。 +- 选 MySQL 是为走 go-admin 主路径;代价是 `capabilities` 这类数组字段只能用 JSON 列或关联表表达,查询不如 Postgres `TEXT[]` 直接。 +- 角色规则默认模板的通用性:cmhub 那段是电商专用文案,是否改中性表述待定。 +- 点数来源:当前定为自有表 + 管理端发放;与 cmhub 账本打通需另设计。 +- 生成物存储增长:本地磁盘会涨得很快,保留期策略要在上线前定。 + +## 登记规则 + +每一行代表一项长期需求或稳定需求领域,不代表一个普通 Bug。至少填写: + +- 清楚、稳定的需求名称; +- 谁在什么场景下需要它; +- 当前状态和所属版本、MVP 或发布范围; +- 唯一的详细 Wiki 页面; +- 当前或主要实施工单; +- 原型状态或明确写“无”; +- 已交付时的任务归档或验收入口。 + +需求正文、接口细节、业务规则和验收标准只在各自事实来源修改。本页使用一至两句话摘要并链接过去,不复制大段内容。 + +## 原型与设计资产 + +### 原型门禁 + +先选择最低成本、足以确认需求的设计证据: + +| 修改类型 | 是否建单 | 原型或替代证据 | +|---|---|---| +| 纯界面显示文案,且不改变语义、流程、权限、状态、接口、高风险文字、国际化键、程序标识符、布局或可访问性 | 否 | 无原型,只做最小界面检查 | +| 现有界面的小范围样式或布局调整 | 是 | 标注截图、低保真图或明确复用的现有规范 | +| 新组件但沿用现有设计体系 | 是 | 组件状态和边界说明,按需提供低保真图 | +| 新页面、独立用户功能、重大交互或导航变化 | 是 | Quant-UX 或其他工具制作并经用户确认的可审阅原型 | +| 后端、接口、数据或定时任务 | 是 | 架构、API、数据、状态或流程设计,不强制 UI 原型 | +| 恢复既有确认行为的 Bug | 是 | 原设计、截图、复现步骤或已有验收证据 | + +“文字豁免”只指用户看到的显示文案,不包括代码组件名、类名、变量、API 字段、数据库字段或国际化键。任何条件不明确时都要建单。 + +新增页面、独立用户功能、重大交互或导航变化的顺序固定为:确认文字需求 → 制作可审阅原型 → 用户确认原型和覆盖范围 → 建立或放行实现工单 → 编写生产代码。原型发生影响页面结构、主要流程、状态、权限、异常处理或验收结果的变化时,必须重新确认。 + +### 本地 HTML 审核快照 + +需要完整原型门禁的新页面、独立用户功能、重大交互或导航变化,在用户审核前把 Quant-UX 或等效设计源的待审核版本生成到 `prototypes/<工单号>/<版本>/index.html`。版本目录内的资源使用相对路径,快照应在本地可浏览;如果必须启动静态服务,在工单记录最小启动命令。可编辑设计源仍以原设计工具为准,Git HTML 是不可覆盖的版本化审核证据,Wiki 和工单负责索引。 + +已确认快照不得原位覆盖。页面结构、流程、状态、权限、异常处理或验收结果变化时创建新版本目录、重新导出并重新确认。审核前检查页面、交互和资源完整性,并删除凭据、账号、个人信息和生产数据。纯显示文案、小范围 UI、非 UI 需求和恢复既有行为的 Bug 不强制生成 HTML。 + +设计工具无法生成可用 HTML 时,工单记录限制并停止审核,等待用户确认等效的本地可浏览方案;不能把难以访问的线上链接直接当作已确认原型。 + +### 原型确认记录 + +原型或替代设计证据至少记录链接/路径、版本/revision或确认日期、状态、确认人、确认时间和覆盖范围。外部原型需要保留可追溯版本;重要已确认版本按需保存快照。没有 UI 原型时,记录采用的技术设计或无需原型的原因。 + +- 与代码版本绑定的图片、HTML 交互稿和设计源文件放入 Git 的 `design/` 或 `prototypes/`,不要手工放入 Wiki 镜像目录 `docs/`。 +- 外部 Figma 等原型记录可访问链接、版本或确认日期、负责人和适用需求;重要的已确认版本保留可追溯快照。 +- 原型必须标记“草稿、已确认、已废弃”之一。草稿不能作为正式实现依据;已废弃原型保留状态和替代入口,不让 Agent 误用。 +- 原型只表达界面和交互意图,不能代替文字业务规则、安全边界、异常处理和验收标准。 +- 没有原型时写“无”和原因,不创建空图片、空目录或占位原型。 +- 原型包含账号、个人信息或生产数据时必须先脱敏;凭据不得进入原型或截图。 + +## 状态规则 + +需求状态使用:待确认、已确认、开发中、待验收、已交付、已停止。 + +- 方案未确认时为“待确认”;用户确认后才能进入“已确认”。 +- 开始执行单元任务后为“开发中”;实现完成并等待用户确认时为“待验收”。 +- 用户明确验收后改为“已交付”。 +- 需求取消或被替代时改为“已停止”,并链接原因和替代需求,不删除历史记录。 +- 一个需求领域包含多个任务时,以尚未完成的关键任务决定状态,并在状态中简短说明。 + +## 更新时机 + +以下情况必须更新本页: + +1. 用户确认新的长期产品需求或新需求领域; +2. 需求进入开发、待验收、已交付或已停止; +3. 需求的正式 Wiki、主要工单、原型或验收入口变化; +4. 原型从草稿变为已确认或已废弃; +5. MVP、版本范围或用户场景发生变化。 + +普通内部重构、小缺陷和不改变长期能力的任务只保留在工单,不必进入本页。 + +## 最小验收清单 + +- [ ] 新成员能从本页找到每项长期需求的详细说明。 +- [ ] 状态与相关 Gitea 工单一致。 +- [ ] 每项已交付需求具有验收入口。 +- [ ] 原型具有路径或链接、版本和确认状态,或者明确写“无”。 +- [ ] 本页没有复制完整工单或主题文档。 +- [ ] 草稿原型没有被描述为正式需求。 +- [ ] 不包含凭据、个人数据或生产数据。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..33295c1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,96 @@ +# chorus 文档中心 + +chorus 是从 cmhub 抽出、用 Go 重写的独立生图生文服务:用户提交提示词(可带原图)得到新图或文本,运营在管理端配置上游模型并查看生成记录。开发过程遵循 DevHarness——Gitea 工单管理任务、Wiki 管理长期文档、Git 记录代码变更、人由人确认与验收。 + +当前处于 **MVP-0:跑通全流程的最小闭环**。目标不是把功能做全,而是先让一条数据从提交走到结果全程可运行、可验证。 + +## 第一次阅读 + +建议按以下顺序,用 10~20 分钟建立整体认识: + +1. [项目档案](00-project-profile.md):项目目标、技术栈、环境、命令和目录边界。 +2. [产品需求总览](09-product-requirements-overview.md):MVP 分期、已锁定决策、验收口径。 +3. [架构与代码地图](02-architecture-and-code-map.md):两条执行路径、从哪个文件开始读。 +4. [业务规则与术语](03-business-rules-and-glossary.md):Provider、路由池、生成状态机和不能破坏的规则。 +5. [本地开发与验证](04-local-development-and-verification.md):怎样跑起来、怎样确认真的跑通了。 +6. [常见修改指南](05-common-changes.md):简单修改的步骤和停止条件。 +7. [故障排查](06-troubleshooting.md):出错时按什么顺序查。 +8. [开发工作流](01-workflow.md):完整建单、实施、验收和归档流程。 + +## 五分钟开始 + +在仓库根目录执行: + +```powershell +git status --short --branch +python dev_scripts/harness.py check --strict +python -m unittest discover -s tests -v +``` + +预期结果: + +- 工作区没有不属于当前任务的修改; +- 输出“DevHarness 检查通过”; +- 所有测试通过。 + +MVP-0 的实现工单落地后,再加上: + +```powershell +go build ./... +go vet ./... +go test ./... +go run ./portal +``` + +预期:编译通过、测试通过、服务监听 `:8080` 且日志显示 worker 已启动。完整的“走通一次生成”步骤见[本地开发与验证](04-local-development-and-verification.md)。 + +如果失败,先看[故障排查](06-troubleshooting.md),不要直接重置工作区或覆盖本地文档。 + +## 简单修改从哪里开始 + +| 想做什么 | 先读哪里 | 主要验证 | +|---|---|---| +| 改用户端页面文案 | Common-Changes、`portal/web/templates/` | 浏览器最小界面检查 | +| 改角色规则默认模板 | Business-Rules、`prompt_templates` 表 | 一次真实生成,核对 `rendered_prompt` | +| 加一个上游 Provider | Common-Changes | 连通性测试或一次真实生成 | +| 看懂一次生成为什么失败 | Troubleshooting | `generations.attempts` 与 `error_message` | +| 改文档 | 对应 Wiki 页面、Common-Changes | Wiki 同步检查 | +| 查看或更新产品需求 | Product-Requirements-Overview | 状态、链接和事实来源核对 | +| 增加 Harness 检查 | `dev_scripts/harness.py` | 成功与失败用例都要有 | + +选路与 `retryable` 判定、熔断、SSRF、密钥加解密、数据库迁移、队列租约、权限、点数写入、删除数据或不可逆操作**不属于简单修改**,必须停止并交给 Agent 分析、等待人工确认。 + +## 事实来源 + +| 信息 | 事实来源 | +|---|---| +| 任务状态、讨论、阻塞、验收过程 | Gitea 工单 | +| 长期产品需求的统一导航和状态 | Product-Requirements-Overview | +| 架构、业务规则、开发规范、操作手册、交付文档、任务归档 | Gitea Wiki | +| 数据库表结构(跨交付单元的共享契约) | `migrations/` 中的 SQL | +| 源码和与特定代码版本强绑定的文档 | Git 仓库 | +| 已确认的界面与交互 | `prototypes/<工单号>/<版本>/index.html` | +| 核心长期文档的离线浏览副本 | Git 仓库中的 `docs/` Wiki 镜像 | + +> **当前例外**:chorus 的线上 Wiki 尚未初始化,`docs/` 是初始人工版本。完成[新项目文档初始化](07-new-project-documentation-setup.md)后转为只读镜像,之后长期文档必须先改 Wiki、读取确认,再导出镜像。 + +## 项目入口 + +- [Gitea 工单](https://git.ilapage.cn/OPC/chorus/issues) +- [产品需求总览](09-product-requirements-overview.md) +- [代码仓库](https://git.ilapage.cn/OPC/chorus) +- [新项目文档初始化](07-new-project-documentation-setup.md) +- [交付文档指南](delivery/README.md) +- [任务归档模板](templates/task-archive.md) + +## 同步原则 + +```text +修改 Wiki → 读取确认 → 导出 docs → 校验差异 → 提交镜像 +``` + +- 核心页面与本地路径通过 `wiki-docs.json` 显式映射;普通同步不处理任务归档。 +- 镜像头记录来源页面、Wiki revision 和同步时间;带 `generated: true` 的文件不得手工编辑。 +- 已映射镜像存在未提交修改时同步必须停止。 +- 页面删除、重命名和映射变更必须人工确认。 +- 凭据、个人数据和生产数据不得进入 Wiki、镜像、原型或工单。 diff --git a/docs/delivery/README.md b/docs/delivery/README.md new file mode 100644 index 0000000..fbaed9d --- /dev/null +++ b/docs/delivery/README.md @@ -0,0 +1,72 @@ +# 交付文档指南 + +## 本页用途 + +本页规定项目开发完成后,怎样为客户、最终用户和其他岗位选择、编写、验证及维护交付文档。交付文档面向实际使用产品的人,不替代开发工作流、架构说明和任务归档。 + +最小原则:先确认交付对象,只创建对方完成工作确实需要的文档,不预建空白手册。 + +## 什么时候需要交付文档 + +出现以下任一情况时,应在单元任务工单中评估并更新交付文档: + +- 新增或改变用户可见功能、操作步骤、界面、权限或限制; +- 改变安装、配置、部署、备份、恢复、监控或升级方法; +- 改变外部 API、数据格式、集成条件或兼容范围; +- 改变常见故障的识别、处理或支持方式; +- 发布新版本或交付客户验收。 + +纯内部重构只有在外部行为、操作方式、配置和支持边界均未改变时,才可选择“无交付文档影响”并说明原因。 + +## 受众与文档选择 + +| 交付对象 | 典型文档 | 需要回答的问题 | +|---|---|---| +| 最终用户 | 用户使用说明 | 怎样完成日常操作,失败后怎么办 | +| 管理员 | 管理员指南 | 怎样配置用户、权限和系统参数 | +| 运维人员 | 部署与运维指南 | 怎样安装、启停、监控、备份和恢复 | +| 客服或一线支持 | 支持与排错指南 | 怎样识别问题、收集信息和升级处理 | +| 集成人员 | 接口与集成指南 | 怎样认证、调用接口和处理兼容性 | +| 验收或项目负责人 | 发布、升级与验收说明 | 本次交付了什么,怎样验证和回退 | + +一个项目只选择实际存在的交付对象。多个岗位需要相同内容时可共享一份文档,但必须明确各自可以执行的操作和权限边界。 + +## 内部文档与交付文档边界 + +交付文档可以包含用户完成工作所需的产品地址、公开接口、配置项、操作步骤、结果、限制和支持渠道。 + +面向客户或公开的文档不得包含: + +- 内部工单链接、聊天记录、内部决策过程或任务归档; +- 内部网络地址、仓库路径、无必要的源码模块名和调试细节; +- 密码、令牌、Cookie、私钥、个人数据或生产数据; +- 未经确认的安全实现、漏洞细节或仅供内部使用的恢复手段; +- 未承诺的路线图、期限和功能。 + +交付前必须检查模板中的“可见范围”。同一主题同时存在内部版和客户版时,应分别维护并明确名称,不能依靠读者自行忽略内部内容。 + +## 编写和维护流程 + +1. 在项目初始化或需求确认时识别交付对象、可见范围和所需文档。 +2. 使用[岗位文档模板](Audience-Document-Template.-)按需创建文档,不创建没有明确读者的空页面。 +3. 单元任务在工单“交付文档影响”中选择无影响并说明原因,或列出需要更新的页面和受众。 +4. 功能、配置或流程改变时,代码与对应交付文档在同一任务中更新。 +5. 由熟悉该岗位但未参与实现的人按文档执行关键步骤;不能验证的环境和步骤必须明确标注。 +6. 发布或交付前确认适用版本、最后验证日期、负责人、已知限制和支持渠道。 +7. 长期维护仍遵循 Wiki-first:先修改 Wiki、读取确认,再导出本地 `docs/` 镜像。 + +具体项目创建的岗位文档应增加到 `wiki-docs.json` 的显式映射中。本模板自身的指南和模板镜像位于 `docs/delivery/`。 + +## 最小验收清单 + +- [ ] 文档有明确受众、适用版本、可见范围和负责人。 +- [ ] 前置条件、操作步骤和预期结果完整且可以对应。 +- [ ] 常见失败、恢复方法、安全提示和已知限制已说明。 +- [ ] 关键步骤由目标岗位视角验证,或明确记录未验证项。 +- [ ] 外部版本不含内部链接、敏感数据和无关实现细节。 +- [ ] 本次功能变化涉及的交付文档已更新并与版本一致。 +- [ ] Wiki 已读取确认,本地镜像检查一致。 + +## 不在本页解决的内容 + +开发任务怎样建单、实施和归档见[开发工作流](Development-Workflow.-);代码结构和维护入口见[架构与代码地图](Architecture-and-Code-Map.-)。本页不规定市场宣传、合同、法务或商务承诺。 diff --git a/docs/delivery/audience-document-template.md b/docs/delivery/audience-document-template.md new file mode 100644 index 0000000..fb03396 --- /dev/null +++ b/docs/delivery/audience-document-template.md @@ -0,0 +1,88 @@ +# 岗位文档模板 + +> 使用说明:复制本模板创建具体岗位文档,删除说明性文字并填写真实内容。没有明确受众时不要创建文档;不得保留“待填写”后直接交付。 + +## 文档信息 + +| 字段 | 内容 | +|---|---| +| 文档名称 | | +| 适用对象 | 最终用户 / 管理员 / 运维 / 客服 / 集成人员 / 验收人员 / 其他 | +| 适用版本 | | +| 最后验证日期 | YYYY-MM-DD | +| 负责人 | | +| 可见范围 | 内部 / 指定客户 / 公开 | +| 相关产品或模块 | | + +## 目的与适用范围 + +说明读者完成什么工作,以及本文包含和不包含什么。用岗位语言描述结果,不复制内部需求分析。 + +## 前置条件 + +- 所需权限: +- 所需环境或设备: +- 已完成的准备: +- 需要提前获得的信息: + +不得在这里填写真实密码、令牌、个人数据或生产数据。 + +## 操作步骤 + +### 任务一:<明确的操作目标> + +1. <执行动作> +2. <执行动作> +3. <执行动作> + +**预期结果**:<读者可以观察到的成功结果> + +**失败时**:<先检查什么;何时停止并联系支持> + +每个独立任务重复以上结构。命令和界面名称应与适用版本一致;危险或不可逆操作必须在执行前给出醒目警告、影响和回退条件。 + +## 常见错误与恢复 + +| 现象或错误信息 | 可能原因 | 处理步骤 | 何时升级 | +|---|---|---|---| +| | | | | + +只记录经过确认的原因和恢复方法。不要让外部读者执行内部调试、绕过权限或可能扩大损失的操作。 + +## 安全与权限 + +- 本岗位允许执行的操作: +- 明确禁止或需要审批的操作: +- 敏感信息处理规则: +- 数据、日志和截图脱敏要求: +- 删除、发布、迁移或其他高风险操作的确认要求: + +## 已知限制 + +- 支持的环境和版本: +- 当前不支持的场景: +- 兼容性限制: +- 未验证的环境或步骤: + +## 支持与升级处理 + +- 支持渠道: +- 服务时间或响应约定: +- 联系支持前需要收集的信息: +- 不得提交的信息: +- 需要升级到下一岗位或负责人的条件: + +## 版本记录 + +| 日期 | 适用版本 | 变更内容 | 验证人 | +|---|---|---|---| +| | | | | + +## 交付前检查 + +- [ ] 目标岗位能够理解术语和步骤。 +- [ ] 前置条件、步骤与预期结果一一对应。 +- [ ] 关键流程已按目标岗位视角验证。 +- [ ] 常见错误、恢复方法和升级条件清楚。 +- [ ] 没有内部工单、内部地址、敏感数据或无关源码细节。 +- [ ] 适用版本、最后验证日期、负责人和可见范围已填写。 diff --git a/docs/task/.gitkeep b/docs/task/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/templates/deployment.md b/docs/templates/deployment.md new file mode 100644 index 0000000..f60d499 --- /dev/null +++ b/docs/templates/deployment.md @@ -0,0 +1,274 @@ +# 部署文档模板 + +> 使用说明:本页是 DevHarness 模板,不描述任何真实服务。有常驻服务的项目复制本页,在自己的 Gitea Wiki 创建 `Deployment-and-Operations` 页面,并在本项目 `wiki-docs.json` 增加映射(建议镜像到 `docs/10-deployment-and-operations.md`)。填写时删除全部说明性文字,不得保留“待填写”后直接交付。没有常驻服务的项目不要创建部署页,在初始化工单记录原因即可。 +> +> 本模板面向项目内部维护者。面向客户或外部运维岗位的部署说明属于交付文档,使用[岗位文档模板](Audience-Document-Template.-)。 +> +> 标准栈为 nginx 反向代理 + supervisor 进程托管,示例按 Python 服务(gunicorn / uvicorn)编写。实际技术栈不同时替换命令,但保留章节结构和“每条命令写明预期结果”的要求。 + +## 本页用途 + +让维护者能够在一台干净的服务器上完成首次部署、日常运维、健康检查和回滚。所有命令默认以具备 sudo 权限的账号在服务器上执行,示例以 Linux 为主。 + +## 安全边界 + +- 本页只写配置项的**名称和来源**,不写任何真实密码、令牌、私钥、证书内容、生产数据库地址或个人数据。 +- 需要凭据的步骤写明“从哪里取”,例如运维密码库条目名或环境变量名。 +- 涉及删除数据、数据库迁移和不可逆操作的步骤必须给出醒目警告、影响范围和回退条件。 + +## 服务概览 + +| 项目 | 内容 | +|---|---| +| 服务名(supervisor program) | `<myapp>` | +| 代码部署目录 | `/srv/<myapp>` | +| 运行账号 | `<myapp>` | +| 运行时 | Python `<3.x>` | +| 应用服务器 | gunicorn / uvicorn | +| 本地监听地址 | `127.0.0.1:<8000>` | +| 进程数 | `<n>` | +| 对外域名与路径 | `https://<example.com>/` | +| 依赖的外部服务 | 数据库 / 缓存 / 对象存储 / 无 | +| 日志目录 | `/var/log/<myapp>/` | + +服务只监听 `127.0.0.1`,不直接对外暴露端口;所有外部访问经 nginx 转发。 + +## 环境要求 + +| 组件 | 版本要求 | 检查命令 | 预期结果 | +|---|---|---|---| +| 操作系统 | `<Ubuntu 22.04>` | `cat /etc/os-release` | 输出与要求一致 | +| Python | `<3.11+>` | `python3 --version` | 输出版本号且不低于要求 | +| nginx | `<1.18+>` | `nginx -v` | 输出版本号 | +| supervisor | `<4.2+>` | `supervisord --version` | 输出版本号 | + +未安装时: + +```bash +sudo apt update +sudo apt install -y nginx supervisor python3-venv +``` + +**预期结果**:`systemctl status nginx` 与 `systemctl status supervisor` 均为 `active (running)`。 + +## 首次部署 + +### 1. 创建运行账号与目录 + +```bash +sudo useradd --system --home /srv/<myapp> --shell /usr/sbin/nologin <myapp> +sudo mkdir -p /srv/<myapp> /var/log/<myapp> +sudo chown -R <myapp>:<myapp> /srv/<myapp> /var/log/<myapp> +``` + +**预期结果**:`id <myapp>` 输出该账号;两个目录存在且属主为 `<myapp>`。 + +服务账号使用 `nologin`,不允许直接登录。 + +### 2. 取得代码 + +```bash +sudo -u <myapp> git clone <仓库地址> /srv/<myapp>/app +cd /srv/<myapp>/app && sudo -u <myapp> git rev-parse HEAD +``` + +**预期结果**:输出本次部署的完整提交哈希,记录到部署记录中。 + +### 3. 安装依赖 + +```bash +sudo -u <myapp> python3 -m venv /srv/<myapp>/venv +sudo -u <myapp> /srv/<myapp>/venv/bin/pip install -r /srv/<myapp>/app/requirements.txt +``` + +**预期结果**:pip 以 `Successfully installed ...` 结束,无 ERROR。 + +### 4. 落位配置文件 + +```bash +sudo install -o <myapp> -g <myapp> -m 600 /dev/null /srv/<myapp>/app.env +sudo -u <myapp> vi /srv/<myapp>/app.env +``` + +**预期结果**:`ls -l /srv/<myapp>/app.env` 显示权限 `-rw-------` 且属主为 `<myapp>`。 + +配置项清单见下方“配置与凭据来源”。配置文件不进入 Git。 + +### 5. 数据库初始化或迁移 + +<!-- 无数据库时删除本节。 --> + +> **注意**:迁移可能不可逆。执行前必须先备份,并确认回退方式。 + +```bash +sudo -u <myapp> /srv/<myapp>/venv/bin/python -m <myapp>.manage migrate +``` + +**预期结果**:输出全部迁移已应用,无失败项。 + +## supervisor 配置 + +写入 `/etc/supervisor/conf.d/<myapp>.conf`: + +```ini +[program:<myapp>] +command=/srv/<myapp>/venv/bin/gunicorn <myapp>.wsgi:application --workers <n> --bind 127.0.0.1:<8000> --timeout 60 +directory=/srv/<myapp>/app +user=<myapp> +environment=PATH="/srv/<myapp>/venv/bin",APP_ENV_FILE="/srv/<myapp>/app.env" +autostart=true +autorestart=true +startsecs=5 +stopasgroup=true +killasgroup=true +stopwaitsecs=30 +stdout_logfile=/var/log/<myapp>/stdout.log +stderr_logfile=/var/log/<myapp>/stderr.log +stdout_logfile_maxbytes=50MB +stdout_logfile_backups=5 +``` + +> 异步框架使用 uvicorn 时把 `command` 换成: +> `/srv/<myapp>/venv/bin/uvicorn <myapp>.asgi:app --host 127.0.0.1 --port <8000> --workers <n>` + +不要在 `environment` 里写明文密码或令牌;敏感值放在 `app.env`,由应用读取。 + +加载配置并启动: + +```bash +sudo supervisorctl reread +sudo supervisorctl update +sudo supervisorctl start <myapp> +sudo supervisorctl status <myapp> +``` + +**预期结果**:`reread` 输出 `<myapp>: available`;`status` 显示 `RUNNING` 且 uptime 持续增长。出现 `BACKOFF` 或 `FATAL` 时查看 `stderr.log`。 + +## nginx 配置 + +写入 `/etc/nginx/sites-available/<myapp>.conf` 并软链到 `sites-enabled`: + +```nginx +server { + listen 80; + server_name <example.com>; + + access_log /var/log/nginx/<myapp>.access.log; + error_log /var/log/nginx/<myapp>.error.log; + + client_max_body_size <20m>; + + location /static/ { + alias /srv/<myapp>/app/static/; + expires 7d; + } + + location / { + proxy_pass http://127.0.0.1:<8000>; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_read_timeout <60s>; + } +} +``` + +<!-- WebSocket 需求存在时,在对应 location 增加 Upgrade 与 Connection 头;无此需求时删除本注释。 --> + +启用并生效: + +```bash +sudo ln -sf /etc/nginx/sites-available/<myapp>.conf /etc/nginx/sites-enabled/<myapp>.conf +sudo nginx -t +sudo systemctl reload nginx +``` + +**预期结果**:`nginx -t` 输出 `syntax is ok` 与 `test is successful`;reload 无输出且 `systemctl status nginx` 仍为 `active (running)`。 + +`nginx -t` 未通过时不要 reload,先修正配置。 + +<!-- 需要 HTTPS 时在此记录证书来源、签发方式和续期检查命令;证书内容和私钥不得写入本页。 --> + +## 配置与凭据来源 + +| 配置项 | 用途 | 来源 | 是否敏感 | +|---|---|---|---| +| `APP_ENV_FILE` | 指向配置文件路径 | supervisor 配置 | 否 | +| `<DATABASE_URL>` | 数据库连接 | `/srv/<myapp>/app.env`,值取自运维密码库条目 `<条目名>` | 是 | +| `<SECRET_KEY>` | 会话与签名 | 同上 | 是 | +| `<LOG_LEVEL>` | 日志级别 | `/srv/<myapp>/app.env` | 否 | + +敏感值只记录取用位置,不在本页、工单、日志和提交中出现真实内容。 + +## 日常运维 + +| 操作 | 命令 | 预期结果 | +|---|---|---| +| 查看状态 | `sudo supervisorctl status <myapp>` | `RUNNING`,uptime 持续增长 | +| 重启服务 | `sudo supervisorctl restart <myapp>` | 输出 `stopped` 后 `started` | +| 停止服务 | `sudo supervisorctl stop <myapp>` | 输出 `stopped` | +| 实时日志 | `sudo supervisorctl tail -f <myapp> stderr` | 持续输出应用日志 | +| 应用日志 | `sudo tail -n 200 /var/log/<myapp>/stderr.log` | 输出最近日志 | +| 接入层日志 | `sudo tail -n 200 /var/log/nginx/<myapp>.error.log` | 输出 nginx 错误 | +| 重载 nginx | `sudo nginx -t && sudo systemctl reload nginx` | 测试通过后无中断生效 | + +修改 supervisor 配置后必须 `reread` + `update`,只 `restart` 不会加载新配置。 + +## 健康检查 + +每次部署、重启和回滚后必须全部执行: + +```bash +sudo supervisorctl status <myapp> +curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<8000><健康检查路径> +curl -sS -o /dev/null -w "%{http_code}\n" https://<example.com><健康检查路径> +sudo tail -n 50 /var/log/<myapp>/stderr.log +``` + +**预期结果**:状态为 `RUNNING`;两个 `curl` 均返回 `200`;日志无新增异常堆栈。 + +任何一项不符合时不视为部署成功,按“升级与回滚”处理。 + +## 升级与回滚 + +### 升级 + +```bash +cd /srv/<myapp>/app +sudo -u <myapp> git rev-parse HEAD # 记录当前提交,回滚需要 +sudo -u <myapp> git fetch --all +sudo -u <myapp> git checkout <目标提交或标签> +sudo -u <myapp> /srv/<myapp>/venv/bin/pip install -r requirements.txt +sudo -u <myapp> /srv/<myapp>/venv/bin/python -m <myapp>.manage migrate # 无数据库时删除 +sudo supervisorctl restart <myapp> +``` + +**预期结果**:restart 后 `status` 为 `RUNNING`,随后健康检查全部通过。 + +升级前必须记录当前提交哈希;涉及数据库迁移时必须先备份。 + +### 回滚 + +```bash +cd /srv/<myapp>/app +sudo -u <myapp> git checkout <升级前记录的提交> +sudo -u <myapp> /srv/<myapp>/venv/bin/pip install -r requirements.txt +sudo supervisorctl restart <myapp> +``` + +**预期结果**:健康检查全部通过。 + +> **注意**:已执行的数据库迁移通常不能通过切回代码撤销。存在迁移时必须先确认迁移是否向后兼容;不兼容时按备份恢复流程处理,并停止自行操作、联系负责人。 + +### 备份与恢复 + +<!-- 记录备份对象、频率、保存位置、保留期和恢复步骤;无持久化数据时说明原因。 --> + +## 已知限制 + +<!-- 记录本环境无法验证的部分,例如未做过真实回滚演练、未验证高并发表现、灰度或多机部署尚未支持。不得留空,无限制时写“无”。 --> + +- <!-- 填写 --> diff --git a/docs/templates/task-archive.md b/docs/templates/task-archive.md new file mode 100644 index 0000000..20d4be1 --- /dev/null +++ b/docs/templates/task-archive.md @@ -0,0 +1,42 @@ +# <工单号> <标题> + +- 类型:需求 / 缺陷 / 重构 +- 所属 Epic:# +- 所属 MVP / 版本:# +- 状态:待验收 / 已完成 +- 日期:YYYY-MM-DD +- Gitea 工单:<链接> +- Wiki 页面:<页面名> +- Wiki revision:见本地镜像头 + +## 背景与目标 + +<!-- 原来有什么问题,这次达到什么结果。 --> + +## 最终方案 + +<!-- 说明实际实现。与建单方案不同之处必须写清原因。 --> + +## 修改文件 + +- `<文件>`:<改动说明> + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| | 通过 / 未通过 | + +## 测试 + +- 执行命令:`<命令>` +- 结果: +- **未验证部分**:<!-- 必填;没有就写“无”。 --> + +## 遗留问题 + +<!-- 没有就删除本节。 --> + +## 相关提交 + +- `<提交哈希>` <提交说明> diff --git a/prototypes/.gitkeep b/prototypes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_harness_docs.py b/tests/test_harness_docs.py new file mode 100644 index 0000000..9de44a4 --- /dev/null +++ b/tests/test_harness_docs.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "dev_scripts")) + +import harness # noqa: E402 +from harness import ( # noqa: E402 + CORE_DOCUMENT_REQUIREMENTS, + CORE_PAGE_PATHS, + REQUIRED_FILES, + check_claude_code_entry, + check_required_files, + check_core_documents, + check_agent_efficiency_rules, + check_repository_readme, + check_task_template, + core_mapping_errors, + missing_sections, +) +from wiki_docs import load_config # noqa: E402 + + +class CoreDocumentTests(unittest.TestCase): + def test_current_core_documents_have_required_sections(self) -> None: + errors: list[str] = [] + check_core_documents(errors) + self.assertEqual(errors, []) + + def test_missing_sections_reports_each_heading(self) -> None: + missing = missing_sections("# 页面\n## 已有\n", ("## 已有", "## 缺少")) + self.assertEqual(missing, ["## 缺少"]) + + def test_every_core_document_is_required(self) -> None: + for path in CORE_DOCUMENT_REQUIREMENTS: + self.assertIn(path, REQUIRED_FILES) + + def test_every_core_page_has_exact_mapping(self) -> None: + config = load_config() + mappings = {mapping.page: mapping.path for mapping in config.mappings} + for page, path in CORE_PAGE_PATHS.items(): + self.assertEqual(mappings.get(page), path) + + def test_task_archives_are_not_core_mappings(self) -> None: + config = load_config() + self.assertFalse( + any(mapping.path.startswith("docs/task/") for mapping in config.mappings) + ) + + def test_existing_project_adoption_guide_is_core_document(self) -> None: + path = "docs/08-existing-project-adoption.md" + self.assertEqual( + CORE_PAGE_PATHS.get("Existing-Project-Adoption-Guide"), + path, + ) + self.assertIn(path, CORE_DOCUMENT_REQUIREMENTS) + self.assertIn("## 可复制 Agent 指令", CORE_DOCUMENT_REQUIREMENTS[path]) + + def test_product_requirements_overview_is_core_document(self) -> None: + path = "docs/09-product-requirements-overview.md" + self.assertEqual( + CORE_PAGE_PATHS.get("Product-Requirements-Overview"), + path, + ) + required = CORE_DOCUMENT_REQUIREMENTS[path] + self.assertIn("## 当前需求索引", required) + self.assertIn("## 原型与设计资产", required) + self.assertIn("### 原型门禁", required) + self.assertIn("### 本地 HTML 审核快照", required) + self.assertIn("### 原型确认记录", required) + self.assertIn("## 更新时机", required) + + def test_workflow_requires_ticket_and_design_evidence_gates(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + self.assertIn("## 工单与设计证据双门禁", required) + self.assertIn("### 先判断是否需要工单", required) + self.assertIn("### 再判断设计证据", required) + self.assertIn("### 本地 HTML 审核快照", required) + self.assertIn("### 记录和重新确认", required) + + def test_workflow_requires_online_wiki_initialization_gate(self) -> None: + workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + setup = CORE_DOCUMENT_REQUIREMENTS[ + "docs/07-new-project-documentation-setup.md" + ] + self.assertIn("## 新项目 Wiki 初始化门禁", workflow) + self.assertIn("#### 在线创建与回读门禁", setup) + + def test_existing_project_adoption_requires_upgrade_process(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS[ + "docs/08-existing-project-adoption.md" + ] + self.assertIn("## 后续升级", required) + self.assertIn("### 升级步骤", required) + self.assertIn("### 可复制升级指令", required) + + def test_project_profile_requires_dev_harness_baseline(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS["docs/00-project-profile.md"] + self.assertIn("## DevHarness 来源与基线", required) + + def test_new_project_setup_requires_baseline_selection(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS[ + "docs/07-new-project-documentation-setup.md" + ] + self.assertIn("### 2. 选择建设基线", required) + self.assertIn("#### 判断案例", required) + self.assertIn("### 3. 识别子项目与交付单元", required) + self.assertIn("#### 需求总览启用条件", required) + + def test_deployment_template_is_required_and_mapped(self) -> None: + path = "docs/templates/deployment.md" + self.assertIn(path, REQUIRED_FILES) + config = load_config() + mappings = {mapping.page: mapping.path for mapping in config.mappings} + self.assertEqual(mappings.get("Deployment-Template"), path) + # 部署页按项目需要创建,不强制每个项目产出实例页。 + self.assertNotIn(path, CORE_DOCUMENT_REQUIREMENTS) + + def test_missing_deployment_template_is_reported(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with unittest.mock.patch.object(harness, "ROOT", root): + errors: list[str] = [] + check_required_files(errors) + self.assertTrue( + any("docs/templates/deployment.md" in error for error in errors) + ) + + def test_missing_or_wrong_core_mapping_is_reported(self) -> None: + errors = core_mapping_errors({"Home": "docs/wrong.md"}) + self.assertTrue(any("Home -> docs/README.md" in error for error in errors)) + self.assertTrue( + any("Architecture-and-Code-Map" in error for error in errors) + ) + + +class TaskTemplateTests(unittest.TestCase): + def test_task_template_requires_document_impact(self) -> None: + errors: list[str] = [] + check_task_template(errors) + self.assertEqual(errors, []) + + def test_task_template_requires_delivery_document_impact(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text( + "## 依赖与并行\n" + "- 前置工单:无 / #编号\n" + "- 是否允许与前置工单并行:是 / 否\n" + "- 原因:\n" + "## 原始需求\n" + "- 来源:用户对话 / Gitea / 其他\n" + "- 提出时间:\n" + "- 关键原话或脱敏摘要:\n" + "## 需求变化记录\n" + "| 日期 | 变化内容 | 原因 | 用户确认 |\n" + "## 文档影响\n" + "- [ ] 不影响长期文档,原因:\n" + "- [ ] 更新架构与代码地图\n" + "- [ ] 更新业务规则与术语\n" + "- [ ] 更新常见修改或故障排查\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 交付文档影响", errors) + self.assertIn( + "单元任务模板缺少:- [ ] 无交付文档影响,原因:", + errors, + ) + + def test_task_template_requires_design_and_prototype_gate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text("## 基本信息\n", encoding="utf-8") + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 设计与原型门禁", errors) + self.assertIn( + "单元任务模板缺少:- 可编辑设计源链接、版本或事实来源:", + errors, + ) + self.assertIn( + "单元任务模板缺少:- 本地 HTML 审核快照路径和版本(不适用时说明原因):", + errors, + ) + self.assertIn( + "单元任务模板缺少:- 本地浏览方式和资源完整性检查:", + errors, + ) + + def test_task_template_requires_subproject_impact(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text("## 基本信息\n", encoding="utf-8") + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 子项目影响", errors) + self.assertIn( + "单元任务模板缺少:- 是否跨子项目:是 / 否", + errors, + ) + self.assertIn( + "单元任务模板缺少:- 是否修改共享接口或契约:是 / 否;唯一事实来源:", + errors, + ) + + def test_task_template_requires_dependency_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text( + "## 文档影响\n" + "- [ ] 不影响长期文档,原因:\n" + "- [ ] 更新架构与代码地图\n" + "- [ ] 更新业务规则与术语\n" + "- [ ] 更新常见修改或故障排查\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 依赖与并行", errors) + self.assertIn("单元任务模板缺少:- 前置工单:无 / #编号", errors) + + def test_task_template_requires_requirement_traceability(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + template = root / ".gitea" / "issue_template" / "task.md" + template.parent.mkdir(parents=True) + template.write_text( + "## 依赖与并行\n" + "- 前置工单:无 / #编号\n" + "- 是否允许与前置工单并行:是 / 否\n" + "- 原因:\n" + "## 文档影响\n" + "- [ ] 不影响长期文档,原因:\n" + "- [ ] 更新架构与代码地图\n" + "- [ ] 更新业务规则与术语\n" + "- [ ] 更新常见修改或故障排查\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_task_template(errors, root) + self.assertIn("单元任务模板缺少:## 原始需求", errors) + self.assertIn("单元任务模板缺少:## 需求变化记录", errors) + + +class AgentRuleTests(unittest.TestCase): + def test_required_agent_rules_are_present(self) -> None: + errors: list[str] = [] + check_agent_efficiency_rules(errors) + self.assertEqual(errors, []) + + def test_repository_readme_requires_online_wiki_gate(self) -> None: + errors: list[str] = [] + check_repository_readme(errors) + self.assertEqual(errors, []) + + def test_repository_readme_rejects_missing_gate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "README.md").write_text( + "# 项目\n创建 Gitea 远端仓库\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_repository_readme(errors, root) + self.assertTrue(any("README.md 缺少" in error for error in errors)) + + def test_claude_code_entry_imports_shared_rules(self) -> None: + errors: list[str] = [] + check_claude_code_entry(errors) + self.assertEqual(errors, []) + + def test_claude_code_entry_requires_exact_import_line(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "CLAUDE.md").write_text( + "共同规则事实来源\n只记录 Claude Code 特有\n" + "共同规则只修改 `AGENTS.md`\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_claude_code_entry(errors, root) + self.assertIn("CLAUDE.md 缺少独立的 @AGENTS.md 导入", errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wiki_docs.py b/tests/test_wiki_docs.py new file mode 100644 index 0000000..bc6b58e --- /dev/null +++ b/tests/test_wiki_docs.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "dev_scripts")) + +from harness import ( # noqa: E402 + build_archive, + existing_task_mirrors, + export_task_archives, + main as harness_main, + safe_title, + task_target, +) +from wiki_docs import ( # noqa: E402 + Config, + Mapping, + WikiClient, + WikiDocsError, + WikiPage, + dirty_mirror_paths, + load_config, + parse_mirror, + render_mirror, + sync_all, + validate_mappings, +) + + +class MappingTests(unittest.TestCase): + def test_rejects_path_outside_docs(self) -> None: + with self.assertRaisesRegex(WikiDocsError, "docs/"): + validate_mappings([{"page": "Home", "path": "README.md"}]) + + def test_rejects_duplicate_page(self) -> None: + with self.assertRaisesRegex(WikiDocsError, "重复映射"): + validate_mappings( + [ + {"page": "Home", "path": "docs/README.md"}, + {"page": "Home", "path": "docs/other.md"}, + ] + ) + + def test_normalizes_api_suffix_from_environment(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "wiki-docs.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "gitea_url": "http://configured.example", + "owner": "owner", + "repository": "repo", + "mappings": [ + {"page": "Home", "path": "docs/README.md"} + ], + } + ), + encoding="utf-8", + ) + with patch.dict( + os.environ, {"GITEA_URL": "http://gitea.example/api/v1"}, clear=False + ): + config = load_config(path) + self.assertEqual(config.gitea_url, "http://gitea.example") + + +class MirrorTests(unittest.TestCase): + def setUp(self) -> None: + self.page = WikiPage( + title="Home", + sub_url="Home", + text="# 首页\n", + revision="a" * 40, + html_url="http://gitea.example/o/r/wiki/Home", + ) + + def test_render_includes_traceable_metadata(self) -> None: + rendered = render_mirror(self.page) + metadata, body = parse_mirror(rendered) + self.assertEqual(metadata["wiki_page"], "Home") + self.assertEqual(metadata["wiki_revision"], "a" * 40) + self.assertTrue(metadata["synchronized_at"].endswith("Z")) + self.assertEqual(body, "# 首页\n") + + def test_unchanged_revision_preserves_sync_time(self) -> None: + first = render_mirror(self.page) + second = render_mirror(self.page, first) + self.assertEqual(first, second) + + @patch("wiki_docs.subprocess.run") + def test_dirty_mirror_paths_are_reported(self, run) -> None: + run.return_value.stdout = " M docs/README.md\n" + config = Config( + path=Path("wiki-docs.json"), + gitea_url="http://gitea.example", + owner="o", + repository="r", + mappings=(Mapping("Home", "docs/README.md"),), + ) + self.assertEqual(dirty_mirror_paths(config), [" M docs/README.md"]) + + @patch("wiki_docs.dirty_mirror_paths", return_value=[" M docs/README.md"]) + def test_sync_stops_before_reading_wiki_when_mirror_is_dirty(self, _dirty) -> None: + config = Config( + path=Path("wiki-docs.json"), + gitea_url="http://gitea.example", + owner="o", + repository="r", + mappings=(Mapping("Home", "docs/README.md"),), + ) + client = Mock() + with self.assertRaisesRegex(WikiDocsError, "未提交改动"): + sync_all(config, client) + client.get_page.assert_not_called() + + +class WikiClientTests(unittest.TestCase): + def test_encoded_unicode_sub_url_is_not_double_encoded(self) -> None: + config = Config( + path=Path("wiki-docs.json"), + gitea_url="http://gitea.example", + owner="o", + repository="r", + mappings=(Mapping("中文", "docs/chinese.md"),), + ) + client = WikiClient(config, token="") + client.list_pages = Mock( + return_value=[{"title": "中文", "sub_url": "%E4%B8%AD%E6%96%87.-"}] + ) + encoded = __import__("base64").b64encode("# 中文\n".encode()).decode() + with patch.object( + client, + "_request", + return_value={ + "title": "中文", + "content_base64": encoded, + "last_commit": {"sha": "b" * 40}, + }, + ) as request: + page = client.get_page("中文") + api_path = request.call_args.args[1] + self.assertIn("%E4%B8%AD%E6%96%87.-", api_path) + self.assertNotIn("%25E4", api_path) + self.assertTrue(page.html_url.endswith("/%E4%B8%AD%E6%96%87.-")) + + +class ArchiveTests(unittest.TestCase): + def test_safe_title_handles_windows_characters(self) -> None: + self.assertEqual(safe_title(' 修复:"登录" / 超时 '), "修复-登录-超时") + + def test_build_archive_replaces_known_fields(self) -> None: + template = "# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n" + result = build_archive(template, "12", "修复登录", "Task-12-login", "http://i/12") + self.assertIn("# 12 修复登录", result) + self.assertIn("http://i/12", result) + self.assertIn("Task-12-login", result) + self.assertNotIn("YYYY-MM-DD", result) + + @patch("harness.WikiClient") + def test_create_archive_does_not_change_core_mapping(self, client_class) -> None: + with tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "wiki-docs.json" + original = json.dumps( + { + "schema_version": 1, + "gitea_url": "http://gitea.example", + "owner": "o", + "repository": "r", + "mappings": [ + {"page": "Home", "path": "docs/README.md"} + ], + } + ) + config_path.write_text(original, encoding="utf-8") + client = client_class.return_value + client.list_pages.return_value = [] + client.get_page.return_value = WikiPage( + title="Task-Archive-Template", + sub_url="Task-Archive-Template.-", + text="# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n", + revision="a" * 40, + html_url="http://gitea.example/wiki/template", + ) + client.create_page.return_value = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="b" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + with patch.object( + sys, + "argv", + [ + "harness.py", + "archive", + "14", + "按需导出", + "--config", + str(config_path), + ], + ): + result = harness_main() + self.assertEqual(config_path.read_text(encoding="utf-8"), original) + self.assertEqual(result, 0) + client.create_page.assert_called_once() + + def test_task_target_uses_stable_safe_name(self) -> None: + with tempfile.TemporaryDirectory() as directory: + target = task_target("Task-14-修复:导出", Path(directory)) + self.assertEqual(target.name, "14-修复-导出.md") + + def test_existing_mirror_keeps_historical_custom_filename(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "docs" / "task" / "2-初级维护者文档体系.md" + path.parent.mkdir(parents=True) + page = WikiPage( + title="Task-2-Junior-Maintainer-Docs", + sub_url="Task-2-Junior-Maintainer-Docs.-", + text="# 2 文档\n", + revision="c" * 40, + html_url="http://gitea.example/wiki/task-2", + ) + path.write_text(render_mirror(page), encoding="utf-8") + mirrors = existing_task_mirrors(root) + self.assertEqual( + mirrors["Task-2-Junior-Maintainer-Docs"].name, + "2-初级维护者文档体系.md", + ) + + @patch("harness.dirty_paths", return_value=[]) + def test_incremental_export_skips_same_revision(self, _dirty) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "docs" / "task" / "14-按需导出.md" + path.parent.mkdir(parents=True) + page = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="d" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + path.write_text(render_mirror(page), encoding="utf-8") + client = Mock() + client.list_pages.return_value = [ + { + "title": page.title, + "sub_url": page.sub_url, + "last_commit": {"sha": page.revision}, + } + ] + messages = export_task_archives(client, root=root) + self.assertTrue(messages[0].startswith("跳过:")) + client.get_page_from_metadata.assert_not_called() + + @patch( + "harness.dirty_paths", + return_value=[" M docs/task/14-按需导出.md"], + ) + def test_export_stops_before_wiki_read_when_task_mirror_is_dirty( + self, _dirty + ) -> None: + client = Mock() + with self.assertRaisesRegex(WikiDocsError, "未提交改动"): + export_task_archives(client) + client.list_pages.assert_not_called() + + @patch("harness.dirty_paths", return_value=[]) + def test_full_export_reads_all_and_never_deletes_extra_file(self, _dirty) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_dir = root / "docs" / "task" + task_dir.mkdir(parents=True) + extra = task_dir / "99-历史快照.md" + extra_page = WikiPage( + title="Task-99-历史快照", + sub_url="Task-99.-", + text="# 99 历史快照\n", + revision="e" * 40, + html_url="http://gitea.example/wiki/task-99", + ) + extra.write_text(render_mirror(extra_page), encoding="utf-8") + page = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="f" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + client = Mock() + metadata = { + "title": page.title, + "sub_url": page.sub_url, + "last_commit": {"sha": page.revision}, + } + client.list_pages.return_value = [metadata] + client.get_page_from_metadata.return_value = page + messages = export_task_archives(client, export_all=True, root=root) + exported = root / "docs" / "task" / "14-按需导出.md" + self.assertTrue(exported.is_file()) + self.assertTrue(extra.is_file()) + self.assertTrue(messages[0].startswith("已导出:")) + client.get_page_from_metadata.assert_called_once_with(metadata, page.title) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki-docs.json b/wiki-docs.json new file mode 100644 index 0000000..b441944 --- /dev/null +++ b/wiki-docs.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "gitea_url": "https://git.ilapage.cn", + "owner": "OPC", + "repository": "chorus", + "mappings": [ + { "page": "Home", "path": "docs/README.md" }, + { "page": "Project-Profile", "path": "docs/00-project-profile.md" }, + { "page": "Development-Workflow", "path": "docs/01-workflow.md" }, + { "page": "Architecture-and-Code-Map", "path": "docs/02-architecture-and-code-map.md" }, + { "page": "Business-Rules-and-Glossary", "path": "docs/03-business-rules-and-glossary.md" }, + { "page": "Local-Development-and-Verification", "path": "docs/04-local-development-and-verification.md" }, + { "page": "Common-Changes", "path": "docs/05-common-changes.md" }, + { "page": "Troubleshooting", "path": "docs/06-troubleshooting.md" }, + { "page": "New-Project-Documentation-Setup", "path": "docs/07-new-project-documentation-setup.md" }, + { "page": "Existing-Project-Adoption-Guide", "path": "docs/08-existing-project-adoption.md" }, + { "page": "Product-Requirements-Overview", "path": "docs/09-product-requirements-overview.md" }, + { "page": "Delivery-Documentation-Guide", "path": "docs/delivery/README.md" }, + { "page": "Audience-Document-Template", "path": "docs/delivery/audience-document-template.md" }, + { "page": "Deployment-Template", "path": "docs/templates/deployment.md" }, + { "page": "Task-Archive-Template", "path": "docs/templates/task-archive.md" } + ] +}