引导提交:接入 DevHarness 并建立 chorus 项目文档

- 从 dev_harness (3696663) 复制流程骨架:AGENTS/CLAUDE、工单模板、harness 工具与测试、通用流程文档
- 按需求方案改写为 chorus:项目档案、架构与代码地图、业务规则、本地验证、常见修改、故障排查、产品需求总览
- 需求分期为 MVP-0(跑通全流程最小闭环)/ MVP-1 / MVP-2
- AGENTS.md 增加 12 条 chorus 专用红线

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ila
2026-08-20 11:29:12 +08:00
co-authored by Claude Opus 5
parent a206d1979e
commit 3895c873ec
30 changed files with 4419 additions and 0 deletions
+12
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
## 背景
<!-- 为什么要做这个产品或长期能力。 -->
## 目标
- <!-- 填写 -->
## 非目标
- <!-- 填写 -->
## 总体方案
<!-- 只写稳定的架构和关键决策,不复制子任务细节。 -->
## 阶段路线
1. <!-- 填写 -->
## MVP 与任务索引
- [ ] # MVP 工单
## 依赖、风险和回退
- <!-- 填写 -->
## 最终验收标准
- [ ] <!-- 填写 -->
+27
View File
@@ -0,0 +1,27 @@
## 基本信息
- 所属 Epic:#
## MVP 目标
<!-- 这个版本交付后,用户能够完成什么。 -->
## 包含范围
- <!-- 填写 -->
## 排除范围
- <!-- 填写 -->
## 阶段与单元任务
- [ ] # 单元任务
## 集成风险和回退
- <!-- 填写 -->
## MVP 验收标准
- [ ] <!-- 填写 -->
+103
View File
@@ -0,0 +1,103 @@
## 基本信息
- 类型:需求 / 缺陷 / 重构
- 所属 Epic:#
- 所属 MVP / 版本:#
- 阶段:
## 依赖与并行
- 前置工单:无 / #编号
- 是否允许与前置工单并行:是 / 否
- 原因:
## 子项目影响
<!-- 单应用项目填写唯一交付单元;多应用单仓库必须明确单端、跨端和共享契约影响。 -->
- 仅影响的子项目 / 交付单元:
- 是否跨子项目:是 / 否
- 是否修改共享接口或契约:是 / 否;唯一事实来源:
- 各子项目需要执行的验证:
## 原始需求
- 来源:用户对话 / Gitea / 其他
- 提出时间:
- 关键原话或脱敏摘要:
<!-- 只保留表达用户目的、场景和限制所需的内容;不要复制完整聊天、内部推理或敏感信息。 -->
## 要解决什么
<!-- 描述现状和目标。缺陷需要写清复现步骤、实际结果和期望结果。 -->
## 做什么 / 不做什么
- 做:
- 不做:
## 已确认方案
<!-- 写清修改范围、关键设计,以及是否影响接口、数据库和安全边界。 -->
预计修改文件:
- <!-- 填写 -->
## 需求变化记录
<!-- 只记录影响范围、接口、数据、风险或验收的变化;没有变化时填写“无”。 -->
| 日期 | 变化内容 | 原因 | 用户确认 |
|---|---|---|---|
| | | | 是 / 否 |
## 设计与原型门禁
<!-- 先判断是否属于纯显示文案豁免,再选择最低成本、足以确认的设计证据。 -->
- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug
- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据
- 可编辑设计源链接、版本或事实来源:
- 本地 HTML 审核快照路径和版本(不适用时说明原因):
- 本地浏览方式和资源完整性检查:
- 版本、revision 或确认日期:
- 状态:无 / 草稿 / 已确认 / 已废弃
- 确认人、确认时间和覆盖范围:
- 无需 UI 原型或无需任何原型的原因:
<!-- 新页面、独立用户功能、重大交互或导航变化:先生成 prototypes/<工单号>/<版本>/index.html 审核快照;原型和文字需求未确认前不得编写生产代码。 -->
## 文档影响
<!-- 至少选择一项;不影响长期文档时必须写明原因。 -->
- [ ] 不影响长期文档,原因:
- [ ] 更新项目档案或本地开发与验证
- [ ] 更新架构与代码地图
- [ ] 更新业务规则与术语
- [ ] 更新常见修改或故障排查
- [ ] 更新其他 Wiki 页面:
## 交付文档影响
<!-- 至少选择一项;面向用户、客户或其他岗位的行为、配置、部署、接口或支持方式变化时,必须列出受众和页面。 -->
- [ ] 无交付文档影响,原因:
- [ ] 更新已有交付文档,受众与页面:
- [ ] 新增交付文档,受众与页面:
- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:
## 验收标准
- [ ] <!-- 填写 -->
- [ ] <!-- 填写 -->
## 验证方式
<!-- 写出可复制的命令;需要真机、生产环境或人工检查时明确说明。 -->
## 风险和回退
<!-- 普通低风险任务可删除本节;涉及接口、迁移、安全或不可逆操作时必填。 -->
+11
View File
@@ -25,3 +25,14 @@ go.work.sum
# env file
.env
# Python 缓存(Harness 工具)
__pycache__/
*.pyc
# 本地运行产物
/data/
/uploads/
# Gitea 凭据,禁止提交
gitea.env
+233
View File
@@ -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. 项目专用规则
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
- 长期开发文档以 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 重新确认。
+31
View File
@@ -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 处理项目内容。
+60
View File
@@ -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 首个实现工单落地,规划见架构与代码地图。
+725
View File
@@ -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<number>\d+)-(?P<title>.+)$")
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())
+422
View File
@@ -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)
+121
View File
@@ -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 校验、密钥加解密和迁移的修改必须有针对性单元测试。
- 未执行或无法覆盖的验证必须记录到工单。
+314
View File
@@ -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` 镜像 |
|---|---:|---:|---:|
| 讨论过程和临时方案 | 是 | 否 | 否 |
| 实施进度和阻塞 | 是 | 否 | 否 |
| 长期有效的最终方案 | 链接 | 是 | 镜像 |
| 测试结果与未验证内容 | 是 | 任务归档 | 按需镜像 |
| 提交哈希 | 是 | 任务归档 | 按需镜像 |
| 与具体代码版本绑定的说明 | 可链接 | 提供入口 | 是 |
+102
View File
@@ -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`),预览列表不得直接加载原图。
+75
View File
@@ -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、模型和额度来源。
@@ -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/` 的影响;
- 未执行或无法覆盖的验证 → 已写进工单,不得默认“应该没问题”。
+93
View File
@@ -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`;
- 需要删除数据、清理生成物或执行任何不可逆操作;
- 修改会改变已确认原型的页面结构、流程、状态、权限或异常处理;
- 你不确定这属于哪一级风险。
+79
View File
@@ -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;
- 你已经在同一处试了两次仍不确定根因。
+202
View File
@@ -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 或负责人;
- 项目采用哪个建设基线,为什么适合二次开发,或者为什么选择从零开发;
- 采用开源基线时,上游仓库、基线版本、许可证、定制范围和升级策略是什么;
- 项目包含哪些子项目和独立交付单元,各自怎样构建、测试和发布;
- 跨子项目共享什么接口或契约,其唯一事实来源在哪里;
- 项目需要向哪些岗位交付什么文档,以及哪些内容不能对外提供。
回答不了的问题应继续补充主题文档,而不是堆入任务归档。
+227
View File
@@ -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 页面删除、重命名、历史清理或事实来源反向切换不是普通回退,必须另行建单并等待确认。
+221
View File
@@ -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 工单一致。
- [ ] 每项已交付需求具有验收入口。
- [ ] 原型具有路径或链接、版本和确认状态,或者明确写“无”。
- [ ] 本页没有复制完整工单或主题文档。
- [ ] 草稿原型没有被描述为正式需求。
- [ ] 不包含凭据、个人数据或生产数据。
+96
View File
@@ -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、镜像、原型或工单。
+72
View File
@@ -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.-)。本页不规定市场宣传、合同、法务或商务承诺。
@@ -0,0 +1,88 @@
# 岗位文档模板
> 使用说明:复制本模板创建具体岗位文档,删除说明性文字并填写真实内容。没有明确受众时不要创建文档;不得保留“待填写”后直接交付。
## 文档信息
| 字段 | 内容 |
|---|---|
| 文档名称 | |
| 适用对象 | 最终用户 / 管理员 / 运维 / 客服 / 集成人员 / 验收人员 / 其他 |
| 适用版本 | |
| 最后验证日期 | YYYY-MM-DD |
| 负责人 | |
| 可见范围 | 内部 / 指定客户 / 公开 |
| 相关产品或模块 | |
## 目的与适用范围
说明读者完成什么工作,以及本文包含和不包含什么。用岗位语言描述结果,不复制内部需求分析。
## 前置条件
- 所需权限:
- 所需环境或设备:
- 已完成的准备:
- 需要提前获得的信息:
不得在这里填写真实密码、令牌、个人数据或生产数据。
## 操作步骤
### 任务一:<明确的操作目标>
1. <执行动作>
2. <执行动作>
3. <执行动作>
**预期结果**:<读者可以观察到的成功结果>
**失败时**:<先检查什么;何时停止并联系支持>
每个独立任务重复以上结构。命令和界面名称应与适用版本一致;危险或不可逆操作必须在执行前给出醒目警告、影响和回退条件。
## 常见错误与恢复
| 现象或错误信息 | 可能原因 | 处理步骤 | 何时升级 |
|---|---|---|---|
| | | | |
只记录经过确认的原因和恢复方法。不要让外部读者执行内部调试、绕过权限或可能扩大损失的操作。
## 安全与权限
- 本岗位允许执行的操作:
- 明确禁止或需要审批的操作:
- 敏感信息处理规则:
- 数据、日志和截图脱敏要求:
- 删除、发布、迁移或其他高风险操作的确认要求:
## 已知限制
- 支持的环境和版本:
- 当前不支持的场景:
- 兼容性限制:
- 未验证的环境或步骤:
## 支持与升级处理
- 支持渠道:
- 服务时间或响应约定:
- 联系支持前需要收集的信息:
- 不得提交的信息:
- 需要升级到下一岗位或负责人的条件:
## 版本记录
| 日期 | 适用版本 | 变更内容 | 验证人 |
|---|---|---|---|
| | | | |
## 交付前检查
- [ ] 目标岗位能够理解术语和步骤。
- [ ] 前置条件、步骤与预期结果一一对应。
- [ ] 关键流程已按目标岗位视角验证。
- [ ] 常见错误、恢复方法和升级条件清楚。
- [ ] 没有内部工单、内部地址、敏感数据或无关源码细节。
- [ ] 适用版本、最后验证日期、负责人和可见范围已填写。
View File
+274
View File
@@ -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>
```
**预期结果**:健康检查全部通过。
> **注意**:已执行的数据库迁移通常不能通过切回代码撤销。存在迁移时必须先确认迁移是否向后兼容;不兼容时按备份恢复流程处理,并停止自行操作、联系负责人。
### 备份与恢复
<!-- 记录备份对象、频率、保存位置、保留期和恢复步骤;无持久化数据时说明原因。 -->
## 已知限制
<!-- 记录本环境无法验证的部分,例如未做过真实回滚演练、未验证高并发表现、灰度或多机部署尚未支持。不得留空,无限制时写“无”。 -->
- <!-- 填写 -->
+42
View File
@@ -0,0 +1,42 @@
# <工单号> <标题>
- 类型:需求 / 缺陷 / 重构
- 所属 Epic:#
- 所属 MVP / 版本:#
- 状态:待验收 / 已完成
- 日期:YYYY-MM-DD
- Gitea 工单:<链接>
- Wiki 页面:<页面名>
- Wiki revision:见本地镜像头
## 背景与目标
<!-- 原来有什么问题,这次达到什么结果。 -->
## 最终方案
<!-- 说明实际实现。与建单方案不同之处必须写清原因。 -->
## 修改文件
- `<文件>`:<改动说明>
## 验收结果
| 验收标准 | 结果 |
|---|---|
| | 通过 / 未通过 |
## 测试
- 执行命令:`<命令>`
- 结果:
- **未验证部分**:<!-- 必填;没有就写“无”。 -->
## 遗留问题
<!-- 没有就删除本节。 -->
## 相关提交
- `<提交哈希>` <提交说明>
View File
+302
View File
@@ -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()
+318
View File
@@ -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()
+23
View File
@@ -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" }
]
}