feat(subscription): add membership access preflight
This commit is contained in:
@@ -1753,6 +1753,37 @@ def _cmhub_call_with_retry(
|
|||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
|
def request_cmhub_json(
|
||||||
|
method,
|
||||||
|
base_url,
|
||||||
|
endpoint,
|
||||||
|
api_key,
|
||||||
|
*,
|
||||||
|
payload=None,
|
||||||
|
connect_timeout=10,
|
||||||
|
read_timeout=30,
|
||||||
|
use_system_proxy=False,
|
||||||
|
headers_extra=None,
|
||||||
|
):
|
||||||
|
"""Call one cmhub JSON endpoint through the shared gateway session.
|
||||||
|
|
||||||
|
This is intentionally small: product-specific modules can reuse the same
|
||||||
|
proxy handling, connection pool, redaction and structured HTTP errors as
|
||||||
|
generation without reaching into private transport helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_apply_cmhub_proxy(use_system_proxy)
|
||||||
|
return _cmhub_call_once(
|
||||||
|
method,
|
||||||
|
appconfig.cmhub_request_url(base_url, endpoint),
|
||||||
|
api_key,
|
||||||
|
payload,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
read_timeout=read_timeout,
|
||||||
|
headers_extra=headers_extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cmhub_call_once(method, url, api_key, payload, connect_timeout, read_timeout, headers_extra=None):
|
def _cmhub_call_once(method, url, api_key, payload, connect_timeout, read_timeout, headers_extra=None):
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer " + str(api_key),
|
"Authorization": "Bearer " + str(api_key),
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ DEFAULT_CONFIG = {
|
|||||||
"ratio": "1:1",
|
"ratio": "1:1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"subscription": {
|
||||||
|
"last_notice_id": "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_AI_MODELS_CONFIG = {
|
DEFAULT_AI_MODELS_CONFIG = {
|
||||||
@@ -706,6 +709,25 @@ def cdp_ready_timeout(config=None) -> int:
|
|||||||
return int(_config_or_load(config).get("cdp_ready_timeout", 60))
|
return int(_config_or_load(config).get("cdp_ready_timeout", 60))
|
||||||
|
|
||||||
|
|
||||||
|
def subscription_notice_id(config=None) -> str:
|
||||||
|
section = _config_or_load(config).get("subscription", {})
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
return ""
|
||||||
|
return str(section.get("last_notice_id") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def save_subscription_notice_id(notice_id, path=CONFIG_PATH) -> dict:
|
||||||
|
"""Persist the last server notification version without storing credentials."""
|
||||||
|
|
||||||
|
config = load_config(path)
|
||||||
|
section = config.get("subscription", {})
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
section = {}
|
||||||
|
section["last_notice_id"] = str(notice_id or "").strip()
|
||||||
|
config["subscription"] = section
|
||||||
|
return save_config(config, path=path)
|
||||||
|
|
||||||
|
|
||||||
def ai_config(config=None) -> dict:
|
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"]))
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ def main() -> int:
|
|||||||
if runtime_lease is not None:
|
if runtime_lease is not None:
|
||||||
app.aboutToQuit.connect(runtime_lease.release)
|
app.aboutToQuit.connect(runtime_lease.release)
|
||||||
window.show()
|
window.show()
|
||||||
|
QTimer.singleShot(0, window.begin_subscription_check)
|
||||||
QTimer.singleShot(
|
QTimer.singleShot(
|
||||||
0,
|
0,
|
||||||
lambda: update_health.write_health(health_context, "main_window_ready"),
|
lambda: update_health.write_health(health_context, "main_window_ready"),
|
||||||
|
|||||||
+218
-1
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PySide6.QtCore import QUrl
|
||||||
|
from PySide6.QtGui import QDesktopServices
|
||||||
|
|
||||||
|
from .. import subscription
|
||||||
from ..version import display_name
|
from ..version import display_name
|
||||||
from .tabs.accounts import AccountsTab
|
from .tabs.accounts import AccountsTab
|
||||||
from .tabs.apply import ApplyTab
|
from .tabs.apply import ApplyTab
|
||||||
@@ -10,6 +14,7 @@ from .tabs.generate import GenerateTab
|
|||||||
from .tabs.product_suite import ProductSuiteTab
|
from .tabs.product_suite import ProductSuiteTab
|
||||||
from .tabs.settings import SettingsTab
|
from .tabs.settings import SettingsTab
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
|
from .workers import SubscriptionCheckWorker
|
||||||
|
|
||||||
|
|
||||||
PREFERRED_WINDOW_SIZE = (1180, 760)
|
PREFERRED_WINDOW_SIZE = (1180, 760)
|
||||||
@@ -106,6 +111,14 @@ class MainWindow(QMainWindow):
|
|||||||
self._settings_tab_index = TAB_TITLES.index("设置")
|
self._settings_tab_index = TAB_TITLES.index("设置")
|
||||||
self._last_tab_index = 0
|
self._last_tab_index = 0
|
||||||
self._reverting_tab_change = False
|
self._reverting_tab_change = False
|
||||||
|
self._subscription_status = subscription.SubscriptionStatus(
|
||||||
|
subscription.STATUS_UNAVAILABLE
|
||||||
|
)
|
||||||
|
self._subscription_worker = None
|
||||||
|
self._subscription_thread = None
|
||||||
|
self._subscription_request_token = 0
|
||||||
|
self._subscription_prompt_key = ""
|
||||||
|
self._subscription_closed = False
|
||||||
self.tabs = QTabWidget()
|
self.tabs = QTabWidget()
|
||||||
self.tabs.setObjectName("mainTabs")
|
self.tabs.setObjectName("mainTabs")
|
||||||
self.tabs.setStyleSheet(TAB_STYLE)
|
self.tabs.setStyleSheet(TAB_STYLE)
|
||||||
@@ -115,7 +128,30 @@ class MainWindow(QMainWindow):
|
|||||||
settings_tab = self._settings_tab()
|
settings_tab = self._settings_tab()
|
||||||
if hasattr(settings_tab, "settingsSaved"):
|
if hasattr(settings_tab, "settingsSaved"):
|
||||||
settings_tab.settingsSaved.connect(self._on_settings_saved)
|
settings_tab.settingsSaved.connect(self._on_settings_saved)
|
||||||
self.setCentralWidget(self.tabs)
|
self._header_title_label = QLabel(display_name())
|
||||||
|
self._header_title_label.setObjectName("appHeaderTitle")
|
||||||
|
self._header_title_label.setStyleSheet(
|
||||||
|
"font-size: 15px; font-weight: 600; color: #24292f;"
|
||||||
|
)
|
||||||
|
self._subscription_label = QLabel("会员:正在验证")
|
||||||
|
self._subscription_label.setObjectName("subscriptionStatusLabel")
|
||||||
|
self._subscription_label.setAccessibleName("会员状态")
|
||||||
|
self._subscription_label.setStyleSheet("color: #0969da;")
|
||||||
|
header = QWidget()
|
||||||
|
header.setObjectName("appHeader")
|
||||||
|
header_layout = QHBoxLayout(header)
|
||||||
|
header_layout.setContentsMargins(18, 8, 18, 4)
|
||||||
|
header_layout.setSpacing(10)
|
||||||
|
header_layout.addWidget(self._header_title_label)
|
||||||
|
header_layout.addStretch(1)
|
||||||
|
header_layout.addWidget(self._subscription_label)
|
||||||
|
central = QWidget()
|
||||||
|
central_layout = QVBoxLayout(central)
|
||||||
|
central_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
central_layout.setSpacing(0)
|
||||||
|
central_layout.addWidget(header)
|
||||||
|
central_layout.addWidget(self.tabs, 1)
|
||||||
|
self.setCentralWidget(central)
|
||||||
self.show_status("就绪", level="muted")
|
self.show_status("就绪", level="muted")
|
||||||
if startup_status:
|
if startup_status:
|
||||||
self.show_status(startup_status, level="success")
|
self.show_status(startup_status, level="success")
|
||||||
@@ -150,6 +186,7 @@ class MainWindow(QMainWindow):
|
|||||||
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
|
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
|
||||||
open_accounts_callback=lambda: self.open_accounts_tab(),
|
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||||
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
||||||
|
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
|
||||||
)
|
)
|
||||||
if title == "③ 更新蝦皮":
|
if title == "③ 更新蝦皮":
|
||||||
return ApplyTab(
|
return ApplyTab(
|
||||||
@@ -179,6 +216,7 @@ class MainWindow(QMainWindow):
|
|||||||
config=self.config,
|
config=self.config,
|
||||||
config_path=self.config_path,
|
config_path=self.config_path,
|
||||||
status_callback=self.show_status,
|
status_callback=self.show_status,
|
||||||
|
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
|
||||||
)
|
)
|
||||||
raise ValueError(f"未知主界面模块:{title}")
|
raise ValueError(f"未知主界面模块:{title}")
|
||||||
|
|
||||||
@@ -195,6 +233,182 @@ class MainWindow(QMainWindow):
|
|||||||
widget.refresh_gateway_state()
|
widget.refresh_gateway_state()
|
||||||
label = "自定义网关" if str(backend) == "direct" else "默认网关"
|
label = "自定义网关" if str(backend) == "direct" else "默认网关"
|
||||||
self.show_status("设置已保存,当前使用%s" % label, level="success")
|
self.show_status("设置已保存,当前使用%s" % label, level="success")
|
||||||
|
self.begin_subscription_check(show_prompt=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subscription_status(self):
|
||||||
|
return self._subscription_status
|
||||||
|
|
||||||
|
def begin_subscription_check(self, *, show_prompt=True):
|
||||||
|
"""Refresh membership state without blocking the Qt GUI thread."""
|
||||||
|
|
||||||
|
self._subscription_request_token += 1
|
||||||
|
token = self._subscription_request_token
|
||||||
|
if self._subscription_worker is not None:
|
||||||
|
self._subscription_worker.cancel()
|
||||||
|
self._set_product_access(False)
|
||||||
|
self._set_subscription_label("会员:正在验证", "info")
|
||||||
|
worker = SubscriptionCheckWorker(
|
||||||
|
config=self.config,
|
||||||
|
cmhub_config_path=self.config.get("cmhub_config_path"),
|
||||||
|
)
|
||||||
|
worker.finished.connect(
|
||||||
|
lambda payload, token=token, show_prompt=show_prompt: self._on_subscription_checked(
|
||||||
|
token,
|
||||||
|
payload,
|
||||||
|
show_prompt=show_prompt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
worker.cancelled.connect(
|
||||||
|
lambda payload, token=token, show_prompt=show_prompt: self._on_subscription_checked(
|
||||||
|
token,
|
||||||
|
payload,
|
||||||
|
show_prompt=show_prompt,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
thread = run_worker(worker, thread_name="SubscriptionCheckWorker", start=False)
|
||||||
|
thread.finished.connect(lambda: self._forget_subscription_thread(thread))
|
||||||
|
self._subscription_worker = worker
|
||||||
|
self._subscription_thread = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def ensure_subscription_for_new_submit(self, action_name="生成"):
|
||||||
|
"""Return whether a new product request may start from the current UI."""
|
||||||
|
|
||||||
|
status = self._subscription_status
|
||||||
|
if status.allows_product_workflows:
|
||||||
|
return True
|
||||||
|
if self._subscription_thread is not None:
|
||||||
|
self.show_status("正在验证会员状态,请稍后再%s" % action_name, level="info")
|
||||||
|
return False
|
||||||
|
self.show_status("%s:%s" % (action_name, status.user_message), level="warning")
|
||||||
|
self._show_subscription_access_prompt(status)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _on_subscription_checked(self, token, payload, *, show_prompt=True):
|
||||||
|
if self._subscription_closed or token != self._subscription_request_token:
|
||||||
|
return
|
||||||
|
result = payload.get("subscription") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(result, subscription.SubscriptionStatus):
|
||||||
|
result = subscription.SubscriptionStatus(subscription.STATUS_UNAVAILABLE)
|
||||||
|
self._subscription_status = result
|
||||||
|
self._apply_subscription_status(result, show_prompt=show_prompt)
|
||||||
|
|
||||||
|
def _forget_subscription_thread(self, thread):
|
||||||
|
if self._subscription_thread is thread:
|
||||||
|
self._subscription_thread = None
|
||||||
|
self._subscription_worker = None
|
||||||
|
|
||||||
|
def _apply_subscription_status(self, status, *, show_prompt=True):
|
||||||
|
self._subscription_status = status
|
||||||
|
if status.state == subscription.STATUS_ACTIVE:
|
||||||
|
expiry = subscription.format_expiry(status.expires_at)
|
||||||
|
self._set_subscription_label(
|
||||||
|
"%s · %s · 有效至%s" % (status.account_name, status.plan_name, expiry),
|
||||||
|
"success",
|
||||||
|
)
|
||||||
|
elif status.state == subscription.STATUS_GRACE:
|
||||||
|
expiry = subscription.format_expiry(status.grace_expires_at) or subscription.format_expiry(status.expires_at)
|
||||||
|
self._set_subscription_label(
|
||||||
|
"%s · %s · 宽限至%s" % (status.account_name, status.plan_name, expiry),
|
||||||
|
"warning",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._set_subscription_label("会员:%s" % status.user_message, self._subscription_level(status))
|
||||||
|
|
||||||
|
self._set_product_access(status.allows_product_workflows)
|
||||||
|
if status.allows_product_workflows:
|
||||||
|
if status.state == subscription.STATUS_LEGACY:
|
||||||
|
self.show_status(status.user_message, level="muted")
|
||||||
|
else:
|
||||||
|
self.show_status("会员状态已验证", level="success")
|
||||||
|
self._show_subscription_notice_once(status)
|
||||||
|
return
|
||||||
|
self.open_settings_tab()
|
||||||
|
self.show_status(status.user_message, level=self._subscription_level(status))
|
||||||
|
if show_prompt:
|
||||||
|
self._show_subscription_access_prompt(status)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _subscription_level(status):
|
||||||
|
if status.state in {subscription.STATUS_UNAVAILABLE, subscription.STATUS_KEY_INVALID}:
|
||||||
|
return "warning"
|
||||||
|
return "danger"
|
||||||
|
|
||||||
|
def _set_subscription_label(self, text, level):
|
||||||
|
color = _status_level_color(level)
|
||||||
|
self._subscription_label.setText(str(text))
|
||||||
|
self._subscription_label.setStyleSheet("color: %s;" % color)
|
||||||
|
self._subscription_label.setToolTip(str(text))
|
||||||
|
|
||||||
|
def _set_product_access(self, enabled):
|
||||||
|
for index, title in enumerate(TAB_TITLES):
|
||||||
|
self.tabs.setTabEnabled(index, bool(enabled) or title == "设置")
|
||||||
|
|
||||||
|
def _show_subscription_access_prompt(self, status):
|
||||||
|
if self._subscription_closed:
|
||||||
|
return
|
||||||
|
if status.state in {subscription.STATUS_ACTIVE, subscription.STATUS_GRACE, subscription.STATUS_LEGACY}:
|
||||||
|
return
|
||||||
|
prompt_key = "|".join((status.state, status.notice_id, status.manage_url))
|
||||||
|
if prompt_key and prompt_key == self._subscription_prompt_key:
|
||||||
|
return
|
||||||
|
self._subscription_prompt_key = prompt_key
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Warning)
|
||||||
|
box.setWindowTitle("需要配置会员账号")
|
||||||
|
box.setText(status.user_message)
|
||||||
|
box.setInformativeText("请配置有效的 cmhub API Key,或前往线上会员中心开通、续费后重试。")
|
||||||
|
settings_button = box.addButton("打开设置", QMessageBox.AcceptRole)
|
||||||
|
manage_button = None
|
||||||
|
if status.manage_url:
|
||||||
|
manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole)
|
||||||
|
exit_button = box.addButton("退出", QMessageBox.RejectRole)
|
||||||
|
box.setDefaultButton(settings_button)
|
||||||
|
box.setEscapeButton(exit_button)
|
||||||
|
box.exec()
|
||||||
|
if box.clickedButton() is settings_button:
|
||||||
|
self.open_settings_tab()
|
||||||
|
elif manage_button is not None and box.clickedButton() is manage_button:
|
||||||
|
QDesktopServices.openUrl(QUrl(status.manage_url))
|
||||||
|
elif box.clickedButton() is exit_button:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _show_subscription_notice_once(self, status):
|
||||||
|
notice_id = str(status.notice_id or "").strip()
|
||||||
|
if not notice_id or notice_id == appconfig.subscription_notice_id(self.config):
|
||||||
|
return
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setIcon(QMessageBox.Information)
|
||||||
|
box.setWindowTitle("已启用会员订阅")
|
||||||
|
expiry = subscription.format_expiry(
|
||||||
|
status.grace_expires_at if status.state == subscription.STATUS_GRACE else status.expires_at
|
||||||
|
)
|
||||||
|
text = "已启用蝦皮圈会员订阅服务。当前账号:%s;套餐:%s。" % (
|
||||||
|
status.account_name,
|
||||||
|
status.plan_name,
|
||||||
|
)
|
||||||
|
if expiry:
|
||||||
|
text += "当前有效至%s。" % expiry
|
||||||
|
text += "请在到期前前往线上会员中心选择或续费套餐。"
|
||||||
|
box.setText(text)
|
||||||
|
manage_button = None
|
||||||
|
if status.manage_url:
|
||||||
|
manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole)
|
||||||
|
close_button = box.addButton("关闭", QMessageBox.AcceptRole)
|
||||||
|
box.setDefaultButton(close_button)
|
||||||
|
box.exec()
|
||||||
|
if manage_button is not None and box.clickedButton() is manage_button:
|
||||||
|
QDesktopServices.openUrl(QUrl(status.manage_url))
|
||||||
|
try:
|
||||||
|
saved = appconfig.save_subscription_notice_id(
|
||||||
|
notice_id,
|
||||||
|
path=self.config_path,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
self.config.clear()
|
||||||
|
self.config.update(saved)
|
||||||
|
|
||||||
def _on_tab_changed(self, index):
|
def _on_tab_changed(self, index):
|
||||||
if self._reverting_tab_change:
|
if self._reverting_tab_change:
|
||||||
@@ -239,6 +453,9 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
if self._confirm_leave_settings_tab():
|
if self._confirm_leave_settings_tab():
|
||||||
|
self._subscription_closed = True
|
||||||
|
if self._subscription_worker is not None:
|
||||||
|
self._subscription_worker.cancel()
|
||||||
event.accept()
|
event.accept()
|
||||||
return
|
return
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
|||||||
@@ -983,6 +983,7 @@ class GenerateTab(QWidget):
|
|||||||
title_templates_dir=None,
|
title_templates_dir=None,
|
||||||
open_accounts_callback=None,
|
open_accounts_callback=None,
|
||||||
refresh_workflow_callback=None,
|
refresh_workflow_callback=None,
|
||||||
|
subscription_preflight_callback=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
|
||||||
@@ -995,6 +996,7 @@ class GenerateTab(QWidget):
|
|||||||
self.status_callback = status_callback
|
self.status_callback = status_callback
|
||||||
self.open_accounts_callback = open_accounts_callback
|
self.open_accounts_callback = open_accounts_callback
|
||||||
self.refresh_workflow_callback = refresh_workflow_callback
|
self.refresh_workflow_callback = refresh_workflow_callback
|
||||||
|
self.subscription_preflight_callback = subscription_preflight_callback
|
||||||
self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config)
|
self.title_prompt_path = title_prompt_path or appconfig.title_prompt_path(self.config)
|
||||||
self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config)
|
self.cover_prompts_dir = cover_prompts_dir or appconfig.cover_prompts_dir(self.config)
|
||||||
self.title_templates_dir = title_templates_dir or appconfig.title_templates_dir(self.config)
|
self.title_templates_dir = title_templates_dir or appconfig.title_templates_dir(self.config)
|
||||||
@@ -1757,6 +1759,8 @@ class GenerateTab(QWidget):
|
|||||||
if self.generate_thread is not None:
|
if self.generate_thread is not None:
|
||||||
self._set_status("AI 生成正在进行...")
|
self._set_status("AI 生成正在进行...")
|
||||||
return
|
return
|
||||||
|
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 生成"):
|
||||||
|
return
|
||||||
if not self._save_generate_mode_setting(show_status=False):
|
if not self._save_generate_mode_setting(show_status=False):
|
||||||
return
|
return
|
||||||
generate_mode = self._current_generate_mode()
|
generate_mode = self._current_generate_mode()
|
||||||
|
|||||||
@@ -1973,6 +1973,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
config=None,
|
config=None,
|
||||||
config_path=None,
|
config_path=None,
|
||||||
status_callback=None,
|
status_callback=None,
|
||||||
|
subscription_preflight_callback=None,
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setObjectName("productSuiteTab")
|
self.setObjectName("productSuiteTab")
|
||||||
@@ -1982,6 +1983,7 @@ class ProductSuiteTab(QWidget):
|
|||||||
self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config)
|
self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config)
|
||||||
self.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config)
|
self.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config)
|
||||||
self.status_callback = status_callback
|
self.status_callback = status_callback
|
||||||
|
self.subscription_preflight_callback = subscription_preflight_callback
|
||||||
self.accounts = []
|
self.accounts = []
|
||||||
self._states = {}
|
self._states = {}
|
||||||
self._retired_states = []
|
self._retired_states = []
|
||||||
@@ -4392,6 +4394,8 @@ class ProductSuiteTab(QWidget):
|
|||||||
def start_ai_write(self, checked=False):
|
def start_ai_write(self, checked=False):
|
||||||
if not self._require_default_gateway("商品套图AI帮写"):
|
if not self._require_default_gateway("商品套图AI帮写"):
|
||||||
return
|
return
|
||||||
|
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 帮写"):
|
||||||
|
return
|
||||||
state = self._displayed_state
|
state = self._displayed_state
|
||||||
if state is None:
|
if state is None:
|
||||||
return
|
return
|
||||||
@@ -4729,6 +4733,8 @@ class ProductSuiteTab(QWidget):
|
|||||||
retry_job_id=None,
|
retry_job_id=None,
|
||||||
confirm_direct_retry=False,
|
confirm_direct_retry=False,
|
||||||
):
|
):
|
||||||
|
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("生成商品套图"):
|
||||||
|
return False
|
||||||
if not self._ensure_generation_gateway():
|
if not self._ensure_generation_gateway():
|
||||||
return False
|
return False
|
||||||
if state.generation_running():
|
if state.generation_running():
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .. import (
|
|||||||
image_studio_generation,
|
image_studio_generation,
|
||||||
image_studio_images,
|
image_studio_images,
|
||||||
product_status,
|
product_status,
|
||||||
|
subscription,
|
||||||
)
|
)
|
||||||
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
|
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
@@ -34,6 +35,22 @@ _USER_LOG_PATH_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionCheckWorker(BaseWorker):
|
||||||
|
"""Fetch the current cmshopee account subscription away from the GUI thread."""
|
||||||
|
|
||||||
|
def __init__(self, *, config=None, cmhub_config_path=None):
|
||||||
|
super().__init__()
|
||||||
|
self.config = config
|
||||||
|
self.cmhub_config_path = cmhub_config_path
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
result = subscription.check_status(
|
||||||
|
self.config,
|
||||||
|
cmhub_config_path=self.cmhub_config_path,
|
||||||
|
)
|
||||||
|
return {"subscription": result}
|
||||||
|
|
||||||
|
|
||||||
def _image_studio_step_label(step):
|
def _image_studio_step_label(step):
|
||||||
return {
|
return {
|
||||||
"ensure_chrome": "准备账号浏览器",
|
"ensure_chrome": "准备账号浏览器",
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""cmshopee account subscription status helpers.
|
||||||
|
|
||||||
|
The remote service remains the authority for subscription enforcement. This
|
||||||
|
module only normalizes the status response for desktop UI and preflight use;
|
||||||
|
it deliberately never persists or exposes the API key it uses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from . import ai, appconfig
|
||||||
|
|
||||||
|
|
||||||
|
PRODUCT_CODE = "cmshopee"
|
||||||
|
STATUS_NOT_CONFIGURED = "not_configured"
|
||||||
|
STATUS_ACTIVE = "active"
|
||||||
|
STATUS_GRACE = "grace"
|
||||||
|
STATUS_REQUIRED = "required"
|
||||||
|
STATUS_EXPIRED = "expired"
|
||||||
|
STATUS_REVOKED = "revoked"
|
||||||
|
STATUS_ACCOUNT_DISABLED = "account_disabled"
|
||||||
|
STATUS_KEY_INVALID = "key_invalid"
|
||||||
|
STATUS_UNAVAILABLE = "unavailable"
|
||||||
|
STATUS_LEGACY = "legacy"
|
||||||
|
|
||||||
|
_ALLOWED_STATES = {
|
||||||
|
STATUS_NOT_CONFIGURED,
|
||||||
|
STATUS_ACTIVE,
|
||||||
|
STATUS_GRACE,
|
||||||
|
STATUS_REQUIRED,
|
||||||
|
STATUS_EXPIRED,
|
||||||
|
STATUS_REVOKED,
|
||||||
|
STATUS_ACCOUNT_DISABLED,
|
||||||
|
STATUS_KEY_INVALID,
|
||||||
|
STATUS_UNAVAILABLE,
|
||||||
|
STATUS_LEGACY,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SubscriptionStatus:
|
||||||
|
"""A redacted, UI-safe view of one cmshopee subscription lookup."""
|
||||||
|
|
||||||
|
state: str
|
||||||
|
account_name: str = ""
|
||||||
|
plan_name: str = ""
|
||||||
|
expires_at: str = ""
|
||||||
|
grace_expires_at: str = ""
|
||||||
|
manage_url: str = ""
|
||||||
|
notice_id: str = ""
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.state not in _ALLOWED_STATES:
|
||||||
|
raise ValueError("未知订阅状态")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allows_product_workflows(self) -> bool:
|
||||||
|
return self.state in {STATUS_ACTIVE, STATUS_GRACE, STATUS_LEGACY}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interface_available(self) -> bool:
|
||||||
|
return self.state != STATUS_LEGACY
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_message(self) -> str:
|
||||||
|
return {
|
||||||
|
STATUS_NOT_CONFIGURED: "请先在设置配置 cmhub API Key",
|
||||||
|
STATUS_ACTIVE: "会员有效",
|
||||||
|
STATUS_GRACE: "会员处于宽限期",
|
||||||
|
STATUS_REQUIRED: "当前账号尚未开通蝦皮圈会员",
|
||||||
|
STATUS_EXPIRED: "当前账号的蝦皮圈会员已到期",
|
||||||
|
STATUS_REVOKED: "当前账号的蝦皮圈会员已撤销",
|
||||||
|
STATUS_ACCOUNT_DISABLED: "cmhub 账号当前不可用",
|
||||||
|
STATUS_KEY_INVALID: "cmhub API Key 无效,请在设置重新填写",
|
||||||
|
STATUS_UNAVAILABLE: "暂时无法确认会员状态",
|
||||||
|
STATUS_LEGACY: "会员服务尚未启用,当前按原有方式运行",
|
||||||
|
}[self.state]
|
||||||
|
|
||||||
|
|
||||||
|
def check_status(
|
||||||
|
config=None,
|
||||||
|
*,
|
||||||
|
cmhub_config_path=None,
|
||||||
|
request_json=None,
|
||||||
|
) -> SubscriptionStatus:
|
||||||
|
"""Look up the current API-key account subscription without leaking secrets.
|
||||||
|
|
||||||
|
A service that has not yet deployed the endpoint returns ``404``. That is
|
||||||
|
an explicit rollout compatibility state rather than an invalid membership.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cfg = appconfig.load_config() if config is None else config
|
||||||
|
try:
|
||||||
|
cmhub = appconfig.cmhub_config(cfg)
|
||||||
|
base_url = appconfig.normalize_cmhub_base_url(cmhub.get("base_url"))
|
||||||
|
key_path = (
|
||||||
|
cmhub_config_path
|
||||||
|
or cfg.get("cmhub_config_path")
|
||||||
|
or appconfig.cmhub_config_file_path(cfg)
|
||||||
|
)
|
||||||
|
api_key = appconfig.get_cmhub_api_key(path=key_path)
|
||||||
|
except Exception:
|
||||||
|
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
|
||||||
|
if not base_url or not api_key:
|
||||||
|
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
|
||||||
|
|
||||||
|
requester = request_json or ai.request_cmhub_json
|
||||||
|
try:
|
||||||
|
data = requester(
|
||||||
|
"GET",
|
||||||
|
base_url,
|
||||||
|
"/api/v1/cmshopee/subscription/status",
|
||||||
|
api_key,
|
||||||
|
connect_timeout=cmhub.get(
|
||||||
|
"connect_timeout",
|
||||||
|
appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT,
|
||||||
|
),
|
||||||
|
read_timeout=30,
|
||||||
|
use_system_proxy=bool(appconfig.ai_config(cfg).get("use_system_proxy")),
|
||||||
|
)
|
||||||
|
except ai.CMHubError as exc:
|
||||||
|
return _status_from_cmhub_error(exc)
|
||||||
|
except Exception:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
return _parse_status_response(data, base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def format_expiry(value: str) -> str:
|
||||||
|
"""Return a compact local date for the title-area membership label."""
|
||||||
|
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
return parsed.date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _status_from_cmhub_error(exc: ai.CMHubError) -> SubscriptionStatus:
|
||||||
|
code = str(getattr(exc, "code", "") or "").strip().lower()
|
||||||
|
if code == "not_found":
|
||||||
|
return SubscriptionStatus(STATUS_LEGACY)
|
||||||
|
if code == "unauthorized":
|
||||||
|
return SubscriptionStatus(STATUS_KEY_INVALID)
|
||||||
|
if code == "account_disabled":
|
||||||
|
return SubscriptionStatus(STATUS_ACCOUNT_DISABLED)
|
||||||
|
if code in {"subscription_required", "subscription_missing"}:
|
||||||
|
return SubscriptionStatus(STATUS_REQUIRED)
|
||||||
|
if code in {"subscription_expired", "license_expired"}:
|
||||||
|
return SubscriptionStatus(STATUS_EXPIRED)
|
||||||
|
if code in {"subscription_revoked", "subscription_cancelled"}:
|
||||||
|
return SubscriptionStatus(STATUS_REVOKED)
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_status_response(data, base_url: str) -> SubscriptionStatus:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
if str(data.get("product_code") or "").strip().lower() != PRODUCT_CODE:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
|
||||||
|
normalized = _normalize_remote_state(data.get("status"))
|
||||||
|
if not normalized:
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
account = data.get("account") if isinstance(data.get("account"), dict) else {}
|
||||||
|
plan = data.get("plan") if isinstance(data.get("plan"), dict) else {}
|
||||||
|
account_name = str(account.get("display_name") or "").strip()
|
||||||
|
plan_name = str(plan.get("display_name") or "").strip()
|
||||||
|
expires_at = str(data.get("expires_at") or "").strip()
|
||||||
|
grace_expires_at = str(data.get("grace_expires_at") or "").strip()
|
||||||
|
manage_url = _safe_manage_url(data.get("manage_url"), base_url)
|
||||||
|
notice_id = str(data.get("notice_id") or "").strip()
|
||||||
|
|
||||||
|
if normalized in {STATUS_ACTIVE, STATUS_GRACE}:
|
||||||
|
if not account_name or not plan_name or not format_expiry(expires_at):
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
if (
|
||||||
|
normalized == STATUS_GRACE
|
||||||
|
and grace_expires_at
|
||||||
|
and not format_expiry(grace_expires_at)
|
||||||
|
):
|
||||||
|
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||||
|
|
||||||
|
return SubscriptionStatus(
|
||||||
|
normalized,
|
||||||
|
account_name=account_name,
|
||||||
|
plan_name=plan_name,
|
||||||
|
expires_at=expires_at,
|
||||||
|
grace_expires_at=grace_expires_at,
|
||||||
|
manage_url=manage_url,
|
||||||
|
notice_id=notice_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_remote_state(value) -> str:
|
||||||
|
normalized = str(value or "").strip().lower().replace("-", "_")
|
||||||
|
return {
|
||||||
|
"active": STATUS_ACTIVE,
|
||||||
|
"grace": STATUS_GRACE,
|
||||||
|
"required": STATUS_REQUIRED,
|
||||||
|
"subscription_required": STATUS_REQUIRED,
|
||||||
|
"expired": STATUS_EXPIRED,
|
||||||
|
"subscription_expired": STATUS_EXPIRED,
|
||||||
|
"revoked": STATUS_REVOKED,
|
||||||
|
"cancelled": STATUS_REVOKED,
|
||||||
|
"subscription_revoked": STATUS_REVOKED,
|
||||||
|
"account_disabled": STATUS_ACCOUNT_DISABLED,
|
||||||
|
}.get(normalized, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_manage_url(value, base_url: str) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
parsed = urlsplit(text)
|
||||||
|
base = urlsplit(str(base_url or ""))
|
||||||
|
if parsed.scheme != "https" or not parsed.hostname:
|
||||||
|
return ""
|
||||||
|
if (
|
||||||
|
parsed.username
|
||||||
|
or parsed.password
|
||||||
|
or not base.hostname
|
||||||
|
or parsed.hostname.lower() != base.hostname.lower()
|
||||||
|
):
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
@@ -25,6 +25,7 @@ GUI(PySide6 QTabWidget,当前显示 6 Tab)
|
|||||||
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
|
├── cdp CDP 客户端(连接、找/开 tab、执行 JS、拖拽)
|
||||||
├── editor 登录检测 / 检测商品状态 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
├── editor 登录检测 / 检测商品状态 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
||||||
├── product_status 商品状态代码、显示和分组规则
|
├── product_status 商品状态代码、显示和分组规则
|
||||||
|
├── subscription cmhub 账号订阅状态查询、响应归一化与客户端预检状态
|
||||||
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写)
|
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写)
|
||||||
├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
|
├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
|
||||||
├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
|
├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
|
||||||
@@ -78,6 +79,7 @@ imported → collected → generated → applied
|
|||||||
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
||||||
- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
||||||
- `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。
|
- `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。
|
||||||
|
- `subscription`:使用 cmhub API Key 查询 `GET /api/v1/cmshopee/subscription/status`,把远端权益结果归一化为有效、宽限、未订阅、到期、撤销、账号不可用、Key 无效、暂时不可用或旧服务兼容状态;不保存、展示或记录 API Key,服务端始终是最终授权方。
|
||||||
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供商品套图中的「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。
|
- `ai`:`gen_title(prompt, old_title)`、`gen_cover(prompt, old_cover_path)`、`analyze_product_images(instruction, context, image_paths)`;前两者分别负责②标题/生图,后者只供商品套图中的「AI帮写」调用 cmhub 图片理解接口,读取1至8张按 `source_order` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。
|
||||||
- `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。
|
- `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。
|
||||||
|
|
||||||
@@ -86,6 +88,7 @@ imported → collected → generated → applied
|
|||||||
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。
|
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。
|
||||||
- AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `data/config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。
|
- AI 模型清单(direct 内部兼容模式 url/模型/密钥/类型/连接超时)→ `data/config/ai_models.json`(API Key 本地明文保存,必须 gitignore,UI 打码显示;普通设置页不再暴露 direct 切换入口)。
|
||||||
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
|
- cmhub 网关 Key → `data/config/cmhub.json`,schema `{ "api_key": "..." }`;`config.json` 只保存 Base URL、别名和超时,不保存 Key。
|
||||||
|
- 订阅状态 → 仅进程内 `SubscriptionStatus`;顶部 GUI 只展示服务端返回的账号显示名、套餐名和有效期,不写入 SQLite、诊断日志或导出。`404` 表示服务端尚未启用订阅接口,客户端保持旧行为;网络异常不被误判为会员到期。
|
||||||
- cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。
|
- cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。
|
||||||
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
|
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
|
||||||
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
|
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
|
||||||
|
|||||||
+12
-1
@@ -25,6 +25,14 @@
|
|||||||
|
|
||||||
创建六个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
|
创建六个业务Tab之前先请求版本接口。服务端明确要求强制升级时,不创建 `MainWindow`,而是显示「必须升级」模态进度窗口:用户点击「立即升级」后可看到下载、校验、准备新版和重启阶段,以及百分比和字节数;运行中可「取消并退出」,失败后可重试。校验完成后软件启动安装目录外的独立更新器并退出,更新器替换程序后自动重启新版。版本接口完全不可达或非法时仍失败放行;一旦已明确强制,元数据缺失或后续失败都不允许进入旧版主界面。
|
||||||
|
|
||||||
|
## 会员订阅接入
|
||||||
|
|
||||||
|
- 强制升级检查完成、主窗口显示后,后台用 `data/config/cmhub.json` 的 API Key 请求 `GET /api/v1/cmshopee/subscription/status`;不会阻塞 Qt 主线程或把 Key 放入 URL、状态栏、日志和错误提示。
|
||||||
|
- 状态接口已启用时,未配置 Key、Key 无效、账号禁用、未订阅、到期、撤销或暂时无法确认会员状态,主窗口只保留「设置」Tab,并在顶部显示中文原因;用户可打开设置重填 Key、按服务端返回的 `manage_url` 用默认浏览器前往线上会员中心,或退出。已接受的异步任务、本地数据和导出不删除。
|
||||||
|
- 订阅有效时,应用顶部标题区域右侧显示“账号名 · 套餐名 · 有效至日期”;宽限期显示宽限截止日。不得把长会员文本拼进 Windows 原生窗口标题,也不得展示 API Key。
|
||||||
|
- 当前服务端未部署订阅接口时,`404` 视为旧服务兼容:顶部显示“会员服务尚未启用”,六个工作流继续按旧行为运行,不弹阻断窗口。网络或格式错误显示“暂时无法确认会员状态”,不能误报为 Key 无效或会员到期。
|
||||||
|
- ② AI生成、⑥商品套图和「AI帮写」的每次新提交均读取同一订阅预检状态;客户端预检只改善体验,cmhub 产品接口仍必须服务端最终裁决。已提交的异步任务继续查询、下载和查看。自定义网关直连同样做客户端软校验,不能作为不可绕过的授权保护。
|
||||||
|
|
||||||
## 全局 Tab 栏可用性
|
## 全局 Tab 栏可用性
|
||||||
|
|
||||||
当前 6 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
|
当前 6 个主 Tab 是高频导航入口,不能使用 Qt 默认的紧凑宽度。`MainWindow` 必须为 `QTabWidget/QTabBar` 设置基础样式:
|
||||||
@@ -110,6 +118,7 @@
|
|||||||
- 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。
|
- 筛选行提供「打开图片文件夹」按钮,用于只读打开本地图片目录:选中某行时打开该商品所在账号图片文件夹(优先打开已有新/旧封面文件的真实父目录,缺失时回退到规范账号目录);未选行且选择具体批次时打开该批次图片文件夹;未选行且为全部批次时打开图片根目录。目录不存在只中文提示,不自动创建目录,不修改任务状态。
|
||||||
- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。
|
- 右下:任务列表(店铺名、商品id、旧标题、新标题、标题状态、图片状态)+ AI生成运行日志;标题/图片状态由 `new_title`、`new_cover_path`、`stage/status` 和失败步骤推导,帮助用户区分“标题未生成 / 图片未生成 / 标题成功但图片失败”。商品ID列按原等分宽度约 50% 显示;标题状态和图片状态列在 T-554 基础上再缩到约 33%,缩出的宽度平均给旧标题和新标题。已生成、未提交线上、非运行中的任务可双击「新标题」列本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。
|
||||||
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出与①共用的纵向范围确认框:默认「生成架上商品」(即仅检测结果为正常的商品),「生成全部商品」为警示橙色描边,还会处理未上架、审核中、状态未知商品,可能额外消耗点数;未上架、审核中、状态未知默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。商品状态功能上线前的历史空状态会启动时迁移为默认正常;上线后仍为空的记录按未知处理。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时该警示选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。
|
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面prompt参考,新旧标题都为空才不纳入。开始前先从真实候选重新分组商品状态,弹出与①共用的纵向范围确认框:默认「生成架上商品」(即仅检测结果为正常的商品),「生成全部商品」为警示橙色描边,还会处理未上架、审核中、状态未知商品,可能额外消耗点数;未上架、审核中、状态未知默认不入队、不请求 AI、不消耗点数,用户明确选择全部范围才可入队。商品状态功能上线前的历史空状态会启动时迁移为默认正常;上线后仍为空的记录按未知处理。范围确认不跨轮记忆,取消或候选在确认期间变化均不启动生成;无异常候选时该警示选项禁用。标题/图片两条进度条右侧分别显示同宽用时标签(`生标题用时 N 秒` / `生图用时 N 秒`),运行中每秒递增,完成/停止后冻结;原图片进度条右侧的失败数和 cmhub 余额不再占用该位置。cmhub 模式会把用户设置的图片并发内部限制到最大 5,并用同样最大 5 的独立下载线程池拉取 `image_url`,不新增用户可见下载并发配置;运行日志显示用户设置并发和实际并发。下拉状态持久化到 `config.json` 的 `ai.generate_mode`,并继续写回旧兼容 `ai.generate_cover`。
|
||||||
|
- 点击「开始生成」还会先读取主窗口已验证的会员状态。未订阅、到期、Key 无效或状态暂时无法确认时不创建 `GenerateWorker`,引导到⑤设置或线上会员中心;旧服务订阅接口尚未启用时保持原有生成行为。
|
||||||
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
|
- 生成参数(标题/图片并发数、失败重试、分辨率、jpg 质量、cmhub 网关/Key/别名)在 **设置**;②只暴露本轮生成标题/封面/图文的内容模式。设置不新增“下载并发”控件;cmhub 图片下载并发由程序按实际生图并发自动计算,最大 5。
|
||||||
- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。
|
- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。
|
||||||
- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
||||||
@@ -223,6 +232,7 @@
|
|||||||
- AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。
|
- AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。
|
||||||
- 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。
|
- 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。
|
||||||
- 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
|
- 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
|
||||||
|
- 「AI帮写」和「生成套图」在新提交前共用主窗口会员预检;默认网关或自定义网关直连都一样。继续查询已提交默认网关任务、下载、预览、历史查看和导出不属于新提交,不因会员状态变化中断。
|
||||||
|
|
||||||
## 流程导航
|
## 流程导航
|
||||||
|
|
||||||
@@ -246,7 +256,7 @@
|
|||||||
|
|
||||||
| 组件 | 归属 | 说明 |
|
| 组件 | 归属 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
|
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、顶部会员状态、订阅受限恢复入口和全局消息 |
|
||||||
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
||||||
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` |
|
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` |
|
||||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
||||||
@@ -257,6 +267,7 @@
|
|||||||
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
||||||
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志;执行层再次拒绝非正常商品状态 |
|
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志;执行层再次拒绝非正常商品状态 |
|
||||||
| `AIModelTestWorker(BaseWorker)` | 设置 | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
| `AIModelTestWorker(BaseWorker)` | 设置 | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
||||||
|
| `SubscriptionCheckWorker(BaseWorker)` | 根窗口 | 后台查询 cmhub 账号订阅状态;结果只通过 signal 回主线程更新会员标签和工作流可用性 |
|
||||||
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
||||||
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、默认网关异步或自定义网关同步套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget |
|
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、默认网关异步或自定义网关同步套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget |
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -3,7 +3,7 @@ id: T-686
|
|||||||
title: 会员订阅状态接入与启动访问门禁
|
title: 会员订阅状态接入与启动访问门禁
|
||||||
phase: 8
|
phase: 8
|
||||||
deps: []
|
deps: []
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-21
|
created: 2026-07-21
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,7 +59,10 @@ cmhub 需提供 `GET /api/v1/cmshopee/subscription/status`。2026-07-21 无凭
|
|||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
py -3.10 -m unittest tests.test_subscription tests.test_ai tests.test_image_studio_generation tests.test_gui
|
py -3.10 -m unittest discover -s tests -p test_subscription.py
|
||||||
|
py -3.10 -m unittest discover -s tests -p test_ai.py
|
||||||
|
py -3.10 -m unittest discover -s tests -p test_image_studio_generation.py
|
||||||
|
py -3.10 -m unittest discover -s tests -p test_gui.py
|
||||||
py -3.10 -m unittest discover -s tests
|
py -3.10 -m unittest discover -s tests
|
||||||
py -3.10 -m ruff check app tests main.py
|
py -3.10 -m ruff check app tests main.py
|
||||||
py -3.10 -m compileall app main.py
|
py -3.10 -m compileall app main.py
|
||||||
@@ -82,3 +85,5 @@ git diff --check
|
|||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
- 2026-07-21:任务创建;cmhub 订阅状态接口尚未上线,需在实现中保持 `404` 旧服务兼容。
|
- 2026-07-21:任务创建;cmhub 订阅状态接口尚未上线,需在实现中保持 `404` 旧服务兼容。
|
||||||
|
- 2026-07-21:新增 `app/subscription.py` 和 `SubscriptionCheckWorker`,复用 cmhub 共享 HTTP 会话查询订阅状态;主窗口在显示后异步检查,顶部显示账号/套餐/有效期,受限状态只保留设置恢复入口。② AI生成、⑥商品套图和 AI帮写的新提交接入同一预检,旧服务 `404` 放行既有工作流。
|
||||||
|
- 2026-07-21:验证通过:`py -3.10 -m compileall app main.py`、`py -3.10 -m ruff check app tests main.py`、订阅/应用配置/AI/套图/GUI 定向测试,以及 `py -3.10 -m unittest discover -s tests`(664 项)。无凭据请求线上订阅状态接口返回 `404`,已由单测和运行时兼容分支覆盖;服务端上线后需使用测试 API Key 做一次真实有效/到期/未订阅联调,不属于本仓库可完成的接口发布工作。
|
||||||
|
|||||||
@@ -480,6 +480,29 @@ class AppConfigTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_subscription_notice_id_persists_without_credentials(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config_path = os.path.join(temp_dir, "config.json")
|
||||||
|
|
||||||
|
loaded = appconfig.load_config(config_path)
|
||||||
|
self.assertEqual("", appconfig.subscription_notice_id(loaded))
|
||||||
|
|
||||||
|
saved = appconfig.save_subscription_notice_id(
|
||||||
|
"subscription-notice-20260721",
|
||||||
|
path=config_path,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"subscription-notice-20260721",
|
||||||
|
appconfig.subscription_notice_id(saved),
|
||||||
|
)
|
||||||
|
reloaded = appconfig.load_config(config_path)
|
||||||
|
self.assertEqual(
|
||||||
|
"subscription-notice-20260721",
|
||||||
|
appconfig.subscription_notice_id(reloaded),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_cmhub_base_url_normalizes_to_gateway_root(self):
|
def test_cmhub_base_url_normalizes_to_gateway_root(self):
|
||||||
cases = {
|
cases = {
|
||||||
"https://cmhub.example.com/": "https://cmhub.example.com",
|
"https://cmhub.example.com/": "https://cmhub.example.com",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app import (
|
|||||||
image_studio,
|
image_studio,
|
||||||
product_status,
|
product_status,
|
||||||
prompts,
|
prompts,
|
||||||
|
subscription,
|
||||||
update_check,
|
update_check,
|
||||||
update_installer,
|
update_installer,
|
||||||
)
|
)
|
||||||
@@ -11154,6 +11155,62 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_main_window_displays_active_subscription_in_header(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
window = MainWindow(config=self.make_config(temp_dir))
|
||||||
|
self.addCleanup(window.close)
|
||||||
|
status = subscription.SubscriptionStatus(
|
||||||
|
subscription.STATUS_ACTIVE,
|
||||||
|
account_name="主账号",
|
||||||
|
plan_name="专业版",
|
||||||
|
expires_at="2026-08-20T23:59:59+08:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
window._apply_subscription_status(status, show_prompt=False)
|
||||||
|
|
||||||
|
self.assertIn("主账号 · 专业版 · 有效至2026-08-20", window._subscription_label.text())
|
||||||
|
self.assertTrue(
|
||||||
|
all(window.tabs.isTabEnabled(index) for index in range(window.tabs.count()))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_main_window_restricts_tabs_when_subscription_is_invalid(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
window = MainWindow(config=self.make_config(temp_dir))
|
||||||
|
self.addCleanup(window.close)
|
||||||
|
status = subscription.SubscriptionStatus(subscription.STATUS_EXPIRED)
|
||||||
|
|
||||||
|
window._apply_subscription_status(status, show_prompt=False)
|
||||||
|
|
||||||
|
for index, title in enumerate(TAB_TITLES):
|
||||||
|
self.assertEqual(title == "设置", window.tabs.isTabEnabled(index))
|
||||||
|
self.assertEqual(TAB_TITLES.index("设置"), window.tabs.currentIndex())
|
||||||
|
|
||||||
|
def test_generate_and_suite_preflight_can_stop_new_submissions(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self.make_config(temp_dir)
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
def deny(action):
|
||||||
|
actions.append(action)
|
||||||
|
return False
|
||||||
|
|
||||||
|
generate_tab = GenerateTab(
|
||||||
|
config=config,
|
||||||
|
subscription_preflight_callback=deny,
|
||||||
|
)
|
||||||
|
self.addCleanup(generate_tab.close)
|
||||||
|
generate_tab.start_generate()
|
||||||
|
|
||||||
|
suite_tab = ProductSuiteTab(
|
||||||
|
config=config,
|
||||||
|
db_path=config["db_path"],
|
||||||
|
subscription_preflight_callback=deny,
|
||||||
|
)
|
||||||
|
self.addCleanup(suite_tab.close)
|
||||||
|
self.assertFalse(suite_tab.start_generation(None))
|
||||||
|
|
||||||
|
self.assertEqual(["开始 AI 生成", "生成商品套图"], actions)
|
||||||
|
|
||||||
def test_accounts_tab_launch_failure_restores_controls(self):
|
def test_accounts_tab_launch_failure_restores_controls(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = self.make_config(temp_dir)
|
cfg = self.make_config(temp_dir)
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from _helpers import TempDirMixin
|
||||||
|
|
||||||
|
from app import ai, appconfig, subscription
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionTests(TempDirMixin, unittest.TestCase):
|
||||||
|
def _config(self, temp_dir):
|
||||||
|
cmhub_path = os.path.join(temp_dir, "cmhub.json")
|
||||||
|
appconfig.save_cmhub_config({"api_key": "test-key"}, path=cmhub_path)
|
||||||
|
return {
|
||||||
|
"cmhub_config_path": cmhub_path,
|
||||||
|
"ai": {
|
||||||
|
"use_system_proxy": False,
|
||||||
|
"cmhub": {
|
||||||
|
"base_url": "https://cm.example.com",
|
||||||
|
"connect_timeout": 66,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_missing_key_requires_configuration(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
appconfig.save_cmhub_config({"api_key": ""}, path=config["cmhub_config_path"])
|
||||||
|
|
||||||
|
result = subscription.check_status(config)
|
||||||
|
|
||||||
|
self.assertEqual(subscription.STATUS_NOT_CONFIGURED, result.state)
|
||||||
|
self.assertFalse(result.allows_product_workflows)
|
||||||
|
|
||||||
|
def test_active_response_returns_safe_display_fields(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def request_json(*args, **kwargs):
|
||||||
|
calls.append((args, kwargs))
|
||||||
|
return {
|
||||||
|
"product_code": "cmshopee",
|
||||||
|
"account": {"display_name": "主账号"},
|
||||||
|
"plan": {"code": "pro", "display_name": "专业版"},
|
||||||
|
"status": "active",
|
||||||
|
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||||
|
"grace_expires_at": None,
|
||||||
|
"manage_url": "https://cm.example.com/user/subscriptions/cmshopee",
|
||||||
|
"notice_id": "notice-1",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subscription.check_status(
|
||||||
|
self._config(temp_dir),
|
||||||
|
request_json=request_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(subscription.STATUS_ACTIVE, result.state)
|
||||||
|
self.assertTrue(result.allows_product_workflows)
|
||||||
|
self.assertEqual("主账号", result.account_name)
|
||||||
|
self.assertEqual("专业版", result.plan_name)
|
||||||
|
self.assertEqual("2026-08-20", subscription.format_expiry(result.expires_at))
|
||||||
|
self.assertEqual(
|
||||||
|
"https://cm.example.com/user/subscriptions/cmshopee",
|
||||||
|
result.manage_url,
|
||||||
|
)
|
||||||
|
self.assertEqual("GET", calls[0][0][0])
|
||||||
|
self.assertNotIn("test-key", repr(result))
|
||||||
|
|
||||||
|
def test_grace_and_expired_states_are_distinct(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
|
||||||
|
def grace_request(*args, **kwargs):
|
||||||
|
return {
|
||||||
|
"product_code": "cmshopee",
|
||||||
|
"account": {"display_name": "主账号"},
|
||||||
|
"plan": {"display_name": "专业版"},
|
||||||
|
"status": "grace",
|
||||||
|
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||||
|
"grace_expires_at": "2026-08-23T23:59:59+08:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
grace = subscription.check_status(config, request_json=grace_request)
|
||||||
|
self.assertEqual(subscription.STATUS_GRACE, grace.state)
|
||||||
|
self.assertTrue(grace.allows_product_workflows)
|
||||||
|
|
||||||
|
def expired_request(*args, **kwargs):
|
||||||
|
return {
|
||||||
|
"product_code": "cmshopee",
|
||||||
|
"account": {"display_name": "主账号"},
|
||||||
|
"plan": {"display_name": "专业版"},
|
||||||
|
"status": "expired",
|
||||||
|
"expires_at": "2026-08-20T23:59:59+08:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
expired = subscription.check_status(config, request_json=expired_request)
|
||||||
|
self.assertEqual(subscription.STATUS_EXPIRED, expired.state)
|
||||||
|
self.assertFalse(expired.allows_product_workflows)
|
||||||
|
|
||||||
|
def test_legacy_404_keeps_existing_workflows_available(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
def request_json(*args, **kwargs):
|
||||||
|
raise ai.CMHubError("not_found", "接口不存在", status=404)
|
||||||
|
|
||||||
|
result = subscription.check_status(
|
||||||
|
self._config(temp_dir),
|
||||||
|
request_json=request_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(subscription.STATUS_LEGACY, result.state)
|
||||||
|
self.assertTrue(result.allows_product_workflows)
|
||||||
|
self.assertFalse(result.interface_available)
|
||||||
|
|
||||||
|
def test_auth_error_and_network_failure_have_different_states(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
|
||||||
|
def invalid_key(*args, **kwargs):
|
||||||
|
raise ai.CMHubError("unauthorized", "不应展示", status=401)
|
||||||
|
|
||||||
|
result = subscription.check_status(config, request_json=invalid_key)
|
||||||
|
self.assertEqual(subscription.STATUS_KEY_INVALID, result.state)
|
||||||
|
self.assertNotIn("不应展示", result.user_message)
|
||||||
|
|
||||||
|
def network_failure(*args, **kwargs):
|
||||||
|
raise ai.CMHubError("network_error", "不应展示")
|
||||||
|
|
||||||
|
result = subscription.check_status(config, request_json=network_failure)
|
||||||
|
self.assertEqual(subscription.STATUS_UNAVAILABLE, result.state)
|
||||||
|
self.assertNotIn("不应展示", result.user_message)
|
||||||
|
|
||||||
|
def test_invalid_product_or_external_manage_url_is_not_trusted(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
config = self._config(temp_dir)
|
||||||
|
|
||||||
|
def wrong_product(*args, **kwargs):
|
||||||
|
return {"product_code": "another-product", "status": "active"}
|
||||||
|
|
||||||
|
result = subscription.check_status(config, request_json=wrong_product)
|
||||||
|
self.assertEqual(subscription.STATUS_UNAVAILABLE, result.state)
|
||||||
|
|
||||||
|
def external_manage_url(*args, **kwargs):
|
||||||
|
return {
|
||||||
|
"product_code": "cmshopee",
|
||||||
|
"account": {"display_name": "主账号"},
|
||||||
|
"plan": {"display_name": "专业版"},
|
||||||
|
"status": "required",
|
||||||
|
"manage_url": "https://other.example.com/account",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subscription.check_status(config, request_json=external_manage_url)
|
||||||
|
self.assertEqual(subscription.STATUS_REQUIRED, result.state)
|
||||||
|
self.assertEqual("", result.manage_url)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user