From df9da638413903d99d725c30bdc1ffd749c11f90 Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 28 Jul 2026 15:42:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=AD=96=E7=95=A5=E9=97=A8=E7=A6=81=20T-705?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/client_policy.py | 205 ++++++++++++++++++++++++++++++++++++ app/gui/__init__.py | 48 ++++++++- app/gui/main_window.py | 169 ++++++++++++++++++++++------- app/gui/tabs/settings.py | 32 +++++- app/subscription.py | 14 ++- app/update_check.py | 34 +++++- docs/04-architecture.md | 7 +- docs/api.md | 47 +++++++++ docs/packaging.md | 9 +- docs/routes.md | 15 +-- docs/tasks/T-705.md | 35 +++--- tests/test_client_policy.py | 97 +++++++++++++++++ tests/test_gui.py | 186 ++++++++++++++++++++++++++++++++ tests/test_subscription.py | 36 ++++++- tests/test_update_check.py | 64 +++++++++++ 15 files changed, 925 insertions(+), 73 deletions(-) create mode 100644 app/client_policy.py create mode 100644 tests/test_client_policy.py diff --git a/app/client_policy.py b/app/client_policy.py new file mode 100644 index 0000000..6273c59 --- /dev/null +++ b/app/client_policy.py @@ -0,0 +1,205 @@ +"""Remote client-policy parsing and non-sensitive local caching.""" + +from __future__ import annotations + +import json +import os +import tempfile +from dataclasses import dataclass, replace +from datetime import datetime, timezone + +from . import appconfig + + +POLICY_VERSION = 1 +CACHE_VERSION = 1 +CACHE_FILENAME = "client_policy.json" + + +class ClientPolicyError(ValueError): + """Raised when a remote or cached client policy is malformed.""" + + +@dataclass(frozen=True) +class ClientPolicy: + policy_version: int + subscription_check_enabled: bool + subscription_enforcement_enabled: bool + updated_at: str + source: str = "remote" + warning: str = "" + + @property + def mode(self) -> str: + if not self.subscription_check_enabled: + return "off" + if self.subscription_enforcement_enabled: + return "enforce" + return "observe" + + def to_contract_dict(self) -> dict: + return { + "policy_version": self.policy_version, + "subscription_check_enabled": self.subscription_check_enabled, + "subscription_enforcement_enabled": ( + self.subscription_enforcement_enabled + ), + "updated_at": self.updated_at, + } + + +def observation_policy(*, source="fallback", warning="") -> ClientPolicy: + """Return the fail-open client fallback that still observes entitlement.""" + + return ClientPolicy( + policy_version=POLICY_VERSION, + subscription_check_enabled=True, + subscription_enforcement_enabled=False, + updated_at="", + source=source, + warning=warning, + ) + + +def parse_client_policy(value, *, source="remote") -> ClientPolicy: + if not isinstance(value, dict): + raise ClientPolicyError("客户端策略不是 JSON 对象") + + policy_version = value.get("policy_version") + if type(policy_version) is not int or policy_version != POLICY_VERSION: + raise ClientPolicyError("客户端策略版本不受支持") + + check_enabled = value.get("subscription_check_enabled") + enforcement_enabled = value.get("subscription_enforcement_enabled") + if type(check_enabled) is not bool or type(enforcement_enabled) is not bool: + raise ClientPolicyError("客户端订阅策略开关必须是布尔值") + + updated_at = _parse_timezone_timestamp( + value.get("updated_at"), + field_name="客户端策略更新时间", + ) + warning = "" + if not check_enabled and enforcement_enabled: + enforcement_enabled = False + warning = "客户端订阅策略组合非法,已按关闭模式处理" + + return ClientPolicy( + policy_version=policy_version, + subscription_check_enabled=check_enabled, + subscription_enforcement_enabled=enforcement_enabled, + updated_at=updated_at, + source=source, + warning=warning, + ) + + +def policy_cache_path(config=None) -> str: + return appconfig.data_path("config", CACHE_FILENAME, config=config) + + +def save_cached_policy( + policy: ClientPolicy, + *, + config=None, + path=None, + cached_at=None, +) -> str: + destination = os.path.abspath(path or policy_cache_path(config)) + os.makedirs(os.path.dirname(destination), exist_ok=True) + acquired_at = _parse_timezone_timestamp( + cached_at or _now_iso(), + field_name="客户端策略缓存时间", + ) + payload = { + "cache_version": CACHE_VERSION, + "cached_at": acquired_at, + "client_policy": policy.to_contract_dict(), + } + temporary_path = "" + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=os.path.dirname(destination), + prefix="client-policy-", + suffix=".tmp", + delete=False, + ) as handle: + temporary_path = handle.name + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary_path, destination) + finally: + if temporary_path and os.path.exists(temporary_path): + try: + os.remove(temporary_path) + except OSError: + pass + return destination + + +def load_cached_policy(*, config=None, path=None) -> ClientPolicy: + source_path = os.path.abspath(path or policy_cache_path(config)) + try: + with open(source_path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except (OSError, ValueError) as exc: + raise ClientPolicyError("客户端策略缓存不可用") from exc + cache_version = payload.get("cache_version") if isinstance(payload, dict) else None + if type(cache_version) is not int or cache_version != CACHE_VERSION: + raise ClientPolicyError("客户端策略缓存版本不受支持") + _parse_timezone_timestamp( + payload.get("cached_at"), + field_name="客户端策略缓存时间", + ) + return parse_client_policy(payload.get("client_policy"), source="cache") + + +def resolve_client_policy( + remote_policy, + *, + config=None, + path=None, +) -> ClientPolicy: + """Prefer a valid live policy, then cache, then observation mode.""" + + if isinstance(remote_policy, ClientPolicy): + try: + save_cached_policy(remote_policy, config=config, path=path) + except (OSError, ClientPolicyError) as exc: + warning = _join_warning( + remote_policy.warning, + "客户端策略缓存写入失败:%s" % exc, + ) + return replace(remote_policy, source="remote", warning=warning) + return replace(remote_policy, source="remote") + + try: + return load_cached_policy(config=config, path=path) + except ClientPolicyError as exc: + return observation_policy( + warning="未取得有效远程策略或本地缓存,已使用观察模式:%s" % exc + ) + + +def _parse_timezone_timestamp(value, *, field_name) -> str: + text = str(value or "").strip() + if not text: + raise ClientPolicyError("%s不能为空" % field_name) + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ClientPolicyError("%s格式不正确" % field_name) from exc + if parsed.tzinfo is None or parsed.utcoffset() is None: + raise ClientPolicyError("%s必须包含时区" % field_name) + return text + + +def _now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def _join_warning(*messages) -> str: + return ";".join(str(message).strip() for message in messages if str(message).strip()) diff --git a/app/gui/__init__.py b/app/gui/__init__.py index 7af5c29..20f8c74 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -6,7 +6,16 @@ import os import sys from dataclasses import replace -from .. import appconfig, chrome, db, diagnostics, image_studio_generation, update_check, update_health +from .. import ( + appconfig, + chrome, + client_policy, + db, + diagnostics, + image_studio_generation, + update_check, + update_health, +) from ..version import APP_NAME, APP_VERSION, display_name from . import widgets as _widgets from .widgets import * @@ -70,6 +79,10 @@ def _write_update_check_diagnostic(message, *, result=None, exc=None): "min_supported_version": result.min_supported_version, "download_url": result.download_url, "error": result.error, + "client_policy_mode": ( + result.client_policy.mode if result.client_policy is not None else "" + ), + "client_policy_error": result.client_policy_error, } try: diagnostics.write_diagnostic_log( @@ -91,15 +104,24 @@ def _show_forced_update_dialog(result, *, parent=None, dialog_factory=None) -> b return False -def _run_startup_update_gate(*, checker=None, dialog_factory=None) -> bool: +def _run_startup_update_gate( + *, + checker=None, + dialog_factory=None, + result_callback=None, +) -> bool: try: result = (checker or update_check.check_for_update)() except Exception as exc: _write_update_check_diagnostic("启动版本检查异常,已允许继续使用", exc=exc) return True + if result_callback is not None: + result_callback(result) if result.error: _write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result) + elif result.client_policy_error: + _write_update_check_diagnostic("客户端订阅策略已按安全兼容规则处理", result=result) if result.forced: failed = update_health.get_failed_release( appconfig.app_base_dir(), @@ -148,7 +170,8 @@ def main() -> int: update_health.write_health(health_context, "environment_blocked", str(exc)) QMessageBox.critical(None, "启动配置错误", str(exc)) return 1 - if not _run_startup_update_gate(): + startup_update_results = [] + if not _run_startup_update_gate(result_callback=startup_update_results.append): return 1 try: startup = chrome.ensure_configured_chrome_path() @@ -183,9 +206,28 @@ def main() -> int: ) except Exception as exc: _write_update_check_diagnostic("自定义网关套图任务恢复检查失败", exc=exc) + remote_policy = ( + startup_update_results[-1].client_policy + if startup_update_results + else None + ) + resolved_policy = client_policy.resolve_client_policy( + remote_policy, + config=startup["config"], + ) + if resolved_policy.warning: + _write_update_check_diagnostic( + "客户端订阅策略使用兼容模式", + result=( + startup_update_results[-1] + if startup_update_results + else None + ), + ) window = MainWindow( config=startup["config"], startup_status=startup["message"], + subscription_policy=resolved_policy, ) if runtime_lease is not None: app.aboutToQuit.connect(runtime_lease.release) diff --git a/app/gui/main_window.py b/app/gui/main_window.py index 51a4641..49477f2 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -5,7 +5,7 @@ from __future__ import annotations from PySide6.QtCore import QUrl from PySide6.QtGui import QDesktopServices -from .. import subscription +from .. import client_policy, subscription from ..version import display_name from .activation_dialog import MembershipActivationDialog from .tabs.accounts import AccountsTab @@ -21,7 +21,7 @@ from .workers import SubscriptionCheckWorker PREFERRED_WINDOW_SIZE = (1180, 760) MIN_WINDOW_SIZE = (960, 640) WINDOW_SCREEN_MARGIN = 40 -# 当前开发版本启用真实查询和客户端强制门禁。 +# 仅供直接构造 MainWindow 的兼容入口使用;正式启动以远程策略为准。 SUBSCRIPTION_CHECK_ENABLED = True SUBSCRIPTION_ENFORCEMENT_ENABLED = True @@ -91,6 +91,7 @@ class MainWindow(QMainWindow): config_path=None, ai_models_path=None, startup_status="", + subscription_policy=None, ): super().__init__() self._initial_window_fit_applied_after_show = False @@ -106,6 +107,17 @@ class MainWindow(QMainWindow): or self.config.get("ai_models_path") or appconfig.ai_models_config_path(self.config) ) + self._client_policy = ( + subscription_policy + if isinstance(subscription_policy, client_policy.ClientPolicy) + else client_policy.ClientPolicy( + policy_version=client_policy.POLICY_VERSION, + subscription_check_enabled=SUBSCRIPTION_CHECK_ENABLED, + subscription_enforcement_enabled=SUBSCRIPTION_ENFORCEMENT_ENABLED, + updated_at="", + source="compatibility", + ) + ) self.setWindowTitle(display_name()) window_icon = app_icon() if window_icon is not None: @@ -123,6 +135,8 @@ class MainWindow(QMainWindow): self._subscription_request_token = 0 self._subscription_closed = False self._expired_subscription_notice_shown = False + self._subscription_notice_state = None + self._subscription_notice_states_seen = set() self._activation_dialog = None self._first_use_guide_shown = False self.tabs = QTabWidget() @@ -196,12 +210,17 @@ class MainWindow(QMainWindow): status_callback=self.show_status, ) if title == "设置": - return SettingsTab( + tab = SettingsTab( config=self.config, config_path=self.config_path, ai_models_path=self.ai_models_path, status_callback=self.show_status, ) + if hasattr(tab, "set_subscription_policy_enabled"): + tab.set_subscription_policy_enabled( + self._client_policy.subscription_check_enabled + ) + return tab if title == "商品套图": return ProductSuiteTab( db_path=self.db_path, @@ -231,20 +250,26 @@ class MainWindow(QMainWindow): def subscription_status(self): return self._subscription_status + @property + def subscription_policy(self): + return self._client_policy + def begin_subscription_check(self): """Refresh membership state without blocking the Qt GUI thread.""" self._set_membership_window_title() - if not SUBSCRIPTION_CHECK_ENABLED: + if not self._client_policy.subscription_check_enabled: self._set_product_access(True) self._set_subscription_check_running(False) - self.show_status("会员订阅检测已暂停", level="muted") + self.show_status("服务端暂未启用会员检测", level="muted") return 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(not SUBSCRIPTION_ENFORCEMENT_ENABLED) + self._set_product_access( + not self._client_policy.subscription_enforcement_enabled + ) self._set_subscription_check_running(True) self.show_status("正在验证会员状态", level="info") worker = SubscriptionCheckWorker( @@ -272,7 +297,10 @@ class MainWindow(QMainWindow): def ensure_subscription_for_new_submit(self, action_name="生成"): """Return whether a new product request may start from the current UI.""" - if not SUBSCRIPTION_CHECK_ENABLED or not SUBSCRIPTION_ENFORCEMENT_ENABLED: + if ( + not self._client_policy.subscription_check_enabled + or not self._client_policy.subscription_enforcement_enabled + ): return True status = self._subscription_status if status.allows_product_workflows: @@ -309,6 +337,8 @@ class MainWindow(QMainWindow): self._subscription_status = status if status.allows_product_workflows: self._expired_subscription_notice_shown = False + self._subscription_notice_state = None + self._subscription_notice_states_seen.clear() if status.state == subscription.STATUS_ACTIVE: expiry = subscription.format_expiry(status.expires_at) self._set_membership_window_title( @@ -330,25 +360,25 @@ class MainWindow(QMainWindow): access_allowed = ( status.allows_product_workflows - or not SUBSCRIPTION_ENFORCEMENT_ENABLED + or not self._client_policy.subscription_enforcement_enabled ) self._set_product_access(access_allowed) if status.allows_product_workflows: - if status.state == subscription.STATUS_LEGACY: - self.show_status(status.user_message, level="muted") - else: - message = ( - "会员状态已验证" - if SUBSCRIPTION_ENFORCEMENT_ENABLED - else "会员状态已验证,当前处于观察模式" - ) - self.show_status(message, level="success") - if SUBSCRIPTION_ENFORCEMENT_ENABLED and show_notice: - self._show_subscription_notice_once(status) + message = ( + "会员状态已验证" + if self._client_policy.subscription_enforcement_enabled + else "会员状态已验证,当前处于观察模式" + ) + self.show_status(message, level="success") + if ( + self._client_policy.subscription_enforcement_enabled + and show_notice + ): + self._show_subscription_notice_once(status) if show_first_use_guide: QTimer.singleShot(0, self._show_first_use_guide_if_pending) return - if not SUBSCRIPTION_ENFORCEMENT_ENABLED: + if not self._client_policy.subscription_enforcement_enabled: self.show_status( "订阅观察:%s,当前不影响使用" % status.user_message, level=self._subscription_level(status), @@ -359,9 +389,17 @@ class MainWindow(QMainWindow): self.show_status("请先绑定会员账号", level="warning") self._show_membership_activation() return + if status.state == subscription.STATUS_KEY_INVALID: + self.open_settings_tab() + self._show_membership_activation() + if self._activation_dialog is not None: + self._activation_dialog.focus_api_key() + return self.open_settings_tab() if status.state == subscription.STATUS_EXPIRED: self._show_expired_subscription_notice_once(status) + return + self._show_subscription_state_notice_once(status) def _show_membership_activation(self): if self._activation_dialog is not None: @@ -489,6 +527,7 @@ class MainWindow(QMainWindow): if ( result != QDialog.Accepted and not self._subscription_closed + and self._client_policy.subscription_enforcement_enabled and not self._subscription_status.allows_product_workflows ): self.close() @@ -605,38 +644,94 @@ class MainWindow(QMainWindow): settings_tab.set_subscription_check_running(running) def _show_expired_subscription_notice_once(self, status): - if self._expired_subscription_notice_shown: - return - self._expired_subscription_notice_shown = True + self._show_subscription_state_notice_once(status) + def _show_subscription_state_notice_once(self, status): + state = status.state + notice_state = ( + subscription.STATUS_UNAVAILABLE + if state == subscription.STATUS_LEGACY + else state + ) + if notice_state in self._subscription_notice_states_seen: + return + self._subscription_notice_states_seen.add(notice_state) + self._subscription_notice_state = notice_state + self._expired_subscription_notice_shown = ( + notice_state == subscription.STATUS_EXPIRED + ) + + titles = { + subscription.STATUS_REQUIRED: "尚未开通会员套餐", + subscription.STATUS_EXPIRED: "会员套餐已过期", + subscription.STATUS_REVOKED: "会员套餐已失效", + subscription.STATUS_ACCOUNT_DISABLED: "会员账号当前不可用", + subscription.STATUS_UNAVAILABLE: "暂时无法验证会员状态", + } + messages = { + subscription.STATUS_REQUIRED: ( + "当前账号尚未开通蝦皮圈会员套餐,业务功能已暂停。" + "请前往会员中心选择套餐,完成后重新检测会员状态。" + ), + subscription.STATUS_EXPIRED: ( + "当前账号的蝦皮圈会员已到期,业务功能已暂停。" + "请前往会员中心续费或更换套餐,完成后重新检测会员状态。" + ), + subscription.STATUS_REVOKED: ( + "当前账号的蝦皮圈会员套餐已失效,业务功能已暂停。" + "请前往会员中心查看套餐状态,处理后重新检测。" + ), + subscription.STATUS_ACCOUNT_DISABLED: ( + "当前会员账号暂时不可用,业务功能已暂停。" + "请前往会员中心查看账号状态或联系管理员处理。" + ), + subscription.STATUS_UNAVAILABLE: ( + "当前暂时无法验证会员状态,业务功能已暂停。" + "这不代表账号未付费,请检查网络或服务状态后重新检测。" + ), + } box = QMessageBox(self) box.setIcon(QMessageBox.Warning) - box.setWindowTitle("会员套餐已过期") - text = ( - "当前账号的蝦皮圈会员已到期,业务功能已暂停。" - "请前往会员中心续费或更换套餐,完成后返回设置重新检测会员状态。" + box.setWindowTitle(titles.get(notice_state, "暂时无法验证会员状态")) + text = messages.get( + notice_state, + messages[subscription.STATUS_UNAVAILABLE], ) + + manage_button = None + settings_button = None manage_url = str(status.manage_url or "").strip() - if not manage_url: - text += "\n\n会员中心地址当前不可用,请检查默认网关配置或稍后重试。" - box.setText(text) - manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole) + if notice_state != subscription.STATUS_UNAVAILABLE: + manage_button = box.addButton( + "前往会员中心", + QMessageBox.ActionRole, + ) + manage_button.setEnabled(bool(manage_url)) + if manage_url: + box.setDefaultButton(manage_button) + else: + manage_button.setToolTip("会员中心地址当前不可用") + text += "\n\n会员中心地址当前不可用,请稍后重试。" + retry_button = box.addButton("重新检测", QMessageBox.AcceptRole) + if notice_state == subscription.STATUS_UNAVAILABLE: + settings_button = box.addButton("打开设置", QMessageBox.ActionRole) + box.setDefaultButton(retry_button) exit_button = box.addButton("退出程序", QMessageBox.DestructiveRole) - manage_button.setEnabled(bool(manage_url)) - if manage_url: - box.setDefaultButton(manage_button) - else: - manage_button.setToolTip("会员中心地址当前不可用") + box.setText(text) box.exec() clicked = box.clickedButton() - if clicked is manage_button and manage_url: + if manage_button is not None and clicked is manage_button and manage_url: opened = QDesktopServices.openUrl(QUrl(manage_url)) if opened is False: self.show_status( "无法打开会员中心,请检查系统默认浏览器后重试", level="warning", ) + elif clicked is retry_button: + self.begin_subscription_check() + elif settings_button is not None and clicked is settings_button: + self.open_settings_tab() elif clicked is exit_button: self.close() diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index b190dc6..e5b695b 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -283,6 +283,7 @@ class SettingsTab(QWidget): self.subscription_check_button = QPushButton("重新检测会员状态") self.subscription_check_button.setObjectName("subscriptionCheckButton") self.subscription_check_button.setToolTip("重新查询当前 cmhub 账号的会员套餐状态") + self._subscription_policy_enabled = True self.unsaved_changes_label = QLabel("● 未保存更改") self.unsaved_changes_label.setObjectName("settingsUnsavedChangesLabel") self.unsaved_changes_label.setStyleSheet("color: #bc4c00; font-weight: 600;") @@ -557,13 +558,40 @@ class SettingsTab(QWidget): _emit_status(self.status_callback, message, level=level) def _request_subscription_check(self, checked=False): + if not self._subscription_policy_enabled: + return self.subscriptionCheckRequested.emit() + def set_subscription_policy_enabled(self, enabled): + self._subscription_policy_enabled = bool(enabled) + self.subscription_check_button.setVisible(True) + self.subscription_check_button.setEnabled(self._subscription_policy_enabled) + if self._subscription_policy_enabled: + self.subscription_check_button.setText("重新检测会员状态") + self.subscription_check_button.setToolTip( + "重新查询当前 cmhub 账号的会员套餐状态" + ) + return + self.subscription_check_button.setText("会员检测暂未启用") + self.subscription_check_button.setToolTip( + "服务端当前未启用客户端会员检测,本次运行无需手动检测" + ) + def set_subscription_check_running(self, running): running = bool(running) - self.subscription_check_button.setEnabled(not running) + self.subscription_check_button.setEnabled( + self._subscription_policy_enabled and not running + ) self.subscription_check_button.setText( - "正在检测会员状态..." if running else "重新检测会员状态" + ( + "正在检测会员状态..." + if running + else ( + "重新检测会员状态" + if self._subscription_policy_enabled + else "会员检测暂未启用" + ) + ) ) def refresh_default_gateway_credentials(self): diff --git a/app/subscription.py b/app/subscription.py index 7015263..2cf67c9 100644 --- a/app/subscription.py +++ b/app/subscription.py @@ -51,6 +51,7 @@ class SubscriptionStatus: grace_expires_at: str = "" manage_url: str = "" notice_id: str = "" + real_entitlement_allowed: bool = False def __post_init__(self): if self.state not in _ALLOWED_STATES: @@ -58,7 +59,7 @@ class SubscriptionStatus: @property def allows_product_workflows(self) -> bool: - return self.state in {STATUS_ACTIVE, STATUS_GRACE, STATUS_LEGACY} + return self.real_entitlement_allowed @property def credentials_accepted(self) -> bool: @@ -184,6 +185,16 @@ def _parse_status_response(data, base_url: str) -> SubscriptionStatus: normalized = _normalize_remote_state(data.get("status")) if not normalized: return SubscriptionStatus(STATUS_UNAVAILABLE) + real_entitlement_allowed = data.get("real_entitlement_allowed") + if type(real_entitlement_allowed) is not bool: + return SubscriptionStatus(STATUS_UNAVAILABLE) + if real_entitlement_allowed and normalized not in { + STATUS_ACTIVE, + STATUS_GRACE, + }: + return SubscriptionStatus(STATUS_UNAVAILABLE) + if normalized in {STATUS_ACTIVE, STATUS_GRACE} and not real_entitlement_allowed: + 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() @@ -211,6 +222,7 @@ def _parse_status_response(data, base_url: str) -> SubscriptionStatus: grace_expires_at=grace_expires_at, manage_url=manage_url, notice_id=notice_id, + real_entitlement_allowed=real_entitlement_allowed, ) diff --git a/app/update_check.py b/app/update_check.py index da9ad13..0b69cbe 100644 --- a/app/update_check.py +++ b/app/update_check.py @@ -11,6 +11,7 @@ import re import urllib.request from dataclasses import dataclass +from . import client_policy from .version import APP_CODE_NAME, APP_UPDATE_CHECK_URL, APP_VERSION DEFAULT_TIMEOUT_SECONDS = 3.0 @@ -54,6 +55,8 @@ class UpdateCheckResult: automatic_update_error: str = "" message: str = "" error: str = "" + client_policy: client_policy.ClientPolicy | None = None + client_policy_error: str = "" @property def can_enter(self) -> bool: @@ -202,8 +205,31 @@ def check_for_update( return UpdateCheckResult(current_version=current_version, checked=False) try: - payload = (fetcher or fetch_update_payload)(url, timeout) - info = parse_update_info(_decode_payload(payload)) + payload = _decode_payload((fetcher or fetch_update_payload)(url, timeout)) + except Exception as exc: + return UpdateCheckResult( + current_version=current_version, + checked=True, + forced=False, + error=f"启动版本检查失败,已允许继续使用:{exc}", + ) + + resolved_policy = None + policy_error = "" + if isinstance(payload, dict) and "client_policy" in payload: + try: + resolved_policy = client_policy.parse_client_policy( + payload.get("client_policy") + ) + policy_error = resolved_policy.warning + except client_policy.ClientPolicyError as exc: + policy_error = str(exc) + + try: + if isinstance(payload, dict) and payload.get("release", object()) is None: + info = UpdateInfo() + else: + info = parse_update_info(payload) forced = is_forced_update(info, current_version) return UpdateCheckResult( current_version=current_version, @@ -220,6 +246,8 @@ def check_for_update( signature_algorithm=info.signature_algorithm, manifest_signature=info.manifest_signature, message=info.message, + client_policy=resolved_policy, + client_policy_error=policy_error, ) except Exception as exc: return UpdateCheckResult( @@ -227,4 +255,6 @@ def check_for_update( checked=True, forced=False, error=f"启动版本检查失败,已允许继续使用:{exc}", + client_policy=resolved_policy, + client_policy_error=policy_error, ) diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 7ab9f14..20f4c32 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 商品状态代码、显示和分组规则 + ├── client_policy 启动订阅策略解析、非敏感缓存与兼容回退 ├── subscription cmhub 账号订阅状态查询、响应归一化与客户端预检状态 ├── ai 文本生成(提示词+旧标题→新标题)/ 图像生成(提示词+旧封面→新封面)/ 商品原图理解(商品套图AI帮写) ├── image_studio 商品套图项目/资产/job数据服务(兼容旧AI工场终选) @@ -79,7 +80,8 @@ imported → collected → generated → applied - `cdp`:连接调试端口、找/开 tab、执行 JS、拖拽、注入文件。 - `editor`:登录检测、读取商品状态、**采集**(读旧标题、下载旧封面)、改标题、换封面、点更新。 - `product_status`:商品状态代码 `normal/unlisted/reviewing/unknown` 的归一化、中文显示、EDS 提示分类和下游任务分组;①②③只能复用该模块,不各自判断。 -- `subscription`:使用默认网关 API Key 查询 `GET /api/v1/cmshopee/subscription/status`,把远端权益结果归一化为有效、宽限、未订阅、到期、撤销、账号不可用、Key 无效、暂时不可用或旧服务兼容状态;不展示或记录 API Key,服务端始终是最终授权方。T-700 后当前开发版本固定为 `SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = True`:查询期间和明确不允许状态只保留“设置”Tab,新提交统一由主窗口预检拦截;有效、宽限和旧服务兼容状态恢复工作流。T-701 后缺少 Key 的干净安装不再裸露技术设置,而是显示只收集“会员 API Key”的激活窗口;输入值先在内存中验证,只有服务端未拒绝该凭据时才保存到 `data/config/cmhub.json`。 +- `client_policy`:解析启动版本接口顶层 `client_policy`,严格校验 `policy_version=1`、布尔开关和带时区 `updated_at`;把最后一次有效的非敏感策略及取得时间原子缓存到 `data/config/client_policy.json`。本轮运行按 `off/observe/enforce` 三种模式执行;远端缺失或非法时优先读缓存,无缓存回退观察模式。`check=false,enforcement=true` 防御性归一化为关闭,不会在未检测时锁死客户端。 +- `subscription`:使用默认网关 API Key 查询 `GET /api/v1/cmshopee/subscription/status`,把远端结果归一化为有效、宽限、未订阅、到期、账号不可用、Key 无效或暂时不可用;兼容识别历史撤销/旧接口状态,但不把它们视为真实权益。强制模式只以服务端顶层严格布尔字段 `real_entitlement_allowed` 放行业务功能,不依据展示用 `status` 或兼容 `allowed` 推断;服务端始终是最终授权方。T-701 后缺少 Key 的干净安装显示只收集“会员 API Key”的激活窗口,输入值先在内存中验证,只有服务端未拒绝该凭据时才保存到 `data/config/cmhub.json`。 - `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、日志或导出文件。 @@ -88,7 +90,8 @@ 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`;有效/宽限状态把服务端返回的账号显示名、套餐名和有效期追加到 Windows 原生窗口标题,其他状态恢复纯应用名称并在底部状态栏显示脱敏中文结果。Tabs 上方不保留会员状态行,状态不写入 SQLite、诊断日志或导出。强制模式首次进入 `expired` 时,在门禁生效后弹一次中文窗口;安全的同网关 HTTPS `manage_url` 可用默认浏览器打开,退出走主窗口正常关闭。重复 `expired` 不重弹,恢复允许状态后才重置本次运行的弹窗标记。设置页只发出“重新检测会员状态”信号,由主窗口复用异步查询和陈旧结果隔离;`404` 表示服务端尚未启用订阅接口并按旧服务兼容放行,网络异常不被误判为会员到期。首次激活后的使用清单只在 `config.json` 保存非敏感状态 `onboarding.first_use_guide_state`,取值为空、`pending`、`completed` 或 `dismissed`;默认空值保证存量用户不会被误判为新用户。 +- 客户端订阅策略 → `data/config/client_policy.json`,只含策略版本、两个布尔开关、服务端更新时间和本地取得时间,不含 Key、账号、套餐或订阅结果;线上有效响应覆盖缓存,接口失败、旧响应或策略字段非法时读取缓存,无缓存使用观察模式。 +- 订阅状态 → 仅进程内 `SubscriptionStatus`;有效/宽限状态把服务端返回的账号显示名、套餐名和有效期追加到 Windows 原生窗口标题,其他状态恢复纯应用名称并在底部状态栏显示脱敏中文结果。`off` 不请求状态且放行;`observe` 查询展示但不限制;`enforce` 查询期间及真实权益无效时只保留“设置”Tab,并按未配置、未开通、到期、撤销兼容、账号不可用、Key 无效、接口不可用显示对应中文处理窗口。窗口按状态转换去重,恢复真实权益后才允许同类状态再次提示;网络异常不得描述成未付费。首次激活后的使用清单只在 `config.json` 保存非敏感状态 `onboarding.first_use_guide_state`,取值为空、`pending`、`completed` 或 `dismissed`。 - cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 diff --git a/docs/api.md b/docs/api.md index 71f2e60..0378cf4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -61,6 +61,53 @@ get_cmhub_api_key(path="data/config/cmhub.json", masked=False) -> str ``` `data/config.json` 只保存 `ai.backend`、`ai.cmhub.base_url/title_alias/image_alias/vision_alias/connect_timeout` 等非密钥配置;`vision_alias` 默认 `vision-standard`,供商品套图中的「AI帮写」独立使用。T-529 后普通设置页固定保存 `ai.backend=cmhub`,不暴露后端切换;`data/config/cmhub.json` 必须 gitignore,展示时打码,不写日志/导出。 + +## client_policy / update_check 模块(`app/client_policy.py`、`app/update_check.py`) + +启动版本接口 `GET /api/v1/client/releases/latest?platform=windows` 除 `release` 外可返回顶层 `client_policy`。策略解析与版本解析互相隔离:策略字段错误不能破坏强制升级判断,`release=null` 也可单独下发策略。 + +```python +class ClientPolicyError(ValueError): ... +class ClientPolicy: + policy_version: int + subscription_check_enabled: bool + subscription_enforcement_enabled: bool + updated_at: str + source: str + warning: str + mode: str # off / observe / enforce + +parse_client_policy(value, source="remote") -> ClientPolicy +policy_cache_path(config=None) -> str # data/config/client_policy.json +save_cached_policy(policy, config=None, path=None, cached_at=None) -> str +load_cached_policy(config=None, path=None) -> ClientPolicy +resolve_client_policy(remote_policy, config=None, path=None) -> ClientPolicy +check_for_update(...) -> UpdateCheckResult # 同时透传 client_policy/client_policy_error +``` + +`policy_version` 必须为整数 `1`,两个开关必须为 JSON 布尔值,`updated_at` 必须是带时区 ISO 8601。`false/true` 非法组合归一化为 `false/false`。有效远程策略写非敏感原子缓存;远程缺失/非法/不可达时先用缓存,无缓存回退 `true/false` 观察模式。缓存不得包含 API Key、账号、套餐或订阅结果。 + +## subscription 模块(`app/subscription.py`) + +```python +class SubscriptionStatus: + state: str + account_name: str + plan_name: str + expires_at: str + grace_expires_at: str + manage_url: str + notice_id: str + real_entitlement_allowed: bool + + allows_product_workflows: bool # 只返回 real_entitlement_allowed + +check_status(config=None, ...) -> SubscriptionStatus +safe_manage_url(value, base_url) -> str +``` + +订阅状态接口为 `GET /api/v1/cmshopee/subscription/status`。成功响应必须提供顶层严格布尔字段 `real_entitlement_allowed`;强制模式只认该字段,不使用展示状态 `status` 或服务端兼容字段 `allowed` 推断授权。`true` 只允许与 `active/grace` 同时出现,字段缺失、类型错误或组合矛盾统一归为“暂时无法验证”。`manage_url` 只接受与默认网关同主机的 HTTPS 地址。 + AI 模型清单(`data/config/ai_models.json`,含本地明文密钥,已建;UI 由 设置复用): ```python diff --git a/docs/packaging.md b/docs/packaging.md index 41af871..9dcf1a8 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -188,6 +188,8 @@ T-623 为外部对象存储/CDN下载链接增加临时 HTTP 兼容。版本检 T-624 将 cmhub 版本接口收敛为实际字段:`version`、`download_url`、`sha256`、`release_notes`、`force_update`、`size_bytes`、`published_at`。服务端不需要返回 `package_format` 或更新器协议;客户端固定使用当前 `cmshopee-portable-v1` 和更新器协议构造安装元数据,并在解压后以包内manifest复核真实格式和协议。因此接口字段减少不等于跳过包格式或协议安全检查。 +T-705 复用同一 HTTPS 版本接口顶层 `client_policy` 下发订阅检测策略,不增加新的同步启动请求。最后一次有效策略缓存到 `data/config/client_policy.json`,只含非敏感开关与时间;远端和缓存都不可用时回退观察模式。安装包不得预置该缓存,干净安装第一次启动以线上响应为准。远程 `off` 跳过订阅状态请求,`observe` 只展示不限制,`enforce` 才在主窗口显示后异步查询并按 `real_entitlement_allowed` 执行门禁。 + ## 四、绝不打包的本地数据 发布包里不能包含以下本地数据、密钥、业务数据或登录态: @@ -224,6 +226,7 @@ cmshopee\ config\ ai_models.json cmhub.json + client_policy.json cmshopee.db chrome_user_data_dir\ images\ @@ -234,7 +237,7 @@ cmshopee\ 源码运行时同理使用项目根目录下的 `data\`。`config.json` 内的 `user_data_root`、`image_dir`、`db_path` 默认仍保存为 `chrome_user_data_dir`、`images`、`cmshopee.db` 等相对值,运行时再解析到 `data\` 下,保持便携。 -干净安装的默认网关地址已经内置为 `https://cm.833729.com`,用户不需要先填写 Base URL。`data\config\cmhub.json` 不随安装包分发真实 Key;首次启动检测到 Key 缺失时会显示「激活蝦皮圈优化助手」,用户从官方会员中心取得会员 API Key 后在该窗口验证。无效 Key 或网络失败不会写入本地,验证通过后才保存到 `data\config\cmhub.json`,随后显示添加店铺、人工登录、导入 Excel 和开始采集的首次使用清单。 +干净安装的默认网关地址已经内置为 `https://cm.833729.com`,用户不需要先填写 Base URL。`data\config\cmhub.json` 不随安装包分发真实 Key;当线上策略为强制模式且检测到 Key 缺失时显示「激活蝦皮圈优化助手」,用户从官方会员中心取得会员 API Key 后在该窗口验证。无效 Key 或网络失败不会写入本地,验证通过后才保存到 `data\config\cmhub.json`,随后显示添加店铺、人工登录、导入 Excel 和开始采集的首次使用清单。关闭或观察模式不得因缺 Key 阻断启动。 这些文件属于用户本地数据,不随新版本程序包覆盖。 @@ -274,8 +277,8 @@ powershell -ExecutionPolicy Bypass -File scripts\build_exe.ps1 - 当前 PyInstaller 6.11.1 下 `dist\cmshopee\_internal\` 必须存在。 - `dist\cmshopee\` 中没有第四节列出的本地数据,尤其不能含 `data\`。 - `release\蝦皮圈優化助手\version.txt`、GUI 标题栏版本、压缩包文件名三者一致。 -- 在干净目录首次启动时能生成 `data\config.json`,默认网关地址为 `https://cm.833729.com`,并出现只要求“会员 API Key”的中文激活窗口;安装包和新建 `data\` 中不得预置任何真实 Key。 -- 使用测试接口模拟无效 Key、网络失败和有效套餐:前两者不得生成含输入 Key 的本地文件或日志,有效套餐才写入 `data\config\cmhub.json`、启用业务 Tab 并显示首次使用清单。 +- 在干净目录首次启动时能生成 `data\config.json`,默认网关地址为 `https://cm.833729.com`;安装包和新建 `data\` 中不得预置真实 Key 或 `client_policy.json`。线上强制策略且缺 Key 时才出现只要求“会员 API Key”的中文激活窗口。 +- 使用测试接口模拟 `off/observe/enforce`、无效 Key、网络失败和有效套餐:关闭模式不请求订阅状态,观察模式不限制,强制模式只有 `real_entitlement_allowed=true` 才启用业务 Tab;输入的无效 Key 和网络失败不得写入本地文件或日志。 - 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。 涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。 diff --git a/docs/routes.md b/docs/routes.md index 0fee380..a9b7056 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -27,14 +27,15 @@ ## 会员订阅接入 -- 当前开发版本启用真实查询和强制门禁:`SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = True`。查询期间以及未配置 Key、Key 无效、账号禁用、未订阅、到期、撤销或暂时不可用时,只保留「设置」Tab并阻止新的产品请求;有效、宽限或旧服务兼容状态恢复工作流。 -- 强制升级检查完成、主窗口显示后,后台用 `data/config/cmhub.json` 的 API Key 请求 `GET /api/v1/cmshopee/subscription/status`;不会阻塞 Qt 主线程或把 Key 放入 URL、状态栏、日志和错误提示。 +- 启动 HTTPS 版本接口同时返回顶层 `client_policy`,本次运行据此确定 `off/observe/enforce`。有效策略缓存到 `data/config/client_policy.json`;接口失败、字段缺失或非法时优先用缓存,无缓存回退观察模式。策略只在下次启动刷新,设置保存和手动重检不改变本次模式。 +- `off` 不请求订阅状态、不弹会员处理窗口、不限制业务功能,设置页入口显示「会员检测暂未启用」并禁用;`observe` 后台查询并展示账号/套餐/有效期,但不锁 Tab、不拦截新提交、不弹强制窗口;`enforce` 查询期间及真实权益无效时只保留「设置」Tab,并阻止 AI生成、AI帮写和商品套图的新提交。 +- 需要查询时,主窗口显示后后台用 `data/config/cmhub.json` 的 API Key 请求 `GET /api/v1/cmshopee/subscription/status`;不会阻塞 Qt 主线程或把 Key 放入 URL、状态栏、日志和错误提示。强制模式只认服务端 `real_entitlement_allowed=true`,不根据展示状态或 `allowed` 兼容字段推断放行。 - 干净安装默认网关地址内置为 `https://cm.833729.com`。检测到 Key 缺失时不把零基础用户直接丢进技术设置,而是显示窗口级模态框「激活蝦皮圈优化助手」:只收集“会员 API Key”,提供显示/隐藏、验证、前往官方会员中心和退出。输入值先由 worker 在内存中验证;Key 无效或网络失败不落盘,服务端未拒绝凭据后才写入 `data/config/cmhub.json`。关闭激活窗口等同退出程序,后台业务 Tab 始终不可操作。 - 首次激活成功后显示四步使用清单“添加店铺账号 → 人工登录 Chrome → 导入 Excel → 采集/生成/更新”。「开始配置店铺」进入账号管理,「稍后提醒」仅延后到下次启动,「不再提示」停止提示。该状态只存 `onboarding.first_use_guide_state`;默认空值不触发,因此已有 Key 的存量用户不会被误弹新手引导。 - Tabs 上方不保留应用内标题或会员状态行。Windows 原生标题默认只显示应用名称;订阅有效时追加“账号名 · 套餐名 · 有效至日期”,宽限期追加宽限截止日。检测中和其他状态立即恢复纯应用名称并在底部状态栏显示脱敏中文结果。标题不得展示 API Key、接口地址、会员中心地址、通知标识或原始错误。 -- 本次程序运行中首次进入 `expired` 时,先应用门禁并切换「设置」,再弹出「会员套餐已过期」。安全 `manage_url` 可通过系统默认浏览器打开;地址缺失时跳转按钮禁用。重复过期检查不重弹,恢复有效后再次到期才重弹;「退出程序」沿用正常关闭和未保存设置确认。 -- 设置页底部提供「重新检测会员状态」;检测期间按钮禁用并显示运行状态。它只通知主窗口复用现有异步检查,保存 cmhub 设置后的自动重查和陈旧线程结果隔离保持不变。 -- 当前服务端未部署订阅接口时,`404` 视为旧服务兼容:底部状态栏显示“会员服务尚未启用”,六个工作流继续按旧行为运行,不弹阻断窗口。网络或格式错误显示“暂时无法确认会员状态”,不能误报为 Key 无效或会员到期。 +- 强制模式按原因处理:缺 Key 或 Key 无效打开会员 API Key 激活窗口;未开通、到期、撤销兼容状态和账号不可用分别显示对应中文窗口;接口/网络/格式异常显示「暂时无法验证会员状态」,不得描述成未付费。窗口提供适用的会员中心、重新检测、打开设置和退出操作;同一状态连续结果只提示一次,恢复真实权益后才允许同类状态再次提示。 +- 设置页底部提供「重新检测会员状态」;检测期间按钮禁用并显示运行状态。它只通知主窗口复用现有异步检查,保存 cmhub 设置后的自动重查和陈旧线程结果隔离保持不变。远程 `off` 时按钮保留但禁用,避免用户误以为检测失败。 +- 订阅接口 `404` 只作为旧服务兼容状态展示,不再在强制模式中代表真实权益;网络或格式错误同样不放行,但必须明确是验证不可用而不是会员到期。服务端产品接口始终独立校验授权,客户端策略开关不是授权凭据。 - ② AI生成、⑥商品套图和「AI帮写」继续调用同一订阅预检入口;非允许状态不创建新 worker/job。cmhub 产品接口仍是最终授权方;已提交异步任务的查询、下载、历史、预览和导出不因会员状态变化中断。 ## 全局 Tab 栏可用性 @@ -122,7 +123,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`,引导到⑤设置或线上会员中心;旧服务订阅接口尚未启用时保持原有生成行为。 +- 点击「开始生成」还会经过主窗口统一会员预检。远程策略为 `off/observe` 时直接放行;`enforce` 时只有 `real_entitlement_allowed=true` 才创建 `GenerateWorker`,未订阅、到期、Key 无效或状态暂时无法确认时引导到设置、激活窗口或线上会员中心。 - 生成参数(标题/图片并发数、失败重试、分辨率、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,默认不删除本地新封面文件。若范围内包含已提交线上记录,必须提示本地重置不回滚蝦皮,重生成后再更新会再次提交线上。 @@ -237,7 +238,7 @@ - AI帮写提交图片理解前先显示「开始AI帮写」确认框:按 `source_order` 说明会理解当前商品前1至8张可用原图并生成商品卖点与要求。模型目录只走后台读取或进程内短期缓存;仅当前图片理解别名有唯一无条件价格时显示「预计扣点:X 点」,否则明确实际以网关返回为准。确认框默认、Esc 和关闭均取消,不提交图片;开始后可取消本地等待,但已提交网关的请求仍可能产生扣点。预估不写入业务数据,完成后仍只显示接口返回的实际扣点和余额。 - 常规「生成套图」保留“已有成功历史”优先确认,选择继续后才后台读取或复用同一模型目录缓存,并显示正式生成确认。确认严格按最终 planned `specs` 展示各分类实际张数、总张数和比例;逐图主图开启时明确白底图只用第一张原图,其他分类按每张原图生成;关闭时所有分类使用第1张主图及同一批冻结参考图。仅唯一无条件的生图价格显示预计单张和总扣点,总价只按 `len(specs)` 计算;价格未知时不显示数字。默认、Esc、关闭、切换任务、取消读取或计划变化均不创建生图 job;单图失败重试和恢复未完成任务不增加这一层批量确认。 - 商品套图只管理本地图片资产,不自动上传或修改蝦皮;③线上更新边界不受影响。旧 `ImageStudioTab` 留作代码兼容但不再作为主窗口入口。 -- 「AI帮写」和「生成套图」在新提交前共用主窗口会员预检;默认网关或自定义网关直连都一样。继续查询已提交默认网关任务、下载、预览、历史查看和导出不属于新提交,不因会员状态变化中断。 +- 「AI帮写」和「生成套图」在新提交前共用主窗口会员预检;默认网关或自定义网关直连都一样。`off/observe` 放行,`enforce` 只按真实权益放行。继续查询已提交默认网关任务、下载、预览、历史查看和导出不属于新提交,不因会员状态变化中断。 ## 流程导航 diff --git a/docs/tasks/T-705.md b/docs/tasks/T-705.md index 9c62574..f4658f0 100644 --- a/docs/tasks/T-705.md +++ b/docs/tasks/T-705.md @@ -3,7 +3,7 @@ id: T-705 title: cmhub 远程订阅策略开关与分类门禁提示 phase: 8 deps: [T-700, T-701] -status: BLOCKED +status: DONE created: 2026-07-28 --- @@ -15,9 +15,9 @@ created: 2026-07-28 不能把远程开关放进订阅状态接口,否则客户端必须先请求该接口才能知道是否应跳过它。客户端门禁也不能成为服务端授权的唯一安全边界,cmhub 产品接口仍必须独立校验 API Key 和套餐。 -## 外部前置条件 +## 外部契约(已交付) -cmhub 先按 Obsidian《cmshopee-客户端订阅策略接口契约》交付并冻结启动策略字段、兼容规则和测试响应;未交付前本任务保持 `BLOCKED`,不得猜测字段或先写临时解析。 +cmhub 已按 Obsidian《cmhub-cmshopee客户端订阅策略接口契约》完成 T-635。客户端复用现有 HTTPS 启动版本接口: 最低要求是在客户端启动时已经请求的 HTTPS 版本接口中增加可选 `client_policy`: @@ -32,7 +32,9 @@ cmhub 先按 Obsidian《cmshopee-客户端订阅策略接口契约》交付并 } ``` -cmhub 必须保证:`subscription_check_enabled=false` 时服务端产品接口的订阅策略与客户端关闭策略一致;无论客户端门禁是否开启,付费授权最终仍由服务端执行。 +cmhub 内部只配置 `CMSHOPEE_CLIENT_SUBSCRIPTION_POLICY=off|observe|enforce`,对客户端仍输出上述两个布尔字段。订阅状态成功响应另提供顶层严格布尔字段 `real_entitlement_allowed`;强制模式必须只按该字段放行,不得使用展示用 `status` 或兼容 `allowed` 推断。当前成功状态为 `active/grace/required/expired`,账号禁用使用 HTTP 403;`revoked` 只保留客户端兼容处理,不属于当前服务端承诺状态。 + +cmhub 保证无论客户端门禁是否开启,付费产品接口仍独立执行服务端授权;客户端开关不是授权凭据。 ## 方案 @@ -40,14 +42,14 @@ cmhub 必须保证:`subscription_check_enabled=false` 时服务端产品接口 - `check=false`:跳过 `SubscriptionCheckWorker`,不显示会员激活/过期/异常窗口,不锁定业务 Tab;生成前订阅预检直接放行,设置页重新检测入口显示“服务端暂未启用会员检测”并禁用。 - `check=true, enforcement=false`:执行真实查询并展示账号、套餐、有效期和观察状态,但不禁用业务功能、不拦截新提交、不弹强制处理窗口。 -- `check=true, enforcement=true`:保持现有强制门禁;仅 `active/grace/legacy` 放行业务功能。 +- `check=true, enforcement=true`:保持现有强制门禁;仅 `real_entitlement_allowed=true` 放行业务功能,该值只能与 `active/grace` 同时出现。 - `check=false, enforcement=true` 属于非法组合,客户端归一化为两者均关闭并写脱敏诊断,不允许在没有检测结果时强制锁定。 ### 2. 启动策略获取与兼容 - 优先复用启动版本接口返回的 `client_policy`,不得为了读取开关再调用订阅状态接口,也不增加独立同步阻塞请求。 - 缓存最近一次结构合法的非敏感策略和取得时间到 `data/config/` 独立文件;不包含 API Key、账号、套餐或订阅结果。 -- 当前响应有效时覆盖缓存;接口不可达、字段缺失或非法时使用最近一次有效缓存。无有效缓存时回退为“检测开启、强制关闭”的观察模式,避免控制面异常锁死正常用户;cmhub 产品接口继续作为最终授权边界。 +- 当前响应有效时覆盖缓存;接口不可达、字段缺失或非法时使用最近一次有效缓存。缓存额外记录本地取得时间。无有效缓存时回退为“检测开启、强制关闭”的观察模式,避免控制面异常锁死正常用户;cmhub 产品接口继续作为最终授权边界。 - 旧 cmhub 响应不含 `client_policy` 时按上述兼容规则处理,不影响启动版本检查和强制升级。 - 只在启动时确定本次运行策略;服务端开关变更下一次启动生效。设置保存和“重新检测会员状态”只重查套餐,不偷偷改变本次策略。 @@ -56,7 +58,7 @@ cmhub 必须保证:`subscription_check_enabled=false` 时服务端产品接口 - `not_configured`:复用会员 API Key 激活窗口。 - `required`:新增“尚未开通会员套餐”,提供“前往会员中心”“重新检测”“退出程序”。 - `expired`:保留现有“会员套餐已过期”窗口及会员中心入口。 -- `revoked`:新增“会员套餐已失效”,不得使用含糊的技术错误。 +- `revoked`:保留兼容窗口“会员套餐已失效”,不得使用含糊的技术错误;当前 cmhub 不承诺返回该状态。 - `account_disabled`:新增“会员账号当前不可用”,提示前往会员中心或联系处理。 - `key_invalid`:打开可重新填写 API Key 的激活窗口并聚焦输入框,不使用泛化订阅窗口。 - `unavailable`:显示“暂时无法验证会员状态”,提供“重新检测”“打开设置”“退出程序”;不得误导用户未付费或要求重复订阅。 @@ -71,13 +73,13 @@ cmhub 必须保证:`subscription_check_enabled=false` 时服务端产品接口 ## 验收要点 -- [ ] 服务端双开关三种合法组合分别得到关闭、观察、强制三种稳定行为,非法组合不会锁死客户端。 -- [ ] 明确关闭时启动不请求订阅状态;观察模式查询但不限制;强制模式保持现有门禁。 -- [ ] 策略响应、缓存、旧响应、接口失败和无缓存回退均有测试,不记录敏感信息。 -- [ ] `required/expired/revoked/account_disabled/key_invalid/unavailable/not_configured` 使用对应中文窗口或激活流程,不把技术异常说成未订阅。 -- [ ] 分类窗口按状态转换去重,会员中心 URL 安全校验、重新检测、设置和退出操作可用。 -- [ ] 服务端仍是授权最终来源;修改客户端开关不能绕过 cmhub 生成接口套餐校验。 -- [ ] 不修改 Chrome、CDP、蝦皮采集、封面上传、拖拽或线上更新逻辑。 +- [x] 服务端双开关三种合法组合分别得到关闭、观察、强制三种稳定行为,非法组合不会锁死客户端。 +- [x] 明确关闭时启动不请求订阅状态;观察模式查询但不限制;强制模式保持现有门禁。 +- [x] 策略响应、缓存、旧响应、接口失败和无缓存回退均有测试,不记录敏感信息。 +- [x] `required/expired/revoked/account_disabled/key_invalid/unavailable/not_configured` 使用对应中文窗口或激活流程,不把技术异常说成未订阅。 +- [x] 分类窗口按状态转换去重,会员中心 URL 安全校验、重新检测、设置和退出操作可用。 +- [x] 服务端仍是授权最终来源;修改客户端开关不能绕过 cmhub 生成接口套餐校验。 +- [x] 不修改 Chrome、CDP、蝦皮采集、封面上传、拖拽或线上更新逻辑。 ## 测试与文档 @@ -107,3 +109,8 @@ git diff --check ## 执行记录 - 2026-07-28:完成客户端任务定义;等待 cmhub 启动策略接口契约与测试响应交付后解除阻塞。 +- 2026-07-28:cmhub T-635 与线上契约已交付,确认 `client_policy`、服务端单枚举配置及 `real_entitlement_allowed` 语义,任务解除阻塞并开始实现。 +- 2026-07-28:新增 `app/client_policy.py`,接入版本响应策略解析、非法组合归一化、`data/config/client_policy.json` 非敏感原子缓存和无缓存观察回退;启动流程把本轮策略传入主窗口。 +- 2026-07-28:订阅状态改为只按 `real_entitlement_allowed` 放行;完成关闭/观察/强制三模式、设置页关闭态、七类中文处理流程和恢复前按状态去重,不修改 Shopee/CDP 链路。 +- 2026-07-28:线上版本接口实测 HTTP 200、`Cache-Control: no-store`、策略 `off`;线上订阅接口脱敏实测返回 `active` 且真实权益允许。 +- 2026-07-28:验证通过:策略/版本/订阅/GUI 定向测试 260 项;全量 `unittest discover` 717 项;`ruff check app tests main.py`、`compileall app main.py`、`git diff --check` 全部通过。 diff --git a/tests/test_client_policy.py b/tests/test_client_policy.py new file mode 100644 index 0000000..d9af347 --- /dev/null +++ b/tests/test_client_policy.py @@ -0,0 +1,97 @@ +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) + +from _helpers import TempDirMixin + +from app import client_policy + + +class ClientPolicyTests(TempDirMixin, unittest.TestCase): + def _payload(self, *, check=True, enforce=False): + return { + "policy_version": 1, + "subscription_check_enabled": check, + "subscription_enforcement_enabled": enforce, + "updated_at": "2026-07-28T15:19:19+08:00", + } + + def test_parse_supports_off_observe_and_enforce(self): + off = client_policy.parse_client_policy( + self._payload(check=False, enforce=False) + ) + observe = client_policy.parse_client_policy( + self._payload(check=True, enforce=False) + ) + enforce = client_policy.parse_client_policy( + self._payload(check=True, enforce=True) + ) + + self.assertEqual("off", off.mode) + self.assertEqual("observe", observe.mode) + self.assertEqual("enforce", enforce.mode) + + def test_illegal_combination_is_normalized_to_off(self): + policy = client_policy.parse_client_policy( + self._payload(check=False, enforce=True) + ) + + self.assertEqual("off", policy.mode) + self.assertFalse(policy.subscription_enforcement_enabled) + self.assertIn("组合非法", policy.warning) + + def test_parser_rejects_loose_types_or_timestamp_without_timezone(self): + invalid_bool = self._payload() + invalid_bool["subscription_check_enabled"] = 1 + invalid_time = self._payload() + invalid_time["updated_at"] = "2026-07-28T15:19:19" + + with self.assertRaises(client_policy.ClientPolicyError): + client_policy.parse_client_policy(invalid_bool) + with self.assertRaises(client_policy.ClientPolicyError): + client_policy.parse_client_policy(invalid_time) + + def test_valid_remote_policy_is_cached_without_sensitive_fields(self): + with self.make_temp_dir() as temp_dir: + path = os.path.join(temp_dir, "config", "client_policy.json") + remote = client_policy.parse_client_policy( + self._payload(check=True, enforce=True) + ) + + resolved = client_policy.resolve_client_policy(remote, path=path) + loaded = client_policy.load_cached_policy(path=path) + + self.assertEqual("remote", resolved.source) + self.assertEqual("cache", loaded.source) + self.assertEqual("enforce", loaded.mode) + with open(path, "r", encoding="utf-8") as handle: + raw = json.load(handle) + self.assertIn("cached_at", raw) + self.assertNotIn("api_key", json.dumps(raw).lower()) + self.assertNotIn("account", json.dumps(raw).lower()) + + def test_missing_remote_uses_cache_then_observation_fallback(self): + with self.make_temp_dir() as temp_dir: + path = os.path.join(temp_dir, "client_policy.json") + cached = client_policy.parse_client_policy( + self._payload(check=False, enforce=False) + ) + client_policy.save_cached_policy(cached, path=path) + + from_cache = client_policy.resolve_client_policy(None, path=path) + self.assertEqual("cache", from_cache.source) + self.assertEqual("off", from_cache.mode) + + with open(path, "w", encoding="utf-8") as handle: + handle.write("{broken") + fallback = client_policy.resolve_client_policy(None, path=path) + self.assertEqual("fallback", fallback.source) + self.assertEqual("observe", fallback.mode) + self.assertIn("观察模式", fallback.warning) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gui.py b/tests/test_gui.py index 911c2fa..85d161a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -17,6 +17,7 @@ from app import ( accounts, ai, appconfig, + client_policy, db, image_paths, image_studio, @@ -2010,6 +2011,30 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertTrue(allowed) write_log.assert_called_once() + def test_startup_update_gate_returns_client_policy_to_startup(self): + policy = client_policy.parse_client_policy( + { + "policy_version": 1, + "subscription_check_enabled": False, + "subscription_enforcement_enabled": False, + "updated_at": "2026-07-28T15:19:19+08:00", + } + ) + result = update_check.UpdateCheckResult( + current_version="1.0.0", + checked=True, + client_policy=policy, + ) + captured = [] + + allowed = gui._run_startup_update_gate( + checker=lambda: result, + result_callback=captured.append, + ) + + self.assertTrue(allowed) + self.assertEqual([result], captured) + def test_status_callbacks_classify_success_warning_and_failure(self): with self.make_temp_dir() as temp_dir: statuses = [] @@ -11364,6 +11389,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): account_name="新账号", plan_name="测试套餐", expires_at="2026-08-20T23:59:59+08:00", + real_entitlement_allowed=True, ) with mock.patch.object(main_window.QTimer, "singleShot"): @@ -11405,6 +11431,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): account_name="新账号", plan_name="测试套餐", expires_at="2026-08-20T23:59:59+08:00", + real_entitlement_allowed=True, ) fake_box, boxes = self.make_fake_message_box("开始配置店铺") @@ -11466,6 +11493,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): account_name="主账号", plan_name="专业版", expires_at="2026-08-20T23:59:59+08:00", + real_entitlement_allowed=True, ) window._apply_subscription_status(status) @@ -11491,6 +11519,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): grace_expires_at="2026-08-23T23:59:59+08:00", manage_url="https://cm.example.com/subscription", notice_id="private-notice", + real_entitlement_allowed=True, ) window._apply_subscription_status(grace) @@ -11544,6 +11573,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): account_name="主账号", plan_name="测试", expires_at="2026-08-21T23:59:59+08:00", + real_entitlement_allowed=True, ) with mock.patch.object( @@ -11666,6 +11696,161 @@ class GuiTests(TempDirMixin, unittest.TestCase): settings_tab.subscription_check_button.text(), ) + def test_remote_policy_off_skips_check_and_keeps_workflows_available(self): + policy = client_policy.ClientPolicy( + policy_version=1, + subscription_check_enabled=False, + subscription_enforcement_enabled=False, + updated_at="2026-07-28T15:19:19+08:00", + ) + with self.make_temp_dir() as temp_dir: + window = MainWindow( + config=self.make_config(temp_dir), + subscription_policy=policy, + ) + self.addCleanup(window.close) + settings_tab = window._settings_tab() + + with mock.patch( + "app.gui.main_window.SubscriptionCheckWorker", + ) as worker_factory: + window.begin_subscription_check() + + worker_factory.assert_not_called() + self.assertFalse(settings_tab.subscription_check_button.isHidden()) + self.assertFalse(settings_tab.subscription_check_button.isEnabled()) + self.assertEqual( + "会员检测暂未启用", + settings_tab.subscription_check_button.text(), + ) + self.assertTrue( + all( + window.tabs.isTabEnabled(index) + for index in range(window.tabs.count()) + ) + ) + self.assertTrue(window.ensure_subscription_for_new_submit("开始 AI 生成")) + + def test_enforcement_uses_classified_subscription_windows(self): + expected = [ + (subscription.STATUS_REQUIRED, "尚未开通会员套餐", "前往会员中心"), + (subscription.STATUS_REVOKED, "会员套餐已失效", "前往会员中心"), + ( + subscription.STATUS_ACCOUNT_DISABLED, + "会员账号当前不可用", + "前往会员中心", + ), + ( + subscription.STATUS_UNAVAILABLE, + "暂时无法验证会员状态", + "打开设置", + ), + ] + with self.make_temp_dir() as temp_dir: + window = MainWindow(config=self.make_config(temp_dir)) + self.addCleanup(window.close) + fake_box, boxes = self.make_sequence_message_box( + [selected for _state, _title, selected in expected] + ) + with mock.patch.object( + main_window, + "QMessageBox", + fake_box, + ), mock.patch.object( + main_window.QDesktopServices, + "openUrl", + return_value=True, + ) as open_url: + for state, _title, _selected in expected: + window._apply_subscription_status( + subscription.SubscriptionStatus( + state, + manage_url=( + "https://cm.example.com/user/subscriptions/cmshopee" + if state != subscription.STATUS_UNAVAILABLE + else "" + ), + ) + ) + + self.assertEqual( + [title for _state, title, _selected in expected], + [box.title for box in boxes], + ) + self.assertEqual(3, open_url.call_count) + self.assertEqual( + TAB_TITLES.index("设置"), + window.tabs.currentIndex(), + ) + + def test_subscription_window_state_is_deduplicated_until_entitlement_recovers(self): + with self.make_temp_dir() as temp_dir: + window = MainWindow(config=self.make_config(temp_dir)) + self.addCleanup(window.close) + fake_box, boxes = self.make_sequence_message_box( + ["前往会员中心", "打开设置", "前往会员中心"] + ) + required = subscription.SubscriptionStatus( + subscription.STATUS_REQUIRED, + manage_url="https://cm.example.com/user/subscriptions/cmshopee", + ) + unavailable = subscription.SubscriptionStatus( + subscription.STATUS_UNAVAILABLE + ) + active = subscription.SubscriptionStatus( + subscription.STATUS_ACTIVE, + account_name="主账号", + plan_name="测试", + expires_at="2026-08-21T23:59:59+08:00", + real_entitlement_allowed=True, + ) + + with mock.patch.object( + main_window, + "QMessageBox", + fake_box, + ), mock.patch.object( + main_window.QDesktopServices, + "openUrl", + return_value=True, + ): + window._apply_subscription_status(required) + window._apply_subscription_status(unavailable) + window._apply_subscription_status(required) + window._apply_subscription_status(active) + window._apply_subscription_status(required) + + self.assertEqual( + [ + "尚未开通会员套餐", + "暂时无法验证会员状态", + "尚未开通会员套餐", + ], + [box.title for box in boxes], + ) + + def test_invalid_key_uses_activation_dialog_in_enforcement_mode(self): + with self.make_temp_dir() as temp_dir: + window = MainWindow(config=self.make_config(temp_dir)) + self.addCleanup(window.close) + + with mock.patch.object( + MembershipActivationDialog, + "focus_api_key", + ) as focus_api_key: + window._apply_subscription_status( + subscription.SubscriptionStatus( + subscription.STATUS_KEY_INVALID + ) + ) + + self.assertIsNotNone(window._activation_dialog) + focus_api_key.assert_called_once_with() + self.assertEqual( + TAB_TITLES.index("设置"), + window.tabs.currentIndex(), + ) + @mock.patch.object(main_window, "SUBSCRIPTION_ENFORCEMENT_ENABLED", False) def test_main_window_observation_mode_checks_without_restricting_workflows(self): self.assertTrue(main_window.SUBSCRIPTION_CHECK_ENABLED) @@ -11741,6 +11926,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): plan_name="测试", expires_at="2026-08-21T23:59:59+08:00", notice_id="subscription-test-plan", + real_entitlement_allowed=True, ) with mock.patch.object(window, "_show_subscription_notice_once") as notice: diff --git a/tests/test_subscription.py b/tests/test_subscription.py index fa98c35..cc8efe1 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -42,6 +42,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): "account": {"display_name": "主账号"}, "plan": {"code": "pro", "display_name": "专业版"}, "status": "active", + "real_entitlement_allowed": True, "expires_at": "2026-08-20T23:59:59+08:00", "grace_expires_at": None, "manage_url": "https://cm.example.com/user/subscriptions/cmshopee", @@ -79,6 +80,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): "account": {"display_name": "新账号"}, "plan": {"display_name": "测试套餐"}, "status": "active", + "real_entitlement_allowed": True, "expires_at": "2026-08-20T23:59:59+08:00", } @@ -104,6 +106,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): "account": {"display_name": "主账号"}, "plan": {"display_name": "专业版"}, "status": "grace", + "real_entitlement_allowed": True, "expires_at": "2026-08-20T23:59:59+08:00", "grace_expires_at": "2026-08-23T23:59:59+08:00", } @@ -118,6 +121,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): "account": {"display_name": "主账号"}, "plan": {"display_name": "专业版"}, "status": "expired", + "real_entitlement_allowed": False, "expires_at": "2026-08-20T23:59:59+08:00", "manage_url": "https://cm.example.com/user/subscriptions/cmshopee", } @@ -130,7 +134,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): expired.manage_url, ) - def test_legacy_404_keeps_existing_workflows_available(self): + def test_legacy_404_does_not_claim_real_entitlement(self): with self.make_temp_dir() as temp_dir: def request_json(*args, **kwargs): raise ai.CMHubError("not_found", "接口不存在", status=404) @@ -141,7 +145,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): ) self.assertEqual(subscription.STATUS_LEGACY, result.state) - self.assertTrue(result.allows_product_workflows) + self.assertFalse(result.allows_product_workflows) self.assertFalse(result.interface_available) def test_auth_error_and_network_failure_have_different_states(self): @@ -180,6 +184,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): "account": {"display_name": "主账号"}, "plan": {"display_name": "专业版"}, "status": "required", + "real_entitlement_allowed": False, "manage_url": "https://other.example.com/account", } @@ -187,6 +192,33 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): self.assertEqual(subscription.STATUS_REQUIRED, result.state) self.assertEqual("", result.manage_url) + def test_real_entitlement_field_is_required_and_consistent(self): + with self.make_temp_dir() as temp_dir: + config = self._config(temp_dir) + common = { + "product_code": "cmshopee", + "account": {"display_name": "主账号"}, + "plan": {"display_name": "专业版"}, + "status": "active", + "expires_at": "2026-08-20T23:59:59+08:00", + } + + missing = subscription.check_status( + config, + request_json=lambda *args, **kwargs: dict(common), + ) + inconsistent = subscription.check_status( + config, + request_json=lambda *args, **kwargs: { + **common, + "real_entitlement_allowed": False, + }, + ) + + self.assertEqual(subscription.STATUS_UNAVAILABLE, missing.state) + self.assertEqual(subscription.STATUS_UNAVAILABLE, inconsistent.state) + self.assertFalse(missing.allows_product_workflows) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_update_check.py b/tests/test_update_check.py index 86396b5..4862b46 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -120,6 +120,70 @@ class UpdateCheckTests(unittest.TestCase): self.assertEqual("", result.package_format) self.assertEqual(0, result.updater_protocol) + def test_check_for_update_parses_client_policy_without_affecting_release(self): + result = update_check.check_for_update( + current_version="0.1.5", + url="https://cm.example.test/api/v1/client/releases/latest?platform=windows", + fetcher=lambda _url, _timeout: { + "platform": "windows", + "release": { + "version": "0.1.6", + "force_update": False, + }, + "client_policy": { + "policy_version": 1, + "subscription_check_enabled": True, + "subscription_enforcement_enabled": True, + "updated_at": "2026-07-28T15:19:19+08:00", + }, + }, + ) + + self.assertEqual("0.1.6", result.latest_version) + self.assertEqual("enforce", result.client_policy.mode) + self.assertEqual("", result.client_policy_error) + + def test_invalid_client_policy_does_not_break_update_detection(self): + result = update_check.check_for_update( + current_version="0.1.5", + url="https://cm.example.test/version.json", + fetcher=lambda _url, _timeout: { + "release": {"version": "0.1.6"}, + "client_policy": { + "policy_version": 1, + "subscription_check_enabled": "false", + "subscription_enforcement_enabled": True, + "updated_at": "2026-07-28T15:19:19+08:00", + }, + }, + ) + + self.assertEqual("0.1.6", result.latest_version) + self.assertIsNone(result.client_policy) + self.assertIn("布尔值", result.client_policy_error) + self.assertEqual("", result.error) + + def test_release_can_be_null_when_policy_is_available(self): + result = update_check.check_for_update( + current_version="0.1.5", + url="https://cm.example.test/version.json", + fetcher=lambda _url, _timeout: { + "platform": "windows", + "release": None, + "client_policy": { + "policy_version": 1, + "subscription_check_enabled": False, + "subscription_enforcement_enabled": False, + "updated_at": "2026-07-28T15:19:19+08:00", + }, + }, + ) + + self.assertTrue(result.checked) + self.assertFalse(result.forced) + self.assertEqual("", result.error) + self.assertEqual("off", result.client_policy.mode) + def test_network_failure_allows_entry(self): def fetcher(_url, _timeout): raise socket.timeout("timeout")