feat(T-003): 建立日志与运行产物目录策略

- src/paths.py: 集中管理 logs/、artifacts/ 路径与产物命名
  (artifacts/<task_id>/<时间戳>_<step>.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 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-06-24 17:32:28 +08:00
co-authored by Claude Opus 4.8
parent 41bc666322
commit b1e38ffd7a
13 changed files with 246 additions and 25 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
title run
cd /d "%~dp0src" && python main.py
cd /d "%~dp0" && python -m src.main
pause
+1 -1
View File
@@ -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
```
说明:
+2 -2
View File
@@ -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 .
```
+6 -3
View File
@@ -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/<task_id>/<时间戳>_<step>.png|.xml` 归档,`task_id`/`step` 入路径前安全化以防穿越。
- `logs/`:本地运行日志(`app.log`),运行时自动创建并被 `.gitignore` 忽略,不提交真实敏感日志。
- `artifacts/`:截图、UI XML、结果文件等运行产物,运行时自动创建并被 `.gitignore` 忽略,不提交真实数据。
- `samples/`:脱敏样例 Excel。
## 八、架构纪律
+1 -1
View File
@@ -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. 绝不
+10 -10
View File
@@ -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 .
+12 -3
View File
@@ -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/<task_id>/<时间戳>_<step>.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 表头待提供。
+62
View File
@@ -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})
+10 -3
View File
@@ -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
+58
View File
@@ -0,0 +1,58 @@
"""运行时路径与产物归档策略(T-003)。
集中管理日志与运行产物(截图、UI XML)的输出位置与命名,
确保排查时能按任务 ID 定位证据。
约定:
- `logs/`、`artifacts/` 位于项目根,运行时自动创建,并被 `.gitignore` 忽略,
不提交真实运行数据。
- 截图与 UI XML 按 `artifacts/<task_id>/<时间戳>_<step>.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/<task_id>/,并确保存在。"""
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/<task_id>/<时间戳>_<step>.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/<task_id>/<时间戳>_<step>.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"
+1 -1
View File
@@ -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 · 最高风险验证
+41
View File
@@ -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()
+41
View File
@@ -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/<task_id>/ 子目录
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()