feat: complete T-528 cmhub billing feedback
This commit is contained in:
@@ -1082,6 +1082,15 @@ def _emit_generation_error(callback, task, phase, step, exc, error):
|
||||
"error": diagnostics.redact_log_text(error),
|
||||
"exception": exc,
|
||||
}
|
||||
code = getattr(exc, "code", None)
|
||||
status = getattr(exc, "status", None)
|
||||
retryable = getattr(exc, "retryable", None)
|
||||
if code is not None:
|
||||
payload["code"] = str(code)
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if retryable is not None:
|
||||
payload["retryable"] = bool(retryable)
|
||||
try:
|
||||
callback(payload)
|
||||
except Exception:
|
||||
|
||||
@@ -102,6 +102,8 @@ class GenerateTab(QWidget):
|
||||
self.cover_progress_bar.setValue(0)
|
||||
self.failed_progress_label = QLabel("失败 0")
|
||||
self.failed_progress_label.setObjectName("generateFailedProgressLabel")
|
||||
self.cmhub_balance_label = QLabel("cmhub余额:未获取")
|
||||
self.cmhub_balance_label.setObjectName("generateCmhubBalanceLabel")
|
||||
|
||||
left_panel = QWidget()
|
||||
left_layout = QVBoxLayout(left_panel)
|
||||
@@ -196,6 +198,7 @@ class GenerateTab(QWidget):
|
||||
cover_progress_layout.addWidget(self.cover_progress_label)
|
||||
cover_progress_layout.addWidget(self.cover_progress_bar, 1)
|
||||
cover_progress_layout.addWidget(self.failed_progress_label)
|
||||
cover_progress_layout.addWidget(self.cmhub_balance_label)
|
||||
progress_layout = QVBoxLayout()
|
||||
progress_layout.addLayout(title_progress_layout)
|
||||
progress_layout.addLayout(cover_progress_layout)
|
||||
@@ -239,6 +242,7 @@ class GenerateTab(QWidget):
|
||||
self.refresh_cover_templates()
|
||||
self.refresh_tasks()
|
||||
self._show_current_run_log_empty()
|
||||
self._update_cmhub_balance_label({})
|
||||
|
||||
def _set_status(self, message):
|
||||
if self.status_callback is not None:
|
||||
@@ -278,7 +282,7 @@ class GenerateTab(QWidget):
|
||||
payload = {
|
||||
key: value
|
||||
for key, value in self.config.items()
|
||||
if key not in {"config_path", "ai_models_path"}
|
||||
if key not in {"config_path", "ai_models_path", "cmhub_config_path"}
|
||||
}
|
||||
payload["ai"] = ai_settings
|
||||
try:
|
||||
@@ -289,7 +293,7 @@ class GenerateTab(QWidget):
|
||||
internal = {
|
||||
key: value
|
||||
for key, value in self.config.items()
|
||||
if key in {"config_path", "ai_models_path"}
|
||||
if key in {"config_path", "ai_models_path", "cmhub_config_path"}
|
||||
}
|
||||
self.config.clear()
|
||||
self.config.update(saved)
|
||||
@@ -489,6 +493,7 @@ class GenerateTab(QWidget):
|
||||
worker.finished.connect(self._on_generate_finished)
|
||||
worker.cancelled.connect(self._on_generate_cancelled)
|
||||
self._reset_generate_run_log(tasks, generate_cover)
|
||||
self._reset_cmhub_balance_label()
|
||||
thread = run_worker(worker, thread_name="GenerateWorker", start=False)
|
||||
thread.finished.connect(lambda: self._forget_generate_thread(thread))
|
||||
self.generate_worker = worker
|
||||
@@ -640,6 +645,7 @@ class GenerateTab(QWidget):
|
||||
|
||||
def _on_generate_progress(self, payload):
|
||||
self._update_generate_progress(payload)
|
||||
self._update_cmhub_balance_label(payload)
|
||||
self._set_status("生成进度:" + self._generate_progress_text(payload))
|
||||
|
||||
def _on_generate_row_updated(self, task_id, fields):
|
||||
@@ -652,6 +658,11 @@ class GenerateTab(QWidget):
|
||||
self._set_generate_running(False)
|
||||
self.refresh_tasks()
|
||||
self._update_generate_progress(payload)
|
||||
self._update_cmhub_balance_label(payload)
|
||||
billing_error = payload.get("billing_error")
|
||||
if billing_error:
|
||||
self._show_billing_error(billing_error)
|
||||
return
|
||||
if payload.get("error"):
|
||||
self._set_status(f"AI 生成失败:{payload.get('error')}")
|
||||
return
|
||||
@@ -661,6 +672,11 @@ class GenerateTab(QWidget):
|
||||
self._set_generate_running(False)
|
||||
self.refresh_tasks()
|
||||
self._update_generate_progress(payload)
|
||||
self._update_cmhub_balance_label(payload)
|
||||
billing_error = payload.get("billing_error")
|
||||
if billing_error:
|
||||
self._show_billing_error(billing_error)
|
||||
return
|
||||
self._set_status("AI 生成已停止:" + self._generate_progress_text(payload))
|
||||
|
||||
def _update_generate_progress(self, payload):
|
||||
@@ -676,6 +692,36 @@ class GenerateTab(QWidget):
|
||||
self._set_progress_bar(self.title_progress_bar, title_done, total)
|
||||
self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total)
|
||||
|
||||
def _reset_cmhub_balance_label(self):
|
||||
if self._is_cmhub_backend():
|
||||
self.cmhub_balance_label.setVisible(True)
|
||||
self.cmhub_balance_label.setText("cmhub余额:生成后刷新")
|
||||
else:
|
||||
self.cmhub_balance_label.setVisible(False)
|
||||
|
||||
def _update_cmhub_balance_label(self, payload):
|
||||
if not self._is_cmhub_backend():
|
||||
self.cmhub_balance_label.setVisible(False)
|
||||
return
|
||||
self.cmhub_balance_label.setVisible(True)
|
||||
balance = payload.get("points_balance") if isinstance(payload, dict) else None
|
||||
if balance is None:
|
||||
if not self.cmhub_balance_label.text():
|
||||
self.cmhub_balance_label.setText("cmhub余额:未获取")
|
||||
return
|
||||
self.cmhub_balance_label.setText(f"cmhub余额:{balance}")
|
||||
|
||||
def _is_cmhub_backend(self):
|
||||
try:
|
||||
return appconfig.ai_config(self.config).get("backend") == "cmhub"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _show_billing_error(self, billing_error):
|
||||
message = billing_error.get("message") or "点数不足,请先充值。"
|
||||
QMessageBox.warning(self, "AI生成", message)
|
||||
self._set_status("AI 生成已中止:" + message)
|
||||
|
||||
def _set_progress_bar(self, bar, done, total):
|
||||
maximum = max(1, int(total or 0))
|
||||
value = min(max(0, int(done or 0)), maximum)
|
||||
|
||||
+85
-6
@@ -24,6 +24,10 @@ class GenerateWorker(BaseWorker):
|
||||
self._account_by_alias = {}
|
||||
self._task_positions = {}
|
||||
self._eligible_total = 0
|
||||
self._last_progress_payload = {}
|
||||
self._cmhub_points_balance = None
|
||||
self._billing_error = None
|
||||
self._billing_stop_requested = False
|
||||
|
||||
def execute(self):
|
||||
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
|
||||
@@ -72,8 +76,8 @@ class GenerateWorker(BaseWorker):
|
||||
"on_error": self._on_generation_error,
|
||||
"generate_cover": generate_cover,
|
||||
},
|
||||
on_progress=self.progress.emit,
|
||||
should_stop=self.should_cancel,
|
||||
on_progress=self._emit_generate_progress,
|
||||
should_stop=self._should_stop_generation,
|
||||
)
|
||||
except Exception as exc:
|
||||
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
|
||||
@@ -100,30 +104,59 @@ class GenerateWorker(BaseWorker):
|
||||
payload={"error": error},
|
||||
exc=exc,
|
||||
)
|
||||
if self._cmhub_points_balance is not None:
|
||||
summary["points_balance"] = self._cmhub_points_balance
|
||||
if self._billing_error is not None:
|
||||
summary["billing_error"] = dict(self._billing_error)
|
||||
summary["ok"] = False
|
||||
summary["cancelled"] = True
|
||||
summary["run_id"] = self._run_id
|
||||
summary["batch_ids"] = batch_ids
|
||||
status = "cancelled" if summary.get("cancelled") else "done"
|
||||
level = "warning" if summary.get("cancelled") or summary.get("error") else "info"
|
||||
status = "failed" if summary.get("billing_error") or summary.get("error") else ("cancelled" if summary.get("cancelled") else "done")
|
||||
level = "error" if summary.get("billing_error") or summary.get("error") else ("warning" if summary.get("cancelled") else "info")
|
||||
self._log_run_event(self._format_generate_completion(summary), level=level)
|
||||
self._finish_run_log(status, summary)
|
||||
return summary
|
||||
|
||||
def _emit_generate_progress(self, payload):
|
||||
progress = dict(payload or {})
|
||||
if self._cmhub_points_balance is not None:
|
||||
progress["points_balance"] = self._cmhub_points_balance
|
||||
if self._billing_error is not None:
|
||||
progress["billing_error"] = dict(self._billing_error)
|
||||
self._last_progress_payload = dict(progress)
|
||||
self.progress.emit(progress)
|
||||
|
||||
def _should_stop_generation(self):
|
||||
return self.should_cancel() or self._billing_stop_requested
|
||||
|
||||
def _emit_row_update(self, task_id, fields):
|
||||
self.row_updated.emit(int(task_id), dict(fields or {}))
|
||||
|
||||
def _on_generation_event(self, payload):
|
||||
task = payload.get("task")
|
||||
self._remember_cmhub_metadata(payload)
|
||||
message = self._format_generation_event(payload)
|
||||
if not message:
|
||||
return
|
||||
self._log_run_event(message, task=task, level=payload.get("level") or "info")
|
||||
|
||||
def _remember_cmhub_metadata(self, payload):
|
||||
metadata = payload.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
if metadata.get("points_balance") is not None:
|
||||
self._cmhub_points_balance = metadata.get("points_balance")
|
||||
self._emit_generate_progress(self._last_progress_payload)
|
||||
|
||||
def _format_generation_event(self, payload):
|
||||
task = payload.get("task")
|
||||
phase = payload.get("phase") or "generate"
|
||||
step = payload.get("step") or "unknown"
|
||||
result = payload.get("result") or "start"
|
||||
detail = self._short_detail(payload.get("detail"))
|
||||
if isinstance(payload.get("metadata"), dict):
|
||||
return self._format_cmhub_billing_event(task, phase, payload.get("metadata"))
|
||||
if phase == "title":
|
||||
if result == "start" and step == "title_submit":
|
||||
return f"[标题] {self._task_progress_label(task)} 开始生成"
|
||||
@@ -154,6 +187,22 @@ class GenerateWorker(BaseWorker):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _format_cmhub_billing_event(self, task, phase, metadata):
|
||||
label = "标题" if phase == "title" else ("图片" if phase == "cover" else "AI")
|
||||
parts = []
|
||||
alias = metadata.get("alias") or metadata.get("model_used")
|
||||
if alias:
|
||||
parts.append(f"别名 {alias}")
|
||||
if metadata.get("points_cost") is not None:
|
||||
parts.append(f"扣点 {metadata.get('points_cost')}")
|
||||
if metadata.get("points_balance") is not None:
|
||||
parts.append(f"余额 {metadata.get('points_balance')}")
|
||||
if metadata.get("call_id"):
|
||||
parts.append(f"call_id={metadata.get('call_id')}")
|
||||
if not parts:
|
||||
return None
|
||||
return f"[计费] {self._task_plain_label(task)} {label}生成:" + ",".join(str(part) for part in parts)
|
||||
|
||||
def _retry_message(self, label, task, payload, detail):
|
||||
attempt = int(payload.get("attempt", 0) or 0)
|
||||
attempts = int(payload.get("attempts", 0) or 0)
|
||||
@@ -192,6 +241,9 @@ class GenerateWorker(BaseWorker):
|
||||
|
||||
def _format_generate_completion(self, summary):
|
||||
progress = self._summary_text(summary)
|
||||
billing_error = summary.get("billing_error") or {}
|
||||
if billing_error:
|
||||
return f"[失败] AI 生成已中止:{billing_error.get('message') or '点数不足,请先充值'},{progress}"
|
||||
if summary.get("cancelled"):
|
||||
return f"[停止] AI 生成已停止:{progress}"
|
||||
if summary.get("error"):
|
||||
@@ -212,14 +264,40 @@ class GenerateWorker(BaseWorker):
|
||||
task = payload.get("task")
|
||||
phase = payload.get("phase") or "generate"
|
||||
step = payload.get("step") or "unknown"
|
||||
exception = payload.get("exception")
|
||||
code = payload.get("code") or getattr(exception, "code", None)
|
||||
status = payload.get("status") or getattr(exception, "status", None)
|
||||
error = diagnostics.redact_log_text(payload.get("error") or "未知错误")
|
||||
if str(code or "") == "insufficient_points":
|
||||
self._billing_stop_requested = True
|
||||
message = "点数不足,请先充值。本轮未开始任务将停止。"
|
||||
self._billing_error = {
|
||||
"code": "insufficient_points",
|
||||
"message": message,
|
||||
"phase": phase,
|
||||
"task_id": getattr(task, "id", None),
|
||||
"item_id": getattr(task, "item_id", None),
|
||||
}
|
||||
if status is not None:
|
||||
self._billing_error["status"] = status
|
||||
self._log_run_event(
|
||||
f"[计费] {self._task_plain_label(task)} 点数不足,请先充值;本轮未开始任务将停止",
|
||||
task=task,
|
||||
level="error",
|
||||
)
|
||||
self._emit_generate_progress(self._last_progress_payload)
|
||||
diagnostic_payload = {"phase": phase, "error": error}
|
||||
if code is not None:
|
||||
diagnostic_payload["code"] = str(code)
|
||||
if status is not None:
|
||||
diagnostic_payload["status"] = status
|
||||
self._write_diagnostic_log(
|
||||
"AI生成任务失败",
|
||||
level="ERROR",
|
||||
step=step,
|
||||
task=task,
|
||||
payload={"phase": phase, "error": error},
|
||||
exc=payload.get("exception"),
|
||||
payload=diagnostic_payload,
|
||||
exc=exception,
|
||||
)
|
||||
|
||||
def _batch_ids(self, tasks):
|
||||
@@ -245,6 +323,7 @@ class GenerateWorker(BaseWorker):
|
||||
"title_concurrency": ai_cfg.get("title_concurrency"),
|
||||
"image_concurrency": ai_cfg.get("image_concurrency"),
|
||||
"generate_cover": ai_cfg.get("generate_cover", False),
|
||||
"backend": ai_cfg.get("backend", "direct"),
|
||||
},
|
||||
path=self.db_path,
|
||||
)
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 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 流程 | 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 流程 | 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 流程 | DONE |
|
||||
|
||||
## Phase 8 · 工程基础设施后续(`docs/engineering-review.md`)
|
||||
|
||||
|
||||
+10
-9
File diff suppressed because one or more lines are too long
+10
@@ -1125,3 +1125,13 @@
|
||||
- 文档:`docs/06-tasks.md` 将 T-527 标为 DONE;同步 `docs/current-state.md`,下一步为 T-528。
|
||||
- 验证:`python -m py_compile tests\test_ai.py tests\test_gui.py` 通过;`python -m unittest discover -s tests -p "test_ai.py"` 通过(17 tests);`python -m unittest discover -s tests -p "test_gui.py"` 通过(77 tests);`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(184 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。全量测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||
- 下一步:T-528 ②计费错误提示 + 余额展示。
|
||||
|
||||
## 【2026-07-04】T-528 完成 · ②计费错误提示 + 余额展示
|
||||
|
||||
- 状态:DONE
|
||||
- 代码:②AI生成页新增 cmhub 余额显示,仅在 `backend=cmhub` 时展示;`GenerateWorker` 读取 cmhub metadata,把 `points_cost`、`points_balance`、`call_id` 以脱敏用户日志写入 `run_logs/run_log_events`,并把最新 `points_balance` 回传给页面。
|
||||
- 点数不足:`app/ai.py` 的生成错误回调会透传 `CMHubError.code/status/retryable`;`GenerateWorker` 通过 `code=insufficient_points` 识别点数不足,设置本轮停止标记,弹「点数不足,请先充值。本轮未开始任务将停止。」并让未开始任务不再继续,不靠中文错误字符串匹配。
|
||||
- 边界:只改②AI生成 UI、worker 事件处理和测试;未改 AI HTTP 协议、DB schema、Excel、Shopee/CDP 流程。
|
||||
- 文档:`docs/06-tasks.md` 将 T-528 标为 DONE;同步 `docs/current-state.md`,下一个可领取任务回到 T-525 ruff。
|
||||
- 测试:`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 提示。全量测试仍有本机 PySide6 字体目录提示,不影响结果。
|
||||
- 下一步:T-525「引入 ruff(lint + format)+ 可选 pre-commit」。
|
||||
|
||||
+213
-1
@@ -12,7 +12,7 @@ sys.path.insert(0, os.path.dirname(__file__))
|
||||
from _helpers import TempDirMixin
|
||||
|
||||
from app import gui
|
||||
from app import accounts, appconfig, db, prompts
|
||||
from app import accounts, ai, appconfig, db, prompts
|
||||
|
||||
if gui.QT_IMPORT_ERROR is not None:
|
||||
raise unittest.SkipTest("PySide6 未安装")
|
||||
@@ -962,6 +962,9 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
self.assertEqual("标题 0/0", tab.title_progress_label.text())
|
||||
self.assertEqual("图片 0/0", tab.cover_progress_label.text())
|
||||
self.assertEqual("失败 0", tab.failed_progress_label.text())
|
||||
self.assertEqual("generateCmhubBalanceLabel", tab.cmhub_balance_label.objectName())
|
||||
self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text())
|
||||
self.assertTrue(tab.cmhub_balance_label.isHidden())
|
||||
self.assertEqual(0, tab.title_progress_bar.value())
|
||||
self.assertEqual(0, tab.cover_progress_bar.value())
|
||||
self.assertEqual(1, tab.title_progress_bar.maximum())
|
||||
@@ -981,6 +984,52 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_shows_cmhub_balance_and_billing_error(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.ai_config(cfg)
|
||||
cfg["ai"]["backend"] = "cmhub"
|
||||
statuses = []
|
||||
tab = GenerateTab(config=cfg, status_callback=statuses.append)
|
||||
self.addCleanup(tab.close)
|
||||
|
||||
self.assertFalse(tab.cmhub_balance_label.isHidden())
|
||||
self.assertEqual("cmhub余额:未获取", tab.cmhub_balance_label.text())
|
||||
|
||||
tab._on_generate_progress(
|
||||
{
|
||||
"total": 1,
|
||||
"title_done": 1,
|
||||
"cover_done": 0,
|
||||
"cover_total": 0,
|
||||
"failed": 0,
|
||||
"generate_cover": False,
|
||||
"points_balance": 88,
|
||||
}
|
||||
)
|
||||
self.assertEqual("cmhub余额:88", tab.cmhub_balance_label.text())
|
||||
|
||||
with mock.patch("app.gui.QMessageBox.warning") as warning:
|
||||
tab._on_generate_finished(
|
||||
{
|
||||
"total": 1,
|
||||
"title_done": 0,
|
||||
"cover_done": 0,
|
||||
"cover_total": 0,
|
||||
"failed": 1,
|
||||
"billing_error": {
|
||||
"code": "insufficient_points",
|
||||
"message": "点数不足,请先充值。本轮未开始任务将停止。",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
warning.assert_called_once()
|
||||
self.assertIn("点数不足,请先充值", warning.call_args[0][2])
|
||||
self.assertIn("AI 生成已中止", statuses[-1])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_tab_persists_generate_cover_toggle(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
@@ -1262,6 +1311,169 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_worker_records_cmhub_billing_metadata(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.ai_config(cfg)
|
||||
cfg["ai"]["backend"] = "cmhub"
|
||||
cfg["ai"]["generate_cover"] = True
|
||||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639510",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
progress = []
|
||||
logs = []
|
||||
|
||||
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
||||
on_progress({"total": 1, "title_done": 1, "cover_done": 0, "failed": 0})
|
||||
ai_cfg["on_event"](
|
||||
{
|
||||
"task": tasks_arg[0],
|
||||
"phase": "title",
|
||||
"step": "title_request",
|
||||
"result": "meta",
|
||||
"metadata": {
|
||||
"alias": "title-standard",
|
||||
"points_cost": 1,
|
||||
"points_balance": 88,
|
||||
"call_id": "call-1",
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"total": 1,
|
||||
"title_done": 1,
|
||||
"cover_done": 1,
|
||||
"cover_total": 1,
|
||||
"generated_done": 1,
|
||||
"failed": 0,
|
||||
"generate_cover": True,
|
||||
}
|
||||
|
||||
worker = GenerateWorker(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面提示"},
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
)
|
||||
worker.progress.connect(progress.append)
|
||||
worker.log.connect(logs.append)
|
||||
|
||||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||||
summary = worker.execute()
|
||||
|
||||
self.assertEqual(88, summary["points_balance"])
|
||||
self.assertEqual(88, progress[-1]["points_balance"])
|
||||
joined_logs = "\n".join(logs)
|
||||
self.assertIn("[计费] 商品 51100639510", joined_logs)
|
||||
self.assertIn("别名 title-standard", joined_logs)
|
||||
self.assertIn("扣点 1", joined_logs)
|
||||
self.assertIn("余额 88", joined_logs)
|
||||
self.assertIn("call_id=call-1", joined_logs)
|
||||
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
|
||||
self.assertEqual("cmhub", run_log.options["backend"])
|
||||
events = db.list_run_log_events(summary["run_id"], path=cfg["db_path"])
|
||||
messages = "\n".join(event.message for event in events)
|
||||
self.assertIn("[计费] 商品 51100639510", messages)
|
||||
self.assertIn("余额 88", messages)
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_worker_stops_on_cmhub_insufficient_points(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
cfg["ai"] = appconfig.ai_config(cfg)
|
||||
cfg["ai"]["backend"] = "cmhub"
|
||||
accounts.create_account("主店", "alias-a", debug_port=9222, config=cfg)
|
||||
batch_id = db.create_batch(["input.xlsx"], path=cfg["db_path"])
|
||||
db.insert_tasks(
|
||||
batch_id,
|
||||
[
|
||||
{
|
||||
"source_file_abs": os.path.join(temp_dir, "input.xlsx"),
|
||||
"source_sheet": "商品",
|
||||
"source_row": 2,
|
||||
"account_name": "Excel主店",
|
||||
"alias": "alias-a",
|
||||
"item_id": "51100639510",
|
||||
}
|
||||
],
|
||||
path=cfg["db_path"],
|
||||
)
|
||||
task = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])[0]
|
||||
db.set_collected(task.id, "旧标题", "old.jpg", path=cfg["db_path"])
|
||||
tasks = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||
progress = []
|
||||
logs = []
|
||||
|
||||
def fake_generate_batch(tasks_arg, prompt_values, ai_cfg=None, on_progress=None, should_stop=None):
|
||||
exc = ai.CMHubError("insufficient_points", "点数不足,请先充值", status=402)
|
||||
ai_cfg["on_error"](
|
||||
{
|
||||
"task": tasks_arg[0],
|
||||
"phase": "title",
|
||||
"step": "title_request",
|
||||
"error": str(exc),
|
||||
"exception": exc,
|
||||
"code": exc.code,
|
||||
"status": exc.status,
|
||||
}
|
||||
)
|
||||
self.assertTrue(should_stop())
|
||||
return {
|
||||
"ok": False,
|
||||
"total": 1,
|
||||
"title_done": 0,
|
||||
"cover_done": 0,
|
||||
"cover_total": 0,
|
||||
"generated_done": 0,
|
||||
"failed": 1,
|
||||
"cancelled": should_stop(),
|
||||
"generate_cover": False,
|
||||
}
|
||||
|
||||
worker = GenerateWorker(
|
||||
tasks,
|
||||
{"title": "标题提示", "cover": "封面提示"},
|
||||
db_path=cfg["db_path"],
|
||||
config=cfg,
|
||||
)
|
||||
worker.progress.connect(progress.append)
|
||||
worker.log.connect(logs.append)
|
||||
|
||||
with mock.patch("app.gui.ai.generate_batch", side_effect=fake_generate_batch):
|
||||
summary = worker.execute()
|
||||
|
||||
self.assertFalse(summary["ok"])
|
||||
self.assertTrue(summary["cancelled"])
|
||||
self.assertEqual("insufficient_points", summary["billing_error"]["code"])
|
||||
self.assertIn("点数不足,请先充值", summary["billing_error"]["message"])
|
||||
self.assertEqual("insufficient_points", progress[-1]["billing_error"]["code"])
|
||||
joined_logs = "\n".join(logs)
|
||||
self.assertIn("[计费] 商品 51100639510", joined_logs)
|
||||
self.assertIn("点数不足,请先充值", joined_logs)
|
||||
run_log = db.list_run_logs(limit=1, run_type="generate", path=cfg["db_path"])[0]
|
||||
self.assertEqual("failed", run_log.status)
|
||||
self.assertEqual("insufficient_points", run_log.summary["billing_error"]["code"])
|
||||
|
||||
self.assert_removed(temp_dir)
|
||||
|
||||
def test_generate_worker_writes_run_log_and_diagnostic_log_on_image_failure(self):
|
||||
with self.make_temp_dir() as temp_dir:
|
||||
cfg = self.make_config(temp_dir)
|
||||
|
||||
Reference in New Issue
Block a user