From bcec012c8b501448721257e39b9884e320d7dd1c Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 3 Aug 2026 18:32:00 +0800 Subject: [PATCH] feat(client): initialize procurement tool skeleton --- client/.gitignore | 23 ++++++ client/pyproject.toml | 19 +++++ client/requirements.txt | 7 ++ client/scripts/verify_wheel_metadata.py | 80 +++++++++++++++++++++ client/src/cmbuyer_client/__init__.py | 6 ++ client/src/cmbuyer_client/__main__.py | 7 ++ client/src/cmbuyer_client/app.py | 57 +++++++++++++++ client/src/cmbuyer_client/logging_policy.py | 66 +++++++++++++++++ client/src/cmbuyer_client/runtime.py | 42 +++++++++++ client/tests/__init__.py | 1 + client/tests/test_app.py | 24 +++++++ client/tests/test_logging_policy.py | 49 +++++++++++++ client/tests/test_runtime.py | 25 +++++++ client/tests/test_wheel_metadata.py | 29 ++++++++ docs/00-ai-start-here.md | 27 ++++--- docs/03-tech-stack.md | 24 +++++-- docs/current-state.md | 51 +++++++++---- docs/tasks/T-002.md | 10 ++- 18 files changed, 516 insertions(+), 31 deletions(-) create mode 100644 client/.gitignore create mode 100644 client/pyproject.toml create mode 100644 client/requirements.txt create mode 100644 client/scripts/verify_wheel_metadata.py create mode 100644 client/src/cmbuyer_client/__init__.py create mode 100644 client/src/cmbuyer_client/__main__.py create mode 100644 client/src/cmbuyer_client/app.py create mode 100644 client/src/cmbuyer_client/logging_policy.py create mode 100644 client/src/cmbuyer_client/runtime.py create mode 100644 client/tests/__init__.py create mode 100644 client/tests/test_app.py create mode 100644 client/tests/test_logging_policy.py create mode 100644 client/tests/test_runtime.py create mode 100644 client/tests/test_wheel_metadata.py diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..e59120a --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,23 @@ +# 本机开发环境与解释器缓存 +.venv/ +venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ + +# 运行时生成的日志、截图和其他证据产物不得进入版本库。 +logs/ +artifacts/ +runtime/ +*.log + +# 本机凭据或环境覆盖仅可保存在未跟踪文件中。 +.env +.env.* +secrets/ + +# 打包工具生成的本机产物 +build/ +dist/ +*.egg-info/ diff --git a/client/pyproject.toml b/client/pyproject.toml new file mode 100644 index 0000000..6049757 --- /dev/null +++ b/client/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "cmbuyer-client" +version = "0.1.0" +description = "cmbuyer 采购工具桌面端" +requires-python = ">=3.11" +dynamic = ["dependencies"] + +[project.scripts] +cmbuyer-client = "cmbuyer_client.app:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } diff --git a/client/requirements.txt b/client/requirements.txt new file mode 100644 index 0000000..b38251b --- /dev/null +++ b/client/requirements.txt @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# 桌面界面(Qt 官方 Python 绑定)。 +PySide6 +# 后续真机取证会使用;本阶段不导入或连接设备。 +uiautomator2 +# 后续截图完整性检查会使用。 +Pillow diff --git a/client/scripts/verify_wheel_metadata.py b/client/scripts/verify_wheel_metadata.py new file mode 100644 index 0000000..44ce6dd --- /dev/null +++ b/client/scripts/verify_wheel_metadata.py @@ -0,0 +1,80 @@ +"""验证 wheel 元数据从 requirements.txt 声明了全部运行时依赖。""" + +from __future__ import annotations + +import argparse +from email import policy +from email.parser import BytesParser +from pathlib import Path +import re +import sys +import zipfile + + +def normalize_project_name(name: str) -> str: + """使用足以比较 requirements 与 Core Metadata 的项目名规范化规则。""" + + return re.sub(r"[-_.]+", "-", name).lower() + + +def dependencies_from_requirements(requirements_file: Path) -> set[str]: + """从唯一依赖来源读取项目名;当前 requirements 不允许间接或可编辑依赖。""" + + dependencies: set[str] = set() + for line in requirements_file.read_text(encoding="utf-8").splitlines(): + requirement = line.partition("#")[0].strip() + if not requirement: + continue + match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement) + if match is None: + raise ValueError(f"requirements.txt 包含不支持的依赖声明:{requirement}") + dependencies.add(normalize_project_name(match.group())) + return dependencies + + +def dependencies_from_wheel(wheel_file: Path) -> set[str]: + """读取 wheel 的 Core Metadata 中声明的 Requires-Dist 项目名。""" + + with zipfile.ZipFile(wheel_file) as wheel: + metadata_members = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")] + if len(metadata_members) != 1: + raise ValueError("wheel 中必须恰有一个 .dist-info/METADATA 文件") + metadata = BytesParser(policy=policy.default).parsebytes(wheel.read(metadata_members[0])) + + dependencies = set() + for requirement in metadata.get_all("Requires-Dist", []): + match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement) + if match is None: + raise ValueError(f"wheel METADATA 包含无效的 Requires-Dist:{requirement}") + dependencies.add(normalize_project_name(match.group())) + return dependencies + + +def verify_wheel_metadata(wheel_file: Path, requirements_file: Path) -> set[str]: + """返回没有被 wheel 元数据声明的 requirements 项目名。""" + + return dependencies_from_requirements(requirements_file) - dependencies_from_wheel(wheel_file) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="检查 wheel 是否包含 requirements.txt 的依赖元数据") + parser.add_argument("wheel", type=Path, help="待检查的 wheel 文件") + parser.add_argument( + "--requirements", + type=Path, + default=Path(__file__).resolve().parents[1] / "requirements.txt", + help="唯一依赖来源 requirements.txt 的路径", + ) + arguments = parser.parse_args(argv) + + missing = verify_wheel_metadata(arguments.wheel, arguments.requirements) + if missing: + print(f"wheel METADATA 缺少依赖:{', '.join(sorted(missing))}", file=sys.stderr) + return 1 + + print("wheel METADATA 已声明 requirements.txt 中的全部依赖。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client/src/cmbuyer_client/__init__.py b/client/src/cmbuyer_client/__init__.py new file mode 100644 index 0000000..2bb2895 --- /dev/null +++ b/client/src/cmbuyer_client/__init__.py @@ -0,0 +1,6 @@ +"""采购工具桌面端包。 + +本包当前只提供应用骨架和安全的本地运行基础设施;不包含真机操作或采购流程。 +""" + +__version__ = "0.1.0" diff --git a/client/src/cmbuyer_client/__main__.py b/client/src/cmbuyer_client/__main__.py new file mode 100644 index 0000000..be3fda2 --- /dev/null +++ b/client/src/cmbuyer_client/__main__.py @@ -0,0 +1,7 @@ +"""支持通过 ``python -m cmbuyer_client`` 启动应用。""" + +from .app import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/client/src/cmbuyer_client/app.py b/client/src/cmbuyer_client/app.py new file mode 100644 index 0000000..2c291a9 --- /dev/null +++ b/client/src/cmbuyer_client/app.py @@ -0,0 +1,57 @@ +"""采购工具的最小桌面应用入口。""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence + +from .logging_policy import configure_application_logger +from .runtime import RuntimePaths + + +def select_application_argv(argv: Sequence[str] | None) -> list[str]: + """保留调用方明确给出的空参数列表,避免改变测试或打包入口的语义。""" + + return list(sys.argv if argv is None else argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """启动只表达当前工程状态的桌面外壳。 + + 真机控制和采购执行必须在完成取证并实现后才可接入,因此此入口不导入 + uiautomator2,也不提供任何会影响采购或支付状态的命令。 + """ + + try: + paths = RuntimePaths.default() + logger = configure_application_logger(paths) + except OSError as error: + print(f"无法创建采购工具运行目录:{error}", file=sys.stderr) + return 1 + + try: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QApplication, QLabel, QMainWindow + except ImportError: + logger.error("缺少 PySide6,无法启动桌面界面。") + print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr) + return 2 + + application = QApplication.instance() or QApplication(select_application_argv(argv)) + application.setApplicationName("采购工具") + + window = QMainWindow() + window.setWindowTitle("采购工具") + window.setAccessibleName("采购工具") + window.setMinimumSize(420, 240) + window.resize(560, 320) + + message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。") + message.setAlignment(Qt.AlignmentFlag.AlignCenter) + message.setWordWrap(True) + message.setAccessibleName("当前状态") + window.setCentralWidget(message) + + logger.info("应用已启动;采购执行功能尚未启用。") + window.show() + return application.exec() diff --git a/client/src/cmbuyer_client/logging_policy.py b/client/src/cmbuyer_client/logging_policy.py new file mode 100644 index 0000000..1d1512d --- /dev/null +++ b/client/src/cmbuyer_client/logging_policy.py @@ -0,0 +1,66 @@ +"""采购工具日志的最小脱敏策略。""" + +from __future__ import annotations + +import logging +from pathlib import Path +import re + +from .runtime import RuntimePaths + + +LOGGER_NAME = "cmbuyer_client" +REDACTED = "[已隐藏]" + +_SENSITIVE_KEY_PATTERN = ( + r"token|authorization|password|secret|api[_-]?key|" + r"address|phone|mobile|payment|pay|card|bank[_-]?account|" + r"令牌|授权|密码|密钥|地址|手机号|电话|支付|银行卡" +) +_KEY_VALUE_PATTERN = re.compile( + rf"(?P{_SENSITIVE_KEY_PATTERN})\s*(?P[:=])\s*" + r"(?P\"[^\"]*\"|'[^']*'|[^\s,;]+)", + flags=re.IGNORECASE, +) +_PHONE_PATTERN = re.compile(r"(? str: + """移除日志文本中的凭据、地址、手机号和支付字段值。""" + + def replace_key_value(match: re.Match[str]) -> str: + return f"{match.group('key')}{match.group('separator')}{REDACTED}" + + redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message) + return _PHONE_PATTERN.sub(REDACTED, redacted) + + +class SensitiveDataFilter(logging.Filter): + """在任何 handler 格式化记录前,清除敏感字段。 + + 该过滤器在日志写入前替换 ``msg`` 与 ``args``,确保文件 handler 不会得到原始值。 + """ + + def filter(self, record: logging.LogRecord) -> bool: + record.msg = redact_text(record.getMessage()) + record.args = () + return True + + +def configure_application_logger(paths: RuntimePaths) -> logging.Logger: + """配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。""" + + paths.ensure_exists() + logger = logging.getLogger(LOGGER_NAME) + logger.setLevel(logging.INFO) + logger.propagate = False + + for handler in tuple(logger.handlers): + logger.removeHandler(handler) + handler.close() + + handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8") + handler.addFilter(SensitiveDataFilter()) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + logger.addHandler(handler) + return logger diff --git a/client/src/cmbuyer_client/runtime.py b/client/src/cmbuyer_client/runtime.py new file mode 100644 index 0000000..4a761c6 --- /dev/null +++ b/client/src/cmbuyer_client/runtime.py @@ -0,0 +1,42 @@ +"""采购工具的本地运行目录策略。""" + +from __future__ import annotations + +from dataclasses import dataclass +import os +from pathlib import Path + + +@dataclass(frozen=True) +class RuntimePaths: + """仅包含应用生成物的本地目录。 + + 日志和证据产物不落在源码树中,避免把含有现场信息的运行数据误提交到 Git。 + """ + + root: Path + logs: Path + artifacts: Path + + @classmethod + def from_root(cls, root: Path) -> "RuntimePaths": + resolved_root = root.expanduser() + return cls( + root=resolved_root, + logs=resolved_root / "logs", + artifacts=resolved_root / "artifacts", + ) + + @classmethod + def default(cls) -> "RuntimePaths": + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + return cls.from_root(Path(local_app_data) / "cmbuyer") + + return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer") + + def ensure_exists(self) -> None: + """创建运行目录;调用方负责向用户呈现无法创建目录的错误。""" + + self.logs.mkdir(parents=True, exist_ok=True) + self.artifacts.mkdir(parents=True, exist_ok=True) diff --git a/client/tests/__init__.py b/client/tests/__init__.py new file mode 100644 index 0000000..9301320 --- /dev/null +++ b/client/tests/__init__.py @@ -0,0 +1 @@ +"""采购工具的离线单元测试。""" diff --git a/client/tests/test_app.py b/client/tests/test_app.py new file mode 100644 index 0000000..13d2de7 --- /dev/null +++ b/client/tests/test_app.py @@ -0,0 +1,24 @@ +"""验证桌面入口的纯参数处理,不启动 PySide6。""" + +from __future__ import annotations + +import sys +from pathlib import Path +import unittest +from unittest import mock + + +CLIENT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CLIENT_ROOT / "src")) + +from cmbuyer_client.app import select_application_argv + + +class ApplicationArgumentsTests(unittest.TestCase): + def test_empty_argument_list_is_not_replaced_with_process_arguments(self) -> None: + with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]): + self.assertEqual([], select_application_argv([])) + + def test_none_uses_process_arguments(self) -> None: + with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]): + self.assertEqual(["process-name", "--process-option"], select_application_argv(None)) diff --git a/client/tests/test_logging_policy.py b/client/tests/test_logging_policy.py new file mode 100644 index 0000000..ec72396 --- /dev/null +++ b/client/tests/test_logging_policy.py @@ -0,0 +1,49 @@ +"""验证日志脱敏边界,不需要 PySide6 或真机。""" + +from __future__ import annotations + +import sys +from pathlib import Path +import tempfile +import unittest + + +CLIENT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CLIENT_ROOT / "src")) + +from cmbuyer_client.logging_policy import configure_application_logger, redact_text +from cmbuyer_client.runtime import RuntimePaths + + +class LoggingPolicyTests(unittest.TestCase): + def test_redact_text_hides_required_sensitive_values(self) -> None: + message = ( + "token=secret-value authorization: Bearer-value " + "address='浙江省杭州市' phone=13800138000 payment=card-value" + ) + + redacted = redact_text(message) + + for raw_value in ("secret-value", "Bearer-value", "浙江省杭州市", "13800138000", "card-value"): + self.assertNotIn(raw_value, redacted) + self.assertIn("token=[已隐藏]", redacted) + + def test_file_handler_writes_only_redacted_text(self) -> None: + with tempfile.TemporaryDirectory() as directory: + paths = RuntimePaths.from_root(Path(directory)) + logger = configure_application_logger(paths) + try: + logger.info("token=not-for-log phone=13900139000 payment=not-for-log") + + for handler in logger.handlers: + handler.flush() + content = (paths.logs / "client.log").read_text(encoding="utf-8") + finally: + # Windows 不允许在 FileHandler 持有文件时删除临时目录。 + for handler in tuple(logger.handlers): + logger.removeHandler(handler) + handler.close() + + self.assertNotIn("not-for-log", content) + self.assertNotIn("13900139000", content) + self.assertIn("[已隐藏]", content) diff --git a/client/tests/test_runtime.py b/client/tests/test_runtime.py new file mode 100644 index 0000000..d157ae0 --- /dev/null +++ b/client/tests/test_runtime.py @@ -0,0 +1,25 @@ +"""验证本地运行目录策略,不需要连接设备。""" + +from __future__ import annotations + +import sys +from pathlib import Path +import tempfile +import unittest + + +CLIENT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CLIENT_ROOT / "src")) + +from cmbuyer_client.runtime import RuntimePaths + + +class RuntimePathsTests(unittest.TestCase): + def test_ensure_exists_creates_only_runtime_directories(self) -> None: + with tempfile.TemporaryDirectory() as directory: + paths = RuntimePaths.from_root(Path(directory) / "runtime") + + paths.ensure_exists() + + self.assertTrue(paths.logs.is_dir()) + self.assertTrue(paths.artifacts.is_dir()) diff --git a/client/tests/test_wheel_metadata.py b/client/tests/test_wheel_metadata.py new file mode 100644 index 0000000..972a45b --- /dev/null +++ b/client/tests/test_wheel_metadata.py @@ -0,0 +1,29 @@ +"""验证 wheel 元数据检查脚本,不构建 wheel 或安装运行时依赖。""" + +from __future__ import annotations + +import sys +from pathlib import Path +import tempfile +import unittest +import zipfile + + +CLIENT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(CLIENT_ROOT / "scripts")) + +from verify_wheel_metadata import dependencies_from_requirements, verify_wheel_metadata + + +class WheelMetadataTests(unittest.TestCase): + def test_metadata_checker_accepts_dependencies_from_the_single_requirements_file(self) -> None: + expected_dependencies = dependencies_from_requirements(CLIENT_ROOT / "requirements.txt") + with tempfile.TemporaryDirectory() as directory: + wheel_file = Path(directory) / "cmbuyer_client-0.1.0-py3-none-any.whl" + metadata = "Metadata-Version: 2.3\n" + "".join( + f"Requires-Dist: {dependency}\n" for dependency in sorted(expected_dependencies) + ) + with zipfile.ZipFile(wheel_file, "w") as wheel: + wheel.writestr("cmbuyer_client-0.1.0.dist-info/METADATA", metadata) + + self.assertEqual(set(), verify_wheel_metadata(wheel_file, CLIENT_ROOT / "requirements.txt")) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 3e02b29..09cb38c 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -85,12 +85,12 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/` ## 当前阶段 -**Phase 0 · 地基。** 采购服务骨架已初始化,采购工具骨架仍在进行;尚无采购业务代码。 +**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。 执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径: -1. 继续完成 T-002 采购工具骨架;采购服务方向可并行推进 T-004 数据模型。 -2. T-002 完成后立即推进 **T-101 → T-102 → T-103 真机取证**,并可并行补 T-003 统一入口。 +1. T-004 核心数据模型正在进行;T-003 统一入口与 T-101 真机环境盘点可开始。 +2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**,并可并行补 T-003 统一入口。 3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与 T-104 → T-107 后续真机安全判据按依赖并行推进。 4. Phase 3:双端打通与**第一趟试选**端到端。 @@ -172,22 +172,31 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/` ## 验证命令 -> 采购服务命令已由 T-001 实际验证;采购工具命令在 T-002 完成前仍是占位路径。 +> 采购服务命令已由 T-001 实际验证;采购工具的离线测试、编译和 wheel 元数据命令已由 +> Python 3.12 实际验证。当前默认 `python` 是 Python 3.10.11,统一入口尚未正确选择 +> Python 3.11+,此缺口由 T-003 修复。 -```bash +```powershell # 采购服务 admin/(改了 Go 代码后) cd admin go test ./... go vet ./... go build ./... -# 采购工具 client/(T-002 完成后启用) +# 采购工具 client/(使用 Python 3.11+;本机已验证 3.12) cd client -python -m unittest discover -s tests -t . -python -m compileall -q src tests +py -3.12 -m unittest discover -s tests -t . +py -3.12 -m compileall -q src tests scripts +$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $wheelDir | Out-Null +py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir +py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName # 跨端契约改动:两端全跑 ``` 验证层级何时触发见 [`03-tech-stack.md`](03-tech-stack.md) 第六节验证矩阵。 -如果命令当前不可运行,必须在回复里如实说明原因。 +`requirements.txt` 是采购工具唯一的运行时依赖来源,`pyproject.toml` 动态读取它写入 wheel +元数据。规范安装是 Python 3.11+ 虚拟环境中的 `python -m pip install -e .`,已由 Python 3.12 +验证通过;桌面 GUI 和真机流程不属于本次验收。`init.ps1` 仍未正确选择 Python 3.11+,统一入口 +由 T-003 修复。若命令当前不可运行,必须在回复里如实说明原因。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 91d4a1e..32486c0 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -59,15 +59,25 @@ ## 五、构建与运行命令 -> 采购服务命令已由 T-001 实际验证;采购工具命令由 T-002 完成后替换剩余占位项。 +> 采购服务命令已由 T-001 实际验证。采购工具以下离线测试、编译和 wheel 元数据命令已由 +> Python 3.12 实际验证;桌面 GUI 和真机流程不属于 T-002 验收范围。 | 用途 | 采购服务(`admin/`) | 采购工具(`client/`) | | --- | --- | --- | -| 安装依赖 | `go mod download` | `python -m venv .venv` + `pip install -r requirements.txt` | -| 本地开发 | `go run ./cmd/server` | 【T-002 填写】 | -| 构建 | `go build ./...` | 【T-002 填写】 | -| 测试 | `go test ./...` | `python -m unittest discover -s tests -t .` | -| 静态检查 | `go vet ./...` | `python -m compileall -q src tests` | +| 安装依赖 | `go mod download` | `py -3.12 -m venv .venv` 后运行 `.\.venv\Scripts\python.exe -m pip install -e .` | +| 本地开发 | `go run ./cmd/server` | `.\.venv\Scripts\python.exe -m cmbuyer_client` | +| 构建 | `go build ./...` | `py -3.12 -m pip wheel --no-deps . --wheel-dir <输出目录>`,再运行 `py -3.12 scripts/verify_wheel_metadata.py ` | +| 测试 | `go test ./...` | `py -3.12 -m unittest discover -s tests -t .` | +| 静态检查 | `go vet ./...` | `py -3.12 -m compileall -q src tests scripts` | + +采购工具的运行时依赖只维护在 `client/requirements.txt`。`client/pyproject.toml` 通过 setuptools +动态读取该文件生成 wheel 的 `Requires-Dist`,避免两份依赖列表漂移; +`scripts/verify_wheel_metadata.py` 会验证生成 wheel 已声明全部这些依赖。 + +当前 Windows 默认 `python` 指向 Python 3.10.11,不满足采购工具的 Python 3.11+ 下限, +不得把未加版本选择器的 `python` 当作采购工具的已验证命令。T-003 负责统一入口与解释器选择; +在此之前使用 `py -3.12` 或 Python 3.11+ 虚拟环境。Python 3.12 虚拟环境中的完整 +`pip install -e .` 已验证通过;桌面 GUI 与真机流程不属于 T-002 验收范围。 Windows PowerShell 差异: @@ -80,7 +90,7 @@ $env:GOTOOLCHAIN = "local" | 层级 | 触发条件 | 命令 / 操作 | 通过证据 | | --- | --- | --- | --- | -| 任务相关验证 | 每个任务必跑 | 改 `admin/` 跑 `go test ./...` + `go vet ./...`;改 `client/` 跑 `python -m unittest discover -s tests -t .` + `python -m compileall -q src tests` | 退出码 0、测试数 | +| 任务相关验证 | 每个任务必跑 | 改 `admin/` 跑 `go test ./...` + `go vet ./...`;改 `client/` 跑 `py -3.12 -m unittest discover -s tests -t .` + `py -3.12 -m compileall -q src tests scripts` | 退出码 0、测试数 | | 完整门禁 | 发布前;修改 HTTP 契约、数据库 schema、依赖或构建配置时;跨端改动时 | 两端全部测试 + 静态检查 + 两端构建 | 退出码 0、测试数、产物路径 | | 人工 / 设备验收 | 任何涉及真机页面判据、下单动作或付款路径的任务 | 连接真机执行,记录设备型号、Android 版本、拼多多版本、goods_id、截图与页面 XML 路径 | 人工结论 + 证据文件路径 | diff --git a/docs/current-state.md b/docs/current-state.md index 9956ca2..aee51e3 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,30 +12,33 @@ ## 当前快照 - 日期:2026-08-03 -- 阶段:**Phase 0 · 地基(采购服务骨架完成,采购工具骨架进行中)** +- 阶段:**Phase 0 · 地基(采购服务与采购工具骨架已初始化,尚无采购业务代码)** - MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款 - 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`) 使用 Python 3.11+ / uiautomator2 / PySide6。 详见 [`03-tech-stack.md`](03-tech-stack.md) -- 生产代码:`admin/` 已有最小 Go 服务、`GET /healthz` 与 SQLite 驱动封装;尚无采购业务代码 -- 测试:采购服务 2 项离线单元测试 +- 生产代码:`admin/` 已有最小 Go 服务、`GET /healthz` 与 SQLite 驱动封装;`client/` 已有 + Python 包、PySide6 最小入口、运行目录与日志脱敏策略;两端均尚无采购业务代码 +- 测试:采购服务 2 项离线单元测试;采购工具 6 项离线单元测试(不连接真机) - 数据:**无** -- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。当前会验证采购服务,再因 - `client/requirements.txt` 尚不存在以退出码 3 提示 T-002——这是预期行为 -- 标准验证路径:`admin/` 下运行 `go test ./...`、`go vet ./...`、`go build ./...`;仓库根运行 +- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。两端骨架现已存在,但 `init.ps1` 仍使用 + 默认 Python 3.10,不能正确验证要求 Python 3.11+ 的采购工具;T-003 负责统一入口与解释器选择。 +- 标准验证路径:`admin/` 下运行 `go test ./...`、`go vet ./...`、`go build ./...`;`client/` 下使用 + `py -3.12 -m unittest discover -s tests -t .`、`py -3.12 -m compileall -q src tests scripts`;仓库根运行 `python scripts/validate_agent_context.py` -- 当前 blocker:无外部 blocker。T-002 正在实现;T-004 已因 T-001 完成而可领取。 +- 当前 blocker:无外部 blocker。T-002 已完成;T-004 核心数据模型正在进行;T-003 统一入口与 + T-101 真机环境盘点已就绪。当前不宣称统一入口或桌面 GUI 已验收。 ## 当前目录要点 | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | 项目规范化文档,本次已完整生成 | -| `docs/tasks/` | 已有(T-001、T-002、T-005~T-009) | T-001 已完成;T-002 正在进行;其余既有任务已完成 | +| `docs/tasks/` | 已有(T-001、T-002、T-004~T-009) | T-001、T-002 已完成;T-004 正在进行;其余既有任务已完成 | | `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 | | `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 | | `admin/` | 已初始化 | Go 1.23+ / gin / SQLite 骨架与健康检查;无采购业务路由 | -| `client/` | **待建** | “采购工具”Python 桌面端(T-002) | +| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、离线测试与 wheel 元数据检查;无真机或采购流程 | | `init.ps1` / `init.sh` | 已有(骨架) | 统一入口。两端目录建好后由 T-003 补全并验证 | ## 任务状态 @@ -45,8 +48,8 @@ - 已完成:T-005(采购服务交互原型)、T-006(采购工具交互原型)、T-007(产品名称与 源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次), 以及 T-001(采购服务 Go 骨架)。 -- 正在进行:T-002(采购工具 Python 骨架)。采购服务方向可并行落成 T-004;T-002 完成后 - 立即推进 T-101 → T-102 → T-103,并可补 T-003 统一入口。 +- 已完成:T-002(采购工具 Python 骨架)。正在进行:T-004(核心数据模型)。 + T-003(统一入口)与 T-101(真机环境盘点)已就绪,立即推进 T-101 → T-102 → T-103。 - T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。 - 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变 可读字段时必须先回修原型与交互清单。 @@ -73,7 +76,31 @@ cd .. python scripts/validate_agent_context.py ``` -采购工具与统一入口的真实命令分别等待 T-002、T-003 完成后补齐。 +采购工具当前可运行的离线验证与 wheel 元数据检查: + +```powershell +cd client +py -3.12 -m unittest discover -s tests -t . +py -3.12 -m compileall -q src tests scripts +$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid()) +New-Item -ItemType Directory -Path $wheelDir | Out-Null +py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir +py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName +``` + +采购工具规范安装与本地开发入口: + +```powershell +cd client +py -3.12 -m venv .venv +.\.venv\Scripts\python.exe -m pip install -e . +.\.venv\Scripts\python.exe -m cmbuyer_client +``` + +`client/requirements.txt` 是唯一依赖来源,`client/pyproject.toml` 动态读取它生成 wheel 的 +`Requires-Dist`。Python 3.12 已验证 6 项离线测试、编译与 wheel 元数据;完整运行时依赖安装 +(`pip install -e .`)已通过。`init.ps1` 当前仍选择 Python 3.10,因此不能声称统一入口已正确; +桌面 GUI 与真机流程未作为 T-002 验收执行。 ## 关键背景 diff --git a/docs/tasks/T-002.md b/docs/tasks/T-002.md index e0a8ad1..175f311 100644 --- a/docs/tasks/T-002.md +++ b/docs/tasks/T-002.md @@ -3,7 +3,7 @@ id: T-002 title: 初始化采购工具 client Python 骨架 phase: 0 deps: [] -status: DOING +status: DONE created: 2026-08-03 vikunja_task_id: 17 context_ref: a63bdb8 @@ -18,7 +18,7 @@ write_paths: - docs/current-state.md --- - + ## 问题 / 背景 仓库尚无 client/requirements.txt 和可运行的采购工具代码,init.ps1 因此按预期以退出码 3 停止。没有稳定的 Python 包、测试入口、日志与产物策略,后续 ADB/uiautomator2 真机取证无法开始。 @@ -47,7 +47,11 @@ write_paths: ## 执行记录 -(暂无) +### 2026-08-03T10:30:56Z · ila + +主 agent 终审通过:采购工具 Python 3.11+ 骨架、PySide6 最小入口、运行目录和敏感日志脱敏策略均已落成;无真机、页面判据、下单或付款逻辑。 +独立验证:6 项 unittest 通过;compileall 通过;wheel 构建及 requirements.txt→Requires-Dist 元数据核对通过;Python 3.12 editable install 通过;离屏窗口构造通过;采购服务 race/vet/build 回归及 agent-context 门禁通过。 +共享文档已更新为 T-002 完成快照,并如实保留 init.ps1 默认 Python 3.10 风险给 T-003。 ## 边界