feat: 完成账号目录slug工具

- 新增 app/config.py,生成稳定账号 slug 并创建 user-data-dir

- db 账号创建复用统一 slug 规则

- 新增 config 单元测试覆盖 slug 和目录创建

- 更新任务看板、API 合约、当前状态和进度记录
This commit is contained in:
chengma
2026-06-27 09:21:33 +08:00
parent aba272dd21
commit d3afe69c10
7 changed files with 123 additions and 18 deletions
+48
View File
@@ -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
+2 -9
View File
@@ -2,10 +2,8 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os import os
import re
import sqlite3 import sqlite3
import uuid import uuid
from contextlib import contextmanager from contextlib import contextmanager
@@ -14,6 +12,7 @@ from datetime import datetime
from typing import Iterable, Optional from typing import Iterable, Optional
from . import appconfig from . import appconfig
from .config import make_slug
DEFAULT_BUSY_TIMEOUT_MS = 5000 DEFAULT_BUSY_TIMEOUT_MS = 5000
@@ -220,12 +219,6 @@ def _validate_fields(fields, allowed):
raise DbError(f"不支持更新字段: {', '.join(unknown)}") 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: def _row_key(batch_id, source_file_abs, source_sheet, source_row) -> str:
return f"{batch_id}:{source_file_abs}:{source_sheet}:{source_row}" return f"{batch_id}:{source_file_abs}:{source_sheet}:{source_row}"
@@ -330,7 +323,7 @@ def add_account(
path=None, path=None,
conn=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) user_data_dir = user_data_dir or os.path.join("chrome_user_data_dir", slug)
now = _now() now = _now()
with _connection(conn, path) as database: with _connection(conn, path) as database:
+1 -1
View File
@@ -35,7 +35,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-101 | `config` 生成 slug + 创建 `chrome_user_data_dir/<slug>` | T-003 | 别名→唯一 slug;目录按需建;路径绝对化 | TODO | | T-101 | `config` 生成 slug + 创建 `chrome_user_data_dir/<slug>` | T-003 | 别名→唯一 slug;目录按需建;路径绝对化 | DONE |
| T-102 | `app/chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | TODO | | T-102 | `app/chrome.py` 启动器:拼参数并启动、探测端口 | T-101, T-002 | 含三参数;端口就绪可探测 | TODO |
| T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | TODO | | T-103 | 首次登录保活 + 登录检测 `is_logged_in` | T-102, T-001 | 关闭再启动免重登;登录/未登录判断准确 | TODO |
| T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | TODO | | T-104 | PySide6 五 Tab 主窗口骨架(`QMainWindow` + `QTabWidget`,5 Tab 空壳) | T-002 | 五个 Tab 按顺序可切换;启动不阻塞;基础状态栏可用 | TODO |
+6 -2
View File
@@ -110,13 +110,17 @@ export_copy(batch_id, out_dir_or_path) -> dict # 退路:另存新结果
列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威。 列模板见 [架构 5.3](04-architecture.md);别名以“别名”列为权威。
## config 模块(`app/config.py`,待建) ## config 模块(`app/config.py`,已建)
```python ```python
class ConfigPathError(RuntimeError): ...
make_slug(alias) -> str # 别名→唯一 slug [a-z0-9_] make_slug(alias) -> str # 别名→唯一 slug [a-z0-9_]
ensure_user_data_dir(slug) -> str # chrome_user_data_dir/<slug> 绝对路径,按需创建 ensure_user_data_dir(slug, root=None, config=None) -> str
# 默认 root = appconfig.user_data_root(config);返回 chrome_user_data_dir/<slug> 绝对路径,按需创建
``` ```
`make_slug()` 使用可读 ASCII 前缀 + 8 位 SHA1 后缀,保证稳定且降低别名冲突;非 ASCII 别名使用 `account_<hash>`。`ensure_user_data_dir()` 拒绝非 `[a-z0-9_]` slug,防止路径穿越。
## chrome 模块(`app/chrome.py`,待建) ## chrome 模块(`app/chrome.py`,待建)
```python ```python
+10 -6
View File
@@ -6,10 +6,10 @@
## 当前快照 ## 当前快照
- 日期:2026-06-27 - 日期: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(已定)。 - 技术栈: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。 - 生产代码:已建立 `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,并对尚未实现的 app.excel/app.prompts 做契约占位 skip;CDP/Shopee 改动仍需测试商品手动验证。 - 测试:`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。 - 数据:`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/editor.py` | 已有 | T-001 产出:登录检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task |
| `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 | | `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 |
| `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 | | `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 模块契约占位测试 | | `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/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 | | `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)。 任务状态以 [`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 临时清单读写与打码检查 # 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)])" 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 python main.py
py -3 -m app py -3 -m app
+8
View File
@@ -295,3 +295,11 @@
- 说明:`app.excel` 与 `app.prompts` 尚未实现,`test_module_contracts.py` 对这两个模块做契约占位测试;当前表现为 2 个 skip,后续模块文件出现后会检查公开函数是否齐全。 - 说明:`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/` 下并已清理。 - 验证:`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。 - 下一步:按任务看板领取 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_<hash>`;`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。
+48
View File
@@ -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()