feat: complete T-530 cmhub URL normalization
This commit is contained in:
@@ -771,7 +771,7 @@ def _cmhub_error_from_response(data, response, api_key):
|
||||
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))
|
||||
code = _normalize_cmhub_error_code(error.get("code") or _cmhub_code_for_status(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]
|
||||
@@ -786,12 +786,20 @@ def _cmhub_error_from_response(data, response, api_key):
|
||||
)
|
||||
|
||||
|
||||
def _normalize_cmhub_error_code(code, status=None):
|
||||
normalized = str(code or "unknown").strip().lower().replace("-", "_")
|
||||
if status == 404 or normalized in {"notfound", "not_found"}:
|
||||
return "not_found"
|
||||
return normalized or "unknown"
|
||||
|
||||
|
||||
def _cmhub_code_for_status(status):
|
||||
return {
|
||||
400: "bad_request",
|
||||
401: "unauthorized",
|
||||
402: "insufficient_points",
|
||||
403: "account_disabled",
|
||||
404: "not_found",
|
||||
429: "rate_limited",
|
||||
502: "upstream_error",
|
||||
}.get(status, "unknown")
|
||||
@@ -812,8 +820,11 @@ def _cmhub_user_message(code, message):
|
||||
"content_blocked": "cmhub 内容安全策略拒绝本次生成",
|
||||
"upstream_error": "cmhub 上游生成失败,请稍后重试",
|
||||
"rate_limited": "cmhub 请求过于频繁,请稍后重试",
|
||||
"not_found": "cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models",
|
||||
}
|
||||
default = defaults.get(str(code))
|
||||
if str(code) == "not_found":
|
||||
return default
|
||||
if default and message and str(message) not in default:
|
||||
return "%s:%s" % (default, message)
|
||||
return default or str(message or code)
|
||||
|
||||
+33
-2
@@ -9,6 +9,7 @@ import copy
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
|
||||
@@ -129,6 +130,17 @@ def _deep_merge(defaults, loaded):
|
||||
return merged
|
||||
|
||||
|
||||
def _normalize_config_values(config):
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
ai = config.get("ai")
|
||||
if isinstance(ai, dict):
|
||||
cmhub = ai.get("cmhub")
|
||||
if isinstance(cmhub, dict):
|
||||
cmhub["base_url"] = normalize_cmhub_base_url(cmhub.get("base_url", ""))
|
||||
return config
|
||||
|
||||
|
||||
def _assert_no_secrets(config):
|
||||
def visit(value, path):
|
||||
if isinstance(value, dict):
|
||||
@@ -247,6 +259,7 @@ def save_config(config, path=CONFIG_PATH) -> dict:
|
||||
"""Persist config to JSON and return the normalized config."""
|
||||
|
||||
normalized = _deep_merge(DEFAULT_CONFIG, config)
|
||||
_normalize_config_values(normalized)
|
||||
_assert_no_secrets(normalized)
|
||||
directory = os.path.dirname(os.path.abspath(path))
|
||||
if directory:
|
||||
@@ -268,6 +281,7 @@ def load_config(path=CONFIG_PATH) -> dict:
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ConfigError(f"配置文件不是有效 JSON: {path}") from exc
|
||||
normalized = _deep_merge(DEFAULT_CONFIG, loaded)
|
||||
_normalize_config_values(normalized)
|
||||
_assert_no_secrets(normalized)
|
||||
return normalized
|
||||
|
||||
@@ -334,7 +348,7 @@ def cmhub_config(config=None) -> dict:
|
||||
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["base_url"] = normalize_cmhub_base_url(merged.get("base_url", ""))
|
||||
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)
|
||||
@@ -344,8 +358,25 @@ def cmhub_config(config=None) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
def normalize_cmhub_base_url(base_url) -> str:
|
||||
"""Return the cmhub gateway root URL without path/query/fragment."""
|
||||
|
||||
text = str(base_url or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
parts = urllib.parse.urlsplit(text)
|
||||
if parts.scheme and parts.netloc:
|
||||
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
if parts.netloc:
|
||||
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
||||
without_query = text.split("?", 1)[0].split("#", 1)[0].strip().strip("/")
|
||||
if "://" not in without_query:
|
||||
return without_query.split("/", 1)[0]
|
||||
return text.rstrip("/")
|
||||
|
||||
|
||||
def cmhub_request_url(base_url, endpoint) -> str:
|
||||
base = str(base_url or "").strip().rstrip("/")
|
||||
base = normalize_cmhub_base_url(base_url)
|
||||
path = "/" + str(endpoint or "").strip().lstrip("/")
|
||||
if not base:
|
||||
return path
|
||||
|
||||
@@ -71,7 +71,7 @@ class SettingsTab(QWidget):
|
||||
self.backend_combo.setVisible(False)
|
||||
self.cmhub_base_url_edit = QLineEdit()
|
||||
self.cmhub_base_url_edit.setObjectName("cmhubBaseUrlEdit")
|
||||
self.cmhub_base_url_edit.setPlaceholderText("https://<cmhub>")
|
||||
self.cmhub_base_url_edit.setPlaceholderText("https://host(不要带 /api 或 /api/v1)")
|
||||
self.cmhub_api_key_edit = QLineEdit()
|
||||
self.cmhub_api_key_edit.setObjectName("cmhubApiKeyEdit")
|
||||
self.cmhub_api_key_edit.setEchoMode(QLineEdit.Password)
|
||||
@@ -93,6 +93,9 @@ class SettingsTab(QWidget):
|
||||
self.cmhub_result_label = QLabel("")
|
||||
self.cmhub_result_label.setObjectName("cmhubResultLabel")
|
||||
self.cmhub_result_label.setWordWrap(True)
|
||||
self.cmhub_base_url_hint_label = QLabel("Base URL 只填网关根,如 https://host;不要带 /api 或 /api/v1。")
|
||||
self.cmhub_base_url_hint_label.setObjectName("cmhubBaseUrlHintLabel")
|
||||
self.cmhub_base_url_hint_label.setWordWrap(True)
|
||||
self.cmhub_key_hint_label = QLabel("API Key 仅在 cmhub 网页端创建时显示一次;复制到此处后会本地明文保存并打码显示。")
|
||||
self.cmhub_key_hint_label.setObjectName("cmhubKeyHintLabel")
|
||||
self.cmhub_key_hint_label.setWordWrap(True)
|
||||
@@ -302,6 +305,7 @@ class SettingsTab(QWidget):
|
||||
cmhub_panel_layout.setContentsMargins(0, 0, 0, 0)
|
||||
cmhub_panel_layout.setSpacing(8)
|
||||
cmhub_panel_layout.addLayout(cmhub_form)
|
||||
cmhub_panel_layout.addWidget(self.cmhub_base_url_hint_label)
|
||||
cmhub_panel_layout.addWidget(self.cmhub_key_hint_label)
|
||||
cmhub_panel_layout.addLayout(cmhub_action_layout)
|
||||
cmhub_panel_layout.addWidget(self.cmhub_result_label)
|
||||
@@ -673,7 +677,7 @@ class SettingsTab(QWidget):
|
||||
def _cmhub_settings_values(self, backend):
|
||||
current = appconfig.cmhub_config(self.config)
|
||||
values = {
|
||||
"base_url": self.cmhub_base_url_edit.text().strip(),
|
||||
"base_url": appconfig.normalize_cmhub_base_url(self.cmhub_base_url_edit.text()),
|
||||
"title_alias": self.cmhub_title_alias_combo.currentData() or "",
|
||||
"image_alias": self.cmhub_image_alias_combo.currentData() or "",
|
||||
"connect_timeout": self.cmhub_connect_timeout_spin.value(),
|
||||
@@ -700,7 +704,7 @@ class SettingsTab(QWidget):
|
||||
ai_cfg = appconfig.ai_config(self.config)
|
||||
self._set_combo_by_data(self.backend_combo, "cmhub")
|
||||
cmhub_cfg = appconfig.cmhub_config(self.config)
|
||||
self.cmhub_base_url_edit.setText(cmhub_cfg.get("base_url", ""))
|
||||
self.cmhub_base_url_edit.setText(appconfig.normalize_cmhub_base_url(cmhub_cfg.get("base_url", "")))
|
||||
self._loaded_cmhub_api_key = appconfig.get_cmhub_api_key(path=self.cmhub_config_path)
|
||||
self.cmhub_api_key_edit.setText(self._loaded_cmhub_api_key)
|
||||
self.cmhub_connect_timeout_spin.setValue(
|
||||
@@ -937,7 +941,9 @@ class SettingsTab(QWidget):
|
||||
if self.cmhub_thread is not None:
|
||||
self._set_status("cmhub 检测正在进行...")
|
||||
return
|
||||
base_url = self.cmhub_base_url_edit.text().strip()
|
||||
base_url = appconfig.normalize_cmhub_base_url(self.cmhub_base_url_edit.text())
|
||||
if base_url != self.cmhub_base_url_edit.text().strip():
|
||||
self.cmhub_base_url_edit.setText(base_url)
|
||||
api_key = self.cmhub_api_key_edit.text()
|
||||
missing = []
|
||||
if not base_url:
|
||||
|
||||
@@ -135,7 +135,7 @@ imported → collected → generated → applied
|
||||
`ai` 段只放**选择 + 全局生成参数**:
|
||||
|
||||
- `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,⑤设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。
|
||||
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,`title_alias` / `image_alias` 为 `GET /api/v1/models` 发现的能力别名,`connect_timeout` 为连接超时;API Key 不在此处保存。
|
||||
- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`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`。
|
||||
@@ -196,7 +196,7 @@ imported → collected → generated → applied
|
||||
|
||||
- 文件必须 gitignore,不提交;UI 展示打码。
|
||||
- `appconfig.load_cmhub_config()` 缺文件时返回空 Key;普通产品默认 backend 仍为 cmhub,但未配置 Key/Base URL/别名时生成阶段会给出清晰配置错误,不静默回退 direct。
|
||||
- `backend=cmhub` 但 Base URL、API Key 或别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示去⑤设置配置,不静默回退 direct。
|
||||
- `backend=cmhub` 但 Base URL、API Key 或别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示去⑤设置配置,不静默回退 direct;cmhub HTTP 404 映射为 `CMHubError(code="not_found")`,提示检查 Base URL 或实例是否已部署 `/api/v1/models`。
|
||||
|
||||
### 5.2 SQLite `cmshopee.db`
|
||||
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@
|
||||
| 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 流程 | DONE |
|
||||
| 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 流程 | DONE |
|
||||
| T-529 | 默认 cmhub 网关并隐藏 AI 后端选择 | T-528, T-527 | 产品收口:后续普通用户只使用 cmhub 网关,不再在⑤设置页暴露「AI 后端」label 与 direct/cmhub 下拉框。方案:`DEFAULT_CONFIG.ai.backend` 改为 `cmhub`;⑤设置页默认直接展示 cmhub 网关配置(Base URL、API Key、生文/生图别名、刷新别名、测试连接/查余额),隐藏 direct 模型选择、模型详情、标题/图片模型角色下拉和「AI 后端」下拉;保存设置固定写 `ai.backend=cmhub`,允许先保存不完整 cmhub 配置,②生成时仍由 `app/ai.py` 对缺 Base URL/Key/别名给出「请去⑤配置 cmhub」错误。direct 代码、`config/ai_models.json` helper 与 direct 单测保留为内部兼容/手工回滚路径,但普通 UI 不提供切换入口。同步 `appconfig`、⑤设置页、②余额默认显示和 GUI/appconfig 单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
|
||||
| T-530 | cmhub Base URL 规整 + 404 明确提示 | T-526, T-527 | 现象:手动填入 Base URL 后点「刷新别名」提示 notfound(HTTP 404)。核对确认 cmshopee 请求已达标(`GET /api/v1/models` + Bearer,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由,见 `docs/troubleshooting.md`「cmhub 刷新别名 notfound」与 `docs/cmhub-integration-design.md` v3.4);404 根因是 Base URL 带多余 `/api(/v1)` 路径导致双拼、或所连实例未部署 `/api/v1/models`。方案:保存/请求前规整 Base URL——去掉结尾的 `/`、`/api`、`/api/v1` 等多余路径段,只保留 scheme+host(+port)(`appconfig.cmhub_request_url()` 或保存时统一处理),并在⑤输入框旁给出「只填网关根,如 https://host」提示;`_cmhub_code_for_status` 给 404 一个明确码/中文提示(如「cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models」),不再透传生涩英文原文。补 `test_appconfig`(Base URL 规整:带 `/api/v1`、带尾斜杠、带路径均归一到根)与 `test_ai`(404 → 明确提示)单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | TODO |
|
||||
| T-530 | cmhub Base URL 规整 + 404 明确提示 | T-526, T-527 | 现象:手动填入 Base URL 后点「刷新别名」提示 notfound(HTTP 404)。核对确认 cmshopee 请求已达标(`GET /api/v1/models` + Bearer,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由,见 `docs/troubleshooting.md`「cmhub 刷新别名 notfound」与 `docs/cmhub-integration-design.md` v3.4);404 根因是 Base URL 带多余 `/api(/v1)` 路径导致双拼、或所连实例未部署 `/api/v1/models`。方案:保存/请求前规整 Base URL——去掉结尾的 `/`、`/api`、`/api/v1` 等多余路径段,只保留 scheme+host(+port)(`appconfig.cmhub_request_url()` 或保存时统一处理),并在⑤输入框旁给出「只填网关根,如 https://host」提示;`_cmhub_code_for_status` 给 404 一个明确码/中文提示(如「cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models」),不再透传生涩英文原文。补 `test_appconfig`(Base URL 规整:带 `/api/v1`、带尾斜杠、带路径均归一到根)与 `test_ai`(404 → 明确提示)单测;不改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程 | DONE |
|
||||
|
||||
## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`)
|
||||
|
||||
|
||||
+3
-2
@@ -31,7 +31,8 @@ ai_config(config=None) -> dict # default_text_model/default_image
|
||||
# resolution/resolution_timeouts
|
||||
ai_backend(config=None) -> str # 默认 cmhub;direct 仅内部兼容/手工回滚
|
||||
cmhub_config(config=None) -> dict # base_url/title_alias/image_alias/connect_timeout
|
||||
cmhub_request_url(base_url, endpoint) -> str
|
||||
normalize_cmhub_base_url(base_url) -> str # 规整为 cmhub 网关根:scheme+host(+port)
|
||||
cmhub_request_url(base_url, endpoint) -> str # 先规整 base_url,再拼 /api/v1/...
|
||||
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
||||
```
|
||||
|
||||
@@ -305,7 +306,7 @@ fetch_cmhub_models(base_url, api_key, connect_timeout=10, read_timeout=30) -> li
|
||||
|
||||
- `backend=direct`:内部兼容/手工回滚路径;标题用 `default_text_model`、封面用 `default_image_model`(`appconfig.get_model` 取定义,含 url/key/api_type)。
|
||||
- `backend=cmhub`:普通产品默认路径;标题调用 `POST /api/v1/generate/title`,封面调用 `POST /api/v1/generate/image`,模型字段使用 `ai.cmhub.title_alias/image_alias`,Key 来自 `config/cmhub.json`。
|
||||
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用。
|
||||
- `fetch_cmhub_models()` 调 `GET /api/v1/models` 返回别名清单,供⑤设置页动态下拉使用;Base URL 会先规整为网关根,HTTP 404 映射为 `not_found` 并提示检查 Base URL 或实例是否部署 `/api/v1/models`。
|
||||
- `api_type=chat/auto` 走 OpenAI-compatible chat JSON;`api_type=images_edits` 走 multipart form。
|
||||
- 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。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
> **v3.1 修订(2026-07-04,实现前澄清)**:当时为保护既有直连流程,要求全新配置默认 backend 为 `direct`(cmhub 显式 opt-in),且 `backend=cmhub` 但未配置时须抛清晰"去⑤配置"错误而非崩溃(见 §4.1);② 明确"返回值不变"≠"回调不变"——计费元数据须给 `gen_title`/`gen_cover` **新增可选事件回调参数**承载,是向后兼容加参(见 §4.2)。默认策略已被 v3.3/T-529 覆盖为普通产品默认 cmhub。
|
||||
> **v3.2 修订(2026-07-04,同步对接文档新增)**:cmhub 新增第 4 个接口 `GET /api/v1/models`(别名自助发现,见 §4.6),⑤设置别名由手填改为**动态下拉**(按 `operation_type` 分生文/生图、过滤 `unpriced`、展示单价、`requires_image` 提示),关闭原待确认 #1;仅 Base URL 仍待部署方提供。
|
||||
> **v3.3 修订(2026-07-04,产品收口)**:普通用户后续默认使用 `cmhub` 网关;⑤设置页去掉「AI 后端」label 和 direct/cmhub 下拉,直接展示 cmhub 网关配置。`direct` 代码和 `config/ai_models.json` 保留为内部兼容/手工回滚路径,但普通 UI 不提供切换入口;保存设置固定写 `ai.backend=cmhub`,允许先保存不完整 cmhub 配置,②生成时再提示去⑤补齐 Base URL/API Key/别名。已同步 T-529。
|
||||
> **v3.4 核对(2026-07-06,刷新别名 notfound)**:核对对接文档 §4.4 与 cmhub `ModelsView` 路由,确认 cmshopee `GET /api/v1/models` + Bearer 请求**已达标**;「notfound」为 404,根因是 Base URL 带多余 `/api(/v1)` 路径(双拼)或所连实例未部署 `/api/v1/models`(见 `docs/troubleshooting.md`)。健壮性改进(Base URL 规整 + 404 明确提示)落 **T-530**。
|
||||
> **v3.4 核对(2026-07-06,刷新别名 notfound)**:核对对接文档 §4.4 与 cmhub `ModelsView` 路由,确认 cmshopee `GET /api/v1/models` + Bearer 请求**已达标**;「notfound」为 404,根因是 Base URL 带多余 `/api(/v1)` 路径(双拼)或所连实例未部署 `/api/v1/models`(见 `docs/troubleshooting.md`)。**T-530 已落地**:保存/请求前规整 Base URL 到网关根,HTTP 404 映射为 `not_found` 并给出中文排障提示。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
- **cmhub API Key 不进 `config.json`**(避免与其它设置混放、避免误提交)。固定存到 `config/cmhub.json`,文件 schema 第一版为 `{ "api_key": "sk_cmhub_xxx" }`;新增 `CMHUB_CONFIG_PATH`、`load_cmhub_config()`、`save_cmhub_config()`、`get_cmhub_api_key(masked=False)` 等 helper;`config/cmhub.json` 必须加入 `.gitignore`。沿用现有"本地明文保存但 gitignore + UI 打码 + 日志脱敏"纪律(T-503)。
|
||||
- **`ai_models.json` 去留**:`direct` 模式继续用;`cmhub` 模式不读它。文件保留但标记 legacy。
|
||||
- **默认 backend 与配置不完整处理(T-529 后)**:`DEFAULT_CONFIG` / `default_config()` 造全新配置时写 `backend=cmhub`;⑤设置页隐藏 direct/cmhub 下拉并固定保存 `backend=cmhub`。允许先保存不完整 cmhub 配置,便于用户先保存其它路径/安全设置;②生成真正调用时如果 `base_url`/Key/别名缺失,必须抛**清晰的"请去⑤配置 cmhub"错误**(`CMHubError`/`AIError`),不得崩溃或静默回退 direct。显式手工配置 `backend=direct` 仍作为内部回滚路径保留,但普通 UI 不提供入口。
|
||||
- **Base URL 必须是网关根**:只填 `https://<cmhub-域名>`,**不带** `/api`、`/api/v1` 或任何路径。`cmhub_request_url()` 会自行拼 `/api/v1/...`;若 Base URL 已含 `/api/v1`,会双拼成 `.../api/v1/api/v1/...` → 404(正是「刷新别名 notfound」现象,见 `docs/troubleshooting.md`)。**当前请求本身已达标**(`GET /api/v1/models` + `Authorization: Bearer <key>`,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由);404 的根因是 Base URL 带多余路径或所连实例未部署 `/api/v1/models`。**T-530** 将在保存/请求前规整 Base URL 去掉多余 `/api(/v1)` 尾部,并给 404 明确中文提示。
|
||||
- **Base URL 必须是网关根**:只填 `https://<cmhub-域名>`,**不带** `/api`、`/api/v1` 或任何路径。`cmhub_request_url()` 会自行拼 `/api/v1/...`;若 Base URL 已含 `/api/v1`,会双拼成 `.../api/v1/api/v1/...` → 404(正是「刷新别名 notfound」现象,见 `docs/troubleshooting.md`)。**当前请求本身已达标**(`GET /api/v1/models` + `Authorization: Bearer <key>`,对齐对接文档 §4.4 与 cmhub `ModelsView` 路由);404 的根因是 Base URL 带多余路径或所连实例未部署 `/api/v1/models`。**T-530 已在保存/请求前规整 Base URL**,会去掉多余路径、查询串和片段,只保留 scheme+host(+port);404 会给出明确中文提示。
|
||||
|
||||
### 4.2 `gen_title` 改造(cmhub 分支)
|
||||
|
||||
@@ -163,7 +163,7 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
||||
|
||||
## 8. 迁移与回退
|
||||
|
||||
- 既有配置缺 `backend` 时迁移为 `direct`,避免升级后未配置 cmhub 就让②生成失败;用户在⑤设置选择 `cmhub` 并配好 `base_url + Key + 两个别名`后启用网关。
|
||||
- T-529 后普通配置默认 `backend=cmhub`;显式手工配置 `backend=direct` 仍可作为内部回滚路径。⑤普通 UI 不再提供后端切换,用户只需配好 `base_url + Key + 两个别名`。
|
||||
- 回退:`backend=direct` 立即切回本地直连,`ai_models.json` 仍有效。
|
||||
- 灰度:可先在 ② 单条生成上验证 cmhub 联通与计费,再放批量。
|
||||
|
||||
@@ -183,6 +183,7 @@ cmhub 返回结构化 `{error:{code}}`。映射层**按 `code` 优先分支**(
|
||||
- **第二步**:⑤设置 UI cmhub 面板 + 测试连接/查余额。
|
||||
- **第三步**:② 计费错误提示(`insufficient_points` 引导充值)+ 可选余额展示。
|
||||
- **第四步(T-529)**:产品默认 cmhub,⑤去掉 AI 后端选择,保存固定 `backend=cmhub`。
|
||||
- **第五步(T-530)**:Base URL 规整到网关根,404 给出明确中文提示。
|
||||
- 文档随每步同步。
|
||||
|
||||
> 当前已在 `docs/06-tasks.md` 落成 T-526~T-528;下一步按 T-526 开始代码实现,仍遵守 `docs/05-coding-rules.md` 验证清单。
|
||||
> 当前已在 `docs/06-tasks.md` 落成 T-526~T-530;下一步回到 T-525 工程基础设施,仍遵守 `docs/05-coding-rules.md` 验证清单。
|
||||
|
||||
+10
-9
File diff suppressed because one or more lines are too long
+1
-1
@@ -143,7 +143,7 @@
|
||||
## ⑤ 设置
|
||||
|
||||
- 设置页整体布局:内容区居中,左右留白已从 T-506 初始实现缩短到约 40%;实现上用最大内容宽度 + 自适应 margin,而不是写死窗口像素。所有设置组默认响应式 3 列表单:短字段占 1 格,URL/API Key/路径等长字段跨 2 格或 3 格;窄窗口自动降为 2 列/1 列。点击「保存设置」成功后,状态栏显示“设置已保存”,并弹出轻量提示框。
|
||||
- cmhub 网关配置(T-529 后普通用户唯一 AI 入口):⑤设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额。
|
||||
- cmhub 网关配置(T-529 后普通用户唯一 AI 入口):⑤设置页不再显示「AI 后端」label 或 direct/cmhub 下拉,直接展示 cmhub 网关 Base URL、API Key、生文别名、生图别名、连接超时、刷新别名、测试连接/查余额;Base URL 输入框旁提示“只填网关根,如 https://host”,保存/刷新前会规整掉 `/api`、`/api/v1` 或其它路径。
|
||||
- API Key 存在 `config/cmhub.json`,本地明文保存;保存/变更时提示;UI 使用密码框打码显示,不进入日志/导出。
|
||||
- 生文/生图别名来自 `GET /api/v1/models` 动态下拉,过滤未定价别名并展示单价/需原图提示;网关临时不可达时保留已存别名。
|
||||
- 保存设置固定写 `ai.backend=cmhub`。允许先保存不完整 cmhub 配置,②真正生成时如果缺 Base URL/API Key/别名,会提示去⑤补配置,不静默回退 direct。
|
||||
|
||||
@@ -144,7 +144,7 @@ python -c "import json; from urllib.parse import urlsplit,urlunsplit; p='config/
|
||||
|
||||
先明确:**cmshopee 的请求本身是达标的**——`GET {Base URL}/api/v1/models`,头 `Authorization: Bearer <key>`,与对接文档《cmhub-接口对接文档-桌面端生文生图》§4.4 和 cmhub 服务端路由 `path("v1/models", ModelsView)` 完全一致。404 表示服务端已响应但该路径不存在,原因在请求之外,通常是两类:
|
||||
|
||||
1. **Base URL 填了多余路径(手动填入最常见)**:Base URL 只能填**网关根**,例如 `https://cmhub.example.com`。当前 `appconfig.cmhub_request_url()` 是 `base_url.rstrip("/") + "/api/v1/models"`,**不会**替你去掉结尾的 `/api` 或 `/api/v1`;若你填成 `https://host/api/v1`,会拼成 `.../api/v1/api/v1/models` → 404。
|
||||
1. **Base URL 填了多余路径(手动填入最常见)**:Base URL 只能填**网关根**,例如 `https://cmhub.example.com`。T-530 后程序会在保存和请求前规整 Base URL,填成 `https://host/api/v1` 也会按 `https://host` 拼出 `https://host/api/v1/models`;仍建议只填网关根,便于人工排查。
|
||||
2. **连的那台 cmhub 实例没有该路由**:`ModelsView` 在 cmhub 仓库代码里有,但**运行中的实例**若未重新部署/重启,`/api/v1/models`(较新接口)会 404,而 `/api/v1/balance`(较老接口)可能正常。
|
||||
|
||||
### 隔离方法(一条 curl 定位)
|
||||
@@ -160,9 +160,9 @@ curl -i "https://<你的BaseURL>/api/v1/balance" -H "Authorization: Bearer sk_cm
|
||||
|
||||
> 不要把 API Key 贴进日志、文档或聊天;curl 里的 `sk_cmhub_xxx` 是占位。
|
||||
|
||||
### 待改进(T-530)
|
||||
### 当前处理(T-530 后)
|
||||
|
||||
`cmhub_request_url()` 目前不规整带 `/api`/`/api/v1` 的 Base URL,且 404 落到 `unknown` 后透传生涩英文原文。跟进任务 T-530 将:保存/请求前规整 Base URL 去掉多余 `/api(/v1)` 尾部;给 404 一个明确中文提示(“cmhub 接口不存在,检查 Base URL 或该实例是否已部署 /api/v1/models”)。
|
||||
T-530 已实现:保存和请求前都会把 Base URL 规整为网关根,去掉多余路径、查询串和片段;HTTP 404 会映射为 `not_found`,显示“cmhub 接口不存在,请检查 Base URL 或该实例是否已部署 /api/v1/models”,不再透传 `notfound` 这类生涩原文。
|
||||
|
||||
## AI生成:标题成功但图片生成失败,且看不到原因
|
||||
|
||||
|
||||
+9
-1
@@ -1142,4 +1142,12 @@
|
||||
- 代码:`DEFAULT_CONFIG.ai.backend` 改为 `cmhub`,`ai_backend()` 缺省跟随默认 backend;⑤设置页隐藏「AI 后端」下拉、direct 模型选择、模型详情和标题/图片角色下拉,普通 UI 直接展示 cmhub 网关配置;保存设置固定写 `ai.backend=cmhub`,允许先保存不完整 cmhub 配置,真正生成时仍由 `app/ai.py` 提示补齐 Base URL/API Key/别名。
|
||||
- 兼容:direct 代码、`config/ai_models.json`、AI 模型测试 worker 和 direct 单测保留为内部兼容/手工回滚路径;未改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。
|
||||
- 测试:更新 appconfig/GUI/AI 单测,direct 分支测试显式设置 `backend=direct`;②AI生成页默认显示 cmhub 余额标签。
|
||||
- 验证:`python -m py_compile app\appconfig.py app\gui\tabs\settings.py app\gui\tabs\generate.py tests\test_appconfig.py tests\test_gui.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_gui.py` 通过(80 tests);`python -m unittest discover -s tests -p test_ai.py` 通过(17 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(187 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。全量 GUI 测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||
- 验证:`python -m py_compile app\appconfig.py app\gui\tabs\settings.py app\gui\tabs\generate.py tests\test_appconfig.py tests\test_gui.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_gui.py` 通过(80 tests);`python -m unittest discover -s tests -p test_ai.py` 通过(17 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(187 tests);`git diff --check` 通过,仅有本机 LF/CRLF 提示。全量 GUI 测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||
## 【2026-07-06】T-530 完成 · cmhub Base URL 规整 + 404 明确提示
|
||||
|
||||
- 状态:DONE
|
||||
- 代码:新增 `appconfig.normalize_cmhub_base_url()`,并让 `cmhub_request_url()`、`save_config()`、`load_config()`、`cmhub_config()` 使用规整后的网关根;⑤设置页新增 Base URL 网关根提示,保存/刷新别名前会把输入同步规整;`app/ai.py` 将 HTTP 404 / `notfound` 统一映射为 `CMHubError(code="not_found")`,显示中文排障提示。
|
||||
- 边界:未改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程;direct 兼容路径不变。
|
||||
- 测试:新增 appconfig URL 规整测试、ai 404 `not_found` 测试、GUI Base URL 提示/保存归一断言。
|
||||
- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(189 tests)。全量 GUI 测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||
- 下一步:T-525「引入 ruff(lint + format)+ 可选 pre-commit」。
|
||||
@@ -516,6 +516,28 @@ class AITests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual("https://cmhub.example.com/api/v1/models", calls[0][1])
|
||||
self.assertEqual("title-standard", models[0]["alias"])
|
||||
|
||||
def test_fetch_cmhub_models_404_returns_clear_base_url_error(self):
|
||||
calls = []
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
calls.append((method, url, kwargs))
|
||||
return _RequestsResponse({"detail": "notfound"}, status_code=404)
|
||||
|
||||
with mock.patch("app.ai.requests.request", side_effect=fake_request):
|
||||
with self.assertRaises(ai.CMHubError) as raised:
|
||||
ai.fetch_cmhub_models(
|
||||
"https://cmhub.example.com/api/v1/",
|
||||
"sk-cmhub-secret",
|
||||
)
|
||||
|
||||
self.assertEqual("GET", calls[0][0])
|
||||
self.assertEqual("https://cmhub.example.com/api/v1/models", calls[0][1])
|
||||
self.assertEqual("not_found", raised.exception.code)
|
||||
self.assertEqual(404, raised.exception.status)
|
||||
message = str(raised.exception)
|
||||
self.assertIn("cmhub 接口不存在", message)
|
||||
self.assertIn("/api/v1/models", message)
|
||||
self.assertNotIn("notfound", message)
|
||||
def test_fetch_cmhub_balance_returns_points(self):
|
||||
calls = []
|
||||
|
||||
|
||||
@@ -64,6 +64,34 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_cmhub_base_url_normalizes_to_gateway_root(self):
|
||||
cases = {
|
||||
"https://cmhub.example.com/": "https://cmhub.example.com",
|
||||
"https://cmhub.example.com/api": "https://cmhub.example.com",
|
||||
"https://cmhub.example.com/api/v1/": "https://cmhub.example.com",
|
||||
"https://cmhub.example.com/some/path?x=1": "https://cmhub.example.com",
|
||||
"http://localhost:8000/api/v1": "http://localhost:8000",
|
||||
"localhost:8000/api/v1": "localhost:8000",
|
||||
}
|
||||
for raw, expected in cases.items():
|
||||
with self.subTest(raw=raw):
|
||||
self.assertEqual(expected, appconfig.normalize_cmhub_base_url(raw))
|
||||
self.assertEqual(
|
||||
expected + "/api/v1/models",
|
||||
appconfig.cmhub_request_url(raw, "/api/v1/models"),
|
||||
)
|
||||
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
config_path = os.path.join(temp_dir, "config.json")
|
||||
config = appconfig.default_config()
|
||||
config["ai"]["cmhub"]["base_url"] = "https://cmhub.example.com/api/v1/"
|
||||
|
||||
saved = appconfig.save_config(config, path=config_path)
|
||||
self.assertEqual("https://cmhub.example.com", saved["ai"]["cmhub"]["base_url"])
|
||||
loaded = appconfig.load_config(config_path)
|
||||
self.assertEqual("https://cmhub.example.com", loaded["ai"]["cmhub"]["base_url"])
|
||||
|
||||
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")
|
||||
|
||||
+3
-1
@@ -818,13 +818,14 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertTrue(tab.model_picker_panel.isHidden())
|
||||
self.assertTrue(tab.direct_role_panel.isHidden())
|
||||
self.assertFalse(tab.cmhub_panel.isHidden())
|
||||
self.assertIn("不要带 /api", tab.cmhub_base_url_hint_label.text())
|
||||
self.assertEqual("https://cmhub.old", tab.cmhub_base_url_edit.text())
|
||||
self.assertEqual("sk-old-secret", tab.cmhub_api_key_edit.text())
|
||||
self.assertEqual(QLineEdit.Password, tab.cmhub_api_key_edit.echoMode())
|
||||
self.assertEqual("title-old", tab.cmhub_title_alias_combo.currentData())
|
||||
self.assertEqual("image-old", tab.cmhub_image_alias_combo.currentData())
|
||||
|
||||
tab.cmhub_base_url_edit.setText("https://cmhub.example.com")
|
||||
tab.cmhub_base_url_edit.setText("https://cmhub.example.com/api/v1/")
|
||||
tab.cmhub_api_key_edit.setText("sk-new-secret")
|
||||
tab.cmhub_connect_timeout_spin.setValue(12)
|
||||
tab.cmhub_check_balance_checkbox.setChecked(True)
|
||||
@@ -859,6 +860,7 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
saved = appconfig.load_config(cfg["config_path"])
|
||||
self.assertEqual("cmhub", saved["ai"]["backend"])
|
||||
self.assertEqual("https://cmhub.example.com", saved["ai"]["cmhub"]["base_url"])
|
||||
self.assertEqual("https://cmhub.example.com", tab.cmhub_base_url_edit.text())
|
||||
self.assertEqual("title-standard", saved["ai"]["cmhub"]["title_alias"])
|
||||
self.assertEqual("image-standard", saved["ai"]["cmhub"]["image_alias"])
|
||||
self.assertEqual(12, saved["ai"]["cmhub"]["connect_timeout"])
|
||||
|
||||
Reference in New Issue
Block a user