From d3afe69c10cb1cf5b29bd7798031c7589582970a Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 27 Jun 2026 09:21:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=9B=AE=E5=BD=95slug=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 app/config.py,生成稳定账号 slug 并创建 user-data-dir - db 账号创建复用统一 slug 规则 - 新增 config 单元测试覆盖 slug 和目录创建 - 更新任务看板、API 合约、当前状态和进度记录 --- app/config.py | 48 +++++++++++++++++++++++++++++++++++++++++++ app/db.py | 11 ++-------- docs/06-tasks.md | 2 +- docs/api.md | 8 ++++++-- docs/current-state.md | 16 +++++++++------ progress.md | 8 ++++++++ tests/test_config.py | 48 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 app/config.py create mode 100644 tests/test_config.py diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..357db1f --- /dev/null +++ b/app/config.py @@ -0,0 +1,48 @@ +"""Account path helpers for Chrome user-data directories.""" + +from __future__ import annotations + +import hashlib +import os +import re + +from . import appconfig + + +SLUG_PATTERN = re.compile(r"^[a-z0-9_]+$") + + +class ConfigPathError(RuntimeError): + """Raised when account path configuration is invalid.""" + + +def make_slug(alias) -> str: + """Return a stable unique slug for an account alias.""" + + text = str(alias or "").strip() + if not text: + raise ConfigPathError("别名不能为空") + base = re.sub(r"[^a-z0-9_]+", "_", text.lower()).strip("_") + suffix = hashlib.sha1(text.encode("utf-8")).hexdigest()[:8] + return f"{base}_{suffix}" if base else f"account_{suffix}" + + +def _validate_slug(slug) -> str: + text = str(slug or "").strip() + if not text: + raise ConfigPathError("slug 不能为空") + if not SLUG_PATTERN.fullmatch(text): + raise ConfigPathError("slug 只能包含小写字母、数字和下划线") + return text + + +def ensure_user_data_dir(slug, root=None, config=None) -> str: + """Create and return the absolute user-data-dir path for a slug.""" + + safe_slug = _validate_slug(slug) + user_data_root = root if root is not None else appconfig.user_data_root(config) + if not str(user_data_root).strip(): + raise ConfigPathError("user_data_root 不能为空") + path = os.path.abspath(os.path.join(str(user_data_root), safe_slug)) + os.makedirs(path, exist_ok=True) + return path diff --git a/app/db.py b/app/db.py index 83d03ec..7e57250 100644 --- a/app/db.py +++ b/app/db.py @@ -2,10 +2,8 @@ from __future__ import annotations -import hashlib import json import os -import re import sqlite3 import uuid from contextlib import contextmanager @@ -14,6 +12,7 @@ from datetime import datetime from typing import Iterable, Optional from . import appconfig +from .config import make_slug DEFAULT_BUSY_TIMEOUT_MS = 5000 @@ -220,12 +219,6 @@ def _validate_fields(fields, allowed): raise DbError(f"不支持更新字段: {', '.join(unknown)}") -def _slug_from_alias(alias: str) -> str: - base = re.sub(r"[^a-z0-9_]+", "_", alias.lower()).strip("_") - suffix = hashlib.sha1(alias.encode("utf-8")).hexdigest()[:8] - return f"{base}_{suffix}" if base else f"account_{suffix}" - - def _row_key(batch_id, source_file_abs, source_sheet, source_row) -> str: return f"{batch_id}:{source_file_abs}:{source_sheet}:{source_row}" @@ -330,7 +323,7 @@ def add_account( path=None, conn=None, ): - slug = slug or _slug_from_alias(alias) + slug = slug or make_slug(alias) user_data_dir = user_data_dir or os.path.join("chrome_user_data_dir", slug) now = _now() with _connection(conn, path) as database: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 4cf0c17..3751521 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -35,7 +35,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-101 | `config` 生成 slug + 创建 `chrome_user_data_dir/` | T-003 | 别名→唯一 slug;目录按需建;路径绝对化 | TODO | +| T-101 | `config` 生成 slug + 创建 `chrome_user_data_dir/` | T-003 | 别名→唯一 slug;目录按需建;路径绝对化 | DONE | | T-102 | `app/chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | TODO | | T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | TODO | | T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | TODO | diff --git a/docs/api.md b/docs/api.md index 8dfa484..b16beb9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,13 +110,17 @@ export_copy(batch_id, out_dir_or_path) -> dict # 退路:另存新结果 列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威。 -## config 模块(`app/config.py`,待建) +## config 模块(`app/config.py`,已建) ```python +class ConfigPathError(RuntimeError): ... make_slug(alias) -> str # 别名→唯一 slug [a-z0-9_] -ensure_user_data_dir(slug) -> str # chrome_user_data_dir/ 绝对路径,按需创建 +ensure_user_data_dir(slug, root=None, config=None) -> str +# 默认 root = appconfig.user_data_root(config);返回 chrome_user_data_dir/ 绝对路径,按需创建 ``` +`make_slug()` 使用可读 ASCII 前缀 + 8 位 SHA1 后缀,保证稳定且降低别名冲突;非 ASCII 别名使用 `account_`。`ensure_user_data_dir()` 拒绝非 `[a-z0-9_]` slug,防止路径穿越。 + ## chrome 模块(`app/chrome.py`,待建) ```python diff --git a/docs/current-state.md b/docs/current-state.md index 9ecdade..407cafa 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,10 +6,10 @@ ## 当前快照 - 日期:2026-06-27 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具。 - 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。 -- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数与端口读取,以及 `config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。 +- 测试:`tests/` 已建立;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) @@ -36,8 +36,9 @@ | `app/editor.py` | 已有 | T-001 产出:登录检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task | | `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 | | `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | +| `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `tests/` | 已有 | T-006 产出:stdlib unittest 基座;appconfig/db 单元测试;excel/prompts 模块契约占位测试 | -| `app/excel.py` / `app/config.py` / `app/chrome.py` / `app/workers.py` | 待建 | Phase 0-3 产出 | +| `app/excel.py` / `app/chrome.py` / `app/workers.py` | 待建 | Phase 1-3 产出 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | ## 已验证能力(单账号) @@ -52,9 +53,9 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)。 - 正在进行:无。 -- 下一个可领取任务:**T-101(`config` 生成 slug + 创建账号 user-data-dir)**。 +- 下一个可领取任务:**T-102(`app/chrome.py` 启动器)**。 ## 当前可运行内容 @@ -74,6 +75,9 @@ git check-ignore -v -- config.json config/ai_models.json cmshopee.db chrome_user # ai_models 临时清单读写与打码检查 py -3 -c "import os,tempfile; from app import appconfig; d=tempfile.TemporaryDirectory(dir='.'); p=os.path.join(d.name,'ai_models.json'); print([m['category'] for m in appconfig.list_ai_models(path=p)])" +# config slug 与 user-data-dir 临时目录检查 +py -3 -c "import tempfile; from app import config; d=tempfile.TemporaryDirectory(dir='.'); print(config.ensure_user_data_dir(config.make_slug('alias'), root=d.name))" + # 当前入口占位 python main.py py -3 -m app diff --git a/progress.md b/progress.md index c1d32e6..1eac096 100644 --- a/progress.md +++ b/progress.md @@ -295,3 +295,11 @@ - 说明:`app.excel` 与 `app.prompts` 尚未实现,`test_module_contracts.py` 对这两个模块做契约占位测试;当前表现为 2 个 skip,后续模块文件出现后会检查公开函数是否齐全。 - 验证:`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(9 tests,skipped=2);`python -m unittest discover -s tests` 通过(9 tests,skipped=2);测试临时目录位于 `tests/` 下并已清理。 - 下一步:按任务看板领取 T-101。 + +## 【2026-06-27】T-101 账号 slug 与 user-data-dir + +- 状态:DONE +- 变更:新增 `app/config.py`,实现 `make_slug(alias)` 与 `ensure_user_data_dir(slug, root=None, config=None)`;`app/db.py` 改为复用同一 slug 生成规则;新增 `tests/test_config.py` 覆盖 slug 稳定性、非 ASCII 别名、非法 slug 拒绝、目录创建与绝对路径;同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`。 +- 规则:slug = 可读 ASCII 前缀 + 8 位 SHA1 后缀;非 ASCII 别名使用 `account_`;`ensure_user_data_dir` 只接受 `[a-z0-9_]`,防止路径穿越。 +- 验证:`py -3 -m compileall app main.py tests` 通过;`py -3 -m unittest discover -s tests` 通过(12 tests,skipped=2);`python -m unittest discover -s tests` 通过(12 tests,skipped=2);测试临时目录已清理。 +- 下一步:按任务看板领取 T-102。 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a8e50a4 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,48 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) + +from _helpers import TempDirMixin + +from app import config + + +class ConfigPathTests(TempDirMixin, unittest.TestCase): + def test_make_slug_is_stable_and_safe(self): + self.assertEqual("alias_cdb6fdbe", config.make_slug("alias")) + self.assertEqual("my_shop_01_b92aea49", config.make_slug("My Shop 01")) + + chinese_slug = config.make_slug("台湾店铺") + self.assertTrue(chinese_slug.startswith("account_")) + self.assertRegex(chinese_slug, r"^account_[0-9a-f]{8}$") + + with self.assertRaises(config.ConfigPathError): + config.make_slug("") + + def test_ensure_user_data_dir_creates_absolute_path(self): + with self.make_temp_dir() as temp_dir: + slug = config.make_slug("alias") + path = config.ensure_user_data_dir(slug, root=temp_dir) + same_path = config.ensure_user_data_dir(slug, root=temp_dir) + + self.assertEqual(path, same_path) + self.assertTrue(os.path.isabs(path)) + self.assertTrue(os.path.isdir(path)) + self.assertEqual(slug, os.path.basename(path)) + + self.assert_removed(temp_dir) + + def test_ensure_user_data_dir_rejects_unsafe_slug(self): + with self.make_temp_dir() as temp_dir: + for slug in ("../alias", "Alias", "a-b", ""): + with self.subTest(slug=slug): + with self.assertRaises(config.ConfigPathError): + config.ensure_user_data_dir(slug, root=temp_dir) + + self.assert_removed(temp_dir) + + +if __name__ == "__main__": + unittest.main()