feat: 完成账号目录slug工具
- 新增 app/config.py,生成稳定账号 slug 并创建 user-data-dir - db 账号创建复用统一 slug 规则 - 新增 config 单元测试覆盖 slug 和目录创建 - 更新任务看板、API 合约、当前状态和进度记录
This commit is contained in:
@@ -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,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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user