docs: prepare Wiki-first migration (#43)

This commit is contained in:
QiuSW
2026-08-17 11:27:09 +08:00
parent cde2a84211
commit a4ac398ca0
17 changed files with 1457 additions and 103 deletions
+42 -12
View File
@@ -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 @@
## 验证方式
<!-- 写出可复制命令;需要真机、生产环境或人工检查时明确说明。 -->
## 风险和回退
<!-- 普通低风险任务可简写;涉及接口、迁移、安全或不可逆操作时必填。 -->
+2
View File
@@ -29,3 +29,5 @@
/android/.gradle/
/android/local.properties
/android/**/build/
__pycache__/
*.py[cod]
+56 -19
View File
@@ -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。
+8 -1
View File
@@ -2,4 +2,11 @@
@AGENTS.md
共同开发规则以 `AGENTS.md` 为唯一入口。本文件不重复业务规则;Claude Code 在修改前必须读取当前工单和受影响交付单元文档。
## Claude Code 专用说明
- `AGENTS.md` 是所有编码 Agent 的共同规则事实来源;本文件不重复业务规则。
- 目标、范围和修改位置明确时使用常规开发模型完成分析、建单和实施。
- 需求模糊、根因不明,或涉及跨模块架构、安全、权限、并发、迁移、创建订单和不可逆操作时,先使用更强推理模型制定方案并等待确认。
- 轻量模型只处理范围明确的只读查找、文档读取和日志事实提取,不决定最终根因、风险等级、技术方案或验收结论。
- Agent 交接必须包含目标、非目标、事实、假设、方案、修改范围、风险、回退和验收标准。
- 只读子 Agent 必须通过工具权限保证只读;未配置只读权限时不得让它接触项目写操作。
+131
View File
@@ -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<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 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())
+87
View File
@@ -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())
+33
View File
@@ -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())
+422
View File
@@ -0,0 +1,422 @@
"""Gitea Wiki 到本地 docs 镜像的共享实现。"""
from __future__ import annotations
import base64
import json
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlencode
from urllib.request import Request, urlopen
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = ROOT / "wiki-docs.json"
MIRROR_START = "<!-- gitea-wiki-mirror:start -->"
MIRROR_END = "<!-- gitea-wiki-mirror:end -->"
HEADER_PATTERN = re.compile(
rf"\A{re.escape(MIRROR_START)}\n(?P<metadata>.*?)\n"
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z",
re.DOTALL,
)
class WikiDocsError(RuntimeError):
"""可供命令行直接展示的 Wiki 文档错误。"""
@dataclass(frozen=True)
class Mapping:
page: str
path: str
@dataclass(frozen=True)
class Config:
path: Path
gitea_url: str
owner: str
repository: str
mappings: tuple[Mapping, ...]
@dataclass(frozen=True)
class WikiPage:
title: str
sub_url: str
text: str
revision: str
html_url: str
def _required_string(data: dict[str, Any], key: str) -> str:
value = data.get(key)
if not isinstance(value, str) or not value.strip():
raise WikiDocsError(f"配置字段 {key!r} 必须是非空字符串")
return value.strip()
def validate_mappings(raw_mappings: Any) -> tuple[Mapping, ...]:
"""校验显式页面映射,确保只会写入 docs 下的 Markdown。"""
if not isinstance(raw_mappings, list) or not raw_mappings:
raise WikiDocsError("配置字段 'mappings' 必须是非空数组")
mappings: list[Mapping] = []
pages: set[str] = set()
paths: set[str] = set()
for index, item in enumerate(raw_mappings, start=1):
if not isinstance(item, dict):
raise WikiDocsError(f"第 {index} 个映射必须是对象")
page = _required_string(item, "page")
path = _required_string(item, "path").replace("\\", "/")
pure_path = PurePosixPath(path)
if (
pure_path.is_absolute()
or ".." in pure_path.parts
or not pure_path.parts
or pure_path.parts[0] != "docs"
or pure_path.suffix.lower() != ".md"
):
raise WikiDocsError(f"镜像路径必须是 docs/ 下的 Markdown:{path}")
if page in pages:
raise WikiDocsError(f"Wiki 页面重复映射:{page}")
if path in paths:
raise WikiDocsError(f"本地路径重复映射:{path}")
pages.add(page)
paths.add(path)
mappings.append(Mapping(page=page, path=path))
return tuple(mappings)
def load_config(path: Path = DEFAULT_CONFIG) -> Config:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise WikiDocsError(f"无法读取 Wiki 映射配置 {path}: {exc}") from exc
if not isinstance(raw, dict) or raw.get("schema_version") != 1:
raise WikiDocsError("wiki-docs.json 的 schema_version 必须为 1")
configured_url = _required_string(raw, "gitea_url")
gitea_url = os.environ.get("GITEA_URL", configured_url).rstrip("/")
if gitea_url.endswith("/api/v1"):
gitea_url = gitea_url[: -len("/api/v1")]
return Config(
path=path,
gitea_url=gitea_url,
owner=_required_string(raw, "owner"),
repository=_required_string(raw, "repository"),
mappings=validate_mappings(raw.get("mappings")),
)
class WikiClient:
"""只使用标准库访问 Gitea Wiki API。"""
def __init__(self, config: Config, token: str | None = None) -> None:
self.config = config
self.token = token if token is not None else os.environ.get("GITEA_TOKEN")
def _request(
self,
method: str,
api_path: str,
*,
payload: dict[str, Any] | None = None,
query: dict[str, Any] | None = None,
) -> Any:
url = f"{self.config.gitea_url}/api/v1{api_path}"
if query:
url = f"{url}?{urlencode(query)}"
headers = {"Accept": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
data = None
if payload is not None:
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(url, data=data, headers=headers, method=method)
try:
with urlopen(request, timeout=30) as response:
body = response.read()
except HTTPError as exc:
if self.token and method == "GET" and exc.code in {401, 403, 404}:
# 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于
# 另一个实例或已失效。只对只读请求安全降级为匿名访问。
anonymous_headers = {"Accept": "application/json"}
anonymous_request = Request(
url, data=data, headers=anonymous_headers, method=method
)
try:
with urlopen(anonymous_request, timeout=30) as response:
body = response.read()
except HTTPError as anonymous_exc:
detail = anonymous_exc.read().decode("utf-8", errors="replace")
raise WikiDocsError(
f"Gitea API {method} {api_path} 返回 "
f"{anonymous_exc.code}: {detail}"
) from anonymous_exc
except URLError as anonymous_exc:
raise WikiDocsError(
f"无法连接 Gitea:{anonymous_exc.reason}"
) from anonymous_exc
else:
detail = exc.read().decode("utf-8", errors="replace")
raise WikiDocsError(
f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}"
) from exc
except URLError as exc:
raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc
if not body:
return None
try:
return json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise WikiDocsError("Gitea API 返回了无效的 UTF-8 JSON") from exc
def list_pages(self) -> list[dict[str, Any]]:
pages: list[dict[str, Any]] = []
page_number = 1
while True:
batch = self._request(
"GET",
f"/repos/{quote(self.config.owner, safe='')}/"
f"{quote(self.config.repository, safe='')}/wiki/pages",
query={"page": page_number, "limit": 50},
)
if not isinstance(batch, list):
raise WikiDocsError("Gitea Wiki 页面列表格式无效")
pages.extend(item for item in batch if isinstance(item, dict))
if len(batch) < 50:
return pages
page_number += 1
def get_page(self, page_name: str) -> WikiPage:
metadata = next(
(
item
for item in self.list_pages()
if item.get("title") == page_name or item.get("sub_url") == page_name
),
None,
)
if metadata is None:
raise WikiDocsError(
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
)
return self.get_page_from_metadata(metadata, page_name)
def get_page_from_metadata(
self, metadata: dict[str, Any], page_name: str | None = None
) -> WikiPage:
"""使用页面列表元数据读取正文,避免重复获取完整页面列表。"""
resolved_name = page_name or _required_string(metadata, "title")
sub_url = _required_string(metadata, "sub_url")
page = self._request(
"GET",
f"/repos/{quote(self.config.owner, safe='')}/"
f"{quote(self.config.repository, safe='')}/wiki/page/"
f"{quote(sub_url, safe='%')}",
)
if not isinstance(page, dict):
raise WikiDocsError(f"Wiki 页面响应格式无效:{resolved_name}")
encoded_content = page.get("content_base64")
if not isinstance(encoded_content, str):
raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_name}")
try:
text = base64.b64decode(encoded_content, validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError) as exc:
raise WikiDocsError(
f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_name}"
) from exc
last_commit = page.get("last_commit")
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
if not isinstance(revision, str) or not revision:
raise WikiDocsError(f"Wiki 页面缺少 revision:{resolved_name}")
title = page.get("title")
resolved_title = title if isinstance(title, str) and title else resolved_name
html_url = (
f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/"
f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
)
return WikiPage(
title=resolved_title,
sub_url=sub_url,
text=normalize_body(text),
revision=revision,
html_url=html_url,
)
def create_page(self, title: str, content: str, message: str) -> WikiPage:
if not self.token:
raise WikiDocsError("创建 Wiki 页面需要通过 GITEA_TOKEN 提供写入令牌")
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
self._request(
"POST",
f"/repos/{quote(self.config.owner, safe='')}/"
f"{quote(self.config.repository, safe='')}/wiki/new",
payload={"title": title, "content_base64": encoded, "message": message},
)
return self.get_page(title)
def normalize_body(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n"
def parse_mirror(text: str) -> tuple[dict[str, str], str]:
match = HEADER_PATTERN.match(text.replace("\r\n", "\n").replace("\r", "\n"))
if match is None:
raise WikiDocsError("缺少或损坏 gitea-wiki-mirror 元数据头")
metadata: dict[str, str] = {}
for line in match.group("metadata").splitlines():
key, separator, value = line.partition(": ")
if not separator or not key or not value:
raise WikiDocsError(f"无效的镜像元数据行:{line}")
metadata[key] = value
return metadata, normalize_body(match.group("body"))
def render_mirror(page: WikiPage, existing: str | None = None) -> str:
synchronized_at: str | None = None
if existing is not None:
try:
metadata, body = parse_mirror(existing)
except WikiDocsError:
pass
else:
if metadata.get("wiki_revision") == page.revision and body == page.text:
synchronized_at = metadata.get("synchronized_at")
if not synchronized_at:
synchronized_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
"+00:00", "Z"
)
header = "\n".join(
(
MIRROR_START,
"generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)",
f"wiki_page: {page.title}",
f"wiki_url: {page.html_url}",
f"wiki_revision: {page.revision}",
f"synchronized_at: {synchronized_at}",
MIRROR_END,
)
)
return f"{header}\n\n{page.text}"
def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]:
return dirty_paths([mapping.path for mapping in config.mappings], root)
def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]:
"""返回指定路径中已有、修改或未跟踪的工作区条目。"""
if not paths:
return []
result = subprocess.run(
["git", "status", "--porcelain", "--", *paths],
cwd=root,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return [line for line in result.stdout.splitlines() if line.strip()]
def _write_atomic(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
handle, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
)
try:
with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
stream.write(content)
os.replace(temporary_name, path)
except BaseException:
Path(temporary_name).unlink(missing_ok=True)
raise
def write_mirror(path: Path, page: WikiPage) -> bool:
"""写入一份 Wiki 镜像;内容无变化时返回 False。"""
existing = path.read_text(encoding="utf-8") if path.is_file() else None
rendered = render_mirror(page, existing)
if existing == rendered:
return False
_write_atomic(path, rendered)
return True
def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
if not path.is_file():
return [f"缺少镜像:{mapping.path}"]
try:
metadata, body = parse_mirror(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, WikiDocsError) as exc:
return [f"镜像无效 {mapping.path}: {exc}"]
expected = {
"wiki_page": page.title,
"wiki_url": page.html_url,
"wiki_revision": page.revision,
}
errors = [
f"{mapping.path} 的 {key} 不一致"
for key, value in expected.items()
if metadata.get(key) != value
]
if not metadata.get("synchronized_at"):
errors.append(f"{mapping.path} 缺少 synchronized_at")
if body != page.text:
errors.append(f"{mapping.path} 的正文与 Wiki 不一致")
return errors
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
if not check:
dirty = dirty_mirror_paths(config)
if dirty:
details = "\n".join(dirty)
raise WikiDocsError(
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
)
messages: list[str] = []
for mapping in config.mappings:
page = client.get_page(mapping.page)
target = ROOT / PurePosixPath(mapping.path)
if check:
errors = check_mirror(mapping, page, target)
if errors:
raise WikiDocsError("\n".join(errors))
messages.append(f"一致:{mapping.path} <- {page.title}@{page.revision[:12]}")
continue
existing = target.read_text(encoding="utf-8") if target.is_file() else None
rendered = render_mirror(page, existing)
if existing != rendered:
_write_atomic(target, rendered)
messages.append(f"已更新:{mapping.path} <- {page.title}@{page.revision[:12]}")
else:
messages.append(f"无变化:{mapping.path} <- {page.title}@{page.revision[:12]}")
return messages
def append_mapping(config: Config, mapping: Mapping) -> None:
raw = json.loads(config.path.read_text(encoding="utf-8"))
mappings = validate_mappings(raw.get("mappings"))
if any(item.page == mapping.page or item.path == mapping.path for item in mappings):
raise WikiDocsError(f"页面或路径已经登记:{mapping.page} -> {mapping.path}")
raw["mappings"].append({"page": mapping.page, "path": mapping.path})
rendered = json.dumps(raw, ensure_ascii=False, indent=2) + "\n"
_write_atomic(config.path, rendered)
+35 -9
View File
@@ -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 原型通过前进入采购业务代码实施。采购永不支付,真实地址修改和创建订单属于必须再次人工确认的高风险范围。
+83 -23
View File
@@ -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、数据结构、状态、业务规则、安全边界或日志位置变化时必须更新对应文档。
+47 -5
View File
@@ -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 修改
至少确认:解决哪个工单目标、修改入口和调用路径、行为变化、测试结果、未验证内容、文档影响、提交哈希和回退方式。只看到“测试通过”不足以验收。
+48 -19
View File
@@ -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;部分结果正常展示。
- 重置后原结果彻底清除,快照保持不变,并可重新采集。
- 离线和各类安全页面返回明确错误且不自动重试。
## 更新时机
新的长期需求、状态变化、主要工单、原型或验收入口变化时更新本页。普通内部重构、小缺陷和不改变长期能力的任务只保留在工单。
+21
View File
@@ -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。
+59 -15
View File
@@ -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 或文档。
+42
View File
@@ -0,0 +1,42 @@
# <工单号> <标题>
- 类型:需求 / 缺陷 / 重构
- 所属 Epic:#
- 所属 MVP / 版本:#
- 状态:待验收 / 已完成
- 日期:YYYY-MM-DD
- Gitea 工单:<链接>
- Wiki 页面:<页面名>
- Wiki revision:见本地镜像头
## 背景与目标
<!-- 原来有什么问题,这次达到什么结果。 -->
## 最终方案
<!-- 说明实际实现。与建单方案不同之处必须写清原因。 -->
## 修改文件
- `<文件>`:<改动说明>
## 验收结果
| 验收标准 | 结果 |
|---|---|
| | 通过 / 未通过 |
## 测试
- 执行命令:`<命令>`
- 结果:
- **未验证部分**:<!-- 必填;没有就写“无”。 -->
## 遗留问题
<!-- 没有就删除本节。 -->
## 相关提交
- `<提交哈希>` <提交说明>
+319
View File
@@ -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()
+22
View File
@@ -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" }
]
}