feat(subscription): add membership access preflight
This commit is contained in:
@@ -1753,6 +1753,37 @@ def _cmhub_call_with_retry(
|
||||
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):
|
||||
headers = {
|
||||
"Authorization": "Bearer " + str(api_key),
|
||||
|
||||
@@ -122,6 +122,9 @@ DEFAULT_CONFIG = {
|
||||
"ratio": "1:1",
|
||||
},
|
||||
},
|
||||
"subscription": {
|
||||
"last_notice_id": "",
|
||||
},
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
app.aboutToQuit.connect(runtime_lease.release)
|
||||
window.show()
|
||||
QTimer.singleShot(0, window.begin_subscription_check)
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: update_health.write_health(health_context, "main_window_ready"),
|
||||
|
||||
+218
-1
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from .. import subscription
|
||||
from ..version import display_name
|
||||
from .tabs.accounts import AccountsTab
|
||||
from .tabs.apply import ApplyTab
|
||||
@@ -10,6 +14,7 @@ from .tabs.generate import GenerateTab
|
||||
from .tabs.product_suite import ProductSuiteTab
|
||||
from .tabs.settings import SettingsTab
|
||||
from .widgets import *
|
||||
from .workers import SubscriptionCheckWorker
|
||||
|
||||
|
||||
PREFERRED_WINDOW_SIZE = (1180, 760)
|
||||
@@ -106,6 +111,14 @@ class MainWindow(QMainWindow):
|
||||
self._settings_tab_index = TAB_TITLES.index("设置")
|
||||
self._last_tab_index = 0
|
||||
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.setObjectName("mainTabs")
|
||||
self.tabs.setStyleSheet(TAB_STYLE)
|
||||
@@ -115,7 +128,30 @@ class MainWindow(QMainWindow):
|
||||
settings_tab = self._settings_tab()
|
||||
if hasattr(settings_tab, "settingsSaved"):
|
||||
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")
|
||||
if startup_status:
|
||||
self.show_status(startup_status, level="success")
|
||||
@@ -150,6 +186,7 @@ class MainWindow(QMainWindow):
|
||||
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
|
||||
open_accounts_callback=lambda: self.open_accounts_tab(),
|
||||
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
|
||||
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
|
||||
)
|
||||
if title == "③ 更新蝦皮":
|
||||
return ApplyTab(
|
||||
@@ -179,6 +216,7 @@ class MainWindow(QMainWindow):
|
||||
config=self.config,
|
||||
config_path=self.config_path,
|
||||
status_callback=self.show_status,
|
||||
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
|
||||
)
|
||||
raise ValueError(f"未知主界面模块:{title}")
|
||||
|
||||
@@ -195,6 +233,182 @@ class MainWindow(QMainWindow):
|
||||
widget.refresh_gateway_state()
|
||||
label = "自定义网关" if str(backend) == "direct" else "默认网关"
|
||||
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):
|
||||
if self._reverting_tab_change:
|
||||
@@ -239,6 +453,9 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self._confirm_leave_settings_tab():
|
||||
self._subscription_closed = True
|
||||
if self._subscription_worker is not None:
|
||||
self._subscription_worker.cancel()
|
||||
event.accept()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
@@ -983,6 +983,7 @@ class GenerateTab(QWidget):
|
||||
title_templates_dir=None,
|
||||
open_accounts_callback=None,
|
||||
refresh_workflow_callback=None,
|
||||
subscription_preflight_callback=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
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.open_accounts_callback = open_accounts_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.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)
|
||||
@@ -1757,6 +1759,8 @@ class GenerateTab(QWidget):
|
||||
if self.generate_thread is not None:
|
||||
self._set_status("AI 生成正在进行...")
|
||||
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):
|
||||
return
|
||||
generate_mode = self._current_generate_mode()
|
||||
|
||||
@@ -1973,6 +1973,7 @@ class ProductSuiteTab(QWidget):
|
||||
config=None,
|
||||
config_path=None,
|
||||
status_callback=None,
|
||||
subscription_preflight_callback=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
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.product_suite_prompt_path = appconfig.product_suite_prompt_path(self.config)
|
||||
self.status_callback = status_callback
|
||||
self.subscription_preflight_callback = subscription_preflight_callback
|
||||
self.accounts = []
|
||||
self._states = {}
|
||||
self._retired_states = []
|
||||
@@ -4392,6 +4394,8 @@ class ProductSuiteTab(QWidget):
|
||||
def start_ai_write(self, checked=False):
|
||||
if not self._require_default_gateway("商品套图AI帮写"):
|
||||
return
|
||||
if self.subscription_preflight_callback is not None and not self.subscription_preflight_callback("开始 AI 帮写"):
|
||||
return
|
||||
state = self._displayed_state
|
||||
if state is None:
|
||||
return
|
||||
@@ -4729,6 +4733,8 @@ class ProductSuiteTab(QWidget):
|
||||
retry_job_id=None,
|
||||
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():
|
||||
return False
|
||||
if state.generation_running():
|
||||
|
||||
@@ -23,6 +23,7 @@ from .. import (
|
||||
image_studio_generation,
|
||||
image_studio_images,
|
||||
product_status,
|
||||
subscription,
|
||||
)
|
||||
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
|
||||
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):
|
||||
return {
|
||||
"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、拖拽)
|
||||
├── editor 登录检测 / 检测商品状态 / 采集旧标题旧封面 / 改标题 / 换封面 / 点更新
|
||||
├── product_status 商品状态代码、显示和分组规则
|
||||
├── subscription cmhub 账号订阅状态查询、响应归一化与客户端预检状态
|
||||
├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写)
|
||||
├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选)
|
||||
├── product_suite 套图设置归一化、数量计算、完整提示词与job规划
|
||||
@@ -78,6 +79,7 @@ imported → collected → generated → applied
|
||||
- `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。
|
||||
- `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。
|
||||
- `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` 排序的本地商品原图并返回可编辑卖点与白名单计费元数据。
|
||||
- `cmhub_models`:格式化 cmhub 模型别名,并维护仅进程内有效的短期模型目录缓存;缓存键使用规整网关地址和别名,不含 API Key,不写入配置、SQLite、日志或导出文件。
|
||||
|
||||
@@ -86,6 +88,7 @@ imported → collected → generated → applied
|
||||
- 应用配置(模型选择、生成参数、目录、Chrome 路径)→ `data/config.json`。
|
||||
- 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。
|
||||
- 订阅状态 → 仅进程内 `SubscriptionStatus`;顶部 GUI 只展示服务端返回的账号显示名、套餐名和有效期,不写入 SQLite、诊断日志或导出。`404` 表示服务端尚未启用订阅接口,客户端保持旧行为;网络异常不被误判为会员到期。
|
||||
- cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。
|
||||
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
|
||||
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
|
||||
|
||||
+12
-1
@@ -25,6 +25,14 @@
|
||||
|
||||
创建六个业务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 栏可用性
|
||||
|
||||
当前 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` 并回到可更新;双击其他列弹窗展示旧封面、新封面和历史候选图。
|
||||
- 底部**单个「开始生成」+「停止」**,并增加「生成内容」下拉:默认只生成标题,可选只生成封面或生成标题和封面;只生成封面不调用生文,有新标题时优先使用,没有时用已采集旧标题作为封面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。
|
||||
- 只生成标题时标题成功即写库并进入 `generated`,保留已有封面;只生成封面时通过组件级写库只保存 `new_cover_path`,不覆盖已有标题,也不把旧标题写入空的 `new_title`;生成标题和封面时按缺失组件增量补齐。“有新封面、无新标题”时标题状态为待生成、图片状态为已生成,后续补标题不重复生图。三种模式都写 `run_type=generate` 的 `run_logs/run_log_events` 和用户可读滚动日志;日志开头明确显示本轮生成内容。点击「开始生成」时先清空②界面可见日志并写入本轮开始摘要,运行中只追加本轮日志;不删除历史 `run_logs/run_log_events` 或本地 `data/logs/`。进入页面默认可显示“本轮日志会在开始运行后显示”,历史日志不自动混入当前运行界面。「停止」取消未开始项,可再次「开始生成」对剩余继续。即使用户通过商品状态筛选查看异常商品,也必须在范围确认框主动选择「生成全部商品」才会入队;默认「生成架上商品」不会因筛选而放宽。
|
||||
- 「重置生成结果」支持选中任务或当前筛选结果,运行中禁用;确认框提供「重置标题 / 重置封面 / 重置全部」,只改本地 DB,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。
|
||||
@@ -223,6 +232,7 @@
|
||||
- AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。
|
||||
- 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。
|
||||
- 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。
|
||||
- 「AI帮写」和「生成套图」在新提交前共用主窗口会员预检;默认网关或自定义网关直连都一样。继续查询已提交默认网关任务、下载、预览、历史查看和导出不属于新提交,不因会员状态变化中断。
|
||||
|
||||
## 流程导航
|
||||
|
||||
@@ -246,7 +256,7 @@
|
||||
|
||||
| 组件 | 归属 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、全局消息 |
|
||||
| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、状态栏、顶部会员状态、订阅受限恢复入口和全局消息 |
|
||||
| `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 |
|
||||
| `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` |
|
||||
| `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 |
|
||||
@@ -257,6 +267,7 @@
|
||||
| `BaseWorker(QObject)` | 后台 | 定义 `progress/log/row_updated/failed/finished/cancelled` signals |
|
||||
| `ApplyWorker(BaseWorker)` | ③ | 账号就绪预检、检查本轮更新、按每批最大条数分批、按账号并行或串行调用 `editor.apply_task(...)`、逐条 `set_applied()`,失败继续,写运行日志;执行层再次拒绝非正常商品状态 |
|
||||
| `AIModelTestWorker(BaseWorker)` | 设置 | 后台调用 `appconfig.test_ai_model()` 测试模型连接 |
|
||||
| `SubscriptionCheckWorker(BaseWorker)` | 根窗口 | 后台查询 cmhub 账号订阅状态;结果只通过 signal 回主线程更新会员标签和工作流可用性 |
|
||||
| `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 |
|
||||
| `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、默认网关异步或自定义网关同步套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget |
|
||||
|
||||
|
||||
+7
-2
@@ -3,7 +3,7 @@ id: T-686
|
||||
title: 会员订阅状态接入与启动访问门禁
|
||||
phase: 8
|
||||
deps: []
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-07-21
|
||||
---
|
||||
|
||||
@@ -59,7 +59,10 @@ cmhub 需提供 `GET /api/v1/cmshopee/subscription/status`。2026-07-21 无凭
|
||||
## 验证
|
||||
|
||||
```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 ruff check app tests 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:新增 `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)
|
||||
|
||||
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):
|
||||
cases = {
|
||||
"https://cmhub.example.com/": "https://cmhub.example.com",
|
||||
|
||||
@@ -22,6 +22,7 @@ from app import (
|
||||
image_studio,
|
||||
product_status,
|
||||
prompts,
|
||||
subscription,
|
||||
update_check,
|
||||
update_installer,
|
||||
)
|
||||
@@ -11154,6 +11155,62 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
||||
|
||||
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):
|
||||
with self.make_temp_dir() as 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