docs: prepare Wiki-first migration (#43)
This commit is contained in:
@@ -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 @@
|
||||
|
||||
## 验证方式
|
||||
|
||||
<!-- 写出可复制命令;需要真机、生产环境或人工检查时明确说明。 -->
|
||||
|
||||
## 风险和回退
|
||||
|
||||
<!-- 普通低风险任务可简写;涉及接口、迁移、安全或不可逆操作时必填。 -->
|
||||
|
||||
@@ -29,3 +29,5 @@
|
||||
/android/.gradle/
|
||||
/android/local.properties
|
||||
/android/**/build/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -2,4 +2,11 @@
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
共同开发规则以 `AGENTS.md` 为唯一入口。本文件不重复业务规则;Claude Code 在修改前必须读取当前工单和受影响交付单元文档。
|
||||
## Claude Code 专用说明
|
||||
|
||||
- `AGENTS.md` 是所有编码 Agent 的共同规则事实来源;本文件不重复业务规则。
|
||||
- 目标、范围和修改位置明确时使用常规开发模型完成分析、建单和实施。
|
||||
- 需求模糊、根因不明,或涉及跨模块架构、安全、权限、并发、迁移、创建订单和不可逆操作时,先使用更强推理模型制定方案并等待确认。
|
||||
- 轻量模型只处理范围明确的只读查找、文档读取和日志事实提取,不决定最终根因、风险等级、技术方案或验收结论。
|
||||
- Agent 交接必须包含目标、非目标、事实、假设、方案、修改范围、风险、回退和验收标准。
|
||||
- 只读子 Agent 必须通过工具权限保证只读;未配置只读权限时不得让它接触项目写操作。
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
@@ -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、数据结构、状态、业务规则、安全边界或日志位置变化时必须更新对应文档。
|
||||
|
||||
@@ -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
@@ -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;部分结果正常展示。
|
||||
- 重置后原结果彻底清除,快照保持不变,并可重新采集。
|
||||
- 离线和各类安全页面返回明确错误且不自动重试。
|
||||
|
||||
## 更新时机
|
||||
|
||||
新的长期需求、状态变化、主要工单、原型或验收入口变化时更新本页。普通内部重构、小缺陷和不改变长期能力的任务只保留在工单。
|
||||
|
||||
@@ -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
@@ -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 或文档。
|
||||
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
# <工单号> <标题>
|
||||
|
||||
- 类型:需求 / 缺陷 / 重构
|
||||
- 所属 Epic:#
|
||||
- 所属 MVP / 版本:#
|
||||
- 状态:待验收 / 已完成
|
||||
- 日期:YYYY-MM-DD
|
||||
- Gitea 工单:<链接>
|
||||
- Wiki 页面:<页面名>
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
<!-- 原来有什么问题,这次达到什么结果。 -->
|
||||
|
||||
## 最终方案
|
||||
|
||||
<!-- 说明实际实现。与建单方案不同之处必须写清原因。 -->
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `<文件>`:<改动说明>
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| | 通过 / 未通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行命令:`<命令>`
|
||||
- 结果:
|
||||
- **未验证部分**:<!-- 必填;没有就写“无”。 -->
|
||||
|
||||
## 遗留问题
|
||||
|
||||
<!-- 没有就删除本节。 -->
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `<提交哈希>` <提交说明>
|
||||
@@ -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()
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user