feat: store packaged user data under data directory

This commit is contained in:
chengma
2026-07-07 14:12:32 +08:00
parent caa04a9aa7
commit 148c4a73eb
33 changed files with 602 additions and 196 deletions
+2
View File
@@ -14,6 +14,8 @@ chrome_user_data_dir/
images/ images/
# 本地诊断日志 # 本地诊断日志
logs/ logs/
# T-538 后统一数据根(配置、DB、图片、登录态、日志、提示词)
data/
# 运营填写后的 Excel 业务文件;标准空模板可提交 # 运营填写后的 Excel 业务文件;标准空模板可提交
*.xlsx *.xlsx
*.xlsm *.xlsm
+231 -14
View File
@@ -1,24 +1,64 @@
"""Application-level configuration for cmshopee. """Application-level configuration for cmshopee.
This module owns `config.json` plus `config/ai_models.json`. `config.json` This module owns `data/config.json` plus `data/config/ai_models.json`.
stores local app settings and AI role/generation parameters. AI provider `config.json` stores local app settings and AI role/generation parameters.
definitions and local plaintext API keys live in ignored `config/ai_models.json`. AI provider definitions and local plaintext API keys live in ignored
`data/config/ai_models.json`.
""" """
import copy import copy
import json import json
import os import os
import shutil
import sys
import tempfile
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
CONFIG_PATH = "config.json" DATA_DIR_NAME = "data"
AI_MODELS_PATH = os.path.join("config", "ai_models.json")
CMHUB_CONFIG_PATH = os.path.join("config", "cmhub.json")
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"} CATEGORIES = {"text", "image"}
API_TYPES = {"chat", "images_edits", "auto"} API_TYPES = {"chat", "images_edits", "auto"}
AI_BACKENDS = {"direct", "cmhub"} 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 = { DEFAULT_CONFIG = {
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe", "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.""" """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: def default_config() -> dict:
"""Return a new copy of the default config.""" """Return a new copy of the default config."""
@@ -241,7 +455,7 @@ def _normalize_cmhub_config(config):
if config is None: if config is None:
config = {} config = {}
if not isinstance(config, dict): if not isinstance(config, dict):
raise ConfigError("config/cmhub.json 必须是对象") raise ConfigError("cmhub 配置必须是对象")
return {"api_key": str(config.get("api_key", "") or "")} 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: def save_config(config, path=CONFIG_PATH) -> dict:
"""Persist config to JSON and return the normalized config.""" """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) _normalize_config_values(normalized)
_assert_no_secrets(normalized) _assert_no_secrets(normalized)
directory = os.path.dirname(os.path.abspath(path)) 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: with open(path, "w", encoding="utf-8") as fh:
json.dump(normalized, fh, ensure_ascii=False, indent=2) json.dump(normalized, fh, ensure_ascii=False, indent=2)
fh.write("\n") fh.write("\n")
return normalized return _with_runtime_paths(normalized, path)
def load_config(path=CONFIG_PATH) -> dict: def load_config(path=CONFIG_PATH) -> dict:
@@ -304,7 +518,7 @@ def load_config(path=CONFIG_PATH) -> dict:
normalized = _deep_merge(DEFAULT_CONFIG, loaded) normalized = _deep_merge(DEFAULT_CONFIG, loaded)
_normalize_config_values(normalized) _normalize_config_values(normalized)
_assert_no_secrets(normalized) _assert_no_secrets(normalized)
return normalized return _with_runtime_paths(normalized, path)
def update_config(updates, path=CONFIG_PATH) -> dict: 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: 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: 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: 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: def default_debug_port(config=None) -> int:
@@ -446,7 +663,7 @@ def _normalize_ai_model(model):
def _normalize_ai_models_config(config): def _normalize_ai_models_config(config):
models = config.get("models") if isinstance(config, dict) else None models = config.get("models") if isinstance(config, dict) else None
if not isinstance(models, list): 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]} normalized = {"models": [_normalize_ai_model(model) for model in models]}
_assert_unique_model_names(normalized["models"]) _assert_unique_model_names(normalized["models"])
_assert_required_categories(normalized["models"]) _assert_required_categories(normalized["models"])
+2 -2
View File
@@ -10,7 +10,7 @@ import traceback
from . import appconfig from . import appconfig
DEFAULT_LOG_DIR = "logs" DEFAULT_LOG_DIR = appconfig.diagnostic_log_dir()
DEFAULT_LOG_FILE = "cmshopee.log" DEFAULT_LOG_FILE = "cmshopee.log"
DEFAULT_MAX_BYTES = 2 * 1024 * 1024 DEFAULT_MAX_BYTES = 2 * 1024 * 1024
DEFAULT_BACKUPS = 3 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}" target = f"{path}.{index + 1}"
if os.path.exists(source): if os.path.exists(source):
os.replace(source, target) os.replace(source, target)
os.replace(path, f"{path}.1") os.replace(path, f"{path}.1")
+7 -2
View File
@@ -5,7 +5,7 @@ import os
import time import time
from urllib.parse import urlparse 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 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) old_cover_src = read_cover_src(cdp)
out_path = _get(task, "old_cover_path") out_path = _get(task, "old_cover_path")
if not out_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") _notify_collect_step(on_step, "download_cover")
old_cover_path = download_cover(old_cover_src, out_path) old_cover_path = download_cover(old_cover_src, out_path)
return { return {
+12
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os import os
import sys import sys
from .. import appconfig
from . import widgets as _widgets from . import widgets as _widgets
from .widgets import * from .widgets import *
@@ -46,6 +47,17 @@ def main() -> int:
return 1 return 1
_ensure_offscreen_for_headless_tests() _ensure_offscreen_for_headless_tests()
app = QApplication.instance() or QApplication(sys.argv) 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 = MainWindow()
window.show() window.show()
return app.exec() return app.exec()
+3 -1
View File
@@ -84,7 +84,7 @@ class MainWindow(QMainWindow):
self.ai_models_path = ( self.ai_models_path = (
ai_models_path ai_models_path
or self.config.get("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("蝦皮圈優化助手") self.setWindowTitle("蝦皮圈優化助手")
_fit_and_center_window(self) _fit_and_center_window(self)
@@ -123,6 +123,8 @@ class MainWindow(QMainWindow):
config=self.config, config=self.config,
config_path=self.config_path, config_path=self.config_path,
status_callback=self.statusBar().showMessage, 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(), open_accounts_callback=lambda: self.open_accounts_tab(),
) )
if title == "③ 更新shopee": if title == "③ 更新shopee":
+4 -4
View File
@@ -48,8 +48,8 @@ class GenerateTab(QWidget):
self.db_path = _database_path(db_path, self.config) self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback self.open_accounts_callback = open_accounts_callback
self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config)
self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config)
self.current_cover_template = None self.current_cover_template = None
self.generate_worker = None self.generate_worker = None
self.generate_thread = None self.generate_thread = None
@@ -288,7 +288,7 @@ class GenerateTab(QWidget):
payload = { payload = {
key: value key: value
for key, value in self.config.items() 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 payload["ai"] = ai_settings
try: try:
@@ -299,7 +299,7 @@ class GenerateTab(QWidget):
internal = { internal = {
key: value key: value
for key, value in self.config.items() 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.clear()
self.config.update(saved) self.config.update(saved)
+12 -19
View File
@@ -10,7 +10,7 @@ from ..workers import CMHubSettingsWorker as _RealCMHubSettingsWorker
PLAINTEXT_CMHUB_API_KEY_WARNING = ( PLAINTEXT_CMHUB_API_KEY_WARNING = (
"cmhub API Key 会以本地明文保存到 config/cmhub.json,仅供本机调用 cmhub 网关使用。" "cmhub API Key 会以本地明文保存到 data/config/cmhub.json,仅供本机调用 cmhub 网关使用。"
"该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。"
) )
@@ -23,7 +23,7 @@ def CMHubSettingsWorker(*args, **kwargs):
return _call_package_attr("CMHubSettingsWorker", _RealCMHubSettingsWorker, *args, **kwargs) return _call_package_attr("CMHubSettingsWorker", _RealCMHubSettingsWorker, *args, **kwargs)
class SettingsTab(QWidget): 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")] BACKEND_ITEMS = [("直连模型", "direct"), ("cmhub 网关", "cmhub")]
CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")] CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")]
@@ -48,11 +48,11 @@ class SettingsTab(QWidget):
self.ai_models_path = ( self.ai_models_path = (
ai_models_path ai_models_path
or self.config.get("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.cmhub_config_path = (
self.config.get("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.status_callback = status_callback
self.models = [] self.models = []
@@ -546,15 +546,6 @@ class SettingsTab(QWidget):
self._cmhub_auto_refresh_done = True self._cmhub_auto_refresh_done = True
self.refresh_cmhub_models() 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): def _on_backend_changed(self, index=None):
self.backend_combo.setVisible(False) self.backend_combo.setVisible(False)
self.model_picker_panel.setVisible(False) self.model_picker_panel.setVisible(False)
@@ -752,7 +743,7 @@ class SettingsTab(QWidget):
settings = { settings = {
key: value key: value
for key, value in self.config.items() 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( settings.update(
{ {
@@ -799,6 +790,8 @@ class SettingsTab(QWidget):
internal["ai_models_path"] = self.ai_models_path internal["ai_models_path"] = self.ai_models_path
if self.cmhub_config_path != appconfig.CMHUB_CONFIG_PATH: if self.cmhub_config_path != appconfig.CMHUB_CONFIG_PATH:
internal["cmhub_config_path"] = self.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.clear()
self.config.update(saved) self.config.update(saved)
self.config.update(internal) 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.jpg_quality_spin.setValue(int(ai_cfg.get("jpg_quality", 90) or 90))
self.chrome_path_edit.setText(appconfig.chrome_path(self.config)) self.chrome_path_edit.setText(appconfig.chrome_path(self.config))
self.user_data_root_edit.setText(appconfig.user_data_root(self.config)) self.user_data_root_edit.setText(
self.image_dir_edit.setText(appconfig.image_dir(self.config)) str(self.config.get("user_data_root", "chrome_user_data_dir") or "")
self.db_path_edit.setText(appconfig.db_path(self.config)) )
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( self.default_debug_port_spin.setValue(
int(appconfig.default_debug_port(self.config)) int(appconfig.default_debug_port(self.config))
) )
@@ -1318,5 +1313,3 @@ class SettingsTab(QWidget):
def _category_label(self, category): def _category_label(self, category):
return {"text": "文本", "image": "图像"}.get(category, category) return {"text": "文本", "image": "图像"}.get(category, category)
+1 -1
View File
@@ -170,7 +170,7 @@ def run_worker(*args, **kwargs):
PLAINTEXT_SECRET_TITLE = "本地明文保存提示" PLAINTEXT_SECRET_TITLE = "本地明文保存提示"
PLAINTEXT_API_KEY_WARNING = ( PLAINTEXT_API_KEY_WARNING = (
"API Key 会以本地明文保存到 config/ai_models.json,仅供本机调用 AI 使用。" "API Key 会以本地明文保存到 data/config/ai_models.json,仅供本机调用 AI 使用。"
"该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。"
) )
PLAINTEXT_PASSWORD_WARNING = ( PLAINTEXT_PASSWORD_WARNING = (
+3 -2
View File
@@ -4,9 +4,10 @@ from __future__ import annotations
import os import os
from . import appconfig
TITLE_PROMPT_PATH = "title_prompt.txt" TITLE_PROMPT_PATH = appconfig.title_prompt_path()
COVER_PROMPTS_DIR = os.path.join("prompts", "cover") COVER_PROMPTS_DIR = appconfig.cover_prompts_dir()
TEMPLATE_EXT = ".txt" TEMPLATE_EXT = ".txt"
INVALID_NAME_CHARS = set('\\/:*?"<>|') INVALID_NAME_CHARS = set('\\/:*?"<>|')
+2 -2
View File
@@ -11,7 +11,7 @@
目标闭环:④ 配账号并登录 → ① 导入 Excel(按“别名”列关联账号)、采集旧标题/旧封面并回写 → ② 用提示词 AI 生成新标题/新封面(不设逐条确认阶段)→ ③ 对已生成任务点击「开始更新」,弹窗确认后批量改标题+换封面并点「更新」提交 → 结果实时存 SQLite、批量回写原 Excel。 目标闭环:④ 配账号并登录 → ① 导入 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) 第七节: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` 实地确认)。 - 真实页面探查结果(用 `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` 中跑通的逻辑。 - 已验证脚本 `cdp.py`(T-000 后迁入 `app/cdp.py`)、`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py` 中跑通的逻辑。
+4 -4
View File
@@ -43,7 +43,7 @@
| 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 | | 导入校验汇总(①) | 导入后展示文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数;跑采集前先纠错 | P0 |
| 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 | | 采集旧数据(①) | 程序只读打开商品页,抓取旧标题、下载旧封面到本地,回写 Excel 旧字段 | P0 |
| AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题;封面图片按②本轮「生成封面图片(成本较高)」开关可选生成,避免用户无意产生图片模型成本;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 | | AI 生成(②) | 输入标题/封面提示词,AI 据提示词+旧标题生成新标题;封面图片按②本轮「生成封面图片(成本较高)」开关可选生成,避免用户无意产生图片模型成本;点击「开始生成」时清空②界面旧日志,只显示本轮生成日志,历史 run_logs 仍保留 | P0 |
| 提示词管理(②) | 标题提示词「保存」到 `title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 | | 提示词管理(②) | 标题提示词「保存」到 `data/title_prompt.txt` 并启动回显;封面提示词多模板(下拉 + 新建/保存/另存为/重命名/删除)+ 插入 `{新标题}` + 预览(变量替换) | P0 |
| 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 | | 查看对照(②) | 双击任务弹窗查看新旧封面(纯查看,无逐条审核阶段);可选对单行重生成 | P0 |
| 更新 shopee(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新 Shopee,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 | | 更新 shopee(③) | 按批次/店铺/状态筛选;可先「检查本轮更新」确认范围;点击「开始更新」后弹窗确认,确认后对当前筛选出的已生成任务按每批最大条数分批更新 Shopee,逐条换标题+封面并点「更新」提交线上;普通正式更新不再受测试商品 ID 限制,可批量更新真实商品;可按状态=失败重试 | P0 |
| 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 | | 检查本轮更新 / 运行日志 / 多账号并行(③/⑤) | ③ 提供「检查本轮更新」按钮,不打开 Shopee、不提交、不改任务状态,只显示当前筛选范围、店铺分布、预计分批和会更新字段;点击「检查本轮更新」或「开始更新」时清空③界面旧日志,只显示本轮检查/更新日志;真实更新写运行日志;可在⑤开启多账号并行,同账号内仍串行 | V2 已接入,检查按钮已接入 |
@@ -91,7 +91,7 @@
| 更新前账号就绪 | ③ 真实更新前检查当前筛选结果需要的账号;Chrome 未启动、CDP 端口不可达或未登录时整体中止并提示去④,不静默自动启动账号浏览器 | | 更新前账号就绪 | ③ 真实更新前检查当前筛选结果需要的账号;Chrome 未启动、CDP 端口不可达或未登录时整体中止并提示去④,不静默自动启动账号浏览器 |
| 旧标题/旧封面 | 程序在「采集」阶段改前抓取的快照(输出列),运营不填 | | 旧标题/旧封面 | 程序在「采集」阶段改前抓取的快照(输出列),运营不填 |
| 新标题/新封面 | AI 生成(输出列),直接用于 ③;不设逐条确认阶段,本地留档+回写 Excel 供追溯 | | 新标题/新封面 | 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 | | 本地图片 | 旧封面下载、新封面生成存本地图片目录,路径记 DB |
| 结果落库时机 | 各阶段处理完立即写 SQLite;该 Excel 全部完成后批量回写原文件 | | 结果落库时机 | 各阶段处理完立即写 SQLite;该 Excel 全部完成后批量回写原文件 |
| 原文件被占用 | 回写时若原 Excel 被锁定,提示关闭重试或另存副本(SQLite 为事实来源) | | 原文件被占用 | 回写时若原 Excel 被锁定,提示关闭重试或另存副本(SQLite 为事实来源) |
@@ -100,11 +100,11 @@
## 七、待确认 / 风险点 ## 七、待确认 / 风险点
- **账号 / 凭证风险**:登录态存在各账号 user-data-dir,等同账号凭证;目录不得提交版本库、不得外传。 - **账号 / 凭证风险**:登录态存在各账号 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 列为准;不匹配的行略过并最后弹窗,执行起始在日志逐条打印「文件→匹配账号」留痕,防误改。 - **别名匹配风险**:别名以 Excel 列为准;不匹配的行略过并最后弹窗,执行起始在日志逐条打印「文件→匹配账号」留痕,防误改。
- **AI 主图风险(高,已知并接受)**:本设计不设逐条人工审核和常驻提交开关;AI 生成的标题/封面经 ③ 批量确认后会提交线上。主图若失真/夸大/侵权可能违反 Shopee 规则甚至下架。缓解:开始更新前弹窗确认筛选范围和任务数量,新图本地留档 + 回写 Excel 供事后追溯;强烈建议先在测试商品验证再批量。 - **AI 主图风险(高,已知并接受)**:本设计不设逐条人工审核和常驻提交开关;AI 生成的标题/封面经 ③ 批量确认后会提交线上。主图若失真/夸大/侵权可能违反 Shopee 规则甚至下架。缓解:开始更新前弹窗确认筛选范围和任务数量,新图本地留档 + 回写 Excel 供事后追溯;强烈建议先在测试商品验证再批量。
- **AI 成本与依赖**:接入文本+图像 AI = 新外部依赖 + API 费用 + Key 管理;服务商/模型/合规待 [技术栈](03-tech-stack.md) 确认。 - **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 名、接口随时可能变;限流、风控、封号风险存在,禁止高频批量。 - **第三方平台风险**:Shopee 页面结构、class 名、接口随时可能变;限流、风控、封号风险存在,禁止高频批量。
- **自动化边界风险**:③ 确认后会自动改标题、上传图片、拖拽并点「更新」提交线上;点「开始更新」并确认前需自行确保筛选范围、任务来源与 AI 产出可接受。 - **自动化边界风险**:③ 确认后会自动改标题、上传图片、拖拽并点「更新」提交线上;点「开始更新」并确认前需自行确保筛选范围、任务来源与 AI 产出可接受。
- **检查边界**:③「检查本轮更新」只检查当前筛选任务并写运行日志,不打开 Shopee、不点击更新、不改任务状态;点击「开始更新」并确认后才可能真实提交。 - **检查边界**:③「检查本轮更新」只检查当前筛选任务并写运行日志,不打开 Shopee、不点击更新、不改任务状态;点击「开始更新」并确认后才可能真实提交。
+9 -9
View File
@@ -14,34 +14,34 @@
| HTTP | `requests` | 已定 | 读 `/json` 拿 tab 列表;`trust_env=False` 忽略代理 | | HTTP | `requests` | 已定 | 读 `/json` 拿 tab 列表;`trust_env=False` 忽略代理 |
| 浏览器 | Google Chrome(已安装) | 已定 | 带 `--remote-debugging-port` 启动 | | 浏览器 | Google Chrome(已安装) | 已定 | 带 `--remote-debugging-port` 启动 |
| GUI 框架 | PySide6(Qt for Python,`QTabWidget` 5 Tab) | 已定 | 当前环境已安装 PySide6;V1 需要表格、图片预览、后台任务进度、确认弹窗,Qt 的 signal/slot + QThread 更适合 | | GUI 框架 | PySide6(Qt for Python,`QTabWidget` 5 Tab) | 已定 | 当前环境已安装 PySide6;V1 需要表格、图片预览、后台任务进度、确认弹窗,Qt 的 signal/slot + QThread 更适合 |
| 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 | | 应用配置 | `data/config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 |
| 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 | | 业务数据 | SQLite(stdlib `sqlite3`,`data/cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 |
| Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx | | 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/title`,保留 direct chat JSON 兼容分支 | 已接入 | 提示词+旧标题→新标题;返回值不变;cmhub 计费 metadata 通过事件回调上报 |
| AI 图像生成 | `app/ai.py` 默认 cmhub `POST /api/v1/generate/image`,保留 direct chat/images_edits 兼容分支 | 已接入 | 提示词+旧封面→新封面;cmhub 拿 `image_url` 后安全下载并转本地 JPEG;生图读超时不自动重发 | | AI 图像生成 | `app/ai.py` 默认 cmhub `POST /api/v1/generate/image`,保留 direct chat/images_edits 兼容分支 | 已接入 | 提示词+旧封面→新封面;cmhub 拿 `image_url` 后安全下载并转本地 JPEG;生图读超时不自动重发 |
| 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;③ 可按账号并行更新;可停止、可重试 | | 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;③ 可按账号并行更新;可停止、可重试 |
| 运行日志 | SQLite `run_logs` / `run_log_events` | 已定 | ③ dry-run 与真实更新都留痕;结构化内容走脱敏 | | 运行日志 | SQLite `run_logs` / `run_log_events` | 已定 | ③ dry-run 与真实更新都留痕;结构化内容走脱敏 |
| 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 | | 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 |
| 测试 | `python -m compileall app main.py` + `unittest` + 手动 CDP/AI 验证 | 已定(分层) | 配置/DB/Excel/prompts 用单测;CDP/Shopee 与真实 AI 属集成验证或 mock | | 测试 | `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。 - **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。 - **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 更贴合用户习惯。 - **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。
- **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。 - **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。
- **快捷方式生成用 PowerShell(无额外依赖)**:用 `WScript.Shell.CreateShortcut` 生成 `.lnk`,不引入 `pywin32` 等依赖。 - **快捷方式生成用 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 服务商收口到 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 是硬约束**:`config/ai_models.json` 每个模型必须有 `category=text` 或 `category=image`;启动时报 “AI 模型 category 必须是 text 或 image” 时,按 [常见问题排查](troubleshooting.md) 修复本地配置,不删除或提交含 Key 的配置文件。 - **AI 模型 category 是硬约束**:`data/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 Key 只在本机 SQLite / `data/config/ai_models.json` / `data/config/cmhub.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。
- **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。 - **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。
- **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。 - **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。
- **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。 - **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。
- **生产在 Windows 直跑**:开发期我们用过 WSL→Windows 的 `netsh portproxy`(9333→9222)连 CDP;但 GUI 与 Chrome 都在 Windows 时,直接连 `127.0.0.1:9222`,无需 portproxy。 - **生产在 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/`。
## 三、构建与运行命令 ## 三、构建与运行命令
+42 -39
View File
@@ -16,7 +16,7 @@ GUI(PySide6 QTabWidget,5 Tab)
| |
v v
核心模块(Python) 核心模块(Python)
├── appconfig 读应用配置 config.json(Chrome 路径、目录根、AI 配置、端口范围…) ├── appconfig 读应用配置 data/config.json(Chrome 路径、目录根、AI 配置、端口范围…)
├── db SQLite 读写:账号、任务、各阶段结果(cmshopee.db) ├── db SQLite 读写:账号、任务、各阶段结果(cmshopee.db)
├── excel openpyxl 导入输入列 / 回写输出列到原 Excel ├── excel openpyxl 导入输入列 / 回写输出列到原 Excel
├── config 账号 ↔ user-data-dir 绑定、slug、目录创建 ├── 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`。 - 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` 为已验证来源)。 - 核心模块统一放在正式代码包 `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`。 - 已验证脚本(重构进模块):`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 读写账号、任务、各阶段结果;建表/迁移。 - `db`:SQLite 读写账号、任务、各阶段结果;建表/迁移。
- `excel`:openpyxl 读输入列、把输出列回写原 Excel(处理文件锁)。 - `excel`:openpyxl 读输入列、把输出列回写原 Excel(处理文件锁)。
- `config`:账号 ↔ user-data-dir 绑定;slug;目录创建。 - `config`:账号 ↔ user-data-dir 绑定;slug;目录创建。
@@ -75,13 +75,15 @@ imported → collected → generated → applied
**存储(同一事实只存一处)** **存储(同一事实只存一处)**
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `config.json`。 - 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。
- AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。 - AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `data/config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。
- cmhub 网关 Key → `config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 - cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
- 业务数据(账号、任务、各阶段结果)→ SQLite `cmshopee.db`。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
- 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
- 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。 - 提示词 → 标题提示词存单文件 `data/title_prompt.txt`;封面提示词存多模板 `data/prompts/cover/<名称>.txt`。
- 登录态 → 各账号 `chrome_user_data_dir/<slug>/`。 - 登录态 → 各账号 `data/chrome_user_data_dir/<slug>/`。
T-538 后统一数据根为 `data/`:打包版默认 `<exe目录>/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 ```json
{ {
@@ -142,8 +144,8 @@ imported → collected → generated → applied
- `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。 - `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。 - `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
- `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。 - `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。
- direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 - direct 内部兼容模式模型本身的定义(url/key/类型/连接超时…)在 `data/config/ai_models.json`,见 5.1b。
- 密钥不在 `config.json`:cmhub API Key 存于 `config/cmhub.json`;direct 内部兼容模式每个模型的 `api_key` 存于 `config/ai_models.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。 - 密钥不在 `config.json`:cmhub API Key 存于 `data/config/cmhub.json`;direct 内部兼容模式每个模型的 `api_key` 存于 `data/config/ai_models.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。
`shopee_update` 段放**真实更新前的安全开关**: `shopee_update` 段放**真实更新前的安全开关**:
@@ -158,7 +160,7 @@ imported → collected → generated → applied
该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`dry_run=true` 时不会真实提交;`dry_run=false` 时仍必须先通过真实更新安全开关检查。普通正式更新的安全检查不读取 `test_item_id` 做阻断。 该段不是替代 ③ 确认弹窗的常驻授权;③ 仍必须弹窗确认,用户点是后才执行。`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 别名,不读取此文件。 模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。该文件只用于内部兼容 `backend=direct`;普通用户默认 `backend=cmhub`,生文/生图使用 cmhub 别名,不读取此文件。
@@ -190,7 +192,7 @@ imported → collected → generated → applied
- `name` 唯一;`api_key` 本地明文保存、保存/变更时弹窗提示、打码显示。 - `name` 唯一;`api_key` 本地明文保存、保存/变更时弹窗提示、打码显示。
- 日志/状态/导出不得含密码或 API Key;结构化对象统一先过 `appconfig.sanitize_for_log()`,自由文本只允许在掌握明文值时用 `appconfig.redact_secrets()` 替换。 - 日志/状态/导出不得含密码或 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 ```json
{ "api_key": "sk_cmhub_xxx" } { "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。 - **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。 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 运行日志和本地 `logs/cmshopee.log` 都写脱敏错误。 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`。失败时本地 `logs/cmshopee.log` 写脱敏 traceback,业务日志和状态栏只写脱敏短错误。 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。 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 本地图片目录 ### 5.4 本地图片目录
```text ```text
images/<batch_id>/<slug>/<task_id>_<item_id>_old.<ext> # 采集下载的旧封面 data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.<ext> # 采集下载的旧封面
images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封面 data/images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封面
``` ```
路径记入 DB 后以 `old_cover_path/new_cover_path` 为权威;上传新封面使用 DB 中的本地绝对路径(Windows 绝对路径传 `setFileInputFiles`)。历史数据里的 `images/<slug>/<item_id>_*.jpg` 路径继续有效,不做强制迁移;新采集/新生成才使用按批次细分的目录。 路径记入 DB 后以 `old_cover_path/new_cover_path` 为权威;上传新封面使用 DB 中的本地绝对路径(Windows 绝对路径传 `setFileInputFiles`)。历史数据里的 `images/<slug>/<item_id>_*.jpg` 路径继续有效,不做强制迁移;T-538 后新采集/新生成默认写入 `data/images/` 下的按批次细分目录。
## 六、关键流程细节 ## 六、关键流程细节
@@ -389,7 +391,7 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
- `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。 - `open_product` 先复用已打开的同商品 tab;没有才新建商品编辑页 tab。采集完成后只关闭本次程序自动新建的商品 tab,不关闭用户原本已经打开的 tab。`CDP.close()` 只断开 WebSocket 控制连接,不等于关闭浏览器 tab。
- 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。 - 若商品 ID 失效、无权限或店铺不匹配导致商品编辑页无法就绪,`open_product` 必须读取/捕获 Shopee toast,把最近错误文案写入采集失败原因和诊断日志,不能只返回泛化超时。①列表只在明确捕获商品失效类 toast 时把“阶段”显示为“商品失效”;底层 `stage` 不新增中文值。若这个失败发生在程序自动新建的商品 tab 内,`open_product` 要关闭该 tab;复用用户已有 tab 不关闭。
- 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。 - 旧封面:取第一张 itembox 的 `img.src`(CDN 链接),下载到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`。
- 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。 - 写 `old_title/old_cover_path`、stage=collected;批量回写 Excel 旧字段。
- 采集任务结束且本轮有成功采集行时,自动触发当前批次旧字段回写;原 Excel 被锁时不影响 SQLite 结果,提示关闭后重试,并保留手动「回写旧数据到 Excel」入口。 - 采集任务结束且本轮有成功采集行时,自动触发当前批次旧字段回写;原 Excel 被锁时不影响 SQLite 结果,提示关闭后重试,并保留手动「回写旧数据到 Excel」入口。
@@ -398,7 +400,7 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成: 单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成:
1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。默认 `backend=cmhub`,调用 `POST /api/v1/generate/title` 并使用 `title_alias`;`backend=direct` 仅内部兼容时使用 `default_text_model`。 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/<batch_id>/<slug>/<task_id>_<item_id>_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/<batch_id>/<slug>/<task_id>_<item_id>_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)。 - 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。
3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。 3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。
@@ -407,19 +409,19 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
- **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。 - **「停止」**:取消未开始的任务,正在跑的少量完成或中断;停止后可再次「开始生成」对剩余继续。
- 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。 - 进度:标题和图片两条进度分开显示;未勾选生成封面时图片进度显示本轮未生成/0 张,并在运行日志写明“本轮仅生成标题”。
- 生成后 stage=generated;**不设逐条人工审核阶段**。若未生成封面,任务仍可进入③并只更新标题;若后续需要封面,用户可重置生成结果或重新生成该任务。双击任务弹窗查看新旧封面(纯查看,无新封面时显示为空);T-404a 已提供对当前选中单条的「重置生成结果」,确认后只清本地 AI 结果并退回 collected 供重新生成,默认不删除本地新封面文件;新标题直接用 AI 输出(不可编辑)。 - 生成后 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`;软件启动时加载该文件回显到输入框(缺失则空)。 - **标题提示词**:单个文本,「保存」写入 `data/title_prompt.txt`;软件启动时加载该文件回显到输入框(缺失则空)。
- **封面提示词**:多模板。下拉选模板(读 `prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。 - **封面提示词**:多模板。下拉选模板(读 `data/prompts/cover/*.txt`),图标工具栏 新建/保存/另存为/重命名/删除;重名校验、删除二次确认、删空给默认。
- **变量**:封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在光标处插入 `{新标题}`;「预览」= 用某条任务的值替换变量后展示,确认实际发送给 AI 的内容。 - **变量**:封面提示词支持占位符 `{旧标题}`、`{新标题}`、`{商品id}`、`{店铺}`,生成前用该任务真实值替换(`render_prompt`)。「插入标题」= 在光标处插入 `{新标题}`;「预览」= 用某条任务的值替换变量后展示,确认实际发送给 AI 的内容。
### 6.3 应用更新(③ Tab) ### 6.3 应用更新(③ Tab)
- ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。 - ③ 顶部筛选确定本次作用范围;点击「开始更新」后弹窗展示筛选条件、任务数量和“将提交线上”的风险提示。
- ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 Shopee、不提交、不改任务状态;检查汇总展示总数、店铺分布、每批最大条数、预计批次数、更新字段和略过原因。 - ③ 提供「检查本轮更新」按钮:只读取当前筛选结果和写运行日志,不打开 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。 - 真实更新前必须做账号就绪预检:按当前筛选结果汇总需要的账号;无账号、账号 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` 时跳过封面更新,也不需要开启 ⑤「允许更新封面」。 - 对确认后的**已生成(generated)任务**:`open_product` → `change_title(new_title)`(如有)→ `replace_cover(new_cover_path)`(如有)→ `click_update` 提交。标题-only 任务没有 `new_cover_path` 时跳过封面更新,也不需要开启 ⑤「允许更新封面」。
@@ -464,7 +466,7 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # 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。 2. **账号与启动**(④):`config` 建目录、`chrome` 启动器/快捷方式、登录保活与检测、账号 CRUD。
3. **导入采集**(①):`excel` 导入、采集旧标题/旧封面、回写。 3. **导入采集**(①):`excel` 导入、采集旧标题/旧封面、回写。
4. **AI 生成**(②):`ai` 模块、提示词、对照预览。 4. **AI 生成**(②):`ai` 模块、提示词、对照预览。
@@ -484,26 +486,27 @@ cmshopee/
│ ├── editor.py / ai.py / prompts.py / gui.py / workers.py │ ├── editor.py / ai.py / prompts.py / gui.py / workers.py
├── main.py # GUI 启动入口:from app.gui import main ├── main.py # GUI 启动入口:from app.gui import main
├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交
├── config.json # 应用配置(模型选择/生成参数/路径,gitignore) ├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留)
├── config/ai_models.json # direct AI 模型清单(含密钥,必须 gitignore) │ ├── config.json # 应用配置(模型选择/生成参数/路径)
├── config/cmhub.json # cmhub API Key(含密钥,必须 gitignore) │ ├── config/ai_models.json # direct AI 模型清单(含密钥)
├── cmshopee.db # SQLite(账号/任务/结果,gitignore) │ ├── config/cmhub.json # cmhub API Key(含密钥)
├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore) │ ├── cmshopee.db # SQLite(账号/任务/结果)
├── images/ # 旧封面/新封面本地图片(gitignore) │ ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态)
├── title_prompt.txt # 标题提示词(单文件,启动回显) │ ├── images/ # 旧封面/新封面本地图片
├── prompts/cover/<名称>.txt # 封面提示词模板(多个) │ ├── title_prompt.txt # 标题提示词(单文件,启动回显)
│ └── prompts/cover/<名称>.txt
└── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py) └── prototypes/ # 已验证原型/探查脚本(demo/set_*/get_title/cookies/inspect_images/grab/1.py)
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md # 逻辑待并入 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 交互事实变化同步第七节。 - CDP 交互事实变化同步第七节。
- 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 - 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。
- `app` 包内模块优先用相对导入(如 `from .cdp import CDP`);根入口 `main.py` 用 `from app.gui import main`。 - `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 Key 本地明文保存,保存/变更时弹窗提示;UI 打码、不外传、不写日志/导出;不自动登录。
- AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。 - AI 生成内容直接进入 ③ 更新候选;③ 批量确认后提交,新图本地留档 + 回写 Excel 以备追溯。
+2 -2
View File
@@ -78,7 +78,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交)
- 绝不把真实账号、密码、Cookie、token 写进代码、文档或日志。 - 绝不把真实账号、密码、Cookie、token 写进代码、文档或日志。
- 绝不把密码/API Key 写进代码、文档、日志或导出文件;本地配置/DB 可明文保存,必须 gitignore,保存/变更时提示“本地明文保存”,UI 打码。 - 绝不把密码/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` 是标准空模板,允许提交。 - 绝不提交运营填写后的 Excel 业务文件;根目录 `shopee待处理任务模板.xlsx` 是标准空模板,允许提交。
- 绝不自动登录 / 自动填账号密码;登录由人工完成,程序只检测登录态。 - 绝不自动登录 / 自动填账号密码;登录由人工完成,程序只检测登录态。
- 绝不在缺少 ③ 批量确认弹窗确认的情况下点击「更新」提交线上。 - 绝不在缺少 ③ 批量确认弹窗确认的情况下点击「更新」提交线上。
@@ -88,7 +88,7 @@ python prototypes/demo.py # 单账号闭环验证(不提交)
## 8. 安全与合规 ## 8. 安全与合规
- 登录凭证只存在于各账号 user-data-dir;不导出、不外传、不写入配置或日志。 - 登录凭证只存在于各账号 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` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。 - 涉及 Shopee 时,遵守 `04-architecture.md` 写明的页面规则与限流边界;不高频批量、不绕风控/验证码。
- 高风险动作(删除线上封面、点击更新)必须有显式确认,并先在测试商品验证;删除线上第一张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。 - 高风险动作(删除线上封面、点击更新)必须有显式确认,并先在测试商品验证;删除线上第一张封面前还必须有本地旧封面备份,缺失备份时拒绝删除。
- ③ 的批量确认弹窗是提交线上前的确认边界;T-504 的 dry-run 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。 - ③ 的批量确认弹窗是提交线上前的确认边界;T-504 的 dry-run 只预览不提交、不改任务状态。真实更新即使开启多账号并行,也必须经过③确认和⑤安全设置。
+1 -1
View File
@@ -144,7 +144,7 @@
| T-541 | 打包版主窗口初始位置与小屏适配 | T-523, T-537, T-540 | Bug:在 Windows 10 虚拟机/小分辨率环境运行打包后的 `dist/cmshopee/cmshopee.exe` 或 release 包时,主窗口可能贴到屏幕左上角且左边/标题栏显示不全,看不到「蝦皮圈優化助手 v<APP_VERSION>」标题,用户无法拖动窗口到中间。根因:`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-541 | 打包版主窗口初始位置与小屏适配 | T-523, T-537, T-540 | Bug:在 Windows 10 虚拟机/小分辨率环境运行打包后的 `dist/cmshopee/cmshopee.exe` 或 release 包时,主窗口可能贴到屏幕左上角且左边/标题栏显示不全,看不到「蝦皮圈優化助手 v<APP_VERSION>」标题,用户无法拖动窗口到中间。根因:`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-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-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`(默认 `<exe目录>/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`(默认 `<exe目录>/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 | | 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 |
## 里程碑 ## 里程碑
+1 -1
View File
@@ -38,7 +38,7 @@ python -m pip install -r requirements-build.txt
powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1 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 测试。 GitHub Actions 工作流见 `.github/workflows/tests.yml`,在 push / pull_request 上使用 Windows + Python 3.11 安装 `requirements.txt`,并自动运行语法检查和全量单元/GUI 测试。
+35 -32
View File
@@ -6,7 +6,7 @@
- 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:<port>`)+ SQLite + openpyxl + AI 服务调用。 - 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:<port>`)+ SQLite + openpyxl + AI 服务调用。
- 编码:UTF-8;传 Chrome / `setFileInputFiles` 的路径为 **Windows 绝对路径**。 - 编码: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 负责提示,不静默吞错。 - 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。
## appconfig 模块(`app/appconfig.py`,已建) ## appconfig 模块(`app/appconfig.py`,已建)
@@ -16,13 +16,16 @@
```python ```python
class ConfigError(RuntimeError): ... class ConfigError(RuntimeError): ...
default_config() -> dict default_config() -> dict
load_config(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="config.json") -> dict save_config(config, path="data/config.json") -> dict
update_config(updates, path="config.json") -> dict update_config(updates, path="data/config.json") -> dict
chrome_path(config=None) -> str chrome_path(config=None) -> str
user_data_root(config=None) -> str user_data_root(config=None) -> str
image_dir(config=None) -> str image_dir(config=None) -> str
db_path(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 default_debug_port(config=None) -> int
debug_port_range(config=None) -> tuple # (start, end) debug_port_range(config=None) -> tuple # (start, end)
cdp_ready_timeout(config=None) -> int 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](返回超时,随分辨率) 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 ```python
default_cmhub_config() -> dict default_cmhub_config() -> dict
load_cmhub_config(path="config/cmhub.json") -> dict # 缺文件返回空 key;默认 cmhub 但生成时会提示补配置 load_cmhub_config(path="data/config/cmhub.json") -> dict # 缺文件返回空 key;默认 cmhub 但生成时会提示补配置
save_cmhub_config(config, path="config/cmhub.json") -> dict save_cmhub_config(config, path="data/config/cmhub.json") -> dict
get_cmhub_api_key(path="config/cmhub.json", masked=False) -> str 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,展示时打码,不写日志/导出。 `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 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用): AI 模型清单(`data/config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
```python ```python
default_ai_models_config() -> dict default_ai_models_config() -> dict
load_ai_models_config(path="config/ai_models.json") -> dict # 不存在则写默认,至少 text/image 各一个 load_ai_models_config(path="data/config/ai_models.json") -> dict # 不存在则写默认,至少 text/image 各一个
save_ai_models_config(config, path="config/ai_models.json") -> dict 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 list_ai_models(category=None) -> list[dict] # category=text/image 过滤;默认 api_key 打码,含 api_key_set
add_ai_model(model) -> None # name 唯一校验 add_ai_model(model) -> None # name 唯一校验
update_ai_model(name, **fields) -> None update_ai_model(name, **fields) -> None
@@ -286,10 +289,10 @@ apply_task(account, task, close_success_tab=False) -> dict
```python ```python
class AIError(RuntimeError): ... 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;提示词 + 旧标题 → 新标题 # 文本生成:按 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; # 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits,cmhub 走 /generate/image;
# 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`,历史 DB 已存路径继续按原路径读取 # 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir/<batch_id>/<slug>/<task_id>_<item_id>_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=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`。 - `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。 - `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。 - 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): ... class PromptError(RuntimeError): ...
# 标题提示词:单文件 # 标题提示词:单文件
load_title_prompt(path="title_prompt.txt") -> str # 启动回显;缺失返回 "" load_title_prompt(path="data/title_prompt.txt") -> str # 启动回显;缺失返回 ""
save_title_prompt(text, path="title_prompt.txt") -> None # 「保存」按钮 save_title_prompt(text, path="data/title_prompt.txt") -> None # 「保存」按钮
# 封面提示词:多模板(prompts/cover/<名称>.txt) # 封面提示词:多模板(data/prompts/cover/<名称>.txt)
list_cover_templates(directory="prompts/cover") -> list[str] # 模板名列表(下拉用) list_cover_templates(directory="data/prompts/cover") -> list[str] # 模板名列表(下拉用)
load_cover_template(name, directory="prompts/cover") -> str load_cover_template(name, directory="data/prompts/cover") -> str
save_cover_template(name, text, directory="prompts/cover") -> None save_cover_template(name, text, directory="data/prompts/cover") -> None
rename_cover_template(old, new, directory="prompts/cover") -> None rename_cover_template(old, new, directory="data/prompts/cover") -> None
delete_cover_template(name, directory="prompts/cover") -> None delete_cover_template(name, directory="data/prompts/cover") -> None
# 变量替换 # 变量替换
render_prompt(template_text, task) -> str render_prompt(template_text, task) -> str
@@ -339,8 +342,8 @@ render_prompt(template_text, task) -> str
- 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」对选中任务调用 `render_prompt` 后展示。 - 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」对选中任务调用 `render_prompt` 后展示。
- 生成封面时 `gen_cover` 的 prompt = `render_prompt(当前封面模板, task)`。 - 生成封面时 `gen_cover` 的 prompt = `render_prompt(当前封面模板, task)`。
- 模板与 `title_prompt.txt` 均为可手改的纯文本文件。 - 模板与 `data/title_prompt.txt` 均为可手改的纯文本文件。
- `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `prompts/cover/*.txt`。 - `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `data/prompts/cover/*.txt`。
- 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。 - 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。
## gui 模块(`app/gui/` 包,已建,PySide6) ## gui 模块(`app/gui/` 包,已建,PySide6)
@@ -381,13 +384,13 @@ T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负
⑤ 设置当前要点(T-501): ⑤ 设置当前要点(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` 使用居中内容区 + 适度左右留白布局,当前留白已从 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 单独读写 `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` 的 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 或诊断日志。 - 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()` 递归。 - `MainWindow` 已负责⑤设置页离开守卫:切 Tab 与 `closeEvent` 发现 `SettingsTab.is_dirty()` 时弹保存/放弃/取消;保存成功后继续,保存失败或取消时回到⑤。由于 `QTabWidget.currentChanged` 是切换后信号,需维护上一个 index,并用 `_reverting_tab_change` 或等价 guard 防止 `setCurrentIndex()` 递归。
- 模型详情字段按 3 个组件一组排列:启用、类别、api_type、连接超时等短字段一格;服务商名、模型 ID 视宽度占一格或两格;网址、密钥跨整行或跨 2/3 列。 - 模型详情字段按 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`。 - 模型数据读写复用 `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 模型”为硬约束。 - 删除按钮在当前类别只剩 1 个模型时禁用;后端仍以“至少启用一个 text 和 image 模型”为硬约束。
- 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。 - 「测试连接」创建 `AIModelTestWorker` 后台调用 `appconfig.test_ai_model()`,GUI 主线程不直接发网络请求。
- 角色与生成参数读写 `config.json`,并按 3 个组件一组排列:标题大模型(仅 text)、图片大模型(仅 image)、标题/图片并发、失败重试、分辨率、jpg 质量。 - 角色与生成参数读写 `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 + 诊断日志补丁): ② AI生成当前要点(T-302/T-302p/T-303/T-303b + 诊断日志补丁):
- 左右 `QSplitter`:左侧约 1/4 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。 - 左右 `QSplitter`:左侧约 1/4 为标题提示词、封面提示词两个多行输入;右侧为筛选栏 + 任务列表。
- 标题提示词启动时从 `title_prompt.txt` 回显;点击「保存标题提示词」写回该文件。 - 标题提示词启动时从 `data/title_prompt.txt` 回显;点击「保存标题提示词」写回该文件。
- 封面提示词模板下拉读取 `prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。 - 封面提示词模板下拉读取 `data/prompts/cover/*.txt`;支持新建、保存、另存为、重命名、删除。删除由 GUI 二次确认,删空后下拉显示内存态“默认”,不会自动建文件。
- 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。 - 「插入标题」在封面提示词光标处插入 `{新标题}`;「预览」使用当前选中任务(无选择则用第一条)调用 `prompts.render_prompt()` 并弹窗展示。
- 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。 - 筛选栏包含:批次、店铺、商品ID、状态、刷新。批次来自 `db.list_batches()`;店铺来自当前任务别名并优先显示匹配账号名;商品ID输入框按包含匹配 `item_id`,清空表示全部;状态支持全部/待生成/已生成/失败/略过/已更新。
- 任务列表使用 `QTableView + GenerateTaskTableModel`,列为:店铺、商品ID、旧标题、新标题、状态。`stage=collected` 显示“待生成”,`stage=generated` 显示“已生成”,`status=failed/skipped/running` 优先显示对应状态;已生成、未提交线上、非运行中的「新标题」列可双击编辑,调用 `db.update_generated_title()` 写回本地并清空 `last_error`。 - 任务列表使用 `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;只把每条“将更新/将略过”写入运行日志并弹汇总。 - 检查本轮更新:不做账号登录预检,不调用 `editor.apply_task()`,不写任务状态,不回写 Excel;只把每条“将更新/将略过”写入运行日志并弹汇总。
- 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 Chrome 未启动、CDP 端口不可访问、未登录,或本轮涉及账号调试端口冲突时,返回 `blocked=True`,GUI 弹窗汇总并跳转/引导去④账号管理;预检不通过时不调用 `editor.apply_task()`、不写失败状态、不自动启动 Chrome。 - 真实更新前先做账号就绪预检:无账号、当前筛选结果匹配账号 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`,单条失败继续下一条。 - 预检通过后默认串行;若 `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` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。 - 检查和真实更新都会创建 `run_logs`,并把逐条事件写入 `run_log_events`;点击「检查本轮更新」或「开始更新」时先清空 `ApplyTab` 可见日志文本并写入本轮开始摘要,后续只追加本轮日志;③ 页面不自动把上一轮历史日志混入当前运行界面。
- 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;进入编辑页后的失败和复用的用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。 - 若 `close_success_tab=true`,`editor.apply_task()` 只关闭本轮自动新开且成功提交的商品页;确认后跳回商品列表页时,关闭前等待 2 秒;进入编辑页后的失败和复用的用户已有 tab 不关闭。`open_product()` 内部打开失败的新建 tab 由 `open_product()` 自行关闭。
+5 -4
View File
@@ -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.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.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.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` 并给出中文排障提示。 > **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. 背景与目标 ## 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 <API_KEY>` 即可调用。上游 provider、密钥、计费、SSRF 防护、分辨率映射、对象存储与别名发现由 cmhub 承担。 **目标**:改为对接 cmhub 的生成、余额和模型发现接口:`POST /api/v1/generate/title`、`POST /api/v1/generate/image`、`GET /api/v1/balance`、`GET /api/v1/models`,一把 `Authorization: Bearer <API_KEY>` 即可调用。上游 provider、密钥、计费、SSRF 防护、分辨率映射、对象存储与别名发现由 cmhub 承担。
@@ -39,7 +40,7 @@
## 3. 设计原则与边界 ## 3. 设计原则与边界
- **接缝最小化**:保持 `gen_title(...)` / `gen_cover(...)` 的**返回值与现有调用兼容**(`gen_title`→标题字符串、`gen_cover`→已存 JPEG 路径),允许向后兼容新增可选事件回调参数承载计费元数据,只改内层实现。这样 `generate_batch` 编排、并发、重试、DB 写入、诊断日志、JPEG 落盘、`images/<batch_id>/<slug>/...` 路径、T-520 封面开关**全部复用、零改动**;GUI `run_logs` 记录和余额展示放到 T-528。 - **接缝最小化**:保持 `gen_title(...)` / `gen_cover(...)` 的**返回值与现有调用兼容**(`gen_title`→标题字符串、`gen_cover`→已存 JPEG 路径),允许向后兼容新增可选事件回调参数承载计费元数据,只改内层实现。这样 `generate_batch` 编排、并发、重试、DB 写入、诊断日志、JPEG 落盘、`data/images/<batch_id>/<slug>/...` 路径、T-520 封面开关**全部复用、零改动**;GUI `run_logs` 记录和余额展示放到 T-528。
- **不碰高风险层**:`editor.py`/`cdp.py`/`chrome.py`/`accounts.py`/`excel.py`/`db.py`/`workers.py`,以及 ①采集/③更新/④账号全流程**完全不动**。这是本改动最重要的安全边界。 - **不碰高风险层**:`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 / 别名。 - **产品默认 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。 - **`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 不提供入口。 - **默认 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://<cmhub-域名>`,**不带** `/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 <key>`,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由);404 的根因是 Base URL 带多余路径或所连实例未部署 `/api/v1/models`。**T-530 已在保存/请求前规整 Base URL**,会去掉多余路径、查询串和片段,只保留 scheme+host(+port);404 会给出明确中文提示。 - **Base URL 必须是网关根**:只填 `https://<cmhub-域名>`,**不带** `/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 <key>`,对齐对接文档 §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/ai.py` | `gen_title`/`gen_cover` 加 cmhub 分支(请求体+解析+生图下载);抽 backend 选择;错误映射 + 区分重试。`direct` 分支保留现有代码 | M |
| `app/appconfig.py` | `ai` 段加 `backend`/`cmhub` 子段默认值与校验;cmhub Key 的读写与打码(复用脱敏工具);新增 `cmhub_request_url()` 类 helper | S | | `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 | | `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 | | 文档 | `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` 的设置与用户提示层。 **明确不动(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` 的设置与用户提示层。
File diff suppressed because one or more lines are too long
+38 -17
View File
@@ -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` 启动。 发布给用户时,以整个 `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.json`
- `config/ai_models.json` - `config/ai_models.json`
- `config/cmhub.json`
- `cmshopee.db`、`cmshopee.db-wal`、`cmshopee.db-shm` - `cmshopee.db`、`cmshopee.db-wal`、`cmshopee.db-shm`
- `db.sqlite` - `db.sqlite`
- `chrome_user_data_dir/` - `chrome_user_data_dir/`
@@ -48,26 +49,46 @@ dist\cmshopee\cmshopee.exe
- `logs/` - `logs/`
- `prompts/` - `prompts/`
- `title_prompt.txt` - `title_prompt.txt`
- `data/`
- 运营填写后的 Excel 文件 - 运营填写后的 Excel 文件
`cmshopee.spec` 不声明任何 `datas`;`scripts/build_exe.ps1` 会在打包后检查 `dist\cmshopee\`,如果发现上述路径会直接失败。 `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` ```text
- `config/ai_models.json` cmshopee\
- `cmshopee.db` cmshopee.exe
- `chrome_user_data_dir/` <PyInstaller 依赖文件和依赖目录>
- `images/` data\
- `logs/` config.json
- `prompts/`、`title_prompt.txt`(用户维护提示词时生成) 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。 1. 让用户先关闭 cmshopee。
2. 建议用户备份当前整个程序文件夹。 2. 建议用户备份当前整个程序文件夹。
3. 解压新版 `dist\cmshopee\`。 3. 解压新版 `dist\cmshopee\`。
4. 只覆盖程序文件,例如 `cmshopee.exe` 和 `_internal/`。 4. 覆盖新版程序文件和依赖文件。当前 PyInstaller 5.13.2 是扁平 onedir,不能只覆盖 `cmshopee.exe`,也没有 `_internal/` 可单独替换。
5. 不删除也不覆盖用户原有的 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`。 5. 保留旧目录里的 `data\`,不要删除、覆盖或合并它。
如果用户把整个旧目录删除再放新版,账号、任务记录、图片和登录态也会一起丢失,只能从备份恢复。 如果用户把整个旧目录删除再放新版,`data\` 里的账号、任务记录、图片和登录态也会一起丢失,只能从备份恢复。
## 六、验证清单 ## 六、验证清单
@@ -93,9 +114,9 @@ powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1
打包成功后确认: 打包成功后确认:
- `dist\cmshopee\cmshopee.exe` 存在。 - `dist\cmshopee\cmshopee.exe` 存在。
- `dist\cmshopee\` 中没有第三节列出的本地数据。 - 当前 PyInstaller 5.13.2 下 `dist\cmshopee\` 是扁平 onedir,`_internal\` 不存在也属于正常。
- 在干净目录首次启动时能生成默认配置并进入 GUI。 - `dist\cmshopee\` 中没有第三节列出的本地数据,尤其不能含 `data\`。
- 在干净目录首次启动时能生成 `data\config.json` 并进入 GUI。
- 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。 - 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。
涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。 涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。
+8 -8
View File
@@ -53,11 +53,11 @@
- 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。 - 导入:openpyxl 解析**输入列**(账号名/别名/商品id)入 SQLite。
- **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。 - **导入汇总栏**(导入后即时刷新,跑采集前的校验关口):显示 文件数、解析行数(原始数据量)、有效/无效行、匹配账号行数(按账号细分)、未匹配行数。未匹配/无效数字标红可点,点击在列表筛出便于定位纠错。
- 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。 - 采集:用该账号已启动并登录的 Chrome 只读打开商品页,读旧标题、下载旧封面到 `data/images/<batch_id>/<slug>/<task_id>_<item_id>_old.jpg`,写 `old_title/old_cover_path`,stage=collected。若程序为采集自动新建商品页 tab,采集结束后自动关闭;若复用用户原本打开的 tab,则不关闭。
- 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。 - 若商品 ID 已失效、无权限或店铺不匹配,Shopee 可能只弹出短暂错误 toast;采集失败时界面日志应显示捕获到的 toast 文案,并把 toast HTML/URL 写入本地诊断日志,避免用户手动抢复制。只有明确捕获商品失效/商品不存在/无权限类 toast 时,①列表“阶段”列显示“商品失效”;其他商品页打开失败仍显示“失败”。如果失败发生在 `open_product()` 内部,本轮自动新建的商品 tab 必须关闭,复用用户已有 tab 不关闭。
- 回写:采集完成后自动把旧标题/旧封面路径批量回写原 Excel;保留「回写旧数据到 Excel」作为手动重试入口(原文件被锁→提示关闭后重试/另存)。 - 回写:采集完成后自动把旧标题/旧封面路径批量回写原 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。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。 - 「删除批次」位于①批次筛选旁,只能对当前选中的具体批次执行,不能在“全部批次”下执行;运行中禁用。删除是软删除:写本地批次删除标记,不物理删除 DB 记录,不删除原 Excel,不回滚 Shopee。删除后该批次不再出现在①/②/③任何批次下拉、任务列表、筛选、采集、生成、更新、回写入口中。确认框必须显示任务数、已上线任务数,并提示软删除只隐藏本地批次、不会回滚线上修改。
## ② AI生成 ## ② AI生成
@@ -81,14 +81,14 @@
``` ```
- 左侧(提示词管理,上下两块): - 左侧(提示词管理,上下两块):
- **标题提示词**:多行输入 + 「保存」(写 `title_prompt.txt`);启动时自动加载回显。 - **标题提示词**:多行输入 + 「保存」(写 `data/title_prompt.txt`);启动时自动加载回显。
- **封面提示词**:模板下拉(读 `prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。 - **封面提示词**:模板下拉(读 `data/prompts/cover/*.txt`)+ 图标工具栏(新建/保存/另存为/重命名/删除)+ 多行输入 + 「插入标题」(插 `{新标题}`)/「预览」(变量替换后查看)。
- 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。 - 变量:`{旧标题}`/`{新标题}`/`{商品id}`/`{店铺}`,生成前按任务替换。
- 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。 - 右上:按导入批次 / 店铺 / 商品ID / 状态筛选任务;商品ID输入框按包含匹配 `item_id`,清空表示全部。
- 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。 - 右下:任务列表(店铺名、商品id、旧标题、新标题、状态)+ AI生成运行日志;已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面 | 新封面(纯查看)。
- 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 并发生成封面。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。 - 底部**单个「开始生成」+「停止」**,并增加「生成封面图片(成本较高)」复选框:默认不勾选,只生成标题;勾选后才在标题完成后按 `image_concurrency` 并发生成封面。该开关状态持久化到 `config.json` 的 `ai.generate_cover`,但入口放在②,便于用户在每轮生成前做成本判断。
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **⑤ 设置**;②只暴露“本轮是否生成封面”的成本开关。 - 生成参数(标题/图片并发数、失败重试、分辨率、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。 - 「重置生成结果」仅作用当前选中单条,运行中禁用;确认后只改本地 DB,清空 `new_title/new_cover_path/last_error` 并退回 `collected/success` 供重新生成,默认不删除本地新封面文件。若该记录已提交过线上,必须在确认框提示本地重置不回滚 Shopee。
- **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。 - **无逐条人工审核环节**;新标题默认直接用 AI 输出,但允许对已生成且未提交线上的单行做本地微调;可选对单行 `重生成`。生成完即可进入 ③,③ 开始更新前会做批量确认。
@@ -119,7 +119,7 @@
- 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。 - 更新封面时统一按替换第一张执行:删除第一张前必须已有该任务的本地旧封面备份(①采集得到的 `old_cover_path` 且文件存在);备份缺失时阻断该条更新并提示先采集/修复备份,不盲删线上图片。
- 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。 - 默认串行、单条失败继续;⑤ 可开启多账号并行,不同账号同时执行,同一账号内仍串行;真实更新前若本轮账号调试端口冲突则阻断。
- 「检查本轮更新」只写运行日志与检查汇总,不打开 Shopee、不调用 `editor.apply_task()`、不写任务状态、不回写 Excel。 - 「检查本轮更新」只写运行日志与检查汇总,不打开 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。 - 更新前做账号就绪预检:无账号、对应账号 Chrome 未启动、CDP 端口不可访问或未登录时整体阻断并引导去④账号管理,不进入逐条提交,也不自动打开账号 Chrome。
- 若 ⑤ 开启“成功后关闭本次新开编辑页”,则仅关闭本轮程序自动新开且成功提交的商品页;确认后跳回我的商品列表页时,关闭前等待 2 秒;已进入编辑页后的失败任务和用户原本打开的 tab 不关闭。但如果失败发生在 `open_product()` 内部,程序自动新开的无效商品页 tab 要关闭,避免 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 已完成:保存成功会清除未保存标记;保存失败时保留未保存标记并阻止离开。 - 设置页整体布局:内容区居中,左右留白已从 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,成功文案需提醒“记得点保存设置持久化”。 - 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 连接成功:...` 兜底文案。 - 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` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。 - 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。
- 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。 - 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。
- T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。 - T-531 已完成:⑤设置页任意可编辑控件变更都进入未保存状态,保存按钮旁显示“● 未保存更改”;切换到其它 Tab 或关闭窗口时弹出保存/放弃/取消。放弃会重新从本地配置文件回填控件,避免未保存的 URL/API Key 留在界面上;程序化回填、保存后重载和刷新别名填充下拉不会误触发未保存状态。
- direct 模型清单和 `config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。 - direct 模型清单和 `data/config/ai_models.json` 代码路径保留为内部兼容/手工回滚,不在普通 UI 暴露。
- AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列。 - AI 生成参数:标题并发、图片并发、失败重试、分辨率、返回超时、jpg 质量等短字段按三列排列。
- 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。 - 分辨率为 `512 / 1k / 2k / 4k`;返回超时只读展示 `resolution_timeouts[resolution]`,不单独编辑。
- 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。 - 保存写入 `config.json` 的 `ai` 段,供 ② AI生成复用;标题/图片模型角色下拉随 direct UI 一起隐藏。
+16 -16
View File
@@ -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” ## 启动时报 “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`:标题生成模型,会出现在“标题大模型”下拉。 - `text`:标题生成模型,会出现在“标题大模型”下拉。
- `image`:封面生成模型,会出现在“图片大模型”下拉。 - `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 和模型配置。 - 不要删除 `data/data/config/ai_models.json` 来“重置”,否则会丢失本地明文 API Key 和模型配置。
- 不要把 `config/ai_models.json` 提交到 git。 - 不要把 `data/data/config/ai_models.json` 提交到 git。
- 不要把完整文件内容贴到聊天、文档或日志里;该文件含本地明文 API Key。 - 不要把完整文件内容贴到聊天、文档或日志里;该文件含本地明文 API Key。
### 推荐修复 ### 推荐修复
@@ -32,10 +32,10 @@ AI 模型 category 必须是 text 或 image
先只查看非敏感字段,确认哪些模型缺少 `category`: 先只查看非敏感字段,确认哪些模型缺少 `category`:
```powershell ```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 ```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` 等字段。示例: 如果要用脚本批量修复,只按模型名或 `api_type` 推断 `category`,并保留原有 `url`、`model`、`api_key`、`extra_body` 等字段。示例:
```powershell ```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', []): for m in data.get('models', []):
name=m.get('name') name=m.get('name')
if not m.get('category') and name in mapping: 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()])" 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 ```powershell
git status --short --ignored config/ai_models.json git status --short --ignored data/config/ai_models.json
``` ```
正常输出应包含: 正常输出应包含:
```text ```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 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 形态: 只输出非密钥字段确认 URL 形态:
```powershell ```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 ## ⑤ cmhub「刷新别名 / 测试连接」提示 notfound / 404
@@ -207,16 +207,16 @@ T-530 已实现:保存和请求前都会把 Base URL 规整为网关根,去
2. 如果②状态列显示“生成失败”:留在②,确认 cmhub Base URL、API Key、别名和点数正常后,直接点击「开始生成」重试当前筛选结果。 2. 如果②状态列显示“生成失败”:留在②,确认 cmhub Base URL、API Key、别名和点数正常后,直接点击「开始生成」重试当前筛选结果。
3. 如果②状态列显示“更新失败”:到③更新shopee处理;需要重复更新时使用③的重置更新状态入口,不要在②重新生成。 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生成写两层日志: 当前代码会为 ② AI生成写两层日志:
- 页面右下「AI生成运行日志」显示最近一次 `run_type=generate` 的逐条事件,例如 `phase=cover step=cover_request result=failed detail=...`。 - 页面右下「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 ## ④启动登录重复打开 Chrome
+1 -1
View File
@@ -87,5 +87,5 @@
<line x1="361" y1="105" x2="384" y2="105" stroke="#9aa3b2" stroke-width="2" marker-end="url(#arr)"/> <line x1="361" y1="105" x2="384" y2="105" stroke="#9aa3b2" stroke-width="2" marker-end="url(#arr)"/>
</g> </g>
<text class="sub" x="24" y="394">① ③ 依赖账号已配置且已登录(④);未配/未登录则执行按钮禁用并提示去④。存储:config.json + SQLite + 本地 images/。</text> <text class="sub" x="24" y="394">① ③ 依赖账号已配置且已登录(④);未配/未登录则执行按钮禁用并提示去④。存储:data/config.json + SQLite + data/images/。</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+1 -1
View File
@@ -108,7 +108,7 @@
<g font-family="Consolas,monospace" font-size="11.5" fill="#586073"> <g font-family="Consolas,monospace" font-size="11.5" fill="#586073">
<text x="14" y="42">[10:21:03] 文件 tw01.xlsx → 匹配账号「女装店」(9222)</text> <text x="14" y="42">[10:21:03] 文件 tw01.xlsx → 匹配账号「女装店」(9222)</text>
<text x="14" y="62">[10:21:05] 51100639510 采集旧标题:…顯瘦百搭T恤</text> <text x="14" y="62">[10:21:05] 51100639510 采集旧标题:…顯瘦百搭T恤</text>
<text x="14" y="82">[10:21:07] 下载旧封面 → images/nuzhuang_01/51100639510_old.jpg</text> <text x="14" y="82">[10:21:07] 下载旧封面 → data/images/nuzhuang_01/51100639510_old.jpg</text>
<text x="14" y="102">[10:21:08] set_collected ✓(已写 SQLite)</text> <text x="14" y="102">[10:21:08] set_collected ✓(已写 SQLite)</text>
<text x="14" y="122">[10:21:14] 52999999 别名「x别名」未匹配账号 → 略过</text> <text x="14" y="122">[10:21:14] 52999999 别名「x别名」未匹配账号 → 略过</text>
<text x="14" y="142">[10:21:20] 采集完成 3/4,已回写 Excel 旧字段</text> <text x="14" y="142">[10:21:20] 采集完成 3/4,已回写 Excel 旧字段</text>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

+1 -1
View File
@@ -33,7 +33,7 @@
<rect class="ta" x="14" y="36" width="190" height="78" rx="6"/> <rect class="ta" x="14" y="36" width="190" height="78" rx="6"/>
<text class="cell" x="22" y="56" fill="#586073" font-size="11">精简标题、去「T恤」、</text> <text class="cell" x="22" y="56" fill="#586073" font-size="11">精简标题、去「T恤」、</text>
<text class="cell" x="22" y="72" fill="#586073" font-size="11">突出韩版加绒保暖 ≤60字</text> <text class="cell" x="22" y="72" fill="#586073" font-size="11">突出韩版加绒保暖 ≤60字</text>
<text class="mut" x="14" y="128">启动自动加载 title_prompt.txt 回显</text> <text class="mut" x="14" y="128">启动自动加载 data/title_prompt.txt 回显</text>
<!-- cover prompt: multi-template --> <!-- cover prompt: multi-template -->
<text class="sec" x="14" y="160">封面提示词(模板)</text> <text class="sec" x="14" y="160">封面提示词(模板)</text>

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

+1 -1
View File
@@ -74,6 +74,6 @@
<g transform="translate(0,512)"> <g transform="translate(0,512)">
<rect class="btnP" x="822" y="0" width="100" height="34" rx="6"/><text class="btnPT" x="850" y="22">保存设置</text> <rect class="btnP" x="822" y="0" width="100" height="34" rx="6"/><text class="btnPT" x="850" y="22">保存设置</text>
<text class="mut" x="18" y="22">模型清单存 config/ai_models.json(密钥本地明文);选择与参数存 config.json。均 gitignore。</text> <text class="mut" x="18" y="22">模型清单存 data/config/ai_models.json(密钥本地明文);选择与参数存 data/config.json。均 gitignore。</text>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

-2
View File
@@ -14,8 +14,6 @@ def _project_root():
PROJECT_ROOT = _project_root() PROJECT_ROOT = _project_root()
if PROJECT_ROOT not in sys.path: if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT) sys.path.insert(0, PROJECT_ROOT)
if getattr(sys, "frozen", False):
os.chdir(PROJECT_ROOT)
from app.gui import main from app.gui import main
+25
View File
@@ -1287,3 +1287,28 @@
- 文档:`docs/06-tasks.md` 将 T-541 标记为 DONE;`docs/troubleshooting.md`、`docs/packaging.md`、`docs/current-state.md` 更新为当前修复与打包验收口径。 - 文档:`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 合入后仍可打包。 - 测试:`tests/test_gui.py` 新增小屏和大屏 mock geometry 单测,覆盖尺寸限制与居中位置;`scripts/build_exe.ps1` 已重新构建 release,确认 T-541 合入后仍可打包。
- 边界:不保存/恢复窗口历史坐标;未改业务流程、DB、AI、Excel、CDP/Shopee、打包产物结构或用户数据路径。 - 边界:不保存/恢复窗口历史坐标;未改业务流程、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 流程。
+2 -1
View File
@@ -33,7 +33,8 @@ $excluded = @(
"images", "images",
"logs", "logs",
"prompts", "prompts",
"title_prompt.txt" "title_prompt.txt",
"data"
) )
$found = @() $found = @()
+115 -1
View File
@@ -18,7 +18,13 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
config = appconfig.load_config(config_path) config = appconfig.load_config(config_path)
self.assertTrue(os.path.exists(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.assertEqual(240, appconfig.response_timeout(config))
self.assertFalse(appconfig.ai_config(config)["generate_cover"]) self.assertFalse(appconfig.ai_config(config)["generate_cover"])
@@ -31,6 +37,114 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_cmhub_defaults_old_config_and_key_helper(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
config_path = os.path.join(temp_dir, "config.json") config_path = os.path.join(temp_dir, "config.json")
+8 -4
View File
@@ -481,7 +481,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
warning.assert_called_once() warning.assert_called_once()
self.assertIn("本地明文保存", warning.call_args[0][1]) 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) saved = appconfig.get_model("Text Custom", path=models_path)
self.assertEqual("text", saved["category"]) self.assertEqual("text", saved["category"])
@@ -685,8 +685,12 @@ class GuiTests(TempDirMixin, unittest.TestCase):
}, },
saved["shopee_update"], saved["shopee_update"],
) )
self.assertNotIn("ai_models_path", saved) with open(config_path, "r", encoding="utf-8") as fh:
self.assertNotIn("config_path", saved) 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.assertIn("设置已保存", statuses[-1])
self.assert_removed(temp_dir) self.assert_removed(temp_dir)
@@ -942,7 +946,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
tab.save_app_settings() tab.save_app_settings()
warning.assert_called_once() 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, "保存设置", "设置已保存") info.assert_called_once_with(tab, "保存设置", "设置已保存")
saved = appconfig.load_config(cfg["config_path"]) saved = appconfig.load_config(cfg["config_path"])
self.assertEqual("cmhub", saved["ai"]["backend"]) self.assertEqual("cmhub", saved["ai"]["backend"])
+5 -2
View File
@@ -7,12 +7,14 @@ class PackagingTests(unittest.TestCase):
def read_text(self, relative_path): def read_text(self, relative_path):
return (REPO_ROOT / relative_path).read_text(encoding="utf-8") 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") 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('getattr(sys, "frozen", False)', main_py)
self.assertIn("sys.executable", 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): def test_pyinstaller_spec_uses_onedir_without_local_datas(self):
spec = self.read_text("cmshopee.spec") spec = self.read_text("cmshopee.spec")
@@ -37,6 +39,7 @@ class PackagingTests(unittest.TestCase):
"logs", "logs",
"prompts", "prompts",
"title_prompt.txt", "title_prompt.txt",
"data",
): ):
self.assertIn(token, script) self.assertIn(token, script)