From b1e38ffd7aebffe6e27968079694d9c997b2c0c6 Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 24 Jun 2026 17:32:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(T-003):=20=E5=BB=BA=E7=AB=8B=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=B8=8E=E8=BF=90=E8=A1=8C=E4=BA=A7=E7=89=A9=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/paths.py: 集中管理 logs/、artifacts/ 路径与产物命名 (artifacts//<时间戳>_.png|.xml),_safe 安全化防路径穿越, 运行时自动创建且被 .gitignore 忽略。 - src/logging_config.py: 统一日志格式(含 task_id/step),控制台 + logs/app.log, 缺上下文时由 filter 补默认值,setup 幂等;约定不记录敏感信息。 - src/main.py: 启动时初始化运行目录与日志。 - 运行方式统一为 python -m src.main(绝对导入下的唯一干净入口), 同步替换 00/03/05/current-state 文档命令与 dev.bat。 - 文档:04 §七 补 paths.py 与产物命名规则;tasks/progress/current-state 更新。 验证:compileall OK;unittest Ran 17 tests OK;python -m src.main exit=0,logs/app.log 写入正常。 Co-Authored-By: Claude Opus 4.8 --- dev.bat | 2 +- docs/00-ai-start-here.md | 2 +- docs/03-tech-stack.md | 4 +-- docs/04-architecture.md | 9 ++++-- docs/05-coding-rules.md | 2 +- docs/current-state.md | 20 ++++++------- progress.md | 15 ++++++++-- src/logging_config.py | 62 ++++++++++++++++++++++++++++++++++++++++ src/main.py | 13 +++++++-- src/paths.py | 58 +++++++++++++++++++++++++++++++++++++ tasks.md | 2 +- tests/test_logging.py | 41 ++++++++++++++++++++++++++ tests/test_paths.py | 41 ++++++++++++++++++++++++++ 13 files changed, 246 insertions(+), 25 deletions(-) create mode 100644 src/logging_config.py create mode 100644 src/paths.py create mode 100644 tests/test_logging.py create mode 100644 tests/test_paths.py diff --git a/dev.bat b/dev.bat index 86c35a3..9a53e79 100644 --- a/dev.bat +++ b/dev.bat @@ -1,3 +1,3 @@ title run -cd /d "%~dp0src" && python main.py +cd /d "%~dp0" && python -m src.main pause diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 363df0f..6ef93bd 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -115,7 +115,7 @@ MVP 不做: ```powershell python -m compileall src python -m unittest discover -s tests -t . -python src/main.py +python -m src.main ``` 说明: diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index a3e235f..3e73f30 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -38,7 +38,7 @@ | 创建虚拟环境 | `python -m venv .venv` | | 激活虚拟环境 | `.venv\Scripts\Activate.ps1` | | 安装依赖 | `pip install -r requirements.txt` | -| 本地运行 | `python src/main.py` | +| 本地运行 | `python -m src.main` | | 测试 | `python -m unittest discover -s tests -t .` | | 语法检查 | `python -m compileall src` | | 打包(待 T-402) | `pyinstaller app.spec` | @@ -49,7 +49,7 @@ Windows PowerShell: python -m venv .venv .venv\Scripts\Activate.ps1 pip install -r requirements.txt -python src/main.py +python -m src.main python -m unittest discover -s tests -t . ``` diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 09d9df6..106508c 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -324,7 +324,8 @@ cmpdd/ │ ├── android/ │ │ ├── device.py │ │ └── pdd_flow.py -│ └── logging_config.py +│ ├── logging_config.py +│ └── paths.py ├── tests/ ├── samples/ ├── logs/ @@ -337,8 +338,10 @@ cmpdd/ - `src/core/`:任务模型、状态机、执行编排。 - `src/excel/`:Excel 导入、校验、回写。 - `src/android/`:设备连接、拼多多 App 自动化流程。 -- `logs/`:本地运行日志,默认不提交真实敏感日志。 -- `artifacts/`:截图、UI XML、结果文件等运行产物,默认不提交真实数据。 +- `src/logging_config.py`:统一日志格式(含 `task_id`、`step`),输出到控制台与 `logs/app.log`;不记录敏感信息。 +- `src/paths.py`:集中管理运行时路径与产物命名;截图与 UI XML 按 `artifacts//<时间戳>_.png|.xml` 归档,`task_id`/`step` 入路径前安全化以防穿越。 +- `logs/`:本地运行日志(`app.log`),运行时自动创建并被 `.gitignore` 忽略,不提交真实敏感日志。 +- `artifacts/`:截图、UI XML、结果文件等运行产物,运行时自动创建并被 `.gitignore` 忽略,不提交真实数据。 - `samples/`:脱敏样例 Excel。 ## 八、架构纪律 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 54ec67c..72fa9e9 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -78,7 +78,7 @@ ```powershell python -m compileall src python -m unittest discover -s tests -t . -python src/main.py +python -m src.main ``` ## 8. 绝不 diff --git a/docs/current-state.md b/docs/current-state.md index 2608d9d..d718942 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -7,10 +7,10 @@ ## 当前快照 - 日期:2026-06-24 -- 阶段:Phase 0 地基(T-001、T-002 完成)/ 原型验证前 +- 阶段:Phase 0 地基(T-001、T-002、T-003 完成)/ 进入 Phase 1 原型验证 - 技术栈:Python 3.10(骨架已跑通);计划接入 PySide6 + `uiautomator2` + ADB + `openpyxl` -- 生产代码:骨架 + 核心数据模型(`src/main.py` 入口、`src/core/` 状态枚举与任务模型) -- 测试:标准库 `unittest`,覆盖冒烟与核心模型(`tests/test_smoke.py`、`tests/test_models.py`) +- 生产代码:骨架 + 核心数据模型 + 运行时基建(`src/main.py` 入口、`src/core/` 模型与状态、`src/logging_config.py` 日志、`src/paths.py` 产物路径) +- 测试:标准库 `unittest`,覆盖冒烟、核心模型、日志与产物路径(`tests/` 下 4 个测试文件) - 数据:尚未提供真实 Excel 样例 ## 当前目录要点 @@ -18,20 +18,20 @@ | 路径 | 状态 | 说明 | | --- | --- | --- | | `docs/` | 已有 | 项目规范化文档,已按 harness coding 文档风格建立。 | -| `src/` | 已有 | `main.py` 入口 + `core/`(`status.py` 状态枚举、`models.py` 任务模型);GUI/执行器/Excel/Android 模块后续添加。 | -| `tests/` | 已有 | 已建冒烟测试 `tests/test_smoke.py`(unittest)。 | +| `src/` | 已有 | `main.py` 入口 + `core/`(状态枚举、任务模型)+ `logging_config.py`、`paths.py`;GUI/执行器/Excel/Android 模块后续添加。 | +| `tests/` | 已有 | `test_smoke`、`test_models`、`test_paths`、`test_logging`(unittest)。 | | `requirements.txt` | 已有 | 依赖清单;当前零第三方运行时依赖,含依赖纪律注释。 | | `samples/` | 待建 | 脱敏 Excel 样例。 | -| `logs/` | 待建 | 本地运行日志。 | -| `artifacts/` | 待建 | 截图、UI XML、结果文件等运行产物。 | +| `logs/` | 运行时创建 | `app.log` 运行日志;被 `.gitignore` 忽略。 | +| `artifacts/` | 运行时创建 | 截图、UI XML 等运行产物;被 `.gitignore` 忽略。 | ## 任务与进度状态 任务定义以 [`../tasks.md`](../tasks.md) 为准,执行记录以 [`../progress.md`](../progress.md) 为准。 -- 已完成:项目文档初始化;`T-001 初始化 Python 项目骨架`;`T-002 建立核心数据模型和状态枚举`。 +- 已完成:项目文档初始化;`T-001`、`T-002`、`T-003`(Phase 0 地基全部完成)。 - 正在进行:无。 -- 下一个可领取任务:`T-003 建立日志和运行产物目录策略`。 +- 下一个可领取任务:`T-101 验证 ADB 和 uiautomator2 真机连接`(Phase 1,需真机环境)。 ## 当前可运行内容 @@ -39,7 +39,7 @@ ```powershell # 本地运行入口 -python src/main.py +python -m src.main # 测试 python -m unittest discover -s tests -t . diff --git a/progress.md b/progress.md index 3fb73cc..b404aab 100644 --- a/progress.md +++ b/progress.md @@ -14,9 +14,9 @@ ## 当前摘要 - 日期:2026-06-24 -- 阶段:Phase 0 地基(T-001、T-002 完成)/ 原型验证前 -- 当前任务:无(T-002 已完成) -- 下一个任务:`T-003 建立日志和运行产物目录策略` +- 阶段:Phase 0 地基完成(T-001/2/3)/ 进入 Phase 1 原型验证 +- 当前任务:无(T-003 已完成) +- 下一个任务:`T-101 验证 ADB 和 uiautomator2 真机连接`(需真机环境) - 当前阻塞:尚未提供真实 Excel 样例、Android 真机和测试商品链接 ## 进度记录 @@ -66,6 +66,15 @@ - 范围:未含 `PaymentConfig`/`PaymentMode`/`RunnerState`(属支付与执行器任务,按需引入)。 - 验证:`python3 -m compileall src` OK;`python3 -m unittest discover -s tests -t .` → Ran 10 tests, OK。 +### 2026-06-24 · T-003 建立日志和运行产物目录策略(DONE) + +- 完成:`src/paths.py` 集中管理运行时路径与产物命名(`logs/`、`artifacts/` 运行时创建并 gitignore;截图/UI XML 按 `artifacts//<时间戳>_.png|.xml` 归档,`_safe` 安全化防路径穿越);`src/logging_config.py` 统一日志格式(含 `task_id`/`step`,控制台 + `logs/app.log`,缺上下文时由 filter 补默认值,幂等 setup)。 +- 集成:`src/main.py` 启动时 `ensure_runtime_dirs()` + `setup_logging()` 并写一条启动日志。 +- 修正:`main.py` 引入 `from src.*` 后 `python src/main.py` 直接运行会 `ModuleNotFoundError`;运行方式统一为 `python -m src.main`,同步替换 `00/03/05/current-state` 文档命令与 `dev.bat`。 +- 测试:`tests/test_paths.py`、`tests/test_logging.py` 覆盖安全化、路径生成与归档、防穿越、日志上下文与幂等。 +- 安全:日志格式不含敏感字段,并在 `logging_config`/`05` 留下「不记录支付密码/验证码/账号密码」约定。 +- 验证:`python3 -m compileall src` OK;`python3 -m unittest discover -s tests -t .` → Ran 17 tests, OK;`python3 -m src.main` exit=0 且 `logs/app.log` 正常写入。 + ## 阻塞与风险 - 真实 Excel 表头待提供。 diff --git a/src/logging_config.py b/src/logging_config.py new file mode 100644 index 0000000..50b97f7 --- /dev/null +++ b/src/logging_config.py @@ -0,0 +1,62 @@ +"""日志配置(T-003)。 + +统一日志格式,包含时间、级别、任务 ID 与步骤,便于按任务排查。 +日志同时输出到控制台与 logs/app.log。 + +安全:日志不得记录支付密码、短信验证码、账号密码等敏感信息 +(见 docs/05-coding-rules.md §4、§6)。调用方通过 get_task_logger(task_id, step) +获得带任务上下文的 logger。 +""" +from __future__ import annotations + +import logging +from logging import LoggerAdapter + +from src.paths import LOGS_DIR + +LOG_FILE = LOGS_DIR / "app.log" + +# 含任务 ID 与步骤的统一格式;缺失上下文时由过滤器补默认值 +_FORMAT = "%(asctime)s %(levelname)s [task=%(task_id)s step=%(step)s] %(name)s: %(message)s" + +_configured = False + + +class _ContextDefaultsFilter(logging.Filter): + """为缺少 task_id/step 的记录补默认值,确保格式化不因缺字段报错。""" + + def filter(self, record: logging.LogRecord) -> bool: + if not hasattr(record, "task_id"): + record.task_id = "-" + if not hasattr(record, "step"): + record.step = "-" + return True + + +def setup_logging(level: int = logging.INFO) -> None: + """配置根日志:控制台 + 文件(logs/app.log)。重复调用安全(只配置一次)。""" + global _configured + if _configured: + return + LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + formatter = logging.Formatter(_FORMAT) + context_filter = _ContextDefaultsFilter() + + root = logging.getLogger() + root.setLevel(level) + root.handlers.clear() + for handler in (logging.StreamHandler(), logging.FileHandler(LOG_FILE, encoding="utf-8")): + handler.setFormatter(formatter) + handler.addFilter(context_filter) + root.addHandler(handler) + + _configured = True + + +def get_task_logger(task_id: str, step: str = "-") -> LoggerAdapter: + """返回带任务上下文(task_id/step)的 logger 适配器。 + + 安全:调用方不得把支付密码、短信验证码、账号密码等敏感信息写入日志。 + """ + return LoggerAdapter(logging.getLogger("cmpdd"), {"task_id": task_id, "step": step}) diff --git a/src/main.py b/src/main.py index bc13296..4ac46dc 100644 --- a/src/main.py +++ b/src/main.py @@ -5,17 +5,24 @@ T-001 骨架阶段的最小可运行入口,仅用于验证项目结构与运 """ from __future__ import annotations +from src.logging_config import get_task_logger, setup_logging +from src.paths import ensure_runtime_dirs + APP_NAME = "拼多多批量下单执行器" APP_VERSION = "0.1.0" def main() -> int: - """最小入口:打印应用信息并返回退出码 0。 + """最小入口:初始化运行目录与日志,打印应用信息并返回退出码 0。 - GUI 与自动化能力尚未接入,此处只确认骨架可运行。 + GUI 与自动化能力尚未接入,此处只确认骨架可运行、日志可写。 """ + ensure_runtime_dirs() + setup_logging() + log = get_task_logger("-", "startup") + log.info("%s v%s 启动(骨架阶段)", APP_NAME, APP_VERSION) print(f"{APP_NAME} v{APP_VERSION}") - print("项目骨架已就绪(T-001);GUI 与真机自动化将在后续任务接入。") + print("项目骨架已就绪;GUI 与真机自动化将在后续任务接入。") return 0 diff --git a/src/paths.py b/src/paths.py new file mode 100644 index 0000000..a80581c --- /dev/null +++ b/src/paths.py @@ -0,0 +1,58 @@ +"""运行时路径与产物归档策略(T-003)。 + +集中管理日志与运行产物(截图、UI XML)的输出位置与命名, +确保排查时能按任务 ID 定位证据。 + +约定: +- `logs/`、`artifacts/` 位于项目根,运行时自动创建,并被 `.gitignore` 忽略, + 不提交真实运行数据。 +- 截图与 UI XML 按 `artifacts//<时间戳>_.png|.xml` 归档。 +- `task_id` / `step` 进入路径前统一做安全化,避免路径穿越与非法文件名。 +""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +# 本文件位于 src/ 下,项目根为其上一级 +PROJECT_ROOT = Path(__file__).resolve().parent.parent +LOGS_DIR = PROJECT_ROOT / "logs" +ARTIFACTS_DIR = PROJECT_ROOT / "artifacts" + + +def ensure_runtime_dirs() -> None: + """确保 logs/ 与 artifacts/ 存在(幂等)。""" + LOGS_DIR.mkdir(parents=True, exist_ok=True) + ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True) + + +def task_artifact_dir(task_id: str) -> Path: + """返回某任务的产物目录 artifacts//,并确保存在。""" + target = ARTIFACTS_DIR / _safe(task_id) + target.mkdir(parents=True, exist_ok=True) + return target + + +def screenshot_path(task_id: str, step: str) -> Path: + """生成截图保存路径:artifacts//<时间戳>_.png。""" + return task_artifact_dir(task_id) / f"{_timestamp()}_{_safe(step)}.png" + + +def ui_xml_path(task_id: str, step: str) -> Path: + """生成 UI XML 保存路径:artifacts//<时间戳>_.xml。""" + return task_artifact_dir(task_id) / f"{_timestamp()}_{_safe(step)}.xml" + + +def _timestamp() -> str: + # 含微秒,避免同一步骤快速连续产出时文件名冲突 + return datetime.now().strftime("%Y%m%d_%H%M%S_%f") + + +def _safe(name: str) -> str: + """安全化路径片段:仅保留字母数字、连字符、下划线,其余替换为下划线。 + + 用于隔离 task_id/step 中可能出现的 `/`、`..` 等字符,防止逃逸 artifacts 根目录。 + 空结果回退为 'unknown',避免产生空文件名。 + """ + cleaned = "".join(ch if (ch.isalnum() or ch in "-_") else "_" for ch in name) + return cleaned or "unknown" diff --git a/tasks.md b/tasks.md index 3e69446..d4d447a 100644 --- a/tasks.md +++ b/tasks.md @@ -22,7 +22,7 @@ | --- | --- | --- | --- | --- | | T-001 | 初始化 Python 项目骨架 | - | 建立 `src/`、`tests/`、`requirements.txt`;可运行最小入口;不引入无关依赖;用**真实可运行命令**替换 `00-ai-start-here.md`、`03-tech-stack.md`、`05-coding-rules.md`、`current-state.md` 中的占位符验证命令 | DONE | | T-002 | 建立核心数据模型和状态枚举 | T-001 | `OrderTask`、`TaskStatus` 与 `04-architecture.md` 一致;有基础单元测试 | DONE | -| T-003 | 建立日志和运行产物目录策略 | T-001 | 日志、截图、UI XML 输出路径清晰;不记录敏感信息 | TODO | +| T-003 | 建立日志和运行产物目录策略 | T-001 | 日志、截图、UI XML 输出路径清晰;不记录敏感信息 | DONE | ## Phase 1 · 最高风险验证 diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..4016036 --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,41 @@ +"""日志配置测试(T-003)。""" +import logging +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src import logging_config +from src.logging_config import _FORMAT, _ContextDefaultsFilter, get_task_logger + + +class LoggingTest(unittest.TestCase): + def test_task_logger_carries_context(self): + log = get_task_logger("T9", "pay") + self.assertEqual(log.extra["task_id"], "T9") + self.assertEqual(log.extra["step"], "pay") + + def test_format_tolerates_missing_context(self): + """非任务日志(无 task_id/step)也能格式化,不抛 KeyError。""" + record = logging.LogRecord("x", logging.INFO, __file__, 1, "msg", None, None) + _ContextDefaultsFilter().filter(record) + out = logging.Formatter(_FORMAT).format(record) + self.assertIn("task=-", out) + self.assertIn("step=-", out) + + def test_setup_logging_idempotent(self): + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(logging_config, "LOG_FILE", Path(tmp) / "app.log"): + logging_config._configured = False + logging.getLogger().handlers.clear() + logging_config.setup_logging() + first = len(logging.getLogger().handlers) + logging_config.setup_logging() # 第二次应无操作 + self.assertEqual(len(logging.getLogger().handlers), first) + # 还原全局 logging 状态,避免影响其它测试 + logging.getLogger().handlers.clear() + logging_config._configured = False + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..6ab02ae --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,41 @@ +"""运行产物路径策略测试(T-003)。""" +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src import paths + + +class PathsTest(unittest.TestCase): + def test_safe_replaces_illegal_chars(self): + self.assertEqual(paths._safe("color/size..x"), "color_size__x") + self.assertEqual(paths._safe("ok-_1"), "ok-_1") + + def test_safe_empty_fallback(self): + self.assertEqual(paths._safe(""), "unknown") + self.assertEqual(paths._safe("/.."), "___") + + def test_screenshot_and_xml_paths(self): + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(paths, "ARTIFACTS_DIR", Path(tmp)): + png = paths.screenshot_path("T1", "open_product") + xml = paths.ui_xml_path("T1", "open_product") + self.assertTrue(png.name.endswith(".png")) + self.assertTrue(xml.name.endswith(".xml")) + # 同一任务归到 artifacts// 子目录 + self.assertEqual(png.parent.name, "T1") + self.assertEqual(xml.parent.name, "T1") + # 目录已被创建 + self.assertTrue(png.parent.is_dir()) + + def test_illegal_task_id_cannot_escape_root(self): + """task_id 含路径穿越字符时被安全化隔离,不逃逸 artifacts 根。""" + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(paths, "ARTIFACTS_DIR", Path(tmp)): + p = paths.screenshot_path("../evil", "step") + self.assertTrue(str(p.resolve()).startswith(str(Path(tmp).resolve()))) + + +if __name__ == "__main__": + unittest.main()