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
|
||||
Reference in New Issue
Block a user