diff --git a/.gitignore b/.gitignore index c8afa6b..9de8845 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # ── 凭证 / 业务数据 / 本地产物(绝不提交)── # AI 模型清单 ai_models.json(含密钥) config/ai_models.json +# cmhub 网关 API Key(含密钥) +config/cmhub.json # 应用配置(路径/模型选择/参数) config.json # SQLite(账号/任务/结果,含密码) diff --git a/app/ai.py b/app/ai.py index b7aa9c0..9dc8336 100644 --- a/app/ai.py +++ b/app/ai.py @@ -1,17 +1,22 @@ """AI generation helpers backed by configurable HTTP model endpoints.""" import base64 +import ipaddress from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed import copy import json import mimetypes import os +import socket import threading import time import urllib.error +import urllib.parse import urllib.request import uuid +import requests + from . import appconfig, db, diagnostics, image_paths from . import prompts as prompt_module @@ -20,6 +25,18 @@ from . import prompts as prompt_module class AIError(RuntimeError): """Raised when AI generation cannot complete.""" +class CMHubError(AIError): + """Structured cmhub gateway error.""" + + def __init__(self, code, message, status=None, retryable=False, retry_after=None): + self.code = str(code or "unknown") + self.status = status + self.retryable = bool(retryable) + self.retry_after = retry_after + super().__init__("cmhub %s: %s" % (self.code, message)) + + +CMHUB_IMAGE_MAX_BYTES = 20 * 1024 * 1024 _RESOLUTION_SIZES = { "512": (512, 512), @@ -36,11 +53,23 @@ def gen_title( config=None, models_path=appconfig.AI_MODELS_PATH, on_step=None, + on_event=None, + cmhub_config_path=appconfig.CMHUB_CONFIG_PATH, ): """Generate a new product title from a prompt and the old title.""" cfg = appconfig.load_config() if config is None else config ai_cfg = appconfig.ai_config(cfg) + if _ai_backend(ai_cfg) == "cmhub": + return _gen_title_cmhub( + title_prompt, + old_title, + retry=retry, + config=cfg, + cmhub_config_path=cmhub_config_path, + on_step=on_step, + on_event=on_event, + ) _notify_step(on_step, "load_text_model") model = _role_model("text", ai_cfg.get("default_text_model"), models_path) _notify_step(on_step, "title_build_request") @@ -88,6 +117,8 @@ def gen_cover( config=None, models_path=appconfig.AI_MODELS_PATH, on_step=None, + on_event=None, + cmhub_config_path=appconfig.CMHUB_CONFIG_PATH, ): """Generate a new cover image and save it as a JPEG file.""" @@ -100,6 +131,19 @@ def gen_cover( cfg = appconfig.load_config() if config is None else config ai_cfg = appconfig.ai_config(cfg) + if _ai_backend(ai_cfg) == "cmhub": + return _gen_cover_cmhub( + cover_prompt, + old_cover_path, + out_path, + resolution=resolution, + jpg_quality=jpg_quality, + retry=retry, + config=cfg, + cmhub_config_path=cmhub_config_path, + on_step=on_step, + on_event=on_event, + ) _notify_step(on_step, "load_image_model") model = _role_model("image", ai_cfg.get("default_image_model"), models_path) resolution = str(resolution or ai_cfg.get("resolution", "1k")) @@ -200,6 +244,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No ) db_path = runtime.get("db_path") models_path = runtime.get("models_path", appconfig.AI_MODELS_PATH) + cmhub_config_path = runtime.get("cmhub_config_path", appconfig.CMHUB_CONFIG_PATH) image_root = runtime.get("image_dir") or appconfig.image_dir(config) account_by_alias = runtime.get("account_by_alias") or {} on_task_update = runtime.get("on_task_update") @@ -253,6 +298,7 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No level=event.get("level") or ("warning" if result == "retry" else "info"), attempt=event.get("attempt"), attempts=event.get("attempts"), + metadata=event.get("metadata"), ) return set_step(task, event) @@ -278,6 +324,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No config=config, models_path=models_path, on_step=step_callback(task, "title"), + on_event=step_callback(task, "title"), + cmhub_config_path=cmhub_config_path, ) ] = task for future in as_completed(futures): @@ -366,6 +414,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No config=config, models_path=models_path, on_step=step_callback(task, "cover"), + on_event=step_callback(task, "cover"), + cmhub_config_path=cmhub_config_path, ) ] = (task, new_title) except Exception as exc: @@ -415,6 +465,467 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No summary["ok"] = False return summary + +def fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30): + """Fetch cmhub model aliases for settings UI.""" + + base_url = str(base_url or "").strip() + api_key = str(api_key or "") + if not base_url: + raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub Base URL") + if not api_key: + raise CMHubError("cmhub_not_configured", "请去⑤设置配置 cmhub API Key") + data = _cmhub_call_with_retry( + "GET", + appconfig.cmhub_request_url(base_url, "/api/v1/models"), + api_key, + payload=None, + connect_timeout=max(1, int(connect_timeout or 10)), + read_timeout=max(1, int(read_timeout or 30)), + attempts=1, + on_retry=None, + ) + models = data.get("models", []) + if not isinstance(models, list): + raise CMHubError("bad_response", "cmhub 模型列表格式错误") + return [copy.deepcopy(model) for model in models if isinstance(model, dict)] + + +def _ai_backend(ai_cfg): + backend = str(ai_cfg.get("backend", "direct") or "direct").strip().lower() + if backend not in appconfig.AI_BACKENDS: + raise AIError("AI backend 必须是 direct 或 cmhub") + return backend + + +def _gen_title_cmhub( + title_prompt, + old_title, + retry, + config, + cmhub_config_path, + on_step=None, + on_event=None, +): + ai_cfg = appconfig.ai_config(config) + _notify_step(on_step, "load_text_model") + runtime = _cmhub_runtime(config, "title", cmhub_config_path) + resolution = _normalize_cmhub_resolution(ai_cfg.get("resolution", "1k")) + _notify_step(on_step, "title_build_request") + payload = { + "prompt": _cmhub_title_prompt(title_prompt, old_title), + "model": runtime["alias"], + "resolution": resolution, + } + attempts = _attempt_count(ai_cfg, retry) + _notify_step(on_step, "title_request") + data = _cmhub_call_with_retry( + "POST", + appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/generate/title"), + runtime["api_key"], + payload=payload, + connect_timeout=runtime["connect_timeout"], + read_timeout=_cmhub_read_timeout(config, ai_cfg.get("resolution", "1k")), + attempts=attempts, + on_retry=lambda attempt, total_attempts, exc: _notify_cmhub_retry( + on_step, + "title_request", + attempt, + total_attempts, + exc, + ), + ) + _emit_cmhub_metadata(on_event, data, "title_request") + _notify_step(on_step, "title_parse_response") + titles = data.get("titles") + if not isinstance(titles, list) or not titles: + raise AIError("AI 返回为空标题") + text = str(titles[0] or "").strip() + if not text: + raise AIError("AI 返回为空标题") + return text + + +def _gen_cover_cmhub( + cover_prompt, + old_cover_path, + out_path, + resolution, + jpg_quality, + retry, + config, + cmhub_config_path, + on_step=None, + on_event=None, +): + ai_cfg = appconfig.ai_config(config) + _notify_step(on_step, "load_image_model") + runtime = _cmhub_runtime(config, "image", cmhub_config_path) + resolution = str(resolution or ai_cfg.get("resolution", "1k")) + quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90)) + _notify_step(on_step, "cover_build_request") + payload = { + "prompt": str(cover_prompt or ""), + "model": runtime["alias"], + "image_base64": _image_data_url(old_cover_path), + "resolution": _normalize_cmhub_resolution(resolution), + "aspect_ratio": "1:1", + } + attempts = _attempt_count(ai_cfg, retry) + read_timeout = _cmhub_read_timeout(config, resolution) + _notify_step(on_step, "cover_request") + data = _cmhub_call_with_retry( + "POST", + appconfig.cmhub_request_url(runtime["base_url"], "/api/v1/generate/image"), + runtime["api_key"], + payload=payload, + connect_timeout=runtime["connect_timeout"], + read_timeout=read_timeout, + attempts=attempts, + on_retry=lambda attempt, total_attempts, exc: _notify_cmhub_retry( + on_step, + "cover_request", + attempt, + total_attempts, + exc, + ), + ) + _emit_cmhub_metadata(on_event, data, "cover_request") + _notify_step(on_step, "cover_parse_response") + image_url = str(data.get("image_url") or "").strip() + if not image_url: + raise AIError("AI 返回中没有图片数据") + image_bytes = _download_cmhub_image( + image_url, + connect_timeout=runtime["connect_timeout"], + read_timeout=read_timeout, + ) + _notify_step(on_step, "cover_save") + return _save_jpeg(image_bytes, out_path, resolution, quality) + + +def _cmhub_runtime(config, operation, cmhub_config_path): + hub = appconfig.cmhub_config(config) + api_key = appconfig.get_cmhub_api_key(path=cmhub_config_path) + alias_key = "title_alias" if operation == "title" else "image_alias" + missing = [] + if not hub.get("base_url"): + missing.append("Base URL") + if not api_key: + missing.append("API Key") + if not hub.get(alias_key): + missing.append("生文别名" if operation == "title" else "生图别名") + if missing: + raise CMHubError( + "cmhub_not_configured", + "请去⑤设置配置 cmhub:缺少 " + "、".join(missing), + retryable=False, + ) + return { + "base_url": hub["base_url"].rstrip("/"), + "api_key": api_key, + "alias": hub[alias_key], + "connect_timeout": max(1, int(hub.get("connect_timeout", 10) or 10)), + } + + +def _cmhub_title_prompt(title_prompt, old_title): + return "%s\n\n旧标题:\n%s\n\n请只返回新标题,不要解释。" % ( + str(title_prompt or "").strip(), + str(old_title or ""), + ) + + +def _normalize_cmhub_resolution(resolution): + value = str(resolution or "1k").strip().lower() + mapping = { + "512": "512", + "512x512": "512", + "1k": "1K", + "1K": "1K", + "1024": "1K", + "2k": "2K", + "2K": "2K", + "2048": "2K", + "4k": "4K", + "4K": "4K", + "4096": "4K", + } + return mapping.get(value, str(resolution or "1K").upper()) + + +def _cmhub_read_timeout(config, resolution): + try: + timeout = int(appconfig.response_timeout(config)) + except Exception: + timeout = 600 + resolution_key = str(resolution or "").strip().lower() + timeouts = appconfig.ai_config(config).get("resolution_timeouts", {}) + if resolution_key in timeouts: + timeout = int(timeouts[resolution_key]) + return min(600, max(1, timeout)) + + +def _cmhub_call_with_retry( + method, + url, + api_key, + payload, + connect_timeout, + read_timeout, + attempts, + on_retry=None, +): + attempts = max(1, int(attempts or 1)) + last_exc = None + for index in range(attempts): + try: + return _cmhub_call_once( + method, + url, + api_key, + payload, + connect_timeout=connect_timeout, + read_timeout=read_timeout, + ) + except CMHubError as exc: + last_exc = exc + if not exc.retryable or index + 1 >= attempts: + raise + if on_retry is not None: + try: + on_retry(index + 1, attempts, exc) + except Exception: + pass + time.sleep(_cmhub_retry_delay(exc, index)) + raise last_exc + + +def _cmhub_call_once(method, url, api_key, payload, connect_timeout, read_timeout): + headers = { + "Authorization": "Bearer " + str(api_key), + "Accept": "application/json", + } + request_kwargs = { + "headers": headers, + "timeout": (max(1, int(connect_timeout)), max(1, int(read_timeout))), + } + if str(method).upper() != "GET": + request_kwargs["json"] = payload or {} + try: + response = requests.request(str(method).upper(), url, **request_kwargs) + except requests.exceptions.ConnectTimeout as exc: + raise CMHubError("connect_timeout", "连接 cmhub 超时", retryable=True) from exc + except requests.exceptions.ReadTimeout as exc: + raise CMHubError("read_timeout", "等待 cmhub 返回超时", retryable=False) from exc + except requests.exceptions.RequestException as exc: + raise CMHubError( + "network_error", + _redact_cmhub(str(exc), api_key), + retryable=False, + ) from exc + return _cmhub_response_json(response, api_key) + + +def _cmhub_response_json(response, api_key): + try: + data = response.json() + except ValueError: + data = {} + status = getattr(response, "status_code", None) + if status and status >= 400: + raise _cmhub_error_from_response(data, response, api_key) + if isinstance(data, dict) and isinstance(data.get("error"), dict): + raise _cmhub_error_from_response(data, response, api_key) + if not isinstance(data, dict): + raise CMHubError("bad_response", "cmhub 返回格式错误", status=status) + return data + + +def _cmhub_error_from_response(data, response, api_key): + status = getattr(response, "status_code", None) + error = data.get("error") if isinstance(data, dict) else None + if not isinstance(error, dict): + error = {} + code = str(error.get("code") or _cmhub_code_for_status(status)) + raw_message = (error.get("message") or data.get("message")) if isinstance(data, dict) else "" + if not raw_message: + raw_message = getattr(response, "text", "")[:500] + message = _cmhub_user_message(code, _redact_cmhub(raw_message or code, api_key)) + retry_after = _parse_retry_after(getattr(response, "headers", {}).get("Retry-After")) + return CMHubError( + code, + message, + status=status, + retryable=_cmhub_retryable(code), + retry_after=retry_after, + ) + + +def _cmhub_code_for_status(status): + return { + 400: "bad_request", + 401: "unauthorized", + 402: "insufficient_points", + 403: "account_disabled", + 429: "rate_limited", + 502: "upstream_error", + }.get(status, "unknown") + + +def _cmhub_retryable(code): + return str(code) in {"upstream_error", "rate_limited", "connect_timeout"} + + +def _cmhub_user_message(code, message): + defaults = { + "insufficient_points": "点数不足,请先充值", + "unauthorized": "cmhub API Key 无效,请去⑤设置重填", + "account_disabled": "cmhub 账号已禁用,请去网页端处理", + "bad_request": "cmhub 请求参数错误", + "model_not_allowed": "cmhub 模型别名无权限", + "no_pricing_rule": "cmhub 模型别名未配置价格", + "content_blocked": "cmhub 内容安全策略拒绝本次生成", + "upstream_error": "cmhub 上游生成失败,请稍后重试", + "rate_limited": "cmhub 请求过于频繁,请稍后重试", + } + default = defaults.get(str(code)) + if default and message and str(message) not in default: + return "%s:%s" % (default, message) + return default or str(message or code) + + +def _parse_retry_after(value): + try: + if value is None or value == "": + return None + return max(0.0, float(value)) + except (TypeError, ValueError): + return None + + +def _cmhub_retry_delay(exc, index): + if exc.retry_after is not None: + return min(2.0, max(0.1, float(exc.retry_after))) + return min(2.0, 0.4 * (index + 1)) + + +def _notify_cmhub_retry(callback, step, attempt, attempts, exc): + if callback is None: + return + try: + callback( + { + "step": step, + "result": "retry", + "attempt": attempt, + "attempts": attempts, + "detail": str(exc), + } + ) + except Exception: + pass + + +def _emit_cmhub_metadata(callback, data, step): + if callback is None or not isinstance(data, dict): + return + metadata = { + key: data.get(key) + for key in ("alias", "model_used", "points_cost", "points_balance", "call_id") + if data.get(key) is not None + } + if not metadata: + return + try: + callback( + { + "step": step, + "result": "meta", + "level": "info", + "metadata": metadata, + } + ) + except Exception: + pass + + +def _download_cmhub_image(url, connect_timeout, read_timeout, max_bytes=CMHUB_IMAGE_MAX_BYTES): + _assert_public_http_url(url) + try: + response = requests.get( + url, + stream=True, + timeout=(max(1, int(connect_timeout)), max(1, int(read_timeout))), + ) + except requests.exceptions.RequestException as exc: + raise AIError("下载 cmhub 图片失败: %s" % exc) from exc + status = getattr(response, "status_code", 200) + if status >= 400: + raise AIError("下载 cmhub 图片失败: HTTP %s" % status) + chunks = [] + total = 0 + iterator = response.iter_content(chunk_size=65536) if hasattr(response, "iter_content") else [response.content] + for chunk in iterator: + if not chunk: + continue + total += len(chunk) + if total > max_bytes: + raise AIError("下载 cmhub 图片失败: 图片超过大小上限") + chunks.append(chunk) + return b"".join(chunks) + + +def _assert_public_http_url(url): + parts = urllib.parse.urlsplit(str(url or "")) + if parts.scheme not in {"http", "https"}: + raise AIError("cmhub 图片地址只允许 http/https") + host = parts.hostname + if not host: + raise AIError("cmhub 图片地址缺少域名") + if _is_local_hostname(host): + raise AIError("cmhub 图片地址不能指向本机或内网") + try: + _assert_public_ip(host) + return + except ValueError: + pass + try: + addresses = socket.getaddrinfo( + host, + parts.port or (443 if parts.scheme == "https" else 80), + type=socket.SOCK_STREAM, + ) + except OSError as exc: + raise AIError("cmhub 图片地址无法解析: %s" % exc) from exc + if not addresses: + raise AIError("cmhub 图片地址无法解析") + for address in addresses: + ip_text = address[4][0] + _assert_public_ip(ip_text) + + +def _is_local_hostname(host): + lowered = str(host or "").strip().lower().rstrip(".") + return lowered in {"localhost"} or lowered.endswith(".localhost") or lowered.endswith(".local") + + +def _assert_public_ip(value): + ip = ipaddress.ip_address(value) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + raise AIError("cmhub 图片地址不能指向本机或内网") + + +def _redact_cmhub(text, api_key): + return appconfig.redact_secrets(text, [api_key]) + def _role_model(category, name, models_path): if not name: raise AIError("未配置默认 %s 模型" % category) @@ -450,6 +961,8 @@ def _runtime_config(runtime): "resolution", "generate_cover", "resolution_timeouts", + "backend", + "cmhub", } } if ai_updates: @@ -510,6 +1023,7 @@ def _emit_generation_event( level="info", attempt=None, attempts=None, + metadata=None, ): if callback is None: return @@ -526,6 +1040,8 @@ def _emit_generation_event( payload["attempt"] = attempt if attempts is not None: payload["attempts"] = attempts + if metadata is not None: + payload["metadata"] = appconfig.sanitize_for_log(metadata) try: callback(payload) except Exception: diff --git a/app/appconfig.py b/app/appconfig.py index 2280098..d2d374a 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -14,8 +14,10 @@ import urllib.request CONFIG_PATH = "config.json" AI_MODELS_PATH = os.path.join("config", "ai_models.json") +CMHUB_CONFIG_PATH = os.path.join("config", "cmhub.json") CATEGORIES = {"text", "image"} API_TYPES = {"chat", "images_edits", "auto"} +AI_BACKENDS = {"direct", "cmhub"} DEFAULT_CONFIG = { "chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe", @@ -29,6 +31,14 @@ DEFAULT_CONFIG = { "default_text_model": "GPT-5.5 文本", "default_image_model": "Nano Banana 2", "generate_cover": False, + "backend": "direct", + "cmhub": { + "base_url": "", + "title_alias": "", + "image_alias": "", + "connect_timeout": 10, + "check_balance_before_batch": False, + }, "title_concurrency": 4, "image_concurrency": 4, "retry": 2, @@ -82,6 +92,10 @@ DEFAULT_AI_MODELS_CONFIG = { ] } + +DEFAULT_CMHUB_CONFIG = { + "api_key": "", +} SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"} @@ -183,6 +197,52 @@ def redact_secrets(text, secret_values=None) -> str: return redacted + +def default_cmhub_config() -> dict: + """Return a new copy of the default cmhub key config.""" + + return copy.deepcopy(DEFAULT_CMHUB_CONFIG) + + +def _normalize_cmhub_config(config): + if config is None: + config = {} + if not isinstance(config, dict): + raise ConfigError("config/cmhub.json 必须是对象") + return {"api_key": str(config.get("api_key", "") or "")} + + +def load_cmhub_config(path=CMHUB_CONFIG_PATH) -> dict: + """Load cmhub API key config. Missing file means key is not configured.""" + + if not os.path.exists(path): + return default_cmhub_config() + with open(path, "r", encoding="utf-8") as fh: + try: + loaded = json.load(fh) + except json.JSONDecodeError as exc: + raise ConfigError(f"cmhub 配置不是有效 JSON: {path}") from exc + return _normalize_cmhub_config(loaded) + + +def save_cmhub_config(config, path=CMHUB_CONFIG_PATH) -> dict: + """Persist cmhub API key config, including the local plaintext key.""" + + normalized = _normalize_cmhub_config(config) + directory = os.path.dirname(os.path.abspath(path)) + if directory: + os.makedirs(directory, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(normalized, fh, ensure_ascii=False, indent=2) + fh.write("\n") + return normalized + + +def get_cmhub_api_key(path=CMHUB_CONFIG_PATH, masked=False) -> str: + key = load_cmhub_config(path).get("api_key", "") + return mask_secret(key) if masked else key + + def save_config(config, path=CONFIG_PATH) -> dict: """Persist config to JSON and return the normalized config.""" @@ -258,6 +318,39 @@ def ai_config(config=None) -> dict: return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"])) + +def ai_backend(config=None) -> str: + ai = ai_config(config) + backend = str(ai.get("backend", "direct") or "direct").strip().lower() + if backend not in AI_BACKENDS: + raise ConfigError("AI backend 必须是 direct 或 cmhub") + return backend + + +def cmhub_config(config=None) -> dict: + ai = ai_config(config) + value = ai.get("cmhub", {}) + if not isinstance(value, dict): + raise ConfigError("ai.cmhub 必须是对象") + merged = _deep_merge(DEFAULT_CONFIG["ai"]["cmhub"], value) + merged["base_url"] = str(merged.get("base_url", "") or "").strip() + merged["title_alias"] = str(merged.get("title_alias", "") or "").strip() + merged["image_alias"] = str(merged.get("image_alias", "") or "").strip() + merged["connect_timeout"] = int(merged.get("connect_timeout", 10) or 10) + merged["check_balance_before_batch"] = bool(merged.get("check_balance_before_batch", False)) + if merged["connect_timeout"] <= 0: + raise ConfigError("ai.cmhub.connect_timeout 必须大于 0") + return merged + + +def cmhub_request_url(base_url, endpoint) -> str: + base = str(base_url or "").strip().rstrip("/") + path = "/" + str(endpoint or "").strip().lstrip("/") + if not base: + return path + return base + path + + def response_timeout(config=None) -> int: ai = ai_config(config) resolution = str(ai.get("resolution", DEFAULT_CONFIG["ai"]["resolution"])) diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 7d44fae..8ad6a0c 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -17,9 +17,9 @@ | 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 | | 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 | | Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx | -| AI 模型注册 | `config/ai_models.json` 多模型清单(HTTP 调用) | 已定(结构) | 每模型 name/category(text/image)/url/model/key/api_type/连接超时;⑤ 设置可增删改+测试连接;Key 本地明文保存,保存/变更时提示 | -| AI 文本生成 | `app/ai.py` 读取 `default_text_model`(category=text),通用 chat JSON HTTP | 已接入 | 提示词+旧标题→新标题;失败重试,错误脱敏 | -| AI 图像生成 | `app/ai.py` 读取 `default_image_model`(category=image),支持 chat 多模态 JSON / images_edits multipart | 已接入 | 提示词+旧封面→新封面;分辨率 512/1k/2k/4k,jpg_quality 存盘,返回超时随分辨率 | +| AI 模型注册 | `config/ai_models.json` direct 模型清单 + `config/cmhub.json` 网关 Key | 已接入(后端) | direct 模式保留每模型 name/category/url/model/key/api_type;cmhub 模式用 `config.json` 的 Base URL/别名 + `config/cmhub.json` 单 Key;⑤ cmhub 面板待 T-527 | +| AI 文本生成 | `app/ai.py` 支持 direct chat JSON 与 cmhub `POST /api/v1/generate/title` | 已接入 | 提示词+旧标题→新标题;返回值不变;cmhub 计费 metadata 通过事件回调上报 | +| AI 图像生成 | `app/ai.py` 支持 direct chat/images_edits 与 cmhub `POST /api/v1/generate/image` | 已接入 | 提示词+旧封面→新封面;cmhub 拿 `image_url` 后安全下载并转本地 JPEG;生图读超时不自动重发 | | 并发 | 标准库 `concurrent.futures.ThreadPoolExecutor` | 已定 | 标题/图片分别按并发数并行;③ 可按账号并行更新;可停止、可重试 | | 运行日志 | SQLite `run_logs` / `run_log_events` | 已定 | ③ dry-run 与真实更新都留痕;结构化内容走脱敏 | | 图片处理 | `requests`(下载)+ `Pillow`(按分辨率/jpg质量存盘) | 部分待定 | 下载旧封面;新封面按 resolution 生成、jpg_quality 存盘 | @@ -34,9 +34,9 @@ - **Excel 用 openpyxl**:运营用真实 .xlsx;stdlib 无法读写 xlsx,引入一个轻依赖比改用 CSV 更贴合用户习惯。 - **多账号隔离用独立 user-data-dir,不用 Chrome profile**:profile 共享同一 user-data-dir/进程/调试端口,无法每账号独立 CDP 与并行;独立 user-data-dir 才契合自动化。详见 [架构 3.0](04-architecture.md)。 - **快捷方式生成用 PowerShell(无额外依赖)**:用 `WScript.Shell.CreateShortcut` 生成 `.lnk`,不引入 `pywin32` 等依赖。 -- **AI 服务商不写死在代码里**:T-301 已采用 `config/ai_models.json` 的通用 HTTP 接入,当前支持 OpenAI-compatible chat JSON 与 images_edits multipart;具体服务商/模型/Key 由⑤设置维护。 +- **AI 服务商不写死在代码里**:T-301 已采用 `config/ai_models.json` 的 direct 通用 HTTP 接入,支持 OpenAI-compatible chat JSON 与 images_edits multipart;T-526 已新增 `backend=cmhub` 网关后端,cmhub 模式使用 Base URL + 生文/生图别名 + `config/cmhub.json` 单 Key,直连模型清单保留用于回退。 - **AI 模型 category 是硬约束**:`config/ai_models.json` 每个模型必须有 `category=text` 或 `category=image`;启动时报 “AI 模型 category 必须是 text 或 image” 时,按 [常见问题排查](troubleshooting.md) 修复本地配置,不删除或提交含 Key 的配置文件。 -- **敏感信息不加密但强提示与脱敏**:密码与 AI Key 只在本机 SQLite / `config/ai_models.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。 +- **敏感信息不加密但强提示与脱敏**:密码与 AI Key 只在本机 SQLite / `config/ai_models.json` / `config/cmhub.json` 明文保存;保存/变更时弹窗提示,UI 打码,日志/导出必须脱敏,相关本地文件必须 gitignore。 - **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。 - **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。 - **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 29fc853..90b9c7e 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -27,7 +27,7 @@ GUI(PySide6 QTabWidget,5 Tab) └── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面) | v -Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(外部) +Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(direct 外部模型或 cmhub 网关) | v Shopee 卖家中心页面 / 本地图片目录 @@ -38,7 +38,7 @@ Shopee 卖家中心页面 / 本地图片目录 - GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。 - 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。 - 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。 -- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像,服务商/模型由 `config/ai_models.json` 配置);`openpyxl`。 +- 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像;direct 模式由 `config/ai_models.json` 配置,cmhub 模式由 `config.json` 的 `ai.cmhub` + `config/cmhub.json` 配置);`openpyxl`。 ## 二、流水线(核心) @@ -71,12 +71,13 @@ imported → collected → generated → applied - `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。 - `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 - `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 -- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`(外部 AI;模型清单配置;通用 HTTP)。 +- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`(外部 AI;支持 `direct` 模型清单和 `cmhub` 网关 backend;返回值保持标题字符串/本地 JPEG 路径)。 **存储(同一事实只存一处)** - 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `config.json`。 -- AI 模型清单(url/模型/密钥/类型/连接超时)→ `config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示)。 +- AI 模型清单(direct 模式 url/模型/密钥/类型/连接超时)→ `config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示)。 +- cmhub 网关 Key → `config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。 - 业务数据(账号、任务、各阶段结果)→ SQLite `cmshopee.db`。 - 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。 - 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。 @@ -103,6 +104,14 @@ imported → collected → generated → applied "default_text_model": "GPT-5.5 文本", "default_image_model": "Nano Banana 2", "generate_cover": false, + "backend": "direct", + "cmhub": { + "base_url": "", + "title_alias": "", + "image_alias": "", + "connect_timeout": 10, + "check_balance_before_batch": false + }, "title_concurrency": 4, "image_concurrency": 4, "retry": 2, @@ -125,12 +134,14 @@ imported → collected → generated → applied `ai` 段只放**选择 + 全局生成参数**: -- `default_text_model` / `default_image_model`:引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型)。 +- `backend`:`direct` / `cmhub`。全新配置和旧配置缺字段时均为 `direct`,避免未配置 cmhub 时破坏既有直连生成;只有用户在⑤显式切换并配置完整后才走 cmhub。 +- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。 +- `default_text_model` / `default_image_model`:direct 模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);cmhub 模式不读取这些模型定义。 - `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。 - `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。 - `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。 -- 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 -- 密钥不在 `config.json`:每个模型的 `api_key` 存于 `config/ai_models.json`,本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。 +- direct 模式模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 +- 密钥不在 `config.json`:direct 模式每个模型的 `api_key` 存于 `config/ai_models.json`;cmhub 模式 API Key 存于 `config/cmhub.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。 `shopee_update` 段放**真实更新前的安全开关**: @@ -147,7 +158,7 @@ imported → collected → generated → applied ### 5.1b AI 模型清单 `config/ai_models.json` -模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。 +模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。该文件只用于 `backend=direct`;`backend=cmhub` 时生文/生图使用 cmhub 别名,不读取此文件。 ```json { @@ -177,6 +188,16 @@ imported → collected → generated → applied - `name` 唯一;`api_key` 本地明文保存、保存/变更时弹窗提示、打码显示。 - 日志/状态/导出不得含密码或 API Key;结构化对象统一先过 `appconfig.sanitize_for_log()`,自由文本只允许在掌握明文值时用 `appconfig.redact_secrets()` 替换。 +### 5.1c cmhub Key `config/cmhub.json` + +```json +{ "api_key": "sk_cmhub_xxx" } +``` + +- 文件必须 gitignore,不提交;UI 展示打码。 +- `appconfig.load_cmhub_config()` 缺文件时返回空 Key,不自动启用 cmhub。 +- `backend=cmhub` 但 Base URL、API Key 或别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示去⑤设置配置,不静默回退 direct。 + ### 5.2 SQLite `cmshopee.db` ```sql @@ -374,8 +395,8 @@ images///__new. # AI 生成的新封 单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成: -1. **并发生成标题**:线程池大小 = `title_concurrency`,用 `default_text_model` 调 `gen_title(标题提示词, old_title)` → new_title。 -2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images///__new.jpg`。 +1. **并发生成标题**:线程池大小 = `title_concurrency`,调 `gen_title(标题提示词, old_title)` → new_title。`backend=direct` 时使用 `default_text_model`;`backend=cmhub` 时调用 `POST /api/v1/generate/title` 并使用 `title_alias`。 +2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images///__new.jpg`。`backend=direct` 时使用 `default_image_model`;`backend=cmhub` 时调用 `POST /api/v1/generate/image`,拿 `image_url` 后立即安全下载并转本地 JPEG。 - 连接超时取该模型 `connect_timeout_seconds`;**返回超时取 `resolution_timeouts[resolution]`**(512→180/1k→240/2k→360/4k→600)。 3. **若未勾选生成封面**:标题成功后立即写 `new_title`,`new_cover_path=NULL`,不渲染封面提示词、不调用 `gen_cover()`、不创建本地新封面文件。 @@ -462,7 +483,8 @@ cmshopee/ ├── main.py # GUI 启动入口:from app.gui import main ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 ├── config.json # 应用配置(模型选择/生成参数/路径,gitignore) -├── config/ai_models.json # AI 模型清单(含密钥,必须 gitignore) +├── config/ai_models.json # direct AI 模型清单(含密钥,必须 gitignore) +├── config/cmhub.json # cmhub API Key(含密钥,必须 gitignore) ├── cmshopee.db # SQLite(账号/任务/结果,gitignore) ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore) ├── images/ # 旧封面/新封面本地图片(gitignore) @@ -472,7 +494,7 @@ cmshopee/ # 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md ``` -> `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 含密钥/凭证/业务数据,必须 gitignore。`shopee待处理任务模板.xlsx` 是标准空模板,可以提交;运营填写后的 Excel 副本属于业务数据,不提交。 +> `config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 含配置/密钥/凭证/业务数据,必须 gitignore。`shopee待处理任务模板.xlsx` 是标准空模板,可以提交;运营填写后的 Excel 副本属于业务数据,不提交。 ## 十、架构纪律 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 9417d99..b480a26 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -124,7 +124,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-526 | `app/ai.py` + `appconfig` 接入 cmhub backend | T-301, T-303, T-520 | 依据 `docs/cmhub-integration-design.md` v3.2。`config.json` 的 `ai` 段加 `backend`(`cmhub`/`direct`)+ `cmhub` 子段(`base_url`/`title_alias`/`image_alias`/`connect_timeout` 等);为保护既有用户,全新配置默认 `backend=direct`、加载既有配置缺 `backend` 时也补 `direct`,`cmhub` 一律由用户在⑤显式 opt-in;`backend=cmhub` 但 `base_url`/Key 缺失时须抛清晰「请去⑤配置 cmhub」错误(`CMHubError`/`AIError`),不崩溃、不静默直连。cmhub API Key 固定存 `config/cmhub.json`(schema `{ "api_key": "..." }`),新增读写/打码 helper 并把该文件加入 `.gitignore`;日志脱敏。`gen_title`/`gen_cover` **返回值不变**,内层按 backend 分流并保留 `direct`;计费元数据不塞进返回值,允许给二者**新增一个可选事件回调参数**(如 `on_meta`/`on_event`)承载,属向后兼容加参,`generate_batch` 显式传回调不受影响。cmhub 分支:生文 `POST /api/v1/generate/title` 体 `{prompt,model:别名,resolution?}`、取 `titles[0]`、空则 `AIError`;生图 `POST /api/v1/generate/image` 体 `{prompt,model:别名,image_base64:<旧封面>,resolution,aspect_ratio:"1:1"}`、拿 `image_url` 后**立即下载**再走 `_save_jpeg`;`resolution` 归一大写 `512/1K/2K/4K`。新增 `CMHubError(AIError)`,带 `code/status/retryable/retry_after`,错误按 `code` 优先分支(`insufficient_points`/`unauthorized`/`account_disabled`/`bad_request`/`model_not_allowed`/`no_pricing_rule`/`content_blocked`/`upstream_error`/`rate_limited`,未知 code 当不可重试);cmhub HTTP helper 需区分 connect/read timeout(优先用 `requests timeout=(connect, read)`),只对 502/429/连接超时重试,生图读超时绝不自动重发,读超时按分辨率封顶 600s。`points_cost`/`points_balance`/`call_id` 不改返回值,通过 `on_step`/事件回调上报;T-526 只保证 metadata 事件完整传出,T-528 再由 GUI worker 脱敏写 run_logs 和余额展示。`image_url` 下载必须限制 http/https、拒绝内网/回环地址、校验域名解析后的 IP 仍不是内网/回环/本机地址,并设置超时和大小上限。新增 `fetch_cmhub_models(base_url, api_key)` helper 调 `GET /api/v1/models` 返回别名清单(`alias/operation_type/requires_image/pricing_status/prices`)供 T-527 渲染下拉,错误脱敏。不碰 editor/cdp/chrome/accounts/excel/db,也不改 ①③④流程。`tests/test_ai.py` 加 cmhub mock(titles 列表、image_url 下载、安全下载、错误码与重试、读超时不重发、metadata 事件)、`tests/test_appconfig.py` 加 schema 和 key 文件 helper,direct 用例保持绿 | TODO | +| T-526 | `app/ai.py` + `appconfig` 接入 cmhub backend | T-301, T-303, T-520 | 依据 `docs/cmhub-integration-design.md` v3.2。`config.json` 的 `ai` 段加 `backend`(`cmhub`/`direct`)+ `cmhub` 子段(`base_url`/`title_alias`/`image_alias`/`connect_timeout` 等);为保护既有用户,全新配置默认 `backend=direct`、加载既有配置缺 `backend` 时也补 `direct`,`cmhub` 一律由用户在⑤显式 opt-in;`backend=cmhub` 但 `base_url`/Key 缺失时须抛清晰「请去⑤配置 cmhub」错误(`CMHubError`/`AIError`),不崩溃、不静默直连。cmhub API Key 固定存 `config/cmhub.json`(schema `{ "api_key": "..." }`),新增读写/打码 helper 并把该文件加入 `.gitignore`;日志脱敏。`gen_title`/`gen_cover` **返回值不变**,内层按 backend 分流并保留 `direct`;计费元数据不塞进返回值,允许给二者**新增一个可选事件回调参数**(如 `on_meta`/`on_event`)承载,属向后兼容加参,`generate_batch` 显式传回调不受影响。cmhub 分支:生文 `POST /api/v1/generate/title` 体 `{prompt,model:别名,resolution?}`、取 `titles[0]`、空则 `AIError`;生图 `POST /api/v1/generate/image` 体 `{prompt,model:别名,image_base64:<旧封面>,resolution,aspect_ratio:"1:1"}`、拿 `image_url` 后**立即下载**再走 `_save_jpeg`;`resolution` 归一大写 `512/1K/2K/4K`。新增 `CMHubError(AIError)`,带 `code/status/retryable/retry_after`,错误按 `code` 优先分支(`insufficient_points`/`unauthorized`/`account_disabled`/`bad_request`/`model_not_allowed`/`no_pricing_rule`/`content_blocked`/`upstream_error`/`rate_limited`,未知 code 当不可重试);cmhub HTTP helper 需区分 connect/read timeout(优先用 `requests timeout=(connect, read)`),只对 502/429/连接超时重试,生图读超时绝不自动重发,读超时按分辨率封顶 600s。`points_cost`/`points_balance`/`call_id` 不改返回值,通过 `on_step`/事件回调上报;T-526 只保证 metadata 事件完整传出,T-528 再由 GUI worker 脱敏写 run_logs 和余额展示。`image_url` 下载必须限制 http/https、拒绝内网/回环地址、校验域名解析后的 IP 仍不是内网/回环/本机地址,并设置超时和大小上限。新增 `fetch_cmhub_models(base_url, api_key)` helper 调 `GET /api/v1/models` 返回别名清单(`alias/operation_type/requires_image/pricing_status/prices`)供 T-527 渲染下拉,错误脱敏。不碰 editor/cdp/chrome/accounts/excel/db,也不改 ①③④流程。`tests/test_ai.py` 加 cmhub mock(titles 列表、image_url 下载、安全下载、错误码与重试、读超时不重发、metadata 事件)、`tests/test_appconfig.py` 加 schema 和 key 文件 helper,direct 用例保持绿 | DONE | | T-527 | ⑤设置 cmhub 网关面板 | T-526, T-517 | 依据 `docs/cmhub-integration-design.md` v3.2。⑤ AI 设置按 `backend` 切换:cmhub 模式显示「网关 Base URL + API Key(打码,提示从网页端复制、仅显示一次)+ 生文别名 + 生图别名 + 测试连接/查余额」;**别名从 `GET /api/v1/models`(T-526 的 `fetch_cmhub_models`)动态拉取渲染下拉**,按 `operation_type` 分生文/生图,过滤 `pricing_status="unpriced"` 的别名,可展示单价与 `requires_image` 提示,选中值持久化到 `ai.cmhub.title_alias/image_alias`(网关临时不可达时回退已存值);不写死别名。direct 模式保留现有 AI 模型 master-detail。保存写 `config.json` 的 `ai` 段与 `config/cmhub.json`;切换 backend 时不删除 legacy `config/ai_models.json`。测试连接/查余额经后台 worker 调 cmhub(复用 `AIModelTestWorker` 思路或新增 worker),错误必须脱敏并给用户可读提示。同步 GUI 设置测试;不改 Shopee/CDP 流程 | TODO | | T-528 | ② 计费错误提示 + 余额展示 | T-526, T-527, T-303 | 依据 `docs/cmhub-integration-design.md` v3.2。② AI生成页把 cmhub 计费失败态显式化:通过 `CMHubError.code` 识别 `insufficient_points`,弹明确提示「点数不足,请先充值」并引导去网页端充值,本轮未开始任务可提前中止,不靠中文字符串匹配、不淹没在失败计数里;用 T-526 成功响应事件里的 `points_balance` 刷新②页剩余点数显示,`/balance` 仅作手动刷新/可选批量前预检;`points_cost`/`call_id` 记入脱敏 run_logs。只改② UI、`GenerateWorker` 事件处理/文案及 GUI 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | TODO | diff --git a/docs/api.md b/docs/api.md index dd700e0..60c8c53 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,7 +6,7 @@ - 形态:本地函数 + 子进程(Chrome)+ CDP(`127.0.0.1:`)+ SQLite + openpyxl + AI 服务调用。 - 编码:UTF-8;传 Chrome / `setFileInputFiles` 的路径为 **Windows 绝对路径**。 -- 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 必须 gitignore;UI 打码显示,不出现在日志/导出。 +- 凭证:登录态在 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 打码显示,不出现在日志/导出。 - 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。 ## appconfig 模块(`app/appconfig.py`,已建) @@ -29,10 +29,13 @@ cdp_ready_timeout(config=None) -> int ai_config(config=None) -> dict # default_text_model/default_image_model/ # title_concurrency/image_concurrency/retry/jpg_quality/ # resolution/resolution_timeouts +ai_backend(config=None) -> str # direct / cmhub +cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout +cmhub_request_url(base_url, endpoint) -> str 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`。AI Key 留给 `config/ai_models.json`。 +`default_config()` / `load_config()` 包含 `shopee_update` 安全配置段:历史/调试兼容测试商品 ID、是否允许真实提交、是否允许更新封面、每批最大更新条数、成功后是否关闭本轮新开编辑页、内部兼容 `dry_run`、多账号并行、最大并行账号数。普通正式更新不再用测试商品 ID 阻断当前筛选结果。`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `*_token` / `password` / `*_password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`(direct)或 `config/cmhub.json`(cmhub)。 敏感信息展示/日志辅助: @@ -42,6 +45,17 @@ sanitize_for_log(value) -> object # 递归打码 api_key/password/tok redact_secrets(text, secret_values=None) -> str # 用已知明文值替换自由文本中的秘密 ``` + +cmhub Key 文件(`config/cmhub.json`,含本地明文密钥,T-526 已建;UI 由 T-527 接入): + +```python +default_cmhub_config() -> dict +load_cmhub_config(path="config/cmhub.json") -> dict # 缺文件返回空 key,不自动启用 cmhub +save_cmhub_config(config, path="config/cmhub.json") -> dict +get_cmhub_api_key(path="config/cmhub.json", masked=False) -> str +``` + +`config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/connect_timeout` 等非密钥配置;`config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。 AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用): ```python @@ -271,14 +285,15 @@ apply_task(account, task, close_success_tab=False) -> dict ```python class AIError(RuntimeError): ... -gen_title(title_prompt, old_title, retry=None, config=None, models_path="config/ai_models.json", on_step=None) -> str -# 文本生成:读取 default_text_model,chat JSON 请求;提示词 + 旧标题 → 新标题 +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 +# 文本生成:按 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) -> str -# 图像生成(image-to-image):读取 default_image_model;chat 多模态 JSON 或 images_edits multipart; +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 +# 图像生成(image-to-image):按 backend 分流;direct 走 chat/images_edits,cmhub 走 /generate/image; # 支持返回 url / data URL / b64_json,按 resolution resize 并以 jpg_quality 保存 JPEG,返回路径;新生成默认写入 `image_dir///__new.jpg`,历史 DB 已存路径继续按原路径读取 generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict +fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> list[dict] # 编排:先以 title_concurrency 线程池并发跑 gen_title;ai.generate_cover 为 true 时再以 image_concurrency 并发跑 gen_cover # 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项 # on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度 @@ -288,10 +303,12 @@ generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) 要点: -- 标题用 `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`。 +- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供 T-527 设置页动态下拉使用。 - `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。 -- 连接超时参考模型 `connect_timeout_seconds`;**返回超时 = 模型 `timeout_seconds` 或 `appconfig.response_timeout()`(随分辨率:512/1k/2k/4k → 180/240/360/600)**。 -- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;Key 本地明文存储,但不入日志、不导出。 +- 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。 +- 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。 - 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。 - 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。 - 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。 diff --git a/docs/current-state.md b/docs/current-state.md index 93a71cc..79745b1 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,22 +5,22 @@ ## 当前快照 -- 日期:2026-07-03 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe。 -- 技术栈:Python 3.10+,根目录 `requirements.txt` 锁定运行依赖,GitHub Actions 使用 Windows + Python 3.11 自动跑语法和单元/GUI 测试,PyInstaller onedir 打包为 Windows 免安装 exe;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 -- 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 -- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`、`build/`、`dist/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 +- 日期:2026-07-04 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-103b 登录检测补充识别 Shopee accounts 登录页、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包、T-523a ②/③ 新一轮运行前清空界面日志显示、T-404b 商品详情页加载失败 toast 自动捕获、T-524 PyInstaller 打包为免安装 exe、T-526 `app/ai.py` + `appconfig` 接入 cmhub backend。 +- 技术栈:Python 3.10+,根目录 `requirements.txt` 锁定运行依赖,GitHub Actions 使用 Windows + Python 3.11 自动跑语法和单元/GUI 测试,PyInstaller onedir 打包为 Windows 免安装 exe;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(direct: `config/ai_models.json` 通用 HTTP;cmhub: `config.json` backend + `config/cmhub.json` Key),GUI PySide6 5 Tab(已定)。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,打开商品详情页时会安装 toast 捕获并在详情页加载失败时上浮商品失效/无权限等错误原因,`open_product()` 内部失败会关闭本轮自动新建的失败 tab,复用用户已有 tab 不关闭,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,`config/cmhub.json` cmhub Key 读写/打码 helper,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,支持 direct 通用 HTTP 和 cmhub 网关 backend,direct 按默认文本/图片模型调用,cmhub 按 Base URL + 生文/生图别名调用 title/image/models 接口,生图 `image_url` 安全下载后转本地 JPEG,计费 metadata 通过事件回调传出,且保留重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测(含 Shopee accounts 登录页)与商品 tab 生命周期、商品详情页失效/隐藏 toast 捕获、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-524 PyInstaller 打包入口/构建脚本/发布目录校验、T-526 cmhub backend mock;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 +- 数据:`config.json`、`config/ai_models.json`、`config/cmhub.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt`、`build/`、`dist/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 ## 既定设计要点(文档已定) - GUI:**PySide6 5 Tab 流水线**,顺序 ① 导入采集 → ② AI生成 → ③ 更新shopee → ④ 账号管理 → ⑤ 设置;后台任务用 worker/QThread/signal。 - 流水线阶段:imported → collected(采集旧标题/旧封面+下载+回写)→ generated(AI 提示词生成新标题/新封面)→ applied(③ 弹窗批量确认后改 Shopee 并提交)。**无 confirmed、无常驻提交开关。** -- 存储:`config.json`(应用设置)+ `config/ai_models.json`(AI 模型清单与本地明文 Key)+ SQLite `cmshopee.db`(账号/任务/各阶段结果,密码本地明文仅参考)+ openpyxl(Excel)+ 本地 `images/`(旧/新封面);密码/API Key 保存或变更时提示,展示和日志/导出必须脱敏。 +- 存储:`config.json`(应用设置和 AI backend 选择)+ `config/ai_models.json`(direct AI 模型清单与本地明文 Key)+ `config/cmhub.json`(cmhub 本地明文 Key)+ SQLite `cmshopee.db`(账号/任务/各阶段结果,密码本地明文仅参考)+ openpyxl(Excel)+ 本地 `images/`(旧/新封面);密码/API Key 保存或变更时提示,展示和日志/导出必须脱敏。 - 多账号隔离:每账号独立 user-data-dir(非 profile)。 - 账号↔任务绑定:以 Excel“别名”列为权威;未匹配略过,结束弹窗汇总。 - 执行:默认多账号串行、单条失败继续;③ 提供「检查本轮更新」按钮,检查只写运行日志和汇总,不打开 Shopee、不提交线上、不改任务状态;⑤ 可开启多账号并行(不同账号并行、同账号内串行)并设置每批最大更新条数。③ 点击「开始更新」后先按真实更新安全设置弹窗确认当前筛选范围、任务数量、每批大小和预计批次;确认后做本轮账号 Chrome/CDP/登录态预检,未启动或未登录则弹窗列出账号并中止、不自动打开 Chrome;账号就绪后按批逐条点「更新」提交线上,点击停止后不再开始下一条或下一批。 -- AI:服务商/模型/Key 由 `config/ai_models.json` 配置;`app/ai.py` 支持 chat JSON 与 images_edits multipart;生成内容直接用于更新,本地留档+回写 Excel 供追溯。 +- AI:`app/ai.py` 支持 `backend=direct` 与 `backend=cmhub`。direct 模式由 `config/ai_models.json` 配置服务商/模型/Key,支持 chat JSON 与 images_edits multipart;cmhub 模式由 `config.json` 的 Base URL/别名和 `config/cmhub.json` Key 配置,生成返回值保持不变,计费 metadata 通过事件传出。生成内容直接用于更新,本地留档+回写 Excel 供追溯。 - 登录:人工登录 + 程序检测,不自动登录;④「启动登录」只是打开对应账号浏览器供人工登录,④「检测登录」和①/③预检只验证登录态;无 Shopee tab 时检测入口为 `https:///`(默认 `https://seller.shopee.tw/`);页面跳到 `accounts.shopee.tw/seller/login` 时已明确判为未登录;首次未配账号、对应账号 Chrome 未启动或未登录时,① ③ 应禁用或执行前预检提示,并引导去④,③ 不静默启动缺失账号 Chrome。 ## 当前目录要点 @@ -42,9 +42,9 @@ | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502/T-404b + T-404 补丁产出:登录状态检测、打开商品页、详情页加载失败 toast 捕获与商品失效归因、读/写标题、读/下载封面、采集步骤回调、更新步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | -| `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504/T-520 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、Shopee 更新安全与 dry-run/多账号并行默认值,②生成封面默认关闭的 `ai.generate_cover`;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | +| `app/appconfig.py` | 已有 | T-002/T-501c/T-503/T-504/T-520/T-526 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取、AI `backend=direct/cmhub` 和 `ai.cmhub` 默认值、`config/cmhub.json` Key helper、Shopee 更新安全与 dry-run/多账号并行默认值,②生成封面默认关闭的 `ai.generate_cover`;拒绝敏感字段写入;提供敏感值打码、结构化日志脱敏与自由文本替换工具 | | `app/diagnostics.py` | 已有 | T-207 + AI生成诊断补丁 + T-505 产出:本地 `logs/cmshopee.log` 诊断日志、大小滚动、异常类型/traceback/step/耗时记录,结构化 payload 和自由文本脱敏后写入 | -| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;读取默认模型;通用 HTTP 调用;失败重试;错误脱敏;标题/封面步骤级回调;调用失败但仍可重试时上报 `retry` 事件;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成可按 `ai.generate_cover` 跳过封面阶段,标题成功即写库进入 generated;开启封面时先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | +| `app/ai.py` | 已有 | T-301/T-303 + AI生成诊断补丁 + T-519/T-520/T-526 产出:`gen_title()`/`gen_cover()`/`generate_batch()`;支持 direct 默认模型直连和 cmhub 网关 backend;cmhub 使用 `CMHubError` 结构化错误、tuple timeout、非幂等生图读超时不重发、`image_url` 安全下载、`fetch_cmhub_models()` 别名发现和 metadata 事件;direct 保留通用 HTTP 调用、失败重试、错误脱敏;封面按 resolution/jpg_quality 保存并按批次/店铺/任务路径落盘;批量生成可按 `ai.generate_cover` 跳过封面阶段,标题成功即写库进入 generated;开启封面时先标题后封面、进度回调、逐条落库、失败标记、事件/错误回调、停止取消未开始项 | | `app/prompts.py` | 已有 | T-302p 产出:标题提示词读写、封面模板列表/读取/保存/重命名/删除、变量替换 | | `app/db.py` | 已有 | T-003/T-206/T-404a/T-504/T-509 产出:batches/accounts/tasks schema;batches 软删除字段与默认业务查询过滤;run_logs/run_log_events;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库;生成结果/更新状态本地重置;`update_generated_title()` 本地新标题微调;运行日志写入与查询 | | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | @@ -53,7 +53,7 @@ | `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-523/T-523a/T-404b/T-524 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers/packaging | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | -| `config.json` / `config/ai_models.json` / `cmshopee.db` / `cmshopee.db-*` / `chrome_user_data_dir/` / `images/` / `logs/` / `prompts/` / `title_prompt.txt` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片、本地诊断日志和用户提示词,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 | +| `config.json` / `config/ai_models.json` / `config/cmhub.json` / `cmshopee.db` / `cmshopee.db-*` / `chrome_user_data_dir/` / `images/` / `logs/` / `prompts/` / `title_prompt.txt` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片、本地诊断日志和用户提示词,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 | ## 已验证能力(单账号) @@ -68,8 +68,8 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)。 -- 下一个可领取任务:T-526(`app/ai.py` + `appconfig` 接入 cmhub backend);T-525 ruff 顺延到 cmhub 对接后。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-103b(登录检测补充识别 Shopee accounts 登录页)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)、T-523a(②/③ 新一轮运行前清空界面日志显示)、T-404b(商品详情页加载失败 toast 自动捕获)、T-524(PyInstaller 打包为免安装 exe)、T-526(`app/ai.py` + `appconfig` 接入 cmhub backend)。 +- 下一个可领取任务:T-527(⑤设置 cmhub 网关面板);T-525 ruff 顺延到 cmhub 对接后。 ## 当前已知限制 @@ -96,7 +96,7 @@ - T-523a 已完成:②点击「开始生成」、③点击「检查本轮更新」或「开始更新」时,先清空对应界面的旧日志显示并写入本轮开始摘要;运行中只追加本轮日志。历史 `run_logs/run_log_events` 和本地 `logs/` 不删除、不自动混入当前运行界面。 - T-404b 已完成:`open_product()` 在导航商品详情页前后安装 toast 捕获,等待详情页关键元素超时时会读取最近 `.eds-toasts`/toast/message 节点及页面缓存,把明确商品失效/商品不存在/无权限类 toast 上浮为 `商品失效:<原始toast>`;如果失败发生在 `open_product()` 内部,本轮自动新建的失败 tab 会关闭,复用用户已有 tab 不关闭;①列表“阶段”列只在这类明确失效错误时显示“商品失效”,底层仍保持 `stage=imported/status=failed`,其他打开失败仍显示“失败”。 - T-524 已完成:新增 PyInstaller onedir 打包配置、构建脚本和发布目录校验;打包版启动时工作目录切到 `cmshopee.exe` 所在目录,首次运行在 exe 同级生成/使用本地配置、DB、图片、日志、登录态和提示词;发布包不内置这些本地数据,用户后续更新采用关闭程序后覆盖程序文件、保留本地数据的方式。 -- Phase 7 cmhub 网关对接已补设计与任务定义:T-526 先接 `app/ai.py`/`appconfig` backend,T-527 做⑤设置面板,T-528 做②计费错误提示与余额展示;T-525 ruff 因业务优先级顺延。 +- Phase 7 cmhub 网关对接:T-526 已接 `app/ai.py`/`appconfig` backend,保留 direct 默认并新增 cmhub mock 单测;下一步 T-527 做⑤设置面板,T-528 做②计费错误提示与余额展示;T-525 ruff 因业务优先级顺延。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 @@ -124,7 +124,7 @@ py -3 -c "import os,tempfile; from app import appconfig; d=tempfile.TemporaryDir py -3 -c "import os,tempfile; from app import db; d=tempfile.TemporaryDirectory(dir='.'); p=os.path.join(d.name,'cmshopee.db'); db.init_db(p); c=db.connect(p); print(c.execute('PRAGMA foreign_keys').fetchone()[0], c.execute('PRAGMA journal_mode').fetchone()[0], c.execute('PRAGMA busy_timeout').fetchone()[0]); c.close()" # gitignore 核心本地数据检查 -git check-ignore -v -- config.json config/ai_models.json cmshopee.db chrome_user_data_dir/ images/ +git check-ignore -v -- config.json config/ai_models.json config/cmhub.json cmshopee.db chrome_user_data_dir/ images/ # ai_models 临时清单读写与打码检查 py -3 -c "import os,tempfile; from app import appconfig; d=tempfile.TemporaryDirectory(dir='.'); p=os.path.join(d.name,'ai_models.json'); print([m['category'] for m in appconfig.list_ai_models(path=p)])" diff --git a/progress.md b/progress.md index 1d7bcd3..d1883fc 100644 --- a/progress.md +++ b/progress.md @@ -1094,4 +1094,23 @@ - 打包安全:`cmshopee.spec` 不声明本地数据 `datas`;构建脚本打包后检查 `dist\cmshopee`,发现 `config.json`、`config/ai_models.json`、`cmshopee.db*`、`db.sqlite`、`chrome_user_data_dir/`、`images/`、`logs/`、`prompts/`、`title_prompt.txt` 会直接失败;脚本会打印当前 Python 版本,低于项目目标 3.10 时给出警告。 - 文档:新增 `docs/packaging.md`,同步 `docs/README.md`、`docs/03-tech-stack.md`、`docs/06-tasks.md`、`docs/current-state.md`;T-524 标为 DONE,下一个可领取任务更新为 T-525。 - 测试:新增 `tests/test_packaging.py` 覆盖 frozen 入口路径、spec 不打包本地数据、构建脚本排除列表。 -- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests -p "test_packaging.py"` 通过(3 tests);`python -m unittest discover -s tests` 通过(170 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示);`powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1` 成功产出 `dist\cmshopee\cmshopee.exe`,脚本确认未混入本地数据。本机当前 `python` 为 3.7.9,构建脚本已提示低于项目目标;正式发布建议切到 Python 3.10+ 环境后重新打包。 \ No newline at end of file +- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests -p "test_packaging.py"` 通过(3 tests);`python -m unittest discover -s tests` 通过(170 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示);`powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1` 成功产出 `dist\cmshopee\cmshopee.exe`,脚本确认未混入本地数据。本机当前 `python` 为 3.7.9,构建脚本已提示低于项目目标;正式发布建议切到 Python 3.10+ 环境后重新打包。 +## 【2026-07-04】文档修正 · cmhub 网关对接任务定义 + +- 背景:Claude Code 已新增 `docs/cmhub-integration-design.md` 与 T-526~T-528;本轮从全栈工程角度评审后修正任务边界,避免未配置 cmhub 时破坏现有 direct 生文/生图流程。 +- 修正:`docs/cmhub-integration-design.md` 增补 v3 评审结论,明确旧配置缺 `backend` 时按 `direct` 迁移、cmhub Key 固定存 `config/cmhub.json`、新增 `CMHubError` 结构化错误、点数/余额通过事件回调传播、cmhub HTTP 调用需区分 connect/read timeout、`image_url` 下载需安全校验。 +- 任务:`docs/06-tasks.md` 将业务优先级调整为 Phase 7 cmhub,对 T-526/T-527/T-528 验收重写;T-525 ruff 顺延到 Phase 8。`docs/current-state.md` 下一个可领取任务改为 T-526。 +- 验证:文档-only 更新,未改代码,未运行单元测试。 +## 【2026-07-04】文档修正 · cmhub v3.2 六点一致性收口 + +- 修正:`docs/cmhub-integration-design.md` 将目标接口改为 title/image/balance/models 四类接口;统一 `gen_title`/`gen_cover` 为“返回值与现有调用兼容,可新增可选事件回调参数”;把不存在的 `default_ai_config()` 改为现有 `DEFAULT_CONFIG` / `default_config()` 表述;明确 T-526 只发出计费 metadata 事件,T-528 再负责 GUI run_logs 与余额展示;补充 `image_url` 域名解析后 IP 也要拒绝内网/回环/本机地址。 +- 任务:`docs/06-tasks.md` 将 T-526/T-527/T-528 引用统一为 `docs/cmhub-integration-design.md` v3.2,并同步 T-526 的 run_logs 边界与安全下载验收。 +- 验证:文档-only 更新,未改代码,未运行单元测试。 +## 【2026-07-04】T-526 完成 · `app/ai.py` + `appconfig` 接入 cmhub backend + +- 状态:DONE +- 变更:`app/appconfig.py` 在 `config.json` 的 `ai` 段新增 `backend=direct/cmhub` 和 `ai.cmhub` 默认配置,新增 `config/cmhub.json` 的读写/打码 helper;`.gitignore` 增加 `config/cmhub.json`。`app/ai.py` 保留 direct 直连模型清单路径,新增 cmhub 生文、生图和 `/models` 别名发现 helper;`gen_title()` / `gen_cover()` 返回值不变,cmhub 计费 metadata 通过事件回调传出;新增 `CMHubError` 结构化错误、按 code 的重试策略、tuple timeout、生图读超时不自动重发、`image_url` http/https + DNS/IP 内网拦截 + 大小上限下载。 +- 文档:同步 `docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`;T-526 标记 DONE,下一步为 T-527。 +- 测试:`python -m py_compile app\appconfig.py app\ai.py tests\test_appconfig.py tests\test_ai.py` 通过;`python -m unittest discover -s tests -p "test_appconfig.py"` 通过(8 tests);`python -m unittest discover -s tests -p "test_ai.py"` 通过(16 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(179 tests)。全量测试仍有本机 PySide6 字体目录提示,不影响结果。 +- 决策:全新配置和旧配置缺 `ai.backend` 时都保持 `direct`,避免升级后未配置 cmhub 就破坏现有生成;cmhub Key 固定只进 `config/cmhub.json`,不进 `config.json`;T-526 只传出 metadata,GUI 余额/计费提示留给 T-528。 +- 下一步:T-527 ⑤设置 cmhub 网关面板。 \ No newline at end of file diff --git a/tests/test_ai.py b/tests/test_ai.py index 2c8dfde..06776a7 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -2,6 +2,7 @@ import base64 import io import json import os +import socket import sys import unittest from types import SimpleNamespace @@ -28,6 +29,22 @@ class _Response: return json.dumps(self.payload).encode("utf-8") + +class _RequestsResponse: + def __init__(self, payload=None, status_code=200, content=b"", headers=None): + self.payload = payload if payload is not None else {} + self.status_code = status_code + self.content = content + self.headers = headers or {} + self.text = json.dumps(self.payload, ensure_ascii=False) + + def json(self): + return self.payload + + def iter_content(self, chunk_size=65536): + if self.content: + yield self.content + class AITests(TempDirMixin, unittest.TestCase): def _write_models(self, path, text=None, image=None): text = text or { @@ -65,6 +82,21 @@ class AITests(TempDirMixin, unittest.TestCase): cfg["ai"]["jpg_quality"] = 80 return cfg + def _cmhub_config(self, temp_dir): + cfg = self._config() + cfg["ai"]["backend"] = "cmhub" + cfg["ai"]["resolution"] = "512" + cfg["ai"]["cmhub"] = { + "base_url": "https://cmhub.example.com", + "title_alias": "title-standard", + "image_alias": "image-hd", + "connect_timeout": 3, + "check_balance_before_batch": False, + } + key_path = os.path.join(temp_dir, "cmhub.json") + appconfig.save_cmhub_config({"api_key": "sk-cmhub-secret"}, path=key_path) + return cfg, key_path + def _collected_tasks(self, temp_dir, cfg, titles=None): titles = titles or ["旧标题A", "旧标题B"] db.init_db(cfg["db_path"]) @@ -240,6 +272,293 @@ class AITests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cmhub_gen_title_uses_alias_and_emits_metadata(self): + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + calls = [] + events = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return _RequestsResponse( + { + "titles": [" 新标题 "], + "alias": "title-standard", + "model_used": "provider-title-model", + "points_cost": 1, + "points_balance": 99, + "call_id": "call-title-1", + } + ) + + with mock.patch("app.ai.requests.request", side_effect=fake_request): + title = ai.gen_title( + "优化标题", + "旧标题", + config=cfg, + cmhub_config_path=key_path, + on_event=events.append, + ) + + self.assertEqual("新标题", title) + self.assertEqual(1, len(calls)) + method, url, kwargs = calls[0] + self.assertEqual("POST", method) + self.assertEqual("https://cmhub.example.com/api/v1/generate/title", url) + self.assertEqual((3, 180), kwargs["timeout"]) + self.assertEqual("Bearer sk-cmhub-secret", kwargs["headers"]["Authorization"]) + payload = kwargs["json"] + self.assertEqual("title-standard", payload["model"]) + self.assertEqual("512", payload["resolution"]) + self.assertIn("优化标题", payload["prompt"]) + self.assertIn("旧标题", payload["prompt"]) + self.assertTrue(events) + self.assertEqual("meta", events[0]["result"]) + self.assertEqual(99, events[0]["metadata"]["points_balance"]) + + self.assert_removed(temp_dir) + + def test_cmhub_missing_config_raises_clear_error(self): + with self.make_temp_dir() as temp_dir: + cfg = self._config() + cfg["ai"]["backend"] = "cmhub" + cfg["ai"]["cmhub"] = { + "base_url": "", + "title_alias": "", + "image_alias": "", + "connect_timeout": 3, + } + + with self.assertRaises(ai.CMHubError) as raised: + ai.gen_title( + "prompt", + "old", + config=cfg, + cmhub_config_path=os.path.join(temp_dir, "missing.json"), + ) + + self.assertEqual("cmhub_not_configured", raised.exception.code) + self.assertIn("请去⑤设置配置 cmhub", str(raised.exception)) + + self.assert_removed(temp_dir) + + def test_cmhub_gen_cover_downloads_image_url_safely(self): + try: + from PIL import Image + except ImportError: + self.skipTest("Pillow not installed") + + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + cfg["ai"]["resolution"] = "1k" + old_cover = os.path.join(temp_dir, "old.jpg") + output = os.path.join(temp_dir, "new.jpg") + Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG") + generated = io.BytesIO() + Image.new("RGB", (8, 8), (200, 120, 80)).save(generated, "PNG") + calls = [] + downloads = [] + events = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return _RequestsResponse( + { + "image_url": "https://cdn.example.com/generated.png", + "alias": "image-hd", + "model_used": "provider-image-model", + "points_cost": 8, + "points_balance": 91, + "call_id": "call-image-1", + } + ) + + def fake_get(url, **kwargs): + downloads.append((url, kwargs)) + return _RequestsResponse(content=generated.getvalue()) + + with mock.patch("app.ai.requests.request", side_effect=fake_request), \ + mock.patch("app.ai.requests.get", side_effect=fake_get), \ + mock.patch( + "app.ai.socket.getaddrinfo", + return_value=[ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", 443), + ) + ], + ): + result = ai.gen_cover( + "生成封面", + old_cover, + output, + resolution="1k", + config=cfg, + cmhub_config_path=key_path, + on_event=events.append, + ) + + self.assertEqual(os.path.abspath(output), result) + payload = calls[0][2]["json"] + self.assertEqual("image-hd", payload["model"]) + self.assertEqual("1K", payload["resolution"]) + self.assertEqual("1:1", payload["aspect_ratio"]) + self.assertTrue(payload["image_base64"].startswith("data:image/jpeg;base64,")) + self.assertEqual("https://cdn.example.com/generated.png", downloads[0][0]) + self.assertEqual((3, 240), downloads[0][1]["timeout"]) + self.assertEqual(91, events[0]["metadata"]["points_balance"]) + with Image.open(output) as saved: + self.assertEqual((1024, 1024), saved.size) + + self.assert_removed(temp_dir) + + def test_cmhub_image_url_rejects_private_and_private_dns(self): + with self.assertRaises(ai.AIError): + ai._download_cmhub_image("http://127.0.0.1/a.png", 1, 1) + + with mock.patch( + "app.ai.socket.getaddrinfo", + return_value=[ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.2", 443)) + ], + ): + with self.assertRaises(ai.AIError): + ai._download_cmhub_image("https://cdn.example.com/a.png", 1, 1) + + def test_cmhub_upstream_error_retries_and_keeps_metadata(self): + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + calls = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + if len(calls) == 1: + return _RequestsResponse( + {"error": {"code": "upstream_error", "message": "bad gateway"}}, + status_code=502, + ) + return _RequestsResponse({"titles": ["新标题"], "points_balance": 10}) + + with mock.patch("app.ai.requests.request", side_effect=fake_request), \ + mock.patch("app.ai.time.sleep"): + title = ai.gen_title( + "prompt", + "old", + config=cfg, + cmhub_config_path=key_path, + ) + + self.assertEqual("新标题", title) + self.assertEqual(2, len(calls)) + + self.assert_removed(temp_dir) + + def test_cmhub_image_read_timeout_does_not_retry(self): + try: + from PIL import Image + except ImportError: + self.skipTest("Pillow not installed") + + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + old_cover = os.path.join(temp_dir, "old.jpg") + output = os.path.join(temp_dir, "new.jpg") + Image.new("RGB", (16, 16), (20, 30, 40)).save(old_cover, "JPEG") + calls = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + raise ai.requests.exceptions.ReadTimeout("slow") + + with mock.patch("app.ai.requests.request", side_effect=fake_request): + with self.assertRaises(ai.CMHubError) as raised: + ai.gen_cover( + "prompt", + old_cover, + output, + retry=3, + config=cfg, + cmhub_config_path=key_path, + ) + + self.assertEqual("read_timeout", raised.exception.code) + self.assertEqual(1, len(calls)) + + self.assert_removed(temp_dir) + + def test_fetch_cmhub_models_returns_aliases(self): + calls = [] + + def fake_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return _RequestsResponse( + { + "models": [ + { + "alias": "title-standard", + "operation_type": "title", + "requires_image": False, + "pricing_status": "priced", + "prices": [{"resolution": "512", "points_cost": 1}], + } + ] + } + ) + + with mock.patch("app.ai.requests.request", side_effect=fake_request): + models = ai.fetch_cmhub_models("https://cmhub.example.com", "sk-cmhub-secret") + + self.assertEqual("GET", calls[0][0]) + self.assertEqual("https://cmhub.example.com/api/v1/models", calls[0][1]) + self.assertEqual("title-standard", models[0]["alias"]) + + def test_generate_batch_forwards_cmhub_metadata_event(self): + with self.make_temp_dir() as temp_dir: + cfg, key_path = self._cmhub_config(temp_dir) + cfg["db_path"] = os.path.join(temp_dir, "cmshopee.db") + cfg["image_dir"] = os.path.join(temp_dir, "images") + batch_id, tasks = self._collected_tasks(temp_dir, cfg, ["旧标题A"]) + events = [] + + def fake_request(method, url, **kwargs): + return _RequestsResponse( + { + "titles": ["新标题A"], + "alias": "title-standard", + "points_cost": 1, + "points_balance": 88, + "call_id": "call-batch-1", + } + ) + + with mock.patch("app.ai.requests.request", side_effect=fake_request): + summary = ai.generate_batch( + tasks, + {"title": "标题提示", "cover": "封面"}, + ai_cfg={ + "config": cfg, + "db_path": cfg["db_path"], + "cmhub_config_path": key_path, + "on_event": events.append, + }, + ) + + self.assertTrue(summary["ok"]) + self.assertTrue( + any( + event.get("metadata", {}).get("points_balance") == 88 + and event.get("metadata", {}).get("call_id") == "call-batch-1" + for event in events + ) + ) + updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"]) + self.assertEqual("generated", updated[0].stage) + + self.assert_removed(temp_dir) + def test_generate_batch_persists_titles_and_covers_per_task(self): with self.make_temp_dir() as temp_dir: cfg = self._config() diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 26caf4b..e8f4ce3 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -1,3 +1,4 @@ +import json import os import sys import unittest @@ -30,6 +31,35 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cmhub_defaults_old_config_and_key_helper(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + cmhub_path = os.path.join(temp_dir, "config", "cmhub.json") + + config = appconfig.load_config(config_path) + ai = appconfig.ai_config(config) + self.assertEqual("direct", ai["backend"]) + self.assertEqual("direct", appconfig.ai_backend(config)) + self.assertEqual("", appconfig.cmhub_config(config)["base_url"]) + self.assertFalse(os.path.exists(cmhub_path)) + self.assertEqual({"api_key": ""}, appconfig.load_cmhub_config(cmhub_path)) + + saved = appconfig.save_cmhub_config( + {"api_key": "sk-cmhub-123456"}, + path=cmhub_path, + ) + self.assertEqual("sk-cmhub-123456", saved["api_key"]) + self.assertEqual("sk-cmhub-123456", appconfig.get_cmhub_api_key(cmhub_path)) + self.assertEqual("sk-c***3456", appconfig.get_cmhub_api_key(cmhub_path, masked=True)) + + with open(config_path, "w", encoding="utf-8") as fh: + json.dump({"ai": {"resolution": "512"}}, fh) + migrated = appconfig.load_config(config_path) + self.assertEqual("direct", appconfig.ai_config(migrated)["backend"]) + self.assertEqual(180, appconfig.response_timeout(migrated)) + + self.assert_removed(temp_dir) + def test_config_rejects_sensitive_fields(self): with self.make_temp_dir() as temp_dir: config_path = os.path.join(temp_dir, "config.json")