feat(subscription): enable observation mode

This commit is contained in:
chengma
2026-07-23 14:37:00 +08:00
parent a2b57db715
commit 594592c9b2
6 changed files with 129 additions and 22 deletions
+1
View File
@@ -189,6 +189,7 @@ def main() -> int:
if runtime_lease is not None:
app.aboutToQuit.connect(runtime_lease.release)
window.show()
QTimer.singleShot(0, window.begin_subscription_check)
QTimer.singleShot(
0,
lambda: update_health.write_health(health_context, "main_window_ready"),
+33 -7
View File
@@ -20,8 +20,9 @@ from .workers import SubscriptionCheckWorker
PREFERRED_WINDOW_SIZE = (1180, 760)
MIN_WINDOW_SIZE = (960, 640)
WINDOW_SCREEN_MARGIN = 40
# 订阅服务仍在联调期,暂不让客户端检查或限制本地工作流。
SUBSCRIPTION_CHECK_ENABLED = False
# 观察期真实查询会员状态,但不在客户端限制存量用户工作流。
SUBSCRIPTION_CHECK_ENABLED = True
SUBSCRIPTION_ENFORCEMENT_ENABLED = False
def _screen_available_geometry():
@@ -239,6 +240,7 @@ class MainWindow(QMainWindow):
widget.refresh_gateway_state()
label = "自定义网关" if str(backend) == "direct" else "默认网关"
self.show_status("设置已保存,当前使用%s" % label, level="success")
self.begin_subscription_check()
@property
def subscription_status(self):
@@ -255,7 +257,7 @@ class MainWindow(QMainWindow):
token = self._subscription_request_token
if self._subscription_worker is not None:
self._subscription_worker.cancel()
self._set_product_access(False)
self._set_product_access(not SUBSCRIPTION_ENFORCEMENT_ENABLED)
self._set_subscription_label("会员:正在验证", "info")
worker = SubscriptionCheckWorker(
config=self.config,
@@ -282,7 +284,7 @@ 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:
if not SUBSCRIPTION_CHECK_ENABLED or not SUBSCRIPTION_ENFORCEMENT_ENABLED:
return True
status = self._subscription_status
if status.allows_product_workflows:
@@ -322,17 +324,41 @@ class MainWindow(QMainWindow):
"%s · %s · 宽限至%s" % (status.account_name, status.plan_name, expiry),
"warning",
)
elif SUBSCRIPTION_ENFORCEMENT_ENABLED:
self._set_subscription_label(
"会员:%s" % status.user_message,
self._subscription_level(status),
)
else:
self._set_subscription_label("会员:%s" % status.user_message, self._subscription_level(status))
self._set_subscription_label(
"会员观察:%s(当前不影响使用)" % status.user_message,
self._subscription_level(status),
)
self._set_product_access(status.allows_product_workflows)
access_allowed = (
status.allows_product_workflows
or not 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:
self.show_status("会员状态已验证", level="success")
message = (
"会员状态已验证"
if SUBSCRIPTION_ENFORCEMENT_ENABLED
else "会员状态已验证,当前处于观察模式"
)
self.show_status(message, level="success")
if SUBSCRIPTION_ENFORCEMENT_ENABLED:
self._show_subscription_notice_once(status)
return
if not SUBSCRIPTION_ENFORCEMENT_ENABLED:
self.show_status(
"订阅观察:%s,当前不影响使用" % status.user_message,
level=self._subscription_level(status),
)
return
self.open_settings_tab()
self.show_status(status.user_message, level=self._subscription_level(status))
+2 -2
View File
@@ -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,服务端始终是最终授权方。当前联调期由 `app.gui.main_window.SUBSCRIPTION_CHECK_ENABLED = False` 暂停客户端启动检查与本地预检,不请求接口、不限制工作流;恢复发布前必须改回启用并回归验证。
- `subscription`:使用 cmhub API Key 查询 `GET /api/v1/cmshopee/subscription/status`,把远端权益结果归一化为有效、宽限、未订阅、到期、撤销、账号不可用、Key 无效、暂时不可用或旧服务兼容状态;不保存、展示或记录 API Key,服务端始终是最终授权方。T-696 观察期固定为 `SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = False`:真实查询并展示状态,但任何结果都不限制工作流或新提交,正式门禁必须另建任务启用。
- `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`;顶部 GUI 只展示服务端返回的账号显示名、套餐名和有效期,不写入 SQLite、诊断日志或导出。`404` 表示服务端尚未启用订阅接口,客户端保持旧行为;网络异常不被误判为会员到期。
- 订阅状态 → 仅进程内 `SubscriptionStatus`;顶部 GUI 对有效/宽限状态展示服务端返回的账号显示名、套餐名和有效期,其他状态显示脱敏中文观察结果,不写入 SQLite、诊断日志或导出。观察期不显示 `notice_id` 模态通知、不禁用 Tab、不自动切换设置且新提交预检始终放行;`404` 表示服务端尚未启用订阅接口,网络异常不被误判为会员到期。
- cmhub 模型目录与 AI帮写/正式套图预估价格 → 仅内存短期缓存;预估值只供用户确认,实际扣点仍以网关响应 metadata 为准。
- 业务数据(账号、任务、各阶段结果)→ SQLite `data/cmshopee.db`。
- 图片(采集的旧封面、AI 生成的新封面)→ `data/images/`(路径记在 DB)。
+5 -4
View File
@@ -27,12 +27,13 @@
## 会员订阅接入
- 当前处于联调测试期:`app.gui.main_window.SUBSCRIPTION_CHECK_ENABLED = False`,启动、保存设置和新提交均不请求订阅接口,也不限制六个工作流;顶部只显示“会员:订阅检测已暂停”。恢复发布前必须重新启用后再按下列流程验收。
- 当前处于真实接口观察期:`SUBSCRIPTION_CHECK_ENABLED = True`、`SUBSCRIPTION_ENFORCEMENT_ENABLED = False`。客户端真实查询并展示订阅状态,但查询中、未配置 Key、Key 无效、账号禁用、未订阅、到期、撤销或暂时不可用均不禁用六个工作流、不自动切换「设置」、不阻止新提交。
- 强制升级检查完成、主窗口显示后,后台用 `data/config/cmhub.json` 的 API Key 请求 `GET /api/v1/cmshopee/subscription/status`;不会阻塞 Qt 主线程或把 Key 放入 URL、状态栏、日志和错误提示。
- 状态接口已启用时,未配置 Key、Key 无效、账号禁用、未订阅、到期、撤销或暂时无法确认会员状态,主窗口只保留「设置」Tab,并在顶部显示中文原因;用户可打开设置重填 Key、按服务端返回的 `manage_url` 用默认浏览器前往线上会员中心,或退出。已接受的异步任务、本地数据和导出不删除。
- 订阅有效时,应用顶部标题区域右侧显示“账号名 · 套餐名 · 有效至日期”;宽限期显示宽限截止日。不得把长会员文本拼进 Windows 原生窗口标题,也不得展示 API Key。
- 订阅有效时,应用顶部标题区域右侧显示“账号名 · 套餐名 · 有效至日期”;宽限期显示宽限截止日。其他状态显示中文观察结果并明确“当前不影响使用”。不得把长会员文本拼进 Windows 原生窗口标题,也不得展示 API Key。
- 观察期不显示 `notice_id` 对应的“已启用会员订阅”模态通知框;保存 cmhub 设置后会立即重新检查,但陈旧线程结果不能覆盖新状态。
- 当前服务端未部署订阅接口时,`404` 视为旧服务兼容:顶部显示“会员服务尚未启用”,六个工作流继续按旧行为运行,不弹阻断窗口。网络或格式错误显示“暂时无法确认会员状态”,不能误报为 Key 无效或会员到期。
- ② AI生成、⑥商品套图和「AI帮写」的每次新提交均读取同一订阅预检状态;客户端预检只改善体验,cmhub 产品接口仍必须服务端最终裁决。已提交的异步任务继续查询、下载和查看。自定义网关直连同样做客户端软校验,不能作为不可绕过的授权保护。
- ② AI生成、⑥商品套图和「AI帮写」继续调用同一订阅预检入口,但观察模式始终放行;cmhub 产品接口仍必须服务端最终裁决。已提交的异步任务继续查询、下载和查看。自定义网关直连的观察结果同样不能作为不可绕过的授权保护。
- 后续正式门禁需另建任务,先根据观察结果确认存量 Key/套餐覆盖率、网络失败策略和回滚方案;启用后才恢复 T-686 的“非有效状态只保留设置 Tab”和一次性订阅通知逻辑。
## 全局 Tab 栏可用性
+6 -2
View File
@@ -3,7 +3,7 @@ id: T-696
title: 会员订阅真实检测观察模式
phase: 7
deps: [T-686]
status: TODO
status: DONE
created: 2026-07-23
---
@@ -69,4 +69,8 @@ T-686 已完成 cmhub 账号订阅状态查询、顶部会员信息、工作流
## 执行记录
- 待实现。
- 2026-07-23:将订阅查询和客户端强制门禁拆为 `SUBSCRIPTION_CHECK_ENABLED = True` 与 `SUBSCRIPTION_ENFORCEMENT_ENABLED = False`。程序显示主窗口后会异步查询真实订阅接口,保存 cmhub 设置后立即重新查询。
- 观察模式在查询中及任一订阅结果下都保持六个工作流和新提交入口可用;非有效状态只显示“当前不影响使用”的中文观察结果,不自动切换设置。有效/宽限状态继续展示账号、套餐和日期,观察期不显示 `notice_id` 模态通知。
- 保留 T-686 正式门禁分支和回归覆盖;后续启用门禁只需单独评审并切换协调层开关,不需要修改各业务 Tab。
- 使用本地已配置但未输出的 API Key 通过真实 `SubscriptionCheckWorker` 联调成功:状态 `active`、套餐“测试”、有效期 `2026-08-21`,账号字段存在;未记录原始响应或敏感信息。
- 验证通过:订阅解析测试 6 项、主窗口观察/强制定向测试 4 项、`py -3.10 -m unittest discover -s tests`(682 项)、`py -3.10 -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`git diff --check`。
+80 -5
View File
@@ -11180,28 +11180,103 @@ class GuiTests(TempDirMixin, unittest.TestCase):
self.addCleanup(window.close)
status = subscription.SubscriptionStatus(subscription.STATUS_EXPIRED)
with mock.patch.object(
main_window,
"SUBSCRIPTION_ENFORCEMENT_ENABLED",
True,
):
window._apply_subscription_status(status)
for index, title in enumerate(TAB_TITLES):
self.assertEqual(title == "设置", window.tabs.isTabEnabled(index))
self.assertEqual(TAB_TITLES.index("设置"), window.tabs.currentIndex())
def test_main_window_temporarily_bypasses_subscription_checks(self):
self.assertFalse(main_window.SUBSCRIPTION_CHECK_ENABLED)
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)
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()
self.cancelled_requested = False
def cancel(self):
self.cancelled_requested = True
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)
worker = _Worker()
thread = _Thread()
with mock.patch("app.gui.main_window.SubscriptionCheckWorker") as worker:
with mock.patch(
"app.gui.main_window.SubscriptionCheckWorker",
return_value=worker,
) as worker_factory, mock.patch(
"app.gui.main_window.run_worker",
return_value=thread,
):
window.begin_subscription_check()
worker.assert_not_called()
self.assertEqual("会员:订阅检测已暂停", window._subscription_label.text())
worker_factory.assert_called_once()
self.assertTrue(thread.started)
self.assertEqual("会员:正在验证", window._subscription_label.text())
self.assertTrue(
all(window.tabs.isTabEnabled(index) for index in range(window.tabs.count()))
)
expired = subscription.SubscriptionStatus(subscription.STATUS_EXPIRED)
with mock.patch.object(window, "_show_subscription_notice_once") as notice:
window._apply_subscription_status(expired)
notice.assert_not_called()
self.assertIn("当前不影响使用", window._subscription_label.text())
self.assertIn("当前不影响使用", window.statusBar().currentMessage())
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_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))
self.addCleanup(window.close)
status = subscription.SubscriptionStatus(
subscription.STATUS_ACTIVE,
account_name="主账号",
plan_name="测试",
expires_at="2026-08-21T23:59:59+08:00",
notice_id="subscription-test-plan",
)
with mock.patch.object(window, "_show_subscription_notice_once") as notice:
window._apply_subscription_status(status)
notice.assert_not_called()
self.assertIn("主账号 · 测试 · 有效至2026-08-21", window._subscription_label.text())
self.assertIn("观察模式", window.statusBar().currentMessage())
with mock.patch.object(window, "begin_subscription_check") as check:
window._on_settings_saved("cmhub")
check.assert_called_once_with()
def test_generate_and_suite_preflight_can_stop_new_submissions(self):
with self.make_temp_dir() as temp_dir:
config = self.make_config(temp_dir)