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