diff --git a/app/appconfig.py b/app/appconfig.py index ba4186e..0cb22fe 100644 --- a/app/appconfig.py +++ b/app/appconfig.py @@ -50,6 +50,9 @@ SHOPEE_PARALLEL_ACCOUNTS_MIN = 1 SHOPEE_PARALLEL_ACCOUNTS_MAX = 5 CMHUB_CONNECT_TIMEOUT_DEFAULT = 66 CMHUB_CONNECT_TIMEOUT_OLD_DEFAULT = 10 +DEFAULT_CMHUB_BASE_URL = "https://cm.833729.com" +DEFAULT_MEMBER_CENTER_URL = "https://cm.833729.com" +FIRST_USE_GUIDE_STATES = {"", "pending", "completed", "dismissed"} RUNTIME_CONFIG_KEYS = { "config_path", "ai_models_path", @@ -85,7 +88,7 @@ DEFAULT_CONFIG = { "generate_mode": "", "backend": "cmhub", "cmhub": { - "base_url": "", + "base_url": DEFAULT_CMHUB_BASE_URL, "title_alias": "", "image_alias": "", "vision_alias": "vision-standard", @@ -125,6 +128,9 @@ DEFAULT_CONFIG = { "subscription": { "last_notice_id": "", }, + "onboarding": { + "first_use_guide_state": "", + }, } DEFAULT_AI_MODELS_CONFIG = { @@ -409,7 +415,10 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False): ) cmhub = ai.get("cmhub") if isinstance(cmhub, dict): - cmhub["base_url"] = normalize_cmhub_base_url(cmhub.get("base_url", "")) + cmhub["base_url"] = ( + normalize_cmhub_base_url(cmhub.get("base_url", "")) + or DEFAULT_CMHUB_BASE_URL + ) cmhub["title_alias"] = str(cmhub.get("title_alias", "") or "").strip() cmhub["image_alias"] = str(cmhub.get("image_alias", "") or "").strip() cmhub["vision_alias"] = str(cmhub.get("vision_alias", "") or "").strip() @@ -456,6 +465,14 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False): suite["last_settings"] = _normalize_product_suite_last_settings( suite.get("last_settings") ) + onboarding = config.get("onboarding") + if not isinstance(onboarding, dict): + onboarding = {} + config["onboarding"] = onboarding + guide_state = str(onboarding.get("first_use_guide_state") or "").strip().lower() + onboarding["first_use_guide_state"] = ( + guide_state if guide_state in FIRST_USE_GUIDE_STATES else "" + ) return config @@ -728,6 +745,29 @@ def save_subscription_notice_id(notice_id, path=CONFIG_PATH) -> dict: return save_config(config, path=path) +def first_use_guide_state(config=None) -> str: + section = _config_or_load(config).get("onboarding", {}) + if not isinstance(section, dict): + return "" + state = str(section.get("first_use_guide_state") or "").strip().lower() + return state if state in FIRST_USE_GUIDE_STATES else "" + + +def save_first_use_guide_state(state, path=CONFIG_PATH) -> dict: + """Persist the local first-use guide state without storing credentials.""" + + normalized = str(state or "").strip().lower() + if normalized not in FIRST_USE_GUIDE_STATES: + raise ConfigError("首次使用引导状态无效") + config = load_config(path) + section = config.get("onboarding", {}) + if not isinstance(section, dict): + section = {} + section["first_use_guide_state"] = normalized + config["onboarding"] = 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"])) @@ -830,7 +870,10 @@ def cmhub_config(config=None) -> dict: if not isinstance(value, dict): raise ConfigError("ai.cmhub 必须是对象") merged = _deep_merge(DEFAULT_CONFIG["ai"]["cmhub"], value) - merged["base_url"] = normalize_cmhub_base_url(merged.get("base_url", "")) + merged["base_url"] = ( + normalize_cmhub_base_url(merged.get("base_url", "")) + or DEFAULT_CMHUB_BASE_URL + ) merged["title_alias"] = str(merged.get("title_alias", "") or "").strip() merged["image_alias"] = str(merged.get("image_alias", "") or "").strip() merged["vision_alias"] = str(merged.get("vision_alias", "") or "").strip() diff --git a/app/gui/__init__.py b/app/gui/__init__.py index b5745a3..7af5c29 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -16,6 +16,7 @@ QMessageBox = _widgets._RawQMessageBox run_worker = _widgets._raw_run_worker if QT_IMPORT_ERROR is None else _widgets.run_worker if QT_IMPORT_ERROR is None: + from .activation_dialog import MembershipActivationDialog from .models import ApplyTaskTableModel, GenerateTaskTableModel, TaskTableModel from .workers import ( AccountLoginCheckWorker, diff --git a/app/gui/activation_dialog.py b/app/gui/activation_dialog.py new file mode 100644 index 0000000..972bb3b --- /dev/null +++ b/app/gui/activation_dialog.py @@ -0,0 +1,161 @@ +"""First-run membership activation dialog.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QVBoxLayout, +) + +from .widgets import ( + BUTTON_BASE_STYLE, + COLOR_DANGER, + COLOR_INFO, + COLOR_MUTED, + COLOR_SUCCESS, + _primary_button_style, +) + + +class MembershipActivationDialog(QDialog): + """Collect one membership API key and report asynchronous validation state.""" + + validationRequested = Signal(str) + memberCenterRequested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._validating = False + self.setObjectName("membershipActivationDialog") + self.setWindowTitle("激活蝦皮圈优化助手") + self.setModal(True) + self.setWindowModality(Qt.WindowModal) + self.setMinimumWidth(520) + self.setStyleSheet(BUTTON_BASE_STYLE) + + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 22, 24, 22) + layout.setSpacing(12) + + heading = QLabel("首次使用需要绑定会员账号") + heading.setObjectName("membershipActivationHeading") + heading.setStyleSheet("font-size: 16px; font-weight: 600; color: #24292f;") + layout.addWidget(heading) + + description = QLabel( + "请从线上会员中心复制会员 API Key。程序会先验证账号和套餐," + "验证通过后再开放业务功能。" + ) + description.setObjectName("membershipActivationDescription") + description.setWordWrap(True) + description.setStyleSheet(f"color: {COLOR_MUTED};") + layout.addWidget(description) + + key_label = QLabel("会员 API Key") + key_label.setObjectName("membershipApiKeyLabel") + layout.addWidget(key_label) + + self.api_key_edit = QLineEdit() + self.api_key_edit.setObjectName("membershipApiKeyEdit") + self.api_key_edit.setEchoMode(QLineEdit.Password) + self.api_key_edit.setPlaceholderText("粘贴会员 API Key") + self.api_key_edit.setClearButtonEnabled(True) + self.api_key_edit.returnPressed.connect(self._request_validation) + key_label.setBuddy(self.api_key_edit) + layout.addWidget(self.api_key_edit) + + self.show_key_checkbox = QCheckBox("显示 API Key") + self.show_key_checkbox.setObjectName("showMembershipApiKeyCheckbox") + self.show_key_checkbox.toggled.connect(self._toggle_key_visibility) + layout.addWidget(self.show_key_checkbox) + + storage_hint = QLabel( + "API Key 在输入框和设置页中打码显示,但会以本机明文配置保存," + "仅用于会员验证和默认网关请求。" + ) + storage_hint.setObjectName("membershipApiKeyStorageHint") + storage_hint.setWordWrap(True) + storage_hint.setStyleSheet(f"color: {COLOR_MUTED};") + layout.addWidget(storage_hint) + + self.status_label = QLabel("") + self.status_label.setObjectName("membershipActivationStatusLabel") + self.status_label.setWordWrap(True) + self.status_label.setMinimumHeight(36) + layout.addWidget(self.status_label) + + button_layout = QHBoxLayout() + button_layout.setSpacing(8) + self.member_center_button = QPushButton("前往会员中心获取 API Key") + self.member_center_button.setObjectName("membershipCenterButton") + self.member_center_button.clicked.connect(self.memberCenterRequested.emit) + button_layout.addWidget(self.member_center_button) + button_layout.addStretch(1) + + self.exit_button = QPushButton("退出程序") + self.exit_button.setObjectName("membershipActivationExitButton") + self.exit_button.clicked.connect(self.reject) + button_layout.addWidget(self.exit_button) + + self.validate_button = QPushButton("验证并开始使用") + self.validate_button.setObjectName("membershipActivationValidateButton") + self.validate_button.setDefault(True) + self.validate_button.setStyleSheet( + _primary_button_style("membershipActivationValidateButton") + ) + self.validate_button.clicked.connect(self._request_validation) + button_layout.addWidget(self.validate_button) + layout.addLayout(button_layout) + + self.api_key_edit.setFocus() + + def api_key(self) -> str: + return self.api_key_edit.text().strip() + + def set_validating(self, validating: bool) -> None: + self._validating = bool(validating) + self.api_key_edit.setEnabled(not self._validating) + self.show_key_checkbox.setEnabled(not self._validating) + self.validate_button.setEnabled(not self._validating) + self.validate_button.setText( + "正在验证会员账号..." if self._validating else "验证并开始使用" + ) + if self._validating: + self.set_status("正在验证会员账号,请稍候。", level="info") + + def set_status(self, message: str, *, level: str = "muted") -> None: + color = { + "danger": COLOR_DANGER, + "info": COLOR_INFO, + "success": COLOR_SUCCESS, + }.get(str(level), COLOR_MUTED) + self.status_label.setStyleSheet(f"color: {color};") + self.status_label.setText(str(message or "")) + + def focus_api_key(self) -> None: + self.api_key_edit.setFocus() + self.api_key_edit.selectAll() + + def accept_activation(self) -> None: + self.set_validating(False) + self.accept() + + def _request_validation(self) -> None: + if self._validating: + return + key = self.api_key() + if not key: + self.set_status("请输入会员 API Key。", level="danger") + self.focus_api_key() + return + self.validationRequested.emit(key) + + def _toggle_key_visibility(self, checked: bool) -> None: + mode = QLineEdit.Normal if checked else QLineEdit.Password + self.api_key_edit.setEchoMode(mode) diff --git a/app/gui/main_window.py b/app/gui/main_window.py index fce3325..51a4641 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -7,6 +7,7 @@ from PySide6.QtGui import QDesktopServices from .. import subscription from ..version import display_name +from .activation_dialog import MembershipActivationDialog from .tabs.accounts import AccountsTab from .tabs.apply import ApplyTab from .tabs.collect import CollectTab @@ -122,6 +123,8 @@ class MainWindow(QMainWindow): self._subscription_request_token = 0 self._subscription_closed = False self._expired_subscription_notice_shown = False + self._activation_dialog = None + self._first_use_guide_shown = False self.tabs = QTabWidget() self.tabs.setObjectName("mainTabs") self.tabs.setStyleSheet(TAB_STYLE) @@ -296,7 +299,13 @@ class MainWindow(QMainWindow): self._subscription_worker = None self._set_subscription_check_running(False) - def _apply_subscription_status(self, status): + def _apply_subscription_status( + self, + status, + *, + show_notice=True, + show_first_use_guide=True, + ): self._subscription_status = status if status.allows_product_workflows: self._expired_subscription_notice_shown = False @@ -334,8 +343,10 @@ class MainWindow(QMainWindow): else "会员状态已验证,当前处于观察模式" ) self.show_status(message, level="success") - if SUBSCRIPTION_ENFORCEMENT_ENABLED: + if 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: self.show_status( @@ -343,11 +354,225 @@ class MainWindow(QMainWindow): level=self._subscription_level(status), ) return - self.open_settings_tab() self.show_status(status.user_message, level=self._subscription_level(status)) + if status.state == subscription.STATUS_NOT_CONFIGURED: + self.show_status("请先绑定会员账号", level="warning") + self._show_membership_activation() + return + self.open_settings_tab() if status.state == subscription.STATUS_EXPIRED: self._show_expired_subscription_notice_once(status) + def _show_membership_activation(self): + if self._activation_dialog is not None: + self._activation_dialog.raise_() + self._activation_dialog.activateWindow() + return + dialog = MembershipActivationDialog(self) + self._activation_dialog = dialog + dialog.validationRequested.connect(self._begin_activation_validation) + dialog.memberCenterRequested.connect(self._open_activation_member_center) + dialog.finished.connect( + lambda result, current=dialog: self._on_activation_dialog_finished( + current, + result, + ) + ) + dialog.open() + + def _begin_activation_validation(self, api_key): + dialog = self._activation_dialog + key = str(api_key or "").strip() + if dialog is None or not key: + 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(False) + self._set_subscription_check_running(True) + dialog.set_validating(True) + self.show_status("正在验证会员账号", level="info") + worker = SubscriptionCheckWorker( + config=self.config, + cmhub_config_path=self.config.get("cmhub_config_path"), + base_url=appconfig.cmhub_config(self.config).get("base_url"), + api_key=key, + ) + worker.finished.connect( + lambda payload, token=token, key=key: self._on_activation_checked( + token, + key, + payload, + ) + ) + worker.cancelled.connect( + lambda payload, token=token, key=key: self._on_activation_checked( + token, + key, + payload, + ) + ) + thread = run_worker(worker, thread_name="MembershipActivationWorker", start=False) + thread.finished.connect(lambda: self._forget_subscription_thread(thread)) + self._subscription_worker = worker + self._subscription_thread = thread + thread.start() + + def _on_activation_checked(self, token, api_key, payload): + if self._subscription_closed or token != self._subscription_request_token: + return + dialog = self._activation_dialog + if dialog is None: + return + dialog.set_validating(False) + status = payload.get("subscription") if isinstance(payload, dict) else None + if not isinstance(status, subscription.SubscriptionStatus): + status = subscription.SubscriptionStatus(subscription.STATUS_UNAVAILABLE) + self._subscription_status = status + + if not status.credentials_accepted: + self._set_product_access(False) + self._set_membership_window_title() + if status.state == subscription.STATUS_KEY_INVALID: + message = "会员 API Key 无效,请检查后重新输入。" + dialog.focus_api_key() + elif status.state == subscription.STATUS_NOT_CONFIGURED: + message = "请输入有效的会员 API Key。" + dialog.focus_api_key() + else: + message = "暂时无法验证会员账号,请检查网络后重试。" + dialog.set_status(message, level="danger") + self.show_status(message, level="warning") + return + + try: + appconfig.save_cmhub_config( + {"api_key": str(api_key or "").strip()}, + path=self.config.get("cmhub_config_path") + or appconfig.cmhub_config_file_path(self.config), + ) + except (OSError, appconfig.ConfigError): + message = "会员账号已验证,但 API Key 保存失败,请检查数据目录后重试。" + dialog.set_status(message, level="danger") + self.show_status(message, level="danger") + return + + settings_tab = self._settings_tab() + if hasattr(settings_tab, "refresh_default_gateway_credentials"): + settings_tab.refresh_default_gateway_credentials() + + if not status.allows_product_workflows: + self._set_product_access(False) + self._set_membership_window_title() + message = "%s。请前往会员中心处理后重新验证。" % status.user_message + dialog.set_status(message, level="danger") + self.show_status(status.user_message, level=self._subscription_level(status)) + return + + self._save_first_use_state("pending") + if status.notice_id: + self._save_subscription_notice_id(status.notice_id) + self._apply_subscription_status( + status, + show_notice=False, + show_first_use_guide=False, + ) + dialog.set_status("会员账号验证成功,正在进入工作区。", level="success") + dialog.accept_activation() + QTimer.singleShot(0, self._show_first_use_guide_if_pending) + + def _on_activation_dialog_finished(self, dialog, result): + if self._activation_dialog is dialog: + self._activation_dialog = None + dialog.deleteLater() + if ( + result != QDialog.Accepted + and not self._subscription_closed + and not self._subscription_status.allows_product_workflows + ): + self.close() + + def _open_activation_member_center(self): + manage_url = str(self._subscription_status.manage_url or "").strip() + if not manage_url: + manage_url = subscription.safe_manage_url( + appconfig.DEFAULT_MEMBER_CENTER_URL, + appconfig.DEFAULT_CMHUB_BASE_URL, + ) + if not manage_url: + message = "会员中心地址当前不可用,请稍后重试。" + if self._activation_dialog is not None: + self._activation_dialog.set_status(message, level="danger") + self.show_status(message, level="warning") + return + opened = QDesktopServices.openUrl(QUrl(manage_url)) + if opened is False: + message = "无法打开会员中心,请检查系统默认浏览器后重试。" + if self._activation_dialog is not None: + self._activation_dialog.set_status(message, level="danger") + self.show_status(message, level="warning") + + def _show_first_use_guide_if_pending(self): + if self._first_use_guide_shown: + return + if appconfig.first_use_guide_state(self.config) != "pending": + return + if not self._subscription_status.allows_product_workflows: + return + self._first_use_guide_shown = True + + box = QMessageBox(self) + box.setIcon(QMessageBox.Information) + box.setWindowTitle("首次使用指引") + box.setText( + "接下来按以下顺序开始使用:\n\n" + "1. 添加蝦皮店铺账号\n" + "2. 启动 Chrome 并人工完成登录\n" + "3. 导入商品 Excel\n" + "4. 开始采集、AI 生成和更新蝦皮" + ) + start_button = box.addButton("开始配置店铺", QMessageBox.AcceptRole) + later_button = box.addButton("稍后提醒", QMessageBox.RejectRole) + dismiss_button = box.addButton("不再提示", QMessageBox.ActionRole) + box.setDefaultButton(start_button) + box.exec() + clicked = box.clickedButton() + if clicked is start_button: + self._save_first_use_state("completed") + self.open_accounts_tab() + elif clicked is dismiss_button: + self._save_first_use_state("dismissed") + elif clicked is later_button: + self.show_status("可稍后从账号管理开始配置店铺", level="muted") + + def _save_first_use_state(self, state): + try: + saved = appconfig.save_first_use_guide_state( + state, + path=self.config_path, + ) + except (OSError, appconfig.ConfigError): + self.show_status("首次使用引导状态保存失败", level="warning") + return False + self._replace_runtime_config(saved) + return True + + def _save_subscription_notice_id(self, notice_id): + try: + saved = appconfig.save_subscription_notice_id( + notice_id, + path=self.config_path, + ) + except (OSError, appconfig.ConfigError): + return False + self._replace_runtime_config(saved) + return True + + def _replace_runtime_config(self, saved): + self.config.clear() + self.config.update(saved) + @staticmethod def _subscription_level(status): if status.state in {subscription.STATUS_UNAVAILABLE, subscription.STATUS_KEY_INVALID}: @@ -441,15 +666,7 @@ class MainWindow(QMainWindow): 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) + self._save_subscription_notice_id(notice_id) def _on_tab_changed(self, index): if self._reverting_tab_change: diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index 9839fc1..b190dc6 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -566,6 +566,19 @@ class SettingsTab(QWidget): "正在检测会员状态..." if running else "重新检测会员状态" ) + def refresh_default_gateway_credentials(self): + """Refresh default gateway fields after first-run membership activation.""" + + with self._dirty_tracking_suspended(): + cmhub_cfg = appconfig.cmhub_config(self.config) + self.cmhub_base_url_edit.setText( + appconfig.normalize_cmhub_base_url(cmhub_cfg.get("base_url", "")) + ) + self._loaded_cmhub_api_key = appconfig.get_cmhub_api_key( + path=self.cmhub_config_path + ) + self.cmhub_api_key_edit.setText(self._loaded_cmhub_api_key) + @contextmanager def _dirty_tracking_suspended(self): self._suspend_dirty += 1 diff --git a/app/gui/workers.py b/app/gui/workers.py index 113a068..3c947eb 100644 --- a/app/gui/workers.py +++ b/app/gui/workers.py @@ -38,15 +38,26 @@ _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): + def __init__( + self, + *, + config=None, + cmhub_config_path=None, + base_url=None, + api_key=None, + ): super().__init__() self.config = config self.cmhub_config_path = cmhub_config_path + self.base_url = base_url + self.api_key = api_key def execute(self): result = subscription.check_status( self.config, cmhub_config_path=self.cmhub_config_path, + base_url=self.base_url, + api_key=self.api_key, ) return {"subscription": result} diff --git a/app/subscription.py b/app/subscription.py index ff80135..7015263 100644 --- a/app/subscription.py +++ b/app/subscription.py @@ -60,6 +60,14 @@ class SubscriptionStatus: def allows_product_workflows(self) -> bool: return self.state in {STATUS_ACTIVE, STATUS_GRACE, STATUS_LEGACY} + @property + def credentials_accepted(self) -> bool: + return self.state not in { + STATUS_NOT_CONFIGURED, + STATUS_KEY_INVALID, + STATUS_UNAVAILABLE, + } + @property def interface_available(self) -> bool: return self.state != STATUS_LEGACY @@ -84,6 +92,8 @@ def check_status( config=None, *, cmhub_config_path=None, + base_url=None, + api_key=None, request_json=None, ) -> SubscriptionStatus: """Look up the current API-key account subscription without leaking secrets. @@ -95,25 +105,32 @@ def check_status( 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")) + configured_base_url = ( + cmhub.get("base_url") if base_url is None else base_url + ) + resolved_base_url = appconfig.normalize_cmhub_base_url(configured_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) + resolved_api_key = ( + appconfig.get_cmhub_api_key(path=key_path) + if api_key is None + else str(api_key or "").strip() + ) except Exception: return SubscriptionStatus(STATUS_NOT_CONFIGURED) - if not base_url or not api_key: + if not resolved_base_url or not resolved_api_key: return SubscriptionStatus(STATUS_NOT_CONFIGURED) requester = request_json or ai.request_cmhub_json try: data = requester( "GET", - base_url, + resolved_base_url, "/api/v1/cmshopee/subscription/status", - api_key, + resolved_api_key, connect_timeout=cmhub.get( "connect_timeout", appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT, @@ -125,7 +142,7 @@ def check_status( return _status_from_cmhub_error(exc) except Exception: return SubscriptionStatus(STATUS_UNAVAILABLE) - return _parse_status_response(data, base_url) + return _parse_status_response(data, resolved_base_url) def format_expiry(value: str) -> str: @@ -173,7 +190,7 @@ def _parse_status_response(data, base_url: str) -> SubscriptionStatus: 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) + 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}: @@ -213,7 +230,7 @@ def _normalize_remote_state(value) -> str: }.get(normalized, "") -def _safe_manage_url(value, base_url: str) -> str: +def safe_manage_url(value, base_url: str) -> str: text = str(value or "").strip() if not text: return "" @@ -229,3 +246,6 @@ def _safe_manage_url(value, base_url: str) -> str: ): return "" return text + + +_safe_manage_url = safe_manage_url diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 45fdeca..45836b3 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -79,7 +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,服务端始终是最终授权方。T-700 后当前开发版本固定为 `SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = True`:查询期间和明确不允许状态只保留“设置”Tab,新提交统一由主窗口预检拦截;有效、宽限和旧服务兼容状态恢复工作流。 +- `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`。 - `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 +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`;有效/宽限状态把服务端返回的账号显示名、套餐名和有效期追加到 Windows 原生窗口标题,其他状态恢复纯应用名称并在底部状态栏显示脱敏中文结果。Tabs 上方不保留会员状态行,状态不写入 SQLite、诊断日志或导出。强制模式首次进入 `expired` 时,在门禁生效后弹一次中文窗口;安全的同网关 HTTPS `manage_url` 可用默认浏览器打开,退出走主窗口正常关闭。重复 `expired` 不重弹,恢复允许状态后才重置本次运行的弹窗标记。设置页只发出“重新检测会员状态”信号,由主窗口复用异步查询和陈旧结果隔离;`404` 表示服务端尚未启用订阅接口并按旧服务兼容放行,网络异常不被误判为会员到期。 +- 订阅状态 → 仅进程内 `SubscriptionStatus`;有效/宽限状态把服务端返回的账号显示名、套餐名和有效期追加到 Windows 原生窗口标题,其他状态恢复纯应用名称并在底部状态栏显示脱敏中文结果。Tabs 上方不保留会员状态行,状态不写入 SQLite、诊断日志或导出。强制模式首次进入 `expired` 时,在门禁生效后弹一次中文窗口;安全的同网关 HTTPS `manage_url` 可用默认浏览器打开,退出走主窗口正常关闭。重复 `expired` 不重弹,恢复允许状态后才重置本次运行的弹窗标记。设置页只发出“重新检测会员状态”信号,由主窗口复用异步查询和陈旧结果隔离;`404` 表示服务端尚未启用订阅接口并按旧服务兼容放行,网络异常不被误判为会员到期。首次激活后的使用清单只在 `config.json` 保存非敏感状态 `onboarding.first_use_guide_state`,取值为空、`pending`、`completed` 或 `dismissed`;默认空值保证存量用户不会被误判为新用户。 - cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 @@ -123,7 +123,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 "generate_mode": "title", "backend": "cmhub", "cmhub": { - "base_url": "", + "base_url": "https://cm.833729.com", "title_alias": "", "image_alias": "", "vision_alias": "vision-standard", @@ -152,6 +152,12 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 "language": "繁体中文", "ratio": "1:1" } + }, + "subscription": { + "last_notice_id": "" + }, + "onboarding": { + "first_use_guide_state": "" } } ``` @@ -159,7 +165,7 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 `ai` 段只放**选择 + 全局生成参数**: - `backend`:内部字段,取值仍支持 `cmhub` / `direct`;普通产品默认 `cmhub`,设置页不再展示「AI 后端」label 或 direct/cmhub 下拉,保存设置固定写 `cmhub`。`direct` 仅保留为内部兼容/手工回滚路径。 -- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段;`title_alias` / `image_alias` / `vision_alias` 分别对应②标题、②与商品套图正式生图、商品套图中的「AI帮写」图片理解,`vision_alias` 缺失时旧配置迁移为 `vision-standard`,`connect_timeout` 为连接超时;API Key 不在此处保存。设置页展示三类别名和扣点提示,其中图片理解下拉只接受 `operation_type=vision`、`requires_image=true` 且已定价的模型;已保存但暂不可用的值保留并明确提示。商品套图正式生成仍只使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。 +- `cmhub`:cmhub 网关配置,`base_url` 为网关根地址,干净安装和空值默认使用 `https://cm.833729.com`,已有非空地址保持不变;保存和请求前会规整为 scheme+host(+port),去掉 `/api`、`/api/v1`、其它路径、查询串和片段。`title_alias` / `image_alias` / `vision_alias` 分别对应②标题、②与商品套图正式生图、商品套图中的「AI帮写」图片理解,`vision_alias` 缺失时旧配置迁移为 `vision-standard`,`connect_timeout` 为连接超时;API Key 不在此处保存。设置页展示三类别名和扣点提示,其中图片理解下拉只接受 `operation_type=vision`、`requires_image=true` 且已定价的模型;已保存但暂不可用的值保留并明确提示。商品套图正式生成仍只使用已保存的生图 alias,不展示 OpenAI Key、Provider URL、上游接口路径或直连模型 slug。 - `default_text_model` / `default_image_model`:仅 direct 内部兼容模式下引用 `ai_models.json` 里的模型名(标题用文本模型、封面用图像模型);普通 cmhub 模式不读取这些模型定义,设置页不再展示标题/图片模型角色下拉。 - `generate_mode`:②「生成内容」下拉的主字段,取值 `title` / `cover` / `title_cover`,分别表示只生成标题、只生成封面、生成标题和封面;默认 `title`,避免用户无意产生封面生成成本。 - `generate_cover`:旧兼容字段;保存配置时仍写回,值由 `generate_mode` 推导。旧配置 `false` 会迁移为 `title`,`true` 会迁移为 `title_cover`。GUI 和生成逻辑以 `generate_mode` 为准。 @@ -221,8 +227,8 @@ T-538 后统一数据根为 `data/`:打包版默认 `/data`,源 ``` - 文件必须 gitignore,不提交;UI 展示打码。 -- `appconfig.load_cmhub_config()` 缺文件时返回空 Key;普通产品默认 backend 仍为 cmhub,但未配置 Key/Base URL/别名时生成阶段会给出清晰配置错误,不静默回退 direct。 -- `backend=cmhub` 但 Base URL、API Key 或别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示去设置配置,不静默回退 direct;cmhub HTTP 404 映射为 `CMHubError(code="not_found")`,提示检查 Base URL 或实例是否已部署 `/api/v1/models`。 +- `appconfig.load_cmhub_config()` 缺文件时返回空 Key;普通产品默认 backend 和公开 Base URL 已内置,但 API Key/别名未配置时仍给出清晰错误,不静默回退 direct。 +- `backend=cmhub` 但 API Key 或本轮所需别名缺失时,`app/ai.py` 抛 `CMHubError(code="cmhub_not_configured")`,提示完成默认网关配置,不静默回退 direct;cmhub HTTP 404 映射为 `CMHubError(code="not_found")`,提示检查 Base URL 或实例是否已部署 `/api/v1/models`。 - cmhub `/models` 如返回 `display_name/tags/recommended_for/tier/prices`,GUI 优先用这些字段生成中文档位说明;缺少这些字段时按 alias/tag 的保守规则兜底到“默认档”。客户端不得把 OpenAI 原始模型名作为默认执行事实。 ### 5.2 SQLite `cmshopee.db` diff --git a/docs/packaging.md b/docs/packaging.md index 178f167..8c3fd9b 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -234,6 +234,8 @@ 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 和开始采集的首次使用清单。 + 这些文件属于用户本地数据,不随新版本程序包覆盖。 ### 旧布局迁移 @@ -272,7 +274,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` 并进入 GUI。 +- 在干净目录首次启动时能生成 `data\config.json`,默认网关地址为 `https://cm.833729.com`,并出现只要求“会员 API Key”的中文激活窗口;安装包和新建 `data\` 中不得预置任何真实 Key。 +- 使用测试接口模拟无效 Key、网络失败和有效套餐:前两者不得生成含输入 Key 的本地文件或日志,有效套餐才写入 `data\config\cmhub.json`、启用业务 Tab 并显示首次使用清单。 - 在目标 Windows 10/11 机器或虚拟机上启动 release exe 后,主窗口标题栏完整可见,左边缘不出屏,用户能用标题栏拖动窗口;小分辨率环境不得出现窗口卡在左上角且标题栏不可拖动的问题(见 T-541)。 涉及 Shopee/CDP 的真实更新能力,仍按任务文档要求用测试商品做人工回归;打包任务本身不新增自动绕过登录、验证码或风控的能力。 diff --git a/docs/routes.md b/docs/routes.md index 0a701b3..0fee380 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -29,6 +29,8 @@ - 当前开发版本启用真实查询和强制门禁:`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://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 设置后的自动重查和陈旧线程结果隔离保持不变。 @@ -259,7 +261,8 @@ | 组件 | 归属 | 说明 | | --- | --- | --- | -| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、原生窗口标题会员摘要、底部状态栏、订阅受限恢复入口和全局消息 | +| `MainWindow(QMainWindow)` | 根窗口 | 持有 `QTabWidget`、原生窗口标题会员摘要、底部状态栏、首次激活/引导、订阅受限恢复入口和全局消息 | +| `MembershipActivationDialog(QDialog)` | 根窗口 | 干净安装只收集会员 API Key,展示本地明文保存说明并发出验证/会员中心操作;不持有网络请求或保存业务 | | `CollectTab(QWidget)` | ① | 导入、任务表、采集、回写 | | `GenerateTab(QWidget)` | ② | 左提示词管理 + 右筛选/任务列表;双击看新旧封面;先确认商品状态生成范围,再按本轮「生成内容」下拉接入 `GenerateWorker` | | `ApplyTab(QWidget)` | ③ | 已生成任务筛选 +「更新内容」下拉 + 商品状态优先/内容完整性预检剔除 +「检查本轮更新」+ 分批开始更新确认 + 检查/真实更新运行日志 + 结果回写与结束汇总 | @@ -270,7 +273,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 回主线程更新会员标签和工作流可用性 | +| `SubscriptionCheckWorker(BaseWorker)` | 根窗口 | 后台查询默认网关账号订阅状态;首次激活可使用只在内存中存在的 Base URL/Key 覆盖值,结果只通过 signal 回主线程更新会员状态和工作流可用性 | | `WriteBackWorker(BaseWorker)` | ①③ | ①回写旧字段;③回写新标题/新封面/更新状态 | | `ImageStudioPullImagesWorker / ImageStudioDownloadOriginalWorker / ProductSuiteImportImagesWorker / ProductSuiteGenerateWorker / ProductSuiteAiWriteWorker / CMHubModelCatalogWorker` | 商品套图 | 后台执行只读拉主图、远程原图下载、本地图片校验复制、默认网关异步或自定义网关同步套图生成、AI帮写和只读模型目录;拉图和本轮下载支持安全边界协作停止,worker 不直接操作 QWidget | diff --git a/docs/tasks/T-701.md b/docs/tasks/T-701.md index 8dacc3a..784dd8d 100644 --- a/docs/tasks/T-701.md +++ b/docs/tasks/T-701.md @@ -3,7 +3,7 @@ id: T-701 title: 新用户首次启动激活与使用引导 phase: 8 deps: [T-700] -status: TODO +status: DONE created: 2026-07-23 --- @@ -88,3 +88,8 @@ created: 2026-07-23 ## 执行记录 - 2026-07-23:根据零基础新用户首次启动体验评审创建任务。当前实现只自动切到设置并显示状态栏提示,缺少会员 API Key 激活入口、默认地址和激活后的工作流引导。 +- 2026-07-23:内置默认网关和官方会员中心公开地址 `https://cm.833729.com`;配置缺失或空 Base URL 时使用默认值,已有非空地址保持不变。新增非敏感 `onboarding.first_use_guide_state`,空值不触发引导,避免存量用户被误判为新用户。 +- 2026-07-23:新增 `MembershipActivationDialog`。缺少 Key 时只显示会员 API Key、显示/隐藏、本地明文保存说明、验证、会员中心和退出;关闭窗口等同退出。验证使用 worker 内存覆盖值,不提前落盘;Key 无效、网络失败或非法响应保留输入且不保存,服务端未拒绝凭据后才写入 `data/config/cmhub.json`。 +- 2026-07-23:有效、宽限和旧服务兼容状态恢复业务 Tab;有效 Key 但未订阅、过期、撤销或账号不可用时保存 Key 供续费后重查,但激活窗口和业务门禁保持。首次激活成功后显示“添加店铺 → 人工登录 → 导入 Excel → 采集/生成/更新”清单,支持开始配置、稍后提醒和不再提示。 +- 2026-07-23:同步 `docs/04-architecture.md`、`docs/routes.md`、`docs/packaging.md`;新增配置、订阅、激活窗口、凭据落盘边界、官方会员中心、首次引导和存量配置兼容测试。临时 Qt 截图验证窗口约 `520×318`,控件无几何重叠;本机测试环境缺少 Qt 字体目录,因此截图不用于中文字形验收。 +- 2026-07-23:验证通过:`py -3.10 -m unittest discover -s tests`(699 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。自动测试只使用临时目录和模拟订阅结果,未请求真实接口、未写入真实 `data/`、未使用真实 API Key。 diff --git a/tests/test_appconfig.py b/tests/test_appconfig.py index 2f4b63b..9898a76 100644 --- a/tests/test_appconfig.py +++ b/tests/test_appconfig.py @@ -438,7 +438,10 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): ai = appconfig.ai_config(config) self.assertEqual("cmhub", ai["backend"]) self.assertEqual("cmhub", appconfig.ai_backend(config)) - self.assertEqual("", appconfig.cmhub_config(config)["base_url"]) + self.assertEqual( + appconfig.DEFAULT_CMHUB_BASE_URL, + appconfig.cmhub_config(config)["base_url"], + ) self.assertEqual( appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT, appconfig.cmhub_config(config)["connect_timeout"], @@ -480,6 +483,47 @@ class AppConfigTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_default_gateway_url_and_first_use_state_preserve_existing_config(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + with open(config_path, "w", encoding="utf-8") as fh: + json.dump({"ai": {"cmhub": {"base_url": ""}}}, fh) + + migrated = appconfig.load_config(config_path) + + self.assertEqual( + appconfig.DEFAULT_CMHUB_BASE_URL, + appconfig.cmhub_config(migrated)["base_url"], + ) + self.assertEqual("", appconfig.first_use_guide_state(migrated)) + + migrated["ai"]["cmhub"]["base_url"] = "https://gateway.example.com/api/v1" + saved = appconfig.save_config(migrated, path=config_path) + self.assertEqual( + "https://gateway.example.com", + appconfig.cmhub_config(saved)["base_url"], + ) + + pending = appconfig.save_first_use_guide_state( + "pending", + path=config_path, + ) + self.assertEqual("pending", appconfig.first_use_guide_state(pending)) + self.assertEqual( + "https://gateway.example.com", + appconfig.cmhub_config(pending)["base_url"], + ) + + dismissed = appconfig.save_first_use_guide_state( + "dismissed", + path=config_path, + ) + self.assertEqual("dismissed", appconfig.first_use_guide_state(dismissed)) + with self.assertRaises(appconfig.ConfigError): + appconfig.save_first_use_guide_state("unknown", path=config_path) + + 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") diff --git a/tests/test_gui.py b/tests/test_gui.py index bb19f3e..5cc9658 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -64,6 +64,7 @@ from app.gui import ( ImageStudioPreviewDialog, ImageStudioTab, MainWindow, + MembershipActivationDialog, ProductSuitePreviewDialog, ProductSuiteTab, SettingsTab, @@ -344,6 +345,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): DestructiveRole = object() RejectRole = object() Warning = object() + Information = object() def __init__(self, parent=None): self.parent = parent @@ -407,6 +409,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): DestructiveRole = object() RejectRole = object() Warning = object() + Information = object() def __init__(self, parent=None): self.parent = parent @@ -11194,6 +11197,265 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_membership_activation_dialog_collects_only_key_and_validates_locally(self): + dialog = MembershipActivationDialog() + self.addCleanup(dialog.close) + submitted = [] + dialog.validationRequested.connect(submitted.append) + + dialog.validate_button.click() + + self.assertEqual([], submitted) + self.assertIn("请输入会员 API Key", dialog.status_label.text()) + self.assertIsNone(dialog.findChild(QLineEdit, "cmhubBaseUrlEdit")) + self.assertEqual(QLineEdit.Password, dialog.api_key_edit.echoMode()) + + dialog.api_key_edit.setText("temporary-test-key") + dialog.show_key_checkbox.setChecked(True) + dialog.validate_button.click() + + self.assertEqual(["temporary-test-key"], submitted) + self.assertEqual(QLineEdit.Normal, dialog.api_key_edit.echoMode()) + self.assertEqual("验证并开始使用", dialog.validate_button.text()) + + def test_missing_membership_key_opens_activation_instead_of_raw_settings(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_NOT_CONFIGURED + ) + + with mock.patch.object(window, "_show_membership_activation") as activation: + window._apply_subscription_status(status) + + activation.assert_called_once_with() + self.assertEqual("请先绑定会员账号", window.statusBar().currentMessage()) + for index, title in enumerate(TAB_TITLES): + self.assertEqual(title == "设置", window.tabs.isTabEnabled(index)) + + def test_activation_validation_uses_in_memory_key_worker(self): + class _Worker: + def __init__(self): + self.finished = DummySignal() + self.cancelled = DummySignal() + self.cancel_requested = False + + def cancel(self): + self.cancel_requested = True + + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + cfg = appconfig.load_config(config_path) + window = MainWindow(config=cfg, config_path=config_path) + self.addCleanup(window.close) + window._show_membership_activation() + worker = _Worker() + thread = FakeThread() + + with mock.patch.object( + main_window, + "SubscriptionCheckWorker", + return_value=worker, + ) as worker_factory, mock.patch.object( + main_window, + "run_worker", + return_value=thread, + ): + window._activation_dialog.api_key_edit.setText("temporary-test-key") + window._activation_dialog.validate_button.click() + + self.assertTrue(thread.started) + self.assertFalse(window._activation_dialog.validate_button.isEnabled()) + kwargs = worker_factory.call_args.kwargs + self.assertEqual("temporary-test-key", kwargs["api_key"]) + self.assertEqual(appconfig.DEFAULT_CMHUB_BASE_URL, kwargs["base_url"]) + self.assertEqual( + "", + appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]), + ) + + def test_activation_invalid_key_is_not_persisted(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + cfg = appconfig.load_config(config_path) + window = MainWindow(config=cfg, config_path=config_path) + self.addCleanup(window.close) + window._show_membership_activation() + window._subscription_request_token = 7 + + window._on_activation_checked( + 7, + "invalid-test-key", + { + "subscription": subscription.SubscriptionStatus( + subscription.STATUS_KEY_INVALID + ) + }, + ) + + self.assertEqual( + "", + appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]), + ) + self.assertIn("会员 API Key 无效", window._activation_dialog.status_label.text()) + self.assertFalse( + window.tabs.isTabEnabled(TAB_TITLES.index("① 导入采集")) + ) + + def test_activation_valid_key_without_plan_is_saved_but_stays_restricted(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + cfg = appconfig.load_config(config_path) + window = MainWindow(config=cfg, config_path=config_path) + self.addCleanup(window.close) + window._show_membership_activation() + window._subscription_request_token = 8 + + window._on_activation_checked( + 8, + "valid-without-plan-key", + { + "subscription": subscription.SubscriptionStatus( + subscription.STATUS_REQUIRED, + manage_url="https://cm.833729.com", + ) + }, + ) + + self.assertEqual( + "valid-without-plan-key", + appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]), + ) + self.assertIsNotNone(window._activation_dialog) + self.assertIn( + "尚未开通", + window._activation_dialog.status_label.text(), + ) + for index, title in enumerate(TAB_TITLES): + self.assertEqual(title == "设置", window.tabs.isTabEnabled(index)) + + def test_closing_activation_dialog_exits_restricted_app(self): + with self.make_temp_dir() as temp_dir: + window = MainWindow(config=self.make_config(temp_dir)) + self.addCleanup(window.close) + window._subscription_status = subscription.SubscriptionStatus( + subscription.STATUS_NOT_CONFIGURED + ) + window._show_membership_activation() + + with mock.patch.object(window, "close") as close: + window._activation_dialog.reject() + + close.assert_called_once_with() + self.assertIsNone(window._activation_dialog) + + def test_activation_success_saves_key_and_marks_guide_pending(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + cfg = appconfig.load_config(config_path) + window = MainWindow(config=cfg, config_path=config_path) + self.addCleanup(window.close) + window._show_membership_activation() + window._subscription_request_token = 9 + active = subscription.SubscriptionStatus( + subscription.STATUS_ACTIVE, + account_name="新账号", + plan_name="测试套餐", + expires_at="2026-08-20T23:59:59+08:00", + ) + + with mock.patch.object(main_window.QTimer, "singleShot"): + window._on_activation_checked( + 9, + "valid-test-key", + {"subscription": active}, + ) + + self.assertIsNone(window._activation_dialog) + self.assertEqual( + "valid-test-key", + appconfig.get_cmhub_api_key(cfg["cmhub_config_path"]), + ) + self.assertEqual( + "pending", + appconfig.first_use_guide_state( + appconfig.load_config(config_path) + ), + ) + self.assertEqual( + "valid-test-key", + window._settings_tab().cmhub_api_key_edit.text(), + ) + self.assertTrue( + all(window.tabs.isTabEnabled(index) for index in range(window.tabs.count())) + ) + + def test_first_use_guide_can_start_account_setup_or_remind_later(self): + with self.make_temp_dir() as temp_dir: + config_path = os.path.join(temp_dir, "config.json") + cfg = appconfig.load_config(config_path) + appconfig.save_first_use_guide_state("pending", path=config_path) + cfg = appconfig.load_config(config_path) + window = MainWindow(config=cfg, config_path=config_path) + self.addCleanup(window.close) + window._subscription_status = subscription.SubscriptionStatus( + subscription.STATUS_ACTIVE, + account_name="新账号", + plan_name="测试套餐", + expires_at="2026-08-20T23:59:59+08:00", + ) + fake_box, boxes = self.make_fake_message_box("开始配置店铺") + + with mock.patch.object(main_window, "QMessageBox", fake_box): + window._show_first_use_guide_if_pending() + + self.assertEqual(1, len(boxes)) + self.assertIn("1. 添加蝦皮店铺账号", boxes[0].text) + self.assertEqual(TAB_TITLES.index("账号管理"), window.tabs.currentIndex()) + self.assertEqual( + "completed", + appconfig.first_use_guide_state(appconfig.load_config(config_path)), + ) + + appconfig.save_first_use_guide_state("pending", path=config_path) + window.config.clear() + window.config.update(appconfig.load_config(config_path)) + window._first_use_guide_shown = False + fake_box, boxes = self.make_fake_message_box("稍后提醒") + with mock.patch.object(main_window, "QMessageBox", fake_box): + window._show_first_use_guide_if_pending() + + self.assertEqual(1, len(boxes)) + self.assertEqual( + "pending", + appconfig.first_use_guide_state(appconfig.load_config(config_path)), + ) + self.assertIn("稍后", window.statusBar().currentMessage()) + + def test_activation_member_center_uses_fixed_official_https_url(self): + with self.make_temp_dir() as temp_dir: + window = MainWindow(config=self.make_config(temp_dir)) + self.addCleanup(window.close) + window._show_membership_activation() + + with mock.patch.object( + main_window.QDesktopServices, + "openUrl", + return_value=False, + ) as open_url: + window._open_activation_member_center() + + open_url.assert_called_once() + self.assertEqual( + appconfig.DEFAULT_MEMBER_CENTER_URL, + open_url.call_args.args[0].toString(), + ) + self.assertIn( + "无法打开会员中心", + window._activation_dialog.status_label.text(), + ) + 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)) diff --git a/tests/test_product_suite_gui.py b/tests/test_product_suite_gui.py index 9729ba0..60d6676 100644 --- a/tests/test_product_suite_gui.py +++ b/tests/test_product_suite_gui.py @@ -322,7 +322,6 @@ class ProductSuiteGuiTests(TempDirMixin, unittest.TestCase): tab._load_state(state) cases = ( - ("base_url", "", "尚未配置默认网关地址", "test-key"), ("vision_alias", "", "尚未选择图片理解模型", "test-key"), ("vision_alias", "vision-standard", "尚未配置默认网关 API Key", ""), ) diff --git a/tests/test_subscription.py b/tests/test_subscription.py index 2109fa6..fa98c35 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -64,6 +64,35 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): ) self.assertEqual("GET", calls[0][0][0]) self.assertNotIn("test-key", repr(result)) + self.assertTrue(result.credentials_accepted) + + def test_in_memory_key_override_is_used_without_persisting_it(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"]) + calls = [] + + def request_json(*args, **kwargs): + calls.append((args, kwargs)) + return { + "product_code": "cmshopee", + "account": {"display_name": "新账号"}, + "plan": {"display_name": "测试套餐"}, + "status": "active", + "expires_at": "2026-08-20T23:59:59+08:00", + } + + result = subscription.check_status( + config, + base_url="https://activation.example.com/api/v1", + api_key="temporary-test-key", + request_json=request_json, + ) + + self.assertEqual(subscription.STATUS_ACTIVE, result.state) + self.assertEqual("https://activation.example.com", calls[0][0][1]) + self.assertEqual("temporary-test-key", calls[0][0][3]) + self.assertEqual("", appconfig.get_cmhub_api_key(config["cmhub_config_path"])) def test_grace_and_expired_states_are_distinct(self): with self.make_temp_dir() as temp_dir: @@ -125,6 +154,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): result = subscription.check_status(config, request_json=invalid_key) self.assertEqual(subscription.STATUS_KEY_INVALID, result.state) self.assertNotIn("不应展示", result.user_message) + self.assertFalse(result.credentials_accepted) def network_failure(*args, **kwargs): raise ai.CMHubError("network_error", "不应展示") @@ -132,6 +162,7 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): result = subscription.check_status(config, request_json=network_failure) self.assertEqual(subscription.STATUS_UNAVAILABLE, result.state) self.assertNotIn("不应展示", result.user_message) + self.assertFalse(result.credentials_accepted) def test_invalid_product_or_external_manage_url_is_not_trusted(self): with self.make_temp_dir() as temp_dir: