diff --git a/app/ai.py b/app/ai.py index 2673493..30e628e 100644 --- a/app/ai.py +++ b/app/ai.py @@ -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), diff --git a/app/appconfig.py b/app/appconfig.py index 64e7df2..ba4186e 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -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"])) diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 67347df..b5745a3 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -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"), diff --git a/app/gui/main_window.py b/app/gui/main_window.py index 699d2c8..3174eea 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -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() diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 19bc6a5..8258b94 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -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() diff --git a/app/gui/tabs/product_suite.py b/app/gui/tabs/product_suite.py index fcfeb5b..7a74257 100644 --- a/app/gui/tabs/product_suite.py +++ b/app/gui/tabs/product_suite.py @@ -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(): diff --git a/app/gui/workers.py b/app/gui/workers.py index 0ba7d80..5952724 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -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": "准备账号浏览器", diff --git a/app/subscription.py b/app/subscription.py new file mode 100644 index 0000000..ff80135 --- /dev/null +++ b/app/subscription.py @@ -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 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 75ff422..33a9c98 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -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)。 diff --git a/docs/routes.md b/docs/routes.md index cfce71b..8e4674a 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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 | diff --git a/docs/tasks/T-686.md b/docs/tasks/T-686.md index 005206a..ac0c04d 100644 --- a/docs/tasks/T-686.md +++ b/docs/tasks/T-686.md @@ -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 做一次真实有效/到期/未订阅联调,不属于本仓库可完成的接口发布工作。 diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 71d0bfa..2f4b63b 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -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", diff --git a/tests/test_gui.py b/tests/test_gui.py index 7713c87..e0bd570 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -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) diff --git a/tests/test_subscription.py b/tests/test_subscription.py new file mode 100644 index 0000000..71be7bb --- /dev/null +++ b/tests/test_subscription.py @@ -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()