feat: add cmhub AI backend

This commit is contained in:
chengma
2026-07-04 15:13:15 +08:00
parent c8a5e9ada8
commit 1efb095767
11 changed files with 1061 additions and 43 deletions
+2
View File
@@ -1,6 +1,8 @@
# ── 凭证 / 业务数据 / 本地产物(绝不提交)── # ── 凭证 / 业务数据 / 本地产物(绝不提交)──
# AI 模型清单 ai_models.json(含密钥) # AI 模型清单 ai_models.json(含密钥)
config/ai_models.json config/ai_models.json
# cmhub 网关 API Key(含密钥)
config/cmhub.json
# 应用配置(路径/模型选择/参数) # 应用配置(路径/模型选择/参数)
config.json config.json
# SQLite(账号/任务/结果,含密码) # SQLite(账号/任务/结果,含密码)
+516
View File
@@ -1,17 +1,22 @@
"""AI generation helpers backed by configurable HTTP model endpoints.""" """AI generation helpers backed by configurable HTTP model endpoints."""
import base64 import base64
import ipaddress
from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed from concurrent.futures import CancelledError, ThreadPoolExecutor, as_completed
import copy import copy
import json import json
import mimetypes import mimetypes
import os import os
import socket
import threading import threading
import time import time
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
import requests
from . import appconfig, db, diagnostics, image_paths from . import appconfig, db, diagnostics, image_paths
from . import prompts as prompt_module from . import prompts as prompt_module
@@ -20,6 +25,18 @@ from . import prompts as prompt_module
class AIError(RuntimeError): class AIError(RuntimeError):
"""Raised when AI generation cannot complete.""" """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 = { _RESOLUTION_SIZES = {
"512": (512, 512), "512": (512, 512),
@@ -36,11 +53,23 @@ def gen_title(
config=None, config=None,
models_path=appconfig.AI_MODELS_PATH, models_path=appconfig.AI_MODELS_PATH,
on_step=None, 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.""" """Generate a new product title from a prompt and the old title."""
cfg = appconfig.load_config() if config is None else config cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg) 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") _notify_step(on_step, "load_text_model")
model = _role_model("text", ai_cfg.get("default_text_model"), models_path) model = _role_model("text", ai_cfg.get("default_text_model"), models_path)
_notify_step(on_step, "title_build_request") _notify_step(on_step, "title_build_request")
@@ -88,6 +117,8 @@ def gen_cover(
config=None, config=None,
models_path=appconfig.AI_MODELS_PATH, models_path=appconfig.AI_MODELS_PATH,
on_step=None, 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.""" """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 cfg = appconfig.load_config() if config is None else config
ai_cfg = appconfig.ai_config(cfg) 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") _notify_step(on_step, "load_image_model")
model = _role_model("image", ai_cfg.get("default_image_model"), models_path) model = _role_model("image", ai_cfg.get("default_image_model"), models_path)
resolution = str(resolution or ai_cfg.get("resolution", "1k")) 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") db_path = runtime.get("db_path")
models_path = runtime.get("models_path", appconfig.AI_MODELS_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) image_root = runtime.get("image_dir") or appconfig.image_dir(config)
account_by_alias = runtime.get("account_by_alias") or {} account_by_alias = runtime.get("account_by_alias") or {}
on_task_update = runtime.get("on_task_update") 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"), level=event.get("level") or ("warning" if result == "retry" else "info"),
attempt=event.get("attempt"), attempt=event.get("attempt"),
attempts=event.get("attempts"), attempts=event.get("attempts"),
metadata=event.get("metadata"),
) )
return return
set_step(task, event) set_step(task, event)
@@ -278,6 +324,8 @@ def generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=No
config=config, config=config,
models_path=models_path, models_path=models_path,
on_step=step_callback(task, "title"), on_step=step_callback(task, "title"),
on_event=step_callback(task, "title"),
cmhub_config_path=cmhub_config_path,
) )
] = task ] = task
for future in as_completed(futures): 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, config=config,
models_path=models_path, models_path=models_path,
on_step=step_callback(task, "cover"), on_step=step_callback(task, "cover"),
on_event=step_callback(task, "cover"),
cmhub_config_path=cmhub_config_path,
) )
] = (task, new_title) ] = (task, new_title)
except Exception as exc: 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 summary["ok"] = False
return summary 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): def _role_model(category, name, models_path):
if not name: if not name:
raise AIError("未配置默认 %s 模型" % category) raise AIError("未配置默认 %s 模型" % category)
@@ -450,6 +961,8 @@ def _runtime_config(runtime):
"resolution", "resolution",
"generate_cover", "generate_cover",
"resolution_timeouts", "resolution_timeouts",
"backend",
"cmhub",
} }
} }
if ai_updates: if ai_updates:
@@ -510,6 +1023,7 @@ def _emit_generation_event(
level="info", level="info",
attempt=None, attempt=None,
attempts=None, attempts=None,
metadata=None,
): ):
if callback is None: if callback is None:
return return
@@ -526,6 +1040,8 @@ def _emit_generation_event(
payload["attempt"] = attempt payload["attempt"] = attempt
if attempts is not None: if attempts is not None:
payload["attempts"] = attempts payload["attempts"] = attempts
if metadata is not None:
payload["metadata"] = appconfig.sanitize_for_log(metadata)
try: try:
callback(payload) callback(payload)
except Exception: except Exception:
+93
View File
@@ -14,8 +14,10 @@ import urllib.request
CONFIG_PATH = "config.json" CONFIG_PATH = "config.json"
AI_MODELS_PATH = os.path.join("config", "ai_models.json") AI_MODELS_PATH = os.path.join("config", "ai_models.json")
CMHUB_CONFIG_PATH = os.path.join("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"}
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",
@@ -29,6 +31,14 @@ DEFAULT_CONFIG = {
"default_text_model": "GPT-5.5 文本", "default_text_model": "GPT-5.5 文本",
"default_image_model": "Nano Banana 2", "default_image_model": "Nano Banana 2",
"generate_cover": False, "generate_cover": False,
"backend": "direct",
"cmhub": {
"base_url": "",
"title_alias": "",
"image_alias": "",
"connect_timeout": 10,
"check_balance_before_batch": False,
},
"title_concurrency": 4, "title_concurrency": 4,
"image_concurrency": 4, "image_concurrency": 4,
"retry": 2, "retry": 2,
@@ -82,6 +92,10 @@ DEFAULT_AI_MODELS_CONFIG = {
] ]
} }
DEFAULT_CMHUB_CONFIG = {
"api_key": "",
}
SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"} SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"}
@@ -183,6 +197,52 @@ def redact_secrets(text, secret_values=None) -> str:
return redacted 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: 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."""
@@ -258,6 +318,39 @@ def ai_config(config=None) -> dict:
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"])) 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: def response_timeout(config=None) -> int:
ai = ai_config(config) ai = ai_config(config)
resolution = str(ai.get("resolution", DEFAULT_CONFIG["ai"]["resolution"])) resolution = str(ai.get("resolution", DEFAULT_CONFIG["ai"]["resolution"]))
+5 -5
View File
@@ -17,9 +17,9 @@
| 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 | | 应用配置 | `config.json`(JSON,stdlib) | 已定 | 少量应用级设置:Chrome 路径、目录根、端口、DB 路径等 |
| 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 | | 业务数据 | SQLite(stdlib `sqlite3`,`cmshopee.db`) | 已定 | 账号、任务、结果:成行增长、要查询/统计/导出 |
| Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx | | Excel 读写 | `openpyxl` | 已定 | 导入任务、回写结果;stdlib 读不了 .xlsx |
| AI 模型注册 | `config/ai_models.json` 多模型清单(HTTP 调用) | 已定(结构) | 每模型 name/category(text/image)/url/model/key/api_type/连接超时;⑤ 设置可增删改+测试连接;Key 本地明文保存,保存/变更时提示 | | 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` 读取 `default_text_model`(category=text),通用 chat JSON HTTP | 已接入 | 提示词+旧标题→新标题;失败重试,错误脱敏 | | AI 文本生成 | `app/ai.py` 支持 direct chat JSON 与 cmhub `POST /api/v1/generate/title` | 已接入 | 提示词+旧标题→新标题;返回值不变;cmhub 计费 metadata 通过事件回调上报 |
| AI 图像生成 | `app/ai.py` 读取 `default_image_model`(category=image),支持 chat 多模态 JSON / images_edits multipart | 已接入 | 提示词+旧封面→新封面;分辨率 512/1k/2k/4k,jpg_quality 存盘,返回超时随分辨率 | | AI 图像生成 | `app/ai.py` 支持 direct chat/images_edits 与 cmhub `POST /api/v1/generate/image` | 已接入 | 提示词+旧封面→新封面;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 存盘 |
@@ -34,9 +34,9 @@
- **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 服务商不写死在代码里**: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 模型 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 供追溯。 - **AI 产出无逐条审核**:生成的新标题/新封面经 ③ 批量确认后提交线上;无常驻提交开关,本地留档 + 回写 Excel 供追溯。
- **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。 - **T-504 更新执行增强**:③ 支持 dry-run 预览、运行日志和按账号并行;默认 dry-run 关闭、并行关闭,不引入新依赖。
- **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。 - **不引入数据库(指外部 DB)**:用 stdlib SQLite 足够;不引入 Postgres/MySQL 等。
+34 -12
View File
@@ -27,7 +27,7 @@ GUI(PySide6 QTabWidget,5 Tab)
└── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面) └── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)
| |
v v
Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(外部) Google Chrome(每账号独立 --user-data-dir + --remote-debugging-port) + AI 服务(direct 外部模型或 cmhub 网关)
| |
v v
Shopee 卖家中心页面 / 本地图片目录 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`。 - 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 服务(文本+图像,服务商/模型由 `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`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。 - `chrome`:拼接启动命令、启动、探测端口、(可选)生成快捷方式。
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 - `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
- `editor`:登录检测、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 - `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`。 - 应用配置(模型选择、生成参数、目录、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`。 - 业务数据(账号、任务、各阶段结果)→ SQLite `cmshopee.db`。
- 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。 - 图片(采集的旧封面、AI 生成的新封面)→ 本地图片目录(路径记在 DB)。
- 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。 - 提示词 → 标题提示词存单文件 `title_prompt.txt`;封面提示词存多模板 `prompts/cover/<名称>.txt`。
@@ -103,6 +104,14 @@ imported → collected → generated → applied
"default_text_model": "GPT-5.5 文本", "default_text_model": "GPT-5.5 文本",
"default_image_model": "Nano Banana 2", "default_image_model": "Nano Banana 2",
"generate_cover": false, "generate_cover": false,
"backend": "direct",
"cmhub": {
"base_url": "",
"title_alias": "",
"image_alias": "",
"connect_timeout": 10,
"check_balance_before_batch": false
},
"title_concurrency": 4, "title_concurrency": 4,
"image_concurrency": 4, "image_concurrency": 4,
"retry": 2, "retry": 2,
@@ -125,12 +134,14 @@ imported → collected → generated → applied
`ai` 段只放**选择 + 全局生成参数**: `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`。 - `generate_cover`:②「开始生成」时是否调用图片模型生成新封面;默认 `false`,避免用户无意产生图片生成成本。该字段只控制 AI 生成阶段,不等同于 ③ 的 `allow_cover_update`。
- `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。 - `resolution`:当前分辨率,下拉 `512 / 1k / 2k / 4k`。
- `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。 - `resolution_timeouts`:分辨率 → **等待大模型返回超时(秒)** 的映射;用户选分辨率即自动套用,不单独填。
- 模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。 - direct 模式模型本身的定义(url/key/类型/连接超时…)在 `config/ai_models.json`,见 5.1b。
- 密钥不在 `config.json`:每个模型的 `api_key` 存于 `config/ai_models.json`,本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。 - 密钥不在 `config.json`:direct 模式每个模型的 `api_key` 存于 `config/ai_models.json`;cmhub 模式 API Key 存于 `config/cmhub.json`。两者均本地明文保存、保存/变更时弹窗提示、UI 打码、gitignore、不入日志/导出。
`shopee_update` 段放**真实更新前的安全开关**: `shopee_update` 段放**真实更新前的安全开关**:
@@ -147,7 +158,7 @@ imported → collected → generated → applied
### 5.1b AI 模型清单 `config/ai_models.json` ### 5.1b AI 模型清单 `config/ai_models.json`
模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。 模型定义清单("有哪些模型"),与 `config.json` 的 `ai` 段("选了哪个 + 全局参数")职责分开。该文件只用于 `backend=direct`;`backend=cmhub` 时生文/生图使用 cmhub 别名,不读取此文件。
```json ```json
{ {
@@ -177,6 +188,16 @@ 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`
```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` ### 5.2 SQLite `cmshopee.db`
```sql ```sql
@@ -374,8 +395,8 @@ images/<batch_id>/<slug>/<task_id>_<item_id>_new.<ext> # AI 生成的新封
单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成: 单个「开始生成」按钮,标题必生成,封面按本轮成本开关可选生成:
1. **并发生成标题**:线程池大小 = `title_concurrency`,用 `default_text_model` 调 `gen_title(标题提示词, old_title)` → new_title。 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`,用 `default_image_model` 调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images/<batch_id>/<slug>/<task_id>_<item_id>_new.jpg`。 2. **若②勾选「生成封面图片(成本较高)」**:接着并发生成图片,线程池大小 = `image_concurrency`,调 `gen_cover(封面提示词, old_cover_path, resolution, jpg_quality)` → 新图存 `images/<batch_id>/<slug>/<task_id>_<item_id>_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)。 - 连接超时取该模型 `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()`、不创建本地新封面文件。
@@ -462,7 +483,8 @@ cmshopee/
├── 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) ├── 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) ├── cmshopee.db # SQLite(账号/任务/结果,gitignore)
├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore) ├── chrome_user_data_dir/ # 各账号 Chrome 配置(含登录态,gitignore)
├── images/ # 旧封面/新封面本地图片(gitignore) ├── images/ # 旧封面/新封面本地图片(gitignore)
@@ -472,7 +494,7 @@ cmshopee/
# 逻辑待并入 app/editor.py 后清理;见 prototypes/README.md # 逻辑待并入 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 副本属于业务数据,不提交。
## 十、架构纪律 ## 十、架构纪律
+1 -1
View File
@@ -124,7 +124,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | 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-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 | | 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 |
+26 -9
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`、`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 负责提示,不静默吞错。 - 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。
## appconfig 模块(`app/appconfig.py`,已建) ## 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/ ai_config(config=None) -> dict # default_text_model/default_image_model/
# title_concurrency/image_concurrency/retry/jpg_quality/ # title_concurrency/image_concurrency/retry/jpg_quality/
# resolution/resolution_timeouts # 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](返回超时,随分辨率) 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 # 用已知明文值替换自由文本中的秘密 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 由 ⑤ 设置复用): AI 模型清单(`config/ai_models.json`,含本地明文密钥,已建;UI 由 ⑤ 设置复用):
```python ```python
@@ -271,14 +285,15 @@ 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) -> str 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
# 文本生成:读取 default_text_model,chat JSON 请求;提示词 + 旧标题 → 新标题 # 文本生成:按 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 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):读取 default_image_model;chat 多模态 JSON 或 images_edits multipart; # 图像生成(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 已存路径继续按原路径读取
generate_batch(tasks, prompts, ai_cfg=None, on_progress=None, should_stop=None) -> dict 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 # 编排:先以 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() 为真则取消未开始项 # 标题-only 模式标题成功即 db.set_generated(task_id, new_title, None);标题+封面模式图片成功后写 new_cover_path;should_stop() 为真则取消未开始项
# on_progress({"total","title_done","cover_done","failed","cancelled","ok"}) 回调刷新进度 # 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。 - `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)**。 - 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 本地明文存储,但不入日志、不导出。 - 并发数/重试/分辨率/jpg 质量来自 `appconfig.ai_config()`;Key 本地明文存储,但不入日志、不导出。cmhub 响应的 `points_cost/points_balance/call_id` 不改变返回值,通过 `on_event` metadata 事件上报,GUI 余额/计费展示留给 T-528。
- 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。 - 标题快、图片慢:分两段、各用各自并发数;失败按 `retry` 重试,仍失败记 error 不阻塞其余。
- 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。 - 调用有成本与失败可能:超时、限流、内容安全拒绝都要返回明确错误。
- 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。 - 生成结果**直接进入 ③ 更新候选**;③ 点击「开始更新」后弹窗批量确认,确认后提交线上。本地留档 + 回写 Excel 供追溯。
+15 -15
View File
File diff suppressed because one or more lines are too long
+20 -1
View File
@@ -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 时给出警告。 - 打包安全:`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。 - 文档:新增 `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 不打包本地数据、构建脚本排除列表。 - 测试:新增 `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+ 环境后重新打包。 - 验证:`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 网关面板。
+319
View File
@@ -2,6 +2,7 @@ import base64
import io import io
import json import json
import os import os
import socket
import sys import sys
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
@@ -28,6 +29,22 @@ class _Response:
return json.dumps(self.payload).encode("utf-8") 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): class AITests(TempDirMixin, unittest.TestCase):
def _write_models(self, path, text=None, image=None): def _write_models(self, path, text=None, image=None):
text = text or { text = text or {
@@ -65,6 +82,21 @@ class AITests(TempDirMixin, unittest.TestCase):
cfg["ai"]["jpg_quality"] = 80 cfg["ai"]["jpg_quality"] = 80
return cfg 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): def _collected_tasks(self, temp_dir, cfg, titles=None):
titles = titles or ["旧标题A", "旧标题B"] titles = titles or ["旧标题A", "旧标题B"]
db.init_db(cfg["db_path"]) db.init_db(cfg["db_path"])
@@ -240,6 +272,293 @@ class AITests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_generate_batch_persists_titles_and_covers_per_task(self):
with self.make_temp_dir() as temp_dir: with self.make_temp_dir() as temp_dir:
cfg = self._config() cfg = self._config()
+30
View File
@@ -1,3 +1,4 @@
import json
import os import os
import sys import sys
import unittest import unittest
@@ -30,6 +31,35 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
self.assert_removed(temp_dir) 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): def test_config_rejects_sensitive_fields(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")