diff --git a/app/gui/main_window.py b/app/gui/main_window.py index 239e9b6..fce3325 100644 --- a/app/gui/main_window.py +++ b/app/gui/main_window.py @@ -20,9 +20,9 @@ from .workers import SubscriptionCheckWorker PREFERRED_WINDOW_SIZE = (1180, 760) MIN_WINDOW_SIZE = (960, 640) WINDOW_SCREEN_MARGIN = 40 -# 观察期真实查询会员状态,但不在客户端限制存量用户工作流。 +# 当前开发版本启用真实查询和客户端强制门禁。 SUBSCRIPTION_CHECK_ENABLED = True -SUBSCRIPTION_ENFORCEMENT_ENABLED = False +SUBSCRIPTION_ENFORCEMENT_ENABLED = True def _screen_available_geometry(): @@ -121,6 +121,7 @@ class MainWindow(QMainWindow): self._subscription_thread = None self._subscription_request_token = 0 self._subscription_closed = False + self._expired_subscription_notice_shown = False self.tabs = QTabWidget() self.tabs.setObjectName("mainTabs") self.tabs.setStyleSheet(TAB_STYLE) @@ -130,6 +131,10 @@ class MainWindow(QMainWindow): settings_tab = self._settings_tab() if hasattr(settings_tab, "settingsSaved"): settings_tab.settingsSaved.connect(self._on_settings_saved) + if hasattr(settings_tab, "subscriptionCheckRequested"): + settings_tab.subscriptionCheckRequested.connect( + self.begin_subscription_check + ) central = QWidget() central_layout = QVBoxLayout(central) central_layout.setContentsMargins(0, 0, 0, 0) @@ -229,6 +234,7 @@ class MainWindow(QMainWindow): self._set_membership_window_title() if not SUBSCRIPTION_CHECK_ENABLED: self._set_product_access(True) + self._set_subscription_check_running(False) self.show_status("会员订阅检测已暂停", level="muted") return self._subscription_request_token += 1 @@ -236,6 +242,7 @@ class MainWindow(QMainWindow): if self._subscription_worker is not None: self._subscription_worker.cancel() self._set_product_access(not SUBSCRIPTION_ENFORCEMENT_ENABLED) + self._set_subscription_check_running(True) self.show_status("正在验证会员状态", level="info") worker = SubscriptionCheckWorker( config=self.config, @@ -287,9 +294,12 @@ class MainWindow(QMainWindow): if self._subscription_thread is thread: self._subscription_thread = None self._subscription_worker = None + self._set_subscription_check_running(False) def _apply_subscription_status(self, status): self._subscription_status = status + if status.allows_product_workflows: + self._expired_subscription_notice_shown = False if status.state == subscription.STATUS_ACTIVE: expiry = subscription.format_expiry(status.expires_at) self._set_membership_window_title( @@ -335,6 +345,8 @@ class MainWindow(QMainWindow): return self.open_settings_tab() self.show_status(status.user_message, level=self._subscription_level(status)) + if status.state == subscription.STATUS_EXPIRED: + self._show_expired_subscription_notice_once(status) @staticmethod def _subscription_level(status): @@ -362,6 +374,47 @@ class MainWindow(QMainWindow): for index, title in enumerate(TAB_TITLES): self.tabs.setTabEnabled(index, bool(enabled) or title == "设置") + def _set_subscription_check_running(self, running): + settings_tab = self._settings_tab() + if hasattr(settings_tab, "set_subscription_check_running"): + 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 + + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("会员套餐已过期") + text = ( + "当前账号的蝦皮圈会员已到期,业务功能已暂停。" + "请前往会员中心续费或更换套餐,完成后返回设置重新检测会员状态。" + ) + manage_url = str(status.manage_url or "").strip() + if not manage_url: + text += "\n\n会员中心地址当前不可用,请检查默认网关配置或稍后重试。" + box.setText(text) + manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole) + exit_button = box.addButton("退出程序", QMessageBox.DestructiveRole) + manage_button.setEnabled(bool(manage_url)) + if manage_url: + box.setDefaultButton(manage_button) + else: + manage_button.setToolTip("会员中心地址当前不可用") + box.exec() + + clicked = box.clickedButton() + if 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 exit_button: + self.close() + def _show_subscription_notice_once(self, status): notice_id = str(status.notice_id or "").strip() if not notice_id or notice_id == appconfig.subscription_notice_id(self.config): diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py index b8300b5..9839fc1 100644 --- a/app/gui/tabs/settings.py +++ b/app/gui/tabs/settings.py @@ -66,6 +66,7 @@ class SettingsTab(QWidget): """Tab 5: AI model definitions stored in data/config/ai_models.json.""" settingsSaved = Signal(str) + subscriptionCheckRequested = Signal() BACKEND_ITEMS = [ ("默认网关", "cmhub"), @@ -279,6 +280,9 @@ class SettingsTab(QWidget): self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin") self.cdp_ready_timeout_spin.setRange(1, 3600) self.save_config_button = QPushButton("保存设置") + self.subscription_check_button = QPushButton("重新检测会员状态") + self.subscription_check_button.setObjectName("subscriptionCheckButton") + self.subscription_check_button.setToolTip("重新查询当前 cmhub 账号的会员套餐状态") self.unsaved_changes_label = QLabel("● 未保存更改") self.unsaved_changes_label.setObjectName("settingsUnsavedChangesLabel") self.unsaved_changes_label.setStyleSheet("color: #bc4c00; font-weight: 600;") @@ -455,6 +459,7 @@ class SettingsTab(QWidget): save_settings_layout.addWidget(self.save_config_button) save_settings_layout.addWidget(self.unsaved_changes_label) save_settings_layout.addStretch(1) + save_settings_layout.addWidget(self.subscription_check_button) panel_layout.addLayout(save_settings_layout) panel_layout.addStretch(1) @@ -490,6 +495,9 @@ class SettingsTab(QWidget): self.chrome_path_browse_button.clicked.connect(self.browse_chrome_path) self.chrome_path_detect_button.clicked.connect(self.detect_chrome_path) self.save_config_button.clicked.connect(self.save_app_settings) + self.subscription_check_button.clicked.connect( + self._request_subscription_check + ) self._connect_dirty_signals() with self._dirty_tracking_suspended(): @@ -548,6 +556,16 @@ class SettingsTab(QWidget): def _set_status(self, message, level=None): _emit_status(self.status_callback, message, level=level) + def _request_subscription_check(self, checked=False): + self.subscriptionCheckRequested.emit() + + def set_subscription_check_running(self, running): + running = bool(running) + self.subscription_check_button.setEnabled(not running) + self.subscription_check_button.setText( + "正在检测会员状态..." if running else "重新检测会员状态" + ) + @contextmanager def _dirty_tracking_suspended(self): self._suspend_dirty += 1 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 7722988..45fdeca 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-696 观察期固定为 `SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = False`:真实查询并展示状态,但任何结果都不限制工作流或新提交,正式门禁必须另建任务启用。 +- `subscription`:使用 cmhub API Key 查询 `GET /api/v1/cmshopee/subscription/status`,把远端权益结果归一化为有效、宽限、未订阅、到期、撤销、账号不可用、Key 无效、暂时不可用或旧服务兼容状态;不保存、展示或记录 API Key,服务端始终是最终授权方。T-700 后当前开发版本固定为 `SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = True`:查询期间和明确不允许状态只保留“设置”Tab,新提交统一由主窗口预检拦截;有效、宽限和旧服务兼容状态恢复工作流。 - `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、诊断日志或导出。观察期不显示 `notice_id` 模态通知、不禁用 Tab、不自动切换设置且新提交预检始终放行;`404` 表示服务端尚未启用订阅接口,网络异常不被误判为会员到期。 +- 订阅状态 → 仅进程内 `SubscriptionStatus`;有效/宽限状态把服务端返回的账号显示名、套餐名和有效期追加到 Windows 原生窗口标题,其他状态恢复纯应用名称并在底部状态栏显示脱敏中文结果。Tabs 上方不保留会员状态行,状态不写入 SQLite、诊断日志或导出。强制模式首次进入 `expired` 时,在门禁生效后弹一次中文窗口;安全的同网关 HTTPS `manage_url` 可用默认浏览器打开,退出走主窗口正常关闭。重复 `expired` 不重弹,恢复允许状态后才重置本次运行的弹窗标记。设置页只发出“重新检测会员状态”信号,由主窗口复用异步查询和陈旧结果隔离;`404` 表示服务端尚未启用订阅接口并按旧服务兼容放行,网络异常不被误判为会员到期。 - cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。 - 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。 - 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。 diff --git a/docs/routes.md b/docs/routes.md index c8c9630..0a701b3 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -27,13 +27,13 @@ ## 会员订阅接入 -- 当前处于真实接口观察期:`SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = False`。客户端真实查询并展示订阅状态,但查询中、未配置 Key、Key 无效、账号禁用、未订阅、到期、撤销或暂时不可用均不禁用六个工作流、不自动切换「设置」、不阻止新提交。 +- 当前开发版本启用真实查询和强制门禁:`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、状态栏、日志和错误提示。 -- Tabs 上方不保留应用内标题或会员状态行。Windows 原生标题默认只显示应用名称;订阅有效时追加“账号名 · 套餐名 · 有效至日期”,宽限期追加宽限截止日。检测中和其他状态立即恢复纯应用名称,中文观察结果及“当前不影响使用”只显示在底部状态栏。标题不得展示 API Key、接口地址、会员中心地址、通知标识或原始错误。 -- 观察期不显示 `notice_id` 对应的“已启用会员订阅”模态通知框;保存 cmhub 设置后会立即重新检查,但陈旧线程结果不能覆盖新状态。 +- Tabs 上方不保留应用内标题或会员状态行。Windows 原生标题默认只显示应用名称;订阅有效时追加“账号名 · 套餐名 · 有效至日期”,宽限期追加宽限截止日。检测中和其他状态立即恢复纯应用名称并在底部状态栏显示脱敏中文结果。标题不得展示 API Key、接口地址、会员中心地址、通知标识或原始错误。 +- 本次程序运行中首次进入 `expired` 时,先应用门禁并切换「设置」,再弹出「会员套餐已过期」。安全 `manage_url` 可通过系统默认浏览器打开;地址缺失时跳转按钮禁用。重复过期检查不重弹,恢复有效后再次到期才重弹;「退出程序」沿用正常关闭和未保存设置确认。 +- 设置页底部提供「重新检测会员状态」;检测期间按钮禁用并显示运行状态。它只通知主窗口复用现有异步检查,保存 cmhub 设置后的自动重查和陈旧线程结果隔离保持不变。 - 当前服务端未部署订阅接口时,`404` 视为旧服务兼容:底部状态栏显示“会员服务尚未启用”,六个工作流继续按旧行为运行,不弹阻断窗口。网络或格式错误显示“暂时无法确认会员状态”,不能误报为 Key 无效或会员到期。 -- ② AI生成、⑥商品套图和「AI帮写」继续调用同一订阅预检入口,但观察模式始终放行;cmhub 产品接口仍必须服务端最终裁决。已提交的异步任务继续查询、下载和查看。自定义网关直连的观察结果同样不能作为不可绕过的授权保护。 -- 后续正式门禁需另建任务,先根据观察结果确认存量 Key/套餐覆盖率、网络失败策略和回滚方案;启用后才恢复 T-686 的“非有效状态只保留设置 Tab”和一次性订阅通知逻辑。 +- ② AI生成、⑥商品套图和「AI帮写」继续调用同一订阅预检入口;非允许状态不创建新 worker/job。cmhub 产品接口仍是最终授权方;已提交异步任务的查询、下载、历史、预览和导出不因会员状态变化中断。 ## 全局 Tab 栏可用性 diff --git a/docs/tasks/T-700.md b/docs/tasks/T-700.md index e088f21..ac60dbd 100644 --- a/docs/tasks/T-700.md +++ b/docs/tasks/T-700.md @@ -3,7 +3,7 @@ id: T-700 title: 会员过期强制提示与续费后重新检测 phase: 8 deps: [T-696, T-698] -status: TODO +status: DONE created: 2026-07-23 --- @@ -66,4 +66,10 @@ created: 2026-07-23 ## 执行记录 -- 待完成。 +- 2026-07-23:当前开发版本启用真实订阅查询与客户端强制门禁;查询期间及非允许状态只保留“设置”Tab,新提交继续由主窗口统一预检拦截。 +- 新增「会员套餐已过期」中文模态框:本次运行首次进入 `expired` 时在门禁生效后弹出,连续过期查询不重复弹;恢复有效、宽限或旧服务兼容后再次到期可重新提示。 +- 弹窗提供「前往会员中心」和「退出程序」。前者只消费订阅层已过滤的同网关 HTTPS `manage_url` 并用系统默认浏览器打开;地址缺失时禁用按钮,浏览器启动失败显示中文状态。后者调用主窗口正常关闭,保留未保存设置确认和 worker 协作取消。 +- 设置页底部新增「重新检测会员状态」,通过 signal 通知主窗口复用现有异步检查;运行时按钮禁用并显示“正在检测会员状态...”,线程结束后恢复。保存设置自动重查、请求 token 和陈旧结果隔离逻辑不变。 +- 更新架构和界面流程文档;新增过期弹窗去重、浏览器跳转失败、无地址禁用、退出、重新检测按钮状态及观察模式兼容测试。已提交任务的查询、下载、历史、预览和导出不受门禁影响。 +- 本机脱敏联调结果仍为 `active`、允许产品工作流且安全会员中心地址可用;真实 `expired` 弹窗需 cmhub 将 dev 测试账号套餐改为过期后再人工验收,自动测试已覆盖该状态。 +- 验证通过:订阅测试 6 项、`tests.test_gui`(222 项)、`py -3.10 -m unittest discover -s tests`(688 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index 6416c68..bb19f3e 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -327,10 +327,23 @@ class GuiTests(TempDirMixin, unittest.TestCase): def make_fake_message_box(self, selected_label): boxes = [] + class FakeButton: + def __init__(self): + self.enabled = True + self.tooltip = "" + + def setEnabled(self, enabled): + self.enabled = bool(enabled) + + def setToolTip(self, tooltip): + self.tooltip = str(tooltip) + class FakeMessageBox: AcceptRole = object() + ActionRole = object() DestructiveRole = object() RejectRole = object() + Warning = object() def __init__(self, parent=None): self.parent = parent @@ -338,8 +351,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.text = "" self.buttons = {} self.default_button = None + self.icon = None boxes.append(self) + def setIcon(self, icon): + self.icon = icon + def setWindowTitle(self, title): self.title = title @@ -347,7 +364,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.text = text def addButton(self, label, role): - button = object() + button = FakeButton() self.buttons[label] = button return button @@ -373,10 +390,23 @@ class GuiTests(TempDirMixin, unittest.TestCase): boxes = [] labels = list(selected_labels) + class FakeButton: + def __init__(self): + self.enabled = True + self.tooltip = "" + + def setEnabled(self, enabled): + self.enabled = bool(enabled) + + def setToolTip(self, tooltip): + self.tooltip = str(tooltip) + class FakeMessageBox: AcceptRole = object() + ActionRole = object() DestructiveRole = object() RejectRole = object() + Warning = object() def __init__(self, parent=None): self.parent = parent @@ -385,8 +415,12 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.buttons = {} self.default_button = None self.selected_label = labels.pop(0) + self.icon = None boxes.append(self) + def setIcon(self, icon): + self.icon = icon + def setWindowTitle(self, title): self.title = title @@ -394,7 +428,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.text = text def addButton(self, label, role): - button = object() + button = FakeButton() self.buttons[label] = button return button @@ -11181,6 +11215,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): all(window.tabs.isTabEnabled(index) for index in range(window.tabs.count())) ) + @mock.patch.object(main_window, "SUBSCRIPTION_ENFORCEMENT_ENABLED", False) def test_main_window_membership_title_uses_grace_and_resets_for_invalid_status(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) @@ -11212,22 +11247,163 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertIn("当前不影响使用", window.statusBar().currentMessage()) def test_main_window_restricts_tabs_when_subscription_is_invalid(self): + self.assertTrue(main_window.SUBSCRIPTION_ENFORCEMENT_ENABLED) with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) self.addCleanup(window.close) status = subscription.SubscriptionStatus(subscription.STATUS_EXPIRED) with mock.patch.object( - main_window, - "SUBSCRIPTION_ENFORCEMENT_ENABLED", - True, - ): + window, + "_show_expired_subscription_notice_once", + ) as notice: window._apply_subscription_status(status) + notice.assert_called_once_with(status) for index, title in enumerate(TAB_TITLES): self.assertEqual(title == "设置", window.tabs.isTabEnabled(index)) self.assertEqual(TAB_TITLES.index("设置"), window.tabs.currentIndex()) + self.assertFalse(window.ensure_subscription_for_new_submit("开始 AI 生成")) + def test_expired_subscription_notice_opens_member_center_once_per_transition(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( + ["前往会员中心", "前往会员中心"] + ) + expired = subscription.SubscriptionStatus( + subscription.STATUS_EXPIRED, + manage_url="https://cm.example.com/user/subscriptions/cmshopee", + ) + active = subscription.SubscriptionStatus( + subscription.STATUS_ACTIVE, + account_name="主账号", + plan_name="测试", + expires_at="2026-08-21T23:59:59+08:00", + ) + + with mock.patch.object( + main_window, + "QMessageBox", + fake_box, + ), mock.patch.object( + main_window.QDesktopServices, + "openUrl", + side_effect=[True, False], + ) as open_url: + window._apply_subscription_status(expired) + window._apply_subscription_status(expired) + window._apply_subscription_status(active) + window._apply_subscription_status(expired) + + self.assertEqual(2, len(boxes)) + self.assertTrue(all(box.title == "会员套餐已过期" for box in boxes)) + self.assertTrue(all("业务功能已暂停" in box.text for box in boxes)) + self.assertTrue( + all( + box.default_button is box.buttons["前往会员中心"] + for box in boxes + ) + ) + self.assertEqual(2, open_url.call_count) + self.assertIn( + "无法打开会员中心", + window.statusBar().currentMessage(), + ) + self.assertTrue( + all( + call.args[0].toString() + == "https://cm.example.com/user/subscriptions/cmshopee" + for call in open_url.call_args_list + ) + ) + + def test_expired_subscription_notice_disables_missing_url_and_can_exit(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_fake_message_box("退出程序") + expired = subscription.SubscriptionStatus(subscription.STATUS_EXPIRED) + + with mock.patch.object( + main_window, + "QMessageBox", + fake_box, + ), mock.patch.object( + main_window.QDesktopServices, + "openUrl", + ) as open_url, mock.patch.object(window, "close") as close: + window._apply_subscription_status(expired) + + self.assertEqual(1, len(boxes)) + box = boxes[0] + self.assertFalse(box.buttons["前往会员中心"].enabled) + self.assertEqual( + "会员中心地址当前不可用", + box.buttons["前往会员中心"].tooltip, + ) + self.assertIn("会员中心地址当前不可用", box.text) + self.assertIsNone(box.default_button) + open_url.assert_not_called() + close.assert_called_once_with() + + def test_settings_subscription_recheck_uses_main_window_worker_state(self): + class _Signal: + def __init__(self): + self.callbacks = [] + + def connect(self, callback): + self.callbacks.append(callback) + + class _Worker: + def __init__(self): + self.finished = _Signal() + self.cancelled = _Signal() + + def cancel(self): + pass + + class _Thread: + def __init__(self): + self.finished = _Signal() + self.started = False + + def start(self): + self.started = True + + with self.make_temp_dir() as temp_dir: + window = MainWindow(config=self.make_config(temp_dir)) + self.addCleanup(window.close) + settings_tab = window._settings_tab() + worker = _Worker() + thread = _Thread() + + with mock.patch( + "app.gui.main_window.SubscriptionCheckWorker", + return_value=worker, + ), mock.patch( + "app.gui.main_window.run_worker", + return_value=thread, + ): + settings_tab.subscription_check_button.click() + + self.assertTrue(thread.started) + self.assertFalse(settings_tab.subscription_check_button.isEnabled()) + self.assertEqual( + "正在检测会员状态...", + settings_tab.subscription_check_button.text(), + ) + + window._forget_subscription_thread(thread) + + self.assertTrue(settings_tab.subscription_check_button.isEnabled()) + self.assertEqual( + "重新检测会员状态", + settings_tab.subscription_check_button.text(), + ) + + @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) self.assertFalse(main_window.SUBSCRIPTION_ENFORCEMENT_ENABLED) @@ -11291,6 +11467,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): ) self.assertTrue(window.ensure_subscription_for_new_submit("开始 AI 生成")) + @mock.patch.object(main_window, "SUBSCRIPTION_ENFORCEMENT_ENABLED", False) def test_main_window_observation_mode_suppresses_notice_and_rechecks_after_save(self): with self.make_temp_dir() as temp_dir: window = MainWindow(config=self.make_config(temp_dir)) diff --git a/tests/test_subscription.py b/tests/test_subscription.py index 71be7bb..2109fa6 100644 --- a/tests/test_subscription.py +++ b/tests/test_subscription.py @@ -90,11 +90,16 @@ class SubscriptionTests(TempDirMixin, unittest.TestCase): "plan": {"display_name": "专业版"}, "status": "expired", "expires_at": "2026-08-20T23:59:59+08:00", + "manage_url": "https://cm.example.com/user/subscriptions/cmshopee", } expired = subscription.check_status(config, request_json=expired_request) self.assertEqual(subscription.STATUS_EXPIRED, expired.state) self.assertFalse(expired.allows_product_workflows) + self.assertEqual( + "https://cm.example.com/user/subscriptions/cmshopee", + expired.manage_url, + ) def test_legacy_404_keeps_existing_workflows_available(self): with self.make_temp_dir() as temp_dir: