diff --git a/.gitignore b/.gitignore index 9de8845..ebc829e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ chrome_user_data_dir/ images/ # 本地诊断日志 logs/ +# T-538 后统一数据根(配置、DB、图片、登录态、日志、提示词) +data/ # 运营填写后的 Excel 业务文件;标准空模板可提交 *.xlsx *.xlsm diff --git a/app/appconfig.py b/app/appconfig.py index 58f9dd7..fb629aa 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -1,24 +1,64 @@ """Application-level configuration for cmshopee. -This module owns `config.json` plus `config/ai_models.json`. `config.json` -stores local app settings and AI role/generation parameters. AI provider -definitions and local plaintext API keys live in ignored `config/ai_models.json`. +This module owns `data/config.json` plus `data/config/ai_models.json`. +`config.json` stores local app settings and AI role/generation parameters. +AI provider definitions and local plaintext API keys live in ignored +`data/config/ai_models.json`. """ import copy import json import os +import shutil +import sys +import tempfile import urllib.error import urllib.parse import urllib.request -CONFIG_PATH = "config.json" -AI_MODELS_PATH = os.path.join("config", "ai_models.json") -CMHUB_CONFIG_PATH = os.path.join("config", "cmhub.json") +DATA_DIR_NAME = "data" + + +def app_base_dir() -> str: + """Return the application directory used as the parent of local data.""" + + if getattr(sys, "frozen", False): + return os.path.dirname(os.path.abspath(sys.executable)) + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def default_data_dir(base_dir=None) -> str: + """Return the default local user data directory.""" + + return os.path.abspath(os.path.join(base_dir or app_base_dir(), DATA_DIR_NAME)) + + +CONFIG_PATH = os.path.join(default_data_dir(), "config.json") +AI_MODELS_PATH = os.path.join(default_data_dir(), "config", "ai_models.json") +CMHUB_CONFIG_PATH = os.path.join(default_data_dir(), "config", "cmhub.json") CATEGORIES = {"text", "image"} API_TYPES = {"chat", "images_edits", "auto"} AI_BACKENDS = {"direct", "cmhub"} +RUNTIME_CONFIG_KEYS = { + "config_path", + "ai_models_path", + "cmhub_config_path", + "data_dir", +} +LEGACY_USER_DATA_PATHS = ( + "config.json", + "config", + "cmshopee.db", + "cmshopee.db-wal", + "cmshopee.db-shm", + "db.sqlite", + "chrome_user_data_dir", + "images", + "logs", + "prompts", + "title_prompt.txt", +) DEFAULT_CONFIG = { "chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe", @@ -104,6 +144,180 @@ class ConfigError(RuntimeError): """Raised when app configuration is missing or malformed.""" +class DataMigrationConflictError(ConfigError): + """Raised when old-layout data cannot be moved into data/ safely.""" + + +class DataDirectoryWriteError(ConfigError): + """Raised when the local data directory is not writable.""" + + +def _data_dir_for_config_path(path) -> str: + config_path = os.path.abspath(path or CONFIG_PATH) + if config_path == os.path.abspath(CONFIG_PATH): + return default_data_dir() + return os.path.dirname(config_path) + + +def data_dir(config=None) -> str: + """Return the absolute local user data directory.""" + + if isinstance(config, dict): + if config.get("data_dir"): + return os.path.abspath(str(config["data_dir"])) + if config.get("config_path"): + return _data_dir_for_config_path(config["config_path"]) + return default_data_dir() + + +def _strip_runtime_config_keys(config): + if not isinstance(config, dict): + return config + return { + key: copy.deepcopy(value) + for key, value in config.items() + if key not in RUNTIME_CONFIG_KEYS + } + + +def _runtime_paths(config_path, data_dir_path=None): + root = os.path.abspath(data_dir_path or _data_dir_for_config_path(config_path)) + return { + "config_path": os.path.abspath(config_path or os.path.join(root, "config.json")), + "data_dir": root, + "ai_models_path": os.path.join(root, "config", "ai_models.json"), + "cmhub_config_path": os.path.join(root, "config", "cmhub.json"), + } + + +def _with_runtime_paths(config, path): + result = copy.deepcopy(config) + result.update(_runtime_paths(path)) + return result + + +def _data_parent_dir(config=None) -> str: + return os.path.dirname(data_dir(config)) + + +def resolve_data_path(path, config=None) -> str: + """Resolve a user-data path under data_dir unless it is already absolute.""" + + text = str(path or "").strip() + if not text: + return "" + if os.path.isabs(text): + return os.path.abspath(text) + normalized = os.path.normpath(text) + first_part = normalized.split(os.sep, 1)[0] + if first_part == DATA_DIR_NAME: + return os.path.abspath(os.path.join(_data_parent_dir(config), normalized)) + return os.path.abspath(os.path.join(data_dir(config), normalized)) + + +def data_path(*parts, config=None) -> str: + return os.path.abspath(os.path.join(data_dir(config), *[str(part) for part in parts])) + + +def ai_models_config_path(config=None) -> str: + if isinstance(config, dict) and config.get("ai_models_path"): + return os.path.abspath(str(config["ai_models_path"])) + return data_path("config", "ai_models.json", config=config) + + +def cmhub_config_file_path(config=None) -> str: + if isinstance(config, dict) and config.get("cmhub_config_path"): + return os.path.abspath(str(config["cmhub_config_path"])) + return data_path("config", "cmhub.json", config=config) + + +def title_prompt_path(config=None) -> str: + return data_path("title_prompt.txt", config=config) + + +def cover_prompts_dir(config=None) -> str: + return data_path("prompts", "cover", config=config) + + +def diagnostic_log_dir(config=None) -> str: + return data_path("logs", config=config) + + +def _is_within(path, parent) -> bool: + try: + return os.path.commonpath([os.path.abspath(path), os.path.abspath(parent)]) == os.path.abspath(parent) + except ValueError: + return False + + +def migrate_legacy_user_data(base_dir=None, data_dir_path=None): + """Move old exe-top-level local data into data_dir without overwriting.""" + + base = os.path.abspath(base_dir or app_base_dir()) + target_root = os.path.abspath(data_dir_path or default_data_dir(base)) + if base == target_root: + return [] + os.makedirs(target_root, exist_ok=True) + + moves = [] + seen_sources = set() + for relative in LEGACY_USER_DATA_PATHS: + source = os.path.abspath(os.path.join(base, relative)) + if source in seen_sources or not os.path.exists(source): + continue + seen_sources.add(source) + if _is_within(source, target_root): + continue + if not _is_within(source, base): + raise ConfigError(f"旧数据路径不在程序目录内: {source}") + target = os.path.abspath(os.path.join(target_root, relative)) + if not _is_within(target, target_root): + raise ConfigError(f"迁移目标路径不在 data 目录内: {target}") + moves.append((relative, source, target)) + + conflicts = [relative for relative, _source, target in moves if os.path.exists(target)] + if conflicts: + raise DataMigrationConflictError( + "检测到旧布局数据和 data 目录内数据同时存在,无法自动迁移。" + "请先手动合并或备份后再启动。冲突项: " + "、".join(conflicts) + ) + + moved = [] + for relative, source, target in moves: + os.makedirs(os.path.dirname(target), exist_ok=True) + shutil.move(source, target) + moved.append(relative) + return moved + + +def ensure_writable_data_dir(path=None) -> str: + root = os.path.abspath(path or default_data_dir()) + try: + os.makedirs(root, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix=".cmshopee_write_test_", + dir=root, + delete=False, + ) as fh: + marker = fh.name + fh.write("ok") + os.remove(marker) + except Exception as exc: + raise DataDirectoryWriteError( + f"数据目录不可写:{root}。请把程序放到可写目录,勿放 Program Files。" + ) from exc + return root + + +def prepare_data_dir(base_dir=None, data_dir_path=None, migrate=True) -> str: + root = os.path.abspath(data_dir_path or default_data_dir(base_dir)) + if migrate: + migrate_legacy_user_data(base_dir=base_dir, data_dir_path=root) + return ensure_writable_data_dir(root) + + def default_config() -> dict: """Return a new copy of the default config.""" @@ -241,7 +455,7 @@ def _normalize_cmhub_config(config): if config is None: config = {} if not isinstance(config, dict): - raise ConfigError("config/cmhub.json 必须是对象") + raise ConfigError("cmhub 配置必须是对象") return {"api_key": str(config.get("api_key", "") or "")} @@ -279,7 +493,7 @@ def get_cmhub_api_key(path=CMHUB_CONFIG_PATH, masked=False) -> str: def save_config(config, path=CONFIG_PATH) -> dict: """Persist config to JSON and return the normalized config.""" - normalized = _deep_merge(DEFAULT_CONFIG, config) + normalized = _deep_merge(DEFAULT_CONFIG, _strip_runtime_config_keys(config)) _normalize_config_values(normalized) _assert_no_secrets(normalized) directory = os.path.dirname(os.path.abspath(path)) @@ -288,7 +502,7 @@ def save_config(config, path=CONFIG_PATH) -> dict: with open(path, "w", encoding="utf-8") as fh: json.dump(normalized, fh, ensure_ascii=False, indent=2) fh.write("\n") - return normalized + return _with_runtime_paths(normalized, path) def load_config(path=CONFIG_PATH) -> dict: @@ -304,7 +518,7 @@ def load_config(path=CONFIG_PATH) -> dict: normalized = _deep_merge(DEFAULT_CONFIG, loaded) _normalize_config_values(normalized) _assert_no_secrets(normalized) - return normalized + return _with_runtime_paths(normalized, path) def update_config(updates, path=CONFIG_PATH) -> dict: @@ -323,15 +537,18 @@ def chrome_path(config=None) -> str: def user_data_root(config=None) -> str: - return _config_or_load(config).get("user_data_root", "chrome_user_data_dir") + cfg = _config_or_load(config) + return resolve_data_path(cfg.get("user_data_root", "chrome_user_data_dir"), cfg) def image_dir(config=None) -> str: - return _config_or_load(config).get("image_dir", "images") + cfg = _config_or_load(config) + return resolve_data_path(cfg.get("image_dir", "images"), cfg) def db_path(config=None) -> str: - return _config_or_load(config).get("db_path", "cmshopee.db") + cfg = _config_or_load(config) + return resolve_data_path(cfg.get("db_path", "cmshopee.db"), cfg) def default_debug_port(config=None) -> int: @@ -446,7 +663,7 @@ def _normalize_ai_model(model): def _normalize_ai_models_config(config): models = config.get("models") if isinstance(config, dict) else None if not isinstance(models, list): - raise ConfigError("config/ai_models.json 必须包含 models 列表") + raise ConfigError("AI 模型清单必须包含 models 列表") normalized = {"models": [_normalize_ai_model(model) for model in models]} _assert_unique_model_names(normalized["models"]) _assert_required_categories(normalized["models"]) diff --git a/app/diagnostics.py b/app/diagnostics.py index fa42a53..e5f90d0 100644 --- a/app/diagnostics.py +++ b/app/diagnostics.py @@ -10,7 +10,7 @@ import traceback from . import appconfig -DEFAULT_LOG_DIR = "logs" +DEFAULT_LOG_DIR = appconfig.diagnostic_log_dir() DEFAULT_LOG_FILE = "cmshopee.log" DEFAULT_MAX_BYTES = 2 * 1024 * 1024 DEFAULT_BACKUPS = 3 @@ -115,4 +115,4 @@ def _rotate_if_needed(path, max_bytes=DEFAULT_MAX_BYTES, backups=DEFAULT_BACKUPS target = f"{path}.{index + 1}" if os.path.exists(source): os.replace(source, target) - os.replace(path, f"{path}.1") \ No newline at end of file + os.replace(path, f"{path}.1") diff --git a/app/editor.py b/app/editor.py index 3a8eed5..2dff0e3 100644 --- a/app/editor.py +++ b/app/editor.py @@ -5,7 +5,7 @@ import os import time from urllib.parse import urlparse -from . import image_paths +from . import appconfig, image_paths from .cdp import CDP, close_tab, create_tab, create_tab_info, find_product_tab, http_get @@ -834,7 +834,12 @@ def collect(account, task, on_step=None) -> dict: old_cover_src = read_cover_src(cdp) out_path = _get(task, "old_cover_path") if not out_path: - out_path = image_paths.task_image_path("images", task, account, "old") + out_path = image_paths.task_image_path( + appconfig.image_dir(), + task, + account, + "old", + ) _notify_collect_step(on_step, "download_cover") old_cover_path = download_cover(old_cover_src, out_path) return { diff --git a/app/gui/__init__.py b/app/gui/__init__.py index d7393a6..1721602 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import os import sys +from .. import appconfig from . import widgets as _widgets from .widgets import * @@ -46,6 +47,17 @@ def main() -> int: return 1 _ensure_offscreen_for_headless_tests() app = QApplication.instance() or QApplication(sys.argv) + try: + appconfig.prepare_data_dir() + except appconfig.DataMigrationConflictError as exc: + QMessageBox.critical(None, "数据迁移冲突", str(exc)) + return 1 + except appconfig.DataDirectoryWriteError as exc: + QMessageBox.critical(None, "数据目录不可写", str(exc)) + return 1 + except appconfig.ConfigError as exc: + QMessageBox.critical(None, "启动配置错误", str(exc)) + return 1 window = MainWindow() window.show() return app.exec() diff --git a/app/gui/main_window.py b/app/gui/main_window.py index f681f1b..397195b 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -84,7 +84,7 @@ class MainWindow(QMainWindow): self.ai_models_path = ( ai_models_path or self.config.get("ai_models_path") - or appconfig.AI_MODELS_PATH + or appconfig.ai_models_config_path(self.config) ) self.setWindowTitle("蝦皮圈優化助手") _fit_and_center_window(self) @@ -123,6 +123,8 @@ class MainWindow(QMainWindow): config=self.config, config_path=self.config_path, status_callback=self.statusBar().showMessage, + title_prompt_path=appconfig.title_prompt_path(self.config), + cover_prompts_dir=appconfig.cover_prompts_dir(self.config), open_accounts_callback=lambda: self.open_accounts_tab(), ) if title == "③ 更新shopee": diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 16b6ce3..01d9e6d 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -48,8 +48,8 @@ class GenerateTab(QWidget): self.db_path = _database_path(db_path, self.config) self.status_callback = status_callback self.open_accounts_callback = open_accounts_callback - self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH - self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR + self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config) + self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config) self.current_cover_template = None self.generate_worker = None self.generate_thread = None @@ -288,7 +288,7 @@ class GenerateTab(QWidget): payload = { key: value for key, value in self.config.items() - if key not in {"config_path", "ai_models_path", "cmhub_config_path"} + if key not in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"} } payload["ai"] = ai_settings try: @@ -299,7 +299,7 @@ class GenerateTab(QWidget): internal = { key: value for key, value in self.config.items() - if key in {"config_path", "ai_models_path", "cmhub_config_path"} + if key in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"} } self.config.clear() self.config.update(saved) diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index a506619..8a760eb 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -10,7 +10,7 @@ from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker PLAINTEXT_CMHUB_API_KEY_WARNING = ( - "cmhub API Key 会以本地明文保存到 config/cmhub.json,仅供本机调用 cmhub 网关使用。" + "cmhub API Key 会以本地明文保存到 data/config/cmhub.json,仅供本机调用 cmhub 网关使用。" "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" ) @@ -23,7 +23,7 @@ def CMHubSettingsWorker(*args, **kwargs): return _call_package_attr("CMHubSettingsWorker", _RealCMHubSettingsWorker, *args, **kwargs) class SettingsTab(QWidget): - """Tab 5: AI model definitions stored in config/ai_models.json.""" + """Tab 5: AI model definitions stored in data/config/ai_models.json.""" BACKEND_ITEMS = [("直连模型", "direct"), ("cmhub 网关", "cmhub")] CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")] @@ -48,11 +48,11 @@ class SettingsTab(QWidget): self.ai_models_path = ( ai_models_path or self.config.get("ai_models_path") - or appconfig.AI_MODELS_PATH + or appconfig.ai_models_config_path(self.config) ) self.cmhub_config_path = ( self.config.get("cmhub_config_path") - or self._default_cmhub_config_path(self.config_path) + or appconfig.cmhub_config_file_path(self.config) ) self.status_callback = status_callback self.models = [] @@ -546,15 +546,6 @@ class SettingsTab(QWidget): self._cmhub_auto_refresh_done = True self.refresh_cmhub_models() - def _default_cmhub_config_path(self, config_path): - if config_path and config_path != appconfig.CONFIG_PATH: - return os.path.join( - os.path.dirname(os.path.abspath(config_path)), - "config", - "cmhub.json", - ) - return appconfig.CMHUB_CONFIG_PATH - def _on_backend_changed(self, index=None): self.backend_combo.setVisible(False) self.model_picker_panel.setVisible(False) @@ -752,7 +743,7 @@ class SettingsTab(QWidget): settings = { key: value for key, value in self.config.items() - if key not in {"config_path", "ai_models_path", "cmhub_config_path"} + if key not in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"} } settings.update( { @@ -799,6 +790,8 @@ class SettingsTab(QWidget): internal["ai_models_path"] = self.ai_models_path if self.cmhub_config_path != appconfig.CMHUB_CONFIG_PATH: internal["cmhub_config_path"] = self.cmhub_config_path + if self.config.get("data_dir"): + internal["data_dir"] = self.config.get("data_dir") self.config.clear() self.config.update(saved) self.config.update(internal) @@ -847,9 +840,11 @@ class SettingsTab(QWidget): ) self.jpg_quality_spin.setValue(int(ai_cfg.get("jpg_quality", 90) or 90)) self.chrome_path_edit.setText(appconfig.chrome_path(self.config)) - self.user_data_root_edit.setText(appconfig.user_data_root(self.config)) - self.image_dir_edit.setText(appconfig.image_dir(self.config)) - self.db_path_edit.setText(appconfig.db_path(self.config)) + self.user_data_root_edit.setText( + str(self.config.get("user_data_root", "chrome_user_data_dir") or "") + ) + self.image_dir_edit.setText(str(self.config.get("image_dir", "images") or "")) + self.db_path_edit.setText(str(self.config.get("db_path", "cmshopee.db") or "")) self.default_debug_port_spin.setValue( int(appconfig.default_debug_port(self.config)) ) @@ -1318,5 +1313,3 @@ class SettingsTab(QWidget): def _category_label(self, category): return {"text": "文本", "image": "图像"}.get(category, category) - - diff --git a/app/gui/widgets.py b/app/gui/widgets.py index 8e20dc3..0d08ed8 100644 --- a/app/gui/widgets.py +++ b/app/gui/widgets.py @@ -170,7 +170,7 @@ def run_worker(*args, **kwargs): PLAINTEXT_SECRET_TITLE = "本地明文保存提示" PLAINTEXT_API_KEY_WARNING = ( - "API Key 会以本地明文保存到 config/ai_models.json,仅供本机调用 AI 使用。" + "API Key 会以本地明文保存到 data/config/ai_models.json,仅供本机调用 AI 使用。" "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" ) PLAINTEXT_PASSWORD_WARNING = ( diff --git a/app/prompts.py b/app/prompts.py index 2b4fbde..cec46f0 100644 --- a/app/prompts.py +++ b/app/prompts.py @@ -4,9 +4,10 @@ from __future__ import annotations import os +from . import appconfig -TITLE_PROMPT_PATH = "title_prompt.txt" -COVER_PROMPTS_DIR = os.path.join("prompts", "cover") +TITLE_PROMPT_PATH = appconfig.title_prompt_path() +COVER_PROMPTS_DIR = appconfig.cover_prompts_dir() TEMPLATE_EXT = ".txt" INVALID_NAME_CHARS = set('\\/:*?"<>|') diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 6c07ce8..93525a8 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -11,7 +11,7 @@ 目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。 -存储:应用设置 `config.json` + AI 模型清单 `config/ai_models.json`(含本地明文 AI Key,必须 gitignore)+ 业务数据 SQLite `cmshopee.db` + Excel 用 openpyxl + 图片存本地 `images/`。AI 服务商/模型由 `config/ai_models.json` 配置。 +存储:T-538 后统一放在本地 `data/` 下:应用设置 `data/config.json` + cmhub Key `data/config/cmhub.json` + direct AI 模型清单 `data/config/ai_models.json`(含本地明文 AI Key,必须 gitignore)+ 业务数据 SQLite `data/cmshopee.db` + Excel 用 openpyxl + 图片存本地 `data/images/`。普通产品默认由 `data/config.json` + `data/config/cmhub.json` 配置 cmhub 网关。 ## 必读顺序 @@ -84,7 +84,7 @@ 项目事实只信: - [`04-architecture.md`](04-architecture.md) 第七节:CDP 交互已验证结论(选择器、就绪判断、上传/拖拽方式)。 -- [`04-architecture.md`](04-architecture.md) 5.1/5.1b/5.2/5.3:`config.json`、`config/ai_models.json`、SQLite schema、Excel 模板。 +- [`04-architecture.md`](04-architecture.md) 5.1/5.1b/5.2/5.3:`data/config.json`、`data/config/ai_models.json`、SQLite schema、Excel 模板。 - 真实页面探查结果(用 `prototypes/inspect_images.py` / `prototypes/cookies.py` 实地确认)。 - 已验证脚本 `cdp.py`(T-000 后迁入 `app/cdp.py`)、`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py` 中跑通的逻辑。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index eba6640..a303032 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -43,7 +43,7 @@ | 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 | | 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 | | AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题;封面图片按②本轮「生成封面图片(成本较高)」开关可选生成,避免用户无意产生图片模型成本;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 | -| 提示词管理(②) | 标题提示词「保存」到 `title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | +| 提示词管理(②) | 标题提示词「保存」到 `data/title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | | 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 | | 更新 shopee(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新 Shopee,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 | | 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 | @@ -91,7 +91,7 @@ | 更新前账号就绪 | ③ 真实更新前检查当前筛选结果需要的账号;Chrome 未启动、CDP 端口不可达或未登录时整体中止并提示去④,不静默自动启动账号浏览器 | | 旧标题/旧封面 | 程序在「采集」阶段改前抓取的快照(输出列),运营不填 | | 新标题/新封面 | AI 生成(输出列),直接用于 ③;不设逐条确认阶段,本地留档+回写 Excel 供追溯 | -| AI 服务 | 文本+图像生成,服务商/模型待定;Key 本地明文存于 `config/ai_models.json`,首次保存/变更时提示,UI 打码、不入日志;见 [技术栈](03-tech-stack.md) | +| AI 服务 | 文本+图像生成;普通产品默认 cmhub 网关,Key 本地明文存于 `data/config/cmhub.json`;direct 兼容清单存于 `data/config/ai_models.json`;首次保存/变更时提示,UI 打码、不入日志;见 [技术栈](03-tech-stack.md) | | 本地图片 | 旧封面下载、新封面生成存本地图片目录,路径记 DB | | 结果落库时机 | 各阶段处理完立即写 SQLite;该 Excel 全部完成后批量回写原文件 | | 原文件被占用 | 回写时若原 Excel 被锁定,提示关闭重试或另存副本(SQLite 为事实来源) | @@ -100,11 +100,11 @@ ## 七、待确认 / 风险点 - **账号 / 凭证风险**:登录态存在各账号 user-data-dir,等同账号凭证;目录不得提交版本库、不得外传。 -- **本地敏感文件风险**:Tab④ 的密码本地明文存于 SQLite,仅供人工参考,**绝不自动登录/自动填**;AI Key 本地明文存于 `config/ai_models.json`;首次保存/变更密码或 API Key 时必须提示“本地明文保存”;`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 必须 gitignore;UI 打码、日志/导出脱敏(见 [任务 T-503](06-tasks.md))。 +- **本地敏感文件风险**:Tab④ 的密码本地明文存于 SQLite,仅供人工参考,**绝不自动登录/自动填**;AI Key 本地明文存于 `data/config/cmhub.json` 或 direct 兼容的 `data/config/ai_models.json`;首次保存/变更密码或 API Key 时必须提示“本地明文保存”;`data/` 整体必须 gitignore,旧布局 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 也继续 gitignore 以支持迁移;UI 打码、日志/导出脱敏(见 [任务 T-503](06-tasks.md))。 - **别名匹配风险**:别名以 Excel 列为准;不匹配的行略过并最后弹窗,执行起始在日志逐条打印「文件→匹配账号」留痕,防误改。 - **AI 主图风险(高,已知并接受)**:本设计不设逐条人工审核和常驻提交开关;AI 生成的标题/封面经 ③ 批量确认后会提交线上。主图若失真/夸大/侵权可能违反 Shopee 规则甚至下架。缓解:开始更新前弹窗确认筛选范围和任务数量,新图本地留档 + 回写 Excel 供事后追溯;强烈建议先在测试商品验证再批量。 - **AI 成本与依赖**:接入文本+图像 AI = 新外部依赖 + API 费用 + Key 管理;服务商/模型/合规待 [技术栈](03-tech-stack.md) 确认。 -- **AI Key 安全**:Key 本地明文存于 `config/ai_models.json`,保存/变更时提示,UI 打码,不写日志,不提交版本库。 +- **AI Key 安全**:Key 本地明文存于 `data/config/cmhub.json` 或 direct 兼容的 `data/config/ai_models.json`,保存/变更时提示,UI 打码,不写日志,不提交版本库。 - **第三方平台风险**:Shopee 页面结构、class 名、接口随时可能变;限流、风控、封号风险存在,禁止高频批量。 - **自动化边界风险**:③ 确认后会自动改标题、上传图片、拖拽并点「更新」提交线上;点「开始更新」并确认前需自行确保筛选范围、任务来源与 AI 产出可接受。 - **检查边界**:③「检查本轮更新」只检查当前筛选任务并写运行日志,不打开 Shopee、不点击更新、不改任务状态;点击「开始更新」并确认后才可能真实提交。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 02ee580..dd8b5bd 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -14,34 +14,34 @@ | HTTP | `requests` | 已定 | 读 `/json` 拿 tab 列表;`trust_env=False` 忽略代理 | | 浏览器 | Google Chrome(已安装) | 已定 | 带 `--remote-debugging-port` 启动 | | GUI 框架 | PySide6(Qt for Python,`QTabWidget` 5 Tab) | 已定 | 当前环境已安装 PySide6;V1 需要表格、图片预览、后台任务进度、确认弹窗,Qt 的 signal/slot + QThread 更适合 | -| 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 | -| 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 | +| 应用配置 | `data/config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 | +| 业务数据 | SQLite(stdlib `sqlite3`,`data/cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 | | Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx | -| AI 模型注册 | `config/cmhub.json` 网关 Key + `config/ai_models.json` direct 兼容清单 | 已接入 | 普通产品默认 cmhub,使用 `config.json` 的 Base URL/别名 + `config/cmhub.json` 单 Key;direct 模型清单保留为内部兼容/手工回滚路径,普通⑤设置页不暴露后端切换 | +| AI 模型注册 | `data/config/cmhub.json` 网关 Key + `data/config/ai_models.json` direct 兼容清单 | 已接入 | 普通产品默认 cmhub,使用 `data/config.json` 的 Base URL/别名 + `data/config/cmhub.json` 单 Key;direct 模型清单保留为内部兼容/手工回滚路径,普通⑤设置页不暴露后端切换 | | AI 文本生成 | `app/ai.py` 默认 cmhub `POST /api/v1/generate/title`,保留 direct chat JSON 兼容分支 | 已接入 | 提示词+旧标题→新标题;返回值不变;cmhub 计费 metadata 通过事件回调上报 | | AI 图像生成 | `app/ai.py` 默认 cmhub `POST /api/v1/generate/image`,保留 direct chat/images_edits 兼容分支 | 已接入 | 提示词+旧封面→新封面;cmhub 拿 `image_url` 后安全下载并转本地 JPEG;生图读超时不自动重发 | | 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;③ 可按账号并行更新;可停止、可重试 | | 运行日志 | SQLite `run_logs` / `run_log_events` | 已定 | ③ dry-run 与真实更新都留痕;结构化内容走脱敏 | | 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 | | 测试 | `python -m compileall app main.py` + `unittest` + 手动 CDP/AI 验证 | 已定(分层) | 配置/DB/Excel/prompts 用单测;CDP/Shopee 与真实 AI 属集成验证或 mock | -| 打包分发 | PyInstaller onedir(`cmshopee.spec`) | 已定 | 产出 Windows 免安装文件夹;不内置配置、DB、图片、日志、Chrome 登录态或提示词等本地数据 | +| 打包分发 | PyInstaller onedir(`cmshopee.spec`) | 已定 | 产出 Windows 免安装文件夹;不内置配置、DB、图片、日志、Chrome 登录态或提示词等本地数据;运行时统一写入程序同级 `data/` | ## 二、决策记录与演进 - **CDP 自研而非 playwright**:当前已验证根目录 `cdp.py`,正式代码迁入 `app/cdp.py`;它零重依赖、完全可控,并已在开发环境绕开了代理(`*_proxy` 指向本地 :1080)和 Chrome 的 Origin 403 两个坑。未来若交互复杂度大幅上升,再评估 playwright。 - **GUI 选 PySide6**:V1 是 5 Tab 运营工作台,包含任务表格、筛选、图片预览、后台采集/生成/更新、进度与停止。当前环境已安装 PySide6,且 Tkinter 不可用;Qt 的 `QThread`/signal-slot 比 Tkinter 手动 queue/after 更适合长任务回传 UI。 -- **存储拆两层**:应用设置进 `config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。 +- **存储拆两层**:应用设置进 `data/config.json`,账号/任务/结果进 SQLite。判据:少量人改无需查询 → 配置文件;成行增长要查询/导出 → DB。同一事实只存一处,不重复。取代早期的 `accounts.json` 方案。 - **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。 - **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。 - **快捷方式生成用 PowerShell(无额外依赖)**:用 `WScript.Shell.CreateShortcut` 生成 `.lnk`,不引入 `pywin32` 等依赖。 -- **AI 服务商收口到 cmhub 网关**:T-301 的 `config/ai_models.json` direct 通用 HTTP 接入仍保留为内部兼容/手工回滚路径;T-526~T-529 后普通产品默认 `backend=cmhub`,⑤设置页只展示 cmhub Base URL + 生文/生图别名 + `config/cmhub.json` 单 Key,不再暴露 AI 后端切换。 -- **AI 模型 category 是硬约束**:`config/ai_models.json` 每个模型必须有 `category=text` 或 `category=image`;启动时报 “AI 模型 category 必须是 text 或 image” 时,按 [常见问题排查](troubleshooting.md) 修复本地配置,不删除或提交含 Key 的配置文件。 -- **敏感信息不加密但强提示与脱敏**:密码与 AI Key 只在本机 SQLite / `config/ai_models.json` / `config/cmhub.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。 +- **AI 服务商收口到 cmhub 网关**:T-301 的 `data/config/ai_models.json` direct 通用 HTTP 接入仍保留为内部兼容/手工回滚路径;T-526~T-529 后普通产品默认 `backend=cmhub`,⑤设置页只展示 cmhub Base URL + 生文/生图别名 + `data/config/cmhub.json` 单 Key,不再暴露 AI 后端切换。 +- **AI 模型 category 是硬约束**:`data/config/ai_models.json` 每个模型必须有 `category=text` 或 `category=image`;启动时报 “AI 模型 category 必须是 text 或 image” 时,按 [常见问题排查](troubleshooting.md) 修复本地配置,不删除或提交含 Key 的配置文件。 +- **敏感信息不加密但强提示与脱敏**:密码与 AI Key 只在本机 SQLite / `data/config/ai_models.json` / `data/config/cmhub.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。 - **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。 - **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。 - **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。 - **生产在 Windows 直跑**:开发期我们用过 WSL→Windows 的 `netsh portproxy`(9333→9222)连 CDP;但 GUI 与 Chrome 都在 Windows 时,直接连 `127.0.0.1:9222`,无需 portproxy。 -- **PyInstaller 使用 onedir 免安装包**:T-524 先做 `dist/cmshopee/cmshopee.exe` + `_internal/` 的文件夹分发,不做自动更新器;打包版启动时工作目录切到 exe 所在目录,用户本地配置、DB、图片、日志、登录态和提示词保留在程序目录,更新时只覆盖程序文件。 +- **PyInstaller 使用 onedir 免安装包**:T-524 先做 `dist/cmshopee/` onedir,不做自动更新器;当前打包依赖锁定 PyInstaller 5.13.2,onedir 产物是扁平结构,`cmshopee.exe` 和 DLL/PYD/依赖目录同级,`_internal/` 不作为当前验收标准。T-538 后打包版不再 `chdir` 到 exe 目录,用户本地配置、DB、图片、日志、登录态和提示词统一保留在 exe 同级 `data/` 子目录,更新时覆盖程序文件并保留 `data/`。 ## 三、构建与运行命令 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 2e9b5d7..1b6928f 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -16,7 +16,7 @@ GUI(PySide6 QTabWidget,5 Tab) | v 核心模块(Python) - ├── appconfig 读应用配置 config.json(Chrome 路径、目录根、AI 配置、端口范围…) + ├── appconfig 读应用配置 data/config.json(Chrome 路径、目录根、AI 配置、端口范围…) ├── db SQLite 读写:账号、任务、各阶段结果(cmshopee.db) ├── excel openpyxl 导入输入列 / 回写输出列到原 Excel ├── config 账号 ↔ user-data-dir 绑定、slug、目录创建 @@ -38,7 +38,7 @@ Shopee 卖家中心页面 / 本地图片目录 - GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。 - 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。 - 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。 -- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `config.json` 的 `ai.cmhub` + `config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。 +- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;普通产品默认 cmhub 网关,由 `data/config.json` 的 `ai.cmhub` + `data/config/cmhub.json` 配置;direct 直连模型清单仅作为内部兼容/手工回滚路径保留);`openpyxl`。 ## 二、流水线(核心) @@ -63,7 +63,7 @@ imported → collected → generated → applied **核心模块** -- `appconfig`:读写 `config.json`(Chrome 路径、`chrome_user_data_dir` 根、图片目录、AI 配置、端口、DB 路径)。 +- `appconfig`:读写 `data/config.json`(Chrome 路径、`chrome_user_data_dir` 根、图片目录、AI 配置、端口、DB 路径)并把默认相对路径解析到 `data/`。 - `db`:SQLite 读写账号、任务、各阶段结果;建表/迁移。 - `excel`:openpyxl 读输入列、把输出列回写原 Excel(处理文件锁)。 - `config`:账号 ↔ user-data-dir 绑定;slug;目录创建。 @@ -75,13 +75,15 @@ imported → collected → generated → applied **存储(同一事实只存一处)** -- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `config.json`。 -- AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。 -- cmhub 网关 Key → `config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 -- 业务数据(账号、任务、各阶段结果)→ SQLite `cmshopee.db`。 -- 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。 -- 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。 -- 登录态 → 各账号 `chrome_user_data_dir//`。 +- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。 +- AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `data/config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。 +- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 +- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 +- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 +- 提示词 → 标题提示词存单文件 `data/title_prompt.txt`;封面提示词存多模板 `data/prompts/cover/<名称>.txt`。 +- 登录态 → 各账号 `data/chrome_user_data_dir//`。 + +T-538 后统一数据根为 `data/`:打包版默认 `/data`,源码运行默认项目根 `data/`。`config.json` 中 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时由 `appconfig` 解析到 `data/` 下;绝对路径作为高级自定义仍按原值使用。启动时会迁移 T-524 旧包的 exe 顶层数据到 `data/`,并检测 `data/` 可写。 ## 四、多账号隔离方案(决策) @@ -91,7 +93,7 @@ imported → collected → generated → applied ## 五、数据模型 -### 5.1 应用配置 `config.json` +### 5.1 应用配置 `data/config.json` ```json { @@ -142,8 +144,8 @@ imported → collected → generated → applied - `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。 - `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。 - `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。 -- direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 -- 密钥不在 `config.json`:cmhub API Key 存于 `config/cmhub.json`;direct 内部兼容模式每个模型的 `api_key` 存于 `config/ai_models.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。 +- direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `data/config/ai_models.json`,见 5.1b。 +- 密钥不在 `config.json`:cmhub API Key 存于 `data/config/cmhub.json`;direct 内部兼容模式每个模型的 `api_key` 存于 `data/config/ai_models.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。 `shopee_update` 段放**真实更新前的安全开关**: @@ -158,7 +160,7 @@ imported → collected → generated → applied 该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时仍必须先通过真实更新安全开关检查。普通正式更新的安全检查不读取 `test_item_id` 做阻断。 -### 5.1b AI 模型清单 `config/ai_models.json` +### 5.1b AI 模型清单 `data/config/ai_models.json` 模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。该文件只用于内部兼容 `backend=direct`;普通用户默认 `backend=cmhub`,生文/生图使用 cmhub 别名,不读取此文件。 @@ -190,7 +192,7 @@ imported → collected → generated → applied - `name` 唯一;`api_key` 本地明文保存、保存/变更时弹窗提示、打码显示。 - 日志/状态/导出不得含密码或 API Key;结构化对象统一先过 `appconfig.sanitize_for_log()`,自由文本只允许在掌握明文值时用 `appconfig.redact_secrets()` 替换。 -### 5.1c cmhub Key `config/cmhub.json` +### 5.1c cmhub Key `data/config/cmhub.json` ```json { "api_key": "sk_cmhub_xxx" } @@ -310,13 +312,13 @@ CREATE TABLE run_log_events ( 采用两层日志: - **SQLite 运行日志(业务可读)**:复用 `run_logs/run_log_events`,用于 GUI 查看和运营排查。`run_type` 扩展到 `collect/generate/import/write_back/apply/chrome_launch/login_check/ai_model_test`。事件必须带 `task_id/alias/item_id`(能拿到时),`message` 统一包含 `step=<步骤>`、结果和简短错误;不新增敏感字段,不写 Cookie、密码、API Key、token。 -- **本地诊断 log 文件(开发调试)**:写入 `logs/cmshopee.log` 或按日期滚动文件,保存脱敏后的 traceback、异常类型、步骤、耗时和必要上下文。`logs/` 必须 gitignore;自由文本异常进入日志前要用 `appconfig.redact_secrets()`,结构化 payload 先过 `sanitize_for_log()`。 +- **本地诊断 log 文件(开发调试)**:写入 `data/logs/cmshopee.log` 或按日期滚动文件,保存脱敏后的 traceback、异常类型、步骤、耗时和必要上下文。`data/logs/` 必须 gitignore;自由文本异常进入日志前要用 `appconfig.redact_secrets()`,结构化 payload 先过 `sanitize_for_log()`。 优先级: -1. **T-207 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。失败时 DB 事件保存最后步骤和简短错误,本地 `logs/cmshopee.log` 保存完整脱敏 traceback。 -2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `logs/cmshopee.log` 都写脱敏错误。 -3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新shopee 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。 +1. **T-207 已接入 ① 采集**:记录批次开始/结束、账号预检、每个商品开始/成功/失败/略过;关键步骤覆盖 `preflight`、`open_product`、`wait_ready`、`read_title`、`read_cover`、`download_cover`、`db_write`、`excel_write_back`。失败时 DB 事件保存最后步骤和简短错误,本地 `data/logs/cmshopee.log` 保存完整脱敏 traceback。 +2. **② AI生成已先补诊断**:`GenerateWorker` 创建 `run_type=generate`,按任务记录标题/封面阶段事件;关键步骤覆盖 `title_submit`、`load_text_model`、`title_build_request`、`title_request`、`title_parse_response`、`cover_prompt_render`、`cover_submit`、`cover_validate_input`、`load_image_model`、`cover_build_request`、`cover_request`、`cover_parse_response`、`cover_save`、`db_write`。失败时任务 `last_error`、DB 运行日志和本地 `data/logs/cmshopee.log` 都写脱敏错误。 +3. **T-505 已扩展全流程**:Excel 导入创建 `run_type=import`,记录选中文件、缺列、脏行、入库统计;Excel 回写创建 `run_type=write_back`,记录文件写入、文件锁/保存异常和行数;③ 更新shopee 的 `run_type=apply` 覆盖安全/账号预检、Chrome/CDP 检查、登录检测、打开商品页、改标题、换封面、点更新、写库;④「启动登录」创建 `run_type=chrome_launch`,④「检测登录」创建 `run_type=login_check`;⑤ AI 模型测试连接创建 `run_type=ai_model_test`。失败时本地 `data/logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。 4. **T-404b 已接入商品页失败 toast 捕获**:①采集和③更新在 `open_product`/等待详情页就绪失败时,不应只报等待超时。进入/刷新商品编辑页后应捕获 `.eds-toasts .eds-toast__content` 的文本和 `outerHTML`,记录当前 URL、时间、可见状态,并在关键元素超时时把最近错误 toast 作为用户可读失败原因写入 `last_error`、`run_log_events` 与本地诊断日志;例如商品 ID 失效时提示 `please input correct product id`。若 toast 明确属于商品失效/商品不存在/无权限类错误,底层仍写 `stage=imported/status=failed/last_error=商品失效:<原始toast>`,只在①导入采集列表“阶段”显示“商品失效”;其他打开失败仍显示“失败”。若失败发生在 `open_product()` 内部且 `cdp` 尚未返回上层,`open_product()` 必须自行清理本轮自动新建 tab;复用用户已有 tab 只断开 CDP,不关闭页面。失败现场可保存 HTML/toast JSON 片段,但不得记录 Cookie、密码、token。 @@ -366,11 +368,11 @@ CREATE TABLE run_log_events ( ### 5.4 本地图片目录 ```text -images///__old. # 采集下载的旧封面 -images///__new. # AI 生成的新封面 +data/images///__old. # 采集下载的旧封面 +data/images///__new. # AI 生成的新封面 ``` -路径记入 DB 后以 `old_cover_path/new_cover_path` 为权威;上传新封面使用 DB 中的本地绝对路径(Windows 绝对路径传 `setFileInputFiles`)。历史数据里的 `images//_*.jpg` 路径继续有效,不做强制迁移;新采集/新生成才使用按批次细分的目录。 +路径记入 DB 后以 `old_cover_path/new_cover_path` 为权威;上传新封面使用 DB 中的本地绝对路径(Windows 绝对路径传 `setFileInputFiles`)。历史数据里的 `images//_*.jpg` 路径继续有效,不做强制迁移;T-538 后新采集/新生成默认写入 `data/images/` 下的按批次细分目录。 ## 六、关键流程细节 @@ -389,7 +391,7 @@ images///__new. # AI 生成的新封 - `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。 -- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `images///__old.jpg`。 +- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images///__old.jpg`。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 - 采集任务结束且本轮有成功采集行时,自动触发当前批次旧字段回写;原 Excel 被锁时不影响 SQLite 结果,提示关闭后重试,并保留手动「回写旧数据到 Excel」入口。 @@ -398,7 +400,7 @@ images///__new. # AI 生成的新封 单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成: 1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。 -2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images///__new.jpg`。默认 `backend=cmhub`,调用 `POST /api/v1/generate/image`,拿 `image_url` 后立即安全下载并转本地 JPEG;`backend=direct` 仅内部兼容时使用 `default_image_model`。 +2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `data/images///__new.jpg`。默认 `backend=cmhub`,调用 `POST /api/v1/generate/image`,拿 `image_url` 后立即安全下载并转本地 JPEG;`backend=direct` 仅内部兼容时使用 `default_image_model`。 - 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。 3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。 @@ -407,19 +409,19 @@ images///__new. # AI 生成的新封 - **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 - 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。 - 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并只更新标题;若后续需要封面,用户可重置生成结果或重新生成该任务。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。 -- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`config.json` 的 `ai` 段)。 +- 并发数、重试、分辨率、jpg 质量、模型/Key 均来自 ⑤ 设置(`data/config.json` 的 `ai` 段;Key 存 `data/config/cmhub.json` 或 direct 兼容清单)。 提示词管理: -- **标题提示词**:单个文本,「保存」写入 `title_prompt.txt`;软件启动时加载该文件回显到输入框(缺失则空)。 -- **封面提示词**:多模板。下拉选模板(读 `prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。 +- **标题提示词**:单个文本,「保存」写入 `data/title_prompt.txt`;软件启动时加载该文件回显到输入框(缺失则空)。 +- **封面提示词**:多模板。下拉选模板(读 `data/prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。 - **变量**:封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在光标处插入 `{新标题}`;「预览」= 用某条任务的值替换变量后展示,确认实际发送给 AI 的内容。 ### 6.3 应用更新(③ Tab) - ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。 - ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新字段和略过原因。 -- 弹确认前先读取 `config.json` 的 `shopee_update`:未开启 `allow_real_submit`,或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。 +- 弹确认前先读取 `data/config.json` 的 `shopee_update`:未开启 `allow_real_submit`,或任务含新封面但未开启 `allow_cover_update` 时,直接弹警告阻断,不创建真实更新 `ApplyWorker`。普通正式更新不再检查 `test_item_id`,当前筛选结果可以包含多个真实商品 ID。`max_items_per_run` 作为每批最大任务数,当前筛选总数超过该值时自动分批,不再按总数阻断。 - 用户点「是/确认」才开始批量更新;点「否/取消」不执行、不改库。 - 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 Chrome 未启动、CDP 端口不可访问、未登录或本轮账号端口冲突时,整体返回 `blocked` 并由 GUI 弹窗列出账号/原因、引导去④账号管理。预检不通过时不创建商品编辑页、不调用 `editor.apply_task()`、不写失败状态、不自动调用「启动登录」或静默打开 Chrome。 - 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。标题-only 任务没有 `new_cover_path` 时跳过封面更新,也不需要开启 ⑤「允许更新封面」。 @@ -464,7 +466,7 @@ images///__new. # AI 生成的新封 ## 八、推荐开发顺序 -1. **地基**:先建 `app/` 包、`app/cdp.py`、`app/__main__.py`、根目录 `main.py`;再做 `app/editor.py`(含采集)、`app/appconfig.py`+`config.json`、`app/db.py`、`.gitignore`。 +1. **地基**:先建 `app/` 包、`app/cdp.py`、`app/__main__.py`、根目录 `main.py`;再做 `app/editor.py`(含采集)、`app/appconfig.py`+`data/config.json`、`app/db.py`、`.gitignore`。 2. **账号与启动**(④):`config` 建目录、`chrome` 启动器/快捷方式、登录保活与检测、账号 CRUD。 3. **导入采集**(①):`excel` 导入、采集旧标题/旧封面、回写。 4. **AI 生成**(②):`ai` 模块、提示词、对照预览。 @@ -484,26 +486,27 @@ cmshopee/ │ ├── editor.py / ai.py / prompts.py / gui.py / workers.py ├── main.py # GUI 启动入口:from app.gui import main ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 -├── config.json # 应用配置(模型选择/生成参数/路径,gitignore) -├── config/ai_models.json # direct AI 模型清单(含密钥,必须 gitignore) -├── config/cmhub.json # cmhub API Key(含密钥,必须 gitignore) -├── cmshopee.db # SQLite(账号/任务/结果,gitignore) -├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore) -├── images/ # 旧封面/新封面本地图片(gitignore) -├── title_prompt.txt # 标题提示词(单文件,启动回显) -├── prompts/cover/<名称>.txt # 封面提示词模板(多个) +├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留) +│ ├── config.json # 应用配置(模型选择/生成参数/路径) +│ ├── config/ai_models.json # direct AI 模型清单(含密钥) +│ ├── config/cmhub.json # cmhub API Key(含密钥) +│ ├── cmshopee.db # SQLite(账号/任务/结果) +│ ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态) +│ ├── images/ # 旧封面/新封面本地图片 +│ ├── title_prompt.txt # 标题提示词(单文件,启动回显) +│ └── prompts/cover/<名称>.txt └── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py) # 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md ``` -> `config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 含配置/密钥/凭证/业务数据,必须 gitignore。`shopee待处理任务模板.xlsx` 是标准空模板,可以提交;运营填写后的 Excel 副本属于业务数据,不提交。 +> `data/`、旧布局的 `config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 含配置/密钥/凭证/业务数据,必须 gitignore。`shopee待处理任务模板.xlsx` 是标准空模板,可以提交;运营填写后的 Excel 副本属于业务数据,不提交。 ## 十、架构纪律 - CDP 交互事实变化同步第七节。 - 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 - `app` 包内模块优先用相对导入(如 `from .cdp import CDP`);根入口 `main.py` 用 `from app.gui import main`。 -- 存储边界:应用设置→config.json,账号/任务/结果→SQLite,图片→本地目录并记路径于 DB,登录态→user-data-dir;同一事实只存一处。 +- 存储边界:应用设置→`data/config.json`,账号/任务/结果→SQLite,图片→本地目录并记路径于 DB,登录态→user-data-dir;同一事实只存一处。 - 别名是账号↔任务唯一关联键。 - 密码与 AI Key 本地明文保存,保存/变更时弹窗提示;UI 打码、不外传、不写日志/导出;不自动登录。 - AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。 diff --git a/docs/05-coding-rules.md b/docs/05-coding-rules.md index 5ba29b3..385580b 100644 --- a/docs/05-coding-rules.md +++ b/docs/05-coding-rules.md @@ -78,7 +78,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交) - 绝不把真实账号、密码、Cookie、token 写进代码、文档或日志。 - 绝不把密码/API Key 写进代码、文档、日志或导出文件;本地配置/DB 可明文保存,必须 gitignore,保存/变更时提示“本地明文保存”,UI 打码。 -- 绝不把 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 提交版本库。 +- 绝不把 `data/`、`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 提交版本库。 - 绝不提交运营填写后的 Excel 业务文件;根目录 `shopee待处理任务模板.xlsx` 是标准空模板,允许提交。 - 绝不自动登录 / 自动填账号密码;登录由人工完成,程序只检测登录态。 - 绝不在缺少 ③ 批量确认弹窗确认的情况下点击「更新」提交线上。 @@ -88,7 +88,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交) ## 8. 安全与合规 - 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。 -- 写日志、状态 payload、导出调试信息前,结构化数据先过 `appconfig.sanitize_for_log()`;自由文本只有在掌握明文值时才用 `appconfig.redact_secrets()` 替换,不要把原始密码/API Key 拼进异常或状态栏;`logs/` 本地诊断日志必须 gitignore,不提交版本库。 +- 写日志、状态 payload、导出调试信息前,结构化数据先过 `appconfig.sanitize_for_log()`;自由文本只有在掌握明文值时才用 `appconfig.redact_secrets()` 替换,不要把原始密码/API Key 拼进异常或状态栏;`data/logs/` 本地诊断日志必须 gitignore,不提交版本库。 - 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。 - 高风险动作(删除线上封面、点击更新)必须有显式确认,并先在测试商品验证;删除线上第一张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。 - ③ 的批量确认弹窗是提交线上前的确认边界;T-504 的 dry-run 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 468e006..3da8004 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -144,7 +144,7 @@ | T-541 | 打包版主窗口初始位置与小屏适配 | T-523, T-537, T-540 | Bug:在 Windows 10 虚拟机/小分辨率环境运行打包后的 `dist/cmshopee/cmshopee.exe` 或 release 包时,主窗口可能贴到屏幕左上角且左边/标题栏显示不全,看不到「蝦皮圈優化助手 v」标题,用户无法拖动窗口到中间。根因:`MainWindow.__init__()` 只调用 `self.resize(1180, 760)`,没有根据 `QApplication.primaryScreen().availableGeometry()` 限制初始尺寸、没有居中、没有保证窗口 frame/title bar 在可用屏幕范围内;PyInstaller 本身不是根因,小屏、高 DPI、任务栏占用、多显示器或 VM 分辨率更容易触发。方案:① 抽一个主窗口初始布局 helper(如 `_fit_and_center_window(window, preferred_size=(1180,760), margin=40)`),读取当前屏幕 `availableGeometry()`;② 初始宽高不超过可用区域减安全边距,保留合理最小值(如 960x640,若屏幕更小则继续适配);③ `resize()` 后按可用区域中心 `move()`,确保窗口左上角不小于可用区域左上角,标题栏完整可见;④ offscreen/headless 测试环境不依赖真实屏幕时可跳过或用 mock geometry;⑤ 不保存/恢复历史窗口位置,避免把坏坐标持久化。同步 `docs/packaging.md` 打包验收:在目标 VM/小分辨率环境启动 release exe,窗口标题栏必须完整可见且可拖动。GUI 单测覆盖小屏 mock 下尺寸被限制、窗口被居中/夹进可用区域;不改业务流程、DB、AI、Excel、CDP/Shopee、打包产物结构 | DONE | | T-525 | 引入 ruff(lint + format)+ 可选 pre-commit | T-006 | 依据 `docs/engineering-review.md` P1。加 `ruff` 配置(lint + format),先以现状为基线不做大规模风格重排,只开启安全规则(未用 import/变量、明显错误);可选 `.pre-commit-config.yaml`;不改业务逻辑;CI(T-522)可串入 ruff 检查。数据模型渐进上 mypy 作为后续可选。当前因业务优先级调整顺延到 T-526~T-528 之后 | TODO | | T-537 | 品牌名「蝦皮圈優化助手」(显示层中文品牌,cmshopee 保留为代号/标识符) | T-523 | 需求:项目/软件正式定名「蝦皮圈優化助手」。**原则**:产品**显示/品牌名** = 「蝦皮圈優化助手」;`cmshopee` 保留为**代码库/仓库/技术代号**,一切**标识符不改**——遵项目「文档与 UI 文案用中文、标识符用英文」规则,且避免非 ASCII 文件名破坏现有用户数据。**改(显示/品牌层)**:① `app/gui/main_window.py` 窗口标题 `setWindowTitle("cmshopee")` → `"蝦皮圈優化助手"`(任务栏/标题栏显示中文品牌);② 文档产品名散文 `docs/00-ai-start-here.md`、`docs/README.md`、`AGENTS.md`(及 vision/brief 若有)把「cmshopee 是一个…」改为「蝦皮圈優化助手(代号 cmshopee)是一个…」,明确品牌名并保留代号;③ 空的 `README.md` 补品牌标题行;④ 可选:`app/gui/__init__.py` PySide6 缺失启动失败提示带上品牌名。**不改(技术标识符,保留 cmshopee)**:`app` 包名、`cmshopee.exe` 打包产物名、`cmshopee.db`(+`-wal/-shm`)/`db_path` 默认值、`cmshopee.spec`、`cmshopee.log`、`config.json`/`config/*.json` 文件名、slug/`chrome_user_data_dir`、仓库目录;`app/editor.py` 注入页面的 JS 全局 `window.__cmshopee_*`(JS 标识符须 ASCII)、`app/ai.py` multipart boundary `----cmshopee-*`、`app/workers.py` `thread._cmshopee_worker`、`app/chrome.py` 快捷方式内部前缀 `cmshopee-`。**决策点**:`app/excel.py` 回写文件名后缀 `_cmshopee回写` 为用户可见产物名——本任务**默认保留**(改动会影响已生成副本命名习惯),如需改「_優化助手回写」另行确认。**影响**:不改 db/exe/spec/包名 → 打包、更新流程、现有用户数据零影响。验收:窗口标题栏/任务栏/README/相关文档显示「蝦皮圈優化助手」;`cmshopee.db`/exe/包名不变;GUI 单测若断言窗口标题须同步更新;`python -m compileall app main.py` + `python -m unittest discover -s tests` 全绿。边界:纯显示/文档层,不改任何文件名、schema、流程、CDP/Shopee 逻辑 | DONE | -| T-538 | 打包产物用户数据收进 `data/` 子文件夹(便携方案 1) | T-524 | 问题:当前 onedir 产物把用户数据(`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db(+-wal/-shm)`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`)生成在 exe 顶层,和程序文件(`cmshopee.exe`+`_internal/`)混放:更新易误删数据、放进 Program Files 只读目录会写失败/被 VirtualStore 重定向、程序与数据关注点混杂。方案(保持便携):① **统一数据根**——所有用户数据落到 exe 同级的单个 `data/` 子文件夹;`app/appconfig.py` 用显式 `data_dir`(默认 `/data`,源码运行时可为项目根/`data`)解析并返回绝对路径,**替代现在 `main.py` 的 chdir**(chdir 影响全进程相对路径/文件对话框默认目录,脆弱);文件名保持不变(仍是 `cmshopee.db` 等),只是移入 `data/`。② **旧布局迁移**——新版首次运行若在 exe 顶层发现旧的 `config.json`/`cmshopee.db` 等,自动迁移进 `data/`(或明确提示手动迁移),不得对老用户生成空 `data/` 丢失原数据。③ **可写性检测**——启动时若 `data_dir` 不可写(如放在 Program Files),弹清晰错误「请把程序放到可写目录,勿放 Program Files」并优雅退出,不静默失败。④ **发布只含程序**——`cmshopee.spec`/`scripts/build_exe.ps1` 产物仅 `cmshopee.exe`+`_internal/`,构建检查确保不打包 `data/` 或任何用户数据;发布 zip 不含 `data/`,从而「整包解压覆盖」也安全(只覆盖程序、不碰 `data/`)。⑤ **更新流程前提**——原地更新=换程序留 `data/`,依赖:DB 迁移幂等且只向前(现 `init_db`+`ALTER TABLE ADD COLUMN`)、`config` 向前兼容(`load_config` 补默认值)、`_internal/` 整体替换不合并。同步更新 `docs/packaging.md`(目录结构、首次运行数据位置、§5 更新步骤简化为「覆盖 exe+_internal,`data/` 不动」)。测试:`test_appconfig` 断言各路径解析进 `data_dir`、可写性检测行为、旧布局迁移;`test_packaging` 断言产物不含 `data/`/用户数据。边界:只改路径解析层(`appconfig` 路径 + `main.py` 数据根 + 迁移/可写检测)、spec/构建脚本、打包文档;不改文件名、DB schema、业务逻辑、AI/Excel、CDP/Shopee 流程 | DOING | +| T-538 | 打包产物用户数据收进 `data/` 子文件夹(便携方案 1) | T-524 | 问题:当前 onedir 产物把用户数据(`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db(+-wal/-shm)`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`)生成在 exe 顶层,和程序文件/依赖文件混放:更新易误删数据、放进 Program Files 只读目录会写失败/被 VirtualStore 重定向、程序与数据关注点混杂。方案(保持便携):① **统一数据根**——所有用户数据落到 exe 同级的单个 `data/` 子文件夹;`app/appconfig.py` 用显式 `data_dir`(默认 `/data`,源码运行时可为项目根/`data`)解析并返回绝对路径,**替代现在 `main.py` 的 chdir**(chdir 影响全进程相对路径/文件对话框默认目录,脆弱);文件名保持不变(仍是 `cmshopee.db` 等),只是移入 `data/`。② **旧布局迁移**——新版首次运行若在 exe 顶层发现旧的 `config.json`/`cmshopee.db` 等,自动迁移进 `data/`(或明确提示手动迁移),不得对老用户生成空 `data/` 丢失原数据。③ **可写性检测**——启动时若 `data_dir` 不可写(如放在 Program Files),弹清晰错误「请把程序放到可写目录,勿放 Program Files」并优雅退出,不静默失败。④ **发布只含程序**——`cmshopee.spec`/`scripts/build_exe.ps1` 构建检查确保不打包 `data/` 或任何用户数据;当前锁定 PyInstaller 5.13.2,产物是扁平 onedir,`cmshopee.exe` 和 DLL/PYD/依赖目录同级,`_internal/` 不作为当前验收标准;发布 zip 不含 `data/`,从而「整包解压覆盖」也安全(覆盖程序文件、不碰 `data/`)。⑤ **更新流程前提**——原地更新=换程序留 `data/`,依赖:DB 迁移幂等且只向前(现 `init_db`+`ALTER TABLE ADD COLUMN`)、`config` 向前兼容(`load_config` 补默认值)。同步更新 `docs/packaging.md`(目录结构、首次运行数据位置、§5 更新步骤简化为「覆盖新版程序文件和依赖文件,`data/` 不动」)。测试:`test_appconfig` 断言各路径解析进 `data_dir`、可写性检测行为、旧布局迁移;`test_packaging` 断言产物不含 `data/`/用户数据。边界:只改路径解析层(`appconfig` 路径 + `main.py` 数据根 + 迁移/可写检测)、spec/构建脚本、打包文档;不改文件名、DB schema、业务逻辑、AI/Excel、CDP/Shopee 流程 | DONE | | T-539 | ⑤ 隐藏数据路径设置(设置简化,非防逆向) | T-538, T-517 | 背景:T-538 后用户数据固定收进 exe 同级 `data/`,⑤「路径与端口」里的 DB 路径(`dbPathEdit`)、图片目录(`imageDirEdit`)、账号数据根目录(`userDataRootEdit`)已不该由用户改动——改了会指向 `data/` 之外导致数据分裂/丢失。**定性说明**:本任务是**设置简化**,与 T-517/T-529 隐藏 `test_item_id`/`dry_run` 同一思路;**不是防逆向手段**——`cmshopee.db` 在磁盘上任何 SQLite 浏览器可直接打开,隐藏 UI 字段对逆向无意义,防逆向由另行任务(发布包加固/价值上移 cmhub)承担。方案:① ⑤普通设置页**隐藏** `db_path`/`image_dir`/账号数据根三个输入框(不再占表单位置),`config.json` 对应字段保留为内部兼容——手工编辑配置仍生效(内部回滚路径),加载/保存不丢字段;② **保留可见**:Chrome 路径(每台机器安装位置不同,真实配置需求)与默认端口/端口区间/CDP 就绪超时(端口冲突需可调);③ 首次运行/迁移后各路径默认解析到 `data/` 下(沿用 T-538 的 `data_dir` 规则),无需用户感知。同步 `docs/routes.md` ⑤ 说明与 GUI 设置单测(隐藏字段不再可见、config 字段仍读写、手工配置值仍被尊重)。边界:只改⑤设置页展示层与文档;不改 `config.json` schema、路径解析逻辑(T-538 范围)、DB、业务流程、CDP/Shopee | TODO | ## 里程碑 diff --git a/docs/README.md b/docs/README.md index 2a50fbd..227e181 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,7 +38,7 @@ python -m pip install -r requirements-build.txt powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1 ``` -`requirements.txt` 只锁定运行所需第三方依赖;打包环境使用 `requirements-build.txt` 安装 PyInstaller;`config.json`、`config/ai_models.json`、SQLite、图片、登录态和运营 Excel 仍是本地数据,不提交版本库。 +`requirements.txt` 只锁定运行所需第三方依赖;打包环境使用 `requirements-build.txt` 安装 PyInstaller;T-538 后 `data/` 统一保存配置、SQLite、图片、登录态、日志、提示词和密钥,运营 Excel 仍是本地数据,不提交版本库。 GitHub Actions 工作流见 `.github/workflows/tests.yml`,在 push / pull_request 上使用 Windows + Python 3.11 安装 `requirements.txt`,并自动运行语法检查和全量单元/GUI 测试。 diff --git a/docs/api.md b/docs/api.md index eb1ad5e..b19676e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,7 +6,7 @@ - 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:`)+ SQLite + openpyxl + AI 服务调用。 - 编码:UTF-8;传 Chrome / `setFileInputFiles` 的路径为 **Windows 绝对路径**。 -- 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 必须 gitignore;UI 打码显示,不出现在日志/导出。 +- 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;T-538 后默认落在 `data/config.json`、`data/config/ai_models.json`、`data/config/cmhub.json`、`data/cmshopee.db`、`data/chrome_user_data_dir/`、`data/images/`,`data/` 必须 gitignore;UI 打码显示,不出现在日志/导出。 - 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。 ## appconfig 模块(`app/appconfig.py`,已建) @@ -16,13 +16,16 @@ ```python class ConfigError(RuntimeError): ... default_config() -> dict -load_config(path="config.json") -> dict # 不存在则写默认 -save_config(config, path="config.json") -> dict -update_config(updates, path="config.json") -> dict +load_config(path="data/config.json") -> dict # 不存在则写默认;返回运行时 data_dir/config_path/ai_models_path/cmhub_config_path +save_config(config, path="data/config.json") -> dict +update_config(updates, path="data/config.json") -> dict chrome_path(config=None) -> str user_data_root(config=None) -> str image_dir(config=None) -> str db_path(config=None) -> str +data_dir(config=None) -> str +resolve_data_path(path, config=None) -> str +prepare_data_dir(...) -> str # 启动时迁移旧布局并检测 data_dir 可写 default_debug_port(config=None) -> int debug_port_range(config=None) -> tuple # (start, end) cdp_ready_timeout(config=None) -> int @@ -36,7 +39,7 @@ cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /ap response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率) ``` -`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `config/cmhub.json`;`config/ai_models.json` 仅为 direct 内部兼容路径。 +`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。普通产品默认 cmhub,AI Key 存 `data/config/cmhub.json`;`data/config/ai_models.json` 仅为 direct 内部兼容路径。T-538 后,配置中默认仍保存 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时解析到 `data/` 下,保持免安装目录可移动。 敏感信息展示/日志辅助: @@ -47,22 +50,22 @@ redact_secrets(text, secret_values=None) -> str # 用已知明文值替换自 ``` -cmhub Key 文件(`config/cmhub.json`,含本地明文密钥,T-526 已建;UI 由 T-527 接入): +cmhub Key 文件(`data/config/cmhub.json`,含本地明文密钥,T-526 已建;UI 由 T-527 接入): ```python default_cmhub_config() -> dict -load_cmhub_config(path="config/cmhub.json") -> dict # 缺文件返回空 key;默认 cmhub 但生成时会提示补配置 -save_cmhub_config(config, path="config/cmhub.json") -> dict -get_cmhub_api_key(path="config/cmhub.json", masked=False) -> str +load_cmhub_config(path="data/config/cmhub.json") -> dict # 缺文件返回空 key;默认 cmhub 但生成时会提示补配置 +save_cmhub_config(config, path="data/config/cmhub.json") -> dict +get_cmhub_api_key(path="data/config/cmhub.json", masked=False) -> str ``` -`config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/connect_timeout` 等非密钥配置;T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。 -AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用): +`data/config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/connect_timeout` 等非密钥配置;T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`data/config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。 +AI 模型清单(`data/config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用): ```python default_ai_models_config() -> dict -load_ai_models_config(path="config/ai_models.json") -> dict # 不存在则写默认,至少 text/image 各一个 -save_ai_models_config(config, path="config/ai_models.json") -> dict +load_ai_models_config(path="data/config/ai_models.json") -> dict # 不存在则写默认,至少 text/image 各一个 +save_ai_models_config(config, path="data/config/ai_models.json") -> dict list_ai_models(category=None) -> list[dict] # category=text/image 过滤;默认 api_key 打码,含 api_key_set add_ai_model(model) -> None # name 唯一校验 update_ai_model(name, **fields) -> None @@ -286,10 +289,10 @@ apply_task(account, task, close_success_tab=False) -> dict ```python class AIError(RuntimeError): ... -gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="config/cmhub.json") -> str +gen_title(title_prompt, old_title, retry=None, config=None, models_path="data/config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="data/config/cmhub.json") -> str # 文本生成:按 backend 分流;direct 走 chat JSON,cmhub 走 /generate/title;提示词 + 旧标题 → 新标题 -gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="config/cmhub.json") -> str +gen_cover(cover_prompt, old_cover_path, out_path, resolution=None, jpg_quality=None, retry=None, config=None, models_path="data/config/ai_models.json", on_step=None, on_event=None, cmhub_config_path="data/config/cmhub.json") -> str # 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits,cmhub 走 /generate/image; # 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir///__new.jpg`,历史 DB 已存路径继续按原路径读取 @@ -305,7 +308,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li 要点: - `backend=direct`:内部兼容/手工回滚路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。 -- `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`,封面调用 `POST /api/v1/generate/image`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `config/cmhub.json`。 +- `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`,封面调用 `POST /api/v1/generate/image`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `data/config/cmhub.json`。 - `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。 - `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。 - direct 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。cmhub 使用 `requests timeout=(connect, read)`,connect 来自 `ai.cmhub.connect_timeout`,read 随分辨率且封顶 600s。 @@ -320,15 +323,15 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li class PromptError(RuntimeError): ... # 标题提示词:单文件 -load_title_prompt(path="title_prompt.txt") -> str # 启动回显;缺失返回 "" -save_title_prompt(text, path="title_prompt.txt") -> None # 「保存」按钮 +load_title_prompt(path="data/title_prompt.txt") -> str # 启动回显;缺失返回 "" +save_title_prompt(text, path="data/title_prompt.txt") -> None # 「保存」按钮 -# 封面提示词:多模板(prompts/cover/<名称>.txt) -list_cover_templates(directory="prompts/cover") -> list[str] # 模板名列表(下拉用) -load_cover_template(name, directory="prompts/cover") -> str -save_cover_template(name, text, directory="prompts/cover") -> None -rename_cover_template(old, new, directory="prompts/cover") -> None -delete_cover_template(name, directory="prompts/cover") -> None +# 封面提示词:多模板(data/prompts/cover/<名称>.txt) +list_cover_templates(directory="data/prompts/cover") -> list[str] # 模板名列表(下拉用) +load_cover_template(name, directory="data/prompts/cover") -> str +save_cover_template(name, text, directory="data/prompts/cover") -> None +rename_cover_template(old, new, directory="data/prompts/cover") -> None +delete_cover_template(name, directory="data/prompts/cover") -> None # 变量替换 render_prompt(template_text, task) -> str @@ -339,8 +342,8 @@ render_prompt(template_text, task) -> str - 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」对选中任务调用 `render_prompt` 后展示。 - 生成封面时 `gen_cover` 的 prompt = `render_prompt(当前封面模板, task)`。 -- 模板与 `title_prompt.txt` 均为可手改的纯文本文件。 -- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `prompts/cover/*.txt`。 +- 模板与 `data/title_prompt.txt` 均为可手改的纯文本文件。 +- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。 - 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。 ## gui 模块(`app/gui/` 包,已建,PySide6) @@ -381,13 +384,13 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 ⑤ 设置当前要点(T-501): -- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `config.json` + `config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。 -- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `config.json` + `config/cmhub.json` 并回填控件。 +- `SettingsTab` 使用居中内容区 + 适度左右留白布局,当前留白已从 T-506 初始实现缩短到约 40%;实现上使用最大内容宽度和自适应 margin,避免固定像素导致小屏挤压。各设置组默认响应式 3 列表单:短字段占 1 格,长字段(URL/API Key/路径)跨 2 格或 3 格,窄窗口降为 2 列/1 列。点击「保存设置」成功后,调用 `QMessageBox.information` 弹出“设置已保存”轻量提示框,同时保留状态栏提示。T-531 已完成:`save_app_settings()` 返回 bool,成功写 `data/config.json` + `data/config/cmhub.json` 后清 dirty,失败保留 dirty 并让调用方阻止离开。 +- `SettingsTab` 的 cmhub 网关配置:Base URL 保存/刷新前规整为网关根;API Key 单独读写 `data/config/cmhub.json`;别名下拉来自 `fetch_cmhub_models()`,按 `operation_type` 分生文/生图并过滤未计价别名;「测试连接/查余额」调用 cmhub models + balance helper;保存设置固定写 `ai.backend=cmhub`。`refresh_cmhub_models()` / `test_cmhub_connection()` 使用输入框实时值但不得自动保存,成功文案提醒用户保存。`is_dirty()` / `discard_unsaved_changes()` / `_suspend_dirty`(或等价机制)用于 T-531:用户编辑置脏,程序化回填不置脏,放弃时重新加载 `data/config.json` + `data/config/cmhub.json` 并回填控件。 - T-532 要求 `SettingsTab._on_cmhub_finished()` 从 worker payload 的 `balance` / user/account 字段提取 cmhub 账号身份,成功文案优先显示 `cmhub 账号「<账号名>」连接成功:...`;当前 `/balance` 结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,显示名优先 `account.display_name`,再兜底 `account.username` / `user` / 顶层常见字段;账号字段缺失时保持 `cmhub 连接成功:...`。显示名必须脱敏处理邮箱,且不得把 API Key、token 或完整敏感响应写入 GUI、run log 或诊断日志。 - `MainWindow` 已负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。 - 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。 - 模型数据读写复用 `appconfig.list_ai_models(..., reveal_api_key=True)`、`add_ai_model()`、`update_ai_model()`、`delete_ai_model()`;保存时保留现有 `extra_body` 与 `timeout_seconds`。 -- 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `config/ai_models.json`,不得进入日志/导出。 +- 密钥字段使用 `QLineEdit.Password` 打码显示;首次保存/变更非空 Key 前弹窗提示“本地明文保存”;明文只写入已 gitignore 的 `data/config/ai_models.json`,不得进入日志/导出。 - 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。 - 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。 - 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。 @@ -415,8 +418,8 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 ② AI生成当前要点(T-302/T-302p/T-303/T-303b + 诊断日志补丁): - 左右 `QSplitter`:左侧约 1/4 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。 -- 标题提示词启动时从 `title_prompt.txt` 回显;点击「保存标题提示词」写回该文件。 -- 封面提示词模板下拉读取 `prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。 +- 标题提示词启动时从 `data/title_prompt.txt` 回显;点击「保存标题提示词」写回该文件。 +- 封面提示词模板下拉读取 `data/prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。 - 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。 @@ -440,7 +443,7 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负 - 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。 - 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。 - 预检通过后默认串行;若 `parallel_accounts=true` 且 `max_parallel_accounts>1`,按账号分组并行执行,不同账号可同时跑,同一账号内仍串行。每条执行 `db.mark_running(..., "apply")` → `editor.apply_task(account, task, close_success_tab=设置值)` → `db.set_applied()`;成功推进 `stage=applied/status=success/committed=1`,失败保持原 stage、`status=failed/committed=0/last_error`,单条失败继续下一条。 -- 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。 +- 真实更新打开商品页失败时,`ApplyWorker` 应把 `open_product()` 捕获到的 Shopee toast 文案上浮到③可见运行日志、`run_log_events` 和任务失败原因;若失败发生在 `open_product()` 内部,本轮自动新建 tab 要关闭,复用用户已有 tab 不关闭;本地 `data/logs/` 可保存失败现场 HTML/toast JSON 片段供开发排查,但必须脱敏。 - 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。 - 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;进入编辑页后的失败和复用的用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。 diff --git a/docs/cmhub-integration-design.md b/docs/cmhub-integration-design.md index 0d2ff54..044976f 100644 --- a/docs/cmhub-integration-design.md +++ b/docs/cmhub-integration-design.md @@ -9,11 +9,12 @@ > **v3.1 修订(2026-07-04,实现前澄清)**:当时为保护既有直连流程,要求全新配置默认 backend 为 `direct`(cmhub 显式 opt-in),且 `backend=cmhub` 但未配置时须抛清晰"去⑤配置"错误而非崩溃(见 §4.1);② 明确"返回值不变"≠"回调不变"——计费元数据须给 `gen_title`/`gen_cover` **新增可选事件回调参数**承载,是向后兼容加参(见 §4.2)。默认策略已被 v3.3/T-529 覆盖为普通产品默认 cmhub。 > **v3.2 修订(2026-07-04,同步对接文档新增)**:cmhub 新增第 4 个接口 `GET /api/v1/models`(别名自助发现,见 §4.6),⑤设置别名由手填改为**动态下拉**(按 `operation_type` 分生文/生图、过滤 `unpriced`、展示单价、`requires_image` 提示),关闭原待确认 #1;仅 Base URL 仍待部署方提供。 > **v3.3 修订(2026-07-04,产品收口)**:普通用户后续默认使用 `cmhub` 网关;⑤设置页去掉「AI 后端」label 和 direct/cmhub 下拉,直接展示 cmhub 网关配置。`direct` 代码和 `config/ai_models.json` 保留为内部兼容/手工回滚路径,但普通 UI 不提供切换入口;保存设置固定写 `ai.backend=cmhub`,允许先保存不完整 cmhub 配置,②生成时再提示去⑤补齐 Base URL/API Key/别名。已同步 T-529。 +> **v3.4 修订(2026-07-07,T-538 路径收敛)**:打包版和源码运行的用户数据统一落在 `data/` 下;本文早期提到的 `config.json`、`config/ai_models.json`、`config/cmhub.json`、`images/`,当前默认路径分别为 `data/config.json`、`data/config/ai_models.json`、`data/config/cmhub.json`、`data/images/`。文件名和 schema 不变,旧布局由启动迁移逻辑处理。 > **v3.4 核对(2026-07-06,刷新别名 notfound)**:核对对接文档 §4.4 与 cmhub `ModelsView` 路由,确认 cmshopee `GET /api/v1/models` + Bearer 请求**已达标**;「notfound」为 404,根因是 Base URL 带多余 `/api(/v1)` 路径(双拼)或所连实例未部署 `/api/v1/models`(见 `docs/troubleshooting.md`)。**T-530 已落地**:保存/请求前规整 Base URL 到网关根,HTTP 404 映射为 `not_found` 并给出中文排障提示。 ## 1. 背景与目标 -**现状**:`app/ai.py` 直连多个上游 provider——每个模型在 `config/ai_models.json` 配 `url/model/api_key/api_type`,`gen_title` 自拼 chat `messages` 解析文本,`gen_cover` 走 `images_edits` multipart 或 vision chat 直接拿图片字节本地存。 +**现状**:`app/ai.py` 支持 cmhub 网关和 direct 兼容路径。direct 模式下每个模型在 `data/config/ai_models.json` 配 `url/model/api_key/api_type`,`gen_title` 自拼 chat `messages` 解析文本,`gen_cover` 走 `images_edits` multipart 或 vision chat 直接拿图片字节本地存。 **目标**:改为对接 cmhub 的生成、余额和模型发现接口:`POST /api/v1/generate/title`、`POST /api/v1/generate/image`、`GET /api/v1/balance`、`GET /api/v1/models`,一把 `Authorization: Bearer ` 即可调用。上游 provider、密钥、计费、SSRF 防护、分辨率映射、对象存储与别名发现由 cmhub 承担。 @@ -39,7 +40,7 @@ ## 3. 设计原则与边界 -- **接缝最小化**:保持 `gen_title(...)` / `gen_cover(...)` 的**返回值与现有调用兼容**(`gen_title`→标题字符串、`gen_cover`→已存 JPEG 路径),允许向后兼容新增可选事件回调参数承载计费元数据,只改内层实现。这样 `generate_batch` 编排、并发、重试、DB 写入、诊断日志、JPEG 落盘、`images///...` 路径、T-520 封面开关**全部复用、零改动**;GUI `run_logs` 记录和余额展示放到 T-528。 +- **接缝最小化**:保持 `gen_title(...)` / `gen_cover(...)` 的**返回值与现有调用兼容**(`gen_title`→标题字符串、`gen_cover`→已存 JPEG 路径),允许向后兼容新增可选事件回调参数承载计费元数据,只改内层实现。这样 `generate_batch` 编排、并发、重试、DB 写入、诊断日志、JPEG 落盘、`data/images///...` 路径、T-520 封面开关**全部复用、零改动**;GUI `run_logs` 记录和余额展示放到 T-528。 - **不碰高风险层**:`editor.py`/`cdp.py`/`chrome.py`/`accounts.py`/`excel.py`/`db.py`/`workers.py`,以及 ①采集/③更新/④账号全流程**完全不动**。这是本改动最重要的安全边界。 - **产品默认 cmhub,direct 保留兼容**:内层仍抽 `backend`(`cmhub` / `direct`),但普通产品默认走 `cmhub`,⑤设置页不再展示后端切换。老 `ai_models.json` 和 direct 分支不删除,作为内部兼容/手工回滚路径;普通用户只配置 cmhub Base URL / Key / 别名。 @@ -64,7 +65,7 @@ } ``` -- **cmhub API Key 不进 `config.json`**(避免与其它设置混放、避免误提交)。固定存到 `config/cmhub.json`,文件 schema 第一版为 `{ "api_key": "sk_cmhub_xxx" }`;新增 `CMHUB_CONFIG_PATH`、`load_cmhub_config()`、`save_cmhub_config()`、`get_cmhub_api_key(masked=False)` 等 helper;`config/cmhub.json` 必须加入 `.gitignore`。沿用现有"本地明文保存但 gitignore + UI 打码 + 日志脱敏"纪律(T-503)。 +- **cmhub API Key 不进 `data/config.json`**(避免与其它设置混放、避免误提交)。固定存到 `data/config/cmhub.json`,文件 schema 第一版为 `{ "api_key": "sk_cmhub_xxx" }`;新增 `CMHUB_CONFIG_PATH`、`load_cmhub_config()`、`save_cmhub_config()`、`get_cmhub_api_key(masked=False)` 等 helper;`data/config/cmhub.json` 必须加入 `.gitignore`。沿用现有"本地明文保存但 gitignore + UI 打码 + 日志脱敏"纪律(T-503)。 - **`ai_models.json` 去留**:`direct` 模式继续用;`cmhub` 模式不读它。文件保留但标记 legacy。 - **默认 backend 与配置不完整处理(T-529 后)**:`DEFAULT_CONFIG` / `default_config()` 造全新配置时写 `backend=cmhub`;⑤设置页隐藏 direct/cmhub 下拉并固定保存 `backend=cmhub`。允许先保存不完整 cmhub 配置,便于用户先保存其它路径/安全设置;②生成真正调用时如果 `base_url`/Key/别名缺失,必须抛**清晰的"请去⑤配置 cmhub"错误**(`CMHubError`/`AIError`),不得崩溃或静默回退 direct。显式手工配置 `backend=direct` 仍作为内部回滚路径保留,但普通 UI 不提供入口。 - **Base URL 必须是网关根**:只填 `https://`,**不带** `/api`、`/api/v1` 或任何路径。`cmhub_request_url()` 会自行拼 `/api/v1/...`;若 Base URL 已含 `/api/v1`,会双拼成 `.../api/v1/api/v1/...` → 404(正是「刷新别名 notfound」现象,见 `docs/troubleshooting.md`)。**当前请求本身已达标**(`GET /api/v1/models` + `Authorization: Bearer `,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由);404 的根因是 Base URL 带多余路径或所连实例未部署 `/api/v1/models`。**T-530 已在保存/请求前规整 Base URL**,会去掉多余路径、查询串和片段,只保留 scheme+host(+port);404 会给出明确中文提示。 @@ -139,7 +140,7 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**( | `app/ai.py` | `gen_title`/`gen_cover` 加 cmhub 分支(请求体+解析+生图下载);抽 backend 选择;错误映射 + 区分重试。`direct` 分支保留现有代码 | M | | `app/appconfig.py` | `ai` 段加 `backend`/`cmhub` 子段默认值与校验;cmhub Key 的读写与打码(复用脱敏工具);新增 `cmhub_request_url()` 类 helper | S | | `app/gui/tabs/settings.py` / `app/gui/workers.py` | ⑤ AI 设置按 backend 切换:cmhub 模式显示「网关地址 + API Key + 生文/生图别名 + 测试连接/查余额」;direct 模式保留现有 master-detail;测试连接/查余额走后台 worker。**最大 UI 触点** | M | -| 测试 | `tests/test_ai.py` 增 cmhub mock(titles 列表、image_url 下载、安全下载、各错误码与重试、连接/读超时差异);`test_appconfig` 加 schema 与 `config/cmhub.json` helper;⑤ gui 设置测试跟随 | M | +| 测试 | `tests/test_ai.py` 增 cmhub mock(titles 列表、image_url 下载、安全下载、各错误码与重试、连接/读超时差异);`test_appconfig` 加 schema 与 `data/config/cmhub.json` helper;⑤ gui 设置测试跟随 | M | | 文档 | `docs/04-architecture.md` §5.1b/§6.2、`docs/api.md`、`docs/03-tech-stack.md`、`current-state.md` 同步 | S | **明确不动(T-526)**:`editor.py`/`cdp.py`/`chrome.py`/`accounts.py`/`excel.py`/`db.py`,以及 ①采集/③更新/④账号全流程;`generate_batch` 主编排、图片本地路径方案和 T-520 封面开关保持原语义。T-527/T-528 可按任务边界修改 `app/gui/tabs/settings.py`、`app/gui/tabs/generate.py`、`app/gui/workers.py` 的设置与用户提示层。 diff --git a/docs/current-state.md b/docs/current-state.md index 1714d20..478273d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,7 +6,7 @@ ## 当前快照 - 日期:2026-07-07 -- 阶段: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 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-105b ④启动登录复用已打开 Chrome、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe、T-526 `app/ai.py` + `appconfig` 接入 cmhub backend、T-527 ⑤设置 cmhub 网关面板、T-528 ②计费错误提示与余额展示、T-529 默认 cmhub 网关并隐藏 AI 后端选择、T-530 cmhub Base URL 规整与 404 明确提示、T-531 ⑤设置未保存状态追踪与离开确认、T-532 ⑤cmhub连接成功提示显示账号名、T-533 ②增量生成:按缺失组件补生成、T-534 ②重置增强:多选/筛选范围 + 按组件重置、T-535 ②cmhub生成标题读取等待固定600秒、T-536 GUI按钮圆角全局统一、T-537 品牌名显示为蝦皮圈優化助手。 +- 阶段: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 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-105b ④启动登录复用已打开 Chrome、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe、T-526 `app/ai.py` + `appconfig` 接入 cmhub backend、T-527 ⑤设置 cmhub 网关面板、T-528 ②计费错误提示与余额展示、T-529 默认 cmhub 网关并隐藏 AI 后端选择、T-530 cmhub Base URL 规整与 404 明确提示、T-531 ⑤设置未保存状态追踪与离开确认、T-532 ⑤cmhub连接成功提示显示账号名、T-533 ②增量生成:按缺失组件补生成、T-534 ②重置增强:多选/筛选范围 + 按组件重置、T-535 ②cmhub生成标题读取等待固定600秒、T-536 GUI按钮圆角全局统一、T-537 品牌名显示为蝦皮圈優化助手、T-538 打包产物用户数据收进 `data/`。 - 技术栈:Python 3.10+,根目录 `requirements.txt` 锁定运行依赖,GitHub Actions 使用 Windows + Python 3.11 自动跑语法和单元/GUI 测试,PyInstaller onedir 打包为 Windows 免安装 exe;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(默认 cmhub: `config.json` 网关配置 + `config/cmhub.json` Key;direct: `config/ai_models.json` 通用 HTTP 内部兼容),GUI PySide6 5 Tab(已定)。 - 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`ai.backend` 默认 `cmhub`,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper、cmhub Base URL 规整与请求 URL 拼接 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models/balance 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,HTTP 404 映射为 `not_found` 并给出中文排障提示,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a/T-534 本地生成结果/更新状态重置函数(②生成结果可按标题/封面组件重置并保持 generated)、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录幂等复用已打开 Chrome、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`(窗口标题显示「蝦皮圈優化助手」)、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/cmhub余额显示/点数不足弹窗/计费日志/重置生成结果与 `GenerateWorker`,且②重置支持多选/当前筛选结果并可只重置标题、只重置封面或重置全部;③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置页 T-529 后默认展示 cmhub 网关 Base URL、API Key、动态生文/生图别名下拉、Base URL 网关根提示、刷新别名与测试连接/查余额,不再显示「AI 后端」label/dropdown、direct 模型选择、模型详情或标题/图片模型角色下拉;保存设置固定写 `ai.backend=cmhub`,保存/刷新前会把 Base URL 规整为网关根,允许先保存不完整 cmhub 配置,生成时再提示补齐;direct 模型配置、`AIModelTestWorker` 和 `config/ai_models.json` 仅作内部兼容/手工回滚;通用生成参数、路径/端口、Shopee 更新安全与多账号并行设置继续持久化 `config.json`,cmhub Key 单独写入 `config/cmhub.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 - 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts(含 T-105b 启动登录复用已运行 Chrome)/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock、T-527 设置页 cmhub backend 切换/别名下拉/worker mock、T-528 ②cmhub余额显示/计费日志/点数不足中止提示、T-529 默认 cmhub/隐藏 AI 后端选择/保存固定 cmhub/direct 兼容、T-530 cmhub Base URL 规整/404 not_found 明确提示、T-531 设置页未保存状态/离开确认/放弃还原、T-532 cmhub连接成功账号名提示、T-533 增量生成组件口径、T-534 ②按标题/封面组件重置与多选/筛选范围重置、T-535 cmhub标题请求读取等待固定600秒、T-536 GUI按钮全局基础样式与语义色按钮叠加样式、T-537 主窗口标题显示中文品牌;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 @@ -109,8 +109,9 @@ - T-523 已完成:旧 `app/gui.py` 已拆为 `app/gui/` 包,公开导入路径保持兼容;本轮为纯结构重构,未改 Shopee/CDP、AI、DB、Excel 行为。 - T-523a 已完成:②点击「开始生成」、③点击「检查本轮更新」或「开始更新」时,先清空对应界面的旧日志显示并写入本轮开始摘要;运行中只追加本轮日志。历史 `run_logs/run_log_events` 和本地 `logs/` 不删除、不自动混入当前运行界面。 - T-404b 已完成:`open_product()` 在导航商品详情页前后安装 toast 捕获,等待详情页关键元素超时时会读取最近 `.eds-toasts`/toast/message 节点及页面缓存,把明确商品失效/商品不存在/无权限类 toast 上浮为 `商品失效:<原始toast>`;如果失败发生在 `open_product()` 内部,本轮自动新建的失败 tab 会关闭,复用用户已有 tab 不关闭;①列表“阶段”列只在这类明确失效错误时显示“商品失效”,底层仍保持 `stage=imported/status=failed`,其他打开失败仍显示“失败”。 -- T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;打包版启动时工作目录切到 `cmshopee.exe` 所在目录,首次运行在 exe 同级生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置这些本地数据,用户后续更新采用关闭程序后覆盖程序文件、保留本地数据的方式。 +- T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;T-538 后打包版不再 `chdir`,首次运行在 exe 同级 `data/` 中生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置这些本地数据,用户后续更新采用关闭程序后覆盖程序文件、保留 `data/` 的方式。 - T-541 已完成:`MainWindow` 启动时通过 `_fit_and_center_window()` 读取 `QApplication.primaryScreen().availableGeometry()`,按可用屏幕限制初始尺寸、居中并夹进屏幕;首次 `showEvent` 后再应用一次,保证打包版在 Windows 10 VM/小分辨率环境标题栏完整可见、可拖动,不保存历史坏坐标。 +- T-538 已完成:`app/appconfig.py` 新增 `data_dir`、旧布局迁移、迁移冲突保护和可写性检测;`config.json`、`config/ai_models.json`、`config/cmhub.json`、DB、图片、日志、提示词和 Chrome 登录态默认统一落到 `data/`;旧 DB 已存绝对/历史路径继续按原路径读取。 - Phase 7 cmhub 网关对接已完成 T-526~T-535;②生成阶段已支持按标题/封面缺失组件增量生成,②重置生成结果已支持多选/筛选范围和按标题/封面组件重置,cmhub 标题请求读取等待固定 600 秒。下一步按看板回到 T-525 ruff。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 diff --git a/docs/packaging.md b/docs/packaging.md index cc79582..54e6728 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -1,6 +1,6 @@ # 打包与分发 -> T-524 目标:先把当前 Windows 桌面工具打成免安装 onedir `.exe`,方便第一版交付使用。暂不做自动更新器、安装器、增量补丁或在线升级。 +> T-524 目标:先把当前 Windows 桌面工具打成免安装 onedir `.exe`,方便第一版交付使用。T-538 后用户数据统一收进程序同级 `data/`,暂不做自动更新器、安装器、增量补丁或在线升级。 ## 一、打包前提 @@ -33,7 +33,7 @@ python -m PyInstaller --noconfirm --clean cmshopee.spec dist\cmshopee\cmshopee.exe ``` -发布给用户时,以整个 `dist\cmshopee\` 文件夹为单位压缩分发,用户双击 `cmshopee.exe` 启动。 +发布给用户时,以整个 `dist\cmshopee\` 文件夹为单位压缩分发,用户双击 `cmshopee.exe` 启动。当前 PyInstaller 5.13.2 的 onedir 是扁平结构,`cmshopee.exe` 与 DLL/PYD/依赖目录同级,`_internal\` 不作为当前验收标准。 ## 三、绝不打包的本地数据 @@ -41,6 +41,7 @@ dist\cmshopee\cmshopee.exe - `config.json` - `config/ai_models.json` +- `config/cmhub.json` - `cmshopee.db`、`cmshopee.db-wal`、`cmshopee.db-shm` - `db.sqlite` - `chrome_user_data_dir/` @@ -48,26 +49,46 @@ dist\cmshopee\cmshopee.exe - `logs/` - `prompts/` - `title_prompt.txt` +- `data/` - 运营填写后的 Excel 文件 `cmshopee.spec` 不声明任何 `datas`;`scripts/build_exe.ps1` 会在打包后检查 `dist\cmshopee\`,如果发现上述路径会直接失败。 ## 四、首次运行与本地数据位置 -打包版启动时,`main.py` 会把工作目录切到 `cmshopee.exe` 所在目录。 +T-538 后,打包版不再把全进程工作目录切到 `cmshopee.exe` 所在目录;路径由 `app/appconfig.py` 按显式数据根解析。 -因此首次运行时,现有代码会按默认值在 exe 同级目录生成或使用本地文件: +首次运行时,程序会在 exe 同级创建单独的 `data\` 子目录,并把所有用户本地数据放进去: -- `config.json` -- `config/ai_models.json` -- `cmshopee.db` -- `chrome_user_data_dir/` -- `images/` -- `logs/` -- `prompts/`、`title_prompt.txt`(用户维护提示词时生成) +```text +cmshopee\ + cmshopee.exe + + data\ + config.json + config\ + ai_models.json + cmhub.json + cmshopee.db + chrome_user_data_dir\ + images\ + logs\ + prompts\ + title_prompt.txt +``` + +源码运行时同理使用项目根目录下的 `data\`。`config.json` 内的 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时再解析到 `data\` 下,保持便携。 这些文件属于用户本地数据,不随新版本程序包覆盖。 +### 旧布局迁移 + +如果用户从 T-524 旧包升级,新版首次启动会检查 exe 顶层是否存在旧布局数据,例如 `config.json`、`config\`、`cmshopee.db`、`chrome_user_data_dir\`、`images\`、`logs\`、`prompts\`、`title_prompt.txt`。若 `data\` 中没有同名目标,程序会自动移动到 `data\` 下。 + +如果旧布局和 `data\` 中同时存在同名数据,程序不会覆盖,会以“数据迁移冲突”提示用户先手动合并或备份,避免误丢账号、任务、图片或登录态。此类冲突不是目录不可写,不应直接归类为“数据目录不可写”。 + +启动时会检测 `data\` 是否可写;如果程序放在 `Program Files` 等只读目录导致写入失败,才会以“数据目录不可写”提示“请把程序放到可写目录,勿放 Program Files”并退出。 + ## 五、用户后续更新方式 第一版不做自动更新。给用户发新版本时: @@ -75,10 +96,10 @@ dist\cmshopee\cmshopee.exe 1. 让用户先关闭 cmshopee。 2. 建议用户备份当前整个程序文件夹。 3. 解压新版 `dist\cmshopee\`。 -4. 只覆盖程序文件,例如 `cmshopee.exe` 和 `_internal/`。 -5. 不删除也不覆盖用户原有的 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`。 +4. 覆盖新版程序文件和依赖文件。当前 PyInstaller 5.13.2 是扁平 onedir,不能只覆盖 `cmshopee.exe`,也没有 `_internal/` 可单独替换。 +5. 保留旧目录里的 `data\`,不要删除、覆盖或合并它。 -如果用户把整个旧目录删除再放新版,账号、任务记录、图片和登录态也会一起丢失,只能从备份恢复。 +如果用户把整个旧目录删除再放新版,`data\` 里的账号、任务记录、图片和登录态也会一起丢失,只能从备份恢复。 ## 六、验证清单 @@ -93,9 +114,9 @@ powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1 打包成功后确认: - `dist\cmshopee\cmshopee.exe` 存在。 -- `dist\cmshopee\` 中没有第三节列出的本地数据。 -- 在干净目录首次启动时能生成默认配置并进入 GUI。 +- 当前 PyInstaller 5.13.2 下 `dist\cmshopee\` 是扁平 onedir,`_internal\` 不存在也属于正常。 +- `dist\cmshopee\` 中没有第三节列出的本地数据,尤其不能含 `data\`。 +- 在干净目录首次启动时能生成 `data\config.json` 并进入 GUI。 - 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。 涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。 - diff --git a/docs/routes.md b/docs/routes.md index d4515bd..fe4c10d 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -53,11 +53,11 @@ - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 -- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `images///__old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 +- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images///__old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 -- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `logs/`,用于定位失败卡在哪个步骤。 +- 别名未匹配账号 / 账号未登录 → 该行 skipped 并记原因。`T-207` 接入后,① 采集会像③更新一样写 `run_logs/run_log_events`,并把完整脱敏 traceback 写入本地 `data/logs/`,用于定位失败卡在哪个步骤。 - 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚 Shopee。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。 ## ② AI生成 @@ -81,14 +81,14 @@ ``` - 左侧(提示词管理,上下两块): - - **标题提示词**:多行输入 + 「保存」(写 `title_prompt.txt`);启动时自动加载回显。 - - **封面提示词**:模板下拉(读 `prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 + - **标题提示词**:多行输入 + 「保存」(写 `data/title_prompt.txt`);启动时自动加载回显。 + - **封面提示词**:模板下拉(读 `data/prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 - 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。 - 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 并发生成封面。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。 - 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露“本轮是否生成封面”的成本开关。 -- 标题-only 模式标题成功即写库并进入 `generated`,`new_cover_path` 留空;标题+封面模式图片成功后写入本地新封面路径。两种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;未勾选生成封面时日志明确显示“本轮仅生成标题”。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 +- 标题-only 模式标题成功即写库并进入 `generated`,`new_cover_path` 留空;标题+封面模式图片成功后写入本地新封面路径。两种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;未勾选生成封面时日志明确显示“本轮仅生成标题”。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。 - 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚 Shopee。 - **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 @@ -119,7 +119,7 @@ - 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。 - 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。 - 「检查本轮更新」只写运行日志与检查汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。 -- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;检查和真实更新都会写 `run_logs/run_log_events`。点击「检查本轮更新」或「开始更新」时先清空③界面可见日志并写入本轮检查/更新开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `logs/`,历史日志不自动混入当前运行界面。 +- 真实更新每条立即写回 SQLite(committed/状态/error),失败不阻塞后续任务;检查和真实更新都会写 `run_logs/run_log_events`。点击「检查本轮更新」或「开始更新」时先清空③界面可见日志并写入本轮检查/更新开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`,历史日志不自动混入当前运行界面。 - 分批更新时「停止」为协作式停止:已开始的当前商品跑到安全边界并写库后停止,不再开始新商品、不进入下一批;未开始任务保持原状态,下次可继续。 - 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动、CDP 端口不可访问或未登录时整体阻断并引导去④账号管理,不进入逐条提交,也不自动打开账号 Chrome。 - 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 Chrome 残留空错误页。 @@ -145,11 +145,11 @@ - 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。T-531 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。 - cmhub 网关配置(T-529 后普通用户唯一 AI 入口):⑤设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前会规整掉 `/api`、`/api/v1` 或其它路径。刷新别名/测试连接使用当前输入框内容发起请求,但不自动保存 URL/API Key,成功文案需提醒“记得点保存设置持久化”。 - T-532 需求:测试连接/查余额成功时,如果 cmhub `/balance` 返回账号名、用户名或邮箱等可识别信息,成功提示应显示 `cmhub 账号「<账号名>」连接成功:...`,让用户确认当前 API Key 属于哪个 cmhub 账号;当前接口结构兼容 `{ "user": "cmhub_user", "points_balance": 88, "account": { "username": "cmhub_user", "display_name": "主账号" } }`,优先显示 `account.display_name`,没有时再用 `account.username` / `user` 等兜底;邮箱需脱敏,没有账号信息时保留 `cmhub 连接成功:...` 兜底文案。 - - API Key 存在 `config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 + - API Key 存在 `data/config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。 - 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。 - 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。 - T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - - direct 模型清单和 `config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。 + - direct 模型清单和 `data/config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。 - AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列。 - 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。 - 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9a00a16..17649b5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,6 @@ # 常见问题排查 -> 本文只记录可复用的本地排障步骤。涉及 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 时,默认它们是本机敏感或业务数据,必须保持 gitignore,不把真实密码、API Key、Cookie、token 写入文档、日志或提交。 +> 本文只记录可复用的本地排障步骤。T-538 后用户数据默认位于 `data/`,涉及 `data/config.json`、`data/data/config/ai_models.json`、`data/config/cmhub.json`、`data/cmshopee.db`、`data/chrome_user_data_dir/`、`data/images/` 时,默认它们是本机敏感或业务数据,必须保持 gitignore,不把真实密码、API Key、Cookie、token 写入文档、日志或提交。 ## 启动时报 “AI 模型 category 必须是 text 或 image” @@ -14,17 +14,17 @@ AI 模型 category 必须是 text 或 image ### 原因 -`config/ai_models.json` 是本地 AI 模型清单,里面每个模型都必须有合法的 `category`: +`data/data/config/ai_models.json` 是本地 AI 模型清单,里面每个模型都必须有合法的 `category`: - `text`:标题生成模型,会出现在“标题大模型”下拉。 - `image`:封面生成模型,会出现在“图片大模型”下拉。 -早期或手工维护过的 `config/ai_models.json` 可能缺少 `category`,或者填了中文、空值、旧字段,导致 `appconfig.load_ai_models_config()` 严格校验失败,GUI 启动被阻断。 +早期或手工维护过的 `data/data/config/ai_models.json` 可能缺少 `category`,或者填了中文、空值、旧字段,导致 `appconfig.load_ai_models_config()` 严格校验失败,GUI 启动被阻断。 ### 不要这样做 -- 不要删除 `config/ai_models.json` 来“重置”,否则会丢失本地明文 API Key 和模型配置。 -- 不要把 `config/ai_models.json` 提交到 git。 +- 不要删除 `data/data/config/ai_models.json` 来“重置”,否则会丢失本地明文 API Key 和模型配置。 +- 不要把 `data/data/config/ai_models.json` 提交到 git。 - 不要把完整文件内容贴到聊天、文档或日志里;该文件含本地明文 API Key。 ### 推荐修复 @@ -32,10 +32,10 @@ AI 模型 category 必须是 text 或 image 先只查看非敏感字段,确认哪些模型缺少 `category`: ```powershell -python -c "import json; p='config/ai_models.json'; data=json.load(open(p,encoding='utf-8')); [print({'index': i+1, 'name': m.get('name'), 'category': m.get('category'), 'api_type': m.get('api_type'), 'enabled': m.get('enabled'), 'has_api_key': bool(m.get('api_key'))}) for i,m in enumerate(data.get('models', []))]" +python -c "import json; p='data/config/ai_models.json'; data=json.load(open(p,encoding='utf-8')); [print({'index': i+1, 'name': m.get('name'), 'category': m.get('category'), 'api_type': m.get('api_type'), 'enabled': m.get('enabled'), 'has_api_key': bool(m.get('api_key'))}) for i,m in enumerate(data.get('models', []))]" ``` -然后手工编辑 `config/ai_models.json`,只补这些字段: +然后手工编辑 `data/data/config/ai_models.json`,只补这些字段: ```json { @@ -56,7 +56,7 @@ python -c "import json; p='config/ai_models.json'; data=json.load(open(p,encodin 如果要用脚本批量修复,只按模型名或 `api_type` 推断 `category`,并保留原有 `url`、`model`、`api_key`、`extra_body` 等字段。示例: ```powershell -python -c "import json; p='config/ai_models.json'; data=json.load(open(p,encoding='utf-8')); mapping={'你的文本模型名':'text','你的图片模型名':'image'}; changed=False +python -c "import json; p='data/config/ai_models.json'; data=json.load(open(p,encoding='utf-8')); mapping={'你的文本模型名':'text','你的图片模型名':'image'}; changed=False for m in data.get('models', []): name=m.get('name') if not m.get('category') and name in mapping: @@ -75,16 +75,16 @@ print('updated' if changed else 'unchanged')" python -c "import os,sys; sys.path.insert(0, os.getcwd()); from app import appconfig; print([(m['name'], m['category'], m['enabled'], m['api_key_set']) for m in appconfig.list_ai_models()])" ``` -确认 `config/ai_models.json` 仍被忽略: +确认 `data/data/config/ai_models.json` 仍被忽略: ```powershell -git status --short --ignored config/ai_models.json +git status --short --ignored data/config/ai_models.json ``` 正常输出应包含: ```text -!! config/ai_models.json +!! data/config/ai_models.json ``` 最后重新启动: @@ -125,14 +125,14 @@ https://api.vectorengine.ai/v1 https://api.vectorengine.ai/v1/chat/completions ``` -如果仍然 404,优先检查服务商文档要求的 endpoint 路径和 `api_type`,不要把 `config/ai_models.json` 或 API Key 贴到日志、文档或聊天里。 +如果仍然 404,优先检查服务商文档要求的 endpoint 路径和 `api_type`,不要把 `data/data/config/ai_models.json` 或 API Key 贴到日志、文档或聊天里。 ### 非敏感检查 只输出非密钥字段确认 URL 形态: ```powershell -python -c "import json; from urllib.parse import urlsplit,urlunsplit; p='config/ai_models.json'; data=json.load(open(p,encoding='utf-8')); [print({'name':m.get('name'),'category':m.get('category'),'api_type':m.get('api_type'),'url_without_query':urlunsplit((urlsplit(m.get('url','')).scheme,urlsplit(m.get('url','')).netloc,urlsplit(m.get('url','')).path,'','')),'has_api_key':bool(m.get('api_key'))}) for m in data.get('models', [])]" +python -c "import json; from urllib.parse import urlsplit,urlunsplit; p='data/config/ai_models.json'; data=json.load(open(p,encoding='utf-8')); [print({'name':m.get('name'),'category':m.get('category'),'api_type':m.get('api_type'),'url_without_query':urlunsplit((urlsplit(m.get('url','')).scheme,urlsplit(m.get('url','')).netloc,urlsplit(m.get('url','')).path,'','')),'has_api_key':bool(m.get('api_key'))}) for m in data.get('models', [])]" ``` ## ⑤ cmhub「刷新别名 / 测试连接」提示 notfound / 404 @@ -207,16 +207,16 @@ T-530 已实现:保存和请求前都会把 Base URL 规整为网关根,去 2. 如果②状态列显示“生成失败”:留在②,确认 cmhub Base URL、API Key、别名和点数正常后,直接点击「开始生成」重试当前筛选结果。 3. 如果②状态列显示“更新失败”:到③更新shopee处理;需要重复更新时使用③的重置更新状态入口,不要在②重新生成。 -排查时不要把 `cmshopee.db`、`config/cmhub.json`、`config/ai_models.json` 或任何 API Key、密码、Cookie 发到聊天、文档或提交里。 +排查时不要把 `data/cmshopee.db`、`data/config/cmhub.json`、`data/data/config/ai_models.json` 或任何 API Key、密码、Cookie 发到聊天、文档或提交里。 ## AI生成:标题成功但图片生成失败,且看不到原因 当前代码会为 ② AI生成写两层日志: - 页面右下「AI生成运行日志」显示最近一次 `run_type=generate` 的逐条事件,例如 `phase=cover step=cover_request result=failed detail=...`。 -- 本地 `logs/cmshopee.log` 保存脱敏后的 traceback、任务 id、alias、item_id、phase 和 step,用于判断卡在模型配置、封面请求、图片解析、保存文件还是写库。 +- 本地 `data/logs/cmshopee.log` 保存脱敏后的 traceback、任务 id、alias、item_id、phase 和 step,用于判断卡在模型配置、封面请求、图片解析、保存文件还是写库。 -排查顺序:先看 ② 页面运行日志里的 `phase` / `step` / `detail`;如果只看到简短错误,再查看本地 `logs/cmshopee.log`。不要把 `config/ai_models.json` 或 API Key 发到聊天、文档或提交里。 +排查顺序:先看 ② 页面运行日志里的 `phase` / `step` / `detail`;如果只看到简短错误,再查看本地 `data/logs/cmshopee.log`。不要把 `data/data/config/ai_models.json` 或 API Key 发到聊天、文档或提交里。 ## ④启动登录重复打开 Chrome diff --git a/docs/ui/overview-pipeline.svg b/docs/ui/overview-pipeline.svg index 897a857..051416a 100644 --- a/docs/ui/overview-pipeline.svg +++ b/docs/ui/overview-pipeline.svg @@ -87,5 +87,5 @@ - ① ③ 依赖账号已配置且已登录(④);未配/未登录则执行按钮禁用并提示去④。存储:config.json + SQLite + 本地 images/。 + ① ③ 依赖账号已配置且已登录(④);未配/未登录则执行按钮禁用并提示去④。存储:data/config.json + SQLite + data/images/。 diff --git a/docs/ui/tab1-import-collect.svg b/docs/ui/tab1-import-collect.svg index 28c0998..8c627ce 100644 --- a/docs/ui/tab1-import-collect.svg +++ b/docs/ui/tab1-import-collect.svg @@ -108,7 +108,7 @@ [10:21:03] 文件 tw01.xlsx → 匹配账号「女装店」(9222) [10:21:05] 51100639510 采集旧标题:…顯瘦百搭T恤 - [10:21:07] 下载旧封面 → images/nuzhuang_01/51100639510_old.jpg + [10:21:07] 下载旧封面 → data/images/nuzhuang_01/51100639510_old.jpg [10:21:08] set_collected ✓(已写 SQLite) [10:21:14] 52999999 别名「x别名」未匹配账号 → 略过 [10:21:20] 采集完成 3/4,已回写 Excel 旧字段 diff --git a/docs/ui/tab2-ai-generate.svg b/docs/ui/tab2-ai-generate.svg index 6967efa..b129159 100644 --- a/docs/ui/tab2-ai-generate.svg +++ b/docs/ui/tab2-ai-generate.svg @@ -33,7 +33,7 @@ 精简标题、去「T恤」、 突出韩版加绒保暖 ≤60字 - 启动自动加载 title_prompt.txt 回显 + 启动自动加载 data/title_prompt.txt 回显 封面提示词(模板) diff --git a/docs/ui/tab5-settings.svg b/docs/ui/tab5-settings.svg index 88811e2..4081dcb 100644 --- a/docs/ui/tab5-settings.svg +++ b/docs/ui/tab5-settings.svg @@ -74,6 +74,6 @@ 保存设置 - 模型清单存 config/ai_models.json(密钥本地明文);选择与参数存 config.json。均 gitignore。 + 模型清单存 data/config/ai_models.json(密钥本地明文);选择与参数存 data/config.json。均 gitignore。 diff --git a/main.py b/main.py index 0db8958..901f2de 100644 --- a/main.py +++ b/main.py @@ -14,8 +14,6 @@ def _project_root(): PROJECT_ROOT = _project_root() if PROJECT_ROOT not in sys.path: sys.path.insert(0, PROJECT_ROOT) -if getattr(sys, "frozen", False): - os.chdir(PROJECT_ROOT) from app.gui import main diff --git a/progress.md b/progress.md index 39afe04..5a1e637 100644 --- a/progress.md +++ b/progress.md @@ -1287,3 +1287,28 @@ - 文档:`docs/06-tasks.md` 将 T-541 标记为 DONE;`docs/troubleshooting.md`、`docs/packaging.md`、`docs/current-state.md` 更新为当前修复与打包验收口径。 - 测试:`tests/test_gui.py` 新增小屏和大屏 mock geometry 单测,覆盖尺寸限制与居中位置;`scripts/build_exe.ps1` 已重新构建 release,确认 T-541 合入后仍可打包。 - 边界:不保存/恢复窗口历史坐标;未改业务流程、DB、AI、Excel、CDP/Shopee、打包产物结构或用户数据路径。 + +## 【2026-07-07】T-538 完成 · 打包产物用户数据收进 data/ + +- 状态:DONE +- 代码:`app/appconfig.py` 新增 `data_dir` 路径解析、`data/` 默认根、旧顶层布局迁移、冲突保护和可写性检测;`main.py` 移除打包版 `chdir`;GUI 启动时先准备数据目录,不可写时弹中文错误并退出。 +- 路径:`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt` 等默认统一落到 `data/`;配置里保存的相对路径仍保持便携,运行时解析为绝对路径。 +- 打包:`.gitignore` 新增 `data/`;`scripts/build_exe.ps1` 发布目录校验禁止带出 `data/`;更新方式改为覆盖发布包里的程序文件和依赖文件并保留 `data/`。当前 PyInstaller 5.13.2 为扁平 onedir,不要求存在 `_internal/`。 +- 文档:`docs/06-tasks.md` 将 T-538 标记为 DONE;同步 `docs/packaging.md`、架构/技术栈/API/排障/路由/current-state 和 UI 线框图里的数据路径说明。 +- 边界:未改 DB schema、AI HTTP 协议、Excel、账号、CDP 或 Shopee 页面操作流程;旧 DB 已存图片路径继续按原路径读取。 +- 验证:`python -m py_compile app\appconfig.py app\diagnostics.py app\prompts.py app\editor.py app\gui\__init__.py app\gui\main_window.py app\gui\tabs\generate.py app\gui\tabs\settings.py app\gui\widgets.py main.py tests\test_appconfig.py tests\test_packaging.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p test_appconfig.py` 通过;`python -m unittest discover -s tests -p test_packaging.py` 通过;`python -m unittest discover -s tests -p test_gui.py` 通过;`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过;`git diff --check` 通过。 + +## 【2026-07-07】T-538 修正 · 区分数据迁移冲突和目录不可写 + +- 现象:旧布局数据和 `data/` 里同名数据同时存在时,GUI 标题仍显示“数据目录不可写”,但实际原因是迁移冲突。 +- 代码:`appconfig` 新增 `DataMigrationConflictError` 与 `DataDirectoryWriteError`;旧布局同名冲突抛“数据迁移冲突”,目录创建/写入失败才抛“数据目录不可写”。GUI 启动时按错误类型显示不同中文弹窗标题。 +- 文档:`docs/packaging.md` 和 `docs/current-state.md` 记录冲突与不可写的区别。 +- 边界:只改启动错误分类和提示;不自动合并、不删除、不移动用户旧数据;未改 DB schema、路径解析、AI、Excel、CDP 或 Shopee 流程。 + +## 【2026-07-07】T-538 修正 · 打包目录结构按 PyInstaller 5 扁平 onedir 记录 + +- 现象:实际 `dist/cmshopee` 目录包含 `cmshopee.exe`、DLL/PYD 文件和依赖目录同级,并不是 `cmshopee.exe` + `_internal/` 两个条目。 +- 原因:当前 `requirements-build.txt` 锁定 PyInstaller 5.13.2,onedir 默认输出为扁平结构;`_internal/` 属于后续升级 PyInstaller 6.x 后再评估的布局,不是当前验收标准。 +- 文档:`docs/packaging.md`、`docs/03-tech-stack.md`、`docs/06-tasks.md`、`docs/current-state.md` 已改为发布整个 `dist/cmshopee` 文件夹,验证重点是不得带出 `data/` 和用户本地数据。 +- 代码:`scripts/build_exe.ps1` 打包成功后会提示当前依赖布局;无 `_internal/` 时明确说明扁平结构正常,并提醒发布整个 `dist/cmshopee` 目录。 +- 边界:未升级 PyInstaller,未改变 spec 产物布局,未改运行时路径、DB、AI、Excel、CDP 或 Shopee 流程。 diff --git a/scripts/build_exe.ps1 b/scripts/build_exe.ps1 index fb25926..0551092 100644 --- a/scripts/build_exe.ps1 +++ b/scripts/build_exe.ps1 @@ -33,7 +33,8 @@ $excluded = @( "images", "logs", "prompts", - "title_prompt.txt" + "title_prompt.txt", + "data" ) $found = @() diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index faee814..24aebf4 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -18,7 +18,13 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): config = appconfig.load_config(config_path) self.assertTrue(os.path.exists(config_path)) - self.assertEqual("images", appconfig.image_dir(config)) + self.assertEqual(os.path.join(temp_dir, "images"), appconfig.image_dir(config)) + self.assertEqual(os.path.join(temp_dir, "chrome_user_data_dir"), appconfig.user_data_root(config)) + self.assertEqual(os.path.join(temp_dir, "cmshopee.db"), appconfig.db_path(config)) + self.assertEqual(temp_dir, appconfig.data_dir(config)) + self.assertEqual(config_path, config["config_path"]) + self.assertEqual(os.path.join(temp_dir, "config", "ai_models.json"), config["ai_models_path"]) + self.assertEqual(os.path.join(temp_dir, "config", "cmhub.json"), config["cmhub_config_path"]) self.assertEqual(240, appconfig.response_timeout(config)) self.assertFalse(appconfig.ai_config(config)["generate_cover"]) @@ -31,6 +37,114 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_config_save_does_not_persist_runtime_paths(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + + saved = appconfig.save_config( + { + "data_dir": os.path.join(temp_dir, "data"), + "config_path": "runtime-only", + "ai_models_path": "runtime-only", + "cmhub_config_path": "runtime-only", + "image_dir": "images", + }, + path=config_path, + ) + + self.assertEqual(temp_dir, saved["data_dir"]) + with open(config_path, "r", encoding="utf-8") as fh: + persisted = json.load(fh) + self.assertNotIn("data_dir", persisted) + self.assertNotIn("config_path", persisted) + self.assertNotIn("ai_models_path", persisted) + self.assertNotIn("cmhub_config_path", persisted) + + self.assert_removed(temp_dir) + + def test_data_paths_resolve_under_default_data_dir(self): + with self.make_temp_dir() as temp_dir: + data_root = os.path.join(temp_dir, "data") + cfg = { + "data_dir": data_root, + "user_data_root": "chrome_user_data_dir", + "image_dir": "images", + "db_path": "cmshopee.db", + } + + self.assertEqual(os.path.join(data_root, "chrome_user_data_dir"), appconfig.user_data_root(cfg)) + self.assertEqual(os.path.join(data_root, "images"), appconfig.image_dir(cfg)) + self.assertEqual(os.path.join(data_root, "cmshopee.db"), appconfig.db_path(cfg)) + self.assertEqual(os.path.join(data_root, "title_prompt.txt"), appconfig.title_prompt_path(cfg)) + self.assertEqual(os.path.join(data_root, "prompts", "cover"), appconfig.cover_prompts_dir(cfg)) + self.assertEqual(os.path.join(data_root, "logs"), appconfig.diagnostic_log_dir(cfg)) + + portable = dict(cfg) + portable["db_path"] = os.path.join("data", "cmshopee.db") + self.assertEqual(os.path.join(data_root, "cmshopee.db"), appconfig.db_path(portable)) + + self.assert_removed(temp_dir) + + def test_prepare_data_dir_migrates_legacy_layout(self): + with self.make_temp_dir() as temp_dir: + with open(os.path.join(temp_dir, "config.json"), "w", encoding="utf-8") as fh: + fh.write("{}") + with open(os.path.join(temp_dir, "cmshopee.db"), "w", encoding="utf-8") as fh: + fh.write("db") + os.makedirs(os.path.join(temp_dir, "images"), exist_ok=True) + with open(os.path.join(temp_dir, "images", "cover.jpg"), "w", encoding="utf-8") as fh: + fh.write("image") + os.makedirs(os.path.join(temp_dir, "config"), exist_ok=True) + with open(os.path.join(temp_dir, "config", "cmhub.json"), "w", encoding="utf-8") as fh: + fh.write("{}") + + data_root = os.path.join(temp_dir, "data") + prepared = appconfig.prepare_data_dir(base_dir=temp_dir, data_dir_path=data_root) + + self.assertEqual(data_root, prepared) + self.assertTrue(os.path.exists(os.path.join(data_root, "config.json"))) + self.assertTrue(os.path.exists(os.path.join(data_root, "cmshopee.db"))) + self.assertTrue(os.path.exists(os.path.join(data_root, "images", "cover.jpg"))) + self.assertTrue(os.path.exists(os.path.join(data_root, "config", "cmhub.json"))) + self.assertFalse(os.path.exists(os.path.join(temp_dir, "config.json"))) + self.assertFalse(os.path.exists(os.path.join(temp_dir, "images"))) + + self.assert_removed(temp_dir) + + def test_prepare_data_dir_blocks_conflicting_migration(self): + with self.make_temp_dir() as temp_dir: + with open(os.path.join(temp_dir, "config.json"), "w", encoding="utf-8") as fh: + fh.write("{}") + data_root = os.path.join(temp_dir, "data") + os.makedirs(data_root, exist_ok=True) + with open(os.path.join(data_root, "config.json"), "w", encoding="utf-8") as fh: + fh.write("{}") + + with self.assertRaises(appconfig.DataMigrationConflictError) as ctx: + appconfig.prepare_data_dir(base_dir=temp_dir, data_dir_path=data_root) + + self.assertIn("无法自动迁移", str(ctx.exception)) + self.assertTrue(os.path.exists(os.path.join(temp_dir, "config.json"))) + + self.assert_removed(temp_dir) + + def test_prepare_data_dir_reports_unwritable_path(self): + with self.make_temp_dir() as temp_dir: + data_root = os.path.join(temp_dir, "data") + with open(data_root, "w", encoding="utf-8") as fh: + fh.write("not a directory") + + with self.assertRaises(appconfig.DataDirectoryWriteError) as ctx: + appconfig.prepare_data_dir( + base_dir=temp_dir, + data_dir_path=data_root, + migrate=False, + ) + + self.assertIn("数据目录不可写", str(ctx.exception)) + + self.assert_removed(temp_dir) + def test_cmhub_defaults_old_config_and_key_helper(self): with self.make_temp_dir() as temp_dir: config_path = os.path.join(temp_dir, "config.json") diff --git a/tests/test_gui.py b/tests/test_gui.py index 3cfba36..b46b164 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -481,7 +481,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): warning.assert_called_once() self.assertIn("本地明文保存", warning.call_args[0][1]) - self.assertIn("config/ai_models.json", warning.call_args[0][2]) + self.assertIn("data/config/ai_models.json", warning.call_args[0][2]) saved = appconfig.get_model("Text Custom", path=models_path) self.assertEqual("text", saved["category"]) @@ -685,8 +685,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): }, saved["shopee_update"], ) - self.assertNotIn("ai_models_path", saved) - self.assertNotIn("config_path", saved) + with open(config_path, "r", encoding="utf-8") as fh: + persisted = json.load(fh) + self.assertNotIn("ai_models_path", persisted) + self.assertNotIn("config_path", persisted) + self.assertNotIn("cmhub_config_path", persisted) + self.assertNotIn("data_dir", persisted) self.assertIn("设置已保存", statuses[-1]) self.assert_removed(temp_dir) @@ -942,7 +946,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): tab.save_app_settings() warning.assert_called_once() - self.assertIn("config/cmhub.json", warning.call_args[0][2]) + self.assertIn("data/config/cmhub.json", warning.call_args[0][2]) info.assert_called_once_with(tab, "保存设置", "设置已保存") saved = appconfig.load_config(cfg["config_path"]) self.assertEqual("cmhub", saved["ai"]["backend"]) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 50a5f90..b970f05 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -7,12 +7,14 @@ class PackagingTests(unittest.TestCase): def read_text(self, relative_path): return (REPO_ROOT / relative_path).read_text(encoding="utf-8") - def test_frozen_entrypoint_uses_exe_directory(self): + def test_frozen_entrypoint_keeps_program_and_data_paths_separate(self): main_py = self.read_text("main.py") + gui_init = self.read_text("app/gui/__init__.py") self.assertIn('getattr(sys, "frozen", False)', main_py) self.assertIn("sys.executable", main_py) - self.assertIn("os.chdir(PROJECT_ROOT)", main_py) + self.assertNotIn("os.chdir(PROJECT_ROOT)", main_py) + self.assertIn("appconfig.prepare_data_dir()", gui_init) def test_pyinstaller_spec_uses_onedir_without_local_datas(self): spec = self.read_text("cmshopee.spec") @@ -37,6 +39,7 @@ class PackagingTests(unittest.TestCase): "logs", "prompts", "title_prompt.txt", + "data", ): self.assertIn(token, script)