From a4ac398ca0b7ced24fbea7b209c7557bcb048aa2 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 17 Aug 2026 11:27:09 +0800 Subject: [PATCH] docs: prepare Wiki-first migration (#43) --- .gitea/issue_template/task.md | 54 +++- .gitignore | 2 + AGENTS.md | 75 +++-- CLAUDE.md | 9 +- dev_scripts/export_task_archives.py | 131 +++++++++ dev_scripts/new_task_archive.py | 87 ++++++ dev_scripts/sync_wiki_docs.py | 33 +++ dev_scripts/wiki_docs.py | 422 ++++++++++++++++++++++++++++ docs/00-project-profile.md | 44 ++- docs/01-workflow.md | 106 +++++-- docs/05-common-changes.md | 52 +++- docs/07-mvp-requirements.md | 67 +++-- docs/09-delivery-issues.md | 21 ++ docs/README.md | 74 ++++- docs/templates/task-archive.md | 42 +++ tests/test_wiki_docs.py | 319 +++++++++++++++++++++ wiki-docs.json | 22 ++ 17 files changed, 1457 insertions(+), 103 deletions(-) create mode 100644 dev_scripts/export_task_archives.py create mode 100644 dev_scripts/new_task_archive.py create mode 100644 dev_scripts/sync_wiki_docs.py create mode 100644 dev_scripts/wiki_docs.py create mode 100644 docs/templates/task-archive.md create mode 100644 tests/test_wiki_docs.py create mode 100644 wiki-docs.json diff --git a/.gitea/issue_template/task.md b/.gitea/issue_template/task.md index 590d3d6..044cfad 100644 --- a/.gitea/issue_template/task.md +++ b/.gitea/issue_template/task.md @@ -1,21 +1,21 @@ ## 基本信息 -- 类型:需求 / 缺陷 / 重构 -- 所属 Epic:# -- 所属 MVP / 版本:# -- 阶段:待实施 +- 类型:需求 / 缺陷 / 重构 / 文档 +- 所属 Epic:# / 无 +- 所属 MVP / 版本:# / 无 +- 阶段:待确认 / 待实施 / 进行中 / 阻塞 / 待验收 ## 依赖与并行 - 前置工单:无 / #编号 -- 是否允许与前置工单并行:是 / 否 +- 是否允许与未完成的前置工单并行:是 / 否 - 原因: ## 子项目影响 -- 仅影响的子项目 / 交付单元:server / web / android / shared-docs +- 仅影响的子项目 / 交付单元:server / web / android / prototypes / shared-docs - 是否跨子项目:是 / 否 -- 是否修改共享接口或契约:是 / 否;唯一事实来源:`docs/08-agent-api-contract.md` +- 是否修改共享接口或契约:是 / 否;唯一事实来源:Wiki `Android-Agent-API-Contract` - 各子项目需要执行的验证: ## 原始需求 @@ -24,8 +24,12 @@ - 提出时间: - 关键原话或脱敏摘要: + + ## 要解决什么 + + ## 做什么 / 不做什么 - 做: @@ -33,6 +37,8 @@ ## 已确认方案 + + 预计修改文件: - (填写) @@ -43,14 +49,34 @@ |---|---|---|---| | | 无 | | 是 | +## 设计与原型门禁 + +- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug +- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据 +- 证据链接、Git 路径或事实来源: +- App ID、版本、revision 或确认日期: +- 状态:无 / 草稿 / 已确认 / 已废弃 +- 确认人、确认时间和覆盖范围: +- 无需 UI 原型或无需任何原型的原因: + + + ## 文档影响 - [ ] 不影响长期文档,原因: -- [ ] 更新项目档案或本地开发与验证 -- [ ] 更新架构与代码地图 -- [ ] 更新业务规则与术语 -- [ ] 更新 API 契约 -- [ ] 更新常见修改或故障排查 +- [ ] 更新项目档案或本地开发与验证 Wiki +- [ ] 更新架构与代码地图 Wiki +- [ ] 更新业务规则与术语 Wiki +- [ ] 更新 API 契约 Wiki +- [ ] 更新产品需求、常见修改或故障排查 Wiki +- [ ] 创建或更新 Wiki 任务归档;本地快照仅在用户明确要求时导出 + +## 交付文档影响 + +- [ ] 无交付文档影响,原因: +- [ ] 更新已有交付文档,受众与页面: +- [ ] 新增交付文档,受众与页面: +- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式: ## 验收标准 @@ -58,4 +84,8 @@ ## 验证方式 + + ## 风险和回退 + + diff --git a/.gitignore b/.gitignore index d3a4cfa..7dc0a25 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ /android/.gradle/ /android/local.properties /android/**/build/ +__pycache__/ +*.py[cod] diff --git a/AGENTS.md b/AGENTS.md index 7369af0..7f6c6fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,45 +1,82 @@ # Agent 开发规则 -本仓库采用 DevHarness 工作流:Gitea 工单记录任务过程,Git 保存源码与版本绑定文档。当前长期文档事实来源是仓库 `docs/`;接入 Gitea Wiki 同步后再切换为 Wiki 主源,切换必须单独建单。 +本仓库采用 DevHarness 工作流:Gitea 工单记录任务过程,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 保存源码、版本绑定资料和 Wiki 的本地镜像。`docs/` 中显式映射的 Markdown 是 Wiki 只读镜像;`docs/task/` 只保存用户明确要求导出的任务归档快照。 -开始工作前阅读 `docs/00-project-profile.md`、`docs/03-business-rules-and-glossary.md` 和当前工单。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`。 +当前文档规则参考 DevHarness 提交 `b1f500128d6eb100985792d4a715db8b6b5ae203`,但所有模板内容都必须按 GoAuto 事实改写。开始工作前阅读 `docs/00-project-profile.md`、`docs/03-business-rules-and-glossary.md` 和当前工单。不同交付单元规则不同时,在 `server/`、`web/` 或 `android/` 下增加更具体的 `AGENTS.md`。 ## 永久规则 - 不把密码、Token、Cookie、私钥、PDD 账号凭据、个人数据或生产数据写入代码、日志、工单和文档。 - 不执行付款;任何自动支付实现、入口或测试都禁止进入本项目。 -- 当前 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情,不提前实现顺云宝、Shopee、采购、批量任务、规则发布流程、全局停机或实时屏幕。 +- 当前采集 MVP 只实现 PDD 商品、规则、任务、Android 执行和任务详情。采购是独立的后续高风险 MVP,未通过对应原型和工单门禁前不能混入采集代码。 - 不保存原始控件树和设备截图;只保存结构化任务日志、错误码、任务规则快照和采集结果。 -- 一台设备同一时刻只执行一个任务;手机离线时当前任务失败,默认不重试、不自动换机。 +- 一台设备同一时刻只执行一个任务;手机离线时当前采集任务失败,默认不重试、不自动换机。 - 找不到控件、验证码、风控、人机验证或登录失效时明确失败,不使用 OCR/VLM,不猜测规格,不点击相近候选。 - SKU 数据不完整仍须提交并允许在任务详情查看,状态记为 `completed_partial`。 - 规则创建即生效;删除后不能创建新任务,但已有任务继续使用自身规则快照。 -- 同一 PDD 商品不能同时存在多个 `pending` 或 `running` 任务。 -- 重置任务保留 URL、goods_id、规则和设备快照,事务清空旧结果后重新进入 `pending`。 +- 同一 PDD 商品不能同时存在多个 `pending` 或 `running` 采集任务。 +- 重置采集任务保留 URL、goods_id、规则和设备快照,事务清空旧结果后重新进入 `pending`。 - 保留与当前工单无关的工作区改动,不重置、不覆盖、不顺手修改。 +- 测试结果必须真实;未执行或无法覆盖的真机、多设备、云环境和高风险行为必须明确记录。 ## 工单门禁 -- 新功能、缺陷、接口、数据库、权限、并发、状态机和 UI 变化必须先有单元工单。 +- 新功能、缺陷修复、重构,以及接口、数据库、权限、并发、状态机、安全或用户界面变化必须先有单元工单。 +- 只改错别字、注释、文档措辞或不改变含义的纯显示文案时可以免工单;有任何行为、布局、状态或安全含义不确定时不得使用豁免。 - Epic 和 MVP 只维护目标与子工单索引;单元任务是唯一实施单位。 -- 工单必须记录原始需求摘要、前置依赖、是否可并行、子项目影响、方案、验收、验证、风险和文档影响。 -- 实施前检查依赖;真实依赖未满足时保持待实施。 +- 工单必须记录原始需求摘要、前置依赖、是否可并行、子项目影响、方案、设计证据、验收、验证、风险和文档影响。 +- 实施前检查依赖;真实依赖未满足且不允许并行时保持待实施。 - 用户未明确验收前,工单保持待验收,不关闭。 -## 实施规则 +## 工单与设计证据双门禁 -1. 先确认目标、非目标和当前事实。 -2. 只修改工单声明的交付单元和共享契约。 -3. 共享 API 以 `docs/08-agent-api-contract.md` 为唯一事实来源。 -4. 数据库和接口变化必须同步更新架构、业务规则和 API 文档。 -5. 每个动作结果必须与 `taskId`、`deviceId` 和任务内的 `ruleSnapshot` 关联。 -6. Android Agent 必须使用任务租约和本地互斥锁保证串行。 -7. 服务端必须以最终包名、Activity 和页面证据验证动作,不只相信 Portal 的 success 响应。 -8. 测试结果必须真实;未验证的真机、多设备和云环境行为写入工单。 +- 纯显示文案只有在不改变业务含义、流程、权限、状态、接口、数据、安全、支付、金额、单位、程序标识符、布局和可访问性时才免原型,并执行最小界面检查。 +- 现有界面的小范围样式或布局调整至少提供标注截图、低保真图或明确复用的现有规范。 +- 新组件记录正常、空、加载、失败、禁用和权限边界;按需提供低保真图。 +- 新页面、独立用户功能、重大交互或导航变化必须先制作 QuantUX 或其他可审阅原型;用户确认文字需求、原型和覆盖范围后才能编写生产代码。 +- 后端、接口、数据和定时任务不强制 UI 原型,但必须先确认架构、API、数据、状态或流程设计。 +- 原型记录链接或 Git 路径、App ID/版本、草稿或已确认状态、确认人、确认时间和覆盖范围。草稿不能作为正式实现依据。 +- 页面结构、主要流程、状态、权限、异常处理或验收结果变化时,先更新设计证据并重新确认。 + +## 需求记录与实施 + +1. 先确认目标、非目标和当前事实,区分代码事实、用户确认规则和假设。 +2. 创建单元工单时记录来源、提出时间和表达目的所需的少量关键原话或脱敏摘要;不复制完整聊天或 Agent 内部推理。 +3. 只修改工单声明的交付单元和共享契约;新发现的相邻问题记录或另建工单,不混入当前任务。 +4. 共享 API 以 `docs/08-agent-api-contract.md` 为唯一事实来源。 +5. 数据库和接口变化必须同步更新架构、业务规则和 API 文档。 +6. 每个动作结果必须与 `taskId`、`deviceId` 和任务内的 `ruleSnapshot` 关联。 +7. Android Agent 必须使用任务租约和本地互斥锁保证串行。 +8. 服务端必须以最终包名、Activity 和页面证据验证动作,不只相信 Portal 的 success 响应。 +9. 执行与风险相称的测试,把实现、验证、未验证项和提交哈希回写工单。 + +## 自然语言快捷指令 + +快捷指令只是本工作流的别名,不能绕过方案确认、前置依赖、安全规则、工单范围、必要验证或人工验收: + +- `只分析`:只读检查并给出方案;不建单、不修改。 +- `建工单`:根据已确认方案创建单元工单;建单后停止。 +- `执行工单 #N`:检查工单和依赖,实施、测试、提交并回写证据;停在待验收。 +- `建工单并做`:依次建单和执行;停在待验收。 +- `继续工单 #N`:从首个未完成步骤继续,不重复仍然有效的检查。 +- `检查工单 #N`:只读核对范围、验收、测试和证据;不自动修复。 +- `同步文档`:读取 Wiki、导出核心 `docs/` 镜像并检查一致性;不修改 Wiki、不导出任务归档、不自动提交。 +- `导出任务归档`:仅在用户明确提出时增量导出 Wiki 任务归档;`导出全部任务归档` 才执行全量导出。 +- `#N 验收通过`:仅在用户明确验收后更新任务归档、同步必要镜像、关闭工单并同步父工单。 + +## 效率与停止条件 + +- 优先执行能产生真实反馈的最小命令,采用“执行 → 首个真实错误 → 最小修复 → 继续”的闭环。 +- 同一任务和同一环境中已经验证的事实不重复检查;环境、配置、代码或关键前提变化后才重新验证。 +- 不主动增加与验收无关的文档、脚本、框架、重构或扩展性设计。 +- 涉及凭据、权限、安全、迁移、并发、删除、发布、创建订单或其他不可逆动作时,先完成相应门禁,不通过试错获取风险反馈。 +- 完成工单范围、必要测试、文档影响和证据回写后立即停止;未影响当前验收的相邻问题只提示或另建单。 +- 长期文档固定顺序为:修改 Wiki → 读取确认 → 导出核心 `docs/` → 检查一致性 → 提交镜像。不得直接编辑镜像后反向覆盖 Wiki。 ## Git 与验收 - 提交只包含当前工单内容,提交信息引用工单号。 - 优先运行项目档案记录的格式、单元、契约和集成测试。 -- 涉及自动提交订单、权限、安全、并发、迁移和删除数据属于高风险,必须再次等待人工确认。 +- 涉及创建订单、权限、安全、并发、迁移和删除数据属于高风险,真机或正式实施前必须再次等待人工确认。 - 完成后将实现、验证、遗留问题和提交哈希回写工单,等待用户验收。 +- 用户未明确要求时,不导出 `docs/task/`;任务归档默认只保存在 Wiki。 diff --git a/CLAUDE.md b/CLAUDE.md index 6711858..f7e316f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,4 +2,11 @@ @AGENTS.md -共同开发规则以 `AGENTS.md` 为唯一入口。本文件不重复业务规则;Claude Code 在修改前必须读取当前工单和受影响交付单元文档。 +## Claude Code 专用说明 + +- `AGENTS.md` 是所有编码 Agent 的共同规则事实来源;本文件不重复业务规则。 +- 目标、范围和修改位置明确时使用常规开发模型完成分析、建单和实施。 +- 需求模糊、根因不明,或涉及跨模块架构、安全、权限、并发、迁移、创建订单和不可逆操作时,先使用更强推理模型制定方案并等待确认。 +- 轻量模型只处理范围明确的只读查找、文档读取和日志事实提取,不决定最终根因、风险等级、技术方案或验收结论。 +- Agent 交接必须包含目标、非目标、事实、假设、方案、修改范围、风险、回退和验收标准。 +- 只读子 Agent 必须通过工具权限保证只读;未配置只读权限时不得让它接触项目写操作。 diff --git a/dev_scripts/export_task_archives.py b/dev_scripts/export_task_archives.py new file mode 100644 index 0000000..8923af0 --- /dev/null +++ b/dev_scripts/export_task_archives.py @@ -0,0 +1,131 @@ +"""把 Gitea Wiki 任务归档人工按需导出到 docs/task。""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path +from typing import Any + +from new_task_archive import safe_title +from wiki_docs import ( + DEFAULT_CONFIG, + ROOT, + WikiClient, + WikiDocsError, + dirty_paths, + load_config, + parse_mirror, + write_mirror, +) + + +TASK_PAGE_PATTERN = re.compile(r"^Task-(?P\d+)-(?P.+)$") + + +def task_revision(metadata: dict[str, Any], page_name: str) -> str: + last_commit = metadata.get("last_commit") + revision = last_commit.get("sha") if isinstance(last_commit, dict) else None + if not isinstance(revision, str) or not revision: + raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}") + return revision + + +def existing_task_mirrors(root: Path = ROOT) -> dict[str, Path]: + """按镜像头匹配已有文件,兼容历史自定义文件名。""" + + mirrors: dict[str, Path] = {} + task_dir = root / "docs" / "task" + if not task_dir.is_dir(): + return mirrors + for path in task_dir.glob("*.md"): + try: + metadata, _ = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + raise WikiDocsError(f"已有任务镜像无效 {path.name}:{exc}") from exc + page_name = metadata.get("wiki_page", "") + if not TASK_PAGE_PATTERN.fullmatch(page_name): + raise WikiDocsError(f"已有任务镜像页面名无效 {path.name}:{page_name}") + if page_name in mirrors: + raise WikiDocsError(f"任务页面存在重复本地镜像:{page_name}") + mirrors[page_name] = path + return mirrors + + +def task_target(page_name: str, root: Path = ROOT) -> Path: + match = TASK_PAGE_PATTERN.fullmatch(page_name) + if match is None: + raise WikiDocsError(f"不是任务归档页面:{page_name}") + title = safe_title(match.group("title")) + if not title: + raise WikiDocsError(f"任务归档标题无效:{page_name}") + return root / "docs" / "task" / f"{match.group('number')}-{title}.md" + + +def export_task_archives( + client: WikiClient, *, export_all: bool = False, root: Path = ROOT +) -> list[str]: + """增量或全量读取任务归档;绝不删除本地文件。""" + + dirty = dirty_paths(["docs/task"], root) + if dirty: + raise WikiDocsError( + "本地任务镜像存在未提交改动,已停止以防覆盖:\n" + "\n".join(dirty) + ) + + existing = existing_task_mirrors(root) + pages = [] + for metadata in client.list_pages(): + title = metadata.get("title") + if isinstance(title, str) and TASK_PAGE_PATTERN.fullmatch(title): + pages.append((int(title.split("-", 2)[1]), title, metadata)) + pages.sort(key=lambda item: (item[0], item[1])) + + messages: list[str] = [] + targets: set[Path] = set() + for _, page_name, metadata in pages: + target = existing.get(page_name, task_target(page_name, root)) + if target in targets: + raise WikiDocsError(f"多个任务页面映射到同一本地路径:{target.name}") + targets.add(target) + revision = task_revision(metadata, page_name) + if not export_all and target.is_file(): + local_metadata, _ = parse_mirror(target.read_text(encoding="utf-8")) + if ( + local_metadata.get("wiki_page") == page_name + and local_metadata.get("wiki_revision") == revision + ): + messages.append(f"跳过:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + continue + page = client.get_page_from_metadata(metadata, page_name) + changed = write_mirror(target, page) + action = "已导出" if changed else "无变化" + messages.append(f"{action}:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + return messages + + +def main() -> int: + parser = argparse.ArgumentParser(description="人工按需导出 Gitea Wiki 任务归档") + parser.add_argument( + "--all", action="store_true", help="全量读取全部线上任务归档;默认按 revision 增量" + ) + parser.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置" + ) + args = parser.parse_args() + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/new_task_archive.py b/dev_scripts/new_task_archive.py new file mode 100644 index 0000000..25b88fe --- /dev/null +++ b/dev_scripts/new_task_archive.py @@ -0,0 +1,87 @@ +"""只在 Gitea Wiki 创建任务归档;本地镜像由人工按需导出。""" + +from __future__ import annotations + +import argparse +import re +from datetime import date +from pathlib import Path + +from wiki_docs import ( + DEFAULT_CONFIG, + WikiClient, + WikiDocsError, + load_config, +) + + +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) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="在 Gitea Wiki 创建任务归档,不自动导出本地镜像" + ) + parser.add_argument("issue_number", help="Gitea 工单号,例如 123") + parser.add_argument("title", help="简短任务标题") + parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置") + args = parser.parse_args() + + 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("未导出本地任务归档;需要时运行 export_task_archives.py") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/sync_wiki_docs.py b/dev_scripts/sync_wiki_docs.py new file mode 100644 index 0000000..25a30cf --- /dev/null +++ b/dev_scripts/sync_wiki_docs.py @@ -0,0 +1,33 @@ +"""从 Gitea Wiki 单向导出配置中的核心 docs 镜像。""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sync_all + + +def main() -> int: + parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步核心 docs 镜像") + parser.add_argument( + "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" + ) + parser.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" + ) + args = parser.parse_args() + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/wiki_docs.py b/dev_scripts/wiki_docs.py new file mode 100644 index 0000000..6b1008c --- /dev/null +++ b/dev_scripts/wiki_docs.py @@ -0,0 +1,422 @@ +"""Gitea Wiki 到本地 docs 镜像的共享实现。""" + +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_CONFIG = ROOT / "wiki-docs.json" +MIRROR_START = "<!-- gitea-wiki-mirror:start -->" +MIRROR_END = "<!-- gitea-wiki-mirror:end -->" +HEADER_PATTERN = re.compile( + rf"\A{re.escape(MIRROR_START)}\n(?P<metadata>.*?)\n" + rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z", + re.DOTALL, +) + + +class WikiDocsError(RuntimeError): + """可供命令行直接展示的 Wiki 文档错误。""" + + +@dataclass(frozen=True) +class Mapping: + page: str + path: str + + +@dataclass(frozen=True) +class Config: + path: Path + gitea_url: str + owner: str + repository: str + mappings: tuple[Mapping, ...] + + +@dataclass(frozen=True) +class WikiPage: + title: str + sub_url: str + text: str + revision: str + html_url: str + + +def _required_string(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value.strip(): + raise WikiDocsError(f"配置字段 {key!r} 必须是非空字符串") + return value.strip() + + +def validate_mappings(raw_mappings: Any) -> tuple[Mapping, ...]: + """校验显式页面映射,确保只会写入 docs 下的 Markdown。""" + + if not isinstance(raw_mappings, list) or not raw_mappings: + raise WikiDocsError("配置字段 'mappings' 必须是非空数组") + + mappings: list[Mapping] = [] + pages: set[str] = set() + paths: set[str] = set() + for index, item in enumerate(raw_mappings, start=1): + if not isinstance(item, dict): + raise WikiDocsError(f"第 {index} 个映射必须是对象") + page = _required_string(item, "page") + path = _required_string(item, "path").replace("\\", "/") + pure_path = PurePosixPath(path) + if ( + pure_path.is_absolute() + or ".." in pure_path.parts + or not pure_path.parts + or pure_path.parts[0] != "docs" + or pure_path.suffix.lower() != ".md" + ): + raise WikiDocsError(f"镜像路径必须是 docs/ 下的 Markdown:{path}") + if page in pages: + raise WikiDocsError(f"Wiki 页面重复映射:{page}") + if path in paths: + raise WikiDocsError(f"本地路径重复映射:{path}") + pages.add(page) + paths.add(path) + mappings.append(Mapping(page=page, path=path)) + return tuple(mappings) + + +def load_config(path: Path = DEFAULT_CONFIG) -> Config: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise WikiDocsError(f"无法读取 Wiki 映射配置 {path}: {exc}") from exc + if not isinstance(raw, dict) or raw.get("schema_version") != 1: + raise WikiDocsError("wiki-docs.json 的 schema_version 必须为 1") + configured_url = _required_string(raw, "gitea_url") + gitea_url = os.environ.get("GITEA_URL", configured_url).rstrip("/") + if gitea_url.endswith("/api/v1"): + gitea_url = gitea_url[: -len("/api/v1")] + return Config( + path=path, + gitea_url=gitea_url, + owner=_required_string(raw, "owner"), + repository=_required_string(raw, "repository"), + mappings=validate_mappings(raw.get("mappings")), + ) + + +class WikiClient: + """只使用标准库访问 Gitea Wiki API。""" + + def __init__(self, config: Config, token: str | None = None) -> None: + self.config = config + self.token = token if token is not None else os.environ.get("GITEA_TOKEN") + + def _request( + self, + method: str, + api_path: str, + *, + payload: dict[str, Any] | None = None, + query: dict[str, Any] | None = None, + ) -> Any: + url = f"{self.config.gitea_url}/api/v1{api_path}" + if query: + url = f"{url}?{urlencode(query)}" + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + data = None + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + body = response.read() + except HTTPError as exc: + if self.token and method == "GET" and exc.code in {401, 403, 404}: + # 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于 + # 另一个实例或已失效。只对只读请求安全降级为匿名访问。 + anonymous_headers = {"Accept": "application/json"} + anonymous_request = Request( + url, data=data, headers=anonymous_headers, method=method + ) + try: + with urlopen(anonymous_request, timeout=30) as response: + body = response.read() + except HTTPError as anonymous_exc: + detail = anonymous_exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 " + f"{anonymous_exc.code}: {detail}" + ) from anonymous_exc + except URLError as anonymous_exc: + raise WikiDocsError( + f"无法连接 Gitea:{anonymous_exc.reason}" + ) from anonymous_exc + else: + detail = exc.read().decode("utf-8", errors="replace") + raise WikiDocsError( + f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}" + ) from exc + except URLError as exc: + raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc + if not body: + return None + try: + return json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise WikiDocsError("Gitea API 返回了无效的 UTF-8 JSON") from exc + + def list_pages(self) -> list[dict[str, Any]]: + pages: list[dict[str, Any]] = [] + page_number = 1 + while True: + batch = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/pages", + query={"page": page_number, "limit": 50}, + ) + if not isinstance(batch, list): + raise WikiDocsError("Gitea Wiki 页面列表格式无效") + pages.extend(item for item in batch if isinstance(item, dict)) + if len(batch) < 50: + return pages + page_number += 1 + + def get_page(self, page_name: str) -> WikiPage: + metadata = next( + ( + item + for item in self.list_pages() + if item.get("title") == page_name or item.get("sub_url") == page_name + ), + None, + ) + if metadata is None: + raise WikiDocsError( + f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像" + ) + return self.get_page_from_metadata(metadata, page_name) + + def get_page_from_metadata( + self, metadata: dict[str, Any], page_name: str | None = None + ) -> WikiPage: + """使用页面列表元数据读取正文,避免重复获取完整页面列表。""" + + resolved_name = page_name or _required_string(metadata, "title") + sub_url = _required_string(metadata, "sub_url") + page = self._request( + "GET", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/page/" + f"{quote(sub_url, safe='%')}", + ) + if not isinstance(page, dict): + raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}") + encoded_content = page.get("content_base64") + if not isinstance(encoded_content, str): + raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_name}") + try: + text = base64.b64decode(encoded_content, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise WikiDocsError( + f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_name}" + ) from exc + last_commit = page.get("last_commit") + revision = last_commit.get("sha") if isinstance(last_commit, dict) else None + if not isinstance(revision, str) or not revision: + raise WikiDocsError(f"Wiki 页面缺少 revision:{resolved_name}") + title = page.get("title") + resolved_title = title if isinstance(title, str) and title else resolved_name + html_url = ( + f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}" + ) + return WikiPage( + title=resolved_title, + sub_url=sub_url, + text=normalize_body(text), + revision=revision, + html_url=html_url, + ) + + def create_page(self, title: str, content: str, message: str) -> WikiPage: + if not self.token: + raise WikiDocsError("创建 Wiki 页面需要通过 GITEA_TOKEN 提供写入令牌") + encoded = base64.b64encode(content.encode("utf-8")).decode("ascii") + self._request( + "POST", + f"/repos/{quote(self.config.owner, safe='')}/" + f"{quote(self.config.repository, safe='')}/wiki/new", + payload={"title": title, "content_base64": encoded, "message": message}, + ) + return self.get_page(title) + + +def normalize_body(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n" + + +def parse_mirror(text: str) -> tuple[dict[str, str], str]: + match = HEADER_PATTERN.match(text.replace("\r\n", "\n").replace("\r", "\n")) + if match is None: + raise WikiDocsError("缺少或损坏 gitea-wiki-mirror 元数据头") + metadata: dict[str, str] = {} + for line in match.group("metadata").splitlines(): + key, separator, value = line.partition(": ") + if not separator or not key or not value: + raise WikiDocsError(f"无效的镜像元数据行:{line}") + metadata[key] = value + return metadata, normalize_body(match.group("body")) + + +def render_mirror(page: WikiPage, existing: str | None = None) -> str: + synchronized_at: str | None = None + if existing is not None: + try: + metadata, body = parse_mirror(existing) + except WikiDocsError: + pass + else: + if metadata.get("wiki_revision") == page.revision and body == page.text: + synchronized_at = metadata.get("synchronized_at") + if not synchronized_at: + synchronized_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace( + "+00:00", "Z" + ) + header = "\n".join( + ( + MIRROR_START, + "generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)", + f"wiki_page: {page.title}", + f"wiki_url: {page.html_url}", + f"wiki_revision: {page.revision}", + f"synchronized_at: {synchronized_at}", + MIRROR_END, + ) + ) + return f"{header}\n\n{page.text}" + + +def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]: + return dirty_paths([mapping.path for mapping in config.mappings], root) + + +def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]: + """返回指定路径中已有、修改或未跟踪的工作区条目。""" + + if not paths: + return [] + result = subprocess.run( + ["git", "status", "--porcelain", "--", *paths], + cwd=root, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return [line for line in result.stdout.splitlines() if line.strip()] + + +def _write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + handle, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", suffix=".tmp", dir=path.parent + ) + try: + with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + os.replace(temporary_name, path) + except BaseException: + Path(temporary_name).unlink(missing_ok=True) + raise + + +def write_mirror(path: Path, page: WikiPage) -> bool: + """写入一份 Wiki 镜像;内容无变化时返回 False。""" + + existing = path.read_text(encoding="utf-8") if path.is_file() else None + rendered = render_mirror(page, existing) + if existing == rendered: + return False + _write_atomic(path, rendered) + return True + + +def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]: + if not path.is_file(): + return [f"缺少镜像:{mapping.path}"] + try: + metadata, body = parse_mirror(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + return [f"镜像无效 {mapping.path}: {exc}"] + expected = { + "wiki_page": page.title, + "wiki_url": page.html_url, + "wiki_revision": page.revision, + } + errors = [ + f"{mapping.path} 的 {key} 不一致" + for key, value in expected.items() + if metadata.get(key) != value + ] + if not metadata.get("synchronized_at"): + errors.append(f"{mapping.path} 缺少 synchronized_at") + if body != page.text: + errors.append(f"{mapping.path} 的正文与 Wiki 不一致") + return errors + + +def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]: + """检查或写入所有显式映射;绝不处理映射外的文件。""" + + if not check: + dirty = dirty_mirror_paths(config) + if dirty: + details = "\n".join(dirty) + raise WikiDocsError( + "已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details + ) + + messages: list[str] = [] + for mapping in config.mappings: + page = client.get_page(mapping.page) + target = ROOT / PurePosixPath(mapping.path) + if check: + errors = check_mirror(mapping, page, target) + if errors: + raise WikiDocsError("\n".join(errors)) + messages.append(f"一致:{mapping.path} <- {page.title}@{page.revision[:12]}") + continue + existing = target.read_text(encoding="utf-8") if target.is_file() else None + rendered = render_mirror(page, existing) + if existing != rendered: + _write_atomic(target, rendered) + messages.append(f"已更新:{mapping.path} <- {page.title}@{page.revision[:12]}") + else: + messages.append(f"无变化:{mapping.path} <- {page.title}@{page.revision[:12]}") + return messages + + +def append_mapping(config: Config, mapping: Mapping) -> None: + raw = json.loads(config.path.read_text(encoding="utf-8")) + mappings = validate_mappings(raw.get("mappings")) + if any(item.page == mapping.page or item.path == mapping.path for item in mappings): + raise WikiDocsError(f"页面或路径已经登记:{mapping.page} -> {mapping.path}") + raw["mappings"].append({"page": mapping.page, "path": mapping.path}) + rendered = json.dumps(raw, ensure_ascii=False, indent=2) + "\n" + _write_atomic(config.path, rendered) diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index b0d0bff..787396f 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -4,23 +4,46 @@ | 项目 | 内容 | |---|---| -| 项目名称 | GoAuto 移动采集管理平台 | -| 一句话目标 | 管理 Android 手机并从 PDD App 采集商品与规格数据 | -| 主要使用者 | 管理员、开发维护者 | +| 项目名称 | GoAuto 移动采集与采购管理平台 | +| 一句话目标 | 管理 Android 手机,采集 PDD 商品资料,并在独立高风险阶段支持创建待付款采购订单 | +| 主要使用者 | 管理员、采购人员、开发维护者 | | Gitea 仓库 | `OPC/goauto` | | 默认分支 | `main` | -| 当前 MVP | PDD 商品、规则、任务、Android 执行、任务详情结果 | -| 预计规模 | 20 台 Android;每天约 100 个采集任务 | +| 当前已实施范围 | PDD 商品 URL、采集规则、采集任务、Android 执行、任务详情结果 | +| 后续设计范围 | PDD 正式商品档案、Shopee/SYB 商品关系、采购演练、创建待付款订单和物流回填 | +| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 | + +## 建设基线 + +| 基线 | 来源与版本 | 许可证 / 使用方式 | GoAuto 适配 | +|---|---|---|---| +| DevHarness | `D:\OPC\dev_harness`,目标提交 `b1f500128d6eb100985792d4a715db8b6b5ae203` | 开发流程与文档模板 | 2026-08-17 增量升级;保留 GoAuto 专用规则并启用 Wiki-first | +| 服务端 | `go-admin` v2.3.0 | 上游开源管理端基线;升级时复核许可证和安全公告 | 保留认证、菜单、配置和管理端基础能力,新增 GoAuto 业务模块 | +| 管理端 | `go-admin-ui` v3.0.0,`web/package.json` 标注 MIT | Vue 管理界面基线 | 保留应用外壳与通用组件,新增 GoAuto 页面 | +| Android | 原生 Kotlin Agent | 自研业务客户端 | 通过 HTTPS 直连服务端,不保留 Windows 桌面 Client/ADB 作为生产拓扑 | + +当前仓库没有记录最初采用的 DevHarness 完整提交,因此不能声称一个未经验证的旧基线。本次以现有 GoAuto 文档为旧事实,以以上目标提交为可复现的新基线。以后升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。 ## 交付单元 | 目录 | 职责 | 技术栈 | 独立验证 | |---|---|---|---| -| `server/` | go-admin API、任务调度、规则、设备连接、结果持久化 | Go 1.26.5、go-admin v2.3.0、关系数据库 | Go 单元与构建验证 | +| `server/` | go-admin API、任务调度、规则、设备连接、结果持久化 | Go 1.26.5、go-admin v2.3.0、MySQL 8.4 | Go 单元与构建验证 | | `web/` | go-admin-ui 管理端 | Vue、go-admin-ui v3.0.0、pnpm | lint、生产构建、浏览器验收 | | `android/` | Portal/Agent、注册保活、任务执行和结果提交 | Kotlin 1.9.22、Android SDK 34 | Android 单元测试、APK 构建与真机验收 | -| `prototypes/` | 服务端和 Android 交互原型 | HTML/CSS/JavaScript | 静态检查、浏览器人工验收 | -| `docs/` | Harness Coding 和共享契约 | Markdown | 链接与结构检查 | +| `prototypes/` | 与代码版本绑定的服务端和 Android HTML 原型 | HTML/CSS/JavaScript | 静态检查、浏览器人工验收 | +| `docs/` | 核心 Wiki 的本地只读镜像、版本绑定分析和规则文件 | Markdown、JSON | Wiki 镜像一致性、链接、结构和差异检查 | + +跨端共享契约以 [Android Agent API 契约](https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract) 为唯一事实来源。各交付单元可以独立构建和验证,但共享字段、状态或能力变化必须在同一工单中验证所有受影响端。 + +## 文档事实来源 + +- Gitea 工单保存任务过程、确认、状态、验证和验收。 +- Gitea Wiki 保存长期产品需求、架构、业务规则、开发规范、共享契约和任务归档。 +- Git `docs/` 保存核心 Wiki 的只读镜像,以及与代码版本绑定且未映射到 Wiki 的分析和规则文件。 +- QuantUX 保存外部交互原型;工单必须记录 App ID、链接和草稿/确认状态。 +- Gitea Wiki 已于 2026-08-17 完成核心页面迁移、显式映射和单向同步验证,长期文档采用 Wiki-first。 +- 固定更新顺序是修改 Wiki、读取确认、导出本地镜像、检查一致性并提交;不得直接编辑镜像后反向覆盖 Wiki。 ## 环境与凭据 @@ -28,8 +51,11 @@ - 每台设备使用独立 Device Token;Token 只存安全配置,不进入仓库。 - PDD 账号密码、Cookie、验证码和用户个人数据不得进入日志。 - 根目录 `gitea.env` 是本机工单访问配置,已被 Git 忽略。 +- 根目录 `config.yaml` 是本机数据库和端口配置,已被 Git 忽略;示例见 `config.example.yaml`。 - `demo/` 是旧 PoC 的本地归档,已被 Git 忽略,不是新产品代码入口。 ## 当前阶段 -当前 MVP 的 T01~T07、T09~T22 均已完成实现、验证并由用户验收。T08(只读实时屏幕)已延期且未实施,不属于当前 MVP。T17 已在一加/ColorOS 真机完成指定设备领取、空闲领取、PDD 商品详情页到达和部分结果提交验证,不包含华为兼容。T23 已完成 v2 一加真机实施验证,正在等待用户验收。T24 已完成规格面板锚定滚动、逐行蛇形颜色遍历和尺码续页实现,并以商品 `236231603269` 验证 14 色、8 尺码和 112 个 SKU,正在等待用户验收。T25 已实现商品页假售罄的一次性下拉恢复,T26 已实现未进入商品详情页时的一次性浏览器重开恢复。T27 已修复“相似商品”推荐区域污染假售罄判定和 Agent 未声明手势能力的问题,并以商品 `731370706977` 的任务 32 在一加真机验证两次下拉恢复及完整任务完成,正在等待用户验收。T28 已实现由本地 `config.yaml` 统一配置 API 与管理端启动端口,并完成非默认端口联合验证,正在等待用户验收。当前实施范围仍是采集闭环;Agent 架构允许未来增加独立采购规则的创建订单能力,但付款能力禁止进入项目。 +当前采集 MVP 的 T01~T22 已完成实现并由用户验收;T08 只读实时屏幕已延期。T23~T28 的一加真机增强、规格遍历、假售罄恢复、浏览器重开恢复和端口配置已经实现,其中部分工单仍等待用户验收。 + +后续采购方向仍处于设计与工单阶段:#31 PDD 商品档案原型和 #32 采购闭环原型均为草稿、等待用户明确审核;#33~#42 不得在 #32 原型通过前进入采购业务代码实施。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。 diff --git a/docs/01-workflow.md b/docs/01-workflow.md index 8736118..1161ee6 100644 --- a/docs/01-workflow.md +++ b/docs/01-workflow.md @@ -2,41 +2,101 @@ ## 事实来源 -- Gitea Epic/MVP:目标、阶段与子工单索引。 -- Gitea 单元工单:需求、讨论、状态、验证和验收过程。 -- `docs/`:当前架构、业务规则、运行方式和共享接口。 -- Git:源码、迁移、版本绑定文档和原型。 +- Gitea Epic/MVP:长期目标、版本范围和子工单索引。 +- Gitea 单元工单:原始需求摘要、讨论、状态、方案变化、验证和验收过程。 +- Gitea Wiki:长期需求、架构、业务规则、运行方式、共享接口和任务归档。 +- Git:源码、迁移、版本绑定分析、本地原型和核心 Wiki 的只读镜像。 +- QuantUX:外部交互原型;App ID、链接和确认状态必须记录在工单。 -## 任务层级 +核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。页面删除、重命名、映射变化或事实来源反向切换必须另行确认。 + +## 工单与设计证据双门禁 + +正式实施前依次判断是否需要工单,以及需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。 + +### 工单豁免 + +只有纯显示文案同时满足以下全部条件时才可以免工单:不改变业务含义、流程、权限、状态、接口、数据、安全、支付、金额、单位、程序标识符、布局、截断和可访问性。有任何不确定时建立单元工单。 + +### 最低设计证据 + +| 修改类型 | 最低证据 | 正式实施门禁 | +|---|---|---| +| 纯显示文案且满足豁免 | 无原型 | 做最小界面检查 | +| 现有界面小范围样式或布局 | 标注截图、低保真图或明确复用规范 | 工单确认后实施 | +| 新组件 | 正常、空、加载、错误、禁用和权限边界说明 | 工单确认状态后实施 | +| 新页面、独立功能、重大交互或导航 | QuantUX 或其他可审阅原型 | 用户确认文字需求、原型和覆盖范围后实施 | +| 后端、接口、数据或定时任务 | 架构、API、数据、状态或流程设计 | 用户确认技术方案后实施 | +| 恢复既有行为的 Bug | 原设计、截图、复现步骤或已有验收证据 | 确认是恢复而非改需求 | + +设计证据记录链接或路径、App ID/版本、草稿/已确认/已废弃状态、确认人、确认时间和覆盖范围。页面结构、主要流程、状态、权限、异常处理或验收结果变化时必须重新确认。 + +## 任务层级与状态 ```text -[Epic] Android PDD 商品采集平台 -└── [MVP] 第一阶段最小采集闭环 - ├── 单元任务:项目骨架 - ├── 单元任务:设备连接 - ├── 单元任务:规则管理 - └── 单元任务:采集任务与结果 +[Epic] 长期产品目标 +└── [MVP] 一个可交付版本 + ├── 单元任务 #N + └── 单元任务 #N+1 ``` -单元任务是唯一实施单位。创建工单不代表立即实施;前置依赖满足后才能进入进行中。 - -## 状态 +单元任务是唯一实施单位。建立新工单不要求所有依赖已完成,但实施前必须检查真实依赖。 ```text 待确认 → 待实施 → 进行中 → 待验收 → 已完成 └→ 阻塞 ``` +前置依赖未完成且存在实施冲突时保持待实施;已经开始后出现计划外且当前无法解除的问题才标记阻塞。 + ## 单元任务闭环 -1. 读取工单、项目档案、业务规则和受影响契约。 -2. 检查前置工单、分支和工作区。 -3. 严格按范围实现,不混入相邻需求。 -4. 执行对应单元测试、契约测试和必要真机验证。 -5. 更新受影响文档并把结果回写工单。 -6. 提交代码,工单保持待验收。 -7. 用户明确验收后关闭,并同步父工单。 +1. 读取工单、项目档案、业务规则、受影响目录和共享契约。 +2. 区分代码事实、用户确认规则和假设,确认目标、非目标、方案、风险、回退和验证。 +3. 检查前置工单、分支和工作区,只修改工单范围。 +4. 执行与风险相称的格式、单元、契约、集成、浏览器或真机验证。 +5. 先更新受影响的 Wiki 页面并读取确认,再导出核心 `docs/` 镜像并检查一致性;把实现、验证和未验证内容回写工单。 +6. 提交当前工单变更,创建或更新 Wiki 任务归档,工单保持待验收。 +7. 用户明确验收后更新归档状态、关闭工单,并同步 Epic/MVP 子工单索引。 -## 高风险门禁 +高风险数据库迁移、设备认证、并发租约、地址修改、创建订单、权限和不可逆动作必须单独建单并再次等待人工确认。任何自动支付需求直接拒绝。 -数据库迁移、设备认证、并发租约、自动提交订单、权限和不可逆动作必须单独建单并等待确认。任何自动支付需求直接拒绝实施。 +## 需求记录与流转 + +- 聊天用于分析和确认,不是长期事实来源。 +- 单元工单记录来源、提出时间、必要的关键原话或脱敏摘要、目标、非目标、方案、验收和需求变化。 +- 长期稳定的需求进入[产品需求总览](https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview)或对应主题页面;共享接口只进入[API 契约](https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract)。 +- 工单不复制完整聊天,不保存 Agent 内部推理、凭据、个人数据或生产数据。 +- 完成结果进入 Wiki 任务归档;`docs/task/` 仅在用户明确要求时增量或全量导出,不是完整历史。 + +## 自然语言快捷指令 + +| 指令 | 执行动作 | 停止位置 | +|---|---|---| +| `只分析` | 只读检查并给出方案 | 等待确认,不建单、不修改 | +| `建工单` | 根据已确认方案创建单元任务 | 工单创建后停止 | +| `执行工单 #N` | 检查依赖,实施、测试、提交并回写证据 | 工单待验收 | +| `建工单并做` | 依次建单和执行 | 工单待验收 | +| `继续工单 #N` | 从首个未完成步骤继续 | 到当前停止条件 | +| `检查工单 #N` | 只读检查范围、验收、测试和证据 | 输出报告,不自动修复 | +| `同步文档` | 读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档 | 输出差异;不修改 Wiki、不自动提交 | +| `导出任务归档` | 按 revision 增量导出 Wiki 任务归档 | 只写 `docs/task/`,不删除旧快照 | +| `导出全部任务归档` | 全量读取并导出全部 Wiki 任务归档 | 只写 `docs/task/`,不删除旧快照 | +| `#N 验收通过` | 记录验收、更新任务归档、同步必要镜像、同步父工单并关闭任务 | 工单已完成 | + +快捷指令不能绕过方案确认、前置依赖、安全规则、工单范围、必要验证或人工验收。 + +## 效率与范围控制 + +- 默认严格按已确认范围实施,不顺手修复相邻问题。 +- 完成必要安全和前置检查后,优先执行能产生真实反馈的最小命令。 +- 采用“执行 → 查看首个可行动错误 → 最小修复 → 继续”的闭环。 +- 同一任务、同一环境中已经验证的事实不重复检查;环境或关键前提变化后再验证。 +- 不新增与验收无关的文档、脚本、框架、重构或扩展性设计。 +- 完成工单范围、必要验证、文档影响和证据回写后立即停止。 + +## 文档影响 + +每个单元工单至少选择一项:无长期文档影响并说明原因;更新项目档案/运行验证;更新架构;更新业务规则;更新 API 契约;更新产品需求、常见修改或故障排查。长期页面先修改 Wiki,读取确认后运行 `python dev_scripts/sync_wiki_docs.py`;不得直接修改映射镜像。 + +启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界或日志位置变化时必须更新对应文档。 diff --git a/docs/05-common-changes.md b/docs/05-common-changes.md index 53a53bc..d99808b 100644 --- a/docs/05-common-changes.md +++ b/docs/05-common-changes.md @@ -1,16 +1,32 @@ -# 常见修改 +# 常见修改指南 + +## 风险分级 + +| 等级 | 常见修改 | 处理方式 | +|---|---|---| +| 低风险 | 文档措辞、简单查询条件、局部回归 Bug | 初级维护者可在 Agent 协助下修改和验证 | +| 中风险 | API、配置、依赖、跨模块逻辑、数据结构、新组件 | 由 Agent 实现,维护者检查差异并执行验证 | +| 高风险 | 权限、安全、并发、迁移、地址修改、创建订单、支付、删除数据、不可逆操作 | 停止修改,由 Agent 分析并等待人工确认 | + +风险由影响范围决定,不按代码行数判断。新页面、重大交互和导航变化还必须先通过设计与原型门禁。 ## 增加设备字段 -先检查设备表、注册响应、心跳、管理端列表和 API 文档。跨三端字段必须在同一工单修改并做契约测试。 +先检查设备表、注册响应、心跳、管理端列表和 API 文档。跨三端字段必须在同一工单修改,并运行服务端、Web、Android 和契约验证。 + +停止条件:字段涉及设备身份、Token、吊销、在线判定或任务互斥。 ## 修改采集字段 -依次检查规则输出、Android 结果 DTO、服务端校验、结果表和管理端详情。不能只改数据库或前端。 +依次检查规则输出、Android 结果 DTO、服务端校验、结果表和管理端详情。不能只改数据库或前端;长期字段同步更新业务规则和 API 契约。 + +停止条件:无法确定完整与部分结果的合并语义,或会删除已有结果。 ## 新增或修改规则 -规则创建后立即可用。编辑或删除规则不修改已有任务的 `rule_snapshot`;规则软删除后不能创建新任务,已有任务仍可执行。 +规则创建后立即可用。编辑或删除规则不修改已有任务的 `rule_snapshot`;规则软删除后不能创建新任务,已有任务仍可执行。已支持选择器、别名、等待和有限滑动优先放规则;新增通用动作或复杂算法才升级 Agent。 + +停止条件:规则需要创建订单、支付、任意脚本、OCR/VLM 或点击不唯一候选。 ## 增加错误码 @@ -18,8 +34,34 @@ ## 修改任务状态 -任务状态影响数据库、领取租约、Android 本地状态和 UI 筛选,属于跨端高风险修改,必须有迁移与并发测试。 +任务状态影响数据库、领取租约、Android 本地状态和 UI 筛选,属于跨端高风险修改,必须有迁移、并发和重复提交测试。 ## 修改任务重置 重置只允许用于终态任务,并在一个事务中删除旧规格/SKU、清空结果与错误、恢复为 `pending`。URL、goods_id、规则和设备快照保持不变。 + +## 修改界面 + +1. 先判断是否只是纯显示文案;不确定时建单。 +2. 小范围布局使用标注截图或低保真图。 +3. 新组件记录正常、空、加载、错误、禁用和权限状态。 +4. 新页面或重大流程使用 QuantUX 原型,记录 App ID、草稿/确认状态和覆盖范围。 +5. 用户确认原型后才实施生产页面,并用浏览器验证主流程和异常状态。 + +停止条件:原型未确认,或界面文字涉及支付、安全、权限、金额和不可逆操作。 + +## 更新文档 + +GoAuto 的长期文档采用 Wiki-first: + +1. 在单元工单列出受影响页面。 +2. 修改对应 Gitea Wiki 页面,不写入密码、Token、个人数据或生产数据。 +3. 通过 API 或页面回读确认正文和 revision。 +4. 运行 `python dev_scripts/sync_wiki_docs.py` 导出核心镜像。 +5. 运行 `python dev_scripts/sync_wiki_docs.py --check` 和 `git diff --check`,再审查差异。 + +停止条件:需要删除/重命名 Wiki 页面、修改映射、改变事实来源边界,或映射镜像存在未提交修改;这些必须在工单中单独确认,不得强制覆盖。 + +## 验收 Agent 修改 + +至少确认:解决哪个工单目标、修改入口和调用路径、行为变化、测试结果、未验证内容、文档影响、提交哈希和回退方式。只看到“测试通过”不足以验收。 diff --git a/docs/07-mvp-requirements.md b/docs/07-mvp-requirements.md index bd638ee..29dc52f 100644 --- a/docs/07-mvp-requirements.md +++ b/docs/07-mvp-requirements.md @@ -1,6 +1,33 @@ -# 第一阶段最小采集闭环 +# 产品需求总览与当前 MVP -## 用户闭环 +## 本页用途 + +本页统一导航 GoAuto 的长期需求、状态、工单、原型和验收入口,同时保留当前采集 MVP 的稳定范围。它不复制完整工单、原型内容或聊天记录。 + +## 事实来源边界 + +| 信息 | 唯一事实来源 | +|---|---| +| 项目目标、用户、规模和建设基线 | [项目档案](https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile) | +| 长期业务规则和安全边界 | [业务规则与术语](https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary) | +| 单次实现范围、变化和验收 | Gitea 单元工单 | +| Android 与服务端共享接口 | [Agent API 契约](https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract) | +| 外部原型 | QuantUX 应用和对应工单评论 | +| 真机验证结果 | [一加真机验收](https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance)和对应工单 | + +## 长期需求索引 + +| 需求领域 | 用户与场景 | 状态 | 工单 | 原型 / 验收入口 | +|---|---|---|---|---| +| PDD 商品采集闭环 | 管理员维护商品、规则和设备,由 Android 采集结构化结果 | 已交付;T23~T28 增强项部分待验收 | [Epic #1](https://git.ilapage.cn/OPC/goauto/issues/1)、[MVP #2](https://git.ilapage.cn/OPC/goauto/issues/2)、[#3~#30 索引](https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues) | [真机验收](https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance) | +| PDD 正式商品档案 | 采购人员长期复用商品资料,可由采集或人工覆盖维护 | 原型草稿,待用户确认 | [#31](https://git.ilapage.cn/OPC/goauto/issues/31) | [QuantUX App `6a81db5d191a826306a7edd6`](http://124.222.27.183:8082/#/apps/6a81db5d191a826306a7edd6.html) | +| Shopee、SYB 与 PDD 商品关系 | 从 SYB 明细提取 Shopee 商品,人工关联 PDD 商品和规格 | 待实施;受采购总体原型门禁约束 | [#40](https://git.ilapage.cn/OPC/goauto/issues/40)、[#41](https://git.ilapage.cn/OPC/goauto/issues/41) | #32 总体流程原型;独立管理页原型待对应工单补充 | +| PDD 采购闭环 | 采购人员派发任务,Agent 选规格、改地址并创建待付款订单,人工支付后回填物流 | 总体原型草稿,待用户确认;代码未实施 | [#32](https://git.ilapage.cn/OPC/goauto/issues/32)、[#33~#39](https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues)、[#42](https://git.ilapage.cn/OPC/goauto/issues/42) | [QuantUX App `6a827440191a826306a7eddd`](http://124.222.27.183:8082/#/apps/6a827440191a826306a7eddd.html) | +| 开发治理与需求追溯 | 负责人和 Agent 需要可复现模板基线、双门禁和需求索引 | 本次文档升级待验收 | [#43](https://git.ilapage.cn/OPC/goauto/issues/43) | 无 UI 原型;DevHarness 目标提交见项目档案 | + +原型状态只有“草稿、已确认、已废弃”。#31 和 #32 当前均为草稿;用户明确回复对应原型通过后才能作为实现依据。 + +## 当前采集 MVP 用户闭环 1. 管理员添加一个 PDD URL。 2. 服务端提取 `goods_id`;重复时提示商品已存在。 @@ -11,10 +38,9 @@ 7. Android 把完整、部分或失败结果写回该任务。 8. 管理员在任务详情查看结果,必要时重置终态任务重新采集。 -## MVP 包含 +## 当前采集 MVP 包含 -- PDD 商品新增、列表和 URL 编辑。 -- URL 规范化、`goods_id` 提取和唯一性校验。 +- PDD 商品新增、列表和 URL 编辑;URL 规范化、`goods_id` 提取和唯一性校验。 - 规则新增、编辑、列表和软删除;创建即生效。 - 单商品创建任务,可指定设备或留空。 - 设备注册、令牌、心跳、在线状态与单设备串行。 @@ -25,34 +51,37 @@ - 终态任务重置、失败任务删除和任务详情。 - 登录失效、验证码、风控、人机验证、控件缺失和离线错误。 -## MVP 不包含 +## 当前采集 MVP 不包含 -- 顺云宝、货运单和 Shopee 商品。 -- 采购、下单和支付。 +- SYB、货运单和 Shopee 商品。 +- 采购、修改地址、创建订单和支付。 - PDD 与其他平台商品关联。 -- 批量创建任务。 -- 规则草稿、发布、版本历史和全局停机。 -- 独立采集结果主表或独立结果页面。 -- 自动重试、自动换机和离线续跑。 -- 实时屏幕和管理端远程控制。 +- 批量创建任务、规则草稿/发布/版本历史、全局停机和实时屏幕。 +- 独立采集结果主表、自动重试、自动换机和离线续跑。 - OCR/VLM、原始控件树、截图和价格历史。 +后续工单存在不代表以上能力已经进入当前采集 MVP。采购能力必须使用独立任务类型、规则权限和高风险门禁,任何阶段都不允许自动支付。 + ## 数据约束 - `pdd_product.goods_id` 唯一。 -- 同一 `pdd_product_id` 最多一个 `pending` 或 `running` 任务。 +- 同一 `pdd_product_id` 最多一个 `pending` 或 `running` 采集任务。 - 一台设备最多一个 `running` 任务。 -- 规则软删除后不能创建新任务,但已有任务不受影响。 +- 规则软删除后不能创建新任务,但已有任务继续使用自身快照。 - 终态任务提交后结果冻结,除非管理员执行重置。 -- 重置事务保留所有输入快照,删除全部旧结果子记录并清空结果字段。 +- 重置事务保留输入快照,删除旧结果子记录并清空结果字段。 -## 验收 +## 当前 MVP 验收 - 一加/ColorOS 完成真实商品闭环;当前 MVP 不包含华为兼容。 - 重复 goods_id 显示明确冲突,不产生第二条商品。 -- 规则创建后可以直接选用;删除后不能创建新任务,旧任务仍可执行。 -- 指定设备和未指定设备两种领取方式均正确且没有双领。 +- 规则创建后可直接选用;删除后不能创建新任务,旧任务仍可执行。 +- 指定设备和未指定设备两种领取方式正确且没有双领。 - 同一商品不能创建第二个未完成任务。 - 每个颜色价格正确展开到该颜色的尺码 SKU;部分结果正常展示。 - 重置后原结果彻底清除,快照保持不变,并可重新采集。 - 离线和各类安全页面返回明确错误且不自动重试。 + +## 更新时机 + +新的长期需求、状态变化、主要工单、原型或验收入口变化时更新本页。普通内部重构、小缺陷和不改变长期能力的任务只保留在工单。 diff --git a/docs/09-delivery-issues.md b/docs/09-delivery-issues.md index 2899874..d9f5d3a 100644 --- a/docs/09-delivery-issues.md +++ b/docs/09-delivery-issues.md @@ -37,6 +37,27 @@ | T27 | [#29](https://git.ilapage.cn/OPC/goauto/issues/29) | 修复相似商品区域导致假售罄恢复不触发 | T25、T26 | | T28 | [#30](https://git.ilapage.cn/OPC/goauto/issues/30) | 启动端口统一由 config.yaml 配置 | T18 | +## 后续商品与采购设计工单 + +以下工单不属于当前采集 MVP。#31 和 #32 的 QuantUX 原型仍是草稿;用户明确审核通过前,不得把后续采购能力混入生产代码。 + +| 顺序 | 工单 | 交付项 | 主要依赖 / 门禁 | +|---|---|---|---| +| T29 | [#31](https://git.ilapage.cn/OPC/goauto/issues/31) | PDD 商品采购档案与规格 JSON 管理 | 先行 #31 QuantUX 原型审核 | +| T30 | [#32](https://git.ilapage.cn/OPC/goauto/issues/32) | 采购闭环数据关系与交互原型 | #31;#32 原型是全部采购代码门禁 | +| T31 | [#33](https://git.ilapage.cn/OPC/goauto/issues/33) | 采购任务数据模型与共享 API 契约 | #31、#40、#41、#32 原型通过 | +| T32 | [#34](https://git.ilapage.cn/OPC/goauto/issues/34) | 服务端采购任务、租约、幂等与状态机 | #33 | +| T33 | [#35](https://git.ilapage.cn/OPC/goauto/issues/35) | Admin 采购任务与人工处理页面 | #33、#34 | +| T34 | [#36](https://git.ilapage.cn/OPC/goauto/issues/36) | Android 地址后缀、不可逆门禁与创建订单 | #33、#34、#42;真机前再次人工确认 | +| T35 | [#37](https://git.ilapage.cn/OPC/goauto/issues/37) | 服务端物流调度与货运宝自动回填 | 采购任务与有效订单能力 | +| T36 | [#38](https://git.ilapage.cn/OPC/goauto/issues/38) | Android PDD 订单物流采集规则 | 采购订单关联契约 | +| T37 | [#39](https://git.ilapage.cn/OPC/goauto/issues/39) | 采购闭环真机端到端验收 | #33~#38、#42 | +| T38 | [#40](https://git.ilapage.cn/OPC/goauto/issues/40) | Shopee 商品档案、PDD 关联与规格映射 | #31;商品域独立于采购任务 | +| T39 | [#41](https://git.ilapage.cn/OPC/goauto/issues/41) | SYB 货运单商品导入与 Shopee 信息提取 | #40;源数据域独立于采购任务 | +| T40 | [#42](https://git.ilapage.cn/OPC/goauto/issues/42) | Android 采购演练规则与持久执行基线 | #33、#34;只演练,不改地址、不创建订单 | + +推荐依赖顺序:#31、#40、#41 完成商品域 → #33、#34 建立采购契约和服务端状态机 → #42 完成不下单演练 → #35 管理端人工处理 → #36 高风险真实订单动作 → #37、#38 物流闭环 → #39 真机总验收。 + ## 延期 - [#10:只读实时屏幕](https://git.ilapage.cn/OPC/goauto/issues/10) 已关闭,未实施,不属于当前 MVP。 diff --git a/docs/README.md b/docs/README.md index b80ac03..edf88ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,19 +1,63 @@ -# Harness Coding 文档中心 +# GoAuto 文档中心 -本文档集面向项目负责人、开发 Agent 和接手维护的程序员。建议按编号顺序阅读。 +GoAuto 使用 Gitea 工单记录任务过程,使用 Gitea Wiki 维护长期开发文档,使用 Git 保存源码、版本绑定资料和 Wiki 的本地镜像。 -| 文档 | 回答的问题 | +## 建议阅读顺序 + +1. [项目档案](https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile):项目目标、建设基线、交付单元和当前阶段。 +2. [产品需求总览](https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview):长期需求状态、工单、原型和当前采集闭环。 +3. [架构与代码地图](https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map):请求怎样跨服务端、Web 和 Android 流动。 +4. [业务规则与术语](https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary):重要状态和不能破坏的规则。 +5. [本地开发与验证](https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification):怎样启动、测试和真机验证。 +6. [常见修改指南](https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes):常见修改入口、风险和停止条件。 +7. [故障排查](https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting):出现错误时按什么顺序检查。 +8. [开发工作流](https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow):完整建单、设计门禁、实施和验收流程。 + +专题资料: + +- [Android Agent API 契约](https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract) +- [工单与依赖索引](https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues) +- [一加真机验收](https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance) +- [PDD 商品详情规则迁移分析](https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis) + +## 事实来源 + +| 信息 | 唯一事实来源 | |---|---| -| `00-project-profile.md` | 项目是什么、包含哪些交付单元 | -| `01-workflow.md` | 如何从需求、工单走到验收 | -| `02-architecture-and-code-map.md` | 请求如何跨服务端、Web 和 Android 流动 | -| `03-business-rules-and-glossary.md` | 哪些业务与安全规则不能破坏 | -| `04-local-development-and-verification.md` | 如何运行与验证 | -| `05-common-changes.md` | 常见修改从哪里开始 | -| `06-troubleshooting.md` | 失败时按什么顺序排查 | -| `07-mvp-requirements.md` | 第一阶段必须交付什么 | -| `08-agent-api-contract.md` | Android 与服务端的共享接口 | -| `09-delivery-issues.md` | 一期 Epic、MVP、Task 及依赖索引 | -| `10-real-device-acceptance.md` | 一加/ColorOS 真机闭环验收证据与待测项 | +| 任务状态、讨论、阻塞、需求变化和验收过程 | Gitea 单元工单 | +| 长期产品需求、架构、业务规则、开发规范、操作说明和任务归档 | Gitea Wiki | +| 跨服务端与 Android 的共享接口 | Wiki 的 Android-Agent-API-Contract 页面 | +| 源码、迁移、版本绑定分析、本地 HTML 原型和规则 JSON | Git 仓库 | +| 核心长期文档的离线副本 | Git 仓库 `docs/` 中的 Wiki 只读镜像 | +| 外部交互原型 | QuantUX 链接、App ID 和对应工单记录 | -当前 `docs/` 由 Git 直接维护。后续启用 Gitea Wiki 镜像时,必须通过独立工单迁移,不能形成两个互相冲突的事实来源。 +本地映射 Markdown 不是编辑入口。长期文档固定顺序是:修改 Wiki → 读取确认 → 导出核心 `docs/` → 检查一致性 → 提交镜像。任务归档默认只保存在 Wiki,只有用户明确要求时才导出到 `docs/task/`。 + +## 五分钟检查 + +```powershell +git status --short --branch +python dev_scripts/sync_wiki_docs.py --check +python -m unittest discover -s tests -v +.\scripts\verify.ps1 -Component all +``` + +预期结果是工作区范围明确、所有 Wiki 映射一致、Wiki 工具单元测试通过,并且受影响的三端验证通过。具体环境、分组件命令和真机范围见[本地开发与验证](https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification)。 + +## 同步与归档 + +```powershell +# 从 Wiki 单向导出核心文档 +python dev_scripts/sync_wiki_docs.py + +# 只检查一致性 +python dev_scripts/sync_wiki_docs.py --check + +# 创建 Wiki 任务归档草稿,不自动导出本地 +python dev_scripts/new_task_archive.py 43 "升级 DevHarness 文档基线" + +# 仅在用户明确要求时导出任务归档 +python dev_scripts/export_task_archives.py +``` + +凭据只通过 `GITEA_TOKEN` 环境变量提供,不写入配置、日志、工单、Wiki 或文档。 diff --git a/docs/templates/task-archive.md b/docs/templates/task-archive.md new file mode 100644 index 0000000..20d4be1 --- /dev/null +++ b/docs/templates/task-archive.md @@ -0,0 +1,42 @@ +# <工单号> <标题> + +- 类型:需求 / 缺陷 / 重构 +- 所属 Epic:# +- 所属 MVP / 版本:# +- 状态:待验收 / 已完成 +- 日期:YYYY-MM-DD +- Gitea 工单:<链接> +- Wiki 页面:<页面名> +- Wiki revision:见本地镜像头 + +## 背景与目标 + +<!-- 原来有什么问题,这次达到什么结果。 --> + +## 最终方案 + +<!-- 说明实际实现。与建单方案不同之处必须写清原因。 --> + +## 修改文件 + +- `<文件>`:<改动说明> + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| | 通过 / 未通过 | + +## 测试 + +- 执行命令:`<命令>` +- 结果: +- **未验证部分**:<!-- 必填;没有就写“无”。 --> + +## 遗留问题 + +<!-- 没有就删除本节。 --> + +## 相关提交 + +- `<提交哈希>` <提交说明> diff --git a/tests/test_wiki_docs.py b/tests/test_wiki_docs.py new file mode 100644 index 0000000..c902096 --- /dev/null +++ b/tests/test_wiki_docs.py @@ -0,0 +1,319 @@ +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 new_task_archive import ( # noqa: E402 + build_archive, + main as new_archive_main, + safe_title, +) +from export_task_archives import ( # noqa: E402 + existing_task_mirrors, + export_task_archives, + 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("new_task_archive.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", + [ + "new_task_archive.py", + "14", + "按需导出", + "--config", + str(config_path), + ], + ): + result = new_archive_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("export_task_archives.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( + "export_task_archives.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("export_task_archives.dirty_paths", return_value=[]) + def test_full_export_reads_all_and_never_deletes_extra_file(self, _dirty) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_dir = root / "docs" / "task" + task_dir.mkdir(parents=True) + extra = task_dir / "99-历史快照.md" + extra_page = WikiPage( + title="Task-99-历史快照", + sub_url="Task-99.-", + text="# 99 历史快照\n", + revision="e" * 40, + html_url="http://gitea.example/wiki/task-99", + ) + extra.write_text(render_mirror(extra_page), encoding="utf-8") + page = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="f" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + client = Mock() + metadata = { + "title": page.title, + "sub_url": page.sub_url, + "last_commit": {"sha": page.revision}, + } + client.list_pages.return_value = [metadata] + client.get_page_from_metadata.return_value = page + messages = export_task_archives(client, export_all=True, root=root) + exported = root / "docs" / "task" / "14-按需导出.md" + self.assertTrue(exported.is_file()) + self.assertTrue(extra.is_file()) + self.assertTrue(messages[0].startswith("已导出:")) + client.get_page_from_metadata.assert_called_once_with(metadata, page.title) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki-docs.json b/wiki-docs.json new file mode 100644 index 0000000..e260a21 --- /dev/null +++ b/wiki-docs.json @@ -0,0 +1,22 @@ +{ + "schema_version": 1, + "gitea_url": "https://git.ilapage.cn", + "owner": "OPC", + "repository": "goauto", + "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": "Product-Requirements-Overview", "path": "docs/07-mvp-requirements.md" }, + { "page": "Android-Agent-API-Contract", "path": "docs/08-agent-api-contract.md" }, + { "page": "Delivery-Issues", "path": "docs/09-delivery-issues.md" }, + { "page": "OnePlus-Real-Device-Acceptance", "path": "docs/10-real-device-acceptance.md" }, + { "page": "PDD-Detail-Rule-Migration-Analysis", "path": "docs/11-pdd-detail-rule-migration-analysis.md" }, + { "page": "Task-Archive-Template", "path": "docs/templates/task-archive.md" } + ] +}